ai-app: zehn weitere AI-Pakete verdrahten (featureapi) #449
No files matched your search
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user