Files
archivdms/internal/storage/metadata_suggestions_naivebayes.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

129 lines
4.4 KiB
Go

package storage
import (
"context"
"encoding/json"
"fmt"
"archivdms/internal/classifier"
)
// GenerateNaiveBayesSuggestions builds a metadata suggestion for a document
// using the trained Naive-Bayes model (internal/classifier) instead of the
// fuzzy-name heuristic or an LLM. It classifies the document's title+OCR text
// against the tenant's trained document_types / correspondents / tags models,
// maps the predicted class IDs back to taxonomy entities, drops entities that
// are already assigned, and persists the result as a metadata_suggestions row
// with provider='naive_bayes' — in the SAME SuggestionPayload schema the other
// providers produce, so the API/frontend are unchanged.
//
// A kind whose model is untrained (or below the per-class data threshold) simply
// yields no candidates for that kind — not an error. Any real failure (DB error,
// classifier error) is returned as-is: there is NO silent fallback to the
// heuristic provider (GoBD-Nachvollziehbarkeit — the caller reports which
// provider produced or failed the run). requestedBy may be nil for
// non-interactive callers.
func (s *Store) GenerateNaiveBayesSuggestions(ctx context.Context, documentID, tenantID int64, requestedBy *int64) (*MetadataSuggestion, error) {
doc, err := s.GetDocument(ctx, documentID, tenantID)
if err != nil {
return nil, err // ErrDocumentNotFound propagates
}
text := doc.Title
if doc.OCRText != "" {
text = doc.Title + "\n" + doc.OCRText
}
clf := classifier.New(s.db)
// Entities already assigned are excluded from suggestions, matching the
// other providers' behaviour.
assignedTags := map[int64]bool{}
docTags, err := s.ListDocumentTags(ctx, documentID, tenantID)
if err != nil {
return nil, err
}
for _, t := range docTags {
assignedTags[t.ID] = true
}
docTypeCands, err := s.naiveBayesCandidates(ctx, clf, "document_types", tenantID, text, func(id int64) bool {
return doc.DocTypeID != nil && *doc.DocTypeID == id
})
if err != nil {
return nil, err
}
corrCands, err := s.naiveBayesCandidates(ctx, clf, "correspondents", tenantID, text, func(id int64) bool {
return doc.CorrespondentID != nil && *doc.CorrespondentID == id
})
if err != nil {
return nil, err
}
tagCands, err := s.naiveBayesCandidates(ctx, clf, "tags", tenantID, text, func(id int64) bool {
return assignedTags[id]
})
if err != nil {
return nil, err
}
payload := SuggestionPayload{
DocTypeCandidates: docTypeCands,
CorrespondentCandidates: corrCands,
TagCandidates: tagCands,
}
// The Naive-Bayes model does not propose a title (it classifies against
// existing entities only); Title stays nil.
raw, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("storage: marshal naive_bayes suggestion payload: %w", err)
}
row := s.db.QueryRow(ctx, `
INSERT INTO metadata_suggestions (tenant_id, document_id, provider, requested_by, suggestion)
VALUES ($1, $2, 'naive_bayes', $3, $4)
RETURNING `+metadataSuggestionCols,
tenantID, documentID, requestedBy, raw)
m, err := scanMetadataSuggestion(row)
if err != nil {
return nil, fmt.Errorf("storage: insert naive_bayes metadata suggestion: %w", err)
}
return m, nil
}
// naiveBayesCandidates runs the classifier for one kind and maps predicted class
// IDs back to SuggestionCandidate (resolving the entity name from the taxonomy),
// dropping excluded (already-assigned) entities and any predicted ID that no
// longer exists as a live entity. Result is always non-nil.
func (s *Store) naiveBayesCandidates(ctx context.Context, clf *classifier.Classifier, kind string, tenantID int64, text string, excluded func(id int64) bool) ([]SuggestionCandidate, error) {
preds, err := clf.Predict(ctx, tenantID, kind, text)
if err != nil {
return nil, fmt.Errorf("storage: naive_bayes predict %s: %w", kind, err)
}
if len(preds) == 0 {
return make([]SuggestionCandidate, 0), nil
}
entities, err := s.ListTaxonomyEntities(ctx, kind, tenantID)
if err != nil {
return nil, err
}
names := make(map[int64]string, len(entities))
for _, e := range entities {
names[e.ID] = e.Name
}
out := make([]SuggestionCandidate, 0, len(preds))
for _, p := range preds {
if excluded(p.EntityID) {
continue
}
name, ok := names[p.EntityID]
if !ok {
continue // predicted a class whose entity was deleted since training
}
out = append(out, SuggestionCandidate{ID: p.EntityID, Name: name, Score: p.Score, Explanation: p.TopTokens})
}
return out, nil
}