feat: complete SOP execution workflow

This commit is contained in:
Eric 1549169735@qq.com
2026-08-09 00:29:26 +08:00
parent e32025d2ca
commit c5ab886b70
63 changed files with 1229 additions and 215 deletions

View File

@@ -355,6 +355,9 @@ func (h *Handler) Publish(c *gin.Context) {
}
now := time.Now()
err = h.db.Transaction(func(tx *gorm.DB) error {
if err := BindKnowledgeVersions(tx, p.TenantID, version.ID); err != nil {
return err
}
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "superseded").Error; err != nil {
return err
}

View File

@@ -0,0 +1,58 @@
package sop
import (
"encoding/json"
"fmt"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type knowledgeNodeConfig struct {
KnowledgeCardID uint64 `json:"knowledge_card_id"`
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_id"`
}
// BindKnowledgeVersions freezes the current published knowledge-card version
// into every knowledge node before the SOP version becomes immutable.
func BindKnowledgeVersions(tx *gorm.DB, tenantID, sopVersionID uint64) error {
nodes := make([]model.SOPNode, 0)
if err := tx.Where("tenant_id = ? AND sop_version_id = ? AND type = ?", tenantID, sopVersionID, "knowledge").Find(&nodes).Error; err != nil {
return err
}
for _, node := range nodes {
var config knowledgeNodeConfig
if err := json.Unmarshal(node.Config, &config); err != nil || config.KnowledgeCardID == 0 {
return fmt.Errorf("knowledge node %s has invalid configuration", node.NodeKey)
}
var version model.KnowledgeCardVersion
err := tx.Table("knowledge_card_versions kv").Select("kv.*").
Joins("JOIN knowledge_cards kc ON kc.id = kv.knowledge_card_id").
Where("kv.tenant_id = ? AND kv.knowledge_card_id = ? AND kv.status = ? AND kc.tenant_id = ? AND kc.status = ?", tenantID, config.KnowledgeCardID, "published", tenantID, "published").
Order("kv.version DESC").First(&version).Error
if err != nil {
return fmt.Errorf("knowledge node %s has no published card version: %w", node.NodeKey, err)
}
updated, err := withKnowledgeVersion(node.Config, version.ID)
if err != nil {
return fmt.Errorf("update knowledge node %s: %w", node.NodeKey, err)
}
if err := tx.Model(&model.SOPNode{}).Where("id = ? AND tenant_id = ? AND sop_version_id = ?", node.ID, tenantID, sopVersionID).Update("config", updated).Error; err != nil {
return err
}
}
return nil
}
func withKnowledgeVersion(config datatypes.JSON, versionID uint64) (datatypes.JSON, error) {
value := map[string]interface{}{}
if len(config) > 0 {
if err := json.Unmarshal(config, &value); err != nil {
return nil, err
}
}
value["knowledge_card_version_id"] = versionID
encoded, err := json.Marshal(value)
return datatypes.JSON(encoded), err
}

View File

@@ -0,0 +1,22 @@
package sop
import (
"encoding/json"
"testing"
"gorm.io/datatypes"
)
func TestWithKnowledgeVersionPreservesConfiguration(t *testing.T) {
updated, err := withKnowledgeVersion(datatypes.JSON([]byte(`{"knowledge_card_id":12,"display":"full"}`)), 34)
if err != nil {
t.Fatal(err)
}
var value map[string]interface{}
if err := json.Unmarshal(updated, &value); err != nil {
t.Fatal(err)
}
if value["knowledge_card_id"] != float64(12) || value["knowledge_card_version_id"] != float64(34) || value["display"] != "full" {
t.Fatalf("unexpected knowledge config: %#v", value)
}
}

View File

@@ -54,6 +54,8 @@ func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOP
}
if _, exists := nodeMap[startNodeKey]; !exists && startNodeKey != "" {
problems = append(problems, "开始节点不存在")
} else if startNodeKey != "" && nodeMap[startNodeKey].Type != "start" {
problems = append(problems, "开始节点标识必须指向 start 类型节点")
}
adjacency := make(map[string][]string)
@@ -77,6 +79,9 @@ func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOP
if node.Type != "finish" && node.Type != "escalate" && outgoing[node.NodeKey] == 0 {
problems = append(problems, fmt.Sprintf("节点“%s”没有下一步", node.Title))
}
if (node.Type == "finish" || node.Type == "escalate") && outgoing[node.NodeKey] > 0 {
problems = append(problems, fmt.Sprintf("结束节点“%s”不能配置下一步", node.Title))
}
}
visited := map[string]bool{}
@@ -147,6 +152,9 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
problems = append(problems, fmt.Sprintf("节点“%s”引用的字段 %s 不存在", node.Title, fieldKey))
} else {
collected[fieldKey] = true
if node.Type == "choice" {
validateChoiceOptions(node, config, fieldMap[fieldKey], &problems)
}
}
case "form":
keys, _ := config["field_keys"].([]interface{})
@@ -218,12 +226,57 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
return problems
}
func validateChoiceOptions(node model.SOPNode, config map[string]interface{}, field model.ScenarioField, problems *[]string) {
values, ok := config["options"].([]interface{})
if !ok || len(values) == 0 {
*problems = append(*problems, fmt.Sprintf("选择节点“%s”没有配置选项", node.Title))
return
}
if field.FieldType != "text" && field.FieldType != "select" {
*problems = append(*problems, fmt.Sprintf("选择节点“%s”只能写入文本或单选字段", node.Title))
}
seen := map[string]bool{}
for _, value := range values {
option, ok := value.(string)
if !ok || option == "" {
*problems = append(*problems, fmt.Sprintf("选择节点“%s”包含无效选项", node.Title))
continue
}
if seen[option] {
*problems = append(*problems, fmt.Sprintf("选择节点“%s”包含重复选项", node.Title))
}
seen[option] = true
if field.FieldType == "select" && !jsonStringArrayContains(field.Options, option) {
*problems = append(*problems, fmt.Sprintf("选择节点“%s”的选项“%s”不在字段选项中", node.Title, option))
}
}
}
func jsonStringArrayContains(raw []byte, expected string) bool {
var values []string
if json.Unmarshal(raw, &values) != nil {
return false
}
for _, value := range values {
if value == expected {
return true
}
}
return false
}
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
}
_, hasAll := rule["all"]
_, hasAny := rule["any"]
if hasAll && hasAny {
*problems = append(*problems, label+"不能同时包含全部满足和任一满足")
return
}
for _, group := range []string{"all", "any"} {
if raw, exists := rule[group]; exists {
items, ok := raw.([]interface{})

View File

@@ -42,6 +42,12 @@ func TestValidateForPublishBusinessRules(t *testing.T) {
{name: "high risk without escalation", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
(*edges)[1].TargetNodeKey = "finish"
}, want: "没有明确的转人工或转诊路径"},
{name: "terminal node has outgoing edge", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
*edges = append(*edges, model.SOPEdge{SourceNodeKey: "finish", TargetNodeKey: "screen", Condition: jsonData(`{}`)})
}, want: "不能配置下一步"},
{name: "ambiguous condition group", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
(*edges)[1].Condition = jsonData(`{"all":[{"field":"emergency","operator":"equals","value":true}],"any":[{"field":"emergency","operator":"equals","value":true}]}`)
}, want: "不能同时包含全部满足和任一满足"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -55,6 +61,29 @@ func TestValidateForPublishBusinessRules(t *testing.T) {
}
}
func TestValidateGraphRequiresConfiguredStartType(t *testing.T) {
nodes, edges, _ := validPublishGraph()
if problems := ValidateGraph("screen", nodes, edges); !containsProblem(problems, "必须指向 start 类型节点") {
t.Fatalf("ValidateGraph() problems = %v", problems)
}
}
func TestValidateForPublishChoiceConfiguration(t *testing.T) {
nodes := []model.SOPNode{
{NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)},
{NodeKey: "choice", Type: "choice", Title: "客户意向", Config: jsonData(`{"field_key":"intent","options":["继续","无效"]}`)},
{NodeKey: "finish", Type: "finish", Title: "结束", Config: jsonData(`{}`)},
}
edges := []model.SOPEdge{
{SourceNodeKey: "start", TargetNodeKey: "choice", Condition: jsonData(`{}`)},
{SourceNodeKey: "choice", TargetNodeKey: "finish", Condition: jsonData(`{}`)},
}
context := ValidationContext{Fields: []model.ScenarioField{{FieldKey: "intent", FieldName: "客户意向", FieldType: "select", Options: jsonData(`["继续","暂不考虑"]`)}}}
if problems := ValidateForPublish("start", nodes, edges, context); !containsProblem(problems, "不在字段选项中") {
t.Fatalf("ValidateForPublish() problems = %v", problems)
}
}
func TestValidateGraphRejectsNonTerminatingCycle(t *testing.T) {
nodes := []model.SOPNode{
{NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)},