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
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:
commit
8ea0338aa5
9 files changed
+605
-5
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user