diff --git a/ai/docs/CLS-05-PRUEFPROTOKOLL.md b/ai/docs/CLS-05-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..c8ecb63f --- /dev/null +++ b/ai/docs/CLS-05-PRUEFPROTOKOLL.md @@ -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. diff --git a/ai/internal/confidence/confidence.go b/ai/internal/confidence/confidence.go new file mode 100644 index 00000000..3ae6a04d --- /dev/null +++ b/ai/internal/confidence/confidence.go @@ -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 +} diff --git a/ai/internal/confidence/confidence_test.go b/ai/internal/confidence/confidence_test.go new file mode 100644 index 00000000..bd1f724f --- /dev/null +++ b/ai/internal/confidence/confidence_test.go @@ -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") + } +} diff --git a/ai/internal/correction/correction.go b/ai/internal/correction/correction.go new file mode 100644 index 00000000..b03e29d8 --- /dev/null +++ b/ai/internal/correction/correction.go @@ -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 +} diff --git a/ai/internal/correction/correction_test.go b/ai/internal/correction/correction_test.go new file mode 100644 index 00000000..27c8270e --- /dev/null +++ b/ai/internal/correction/correction_test.go @@ -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) + } +} diff --git a/ai/migrations/0010_ai_confidence_thresholds.down.sql b/ai/migrations/0010_ai_confidence_thresholds.down.sql new file mode 100644 index 00000000..86f54b6b --- /dev/null +++ b/ai/migrations/0010_ai_confidence_thresholds.down.sql @@ -0,0 +1 @@ +DROP TABLE ai_confidence_thresholds; diff --git a/ai/migrations/0010_ai_confidence_thresholds.up.sql b/ai/migrations/0010_ai_confidence_thresholds.up.sql new file mode 100644 index 00000000..33e5385f --- /dev/null +++ b/ai/migrations/0010_ai_confidence_thresholds.up.sql @@ -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) +); diff --git a/ai/migrations/0011_ai_classification_corrections.down.sql b/ai/migrations/0011_ai_classification_corrections.down.sql new file mode 100644 index 00000000..4552f0e7 --- /dev/null +++ b/ai/migrations/0011_ai_classification_corrections.down.sql @@ -0,0 +1 @@ +DROP TABLE ai_classification_corrections; diff --git a/ai/migrations/0011_ai_classification_corrections.up.sql b/ai/migrations/0011_ai_classification_corrections.up.sql new file mode 100644 index 00000000..0cc9a308 --- /dev/null +++ b/ai/migrations/0011_ai_classification_corrections.up.sql @@ -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);