Merge pull request 'WFS-03: Vorschlags-Feedback-Loop' (#246) from feature/wfs-03-vorschlags-feedback-loop into main
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s

This commit was merged in pull request #246.
This commit is contained in:
patrick committed 2026-09-28 06:42:24 +02:00
commit 8ea0338aa5
9 files changed
+605 -5

No files matched your search

+2 -1
View File
@@ -33,6 +33,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/quota"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/residency"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/residencyapi"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/routingfeedback"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/routingsuggestapi"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearch"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearchapi"
@@ -137,7 +138,7 @@ func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID stri
residencyapi.NewServer(residency.NewStore(pool), cfg.serviceToken).RegisterRoutes(mux)
routingsuggestapi.NewServer(cfg.serviceToken).RegisterRoutes(mux)
routingsuggestapi.NewServer(cfg.serviceToken).WithFeedback(&routingfeedback.Store{Pool: pool}).RegisterRoutes(mux)
moduleClients := make(map[string]*metadatapushclient.Client, len(cfg.moduleURLs))
for name, baseURL := range cfg.moduleURLs {
+85
View File
@@ -0,0 +1,85 @@
# WFS-03 – Prüfprotokoll
Ticket: `ai-kanban/tickets/WFS-03.md` – Vorschlags-Feedback-Loop.
Aufbauend auf WFS-02 (`ai/internal/routingsuggestapi`, `ai/internal/historysuggest`).
## Umsetzung
- Neues Paket `ai/internal/routingfeedback`: `Store` mit `Record` (AK1),
`Stats` (AK2) und `History` (AK3), Tabelle `routing_suggestion_feedback`
(Migration `0010_routing_suggestion_feedback`).
- `ai/internal/routingsuggestapi` erweitert um:
- `POST /workflow/routing-suggestions/feedback` (Annahme/Korrektur erfassen)
- `GET /workflow/routing-suggestions/feedback/stats?tenant_slug=...`
(Trefferquote)
- `handleSuggest` reichert bei gesetztem `tenant_slug` die Historie um
bereits gespeichertes Feedback an, bevor `historysuggest.Suggest`
aufgerufen wird (AK3) – ohne dessen Code zu ändern.
- `Client` (Referenzimplementierung) um `RecordFeedback`, `FeedbackStats`,
`SuggestForTenant` ergänzt; bestehende `Suggest`-Signatur unverändert
(Aufruf ohne `tenant_slug`).
- `cmd/ai-app/main.go`: Server wird mit `WithFeedback(&routingfeedback.Store{Pool: pool})`
verdrahtet.
## Prüfungen
Ausgeführt auf Testhost `root@192.168.1.204`, eigene Wegwerf-DB
`ai_wfs03_test` (Rolle `ai_wfs03_test`, per Migration aufgebaut/zurückgebaut,
DSN nur über `TEST_TENANT_DSN`-Umgebungsvariable, kein Secret im Code/Repo).
### Prüfung 1 – Erfassung von Annahme/Korrektur im Test bestätigt
Ziel: `Record`/`RecordFeedback` speichern sowohl übernommene als auch
korrigierte Vorschläge korrekt, inkl. Ablehnung ohne `tenant_slug`.
Ergebnis: bestanden.
- `TestRecord_AnnahmeUndKorrektur`, `TestRecord_OhneTenantSlugSchlaegtFehl`
(`ai/internal/routingfeedback`)
- `TestRecordFeedback_AnnahmeUndKorrektur`,
`TestRecordFeedback_OhneFeedbackSpeicherKonfiguriert`
(`ai/internal/routingsuggestapi`)
### Prüfung 2 – Trefferquoten-Auswertung im Test nachvollzogen
Ziel: `Stats`/`FeedbackStats` liefern Gesamtzahl, Anzahl übernommen und
Trefferquote korrekt, auch bei leerem Bestand.
Ergebnis: bestanden.
- `TestStats_Trefferquote` (3 Fälle, 2 übernommen → Quote 2/3, exakt
nachgerechnet), `TestStats_OhneFeedbackIstNull`
(`ai/internal/routingfeedback`)
- `TestFeedbackStats_Trefferquote` über die HTTP-Schnittstelle (4 Fälle,
3 übernommen → Quote 0.75) (`ai/internal/routingsuggestapi`)
### Prüfung 3 – Wirkung des Feedbacks auf Folgevorschlag geprüft
Ziel: gespeichertes Feedback verändert den nächsten Vorschlag nachvollziehbar,
ohne dass der Aufrufer erneut die komplette Historie mitschicken muss.
Ergebnis: bestanden.
- `TestHistory_FliesstInFolgevorschlagEin`: 6x Korrektur "rechnung" →
"mahnung" erfasst; `historysuggest.Suggest` auf der daraus gelesenen
Historie schlägt danach "mahnung" vor (`ai/internal/routingfeedback`).
- `TestSuggestForTenant_FeedbackWirktAufFolgevorschlag`: identischer Ablauf
end-to-end über die HTTP-Schnittstelle (`RecordFeedback` × 6, dann
`SuggestForTenant` ohne eigene History) → Vorschlag "mahnung",
SampleSize 6.
- `TestSuggestForTenant_OhneTenantSlugUnveraendert`: ohne `tenant_slug`
bleibt das WFS-02-Verhalten (nur client-seitige Historie) unverändert.
## Testergebnisse (gesamt)
```
go build ./... -> ok
go vet ./... -> ok
go test ./... -p 1 -> ok (alle Pakete, inkl. neue:
routingfeedback, routingsuggestapi)
```
Migrations-Rundlauf (`migrate.Up` → `migrate.DownOne` → `migrate.Up`) für
`0010_routing_suggestion_feedback` manuell verifiziert (auf/ab/auf ohne
Fehler).
## Offene Punkte
Keine – alle drei Akzeptanzkriterien und alle drei Prüfungen erfüllt.
Wiring des Feedback-Aufrufs im Workflow-Board selbst (workflow-Modul) ist
nicht Bestandteil dieser Kachel (separates, physisch getrenntes Go-Modul,
eigener Ticket-Scope).
@@ -0,0 +1,81 @@
// Package routingfeedback erfasst, ob ein Routing-Vorschlag aus
// routingsuggestapi (WFS-02) übernommen oder korrigiert wurde, wertet die
// Trefferquote aus und stellt die tatsächlich gewählten Kategorien wieder
// als Historie für historysuggest.Suggest bereit (WFS-03).
package routingfeedback
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/historysuggest"
)
type Store struct {
Pool *pgxpool.Pool
}
// Record haelt fest, ob suggestedCategory uebernommen wurde oder auf
// actualCategory korrigiert wurde (AK1).
func (s *Store) Record(ctx context.Context, tenantSlug, suggestedCategory, actualCategory string) error {
if tenantSlug == "" || actualCategory == "" {
return fmt.Errorf("routingfeedback: tenant_slug und actual_category sind pflicht")
}
accepted := suggestedCategory != "" && suggestedCategory == actualCategory
_, err := s.Pool.Exec(ctx, `
INSERT INTO routing_suggestion_feedback (tenant_slug, suggested_category, actual_category, accepted)
VALUES ($1, $2, $3, $4)`,
tenantSlug, suggestedCategory, actualCategory, accepted)
if err != nil {
return fmt.Errorf("routingfeedback: feedback speichern: %w", err)
}
return nil
}
// Stats ist die Trefferquoten-Auswertung (AK2).
type Stats struct {
Total int
Accepted int
AcceptanceRate float64
}
func (s *Store) Stats(ctx context.Context, tenantSlug string) (Stats, error) {
var total, accepted int
err := s.Pool.QueryRow(ctx, `
SELECT count(*), count(*) FILTER (WHERE accepted)
FROM routing_suggestion_feedback WHERE tenant_slug = $1`,
tenantSlug).Scan(&total, &accepted)
if err != nil {
return Stats{}, fmt.Errorf("routingfeedback: trefferquote lesen: %w", err)
}
stats := Stats{Total: total, Accepted: accepted}
if total > 0 {
stats.AcceptanceRate = float64(accepted) / float64(total)
}
return stats, nil
}
// History liefert die tatsaechlich gewaehlten Kategorien aus dem Feedback
// als Historie zurueck (AK3) — jede Korrektur wirkt so auf den naechsten
// Vorschlag aus historysuggest.Suggest, ohne dessen Code zu aendern.
func (s *Store) History(ctx context.Context, tenantSlug string) ([]historysuggest.HistoryEntry, error) {
rows, err := s.Pool.Query(ctx, `
SELECT actual_category FROM routing_suggestion_feedback
WHERE tenant_slug = $1 ORDER BY created_at`, tenantSlug)
if err != nil {
return nil, fmt.Errorf("routingfeedback: historie lesen: %w", err)
}
defer rows.Close()
var entries []historysuggest.HistoryEntry
for rows.Next() {
var category string
if err := rows.Scan(&category); err != nil {
return nil, fmt.Errorf("routingfeedback: historie-eintrag lesen: %w", err)
}
entries = append(entries, historysuggest.HistoryEntry{Category: category})
}
return entries, rows.Err()
}
@@ -0,0 +1,135 @@
package routingfeedback
import (
"context"
"os"
"path/filepath"
"testing"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/historysuggest"
"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 uebersprungen")
}
pool := testdb.Pool(t, dsn)
root, err := filepath.Abs("../../migrations")
if err != nil {
t.Fatalf("migrationsverzeichnis: %v", err)
}
migrations, err := migrate.Load(root)
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 + Pruefung 1: Annahme und Korrektur werden erfasst.
func TestRecord_AnnahmeUndKorrektur(t *testing.T) {
store := newStore(t)
ctx := context.Background()
if err := store.Record(ctx, "tenant-a", "rechnung", "rechnung"); err != nil {
t.Fatalf("Record (Annahme): %v", err)
}
if err := store.Record(ctx, "tenant-a", "rechnung", "mahnung"); err != nil {
t.Fatalf("Record (Korrektur): %v", err)
}
var accepted, corrected int
err := store.Pool.QueryRow(ctx,
`SELECT count(*) FILTER (WHERE accepted), count(*) FILTER (WHERE NOT accepted)
FROM routing_suggestion_feedback WHERE tenant_slug = $1`, "tenant-a").
Scan(&accepted, &corrected)
if err != nil {
t.Fatalf("pruefabfrage: %v", err)
}
if accepted != 1 || corrected != 1 {
t.Fatalf("accepted=%d corrected=%d, want 1 und 1", accepted, corrected)
}
}
func TestRecord_OhneTenantSlugSchlaegtFehl(t *testing.T) {
store := newStore(t)
if err := store.Record(context.Background(), "", "rechnung", "rechnung"); err == nil {
t.Fatal("erwarteter Fehler bei fehlendem tenant_slug blieb aus")
}
}
// AK2 + Pruefung 2: Trefferquote ist einsehbar.
func TestStats_Trefferquote(t *testing.T) {
store := newStore(t)
ctx := context.Background()
tenant := "tenant-b"
if err := store.Record(ctx, tenant, "rechnung", "rechnung"); err != nil {
t.Fatalf("Record: %v", err)
}
if err := store.Record(ctx, tenant, "rechnung", "rechnung"); err != nil {
t.Fatalf("Record: %v", err)
}
if err := store.Record(ctx, tenant, "rechnung", "mahnung"); err != nil {
t.Fatalf("Record: %v", err)
}
stats, err := store.Stats(ctx, tenant)
if err != nil {
t.Fatalf("Stats: %v", err)
}
if stats.Total != 3 {
t.Errorf("Total = %d, want 3", stats.Total)
}
if stats.Accepted != 2 {
t.Errorf("Accepted = %d, want 2", stats.Accepted)
}
want := 2.0 / 3.0
if diff := stats.AcceptanceRate - want; diff > 0.0001 || diff < -0.0001 {
t.Errorf("AcceptanceRate = %v, want %v", stats.AcceptanceRate, want)
}
}
func TestStats_OhneFeedbackIstNull(t *testing.T) {
store := newStore(t)
stats, err := store.Stats(context.Background(), "tenant-ohne-feedback")
if err != nil {
t.Fatalf("Stats: %v", err)
}
if stats.Total != 0 || stats.AcceptanceRate != 0 {
t.Errorf("Stats = %+v, want leer", stats)
}
}
// AK3 + Pruefung 3: Feedback fliesst als Historie in kuenftige Vorschlaege ein.
func TestHistory_FliesstInFolgevorschlagEin(t *testing.T) {
store := newStore(t)
ctx := context.Background()
tenant := "tenant-c"
for i := 0; i < 6; i++ {
if err := store.Record(ctx, tenant, "rechnung", "mahnung"); err != nil {
t.Fatalf("Record: %v", err)
}
}
history, err := store.History(ctx, tenant)
if err != nil {
t.Fatalf("History: %v", err)
}
if len(history) != 6 {
t.Fatalf("len(history) = %d, want 6", len(history))
}
result := historysuggest.Suggest(history)
if result.Category != "mahnung" {
t.Errorf("Folgevorschlag = %q, want mahnung -- Korrektur aus Feedback haette den Vorschlag umkehren muessen", result.Category)
}
}
+66 -1
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)
// Client ruft die Vorschlags-API auf. Referenzimplementierung fuer den
@@ -26,11 +27,17 @@ func NewClient(baseURL, serviceToken string, httpClient *http.Client) *Client {
}
func (c *Client) Suggest(ctx context.Context, history []string) (SuggestResponse, error) {
return c.SuggestForTenant(ctx, "", history)
}
// SuggestForTenant wie Suggest, reichert den Vorschlag aber serverseitig um
// bereits erfasstes Feedback zu tenantSlug an (WFS-03 AK3).
func (c *Client) SuggestForTenant(ctx context.Context, tenantSlug string, history []string) (SuggestResponse, error) {
entries := make([]historyEntryRequest, 0, len(history))
for _, category := range history {
entries = append(entries, historyEntryRequest{Category: category})
}
body, err := json.Marshal(suggestRequest{History: entries})
body, err := json.Marshal(suggestRequest{TenantSlug: tenantSlug, History: entries})
if err != nil {
return SuggestResponse{}, fmt.Errorf("routingsuggestapi: anfrage kodieren: %w", err)
}
@@ -58,3 +65,61 @@ func (c *Client) Suggest(ctx context.Context, history []string) (SuggestResponse
}
return result, nil
}
// RecordFeedback meldet, ob suggestedCategory uebernommen oder auf
// actualCategory korrigiert wurde (WFS-03 AK1).
func (c *Client) RecordFeedback(ctx context.Context, tenantSlug, suggestedCategory, actualCategory string) error {
body, err := json.Marshal(feedbackRequest{
TenantSlug: tenantSlug,
SuggestedCategory: suggestedCategory,
ActualCategory: actualCategory,
})
if err != nil {
return fmt.Errorf("routingsuggestapi: feedback-anfrage kodieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/workflow/routing-suggestions/feedback", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("routingsuggestapi: feedback-anfrage aufbauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Service-Token", c.ServiceToken)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("routingsuggestapi: ai-instanz nicht erreichbar: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("routingsuggestapi: unerwarteter status %d", resp.StatusCode)
}
return nil
}
// FeedbackStats liefert die Trefferquoten-Auswertung fuer tenantSlug
// (WFS-03 AK2).
func (c *Client) FeedbackStats(ctx context.Context, tenantSlug string) (FeedbackStatsResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
c.BaseURL+"/workflow/routing-suggestions/feedback/stats?tenant_slug="+url.QueryEscape(tenantSlug), nil)
if err != nil {
return FeedbackStatsResponse{}, fmt.Errorf("routingsuggestapi: statistik-anfrage aufbauen: %w", err)
}
req.Header.Set("X-Service-Token", c.ServiceToken)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return FeedbackStatsResponse{}, fmt.Errorf("routingsuggestapi: ai-instanz nicht erreichbar: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return FeedbackStatsResponse{}, fmt.Errorf("routingsuggestapi: unerwarteter status %d", resp.StatusCode)
}
var result FeedbackStatsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return FeedbackStatsResponse{}, fmt.Errorf("routingsuggestapi: statistik-antwort dekodieren: %w", err)
}
return result, nil
}
@@ -3,7 +3,8 @@
// siehe workflow/internal/moduleadapter) Routing-Vorschlaege abrufen kann.
// Aufrufer ist das Workflow-Board selbst, nicht der Endnutzer direkt —
// Service-Token-Pflicht wie summaryapi/semanticsearchapi/residencyapi, fail
// closed.
// closed. WFS-03 ergaenzt die Feedback-Erfassung (routingfeedback): ob ein
// Vorschlag uebernommen oder korrigiert wurde, sowie die Trefferquote.
package routingsuggestapi
import (
@@ -11,19 +12,32 @@ import (
"net/http"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/historysuggest"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/routingfeedback"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/svcauth"
)
type Server struct {
serviceToken string
feedback *routingfeedback.Store
}
func NewServer(serviceToken string) *Server {
return &Server{serviceToken: serviceToken}
}
// WithFeedback aktiviert die Feedback-Erfassung/-Auswertung (WFS-03) sowie
// die Anreicherung von handleSuggest um bereits erfasstes Feedback. Ohne
// Aufruf bleibt der Server auf dem WFS-02-Verhalten (kein Feedback-Speicher
// konfiguriert).
func (s *Server) WithFeedback(store *routingfeedback.Store) *Server {
s.feedback = store
return s
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /workflow/routing-suggestions", s.requireAuth(s.handleSuggest))
mux.HandleFunc("POST /workflow/routing-suggestions/feedback", s.requireAuth(s.handleFeedback))
mux.HandleFunc("GET /workflow/routing-suggestions/feedback/stats", s.requireAuth(s.handleFeedbackStats))
}
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
@@ -42,7 +56,22 @@ type historyEntryRequest struct {
}
type suggestRequest struct {
History []historyEntryRequest `json:"history"`
TenantSlug string `json:"tenant_slug,omitempty"`
History []historyEntryRequest `json:"history"`
}
type feedbackRequest struct {
TenantSlug string `json:"tenant_slug"`
SuggestedCategory string `json:"suggested_category,omitempty"`
ActualCategory string `json:"actual_category"`
}
// FeedbackStatsResponse ist die Trefferquoten-Auswertung (AK2): wie oft
// wurde der Vorschlag uebernommen statt korrigiert.
type FeedbackStatsResponse struct {
Total int `json:"total"`
Accepted int `json:"accepted"`
AcceptanceRate float64 `json:"acceptance_rate"`
}
// SuggestResponse ist der Antwortvertrag fuer das Workflow-Board (AK2:
@@ -63,7 +92,19 @@ func (s *Server) handleSuggest(w http.ResponseWriter, r *http.Request) {
return
}
entries := make([]historysuggest.HistoryEntry, 0, len(req.History))
var entries []historysuggest.HistoryEntry
// AK3: bereits erfasstes Feedback fliesst als zusaetzliche Historie in
// den Vorschlag ein, wenn tenant_slug bekannt und Feedback konfiguriert ist.
if s.feedback != nil && req.TenantSlug != "" {
fromFeedback, err := s.feedback.History(r.Context(), req.TenantSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "feedback-historie lesen fehlgeschlagen")
return
}
entries = append(entries, fromFeedback...)
}
for _, h := range req.History {
entries = append(entries, historysuggest.HistoryEntry{Category: h.Category})
}
@@ -78,6 +119,48 @@ func (s *Server) handleSuggest(w http.ResponseWriter, r *http.Request) {
})
}
// handleFeedback erfasst, ob ein Vorschlag uebernommen oder korrigiert wurde
// (AK1).
func (s *Server) handleFeedback(w http.ResponseWriter, r *http.Request) {
if s.feedback == nil {
writeError(w, http.StatusServiceUnavailable, "feedback-erfassung ist nicht konfiguriert")
return
}
var req feedbackRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
return
}
if err := s.feedback.Record(r.Context(), req.TenantSlug, req.SuggestedCategory, req.ActualCategory); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleFeedbackStats liefert die Trefferquoten-Auswertung (AK2).
func (s *Server) handleFeedbackStats(w http.ResponseWriter, r *http.Request) {
if s.feedback == nil {
writeError(w, http.StatusServiceUnavailable, "feedback-erfassung ist nicht konfiguriert")
return
}
tenantSlug := r.URL.Query().Get("tenant_slug")
if tenantSlug == "" {
writeError(w, http.StatusBadRequest, "tenant_slug ist pflicht")
return
}
stats, err := s.feedback.Stats(r.Context(), tenantSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "trefferquote lesen fehlgeschlagen")
return
}
writeJSON(w, http.StatusOK, FeedbackStatsResponse{
Total: stats.Total,
Accepted: stats.Accepted,
AcceptanceRate: stats.AcceptanceRate,
})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
@@ -4,8 +4,14 @@ import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/routingfeedback"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
const testToken = "test-service-token"
@@ -16,6 +22,35 @@ func newTestServer() *httptest.Server {
return httptest.NewServer(mux)
}
// newTestServerWithFeedback wie newTestServer, zusaetzlich mit einem echten
// Feedback-Speicher (WFS-03) auf frischem Test-Schema.
func newTestServerWithFeedback(t *testing.T) *httptest.Server {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
pool := testdb.Pool(t, dsn)
root, err := filepath.Abs("../../migrations")
if err != nil {
t.Fatalf("migrationsverzeichnis: %v", err)
}
migrations, err := migrate.Load(root)
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)
}
mux := http.NewServeMux()
NewServer(testToken).WithFeedback(&routingfeedback.Store{Pool: pool}).RegisterRoutes(mux)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// AK1 + Pruefung 1: Workflow-Board ruft die Schnittstelle ab und erhaelt
// einen Vorschlag.
func TestClientSuggest_AbrufAusWorkflowBoardSicht(t *testing.T) {
@@ -125,3 +160,107 @@ func TestClientSuggest_KontextTimeoutGreift(t *testing.T) {
t.Errorf("Aufruf hat %v gedauert, Kontext-Timeout hat nicht gegriffen", elapsed)
}
}
// WFS-03 AK1 + Pruefung 1: Annahme und Korrektur eines Vorschlags werden ueber
// die Schnittstelle erfasst.
func TestRecordFeedback_AnnahmeUndKorrektur(t *testing.T) {
srv := newTestServerWithFeedback(t)
client := NewClient(srv.URL, testToken, nil)
ctx := context.Background()
if err := client.RecordFeedback(ctx, "tenant-x", "rechnung", "rechnung"); err != nil {
t.Fatalf("RecordFeedback (Annahme): %v", err)
}
if err := client.RecordFeedback(ctx, "tenant-x", "rechnung", "mahnung"); err != nil {
t.Fatalf("RecordFeedback (Korrektur): %v", err)
}
stats, err := client.FeedbackStats(ctx, "tenant-x")
if err != nil {
t.Fatalf("FeedbackStats: %v", err)
}
if stats.Total != 2 || stats.Accepted != 1 {
t.Errorf("Stats = %+v, want Total=2 Accepted=1", stats)
}
}
func TestRecordFeedback_OhneFeedbackSpeicherKonfiguriert(t *testing.T) {
srv := newTestServer()
defer srv.Close()
client := NewClient(srv.URL, testToken, nil)
if err := client.RecordFeedback(context.Background(), "tenant-x", "rechnung", "rechnung"); err == nil {
t.Fatal("erwarteter Fehler ohne konfigurierten Feedback-Speicher blieb aus")
}
}
// WFS-03 AK2 + Pruefung 2: Trefferquoten-Auswertung ist ueber die
// Schnittstelle einsehbar.
func TestFeedbackStats_Trefferquote(t *testing.T) {
srv := newTestServerWithFeedback(t)
client := NewClient(srv.URL, testToken, nil)
ctx := context.Background()
for i := 0; i < 3; i++ {
if err := client.RecordFeedback(ctx, "tenant-y", "rechnung", "rechnung"); err != nil {
t.Fatalf("RecordFeedback: %v", err)
}
}
if err := client.RecordFeedback(ctx, "tenant-y", "rechnung", "mahnung"); err != nil {
t.Fatalf("RecordFeedback: %v", err)
}
stats, err := client.FeedbackStats(ctx, "tenant-y")
if err != nil {
t.Fatalf("FeedbackStats: %v", err)
}
if stats.Total != 4 {
t.Errorf("Total = %d, want 4", stats.Total)
}
if stats.Accepted != 3 {
t.Errorf("Accepted = %d, want 3", stats.Accepted)
}
want := 0.75
if diff := stats.AcceptanceRate - want; diff > 0.0001 || diff < -0.0001 {
t.Errorf("AcceptanceRate = %v, want %v", stats.AcceptanceRate, want)
}
}
// WFS-03 AK3 + Pruefung 3: bereits erfasstes Feedback wirkt nachvollziehbar
// auf den naechsten Vorschlag, ohne dass der Aufrufer seine eigene Historie
// erneut mitschicken muss.
func TestSuggestForTenant_FeedbackWirktAufFolgevorschlag(t *testing.T) {
srv := newTestServerWithFeedback(t)
client := NewClient(srv.URL, testToken, nil)
ctx := context.Background()
for i := 0; i < 6; i++ {
if err := client.RecordFeedback(ctx, "tenant-z", "rechnung", "mahnung"); err != nil {
t.Fatalf("RecordFeedback: %v", err)
}
}
got, err := client.SuggestForTenant(ctx, "tenant-z", nil)
if err != nil {
t.Fatalf("SuggestForTenant: %v", err)
}
if got.Category != "mahnung" {
t.Errorf("Category = %q, want mahnung -- Feedback-Historie haette den Vorschlag umkehren muessen", got.Category)
}
if got.SampleSize != 6 {
t.Errorf("SampleSize = %d, want 6", got.SampleSize)
}
}
func TestSuggestForTenant_OhneTenantSlugUnveraendert(t *testing.T) {
srv := newTestServerWithFeedback(t)
client := NewClient(srv.URL, testToken, nil)
got, err := client.Suggest(context.Background(), []string{"a", "a", "a", "a", "b"})
if err != nil {
t.Fatalf("Suggest: %v", err)
}
if got.Category != "a" {
t.Errorf("Category = %q, want a", got.Category)
}
}
@@ -0,0 +1 @@
DROP TABLE routing_suggestion_feedback;
@@ -0,0 +1,10 @@
CREATE TABLE routing_suggestion_feedback (
id BIGSERIAL PRIMARY KEY,
tenant_slug TEXT NOT NULL,
suggested_category TEXT NOT NULL DEFAULT '',
actual_category TEXT NOT NULL,
accepted BOOLEAN NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX routing_suggestion_feedback_tenant_idx ON routing_suggestion_feedback (tenant_slug, created_at);