RTE-05: KI-gestützte Routing-Vorschläge (Kopplung AI WFS-02) #234

Merged
patrick merged 1 commits from feature/rte-05-ki-gestützte-routing-vorschläge-kopplung-ai-wfs-02 into main 2026-09-28 01:52:56 +02:00
9 changed files with 556 additions and 19 deletions

No files matched your search

+85
View File
@@ -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(<RuleEditor userId="alice" />);
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(<RuleEditor userId="alice" />);
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(<RuleEditor userId="alice" />);
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;
+51 -3
View File
@@ -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<string | null>(null);
const [suggestion, setSuggestion] = useState<AISuggestion | null>(null);
const [suggestionAcknowledged, setSuggestionAcknowledged] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
async function runTest() {
setTestError(null);
setResult(null);
setSuggestion(null);
setSuggestionAcknowledged(false);
let object: Record<string, unknown>;
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 }}
/>
<label htmlFor="test-history">Bisherige Zuordnungen (optional, kommagetrennt)</label>
<input
id="test-history"
value={historyInput}
onChange={(e) => setHistoryInput(e.target.value)}
placeholder="z.B. rechnung, rechnung, mahnung"
style={{ display: "block", width: "100%", maxWidth: 480, marginBottom: 8 }}
/>
<button type="button" onClick={() => void runTest()}>
Testlauf starten
</button>
{testError ? <p role="alert">{testError}</p> : null}
{result ? <p role="status">{result}</p> : null}
{suggestion ? (
<div
role="note"
aria-label="KI-Vorschlag"
style={{ marginTop: 8, padding: 8, border: "1px dashed var(--shl-color-border, #d0d5dd)", borderRadius: 6, maxWidth: 480 }}
>
<p style={{ margin: "0 0 4px", fontWeight: 600 }}>KI-Vorschlag (unverbindlich)</p>
{suggestion.low_data ? (
<p style={{ margin: 0 }}>Zu wenig Datenlage fuer einen belastbaren Vorschlag.</p>
) : (
<>
<p style={{ margin: 0 }}>
Empfehlung: „{suggestion.category}“ (Konfidenz {Math.round(suggestion.confidence * 100)}%, {suggestion.sample_size} Faelle)
</p>
<p style={{ margin: "4px 0" }}>{suggestion.reasoning}</p>
</>
)}
<p style={{ margin: "4px 0", color: "var(--shl-color-text-secondary, #475467)" }}>
Die regelbasierte Entscheidung oben bleibt massgeblich — der Vorschlag wird nie automatisch uebernommen.
</p>
<button type="button" onClick={() => setSuggestionAcknowledged(true)} disabled={suggestionAcknowledged}>
{suggestionAcknowledged ? "Zur Kenntnis genommen" : "Vorschlag zur Kenntnis nehmen"}
</button>
</div>
) : null}
</section>
);
}
+11 -2
View File
@@ -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<T>(res: Response): Promise<T> {
@@ -86,11 +95,11 @@ export async function setEnabled(userId: string, id: string, enabled: boolean):
}
}
export async function testRule(userId: string, object: Record<string, unknown>): Promise<TestResult> {
export async function testRule(userId: string, object: Record<string, unknown>, history?: string[]): Promise<TestResult> {
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<TestResult>(res);
}
+19 -1
View File
@@ -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
+73
View File
@@ -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.
+63 -12
View File
@@ -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 {
@@ -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{
@@ -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
}
@@ -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)
}
}