diff --git a/ai/cmd/ai-app/main.go b/ai/cmd/ai-app/main.go index c039e182..9692f1d6 100644 --- a/ai/cmd/ai-app/main.go +++ b/ai/cmd/ai-app/main.go @@ -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) diff --git a/ai/cmd/ai-app/main_test.go b/ai/cmd/ai-app/main_test.go index 84b0dd0f..0f310de8 100644 --- a/ai/cmd/ai-app/main_test.go +++ b/ai/cmd/ai-app/main_test.go @@ -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) + } +} diff --git a/ai/internal/doclang/doclang.go b/ai/internal/doclang/doclang.go index 25fb206a..698db79e 100644 --- a/ai/internal/doclang/doclang.go +++ b/ai/internal/doclang/doclang.go @@ -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 } diff --git a/ai/internal/suggestapi/suggestapi.go b/ai/internal/suggestapi/suggestapi.go new file mode 100644 index 00000000..02de1c21 --- /dev/null +++ b/ai/internal/suggestapi/suggestapi.go @@ -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}) +} diff --git a/ai/internal/suggestapi/suggestapi_test.go b/ai/internal/suggestapi/suggestapi_test.go new file mode 100644 index 00000000..f8aaf467 --- /dev/null +++ b/ai/internal/suggestapi/suggestapi_test.go @@ -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) + } +}