feat: harden pet consultation SOP execution
This commit is contained in:
@@ -3,11 +3,18 @@ package sop
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
)
|
||||
|
||||
var allowedNodeTypes = map[string]bool{"start": true, "message": true, "question": true, "form": true, "choice": true, "condition": true, "knowledge": true, "escalate": true, "finish": true}
|
||||
var allowedConditionOperators = map[string]bool{"equals": true, "not_equals": true, "contains": true, "greater_than": true, "less_than": true, "exists": true, "not_exists": true, "in": true}
|
||||
|
||||
type ValidationContext struct {
|
||||
Fields []model.ScenarioField
|
||||
PublishedKnowledgeCardIDs map[uint64]bool
|
||||
}
|
||||
|
||||
func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge) []string {
|
||||
var problems []string
|
||||
@@ -29,6 +36,9 @@ func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOP
|
||||
if !allowedNodeTypes[node.Type] {
|
||||
problems = append(problems, fmt.Sprintf("节点 %s 类型不支持", node.Title))
|
||||
}
|
||||
if len(node.Config) > 0 && !json.Valid(node.Config) {
|
||||
problems = append(problems, fmt.Sprintf("节点“%s”的配置不是有效 JSON", node.Title))
|
||||
}
|
||||
if node.Type == "start" {
|
||||
startCount++
|
||||
}
|
||||
@@ -47,6 +57,7 @@ func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOP
|
||||
}
|
||||
|
||||
adjacency := make(map[string][]string)
|
||||
reverse := make(map[string][]string)
|
||||
outgoing := make(map[string]int)
|
||||
for _, edge := range edges {
|
||||
if _, exists := nodeMap[edge.SourceNodeKey]; !exists {
|
||||
@@ -59,6 +70,7 @@ func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOP
|
||||
problems = append(problems, fmt.Sprintf("连线 %s -> %s 的条件不是有效 JSON", edge.SourceNodeKey, edge.TargetNodeKey))
|
||||
}
|
||||
adjacency[edge.SourceNodeKey] = append(adjacency[edge.SourceNodeKey], edge.TargetNodeKey)
|
||||
reverse[edge.TargetNodeKey] = append(reverse[edge.TargetNodeKey], edge.SourceNodeKey)
|
||||
outgoing[edge.SourceNodeKey]++
|
||||
}
|
||||
for _, node := range nodes {
|
||||
@@ -86,5 +98,193 @@ func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOP
|
||||
problems = append(problems, fmt.Sprintf("节点“%s”无法从开始节点到达", node.Title))
|
||||
}
|
||||
}
|
||||
|
||||
canFinish := map[string]bool{}
|
||||
var walkReverse func(string)
|
||||
walkReverse = func(key string) {
|
||||
if canFinish[key] {
|
||||
return
|
||||
}
|
||||
canFinish[key] = true
|
||||
for _, previous := range reverse[key] {
|
||||
walkReverse(previous)
|
||||
}
|
||||
}
|
||||
for _, node := range nodes {
|
||||
if node.Type == "finish" || node.Type == "escalate" {
|
||||
walkReverse(node.NodeKey)
|
||||
}
|
||||
}
|
||||
for key, node := range nodeMap {
|
||||
if visited[key] && !canFinish[key] {
|
||||
problems = append(problems, fmt.Sprintf("节点“%s”所在路径无法结束", node.Title))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge, context ValidationContext) []string {
|
||||
problems := ValidateGraph(startNodeKey, nodes, edges)
|
||||
fieldMap := make(map[string]model.ScenarioField, len(context.Fields))
|
||||
for _, field := range context.Fields {
|
||||
fieldMap[field.FieldKey] = field
|
||||
}
|
||||
collected := map[string]bool{}
|
||||
nodeMap := make(map[string]model.SOPNode, len(nodes))
|
||||
adjacency := make(map[string][]string)
|
||||
for _, node := range nodes {
|
||||
nodeMap[node.NodeKey] = node
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(node.Config, &config); err != nil {
|
||||
continue
|
||||
}
|
||||
switch node.Type {
|
||||
case "question", "choice":
|
||||
fieldKey, _ := config["field_key"].(string)
|
||||
if fieldKey == "" {
|
||||
problems = append(problems, fmt.Sprintf("节点“%s”没有配置采集字段", node.Title))
|
||||
} else if _, exists := fieldMap[fieldKey]; !exists {
|
||||
problems = append(problems, fmt.Sprintf("节点“%s”引用的字段 %s 不存在", node.Title, fieldKey))
|
||||
} else {
|
||||
collected[fieldKey] = true
|
||||
}
|
||||
case "form":
|
||||
keys, _ := config["field_keys"].([]interface{})
|
||||
if len(keys) == 0 {
|
||||
problems = append(problems, fmt.Sprintf("节点“%s”没有配置表单字段", node.Title))
|
||||
}
|
||||
for _, value := range keys {
|
||||
fieldKey, ok := value.(string)
|
||||
if !ok || fieldKey == "" {
|
||||
problems = append(problems, fmt.Sprintf("节点“%s”包含无效的表单字段", node.Title))
|
||||
continue
|
||||
}
|
||||
if _, exists := fieldMap[fieldKey]; !exists {
|
||||
problems = append(problems, fmt.Sprintf("节点“%s”引用的字段 %s 不存在", node.Title, fieldKey))
|
||||
continue
|
||||
}
|
||||
collected[fieldKey] = true
|
||||
}
|
||||
case "knowledge":
|
||||
cardID := uint64FromJSON(config["knowledge_card_id"])
|
||||
if cardID == 0 || !context.PublishedKnowledgeCardIDs[cardID] {
|
||||
problems = append(problems, fmt.Sprintf("节点“%s”没有关联已发布的知识卡", node.Title))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, field := range context.Fields {
|
||||
if field.Required && !collected[field.FieldKey] {
|
||||
problems = append(problems, fmt.Sprintf("必填字段“%s”没有对应的采集节点", field.FieldName))
|
||||
}
|
||||
}
|
||||
|
||||
defaultPaths := map[string]int{}
|
||||
for _, edge := range edges {
|
||||
adjacency[edge.SourceNodeKey] = append(adjacency[edge.SourceNodeKey], edge.TargetNodeKey)
|
||||
if isDefaultCondition(edge.Condition) {
|
||||
defaultPaths[edge.SourceNodeKey]++
|
||||
continue
|
||||
}
|
||||
var rule interface{}
|
||||
if err := json.Unmarshal(edge.Condition, &rule); err != nil {
|
||||
continue
|
||||
}
|
||||
validateCondition(rule, fieldMap, fmt.Sprintf("路径 %s -> %s", edge.SourceNodeKey, edge.TargetNodeKey), &problems)
|
||||
}
|
||||
for source, count := range defaultPaths {
|
||||
if count > 1 {
|
||||
problems = append(problems, fmt.Sprintf("节点 %s 配置了多条默认路径", source))
|
||||
}
|
||||
}
|
||||
|
||||
for _, node := range nodes {
|
||||
var config map[string]interface{}
|
||||
_ = json.Unmarshal(node.Config, &config)
|
||||
if config["risk_level"] != "high" {
|
||||
continue
|
||||
}
|
||||
if !canReachType(node.NodeKey, "escalate", nodeMap, adjacency) {
|
||||
problems = append(problems, fmt.Sprintf("高风险节点“%s”没有明确的转人工或转诊路径", node.Title))
|
||||
}
|
||||
}
|
||||
sort.Strings(problems)
|
||||
return problems
|
||||
}
|
||||
|
||||
func validateCondition(value interface{}, fields map[string]model.ScenarioField, label string, problems *[]string) {
|
||||
rule, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
*problems = append(*problems, label+"的条件结构不正确")
|
||||
return
|
||||
}
|
||||
for _, group := range []string{"all", "any"} {
|
||||
if raw, exists := rule[group]; exists {
|
||||
items, ok := raw.([]interface{})
|
||||
if !ok || len(items) == 0 {
|
||||
*problems = append(*problems, label+"的组合条件不能为空")
|
||||
return
|
||||
}
|
||||
for _, item := range items {
|
||||
validateCondition(item, fields, label, problems)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
field, _ := rule["field"].(string)
|
||||
operator, _ := rule["operator"].(string)
|
||||
if _, exists := fields[field]; field == "" || !exists {
|
||||
*problems = append(*problems, fmt.Sprintf("%s 引用了不存在的字段 %s", label, field))
|
||||
}
|
||||
if !allowedConditionOperators[operator] {
|
||||
*problems = append(*problems, fmt.Sprintf("%s 使用了不支持的运算符 %s", label, operator))
|
||||
}
|
||||
}
|
||||
|
||||
func isDefaultCondition(value []byte) bool {
|
||||
if len(value) == 0 {
|
||||
return true
|
||||
}
|
||||
var condition interface{}
|
||||
if err := json.Unmarshal(value, &condition); err != nil || condition == nil {
|
||||
return condition == nil && err == nil
|
||||
}
|
||||
object, ok := condition.(map[string]interface{})
|
||||
return ok && len(object) == 0
|
||||
}
|
||||
|
||||
func uint64FromJSON(value interface{}) uint64 {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
if typed > 0 {
|
||||
return uint64(typed)
|
||||
}
|
||||
case uint64:
|
||||
return typed
|
||||
case int:
|
||||
if typed > 0 {
|
||||
return uint64(typed)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func canReachType(start, nodeType string, nodes map[string]model.SOPNode, adjacency map[string][]string) bool {
|
||||
visited := map[string]bool{}
|
||||
var walk func(string) bool
|
||||
walk = func(key string) bool {
|
||||
if visited[key] {
|
||||
return false
|
||||
}
|
||||
visited[key] = true
|
||||
if nodes[key].Type == nodeType {
|
||||
return true
|
||||
}
|
||||
for _, next := range adjacency[key] {
|
||||
if walk(next) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return walk(start)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user