Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
204 lines
4.9 KiB
Go
204 lines
4.9 KiB
Go
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)
|
|
}
|