feat: harden pet consultation SOP execution

This commit is contained in:
Eric 1549169735@qq.com
2026-08-08 22:03:08 +08:00
parent 0ec1d0f39d
commit 97a76250f7
14 changed files with 588 additions and 15 deletions

View File

@@ -190,12 +190,16 @@ func (h *Handler) Validate(c *gin.Context) {
if !ok {
return
}
_, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
problems := ValidateGraph(version.StartNodeKey, nodes, edges)
problems, err := h.validateForPublish(item, version.StartNodeKey, nodes, edges, p.TenantID)
if err != nil {
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
return
}
response.OK(c, gin.H{"valid": len(problems) == 0, "problems": problems})
}
@@ -210,7 +214,11 @@ func (h *Handler) SubmitReview(c *gin.Context) {
response.Error(c, http.StatusConflict, "NO_DRAFT_VERSION", "没有可提交审核的草稿版本")
return
}
problems := ValidateGraph(version.StartNodeKey, nodes, edges)
problems, validationErr := h.validateForPublish(item, version.StartNodeKey, nodes, edges, p.TenantID)
if validationErr != nil {
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
return
}
if len(problems) > 0 {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
return
@@ -240,7 +248,11 @@ func (h *Handler) Publish(c *gin.Context) {
response.Error(c, http.StatusConflict, "NO_REVIEW_VERSION", "没有可发布的审核版本")
return
}
problems := ValidateGraph(version.StartNodeKey, nodes, edges)
problems, validationErr := h.validateForPublish(item, version.StartNodeKey, nodes, edges, p.TenantID)
if validationErr != nil {
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
return
}
if len(problems) > 0 {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
return
@@ -368,6 +380,22 @@ func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersio
return item, version, nodes, edges, nil
}
func (h *Handler) validateForPublish(item model.SOP, startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge, tenantID uint64) ([]string, error) {
var fields []model.ScenarioField
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&fields).Error; err != nil {
return nil, err
}
var cards []model.KnowledgeCard
if err := h.db.Where("scenario_id = ? AND tenant_id = ? AND status = ?", item.ScenarioID, tenantID, "published").Find(&cards).Error; err != nil {
return nil, err
}
cardIDs := make(map[uint64]bool, len(cards))
for _, card := range cards {
cardIDs[card.ID] = true
}
return ValidateForPublish(startNodeKey, nodes, edges, ValidationContext{Fields: fields, PublishedKnowledgeCardIDs: cardIDs}), nil
}
func toModels(tenantID, versionID uint64, input graphInput) ([]model.SOPNode, []model.SOPEdge) {
nodes := make([]model.SOPNode, 0, len(input.Nodes))
for _, item := range input.Nodes {

View File

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

View File

@@ -0,0 +1,106 @@
package sop
import (
"strings"
"testing"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/datatypes"
)
func TestValidateForPublishValidHighRiskFlow(t *testing.T) {
nodes, edges, context := validPublishGraph()
if problems := ValidateForPublish("start", nodes, edges, context); len(problems) != 0 {
t.Fatalf("ValidateForPublish() problems = %v", problems)
}
}
func TestValidateForPublishBusinessRules(t *testing.T) {
tests := []struct {
name string
mutate func(*[]model.SOPNode, *[]model.SOPEdge, *ValidationContext)
want string
}{
{name: "missing required collection", mutate: func(_ *[]model.SOPNode, _ *[]model.SOPEdge, context *ValidationContext) {
context.Fields = append(context.Fields, model.ScenarioField{FieldKey: "symptom", FieldName: "主要症状", Required: true})
}, want: "没有对应的采集节点"},
{name: "unknown condition field", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
(*edges)[2].Condition = jsonData(`{"field":"missing","operator":"equals","value":true}`)
}, want: "不存在的字段 missing"},
{name: "invalid operator", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
(*edges)[2].Condition = jsonData(`{"field":"emergency","operator":"matches","value":true}`)
}, want: "不支持的运算符 matches"},
{name: "unpublished knowledge", mutate: func(_ *[]model.SOPNode, _ *[]model.SOPEdge, context *ValidationContext) {
context.PublishedKnowledgeCardIDs = map[uint64]bool{}
}, want: "没有关联已发布的知识卡"},
{name: "duplicate default path", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
(*edges)[1].Condition = jsonData(`{}`)
}, want: "配置了多条默认路径"},
{name: "high risk without escalation", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
(*edges)[1].TargetNodeKey = "finish"
}, want: "没有明确的转人工或转诊路径"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
nodes, edges, context := validPublishGraph()
test.mutate(&nodes, &edges, &context)
problems := ValidateForPublish("start", nodes, edges, context)
if !containsProblem(problems, test.want) {
t.Fatalf("ValidateForPublish() problems = %v, want containing %q", problems, test.want)
}
})
}
}
func TestValidateGraphRejectsNonTerminatingCycle(t *testing.T) {
nodes := []model.SOPNode{
{NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)},
{NodeKey: "loop", Type: "message", Title: "循环", Config: jsonData(`{}`)},
{NodeKey: "finish", Type: "finish", Title: "结束", Config: jsonData(`{}`)},
}
edges := []model.SOPEdge{
{SourceNodeKey: "start", TargetNodeKey: "loop", Condition: jsonData(`{}`)},
{SourceNodeKey: "loop", TargetNodeKey: "loop", Condition: jsonData(`{}`)},
}
if problems := ValidateGraph("start", nodes, edges); !containsProblem(problems, "所在路径无法结束") {
t.Fatalf("ValidateGraph() problems = %v", problems)
}
}
func TestIsDefaultConditionAllowsJSONWhitespace(t *testing.T) {
if !isDefaultCondition([]byte(` { } `)) {
t.Fatal("isDefaultCondition() should accept an empty JSON object with whitespace")
}
}
func validPublishGraph() ([]model.SOPNode, []model.SOPEdge, ValidationContext) {
nodes := []model.SOPNode{
{NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)},
{NodeKey: "screen", Type: "form", Title: "急症筛查", Config: jsonData(`{"field_keys":["emergency"],"risk_level":"high"}`)},
{NodeKey: "knowledge", Type: "knowledge", Title: "用药原则", Config: jsonData(`{"knowledge_card_id":1}`)},
{NodeKey: "escalate", Type: "escalate", Title: "转诊", Config: jsonData(`{}`)},
{NodeKey: "finish", Type: "finish", Title: "结束", Config: jsonData(`{}`)},
}
edges := []model.SOPEdge{
{SourceNodeKey: "start", TargetNodeKey: "screen", Condition: jsonData(`{}`)},
{SourceNodeKey: "screen", TargetNodeKey: "escalate", Condition: jsonData(`{"field":"emergency","operator":"equals","value":true}`)},
{SourceNodeKey: "screen", TargetNodeKey: "knowledge", Condition: jsonData(`{}`)},
{SourceNodeKey: "knowledge", TargetNodeKey: "finish", Condition: jsonData(`{}`)},
}
context := ValidationContext{
Fields: []model.ScenarioField{{FieldKey: "emergency", FieldName: "是否急症", Required: true}},
PublishedKnowledgeCardIDs: map[uint64]bool{1: true},
}
return nodes, edges, context
}
func jsonData(value string) datatypes.JSON { return datatypes.JSON([]byte(value)) }
func containsProblem(problems []string, want string) bool {
for _, problem := range problems {
if strings.Contains(problem, want) {
return true
}
}
return false
}