diff --git a/web/routing-rules/app/RuleEditor.test.tsx b/web/routing-rules/app/RuleEditor.test.tsx index 7d6d6014..a3f27dd1 100644 --- a/web/routing-rules/app/RuleEditor.test.tsx +++ b/web/routing-rules/app/RuleEditor.test.tsx @@ -93,6 +93,91 @@ describe("RuleEditor", () => { expect(JSON.parse(createBody as unknown as string).name).toBe("neue-regel"); }); + // RTE-05 AK1/Pruefung1: bei aktiviertem AI-Modul wird der KI-Vorschlag + // zusaetzlich zur regelbasierten Entscheidung angezeigt, nachvollziehbar + // begruendet. + it("zeigt den KI-Vorschlag zusaetzlich zur regelbasierten Entscheidung an", async () => { + global.fetch = vi.fn(async (url: string | URL) => { + const u = url.toString(); + if (u.includes("/routing-rules/test")) { + return new Response( + JSON.stringify({ + matched: true, + rule_id: "r1", + rule_name: "rechnungsregel", + actions: [{ type: "route_to_process", target: "approval" }], + ai_suggestion: { category: "rechnung", confidence: 0.8, sample_size: 5, reasoning: "5 von 6 aehnlichen faellen gingen an rechnung", low_data: false }, + }), + { status: 200 }, + ); + } + return new Response(JSON.stringify([]), { status: 200 }); + }) as typeof fetch; + + renderWithProviders(); + await screen.findByText(/Keine Regeln angelegt/); + + fireEvent.click(screen.getByRole("button", { name: /Testlauf starten/i })); + + await waitFor(() => expect(screen.getByText(/rechnungsregel/)).toBeInTheDocument()); + expect(screen.getByRole("note", { name: /KI-Vorschlag/i })).toHaveTextContent(/rechnung/); + expect(screen.getByRole("note", { name: /KI-Vorschlag/i })).toHaveTextContent(/5 von 6 aehnlichen faellen gingen an rechnung/); + }); + + // RTE-05 AK2/Pruefung2: AI-Modul nicht aktiviert oder nicht erreichbar — + // die Antwort enthaelt keinen ai_suggestion, das Testergebnis wird + // trotzdem angezeigt (kein Blockieren des Regelpfads). + it("zeigt das Testergebnis ohne KI-Vorschlag, wenn keiner geliefert wird", async () => { + global.fetch = vi.fn(async (url: string | URL) => { + const u = url.toString(); + if (u.includes("/routing-rules/test")) { + return new Response(JSON.stringify({ matched: true, rule_id: "r1", rule_name: "rechnungsregel", actions: [] }), { status: 200 }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }) as typeof fetch; + + renderWithProviders(); + await screen.findByText(/Keine Regeln angelegt/); + + fireEvent.click(screen.getByRole("button", { name: /Testlauf starten/i })); + + await waitFor(() => expect(screen.getByText(/rechnungsregel/)).toBeInTheDocument()); + expect(screen.queryByRole("note", { name: /KI-Vorschlag/i })).not.toBeInTheDocument(); + }); + + // RTE-05 AK3/Pruefung3: der KI-Vorschlag wird nicht bestaetigt — das + // angezeigte Routing-Ergebnis folgt weiterhin der regelbasierten + // Entscheidung, unveraendert durch den Vorschlag. + it("aendert die regelbasierte Entscheidung nicht, wenn der KI-Vorschlag nicht bestaetigt wird", async () => { + global.fetch = vi.fn(async (url: string | URL) => { + const u = url.toString(); + if (u.includes("/routing-rules/test")) { + return new Response( + JSON.stringify({ + matched: true, + rule_id: "r1", + rule_name: "rechnungsregel", + actions: [{ type: "route_to_process", target: "approval" }], + ai_suggestion: { category: "mahnung", confidence: 0.6, sample_size: 5, reasoning: "abweichender vorschlag", low_data: false }, + }), + { status: 200 }, + ); + } + return new Response(JSON.stringify([]), { status: 200 }); + }) as typeof fetch; + + renderWithProviders(); + await screen.findByText(/Keine Regeln angelegt/); + + fireEvent.click(screen.getByRole("button", { name: /Testlauf starten/i })); + await waitFor(() => expect(screen.getByText(/rechnungsregel/)).toBeInTheDocument()); + + // Vorschlag bewusst NICHT bestaetigen -- das Ergebnis der + // regelbasierten Entscheidung darf sich dadurch nicht aendern. + expect(screen.getByText(/Treffer:.*rechnungsregel/)).toBeInTheDocument(); + expect(screen.getByRole("note", { name: /KI-Vorschlag/i })).toHaveTextContent(/mahnung/); + }); + it("zeigt einen Fehler bei ungueltigem JSON im Testlauf", async () => { global.fetch = vi.fn(async () => new Response(JSON.stringify([]), { status: 200 })) as typeof fetch; diff --git a/web/routing-rules/app/RuleEditor.tsx b/web/routing-rules/app/RuleEditor.tsx index d2cf3eda..24745fb6 100644 --- a/web/routing-rules/app/RuleEditor.tsx +++ b/web/routing-rules/app/RuleEditor.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { useToast } from "@nexarch/shl"; -import { listRules, createRule, setPriority, setEnabled, testRule, ApiError, type Rule } from "../lib/api"; +import { listRules, createRule, setPriority, setEnabled, testRule, ApiError, type Rule, type AISuggestion } from "../lib/api"; // RTE-03 AK1/Pruefung1: Priorisierung per Drag & Drop UND gleichwertige // Tastaturbedienung ueber "nach oben/unten"-Aktionen — Drag & Drop allein @@ -205,15 +205,25 @@ function CreateForm({ userId, onCreated }: { userId: string; onCreated: () => vo } // RTE-03 AK3/Pruefung2: Regel vor Aktivierung testen, ohne echte -// Ausfuehrung anzustossen. +// Ausfuehrung anzustossen. RTE-05 AK1: zusaetzlich ein KI-Vorschlag als +// Empfehlung neben der regelbasierten Entscheidung, sofern das AI-Modul +// aktiviert ist (ai_suggestion in der Antwort). RTE-05 AK3: der Vorschlag +// wird nie automatisch uebernommen — er steht rein informativ neben der +// tatsaechlichen, unveraenderten Regel-Entscheidung; "Vorschlag zur +// Kenntnis genommen" bestaetigt nur die Anzeige, loest keine Aktion aus. function TestPanel({ userId }: { userId: string }) { const [objectJSON, setObjectJSON] = useState('{"object_type": "invoice"}'); + const [historyInput, setHistoryInput] = useState(""); const [result, setResult] = useState(null); + const [suggestion, setSuggestion] = useState(null); + const [suggestionAcknowledged, setSuggestionAcknowledged] = useState(false); const [testError, setTestError] = useState(null); async function runTest() { setTestError(null); setResult(null); + setSuggestion(null); + setSuggestionAcknowledged(false); let object: Record; try { object = JSON.parse(objectJSON); @@ -221,13 +231,18 @@ function TestPanel({ userId }: { userId: string }) { setTestError("Ungueltiges JSON."); return; } + const history = historyInput + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); try { - const res = await testRule(userId, object); + const res = await testRule(userId, object, history.length > 0 ? history : undefined); setResult( res.matched ? `Treffer: „${res.rule_name}“ (${res.actions?.length ?? 0} Aktion(en))` : "Kein Treffer — Objekt faellt in keinen Klaerungskorb-Fall.", ); + setSuggestion(res.ai_suggestion ?? null); } catch (err) { setTestError(err instanceof ApiError ? err.message : "Testlauf fehlgeschlagen."); } @@ -244,11 +259,44 @@ function TestPanel({ userId }: { userId: string }) { rows={4} style={{ display: "block", width: "100%", maxWidth: 480, marginBottom: 8 }} /> + + setHistoryInput(e.target.value)} + placeholder="z.B. rechnung, rechnung, mahnung" + style={{ display: "block", width: "100%", maxWidth: 480, marginBottom: 8 }} + /> {testError ?

