diff --git a/ai/docs/CLS-09-PRUEFPROTOKOLL.md b/ai/docs/CLS-09-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..cbb1cbb2 --- /dev/null +++ b/ai/docs/CLS-09-PRUEFPROTOKOLL.md @@ -0,0 +1,32 @@ +# CLS-09 – Kennzeichnung KI-gesetzter Zuordnungen – Prüfprotokoll + +Datum: 2026-11-06. Host: Entwicklungshost 192.168.1.204 (Wegwerf-Rolle/DB `cls09_test`, nach Abschluss entfernt). + +## Umsetzung + +- Migration `0012_ai_origin_marker`: Tabelle `ai_origin_markers` (eine Markierung je Mandant, nicht leer). +- Paket `ai/internal/originmark`: `Store` (Set/Get/Clear, Validierung 1 bis 64 Zeichen ohne Steuerzeichen) und `Service.Apply(ctx, Adoption)`. +- `Apply` liefert die Markierung, die der Aufrufer an die übernommene Zuordnung hängt. Nur bei `Source=ai`; manuelle Zuordnungen bleiben unmarkiert und unprotokolliert. KI-Übernahmen gehen über `decisionaudit.Recorder` ins zentrale Audit, mit `origin_marker` im Ergebnis. + +## Akzeptanzkriterien + +| # | Kriterium | Ergebnis | +|---|---|---| +| 1 | Übernommene KI-Vorschläge tragen eine mandantenweit konfigurierbare Markierung | **Erfüllt auf Paketebene.** `TestApply_AIAdoptionGetsMarkerAndAudit`, `TestStore_PerTenantSetGetClear` (echte DB). Der Aufrufer (DMS/Mail) hängt die Markierung an; diese Anbindung existiert noch nicht. | +| 2 | Manuelle Zuordnungen werden nicht markiert | **Erfüllt.** `TestApply_ManualNeverMarkedNorAudited`. | +| 3 | Markierung im Audit der KI-Entscheidung nachvollziehbar | **Teilweise belegt.** Mit Fake-Recorder: `origin_marker`, `kind` und `value` stehen im Ergebnis. Echter Core-Audit (`auditclient`) nicht getestet. | +| 4 | Ohne Konfiguration keine Markierung | **Erfüllt.** `TestApply_NoConfigNoMarkerButAudited`; die KI-Übernahme wird trotzdem protokolliert. | + +## Prüfungen vor der Abnahme + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Übernahme setzt die Markierung nachweislich, manuelle Zuordnung nicht | **Bestanden** (Unit). Ende-zu-Ende mit DMS: **offen**. | +| 2 | Markierung erscheint im Entscheidungs-Audit | **Teilweise.** Siehe AK3. | +| 3 | Mandantentrennung | **Bestanden.** `TestApply_TenantIsolation` und DB-Test (`acme`/`other`, Clear berührt `other` nicht). | + +## Einschränkungen + +- Kein API-Endpunkt zum Setzen der Markierung, keine Verdrahtung in `cmd/ai-app`, keine Anbindung an DMS/Mail. +- Schlägt das Audit fehl, liefert `Apply` einen Fehler und keine Markierung; ob die Fachaktion dann trotzdem Bestand hat, entscheidet der Aufrufer. +- Oberfläche zur Filterung nach Markierung ist nicht Teil der Kachel. diff --git a/ai/internal/originmark/originmark.go b/ai/internal/originmark/originmark.go new file mode 100644 index 00000000..83b6471e --- /dev/null +++ b/ai/internal/originmark/originmark.go @@ -0,0 +1,133 @@ +package originmark + +import ( + "context" + "errors" + "fmt" + "strings" + "unicode" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/decisionaudit" +) + +const MaxMarkerLen = 64 + +var ErrInvalidMarker = errors.New("originmark: markierung muss 1 bis 64 druckbare zeichen ohne steuerzeichen haben") + +type Store struct { + Pool *pgxpool.Pool +} + +func validate(marker string) (string, error) { + marker = strings.TrimSpace(marker) + if marker == "" || len([]rune(marker)) > MaxMarkerLen { + return "", ErrInvalidMarker + } + for _, r := range marker { + if unicode.IsControl(r) { + return "", ErrInvalidMarker + } + } + return marker, nil +} + +func (s *Store) Set(ctx context.Context, tenantSlug, marker string) error { + tenantSlug = strings.TrimSpace(tenantSlug) + if tenantSlug == "" { + return fmt.Errorf("originmark: tenantSlug darf nicht leer sein") + } + marker, err := validate(marker) + if err != nil { + return err + } + _, err = s.Pool.Exec(ctx, ` + INSERT INTO ai_origin_markers (tenant_slug, marker, updated_at) + VALUES ($1, $2, now()) + ON CONFLICT (tenant_slug) DO UPDATE SET marker = EXCLUDED.marker, updated_at = now() + `, tenantSlug, marker) + if err != nil { + return fmt.Errorf("originmark: speichern: %w", err) + } + return nil +} + +func (s *Store) Clear(ctx context.Context, tenantSlug string) error { + if _, err := s.Pool.Exec(ctx, `DELETE FROM ai_origin_markers WHERE tenant_slug = $1`, tenantSlug); err != nil { + return fmt.Errorf("originmark: loeschen: %w", err) + } + return nil +} + +// Get liefert "" ohne Konfiguration. +func (s *Store) Get(ctx context.Context, tenantSlug string) (string, error) { + var marker string + err := s.Pool.QueryRow(ctx, `SELECT marker FROM ai_origin_markers WHERE tenant_slug = $1`, tenantSlug).Scan(&marker) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("originmark: lesen: %w", err) + } + return marker, nil +} + +type Source string + +const ( + SourceAI Source = "ai" + SourceManual Source = "manual" +) + +type Adoption struct { + Source Source + TenantSlug string + Actor string + Function string + Model string + PromptVersion int + InputRef string + Kind string + Value string +} + +type MarkerReader interface { + Get(ctx context.Context, tenantSlug string) (string, error) +} + +type AuditRecorder interface { + Record(ctx context.Context, d decisionaudit.Decision) error +} + +type Service struct { + Markers MarkerReader + Audit AuditRecorder +} + +// Apply liefert die Herkunftsmarkierung, die der Aufrufer an die uebernommene +// Zuordnung haengen soll; "" fuer manuelle Zuordnungen oder ohne Konfiguration. +// Eine KI-Uebernahme wird immer im Audit festgehalten, mit Markierung falls gesetzt. +func (s *Service) Apply(ctx context.Context, a Adoption) (string, error) { + if a.Source != SourceAI { + return "", nil + } + marker, err := s.Markers.Get(ctx, a.TenantSlug) + if err != nil { + return "", err + } + result := map[string]any{"kind": a.Kind, "value": a.Value, "origin_marker": marker} + if err := s.Audit.Record(ctx, decisionaudit.Decision{ + TenantSlug: a.TenantSlug, + Actor: a.Actor, + Function: a.Function, + Model: a.Model, + PromptVersion: a.PromptVersion, + InputRef: a.InputRef, + Result: result, + }); err != nil { + return "", fmt.Errorf("originmark: audit: %w", err) + } + return marker, nil +} diff --git a/ai/internal/originmark/originmark_test.go b/ai/internal/originmark/originmark_test.go new file mode 100644 index 00000000..a2942269 --- /dev/null +++ b/ai/internal/originmark/originmark_test.go @@ -0,0 +1,141 @@ +package originmark + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/decisionaudit" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/common/testdb" +) + +type fakeMarkers map[string]string + +func (f fakeMarkers) Get(_ context.Context, tenant string) (string, error) { return f[tenant], nil } + +type fakeAudit struct { + got []decisionaudit.Decision + err error +} + +func (f *fakeAudit) Record(_ context.Context, d decisionaudit.Decision) error { + f.got = append(f.got, d) + return f.err +} + +func adoption(src Source, tenant string) Adoption { + return Adoption{Source: src, TenantSlug: tenant, Actor: "u1", Function: "tagsuggest", Model: "m", PromptVersion: 1, InputRef: "doc-1", Kind: "tag", Value: "rechnung"} +} + +func TestApply_AIAdoptionGetsMarkerAndAudit(t *testing.T) { + audit := &fakeAudit{} + s := &Service{Markers: fakeMarkers{"acme": "ai-processed"}, Audit: audit} + got, err := s.Apply(context.Background(), adoption(SourceAI, "acme")) + if err != nil || got != "ai-processed" { + t.Fatalf("got=%q err=%v", got, err) + } + if len(audit.got) != 1 || audit.got[0].Result["origin_marker"] != "ai-processed" || audit.got[0].Result["value"] != "rechnung" { + t.Fatalf("audit: %+v", audit.got) + } +} + +func TestApply_ManualNeverMarkedNorAudited(t *testing.T) { + audit := &fakeAudit{} + s := &Service{Markers: fakeMarkers{"acme": "ai-processed"}, Audit: audit} + got, err := s.Apply(context.Background(), adoption(SourceManual, "acme")) + if err != nil || got != "" || len(audit.got) != 0 { + t.Fatalf("got=%q err=%v audit=%d", got, err, len(audit.got)) + } +} + +func TestApply_NoConfigNoMarkerButAudited(t *testing.T) { + audit := &fakeAudit{} + s := &Service{Markers: fakeMarkers{}, Audit: audit} + got, err := s.Apply(context.Background(), adoption(SourceAI, "acme")) + if err != nil || got != "" || len(audit.got) != 1 || audit.got[0].Result["origin_marker"] != "" { + t.Fatalf("got=%q err=%v audit=%+v", got, err, audit.got) + } +} + +func TestApply_TenantIsolation(t *testing.T) { + s := &Service{Markers: fakeMarkers{"acme": "ai-processed"}, Audit: &fakeAudit{}} + got, _ := s.Apply(context.Background(), adoption(SourceAI, "other")) + if got != "" { + t.Fatalf("marker eines anderen mandanten: %q", got) + } +} + +func TestApply_AuditFailureReturnsError(t *testing.T) { + s := &Service{Markers: fakeMarkers{"acme": "x"}, Audit: &fakeAudit{err: errors.New("down")}} + if got, err := s.Apply(context.Background(), adoption(SourceAI, "acme")); err == nil || got != "" { + t.Fatalf("got=%q err=%v", got, err) + } +} + +func TestValidate(t *testing.T) { + for _, bad := range []string{"", " ", "a\nb", strings.Repeat("x", MaxMarkerLen+1)} { + if _, err := validate(bad); !errors.Is(err, ErrInvalidMarker) { + t.Errorf("%q: err=%v", bad, err) + } + } + if got, err := validate(" ai-processed "); err != nil || got != "ai-processed" { + t.Errorf("got=%q err=%v", got, err) + } +} + +func newStore(t *testing.T) *Store { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen") + } + pool := testdb.Pool(t, dsn) + dir, err := filepath.Abs("../../migrations") + if err != nil { + t.Fatal(err) + } + migrations, err := migrate.Load(dir) + if err != nil { + t.Fatal(err) + } + if _, err := migrate.Up(context.Background(), pool, migrations); err != nil { + t.Fatal(err) + } + return &Store{Pool: pool} +} + +func TestStore_PerTenantSetGetClear(t *testing.T) { + s := newStore(t) + ctx := context.Background() + if got, err := s.Get(ctx, "acme"); err != nil || got != "" { + t.Fatalf("ohne konfiguration: %q %v", got, err) + } + if err := s.Set(ctx, "acme", " ai-processed "); err != nil { + t.Fatal(err) + } + if err := s.Set(ctx, "other", "ki"); err != nil { + t.Fatal(err) + } + if got, _ := s.Get(ctx, "acme"); got != "ai-processed" { + t.Fatalf("acme: %q", got) + } + if got, _ := s.Get(ctx, "other"); got != "ki" { + t.Fatalf("other: %q", got) + } + if err := s.Clear(ctx, "acme"); err != nil { + t.Fatal(err) + } + if got, _ := s.Get(ctx, "acme"); got != "" { + t.Fatalf("nach clear: %q", got) + } + if got, _ := s.Get(ctx, "other"); got != "ki" { + t.Fatalf("other beruehrt: %q", got) + } + if err := s.Set(ctx, "acme", "\t"); !errors.Is(err, ErrInvalidMarker) { + t.Fatalf("err=%v", err) + } +} diff --git a/ai/migrations/0012_ai_origin_marker.down.sql b/ai/migrations/0012_ai_origin_marker.down.sql new file mode 100644 index 00000000..8c0571eb --- /dev/null +++ b/ai/migrations/0012_ai_origin_marker.down.sql @@ -0,0 +1 @@ +DROP TABLE ai_origin_markers; diff --git a/ai/migrations/0012_ai_origin_marker.up.sql b/ai/migrations/0012_ai_origin_marker.up.sql new file mode 100644 index 00000000..ccfa1539 --- /dev/null +++ b/ai/migrations/0012_ai_origin_marker.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE ai_origin_markers ( + tenant_slug TEXT PRIMARY KEY, + marker TEXT NOT NULL CHECK (marker <> ''), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +);