// 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"}) }