Files
archivdms/internal/storage/search.go
T
patrick 9a24ea29e1 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.
2026-08-11 21:27:53 +02:00

116 lines
3.8 KiB
Go

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
}