feat: complete SOP execution workflow
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
58
internal/sop/knowledge_snapshot.go
Normal file
58
internal/sop/knowledge_snapshot.go
Normal 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
|
||||
}
|
||||
22
internal/sop/knowledge_snapshot_test.go
Normal file
22
internal/sop/knowledge_snapshot_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
|
||||
@@ -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(`{}`)},
|
||||
|
||||
Reference in New Issue
Block a user