GOV-05: KI-Entscheidungs-Nachvollziehbarkeit (Kopplung Core AUD) #230
No files matched your search
@@ -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.<function>` 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.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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: ")
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user