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,128 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user