diff --git a/ai/cmd/ai-app/main.go b/ai/cmd/ai-app/main.go index 9692f1d6..d4ac9ac0 100644 --- a/ai/cmd/ai-app/main.go +++ b/ai/cmd/ai-app/main.go @@ -18,20 +18,29 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/classify" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/confidence" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/correction" "gitea.perlbach24.de/scripte/nexarch/ai/internal/decisionaudit" "gitea.perlbach24.de/scripte/nexarch/ai/internal/docchat" "gitea.perlbach24.de/scripte/nexarch/ai/internal/docchatapi" "gitea.perlbach24.de/scripte/nexarch/ai/internal/doclang" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/doctype" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/embedpipeline" "gitea.perlbach24.de/scripte/nexarch/ai/internal/extract" "gitea.perlbach24.de/scripte/nexarch/ai/internal/extractapi" "gitea.perlbach24.de/scripte/nexarch/ai/internal/extractconfirmapi" "gitea.perlbach24.de/scripte/nexarch/ai/internal/extractmapping" "gitea.perlbach24.de/scripte/nexarch/ai/internal/extractvalidate" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/featureapi" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/highlight" "gitea.perlbach24.de/scripte/nexarch/ai/internal/invoiceextract" "gitea.perlbach24.de/scripte/nexarch/ai/internal/metadatapushclient" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/modelconfig" "gitea.perlbach24.de/scripte/nexarch/ai/internal/moduletype" "gitea.perlbach24.de/scripte/nexarch/ai/internal/moduletypeapi" "gitea.perlbach24.de/scripte/nexarch/ai/internal/originmark" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/prompttemplate" "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/ollama" "gitea.perlbach24.de/scripte/nexarch/ai/internal/queue" @@ -45,6 +54,7 @@ import ( "gitea.perlbach24.de/scripte/nexarch/ai/internal/suggestapi" "gitea.perlbach24.de/scripte/nexarch/ai/internal/summarize" "gitea.perlbach24.de/scripte/nexarch/ai/internal/summaryapi" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/tenantfeature" "gitea.perlbach24.de/scripte/nexarch/ai/internal/threadsummarize" "gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex" "gitea.perlbach24.de/scripte/nexarch/common/auditclient" @@ -235,6 +245,30 @@ func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID stri worker := queue.NewWorker(q, prov, workerID) worker.SetUsageRecorder(quotaStore) + taxonomy := &doctype.Taxonomy{Pool: pool} + listers := map[string]modelconfig.ModelLister{} + if l, ok := prov.(modelconfig.ModelLister); ok { + listers["ollama"] = l + } + classifier := &classify.Classifier{Provider: prov, Model: cfg.model} + features := &featureapi.Server{ + Classifier: classifier, + Doctype: &doctype.Detector{Classifier: classifier, Taxonomy: taxonomy}, + Highlight: &highlight.Detector{Provider: prov, Model: cfg.model}, + Taxonomy: taxonomy, + Features: &tenantfeature.Store{Pool: pool}, + Thresholds: &confidence.Store{Pool: pool}, + Corrections: &correction.Recorder{Pool: pool}, + Embeddings: embedpipeline.New(pool, q, cfg.model), + Models: modelconfig.NewStore(pool, listers), + Templates: prompttemplate.New(pool), + ServiceToken: cfg.serviceToken, + } + if cfg.coreBaseURL != "" { + features.Audit = decisionaudit.NewRecorder(auditclient.New(cfg.coreBaseURL, cfg.coreServiceToken)) + } + features.RegisterRoutes(mux) + return wiring{mux: mux, queue: q, worker: worker, quotaStore: quotaStore} } diff --git a/ai/cmd/ai-app/main_test.go b/ai/cmd/ai-app/main_test.go index 0f310de8..18c15fa2 100644 --- a/ai/cmd/ai-app/main_test.go +++ b/ai/cmd/ai-app/main_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -87,6 +88,96 @@ func TestLoadConfig_Defaults(t *testing.T) { } } +func TestBuild_FeatureAPIAgainstRealTables(t *testing.T) { + pool := newTestPool(t) + prov := &mock.Provider{ChatResponse: provider.ChatResponse{Message: provider.Message{Role: provider.RoleAssistant, Content: `{"category":"rechnung","confidence":0.9,"reasoning":"x"}`}}} + w := build(pool, config{serviceToken: "tok", model: "mock"}, prov, "feature-worker") + srv := httptest.NewServer(w.mux) + defer srv.Close() + + call := func(method, path, body string) (int, map[string]any) { + t.Helper() + req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body)) + req.Header.Set("X-Service-Token", "tok") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + defer resp.Body.Close() + var out map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp.StatusCode, out + } + want := func(code int, got int, what string) { + t.Helper() + if got != code { + t.Errorf("%s: status %d, want %d", what, got, code) + } + } + + code, _ := call("POST", "/tenants/featt/doctypes/defaults", `{}`) + want(200, code, "doctype defaults") + code, out := call("POST", "/doctype/detect", `{"tenant_slug":"featt","text":"Rechnung Nr. 5"}`) + want(200, code, "doctype detect") + if out["doc_type"] != "rechnung" { + t.Errorf("doc_type = %v", out["doc_type"]) + } + + code, out = call("POST", "/classify", `{"tenant_slug":"featt","categories":["rechnung","vertrag"],"text":"x"}`) + want(200, code, "classify") + if out["meets_threshold"] != true { + t.Errorf("0.9 gegen default 0.7: meets_threshold = %v", out["meets_threshold"]) + } + code, _ = call("PUT", "/tenants/featt/confidence/classify", `{"threshold":0.95}`) + want(200, code, "confidence put") + _, out = call("POST", "/classify", `{"tenant_slug":"featt","categories":["rechnung","vertrag"],"text":"x"}`) + if out["meets_threshold"] != false { + t.Errorf("0.9 gegen 0.95: meets_threshold = %v", out["meets_threshold"]) + } + + code, _ = call("PUT", "/tenants/featt/features/classify", `{"enabled":false}`) + want(200, code, "feature off") + code, _ = call("POST", "/classify", `{"tenant_slug":"featt","categories":["rechnung"],"text":"x"}`) + want(403, code, "classify deaktiviert") + code, _ = call("POST", "/classify", `{"tenant_slug":"featt2","categories":["rechnung"],"text":"x"}`) + want(200, code, "classify anderer mandant") + + code, _ = call("POST", "/corrections", `{"tenant_slug":"featt","function":"classify","input_ref":"d1","suggested_category":"rechnung","suggested_confidence":0.9,"corrected_category":"vertrag","actor":"u1"}`) + want(204, code, "correction record") + _, out = call("GET", "/tenants/featt/corrections/classify/stats", "") + if out["total_corrections"] != 1.0 { + t.Errorf("total_corrections = %v", out["total_corrections"]) + } + + code, _ = call("POST", "/prompt-templates/featt-key", `{"body":"eins","created_by":"u"}`) + want(201, code, "template save") + code, out = call("GET", "/prompt-templates/featt-key", "") + want(200, code, "template active") + if out["body"] != "eins" { + t.Errorf("body = %v", out["body"]) + } + + code, out = call("POST", "/embeddings/ingest", `{"tenant_slug":"featt","source_type":"doc","source_id":"1","text":"Ein kurzer Text."}`) + want(202, code, "embedding ingest") + code, out = call("GET", "/embeddings/status?tenant_slug=featt&source_type=doc&source_id=1", "") + want(200, code, "embedding status") + if cs, _ := out["chunks"].([]any); len(cs) != 1 { + t.Errorf("chunks = %v", out["chunks"]) + } + + code, _ = call("GET", "/tenants/featt/model-config/chat", "") + want(404, code, "model-config ungesetzt") + code, _ = call("PUT", "/tenants/featt/model-config/chat", `{"provider":"ollama","model":"x"}`) + want(400, code, "model-config ohne lister") + + code, _ = call("POST", "/summary-quality/evaluate", `{"samples":[{"type":"t","source":"a b c","summary":"a b"}]}`) + want(200, code, "summary-quality") + code, _ = call("POST", "/highlight", `{"tenant_slug":"featt","text":"Frist endet morgen."}`) + if code == http.StatusNotFound || code == http.StatusUnauthorized { + t.Errorf("highlight nicht registriert: %d", code) + } +} + func newTestPool(t *testing.T) *pgxpool.Pool { t.Helper() dsn := os.Getenv("TEST_TENANT_DSN") diff --git a/ai/docs/ROUTEN-FEATUREAPI.md b/ai/docs/ROUTEN-FEATUREAPI.md new file mode 100644 index 00000000..af372d84 --- /dev/null +++ b/ai/docs/ROUTEN-FEATUREAPI.md @@ -0,0 +1,27 @@ +# ai-app: Routen aus `featureapi` + +Alle Routen verlangen `X-Service-Token`. Verdrahtet in `ai/cmd/ai-app/main.go` (`build`), Tests in `ai/internal/featureapi` und `TestBuild_FeatureAPIAgainstRealTables`. + +| Route | Paket | +|---|---| +| `POST /classify` | classify (mit Audit, wenn Core konfiguriert und `actor` + `input_ref` gesetzt) | +| `POST /doctype/detect`, `GET`/`POST /tenants/{t}/doctypes`, `POST /tenants/{t}/doctypes/defaults` | doctype | +| `POST /highlight` | highlight | +| `GET`/`PUT /tenants/{t}/features/{function}` | tenantfeature | +| `GET`/`PUT /tenants/{t}/confidence/{function}` | confidence | +| `GET`/`PUT /tenants/{t}/model-config/{function}` | modelconfig | +| `POST /corrections`, `GET /tenants/{t}/corrections/{function}/stats` | correction | +| `POST /embeddings/ingest`, `GET /embeddings/status` | embedpipeline | +| `POST`/`GET /prompt-templates/{key}`, `GET .../history`, `PUT .../active` | prompttemplate | +| `POST /summary-quality/evaluate` | summaryquality | + +## Verhalten + +- `classify`, `doctype`, `highlight` prüfen den Mandantenschalter (`tenantfeature`, Funktionsnamen `classify`, `doctype`, `highlight`). Deaktiviert ergibt 403. Der globale Core-Schalter (LIC-02) ist nicht angebunden und gilt als an. +- `classify` und `doctype` liefern `meets_threshold` gegen die Schwelle aus `confidence` (Standard 0,7). Die Schwelle entscheidet nur die Anzeige, nichts wird automatisch übernommen. +- Embeddings: `ingest` reiht nur ein. Verarbeitet werden die Aufträge vom vorhandenen Queue-Worker. + +## Nicht angebunden + +- `modelconfig` und `prompttemplate` speichern nur. Kein Inferenzpfad liest die Mandanten-Modellwahl oder die aktive Vorlage; Suggest-, Summary- und Extract-Routen nutzen weiter `NEXARCH_AI_APP_MODEL` und die festen Prompts. `modelconfig` kennt nur den Provider `ollama`. +- `notraining` ist ein reiner Architekturtest, `refcorpus` ein Testkorpus. Beide haben keine Laufzeitfunktion und bekommen keine Route. diff --git a/ai/internal/featureapi/featureapi.go b/ai/internal/featureapi/featureapi.go new file mode 100644 index 00000000..389e4695 --- /dev/null +++ b/ai/internal/featureapi/featureapi.go @@ -0,0 +1,641 @@ +package featureapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/classify" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/confidence" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/correction" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/decisionaudit" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/doctype" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/embedpipeline" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/highlight" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/modelconfig" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/prompttemplate" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/summaryquality" + "gitea.perlbach24.de/scripte/nexarch/common/svcauth" +) + +const maxBodyBytes = 4 << 20 + +// Namen der abschaltbaren Funktionen (tenantfeature) und der Schwellen (confidence). +const ( + FunctionClassify = "classify" + FunctionDoctype = "doctype" + FunctionHighlight = "highlight" +) + +type Features interface { + Set(ctx context.Context, tenantSlug, function string, enabled bool) error + IsEnabled(ctx context.Context, tenantSlug, function string, globalEnabled bool) (bool, error) +} + +type Thresholds interface { + Set(ctx context.Context, tenantSlug, function string, threshold float64) error + Get(ctx context.Context, tenantSlug, function string) (float64, error) + MeetsThreshold(ctx context.Context, tenantSlug, function string, value float64) (bool, error) +} + +type Taxonomies interface { + List(ctx context.Context, tenantSlug string) ([]string, error) + Add(ctx context.Context, tenantSlug, code, label string) error + SeedDefaults(ctx context.Context, tenantSlug string) error +} + +type Corrections interface { + Record(ctx context.Context, c correction.Correction) error + Aggregate(ctx context.Context, tenantSlug, function string) (correction.Stats, error) +} + +type Embeddings interface { + Ingest(ctx context.Context, tenantSlug, sourceType, sourceID, text string) ([]embedpipeline.Chunk, error) + Status(ctx context.Context, tenantSlug, sourceType, sourceID string) ([]embedpipeline.ChunkStatus, error) +} + +type Models interface { + Set(ctx context.Context, tenantSlug, function, providerName, model string) error + Get(ctx context.Context, tenantSlug, function string) (modelconfig.Selection, error) +} + +type Templates interface { + Save(ctx context.Context, functionKey, body, createdBy string) (prompttemplate.Version, error) + Activate(ctx context.Context, functionKey string, version int, activatedBy string) error + Active(ctx context.Context, functionKey string) (prompttemplate.Version, error) + History(ctx context.Context, functionKey string) ([]prompttemplate.Version, error) +} + +type Server struct { + Classifier *classify.Classifier + Doctype *doctype.Detector + Highlight *highlight.Detector + Taxonomy Taxonomies + Features Features + Thresholds Thresholds + Corrections Corrections + Embeddings Embeddings + Models Models + Templates Templates + // Audit ist nil ohne Core-Anbindung; dann laufen Klassifizierungen unprotokolliert. + Audit *decisionaudit.Recorder + ServiceToken string +} + +func (s *Server) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /classify", s.auth(s.handleClassify)) + mux.HandleFunc("POST /doctype/detect", s.auth(s.handleDoctypeDetect)) + mux.HandleFunc("GET /tenants/{tenant}/doctypes", s.auth(s.handleDoctypeList)) + mux.HandleFunc("POST /tenants/{tenant}/doctypes", s.auth(s.handleDoctypeAdd)) + mux.HandleFunc("POST /tenants/{tenant}/doctypes/defaults", s.auth(s.handleDoctypeSeed)) + mux.HandleFunc("POST /highlight", s.auth(s.handleHighlight)) + + mux.HandleFunc("GET /tenants/{tenant}/features/{function}", s.auth(s.handleFeatureGet)) + mux.HandleFunc("PUT /tenants/{tenant}/features/{function}", s.auth(s.handleFeaturePut)) + mux.HandleFunc("GET /tenants/{tenant}/confidence/{function}", s.auth(s.handleConfidenceGet)) + mux.HandleFunc("PUT /tenants/{tenant}/confidence/{function}", s.auth(s.handleConfidencePut)) + mux.HandleFunc("GET /tenants/{tenant}/model-config/{function}", s.auth(s.handleModelGet)) + mux.HandleFunc("PUT /tenants/{tenant}/model-config/{function}", s.auth(s.handleModelPut)) + + mux.HandleFunc("POST /corrections", s.auth(s.handleCorrectionRecord)) + mux.HandleFunc("GET /tenants/{tenant}/corrections/{function}/stats", s.auth(s.handleCorrectionStats)) + + mux.HandleFunc("POST /embeddings/ingest", s.auth(s.handleEmbedIngest)) + mux.HandleFunc("GET /embeddings/status", s.auth(s.handleEmbedStatus)) + + mux.HandleFunc("POST /prompt-templates/{key}", s.auth(s.handleTemplateSave)) + mux.HandleFunc("GET /prompt-templates/{key}", s.auth(s.handleTemplateActive)) + mux.HandleFunc("GET /prompt-templates/{key}/history", s.auth(s.handleTemplateHistory)) + mux.HandleFunc("PUT /prompt-templates/{key}/active", s.auth(s.handleTemplateActivate)) + + mux.HandleFunc("POST /summary-quality/evaluate", s.auth(s.handleSummaryQuality)) +} + +func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !svcauth.Equal(r.Header.Get("X-Service-Token"), s.ServiceToken) { + writeError(w, http.StatusUnauthorized, "service-token fehlt oder ist ungueltig") + return + } + next(w, r) + } +} + +func decode(w http.ResponseWriter, r *http.Request, v any) bool { + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(v); err != nil { + writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper") + return false + } + return true +} + +// enabled prueft den Mandantenschalter; der globale Schalter (Core LIC-02) ist hier nicht angebunden und gilt als an. +func (s *Server) enabled(w http.ResponseWriter, r *http.Request, tenant, function string) bool { + ok, err := s.Features.IsEnabled(r.Context(), tenant, function, true) + if err != nil { + writeError(w, http.StatusInternalServerError, "funktionsschalter lesen fehlgeschlagen") + return false + } + if !ok { + writeError(w, http.StatusForbidden, "funktion fuer diesen mandanten deaktiviert") + return false + } + return true +} + +func (s *Server) meets(ctx context.Context, tenant, function string, value float64) (bool, error) { + return s.Thresholds.MeetsThreshold(ctx, tenant, function, value) +} + +type classifyRequest struct { + TenantSlug string `json:"tenant_slug"` + Categories []string `json:"categories"` + Text string `json:"text"` + Actor string `json:"actor"` + InputRef string `json:"input_ref"` + PromptVersion int `json:"prompt_version"` +} + +type classifyResponse struct { + classify.Result + MeetsThreshold bool `json:"meets_threshold"` + Audited bool `json:"audited"` +} + +func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) { + var req classifyRequest + if !decode(w, r, &req) { + return + } + if strings.TrimSpace(req.TenantSlug) == "" || strings.TrimSpace(req.Text) == "" || len(req.Categories) == 0 { + writeError(w, http.StatusBadRequest, "tenant_slug, categories und text sind erforderlich") + return + } + if !s.enabled(w, r, req.TenantSlug, FunctionClassify) { + return + } + schema := classify.Schema{Categories: req.Categories} + audited := s.Audit != nil && strings.TrimSpace(req.Actor) != "" && strings.TrimSpace(req.InputRef) != "" + var ( + res classify.Result + err error + ) + if audited { + dc := classify.DecisionContext{Recorder: s.Audit, TenantSlug: req.TenantSlug, Actor: req.Actor, PromptVersion: req.PromptVersion} + res, err = s.Classifier.ClassifyWithSchemaAudited(r.Context(), schema, req.Text, dc, req.InputRef) + } else { + res, err = s.Classifier.ClassifyWithSchema(r.Context(), schema, req.Text) + } + if err != nil { + writeError(w, http.StatusBadGateway, "klassifizierung fehlgeschlagen") + return + } + ok, err := s.meets(r.Context(), req.TenantSlug, FunctionClassify, res.Confidence) + if err != nil { + writeError(w, http.StatusInternalServerError, "schwellenwert lesen fehlgeschlagen") + return + } + writeJSON(w, http.StatusOK, classifyResponse{Result: res, MeetsThreshold: ok, Audited: audited}) +} + +type doctypeResponse struct { + DocType string `json:"doc_type"` + Confidence float64 `json:"confidence"` + Ambiguous bool `json:"ambiguous"` + Reasoning string `json:"reasoning,omitempty"` + MeetsThreshold bool `json:"meets_threshold"` +} + +func (s *Server) handleDoctypeDetect(w http.ResponseWriter, r *http.Request) { + var req struct { + TenantSlug string `json:"tenant_slug"` + Text string `json:"text"` + } + if !decode(w, r, &req) { + return + } + if strings.TrimSpace(req.TenantSlug) == "" || strings.TrimSpace(req.Text) == "" { + writeError(w, http.StatusBadRequest, "tenant_slug und text sind erforderlich") + return + } + if !s.enabled(w, r, req.TenantSlug, FunctionDoctype) { + return + } + cats, err := s.Taxonomy.List(r.Context(), req.TenantSlug) + if err != nil { + writeError(w, http.StatusInternalServerError, "taxonomie lesen fehlgeschlagen") + return + } + if len(cats) == 0 { + writeError(w, http.StatusConflict, "keine dokumenttyp-taxonomie fuer diesen mandanten") + return + } + res, err := s.Doctype.Detect(r.Context(), req.TenantSlug, req.Text) + if err != nil { + writeError(w, http.StatusBadGateway, "dokumenttyp-erkennung fehlgeschlagen") + return + } + ok, err := s.meets(r.Context(), req.TenantSlug, FunctionDoctype, res.Confidence) + if err != nil { + writeError(w, http.StatusInternalServerError, "schwellenwert lesen fehlgeschlagen") + return + } + writeJSON(w, http.StatusOK, doctypeResponse{DocType: res.DocType, Confidence: res.Confidence, Ambiguous: res.Ambiguous, Reasoning: res.Reasoning, MeetsThreshold: ok}) +} + +func (s *Server) handleDoctypeList(w http.ResponseWriter, r *http.Request) { + codes, err := s.Taxonomy.List(r.Context(), r.PathValue("tenant")) + if err != nil { + writeError(w, http.StatusInternalServerError, "taxonomie lesen fehlgeschlagen") + return + } + if codes == nil { + codes = []string{} + } + writeJSON(w, http.StatusOK, map[string]any{"doc_types": codes}) +} + +func (s *Server) handleDoctypeAdd(w http.ResponseWriter, r *http.Request) { + var req struct { + Code string `json:"code"` + Label string `json:"label"` + } + if !decode(w, r, &req) { + return + } + if strings.TrimSpace(req.Code) == "" || strings.TrimSpace(req.Label) == "" { + writeError(w, http.StatusBadRequest, "code und label sind erforderlich") + return + } + if err := s.Taxonomy.Add(r.Context(), r.PathValue("tenant"), req.Code, req.Label); err != nil { + writeError(w, http.StatusInternalServerError, "dokumenttyp speichern fehlgeschlagen") + return + } + s.handleDoctypeList(w, r) +} + +func (s *Server) handleDoctypeSeed(w http.ResponseWriter, r *http.Request) { + if err := s.Taxonomy.SeedDefaults(r.Context(), r.PathValue("tenant")); err != nil { + writeError(w, http.StatusInternalServerError, "standard-dokumenttypen anlegen fehlgeschlagen") + return + } + s.handleDoctypeList(w, r) +} + +func (s *Server) handleHighlight(w http.ResponseWriter, r *http.Request) { + var req struct { + TenantSlug string `json:"tenant_slug"` + Text string `json:"text"` + } + if !decode(w, r, &req) { + return + } + if strings.TrimSpace(req.TenantSlug) == "" || strings.TrimSpace(req.Text) == "" { + writeError(w, http.StatusBadRequest, "tenant_slug und text sind erforderlich") + return + } + if !s.enabled(w, r, req.TenantSlug, FunctionHighlight) { + return + } + passages, err := s.Highlight.DetectImportant(r.Context(), req.Text) + resp := map[string]any{"passages": nonNil(passages)} + if err != nil { + // Teilergebnisse bleiben erhalten (highlight liefert sie neben dem Fehler). + if len(passages) == 0 { + writeError(w, http.StatusBadGateway, "textstellen-erkennung fehlgeschlagen") + return + } + resp["partial"] = true + } + writeJSON(w, http.StatusOK, resp) +} + +func nonNil(p []highlight.Passage) []highlight.Passage { + if p == nil { + return []highlight.Passage{} + } + return p +} + +func (s *Server) handleFeatureGet(w http.ResponseWriter, r *http.Request) { + on, err := s.Features.IsEnabled(r.Context(), r.PathValue("tenant"), r.PathValue("function"), true) + if err != nil { + writeError(w, http.StatusInternalServerError, "funktionsschalter lesen fehlgeschlagen") + return + } + writeJSON(w, http.StatusOK, map[string]bool{"enabled": on}) +} + +func (s *Server) handleFeaturePut(w http.ResponseWriter, r *http.Request) { + var req struct { + Enabled *bool `json:"enabled"` + } + if !decode(w, r, &req) { + return + } + if req.Enabled == nil { + writeError(w, http.StatusBadRequest, "enabled ist erforderlich") + return + } + if err := s.Features.Set(r.Context(), r.PathValue("tenant"), r.PathValue("function"), *req.Enabled); err != nil { + writeError(w, http.StatusBadRequest, "funktionsschalter speichern fehlgeschlagen") + return + } + s.handleFeatureGet(w, r) +} + +func (s *Server) handleConfidenceGet(w http.ResponseWriter, r *http.Request) { + v, err := s.Thresholds.Get(r.Context(), r.PathValue("tenant"), r.PathValue("function")) + if err != nil { + writeError(w, http.StatusInternalServerError, "schwellenwert lesen fehlgeschlagen") + return + } + writeJSON(w, http.StatusOK, map[string]float64{"threshold": v}) +} + +func (s *Server) handleConfidencePut(w http.ResponseWriter, r *http.Request) { + var req struct { + Threshold *float64 `json:"threshold"` + } + if !decode(w, r, &req) { + return + } + if req.Threshold == nil { + writeError(w, http.StatusBadRequest, "threshold ist erforderlich") + return + } + if err := s.Thresholds.Set(r.Context(), r.PathValue("tenant"), r.PathValue("function"), *req.Threshold); err != nil { + if errors.Is(err, confidence.ErrInvalidThreshold) { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, "schwellenwert speichern fehlgeschlagen") + return + } + s.handleConfidenceGet(w, r) +} + +func (s *Server) handleModelGet(w http.ResponseWriter, r *http.Request) { + sel, err := s.Models.Get(r.Context(), r.PathValue("tenant"), r.PathValue("function")) + if errors.Is(err, modelconfig.ErrNotFound) { + writeError(w, http.StatusNotFound, "keine modellwahl gesetzt") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "modellwahl lesen fehlgeschlagen") + return + } + writeJSON(w, http.StatusOK, map[string]string{"provider": sel.ProviderName, "model": sel.Model}) +} + +func (s *Server) handleModelPut(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + Model string `json:"model"` + } + if !decode(w, r, &req) { + return + } + if strings.TrimSpace(req.Provider) == "" || strings.TrimSpace(req.Model) == "" { + writeError(w, http.StatusBadRequest, "provider und model sind erforderlich") + return + } + err := s.Models.Set(r.Context(), r.PathValue("tenant"), r.PathValue("function"), req.Provider, req.Model) + switch { + case errors.Is(err, modelconfig.ErrUnknownProvider), errors.Is(err, modelconfig.ErrModelNotAvailable): + writeError(w, http.StatusBadRequest, err.Error()) + return + case err != nil: + writeError(w, http.StatusBadGateway, "modellwahl speichern fehlgeschlagen") + return + } + s.handleModelGet(w, r) +} + +func (s *Server) handleCorrectionRecord(w http.ResponseWriter, r *http.Request) { + var req struct { + TenantSlug string `json:"tenant_slug"` + Function string `json:"function"` + InputRef string `json:"input_ref"` + SuggestedCategory string `json:"suggested_category"` + SuggestedConfidence float64 `json:"suggested_confidence"` + CorrectedCategory string `json:"corrected_category"` + Actor string `json:"actor"` + } + if !decode(w, r, &req) { + return + } + err := s.Corrections.Record(r.Context(), correction.Correction{ + TenantSlug: req.TenantSlug, Function: req.Function, InputRef: req.InputRef, + SuggestedCategory: req.SuggestedCategory, SuggestedConfidence: req.SuggestedConfidence, + CorrectedCategory: req.CorrectedCategory, Actor: req.Actor, + }) + if err != nil { + // Record validiert vor dem Schreiben; ein Fehler ist hier fast immer eine unvollstaendige Anfrage. + writeError(w, http.StatusBadRequest, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleCorrectionStats(w http.ResponseWriter, r *http.Request) { + st, err := s.Corrections.Aggregate(r.Context(), r.PathValue("tenant"), r.PathValue("function")) + if err != nil { + writeError(w, http.StatusInternalServerError, "korrekturstatistik lesen fehlgeschlagen") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "total_corrections": st.TotalCorrections, + "top_suggested": nonNilMap(st.TopSuggested), + "top_corrected_to": nonNilMap(st.TopCorrectedTo), + "average_suggested": st.AverageSuggested, + }) +} + +func nonNilMap(m map[string]int) map[string]int { + if m == nil { + return map[string]int{} + } + return m +} + +func (s *Server) handleEmbedIngest(w http.ResponseWriter, r *http.Request) { + var req struct { + TenantSlug string `json:"tenant_slug"` + SourceType string `json:"source_type"` + SourceID string `json:"source_id"` + Text string `json:"text"` + } + if !decode(w, r, &req) { + return + } + if strings.TrimSpace(req.TenantSlug) == "" || strings.TrimSpace(req.SourceType) == "" || strings.TrimSpace(req.SourceID) == "" { + writeError(w, http.StatusBadRequest, "tenant_slug, source_type und source_id sind erforderlich") + return + } + chunks, err := s.Embeddings.Ingest(r.Context(), req.TenantSlug, req.SourceType, req.SourceID, req.Text) + if err != nil { + writeError(w, http.StatusInternalServerError, "einreihen fehlgeschlagen") + return + } + writeJSON(w, http.StatusAccepted, map[string]int{"chunks": len(chunks)}) +} + +func (s *Server) handleEmbedStatus(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + tenant, st, sid := q.Get("tenant_slug"), q.Get("source_type"), q.Get("source_id") + if tenant == "" || st == "" || sid == "" { + writeError(w, http.StatusBadRequest, "tenant_slug, source_type und source_id sind erforderlich") + return + } + rows, err := s.Embeddings.Status(r.Context(), tenant, st, sid) + if err != nil { + writeError(w, http.StatusInternalServerError, "status lesen fehlgeschlagen") + return + } + type chunk struct { + ChunkIndex int `json:"chunk_index"` + JobStatus string `json:"job_status"` + Attempts int `json:"attempts"` + LastError *string `json:"last_error"` + HasVector bool `json:"has_vector"` + } + out := make([]chunk, 0, len(rows)) + for _, c := range rows { + out = append(out, chunk{c.ChunkIndex, c.JobStatus, c.Attempts, c.LastError, c.HasVector}) + } + writeJSON(w, http.StatusOK, map[string]any{"chunks": out}) +} + +type versionJSON struct { + FunctionKey string `json:"function_key"` + Version int `json:"version"` + Body string `json:"body"` + CreatedBy string `json:"created_by"` +} + +func toVersionJSON(v prompttemplate.Version) versionJSON { + return versionJSON{v.FunctionKey, v.Version, v.Body, v.CreatedBy} +} + +func (s *Server) handleTemplateSave(w http.ResponseWriter, r *http.Request) { + var req struct { + Body string `json:"body"` + CreatedBy string `json:"created_by"` + } + if !decode(w, r, &req) { + return + } + if strings.TrimSpace(req.Body) == "" || strings.TrimSpace(req.CreatedBy) == "" { + writeError(w, http.StatusBadRequest, "body und created_by sind erforderlich") + return + } + v, err := s.Templates.Save(r.Context(), r.PathValue("key"), req.Body, req.CreatedBy) + if err != nil { + writeError(w, http.StatusInternalServerError, "vorlage speichern fehlgeschlagen") + return + } + writeJSON(w, http.StatusCreated, toVersionJSON(v)) +} + +func (s *Server) handleTemplateActive(w http.ResponseWriter, r *http.Request) { + v, err := s.Templates.Active(r.Context(), r.PathValue("key")) + if errors.Is(err, prompttemplate.ErrNotFound) { + writeError(w, http.StatusNotFound, "keine vorlage fuer diese funktion") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "vorlage lesen fehlgeschlagen") + return + } + writeJSON(w, http.StatusOK, toVersionJSON(v)) +} + +func (s *Server) handleTemplateHistory(w http.ResponseWriter, r *http.Request) { + vs, err := s.Templates.History(r.Context(), r.PathValue("key")) + if err != nil { + writeError(w, http.StatusInternalServerError, "verlauf lesen fehlgeschlagen") + return + } + out := make([]versionJSON, 0, len(vs)) + for _, v := range vs { + out = append(out, toVersionJSON(v)) + } + writeJSON(w, http.StatusOK, map[string]any{"versions": out}) +} + +func (s *Server) handleTemplateActivate(w http.ResponseWriter, r *http.Request) { + var req struct { + Version int `json:"version"` + ActivatedBy string `json:"activated_by"` + } + if !decode(w, r, &req) { + return + } + if req.Version < 1 || strings.TrimSpace(req.ActivatedBy) == "" { + writeError(w, http.StatusBadRequest, "version und activated_by sind erforderlich") + return + } + err := s.Templates.Activate(r.Context(), r.PathValue("key"), req.Version, req.ActivatedBy) + if errors.Is(err, prompttemplate.ErrNotFound) { + writeError(w, http.StatusNotFound, "version unbekannt") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "version aktivieren fehlgeschlagen") + return + } + s.handleTemplateActive(w, r) +} + +func (s *Server) handleSummaryQuality(w http.ResponseWriter, r *http.Request) { + var req struct { + Samples []struct { + Type string `json:"type"` + Source string `json:"source"` + Summary string `json:"summary"` + } `json:"samples"` + } + if !decode(w, r, &req) { + return + } + if len(req.Samples) == 0 { + writeError(w, http.StatusBadRequest, "samples sind erforderlich") + return + } + type metricJSON struct { + Type string `json:"type"` + GroundednessScore float64 `json:"groundedness_score"` + Flagged bool `json:"flagged"` + Reason string `json:"reason"` + } + metrics := make([]summaryquality.Metric, 0, len(req.Samples)) + items := make([]metricJSON, 0, len(req.Samples)) + for _, sm := range req.Samples { + m := summaryquality.Evaluate(summaryquality.Sample{Type: sm.Type, Source: sm.Source, Summary: sm.Summary}) + metrics = append(metrics, m) + items = append(items, metricJSON{sm.Type, m.GroundednessScore, m.Flagged, m.Reason}) + } + type typeJSON struct { + Count int `json:"count"` + AverageGroundedness float64 `json:"average_groundedness"` + FlaggedCount int `json:"flagged_count"` + } + byType := map[string]typeJSON{} + for k, v := range summaryquality.AggregateByType(metrics) { + byType[k] = typeJSON{v.Count, v.AverageGroundedness, v.FlaggedCount} + } + writeJSON(w, http.StatusOK, map[string]any{"metrics": items, "by_type": byType}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/ai/internal/featureapi/featureapi_test.go b/ai/internal/featureapi/featureapi_test.go new file mode 100644 index 00000000..d43cd3fa --- /dev/null +++ b/ai/internal/featureapi/featureapi_test.go @@ -0,0 +1,387 @@ +package featureapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/classify" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/confidence" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/correction" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/embedpipeline" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/highlight" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/modelconfig" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/prompttemplate" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/mock" +) + +type fakeFeatures struct{ off map[string]bool } + +func (f *fakeFeatures) Set(_ context.Context, t, fn string, on bool) error { + f.off[t+"/"+fn] = !on + return nil +} +func (f *fakeFeatures) IsEnabled(_ context.Context, t, fn string, global bool) (bool, error) { + return global && !f.off[t+"/"+fn], nil +} + +type fakeThresholds struct{ v map[string]float64 } + +func (f *fakeThresholds) Set(_ context.Context, t, fn string, th float64) error { + if th < 0 || th > 1 { + return confidence.ErrInvalidThreshold + } + f.v[t+"/"+fn] = th + return nil +} +func (f *fakeThresholds) Get(_ context.Context, t, fn string) (float64, error) { + if v, ok := f.v[t+"/"+fn]; ok { + return v, nil + } + return confidence.DefaultThreshold, nil +} +func (f *fakeThresholds) MeetsThreshold(ctx context.Context, t, fn string, value float64) (bool, error) { + th, _ := f.Get(ctx, t, fn) + return value >= th, nil +} + +type fakeTaxonomy struct{ codes map[string][]string } + +func (f *fakeTaxonomy) List(_ context.Context, t string) ([]string, error) { return f.codes[t], nil } +func (f *fakeTaxonomy) Add(_ context.Context, t, code, _ string) error { + f.codes[t] = append(f.codes[t], code) + return nil +} +func (f *fakeTaxonomy) SeedDefaults(_ context.Context, t string) error { + f.codes[t] = []string{"rechnung", "vertrag"} + return nil +} + +type fakeCorrections struct{ got []correction.Correction } + +func (f *fakeCorrections) Record(_ context.Context, c correction.Correction) error { + if c.Actor == "" { + return errors.New("correction: actor darf nicht leer sein") + } + f.got = append(f.got, c) + return nil +} +func (f *fakeCorrections) Aggregate(_ context.Context, t, fn string) (correction.Stats, error) { + return correction.Stats{TenantSlug: t, Function: fn, TotalCorrections: len(f.got)}, nil +} + +type fakeEmbeddings struct{} + +func (fakeEmbeddings) Ingest(_ context.Context, _, _, _, text string) ([]embedpipeline.Chunk, error) { + return []embedpipeline.Chunk{{Index: 0, Text: text}, {Index: 1, Text: text}}, nil +} +func (fakeEmbeddings) Status(_ context.Context, _, _, _ string) ([]embedpipeline.ChunkStatus, error) { + return []embedpipeline.ChunkStatus{{ChunkIndex: 0, JobStatus: "done", Attempts: 1, HasVector: true}}, nil +} + +type fakeModels struct { + sel map[string]modelconfig.Selection +} + +func (f *fakeModels) Set(_ context.Context, t, fn, prov, model string) error { + if prov != "ollama" { + return modelconfig.ErrUnknownProvider + } + if model != "known" { + return modelconfig.ErrModelNotAvailable + } + f.sel[t+"/"+fn] = modelconfig.Selection{ProviderName: prov, Model: model} + return nil +} +func (f *fakeModels) Get(_ context.Context, t, fn string) (modelconfig.Selection, error) { + if s, ok := f.sel[t+"/"+fn]; ok { + return s, nil + } + return modelconfig.Selection{}, modelconfig.ErrNotFound +} + +type fakeTemplates struct { + vs map[string][]prompttemplate.Version +} + +func (f *fakeTemplates) Save(_ context.Context, key, body, by string) (prompttemplate.Version, error) { + v := prompttemplate.Version{FunctionKey: key, Version: len(f.vs[key]) + 1, Body: body, CreatedBy: by} + f.vs[key] = append(f.vs[key], v) + return v, nil +} +func (f *fakeTemplates) Activate(_ context.Context, key string, version int, _ string) error { + if version > len(f.vs[key]) { + return prompttemplate.ErrNotFound + } + f.vs[key] = append(f.vs[key][:version:version], f.vs[key][version-1]) + return nil +} +func (f *fakeTemplates) Active(_ context.Context, key string) (prompttemplate.Version, error) { + if len(f.vs[key]) == 0 { + return prompttemplate.Version{}, prompttemplate.ErrNotFound + } + return f.vs[key][len(f.vs[key])-1], nil +} +func (f *fakeTemplates) History(_ context.Context, key string) ([]prompttemplate.Version, error) { + return f.vs[key], nil +} + +type fixture struct { + srv *httptest.Server + prov *mock.Provider + features *fakeFeatures + corr *fakeCorrections +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + f := &fixture{prov: &mock.Provider{}, features: &fakeFeatures{off: map[string]bool{}}, corr: &fakeCorrections{}} + s := &Server{ + Classifier: &classify.Classifier{Provider: f.prov, Model: "m"}, + Highlight: &highlight.Detector{Provider: f.prov, Model: "m"}, + Taxonomy: &fakeTaxonomy{codes: map[string][]string{}}, + Features: f.features, + Thresholds: &fakeThresholds{v: map[string]float64{}}, + Corrections: f.corr, + Embeddings: fakeEmbeddings{}, + Models: &fakeModels{sel: map[string]modelconfig.Selection{}}, + Templates: &fakeTemplates{vs: map[string][]prompttemplate.Version{}}, + ServiceToken: "tok", + } + mux := http.NewServeMux() + s.RegisterRoutes(mux) + f.srv = httptest.NewServer(mux) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fixture) reply(content string) { + f.prov.ChatResponse = provider.ChatResponse{Message: provider.Message{Role: provider.RoleAssistant, Content: content}} +} + +func (f *fixture) do(t *testing.T, method, path, body string, token bool) (int, map[string]any) { + t.Helper() + req, _ := http.NewRequest(method, f.srv.URL+path, strings.NewReader(body)) + if token { + req.Header.Set("X-Service-Token", "tok") + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp.StatusCode, out +} + +func TestAllRoutesRequireServiceToken(t *testing.T) { + f := newFixture(t) + for _, r := range [][2]string{ + {"POST", "/classify"}, {"POST", "/doctype/detect"}, {"GET", "/tenants/a/doctypes"}, {"POST", "/tenants/a/doctypes"}, + {"POST", "/tenants/a/doctypes/defaults"}, {"POST", "/highlight"}, + {"GET", "/tenants/a/features/classify"}, {"PUT", "/tenants/a/features/classify"}, + {"GET", "/tenants/a/confidence/classify"}, {"PUT", "/tenants/a/confidence/classify"}, + {"GET", "/tenants/a/model-config/chat"}, {"PUT", "/tenants/a/model-config/chat"}, + {"POST", "/corrections"}, {"GET", "/tenants/a/corrections/classify/stats"}, + {"POST", "/embeddings/ingest"}, {"GET", "/embeddings/status"}, + {"POST", "/prompt-templates/k"}, {"GET", "/prompt-templates/k"}, {"GET", "/prompt-templates/k/history"}, {"PUT", "/prompt-templates/k/active"}, + {"POST", "/summary-quality/evaluate"}, + } { + if code, _ := f.do(t, r[0], r[1], `{}`, false); code != http.StatusUnauthorized { + t.Errorf("%s %s: status %d, want 401", r[0], r[1], code) + } + } +} + +func TestClassify_ReturnsCategoryAndThresholdVerdict(t *testing.T) { + f := newFixture(t) + f.reply(`{"category":"rechnung","confidence":0.9,"reasoning":"x"}`) + code, out := f.do(t, "PUT", "/tenants/acme/confidence/classify", `{"threshold":0.95}`, true) + if code != http.StatusOK || out["threshold"] != 0.95 { + t.Fatalf("threshold put: %d %v", code, out) + } + code, out = f.do(t, "POST", "/classify", `{"tenant_slug":"acme","categories":["rechnung","vertrag"],"text":"Rechnung Nr. 1"}`, true) + if code != http.StatusOK || out["category"] != "rechnung" { + t.Fatalf("classify: %d %v", code, out) + } + if out["meets_threshold"] != false || out["audited"] != false { + t.Errorf("0.9 < 0.95 muss meets_threshold=false liefern, ohne Audit audited=false: %v", out) + } +} + +func TestClassify_DisabledForTenantIsForbidden(t *testing.T) { + f := newFixture(t) + f.reply(`{"category":"rechnung","confidence":0.9}`) + if code, _ := f.do(t, "PUT", "/tenants/acme/features/classify", `{"enabled":false}`, true); code != http.StatusOK { + t.Fatalf("feature put: %d", code) + } + code, _ := f.do(t, "POST", "/classify", `{"tenant_slug":"acme","categories":["rechnung"],"text":"x"}`, true) + if code != http.StatusForbidden { + t.Fatalf("status %d, want 403", code) + } + if code, _ := f.do(t, "POST", "/classify", `{"tenant_slug":"other","categories":["rechnung"],"text":"x"}`, true); code != http.StatusOK { + t.Fatalf("anderer mandant: status %d, want 200", code) + } +} + +func TestClassify_RejectsIncompleteRequest(t *testing.T) { + f := newFixture(t) + for _, body := range []string{`{}`, `{"tenant_slug":"a","text":"x"}`, `{"tenant_slug":"a","categories":["x"]}`, `nope`} { + if code, _ := f.do(t, "POST", "/classify", body, true); code != http.StatusBadRequest { + t.Errorf("%q: status %d, want 400", body, code) + } + } +} + +func TestClassify_ProviderErrorIsBadGateway(t *testing.T) { + f := newFixture(t) + f.prov.Err = errors.New("down") + code, _ := f.do(t, "POST", "/classify", `{"tenant_slug":"a","categories":["x"],"text":"t"}`, true) + if code != http.StatusBadGateway { + t.Fatalf("status %d, want 502", code) + } +} + +func TestDoctype_TaxonomyManagementAndEmptyTaxonomyConflict(t *testing.T) { + f := newFixture(t) + if code, _ := f.do(t, "POST", "/doctype/detect", `{"tenant_slug":"acme","text":"x"}`, true); code != http.StatusConflict { + t.Fatalf("leere taxonomie: status %d, want 409", code) + } + code, out := f.do(t, "POST", "/tenants/acme/doctypes/defaults", `{}`, true) + if code != http.StatusOK || len(out["doc_types"].([]any)) != 2 { + t.Fatalf("defaults: %d %v", code, out) + } + code, out = f.do(t, "POST", "/tenants/acme/doctypes", `{"code":"kuendigung","label":"Kuendigung"}`, true) + if code != http.StatusOK || len(out["doc_types"].([]any)) != 3 { + t.Fatalf("add: %d %v", code, out) + } + if code, _ := f.do(t, "POST", "/tenants/acme/doctypes", `{"code":"x"}`, true); code != http.StatusBadRequest { + t.Fatalf("add ohne label: status %d, want 400", code) + } +} + +func TestHighlight_ReturnsPassagesOrEmptyList(t *testing.T) { + f := newFixture(t) + f.reply(`[]`) + code, out := f.do(t, "POST", "/highlight", `{"tenant_slug":"acme","text":"Frist endet am 1. Mai."}`, true) + if code != http.StatusOK { + t.Fatalf("status %d", code) + } + if ps, ok := out["passages"].([]any); !ok || len(ps) != 0 { + t.Errorf("passages = %v, want leere liste", out["passages"]) + } +} + +func TestConfidence_RejectsOutOfRange(t *testing.T) { + f := newFixture(t) + if code, _ := f.do(t, "PUT", "/tenants/a/confidence/classify", `{"threshold":1.5}`, true); code != http.StatusBadRequest { + t.Fatalf("status %d, want 400", code) + } + if code, _ := f.do(t, "PUT", "/tenants/a/confidence/classify", `{}`, true); code != http.StatusBadRequest { + t.Fatalf("ohne threshold: status %d, want 400", code) + } + _, out := f.do(t, "GET", "/tenants/a/confidence/classify", ``, true) + if out["threshold"] != confidence.DefaultThreshold { + t.Errorf("default = %v", out["threshold"]) + } +} + +func TestModelConfig_ValidationAndRoundTrip(t *testing.T) { + f := newFixture(t) + if code, _ := f.do(t, "GET", "/tenants/a/model-config/chat", ``, true); code != http.StatusNotFound { + t.Fatalf("ungesetzt: status %d, want 404", code) + } + if code, _ := f.do(t, "PUT", "/tenants/a/model-config/chat", `{"provider":"nope","model":"known"}`, true); code != http.StatusBadRequest { + t.Fatalf("unbekannter provider: status %d, want 400", code) + } + if code, _ := f.do(t, "PUT", "/tenants/a/model-config/chat", `{"provider":"ollama","model":"weg"}`, true); code != http.StatusBadRequest { + t.Fatalf("unbekanntes modell: status %d, want 400", code) + } + code, out := f.do(t, "PUT", "/tenants/a/model-config/chat", `{"provider":"ollama","model":"known"}`, true) + if code != http.StatusOK || out["model"] != "known" { + t.Fatalf("put: %d %v", code, out) + } +} + +func TestCorrections_RecordAndStats(t *testing.T) { + f := newFixture(t) + body := `{"tenant_slug":"a","function":"classify","input_ref":"d1","suggested_category":"x","suggested_confidence":0.4,"corrected_category":"y","actor":"u1"}` + if code, _ := f.do(t, "POST", "/corrections", body, true); code != http.StatusNoContent { + t.Fatalf("record: status %d, want 204", code) + } + if code, _ := f.do(t, "POST", "/corrections", `{"tenant_slug":"a"}`, true); code != http.StatusBadRequest { + t.Fatalf("unvollstaendig: status %d, want 400", code) + } + code, out := f.do(t, "GET", "/tenants/a/corrections/classify/stats", ``, true) + if code != http.StatusOK || out["total_corrections"] != 1.0 { + t.Fatalf("stats: %d %v", code, out) + } +} + +func TestEmbeddings_IngestAndStatus(t *testing.T) { + f := newFixture(t) + code, out := f.do(t, "POST", "/embeddings/ingest", `{"tenant_slug":"a","source_type":"doc","source_id":"1","text":"abc"}`, true) + if code != http.StatusAccepted || out["chunks"] != 2.0 { + t.Fatalf("ingest: %d %v", code, out) + } + if code, _ := f.do(t, "POST", "/embeddings/ingest", `{"tenant_slug":"a"}`, true); code != http.StatusBadRequest { + t.Fatalf("unvollstaendig: status %d, want 400", code) + } + code, out = f.do(t, "GET", "/embeddings/status?tenant_slug=a&source_type=doc&source_id=1", ``, true) + if code != http.StatusOK || len(out["chunks"].([]any)) != 1 { + t.Fatalf("status: %d %v", code, out) + } + if code, _ := f.do(t, "GET", "/embeddings/status", ``, true); code != http.StatusBadRequest { + t.Fatalf("ohne parameter: status %d, want 400", code) + } +} + +func TestPromptTemplates_VersionsAndRollback(t *testing.T) { + f := newFixture(t) + if code, _ := f.do(t, "GET", "/prompt-templates/k", ``, true); code != http.StatusNotFound { + t.Fatalf("ohne vorlage: status %d, want 404", code) + } + f.do(t, "POST", "/prompt-templates/k", `{"body":"eins","created_by":"u"}`, true) + code, out := f.do(t, "POST", "/prompt-templates/k", `{"body":"zwei","created_by":"u"}`, true) + if code != http.StatusCreated || out["version"] != 2.0 { + t.Fatalf("save: %d %v", code, out) + } + if code, _ := f.do(t, "PUT", "/prompt-templates/k/active", `{"version":9,"activated_by":"u"}`, true); code != http.StatusNotFound { + t.Fatalf("unbekannte version: status %d, want 404", code) + } + code, out = f.do(t, "PUT", "/prompt-templates/k/active", `{"version":1,"activated_by":"u"}`, true) + if code != http.StatusOK || out["body"] != "eins" { + t.Fatalf("rollback: %d %v", code, out) + } + if code, _ := f.do(t, "POST", "/prompt-templates/k", `{"body":"x"}`, true); code != http.StatusBadRequest { + t.Fatalf("ohne created_by: status %d, want 400", code) + } +} + +func TestSummaryQuality_FlagsFabricatedSummary(t *testing.T) { + f := newFixture(t) + body := `{"samples":[ + {"type":"rechnung","source":"Die Rechnung ueber 100 Euro ist bis Freitag faellig","summary":"Rechnung ueber 100 Euro faellig bis Freitag"}, + {"type":"rechnung","source":"Die Rechnung ueber 100 Euro ist bis Freitag faellig","summary":"Vertrag wurde fristlos gekuendigt Schadenersatz Klage erhoben"}]}` + code, out := f.do(t, "POST", "/summary-quality/evaluate", body, true) + if code != http.StatusOK { + t.Fatalf("status %d", code) + } + items := out["metrics"].([]any) + if items[0].(map[string]any)["flagged"] != false || items[1].(map[string]any)["flagged"] != true { + t.Errorf("flags = %v", items) + } + bt := out["by_type"].(map[string]any)["rechnung"].(map[string]any) + if bt["count"] != 2.0 || bt["flagged_count"] != 1.0 { + t.Errorf("by_type = %v", bt) + } + if code, _ := f.do(t, "POST", "/summary-quality/evaluate", `{"samples":[]}`, true); code != http.StatusBadRequest { + t.Fatalf("leer: status %d, want 400", code) + } +}