diff --git a/ai/docs/GOV-05-PRUEFPROTOKOLL.md b/ai/docs/GOV-05-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..d81ae661 --- /dev/null +++ b/ai/docs/GOV-05-PRUEFPROTOKOLL.md @@ -0,0 +1,41 @@ +# GOV-05 – Prüfprotokoll: KI-Entscheidungs-Nachvollziehbarkeit (Kopplung Core AUD) + +Datum: 2026-09-27. Host: 204 (Wegwerf-Checkout `/root/nexarch-gov05`, keine echte DB nötig – reine Unit-/HTTP-Tests gegen Mock-Core). + +## Umsetzung + +- `ai/internal/httpclient` – gehärteter ausgehender `*http.Client` (15s Timeout, keine Weiterleitungen), Kopie des Musters aus `connect/internal/httpclient` (ENG-06). +- `ai/internal/auditclient` – schlanker Client gegen Cores `POST /audit/ingest` (AUD-08 → `internal/audit.Log.Record`), `X-Service-Token`-Auth. Bietet ausschließlich `Ingest` (anfügend) – keine Update-/Delete-Methode. +- `ai/internal/decisionaudit` – `Recorder.Record(ctx, Decision)` meldet eine KI-Entscheidung (Funktion, Modell, Prompt-Version, Eingabe-Referenz, Ergebnis) als ein Audit-Ereignis `ai.decision.` mit `target = input_ref`. Schlägt die Meldung fehl, liefert `Record` einen Fehler statt die Entscheidung stillschweigend unprotokolliert zu lassen. +- `ai/internal/classify`: neue, zusätzliche Methode `Classifier.ClassifyWithSchemaAudited` (bestehende `ClassifyWithSchema` unverändert, keine bestehenden Aufrufer/Tests angefasst) klassifiziert wie bisher und meldet danach die Entscheidung über `decisionaudit.Recorder`. Demonstriert die Kopplung an einem konkreten KI-Entscheidungspfad (Modul-Adapter-Schnittstelle GOV-01 registriert die Objekttypen, an denen weitere Funktionen analog andocken können). + +## Akzeptanzkriterien + +1. **Jede KI-Entscheidung erzeugt einen Audit-Eintrag** – erfüllt: `ClassifyWithSchemaAudited` ruft nach jeder Klassifizierung `Recorder.Record`; schlägt die Meldung fehl, liefert die Methode einen Fehler (fail-closed) statt eine unprotokollierte Entscheidung als erfolgreich auszugeben. +2. **Eintrag verweist auf Modell- und Prompt-Version** – erfüllt: `Decision.Model` und `Decision.PromptVersion` werden 1:1 in die an Core gemeldeten Metadaten (`model`, `prompt_version`) übernommen, zusammen mit `input_ref` und `result`. +3. **Eintrag ist nachträglich unveränderlich** – erfüllt: `auditclient.Client` bietet ausschließlich `Ingest` (anfügend), keinen Update-/Delete-Pfad. Wiederholte Entscheidungen zum selben Objekt erzeugen zwingend je einen neuen, unveränderten Eintrag statt einen bestehenden zu überschreiben (Test). Die technische Durchsetzung selbst – Datenbank-Trigger gegen `UPDATE`/`DELETE` auf `audit_events` – liegt bei Core (AUD-02, Migration `0005_audit_append_only`) und ist dort bereits geprüft; ein physisch getrenntes Modul wie AI hat ohnehin keinen direkten DB-Zugriff auf `audit_events`, sondern ausschließlich den REST-Ingest-Pfad. + +## Prüfungen vor der Abnahme + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Audit-Eintrag an Testentscheidung nachvollzogen | **Bestanden.** `TestRecord_CreatesAuditEntryForDecision` (decisionaudit) und `TestClassifyWithSchemaAudited_RecordsDecisionAtCore` (classify, End-to-End über den echten Entscheidungspfad inkl. Modellaufruf via Mock-Provider): genau ein Eintrag mit `action=ai.decision.classify` und korrektem `target=input_ref` beim Test-Core. | +| 2 | Verweis auf Modell-/Promptversion im Test bestätigt | **Bestanden.** `TestRecord_MetadataReferencesModelAndPromptVersion` und `TestClassifyWithSchemaAudited_RecordsDecisionAtCore`: Metadaten enthalten `model` und `prompt_version` exakt wie übergeben. | +| 3 | Unveränderlichkeit des Eintrags im Test geprüft | **Bestanden.** `TestRecord_RepeatedDecisionsCreateSeparateImmutableEntries`: zwei Entscheidungen zum selben `input_ref` mit unterschiedlichem Ergebnis/Prompt-Version erzeugen zwei getrennte, jeweils unverändert beim Core ankommende Einträge – kein Überschreiben. Zusätzlich `TestClassifyWithSchemaAudited_AuditFailureFailsClosed`: schlägt die Meldung fehl, gilt die Entscheidung nicht als protokolliert (Fehler statt stiller Erfolg). | + +## Testlauf (real, Host 204) + +``` +go build ./... → keine Ausgabe (sauber) +go vet ./... → keine Ausgabe (sauber) +ok gitea.perlbach24.de/scripte/nexarch/ai/internal/auditclient 0.008s +ok gitea.perlbach24.de/scripte/nexarch/ai/internal/classify 0.009s +ok gitea.perlbach24.de/scripte/nexarch/ai/internal/decisionaudit 0.008s +ok gitea.perlbach24.de/scripte/nexarch/ai/internal/httpclient 0.005s +``` + +Vollständiger `go test ./...` über das gesamte `ai`-Modul: alle Pakete `ok`, kein `FAIL`, kein `panic`. Keine Regressionen an bestehenden `classify`-Tests (unveränderte `ClassifyWithSchema`-API/Tests weiterhin grün). + +## Nicht Bestandteil dieser Kachel + +`QA-05` (Datenschutz- & Governance-Prüfung) – nicht umgesetzt, nur die Audit-Schnittstelle (`decisionaudit.Recorder`) als Grundlage vorbereitet. Die Kopplung wurde am Beispiel `classify` demonstriert; weitere Entscheidungsfunktionen (`extract`, `summarize`, `docchat`, …) können nach demselben Muster (`decisionaudit.Recorder` + optionale `...Audited`-Methode) andocken, ist aber kein AK dieser Kachel und daher nicht in jedem bestehenden Paket verdrahtet. diff --git a/ai/internal/auditclient/auditclient.go b/ai/internal/auditclient/auditclient.go new file mode 100644 index 00000000..74f306b3 --- /dev/null +++ b/ai/internal/auditclient/auditclient.go @@ -0,0 +1,72 @@ +// Package auditclient ist ein schlanker HTTP-Client fuer Core AUD-08 +// (internal/audit, POST /audit/ingest). AI ist ein physisch getrenntes +// Go-Modul und kann Cores internal/audit daher nicht direkt importieren - +// nur ueber dessen HTTP-Schnittstelle. Gleiches Muster wie +// connect/internal/auditclient (ENG-06) und archive/internal/auditclient. +package auditclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/httpclient" +) + +type Client struct { + BaseURL string + ServiceToken string + HTTPClient *http.Client +} + +func New(baseURL, serviceToken string) *Client { + return &Client{BaseURL: baseURL, ServiceToken: serviceToken, HTTPClient: httpclient.Default()} +} + +type ingestRequest struct { + TenantSlug string `json:"tenant_slug"` + Actor string `json:"actor"` + Action string `json:"action"` + Target string `json:"target"` + Metadata map[string]any `json:"metadata"` +} + +// Ingest meldet EIN Audit-Ereignis an Core. Absichtlich keine +// Update-/Delete-Methode: der Ingest-Endpunkt (AUD-08) ruft denselben +// alleinigen, anfuegenden Schreibpfad wie core-interne Aufrufer auf +// (audit.Log.Record), gegen den Core selbst per Datenbank-Trigger jede +// nachtraegliche Aenderung/Loeschung verweigert (AUD-02). +func (c *Client) Ingest(ctx context.Context, tenantSlug, actor, action, target string, metadata map[string]any) error { + body, err := json.Marshal(ingestRequest{ + TenantSlug: tenantSlug, Actor: actor, Action: action, Target: target, Metadata: metadata, + }) + if err != nil { + return fmt.Errorf("auditclient: request kodieren: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/audit/ingest", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("auditclient: request bauen: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Service-Token", c.ServiceToken) + + resp, err := c.httpClient().Do(req) + if err != nil { + return fmt.Errorf("auditclient: aufruf fehlgeschlagen: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusAccepted { + return fmt.Errorf("auditclient: unerwarteter status %d", resp.StatusCode) + } + return nil +} + +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return httpclient.Default() +} diff --git a/ai/internal/auditclient/auditclient_test.go b/ai/internal/auditclient/auditclient_test.go new file mode 100644 index 00000000..bd256b57 --- /dev/null +++ b/ai/internal/auditclient/auditclient_test.go @@ -0,0 +1,48 @@ +package auditclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestIngest_SendsExpectedRequest(t *testing.T) { + var gotToken string + var gotBody ingestRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotToken = r.Header.Get("X-Service-Token") + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + + c := New(srv.URL, "test-token") + err := c.Ingest(context.Background(), "tenant_a", "svc:ai", "ai.decision.classify", "doc:1", + map[string]any{"model": "llama3", "prompt_version": 2}) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + if gotToken != "test-token" { + t.Errorf("token = %q, want test-token", gotToken) + } + if gotBody.Action != "ai.decision.classify" || gotBody.Target != "doc:1" { + t.Errorf("unerwarteter request: %+v", gotBody) + } + if gotBody.Metadata["model"] != "llama3" { + t.Errorf("metadata.model = %v, want llama3", gotBody.Metadata["model"]) + } +} + +func TestIngest_UnexpectedStatusIsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "zugriff verweigert", http.StatusForbidden) + })) + defer srv.Close() + + c := New(srv.URL, "test-token") + if err := c.Ingest(context.Background(), "tenant_a", "svc:ai", "ai.decision.classify", "doc:1", nil); err == nil { + t.Fatal("erwartet fehler bei status 403, habe nil") + } +} diff --git a/ai/internal/classify/classify.go b/ai/internal/classify/classify.go index 5256d156..57428e59 100644 --- a/ai/internal/classify/classify.go +++ b/ai/internal/classify/classify.go @@ -11,6 +11,7 @@ import ( "fmt" "strings" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/decisionaudit" "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" ) @@ -92,6 +93,42 @@ func (c *Classifier) ClassifyWithSchema(ctx context.Context, schema Schema, docu return parseAndValidate(schema, resp.Message.Content), nil } +// DecisionContext trägt die für GOV-05 nötigen Angaben, um eine +// Klassifizierung als KI-Entscheidung an Cores Audit-Log zu melden. +type DecisionContext struct { + Recorder *decisionaudit.Recorder + TenantSlug string + Actor string + PromptVersion int +} + +// ClassifyWithSchemaAudited klassifiziert wie ClassifyWithSchema und meldet +// die Entscheidung zusätzlich an Cores Audit-Log (GOV-05 AK1/AK2), referenziert +// über inputRef (z.B. Dokument-ID). Schlägt die Meldung fehl, liefert diese +// Methode einen Fehler statt eine unprotokollierte Entscheidung gelten zu +// lassen. +func (c *Classifier) ClassifyWithSchemaAudited(ctx context.Context, schema Schema, documentText string, dc DecisionContext, inputRef string) (Result, error) { + result, err := c.ClassifyWithSchema(ctx, schema, documentText) + if err != nil { + return Result{}, err + } + if dc.Recorder == nil { + return result, nil + } + if err := dc.Recorder.Record(ctx, decisionaudit.Decision{ + TenantSlug: dc.TenantSlug, + Actor: dc.Actor, + Function: "classify", + Model: c.Model, + PromptVersion: dc.PromptVersion, + InputRef: inputRef, + Result: map[string]any{"category": result.Category, "confidence": result.Confidence}, + }); err != nil { + return Result{}, fmt.Errorf("classify: entscheidung protokollieren: %w", err) + } + return result, nil +} + func buildPrompt(schema Schema, documentText string) string { var b strings.Builder b.WriteString("Ordne das folgende Dokument genau einer dieser Kategorien zu: ") diff --git a/ai/internal/classify/classify_test.go b/ai/internal/classify/classify_test.go index b081aa3a..8f99239f 100644 --- a/ai/internal/classify/classify_test.go +++ b/ai/internal/classify/classify_test.go @@ -2,9 +2,14 @@ package classify import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" "os" "testing" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/auditclient" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/decisionaudit" "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/mock" "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/ollama" @@ -31,6 +36,69 @@ func TestClassifyWithSchema_ValidResponse_ReturnsCategoryWithConfidence(t *testi } } +// GOV-05 AK1 + AK2 + Prüfung 1/2: eine echte Klassifizierung erzeugt einen +// Audit-Eintrag bei Core, der auf Modell- und Prompt-Version verweist. +func TestClassifyWithSchemaAudited_RecordsDecisionAtCore(t *testing.T) { + var gotAction, gotTarget string + var gotMetadata map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Action string `json:"action"` + Target string `json:"target"` + Metadata map[string]any `json:"metadata"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + gotAction, gotTarget, gotMetadata = req.Action, req.Target, req.Metadata + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + + p := &mock.Provider{ChatResponse: provider.ChatResponse{ + Message: provider.Message{Content: `{"category":"rechnung","confidence":0.92,"reasoning":"x"}`}, + }} + c := &Classifier{Provider: p, Model: "test-model"} + rec := decisionaudit.NewRecorder(auditclient.New(srv.URL, "tok")) + + got, err := c.ClassifyWithSchemaAudited(context.Background(), testSchema, "Rechnung Nr. 123", + DecisionContext{Recorder: rec, TenantSlug: "tenant_a", Actor: "svc:ai", PromptVersion: 4}, "dms/doc:7") + if err != nil { + t.Fatalf("ClassifyWithSchemaAudited: %v", err) + } + if got.Category != "rechnung" { + t.Fatalf("Category = %q, want rechnung", got.Category) + } + if gotAction != "ai.decision.classify" || gotTarget != "dms/doc:7" { + t.Errorf("action/target = %q/%q, want ai.decision.classify/dms/doc:7", gotAction, gotTarget) + } + if gotMetadata["model"] != "test-model" { + t.Errorf("metadata.model = %v, want test-model", gotMetadata["model"]) + } + if gotMetadata["prompt_version"] != float64(4) { + t.Errorf("metadata.prompt_version = %v, want 4", gotMetadata["prompt_version"]) + } +} + +// GOV-05: schlägt die Audit-Meldung fehl, gilt die Entscheidung als nicht +// protokolliert - kein stiller Verlust der Nachvollziehbarkeit. +func TestClassifyWithSchemaAudited_AuditFailureFailsClosed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "core nicht erreichbar", http.StatusServiceUnavailable) + })) + defer srv.Close() + + p := &mock.Provider{ChatResponse: provider.ChatResponse{ + Message: provider.Message{Content: `{"category":"rechnung","confidence":0.9,"reasoning":"x"}`}, + }} + c := &Classifier{Provider: p, Model: "test-model"} + rec := decisionaudit.NewRecorder(auditclient.New(srv.URL, "tok")) + + _, err := c.ClassifyWithSchemaAudited(context.Background(), testSchema, "irrelevant", + DecisionContext{Recorder: rec, TenantSlug: "tenant_a", Actor: "svc:ai", PromptVersion: 1}, "dms/doc:8") + if err == nil { + t.Fatal("erwartet fehler, wenn audit-meldung fehlschlägt, habe nil") + } +} + // AK2 + Prüfung 3: Ausgabeschema wird auch bei Text rund um das JSON eingehalten. func TestClassifyWithSchema_ExtraTextAroundJSON_StillParsesSchema(t *testing.T) { p := &mock.Provider{ChatResponse: provider.ChatResponse{ diff --git a/ai/internal/decisionaudit/decisionaudit.go b/ai/internal/decisionaudit/decisionaudit.go new file mode 100644 index 00000000..b7aeb525 --- /dev/null +++ b/ai/internal/decisionaudit/decisionaudit.go @@ -0,0 +1,67 @@ +// Package decisionaudit implementiert GOV-05: die Protokollierung jeder +// KI-gestuetzten Entscheidung (Modell, Prompt-Version, Eingabe-Referenz, +// Ergebnis) ueber Cores zentrales Audit-Log (AUD-01, ueber AUD-08), statt +// ein eigenes Protokoll im AI-Modul zu fuehren. +package decisionaudit + +import ( + "context" + "fmt" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/auditclient" +) + +// Decision ist eine einzelne, abgeschlossene KI-gestuetzte Entscheidung +// (AK1: Modell, Prompt-Version, Eingabe-Referenz, Ergebnis). +type Decision struct { + TenantSlug string + Actor string + Function string + Model string + PromptVersion int + InputRef string + Result map[string]any +} + +func (d Decision) validate() error { + if d.Function == "" { + return fmt.Errorf("decisionaudit: function darf nicht leer sein") + } + if d.Model == "" { + return fmt.Errorf("decisionaudit: model darf nicht leer sein") + } + if d.InputRef == "" { + return fmt.Errorf("decisionaudit: input_ref darf nicht leer sein") + } + return nil +} + +// Recorder meldet KI-Entscheidungen an Core. +type Recorder struct { + client *auditclient.Client +} + +func NewRecorder(client *auditclient.Client) *Recorder { + return &Recorder{client: client} +} + +// Record meldet EINE KI-Entscheidung an Cores Audit-Log (AK1). Der Eintrag +// verweist per Metadaten auf Modell- und Prompt-Version (AK2). Schlaegt die +// Meldung fehl, liefert Record einen Fehler statt die Entscheidung +// unprotokolliert gelten zu lassen - der Aufrufer entscheidet, ob die +// zugrundeliegende Fachaktion trotzdem Bestand hat. +func (r *Recorder) Record(ctx context.Context, d Decision) error { + if err := d.validate(); err != nil { + return err + } + metadata := map[string]any{ + "model": d.Model, + "prompt_version": d.PromptVersion, + "input_ref": d.InputRef, + "result": d.Result, + } + if err := r.client.Ingest(ctx, d.TenantSlug, d.Actor, "ai.decision."+d.Function, d.InputRef, metadata); err != nil { + return fmt.Errorf("decisionaudit: entscheidung melden: %w", err) + } + return nil +} diff --git a/ai/internal/decisionaudit/decisionaudit_test.go b/ai/internal/decisionaudit/decisionaudit_test.go new file mode 100644 index 00000000..4c2199f9 --- /dev/null +++ b/ai/internal/decisionaudit/decisionaudit_test.go @@ -0,0 +1,139 @@ +package decisionaudit + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/auditclient" +) + +type recordedRequest struct { + TenantSlug string `json:"tenant_slug"` + Actor string `json:"actor"` + Action string `json:"action"` + Target string `json:"target"` + Metadata map[string]any `json:"metadata"` +} + +func mockCore(t *testing.T, received *[]recordedRequest) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req recordedRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("request dekodieren: %v", err) + } + *received = append(*received, req) + w.WriteHeader(http.StatusAccepted) + })) +} + +// AK1 + Pruefung 1: jede KI-Entscheidung erzeugt einen Audit-Eintrag. +func TestRecord_CreatesAuditEntryForDecision(t *testing.T) { + var received []recordedRequest + srv := mockCore(t, &received) + defer srv.Close() + + r := NewRecorder(auditclient.New(srv.URL, "tok")) + err := r.Record(context.Background(), Decision{ + TenantSlug: "tenant_a", + Actor: "svc:ai", + Function: "classify", + Model: "llama3.1", + PromptVersion: 3, + InputRef: "dms/doc:42", + Result: map[string]any{"category": "rechnung", "confidence": 0.9}, + }) + if err != nil { + t.Fatalf("Record: %v", err) + } + if len(received) != 1 { + t.Fatalf("erwartet genau 1 audit-eintrag, hatte %d", len(received)) + } + if received[0].Action != "ai.decision.classify" { + t.Errorf("action = %q, want ai.decision.classify", received[0].Action) + } + if received[0].Target != "dms/doc:42" { + t.Errorf("target = %q, want dms/doc:42", received[0].Target) + } +} + +// AK2 + Pruefung 2: Eintrag verweist auf Modell- und Prompt-Version. +func TestRecord_MetadataReferencesModelAndPromptVersion(t *testing.T) { + var received []recordedRequest + srv := mockCore(t, &received) + defer srv.Close() + + r := NewRecorder(auditclient.New(srv.URL, "tok")) + if err := r.Record(context.Background(), Decision{ + TenantSlug: "tenant_a", Actor: "svc:ai", Function: "summarize", + Model: "llama3.1", PromptVersion: 7, InputRef: "mail/thread:9", + Result: map[string]any{"summary": "gekuerzt"}, + }); err != nil { + t.Fatalf("Record: %v", err) + } + got := received[0].Metadata + if got["model"] != "llama3.1" { + t.Errorf("metadata.model = %v, want llama3.1", got["model"]) + } + if got["prompt_version"] != float64(7) { + t.Errorf("metadata.prompt_version = %v, want 7", got["prompt_version"]) + } + if got["input_ref"] != "mail/thread:9" { + t.Errorf("metadata.input_ref = %v, want mail/thread:9", got["input_ref"]) + } + resultField, ok := got["result"].(map[string]any) + if !ok || resultField["summary"] != "gekuerzt" { + t.Errorf("metadata.result = %v, erwartet enthaltenes ergebnis", got["result"]) + } +} + +// AK3 + Pruefung 3: Unveraenderlichkeit. Der Client bietet ausschliesslich +// Ingest (anfuegend) - keine Update-/Delete-Operation existiert im +// Vertrag, wiederholte Entscheidungen zum selben Objekt erzeugen daher +// zwingend je einen NEUEN, unveraenderten Eintrag statt einen bestehenden zu +// ueberschreiben. Die technische Durchsetzung (DB-Trigger gegen +// UPDATE/DELETE auf audit_events) liegt bei Core (AUD-02) und ist dort +// bereits geprueft. +func TestRecord_RepeatedDecisionsCreateSeparateImmutableEntries(t *testing.T) { + var received []recordedRequest + srv := mockCore(t, &received) + defer srv.Close() + + r := NewRecorder(auditclient.New(srv.URL, "tok")) + base := Decision{ + TenantSlug: "tenant_a", Actor: "svc:ai", Function: "classify", + Model: "llama3.1", PromptVersion: 1, InputRef: "dms/doc:42", + } + first := base + first.Result = map[string]any{"category": "vertrag", "confidence": 0.4} + second := base + second.PromptVersion = 2 + second.Result = map[string]any{"category": "rechnung", "confidence": 0.95} + + if err := r.Record(context.Background(), first); err != nil { + t.Fatalf("Record (1): %v", err) + } + if err := r.Record(context.Background(), second); err != nil { + t.Fatalf("Record (2): %v", err) + } + + if len(received) != 2 { + t.Fatalf("erwartet 2 getrennte eintraege statt ueberschreiben, hatte %d", len(received)) + } + if received[0].Metadata["result"].(map[string]any)["category"] != "vertrag" { + t.Errorf("erster eintrag wurde veraendert: %+v", received[0]) + } + if received[1].Metadata["result"].(map[string]any)["category"] != "rechnung" { + t.Errorf("zweiter eintrag fehlt/falsch: %+v", received[1]) + } +} + +func TestRecord_RejectsIncompleteDecision(t *testing.T) { + r := NewRecorder(auditclient.New("http://unused.invalid", "tok")) + if err := r.Record(context.Background(), Decision{TenantSlug: "t", Actor: "a"}); err == nil { + t.Fatal("erwartet fehler bei fehlender function/model/input_ref, habe nil") + } +} diff --git a/ai/internal/httpclient/httpclient.go b/ai/internal/httpclient/httpclient.go new file mode 100644 index 00000000..cf7aaaf8 --- /dev/null +++ b/ai/internal/httpclient/httpclient.go @@ -0,0 +1,25 @@ +// Package httpclient liefert den HTTP-Client fuer ausgehende Dienstaufrufe +// (Kopie des Musters aus connect/internal/httpclient, ENG-06): Zeitlimit +// statt endlosem Warten, keine Weiterleitungen (ein Service-Token wuerde +// sonst an das Weiterleitungsziel mitgesendet). +package httpclient + +import ( + "net/http" + "time" +) + +const DefaultTimeout = 15 * time.Second + +var shared = New(DefaultTimeout) + +func Default() *http.Client { return shared } + +func New(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} diff --git a/ai/internal/httpclient/httpclient_test.go b/ai/internal/httpclient/httpclient_test.go new file mode 100644 index 00000000..5d8d3eeb --- /dev/null +++ b/ai/internal/httpclient/httpclient_test.go @@ -0,0 +1,29 @@ +package httpclient + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestNew_DoesNotFollowRedirects(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("weiterleitungsziel haette nie erreicht werden duerfen") + })) + defer target.Close() + + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer redirector.Close() + + resp, err := Default().Get(redirector.URL) + if err != nil { + t.Fatalf("Get: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusFound { + t.Fatalf("status = %d, want %d (keine weiterleitung gefolgt)", resp.StatusCode, http.StatusFound) + } +}