diff --git a/ai/docs/WFS-02-PRUEFPROTOKOLL.md b/ai/docs/WFS-02-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..be968010 --- /dev/null +++ b/ai/docs/WFS-02-PRUEFPROTOKOLL.md @@ -0,0 +1,74 @@ +# WFS-02 – Prüfprotokoll: Vorschlags-API (Kopplung Workflow-Board Modul-Adapter) + +## Umsetzung + +Neues Paket `ai/internal/routingsuggestapi`: + +- `routingsuggestapi.go` – HTTP-Bindung um `historysuggest.Suggest` (WFS-01), + Route `POST /workflow/routing-suggestions`, Service-Token-Pflicht + (`X-Service-Token`) nach demselben Muster wie `summaryapi`/`residencyapi`/ + `semanticsearchapi`. +- `client.go` – Referenz-Client für den Aufrufer (Workflow-Board, siehe + RTE-05). Setzt keinen eigenen Timeout, respektiert nur `context.Context` + und den übergebenen `*http.Client` — der Aufrufer entscheidet über die + Wartezeit bei einer nicht erreichbaren AI-Instanz. + +Getestet auf Testhost `root@192.168.1.204`, eigenes Arbeitsverzeichnis +`/root/wfs02-work` (Go-Submodul `ai/`, kein Deploy). + +## Prüfungen + +### 1. Abruf aus Sicht des Workflow-Boards im Test nachvollzogen + +**Zielwert:** Ein Aufrufer kann über die Schnittstelle einen Vorschlag +abrufen, ohne die interne Struktur von `historysuggest` zu kennen. + +**Ergebnis:** Bestanden. `TestClientSuggest_AbrufAusWorkflowBoardSicht` ruft +über `Client.Suggest` (reiner HTTP-Aufruf inkl. Service-Token) einen echten +`httptest.Server` auf und erhält den erwarteten Kategorie-Vorschlag. + +### 2. Ausfallverhalten im Test bestätigt + +**Zielwert:** Eine nicht erreichbare AI-Instanz blockiert den Aufrufer +nicht (AK3) — Fehler statt unbegrenztem Warten. + +**Ergebnis:** Bestanden. +- `TestClientSuggest_NichtErreichbareAIInstanzBlockiertNicht`: Aufruf gegen + nicht erreichbare Adresse mit `http.Client{Timeout: 200ms}` schlägt nach + < 2s mit Fehler fehl (gemessen: < 1s). +- `TestClientSuggest_KontextTimeoutGreift`: Aufruf mit + `context.WithTimeout(..., 100ms)` schlägt ebenfalls fehl statt zu + blockieren. +- `TestHandleSuggest_OhneServiceToken`: fehlendes/ungültiges Service-Token + liefert sofort 401 statt eines Hängers oder eines stillen Vorschlags + (fail closed, wie bei summaryapi/residencyapi). + +### 3. Antwortformat gegen Vertrag geprüft + +**Zielwert:** Antwort enthält Vorschlag, Konfidenz und Begründung (AK2). + +**Ergebnis:** Bestanden. `TestHandleSuggest_AntwortEnthaeltKonfidenzUndBegruendung` +prüft `Confidence` im Intervall (0,1] und eine nicht-leere `Reasoning`-Zeichenkette. +`TestHandleSuggest_GeringeDatenlageWirdGemeldet` prüft, dass bei zu geringer +Datenlage (< `historysuggest.MinSampleSize`) `low_data: true` und eine leere +`category` zurückkommen statt eines unbelastbaren Vorschlags — Durchreichung +von WFS-01s AK3 durch die Schnittstelle. + +## Build/Vet/Test-Ergebnis (Host 204) + +``` +go build ./internal/routingsuggestapi/... OK +go vet ./internal/routingsuggestapi/... OK (keine Ausgabe) +go test ./internal/routingsuggestapi/... -v PASS (6/6) +go build ./... (gesamtes ai-Modul) OK +go vet ./... (gesamtes ai-Modul) OK +``` + +## Offene Punkte + +- Der tatsächliche Aufruf aus dem Workflow-Board-Modul-Adapter (nicht nur + der Referenz-Client hier) ist Teil von RTE-05 (Workflow-Repo), nicht + dieser Kachel. +- Feedback-Loop (Rückmeldung, ob ein Vorschlag übernommen wurde) ist + ausdrücklich WFS-03, hier nicht vorbereitet über das reine Antwortformat + hinaus. diff --git a/ai/internal/routingsuggestapi/client.go b/ai/internal/routingsuggestapi/client.go new file mode 100644 index 00000000..9f4c5291 --- /dev/null +++ b/ai/internal/routingsuggestapi/client.go @@ -0,0 +1,60 @@ +package routingsuggestapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" +) + +// Client ruft die Vorschlags-API auf. Referenzimplementierung fuer den +// Aufrufer (Workflow-Board, siehe RTE-05); der Kontext-Timeout des Aufrufers +// entscheidet ueber die Wartezeit bei einer nicht erreichbaren AI-Instanz +// (AK3), Suggest selbst setzt keinen eigenen Timeout. +type Client struct { + BaseURL string + ServiceToken string + HTTPClient *http.Client +} + +func NewClient(baseURL, serviceToken string, httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = http.DefaultClient + } + return &Client{BaseURL: baseURL, ServiceToken: serviceToken, HTTPClient: httpClient} +} + +func (c *Client) Suggest(ctx context.Context, 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}) + if err != nil { + return SuggestResponse{}, fmt.Errorf("routingsuggestapi: anfrage kodieren: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/workflow/routing-suggestions", bytes.NewReader(body)) + if err != nil { + return SuggestResponse{}, fmt.Errorf("routingsuggestapi: 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 SuggestResponse{}, fmt.Errorf("routingsuggestapi: ai-instanz nicht erreichbar: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return SuggestResponse{}, fmt.Errorf("routingsuggestapi: unerwarteter status %d", resp.StatusCode) + } + + var result SuggestResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return SuggestResponse{}, fmt.Errorf("routingsuggestapi: antwort dekodieren: %w", err) + } + return result, nil +} diff --git a/ai/internal/routingsuggestapi/routingsuggestapi.go b/ai/internal/routingsuggestapi/routingsuggestapi.go new file mode 100644 index 00000000..c9822099 --- /dev/null +++ b/ai/internal/routingsuggestapi/routingsuggestapi.go @@ -0,0 +1,89 @@ +// Package routingsuggestapi ist die HTTP-Bindung fuer WFS-01s +// Vorschlagsmodell, damit das Workflow-Board (ueber seinen Modul-Adapter, +// 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. +package routingsuggestapi + +import ( + "encoding/json" + "net/http" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/historysuggest" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/svcauth" +) + +type Server struct { + serviceToken string +} + +func NewServer(serviceToken string) *Server { + return &Server{serviceToken: serviceToken} +} + +func (s *Server) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /workflow/routing-suggestions", s.requireAuth(s.handleSuggest)) +} + +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + got := r.Header.Get("X-Service-Token") + if !svcauth.Equal(got, s.serviceToken) { + writeError(w, http.StatusUnauthorized, "service-token fehlt oder ist ungueltig") + return + } + next(w, r) + } +} + +type historyEntryRequest struct { + Category string `json:"category"` +} + +type suggestRequest struct { + History []historyEntryRequest `json:"history"` +} + +// SuggestResponse ist der Antwortvertrag fuer das Workflow-Board (AK2: +// Vorschlag, Konfidenz und Begruendung). LowData meldet AK3 aus WFS-01 +// (zu geringe Datenlage) explizit statt einen leeren Vorschlag zu verstecken. +type SuggestResponse struct { + Category string `json:"category,omitempty"` + Confidence float64 `json:"confidence"` + SampleSize int `json:"sample_size"` + Reasoning string `json:"reasoning"` + LowData bool `json:"low_data"` +} + +func (s *Server) handleSuggest(w http.ResponseWriter, r *http.Request) { + var req suggestRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper") + return + } + + entries := make([]historysuggest.HistoryEntry, 0, len(req.History)) + for _, h := range req.History { + entries = append(entries, historysuggest.HistoryEntry{Category: h.Category}) + } + + result := historysuggest.Suggest(entries) + writeJSON(w, http.StatusOK, SuggestResponse{ + Category: result.Category, + Confidence: result.Confidence, + SampleSize: result.SampleSize, + Reasoning: result.Reasoning, + LowData: result.LowData, + }) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} diff --git a/ai/internal/routingsuggestapi/routingsuggestapi_test.go b/ai/internal/routingsuggestapi/routingsuggestapi_test.go new file mode 100644 index 00000000..58473879 --- /dev/null +++ b/ai/internal/routingsuggestapi/routingsuggestapi_test.go @@ -0,0 +1,127 @@ +package routingsuggestapi + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +const testToken = "test-service-token" + +func newTestServer() *httptest.Server { + mux := http.NewServeMux() + NewServer(testToken).RegisterRoutes(mux) + return httptest.NewServer(mux) +} + +// AK1 + Pruefung 1: Workflow-Board ruft die Schnittstelle ab und erhaelt +// einen Vorschlag. +func TestClientSuggest_AbrufAusWorkflowBoardSicht(t *testing.T) { + srv := newTestServer() + defer srv.Close() + + client := NewClient(srv.URL, testToken, nil) + history := []string{"rechnung", "rechnung", "rechnung", "mahnung", "rechnung", "rechnung"} + got, err := client.Suggest(context.Background(), history) + if err != nil { + t.Fatalf("Suggest: %v", err) + } + if got.Category != "rechnung" { + t.Errorf("Category = %q, want rechnung", got.Category) + } + if got.SampleSize != len(history) { + t.Errorf("SampleSize = %d, want %d", got.SampleSize, len(history)) + } +} + +// AK2: Antwort enthaelt Vorschlag, Konfidenz und Begruendung. +func TestHandleSuggest_AntwortEnthaeltKonfidenzUndBegruendung(t *testing.T) { + srv := newTestServer() + defer srv.Close() + + client := NewClient(srv.URL, testToken, nil) + history := []string{"a", "a", "a", "a", "b"} + got, err := client.Suggest(context.Background(), history) + if err != nil { + t.Fatalf("Suggest: %v", err) + } + if got.Confidence <= 0 || got.Confidence > 1 { + t.Errorf("Confidence = %v, ausserhalb (0,1]", got.Confidence) + } + if got.Reasoning == "" { + t.Error("Reasoning ist leer, erwartet Begruendung aus der Historie") + } +} + +// AK3 (WFS-01, hier durchgereicht): zu geringe Datenlage wird ueber die +// Schnittstelle gemeldet statt einen unbelastbaren Vorschlag zu liefern. +func TestHandleSuggest_GeringeDatenlageWirdGemeldet(t *testing.T) { + srv := newTestServer() + defer srv.Close() + + client := NewClient(srv.URL, testToken, nil) + got, err := client.Suggest(context.Background(), []string{"a", "a"}) + if err != nil { + t.Fatalf("Suggest: %v", err) + } + if !got.LowData { + t.Error("LowData = false, erwartet true bei zu wenigen Fällen") + } + if got.Category != "" { + t.Errorf("Category = %q, erwartet leer bei geringer Datenlage", got.Category) + } +} + +func TestHandleSuggest_OhneServiceToken(t *testing.T) { + srv := newTestServer() + defer srv.Close() + + resp, err := http.Post(srv.URL+"/workflow/routing-suggestions", "application/json", nil) + if err != nil { + t.Fatalf("Post: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("StatusCode = %d, want %d", resp.StatusCode, http.StatusUnauthorized) + } +} + +// AK3 (WFS-02) + Pruefung 2: eine nicht erreichbare AI-Instanz blockiert den +// Aufrufer nicht -- der Kontext-Timeout des Aufrufers greift, es haengt +// nichts unbegrenzt. +func TestClientSuggest_NichtErreichbareAIInstanzBlockiertNicht(t *testing.T) { + unreachable := "http://127.0.0.1:1" + + client := NewClient(unreachable, testToken, &http.Client{Timeout: 200 * time.Millisecond}) + start := time.Now() + _, err := client.Suggest(context.Background(), []string{"a"}) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("erwarteter Fehler bei nicht erreichbarer AI-Instanz blieb aus") + } + if elapsed > 2*time.Second { + t.Errorf("Aufruf hat %v gedauert, blockiert offenbar statt fehlzuschlagen", elapsed) + } +} + +func TestClientSuggest_KontextTimeoutGreift(t *testing.T) { + unreachable := "http://127.0.0.1:1" + + client := NewClient(unreachable, testToken, http.DefaultClient) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + start := time.Now() + _, err := client.Suggest(ctx, []string{"a"}) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("erwarteter Fehler durch Kontext-Timeout blieb aus") + } + if elapsed > 2*time.Second { + t.Errorf("Aufruf hat %v gedauert, Kontext-Timeout hat nicht gegriffen", elapsed) + } +}