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