Files
patrickandClaude Sonnet 5.5 eaffcaf885
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Failing after 1m6s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 3s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 23s
CI / Web (dms-browser) (push) Failing after 1m31s
CI / Web (pdf-viewer) (push) Failing after 1m3s
CI / Web (shl) (push) Failing after 14s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 4m11s
Workflow-Schnittstellen-Vertragstests / contract-tests (push) Failing after 41s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 7s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 8s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 30s
CI / Web (dms-browser) (pull_request) Failing after 1m24s
CI / Web (pdf-viewer) (pull_request) Failing after 57s
CI / Web (shl) (pull_request) Failing after 11s
CI / Core-Modul (Build/Vet/Test) (pull_request) Failing after 11m29s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 10m30s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 3m26s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 1m57s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 10m25s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 9m47s
Workflow-Schnittstellen-Vertragstests / contract-tests (pull_request) Failing after 57s
Workflow-Pflichttest-Gate / pflichttest-gate (pull_request) Failing after 1m13s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 3s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Failing after 3s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s
workflow: svcauth, tenantregistryclient aus common v0.1.0
Das Pflichttest-Gate von workflow verweist nicht mehr auf die geloeschten
Pakete (common hat ein eigenes Gate).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 23:33:55 +01:00

337 lines
11 KiB
Go

