RTE-05: KI-gestützte Routing-Vorschläge (Kopplung AI WFS-02) #234
No files matched your search
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user