FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"archivdms/internal/index"
|
||||
)
|
||||
|
||||
// SearchResultDoc is a single search hit: the full document row (re-hydrated
|
||||
// from Postgres, the source of truth) plus its BM25 relevance score from the
|
||||
// Manticore index. Document is embedded so the JSON shape stays identical to
|
||||
// the /api/documents list response, with an added top-level "score" field.
|
||||
type SearchResultDoc struct {
|
||||
Document
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// SearchResult is the paginated envelope returned by SearchDocuments.
|
||||
type SearchResult struct {
|
||||
Results []SearchResultDoc `json:"results"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// ErrSearchUnavailable is returned by SearchDocuments when no search index is
|
||||
// wired into the store (empty Manticore DSN). The handler maps this to a 503 —
|
||||
// unlike the best-effort write-path sync helpers, a search request must fail
|
||||
// loudly rather than silently return an empty result.
|
||||
var ErrSearchUnavailable = ErrNoIndexer
|
||||
|
||||
// SearchDocuments runs a full-text + attribute query against the tenant's
|
||||
// Manticore index, then re-hydrates the matching document rows from Postgres
|
||||
// (authoritative), preserving the index's relevance ordering and attaching each
|
||||
// hit's score.
|
||||
//
|
||||
// The index only ever returns documents.id + score; the WHERE tenant_id / and
|
||||
// deleted_at IS NULL clause below is the authoritative ownership + soft-delete
|
||||
// boundary — the index is treated as a hint, never as the source of truth.
|
||||
// Returns ErrSearchUnavailable when no indexer is configured.
|
||||
func (s *Store) SearchDocuments(ctx context.Context, tenantID int64, q index.SearchQuery) (*SearchResult, error) {
|
||||
if s.indexer == nil {
|
||||
return nil, ErrSearchUnavailable
|
||||
}
|
||||
|
||||
page := q.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := q.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
q.Page = page
|
||||
q.PageSize = pageSize
|
||||
|
||||
hits, total, err := s.indexer.ForTenant(tenantID).Search(ctx, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: search documents: %w", err)
|
||||
}
|
||||
|
||||
result := &SearchResult{
|
||||
Results: []SearchResultDoc{},
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Collect the hit ids (preserving score order) for a single batch SELECT.
|
||||
ids := make([]int64, len(hits))
|
||||
scoreByID := make(map[int64]float64, len(hits))
|
||||
for i, h := range hits {
|
||||
ids[i] = h.ID
|
||||
scoreByID[h.ID] = h.Score
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, tenant_id, title, COALESCE(doc_type, ''), COALESCE(correspondent, ''), doc_type_id, correspondent_id, storage_path, content_hash,
|
||||
COALESCE(ocr_text, ''), retain_until, COALESCE(source, ''), COALESCE(source_ref, ''), created_at, updated_at
|
||||
FROM documents
|
||||
WHERE id = ANY($1) AND tenant_id = $2 AND deleted_at IS NULL
|
||||
`, ids, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: search hydrate documents: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
docByID := make(map[int64]Document, len(hits))
|
||||
for rows.Next() {
|
||||
var d Document
|
||||
if err := rows.Scan(&d.ID, &d.TenantID, &d.Title, &d.DocType, &d.Correspondent, &d.DocTypeID, &d.CorrespondentID, &d.StoragePath, &d.ContentHash,
|
||||
&d.OCRText, &d.RetainUntil, &d.Source, &d.SourceRef, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("storage: scan search document: %w", err)
|
||||
}
|
||||
docByID[d.ID] = d
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("storage: search rows: %w", err)
|
||||
}
|
||||
|
||||
// Emit in the index's ranking order; skip ids the DB dropped (a stale index
|
||||
// entry for a since-deleted / re-tenanted document).
|
||||
for _, id := range ids {
|
||||
d, ok := docByID[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
result.Results = append(result.Results, SearchResultDoc{Document: d, Score: scoreByID[id]})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user