// Package routingruleapi ist die HTTP-Bindung fuer den Routing-Regel-Editor
// (RTE-03). Nutzeridentitaet uebergangsweise ueber X-User-Id, gleiches
// Muster wie approvalapi (APR-01) vor der Core-JWT-Anbindung.
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}
// 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)
}
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) {
mux.HandleFunc("GET /routing-rules", s.requireUser(s.handleList))
mux.HandleFunc("POST /routing-rules", s.requireUser(s.handleCreate))
mux.HandleFunc("PUT /routing-rules/{id}", s.requireUser(s.handleUpdate))
mux.HandleFunc("PUT /routing-rules/{id}/priority", s.requireUser(s.handleSetPriority))
mux.HandleFunc("PUT /routing-rules/{id}/enabled", s.requireUser(s.handleSetEnabled))
mux.HandleFunc("POST /routing-rules/test", s.requireUser(s.handleTest))
mux.HandleFunc("POST /routing-rules/simulate", s.requireUser(s.handleSimulate))
}
func (s *Server) requireUser(next func(w http.ResponseWriter, r *http.Request, userID string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-Id")
if userID == "" {
writeError(w, http.StatusUnauthorized, "X-User-Id fehlt")
return
}
next(w, r, userID)
}
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request, _ string) {
rules, err := s.store.ListAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "regeln konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, rules)
}
type ruleInput struct {
Name string `json:"name"`
Priority int `json:"priority"`
Conditions []routingrule.Condition `json:"conditions"`
Actions []routingrule.Action `json:"actions"`
}
// validate lehnt strukturell ungueltige Bedingungen/Aktionen ab, bevor sie
// gespeichert werden (Pruefung 3: ungueltige Bedingungseingabe wird
// abgewiesen). Der Store selbst validiert das nicht (RTE-01-Scope), daher
// hier auf API-Ebene statt den bestehenden Store-Code zu veraendern.
func validate(in ruleInput) error {
if in.Name == "" {
return errors.New("name ist pflicht")
}
for _, c := range in.Conditions {
if c.Field == "" {
return errors.New("bedingung: feld ist pflicht")
}
if !allowedOperators[c.Operator] {
return errors.New("bedingung: unbekannter operator " + c.Operator)
}
if c.Value == nil {
return errors.New("bedingung: wert ist pflicht")
}
}
for _, a := range in.Actions {
if !allowedActionTypes[a.Type] {
return errors.New("aktion: unbekannter typ " + a.Type)
}
if a.Target == "" {
return errors.New("aktion: ziel ist pflicht")
}
}
return nil
}
func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request, _ string) {
var in ruleInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "ungueltige anfrage")
return
}
if err := validate(in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
rule, err := s.store.Create(r.Context(), in.Name, in.Priority, in.Conditions, in.Actions)
if err != nil {
writeError(w, http.StatusInternalServerError, "regel konnte nicht angelegt werden")
return
}
writeJSON(w, http.StatusCreated, rule)
}
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request, _ string) {
id := r.PathValue("id")
var in ruleInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "ungueltige anfrage")
return
}
if err := validate(in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
rule, err := s.store.Update(r.Context(), id, in.Conditions, in.Actions)
if err != nil {
if errors.Is(err, routingrule.ErrNotFound) {
writeError(w, http.StatusNotFound, "regel nicht gefunden")
return
}
writeError(w, http.StatusInternalServerError, "regel konnte nicht aktualisiert werden")
return
}
writeJSON(w, http.StatusOK, rule)
}
type priorityInput struct {
Priority int `json:"priority"`
}
func (s *Server) handleSetPriority(w http.ResponseWriter, r *http.Request, _ string) {
id := r.PathValue("id")
var in priorityInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "ungueltige anfrage")
return
}
if err := s.store.SetPriority(r.Context(), id, in.Priority); err != nil {
if errors.Is(err, routingrule.ErrNotFound) {
writeError(w, http.StatusNotFound, "regel nicht gefunden")
return
}
writeError(w, http.StatusInternalServerError, "prioritaet konnte nicht gesetzt werden")
return
}
w.WriteHeader(http.StatusNoContent)
}
type enabledInput struct {
Enabled bool `json:"enabled"`
}
func (s *Server) handleSetEnabled(w http.ResponseWriter, r *http.Request, _ string) {
id := r.PathValue("id")
var in enabledInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "ungueltige anfrage")
return
}
if err := s.store.SetEnabled(r.Context(), id, in.Enabled); err != nil {
if errors.Is(err, routingrule.ErrNotFound) {
writeError(w, http.StatusNotFound, "regel nicht gefunden")
return
}
writeError(w, http.StatusInternalServerError, "status konnte nicht gesetzt werden")
return
}
w.WriteHeader(http.StatusNoContent)
}
type testInput struct {
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"`
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-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 {
writeError(w, http.StatusBadRequest, "ungueltige anfrage")
return
}
if in.Object == nil {
writeError(w, http.StatusBadRequest, "object ist pflicht")
return
}
rules, err := s.store.ListAll(r.Context())
if err != nil {
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 {
if !rule.Enabled {
continue
}
if rule.Matches(in.Object) {
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, 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 {
Label string `json:"label"`
Object map[string]any `json:"object"`
ExpectedRuleID string `json:"expected_rule_id,omitempty"`
}
type simulateInput struct {
Corpus []simulateCase `json:"corpus"`
}
type simulateResult struct {
Label string `json:"label"`
ExpectedRuleID string `json:"expected_rule_id,omitempty"`
ActualRuleID string `json:"actual_rule_id,omitempty"`
ActualRuleName string `json:"actual_rule_name,omitempty"`
Actions []routingrule.Action `json:"actions,omitempty"`
Matched bool `json:"matched"`
AsExpected bool `json:"as_expected"`
}
// handleSimulate ist RTE-04: das Regelwerk gegen einen ganzen Testkorpus
// simulieren (nicht nur ein Objekt wie handleTest), mit Abgleich erwartete
// gegen tatsaechliche Zuordnung. Reiner Lesevorgang ueber routingsim.Simulate
// (kein routing_decisions-/Klaerungskorb-Eintrag). Bereitet zugleich die
// Schnittstelle fuer QA-04 (Pruefgate Routing) vor, ohne dessen Umfang
// vorwegzunehmen.
func (s *Server) handleSimulate(w http.ResponseWriter, r *http.Request, _ string) {
var in simulateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "ungueltige anfrage")
return
}
corpus := make([]routingsim.TestCase, 0, len(in.Corpus))
for _, c := range in.Corpus {
corpus = append(corpus, routingsim.TestCase{Label: c.Label, Object: c.Object, ExpectedRuleID: c.ExpectedRuleID})
}
results, err := routingsim.Simulate(r.Context(), s.store, corpus)
if err != nil {
writeError(w, http.StatusInternalServerError, "simulation fehlgeschlagen")
return
}
out := make([]simulateResult, 0, len(results))
for _, res := range results {
out = append(out, simulateResult{
Label: res.Label,
ExpectedRuleID: res.ExpectedRuleID,
ActualRuleID: res.ActualRuleID,
ActualRuleName: res.ActualRuleName,
Actions: res.Actions,
Matched: res.Matched,
AsExpected: res.AsExpected,
})
}
writeJSON(w, http.StatusOK, out)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}