WFS-02: Vorschlags-API (Kopplung Workflow-Board Modul-Adapter) #228

Merged
patrick merged 1 commits from feature/wfs-02-vorschlags-api-kopplung-workflow-board-modul-adapter into main 2026-09-28 00:10:26 +02:00
4 changed files with 350 additions and 0 deletions

No files matched your search

+74
View File
@@ -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.
+60
View File
@@ -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
}
@@ -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})
}
@@ -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)
}
}