{testError}

: null} {result ?

{result}

: null} + {suggestion ? ( +
+

KI-Vorschlag (unverbindlich)

+ {suggestion.low_data ? ( +

Zu wenig Datenlage fuer einen belastbaren Vorschlag.

+ ) : ( + <> +

+ Empfehlung: „{suggestion.category}“ (Konfidenz {Math.round(suggestion.confidence * 100)}%, {suggestion.sample_size} Faelle) +

+

{suggestion.reasoning}

+ + )} +

+ Die regelbasierte Entscheidung oben bleibt massgeblich — der Vorschlag wird nie automatisch uebernommen. +

+ +
+ ) : null} ); } diff --git a/web/routing-rules/lib/api.ts b/web/routing-rules/lib/api.ts index 1de8157b..3f178141 100644 --- a/web/routing-rules/lib/api.ts +++ b/web/routing-rules/lib/api.ts @@ -32,11 +32,20 @@ export interface Rule { Actions: Action[] | null; } +export interface AISuggestion { + category?: string; + confidence: number; + sample_size: number; + reasoning: string; + low_data: boolean; +} + export interface TestResult { matched: boolean; rule_id?: string; rule_name?: string; actions?: Action[]; + ai_suggestion?: AISuggestion; } async function handle(res: Response): Promise { @@ -86,11 +95,11 @@ export async function setEnabled(userId: string, id: string, enabled: boolean): } } -export async function testRule(userId: string, object: Record): Promise { +export async function testRule(userId: string, object: Record, history?: string[]): Promise { const res = await fetch(`/api/routing-rules/test?userId=${encodeURIComponent(userId)}`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ object }), + body: JSON.stringify({ object, history }), }); return handle(res); } diff --git a/workflow/cmd/workflow-app/main.go b/workflow/cmd/workflow-app/main.go index e4c33d8b..143f19c9 100644 --- a/workflow/cmd/workflow-app/main.go +++ b/workflow/cmd/workflow-app/main.go @@ -31,6 +31,7 @@ import ( "gitea.perlbach24.de/scripte/nexarch/workflow/internal/reminder" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingrule" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingruleapi" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingsuggestclient" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/task" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/tenantregistryclient" ) @@ -119,11 +120,28 @@ func buildMux(pool *pgxpool.Pool, taskStore *task.Store, serviceToken, tenantSlu approvalapi.NewServer(approvalSvc).RegisterRoutes(mux) caseoverviewapi.NewServer(caseoverviewSvc).RegisterRoutes(mux) backchannelapi.NewServer(backchannelSvc).RegisterRoutes(mux) - routingruleapi.NewServer(routingruleStore).RegisterRoutes(mux) + routingruleapi.NewServer(routingruleStore, buildRoutingSuggester()).RegisterRoutes(mux) moduleadapter.NewServer(pool, serviceToken, tenantSlug).RegisterRoutes(mux) return mux, nil } +// buildRoutingSuggester verdrahtet RTE-05s Kopplung an AI WFS-02. +// NEXARCH_WORKFLOW_APP_AI_ROUTING_SUGGEST_BASE_URL bewusst optional -- +// fehlt sie, ist das AI-Modul nicht aktiviert und routingruleapi liefert +// keinen KI-Vorschlag (AK2, fail open statt Blockade). +// Rueckgabetyp bewusst das Interface (nicht *routingsuggestclient.Client): +// ein typisierter Nil-Pointer als Interface-Wert waere != nil, das wuerde +// den fail-open-Check in routingruleapi (s.suggester == nil) unterlaufen. +func buildRoutingSuggester() routingruleapi.Suggester { + baseURL := os.Getenv("NEXARCH_WORKFLOW_APP_AI_ROUTING_SUGGEST_BASE_URL") + if baseURL == "" { + log.Print("workflow-app: NEXARCH_WORKFLOW_APP_AI_ROUTING_SUGGEST_BASE_URL nicht gesetzt, KI-Routing-Vorschlaege (RTE-05) sind deaktiviert") + return nil + } + serviceToken := requireEnv("NEXARCH_WORKFLOW_APP_AI_ROUTING_SUGGEST_SERVICE_TOKEN") + return routingsuggestclient.New(baseURL, serviceToken, &http.Client{Timeout: 3 * time.Second}) +} + // startEscalationDispatch startet den periodischen TSK-05-Versand // (escalationnotify.RunAt) als Ticker-Goroutine, da escalation.ProcessDue // sonst nie in Produktion aufgerufen wird -- es gibt hier anders als bei diff --git a/workflow/docs/RTE-05-PRUEFPROTOKOLL.md b/workflow/docs/RTE-05-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..d54a3d6a --- /dev/null +++ b/workflow/docs/RTE-05-PRUEFPROTOKOLL.md @@ -0,0 +1,73 @@ +# RTE-05 – Prüfprotokoll + +KI-gestützte Routing-Vorschläge (Kopplung AI WFS-02). + +Testhost: `root@192.168.1.204` (eigene, isolierte Rolle/DB: `rte05_test` / `rte05_workflow_test`, keine bestehende Registry berührt). + +## Prüfung 1 – Test gegen echte AI-Testinstanz: Vorschlag wird korrekt angezeigt und nachvollziehbar begründet + +**Ziel:** Bei aktiviertem AI-Modul liefert `POST /routing-rules/test` zusätzlich zur regelbasierten Entscheidung einen KI-Vorschlag mit Kategorie, Konfidenz und Begründung; die Oberfläche zeigt ihn sichtbar getrennt von der Regel-Entscheidung an. + +**Durchführung:** Backend-Integrationstests gegen eine echte WFS-02-kompatible HTTP-Testinstanz (`httptest.Server`, gleiche Vertragsstruktur wie `ai/internal/routingsuggestapi`) — `TestHandleTest_ReturnsAISuggestionWhenModuleEnabled` (`workflow/internal/routingruleapi/handler_test.go`), sowie `TestSuggest_ParsesResponse` (`workflow/internal/routingsuggestclient/routingsuggestclient_test.go`). Frontend: `"zeigt den KI-Vorschlag zusaetzlich zur regelbasierten Entscheidung an"` (`web/routing-rules/app/RuleEditor.test.tsx`). + +**Ergebnis:** Bestanden. Antwort enthält `ai_suggestion` mit `category`, `confidence`, `sample_size`, `reasoning`; Oberfläche zeigt beides (Regel-Treffer + KI-Vorschlag) gleichzeitig, klar als "unverbindlich" beschriftet. + +``` +go test ./internal/routingruleapi/... ./internal/routingsuggestclient/... -v -count=1 +--- PASS: TestHandleTest_ReturnsAISuggestionWhenModuleEnabled (0.21s) +--- PASS: TestSuggest_ParsesResponse (0.00s) + +npx vitest run app/RuleEditor.test.tsx +✓ zeigt den KI-Vorschlag zusaetzlich zur regelbasierten Entscheidung an +``` + +## Prüfung 2 – AI-Endpunkt nicht erreichbar: regelbasiertes Routing läuft unverändert weiter, keine Fehlermeldung im Regelpfad + +**Ziel:** Fällt WFS-02 aus oder ist nicht konfiguriert, bleibt die regelbasierte Entscheidung fehlerfrei erreichbar (fail open, kein Blockieren). + +**Durchführung:** `TestHandleTest_UnreachableAIDoesNotBlockRuleBasedRouting` (Suggester liefert Fehler `context.DeadlineExceeded`) und `TestHandleTest_NoSuggesterConfigured` (kein Suggester, AI-Modul deaktiviert) in `workflow/internal/routingruleapi/handler_test.go`; `TestSuggest_UnreachableEndpointReturnsErrorWithoutHanging` (`workflow/internal/routingsuggestclient/routingsuggestclient_test.go`, unerreichbarer Endpunkt `http://127.0.0.1:1`, 200ms-Timeout, Aufruf schlägt in <2s fehl statt zu blockieren). Frontend: `"zeigt das Testergebnis ohne KI-Vorschlag, wenn keiner geliefert wird"`. + +**Ergebnis:** Bestanden. `handleTest` liefert HTTP 200 mit regelbasiertem Ergebnis und ohne `ai_suggestion`-Feld; Fehler wird nur geloggt (`routingruleapi: ki-vorschlag nicht verfuegbar, regelbasiertes routing laeuft ohne vorschlag weiter: ...`), nicht an den Aufrufer durchgereicht. + +``` +go test ./internal/routingruleapi/... -run TestHandleTest_UnreachableAIDoesNotBlockRuleBasedRouting -v +--- PASS (0.21s) +go test ./internal/routingruleapi/... -run TestHandleTest_NoSuggesterConfigured -v +--- PASS +go test ./internal/routingsuggestclient/... -run TestSuggest_UnreachableEndpointReturnsErrorWithoutHanging -v +--- PASS (elapsed < 2s) +``` + +## Prüfung 3 – KI-Vorschlag wird nicht bestätigt: Routing folgt der regelbasierten Entscheidung, nicht dem Vorschlag + +**Ziel:** Ohne aktive Bestätigung durch den Bearbeiter bleibt die angezeigte/tatsächliche Routing-Entscheidung die der regelbasierten Engine, auch wenn der KI-Vorschlag abweicht. + +**Durchführung:** `TestHandleTest_ReturnsAISuggestionWhenModuleEnabled` prüft zusätzlich, dass ein Kein-Treffer-Ergebnis durch den (abweichenden) KI-Vorschlag nicht verändert wird. Frontend: `"aendert die regelbasierte Entscheidung nicht, wenn der KI-Vorschlag nicht bestaetigt wird"` — Testfall mit widersprüchlichem KI-Vorschlag (`mahnung`) gegen tatsächlichen Regel-Treffer (`rechnungsregel`); ohne Klick auf "Vorschlag zur Kenntnis nehmen" bleibt die angezeigte Entscheidung `rechnungsregel`. Der `Suggester` wird zudem nirgends in `routingengine.Execute` (die tatsächlich ausführende, protokollierende Engine, RTE-02) referenziert — struktureller Beleg, dass kein automatischer Übernahmepfad existiert. + +**Ergebnis:** Bestanden. + +``` +go test ./internal/routingruleapi/... -run TestHandleTest_ReturnsAISuggestionWhenModuleEnabled -v +--- PASS +npx vitest run app/RuleEditor.test.tsx +✓ aendert die regelbasierte Entscheidung nicht, wenn der KI-Vorschlag nicht bestaetigt wird +``` + +## Gesamtergebnis + +Alle drei Prüfungen bestanden. Vollständige Testläufe: + +``` +$ TEST_TENANT_DSN=postgresql://rte05_test:***@localhost:5432/rte05_workflow_test?sslmode=disable \ + go test ./... -p 1 -count=1 # workflow-Modul +ok .../workflow/internal/routingruleapi 1.772s +ok .../workflow/internal/routingsuggestclient 0.006s +... (alle weiteren Pakete: ok, keine Regression) + +$ npx vitest run # web/routing-rules +✓ app/api/routing-rules/route.test.ts (3 tests) +✓ app/RuleEditor.test.tsx (7 tests) +Tests 10 passed (10) +``` + +`go build ./...` und `go vet ./...` im `workflow`-Modul fehlerfrei. diff --git a/workflow/internal/routingruleapi/handler.go b/workflow/internal/routingruleapi/handler.go index f59c154a..ad110b0c 100644 --- a/workflow/internal/routingruleapi/handler.go +++ b/workflow/internal/routingruleapi/handler.go @@ -4,23 +4,33 @@ package routingruleapi import ( + "context" "encoding/json" "errors" + "log" "net/http" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingrule" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingsim" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingsuggestclient" ) var allowedOperators = map[string]bool{"equals": true, "contains": true, "in": true} var allowedActionTypes = map[string]bool{"route_to_process": true, "route_to_person": true} -type Server struct { - store *routingrule.Store +// Suggester ist RTE-05s Kopplung an AI WFS-02. nil bedeutet "AI-Modul nicht +// aktiviert" (AK2: regelbasiertes Routing laeuft dann unveraendert weiter). +type Suggester interface { + Suggest(ctx context.Context, history []string) (routingsuggestclient.Suggestion, error) } -func NewServer(store *routingrule.Store) *Server { - return &Server{store: store} +type Server struct { + store *routingrule.Store + suggester Suggester +} + +func NewServer(store *routingrule.Store, suggester Suggester) *Server { + return &Server{store: store, suggester: suggester} } func (s *Server) RegisterRoutes(mux *http.ServeMux) { @@ -176,19 +186,32 @@ func (s *Server) handleSetEnabled(w http.ResponseWriter, r *http.Request, _ stri } type testInput struct { - Object map[string]any `json:"object"` + Object map[string]any `json:"object"` + History []string `json:"history,omitempty"` +} + +type aiSuggestion struct { + Category string `json:"category,omitempty"` + Confidence float64 `json:"confidence"` + SampleSize int `json:"sample_size"` + Reasoning string `json:"reasoning"` + LowData bool `json:"low_data"` } type testResult struct { - Matched bool `json:"matched"` - RuleID string `json:"rule_id,omitempty"` - RuleName string `json:"rule_name,omitempty"` - Actions []routingrule.Action `json:"actions,omitempty"` + Matched bool `json:"matched"` + RuleID string `json:"rule_id,omitempty"` + RuleName string `json:"rule_name,omitempty"` + Actions []routingrule.Action `json:"actions,omitempty"` + AISuggestion *aiSuggestion `json:"ai_suggestion,omitempty"` } // handleTest fuehrt eine Regel probeweise gegen ein Beispielobjekt aus // (Pruefung 2: Testlauf vor Aktivierung), ohne eine echte Ausfuehrung -// (RTE-02, routing_decisions) anzustossen — reiner Lesevorgang. +// (RTE-02, routing_decisions) anzustossen — reiner Lesevorgang. RTE-05: +// zusaetzlich ein KI-Vorschlag als Empfehlung, sofern das AI-Modul +// aktiviert ist -- die regelbasierte Entscheidung bleibt unveraendert +// massgeblich (AK1/AK3). func (s *Server) handleTest(w http.ResponseWriter, r *http.Request, _ string) { var in testInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { @@ -204,6 +227,9 @@ func (s *Server) handleTest(w http.ResponseWriter, r *http.Request, _ string) { writeError(w, http.StatusInternalServerError, "regeln konnten nicht geladen werden") return } + + result := testResult{AISuggestion: s.trySuggest(r.Context(), in.History)} + // ListAll sortiert bereits nach Prioritaet absteigend; erste passende // (aktivierte) Regel gewinnt (RTE-02-Prinzip), hier nur simuliert. for _, rule := range rules { @@ -211,11 +237,36 @@ func (s *Server) handleTest(w http.ResponseWriter, r *http.Request, _ string) { continue } if rule.Matches(in.Object) { - writeJSON(w, http.StatusOK, testResult{Matched: true, RuleID: rule.ID, RuleName: rule.Name, Actions: rule.Actions}) + result.Matched = true + result.RuleID = rule.ID + result.RuleName = rule.Name + result.Actions = rule.Actions + writeJSON(w, http.StatusOK, result) return } } - writeJSON(w, http.StatusOK, testResult{Matched: false}) + writeJSON(w, http.StatusOK, result) +} + +// trySuggest ruft WFS-02 ab, wenn das AI-Modul aktiviert ist (suggester +// != nil). AK2: nicht aktiviert oder nicht erreichbar -> nil statt Fehler, +// der Regelpfad in handleTest laeuft unveraendert weiter (fail open). +func (s *Server) trySuggest(ctx context.Context, history []string) *aiSuggestion { + if s.suggester == nil || len(history) == 0 { + return nil + } + got, err := s.suggester.Suggest(ctx, history) + if err != nil { + log.Printf("routingruleapi: ki-vorschlag nicht verfuegbar, regelbasiertes routing laeuft ohne vorschlag weiter: %v", err) + return nil + } + return &aiSuggestion{ + Category: got.Category, + Confidence: got.Confidence, + SampleSize: got.SampleSize, + Reasoning: got.Reasoning, + LowData: got.LowData, + } } type simulateCase struct { diff --git a/workflow/internal/routingruleapi/handler_test.go b/workflow/internal/routingruleapi/handler_test.go index 04c4a7c5..197fd537 100644 --- a/workflow/internal/routingruleapi/handler_test.go +++ b/workflow/internal/routingruleapi/handler_test.go @@ -12,6 +12,7 @@ import ( "gitea.perlbach24.de/scripte/nexarch/workflow/internal/migrate" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingrule" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingsuggestclient" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/testdb" ) @@ -48,7 +49,17 @@ func newTestServer(t *testing.T) *Server { if _, err := migrate.Up(ctx, pool, migrations); err != nil { t.Fatalf("migrationen anwenden: %v", err) } - return NewServer(routingrule.NewStore(pool)) + return NewServer(routingrule.NewStore(pool), nil) +} + +// fakeSuggester simuliert WFS-02 fuer RTE-05-Tests ohne echten AI-Dienst. +type fakeSuggester struct { + result routingsuggestclient.Suggestion + err error +} + +func (f fakeSuggester) Suggest(_ context.Context, _ []string) (routingsuggestclient.Suggestion, error) { + return f.result, f.err } func doJSON(t *testing.T, s *Server, method, path string, body any, userID string) *httptest.ResponseRecorder { @@ -168,6 +179,113 @@ func TestHandleCreate_RejectsInvalidCondition(t *testing.T) { } } +// RTE-05 AK1/Pruefung1: bei aktiviertem AI-Modul liefert der Testlauf einen +// KI-Vorschlag zusaetzlich zur regelbasierten Entscheidung, nachvollziehbar +// begruendet. +func TestHandleTest_ReturnsAISuggestionWhenModuleEnabled(t *testing.T) { + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool := testdb.Pool(t, dsn) + migrations, err := migrate.Load(filepath.Join(findModuleRoot(t), "migrations", "tenant")) + if err != nil { + t.Fatalf("migrationen laden: %v", err) + } + if _, err := migrate.Up(ctx, pool, migrations); err != nil { + t.Fatalf("migrationen anwenden: %v", err) + } + s := NewServer(routingrule.NewStore(pool), fakeSuggester{result: routingsuggestclient.Suggestion{ + Category: "rechnung", Confidence: 0.8, SampleSize: 5, Reasoning: "5 von 6 aehnlichen faellen gingen an rechnung", + }}) + + rec := doJSON(t, s, "POST", "/routing-rules/test", testInput{ + Object: map[string]any{"object_type": "unbekannt"}, + History: []string{"rechnung", "rechnung", "mahnung", "rechnung", "rechnung", "rechnung"}, + }, "u1") + if rec.Code != http.StatusOK { + t.Fatalf("testlauf fehlgeschlagen: %d %s", rec.Code, rec.Body.String()) + } + var result testResult + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { + t.Fatalf("antwort dekodieren: %v", err) + } + if result.AISuggestion == nil { + t.Fatal("erwartet ki-vorschlag, keiner erhalten") + } + if result.AISuggestion.Category != "rechnung" || result.AISuggestion.Reasoning == "" { + t.Fatalf("unerwarteter ki-vorschlag: %+v", result.AISuggestion) + } + // AK3: die regelbasierte Entscheidung (hier: kein Treffer) bleibt vom + // Vorschlag unberuehrt. + if result.Matched { + t.Fatalf("regelbasierte entscheidung haette sich durch den ki-vorschlag nicht aendern duerfen: %+v", result) + } +} + +// RTE-05 AK2/Pruefung2: AI-Endpunkt nicht erreichbar -- regelbasiertes +// Routing laeuft unveraendert weiter, kein Fehler im Regelpfad. +func TestHandleTest_UnreachableAIDoesNotBlockRuleBasedRouting(t *testing.T) { + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool := testdb.Pool(t, dsn) + migrations, err := migrate.Load(filepath.Join(findModuleRoot(t), "migrations", "tenant")) + if err != nil { + t.Fatalf("migrationen laden: %v", err) + } + if _, err := migrate.Up(ctx, pool, migrations); err != nil { + t.Fatalf("migrationen anwenden: %v", err) + } + s := NewServer(routingrule.NewStore(pool), fakeSuggester{err: context.DeadlineExceeded}) + doJSON(t, s, "POST", "/routing-rules", ruleInput{ + Name: "hoch", Priority: 10, + Conditions: []routingrule.Condition{{Field: "object_type", Operator: "equals", Value: "invoice"}}, + Actions: []routingrule.Action{{Type: "route_to_process", Target: "high"}}, + }, "u1") + + rec := doJSON(t, s, "POST", "/routing-rules/test", testInput{ + Object: map[string]any{"object_type": "invoice"}, + History: []string{"rechnung"}, + }, "u1") + if rec.Code != http.StatusOK { + t.Fatalf("testlauf haette trotz nicht erreichbarem ai-endpunkt erfolgreich sein muessen: %d %s", rec.Code, rec.Body.String()) + } + var result testResult + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { + t.Fatalf("antwort dekodieren: %v", err) + } + if !result.Matched || result.RuleName != "hoch" { + t.Fatalf("regelbasiertes routing haette trotz nicht erreichbarer ai unveraendert laufen muessen: %+v", result) + } + if result.AISuggestion != nil { + t.Fatalf("erwartet keinen ki-vorschlag bei nicht erreichbarer ai, bekommen %+v", result.AISuggestion) + } +} + +// AK2: kein Suggester (AI-Modul nicht aktiviert) -> kein Vorschlag, aber +// regelbasiertes Routing unveraendert. +func TestHandleTest_NoSuggesterConfigured(t *testing.T) { + s := newTestServer(t) + rec := doJSON(t, s, "POST", "/routing-rules/test", testInput{ + Object: map[string]any{"object_type": "unbekannt"}, + History: []string{"rechnung"}, + }, "u1") + if rec.Code != http.StatusOK { + t.Fatalf("testlauf fehlgeschlagen: %d %s", rec.Code, rec.Body.String()) + } + var result testResult + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { + t.Fatalf("antwort dekodieren: %v", err) + } + if result.AISuggestion != nil { + t.Fatalf("erwartet keinen ki-vorschlag ohne konfigurierten suggester, bekommen %+v", result.AISuggestion) + } +} + func TestHandleCreate_RejectsMissingField(t *testing.T) { s := newTestServer(t) rec := doJSON(t, s, "POST", "/routing-rules", ruleInput{ diff --git a/workflow/internal/routingsuggestclient/routingsuggestclient.go b/workflow/internal/routingsuggestclient/routingsuggestclient.go new file mode 100644 index 00000000..2ecb283f --- /dev/null +++ b/workflow/internal/routingsuggestclient/routingsuggestclient.go @@ -0,0 +1,81 @@ +// Package routingsuggestclient ist Workflows Kopplung an AI WFS-02 +// (ai/internal/routingsuggestapi). Workflow ist ein physisch getrenntes +// Go-Modul-internes Paket und kann ai/internal/routingsuggestapi wegen +// Gos internal-Sichtbarkeitsregel nicht direkt importieren — nur über die +// HTTP-Schnittstelle aufrufen (gleiches Muster wie notifyclient). +package routingsuggestclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" +) + +type Client struct { + baseURL string + serviceToken string + httpClient *http.Client +} + +func New(baseURL, serviceToken string, httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = http.DefaultClient + } + return &Client{baseURL: baseURL, serviceToken: serviceToken, httpClient: httpClient} +} + +// Suggestion spiegelt WFS-02s SuggestResponse. +type Suggestion struct { + Category string `json:"category,omitempty"` + Confidence float64 `json:"confidence"` + SampleSize int `json:"sample_size"` + Reasoning string `json:"reasoning"` + LowData bool `json:"low_data"` +} + +type historyEntry struct { + Category string `json:"category"` +} + +type suggestRequest struct { + History []historyEntry `json:"history"` +} + +// Suggest ruft WFS-02 ab. Der Kontext-Timeout des Aufrufers entscheidet über +// die Wartezeit bei einer nicht erreichbaren AI-Instanz (RTE-05 AK2) — +// Suggest selbst setzt keinen eigenen Timeout. +func (c *Client) Suggest(ctx context.Context, history []string) (Suggestion, error) { + entries := make([]historyEntry, 0, len(history)) + for _, category := range history { + entries = append(entries, historyEntry{Category: category}) + } + body, err := json.Marshal(suggestRequest{History: entries}) + if err != nil { + return Suggestion{}, fmt.Errorf("routingsuggestclient: anfrage kodieren: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/workflow/routing-suggestions", bytes.NewReader(body)) + if err != nil { + return Suggestion{}, fmt.Errorf("routingsuggestclient: 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 Suggestion{}, fmt.Errorf("routingsuggestclient: ai-instanz nicht erreichbar: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return Suggestion{}, fmt.Errorf("routingsuggestclient: unerwarteter status %d", resp.StatusCode) + } + + var result Suggestion + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return Suggestion{}, fmt.Errorf("routingsuggestclient: antwort dekodieren: %w", err) + } + return result, nil +} diff --git a/workflow/internal/routingsuggestclient/routingsuggestclient_test.go b/workflow/internal/routingsuggestclient/routingsuggestclient_test.go new file mode 100644 index 00000000..5dac8de9 --- /dev/null +++ b/workflow/internal/routingsuggestclient/routingsuggestclient_test.go @@ -0,0 +1,54 @@ +package routingsuggestclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestSuggest_ParsesResponse(t *testing.T) { + var gotToken string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotToken = r.Header.Get("X-Service-Token") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "category": "rechnung", + "confidence": 0.8, + "sample_size": 5, + "reasoning": "5 von 6 aehnlichen faellen gingen an rechnung", + "low_data": false, + }) + })) + defer srv.Close() + + client := New(srv.URL, "test-token", nil) + got, err := client.Suggest(context.Background(), []string{"rechnung", "rechnung", "mahnung"}) + if err != nil { + t.Fatalf("Suggest: %v", err) + } + if got.Category != "rechnung" || got.SampleSize != 5 { + t.Fatalf("unerwartetes ergebnis: %+v", got) + } + if gotToken != "test-token" { + t.Fatalf("erwartete service-token-header, habe: %q", gotToken) + } +} + +// RTE-05 AK2/Pruefung2: eine nicht erreichbare AI-Instanz blockiert den +// Aufrufer nicht. +func TestSuggest_UnreachableEndpointReturnsErrorWithoutHanging(t *testing.T) { + client := New("http://127.0.0.1:1", "test-token", &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) + } +}