// 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}) }