From 95ba40c721e3817aca54b3e90ffec4068060adb8 Mon Sep 17 00:00:00 2001 From: patrick Date: Mon, 28 Sep 2026 02:22:23 +0000 Subject: [PATCH] WFS-03: vorschlags-feedback-loop Erfasst, ob ein Routing-Vorschlag aus WFS-02 uebernommen oder korrigiert wurde (ai/internal/routingfeedback), macht die Trefferquote ueber die Vorschlags-API einsehbar und speist gespeichertes Feedback wieder als Historie in kuenftige Vorschlaege ein (historysuggest.Suggest). Co-Authored-By: Claude Sonnet 5 --- ai/cmd/ai-app/main.go | 3 +- ai/docs/WFS-03-PRUEFPROTOKOLL.md | 85 +++++++++++ .../routingfeedback/routingfeedback.go | 81 ++++++++++ .../routingfeedback/routingfeedback_test.go | 135 +++++++++++++++++ ai/internal/routingsuggestapi/client.go | 67 ++++++++- .../routingsuggestapi/routingsuggestapi.go | 89 ++++++++++- .../routingsuggestapi_test.go | 139 ++++++++++++++++++ .../0010_routing_suggestion_feedback.down.sql | 1 + .../0010_routing_suggestion_feedback.up.sql | 10 ++ 9 files changed, 605 insertions(+), 5 deletions(-) create mode 100644 ai/docs/WFS-03-PRUEFPROTOKOLL.md create mode 100644 ai/internal/routingfeedback/routingfeedback.go create mode 100644 ai/internal/routingfeedback/routingfeedback_test.go create mode 100644 ai/migrations/0010_routing_suggestion_feedback.down.sql create mode 100644 ai/migrations/0010_routing_suggestion_feedback.up.sql diff --git a/ai/cmd/ai-app/main.go b/ai/cmd/ai-app/main.go index 86c36410..5960d337 100644 --- a/ai/cmd/ai-app/main.go +++ b/ai/cmd/ai-app/main.go @@ -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 { diff --git a/ai/docs/WFS-03-PRUEFPROTOKOLL.md b/ai/docs/WFS-03-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..df5bb260 --- /dev/null +++ b/ai/docs/WFS-03-PRUEFPROTOKOLL.md @@ -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). diff --git a/ai/internal/routingfeedback/routingfeedback.go b/ai/internal/routingfeedback/routingfeedback.go new file mode 100644 index 00000000..10175a97 --- /dev/null +++ b/ai/internal/routingfeedback/routingfeedback.go @@ -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() +} diff --git a/ai/internal/routingfeedback/routingfeedback_test.go b/ai/internal/routingfeedback/routingfeedback_test.go new file mode 100644 index 00000000..436935cf --- /dev/null +++ b/ai/internal/routingfeedback/routingfeedback_test.go @@ -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) + } +} diff --git a/ai/internal/routingsuggestapi/client.go b/ai/internal/routingsuggestapi/client.go index 9f4c5291..9b105284 100644 --- a/ai/internal/routingsuggestapi/client.go +++ b/ai/internal/routingsuggestapi/client.go @@ -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 +} diff --git a/ai/internal/routingsuggestapi/routingsuggestapi.go b/ai/internal/routingsuggestapi/routingsuggestapi.go index c9822099..715326a6 100644 --- a/ai/internal/routingsuggestapi/routingsuggestapi.go +++ b/ai/internal/routingsuggestapi/routingsuggestapi.go @@ -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) diff --git a/ai/internal/routingsuggestapi/routingsuggestapi_test.go b/ai/internal/routingsuggestapi/routingsuggestapi_test.go index 58473879..89fbde60 100644 --- a/ai/internal/routingsuggestapi/routingsuggestapi_test.go +++ b/ai/internal/routingsuggestapi/routingsuggestapi_test.go @@ -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) + } +} diff --git a/ai/migrations/0010_routing_suggestion_feedback.down.sql b/ai/migrations/0010_routing_suggestion_feedback.down.sql new file mode 100644 index 00000000..42aee3d9 --- /dev/null +++ b/ai/migrations/0010_routing_suggestion_feedback.down.sql @@ -0,0 +1 @@ +DROP TABLE routing_suggestion_feedback; diff --git a/ai/migrations/0010_routing_suggestion_feedback.up.sql b/ai/migrations/0010_routing_suggestion_feedback.up.sql new file mode 100644 index 00000000..c20456b6 --- /dev/null +++ b/ai/migrations/0010_routing_suggestion_feedback.up.sql @@ -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);