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
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:
1 parent
a3d5428c22
commit
b9dbfe8678
5 files changed
+606
-1
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user