ai-app: zehn weitere AI-Pakete verdrahten (featureapi) #449

Open
patrick wants to merge 1 commits from feat/ai-app-restpakete into feat/ai-app-vorschlaege
pull from: feat/ai-app-restpakete
5 changed files with 1180 additions and 0 deletions

No files matched your search

+34
View File
@@ -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}
}
+91
View File
@@ -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")
+27
View File
@@ -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.
+641
View File
@@ -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})
}
+387
View File
@@ -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)
}
}