Files
archivdms/internal/api/metadata_suggestion_handlers.go
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

155 lines
6.6 KiB
Go

// Heuristic metadata-suggestion HTTP handlers (see
// internal/storage/metadata_suggestions.go):
//
// POST /api/documents/{id}/suggest-metadata
// GET /api/documents/{id}/suggest-metadata
// POST /api/documents/{id}/suggest-metadata/{suggestionId}/reviewed
//
// All three are normal authenticated tenant actions (s.auth). Suggestions are
// rule-based (no LLM) and NON-binding: nothing here applies a suggested field —
// accepting one goes through the normal edit endpoints (PATCH title,
// tag-attach, ...). Ownership is enforced in the store layer (id+tenant_id).
package api
import (
"errors"
"net/http"
"strconv"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
// handleGenerateSuggestions handles POST /api/documents/{id}/suggest-metadata.
// Triggers a fresh heuristic suggestion run and returns the persisted result.
func (s *Server) handleGenerateSuggestions(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
docRef := strconv.FormatInt(docID, 10)
// provider selects the suggestion engine: "heuristic" (default, rule-based,
// always available) or "ollama" (external LLM, only when the tenant has it
// enabled). On an Ollama failure there is NO silent fallback to heuristic —
// the error is surfaced so the frontend knows which provider did not answer.
provider := r.URL.Query().Get("provider")
if provider == "" {
provider = "heuristic"
}
var sug *storage.MetadataSuggestion
switch provider {
case "heuristic":
sug, err = s.store.GenerateHeuristicSuggestions(r.Context(), docID, *sess.TenantID, &sess.UserID)
case "ollama":
cfg, cfgErr := s.store.GetOllamaConfig(r.Context(), *sess.TenantID)
if cfgErr != nil {
s.audlog.Log(audit.Entry{EventType: audit.EventSuggestionGenerated, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docRef, Success: false, Detail: "suggest_metadata ollama config err:" + cfgErr.Error()})
writeError(w, http.StatusInternalServerError, "load ollama config failed")
return
}
if !cfg.Enabled {
s.audlog.Log(audit.Entry{EventType: audit.EventSuggestionGenerated, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docRef, Success: false, Detail: "suggest_metadata ollama not enabled"})
writeError(w, http.StatusBadRequest, "ollama provider is not enabled for this tenant")
return
}
sug, err = s.store.GenerateOllamaSuggestions(r.Context(), docID, *sess.TenantID, &sess.UserID, *cfg)
case "naive_bayes":
// Trained, dependency-free ML classifier (internal/classifier). Yields no
// candidates for a kind whose model is untrained/below threshold — that is
// not an error. Any real failure is surfaced (no silent fallback).
sug, err = s.store.GenerateNaiveBayesSuggestions(r.Context(), docID, *sess.TenantID, &sess.UserID)
default:
writeError(w, http.StatusBadRequest, "unknown provider (use 'heuristic', 'ollama' or 'naive_bayes')")
return
}
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, storage.ErrDocumentNotFound) {
status = http.StatusNotFound
} else if provider == "ollama" {
// Ollama unreachable/timeout/bad-response: a dependency failure, not a
// server bug. 502 signals "upstream provider failed".
status = http.StatusBadGateway
}
s.audlog.Log(audit.Entry{EventType: audit.EventSuggestionGenerated, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docRef, Success: false, Detail: "suggest_metadata provider:" + provider + " err:" + err.Error()})
writeError(w, status, "generate metadata suggestions failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventSuggestionGenerated, Username: sess.Username, TenantID: sess.TenantID,
DocumentID: docRef, Success: true, Detail: "suggest_metadata provider:" + provider + " id:" + strconv.FormatInt(sug.ID, 10),
})
writeJSON(w, http.StatusOK, sug)
}
// handleGetLatestSuggestion handles GET /api/documents/{id}/suggest-metadata.
// Returns the most recent suggestion run for the document.
func (s *Server) handleGetLatestSuggestion(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
sug, err := s.store.GetLatestSuggestion(r.Context(), docID, *sess.TenantID)
if err != nil {
if errors.Is(err, storage.ErrSuggestionNotFound) {
writeError(w, http.StatusNotFound, "no metadata suggestion found")
return
}
writeError(w, http.StatusInternalServerError, "get metadata suggestion failed")
return
}
writeJSON(w, http.StatusOK, sug)
}
// handleMarkSuggestionReviewed handles
// POST /api/documents/{id}/suggest-metadata/{suggestionId}/reviewed. Flags a
// suggestion as reviewed (the user acted on it in the UI). Which fields were
// accepted went through the normal edit endpoints, not this call.
func (s *Server) handleMarkSuggestionReviewed(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
suggestionID, err := strconv.ParseInt(r.PathValue("suggestionId"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid suggestion id")
return
}
docRef := strconv.FormatInt(docID, 10)
if err := s.store.MarkSuggestionReviewed(r.Context(), suggestionID, *sess.TenantID, sess.UserID); err != nil {
status := http.StatusNotFound
if !errors.Is(err, storage.ErrSuggestionNotFound) {
status = http.StatusInternalServerError
}
s.audlog.Log(audit.Entry{EventType: audit.EventSuggestionGenerated, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docRef, Success: false, Detail: "suggest_metadata_reviewed id:" + strconv.FormatInt(suggestionID, 10) + " err:" + err.Error()})
writeError(w, status, "mark suggestion reviewed failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventSuggestionGenerated, Username: sess.Username, TenantID: sess.TenantID,
DocumentID: docRef, Success: true, Detail: "suggest_metadata_reviewed id:" + strconv.FormatInt(suggestionID, 10),
})
writeJSON(w, http.StatusOK, map[string]string{"status": "reviewed"})
}