CLS-05: Konfidenz-Schwelle und Korrektur-Feedback-Loop #244
No files matched your search
@@ -0,0 +1,45 @@
|
||||
# CLS-05 – Prüfprotokoll
|
||||
|
||||
Konfigurierbare Konfidenz-Schwelle je Funktion und Erfassung von Nutzerkorrekturen.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `ai/internal/confidence`: `Store` verwaltet Konfidenz-Schwellenwerte je `tenant_slug`+`function` in Postgres (Tabelle `ai_confidence_thresholds`, Migration `0010`). `Get` liest stets live aus der Datenbank (kein Zwischenspeicher). Ohne gesetzten Wert gilt `DefaultThreshold` (0.7). `MeetsThreshold` bereitet die Schnittstelle für das spätere Prüfgate `QA-03` vor, ohne dessen Logik selbst umzusetzen.
|
||||
- `ai/internal/correction`: `Recorder` erfasst Nutzerkorrekturen strukturiert (Tabelle `ai_classification_corrections`, Migration `0011`): Tenant, Funktion, Eingabereferenz, vorgeschlagene Kategorie samt Konfidenz, korrigierte Kategorie, ausführender Nutzer, Zeitstempel. `Aggregate` liefert je Tenant+Funktion Kennzahlen (Gesamtanzahl, Häufigkeit je vorgeschlagener/korrigierter Kategorie, durchschnittliche Konfidenz der korrigierten Vorschläge).
|
||||
- Beide Pakete folgen dem in CLS-01 (`classify`) und ENG-04 (`modelconfig`)/GOV-02 (`tenantfeature`) etablierten Muster: Store mit `*pgxpool.Pool`, Live-Lesen ohne Cache, Integrationstests gegen `TEST_TENANT_DSN`.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
1. **Schwelle ist je Funktion und Tenant konfigurierbar** — erfüllt. `confidence.Store.Set`/`Get` sind über `(tenant_slug, function)` primärgeschlüsselt; Test `TestSetAndGet_PerTenantPerFunction` belegt unabhängige Werte je Tenant/Funktion.
|
||||
2. **Korrektur wird strukturiert erfasst und gespeichert** — erfüllt. `correction.Recorder.Record` validiert Pflichtfelder und persistiert vollständige Datensätze; Test `TestRecord_CapturesCorrectionFully` und `TestRecord_RejectsIncompleteCorrection`.
|
||||
3. **Auswertung der Korrekturen ist einsehbar** — erfüllt. `correction.Recorder.Aggregate` liefert `Stats` je Tenant+Funktion; Test `TestAggregate_YieldsTraceableMetricsPerTenant` (inkl. Tenant-Isolation) und `TestAggregate_EmptyWhenNoCorrections`.
|
||||
|
||||
## Prüfungen vor der Abnahme
|
||||
|
||||
1. **Schwellenwertänderung wirkt ohne Neustart**
|
||||
- Ziel: Ein `Set` ist beim nächsten `Get` einer unabhängigen Store-Instanz sofort sichtbar, ohne Prozess-Neustart.
|
||||
- Durchführung: `TestGet_ChangeTakesEffectWithoutRestart` (zwei `Store`-Instanzen auf demselben Pool, zweifaches `Set` mit unterschiedlichen Werten, `Get` über die zweite Instanz nach jedem Schritt).
|
||||
- Ergebnis: PASS — beide Änderungen werden sofort gelesen, da `Get` ohne Zwischenspeicher direkt aus `ai_confidence_thresholds` liest.
|
||||
|
||||
2. **Korrektur-Feedback im Test vollständig erfasst**
|
||||
- Ziel: Alle Felder einer Korrektur (Tenant, Funktion, Referenz, vorgeschlagene Kategorie+Konfidenz, korrigierte Kategorie, Actor) werden gespeichert und sind über die Auswertung wieder auffindbar.
|
||||
- Durchführung: `TestRecord_CapturesCorrectionFully` (Record + Aggregate, Prüfung aller Felder inkl. Konfidenzwert) sowie `TestRecord_RejectsIncompleteCorrection` (fehlendes Pflichtfeld führt zu Fehler statt Teildatensatz).
|
||||
- Ergebnis: PASS.
|
||||
|
||||
3. **Auswertung liefert nachvollziehbare Kennzahlen**
|
||||
- Ziel: Mehrere Korrekturen über mehrere Tenants/Kategorien ergeben eine korrekte, tenant-isolierte Aggregation (Gesamtanzahl, Kategorie-Verteilung, Durchschnittskonfidenz).
|
||||
- Durchführung: `TestAggregate_YieldsTraceableMetricsPerTenant` (3 Korrekturen für `acme`, 1 für `other`, Prüfung von Summen, Verteilung je Kategorie und Mittelwert) sowie `TestAggregate_EmptyWhenNoCorrections`.
|
||||
- Ergebnis: PASS.
|
||||
|
||||
## Testlauf (live gegen Testhost 192.168.1.204, `TEST_TENANT_DSN`)
|
||||
|
||||
```
|
||||
ok gitea.perlbach24.de/scripte/nexarch/ai/internal/confidence 0.963s
|
||||
ok gitea.perlbach24.de/scripte/nexarch/ai/internal/correction 0.818s
|
||||
```
|
||||
|
||||
Zusätzlich: `go build ./...` und `go vet ./...` für das gesamte `ai`-Modul ohne Fehler.
|
||||
|
||||
## Nicht Bestandteil dieser Kachel
|
||||
|
||||
`QA-03` (Prüfgate Klassifizierung & Extraktion) ist nicht umgesetzt. `confidence.Store.MeetsThreshold` stellt lediglich die Schnittstelle bereit, die ein künftiges Prüfgate nutzen kann.
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package confidence verwaltet je Tenant und Funktion konfigurierbare
|
||||
// Konfidenz-Schwellenwerte (CLS-05 AK1). Get liest live aus der Datenbank,
|
||||
// eine Änderung wirkt ohne Neustart auf den nächsten Aufruf.
|
||||
package confidence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// DefaultThreshold gilt, solange ein Tenant für eine Funktion keine eigene
|
||||
// Schwelle gesetzt hat.
|
||||
const DefaultThreshold = 0.7
|
||||
|
||||
var ErrInvalidThreshold = errors.New("confidence: schwelle muss zwischen 0 und 1 liegen")
|
||||
|
||||
// Store verwaltet die Schwellenwerte je tenant_slug+function.
|
||||
type Store struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// Set legt die Schwelle für tenantSlug+function fest (AK1).
|
||||
func (s *Store) Set(ctx context.Context, tenantSlug, function string, threshold float64) error {
|
||||
tenantSlug = strings.TrimSpace(tenantSlug)
|
||||
function = strings.TrimSpace(function)
|
||||
if tenantSlug == "" || function == "" {
|
||||
return fmt.Errorf("confidence: tenantSlug und function dürfen nicht leer sein")
|
||||
}
|
||||
if threshold < 0 || threshold > 1 {
|
||||
return ErrInvalidThreshold
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
INSERT INTO ai_confidence_thresholds (tenant_slug, function, threshold, updated_at)
|
||||
VALUES ($1, $2, $3, now())
|
||||
ON CONFLICT (tenant_slug, function)
|
||||
DO UPDATE SET threshold = EXCLUDED.threshold, updated_at = now()
|
||||
`, tenantSlug, function, threshold)
|
||||
if err != nil {
|
||||
return fmt.Errorf("confidence: speichern: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get liefert die Schwelle für tenantSlug+function, oder DefaultThreshold,
|
||||
// falls keine gesetzt wurde. Liest stets live aus der Datenbank, kein
|
||||
// Zwischenspeicher, damit eine Änderung ohne Neustart wirkt (Prüfung 1).
|
||||
func (s *Store) Get(ctx context.Context, tenantSlug, function string) (float64, error) {
|
||||
var threshold float64
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT threshold FROM ai_confidence_thresholds WHERE tenant_slug = $1 AND function = $2
|
||||
`, tenantSlug, function).Scan(&threshold)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DefaultThreshold, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("confidence: lesen: %w", err)
|
||||
}
|
||||
return threshold, nil
|
||||
}
|
||||
|
||||
// MeetsThreshold prüft, ob confidenceValue die für tenantSlug+function
|
||||
// konfigurierte Schwelle erreicht (Vorbereitung der Schnittstelle für
|
||||
// QA-03, ohne dessen Prüfgate selbst umzusetzen).
|
||||
func (s *Store) MeetsThreshold(ctx context.Context, tenantSlug, function string, confidenceValue float64) (bool, error) {
|
||||
threshold, err := s.Get(ctx, tenantSlug, function)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return confidenceValue >= threshold, nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package confidence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
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)
|
||||
migrationsDir, err := filepath.Abs("../../migrations")
|
||||
if err != nil {
|
||||
t.Fatalf("migrationsverzeichnis: %v", err)
|
||||
}
|
||||
migrations, err := migrate.Load(migrationsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("migrationen laden: %v", err)
|
||||
}
|
||||
if _, err := migrate.Up(context.Background(), pool, migrations); err != nil {
|
||||
t.Fatalf("migrationen anwenden: %v", err)
|
||||
}
|
||||
return &Store{Pool: pool}
|
||||
}
|
||||
|
||||
// AK1: Schwelle ist je Funktion und Tenant konfigurierbar.
|
||||
func TestSetAndGet_PerTenantPerFunction(t *testing.T) {
|
||||
s := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.Set(ctx, "acme", "classify", 0.9); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if err := s.Set(ctx, "acme", "extract", 0.5); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if err := s.Set(ctx, "other", "classify", 0.3); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.Get(ctx, "acme", "classify")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got != 0.9 {
|
||||
t.Errorf("acme/classify: erwartet 0.9, habe %v", got)
|
||||
}
|
||||
|
||||
got, err = s.Get(ctx, "acme", "extract")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got != 0.5 {
|
||||
t.Errorf("acme/extract: erwartet 0.5, habe %v", got)
|
||||
}
|
||||
|
||||
got, err = s.Get(ctx, "other", "classify")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got != 0.3 {
|
||||
t.Errorf("other/classify sollte unabhängig von acme sein, habe %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Ungesetzte Kombination: DefaultThreshold gilt.
|
||||
func TestGet_DefaultsWhenUnset(t *testing.T) {
|
||||
s := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
got, err := s.Get(ctx, "brandneu", "classify")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got != DefaultThreshold {
|
||||
t.Errorf("erwartet DefaultThreshold %v, habe %v", DefaultThreshold, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSet_RejectsOutOfRangeThreshold(t *testing.T) {
|
||||
s := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.Set(ctx, "acme", "classify", 1.5); !errors.Is(err, ErrInvalidThreshold) {
|
||||
t.Fatalf("erwartet ErrInvalidThreshold, habe %v", err)
|
||||
}
|
||||
if err := s.Set(ctx, "acme", "classify", -0.1); !errors.Is(err, ErrInvalidThreshold) {
|
||||
t.Fatalf("erwartet ErrInvalidThreshold, habe %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// AK1 / Prüfung 1: Schwellenwertänderung wirkt ohne Neustart — zwei
|
||||
// unabhängige Store-Instanzen (wie zwei Prozesse) sehen die Änderung sofort,
|
||||
// da Get live aus der DB liest statt zu cachen.
|
||||
func TestGet_ChangeTakesEffectWithoutRestart(t *testing.T) {
|
||||
s := newStore(t)
|
||||
ctx := context.Background()
|
||||
reader := &Store{Pool: s.Pool}
|
||||
|
||||
if err := s.Set(ctx, "liveflip", "classify", 0.4); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
got, err := reader.Get(ctx, "liveflip", "classify")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got != 0.4 {
|
||||
t.Fatalf("erwartet 0.4 nach erstem Set, habe %v", got)
|
||||
}
|
||||
|
||||
if err := s.Set(ctx, "liveflip", "classify", 0.95); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
got, err = reader.Get(ctx, "liveflip", "classify")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got != 0.95 {
|
||||
t.Fatalf("zweiter Store sollte die Änderung ohne eigenen Neustart sehen, habe %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetsThreshold(t *testing.T) {
|
||||
s := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.Set(ctx, "acme", "classify", 0.8); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
|
||||
ok, err := s.MeetsThreshold(ctx, "acme", "classify", 0.9)
|
||||
if err != nil {
|
||||
t.Fatalf("MeetsThreshold: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("0.9 sollte die Schwelle 0.8 erreichen")
|
||||
}
|
||||
|
||||
ok, err = s.MeetsThreshold(ctx, "acme", "classify", 0.5)
|
||||
if err != nil {
|
||||
t.Fatalf("MeetsThreshold: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("0.5 sollte die Schwelle 0.8 nicht erreichen")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Package correction erfasst Nutzerkorrekturen an KI-Klassifizierungen
|
||||
// strukturiert (CLS-05 AK2) und wertet sie je Tenant/Funktion aus (AK3), um
|
||||
// die Klassifizierungsgüte messbar verbesserbar zu machen statt statisch zu
|
||||
// belassen.
|
||||
package correction
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Correction ist eine einzelne, strukturiert erfasste Nutzerkorrektur einer
|
||||
// automatischen Klassifizierung.
|
||||
type Correction struct {
|
||||
TenantSlug string
|
||||
Function string
|
||||
InputRef string
|
||||
SuggestedCategory string
|
||||
SuggestedConfidence float64
|
||||
CorrectedCategory string
|
||||
Actor string
|
||||
}
|
||||
|
||||
func (c Correction) validate() error {
|
||||
if strings.TrimSpace(c.TenantSlug) == "" {
|
||||
return errors.New("correction: tenantSlug darf nicht leer sein")
|
||||
}
|
||||
if strings.TrimSpace(c.Function) == "" {
|
||||
return errors.New("correction: function darf nicht leer sein")
|
||||
}
|
||||
if strings.TrimSpace(c.InputRef) == "" {
|
||||
return errors.New("correction: inputRef darf nicht leer sein")
|
||||
}
|
||||
if strings.TrimSpace(c.SuggestedCategory) == "" {
|
||||
return errors.New("correction: suggestedCategory darf nicht leer sein")
|
||||
}
|
||||
if strings.TrimSpace(c.CorrectedCategory) == "" {
|
||||
return errors.New("correction: correctedCategory darf nicht leer sein")
|
||||
}
|
||||
if strings.TrimSpace(c.Actor) == "" {
|
||||
return errors.New("correction: actor darf nicht leer sein")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Recorder speichert Korrekturen in Postgres.
|
||||
type Recorder struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// Record erfasst eine Korrektur vollständig (AK2). Fehlt ein Pflichtfeld,
|
||||
// wird nichts gespeichert.
|
||||
func (r *Recorder) Record(ctx context.Context, c Correction) error {
|
||||
if err := c.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
INSERT INTO ai_classification_corrections
|
||||
(tenant_slug, function, input_ref, suggested_category, suggested_confidence, corrected_category, actor)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`, c.TenantSlug, c.Function, c.InputRef, c.SuggestedCategory, c.SuggestedConfidence, c.CorrectedCategory, c.Actor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("correction: speichern: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stats sind die je Tenant+Funktion einsehbaren Kennzahlen zu erfassten
|
||||
// Korrekturen (AK3).
|
||||
type Stats struct {
|
||||
TenantSlug string
|
||||
Function string
|
||||
TotalCorrections int
|
||||
TopSuggested map[string]int // wie oft eine Kategorie vorgeschlagen und dann korrigiert wurde
|
||||
TopCorrectedTo map[string]int // wie oft auf eine Kategorie korrigiert wurde
|
||||
AverageSuggested float64 // durchschnittliche Konfidenz der korrigierten Vorschläge
|
||||
}
|
||||
|
||||
// Aggregate liefert die Auswertung der Korrekturen für tenantSlug+function
|
||||
// (AK3, Prüfung 3: nachvollziehbare Kennzahlen).
|
||||
func (r *Recorder) Aggregate(ctx context.Context, tenantSlug, function string) (Stats, error) {
|
||||
stats := Stats{
|
||||
TenantSlug: tenantSlug,
|
||||
Function: function,
|
||||
TopSuggested: map[string]int{},
|
||||
TopCorrectedTo: map[string]int{},
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT suggested_category, suggested_confidence, corrected_category
|
||||
FROM ai_classification_corrections
|
||||
WHERE tenant_slug = $1 AND function = $2
|
||||
`, tenantSlug, function)
|
||||
if err != nil {
|
||||
return Stats{}, fmt.Errorf("correction: auswertung lesen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var confidenceSum float64
|
||||
for rows.Next() {
|
||||
var suggestedCategory, correctedCategory string
|
||||
var suggestedConfidence float64
|
||||
if err := rows.Scan(&suggestedCategory, &suggestedConfidence, &correctedCategory); err != nil {
|
||||
return Stats{}, fmt.Errorf("correction: auswertung lesen: %w", err)
|
||||
}
|
||||
stats.TotalCorrections++
|
||||
stats.TopSuggested[suggestedCategory]++
|
||||
stats.TopCorrectedTo[correctedCategory]++
|
||||
confidenceSum += suggestedConfidence
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Stats{}, fmt.Errorf("correction: auswertung lesen: %w", err)
|
||||
}
|
||||
if stats.TotalCorrections > 0 {
|
||||
stats.AverageSuggested = confidenceSum / float64(stats.TotalCorrections)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package correction
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
func newRecorder(t *testing.T) *Recorder {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
|
||||
}
|
||||
pool := testdb.Pool(t, dsn)
|
||||
migrationsDir, err := filepath.Abs("../../migrations")
|
||||
if err != nil {
|
||||
t.Fatalf("migrationsverzeichnis: %v", err)
|
||||
}
|
||||
migrations, err := migrate.Load(migrationsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("migrationen laden: %v", err)
|
||||
}
|
||||
if _, err := migrate.Up(context.Background(), pool, migrations); err != nil {
|
||||
t.Fatalf("migrationen anwenden: %v", err)
|
||||
}
|
||||
return &Recorder{Pool: pool}
|
||||
}
|
||||
|
||||
// AK2 / Prüfung 2: Korrektur-Feedback wird vollständig erfasst.
|
||||
func TestRecord_CapturesCorrectionFully(t *testing.T) {
|
||||
r := newRecorder(t)
|
||||
ctx := context.Background()
|
||||
|
||||
c := Correction{
|
||||
TenantSlug: "acme",
|
||||
Function: "classify",
|
||||
InputRef: "doc-1",
|
||||
SuggestedCategory: "rechnung",
|
||||
SuggestedConfidence: 0.42,
|
||||
CorrectedCategory: "mahnung",
|
||||
Actor: "user@acme.example",
|
||||
}
|
||||
if err := r.Record(ctx, c); err != nil {
|
||||
t.Fatalf("Record: %v", err)
|
||||
}
|
||||
|
||||
stats, err := r.Aggregate(ctx, "acme", "classify")
|
||||
if err != nil {
|
||||
t.Fatalf("Aggregate: %v", err)
|
||||
}
|
||||
if stats.TotalCorrections != 1 {
|
||||
t.Fatalf("erwartet 1 korrektur, habe %d", stats.TotalCorrections)
|
||||
}
|
||||
if stats.TopSuggested["rechnung"] != 1 {
|
||||
t.Errorf("suggested-zaehlung fehlt: %+v", stats.TopSuggested)
|
||||
}
|
||||
if stats.TopCorrectedTo["mahnung"] != 1 {
|
||||
t.Errorf("corrected-zaehlung fehlt: %+v", stats.TopCorrectedTo)
|
||||
}
|
||||
if stats.AverageSuggested != 0.42 {
|
||||
t.Errorf("erwartete durchschnittliche konfidenz 0.42, habe %v", stats.AverageSuggested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecord_RejectsIncompleteCorrection(t *testing.T) {
|
||||
r := newRecorder(t)
|
||||
ctx := context.Background()
|
||||
|
||||
err := r.Record(ctx, Correction{
|
||||
TenantSlug: "acme",
|
||||
Function: "classify",
|
||||
SuggestedCategory: "rechnung",
|
||||
CorrectedCategory: "mahnung",
|
||||
Actor: "user@acme.example",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("erwartet fehler bei fehlendem inputRef")
|
||||
}
|
||||
}
|
||||
|
||||
// AK3 / Prüfung 3: Auswertung liefert nachvollziehbare, tenant-isolierte
|
||||
// Kennzahlen über mehrere Korrekturen.
|
||||
func TestAggregate_YieldsTraceableMetricsPerTenant(t *testing.T) {
|
||||
r := newRecorder(t)
|
||||
ctx := context.Background()
|
||||
|
||||
corrections := []Correction{
|
||||
{TenantSlug: "acme", Function: "classify", InputRef: "doc-1", SuggestedCategory: "rechnung", SuggestedConfidence: 0.4, CorrectedCategory: "mahnung", Actor: "u1"},
|
||||
{TenantSlug: "acme", Function: "classify", InputRef: "doc-2", SuggestedCategory: "rechnung", SuggestedConfidence: 0.6, CorrectedCategory: "mahnung", Actor: "u1"},
|
||||
{TenantSlug: "acme", Function: "classify", InputRef: "doc-3", SuggestedCategory: "vertrag", SuggestedConfidence: 0.5, CorrectedCategory: "kuendigung", Actor: "u2"},
|
||||
{TenantSlug: "other", Function: "classify", InputRef: "doc-9", SuggestedCategory: "rechnung", SuggestedConfidence: 0.9, CorrectedCategory: "mahnung", Actor: "u3"},
|
||||
}
|
||||
for _, c := range corrections {
|
||||
if err := r.Record(ctx, c); err != nil {
|
||||
t.Fatalf("Record: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := r.Aggregate(ctx, "acme", "classify")
|
||||
if err != nil {
|
||||
t.Fatalf("Aggregate: %v", err)
|
||||
}
|
||||
if stats.TotalCorrections != 3 {
|
||||
t.Fatalf("erwartet 3 korrekturen fuer acme, habe %d", stats.TotalCorrections)
|
||||
}
|
||||
if stats.TopSuggested["rechnung"] != 2 || stats.TopSuggested["vertrag"] != 1 {
|
||||
t.Errorf("unerwartete suggested-verteilung: %+v", stats.TopSuggested)
|
||||
}
|
||||
if stats.TopCorrectedTo["mahnung"] != 2 || stats.TopCorrectedTo["kuendigung"] != 1 {
|
||||
t.Errorf("unerwartete corrected-verteilung: %+v", stats.TopCorrectedTo)
|
||||
}
|
||||
wantAvg := (0.4 + 0.6 + 0.5) / 3
|
||||
if diff := stats.AverageSuggested - wantAvg; diff > 1e-9 || diff < -1e-9 {
|
||||
t.Errorf("erwartet durchschnitt %v, habe %v", wantAvg, stats.AverageSuggested)
|
||||
}
|
||||
|
||||
otherStats, err := r.Aggregate(ctx, "other", "classify")
|
||||
if err != nil {
|
||||
t.Fatalf("Aggregate: %v", err)
|
||||
}
|
||||
if otherStats.TotalCorrections != 1 {
|
||||
t.Fatalf("acme-korrekturen duerfen other nicht beeinflussen, habe %d", otherStats.TotalCorrections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregate_EmptyWhenNoCorrections(t *testing.T) {
|
||||
r := newRecorder(t)
|
||||
ctx := context.Background()
|
||||
|
||||
stats, err := r.Aggregate(ctx, "unbekannt", "classify")
|
||||
if err != nil {
|
||||
t.Fatalf("Aggregate: %v", err)
|
||||
}
|
||||
if stats.TotalCorrections != 0 {
|
||||
t.Fatalf("erwartet 0 korrekturen, habe %d", stats.TotalCorrections)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE ai_confidence_thresholds;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE ai_confidence_thresholds (
|
||||
tenant_slug TEXT NOT NULL,
|
||||
function TEXT NOT NULL,
|
||||
threshold DOUBLE PRECISION NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tenant_slug, function)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE ai_classification_corrections;
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE ai_classification_corrections (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_slug TEXT NOT NULL,
|
||||
function TEXT NOT NULL,
|
||||
input_ref TEXT NOT NULL,
|
||||
suggested_category TEXT NOT NULL,
|
||||
suggested_confidence DOUBLE PRECISION NOT NULL,
|
||||
corrected_category TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX ai_classification_corrections_tenant_function_idx
|
||||
ON ai_classification_corrections (tenant_slug, function);
|
||||
Reference in new issue
Block a user