Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
116 lines
3.8 KiB
Go
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
|
|
}
|