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
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>
337 lines
11 KiB
Go
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})
|
|
}
|