CLS-09: Kennzeichnung KI-gesetzter Zuordnungen #435
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
Reference in new issue
Block a user