CLS-09: Kennzeichnung KI-gesetzter Zuordnungen #435

Open
patrick wants to merge 2 commits from feat/ai-cls09-herkunftsmarkierung into main
pull from: feat/ai-cls09-herkunftsmarkierung
5 changed files with 312 additions and 0 deletions

No files matched your search

+32
View File
@@ -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.
+133
View File
@@ -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
}
+141
View File
@@ -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)
}
}
@@ -0,0 +1 @@
DROP TABLE ai_origin_markers;
@@ -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()
);