Changeset 0.35.0 (#209)

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
2026-03-20 09:59:53 +00:00
committed by xcaliber
parent d16bb93177
commit bf8082e69f
37 changed files with 2324 additions and 129 deletions

203
server/workflow/routing.go Normal file
View File

@@ -0,0 +1,203 @@
package workflow
import (
"encoding/json"
"fmt"
"strings"
"chat-switchboard/models"
)
// ── Conditional Routing Engine (v0.35.0) ────
//
// Evaluates transition_rules.conditions[] against accumulated stage_data
// to determine which stage to advance to. Falls back to ordinal + 1
// when no conditions are defined or none match.
// Condition is a single routing condition within transition_rules.
type Condition struct {
Field string `json:"field"`
Op string `json:"op"`
Value any `json:"value"`
TargetStage string `json:"target_stage"` // stage name or ordinal as string
}
// TransitionRulesWithConditions extends the existing transition_rules JSON.
type TransitionRulesWithConditions struct {
AutoAssign string `json:"auto_assign,omitempty"`
Conditions []Condition `json:"conditions,omitempty"`
}
// ResolveNextStage evaluates conditions from the current stage's transition_rules
// against accumulated stageData. Returns the target stage ordinal.
// Falls back to currentStage + 1 if no conditions match or none are defined.
func ResolveNextStage(stages []models.WorkflowStage, currentStage int, stageData json.RawMessage) (int, error) {
if currentStage < 0 || currentStage >= len(stages) {
return currentStage + 1, nil
}
stage := stages[currentStage]
var rules TransitionRulesWithConditions
if len(stage.TransitionRules) > 0 {
_ = json.Unmarshal(stage.TransitionRules, &rules)
}
if len(rules.Conditions) == 0 {
return currentStage + 1, nil
}
var data map[string]any
if len(stageData) > 0 {
_ = json.Unmarshal(stageData, &data)
}
if data == nil {
data = map[string]any{}
}
for _, cond := range rules.Conditions {
if evaluateCondition(cond, data) {
target, err := resolveTarget(stages, cond.TargetStage)
if err != nil {
return 0, fmt.Errorf("condition matched but target invalid: %w", err)
}
return target, nil
}
}
return currentStage + 1, nil
}
// ResolveStageByName finds a stage ordinal by name (case-insensitive).
func ResolveStageByName(stages []models.WorkflowStage, name string) (int, error) {
lower := strings.ToLower(strings.TrimSpace(name))
for _, s := range stages {
if strings.ToLower(s.Name) == lower {
return s.Ordinal, nil
}
}
return 0, fmt.Errorf("stage %q not found", name)
}
// resolveTarget resolves a target_stage string to an ordinal.
// Accepts either a stage name or a numeric ordinal string.
func resolveTarget(stages []models.WorkflowStage, target string) (int, error) {
// Try as stage name first
ordinal, err := ResolveStageByName(stages, target)
if err == nil {
return ordinal, nil
}
// Try as numeric ordinal
var n int
if _, err := fmt.Sscanf(target, "%d", &n); err == nil {
if n >= 0 && n < len(stages) {
return n, nil
}
return 0, fmt.Errorf("ordinal %d out of range (0..%d)", n, len(stages)-1)
}
return 0, fmt.Errorf("cannot resolve target stage %q", target)
}
// evaluateCondition checks if a single condition matches against data.
func evaluateCondition(cond Condition, data map[string]any) bool {
val, exists := data[cond.Field]
switch cond.Op {
case "exists":
return exists
case "not_exists":
return !exists
}
if !exists {
return false
}
switch cond.Op {
case "eq":
return compareEq(val, cond.Value)
case "neq":
return !compareEq(val, cond.Value)
case "gt":
return compareNum(val, cond.Value) > 0
case "lt":
return compareNum(val, cond.Value) < 0
case "gte":
return compareNum(val, cond.Value) >= 0
case "lte":
return compareNum(val, cond.Value) <= 0
case "in":
return compareIn(val, cond.Value)
case "contains":
return compareContains(val, cond.Value)
default:
return false
}
}
// compareEq does loose equality: normalizes both sides to string for comparison.
func compareEq(a, b any) bool {
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
}
// compareNum extracts float64 from both sides and returns -1, 0, or 1.
// Returns 0 if either side is not numeric.
func compareNum(a, b any) int {
af := toFloat(a)
bf := toFloat(b)
if af == nil || bf == nil {
return 0
}
switch {
case *af < *bf:
return -1
case *af > *bf:
return 1
default:
return 0
}
}
func toFloat(v any) *float64 {
switch n := v.(type) {
case float64:
return &n
case int:
f := float64(n)
return &f
case json.Number:
if f, err := n.Float64(); err == nil {
return &f
}
case string:
var f float64
if _, err := fmt.Sscanf(n, "%f", &f); err == nil {
return &f
}
}
return nil
}
// compareIn checks if val is in the list (cond.Value must be []any).
func compareIn(val, list any) bool {
arr, ok := list.([]any)
if !ok {
return false
}
vs := fmt.Sprintf("%v", val)
for _, item := range arr {
if fmt.Sprintf("%v", item) == vs {
return true
}
}
return false
}
// compareContains checks if val (as string) contains the target substring.
func compareContains(val, target any) bool {
s := fmt.Sprintf("%v", val)
t := fmt.Sprintf("%v", target)
return strings.Contains(s, t)
}