ai-app: suggestapi verdrahtet (Titel, Korrespondent, Tags, Uebernahme, Mandantenkonfiguration)
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Successful in 38s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 3s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 2s
CI / Web (dms-browser) (push) Failing after 50s
CI / Web (pdf-viewer) (push) Failing after 37s
CI / Web (shl) (push) Failing after 9s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 13s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 22s
AI-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 5m11s
CI / Web (shl) (pull_request) Failing after 9s
CI / Core-Modul (Build/Vet/Test) (pull_request) Successful in 39s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 2s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 3s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 14s
CI / Web (dms-browser) (pull_request) Failing after 59s
CI / Web (pdf-viewer) (pull_request) Failing after 41s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 5m41s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 1m55s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 1m19s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 5m51s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 5m58s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 10m41s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Failing after 2s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s

Neues Paket suggestapi mit Service-Token-Pflicht. Sprachregister aus
NEXARCH_AI_LANGUAGES_FILE beim Start geladen. Uebernahme-Protokollierung
nur mit NEXARCH_AI_APP_CORE_BASE_URL, sonst 503.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5.5 committed 2026-11-06 23:02:21 +01:00
1 parent a3d5428c22
commit b9dbfe8678
5 files changed
+606 -1

No files matched your search

+36
View File
@@ -18,8 +18,10 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"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/extract"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extractapi"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extractconfirmapi"
@@ -29,6 +31,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/metadatapushclient"
"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/provider"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/ollama"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/queue"
@@ -39,10 +42,12 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/routingsuggestapi"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearch"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearchapi"
"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/threadsummarize"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex"
"gitea.perlbach24.de/scripte/nexarch/common/auditclient"
"gitea.perlbach24.de/scripte/nexarch/common/policyclient"
"gitea.perlbach24.de/scripte/nexarch/common/tenantregistryclient"
)
@@ -63,6 +68,7 @@ type config struct {
policyURL string
policyServiceToken string
policyDisabled bool
languages *doclang.Registry
}
// policyClient liefert nil nur mit NEXARCH_AI_APP_POLICY_DISABLED=1.
@@ -119,6 +125,11 @@ func loadConfig(getenv func(string) string) (config, error) {
if cfg.policyURL == "" && !cfg.policyDisabled {
return config{}, errors.New("ai-app: NEXARCH_AI_APP_POLICY_URL ist erforderlich (oder NEXARCH_AI_APP_POLICY_DISABLED=1 als Uebergang)")
}
languages, err := doclang.LoadRegistryFile(getenv(doclang.EnvLanguagesFile))
if err != nil {
return config{}, err
}
cfg.languages = languages
if cfg.addr == "" {
cfg.addr = defaultListenAddr
}
@@ -193,6 +204,31 @@ func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID stri
log.Print("ai-app: NEXARCH_AI_APP_CORE_BASE_URL nicht gesetzt, Objekttyp-Registrierung (GOV-01) deaktiviert")
}
registry := cfg.languages
if registry == nil {
registry = doclang.DefaultRegistry()
}
langStore := &doclang.Store{Pool: pool}
markerStore := &originmark.Store{Pool: pool}
suggest := &suggestapi.Server{
Provider: prov,
Model: cfg.model,
Resolver: &doclang.Resolver{Registry: registry, Tenants: langStore},
Languages: langStore,
Markers: markerStore,
ServiceToken: cfg.serviceToken,
}
// CLS-09: ohne Core-Audit gibt es keine Uebernahme-Protokollierung.
if cfg.coreBaseURL != "" {
suggest.Adopt = &originmark.Service{
Markers: markerStore,
Audit: decisionaudit.NewRecorder(auditclient.New(cfg.coreBaseURL, cfg.coreServiceToken)),
}
} else {
log.Print("ai-app: NEXARCH_AI_APP_CORE_BASE_URL nicht gesetzt, Uebernahme-Protokollierung (/suggest/adopt) deaktiviert")
}
suggest.RegisterRoutes(mux)
quotaStore := quota.NewStore(pool, nil)
q := queue.NewQueue(pool, 5*time.Minute)
q.SetQuotaEnforcer(quotaStore)
+31
View File
@@ -165,6 +165,20 @@ func TestBuild_AllPackagesRegistered(t *testing.T) {
if resp := get("POST", "/invoices/extract", `{"text":""}`); resp.StatusCode == http.StatusNotFound {
t.Errorf("extractapi route nicht registriert (404)")
}
// suggestapi (CLS-06..09): Mandantenkonfiguration laeuft gegen die echten Tabellen.
if resp := get("PUT", "/tenants/acme/language", `{"language":"en"}`); resp.StatusCode != http.StatusOK {
t.Errorf("suggestapi language status = %d, want 200", resp.StatusCode)
}
if resp := get("PUT", "/tenants/acme/origin-marker", `{"marker":"ki"}`); resp.StatusCode != http.StatusOK {
t.Errorf("suggestapi origin-marker status = %d, want 200", resp.StatusCode)
}
if resp := get("POST", "/suggest/title", `{"tenant_slug":"acme","text":""}`); resp.StatusCode != http.StatusBadRequest {
t.Errorf("suggestapi title status = %d, want 400", resp.StatusCode)
}
if resp := get("POST", "/suggest/adopt", `{}`); resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("suggestapi adopt ohne Core-URL status = %d, want 503", resp.StatusCode)
}
}
// AK2 + Pruefung 2: ein echter Inferenz-Auftrag durchlaeuft die Warteschlange
@@ -225,3 +239,20 @@ func TestLoadConfig_PolicyRequiredUnlessDisabled(t *testing.T) {
t.Fatalf("mit POLICY_DISABLED=1 darf der Start nicht scheitern: %v", err)
}
}
func TestLoadConfig_LanguagesFile(t *testing.T) {
env := map[string]string{
"NEXARCH_AI_APP_TENANT_DSN": "postgres://x",
"NEXARCH_AI_APP_SERVICE_TOKEN": "tok",
"NEXARCH_AI_APP_POLICY_DISABLED": "1",
}
get := func(k string) string { return env[k] }
cfg, err := loadConfig(get)
if err != nil || cfg.languages == nil {
t.Fatalf("standardsprachen erwartet: %v", err)
}
env["NEXARCH_AI_LANGUAGES_FILE"] = filepath.Join(t.TempDir(), "fehlt.json")
if _, err := loadConfig(get); err == nil || !strings.Contains(err.Error(), "NEXARCH_AI_LANGUAGES_FILE") {
t.Fatalf("klarer fehler bei fehlender sprachdatei erwartet, bekam %v", err)
}
}
+5 -1
View File
@@ -87,8 +87,12 @@ func (r *Registry) LoadJSON(src io.Reader) error {
}
func RegistryFromEnv() (*Registry, error) {
return LoadRegistryFile(os.Getenv(EnvLanguagesFile))
}
// LoadRegistryFile ergaenzt die Standardsprachen um die Sprachen aus path; leerer Pfad = nur Standard.
func LoadRegistryFile(path string) (*Registry, error) {
r := DefaultRegistry()
path := os.Getenv(EnvLanguagesFile)
if path == "" {
return r, nil
}
+293
View File
@@ -0,0 +1,293 @@
package suggestapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/correspondentsuggest"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/doclang"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/originmark"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/tagsuggest"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/titlesuggest"
"gitea.perlbach24.de/scripte/nexarch/common/svcauth"
)
const maxBodyBytes = 1 << 20
type MarkerStore interface {
Set(ctx context.Context, tenantSlug, marker string) error
Get(ctx context.Context, tenantSlug string) (string, error)
Clear(ctx context.Context, tenantSlug string) error
}
type LanguageStore interface {
Set(ctx context.Context, tenantSlug, code string) error
Get(ctx context.Context, tenantSlug string) (string, error)
}
type Server struct {
Provider provider.Provider
Model string
Resolver *doclang.Resolver
Languages LanguageStore
Markers MarkerStore
Adopt *originmark.Service
ServiceToken string
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /suggest/title", s.auth(s.handleTitle))
mux.HandleFunc("POST /suggest/correspondent", s.auth(s.handleCorrespondent))
mux.HandleFunc("POST /suggest/tags", s.auth(s.handleTags))
mux.HandleFunc("POST /suggest/adopt", s.auth(s.handleAdopt))
mux.HandleFunc("GET /tenants/{tenant}/origin-marker", s.auth(s.handleMarkerGet))
mux.HandleFunc("PUT /tenants/{tenant}/origin-marker", s.auth(s.handleMarkerPut))
mux.HandleFunc("DELETE /tenants/{tenant}/origin-marker", s.auth(s.handleMarkerDelete))
mux.HandleFunc("GET /tenants/{tenant}/language", s.auth(s.handleLanguageGet))
mux.HandleFunc("PUT /tenants/{tenant}/language", s.auth(s.handleLanguagePut))
}
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)
}
}
type suggestRequest struct {
TenantSlug string `json:"tenant_slug"`
Module string `json:"module"`
Text string `json:"text"`
DocumentLanguage string `json:"document_language"`
Existing []string `json:"existing"`
}
type languages struct {
Document string `json:"document_language,omitempty"`
Output string `json:"output_language,omitempty"`
}
// prepare dekodiert und prueft die Anfrage und loest die Sprachen auf; bei Fehler ist die Antwort geschrieben.
func (s *Server) prepare(w http.ResponseWriter, r *http.Request) (suggestRequest, doclang.Resolved, bool) {
var req suggestRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
return req, doclang.Resolved{}, false
}
if strings.TrimSpace(req.TenantSlug) == "" || strings.TrimSpace(req.Text) == "" {
writeError(w, http.StatusBadRequest, "tenant_slug und text sind erforderlich")
return req, doclang.Resolved{}, false
}
res, err := s.Resolver.Resolve(r.Context(), req.TenantSlug, req.DocumentLanguage, req.Text)
if errors.Is(err, doclang.ErrUnknownLanguage) {
writeError(w, http.StatusBadRequest, "document_language ist nicht registriert")
return req, res, false
}
if err != nil {
writeError(w, http.StatusInternalServerError, "sprache ermitteln fehlgeschlagen")
return req, res, false
}
return req, res, true
}
func langsOf(res doclang.Resolved) languages {
return languages{Document: res.Document.Code, Output: res.Output.Code}
}
type titleResponse struct {
languages
Titles []titlesuggest.Suggestion `json:"titles"`
}
func (s *Server) handleTitle(w http.ResponseWriter, r *http.Request) {
req, res, ok := s.prepare(w, r)
if !ok {
return
}
sug := &titlesuggest.Suggester{Provider: s.Provider, Model: s.Model}
titles, err := sug.Suggest(r.Context(), req.Module, res.Output.Name, req.Text)
if err != nil {
writeError(w, http.StatusBadGateway, "titelvorschlag fehlgeschlagen")
return
}
if titles == nil {
titles = []titlesuggest.Suggestion{}
}
writeJSON(w, http.StatusOK, titleResponse{languages: langsOf(res), Titles: titles})
}
type correspondentResponse struct {
languages
Suggestion *correspondentsuggest.Suggestion `json:"suggestion"`
}
func (s *Server) handleCorrespondent(w http.ResponseWriter, r *http.Request) {
req, res, ok := s.prepare(w, r)
if !ok {
return
}
sug := &correspondentsuggest.Suggester{Provider: s.Provider, Model: s.Model}
got, found, err := sug.Suggest(r.Context(), req.Existing, req.Module, req.Text)
if err != nil {
writeError(w, http.StatusBadGateway, "korrespondentenvorschlag fehlgeschlagen")
return
}
resp := correspondentResponse{languages: langsOf(res)}
if found {
resp.Suggestion = &got
}
writeJSON(w, http.StatusOK, resp)
}
type tagsResponse struct {
languages
Tags []tagsuggest.Suggestion `json:"tags"`
}
func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
req, res, ok := s.prepare(w, r)
if !ok {
return
}
sug := &tagsuggest.Suggester{Provider: s.Provider, Model: s.Model, Language: res.Output.Name}
tags, err := sug.Suggest(r.Context(), req.Existing, req.Module, req.Text)
if err != nil {
writeError(w, http.StatusBadGateway, "tagvorschlag fehlgeschlagen")
return
}
if tags == nil {
tags = []tagsuggest.Suggestion{}
}
writeJSON(w, http.StatusOK, tagsResponse{languages: langsOf(res), Tags: tags})
}
type adoptRequest struct {
Source string `json:"source"`
TenantSlug string `json:"tenant_slug"`
Actor string `json:"actor"`
Function string `json:"function"`
Model string `json:"model"`
PromptVersion int `json:"prompt_version"`
InputRef string `json:"input_ref"`
Kind string `json:"kind"`
Value string `json:"value"`
}
func (s *Server) handleAdopt(w http.ResponseWriter, r *http.Request) {
if s.Adopt == nil {
writeError(w, http.StatusServiceUnavailable, "uebernahme-protokollierung nicht konfiguriert")
return
}
var req adoptRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
return
}
src := originmark.Source(req.Source)
if src != originmark.SourceAI && src != originmark.SourceManual {
writeError(w, http.StatusBadRequest, "source muss ai oder manual sein")
return
}
if strings.TrimSpace(req.TenantSlug) == "" || strings.TrimSpace(req.Kind) == "" || strings.TrimSpace(req.Value) == "" {
writeError(w, http.StatusBadRequest, "tenant_slug, kind und value sind erforderlich")
return
}
if src == originmark.SourceAI && (req.Function == "" || req.Model == "" || req.InputRef == "") {
writeError(w, http.StatusBadRequest, "function, model und input_ref sind bei source=ai erforderlich")
return
}
marker, err := s.Adopt.Apply(r.Context(), originmark.Adoption{
Source: src, TenantSlug: req.TenantSlug, Actor: req.Actor, Function: req.Function, Model: req.Model,
PromptVersion: req.PromptVersion, InputRef: req.InputRef, Kind: req.Kind, Value: req.Value,
})
if err != nil {
writeError(w, http.StatusBadGateway, "uebernahme protokollieren fehlgeschlagen")
return
}
writeJSON(w, http.StatusOK, map[string]string{"origin_marker": marker})
}
func (s *Server) handleMarkerGet(w http.ResponseWriter, r *http.Request) {
marker, err := s.Markers.Get(r.Context(), r.PathValue("tenant"))
if err != nil {
writeError(w, http.StatusInternalServerError, "markierung lesen fehlgeschlagen")
return
}
writeJSON(w, http.StatusOK, map[string]string{"marker": marker})
}
func (s *Server) handleMarkerPut(w http.ResponseWriter, r *http.Request) {
var req struct {
Marker string `json:"marker"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
return
}
if err := s.Markers.Set(r.Context(), r.PathValue("tenant"), req.Marker); err != nil {
if errors.Is(err, originmark.ErrInvalidMarker) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeError(w, http.StatusInternalServerError, "markierung speichern fehlgeschlagen")
return
}
s.handleMarkerGet(w, r)
}
func (s *Server) handleMarkerDelete(w http.ResponseWriter, r *http.Request) {
if err := s.Markers.Clear(r.Context(), r.PathValue("tenant")); err != nil {
writeError(w, http.StatusInternalServerError, "markierung loeschen fehlgeschlagen")
return
}
s.handleMarkerGet(w, r)
}
func (s *Server) handleLanguageGet(w http.ResponseWriter, r *http.Request) {
code, err := s.Languages.Get(r.Context(), r.PathValue("tenant"))
if err != nil {
writeError(w, http.StatusInternalServerError, "sprache lesen fehlgeschlagen")
return
}
writeJSON(w, http.StatusOK, map[string]string{"language": code})
}
func (s *Server) handleLanguagePut(w http.ResponseWriter, r *http.Request) {
var req struct {
Language string `json:"language"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
return
}
if _, ok := s.Resolver.Registry.Lookup(req.Language); !ok {
writeError(w, http.StatusBadRequest, "sprache nicht registriert")
return
}
if err := s.Languages.Set(r.Context(), r.PathValue("tenant"), req.Language); err != nil {
if errors.Is(err, doclang.ErrInvalidCode) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeError(w, http.StatusInternalServerError, "sprache speichern fehlgeschlagen")
return
}
s.handleLanguageGet(w, r)
}
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, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
+241
View File
@@ -0,0 +1,241 @@
package suggestapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/decisionaudit"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/doclang"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/originmark"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/mock"
)
type memStore struct{ m map[string]string }
func newMem() *memStore { return &memStore{m: map[string]string{}} }
func (s *memStore) Set(_ context.Context, tenant, v string) error {
if strings.TrimSpace(v) == "" {
return originmark.ErrInvalidMarker
}
s.m[tenant] = v
return nil
}
func (s *memStore) Get(_ context.Context, tenant string) (string, error) { return s.m[tenant], nil }
func (s *memStore) Clear(_ context.Context, tenant string) error { delete(s.m, tenant); return nil }
type recAudit struct{ got []decisionaudit.Decision }
func (r *recAudit) Record(_ context.Context, d decisionaudit.Decision) error {
r.got = append(r.got, d)
return nil
}
type fixture struct {
srv *httptest.Server
markers *memStore
langs *memStore
audit *recAudit
prov *mock.Provider
}
func newFixture(t *testing.T, withAdopt bool) *fixture {
t.Helper()
f := &fixture{markers: newMem(), langs: newMem(), audit: &recAudit{}, prov: &mock.Provider{}}
s := &Server{
Provider: f.prov,
Model: "m",
Resolver: &doclang.Resolver{Registry: doclang.DefaultRegistry(), Tenants: tenantLang{f.langs}},
Languages: f.langs,
Markers: f.markers,
ServiceToken: "tok",
}
if withAdopt {
s.Adopt = &originmark.Service{Markers: f.markers, Audit: f.audit}
}
mux := http.NewServeMux()
s.RegisterRoutes(mux)
f.srv = httptest.NewServer(mux)
t.Cleanup(f.srv.Close)
return f
}
type tenantLang struct{ s *memStore }
func (t tenantLang) Get(ctx context.Context, tenant string) (string, error) {
return t.s.Get(ctx, tenant)
}
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
}
const enDoc = "Dear Sirs, the invoice is not paid and will be sent to you by the reminder that we have prepared for your account."
func TestAllRoutesRequireServiceToken(t *testing.T) {
f := newFixture(t, true)
for _, r := range [][2]string{
{"POST", "/suggest/title"}, {"POST", "/suggest/correspondent"}, {"POST", "/suggest/tags"}, {"POST", "/suggest/adopt"},
{"GET", "/tenants/acme/origin-marker"}, {"PUT", "/tenants/acme/origin-marker"}, {"DELETE", "/tenants/acme/origin-marker"},
{"GET", "/tenants/acme/language"}, {"PUT", "/tenants/acme/language"},
} {
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 TestTitle_ReturnsTitlesAndResolvedLanguages(t *testing.T) {
f := newFixture(t, false)
f.langs.m["acme"] = "de"
f.reply(`[{"title":"Zahlungserinnerung","confidence":0.9}]`)
code, out := f.do(t, "POST", "/suggest/title", `{"tenant_slug":"acme","module":"dms","text":"`+enDoc+`"}`, true)
if code != 200 || out["document_language"] != "en" || out["output_language"] != "de" {
t.Fatalf("%d %v", code, out)
}
titles := out["titles"].([]any)
if len(titles) != 1 || titles[0].(map[string]any)["title"] != "Zahlungserinnerung" {
t.Fatalf("%v", out)
}
}
func TestTitle_EmptyListNotNull(t *testing.T) {
f := newFixture(t, false)
f.reply("kein json")
_, out := f.do(t, "POST", "/suggest/title", `{"tenant_slug":"acme","text":"x"}`, true)
if titles, ok := out["titles"].([]any); !ok || len(titles) != 0 {
t.Fatalf("%v", out)
}
}
func TestCorrespondent_FoundAndNone(t *testing.T) {
f := newFixture(t, false)
f.reply(`{"name":"muster gmbh","confidence":0.8}`)
_, out := f.do(t, "POST", "/suggest/correspondent", `{"tenant_slug":"acme","text":"Brief","existing":["Muster GmbH"]}`, true)
s := out["suggestion"].(map[string]any)
if s["name"] != "Muster GmbH" || s["existing"] != true {
t.Fatalf("%v", out)
}
f.reply(`{"name":"","confidence":0}`)
_, out = f.do(t, "POST", "/suggest/correspondent", `{"tenant_slug":"acme","text":"Brief"}`, true)
if out["suggestion"] != nil {
t.Fatalf("%v", out)
}
}
func TestTags_NormalizesToExisting(t *testing.T) {
f := newFixture(t, false)
f.reply(`[{"tag":"rechnung","confidence":0.9}]`)
_, out := f.do(t, "POST", "/suggest/tags", `{"tenant_slug":"acme","text":"x","existing":["Rechnung"]}`, true)
tags := out["tags"].([]any)
if len(tags) != 1 || tags[0].(map[string]any)["tag"] != "Rechnung" {
t.Fatalf("%v", out)
}
}
func TestSuggest_BadRequests(t *testing.T) {
f := newFixture(t, false)
for name, body := range map[string]string{
"kein json": `{`,
"ohne tenant": `{"text":"x"}`,
"ohne text": `{"tenant_slug":"acme","text":" "}`,
"unbekannt lang": `{"tenant_slug":"acme","text":"x","document_language":"xx"}`,
} {
if code, _ := f.do(t, "POST", "/suggest/title", body, true); code != http.StatusBadRequest {
t.Errorf("%s: status %d, want 400", name, code)
}
}
}
func TestSuggest_ProviderErrorIs502(t *testing.T) {
f := newFixture(t, false)
f.prov.Err = &provider.Error{Provider: "mock", Code: provider.CodeUnavailable, Message: "down"}
for _, p := range []string{"title", "correspondent", "tags"} {
if code, _ := f.do(t, "POST", "/suggest/"+p, `{"tenant_slug":"acme","text":"x"}`, true); code != http.StatusBadGateway {
t.Errorf("%s: status %d, want 502", p, code)
}
}
}
func TestAdopt_AIGetsMarkerManualDoesNot(t *testing.T) {
f := newFixture(t, true)
f.markers.m["acme"] = "ai-processed"
ai := `{"source":"ai","tenant_slug":"acme","actor":"u1","function":"tagsuggest","model":"m","prompt_version":1,"input_ref":"doc-1","kind":"tag","value":"rechnung"}`
code, out := f.do(t, "POST", "/suggest/adopt", ai, true)
if code != 200 || out["origin_marker"] != "ai-processed" || len(f.audit.got) != 1 {
t.Fatalf("%d %v audit=%d", code, out, len(f.audit.got))
}
manual := `{"source":"manual","tenant_slug":"acme","kind":"tag","value":"rechnung"}`
code, out = f.do(t, "POST", "/suggest/adopt", manual, true)
if code != 200 || out["origin_marker"] != "" || len(f.audit.got) != 1 {
t.Fatalf("%d %v audit=%d", code, out, len(f.audit.got))
}
}
func TestAdopt_ValidationAndUnconfigured(t *testing.T) {
f := newFixture(t, true)
for name, body := range map[string]string{
"source falsch": `{"source":"x","tenant_slug":"a","kind":"tag","value":"v"}`,
"ai ohne input": `{"source":"ai","tenant_slug":"a","kind":"tag","value":"v","function":"f","model":"m"}`,
"ohne value": `{"source":"manual","tenant_slug":"a","kind":"tag"}`,
"kein json": `{`,
} {
if code, _ := f.do(t, "POST", "/suggest/adopt", body, true); code != http.StatusBadRequest {
t.Errorf("%s: status %d, want 400", name, code)
}
}
g := newFixture(t, false)
if code, _ := g.do(t, "POST", "/suggest/adopt", `{"source":"manual","tenant_slug":"a","kind":"tag","value":"v"}`, true); code != http.StatusServiceUnavailable {
t.Errorf("status %d, want 503", code)
}
}
func TestOriginMarkerConfig(t *testing.T) {
f := newFixture(t, false)
if code, out := f.do(t, "PUT", "/tenants/acme/origin-marker", `{"marker":"ki"}`, true); code != 200 || out["marker"] != "ki" {
t.Fatalf("%d %v", code, out)
}
if _, out := f.do(t, "GET", "/tenants/other/origin-marker", "", true); out["marker"] != "" {
t.Fatalf("mandantentrennung: %v", out)
}
if code, _ := f.do(t, "PUT", "/tenants/acme/origin-marker", `{"marker":" "}`, true); code != http.StatusBadRequest {
t.Fatalf("status %d, want 400", code)
}
if code, out := f.do(t, "DELETE", "/tenants/acme/origin-marker", "", true); code != 200 || out["marker"] != "" {
t.Fatalf("%d %v", code, out)
}
}
func TestLanguageConfig(t *testing.T) {
f := newFixture(t, false)
if code, _ := f.do(t, "PUT", "/tenants/acme/language", `{"language":"fr"}`, true); code != http.StatusBadRequest {
t.Fatalf("unregistrierte sprache: status %d, want 400", code)
}
if code, out := f.do(t, "PUT", "/tenants/acme/language", `{"language":"en"}`, true); code != 200 || out["language"] != "en" {
t.Fatalf("%d %v", code, out)
}
if _, out := f.do(t, "GET", "/tenants/acme/language", "", true); out["language"] != "en" {
t.Fatalf("%v", out)
}
}