WFS-02: Vorschlags-API (Kopplung Workflow-Board Modul-Adapter) #228
No files matched your search
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user