diff --git a/Makefile b/Makefile index b74f9b2..3cd720a 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,7 @@ package-linux-amd64-prod: BUILD_DIR := bin/linux-amd64-prod package-linux-amd64-prod: PACKAGE_FILE := bin/iqudo-top1-linux-amd64-prod.tar.gz package-linux-amd64-prod: build mkdir -p $(BUILD_DIR) + rm -f $(PACKAGE_FILE) tar -czf $(PACKAGE_FILE) -C $(BUILD_DIR) main configs @echo "created $(PACKAGE_FILE)" diff --git a/configs/config.example.yml b/configs/config.example.yml index 0e2f50c..d5aa95c 100644 --- a/configs/config.example.yml +++ b/configs/config.example.yml @@ -39,7 +39,5 @@ multitable: sops: 0 sop_nodes: 0 sop_edges: 0 - knowledge_items: 0 - knowledge_relations: 0 runs: 0 feedback: 0 diff --git a/configs/config.prod.yml b/configs/config.prod.yml index dba635b..7a9c6f5 100644 --- a/configs/config.prod.yml +++ b/configs/config.prod.yml @@ -33,7 +33,5 @@ multitable: sops: 69 sop_nodes: 70 sop_edges: 71 - knowledge_items: 72 - knowledge_relations: 73 runs: 75 feedback: 76 diff --git a/configs/config.yml b/configs/config.yml index 772296b..88c13f6 100644 --- a/configs/config.yml +++ b/configs/config.yml @@ -39,7 +39,5 @@ multitable: sops: 48 sop_nodes: 49 sop_edges: 50 - knowledge_items: 64 - knowledge_relations: 65 runs: 52 feedback: 53 diff --git a/internal/audit/audit.go b/internal/audit/audit.go index cdd0d51..6c371c9 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -58,8 +58,8 @@ func isProjected(resource, action string) bool { return action == "create" || action == "update" || action == "delete" case "sop": return action == "publish" || action == "offline" || action == "save_graph" - case "scenario_rule", "knowledge_item", "knowledge_relation": - return action == "create" || action == "update" || action == "delete" || action == "archive" + case "scenario_rule", "script_package": + return action == "create" || action == "update" || action == "delete" || action == "archive" || action == "replace" case "sop_run": return action == "start" || action == "finish" || action == "feedback" default: diff --git a/internal/auth/service.go b/internal/auth/service.go index 3ac55ee..70f7011 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -181,8 +181,8 @@ func Seed(db *gorm.DB, cfg config.SeedConfig) error { Permissions []string }{ {Name: "管理员", Code: "admin", Permissions: []string{"*"}}, - {Name: "SOP 编辑者", Code: "editor", Permissions: []string{"dashboard.view", "scenario.view", "scenario.team_view", "scenario.edit", "sop.view", "sop.edit", "knowledge.view", "knowledge.edit", "runs.view_all"}}, - {Name: "一线执行者", Code: "operator", Permissions: []string{"dashboard.view", "scenario.view", "sop.execute", "knowledge.view", "runs.view_own", "runs.feedback"}}, + {Name: "SOP 编辑者", Code: "editor", Permissions: []string{"dashboard.view", "scenario.view", "scenario.team_view", "scenario.edit", "sop.view", "sop.edit", "scriptkit.view", "scriptkit.edit", "runs.view_all"}}, + {Name: "一线执行者", Code: "operator", Permissions: []string{"dashboard.view", "scenario.view", "sop.execute", "scriptkit.view", "runs.view_own", "runs.feedback"}}, } roles := make(map[string]model.Role, len(roleDefinitions)) for _, definition := range roleDefinitions { diff --git a/internal/config/config.go b/internal/config/config.go index 5394115..dc83899 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -76,16 +76,14 @@ type MultiTableConfig struct { } type MultiTableTables struct { - Scenarios uint64 `yaml:"scenarios" env:"APP_MULTITABLE_TABLES_SCENARIOS"` - ScenarioFields uint64 `yaml:"scenario_fields" env:"APP_MULTITABLE_TABLES_SCENARIO_FIELDS"` - ScenarioRules uint64 `yaml:"scenario_rules" env:"APP_MULTITABLE_TABLES_SCENARIO_RULES"` - SOPs uint64 `yaml:"sops" env:"APP_MULTITABLE_TABLES_SOPS"` - SOPNodes uint64 `yaml:"sop_nodes" env:"APP_MULTITABLE_TABLES_SOP_NODES"` - SOPEdges uint64 `yaml:"sop_edges" env:"APP_MULTITABLE_TABLES_SOP_EDGES"` - KnowledgeItems uint64 `yaml:"knowledge_items" env:"APP_MULTITABLE_TABLES_KNOWLEDGE_ITEMS"` - KnowledgeRelations uint64 `yaml:"knowledge_relations" env:"APP_MULTITABLE_TABLES_KNOWLEDGE_RELATIONS"` - Runs uint64 `yaml:"runs" env:"APP_MULTITABLE_TABLES_RUNS"` - Feedback uint64 `yaml:"feedback" env:"APP_MULTITABLE_TABLES_FEEDBACK"` + Scenarios uint64 `yaml:"scenarios" env:"APP_MULTITABLE_TABLES_SCENARIOS"` + ScenarioFields uint64 `yaml:"scenario_fields" env:"APP_MULTITABLE_TABLES_SCENARIO_FIELDS"` + ScenarioRules uint64 `yaml:"scenario_rules" env:"APP_MULTITABLE_TABLES_SCENARIO_RULES"` + SOPs uint64 `yaml:"sops" env:"APP_MULTITABLE_TABLES_SOPS"` + SOPNodes uint64 `yaml:"sop_nodes" env:"APP_MULTITABLE_TABLES_SOP_NODES"` + SOPEdges uint64 `yaml:"sop_edges" env:"APP_MULTITABLE_TABLES_SOP_EDGES"` + Runs uint64 `yaml:"runs" env:"APP_MULTITABLE_TABLES_RUNS"` + Feedback uint64 `yaml:"feedback" env:"APP_MULTITABLE_TABLES_FEEDBACK"` } type LoadOptions struct { @@ -189,8 +187,6 @@ func (c Config) Validate() error { c.MultiTable.Tables.SOPs, c.MultiTable.Tables.SOPNodes, c.MultiTable.Tables.SOPEdges, - c.MultiTable.Tables.KnowledgeItems, - c.MultiTable.Tables.KnowledgeRelations, c.MultiTable.Tables.Runs, c.MultiTable.Tables.Feedback, } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c3bf300..8e95b2b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -101,7 +101,7 @@ func TestLoadMultiTableEnvironmentConfiguration(t *testing.T) { if err != nil { t.Fatalf("Load() error = %v", err) } - if !cfg.MultiTable.Enabled || cfg.MultiTable.Tables.Scenarios != 46 || cfg.MultiTable.Tables.ScenarioRules != 54 || cfg.MultiTable.Tables.KnowledgeItems != 55 || cfg.MultiTable.Tables.KnowledgeRelations != 56 || cfg.MultiTable.Tables.Feedback != 53 { + if !cfg.MultiTable.Enabled || cfg.MultiTable.Tables.Scenarios != 46 || cfg.MultiTable.Tables.ScenarioRules != 54 || cfg.MultiTable.Tables.Feedback != 53 { t.Fatalf("unexpected multitable configuration: %+v", cfg.MultiTable) } } diff --git a/internal/dashboard/scriptkit.go b/internal/dashboard/scriptkit.go new file mode 100644 index 0000000..3f23615 --- /dev/null +++ b/internal/dashboard/scriptkit.go @@ -0,0 +1,56 @@ +package dashboard + +import ( + "git.iwork-ai.com/xdc/iqudo-top1/internal/auth" + "git.iwork-ai.com/xdc/iqudo-top1/internal/response" + "github.com/gin-gonic/gin" +) + +type dimensionStat struct { + ValueKey string `json:"value_key"` + Name string `json:"name"` + DimKey string `json:"dim_key"` + Runs int64 `json:"runs"` + AverageWeight float64 `json:"average_weight"` +} + +type scriptFeedbackStat struct { + ScriptKey string `json:"script_key"` + Name string `json:"name"` + FeedbackType string `json:"feedback_type"` + Count int64 `json:"count"` +} + +// Scriptkit returns script package analytics: average dimension weights per +// value and script feedback counts. +func (h *Handler) Scriptkit(c *gin.Context) { + principal, _ := auth.PrincipalFromContext(c) + dimensions := make([]dimensionStat, 0) + err := h.db.Raw(` + SELECT dv.value_key, dv.name, d.dim_key, COUNT(DISTINCT rd.run_id) AS runs, AVG(rd.weight) AS average_weight + FROM run_dimensions rd + JOIN dimension_values dv ON dv.dimension_id = rd.dimension_id AND dv.value_key = rd.value_key AND dv.tenant_id = rd.tenant_id + JOIN package_dimensions d ON d.id = rd.dimension_id AND d.tenant_id = rd.tenant_id + WHERE rd.tenant_id = ? AND rd.weight > 0 + GROUP BY dv.value_key, dv.name, d.dim_key + ORDER BY runs DESC, average_weight DESC + LIMIT 100`, principal.TenantID).Scan(&dimensions).Error + if err != nil { + queryFailed(c) + return + } + feedback := make([]scriptFeedbackStat, 0) + err = h.db.Raw(` + SELECT ss.script_key, ss.name, sf.feedback_type, COUNT(*) AS count + FROM script_feedbacks sf + JOIN stage_scripts ss ON ss.id = sf.script_id + WHERE sf.tenant_id = ? + GROUP BY ss.script_key, ss.name, sf.feedback_type + ORDER BY count DESC + LIMIT 100`, principal.TenantID).Scan(&feedback).Error + if err != nil { + queryFailed(c) + return + } + response.OK(c, gin.H{"dimensions": dimensions, "feedback": feedback}) +} diff --git a/internal/database/database.go b/internal/database/database.go index 12f25ca..5d307b4 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -55,6 +55,9 @@ func AutoMigrate(db *gorm.DB) error { if err := prepareSingleVersionSchema(db); err != nil { return err } + if err := dropLegacyKnowledgeSchema(db); err != nil { + return err + } return db.AutoMigrate( &model.Tenant{}, &model.User{}, @@ -66,8 +69,16 @@ func AutoMigrate(db *gorm.DB) error { &model.SOP{}, &model.SOPNode{}, &model.SOPEdge{}, - &model.KnowledgeItem{}, - &model.KnowledgeRelation{}, + &model.ScriptPackage{}, + &model.PackageDimension{}, + &model.DimensionValue{}, + &model.PackageStage{}, + &model.StageScript{}, + &model.ScriptOption{}, + &model.DimensionLinkage{}, + &model.PackageAdapter{}, + &model.RunDimension{}, + &model.ScriptFeedback{}, &model.SOPRun{}, &model.SOPRunEvent{}, &model.PublicRunSession{}, @@ -78,6 +89,24 @@ func AutoMigrate(db *gorm.DB) error { ) } +// dropLegacyKnowledgeSchema removes the knowledge-graph tables and the run +// snapshot column that the script-package rewrite no longer uses. +func dropLegacyKnowledgeSchema(db *gorm.DB) error { + for _, table := range []string{"knowledge_relations", "knowledge_items"} { + if db.Migrator().HasTable(table) { + if err := db.Exec("DROP TABLE " + quoteIdentifier(table)).Error; err != nil { + return fmt.Errorf("drop legacy table %s: %w", table, err) + } + } + } + if db.Migrator().HasTable("sop_runs") && db.Migrator().HasColumn("sop_runs", "knowledge_snapshot") { + if err := db.Exec("ALTER TABLE sop_runs DROP COLUMN knowledge_snapshot").Error; err != nil { + return fmt.Errorf("drop sop_runs.knowledge_snapshot: %w", err) + } + } + return nil +} + // dropUnusedLegacyTables removes schema objects that are no longer part of // the scenario knowledge graph. Drop the version table before its parent so // this remains safe for databases that still have a historical constraint. diff --git a/internal/httpserver/server.go b/internal/httpserver/server.go index df6fb31..2e73109 100644 --- a/internal/httpserver/server.go +++ b/internal/httpserver/server.go @@ -9,11 +9,11 @@ import ( "git.iwork-ai.com/xdc/iqudo-top1/internal/auth" "git.iwork-ai.com/xdc/iqudo-top1/internal/dashboard" - "git.iwork-ai.com/xdc/iqudo-top1/internal/knowledge" "git.iwork-ai.com/xdc/iqudo-top1/internal/member" "git.iwork-ai.com/xdc/iqudo-top1/internal/middleware" runhandler "git.iwork-ai.com/xdc/iqudo-top1/internal/run" "git.iwork-ai.com/xdc/iqudo-top1/internal/scenario" + "git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit" "git.iwork-ai.com/xdc/iqudo-top1/internal/sop" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -35,7 +35,7 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, sdkFS fs.FS, lo scenarioHandler := scenario.NewHandler(db) sopHandler := sop.NewHandler(db) runHandler := runhandler.NewHandler(db) - knowledgeHandler := knowledge.NewHandler(db) + scriptkitHandler := scriptkit.NewHandler(db) memberHandler := member.NewHandler(db) dashboardHandler := dashboard.NewHandler(db) @@ -48,8 +48,11 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, sdkFS fs.FS, lo public := router.Group("/public") public.POST("/scenarios/:scenarioKey/runs", runHandler.PublicStart) public.GET("/runs/:id/current", runHandler.PublicCurrent) + public.POST("/runs/:id/answer", runHandler.PublicAnswer) public.POST("/runs/:id/submit", runHandler.PublicSubmit) public.POST("/runs/:id/next", runHandler.PublicNext) + public.POST("/runs/:id/back", runHandler.PublicBack) + public.POST("/runs/:id/feedback", runHandler.PublicFeedback) public.POST("/runs/:id/finish", runHandler.PublicFinish) public.POST("/runs/:id/reset", runHandler.PublicReset) @@ -60,6 +63,7 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, sdkFS fs.FS, lo protected.POST("/members", middleware.RequirePermission("member.manage"), memberHandler.Create) protected.PUT("/members/:id", middleware.RequirePermission("member.manage"), memberHandler.Update) protected.GET("/dashboard/summary", middleware.RequirePermission("dashboard.view"), dashboardHandler.Summary) + protected.GET("/dashboard/scriptkit", middleware.RequirePermission("dashboard.view"), dashboardHandler.Scriptkit) protected.GET("/scenarios", middleware.RequirePermission("scenario.view"), scenarioHandler.List) protected.POST("/scenarios", middleware.RequirePermission("scenario.edit"), scenarioHandler.Create) @@ -87,11 +91,15 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, sdkFS fs.FS, lo protected.GET("/runs/:id", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Get) protected.GET("/runs/:id/detail", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Detail) protected.POST("/runs/:id/answer", middleware.RequirePermission("sop.execute"), runHandler.Answer) + protected.POST("/runs/:id/next", middleware.RequirePermission("sop.execute"), runHandler.Next) + protected.POST("/runs/:id/back", middleware.RequirePermission("sop.execute"), runHandler.Back) + protected.POST("/runs/:id/script-usage", middleware.RequirePermission("sop.execute"), runHandler.RecordScriptUsage) + protected.POST("/runs/:id/script-feedback", middleware.RequirePermission("sop.execute"), runHandler.ScriptFeedback) protected.POST("/runs/:id/finish", middleware.RequirePermission("sop.execute"), runHandler.Finish) protected.POST("/runs/:id/feedback", middleware.RequirePermission("runs.feedback"), runHandler.Feedback) - protected.GET("/scenarios/:id/knowledge-graph", middleware.RequirePermission("knowledge.view"), knowledgeHandler.GetGraph) - protected.PUT("/scenarios/:id/knowledge-graph", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.ReplaceGraph) + protected.GET("/scenarios/:id/script-package", middleware.RequirePermission("scriptkit.view"), scriptkitHandler.Get) + protected.PUT("/scenarios/:id/script-package", middleware.RequirePermission("scriptkit.edit"), scriptkitHandler.Replace) router.NoRoute(spaHandler(frontend)) return router diff --git a/internal/knowledge/graph.go b/internal/knowledge/graph.go deleted file mode 100644 index 6232bf9..0000000 --- a/internal/knowledge/graph.go +++ /dev/null @@ -1,196 +0,0 @@ -package knowledge - -import ( - "encoding/json" - "fmt" - "net/http" - "regexp" - "strconv" - - "git.iwork-ai.com/xdc/iqudo-top1/internal/access" - "git.iwork-ai.com/xdc/iqudo-top1/internal/audit" - "git.iwork-ai.com/xdc/iqudo-top1/internal/auth" - "git.iwork-ai.com/xdc/iqudo-top1/internal/model" - "git.iwork-ai.com/xdc/iqudo-top1/internal/response" - "github.com/gin-gonic/gin" - "gorm.io/datatypes" - "gorm.io/gorm" -) - -var graphKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,63}$`) - -type GraphInput struct { - Items []GraphItemInput `json:"items"` - Relations []GraphRelationInput `json:"relations"` - Symptoms []SymptomInput `json:"symptoms"` -} - -type GraphItemInput struct { - Key string `json:"key"` - Name string `json:"name"` - Type string `json:"type"` - Content map[string]interface{} `json:"content"` - Status string `json:"status"` - SortOrder int `json:"sort_order"` -} - -type GraphRelationInput struct { - From string `json:"from"` - RelationType string `json:"relation_type"` - To string `json:"to"` - Condition map[string]interface{} `json:"condition"` - SortOrder int `json:"sort_order"` -} - -type SymptomInput struct { - Key string `json:"key"` - Name string `json:"name"` - CopyTemplateIDs []string `json:"copy_template_ids"` - Diseases []GraphItemInput `json:"diseases"` -} - -func (h *Handler) GetGraph(c *gin.Context) { - p, _ := auth.PrincipalFromContext(c) - scenarioID, ok := graphScenarioID(c) - if !ok || !access.CanViewScenario(h.db, p, scenarioID) { - if ok { - response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在") - } - return - } - items := make([]model.KnowledgeItem, 0) - relations := make([]model.KnowledgeRelation, 0) - if err := h.db.Where("tenant_id = ? AND scenario_id = ? AND status <> ?", p.TenantID, scenarioID, "archived").Order("sort_order, id").Find(&items).Error; err != nil { - response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识失败") - return - } - if err := h.db.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Order("sort_order, id").Find(&relations).Error; err != nil { - response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识关系失败") - return - } - response.OK(c, gin.H{"items": items, "relations": relations}) -} - -func (h *Handler) ReplaceGraph(c *gin.Context) { - p, _ := auth.PrincipalFromContext(c) - scenarioID, ok := graphScenarioID(c) - if !ok || !access.CanEditScenario(h.db, p, scenarioID) { - if ok { - response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑") - } - return - } - var input GraphInput - if err := c.ShouldBindJSON(&input); err != nil { - response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识 JSON 格式不正确") - return - } - items, relations, err := normalizeGraphInput(input) - if err != nil { - response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error()) - return - } - err = h.db.Transaction(func(tx *gorm.DB) error { - var oldItems []model.KnowledgeItem - var oldRelations []model.KnowledgeRelation - if err := tx.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Find(&oldItems).Error; err != nil { - return err - } - if err := tx.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Find(&oldRelations).Error; err != nil { - return err - } - if err := tx.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Delete(&model.KnowledgeRelation{}).Error; err != nil { - return err - } - if err := tx.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Delete(&model.KnowledgeItem{}).Error; err != nil { - return err - } - ids := make(map[string]uint64, len(items)) - for _, item := range items { - raw, _ := json.Marshal(item.Content) - row := model.KnowledgeItem{TenantID: p.TenantID, ScenarioID: scenarioID, ItemKey: item.Key, Name: item.Name, Type: item.Type, Content: datatypes.JSON(raw), Status: item.Status, SortOrder: item.SortOrder} - if err := tx.Create(&row).Error; err != nil { - return err - } - ids[item.Key] = row.ID - if err := audit.RecordTx(tx, p, "create", "knowledge_item", row.ID, gin.H{"scenario_id": scenarioID, "key": row.ItemKey}); err != nil { - return err - } - } - for _, relation := range relations { - raw, _ := json.Marshal(relation.Condition) - row := model.KnowledgeRelation{TenantID: p.TenantID, ScenarioID: scenarioID, FromKnowledgeID: ids[relation.From], RelationType: relation.RelationType, ToKnowledgeID: ids[relation.To], Condition: datatypes.JSON(raw), SortOrder: relation.SortOrder} - if err := tx.Create(&row).Error; err != nil { - return err - } - if err := audit.RecordTx(tx, p, "create", "knowledge_relation", row.ID, gin.H{"scenario_id": scenarioID, "from": relation.From, "relation_type": relation.RelationType, "to": relation.To}); err != nil { - return err - } - } - for _, relation := range oldRelations { - if err := audit.RecordTx(tx, p, "archive", "knowledge_relation", relation.ID, gin.H{"scenario_id": scenarioID, "from_knowledge_id": relation.FromKnowledgeID, "relation_type": relation.RelationType, "to_knowledge_id": relation.ToKnowledgeID, "sort_order": relation.SortOrder}); err != nil { - return err - } - } - for _, item := range oldItems { - if err := audit.RecordTx(tx, p, "archive", "knowledge_item", item.ID, gin.H{"scenario_id": scenarioID, "key": item.ItemKey, "name": item.Name, "type": item.Type, "status": item.Status, "sort_order": item.SortOrder}); err != nil { - return err - } - } - return nil - }) - if err != nil { - response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存知识关系失败") - return - } - h.GetGraph(c) -} - -func normalizeGraphInput(input GraphInput) ([]GraphItemInput, []GraphRelationInput, error) { - items := append([]GraphItemInput{}, input.Items...) - relations := append([]GraphRelationInput{}, input.Relations...) - for _, symptom := range input.Symptoms { - items = append(items, GraphItemInput{Key: symptom.Key, Name: symptom.Name, Type: "symptom", Status: "active"}) - for _, disease := range symptom.Diseases { - disease.Type = "disease" - items = append(items, disease) - relations = append(relations, GraphRelationInput{From: symptom.Key, RelationType: "possible_disease", To: disease.Key}) - } - for _, copyKey := range symptom.CopyTemplateIDs { - relations = append(relations, GraphRelationInput{From: symptom.Key, RelationType: "recommended_copy", To: copyKey}) - } - } - seen := map[string]bool{} - unique := make([]GraphItemInput, 0, len(items)) - for _, item := range items { - if !graphKeyPattern.MatchString(item.Key) || item.Name == "" || item.Type == "" { - return nil, nil, fmt.Errorf("知识 key、name 和 type 必须填写且格式正确") - } - if seen[item.Key] { - continue - } - seen[item.Key] = true - if item.Status == "" { - item.Status = "active" - } - if item.Content == nil { - item.Content = map[string]interface{}{} - } - unique = append(unique, item) - } - for _, relation := range relations { - if !seen[relation.From] || !seen[relation.To] || relation.RelationType == "" { - return nil, nil, fmt.Errorf("知识关系引用了不存在的 key") - } - } - return unique, relations, nil -} - -func graphScenarioID(c *gin.Context) (uint64, bool) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil || id == 0 { - response.Error(c, http.StatusBadRequest, "INVALID_ID", "场景 ID 不正确") - return 0, false - } - return id, true -} diff --git a/internal/knowledge/graph_test.go b/internal/knowledge/graph_test.go deleted file mode 100644 index 5ecce8e..0000000 --- a/internal/knowledge/graph_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package knowledge - -import "testing" - -func TestNormalizeGraphInputSupportsMultipleDiseases(t *testing.T) { - items, relations, err := normalizeGraphInput(GraphInput{Symptoms: []SymptomInput{{Key: "poor_appetite", Name: "食欲下降", Diseases: []GraphItemInput{{Key: "gi", Name: "肠胃不适"}, {Key: "dental", Name: "口腔问题"}}}}}) - if err != nil { - t.Fatal(err) - } - if len(items) != 3 || len(relations) != 2 { - t.Fatalf("items=%d relations=%d", len(items), len(relations)) - } -} diff --git a/internal/knowledge/handler.go b/internal/knowledge/handler.go deleted file mode 100644 index 29fe11b..0000000 --- a/internal/knowledge/handler.go +++ /dev/null @@ -1,12 +0,0 @@ -package knowledge - -import "gorm.io/gorm" - -// Handler exposes the scenario knowledge-graph API. -type Handler struct { - db *gorm.DB -} - -func NewHandler(db *gorm.DB) *Handler { - return &Handler{db: db} -} diff --git a/internal/model/models.go b/internal/model/models.go index daf6d9d..89ce4cd 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -122,46 +122,23 @@ type SOPEdge struct { Priority int `json:"priority" gorm:"not null"` } -type KnowledgeItem struct { - Base - TenantID uint64 `json:"tenant_id" gorm:"not null;index"` - ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"` - ItemKey string `json:"key" gorm:"size:64;not null"` - Name string `json:"name" gorm:"size:128;not null"` - Type string `json:"type" gorm:"size:64;not null"` - Content datatypes.JSON `json:"content" gorm:"type:json;not null"` - Status string `json:"status" gorm:"size:24;not null"` - SortOrder int `json:"sort_order" gorm:"not null"` -} - -type KnowledgeRelation struct { - Base - TenantID uint64 `json:"tenant_id" gorm:"not null;index"` - ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"` - FromKnowledgeID uint64 `json:"from_knowledge_id" gorm:"not null;index"` - RelationType string `json:"relation_type" gorm:"size:64;not null"` - ToKnowledgeID uint64 `json:"to_knowledge_id" gorm:"not null;index"` - Condition datatypes.JSON `json:"condition" gorm:"type:json;not null"` - SortOrder int `json:"sort_order" gorm:"not null"` -} - type SOPRun struct { Base - TenantID uint64 `json:"tenant_id" gorm:"not null;index"` - SOPID uint64 `json:"sop_id" gorm:"not null;index"` - OperatorID uint64 `json:"operator_id" gorm:"not null;index"` - ExternalRef string `json:"external_ref" gorm:"size:191;not null;index"` - CurrentNodeKey string `json:"current_node_key" gorm:"size:64;not null"` - Status string `json:"status" gorm:"size:24;not null"` - Answers datatypes.JSON `json:"answers" gorm:"type:json;not null"` - Input datatypes.JSON `json:"input" gorm:"type:json;not null"` - Derived datatypes.JSON `json:"derived" gorm:"type:json;not null"` - Outputs datatypes.JSON `json:"outputs" gorm:"type:json;not null"` - KnowledgeSnapshot datatypes.JSON `json:"knowledge_snapshot" gorm:"type:json;not null"` - Result string `json:"result" gorm:"size:64;not null"` - FinalResult datatypes.JSON `json:"final_result" gorm:"type:json"` - StartedAt time.Time `json:"started_at"` - CompletedAt *time.Time `json:"completed_at"` + TenantID uint64 `json:"tenant_id" gorm:"not null;index"` + SOPID uint64 `json:"sop_id" gorm:"not null;index"` + OperatorID uint64 `json:"operator_id" gorm:"not null;index"` + ExternalRef string `json:"external_ref" gorm:"size:191;not null;index"` + CurrentNodeKey string `json:"current_node_key" gorm:"size:64;not null"` + Status string `json:"status" gorm:"size:24;not null"` + Answers datatypes.JSON `json:"answers" gorm:"type:json;not null"` + Input datatypes.JSON `json:"input" gorm:"type:json;not null"` + Derived datatypes.JSON `json:"derived" gorm:"type:json;not null"` + Outputs datatypes.JSON `json:"outputs" gorm:"type:json;not null"` + ScriptState datatypes.JSON `json:"script_state" gorm:"type:json;not null"` + Result string `json:"result" gorm:"size:64;not null"` + FinalResult datatypes.JSON `json:"final_result" gorm:"type:json"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` } type SOPRunEvent struct { diff --git a/internal/model/scriptkit.go b/internal/model/scriptkit.go new file mode 100644 index 0000000..0653495 --- /dev/null +++ b/internal/model/scriptkit.go @@ -0,0 +1,148 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" +) + +// ScriptPackage is the scenario-level script package. It replaces the legacy +// knowledge graph for the consultation scenario. A package owns dimensions, +// dimension values, stages, scripts, options, linkages and entry adapters. +type ScriptPackage struct { + Base + TenantID uint64 `json:"tenant_id" gorm:"not null;index"` + ScenarioID uint64 `json:"scenario_id" gorm:"not null;uniqueIndex"` + Name string `json:"name" gorm:"size:128;not null"` + Status string `json:"status" gorm:"size:24;not null"` + StartStageKey string `json:"start_stage_key" gorm:"size:64;not null"` + CreatedBy uint64 `json:"created_by" gorm:"not null"` +} + +// PackageDimension is a quantifiable dimension (symptom, disease, plan, ...). +type PackageDimension struct { + Base + TenantID uint64 `json:"tenant_id" gorm:"not null;index"` + PackageID uint64 `json:"package_id" gorm:"not null;index"` + DimKey string `json:"dim_key" gorm:"size:64;not null"` + Name string `json:"name" gorm:"size:128;not null"` + SortOrder int `json:"sort_order" gorm:"not null"` +} + +// DimensionValue is one value of a dimension and carries a runtime weight. +type DimensionValue struct { + Base + TenantID uint64 `json:"tenant_id" gorm:"not null;index"` + PackageID uint64 `json:"package_id" gorm:"not null;index"` + DimensionID uint64 `json:"dimension_id" gorm:"not null;index"` + ValueKey string `json:"value_key" gorm:"size:64;not null"` + Name string `json:"name" gorm:"size:128;not null"` + InitialWeight int `json:"initial_weight" gorm:"not null"` + SortOrder int `json:"sort_order" gorm:"not null"` +} + +// PackageStage is a SOP stage inside a script package. Each stage has a primary +// dimension that drives script matching. +type PackageStage struct { + Base + TenantID uint64 `json:"tenant_id" gorm:"not null;index"` + PackageID uint64 `json:"package_id" gorm:"not null;index"` + StageKey string `json:"stage_key" gorm:"size:64;not null"` + Name string `json:"name" gorm:"size:128;not null"` + Purpose string `json:"purpose" gorm:"type:text;not null"` + PrimaryDimensionID uint64 `json:"primary_dimension_id" gorm:"not null;index"` + SortOrder int `json:"sort_order" gorm:"not null"` +} + +// StageScript is a script shown inside a stage. ScriptType is one of +// confirm / info / choice / template / fallback / message. Products is the +// static product list attached to recommendation scripts. Multiple marks a +// choice script whose options can be selected together. +type StageScript struct { + Base + TenantID uint64 `json:"tenant_id" gorm:"not null;index"` + PackageID uint64 `json:"package_id" gorm:"not null;index"` + StageID uint64 `json:"stage_id" gorm:"not null;index"` + ScriptKey string `json:"script_key" gorm:"size:64;not null"` + Name string `json:"name" gorm:"size:128;not null"` + ScriptType string `json:"script_type" gorm:"size:32;not null"` + Content string `json:"content" gorm:"type:text;not null"` + DimensionValueID *uint64 `json:"dimension_value_id" gorm:"index"` + ShowThreshold int `json:"show_threshold" gorm:"not null"` + ConfirmThreshold int `json:"confirm_threshold" gorm:"not null"` + CollectFieldKey string `json:"collect_field_key" gorm:"size:64;not null"` + Required bool `json:"required" gorm:"not null"` + Multiple bool `json:"multiple" gorm:"not null"` + Products datatypes.JSON `json:"products" gorm:"type:json;not null"` + SortOrder int `json:"sort_order" gorm:"not null"` +} + +// ScriptOption is one answer option of a script. Effect is set/add/subtract/zero +// and targets a dimension value. +type ScriptOption struct { + Base + TenantID uint64 `json:"tenant_id" gorm:"not null;index"` + PackageID uint64 `json:"package_id" gorm:"not null;index"` + ScriptID uint64 `json:"script_id" gorm:"not null;index"` + OptionKey string `json:"option_key" gorm:"size:64;not null"` + Label string `json:"label" gorm:"size:128;not null"` + TargetDimensionValueID *uint64 `json:"target_dimension_value_id" gorm:"index"` + Effect string `json:"effect" gorm:"size:16;not null"` + EffectValue int `json:"effect_value" gorm:"not null"` + SortOrder int `json:"sort_order" gorm:"not null"` +} + +// DimensionLinkage propagates a confirmed source dimension value weight into +// a target dimension value (for example symptom -> disease). A source counts +// as confirmed only when its weight reaches ActivationThreshold (engine +// default 5 when the value is zero). +type DimensionLinkage struct { + Base + TenantID uint64 `json:"tenant_id" gorm:"not null;index"` + PackageID uint64 `json:"package_id" gorm:"not null;index"` + FromDimensionValueID uint64 `json:"from_dimension_value_id" gorm:"not null;index"` + ToDimensionValueID uint64 `json:"to_dimension_value_id" gorm:"not null;index"` + RelationType string `json:"relation_type" gorm:"size:64;not null"` + Contribution int `json:"contribution" gorm:"not null"` + ActivationThreshold int `json:"activation_threshold" gorm:"not null"` + Condition datatypes.JSON `json:"condition" gorm:"type:json;not null"` + SortOrder int `json:"sort_order" gorm:"not null"` +} + +// PackageAdapter initializes a dimension value weight from an incoming or +// derived field value (for example an order product id or symptom tag). +type PackageAdapter struct { + Base + TenantID uint64 `json:"tenant_id" gorm:"not null;index"` + PackageID uint64 `json:"package_id" gorm:"not null;index"` + SourceField string `json:"source_field" gorm:"size:128;not null"` + MatchValue string `json:"match_value" gorm:"size:191;not null"` + TargetDimensionValueID uint64 `json:"target_dimension_value_id" gorm:"not null;index"` + Weight int `json:"weight" gorm:"not null"` + SortOrder int `json:"sort_order" gorm:"not null"` +} + +// RunDimension is the materialized current weight of one dimension value in a +// run. It is recomputed deterministically after every mutation. +type RunDimension struct { + Base + TenantID uint64 `json:"tenant_id" gorm:"not null;index"` + RunID uint64 `json:"run_id" gorm:"not null;index"` + PackageID uint64 `json:"package_id" gorm:"not null;index"` + DimensionID uint64 `json:"dimension_id" gorm:"not null;index"` + ValueKey string `json:"value_key" gorm:"size:64;not null"` + Weight int `json:"weight" gorm:"not null"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ScriptFeedback records like/report/unreasonable feedback on a script. +type ScriptFeedback struct { + Base + TenantID uint64 `json:"tenant_id" gorm:"not null;index"` + RunID uint64 `json:"run_id" gorm:"not null;index"` + StageID uint64 `json:"stage_id" gorm:"not null;index"` + ScriptID uint64 `json:"script_id" gorm:"not null;index"` + FeedbackType string `json:"feedback_type" gorm:"size:32;not null"` + OperatorID uint64 `json:"operator_id" gorm:"not null;index"` + Note string `json:"note" gorm:"type:text;not null"` +} diff --git a/internal/multitable/outbox.go b/internal/multitable/outbox.go index bf015cf..9a0ce78 100644 --- a/internal/multitable/outbox.go +++ b/internal/multitable/outbox.go @@ -18,8 +18,6 @@ func EnqueueBackfill(db *gorm.DB) (int, error) { {"scenario_field", &model.ScenarioField{}}, {"scenario_rule", &model.ScenarioRule{}}, {"sop", &model.SOP{}}, - {"knowledge_item", &model.KnowledgeItem{}}, - {"knowledge_relation", &model.KnowledgeRelation{}}, {"sop_run", &model.SOPRun{}}, } count := 0 diff --git a/internal/multitable/projector.go b/internal/multitable/projector.go index f3b807d..1fe1a23 100644 --- a/internal/multitable/projector.go +++ b/internal/multitable/projector.go @@ -33,10 +33,6 @@ func (p *Projector) Project(ctx context.Context, event model.MultiTableOutbox) e return p.scenarioRule(ctx, event) case "sop": return p.sop(ctx, event) - case "knowledge_item": - return p.knowledgeItem(ctx, event) - case "knowledge_relation": - return p.knowledgeRelation(ctx, event) case "sop_run": if err := p.run(ctx, event); err != nil { return err @@ -184,55 +180,6 @@ func (p *Projector) sop(ctx context.Context, event model.MultiTableOutbox) error return nil } -func (p *Projector) knowledgeItem(ctx context.Context, event model.MultiTableOutbox) error { - var item model.KnowledgeItem - err := p.db.Where("id = ? AND tenant_id = ?", event.ResourceID, event.TenantID).First(&item).Error - if err == nil { - return p.client.Upsert(ctx, p.tables.KnowledgeItems, id(item.ID), knowledgeItemProjection(item, statusFor(item.Status))) - } - if !errors.Is(err, gorm.ErrRecordNotFound) || event.Action != "archive" { - return err - } - var payload struct { - ScenarioID uint64 `json:"scenario_id"` - Key string `json:"key"` - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - SortOrder int `json:"sort_order"` - } - if err := json.Unmarshal(event.Payload, &payload); err != nil { - return err - } - return p.client.Upsert(ctx, p.tables.KnowledgeItems, id(event.ResourceID), common(event.ResourceID, event.TenantID, "已归档", event.UpdatedAt, map[string]interface{}{ - "场景来源ID": id(payload.ScenarioID), "知识标识": payload.Key, "名称": payload.Name, "知识类型": payload.Type, "知识状态": payload.Status, "排序": payload.SortOrder, - })) -} - -func (p *Projector) knowledgeRelation(ctx context.Context, event model.MultiTableOutbox) error { - var item model.KnowledgeRelation - err := p.db.Where("id = ? AND tenant_id = ?", event.ResourceID, event.TenantID).First(&item).Error - if err == nil { - return p.client.Upsert(ctx, p.tables.KnowledgeRelations, id(item.ID), knowledgeRelationProjection(item, "正常")) - } - if !errors.Is(err, gorm.ErrRecordNotFound) || event.Action != "archive" { - return err - } - var payload struct { - ScenarioID uint64 `json:"scenario_id"` - FromKnowledge uint64 `json:"from_knowledge_id"` - RelationType string `json:"relation_type"` - ToKnowledge uint64 `json:"to_knowledge_id"` - SortOrder int `json:"sort_order"` - } - if err := json.Unmarshal(event.Payload, &payload); err != nil { - return err - } - return p.client.Upsert(ctx, p.tables.KnowledgeRelations, id(event.ResourceID), common(event.ResourceID, event.TenantID, "已归档", event.UpdatedAt, map[string]interface{}{ - "场景来源ID": id(payload.ScenarioID), "起点知识来源ID": id(payload.FromKnowledge), "关系类型": payload.RelationType, "终点知识来源ID": id(payload.ToKnowledge), "排序": payload.SortOrder, - })) -} - func (p *Projector) run(ctx context.Context, event model.MultiTableOutbox) error { var row struct { model.SOPRun @@ -282,14 +229,6 @@ func scenarioRuleProjection(item model.ScenarioRule, status string) map[string]i return common(item.ID, item.TenantID, status, item.UpdatedAt, map[string]interface{}{"场景来源ID": id(item.ScenarioID), "规则标识": item.RuleKey, "名称": item.Name, "优先级": item.Priority, "规则状态": item.Status}) } -func knowledgeItemProjection(item model.KnowledgeItem, status string) map[string]interface{} { - return common(item.ID, item.TenantID, status, item.UpdatedAt, map[string]interface{}{"场景来源ID": id(item.ScenarioID), "知识标识": item.ItemKey, "名称": item.Name, "知识类型": item.Type, "知识状态": item.Status, "排序": item.SortOrder}) -} - -func knowledgeRelationProjection(item model.KnowledgeRelation, status string) map[string]interface{} { - return common(item.ID, item.TenantID, status, item.UpdatedAt, map[string]interface{}{"场景来源ID": id(item.ScenarioID), "起点知识来源ID": id(item.FromKnowledgeID), "关系类型": item.RelationType, "终点知识来源ID": id(item.ToKnowledgeID), "排序": item.SortOrder}) -} - func id(value uint64) string { return strconv.FormatUint(value, 10) } func formatTime(value *time.Time) string { if value == nil { diff --git a/internal/multitable/projector_test.go b/internal/multitable/projector_test.go index 184d940..4642035 100644 --- a/internal/multitable/projector_test.go +++ b/internal/multitable/projector_test.go @@ -15,8 +15,6 @@ func TestNewResourceProjections(t *testing.T) { want map[string]interface{} }{ {"scenario rule", scenarioRuleProjection(model.ScenarioRule{Base: model.Base{ID: 11, UpdatedAt: now}, TenantID: 2, ScenarioID: 3, RuleKey: "match", Name: "匹配", Priority: 10, Status: "active"}, "正常"), map[string]interface{}{"来源ID": "11", "场景来源ID": "3", "规则标识": "match", "优先级": 10}}, - {"knowledge item", knowledgeItemProjection(model.KnowledgeItem{Base: model.Base{ID: 12, UpdatedAt: now}, TenantID: 2, ScenarioID: 3, ItemKey: "soft", Name: "软便", Type: "symptom", Status: "active", SortOrder: 4}, "正常"), map[string]interface{}{"来源ID": "12", "知识标识": "soft", "知识类型": "symptom", "排序": 4}}, - {"knowledge relation", knowledgeRelationProjection(model.KnowledgeRelation{Base: model.Base{ID: 13, UpdatedAt: now}, TenantID: 2, ScenarioID: 3, FromKnowledgeID: 12, RelationType: "recommended_copy", ToKnowledgeID: 14, SortOrder: 5}, "正常"), map[string]interface{}{"来源ID": "13", "起点知识来源ID": "12", "关系类型": "recommended_copy", "终点知识来源ID": "14"}}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/internal/run/detail.go b/internal/run/detail.go index 0af6eb7..285c427 100644 --- a/internal/run/detail.go +++ b/internal/run/detail.go @@ -1,7 +1,6 @@ package run import ( - "encoding/json" "net/http" "git.iwork-ai.com/xdc/iqudo-top1/internal/auth" @@ -20,11 +19,10 @@ type DetailHeader struct { type DetailEvent struct { model.SOPRunEvent - NodeTitle string `json:"node_title"` - NodeType string `json:"node_type"` - NodeContent string `json:"node_content"` - NodeConfig datatypes.JSON `json:"-"` - Outputs []KnowledgeGroup `json:"outputs,omitempty" gorm:"-"` + NodeTitle string `json:"node_title"` + NodeType string `json:"node_type"` + NodeContent string `json:"node_content"` + NodeConfig datatypes.JSON `json:"-"` } type DetailFeedback struct { @@ -51,33 +49,15 @@ func (h *Handler) Detail(c *gin.Context) { response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行事件失败") return } - answers := map[string]interface{}{} - _ = json.Unmarshal(header.Answers, &answers) - derived := map[string]interface{}{} - _ = json.Unmarshal(header.Derived, &derived) - input := map[string]interface{}{} - _ = json.Unmarshal(header.Input, &input) - context := runtimeContext(input, derived, answers) - context["__knowledge_snapshot"] = json.RawMessage(header.KnowledgeSnapshot) - for i := range events { - if events[i].NodeType != "knowledge" { - continue - } - var config knowledgeNodeConfig - _ = json.Unmarshal(events[i].NodeConfig, &config) - if config.KnowledgeSelector == nil { - continue - } - var node model.SOPNode - if err := h.db.Where("sop_id = ? AND node_key = ?", header.SOPID, events[i].NodeKey).First(&node).Error; err != nil { - continue - } - outputs, loadErr := loadKnowledgeOutputs(h.db, node, principal.TenantID, context, *config.KnowledgeSelector) - if loadErr != nil { - response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "执行记录知识快照不可用") - return - } - events[i].Outputs = outputs + dimensions := make([]model.RunDimension, 0) + if err := h.db.Where("run_id = ? AND tenant_id = ?", id, principal.TenantID).Order("id").Find(&dimensions).Error; err != nil { + response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询维度状态失败") + return + } + scriptFeedback := make([]model.ScriptFeedback, 0) + if err := h.db.Where("run_id = ? AND tenant_id = ?", id, principal.TenantID).Order("created_at").Find(&scriptFeedback).Error; err != nil { + response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询话术反馈失败") + return } feedback := make([]DetailFeedback, 0) if err := h.db.Table("sop_feedback f").Select("f.*, u.display_name AS user_name").Joins("JOIN users u ON u.id = f.user_id").Where("f.run_id = ? AND f.tenant_id = ?", id, principal.TenantID).Order("f.created_at").Scan(&feedback).Error; err != nil { @@ -92,5 +72,5 @@ func (h *Handler) Detail(c *gin.Context) { if header.Answers == nil { header.Answers = datatypes.JSON([]byte(`{}`)) } - response.OK(c, gin.H{"run": header, "events": events, "feedback": feedback, "fields": fields}) + response.OK(c, gin.H{"run": header, "events": events, "feedback": feedback, "fields": fields, "dimensions": dimensions, "script_feedback": scriptFeedback}) } diff --git a/internal/run/handler.go b/internal/run/handler.go index 2c60cdc..1dbbae6 100644 --- a/internal/run/handler.go +++ b/internal/run/handler.go @@ -14,6 +14,7 @@ import ( "git.iwork-ai.com/xdc/iqudo-top1/internal/model" "git.iwork-ai.com/xdc/iqudo-top1/internal/response" "git.iwork-ai.com/xdc/iqudo-top1/internal/resultcontract" + "git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit" "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/datatypes" @@ -125,20 +126,21 @@ func (h *Handler) Start(c *gin.Context) { return } derivedRaw, _ := json.Marshal(derived) - knowledgeRaw, err := snapshotKnowledge(h.db, p.TenantID, sopItem.ScenarioID) - if err != nil { - response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_SNAPSHOT_FAILED", "生成知识快照失败") - return - } externalRef := input.ExternalRef if externalRef == "" { externalRef = "run-" + uuid.NewString() } - run := model.SOPRun{TenantID: p.TenantID, SOPID: input.SOPID, OperatorID: p.UserID, ExternalRef: externalRef, CurrentNodeKey: sop.StartNodeKey, Status: "running", Answers: datatypes.JSON(initialAnswers), Input: datatypes.JSON(initialAnswers), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), KnowledgeSnapshot: datatypes.JSON(knowledgeRaw), Result: "", StartedAt: time.Now()} + scriptStateRaw, _ := json.Marshal(scriptkit.ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}) + run := model.SOPRun{TenantID: p.TenantID, SOPID: input.SOPID, OperatorID: p.UserID, ExternalRef: externalRef, CurrentNodeKey: sop.StartNodeKey, Status: "running", Answers: datatypes.JSON(initialAnswers), Input: datatypes.JSON(initialAnswers), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), ScriptState: datatypes.JSON(scriptStateRaw), Result: "", StartedAt: time.Now()} err = h.db.Transaction(func(tx *gorm.DB) error { if err := tx.Create(&run).Error; err != nil { return err } + if pkg, loadErr := loadRunPackage(tx, run); loadErr == nil { + if persistErr := persistRunWeights(tx, run, pkg, runWeights(run, pkg)); persistErr != nil { + return persistErr + } + } payload, err := json.Marshal(gin.H{"source": "scenario_input", "mapped_field_keys": sortedKeys(normalizedInput), "matched_rule_keys": matchedRules}) if err != nil { return err @@ -235,16 +237,27 @@ func (h *Handler) Options(c *gin.Context) { response.OK(c, gin.H{"sops": sops, "operators": operators}) } +// answerInput accepts both legacy node answers and stage script answers. +type answerInput struct { + NodeKey string `json:"node_key"` + Answers map[string]interface{} `json:"answers"` + // stage-specific fields + ScriptKey string `json:"script_key"` + OptionKeys []string `json:"option_keys"` + Value string `json:"value"` + ScriptAnswers []stageScriptAnswerInput `json:"script_answers"` + DimensionSelects map[string]bool `json:"dimension_selects"` + DimensionValueKey string `json:"dimension_value_key"` + Selected *bool `json:"selected"` +} + func (h *Handler) Answer(c *gin.Context) { p, _ := auth.PrincipalFromContext(c) id, ok := runID(c) if !ok { return } - var input struct { - NodeKey string `json:"node_key"` - Answers map[string]interface{} `json:"answers"` - } + var input answerInput if err := c.ShouldBindJSON(&input); err != nil { response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "回答格式不正确") return @@ -267,6 +280,9 @@ func (h *Handler) Answer(c *gin.Context) { if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPID, updated.CurrentNodeKey, p.TenantID).First(¤tNode).Error; err != nil { return err } + if currentNode.Type == "stage" { + return applyStageAnswer(tx, &updated, currentNode, stageAnswerInput{NodeKey: input.NodeKey, ScriptKey: input.ScriptKey, OptionKeys: input.OptionKeys, Value: input.Value, ScriptAnswers: input.ScriptAnswers, DimensionSelects: input.DimensionSelects, Answers: input.Answers, DimensionValueKey: input.DimensionValueKey, Selected: input.Selected}) + } fields := make([]model.ScenarioField, 0) if err := tx.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", updated.SOPID, p.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil { return err @@ -281,53 +297,16 @@ func (h *Handler) Answer(c *gin.Context) { for key, value := range input.Answers { answers[key] = value } - if err := validateKnowledgeSelections(currentNode, updated, answers); err != nil { - return err - } - var edges []model.SOPEdge - if err := tx.Where("sop_id = ? AND source_node_key = ? AND tenant_id = ?", updated.SOPID, updated.CurrentNodeKey, p.TenantID).Order("priority, id").Find(&edges).Error; err != nil { - return err - } - sortEdges(edges) - nextKey := "" - for _, edge := range edges { - matched, matchErr := matchCondition(json.RawMessage(edge.Condition), answers) - if matchErr != nil { - return matchErr - } - if matched { - nextKey = edge.TargetNodeKey - break - } - } - if nextKey == "" { - return errors.New("没有满足条件的下一节点") - } answerBytes, _ := json.Marshal(answers) payload, _ := json.Marshal(input) if err := tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: updated.ID, NodeKey: updated.CurrentNodeKey, Action: "answer", Payload: datatypes.JSON(payload)}).Error; err != nil { return err } - var next model.SOPNode - if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPID, nextKey, p.TenantID).First(&next).Error; err != nil { - return err - } - updates := map[string]interface{}{"current_node_key": nextKey, "answers": datatypes.JSON(answerBytes)} - if next.Type == "finish" || next.Type == "escalate" { - now := time.Now() - updates["status"] = "completed" - updates["completed_at"] = &now - updates["result"] = next.Type - updated.Status = "completed" - updated.CompletedAt = &now - updated.Result = next.Type - } - if err := tx.Model(&updated).Updates(updates).Error; err != nil { - return err - } - updated.CurrentNodeKey = nextKey updated.Answers = datatypes.JSON(answerBytes) - return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: updated.ID, NodeKey: nextKey, Action: "enter", Payload: datatypes.JSON([]byte(`{}`))}).Error + if err := tx.Model(&updated).Update("answers", updated.Answers).Error; err != nil { + return err + } + return advanceRunNode(tx, &updated) }) if err != nil { response.Error(c, http.StatusUnprocessableEntity, "ADVANCE_FAILED", err.Error()) @@ -336,6 +315,127 @@ func (h *Handler) Answer(c *gin.Context) { h.respondRun(c, updated) } +// Next advances a stage run to the next node after checking stage completion. +func (h *Handler) Next(c *gin.Context) { + p, _ := auth.PrincipalFromContext(c) + id, ok := runID(c) + if !ok { + return + } + var updated model.SOPRun + err := h.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&updated).Error; err != nil { + return err + } + if updated.Status != "running" { + return errors.New("run is not active") + } + if !canOperateRun(p, updated) { + return gorm.ErrRecordNotFound + } + var currentNode model.SOPNode + if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPID, updated.CurrentNodeKey, p.TenantID).First(¤tNode).Error; err != nil { + return err + } + if currentNode.Type == "stage" { + if err := advanceFromStage(tx, &updated, currentNode); err != nil { + return err + } + } else if currentNode.Type == "question" || currentNode.Type == "choice" || currentNode.Type == "form" { + return errors.New("当前节点需要先提交表单") + } else { + if err := advanceRunNode(tx, &updated); err != nil { + return err + } + } + return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: updated.ID, NodeKey: updated.CurrentNodeKey, Action: "next", Payload: datatypes.JSON([]byte(`{}`))}).Error + }) + if err != nil { + response.Error(c, http.StatusUnprocessableEntity, "ADVANCE_FAILED", err.Error()) + return + } + h.respondRun(c, updated) +} + +// Back moves the run to the previous node. +func (h *Handler) Back(c *gin.Context) { + p, _ := auth.PrincipalFromContext(c) + id, ok := runID(c) + if !ok { + return + } + var updated model.SOPRun + err := h.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&updated).Error; err != nil { + return err + } + if updated.Status != "running" { + return errors.New("run is not active") + } + if !canOperateRun(p, updated) { + return gorm.ErrRecordNotFound + } + if err := backRunNode(tx, &updated); err != nil { + return err + } + return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: updated.ID, NodeKey: updated.CurrentNodeKey, Action: "back", Payload: datatypes.JSON([]byte(`{}`))}).Error + }) + if err != nil { + response.Error(c, http.StatusUnprocessableEntity, "ADVANCE_FAILED", err.Error()) + return + } + h.respondRun(c, updated) +} + +// ScriptFeedback records like/report/unreasonable feedback on a script. +func (h *Handler) ScriptFeedback(c *gin.Context) { + p, _ := auth.PrincipalFromContext(c) + id, ok := runID(c) + if !ok { + return + } + var input struct { + NodeKey string `json:"node_key"` + ScriptKey string `json:"script_key" binding:"required"` + FeedbackType string `json:"feedback_type" binding:"required"` + Note string `json:"note" binding:"max=2000"` + } + if err := c.ShouldBindJSON(&input); err != nil { + response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "话术反馈格式不正确") + return + } + if input.FeedbackType != "like" && input.FeedbackType != "report" && input.FeedbackType != "unreasonable" { + response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "话术反馈类型不正确") + return + } + var run model.SOPRun + if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&run).Error; err != nil || !canViewRun(p, run) { + response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在") + return + } + pkg, err := loadRunPackage(h.db, run) + if err != nil { + response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景没有配置话术包") + return + } + script, ok := pkg.ScriptByKey(input.ScriptKey) + if !ok { + response.Error(c, http.StatusNotFound, "NOT_FOUND", "话术不存在") + return + } + item := model.ScriptFeedback{TenantID: p.TenantID, RunID: run.ID, StageID: script.StageID, ScriptID: script.ID, FeedbackType: input.FeedbackType, OperatorID: p.UserID, Note: input.Note} + if err := h.db.Create(&item).Error; err != nil { + response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存话术反馈失败") + return + } + payload, _ := json.Marshal(input) + if err := h.db.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: run.ID, NodeKey: input.NodeKey, Action: "script_feedback", Payload: datatypes.JSON(payload)}).Error; err != nil { + response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存话术反馈失败") + return + } + response.OK(c, gin.H{"recorded": true}) +} + func sortEdges(edges []model.SOPEdge) { sort.SliceStable(edges, func(i, j int) bool { leftDefault := defaultCondition(edges[i].Condition) @@ -368,6 +468,40 @@ func defaultCondition(raw []byte) bool { return ok && len(object) == 0 } +func (h *Handler) RecordScriptUsage(c *gin.Context) { + p, _ := auth.PrincipalFromContext(c) + id, ok := runID(c) + if !ok { + return + } + var input struct { + NodeKey string `json:"node_key"` + ScriptKey string `json:"script_key"` + ScriptTitle string `json:"script_title"` + Template string `json:"template"` + Scene string `json:"scene"` + } + if err := c.ShouldBindJSON(&input); err != nil { + response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "话术使用记录格式不正确") + return + } + var run model.SOPRun + if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&run).Error; err != nil { + response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在") + return + } + if !canOperateRun(p, run) { + response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在") + return + } + payload, _ := json.Marshal(input) + if err := h.db.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: run.ID, NodeKey: input.NodeKey, Action: "script_used", Payload: datatypes.JSON(payload)}).Error; err != nil { + response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存话术使用记录失败") + return + } + response.OK(c, gin.H{"recorded": true}) +} + func (h *Handler) Finish(c *gin.Context) { p, _ := auth.PrincipalFromContext(c) id, ok := runID(c) @@ -492,20 +626,12 @@ func (h *Handler) respondRun(c *gin.Context, item model.SOPRun) { response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在") return } - answers := map[string]interface{}{} - _ = json.Unmarshal(item.Answers, &answers) - derived := map[string]interface{}{} - _ = json.Unmarshal(item.Derived, &derived) - input := map[string]interface{}{} - _ = json.Unmarshal(item.Input, &input) - context := runtimeContext(input, derived, answers) - context["__knowledge_snapshot"] = json.RawMessage(item.KnowledgeSnapshot) - nodeView, err := h.nodeView(h.db, node, item.TenantID, context) + nodeView, err := nodeView(h.db, item, node) if err != nil { - response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "当前节点关联的历史知识内容不存在") + response.Error(c, http.StatusInternalServerError, "STAGE_FAILED", "生成当前阶段内容失败") return } - outputs, err := buildScenarioOutputs(h.db, item, nodeView.Outputs) + outputs, err := buildScenarioOutputs(h.db, item) if err != nil { response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成场景输出失败") return diff --git a/internal/run/helpers.go b/internal/run/helpers.go new file mode 100644 index 0000000..70e174f --- /dev/null +++ b/internal/run/helpers.go @@ -0,0 +1,11 @@ +package run + +// containsString reports whether the slice contains the value. +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/internal/run/knowledge.go b/internal/run/knowledge.go deleted file mode 100644 index 48f64e9..0000000 --- a/internal/run/knowledge.go +++ /dev/null @@ -1,510 +0,0 @@ -package run - -import ( - "encoding/json" - "fmt" - "regexp" - "sort" - "strings" - - "git.iwork-ai.com/xdc/iqudo-top1/internal/model" - "gorm.io/gorm" -) - -var knowledgePlaceholderPattern = regexp.MustCompile(`\{\{\s*(?:input|derived|form)\.([A-Za-z][A-Za-z0-9_]*)\s*\}\}`) - -type NodeView struct { - NodeKey string `json:"node_key"` - Type string `json:"type"` - Title string `json:"title"` - Content string `json:"content"` - Config json.RawMessage `json:"config,omitempty"` - Fields []PublicFieldView `json:"fields,omitempty"` - Presentation *NodePresentation `json:"presentation,omitempty"` - Outputs []KnowledgeGroup `json:"outputs"` - Collection *KnowledgeCollectionView `json:"collection,omitempty"` -} - -type PublicFieldView struct { - Key string `json:"key"` - Name string `json:"name"` - Type string `json:"type"` - Required bool `json:"required"` - Options json.RawMessage `json:"options"` - Validation json.RawMessage `json:"validation"` -} - -type KnowledgeGroup struct { - Key string `json:"key"` - Name string `json:"name"` - Type string `json:"type"` - Content json.RawMessage `json:"content"` - Relations map[string][]KnowledgeItemView `json:"relations"` - Suggested bool `json:"suggested,omitempty"` -} - -type KnowledgeItemView struct { - Key string `json:"key"` - Name string `json:"name"` - Type string `json:"type"` - Content json.RawMessage `json:"content"` -} - -type knowledgeNodeConfig struct { - KnowledgeSelector *knowledgeSelector `json:"knowledge_selector"` - KnowledgeCollection *knowledgeCollectionConfig `json:"knowledge_collection"` -} - -type knowledgeCollectionConfig struct { - ContextFieldKeys []string `json:"context_field_keys"` - ContextTitle string `json:"context_title"` - ContextHint string `json:"context_hint"` - SelectionTitle string `json:"selection_title"` - SelectionHint string `json:"selection_hint"` - Steps []knowledgeCollectionStep `json:"steps"` -} - -type knowledgeCollectionStep struct { - FieldKey string `json:"field_key"` - Name string `json:"name"` - Root bool `json:"root"` - CandidateScope string `json:"candidate_scope"` - KnowledgeTypes []string `json:"knowledge_types"` - FromField string `json:"from_field"` - RelationType string `json:"relation_type"` - Required bool `json:"required"` - Multiple bool `json:"multiple"` -} - -type KnowledgeCollectionView struct { - ContextFields []PublicFieldView `json:"context_fields"` - ContextTitle string `json:"context_title"` - ContextHint string `json:"context_hint"` - SelectionTitle string `json:"selection_title"` - SelectionHint string `json:"selection_hint"` - Steps []KnowledgeCollectionStepView `json:"steps"` -} - -type KnowledgeCollectionStepView struct { - FieldKey string `json:"field_key"` - Name string `json:"name"` - Required bool `json:"required"` - Multiple bool `json:"multiple"` - FromField string `json:"from_field,omitempty"` - Options []KnowledgeCollectionOption `json:"options"` -} - -type KnowledgeCollectionOption struct { - Value string `json:"value"` - Label string `json:"label"` - Parents []string `json:"parents,omitempty"` - Suggested bool `json:"suggested,omitempty"` -} - -type knowledgeSelector struct { - DerivedField string `json:"derived_field"` - AnswerField string `json:"answer_field"` - CandidateScope string `json:"candidate_scope"` - KnowledgeTypes []string `json:"knowledge_types"` - KnowledgeKeys []string `json:"knowledge_keys"` - RelationTypes []string `json:"relation_types"` - RelationLabels map[string]string `json:"relation_labels"` -} - -func (h *Handler) nodeView(db *gorm.DB, node model.SOPNode, tenantID uint64, answers map[string]interface{}) (NodeView, error) { - view := NodeView{NodeKey: node.NodeKey, Type: node.Type, Title: node.Title, Content: node.Content, Config: json.RawMessage(node.Config)} - if node.Type == "start" { - presentation, err := loadStartPresentation(db, node, tenantID, answers) - if err != nil { - return view, err - } - view.Presentation = presentation - } - if node.Type == "question" || node.Type == "choice" || node.Type == "form" { - fields, err := loadPublicNodeFields(db, node, tenantID) - if err != nil { - return view, err - } - view.Fields = fields - } - if node.Type != "knowledge" { - return view, nil - } - var config knowledgeNodeConfig - if err := json.Unmarshal(node.Config, &config); err != nil { - return view, err - } - if config.KnowledgeSelector != nil { - outputs, err := loadKnowledgeOutputs(db, node, tenantID, answers, *config.KnowledgeSelector) - view.Outputs = outputs - if err != nil { - return view, err - } - if config.KnowledgeCollection != nil { - collection, err := loadKnowledgeCollection(db, node, tenantID, *config.KnowledgeCollection, outputs, answers) - view.Collection = &collection - return view, err - } - return view, nil - } - return view, nil -} - -func loadKnowledgeCollection(db *gorm.DB, node model.SOPNode, tenantID uint64, config knowledgeCollectionConfig, roots []KnowledgeGroup, context map[string]interface{}) (KnowledgeCollectionView, error) { - fields, err := loadPublicFieldsByKeys(db, node, tenantID, config.ContextFieldKeys) - if err != nil { - return KnowledgeCollectionView{}, err - } - view := KnowledgeCollectionView{ContextFields: fields, ContextTitle: config.ContextTitle, ContextHint: config.ContextHint, SelectionTitle: config.SelectionTitle, SelectionHint: config.SelectionHint, Steps: make([]KnowledgeCollectionStepView, 0, len(config.Steps))} - childrenByParent := map[string]map[string][]string{} - allItems := make([]model.KnowledgeItem, 0) - if raw, ok := context["__knowledge_snapshot"].(json.RawMessage); ok && len(raw) > 0 { - snapshot, parseErr := parseKnowledgeSnapshot(raw) - if parseErr != nil { - return KnowledgeCollectionView{}, parseErr - } - byID := map[uint64]model.KnowledgeItem{} - for _, item := range snapshot.Items { - byID[item.ID] = item - if item.Status == "active" { - allItems = append(allItems, item) - } - } - for _, relation := range snapshot.Relations { - from, fromOK := byID[relation.FromKnowledgeID] - to, toOK := byID[relation.ToKnowledgeID] - if !fromOK || !toOK || from.Status != "active" || to.Status != "active" { - continue - } - if childrenByParent[from.Name] == nil { - childrenByParent[from.Name] = map[string][]string{} - } - childrenByParent[from.Name][relation.RelationType] = appendUnique(childrenByParent[from.Name][relation.RelationType], to.Name) - } - } - suggestedRoots := make(map[string]bool, len(roots)) - rootOptions := make([]KnowledgeCollectionOption, 0, len(roots)) - for _, root := range roots { - suggestedRoots[root.Name] = root.Suggested - rootOptions = append(rootOptions, KnowledgeCollectionOption{Value: root.Name, Label: root.Name, Suggested: root.Suggested}) - } - for _, step := range config.Steps { - item := KnowledgeCollectionStepView{FieldKey: step.FieldKey, Name: step.Name, Required: step.Required, Multiple: step.Multiple, FromField: step.FromField, Options: []KnowledgeCollectionOption{}} - if step.Root { - item.Options = append(item.Options, rootOptions...) - if step.CandidateScope == "all" { - seen := map[string]bool{} - for _, option := range item.Options { - seen[option.Value] = true - } - for _, knowledgeItem := range allItems { - if seen[knowledgeItem.Name] || (len(step.KnowledgeTypes) > 0 && !containsString(step.KnowledgeTypes, knowledgeItem.Type)) { - continue - } - item.Options = append(item.Options, KnowledgeCollectionOption{Value: knowledgeItem.Name, Label: knowledgeItem.Name, Suggested: suggestedRoots[knowledgeItem.Name]}) - } - sort.SliceStable(item.Options, func(i, j int) bool { - if item.Options[i].Suggested != item.Options[j].Suggested { - return item.Options[i].Suggested - } - return item.Options[i].Label < item.Options[j].Label - }) - } - } else { - seen := map[string]*KnowledgeCollectionOption{} - var parentStep *KnowledgeCollectionStepView - for index := range view.Steps { - if view.Steps[index].FieldKey == step.FromField { - parentStep = &view.Steps[index] - break - } - } - if parentStep != nil { - for _, parent := range parentStep.Options { - for _, childName := range childrenByParent[parent.Value][step.RelationType] { - option := seen[childName] - if option == nil { - option = &KnowledgeCollectionOption{Value: childName, Label: childName} - seen[childName] = option - } - option.Parents = appendUnique(option.Parents, parent.Value) - } - } - } - for _, option := range seen { - item.Options = append(item.Options, *option) - } - sort.Slice(item.Options, func(i, j int) bool { return item.Options[i].Label < item.Options[j].Label }) - } - view.Steps = append(view.Steps, item) - } - return view, nil -} - -func loadPublicFieldsByKeys(db *gorm.DB, node model.SOPNode, tenantID uint64, keys []string) ([]PublicFieldView, error) { - if len(keys) == 0 { - return []PublicFieldView{}, nil - } - var fields []model.ScenarioField - if err := db.Table("scenario_fields sf").Select("sf.*").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPID, tenantID, keys).Find(&fields).Error; err != nil { - return nil, err - } - byKey := map[string]model.ScenarioField{} - for _, field := range fields { - byKey[field.FieldKey] = field - } - result := make([]PublicFieldView, 0, len(keys)) - for _, key := range keys { - if field, ok := byKey[key]; ok { - result = append(result, PublicFieldView{Key: field.FieldKey, Name: field.FieldName, Type: field.FieldType, Required: field.Required, Options: json.RawMessage(field.Options), Validation: json.RawMessage(field.Validation)}) - } - } - return result, nil -} - -func appendUnique(values []string, value string) []string { - for _, existing := range values { - if existing == value { - return values - } - } - return append(values, value) -} - -func loadPublicNodeFields(db *gorm.DB, node model.SOPNode, tenantID uint64) ([]PublicFieldView, error) { - var config answerNodeConfig - if err := json.Unmarshal(node.Config, &config); err != nil { - return nil, err - } - keys := config.FieldKeys - if config.FieldKey != "" { - keys = []string{config.FieldKey} - } - if len(keys) == 0 { - return nil, nil - } - var fields []model.ScenarioField - if err := db.Table("scenario_fields sf").Select("sf.*").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPID, tenantID, keys).Find(&fields).Error; err != nil { - return nil, err - } - byKey := make(map[string]model.ScenarioField, len(fields)) - for _, field := range fields { - byKey[field.FieldKey] = field - } - views := make([]PublicFieldView, 0, len(keys)) - for _, key := range keys { - field, ok := byKey[key] - if !ok { - continue - } - views = append(views, PublicFieldView{Key: field.FieldKey, Name: field.FieldName, Type: field.FieldType, Required: field.Required || config.Required || containsString(config.RequiredFieldKeys, key), Options: json.RawMessage(field.Options), Validation: json.RawMessage(field.Validation)}) - } - return views, nil -} - -func loadKnowledgeOutputs(db *gorm.DB, node model.SOPNode, tenantID uint64, context map[string]interface{}, selector knowledgeSelector) ([]KnowledgeGroup, error) { - var sopRow struct{ ScenarioID uint64 } - if err := db.Table("sop_nodes n").Select("s.scenario_id").Joins("JOIN sops s ON s.id = n.sop_id").Where("n.id = ? AND n.tenant_id = ?", node.ID, tenantID).Scan(&sopRow).Error; err != nil { - return nil, err - } - return loadKnowledgeOutputsForScenario(db, sopRow.ScenarioID, tenantID, context, selector) -} - -func loadKnowledgeOutputsForScenario(db *gorm.DB, scenarioID, tenantID uint64, context map[string]interface{}, selector knowledgeSelector) ([]KnowledgeGroup, error) { - keys := append([]string{}, selector.KnowledgeKeys...) - if selector.DerivedField != "" { - value, _ := lookupContextValue(context, "derived."+selector.DerivedField) - keys = append(keys, stringSlice(value)...) - } - if selector.AnswerField != "" { - value, _ := lookupContextValue(context, "form."+selector.AnswerField) - keys = append(keys, stringSlice(value)...) - } - if len(keys) == 0 && selector.CandidateScope != "all" { - return []KnowledgeGroup{}, nil - } - keySet := make(map[string]bool, len(keys)) - for _, key := range keys { - keySet[key] = true - } - items := make([]model.KnowledgeItem, 0) - relations := make([]model.KnowledgeRelation, 0) - snapshotTargets := map[uint64]model.KnowledgeItem{} - if raw, ok := context["__knowledge_snapshot"].(json.RawMessage); ok && len(raw) > 0 { - snapshot, err := parseKnowledgeSnapshot(raw) - if err != nil { - return nil, err - } - for _, item := range snapshot.Items { - snapshotTargets[item.ID] = item - if item.Status != "active" { - continue - } - if selector.CandidateScope != "all" && len(keys) > 0 && !knowledgeCandidateMatches(keySet, item) { - continue - } - if len(selector.KnowledgeTypes) > 0 && !containsString(selector.KnowledgeTypes, item.Type) { - continue - } - items = append(items, item) - } - for _, relation := range snapshot.Relations { - if len(selector.RelationTypes) > 0 && !containsString(selector.RelationTypes, relation.RelationType) { - continue - } - relations = append(relations, relation) - } - } else { - query := db.Where("tenant_id = ? AND scenario_id = ? AND status = ?", tenantID, scenarioID, "active") - if selector.CandidateScope != "all" && len(keys) > 0 { - query = query.Where("item_key IN ? OR name IN ?", keys, keys) - } - if len(selector.KnowledgeTypes) > 0 { - query = query.Where("type IN ?", selector.KnowledgeTypes) - } - if err := query.Order("sort_order, id").Find(&items).Error; err != nil { - return nil, err - } - } - ids := make([]uint64, 0, len(items)) - for _, item := range items { - if selector.CandidateScope == "all" || len(keySet) == 0 || knowledgeCandidateMatches(keySet, item) { - ids = append(ids, item.ID) - } - } - if len(relations) == 0 && len(ids) > 0 { - rq := db.Where("tenant_id = ? AND scenario_id = ? AND from_knowledge_id IN ?", tenantID, scenarioID, ids) - if len(selector.RelationTypes) > 0 { - rq = rq.Where("relation_type IN ?", selector.RelationTypes) - } - if err := rq.Order("sort_order,id").Find(&relations).Error; err != nil { - return nil, err - } - } - targetIDs := make([]uint64, 0, len(relations)) - for _, rel := range relations { - targetIDs = append(targetIDs, rel.ToKnowledgeID) - } - targets := make([]model.KnowledgeItem, 0) - if len(snapshotTargets) > 0 { - for _, id := range targetIDs { - if target, ok := snapshotTargets[id]; ok && target.Status == "active" { - targets = append(targets, target) - } - } - } else if len(targetIDs) > 0 { - if err := db.Where("tenant_id = ? AND id IN ? AND status = ?", tenantID, targetIDs, "active").Find(&targets).Error; err != nil { - return nil, err - } - } - targetMap := map[uint64]model.KnowledgeItem{} - for _, target := range targets { - targetMap[target.ID] = target - } - relationMap := map[uint64]map[string][]KnowledgeItemView{} - for _, rel := range relations { - matched, err := matchCondition(json.RawMessage(rel.Condition), context) - if err != nil { - return nil, fmt.Errorf("知识关系 %d 条件不正确: %w", rel.ID, err) - } - if !matched { - continue - } - target, ok := targetMap[rel.ToKnowledgeID] - if !ok { - continue - } - if relationMap[rel.FromKnowledgeID] == nil { - relationMap[rel.FromKnowledgeID] = map[string][]KnowledgeItemView{} - } - relationMap[rel.FromKnowledgeID][rel.RelationType] = append(relationMap[rel.FromKnowledgeID][rel.RelationType], KnowledgeItemView{Key: target.ItemKey, Name: target.Name, Type: target.Type, Content: renderKnowledgeContent(target.Content, context)}) - } - outputs := make([]KnowledgeGroup, 0, len(items)) - for _, item := range items { - outputs = append(outputs, KnowledgeGroup{Key: item.ItemKey, Name: item.Name, Type: item.Type, Content: renderKnowledgeContent(item.Content, context), Relations: relationMap[item.ID], Suggested: knowledgeCandidateMatches(keySet, item)}) - } - return outputs, nil -} - -func knowledgeCandidateMatches(candidates map[string]bool, item model.KnowledgeItem) bool { - return candidates[item.ItemKey] || candidates[item.Name] -} - -func containsString(values []string, target string) bool { - for _, value := range values { - if value == target { - return true - } - } - return false -} - -func renderKnowledgeContent(raw []byte, context map[string]interface{}) json.RawMessage { - var value interface{} - if json.Unmarshal(raw, &value) != nil { - return json.RawMessage(raw) - } - value = renderKnowledgeValue(value, context) - rendered, _ := json.Marshal(value) - return rendered -} -func renderKnowledgeValue(value interface{}, context map[string]interface{}) interface{} { - switch typed := value.(type) { - case string: - return knowledgePlaceholderPattern.ReplaceAllStringFunc(typed, func(token string) string { - match := knowledgePlaceholderPattern.FindStringSubmatch(token) - if len(match) != 2 { - return token - } - resolved, ok := lookupContextValue(context, tokenNamespaceKey(token, match[1])) - if !ok || resolved == nil { - return "未提供" - } - if values, ok := resolved.([]interface{}); ok { - parts := make([]string, 0, len(values)) - for _, item := range values { - parts = append(parts, fmt.Sprint(item)) - } - return strings.Join(parts, "、") - } - return fmt.Sprint(resolved) - }) - case []interface{}: - for index, item := range typed { - typed[index] = renderKnowledgeValue(item, context) - } - return typed - case map[string]interface{}: - for key, item := range typed { - typed[key] = renderKnowledgeValue(item, context) - } - return typed - default: - return value - } -} - -func tokenNamespaceKey(token, key string) string { - trimmed := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(token, "{{"), "}}")) - if strings.Contains(trimmed, ".") { - return trimmed - } - return key -} - -func stringSlice(value interface{}) []string { - result := []string{} - switch values := value.(type) { - case []interface{}: - for _, item := range values { - if text, ok := item.(string); ok { - result = append(result, text) - } - } - case []string: - return values - case string: - return []string{values} - } - return result -} diff --git a/internal/run/knowledge_collection.go b/internal/run/knowledge_collection.go deleted file mode 100644 index da9e37b..0000000 --- a/internal/run/knowledge_collection.go +++ /dev/null @@ -1,79 +0,0 @@ -package run - -import ( - "encoding/json" - "fmt" - - "git.iwork-ai.com/xdc/iqudo-top1/internal/model" -) - -func validateKnowledgeSelections(node model.SOPNode, run model.SOPRun, answers map[string]interface{}) error { - if node.Type != "knowledge" { - return nil - } - var config knowledgeNodeConfig - if json.Unmarshal(node.Config, &config) != nil || config.KnowledgeCollection == nil { - return nil - } - snapshot, err := parseKnowledgeSnapshot(run.KnowledgeSnapshot) - if err != nil { - return err - } - itemsByID := map[uint64]model.KnowledgeItem{} - itemsByName := map[string]model.KnowledgeItem{} - for _, item := range snapshot.Items { - if item.Status == "active" { - itemsByID[item.ID] = item - itemsByName[item.Name] = item - } - } - relations := map[uint64]map[string]map[uint64]bool{} - for _, rel := range snapshot.Relations { - if relations[rel.FromKnowledgeID] == nil { - relations[rel.FromKnowledgeID] = map[string]map[uint64]bool{} - } - if relations[rel.FromKnowledgeID][rel.RelationType] == nil { - relations[rel.FromKnowledgeID][rel.RelationType] = map[uint64]bool{} - } - relations[rel.FromKnowledgeID][rel.RelationType][rel.ToKnowledgeID] = true - } - selected := map[string][]model.KnowledgeItem{} - derived := map[string]interface{}{} - _ = json.Unmarshal(run.Derived, &derived) - rootCandidates := map[string]bool{} - if config.KnowledgeSelector != nil { - for _, value := range stringSlice(derived[config.KnowledgeSelector.DerivedField]) { - rootCandidates[value] = true - } - } - for _, step := range config.KnowledgeCollection.Steps { - values, _ := stringValues(answers[step.FieldKey]) - for _, value := range values { - item, ok := itemsByName[value] - if !ok { - return fmt.Errorf("%s包含不存在的知识选项:%s", step.Name, value) - } - if step.Root { - if len(step.KnowledgeTypes) > 0 && !containsString(step.KnowledgeTypes, item.Type) { - return fmt.Errorf("%s的知识类型不正确:%s", step.Name, value) - } - if step.CandidateScope != "all" && !rootCandidates[item.Name] && !rootCandidates[item.ItemKey] { - return fmt.Errorf("%s不属于本次订单推断结果:%s", step.Name, value) - } - } else { - valid := false - for _, parent := range selected[step.FromField] { - if relations[parent.ID][step.RelationType][item.ID] { - valid = true - break - } - } - if !valid { - return fmt.Errorf("%s与已选择的上级知识不关联:%s", step.Name, value) - } - } - selected[step.FieldKey] = append(selected[step.FieldKey], item) - } - } - return nil -} diff --git a/internal/run/knowledge_collection_test.go b/internal/run/knowledge_collection_test.go deleted file mode 100644 index 9ed3ede..0000000 --- a/internal/run/knowledge_collection_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package run - -import ( - "encoding/json" - "testing" - - "git.iwork-ai.com/xdc/iqudo-top1/internal/model" - "gorm.io/datatypes" -) - -func TestValidateKnowledgeSelectionsRejectsUnrelatedPlan(t *testing.T) { - config := map[string]interface{}{ - "knowledge_selector": map[string]interface{}{"derived_field": "matched"}, - "knowledge_collection": map[string]interface{}{"steps": []map[string]interface{}{ - {"field_key": "segments", "name": "客户分群", "root": true, "required": true, "multiple": true}, - {"field_key": "strategies", "name": "推荐策略", "from_field": "segments", "relation_type": "matched_strategy", "required": true, "multiple": true}, - {"field_key": "offers", "name": "推荐内容", "from_field": "strategies", "relation_type": "recommended_offer", "required": true, "multiple": true}, - }}, - } - configRaw, _ := json.Marshal(config) - snapshotRaw, _ := json.Marshal(knowledgeSnapshot{ - Items: []model.KnowledgeItem{ - {Base: model.Base{ID: 1}, ItemKey: "vip", Name: "高价值客户", Status: "active"}, - {Base: model.Base{ID: 2}, ItemKey: "renewal", Name: "续费策略", Status: "active"}, - {Base: model.Base{ID: 3}, ItemKey: "annual", Name: "年度套餐", Status: "active"}, - {Base: model.Base{ID: 4}, ItemKey: "trial", Name: "试用课程", Status: "active"}, - }, - Relations: []model.KnowledgeRelation{ - {FromKnowledgeID: 1, ToKnowledgeID: 2, RelationType: "matched_strategy"}, - {FromKnowledgeID: 2, ToKnowledgeID: 3, RelationType: "recommended_offer"}, - }, - }) - derivedRaw, _ := json.Marshal(map[string]interface{}{"matched": []string{"高价值客户"}}) - run := model.SOPRun{Derived: datatypes.JSON(derivedRaw), KnowledgeSnapshot: datatypes.JSON(snapshotRaw)} - node := model.SOPNode{Type: "knowledge", Config: datatypes.JSON(configRaw)} - valid := map[string]interface{}{"segments": []interface{}{"高价值客户"}, "strategies": []interface{}{"续费策略"}, "offers": []interface{}{"年度套餐"}} - if err := validateKnowledgeSelections(node, run, valid); err != nil { - t.Fatalf("valid selection rejected: %v", err) - } - invalid := map[string]interface{}{"segments": []interface{}{"高价值客户"}, "strategies": []interface{}{"续费策略"}, "offers": []interface{}{"试用课程"}} - if err := validateKnowledgeSelections(node, run, invalid); err == nil { - t.Fatal("unrelated plan should be rejected") - } -} - -func TestValidateKnowledgeSelectionsAllowsAdditionalRootFromConfiguredKnowledgeType(t *testing.T) { - configRaw := datatypes.JSON([]byte(`{"knowledge_selector":{"derived_field":"matched"},"knowledge_collection":{"steps":[{"field_key":"symptoms","name":"症状","root":true,"candidate_scope":"all","knowledge_types":["symptom"],"required":true,"multiple":true}]}}`)) - snapshotRaw, _ := json.Marshal(knowledgeSnapshot{Items: []model.KnowledgeItem{ - {Base: model.Base{ID: 1}, ItemKey: "diarrhea", Name: "腹泻", Type: "symptom", Status: "active"}, - {Base: model.Base{ID: 2}, ItemKey: "vomiting", Name: "呕吐", Type: "symptom", Status: "active"}, - {Base: model.Base{ID: 3}, ItemKey: "disease", Name: "胃肠炎", Type: "disease", Status: "active"}, - }}) - derivedRaw, _ := json.Marshal(map[string]interface{}{"matched": []string{"腹泻"}}) - run := model.SOPRun{Derived: datatypes.JSON(derivedRaw), KnowledgeSnapshot: datatypes.JSON(snapshotRaw)} - node := model.SOPNode{Type: "knowledge", Config: configRaw} - if err := validateKnowledgeSelections(node, run, map[string]interface{}{"symptoms": []interface{}{"腹泻", "呕吐"}}); err != nil { - t.Fatalf("additional symptom should be accepted: %v", err) - } - if err := validateKnowledgeSelections(node, run, map[string]interface{}{"symptoms": []interface{}{"胃肠炎"}}); err == nil { - t.Fatal("knowledge item of another type should be rejected") - } -} diff --git a/internal/run/knowledge_graph_test.go b/internal/run/knowledge_graph_test.go deleted file mode 100644 index 0dc8490..0000000 --- a/internal/run/knowledge_graph_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package run - -import ( - "encoding/json" - "testing" - - "git.iwork-ai.com/xdc/iqudo-top1/internal/model" -) - -func TestRenderKnowledgeContent(t *testing.T) { - got := string(renderKnowledgeContent([]byte(`{"template":"您好{{input.name}},标签:{{derived.tags}}"}`), map[string]interface{}{"name": "王女士", "tags": []interface{}{"高意向", "复购"}})) - want := `{"template":"您好王女士,标签:高意向、复购"}` - if got != want { - t.Fatalf("got %s want %s", got, want) - } -} - -func TestRenderKnowledgeContentSupportsFormNamespace(t *testing.T) { - got := string(renderKnowledgeContent([]byte(`{"template":"结果:{{form.call_result}}"}`), runtimeContext(nil, nil, map[string]interface{}{"call_result": "已接受"}))) - if got != `{"template":"结果:已接受"}` { - t.Fatalf("got %s", got) - } -} - -func TestTemplateNamespacesDoNotOverwriteEachOther(t *testing.T) { - context := runtimeContext(map[string]interface{}{"status": "input"}, map[string]interface{}{"status": "derived"}, map[string]interface{}{"status": "form"}) - got := string(renderKnowledgeContent([]byte(`{"template":"{{input.status}}/{{derived.status}}/{{form.status}}"}`), context)) - if got != `{"template":"input/derived/form"}` { - t.Fatalf("got %s", got) - } -} - -func TestKnowledgeRelationCondition(t *testing.T) { - condition := json.RawMessage(`{"field":"derived.segment","operator":"equals","value":"vip"}`) - matched, err := matchCondition(condition, map[string]interface{}{"segment": "vip"}) - if err != nil || !matched { - t.Fatalf("matched=%v err=%v", matched, err) - } - matched, err = matchCondition(condition, map[string]interface{}{"segment": "normal"}) - if err != nil || matched { - t.Fatalf("matched=%v err=%v", matched, err) - } -} - -func TestSnapshotRelationsHonorSelector(t *testing.T) { - snapshot := knowledgeSnapshot{Relations: []model.KnowledgeRelation{ - {FromKnowledgeID: 1, ToKnowledgeID: 2, RelationType: "recommended_copy"}, - {FromKnowledgeID: 1, ToKnowledgeID: 3, RelationType: "internal_note"}, - }} - raw, err := json.Marshal(snapshot) - if err != nil { - t.Fatal(err) - } - parsed, err := parseKnowledgeSnapshot(raw) - if err != nil { - t.Fatal(err) - } - selector := knowledgeSelector{RelationTypes: []string{"recommended_copy"}} - filtered := make([]model.KnowledgeRelation, 0) - for _, relation := range parsed.Relations { - if len(selector.RelationTypes) == 0 || containsString(selector.RelationTypes, relation.RelationType) { - filtered = append(filtered, relation) - } - } - if len(filtered) != 1 || filtered[0].RelationType != "recommended_copy" { - t.Fatalf("filtered relations = %#v", filtered) - } -} - -func TestKnowledgeSelectorWithoutCandidateKeysReturnsEmpty(t *testing.T) { - outputs, err := loadKnowledgeOutputsForScenario(nil, 1, 1, map[string]interface{}{}, knowledgeSelector{DerivedField: "matched", KnowledgeTypes: []string{"symptom"}}) - if err != nil { - t.Fatal(err) - } - if len(outputs) != 0 { - t.Fatalf("outputs = %#v", outputs) - } -} - -func TestKnowledgeCandidateMatchesKeyOrDisplayName(t *testing.T) { - item := model.KnowledgeItem{ItemKey: "xlsx_symptom_123", Name: "腹泻"} - if !knowledgeCandidateMatches(map[string]bool{"腹泻": true}, item) { - t.Fatal("display name should match an external symptom tag") - } - if !knowledgeCandidateMatches(map[string]bool{"xlsx_symptom_123": true}, item) { - t.Fatal("item key should remain supported") - } -} diff --git a/internal/run/node_view_test.go b/internal/run/node_view_test.go index 3cb4bf4..8415557 100644 --- a/internal/run/node_view_test.go +++ b/internal/run/node_view_test.go @@ -6,7 +6,7 @@ import ( ) func TestNodeViewDoesNotExposeInternalSnapshotFields(t *testing.T) { - raw, err := json.Marshal(NodeView{NodeKey: "knowledge", Type: "knowledge", Title: "知识", Content: "内容"}) + raw, err := json.Marshal(NodeView{NodeKey: "stage", Type: "stage", Title: "知识", Content: "内容"}) if err != nil { t.Fatal(err) } @@ -14,7 +14,7 @@ func TestNodeViewDoesNotExposeInternalSnapshotFields(t *testing.T) { if err := json.Unmarshal(raw, &value); err != nil { t.Fatal(err) } - for _, key := range []string{"id", "tenant_id", "sop_id", "sop_version_id", "config", "position_x", "position_y"} { + for _, key := range []string{"id", "tenant_id", "sop_id", "config", "position_x", "position_y"} { if _, exists := value[key]; exists { t.Fatalf("public node contains internal field %s: %s", key, raw) } diff --git a/internal/run/outputs.go b/internal/run/outputs.go index d66615a..3f7634b 100644 --- a/internal/run/outputs.go +++ b/internal/run/outputs.go @@ -27,7 +27,7 @@ type OutputView struct { Value interface{} `json:"value"` } -func buildScenarioOutputs(db *gorm.DB, run model.SOPRun, knowledge []KnowledgeGroup) ([]OutputView, error) { +func buildScenarioOutputs(db *gorm.DB, run model.SOPRun) ([]OutputView, error) { var scenario model.Scenario if err := db.Table("scenarios sc").Select("sc.*").Joins("JOIN sops s ON s.scenario_id = sc.id").Where("s.id = ? AND sc.tenant_id = ?", run.SOPID, run.TenantID).First(&scenario).Error; err != nil { return nil, err @@ -38,10 +38,10 @@ func buildScenarioOutputs(db *gorm.DB, run model.SOPRun, knowledge []KnowledgeGr _ = json.Unmarshal(run.Input, &input) _ = json.Unmarshal(run.Derived, &derived) _ = json.Unmarshal(run.Answers, &answers) - return buildOutputViews(scenario.OutputSchema, input, derived, answers, knowledge, run.Status, run.Result) + return buildOutputViews(scenario.OutputSchema, input, derived, answers, run.Status, run.Result) } -func buildOutputViews(raw []byte, input, derived, answers map[string]interface{}, knowledge []KnowledgeGroup, status, result string) ([]OutputView, error) { +func buildOutputViews(raw []byte, input, derived, answers map[string]interface{}, status, result string) ([]OutputView, error) { var schema outputSchema if err := json.Unmarshal(raw, &schema); err != nil { return nil, err @@ -57,17 +57,6 @@ func buildOutputViews(raw []byte, input, derived, answers map[string]interface{} sourceField = field.Key } value, ok = derived[sourceField] - case "knowledge": - if sourceField == "" { - value, ok = knowledge, true - } else { - for _, group := range knowledge { - if group.Key == sourceField { - value, ok = group, true - break - } - } - } case "form": if sourceField == "" { sourceField = field.Key diff --git a/internal/run/outputs_test.go b/internal/run/outputs_test.go deleted file mode 100644 index 06796fd..0000000 --- a/internal/run/outputs_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package run - -import "testing" - -func TestBuildOutputViewsSupportsKnowledge(t *testing.T) { - knowledge := []KnowledgeGroup{{Key: "soft_stool", Name: "软便", Type: "symptom"}} - raw := []byte(`{"fields":[{"key":"recommended","name":"推荐知识","type":"array","source":"knowledge"},{"key":"symptom","name":"症状","type":"object","source":"knowledge","source_field":"soft_stool"}]}`) - outputs, err := buildOutputViews(raw, nil, nil, nil, knowledge, "preview", "") - if err != nil { - t.Fatal(err) - } - if len(outputs) != 2 { - t.Fatalf("outputs=%#v", outputs) - } - if groups, ok := outputs[0].Value.([]KnowledgeGroup); !ok || len(groups) != 1 { - t.Fatalf("knowledge output=%#v", outputs[0].Value) - } - if group, ok := outputs[1].Value.(KnowledgeGroup); !ok || group.Key != "soft_stool" { - t.Fatalf("selected output=%#v", outputs[1].Value) - } -} diff --git a/internal/run/preview.go b/internal/run/preview.go index 1ec8f1b..48c6b91 100644 --- a/internal/run/preview.go +++ b/internal/run/preview.go @@ -8,6 +8,7 @@ import ( "git.iwork-ai.com/xdc/iqudo-top1/internal/auth" "git.iwork-ai.com/xdc/iqudo-top1/internal/model" "git.iwork-ai.com/xdc/iqudo-top1/internal/response" + "git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit" "github.com/gin-gonic/gin" ) @@ -21,7 +22,7 @@ func (h *Handler) PreviewScenario(c *gin.Context) { var body struct { Input map[string]interface{} `json:"input"` InitialValues map[string]interface{} `json:"initial_values"` - Selector knowledgeSelector `json:"knowledge_selector"` + StageKey string `json:"stage_key"` } if err := c.ShouldBindJSON(&body); err != nil { response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "预览参数格式不正确") @@ -47,16 +48,29 @@ func (h *Handler) PreviewScenario(c *gin.Context) { response.Error(c, http.StatusUnprocessableEntity, "RULE_EVALUATION_FAILED", err.Error()) return } - context := runtimeContext(mapped, derived, nil) - knowledge, err := loadKnowledgeOutputsForScenario(h.db, scenarioID, p.TenantID, context, body.Selector) - if err != nil { - response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成知识预览失败") - return - } - outputs, err := buildOutputViews(scenario.OutputSchema, mapped, derived, map[string]interface{}{}, knowledge, "preview", "") + outputs, err := buildOutputViews(scenario.OutputSchema, mapped, derived, map[string]interface{}{}, "preview", "") if err != nil { response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成场景输出失败") return } - response.OK(c, gin.H{"input": mapped, "derived": derived, "matched_rules": matchedRules, "knowledge": knowledge, "outputs": outputs}) + data := gin.H{"input": mapped, "derived": derived, "matched_rules": matchedRules, "outputs": outputs} + pkg, loadErr := scriptkit.LoadPackageByScenario(h.db, p.TenantID, scenarioID) + if loadErr != nil { + response.OK(c, data) + return + } + stageKey := body.StageKey + if stageKey == "" { + stageKey = pkg.Package.StartStageKey + } + stage, ok := pkg.StageByKey(stageKey) + if !ok { + response.OK(c, data) + return + } + ctx := scriptkit.RenderContext{Input: mapped, Derived: derived, Form: map[string]interface{}{}} + state := scriptkit.ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}} + weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx) + data["stage"] = pkg.BuildStageView(stage, weights, ctx, state, []scriptkit.FormFieldView{}) + response.OK(c, data) } diff --git a/internal/run/public.go b/internal/run/public.go index 821c389..4a5a401 100644 --- a/internal/run/public.go +++ b/internal/run/public.go @@ -14,6 +14,7 @@ import ( "git.iwork-ai.com/xdc/iqudo-top1/internal/model" "git.iwork-ai.com/xdc/iqudo-top1/internal/response" "git.iwork-ai.com/xdc/iqudo-top1/internal/resultcontract" + "git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit" "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/datatypes" @@ -82,21 +83,22 @@ func (h *Handler) PublicStart(c *gin.Context) { return } derivedRaw, _ := json.Marshal(derived) - knowledgeRaw, err := snapshotKnowledge(h.db, scenario.TenantID, scenario.ID) - if err != nil { - response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_SNAPSHOT_FAILED", "生成知识快照失败") - return - } externalRef := body.ExternalRef if externalRef == "" { externalRef = "run-" + uuid.NewString() } - run := model.SOPRun{TenantID: scenario.TenantID, SOPID: sop.ID, OperatorID: scenario.CreatedBy, ExternalRef: externalRef, CurrentNodeKey: sop.StartNodeKey, Status: "running", Answers: datatypes.JSON(raw), Input: datatypes.JSON(raw), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), KnowledgeSnapshot: datatypes.JSON(knowledgeRaw), StartedAt: time.Now()} + scriptStateRaw, _ := json.Marshal(scriptkit.ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}) + run := model.SOPRun{TenantID: scenario.TenantID, SOPID: sop.ID, OperatorID: scenario.CreatedBy, ExternalRef: externalRef, CurrentNodeKey: sop.StartNodeKey, Status: "running", Answers: datatypes.JSON(raw), Input: datatypes.JSON(raw), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), ScriptState: datatypes.JSON(scriptStateRaw), StartedAt: time.Now()} token := uuid.NewString() + uuid.NewString() err = h.db.Transaction(func(tx *gorm.DB) error { if err := tx.Create(&run).Error; err != nil { return err } + if pkg, loadErr := loadRunPackage(tx, run); loadErr == nil { + if persistErr := persistRunWeights(tx, run, pkg, runWeights(run, pkg)); persistErr != nil { + return persistErr + } + } if err := tx.Create(&model.PublicRunSession{TenantID: scenario.TenantID, RunID: run.ID, TokenHash: publicTokenHash(token), ExpiresAt: time.Now().Add(24 * time.Hour)}).Error; err != nil { return err } @@ -160,6 +162,14 @@ func (h *Handler) PublicSubmit(c *gin.Context) { if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil { return err } + if node.Type == "stage" { + if len(body.Answers) > 0 { + if err := applyStageAnswer(tx, &run, node, stageAnswerInput{NodeKey: body.NodeKey, Answers: body.Answers}); err != nil { + return err + } + } + return advanceFromStage(tx, &run, node) + } fields := make([]model.ScenarioField, 0) if err := tx.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", run.SOPID, run.TenantID).Find(&fields).Error; err != nil { return err @@ -174,11 +184,14 @@ func (h *Handler) PublicSubmit(c *gin.Context) { } answerRaw, _ := json.Marshal(answers) run.Answers = datatypes.JSON(answerRaw) + if err := tx.Model(&run).Update("answers", run.Answers).Error; err != nil { + return err + } payload, _ := json.Marshal(body.Answers) if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "answer", Payload: datatypes.JSON(payload)}).Error; err != nil { return err } - if err := advancePublicRun(tx, &run); err != nil { + if err := advanceRunNode(tx, &run); err != nil { return err } if run.Status == "completed" { @@ -193,6 +206,44 @@ func (h *Handler) PublicSubmit(c *gin.Context) { h.respondPublicRun(c, run, "") } +// PublicAnswer records one stage script/dimension answer without advancing. +func (h *Handler) PublicAnswer(c *gin.Context) { + session, _, ok := h.publicSession(c) + if !ok { + return + } + var body stageAnswerInput + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "回答格式不正确") + return + } + var run model.SOPRun + err := h.db.Transaction(func(tx *gorm.DB) error { + if err := lockPublicRun(tx, session, &run); err != nil { + return err + } + if run.Status != "running" { + return errPublicRunCompleted + } + if body.NodeKey != "" && body.NodeKey != run.CurrentNodeKey { + return errPublicNodeChanged + } + var node model.SOPNode + if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil { + return err + } + if node.Type != "stage" { + return errors.New("当前节点不是话术阶段") + } + return applyStageAnswer(tx, &run, node, body) + }) + if err != nil { + h.respondPublicMutationError(c, err, "保存回答失败") + return + } + h.respondPublicRun(c, run, "") +} + func (h *Handler) PublicNext(c *gin.Context) { session, _, ok := h.publicSession(c) if !ok { @@ -210,13 +261,16 @@ func (h *Handler) PublicNext(c *gin.Context) { if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil { return err } - if node.Type == "question" || node.Type == "choice" || node.Type == "form" { + if node.Type == "stage" { + if err := advanceFromStage(tx, &run, node); err != nil { + return err + } + } else if node.Type == "question" || node.Type == "choice" || node.Type == "form" { return errPublicInputNeeded - } - if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "next", Payload: datatypes.JSON([]byte(`{}`))}).Error; err != nil { + } else if err := advanceRunNode(tx, &run); err != nil { return err } - return advancePublicRun(tx, &run) + return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "next", Payload: datatypes.JSON([]byte(`{}`))}).Error }) if err != nil { h.respondPublicMutationError(c, err, "推进节点失败") @@ -225,6 +279,76 @@ func (h *Handler) PublicNext(c *gin.Context) { h.respondPublicRun(c, run, "") } +// PublicBack moves the run to the previous node. +func (h *Handler) PublicBack(c *gin.Context) { + session, _, ok := h.publicSession(c) + if !ok { + return + } + var run model.SOPRun + err := h.db.Transaction(func(tx *gorm.DB) error { + if err := lockPublicRun(tx, session, &run); err != nil { + return err + } + if run.Status != "running" { + return errPublicRunCompleted + } + if err := backRunNode(tx, &run); err != nil { + return err + } + return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "back", Payload: datatypes.JSON([]byte(`{}`))}).Error + }) + if err != nil { + h.respondPublicMutationError(c, err, "返回上一步失败") + return + } + h.respondPublicRun(c, run, "") +} + +// PublicFeedback records like/report/unreasonable feedback on a script. +func (h *Handler) PublicFeedback(c *gin.Context) { + session, run, ok := h.publicSession(c) + if !ok { + return + } + var body struct { + NodeKey string `json:"node_key"` + ScriptKey string `json:"script_key" binding:"required"` + FeedbackType string `json:"feedback_type" binding:"required"` + Note string `json:"note"` + } + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "话术反馈格式不正确") + return + } + if body.FeedbackType != "like" && body.FeedbackType != "report" && body.FeedbackType != "unreasonable" { + response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "话术反馈类型不正确") + return + } + pkg, err := loadRunPackage(h.db, run) + if err != nil { + response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景没有配置话术包") + return + } + script, found := pkg.ScriptByKey(body.ScriptKey) + if !found { + response.Error(c, http.StatusNotFound, "NOT_FOUND", "话术不存在") + return + } + item := model.ScriptFeedback{TenantID: run.TenantID, RunID: run.ID, StageID: script.StageID, ScriptID: script.ID, FeedbackType: body.FeedbackType, OperatorID: run.OperatorID, Note: body.Note} + if err := h.db.Create(&item).Error; err != nil { + response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存话术反馈失败") + return + } + payload, _ := json.Marshal(body) + if err := h.db.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: body.NodeKey, Action: "script_feedback", Payload: datatypes.JSON(payload)}).Error; err != nil { + response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存话术反馈失败") + return + } + _ = session + response.OK(c, gin.H{"recorded": true}) +} + func (h *Handler) PublicFinish(c *gin.Context) { session, _, ok := h.publicSession(c) if !ok { @@ -305,9 +429,15 @@ func (h *Handler) PublicReset(c *gin.Context) { return err } run.CurrentNodeKey, run.Status, run.Result, run.FinalResult, run.CompletedAt, run.Answers = sop.StartNodeKey, "running", "", nil, nil, run.Input - if err := tx.Model(&run).Updates(map[string]interface{}{"current_node_key": run.CurrentNodeKey, "status": run.Status, "result": run.Result, "final_result": nil, "completed_at": nil, "answers": run.Input, "outputs": datatypes.JSON([]byte(`[]`))}).Error; err != nil { + run.ScriptState = datatypes.JSON([]byte(`{"script_answers":{},"dimension_selects":{}}`)) + if err := tx.Model(&run).Updates(map[string]interface{}{"current_node_key": run.CurrentNodeKey, "status": run.Status, "result": run.Result, "final_result": nil, "completed_at": nil, "answers": run.Input, "outputs": datatypes.JSON([]byte(`[]`)), "script_state": run.ScriptState}).Error; err != nil { return err } + if pkg, loadErr := loadRunPackage(tx, run); loadErr == nil { + if persistErr := persistRunWeights(tx, run, pkg, runWeights(run, pkg)); persistErr != nil { + return persistErr + } + } if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "reset", Payload: datatypes.JSON([]byte(`{}`))}).Error; err != nil { return err } @@ -320,58 +450,6 @@ func (h *Handler) PublicReset(c *gin.Context) { h.respondPublicRun(c, run, "") } -func advancePublicRun(tx *gorm.DB, run *model.SOPRun) error { - var edges []model.SOPEdge - if err := tx.Where("sop_id = ? AND source_node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).Order("priority,id").Find(&edges).Error; err != nil { - return err - } - answers := map[string]interface{}{} - _ = json.Unmarshal(run.Answers, &answers) - derived := map[string]interface{}{} - _ = json.Unmarshal(run.Derived, &derived) - input := map[string]interface{}{} - _ = json.Unmarshal(run.Input, &input) - context := runtimeContext(input, derived, answers) - sortEdges(edges) - nextKey := "" - for _, edge := range edges { - matched, err := matchCondition(json.RawMessage(edge.Condition), context) - if err != nil { - return err - } - if matched { - nextKey = edge.TargetNodeKey - break - } - } - if nextKey == "" { - return errors.New("没有满足条件的下一节点") - } - var next model.SOPNode - if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, nextKey, run.TenantID).First(&next).Error; err != nil { - return err - } - updates := map[string]interface{}{"current_node_key": nextKey} - if next.Type == "finish" || next.Type == "escalate" { - now := time.Now() - updates["status"] = "completed" - updates["completed_at"] = &now - updates["result"] = next.Type - run.Status, run.CompletedAt, run.Result = "completed", &now, next.Type - } - if err := tx.Model(run).Updates(updates).Error; err != nil { - return err - } - run.CurrentNodeKey = nextKey - if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: nextKey, Action: "enter", Payload: datatypes.JSON([]byte(`{}`))}).Error; err != nil { - return err - } - if run.Status == "completed" { - return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: nextKey, Action: "finish", Payload: datatypes.JSON([]byte(`{"source":"terminal_node"}`))}).Error - } - return nil -} - func lockPublicRun(tx *gorm.DB, session model.PublicRunSession, run *model.SOPRun) error { return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", session.RunID, session.TenantID).First(run).Error } @@ -397,21 +475,13 @@ func (h *Handler) respondPublicRun(c *gin.Context, run model.SOPRun, token strin response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在") return } - answers := map[string]interface{}{} - _ = json.Unmarshal(run.Answers, &answers) - derived := map[string]interface{}{} - _ = json.Unmarshal(run.Derived, &derived) - input := map[string]interface{}{} - _ = json.Unmarshal(run.Input, &input) - context := runtimeContext(input, derived, answers) - context["__knowledge_snapshot"] = json.RawMessage(run.KnowledgeSnapshot) - view, err := h.nodeView(h.db, node, run.TenantID, context) + view, err := nodeView(h.db, run, node) if err != nil { response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成节点输出失败") return } view.Config = nil - outputs, err := buildScenarioOutputs(h.db, run, view.Outputs) + outputs, err := buildScenarioOutputs(h.db, run) if err != nil { response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成场景输出失败") return diff --git a/internal/run/snapshot.go b/internal/run/snapshot.go deleted file mode 100644 index a90bec6..0000000 --- a/internal/run/snapshot.go +++ /dev/null @@ -1,29 +0,0 @@ -package run - -import ( - "encoding/json" - "git.iwork-ai.com/xdc/iqudo-top1/internal/model" - "gorm.io/gorm" -) - -type knowledgeSnapshot struct { - Items []model.KnowledgeItem `json:"items"` - Relations []model.KnowledgeRelation `json:"relations"` -} - -func snapshotKnowledge(db *gorm.DB, tenantID, scenarioID uint64) ([]byte, error) { - var snapshot knowledgeSnapshot - if err := db.Where("tenant_id = ? AND scenario_id = ? AND status = ?", tenantID, scenarioID, "active").Order("sort_order,id").Find(&snapshot.Items).Error; err != nil { - return nil, err - } - if err := db.Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Order("sort_order,id").Find(&snapshot.Relations).Error; err != nil { - return nil, err - } - return json.Marshal(snapshot) -} - -func parseKnowledgeSnapshot(raw []byte) (knowledgeSnapshot, error) { - var snapshot knowledgeSnapshot - err := json.Unmarshal(raw, &snapshot) - return snapshot, err -} diff --git a/internal/run/stage.go b/internal/run/stage.go new file mode 100644 index 0000000..289e6c5 --- /dev/null +++ b/internal/run/stage.go @@ -0,0 +1,462 @@ +package run + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "git.iwork-ai.com/xdc/iqudo-top1/internal/model" + "git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +// stageNodeConfig is the config of a "stage" SOP node. It binds the node to a +// script package stage and optionally to a content supplement form. +type stageNodeConfig struct { + PackageID *uint64 `json:"package_id"` + StageKey string `json:"stage_key"` + FieldKeys []string `json:"field_keys"` + RequiredFieldKeys []string `json:"required_field_keys"` +} + +// stageScriptAnswerInput is one script answer inside a batch stage submission. +type stageScriptAnswerInput struct { + ScriptKey string `json:"script_key"` + OptionKeys []string `json:"option_keys"` + Value string `json:"value"` +} + +// stageAnswerInput is the shared payload of the stage answer endpoint. Clients +// answer questions locally and submit the whole stage once: script_answers +// carries every answered script, dimension_selects carries the manual +// dimension toggles and answers carries the content supplement form. +type stageAnswerInput struct { + NodeKey string `json:"node_key"` + ScriptKey string `json:"script_key"` + OptionKeys []string `json:"option_keys"` + Value string `json:"value"` + ScriptAnswers []stageScriptAnswerInput `json:"script_answers"` + DimensionSelects map[string]bool `json:"dimension_selects"` + Answers map[string]interface{} `json:"answers"` + DimensionValueKey string `json:"dimension_value_key"` + Selected *bool `json:"selected"` +} + +func parseStageConfig(node model.SOPNode) (stageNodeConfig, error) { + var config stageNodeConfig + if len(node.Config) > 0 { + if err := json.Unmarshal(node.Config, &config); err != nil { + return config, fmt.Errorf("当前阶段节点配置不正确") + } + } + if config.StageKey == "" { + return config, errors.New("当前阶段节点没有绑定话术包阶段") + } + return config, nil +} + +// loadRunPackage loads the script package that drives a run. +func loadRunPackage(db *gorm.DB, run model.SOPRun) (*scriptkit.Package, error) { + var scenarioID uint64 + if err := db.Table("sops s").Select("s.scenario_id").Where("s.id = ? AND s.tenant_id = ?", run.SOPID, run.TenantID).Scan(&scenarioID).Error; err != nil { + return nil, err + } + pkg, err := scriptkit.LoadPackageByScenario(db, run.TenantID, scenarioID) + if err != nil { + return nil, errors.New("场景没有配置话术包") + } + return pkg, nil +} + +func runScriptState(run model.SOPRun) scriptkit.ScriptState { + state := scriptkit.ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}} + if len(run.ScriptState) > 0 { + _ = json.Unmarshal(run.ScriptState, &state) + } + if state.ScriptAnswers == nil { + state.ScriptAnswers = map[string][]string{} + } + if state.DimensionSelects == nil { + state.DimensionSelects = map[string]bool{} + } + return state +} + +func runRenderContext(run model.SOPRun) scriptkit.RenderContext { + input := map[string]interface{}{} + _ = json.Unmarshal(run.Input, &input) + derived := map[string]interface{}{} + _ = json.Unmarshal(run.Derived, &derived) + answers := map[string]interface{}{} + _ = json.Unmarshal(run.Answers, &answers) + return scriptkit.RenderContext{Input: input, Derived: derived, Form: answers} +} + +// runWeights deterministically recomputes the current dimension weights. +func runWeights(run model.SOPRun, pkg *scriptkit.Package) map[uint64]int { + ctx := runRenderContext(run) + return pkg.RecomputeWeights(pkg.InitialWeights(ctx), runScriptState(run), ctx) +} + +// persistRunWeights rewrites the materialized run_dimensions rows inside the +// current transaction. +func persistRunWeights(tx *gorm.DB, run model.SOPRun, pkg *scriptkit.Package, weights map[uint64]int) error { + if err := tx.Where("tenant_id = ? AND run_id = ?", run.TenantID, run.ID).Delete(&model.RunDimension{}).Error; err != nil { + return err + } + for _, value := range pkg.Values { + row := model.RunDimension{TenantID: run.TenantID, RunID: run.ID, PackageID: pkg.Package.ID, DimensionID: value.DimensionID, ValueKey: value.ValueKey, Weight: weights[value.ID]} + if err := tx.Create(&row).Error; err != nil { + return err + } + } + return nil +} + +// buildStageView renders the stage bound to a node for the current run. +func buildStageView(db *gorm.DB, run model.SOPRun, node model.SOPNode) (*scriptkit.StageView, error) { + config, err := parseStageConfig(node) + if err != nil { + return nil, err + } + pkg, err := loadRunPackage(db, run) + if err != nil { + return nil, err + } + stage, ok := pkg.StageByKey(config.StageKey) + if !ok { + return nil, errors.New("当前节点关联的阶段不存在") + } + formFields, err := loadStageFormFields(db, node, run.TenantID, config) + if err != nil { + return nil, err + } + view := pkg.BuildStageView(stage, runWeights(run, pkg), runRenderContext(run), runScriptState(run), formFields) + return &view, nil +} + +func loadStageFormFields(db *gorm.DB, node model.SOPNode, tenantID uint64, config stageNodeConfig) ([]scriptkit.FormFieldView, error) { + if len(config.FieldKeys) == 0 { + return []scriptkit.FormFieldView{}, nil + } + var fields []model.ScenarioField + if err := db.Table("scenario_fields sf").Select("sf.*").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPID, tenantID, config.FieldKeys).Find(&fields).Error; err != nil { + return nil, err + } + byKey := map[string]model.ScenarioField{} + for _, field := range fields { + byKey[field.FieldKey] = field + } + views := make([]scriptkit.FormFieldView, 0, len(config.FieldKeys)) + for _, key := range config.FieldKeys { + field, ok := byKey[key] + if !ok { + continue + } + required := field.Required || containsString(config.RequiredFieldKeys, key) + views = append(views, scriptkit.FormFieldView{Key: field.FieldKey, Name: field.FieldName, Type: field.FieldType, Required: required, Options: json.RawMessage(field.Options), Validation: json.RawMessage(field.Validation)}) + } + return views, nil +} + +// applyStageAnswer records one or more stage answers (script answers, dimension +// selects, form fields) in one batch and recomputes the run dimension weights +// once. The run stays on the same node. +func applyStageAnswer(tx *gorm.DB, run *model.SOPRun, node model.SOPNode, input stageAnswerInput) error { + pkg, err := loadRunPackage(tx, *run) + if err != nil { + return err + } + config, err := parseStageConfig(node) + if err != nil { + return err + } + stage, ok := pkg.StageByKey(config.StageKey) + if !ok { + return errors.New("当前节点关联的阶段不存在") + } + state := runScriptState(*run) + answers := map[string]interface{}{} + if len(run.Answers) > 0 { + _ = json.Unmarshal(run.Answers, &answers) + } + if answers == nil { + answers = map[string]interface{}{} + } + ctx := runRenderContext(*run) + base := pkg.InitialWeights(ctx) + changed := false + + items := input.ScriptAnswers + if len(items) == 0 && input.ScriptKey != "" { + items = []stageScriptAnswerInput{{ScriptKey: input.ScriptKey, OptionKeys: input.OptionKeys, Value: input.Value}} + } + // 筛选题先处理:它按当前候选症状整体升维/归零,顺序无关但语义上应最先。 + for _, item := range items { + if item.ScriptKey == "" { + continue + } + script, found := pkg.ScriptByKey(item.ScriptKey) + if !found || script.StageID != stage.ID { + return fmt.Errorf("话术 %s 不属于当前阶段", item.ScriptKey) + } + if script.ScriptType == "screen" { + if err := recordStageScriptAnswer(pkg, script, &state, answers, ctx, base, item); err != nil { + return err + } + changed = true + } + } + for _, item := range items { + if item.ScriptKey == "" { + continue + } + script, found := pkg.ScriptByKey(item.ScriptKey) + if !found || script.StageID != stage.ID { + return fmt.Errorf("话术 %s 不属于当前阶段", item.ScriptKey) + } + if script.ScriptType == "screen" { + continue + } + if err := recordStageScriptAnswer(pkg, script, &state, answers, ctx, base, item); err != nil { + return err + } + changed = true + } + + if input.DimensionValueKey != "" && input.Selected != nil { + if _, ok := pkg.ValueByKey(input.DimensionValueKey); !ok { + return fmt.Errorf("维度值 %s 不存在", input.DimensionValueKey) + } + state.DimensionSelects[input.DimensionValueKey] = *input.Selected + changed = true + } + if len(input.DimensionSelects) > 0 { + for key, selected := range input.DimensionSelects { + if _, ok := pkg.ValueByKey(key); !ok { + return fmt.Errorf("维度值 %s 不存在", key) + } + state.DimensionSelects[key] = selected + } + changed = true + } + + if len(input.Answers) > 0 { + if len(config.FieldKeys) == 0 { + return errors.New("当前阶段没有内容补充表单") + } + fields := make([]model.ScenarioField, 0) + if err := tx.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", run.SOPID, run.TenantID).Find(&fields).Error; err != nil { + return err + } + formNode := model.SOPNode{Type: "form", Config: datatypes.JSON(mustJSON(config))} + if err := validateNodeAnswers(formNode, fields, input.Answers); err != nil { + return err + } + for key, value := range input.Answers { + answers[key] = value + } + changed = true + } + + if changed { + answersRaw, _ := json.Marshal(answers) + stateRaw, _ := json.Marshal(state) + run.Answers = datatypes.JSON(answersRaw) + run.ScriptState = datatypes.JSON(stateRaw) + if err := tx.Model(run).Updates(map[string]interface{}{"answers": run.Answers, "script_state": run.ScriptState}).Error; err != nil { + return err + } + weights := runWeights(*run, pkg) + if err := persistRunWeights(tx, *run, pkg, weights); err != nil { + return err + } + payload, _ := json.Marshal(input) + return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "answer", Payload: datatypes.JSON(payload)}).Error + } + return nil +} + +// recordStageScriptAnswer applies one script answer to the in-memory state. +// Weights are not persisted here; the caller recomputes them once after the +// whole batch is applied. +func recordStageScriptAnswer(pkg *scriptkit.Package, script model.StageScript, state *scriptkit.ScriptState, answers map[string]interface{}, ctx scriptkit.RenderContext, base map[uint64]int, item stageScriptAnswerInput) error { + switch { + case item.OptionKeys != nil && script.ScriptType == "screen": + // 症状筛选题:多选。被选中的症状确认并进入细节, + // 未选中的候选症状统一归零,不再追问。 + selected := map[uint64]bool{} + for _, key := range item.OptionKeys { + option, ok := pkg.OptionByKey(script, key) + if !ok { + return fmt.Errorf("话术 %s 的选项 %s 不存在", item.ScriptKey, key) + } + if option.TargetDimensionValueID != nil { + selected[*option.TargetDimensionValueID] = true + } + } + if script.CollectFieldKey != "" { + labels := make([]string, 0, len(item.OptionKeys)) + for _, key := range item.OptionKeys { + if option, ok := pkg.OptionByKey(script, key); ok { + labels = append(labels, option.Label) + } + } + answers[script.CollectFieldKey] = strings.Join(labels, "、") + } + state.ScriptAnswers[item.ScriptKey] = item.OptionKeys + dimensionID := screenScriptDimension(pkg, script) + if dimensionID != 0 { + current := pkg.RecomputeWeights(base, *state, ctx) + for _, value := range pkg.Values { + if value.DimensionID == dimensionID && current[value.ID] > 0 { + state.DimensionSelects[value.ValueKey] = selected[value.ID] + } + } + } + case item.OptionKeys != nil: + if script.ScriptType != "confirm" && script.ScriptType != "choice" && script.ScriptType != "info" { + return fmt.Errorf("话术 %s 不接受选项回答", item.ScriptKey) + } + // 选择题支持多选和全部取消;确认题至少需要一个选项。 + if len(item.OptionKeys) == 0 && script.ScriptType != "choice" { + return errors.New("回答缺少选项") + } + for _, key := range item.OptionKeys { + if _, ok := pkg.OptionByKey(script, key); !ok { + return fmt.Errorf("话术 %s 的选项 %s 不存在", item.ScriptKey, key) + } + } + if script.CollectFieldKey != "" { + labels := make([]string, 0, len(item.OptionKeys)) + for _, key := range item.OptionKeys { + if option, ok := pkg.OptionByKey(script, key); ok { + labels = append(labels, option.Label) + } + } + answers[script.CollectFieldKey] = strings.Join(labels, "、") + } + state.ScriptAnswers[item.ScriptKey] = item.OptionKeys + case item.Value != "": + if script.CollectFieldKey == "" { + return fmt.Errorf("话术 %s 不采集信息", item.ScriptKey) + } + answers[script.CollectFieldKey] = item.Value + state.ScriptAnswers[item.ScriptKey] = []string{item.Value} + default: + return errors.New("回答缺少选项或内容") + } + return nil +} + +// screenScriptDimension returns the dimension targeted by a screening script's +// options, or zero when the script has no dimension targets. +func screenScriptDimension(pkg *scriptkit.Package, script model.StageScript) uint64 { + for _, option := range pkg.Options { + if option.ScriptID != script.ID || option.TargetDimensionValueID == nil { + continue + } + for _, value := range pkg.Values { + if value.ID == *option.TargetDimensionValueID { + return value.DimensionID + } + } + } + return 0 +} + +func mustJSON(value interface{}) []byte { + raw, _ := json.Marshal(value) + return raw +} + +// stageCanNext reports whether the current stage view allows advancing. +func stageCanNext(db *gorm.DB, run model.SOPRun, node model.SOPNode) (bool, error) { + view, err := buildStageView(db, run, node) + if err != nil { + return false, err + } + return view.CanNext, nil +} + +// advanceFromStage moves the run to the next node when the stage is complete. +func advanceFromStage(tx *gorm.DB, run *model.SOPRun, node model.SOPNode) error { + view, err := buildStageView(tx, *run, node) + if err != nil { + return err + } + if !view.CanNext { + if view.CanNextReason != "" { + return errors.New(view.CanNextReason) + } + return errors.New("当前阶段还有必填内容未完成") + } + return advanceRunNode(tx, run) +} + +// advanceRunNode moves the run to the next node matched by edges. +func advanceRunNode(tx *gorm.DB, run *model.SOPRun) error { + var edges []model.SOPEdge + if err := tx.Where("sop_id = ? AND source_node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).Order("priority, id").Find(&edges).Error; err != nil { + return err + } + context := runtimeContext(mapsFromRun(*run)) + sortEdges(edges) + nextKey := "" + for _, edge := range edges { + matched, err := matchCondition(json.RawMessage(edge.Condition), context) + if err != nil { + return err + } + if matched { + nextKey = edge.TargetNodeKey + break + } + } + if nextKey == "" { + return errors.New("没有满足条件的下一节点") + } + var next model.SOPNode + if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, nextKey, run.TenantID).First(&next).Error; err != nil { + return err + } + updates := map[string]interface{}{"current_node_key": nextKey} + if next.Type == "finish" || next.Type == "escalate" { + now := time.Now() + updates["status"] = "completed" + updates["completed_at"] = &now + updates["result"] = next.Type + run.Status, run.CompletedAt, run.Result = "completed", &now, next.Type + } + if err := tx.Model(run).Updates(updates).Error; err != nil { + return err + } + run.CurrentNodeKey = nextKey + if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: nextKey, Action: "enter", Payload: datatypes.JSON([]byte(`{}`))}).Error; err != nil { + return err + } + if run.Status == "completed" { + return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: nextKey, Action: "finish", Payload: datatypes.JSON([]byte(`{"source":"terminal_node"}`))}).Error + } + return nil +} + +// backRunNode moves the run to the previous node via reverse edges. +func backRunNode(tx *gorm.DB, run *model.SOPRun) error { + var edges []model.SOPEdge + if err := tx.Where("sop_id = ? AND target_node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).Order("priority, id").Find(&edges).Error; err != nil { + return err + } + if len(edges) == 0 { + return errors.New("当前已经是第一步") + } + prevKey := edges[0].SourceNodeKey + if err := tx.Model(run).Update("current_node_key", prevKey).Error; err != nil { + return err + } + run.CurrentNodeKey = prevKey + return nil +} diff --git a/internal/run/start_presentation.go b/internal/run/start_presentation.go index c0eae0d..d62a210 100644 --- a/internal/run/start_presentation.go +++ b/internal/run/start_presentation.go @@ -2,9 +2,9 @@ package run import ( "encoding/json" - "fmt" "git.iwork-ai.com/xdc/iqudo-top1/internal/model" + "git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit" "gorm.io/gorm" ) @@ -42,7 +42,7 @@ type startPresentationConfig struct { OpeningTemplate string `json:"opening_template"` } -func loadStartPresentation(db *gorm.DB, node model.SOPNode, tenantID uint64, context map[string]interface{}) (*NodePresentation, error) { +func loadStartPresentation(db *gorm.DB, node model.SOPNode, tenantID uint64, context scriptkit.RenderContext) (*NodePresentation, error) { var config startNodeConfig if err := json.Unmarshal(node.Config, &config); err != nil { return nil, err @@ -68,14 +68,14 @@ func loadStartPresentation(db *gorm.DB, node model.SOPNode, tenantID uint64, con return buildStartPresentation(*config.Presentation, byKey, context), nil } -func buildStartPresentation(config startPresentationConfig, fields map[string]model.ScenarioField, context map[string]interface{}) *NodePresentation { +func buildStartPresentation(config startPresentationConfig, fields map[string]model.ScenarioField, context scriptkit.RenderContext) *NodePresentation { view := &NodePresentation{Summary: []PresentationField{}, Items: []PresentationItem{}} imageKeys := make(map[string]bool, len(config.ImageFieldKeys)) for _, key := range config.ImageFieldKeys { imageKeys[key] = true } for _, key := range config.SummaryFieldKeys { - value, ok := lookupContextValue(context, "input."+key) + value, ok := context.Input[key] if !ok || presentationValueEmpty(value) { continue } @@ -84,7 +84,7 @@ func buildStartPresentation(config startPresentationConfig, fields map[string]mo itemValues := make(map[string][]interface{}, len(config.ItemFieldKeys)) itemCount := 0 for _, key := range config.ItemFieldKeys { - value, _ := lookupContextValue(context, "input."+key) + value := context.Input[key] values := presentationValues(value) itemValues[key] = values if len(values) > itemCount { @@ -109,7 +109,7 @@ func buildStartPresentation(config startPresentationConfig, fields map[string]mo if title == "" { title = "开场话术" } - view.Opening = &PresentationCopy{Title: title, Content: fmt.Sprint(renderKnowledgeValue(config.OpeningTemplate, context))} + view.Opening = &PresentationCopy{Title: title, Content: scriptkit.RenderTemplate(config.OpeningTemplate, context)} } return view } diff --git a/internal/run/start_presentation_test.go b/internal/run/start_presentation_test.go index a4ea667..f51b81f 100644 --- a/internal/run/start_presentation_test.go +++ b/internal/run/start_presentation_test.go @@ -4,6 +4,7 @@ import ( "testing" "git.iwork-ai.com/xdc/iqudo-top1/internal/model" + "git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit" ) func TestBuildStartPresentation(t *testing.T) { @@ -22,13 +23,13 @@ func TestBuildStartPresentation(t *testing.T) { "product_names": {FieldKey: "product_names", FieldName: "订单商品名称"}, "product_ids": {FieldKey: "product_ids", FieldName: "订单商品 ID"}, } - context := runtimeContext(map[string]interface{}{ + context := scriptkit.RenderContext{Input: map[string]interface{}{ "order_id": "ORDER-001", "customer_name": "王女士", "product_images": []interface{}{"https://img.example.com/a.jpg", "https://img.example.com/b.jpg"}, "product_names": []interface{}{"商品 A", "商品 B"}, "product_ids": []interface{}{"SKU-A", "SKU-B"}, - }, nil, nil) + }, Derived: map[string]interface{}{}, Form: map[string]interface{}{}} got := buildStartPresentation(config, fields, context) if len(got.Summary) != 2 { diff --git a/internal/run/validation.go b/internal/run/validation.go index 7f84812..d1b82af 100644 --- a/internal/run/validation.go +++ b/internal/run/validation.go @@ -69,9 +69,6 @@ func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answe expected[key] = containsString(config.RequiredFieldKeys, key) } default: - if node.Type == "knowledge" { - return validateKnowledgeNodeAnswers(node, fieldMap, answers) - } if len(answers) > 0 { return fmt.Errorf("当前节点不接受字段回答") } @@ -106,67 +103,6 @@ func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answe return nil } -func validateKnowledgeNodeAnswers(node model.SOPNode, fields map[string]model.ScenarioField, answers map[string]interface{}) error { - var config knowledgeNodeConfig - if json.Unmarshal(node.Config, &config) != nil || config.KnowledgeCollection == nil { - if len(answers) > 0 { - return fmt.Errorf("当前节点不接受字段回答") - } - return nil - } - expected := map[string]bool{} - for _, key := range config.KnowledgeCollection.ContextFieldKeys { - expected[key] = fields[key].Required - } - for _, step := range config.KnowledgeCollection.Steps { - expected[step.FieldKey] = step.Required - } - for key := range answers { - if _, ok := expected[key]; !ok { - return fmt.Errorf("字段 %s 不属于当前节点", key) - } - } - for key, required := range expected { - value := answers[key] - if isEmptyValue(value) { - if required { - return fmt.Errorf("请填写%s", knowledgeAnswerName(config, fields, key)) - } - continue - } - if field, ok := fields[key]; ok && !isKnowledgeStep(config, key) { - if err := validateFieldValue(field, value); err != nil { - return err - } - } - if isKnowledgeStep(config, key) { - if _, ok := stringValues(value); !ok { - return fmt.Errorf("%s必须选择有效选项", knowledgeAnswerName(config, fields, key)) - } - } - } - return nil -} - -func knowledgeAnswerName(config knowledgeNodeConfig, fields map[string]model.ScenarioField, key string) string { - if field, ok := fields[key]; ok { - return field.FieldName - } - for _, step := range config.KnowledgeCollection.Steps { - if step.FieldKey == key { - return step.Name - } - } - return key -} -func isKnowledgeStep(config knowledgeNodeConfig, key string) bool { - for _, step := range config.KnowledgeCollection.Steps { - if step.FieldKey == key { - return true - } - } - return false -} func stringValues(value interface{}) ([]string, bool) { switch typed := value.(type) { case string: diff --git a/internal/run/view.go b/internal/run/view.go new file mode 100644 index 0000000..07ccc64 --- /dev/null +++ b/internal/run/view.go @@ -0,0 +1,99 @@ +package run + +import ( + "encoding/json" + + "git.iwork-ai.com/xdc/iqudo-top1/internal/model" + "git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit" + "gorm.io/gorm" +) + +// NodeView is the public view of the current SOP node. +type NodeView struct { + NodeKey string `json:"node_key"` + Type string `json:"type"` + Title string `json:"title"` + Content string `json:"content"` + Config json.RawMessage `json:"config,omitempty"` + Fields []PublicFieldView `json:"fields,omitempty"` + Presentation *NodePresentation `json:"presentation,omitempty"` + Stage *scriptkit.StageView `json:"stage,omitempty"` +} + +// PublicFieldView mirrors a scenario field for the client. +type PublicFieldView struct { + Key string `json:"key"` + Name string `json:"name"` + Type string `json:"type"` + Required bool `json:"required"` + Options json.RawMessage `json:"options"` + Validation json.RawMessage `json:"validation"` +} + +func nodeView(db *gorm.DB, run model.SOPRun, node model.SOPNode) (NodeView, error) { + view := NodeView{NodeKey: node.NodeKey, Type: node.Type, Title: node.Title, Content: node.Content, Config: json.RawMessage(node.Config)} + switch node.Type { + case "start": + input, derived, answers := mapsFromRun(run) + presentation, err := loadStartPresentation(db, node, run.TenantID, scriptkit.RenderContext{Input: input, Derived: derived, Form: answers}) + if err != nil { + return view, err + } + view.Presentation = presentation + case "question", "choice", "form": + fields, err := loadPublicNodeFields(db, node, run.TenantID) + if err != nil { + return view, err + } + view.Fields = fields + case "stage": + stage, err := buildStageView(db, run, node) + if err != nil { + return view, err + } + view.Stage = stage + } + return view, nil +} + +// mapsFromRun returns input, derived and form maps for legacy views. +func mapsFromRun(run model.SOPRun) (map[string]interface{}, map[string]interface{}, map[string]interface{}) { + input := map[string]interface{}{} + _ = json.Unmarshal(run.Input, &input) + derived := map[string]interface{}{} + _ = json.Unmarshal(run.Derived, &derived) + answers := map[string]interface{}{} + _ = json.Unmarshal(run.Answers, &answers) + return input, derived, answers +} + +func loadPublicNodeFields(db *gorm.DB, node model.SOPNode, tenantID uint64) ([]PublicFieldView, error) { + var config answerNodeConfig + if err := json.Unmarshal(node.Config, &config); err != nil { + return nil, err + } + keys := config.FieldKeys + if config.FieldKey != "" { + keys = []string{config.FieldKey} + } + if len(keys) == 0 { + return nil, nil + } + var fields []model.ScenarioField + if err := db.Table("scenario_fields sf").Select("sf.*").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPID, tenantID, keys).Find(&fields).Error; err != nil { + return nil, err + } + byKey := make(map[string]model.ScenarioField, len(fields)) + for _, field := range fields { + byKey[field.FieldKey] = field + } + views := make([]PublicFieldView, 0, len(keys)) + for _, key := range keys { + field, ok := byKey[key] + if !ok { + continue + } + views = append(views, PublicFieldView{Key: field.FieldKey, Name: field.FieldName, Type: field.FieldType, Required: field.Required || config.Required || containsString(config.RequiredFieldKeys, key), Options: json.RawMessage(field.Options), Validation: json.RawMessage(field.Validation)}) + } + return views, nil +} diff --git a/internal/scenario/contract.go b/internal/scenario/contract.go index e2c75fe..c6f184d 100644 --- a/internal/scenario/contract.go +++ b/internal/scenario/contract.go @@ -141,7 +141,7 @@ func validateContractInput(input contractInput) error { return fmt.Errorf("output_schema.fields 必须是数组") } outputKeys := map[string]bool{} - allowedSources := map[string]bool{"input": true, "derived": true, "knowledge": true, "form": true, "system": true} + allowedSources := map[string]bool{"input": true, "derived": true, "form": true, "system": true} for index, raw := range fields { field, ok := raw.(map[string]interface{}) if !ok { diff --git a/internal/scriptkit/engine.go b/internal/scriptkit/engine.go new file mode 100644 index 0000000..148ce57 --- /dev/null +++ b/internal/scriptkit/engine.go @@ -0,0 +1,697 @@ +// Package scriptkit implements the script-package engine: dimensions with +// weights, stage scripts, up/down weighting and dimension linkage. It replaces +// the legacy knowledge graph for the consultation scenario. +package scriptkit + +import ( + "encoding/json" + "fmt" + "regexp" + "sort" + "strings" + + "git.iwork-ai.com/xdc/iqudo-top1/internal/model" + "gorm.io/gorm" +) + +// SelectWeight is the weight assigned when a dimension value is selected +// directly (manual check). It exceeds every script show threshold. +const SelectWeight = 10 + +// templatePattern matches {{input.x}} / {{derived.x}} / {{form.x}} placeholders. +var templatePattern = regexp.MustCompile(`\{\{\s*(input|derived|form)\.([A-Za-z][A-Za-z0-9_]*)\s*\}\}`) + +// RenderContext provides the values available for script templates. +type RenderContext struct { + Input map[string]interface{} + Derived map[string]interface{} + Form map[string]interface{} +} + +// ScriptState is the deterministic answer state of a run. It is the only +// source of truth for dimension weights; run_dimensions is a materialized +// cache of RecomputeWeights. +type ScriptState struct { + // ScriptAnswers maps script_key -> selected option keys (or collected + // values for info scripts). + ScriptAnswers map[string][]string `json:"script_answers"` + // DimensionSelects maps dimension value_key -> selected (manual check). + DimensionSelects map[string]bool `json:"dimension_selects"` +} + +// Package is a fully loaded script package. +type Package struct { + Package model.ScriptPackage + Dimensions []model.PackageDimension + Values []model.DimensionValue + Stages []model.PackageStage + Scripts []model.StageScript + Options []model.ScriptOption + Linkages []model.DimensionLinkage + Adapters []model.PackageAdapter +} + +// StageView is the rendered view of one stage for a run. It is the complete +// question set of the stage: clients answer locally and submit all answers +// once at the end of the stage, revealing follow-up questions themselves +// from the per-script confirm/show thresholds. ScreeningRequired marks +// symptom stages with more than 3 candidates, where the screen question is +// answered before the per-symptom questions. +type StageView struct { + StageKey string `json:"stage_key"` + Name string `json:"name"` + Purpose string `json:"purpose"` + Dimensions []DimensionView `json:"dimensions"` + Scripts []ScriptView `json:"scripts"` + Fallback string `json:"fallback,omitempty"` + Form *FormView `json:"form,omitempty"` + ScreeningRequired bool `json:"screening_required"` + CanNext bool `json:"can_next"` + CanNextReason string `json:"can_next_reason,omitempty"` +} + +// DimensionView is one dimension and its weighted values. +type DimensionView struct { + DimKey string `json:"dim_key"` + Name string `json:"name"` + Values []ValueView `json:"values"` +} + +// ValueView is one dimension value with its current weight. +type ValueView struct { + ValueKey string `json:"value_key"` + Name string `json:"name"` + Weight int `json:"weight"` + Hot bool `json:"hot"` +} + +// ScriptView is one matched script rendered for the client. Weight and +// thresholds let clients run the progressive reveal locally and submit all +// answers once at the end of the stage. +type ScriptView struct { + ScriptKey string `json:"script_key"` + Name string `json:"name"` + Type string `json:"script_type"` + Content string `json:"content"` + DimensionValueKey string `json:"dimension_value_key,omitempty"` + Weight int `json:"weight,omitempty"` + ConfirmThreshold int `json:"confirm_threshold,omitempty"` + ShowThreshold int `json:"show_threshold,omitempty"` + Options []OptionView `json:"options,omitempty"` + Products []ProductView `json:"products,omitempty"` + Feedback bool `json:"feedback"` + Required bool `json:"required"` + Multiple bool `json:"multiple"` +} + +// ProductView is one static product attached to a recommendation script. +type ProductView struct { + SKUCode string `json:"sku_code"` + ProductName string `json:"product_name"` +} + +// OptionView is one answer option of a script. +type OptionView struct { + OptionKey string `json:"option_key"` + Label string `json:"label"` +} + +// FormView is the content supplement form of a stage. +type FormView struct { + Fields []FormFieldView `json:"fields"` +} + +// FormFieldView mirrors scenario field definitions for the client. +type FormFieldView struct { + Key string `json:"key"` + Name string `json:"name"` + Type string `json:"type"` + Required bool `json:"required"` + Options json.RawMessage `json:"options"` + Validation json.RawMessage `json:"validation"` +} + +// LoadPackageByID loads a script package with all children. +func LoadPackageByID(db *gorm.DB, tenantID, packageID uint64) (*Package, error) { + var row model.ScriptPackage + if err := db.Where("id = ? AND tenant_id = ?", packageID, tenantID).First(&row).Error; err != nil { + return nil, err + } + return loadPackage(db, tenantID, row) +} + +// LoadPackageByScenario loads the package owned by a scenario. +func LoadPackageByScenario(db *gorm.DB, tenantID, scenarioID uint64) (*Package, error) { + var row model.ScriptPackage + if err := db.Where("scenario_id = ? AND tenant_id = ?", scenarioID, tenantID).First(&row).Error; err != nil { + return nil, err + } + return loadPackage(db, tenantID, row) +} + +func loadPackage(db *gorm.DB, tenantID uint64, row model.ScriptPackage) (*Package, error) { + pkg := &Package{Package: row} + queries := []struct { + target interface{} + order string + }{ + {&pkg.Dimensions, "sort_order, id"}, + {&pkg.Values, "sort_order, id"}, + {&pkg.Stages, "sort_order, id"}, + {&pkg.Scripts, "sort_order, id"}, + {&pkg.Options, "sort_order, id"}, + {&pkg.Linkages, "sort_order, id"}, + {&pkg.Adapters, "sort_order, id"}, + } + for _, query := range queries { + if err := db.Where("package_id = ? AND tenant_id = ?", row.ID, tenantID).Order(query.order).Find(query.target).Error; err != nil { + return nil, err + } + } + return pkg, nil +} + +// StageByKey returns the stage with the given key. +func (p *Package) StageByKey(stageKey string) (model.PackageStage, bool) { + for _, stage := range p.Stages { + if stage.StageKey == stageKey { + return stage, true + } + } + return model.PackageStage{}, false +} + +// ValueByKey returns the dimension value with the given key. +func (p *Package) ValueByKey(valueKey string) (model.DimensionValue, bool) { + for _, value := range p.Values { + if value.ValueKey == valueKey { + return value, true + } + } + return model.DimensionValue{}, false +} + +// ScriptByKey returns the script with the given key. +func (p *Package) ScriptByKey(scriptKey string) (model.StageScript, bool) { + for _, script := range p.Scripts { + if script.ScriptKey == scriptKey { + return script, true + } + } + return model.StageScript{}, false +} + +// OptionByKey returns the option of a script with the given key. +func (p *Package) OptionByKey(script model.StageScript, optionKey string) (model.ScriptOption, bool) { + for _, option := range p.Options { + if option.ScriptID == script.ID && option.OptionKey == optionKey { + return option, true + } + } + return model.ScriptOption{}, false +} + +// InitialWeights computes the entry weights from package adapters. +func (p *Package) InitialWeights(ctx RenderContext) map[uint64]int { + weights := make(map[uint64]int) + for _, adapter := range p.Adapters { + if adapterMatches(adapter, ctx) { + if current, exists := weights[adapter.TargetDimensionValueID]; !exists || current < adapter.Weight { + weights[adapter.TargetDimensionValueID] = adapter.Weight + } + } + } + for _, value := range p.Values { + if value.InitialWeight > 0 { + if current, exists := weights[value.ID]; !exists || current < value.InitialWeight { + weights[value.ID] = value.InitialWeight + } + } + } + return weights +} + +func adapterMatches(adapter model.PackageAdapter, ctx RenderContext) bool { + value, ok := lookupContext(ctx, adapter.SourceField) + if !ok { + return false + } + switch typed := value.(type) { + case []interface{}: + for _, item := range typed { + if fmt.Sprint(item) == adapter.MatchValue { + return true + } + } + return false + case []string: + for _, item := range typed { + if item == adapter.MatchValue { + return true + } + } + return false + default: + return fmt.Sprint(typed) == adapter.MatchValue + } +} + +// RecomputeWeights deterministically derives the current weights from the +// initial weights plus the script answer state and dimension linkage. Values +// explicitly set by a user answer (set effect or dimension select) are marked +// confirmed and no longer receive linkage contributions, so a confirmed +// disease keeps weight 10 instead of stacking symptom evidence on top. +func (p *Package) RecomputeWeights(base map[uint64]int, state ScriptState, ctx RenderContext) map[uint64]int { + weights := cloneWeights(base) + confirmed := map[uint64]bool{} + for scriptKey, optionKeys := range state.ScriptAnswers { + script, ok := p.ScriptByKey(scriptKey) + if !ok { + continue + } + for _, optionKey := range optionKeys { + option, found := p.OptionByKey(script, optionKey) + if !found || option.TargetDimensionValueID == nil { + continue + } + applyEffect(weights, *option.TargetDimensionValueID, option.Effect, option.EffectValue) + if option.Effect == "set" { + confirmed[*option.TargetDimensionValueID] = true + } + } + } + for valueKey, selected := range state.DimensionSelects { + value, ok := p.ValueByKey(valueKey) + if !ok { + continue + } + if selected { + if current := weights[value.ID]; current < SelectWeight { + weights[value.ID] = SelectWeight + } + confirmed[value.ID] = true + } else { + weights[value.ID] = 0 + delete(confirmed, value.ID) + } + } + return p.propagate(weights, confirmed) +} + +// linkageDefaultThreshold is the minimum source weight for a dimension value +// to count as confirmed and propagate through linkages. +const linkageDefaultThreshold = 5 + +// propagate spreads confirmed dimension values through dimension linkages until +// stable. A source only propagates when its weight reaches the linkage +// activation threshold, so unconfirmed hints (for example a SKU related symptom +// at weight 3) never pull in their diseases. Targets that were explicitly +// confirmed by the user keep their own weight and are not stacked again. Each +// pass recomputes target weights from the direct weights plus the contributions +// of sources that were confirmed in the previous pass, which is stable for DAG +// shaped packages. +func (p *Package) propagate(direct map[uint64]int, confirmed map[uint64]bool) map[uint64]int { + if len(p.Linkages) == 0 { + return cloneWeights(direct) + } + weights := cloneWeights(direct) + for pass := 0; pass <= len(p.Linkages)+2; pass++ { + next := cloneWeights(direct) + for _, linkage := range p.Linkages { + threshold := linkage.ActivationThreshold + if threshold <= 0 { + threshold = linkageDefaultThreshold + } + if confirmed[linkage.ToDimensionValueID] { + continue + } + if weights[linkage.FromDimensionValueID] >= threshold { + next[linkage.ToDimensionValueID] += linkage.Contribution + } + } + if weightsEqual(next, weights) { + return next + } + weights = next + } + return weights +} + +func applyEffect(weights map[uint64]int, target uint64, effect string, value int) { + switch effect { + case "set": + weights[target] = value + case "add": + weights[target] += value + case "subtract": + weights[target] -= value + if weights[target] < 0 { + weights[target] = 0 + } + case "zero": + weights[target] = 0 + default: + weights[target] = value + } +} + +// BuildStageView matches and renders the scripts of one stage. The view is the +// complete question set for the stage: clients answer locally and submit once +// at the end of the stage, revealing follow-up questions themselves from the +// confirm/show thresholds. ScreeningRequired tells symptom stages with more +// than 3 candidates to show the screen question first. +func (p *Package) BuildStageView(stage model.PackageStage, weights map[uint64]int, ctx RenderContext, state ScriptState, formFields []FormFieldView) StageView { + view := StageView{ + StageKey: stage.StageKey, Name: stage.Name, Purpose: stage.Purpose, + Dimensions: []DimensionView{}, Scripts: []ScriptView{}, CanNext: true, + } + view.Dimensions = p.buildDimensions(stage, weights) + primaryZero := p.primaryDimensionZero(stage, weights) + scripts := p.scriptsForStage(stage) + screening := p.findScript(scripts, "screen") + view.ScreeningRequired = screening != nil && p.candidateCount(stage, weights) > 3 + matches := make([]scriptMatch, 0) + for _, script := range scripts { + weight, included := p.scriptIncluded(script, stage, weights, primaryZero) + if !included { + continue + } + matches = append(matches, scriptMatch{script: script, weight: weight}) + } + sort.SliceStable(matches, func(i, j int) bool { + if matches[i].weight != matches[j].weight { + return matches[i].weight > matches[j].weight + } + return matches[i].script.SortOrder < matches[j].script.SortOrder + }) + for _, match := range matches { + script := match.script + rendered := RenderTemplate(script.Content, ctx) + view.Scripts = append(view.Scripts, ScriptView{ + ScriptKey: script.ScriptKey, Name: script.Name, Type: script.ScriptType, + Content: rendered, DimensionValueKey: p.valueKey(script.DimensionValueID), + Weight: match.weight, ConfirmThreshold: script.ConfirmThreshold, ShowThreshold: script.ShowThreshold, + Options: p.optionsFor(script), + Products: p.productsFor(script), + Feedback: script.ScriptType != "template", Required: script.Required, Multiple: script.Multiple, + }) + if script.ScriptType == "fallback" { + view.Fallback = rendered + } + if script.Required && !stateAnswered(state, script) { + view.CanNext = false + } + } + // 拟诊阶段必须至少确认一个疾病方向,否则药品推荐节点会没有药物推荐。 + if stage.StageKey == "diagnosis" && !p.stageHasConfirmedPrimary(stage, weights) { + view.CanNext = false + view.CanNextReason = "请至少确认一个疾病方向(或手动勾选一个),确认后才能进入药品推荐" + } + if len(formFields) > 0 { + view.Form = &FormView{Fields: formFields} + for _, field := range formFields { + if field.Required && isEmptyValue(ctx.Form[field.Key]) { + view.CanNext = false + } + } + } + return view +} + +// scriptIncluded decides whether a script belongs to the complete stage view +// and returns its sort weight. Confirmed questions disappear once the value is +// confirmed; follow-up scripts stay included so clients can reveal them +// locally after the user answers. +func (p *Package) scriptIncluded(script model.StageScript, stage model.PackageStage, weights map[uint64]int, primaryZero bool) (int, bool) { + if script.DimensionValueID == nil { + if script.ScriptType == "fallback" { + return 0, primaryZero + } + return 0, true + } + weight := weights[*script.DimensionValueID] + if weight <= 0 { + return 0, false + } + switch script.ScriptType { + case "confirm": + if weight >= script.ShowThreshold { + return weight, false + } + return weight, true + case "choice", "info": + return weight, true + case "message": + // 推荐阶段只带已确认(权重达到显示阈值)的疾病;拟诊阶段把确认后 + // 话术一并带出,由客户端在本地确认后展示。 + if stage.StageKey == "recommend" && weight < script.ShowThreshold { + return weight, false + } + return weight, true + default: + return weight, true + } +} + +// findScript returns the first stage script with the given type. +func (p *Package) findScript(scripts []model.StageScript, scriptType string) *model.StageScript { + for index := range scripts { + if scripts[index].ScriptType == scriptType { + return &scripts[index] + } + } + return nil +} + +// candidateCount counts the primary dimension values that currently carry a +// weight greater than zero. +func (p *Package) candidateCount(stage model.PackageStage, weights map[uint64]int) int { + count := 0 + for _, value := range p.Values { + if value.DimensionID == stage.PrimaryDimensionID && weights[value.ID] > 0 { + count++ + } + } + return count +} + +// stageHasConfirmedPrimary reports whether at least one primary dimension value +// reached the confirmation threshold of the stage's confirm scripts. Stages +// without confirm scripts are not constrained. For the diagnosis stage this +// guarantees the recommendation node always has confirmed diseases to +// recommend, so entering it always comes with product recommendations. +func (p *Package) stageHasConfirmedPrimary(stage model.PackageStage, weights map[uint64]int) bool { + threshold := 0 + for _, script := range p.Scripts { + if script.StageID == stage.ID && script.ScriptType == "confirm" && script.ShowThreshold > threshold { + threshold = script.ShowThreshold + } + } + if threshold <= 0 { + return true + } + for _, value := range p.Values { + if value.DimensionID == stage.PrimaryDimensionID && weights[value.ID] >= threshold { + return true + } + } + return false +} + +type scriptMatch struct { + script model.StageScript + weight int +} + +// buildDimensions renders only the primary dimension of the stage. Symptoms +// therefore only appear in the symptom stage and diseases only in the +// diagnosis/recommend stages. A stage without a primary dimension (opening) +// renders no dimension chips. +func (p *Package) buildDimensions(stage model.PackageStage, weights map[uint64]int) []DimensionView { + if stage.PrimaryDimensionID == 0 { + return []DimensionView{} + } + views := make([]DimensionView, 0, 1) + for _, dimension := range p.Dimensions { + if dimension.ID != stage.PrimaryDimensionID { + continue + } + values := make([]ValueView, 0) + for _, value := range p.Values { + if value.DimensionID != dimension.ID { + continue + } + values = append(values, ValueView{ValueKey: value.ValueKey, Name: value.Name, Weight: weights[value.ID], Hot: weights[value.ID] > 0}) + } + sort.SliceStable(values, func(i, j int) bool { + if values[i].Weight != values[j].Weight { + return values[i].Weight > values[j].Weight + } + return values[i].ValueKey < values[j].ValueKey + }) + views = append(views, DimensionView{DimKey: dimension.DimKey, Name: dimension.Name, Values: values}) + break + } + return views +} + +func (p *Package) primaryDimensionZero(stage model.PackageStage, weights map[uint64]int) bool { + for _, value := range p.Values { + if value.DimensionID == stage.PrimaryDimensionID && weights[value.ID] > 0 { + return false + } + } + return true +} + +func (p *Package) scriptsForStage(stage model.PackageStage) []model.StageScript { + scripts := make([]model.StageScript, 0) + for _, script := range p.Scripts { + if script.StageID == stage.ID { + scripts = append(scripts, script) + } + } + return scripts +} + +func (p *Package) optionsFor(script model.StageScript) []OptionView { + if script.ScriptType != "confirm" && script.ScriptType != "choice" && script.ScriptType != "info" && script.ScriptType != "screen" { + return nil + } + options := make([]OptionView, 0) + for _, option := range p.Options { + if option.ScriptID != script.ID { + continue + } + options = append(options, OptionView{OptionKey: option.OptionKey, Label: option.Label}) + } + return options +} + +func (p *Package) valueKey(valueID *uint64) string { + if valueID == nil { + return "" + } + for _, value := range p.Values { + if value.ID == *valueID { + return value.ValueKey + } + } + return "" +} + +func (p *Package) productsFor(script model.StageScript) []ProductView { + if len(script.Products) == 0 || string(script.Products) == "[]" || string(script.Products) == "{}" { + return nil + } + var products []ProductView + if err := json.Unmarshal(script.Products, &products); err != nil { + return nil + } + return products +} + +// stateAnswered reports whether a script has an answer entry. The key presence +// counts as answered so a multi-select question can be answered with an empty +// selection. +func stateAnswered(state ScriptState, script model.StageScript) bool { + _, ok := state.ScriptAnswers[script.ScriptKey] + return ok +} + +// RenderTemplate replaces {{input.x}} / {{derived.x}} / {{form.x}} with the +// current context values. Missing values render as empty strings so callers +// can fall back to generic copy. +func RenderTemplate(content string, ctx RenderContext) string { + return templatePattern.ReplaceAllStringFunc(content, func(match string) string { + parts := templatePattern.FindStringSubmatch(match) + if len(parts) != 3 { + return "" + } + value, ok := lookupContext(ctx, parts[1]+"."+parts[2]) + if !ok || value == nil { + return "" + } + return formatTemplateValue(value) + }) +} + +// formatTemplateValue renders a context value for template interpolation. +// Arrays are joined with "、" for natural Chinese copy. +func formatTemplateValue(value interface{}) string { + switch typed := value.(type) { + case []interface{}: + parts := make([]string, 0, len(typed)) + for _, item := range typed { + parts = append(parts, fmt.Sprint(item)) + } + return strings.Join(parts, "、") + case []string: + return strings.Join(typed, "、") + default: + return fmt.Sprint(typed) + } +} + +// lookupContext resolves a namespaced field (input.x / derived.x / form.x) +// or a bare field name. +func lookupContext(ctx RenderContext, field string) (interface{}, bool) { + if strings.HasPrefix(field, "input.") { + value, ok := ctx.Input[strings.TrimPrefix(field, "input.")] + return value, ok + } + if strings.HasPrefix(field, "derived.") { + value, ok := ctx.Derived[strings.TrimPrefix(field, "derived.")] + return value, ok + } + if strings.HasPrefix(field, "form.") { + value, ok := ctx.Form[strings.TrimPrefix(field, "form.")] + return value, ok + } + if value, ok := ctx.Form[field]; ok { + return value, true + } + if value, ok := ctx.Input[field]; ok { + return value, true + } + if value, ok := ctx.Derived[field]; ok { + return value, true + } + return nil, false +} + +func cloneWeights(source map[uint64]int) map[uint64]int { + clone := make(map[uint64]int, len(source)) + for key, value := range source { + clone[key] = value + } + return clone +} + +func weightsEqual(left, right map[uint64]int) bool { + if len(left) != len(right) { + return false + } + for key, value := range left { + if right[key] != value { + return false + } + } + return true +} + +func isEmptyValue(value interface{}) bool { + if value == nil { + return true + } + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) == "" + case []interface{}: + return len(typed) == 0 + case []string: + return len(typed) == 0 + default: + return false + } +} diff --git a/internal/scriptkit/engine_test.go b/internal/scriptkit/engine_test.go new file mode 100644 index 0000000..e54542b --- /dev/null +++ b/internal/scriptkit/engine_test.go @@ -0,0 +1,321 @@ +package scriptkit + +import ( + "testing" + + "git.iwork-ai.com/xdc/iqudo-top1/internal/model" +) + +func buildTestPackage() *Package { + // symptom dimension: vomiting, diarrhea + // disease dimension: gastritis, enteritis + pkg := &Package{Package: model.ScriptPackage{Base: model.Base{ID: 1}, StartStageKey: "symptom"}} + pkg.Dimensions = []model.PackageDimension{ + {Base: model.Base{ID: 1}, PackageID: 1, DimKey: "symptom", Name: "症状"}, + {Base: model.Base{ID: 2}, PackageID: 1, DimKey: "disease", Name: "疾病"}, + } + pkg.Values = []model.DimensionValue{ + {Base: model.Base{ID: 11}, PackageID: 1, DimensionID: 1, ValueKey: "vomiting", Name: "呕吐"}, + {Base: model.Base{ID: 12}, PackageID: 1, DimensionID: 1, ValueKey: "diarrhea", Name: "腹泻"}, + {Base: model.Base{ID: 21}, PackageID: 1, DimensionID: 2, ValueKey: "gastritis", Name: "胃肠炎"}, + {Base: model.Base{ID: 22}, PackageID: 1, DimensionID: 2, ValueKey: "enteritis", Name: "肠炎"}, + } + pkg.Stages = []model.PackageStage{ + {Base: model.Base{ID: 31}, PackageID: 1, StageKey: "symptom", Name: "症状确认", PrimaryDimensionID: 1}, + {Base: model.Base{ID: 32}, PackageID: 1, StageKey: "diagnosis", Name: "拟诊确认", PrimaryDimensionID: 2}, + } + vomiting := uint64(11) + diarrhea := uint64(12) + pkg.Scripts = []model.StageScript{ + {Base: model.Base{ID: 101}, PackageID: 1, StageID: 31, ScriptKey: "vomiting_info", Name: "呕吐细节", ScriptType: "info", Content: "吐的是什么?", DimensionValueID: &vomiting, ShowThreshold: 5, ConfirmThreshold: 3}, + {Base: model.Base{ID: 102}, PackageID: 1, StageID: 31, ScriptKey: "vomiting_confirm", Name: "确认呕吐", ScriptType: "confirm", Content: "有呕吐吗?", DimensionValueID: &vomiting, ShowThreshold: 5, ConfirmThreshold: 3}, + {Base: model.Base{ID: 103}, PackageID: 1, StageID: 31, ScriptKey: "diarrhea_confirm", Name: "确认腹泻", ScriptType: "confirm", Content: "有腹泻吗?", DimensionValueID: &diarrhea, ShowThreshold: 5, ConfirmThreshold: 3}, + {Base: model.Base{ID: 104}, PackageID: 1, StageID: 31, ScriptKey: "symptom_fallback", Name: "兜底", ScriptType: "fallback", Content: "哪里不舒服?"}, + {Base: model.Base{ID: 105}, PackageID: 1, StageID: 32, ScriptKey: "gastritis_confirm", Name: "确认胃肠炎", ScriptType: "confirm", Content: "更像胃肠炎吗?", DimensionValueID: u64(21), ShowThreshold: 10, ConfirmThreshold: 5}, + {Base: model.Base{ID: 106}, PackageID: 1, StageID: 32, ScriptKey: "gastritis_msg", Name: "胃肠炎话术", ScriptType: "message", Content: "更符合胃肠炎方向。", DimensionValueID: u64(21), ShowThreshold: 10, ConfirmThreshold: 5}, + } + pkg.Options = []model.ScriptOption{ + {Base: model.Base{ID: 201}, PackageID: 1, ScriptID: 102, OptionKey: "yes", Label: "有", TargetDimensionValueID: &vomiting, Effect: "set", EffectValue: 8}, + {Base: model.Base{ID: 202}, PackageID: 1, ScriptID: 102, OptionKey: "no", Label: "没有", TargetDimensionValueID: &vomiting, Effect: "zero"}, + {Base: model.Base{ID: 203}, PackageID: 1, ScriptID: 103, OptionKey: "yes", Label: "有", TargetDimensionValueID: &diarrhea, Effect: "set", EffectValue: 8}, + {Base: model.Base{ID: 204}, PackageID: 1, ScriptID: 103, OptionKey: "no", Label: "没有", TargetDimensionValueID: &diarrhea, Effect: "zero"}, + {Base: model.Base{ID: 205}, PackageID: 1, ScriptID: 105, OptionKey: "yes", Label: "对,是这样", TargetDimensionValueID: u64(21), Effect: "set", EffectValue: 10}, + {Base: model.Base{ID: 206}, PackageID: 1, ScriptID: 105, OptionKey: "no", Label: "不是这样", TargetDimensionValueID: u64(21), Effect: "zero"}, + } + pkg.Linkages = []model.DimensionLinkage{ + {Base: model.Base{ID: 301}, PackageID: 1, FromDimensionValueID: 11, ToDimensionValueID: 21, RelationType: "contributes_to", Contribution: 5}, + {Base: model.Base{ID: 302}, PackageID: 1, FromDimensionValueID: 12, ToDimensionValueID: 21, RelationType: "contributes_to", Contribution: 5}, + {Base: model.Base{ID: 303}, PackageID: 1, FromDimensionValueID: 12, ToDimensionValueID: 22, RelationType: "contributes_to", Contribution: 5}, + } + pkg.Adapters = []model.PackageAdapter{ + {PackageID: 1, SourceField: "input.tags", MatchValue: "呕吐", TargetDimensionValueID: 11, Weight: 8}, + {PackageID: 1, SourceField: "input.tags", MatchValue: "腹泻", TargetDimensionValueID: 12, Weight: 3}, + } + return pkg +} + +func u64(value uint64) *uint64 { return &value } + +func TestFuzzyWeightShowsConfirmationOnly(t *testing.T) { + pkg := buildTestPackage() + ctx := RenderContext{Input: map[string]interface{}{"tags": []interface{}{"腹泻"}}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}} + weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, ctx) + if weights[12] != 3 { + t.Fatalf("diarrhea weight = %d, want 3", weights[12]) + } + stage, _ := pkg.StageByKey("symptom") + view := pkg.BuildStageView(stage, weights, ctx, ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, nil) + if len(view.Scripts) != 1 || view.Scripts[0].ScriptKey != "diarrhea_confirm" { + t.Fatalf("fuzzy weight should only show the confirmation script, got %#v", view.Scripts) + } +} + +func TestScreeningRequiredWithMoreThanThreeCandidates(t *testing.T) { + a, b, c, d := uint64(11), uint64(12), uint64(13), uint64(14) + pkg := &Package{Package: model.ScriptPackage{Base: model.Base{ID: 1}, StartStageKey: "symptom"}} + pkg.Dimensions = []model.PackageDimension{{Base: model.Base{ID: 1}, PackageID: 1, DimKey: "symptom", Name: "症状"}} + pkg.Values = []model.DimensionValue{ + {Base: model.Base{ID: a}, PackageID: 1, DimensionID: 1, ValueKey: "a", Name: "A", InitialWeight: 3}, + {Base: model.Base{ID: b}, PackageID: 1, DimensionID: 1, ValueKey: "b", Name: "B", InitialWeight: 3}, + {Base: model.Base{ID: c}, PackageID: 1, DimensionID: 1, ValueKey: "c", Name: "C", InitialWeight: 3}, + {Base: model.Base{ID: d}, PackageID: 1, DimensionID: 1, ValueKey: "d", Name: "D", InitialWeight: 3}, + } + pkg.Stages = []model.PackageStage{{Base: model.Base{ID: 31}, PackageID: 1, StageKey: "symptom", Name: "症状确认", PrimaryDimensionID: 1}} + pkg.Scripts = []model.StageScript{ + {Base: model.Base{ID: 101}, PackageID: 1, StageID: 31, ScriptKey: "symptom_screen", Name: "症状筛选", ScriptType: "screen", Content: "有什么表现?", Multiple: true}, + {Base: model.Base{ID: 102}, PackageID: 1, StageID: 31, ScriptKey: "confirm_a", Name: "确认A", ScriptType: "confirm", Content: "有A吗?", DimensionValueID: &a, ShowThreshold: 5, ConfirmThreshold: 3}, + {Base: model.Base{ID: 103}, PackageID: 1, StageID: 31, ScriptKey: "choice_a", Name: "A细节", ScriptType: "choice", Content: "A多久了?", DimensionValueID: &a, ShowThreshold: 5, ConfirmThreshold: 3}, + } + pkg.Options = []model.ScriptOption{ + {Base: model.Base{ID: 201}, PackageID: 1, ScriptID: 101, OptionKey: "a", Label: "A", TargetDimensionValueID: &a, Effect: "set", EffectValue: 10}, + {Base: model.Base{ID: 202}, PackageID: 1, ScriptID: 101, OptionKey: "b", Label: "B", TargetDimensionValueID: &b, Effect: "set", EffectValue: 10}, + {Base: model.Base{ID: 203}, PackageID: 1, ScriptID: 101, OptionKey: "c", Label: "C", TargetDimensionValueID: &c, Effect: "set", EffectValue: 10}, + {Base: model.Base{ID: 204}, PackageID: 1, ScriptID: 101, OptionKey: "d", Label: "D", TargetDimensionValueID: &d, Effect: "set", EffectValue: 10}, + {Base: model.Base{ID: 205}, PackageID: 1, ScriptID: 102, OptionKey: "yes", Label: "有", TargetDimensionValueID: &a, Effect: "set", EffectValue: 8}, + {Base: model.Base{ID: 206}, PackageID: 1, ScriptID: 102, OptionKey: "no", Label: "没有", TargetDimensionValueID: &a, Effect: "zero"}, + } + stage, _ := pkg.StageByKey("symptom") + ctx := RenderContext{Input: map[string]interface{}{}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}} + emptyState := ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}} + weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), emptyState, ctx) + view := pkg.BuildStageView(stage, weights, ctx, emptyState, nil) + if !view.ScreeningRequired { + t.Fatalf("4 candidates should require the screening question, got %#v", view) + } + // 阶段视图带出完整题目集合:筛选题 + 每个候选的确认题与细节题, + // 客户端本地作答、结束时一次性提交。 + keys := map[string]bool{} + for _, script := range view.Scripts { + keys[script.ScriptKey] = true + } + if !keys["symptom_screen"] || !keys["confirm_a"] || !keys["choice_a"] { + t.Fatalf("stage view should carry the complete question set, got %v", keys) + } + // 筛选:只选 A、B -> A/B 确认(10),C/D 归零;筛选不再要求。 + state := ScriptState{ScriptAnswers: map[string][]string{"symptom_screen": {"a", "b"}}, DimensionSelects: map[string]bool{"a": true, "b": true, "c": false, "d": false}} + weights = pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx) + if weights[a] != 10 || weights[b] != 10 || weights[c] != 0 || weights[d] != 0 { + t.Fatalf("screening weights = %v", weights) + } + view = pkg.BuildStageView(stage, weights, ctx, state, nil) + if view.ScreeningRequired { + t.Fatalf("screening should not be required after selection, got %#v", view) + } + for _, script := range view.Scripts { + if script.ScriptKey == "confirm_a" { + t.Fatalf("confirmed value should not carry its confirm question, got %#v", view.Scripts) + } + if script.ScriptKey == "choice_a" && script.Weight != 10 { + t.Fatalf("selected value details should carry weight 10, got %#v", script) + } + } + // 候选 <= 3 时不要求筛选。 + lessState := ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{"c": false, "d": false}} + lessWeights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), lessState, ctx) + lessView := pkg.BuildStageView(stage, lessWeights, ctx, lessState, nil) + if lessView.ScreeningRequired { + t.Fatalf("screening should not be required with <=3 candidates, got %#v", lessView) + } +} + +func TestFuzzyHintDoesNotPropagateDisease(t *testing.T) { + pkg := buildTestPackage() + ctx := RenderContext{Input: map[string]interface{}{"tags": []interface{}{"腹泻"}}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}} + weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, ctx) + if weights[12] != 3 { + t.Fatalf("diarrhea weight = %d, want 3", weights[12]) + } + if weights[21] != 0 || weights[22] != 0 { + t.Fatalf("fuzzy hint must not pull diseases in: %v", weights) + } +} + +func TestStrongWeightShowsInfoScripts(t *testing.T) { + pkg := buildTestPackage() + ctx := RenderContext{Input: map[string]interface{}{"tags": []interface{}{"呕吐"}}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}} + weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, ctx) + stage, _ := pkg.StageByKey("symptom") + view := pkg.BuildStageView(stage, weights, ctx, ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, nil) + if len(view.Scripts) != 1 || view.Scripts[0].ScriptKey != "vomiting_info" { + t.Fatalf("strong weight should show the info script, got %#v", view.Scripts) + } +} + +func TestAllZeroShowsFallback(t *testing.T) { + pkg := buildTestPackage() + ctx := RenderContext{Input: map[string]interface{}{}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}} + weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, ctx) + stage, _ := pkg.StageByKey("symptom") + view := pkg.BuildStageView(stage, weights, ctx, ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, nil) + if len(view.Scripts) != 1 || view.Scripts[0].Type != "fallback" { + t.Fatalf("all zero weights should show fallback, got %#v", view.Scripts) + } + if view.Fallback != "哪里不舒服?" { + t.Fatalf("fallback = %q", view.Fallback) + } +} + +func TestConfirmYesSetsWeightAndPropagatesDisease(t *testing.T) { + pkg := buildTestPackage() + ctx := RenderContext{Input: map[string]interface{}{}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}} + state := ScriptState{ScriptAnswers: map[string][]string{"vomiting_confirm": {"yes"}}, DimensionSelects: map[string]bool{}} + weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx) + if weights[11] != 8 { + t.Fatalf("vomiting weight = %d, want 8", weights[11]) + } + if weights[21] != 5 { + t.Fatalf("gastritis weight = %d, want 5 via linkage", weights[21]) + } + // 候选疾病先带出确认题和确认后话术(完整题目集合,客户端本地作答后 + // 一次性提交;确认后话术在本地确认后再展示)。 + stage, _ := pkg.StageByKey("diagnosis") + view := pkg.BuildStageView(stage, weights, ctx, state, nil) + keys := map[string]bool{} + for _, script := range view.Scripts { + keys[script.ScriptKey] = true + } + if !keys["gastritis_confirm"] || !keys["gastritis_msg"] { + t.Fatalf("candidate disease should carry confirm question and follow-up copy, got %v", keys) + } + // 确认疾病后(对,是这样 -> 权重10),确认题隐藏,只保留该疾病话术。 + state.ScriptAnswers["gastritis_confirm"] = []string{"yes"} + weights = pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx) + if weights[21] != 10 { + t.Fatalf("confirmed gastritis weight = %d, want 10", weights[21]) + } + view = pkg.BuildStageView(stage, weights, ctx, state, nil) + if len(view.Scripts) != 1 || view.Scripts[0].ScriptKey != "gastritis_msg" { + t.Fatalf("confirmed disease should keep only its copy, got %#v", view.Scripts) + } +} + +func TestMultipleDiseasesCanBeConfirmed(t *testing.T) { + pkg := buildTestPackage() + ctx := RenderContext{Input: map[string]interface{}{}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}} + // 两个症状都确认(呕吐、腹泻),再确认两个疾病方向。 + state := ScriptState{ScriptAnswers: map[string][]string{ + "vomiting_confirm": {"yes"}, + "diarrhea_confirm": {"yes"}, + "gastritis_confirm": {"yes"}, + }, DimensionSelects: map[string]bool{"enteritis": true}} + weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx) + if weights[21] != 10 { + t.Fatalf("gastritis weight = %d, want 10 after confirmation", weights[21]) + } + if weights[22] < 10 { + t.Fatalf("enteritis weight = %d, want >= 10 after confirmation", weights[22]) + } +} + +func TestConfirmNoZeroesWeight(t *testing.T) { + pkg := buildTestPackage() + ctx := RenderContext{Input: map[string]interface{}{"tags": []interface{}{"腹泻"}}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}} + state := ScriptState{ScriptAnswers: map[string][]string{"diarrhea_confirm": {"no"}}, DimensionSelects: map[string]bool{}} + weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx) + if weights[12] != 0 { + t.Fatalf("diarrhea weight = %d, want 0", weights[12]) + } +} + +func TestDirectSelectSetsHighWeight(t *testing.T) { + pkg := buildTestPackage() + ctx := RenderContext{Input: map[string]interface{}{}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}} + state := ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{"vomiting": true}} + weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx) + if weights[11] != SelectWeight { + t.Fatalf("selected weight = %d, want %d", weights[11], SelectWeight) + } +} + +func TestRecomputeIsDeterministic(t *testing.T) { + pkg := buildTestPackage() + ctx := RenderContext{Input: map[string]interface{}{"tags": []interface{}{"腹泻"}}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}} + state := ScriptState{ScriptAnswers: map[string][]string{"diarrhea_confirm": {"yes"}}, DimensionSelects: map[string]bool{}} + first := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx) + for range 5 { + again := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx) + if !weightsEqual(first, again) { + t.Fatalf("recompute is not deterministic: %v vs %v", first, again) + } + } +} + +func TestRenderTemplate(t *testing.T) { + ctx := RenderContext{ + Input: map[string]interface{}{"customer_name": "王女士", "products": []interface{}{"商品A", "商品B"}}, + Derived: map[string]interface{}{}, + Form: map[string]interface{}{"note": "已接受"}, + } + got := RenderTemplate("您好{{input.customer_name}},看到{{input.products}},备注{{form.note}}。", ctx) + if got != "您好王女士,看到商品A、商品B,备注已接受。" { + t.Fatalf("RenderTemplate = %q", got) + } +} + +func TestDiagnosisRequiresConfirmedDiseaseBeforeAdvancing(t *testing.T) { + pkg := buildTestPackage() + ctx := RenderContext{Input: map[string]interface{}{}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}} + // 症状确认带出候选疾病(权重5),但还没有确认任何疾病方向。 + state := ScriptState{ScriptAnswers: map[string][]string{"vomiting_confirm": {"yes"}}, DimensionSelects: map[string]bool{}} + weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx) + stage, _ := pkg.StageByKey("diagnosis") + view := pkg.BuildStageView(stage, weights, ctx, state, nil) + if view.CanNext { + t.Fatalf("diagnosis should require a confirmed disease before advancing, got can_next=%v", view.CanNext) + } + if view.CanNextReason == "" { + t.Fatalf("diagnosis block should carry a reason, got %q", view.CanNextReason) + } + // 确认疾病后(权重10)才能进入药品推荐。 + state.ScriptAnswers["gastritis_confirm"] = []string{"yes"} + weights = pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx) + view = pkg.BuildStageView(stage, weights, ctx, state, nil) + if !view.CanNext { + t.Fatalf("confirmed disease should allow advancing, got can_next=%v reason=%q", view.CanNext, view.CanNextReason) + } +} + +func TestStageViewOnlyShowsPrimaryDimension(t *testing.T) { + pkg := buildTestPackage() + ctx := RenderContext{Input: map[string]interface{}{"tags": []interface{}{"呕吐"}}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}} + state := ScriptState{ScriptAnswers: map[string][]string{"vomiting_confirm": {"yes"}}, DimensionSelects: map[string]bool{}} + weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx) + // 症状阶段:症状与疾病(联动权重5)都有值,但只显示主维度症状。 + stage, _ := pkg.StageByKey("symptom") + view := pkg.BuildStageView(stage, weights, ctx, state, nil) + if len(view.Dimensions) != 1 || view.Dimensions[0].DimKey != "symptom" { + t.Fatalf("symptom stage should only show the symptom dimension, got %#v", view.Dimensions) + } + // 拟诊阶段:只显示疾病维度,不显示症状。 + diagStage, _ := pkg.StageByKey("diagnosis") + diagView := pkg.BuildStageView(diagStage, weights, ctx, state, nil) + if len(diagView.Dimensions) != 1 || diagView.Dimensions[0].DimKey != "disease" { + t.Fatalf("diagnosis stage should only show the disease dimension, got %#v", diagView.Dimensions) + } + // 没有主维度的阶段(开场)不显示任何维度。 + openStage := model.PackageStage{Base: model.Base{ID: 33}, PackageID: 1, StageKey: "opening", Name: "开场", PrimaryDimensionID: 0} + openView := pkg.BuildStageView(openStage, weights, ctx, state, nil) + if len(openView.Dimensions) != 0 { + t.Fatalf("opening stage should show no dimensions, got %#v", openView.Dimensions) + } +} diff --git a/internal/scriptkit/handler.go b/internal/scriptkit/handler.go new file mode 100644 index 0000000..2973e04 --- /dev/null +++ b/internal/scriptkit/handler.go @@ -0,0 +1,322 @@ +package scriptkit + +import ( + "encoding/json" + "net/http" + "strconv" + + "git.iwork-ai.com/xdc/iqudo-top1/internal/access" + "git.iwork-ai.com/xdc/iqudo-top1/internal/audit" + "git.iwork-ai.com/xdc/iqudo-top1/internal/auth" + "git.iwork-ai.com/xdc/iqudo-top1/internal/model" + "git.iwork-ai.com/xdc/iqudo-top1/internal/response" + "github.com/gin-gonic/gin" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +// Handler exposes the script package admin API. +type Handler struct { + db *gorm.DB +} + +func NewHandler(db *gorm.DB) *Handler { + return &Handler{db: db} +} + +// PackageInput is the replaceable document of a script package. +type PackageInput struct { + Name string `json:"name"` + StartStageKey string `json:"start_stage_key"` + Dimensions []DimensionInput `json:"dimensions"` + Stages []StageInput `json:"stages"` + Linkages []LinkageInput `json:"linkages"` + Adapters []AdapterInput `json:"adapters"` +} + +// DimensionInput is one dimension with its values. +type DimensionInput struct { + DimKey string `json:"dim_key"` + Name string `json:"name"` + SortOrder int `json:"sort_order"` + Values []ValueInput `json:"values"` +} + +// ValueInput is one dimension value. +type ValueInput struct { + ValueKey string `json:"value_key"` + Name string `json:"name"` + InitialWeight int `json:"initial_weight"` + SortOrder int `json:"sort_order"` +} + +// StageInput is one package stage with its scripts. +type StageInput struct { + StageKey string `json:"stage_key"` + Name string `json:"name"` + Purpose string `json:"purpose"` + PrimaryDimensionKey string `json:"primary_dimension_key"` + SortOrder int `json:"sort_order"` + Scripts []ScriptInput `json:"scripts"` +} + +// ScriptInput is one stage script. +type ScriptInput struct { + ScriptKey string `json:"script_key"` + Name string `json:"name"` + ScriptType string `json:"script_type"` + Content string `json:"content"` + DimensionValueKey string `json:"dimension_value_key"` + ShowThreshold int `json:"show_threshold"` + ConfirmThreshold int `json:"confirm_threshold"` + CollectFieldKey string `json:"collect_field_key"` + Required bool `json:"required"` + Multiple bool `json:"multiple"` + Products []ProductInput `json:"products"` + SortOrder int `json:"sort_order"` + Options []OptionInput `json:"options"` +} + +// ProductInput is one static product attached to a script. +type ProductInput struct { + SKUCode string `json:"sku_code"` + ProductName string `json:"product_name"` +} + +// OptionInput is one answer option of a script. +type OptionInput struct { + OptionKey string `json:"option_key"` + Label string `json:"label"` + TargetDimensionValueKey string `json:"target_dimension_value_key"` + Effect string `json:"effect"` + EffectValue int `json:"effect_value"` + SortOrder int `json:"sort_order"` +} + +// LinkageInput is one dimension linkage. +type LinkageInput struct { + FromDimensionValueKey string `json:"from_dimension_value_key"` + ToDimensionValueKey string `json:"to_dimension_value_key"` + RelationType string `json:"relation_type"` + Contribution int `json:"contribution"` + ActivationThreshold int `json:"activation_threshold"` + SortOrder int `json:"sort_order"` +} + +// AdapterInput is one entry weight adapter. +type AdapterInput struct { + SourceField string `json:"source_field"` + MatchValue string `json:"match_value"` + TargetDimensionValueKey string `json:"target_dimension_value_key"` + Weight int `json:"weight"` + SortOrder int `json:"sort_order"` +} + +func scenarioPackageID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.Error(c, http.StatusBadRequest, "INVALID_ID", "场景 ID 不正确") + return 0, false + } + return id, true +} + +// Get returns the script package of a scenario as a nested JSON document. +func (h *Handler) Get(c *gin.Context) { + p, _ := auth.PrincipalFromContext(c) + scenarioID, ok := scenarioPackageID(c) + if !ok || !access.CanViewScenario(h.db, p, scenarioID) { + if ok { + response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在") + } + return + } + pkg, err := LoadPackageByScenario(h.db, p.TenantID, scenarioID) + if err != nil { + response.Error(c, http.StatusNotFound, "NOT_FOUND", "话术包不存在") + return + } + response.OK(c, PackageDocument(pkg)) +} + +// Replace replaces the whole script package of a scenario in one transaction. +func (h *Handler) Replace(c *gin.Context) { + p, _ := auth.PrincipalFromContext(c) + scenarioID, ok := scenarioPackageID(c) + if !ok || !access.CanEditScenario(h.db, p, scenarioID) { + if ok { + response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑") + } + return + } + var input PackageInput + if err := c.ShouldBindJSON(&input); err != nil { + response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "话术包 JSON 格式不正确") + return + } + if err := ValidatePackageInput(input); err != nil { + response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error()) + return + } + if err := SavePackage(h.db, p.TenantID, p.UserID, scenarioID, input); err != nil { + response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存话术包失败") + return + } + h.Get(c) +} + +// SavePackage replaces the script package of a scenario in one transaction. +func SavePackage(db *gorm.DB, tenantID, userID, scenarioID uint64, input PackageInput) error { + if err := ValidatePackageInput(input); err != nil { + return err + } + var old model.ScriptPackage + hadOld := true + if err := db.Where("scenario_id = ? AND tenant_id = ?", scenarioID, tenantID).First(&old).Error; err != nil { + hadOld = false + } + principal := auth.Principal{TenantID: tenantID, UserID: userID} + return db.Transaction(func(tx *gorm.DB) error { + if hadOld { + for _, target := range []interface{}{ + &model.ScriptOption{}, &model.StageScript{}, &model.DimensionLinkage{}, + &model.PackageAdapter{}, &model.PackageStage{}, &model.DimensionValue{}, &model.PackageDimension{}, + } { + if err := tx.Where("tenant_id = ? AND package_id = ?", tenantID, old.ID).Delete(target).Error; err != nil { + return err + } + } + } + pkg := model.ScriptPackage{TenantID: tenantID, ScenarioID: scenarioID, Name: input.Name, Status: "active", StartStageKey: input.StartStageKey, CreatedBy: userID} + if hadOld { + pkg.ID = old.ID + pkg.CreatedAt = old.CreatedAt + } + if err := tx.Save(&pkg).Error; err != nil { + return err + } + dimensionIDs := map[string]uint64{} + for _, dimension := range input.Dimensions { + row := model.PackageDimension{TenantID: tenantID, PackageID: pkg.ID, DimKey: dimension.DimKey, Name: dimension.Name, SortOrder: dimension.SortOrder} + if err := tx.Create(&row).Error; err != nil { + return err + } + dimensionIDs[dimension.DimKey] = row.ID + } + valueIDs := map[string]uint64{} + for _, dimension := range input.Dimensions { + for _, value := range dimension.Values { + row := model.DimensionValue{TenantID: tenantID, PackageID: pkg.ID, DimensionID: dimensionIDs[dimension.DimKey], ValueKey: value.ValueKey, Name: value.Name, InitialWeight: value.InitialWeight, SortOrder: value.SortOrder} + if err := tx.Create(&row).Error; err != nil { + return err + } + valueIDs[value.ValueKey] = row.ID + } + } + for _, stage := range input.Stages { + row := model.PackageStage{TenantID: tenantID, PackageID: pkg.ID, StageKey: stage.StageKey, Name: stage.Name, Purpose: stage.Purpose, PrimaryDimensionID: dimensionIDs[stage.PrimaryDimensionKey], SortOrder: stage.SortOrder} + if err := tx.Create(&row).Error; err != nil { + return err + } + for _, script := range stage.Scripts { + var dimensionValueID *uint64 + if script.DimensionValueKey != "" { + id := valueIDs[script.DimensionValueKey] + dimensionValueID = &id + } + products, _ := json.Marshal(script.Products) + if script.Products == nil { + products = []byte(`[]`) + } + scriptRow := model.StageScript{TenantID: tenantID, PackageID: pkg.ID, StageID: row.ID, ScriptKey: script.ScriptKey, Name: script.Name, ScriptType: script.ScriptType, Content: script.Content, DimensionValueID: dimensionValueID, ShowThreshold: script.ShowThreshold, ConfirmThreshold: script.ConfirmThreshold, CollectFieldKey: script.CollectFieldKey, Required: script.Required, Multiple: script.Multiple, Products: datatypes.JSON(products), SortOrder: script.SortOrder} + if err := tx.Create(&scriptRow).Error; err != nil { + return err + } + for _, option := range script.Options { + var targetID *uint64 + if option.TargetDimensionValueKey != "" { + id := valueIDs[option.TargetDimensionValueKey] + targetID = &id + } + optionRow := model.ScriptOption{TenantID: tenantID, PackageID: pkg.ID, ScriptID: scriptRow.ID, OptionKey: option.OptionKey, Label: option.Label, TargetDimensionValueID: targetID, Effect: option.Effect, EffectValue: option.EffectValue, SortOrder: option.SortOrder} + if err := tx.Create(&optionRow).Error; err != nil { + return err + } + } + } + } + for _, linkage := range input.Linkages { + row := model.DimensionLinkage{TenantID: tenantID, PackageID: pkg.ID, FromDimensionValueID: valueIDs[linkage.FromDimensionValueKey], ToDimensionValueID: valueIDs[linkage.ToDimensionValueKey], RelationType: linkage.RelationType, Contribution: linkage.Contribution, ActivationThreshold: linkage.ActivationThreshold, Condition: datatypes.JSON([]byte(`{}`)), SortOrder: linkage.SortOrder} + if err := tx.Create(&row).Error; err != nil { + return err + } + } + for _, adapter := range input.Adapters { + row := model.PackageAdapter{TenantID: tenantID, PackageID: pkg.ID, SourceField: adapter.SourceField, MatchValue: adapter.MatchValue, TargetDimensionValueID: valueIDs[adapter.TargetDimensionValueKey], Weight: adapter.Weight, SortOrder: adapter.SortOrder} + if err := tx.Create(&row).Error; err != nil { + return err + } + } + return audit.RecordTx(tx, principal, "replace", "script_package", pkg.ID, gin.H{"scenario_id": scenarioID}) + }) +} + +// PackageDocument renders a loaded package as a nested JSON document. +func PackageDocument(pkg *Package) gin.H { + valuesByDimension := map[uint64][]ValueInput{} + valueKeys := map[uint64]string{} + dimensionKeys := map[uint64]string{} + for _, value := range pkg.Values { + valueKeys[value.ID] = value.ValueKey + valuesByDimension[value.DimensionID] = append(valuesByDimension[value.DimensionID], ValueInput{ValueKey: value.ValueKey, Name: value.Name, InitialWeight: value.InitialWeight, SortOrder: value.SortOrder}) + } + for _, dimension := range pkg.Dimensions { + dimensionKeys[dimension.ID] = dimension.DimKey + } + dimensions := make([]DimensionInput, 0, len(pkg.Dimensions)) + for _, dimension := range pkg.Dimensions { + dimensions = append(dimensions, DimensionInput{DimKey: dimension.DimKey, Name: dimension.Name, SortOrder: dimension.SortOrder, Values: valuesByDimension[dimension.ID]}) + } + stages := make([]StageInput, 0, len(pkg.Stages)) + for _, stage := range pkg.Stages { + item := StageInput{StageKey: stage.StageKey, Name: stage.Name, Purpose: stage.Purpose, PrimaryDimensionKey: dimensionKeys[stage.PrimaryDimensionID], SortOrder: stage.SortOrder, Scripts: []ScriptInput{}} + for _, script := range pkg.Scripts { + if script.StageID != stage.ID { + continue + } + scriptItem := ScriptInput{ScriptKey: script.ScriptKey, Name: script.Name, ScriptType: script.ScriptType, Content: script.Content, DimensionValueKey: valueKeys[derefID(script.DimensionValueID)], ShowThreshold: script.ShowThreshold, ConfirmThreshold: script.ConfirmThreshold, CollectFieldKey: script.CollectFieldKey, Required: script.Required, Multiple: script.Multiple, Products: []ProductInput{}, SortOrder: script.SortOrder, Options: []OptionInput{}} + var products []ProductInput + if err := json.Unmarshal(script.Products, &products); err == nil && products != nil { + scriptItem.Products = products + } + for _, option := range pkg.Options { + if option.ScriptID != script.ID { + continue + } + scriptItem.Options = append(scriptItem.Options, OptionInput{OptionKey: option.OptionKey, Label: option.Label, TargetDimensionValueKey: valueKeys[derefID(option.TargetDimensionValueID)], Effect: option.Effect, EffectValue: option.EffectValue, SortOrder: option.SortOrder}) + } + item.Scripts = append(item.Scripts, scriptItem) + } + stages = append(stages, item) + } + linkages := make([]LinkageInput, 0, len(pkg.Linkages)) + for _, linkage := range pkg.Linkages { + linkages = append(linkages, LinkageInput{FromDimensionValueKey: valueKeys[linkage.FromDimensionValueID], ToDimensionValueKey: valueKeys[linkage.ToDimensionValueID], RelationType: linkage.RelationType, Contribution: linkage.Contribution, ActivationThreshold: linkage.ActivationThreshold, SortOrder: linkage.SortOrder}) + } + adapters := make([]AdapterInput, 0, len(pkg.Adapters)) + for _, adapter := range pkg.Adapters { + adapters = append(adapters, AdapterInput{SourceField: adapter.SourceField, MatchValue: adapter.MatchValue, TargetDimensionValueKey: valueKeys[adapter.TargetDimensionValueID], Weight: adapter.Weight, SortOrder: adapter.SortOrder}) + } + return gin.H{ + "id": pkg.Package.ID, "name": pkg.Package.Name, "status": pkg.Package.Status, + "start_stage_key": pkg.Package.StartStageKey, + "dimensions": dimensions, "stages": stages, "linkages": linkages, "adapters": adapters, + } +} + +func derefID(value *uint64) uint64 { + if value == nil { + return 0 + } + return *value +} diff --git a/internal/scriptkit/validator.go b/internal/scriptkit/validator.go new file mode 100644 index 0000000..73e82e6 --- /dev/null +++ b/internal/scriptkit/validator.go @@ -0,0 +1,136 @@ +package scriptkit + +import ( + "fmt" + "regexp" +) + +var keyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`) + +var allowedScriptTypes = map[string]bool{ + "confirm": true, "info": true, "choice": true, "screen": true, "template": true, "fallback": true, "message": true, +} + +var allowedEffects = map[string]bool{ + "set": true, "add": true, "subtract": true, "zero": true, +} + +var allowedRelationTypes = map[string]bool{ + "contributes_to": true, "requires": true, "excludes": true, +} + +// ValidatePackageInput checks a package document before saving. +func ValidatePackageInput(input PackageInput) error { + if input.Name == "" { + return fmt.Errorf("话术包缺少名称") + } + if !keyPattern.MatchString(input.StartStageKey) { + return fmt.Errorf("开始阶段标识格式不正确") + } + dimensionKeys := map[string]bool{} + valueKeys := map[string]bool{} + stageKeys := map[string]bool{} + scriptKeys := map[string]bool{} + valueDimension := map[string]string{} + for _, dimension := range input.Dimensions { + if !keyPattern.MatchString(dimension.DimKey) || dimensionKeys[dimension.DimKey] { + return fmt.Errorf("维度标识必须唯一且格式正确") + } + if dimension.Name == "" { + return fmt.Errorf("维度 %s 缺少名称", dimension.DimKey) + } + dimensionKeys[dimension.DimKey] = true + for _, value := range dimension.Values { + if !keyPattern.MatchString(value.ValueKey) || valueKeys[value.ValueKey] { + return fmt.Errorf("维度值标识必须唯一且格式正确") + } + if value.Name == "" { + return fmt.Errorf("维度值 %s 缺少名称", value.ValueKey) + } + valueKeys[value.ValueKey] = true + valueDimension[value.ValueKey] = dimension.DimKey + } + } + for _, stage := range input.Stages { + if !keyPattern.MatchString(stage.StageKey) || stageKeys[stage.StageKey] { + return fmt.Errorf("阶段标识必须唯一且格式正确") + } + if stage.Name == "" { + return fmt.Errorf("阶段 %s 缺少名称", stage.StageKey) + } + if stage.PrimaryDimensionKey != "" && !dimensionKeys[stage.PrimaryDimensionKey] { + return fmt.Errorf("阶段 %s 的主维度不存在", stage.StageKey) + } + stageKeys[stage.StageKey] = true + for _, script := range stage.Scripts { + if !keyPattern.MatchString(script.ScriptKey) || scriptKeys[script.ScriptKey] { + return fmt.Errorf("话术标识必须唯一且格式正确") + } + if script.Name == "" { + return fmt.Errorf("话术 %s 缺少名称", script.ScriptKey) + } + if !allowedScriptTypes[script.ScriptType] { + return fmt.Errorf("话术 %s 的类型 %s 不支持", script.ScriptKey, script.ScriptType) + } + if script.DimensionValueKey != "" && !valueKeys[script.DimensionValueKey] { + return fmt.Errorf("话术 %s 关联的维度值不存在", script.ScriptKey) + } + if script.ScriptType == "confirm" && script.DimensionValueKey == "" { + return fmt.Errorf("确认性话术 %s 必须关联维度值", script.ScriptKey) + } + if script.ShowThreshold < 0 || script.ConfirmThreshold < 0 { + return fmt.Errorf("话术 %s 的阈值不能为负数", script.ScriptKey) + } + if script.ConfirmThreshold > script.ShowThreshold && script.ShowThreshold > 0 { + return fmt.Errorf("话术 %s 的确认阈值不能大于显示阈值", script.ScriptKey) + } + scriptKeys[script.ScriptKey] = true + optionKeys := map[string]bool{} + for _, option := range script.Options { + if !keyPattern.MatchString(option.OptionKey) || optionKeys[option.OptionKey] { + return fmt.Errorf("话术 %s 的选项标识必须唯一且格式正确", script.ScriptKey) + } + if option.Label == "" { + return fmt.Errorf("话术 %s 的选项 %s 缺少文案", script.ScriptKey, option.OptionKey) + } + if option.TargetDimensionValueKey != "" && !valueKeys[option.TargetDimensionValueKey] { + return fmt.Errorf("话术 %s 选项 %s 关联的维度值不存在", script.ScriptKey, option.OptionKey) + } + if !allowedEffects[option.Effect] { + return fmt.Errorf("话术 %s 选项 %s 的效果 %s 不支持", script.ScriptKey, option.OptionKey, option.Effect) + } + if option.EffectValue < 0 { + return fmt.Errorf("话术 %s 选项 %s 的效果值不能为负数", script.ScriptKey, option.OptionKey) + } + optionKeys[option.OptionKey] = true + } + } + } + if !stageKeys[input.StartStageKey] { + return fmt.Errorf("开始阶段 %s 不存在", input.StartStageKey) + } + for _, linkage := range input.Linkages { + if !valueKeys[linkage.FromDimensionValueKey] || !valueKeys[linkage.ToDimensionValueKey] { + return fmt.Errorf("维度联动引用了不存在的维度值") + } + if !allowedRelationTypes[linkage.RelationType] { + return fmt.Errorf("联动关系 %s 不支持", linkage.RelationType) + } + if linkage.Contribution <= 0 { + return fmt.Errorf("联动贡献值必须为正数") + } + } + for _, adapter := range input.Adapters { + if adapter.SourceField == "" || adapter.MatchValue == "" { + return fmt.Errorf("入场适配规则缺少来源字段或匹配值") + } + if !valueKeys[adapter.TargetDimensionValueKey] { + return fmt.Errorf("入场适配规则 %s=%s 关联的维度值不存在", adapter.SourceField, adapter.MatchValue) + } + if adapter.Weight <= 0 { + return fmt.Errorf("入场适配规则 %s=%s 的权重必须为正数", adapter.SourceField, adapter.MatchValue) + } + } + _ = valueDimension + return nil +} diff --git a/internal/sop/handler.go b/internal/sop/handler.go index 0dd531a..cd43a12 100644 --- a/internal/sop/handler.go +++ b/internal/sop/handler.go @@ -11,6 +11,7 @@ import ( "git.iwork-ai.com/xdc/iqudo-top1/internal/auth" "git.iwork-ai.com/xdc/iqudo-top1/internal/model" "git.iwork-ai.com/xdc/iqudo-top1/internal/response" + "git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit" "github.com/gin-gonic/gin" "gorm.io/datatypes" "gorm.io/gorm" @@ -259,15 +260,13 @@ func (h *Handler) validateForPublish(item model.SOP, startNodeKey string, nodes if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&fields).Error; err != nil { return nil, err } - var knowledgeItems []model.KnowledgeItem - if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&knowledgeItems).Error; err != nil { - return nil, err + stageKeys := make([]string, 0) + if pkg, err := scriptkit.LoadPackageByScenario(h.db, tenantID, item.ScenarioID); err == nil { + for _, stage := range pkg.Stages { + stageKeys = append(stageKeys, stage.StageKey) + } } - var knowledgeRelations []model.KnowledgeRelation - if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&knowledgeRelations).Error; err != nil { - return nil, err - } - return ValidateForPublish(startNodeKey, nodes, edges, ValidationContext{Fields: fields, KnowledgeItems: knowledgeItems, KnowledgeRelations: knowledgeRelations}), nil + return ValidateForPublish(startNodeKey, nodes, edges, ValidationContext{Fields: fields, StageKeys: stageKeys}), nil } func toModels(tenantID, sopID uint64, input graphInput) ([]model.SOPNode, []model.SOPEdge) { diff --git a/internal/sop/validator.go b/internal/sop/validator.go index fc809e1..16805f0 100644 --- a/internal/sop/validator.go +++ b/internal/sop/validator.go @@ -8,13 +8,12 @@ import ( "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 allowedNodeTypes = map[string]bool{"start": true, "message": true, "question": true, "form": true, "choice": true, "condition": true, "stage": 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 - KnowledgeItems []model.KnowledgeItem - KnowledgeRelations []model.KnowledgeRelation + Fields []model.ScenarioField + StageKeys []string } func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge) []string { @@ -135,22 +134,9 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode for _, field := range context.Fields { fieldMap[field.FieldKey] = field } - knowledgeByKey := make(map[string]model.KnowledgeItem, len(context.KnowledgeItems)) - knowledgeTypes := make(map[string]bool) - knowledgeIDs := make(map[uint64]bool, len(context.KnowledgeItems)) - for _, item := range context.KnowledgeItems { - if item.Status != "active" { - continue - } - knowledgeByKey[item.ItemKey] = item - knowledgeTypes[item.Type] = true - knowledgeIDs[item.ID] = true - } - relationTypes := make(map[string]bool) - for _, relation := range context.KnowledgeRelations { - if knowledgeIDs[relation.FromKnowledgeID] && knowledgeIDs[relation.ToKnowledgeID] { - relationTypes[relation.RelationType] = true - } + stageKeys := make(map[string]bool, len(context.StageKeys)) + for _, key := range context.StageKeys { + stageKeys[key] = true } collected := map[string]bool{} nodeMap := make(map[string]model.SOPNode, len(nodes)) @@ -191,33 +177,25 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode } collected[fieldKey] = true } - case "knowledge": - selector, ok := config["knowledge_selector"].(map[string]interface{}) - if !ok { - problems = append(problems, fmt.Sprintf("节点“%s”没有配置知识选择器", node.Title)) - break + case "stage": + stageKey, _ := config["stage_key"].(string) + if stageKey == "" { + problems = append(problems, fmt.Sprintf("节点“%s”没有绑定话术包阶段", node.Title)) + } else if len(stageKeys) > 0 && !stageKeys[stageKey] { + problems = append(problems, fmt.Sprintf("节点“%s”绑定的话术包阶段 %s 不存在", node.Title, stageKey)) } - validateKnowledgeSelector(node, selector, knowledgeByKey, knowledgeTypes, relationTypes, &problems) - if collection, ok := config["knowledge_collection"].(map[string]interface{}); ok { - if keys, ok := collection["context_field_keys"].([]interface{}); ok { - for _, value := range keys { - if key, ok := value.(string); ok { - if _, exists := fieldMap[key]; !exists { - problems = append(problems, fmt.Sprintf("节点“%s”引用的采集字段 %s 不存在", node.Title, key)) - } else { - collected[key] = true - } - } + if keys, ok := config["field_keys"].([]interface{}); ok { + for _, value := range keys { + fieldKey, keyOK := value.(string) + if !keyOK || fieldKey == "" { + problems = append(problems, fmt.Sprintf("节点“%s”包含无效的表单字段", node.Title)) + continue } - } - if steps, ok := collection["steps"].([]interface{}); ok { - for _, raw := range steps { - if step, ok := raw.(map[string]interface{}); ok { - if key, ok := step["field_key"].(string); ok && key != "" { - collected[key] = true - } - } + if _, exists := fieldMap[fieldKey]; !exists { + problems = append(problems, fmt.Sprintf("节点“%s”引用的字段 %s 不存在", node.Title, fieldKey)) + continue } + collected[fieldKey] = true } } } @@ -354,63 +332,6 @@ func isDefaultCondition(value []byte) bool { return ok && len(object) == 0 } -func validateKnowledgeSelector(node model.SOPNode, selector map[string]interface{}, items map[string]model.KnowledgeItem, availableTypes, availableRelations map[string]bool, problems *[]string) { - derivedField, _ := selector["derived_field"].(string) - answerField, _ := selector["answer_field"].(string) - keys, keysValid := selectorStrings(selector, "knowledge_keys") - types, typesValid := selectorStrings(selector, "knowledge_types") - relations, relationsValid := selectorStrings(selector, "relation_types") - if !keysValid || !typesValid || !relationsValid { - *problems = append(*problems, fmt.Sprintf("节点“%s”的知识选择器必须使用字符串数组", node.Title)) - return - } - if derivedField == "" && answerField == "" && len(keys) == 0 { - *problems = append(*problems, fmt.Sprintf("节点“%s”没有配置派生知识字段或固定知识 key", node.Title)) - } - allowedTypes := make(map[string]bool, len(types)) - for _, itemType := range types { - allowedTypes[itemType] = true - if !availableTypes[itemType] { - *problems = append(*problems, fmt.Sprintf("节点“%s”引用的知识类型 %s 不存在", node.Title, itemType)) - } - } - for _, key := range keys { - item, exists := items[key] - if !exists { - *problems = append(*problems, fmt.Sprintf("节点“%s”引用的知识 key %s 不存在或未启用", node.Title, key)) - continue - } - if len(allowedTypes) > 0 && !allowedTypes[item.Type] { - *problems = append(*problems, fmt.Sprintf("节点“%s”的知识 key %s 不属于允许的根类型", node.Title, key)) - } - } - for _, relationType := range relations { - if !availableRelations[relationType] { - *problems = append(*problems, fmt.Sprintf("节点“%s”引用的知识关系类型 %s 不存在", node.Title, relationType)) - } - } -} - -func selectorStrings(selector map[string]interface{}, key string) ([]string, bool) { - raw, exists := selector[key] - if !exists || raw == nil { - return nil, true - } - values, ok := raw.([]interface{}) - if !ok { - return nil, false - } - result := make([]string, 0, len(values)) - for _, value := range values { - text, ok := value.(string) - if !ok || text == "" { - return nil, false - } - result = append(result, text) - } - return result, true -} - func canReachType(start, nodeType string, nodes map[string]model.SOPNode, adjacency map[string][]string) bool { visited := map[string]bool{} var walk func(string) bool diff --git a/internal/sop/validator_test.go b/internal/sop/validator_test.go index ed26ee8..c4cec0b 100644 --- a/internal/sop/validator_test.go +++ b/internal/sop/validator_test.go @@ -38,15 +38,13 @@ func TestValidateForPublishBusinessRules(t *testing.T) { {name: "invalid operator", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) { (*edges)[2].Condition = jsonData(`{"field":"emergency","operator":"matches","value":true}`) }, want: "不支持的运算符 matches"}, - {name: "missing knowledge key", mutate: func(_ *[]model.SOPNode, _ *[]model.SOPEdge, context *ValidationContext) { - context.KnowledgeItems = nil - }, want: "知识 key safety 不存在或未启用"}, - {name: "missing knowledge type", mutate: func(nodes *[]model.SOPNode, _ *[]model.SOPEdge, _ *ValidationContext) { - (*nodes)[2].Config = jsonData(`{"knowledge_selector":{"knowledge_keys":["safety"],"knowledge_types":["missing"],"relation_types":["related_copy"]}}`) - }, want: "知识类型 missing 不存在"}, - {name: "missing relation type", mutate: func(nodes *[]model.SOPNode, _ *[]model.SOPEdge, _ *ValidationContext) { - (*nodes)[2].Config = jsonData(`{"knowledge_selector":{"knowledge_keys":["safety"],"knowledge_types":["guidance"],"relation_types":["missing"]}}`) - }, want: "知识关系类型 missing 不存在"}, + {name: "missing stage key", mutate: func(nodes *[]model.SOPNode, _ *[]model.SOPEdge, _ *ValidationContext) { + (*nodes)[2].Config = jsonData(`{"stage_key":""}`) + }, want: "没有绑定话术包阶段"}, + {name: "unknown stage key", mutate: func(nodes *[]model.SOPNode, _ *[]model.SOPEdge, context *ValidationContext) { + (*nodes)[2].Config = jsonData(`{"stage_key":"missing"}`) + context.StageKeys = []string{"safety"} + }, want: "话术包阶段 missing 不存在"}, {name: "duplicate default path", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) { (*edges)[1].Condition = jsonData(`{}`) }, want: "配置了多条默认路径"}, @@ -123,23 +121,19 @@ 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_selector":{"knowledge_keys":["safety"],"knowledge_types":["guidance"],"relation_types":["related_copy"]}}`)}, + {NodeKey: "safety_stage", Type: "stage", Title: "用药原则", Config: jsonData(`{"stage_key":"safety"}`)}, {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(`{}`)}, + {SourceNodeKey: "screen", TargetNodeKey: "safety_stage", Condition: jsonData(`{}`)}, + {SourceNodeKey: "safety_stage", TargetNodeKey: "finish", Condition: jsonData(`{}`)}, } context := ValidationContext{ - Fields: []model.ScenarioField{{FieldKey: "emergency", FieldName: "是否急症", Required: true}}, - KnowledgeItems: []model.KnowledgeItem{ - {Base: model.Base{ID: 1}, ItemKey: "safety", Type: "guidance", Status: "active"}, - {Base: model.Base{ID: 2}, ItemKey: "safety_copy", Type: "copy", Status: "active"}, - }, - KnowledgeRelations: []model.KnowledgeRelation{{FromKnowledgeID: 1, ToKnowledgeID: 2, RelationType: "related_copy"}}, + Fields: []model.ScenarioField{{FieldKey: "emergency", FieldName: "是否急症", Required: true}}, + StageKeys: []string{"safety"}, } return nodes, edges, context } diff --git a/scripts/backfill-multitable/main.go b/scripts/backfill-multitable/main.go index b4ac65b..eb7e17a 100644 --- a/scripts/backfill-multitable/main.go +++ b/scripts/backfill-multitable/main.go @@ -13,8 +13,9 @@ import ( func main() { configDir := flag.String("config-dir", "configs", "configuration directory") + environment := flag.String("env", "", "runtime environment: development, test, prod") flag.Parse() - cfg, err := config.Load(config.LoadOptions{ConfigDir: *configDir}) + cfg, err := config.Load(config.LoadOptions{ConfigDir: *configDir, Environment: *environment}) if err != nil { fmt.Fprintf(os.Stderr, "load configuration: %v\n", err) os.Exit(1) diff --git a/scripts/seed-pet-doctor/knowledge_import.go b/scripts/seed-pet-doctor/knowledge_import.go deleted file mode 100644 index 25c8bc3..0000000 --- a/scripts/seed-pet-doctor/knowledge_import.go +++ /dev/null @@ -1,320 +0,0 @@ -package main - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "sort" - "strings" - - "git.iwork-ai.com/xdc/iqudo-top1/internal/model" - "github.com/xuri/excelize/v2" - "gorm.io/datatypes" - "gorm.io/gorm" -) - -const petKnowledgeSource = "症状-疾病-方案.xlsx" -const petScriptSource = "症状-疾病-话术.json" - -func resetPetKnowledge(tx *gorm.DB, tenantID, scenarioID uint64) error { - var ids []uint64 - if err := tx.Model(&model.KnowledgeItem{}).Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Pluck("id", &ids).Error; err != nil { - return fmt.Errorf("list old pet knowledge: %w", err) - } - if len(ids) > 0 { - if err := tx.Where("tenant_id = ? AND scenario_id = ? AND (from_knowledge_id IN ? OR to_knowledge_id IN ?)", tenantID, scenarioID, ids, ids).Delete(&model.KnowledgeRelation{}).Error; err != nil { - return fmt.Errorf("delete old pet knowledge relations: %w", err) - } - if err := tx.Where("tenant_id = ? AND scenario_id = ? AND id IN ?", tenantID, scenarioID, ids).Delete(&model.KnowledgeItem{}).Error; err != nil { - return fmt.Errorf("delete old pet knowledge items: %w", err) - } - } - return nil -} - -type petScriptDisease struct { - Plans []struct { - Plan string `json:"plan"` - Products []struct { - SKUCode string `json:"sku_code"` - ProductName string `json:"product_name"` - } `json:"products"` - } `json:"plans"` - Combinations []struct { - Name string `json:"name"` - Weight int `json:"weight"` - Products []struct { - SKUCode string `json:"sku_code"` - ProductName string `json:"product_name"` - } `json:"products"` - Script string `json:"script"` - } `json:"combinations"` -} - -func importPetScripts(tx *gorm.DB, tenantID, scenarioID uint64, filename string) error { - raw, err := os.ReadFile(filename) - if err != nil { - return fmt.Errorf("read pet scripts %s: %w", filename, err) - } - var source map[string]map[string]petScriptDisease - if err := json.Unmarshal(raw, &source); err != nil { - return fmt.Errorf("parse pet scripts %s: %w", filename, err) - } - for symptom, diseases := range source { - for disease, definition := range diseases { - diseaseKey := petKnowledgeKey("disease", disease) - if err := upsertPetScriptItems(tx, tenantID, scenarioID, symptom, disease, diseaseKey, definition); err != nil { - return err - } - } - } - return nil -} - -func upsertPetScriptItems(tx *gorm.DB, tenantID, scenarioID uint64, symptom, disease, diseaseKey string, definition petScriptDisease) error { - symptomKey := petKnowledgeKey("symptom", symptom) - if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, symptomKey, symptom, "symptom", []byte(`{"source":"`+petScriptSource+`"}`)); err != nil { - return err - } - if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, diseaseKey, disease, "disease", []byte(`{"source":"`+petScriptSource+`"}`)); err != nil { - return err - } - if err := upsertPetRelation(tx, scenarioID, symptomKey, "possible_disease", diseaseKey, 3000); err != nil { - return err - } - for index, combination := range definition.Combinations { - copyName := fmt.Sprintf("%s-%s话术", disease, combination.Name) - copyKey := petKnowledgeKey("copy", "json_"+symptom+"_"+disease+"_"+combination.Name) - content, _ := json.Marshal(map[string]interface{}{"source": petScriptSource, "template": strings.ReplaceAll(combination.Script, "{{purchased_products}}", "{{input.product_names}}"), "combination": combination.Name, "weight": combination.Weight}) - if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, copyKey, copyName, "copy", content); err != nil { - return err - } - if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_copy", copyKey, 3000+index); err != nil { - return err - } - for productIndex, product := range combination.Products { - if strings.TrimSpace(product.SKUCode) == "" { - continue - } - productKey := petKnowledgeKey("product", product.SKUCode) - content, _ := json.Marshal(map[string]interface{}{"source": petScriptSource, "sku_code": product.SKUCode, "product_name": product.ProductName}) - if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, productKey, product.ProductName, "product", content); err != nil { - return err - } - if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_product", productKey, 3400+index*100+productIndex); err != nil { - return err - } - } - } - for index, plan := range definition.Plans { - if strings.TrimSpace(plan.Plan) == "" { - continue - } - planKey := petKnowledgeKey("plan", plan.Plan) - if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, planKey, plan.Plan, "plan", []byte(`{"source":"`+petScriptSource+`"}`)); err != nil { - return err - } - if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_plan", planKey, 3200+index); err != nil { - return err - } - for productIndex, product := range plan.Products { - if strings.TrimSpace(product.SKUCode) == "" { - continue - } - productKey := petKnowledgeKey("product", product.SKUCode) - content, _ := json.Marshal(map[string]interface{}{"source": petScriptSource, "sku_code": product.SKUCode, "product_name": product.ProductName}) - if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, productKey, product.ProductName, "product", content); err != nil { - return err - } - if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_product", productKey, 3600+index*100+productIndex); err != nil { - return err - } - } - } - return nil -} - -func upsertPetKnowledgeItem(tx *gorm.DB, tenantID, scenarioID uint64, key, name, typeName string, content []byte) error { - row := model.KnowledgeItem{TenantID: tenantID, ScenarioID: scenarioID, ItemKey: key, Name: name, Type: typeName, Content: datatypes.JSON(content), Status: "active", SortOrder: 3000} - return tx.Where("tenant_id = ? AND scenario_id = ? AND item_key = ?", tenantID, scenarioID, key).Assign(row).FirstOrCreate(&row).Error -} - -func upsertPetRelation(tx *gorm.DB, scenarioID uint64, fromKey, relation, toKey string, order int) error { - var from, to model.KnowledgeItem - if err := tx.Where("scenario_id = ? AND item_key = ?", scenarioID, fromKey).First(&from).Error; err != nil { - return fmt.Errorf("find script relation source %s: %w", fromKey, err) - } - if err := tx.Where("scenario_id = ? AND item_key = ?", scenarioID, toKey).First(&to).Error; err != nil { - return fmt.Errorf("find script relation target %s: %w", toKey, err) - } - row := model.KnowledgeRelation{TenantID: from.TenantID, ScenarioID: scenarioID, FromKnowledgeID: from.ID, RelationType: relation, ToKnowledgeID: to.ID, Condition: datatypes.JSON([]byte(`{}`)), SortOrder: order} - return tx.Where("scenario_id = ? AND from_knowledge_id = ? AND relation_type = ? AND to_knowledge_id = ?", scenarioID, from.ID, relation, to.ID).Assign(row).FirstOrCreate(&row).Error -} - -type petKnowledgeRow struct { - Symptom string - Disease string - Plan string - SKUCode string - ProductName string -} - -func importPetKnowledge(tx *gorm.DB, tenantID, scenarioID uint64, filename string) error { - rows, err := readPetKnowledge(filename) - if err != nil { - return err - } - return upsertPetKnowledge(tx, tenantID, scenarioID, rows) -} - -func readPetKnowledge(filename string) ([]petKnowledgeRow, error) { - book, err := excelize.OpenFile(filename) - if err != nil { - return nil, fmt.Errorf("open pet knowledge %s: %w", filename, err) - } - defer book.Close() - sheets := book.GetSheetList() - if len(sheets) == 0 { - return nil, fmt.Errorf("pet knowledge %s has no worksheet", filename) - } - iterator, err := book.Rows(sheets[0]) - if err != nil { - return nil, fmt.Errorf("read pet knowledge worksheet: %w", err) - } - defer iterator.Close() - - columns := map[string]int{} - result := make([]petKnowledgeRow, 0) - rowNumber := 0 - for iterator.Next() { - rowNumber++ - values, err := iterator.Columns() - if err != nil { - return nil, fmt.Errorf("read pet knowledge row %d: %w", rowNumber, err) - } - if rowNumber == 1 { - for index, value := range values { - columns[strings.TrimSpace(value)] = index - } - for _, required := range []string{"症状", "疾病", "方案", "SKU_CODE", "商品标题"} { - if _, ok := columns[required]; !ok { - return nil, fmt.Errorf("pet knowledge is missing column %q", required) - } - } - continue - } - row := petKnowledgeRow{ - Symptom: excelValue(values, columns["症状"]), - Disease: excelValue(values, columns["疾病"]), - Plan: excelValue(values, columns["方案"]), - SKUCode: excelValue(values, columns["SKU_CODE"]), - ProductName: excelValue(values, columns["商品标题"]), - } - if row.Symptom == "" && row.Disease == "" && row.Plan == "" { - continue - } - if row.Symptom == "" || row.Disease == "" { - return nil, fmt.Errorf("pet knowledge row %d must contain 场景 and 分型", rowNumber) - } - result = append(result, row) - } - if err := iterator.Error(); err != nil { - return nil, fmt.Errorf("iterate pet knowledge: %w", err) - } - if len(result) == 0 { - return nil, fmt.Errorf("pet knowledge contains no data") - } - return result, nil -} - -func excelValue(values []string, index int) string { - if index >= len(values) { - return "" - } - return strings.TrimSpace(values[index]) -} - -func upsertPetKnowledge(tx *gorm.DB, tenantID, scenarioID uint64, rows []petKnowledgeRow) error { - type itemDefinition struct { - name, typeName string - content map[string]interface{} - } - items := map[string]itemDefinition{} - type relationDefinition struct{ from, relation, to string } - relations := map[relationDefinition]bool{} - for _, row := range rows { - symptomKey := petKnowledgeKey("symptom", row.Symptom) - diseaseKey := petKnowledgeKey("disease", row.Disease) - items[symptomKey] = itemDefinition{name: row.Symptom, typeName: "symptom"} - items[diseaseKey] = itemDefinition{name: row.Disease, typeName: "disease"} - relations[relationDefinition{symptomKey, "possible_disease", diseaseKey}] = true - for index, template := range []string{ - "结合客户描述,目前更符合“%s”这一分型。建议先向客户说明判断依据,再介绍对应方案。", - "针对“%s”,这边建议按下面的方案进行护理和商品搭配;如症状持续或加重,应及时就医。", - } { - copyName := fmt.Sprintf("%s推荐话术%d", row.Disease, index+1) - copyKey := petKnowledgeKey("copy", copyName) - items[copyKey] = itemDefinition{name: copyName, typeName: "copy", content: map[string]interface{}{"template": fmt.Sprintf(template, row.Disease)}} - relations[relationDefinition{diseaseKey, "recommended_copy", copyKey}] = true - } - if row.Plan != "" { - planKey := petKnowledgeKey("plan", row.Plan) - items[planKey] = itemDefinition{name: row.Plan, typeName: "plan"} - relations[relationDefinition{diseaseKey, "recommended_plan", planKey}] = true - // The current SOP view expands one relation level from a symptom. - relations[relationDefinition{symptomKey, "recommended_plan", planKey}] = true - } - if row.SKUCode != "" { - productName := row.ProductName - if productName == "" { - productName = row.SKUCode - } - productKey := petKnowledgeKey("product", row.SKUCode) - items[productKey] = itemDefinition{name: productName, typeName: "product", content: map[string]interface{}{"sku_code": row.SKUCode, "product_name": productName}} - relations[relationDefinition{diseaseKey, "recommended_product", productKey}] = true - } - } - - keys := make([]string, 0, len(items)) - for key := range items { - keys = append(keys, key) - } - sort.Strings(keys) - ids := make(map[string]uint64, len(keys)) - for order, key := range keys { - definition := items[key] - payload := map[string]interface{}{"source": petKnowledgeSource} - for name, value := range definition.content { - payload[name] = value - } - content, _ := json.Marshal(payload) - row := model.KnowledgeItem{TenantID: tenantID, ScenarioID: scenarioID, ItemKey: key, Name: definition.name, Type: definition.typeName, Content: datatypes.JSON(content), Status: "active", SortOrder: 1000 + order} - if err := tx.Where("tenant_id = ? AND scenario_id = ? AND item_key = ?", tenantID, scenarioID, key).Assign(row).FirstOrCreate(&row).Error; err != nil { - return fmt.Errorf("upsert imported knowledge item %s: %w", key, err) - } - ids[key] = row.ID - } - - relationList := make([]relationDefinition, 0, len(relations)) - for relation := range relations { - relationList = append(relationList, relation) - } - sort.Slice(relationList, func(i, j int) bool { - left, right := relationList[i], relationList[j] - return left.from+left.relation+left.to < right.from+right.relation+right.to - }) - for order, definition := range relationList { - row := model.KnowledgeRelation{TenantID: tenantID, ScenarioID: scenarioID, FromKnowledgeID: ids[definition.from], RelationType: definition.relation, ToKnowledgeID: ids[definition.to], Condition: datatypes.JSON([]byte(`{}`)), SortOrder: 1000 + order} - if err := tx.Where("scenario_id = ? AND from_knowledge_id = ? AND relation_type = ? AND to_knowledge_id = ?", scenarioID, row.FromKnowledgeID, row.RelationType, row.ToKnowledgeID).Assign(row).FirstOrCreate(&row).Error; err != nil { - return fmt.Errorf("upsert imported knowledge relation: %w", err) - } - } - return nil -} - -func petKnowledgeKey(typeName, name string) string { - digest := sha256.Sum256([]byte(typeName + "\x00" + strings.TrimSpace(name))) - return "xlsx_" + typeName + "_" + hex.EncodeToString(digest[:6]) -} diff --git a/scripts/seed-pet-doctor/main.go b/scripts/seed-pet-doctor/main.go index 43e74b0..3ae7554 100644 --- a/scripts/seed-pet-doctor/main.go +++ b/scripts/seed-pet-doctor/main.go @@ -11,6 +11,8 @@ import ( "git.iwork-ai.com/xdc/iqudo-top1/internal/database" "git.iwork-ai.com/xdc/iqudo-top1/internal/logger" "git.iwork-ai.com/xdc/iqudo-top1/internal/model" + "git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit" + "github.com/google/uuid" "go.uber.org/zap" "gorm.io/datatypes" "gorm.io/gorm" @@ -45,6 +47,12 @@ func main() { configDir := flag.String("config-dir", "configs", "configuration directory") environment := flag.String("env", "", "runtime environment") scriptFile := flag.String("script-file", filepath.Join("..", "docs", "症状-疾病-话术.json"), "pet script knowledge JSON file") + knowledgeFile := flag.String("knowledge-file", filepath.Join("..", "docs", "症状-疾病-方案.xlsx"), "pet SKU knowledge Excel file") + questionFile := flag.String("question-file", filepath.Join("..", "docs", "症状确认话术.json"), "pet colloquial question bank JSON file") + smallCategoryFile := flag.String("small-category-file", filepath.Join("..", "docs", "症状小类-大类.json"), "pet symptom small category mapping JSON file") + smallAskScriptFile := flag.String("small-ask-script-file", filepath.Join("..", "docs", "症状小类询问话术.json"), "pet symptom small ask script JSON file") + preGuideFile := flag.String("pre-guide-file", filepath.Join("..", "docs", "症状确认前引导话术.json"), "pet pre-symptom guide script JSON file") + classificationFile := flag.String("classification-file", filepath.Join("..", "docs", "症状分类.json"), "pet symptom classification JSON file") flag.Parse() cfg, err := config.Load(config.LoadOptions{Environment: *environment, ConfigDir: *configDir}) @@ -63,13 +71,16 @@ func main() { if err != nil { log.Fatal("connect database", zap.Error(err)) } - if err := seed(db, *scriptFile); err != nil { + if err := database.AutoMigrate(db); err != nil { + log.Fatal("synchronize database schema", zap.Error(err)) + } + if err := seed(db, *scriptFile, *knowledgeFile, *questionFile, *smallCategoryFile, *smallAskScriptFile, *preGuideFile, *classificationFile); err != nil { log.Fatal("seed pet doctor scenario", zap.Error(err)) } log.Info("pet doctor scenario is ready") } -func seed(db *gorm.DB, scriptFile string) error { +func seed(db *gorm.DB, scriptFile, knowledgeFile, questionFile, smallCategoryFile, smallAskScriptFile, preGuideFile, classificationFile string) error { return db.Transaction(func(tx *gorm.DB) error { tenant, user, err := seedOwner(tx) if err != nil { @@ -88,11 +99,12 @@ func seed(db *gorm.DB, scriptFile string) error { if err := seedRulesAndOutput(tx, tenant.ID, scenario.ID); err != nil { return err } - if err := resetPetKnowledge(tx, tenant.ID, scenario.ID); err != nil { + packageInput, err := buildScriptPackage(scriptFile, knowledgeFile, smallCategoryFile, smallAskScriptFile, preGuideFile, classificationFile, questionFile) + if err != nil { return err } - if err := importPetScripts(tx, tenant.ID, scenario.ID, scriptFile); err != nil { - return err + if err := scriptkit.SavePackage(tx, tenant.ID, user.ID, scenario.ID, packageInput); err != nil { + return fmt.Errorf("save pet script package: %w", err) } if err := seedSOP(tx, tenant.ID, user.ID, scenario.ID); err != nil { return err @@ -115,18 +127,34 @@ func seedOwner(tx *gorm.DB) (model.Tenant, model.User, error) { func seedScenario(tx *gorm.DB, tenantID, userID uint64) (model.Scenario, error) { var scenario model.Scenario - err := tx.Where("tenant_id = ? AND name = ?", tenantID, "宠物医生问诊问药").FirstOrCreate(&scenario, model.Scenario{ - TenantID: tenantID, Name: "宠物医生问诊问药", CreatedBy: userID, - }).Error + // FirstOrCreate 会把结构体非零字段并入查询条件;公开标识放进 Attrs, + // 避免随机 public_key 让每次匹配失败、重复创建同名场景。 + err := tx.Where("tenant_id = ? AND name = ?", tenantID, "宠物医生问诊问药"). + Attrs(model.Scenario{ + ScenarioKey: "scenario-1", + PublicKey: "pk_" + uuid.NewString(), + AllowedOrigins: datatypes.JSON([]byte(`[]`)), + InputSchema: datatypes.JSON([]byte(`{"fields":[]}`)), + OutputSchema: datatypes.JSON([]byte(`{"fields":[]}`)), + ResultSchema: datatypes.JSON([]byte(`{"fields":[]}`)), + }). + FirstOrCreate(&scenario, model.Scenario{TenantID: tenantID, Name: "宠物医生问诊问药", CreatedBy: userID}).Error if err != nil { return scenario, fmt.Errorf("find or create scenario: %w", err) } updates := map[string]interface{}{ "industry": "宠物医疗", "role_name": "宠物医生、助理、客服", - "goal": "标准化收集宠物基本信息、主诉和病史,优先识别急症,在医生评估前提供安全且一致的沟通指引。", - "trigger_text": "宠物主人通过门店、电话或在线渠道咨询症状、检查、用药或是否需要就医时进入本场景。", + "goal": "通过话术包和维度权重驱动问诊:确认宠物基本信息与主要症状,联动拟诊疾病方向,推荐方案、商品与注意事项,并全程记录话术反馈用于复盘。", + "trigger_text": "宠物主人通过门店、电话或在线渠道咨询症状、用药或护理商品时进入本场景。", "visibility": "tenant", "status": "active", } + // 保持公开标识稳定:已有接入方依赖 scenario-1;缺失时才生成,不覆盖已有值。 + if scenario.ScenarioKey == "" { + updates["scenario_key"] = "scenario-1" + } + if scenario.PublicKey == "" { + updates["public_key"] = "pk_" + uuid.NewString() + } if err := tx.Model(&scenario).Updates(updates).Error; err != nil { return scenario, fmt.Errorf("update scenario: %w", err) } @@ -186,85 +214,18 @@ func petFieldDefinitions() []fieldDefinition { {Key: "pet_age", Name: "宠物年龄", Type: "text", Validation: map[string]interface{}{"max_length": 30}}, {Key: "pet_weight", Name: "宠物体重(kg)", Type: "number", Validation: map[string]interface{}{"min": 0.1, "max": 200}}, {Key: "pet_sex", Name: "宠物性别", Type: "select", Options: []string{"公", "母", "未知"}}, - {Key: "confirmed_symptoms", Name: "客户确认症状", Type: "array", Required: true}, - {Key: "confirmed_diseases", Name: "确认疾病分型", Type: "array", Required: true}, } } -func seedKnowledgeGraph(tx *gorm.DB, tenantID, scenarioID uint64) error { - definitions := map[string]struct { - name string - typeName string - content map[string]interface{} - }{ - "consultation_boundary": {name: "线上问诊边界", typeName: "guidance", content: map[string]interface{}{ - "standard_copy": "线上沟通用于收集信息和判断紧急程度,不能替代体格检查、检验和影像检查。医生需要结合完整病史及检查结果后才能给出诊断和治疗方案。", - "forbidden_copy": "不要仅凭文字、照片或单个症状作出确定诊断,也不要承诺某种处理一定有效。", - "risk_note": "信息不完整、症状持续加重或无法准确观察时,应建议尽快到院评估。", - }}, - "emergency_guidance": {name: "急症红旗征象", typeName: "guidance", content: map[string]interface{}{ - "standard_copy": "出现呼吸困难、持续出血、抽搐或意识异常、严重外伤、无法排尿、持续呕吐或干呕、剧烈疼痛、腹部明显膨大、疑似中暑、无法站立、误食毒物或异物等情况时,应立即建议就近急诊或转诊,并提前联系接诊机构。", - "forbidden_copy": "不要承诺在家观察一定安全;不要在线给出能够替代急诊检查的判断。", - "risk_note": "急症分支优先级最高,不得因继续询问常规病史而延误就医。", - }}, - "medication_safety": {name: "问诊用药安全原则", typeName: "guidance", content: map[string]interface{}{ - "standard_copy": "用药需要结合物种、年龄、体重、既往病史、正在使用的药物和必要检查,由宠物医生评估后确定。请勿自行增加剂量、混用药物或使用人用药。", - "forbidden_copy": "未完成医生评估前,不给出具体处方药名称、剂量和疗程承诺。", - "risk_note": "对乙酰氨基酚、布洛芬等常见人用药可能对宠物造成严重伤害;如已误服,应按急症处理。", - }}, - "safety_net": {name: "观察与复诊提示", typeName: "guidance", content: map[string]interface{}{ - "standard_copy": "请按医生要求记录精神、食欲、饮水、排尿、排便、呕吐次数及症状变化。若症状加重、出现新的急症表现,或在医生建议的观察时间内未改善,应尽快复诊。", - "forbidden_copy": "不要用固定天数替代医生根据病情给出的复诊时间,也不要因一次短暂好转自行停药。", - "risk_note": "离开本次沟通前,应让宠物主人复述下一步安排和需要立即就医的触发条件。", - }}, - "soft_stool": {name: "软便", typeName: "symptom", content: map[string]interface{}{"label": "soft_stool"}}, - "poor_appetite": {name: "食欲下降", typeName: "symptom", content: map[string]interface{}{"label": "poor_appetite"}}, - "ask_stool": {name: "软便追问话术", typeName: "copy", content: map[string]interface{}{"template": "{{input.customer_name}}您好,想了解一下{{input.pet_name}}近期排便的频率和形态,是否伴随呕吐、精神变差或便中带血?"}}, - "recommend_gut": {name: "肠胃护理推荐话术", typeName: "copy", content: map[string]interface{}{"template": "结合{{input.pet_name}}目前的表现,可以先向您介绍肠胃护理方向的产品和日常喂养注意事项,具体用药仍需宠物医生评估。"}}, - "gi_discomfort": {name: "肠胃不适风险", typeName: "disease", content: map[string]interface{}{"risk_level": "normal", "label": "gi_discomfort"}}, - "parasite_risk": {name: "寄生虫风险", typeName: "disease", content: map[string]interface{}{"risk_level": "medium", "label": "parasite_risk"}}, - } - index := 0 - for key, definition := range definitions { - content, _ := json.Marshal(definition.content) - row := model.KnowledgeItem{TenantID: tenantID, ScenarioID: scenarioID, ItemKey: key, Name: definition.name, Type: definition.typeName, Content: datatypes.JSON(content), Status: "active", SortOrder: index} - if err := tx.Where("tenant_id = ? AND scenario_id = ? AND item_key = ?", tenantID, scenarioID, key).Assign(row).FirstOrCreate(&row).Error; err != nil { - return fmt.Errorf("seed knowledge item %s: %w", key, err) - } - index++ - } - relations := []struct{ from, relation, to string }{ - {"soft_stool", "recommended_copy", "ask_stool"}, {"soft_stool", "recommended_copy", "recommend_gut"}, - {"soft_stool", "possible_disease", "gi_discomfort"}, {"soft_stool", "possible_disease", "parasite_risk"}, - {"poor_appetite", "recommended_copy", "recommend_gut"}, {"poor_appetite", "possible_disease", "gi_discomfort"}, - } - ids := map[string]uint64{} - var items []model.KnowledgeItem - if err := tx.Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Find(&items).Error; err != nil { - return err - } - for _, item := range items { - ids[item.ItemKey] = item.ID - } - for order, relation := range relations { - fromID, fromOK := ids[relation.from] - toID, toOK := ids[relation.to] - if !fromOK || !toOK { - return fmt.Errorf("knowledge relation references missing item: %s -> %s", relation.from, relation.to) - } - row := model.KnowledgeRelation{TenantID: tenantID, ScenarioID: scenarioID, FromKnowledgeID: fromID, RelationType: relation.relation, ToKnowledgeID: toID, Condition: datatypes.JSON([]byte(`{}`)), SortOrder: order} - if err := tx.Where("scenario_id = ? AND from_knowledge_id = ? AND relation_type = ? AND to_knowledge_id = ?", scenarioID, fromID, relation.relation, toID).Assign(row).FirstOrCreate(&row).Error; err != nil { - return fmt.Errorf("seed knowledge relation: %w", err) - } - } - return nil -} - func seedRulesAndOutput(tx *gorm.DB, tenantID, scenarioID uint64) error { rule := model.ScenarioRule{TenantID: tenantID, ScenarioID: scenarioID, RuleKey: "derive-matched-symptoms", Name: "从商品标签提取症状", Condition: datatypes.JSON([]byte(`{"field":"input.input_symptom_tags","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"set","field":"matched_symptoms","value_from":"input.input_symptom_tags"}]`)), Priority: 100, Status: "active"} if err := tx.Where("scenario_id = ? AND rule_key = ?", scenarioID, rule.RuleKey).Assign(rule).FirstOrCreate(&rule).Error; err != nil { return err } + skuRule := model.ScenarioRule{TenantID: tenantID, ScenarioID: scenarioID, RuleKey: "derive-matched-skus", Name: "按 SKU_CODE 匹配话术包", Condition: datatypes.JSON([]byte(`{"field":"input.product_ids","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"set","field":"matched_skus","value_from":"input.product_ids"}]`)), Priority: 110, Status: "active"} + if err := tx.Where("scenario_id = ? AND rule_key = ?", scenarioID, skuRule.RuleKey).Assign(skuRule).FirstOrCreate(&skuRule).Error; err != nil { + return err + } out := map[string]interface{}{"fields": []map[string]interface{}{ {"key": "matched_symptoms", "name": "命中症状", "type": "array", "source": "derived", "source_field": "matched_symptoms"}, {"key": "product_names", "name": "订单商品", "type": "array", "source": "input", "source_field": "product_names"}, @@ -291,23 +252,12 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64) error { return fmt.Errorf("find or create SOP: %w", err) } if err := tx.Model(&sop).Updates(map[string]interface{}{ - "description": "根据订单商品携带的症状标签展示关联症状、话术和可能疾病,辅助销售完成商品推荐并记录结果。", + "description": "以话术包和维度权重驱动:开场补充宠物档案,症状确认逐题问答与勾选,拟诊确认联动疾病方向,最后按疾病权重推荐方案、商品与注意事项。", "status": "published", "start_node_key": "start", }).Error; err != nil { return err } - var alreadySeeded int64 - if err := tx.Model(&model.SOPNode{}).Where( - "sop_id = ? AND node_key = ? AND JSON_UNQUOTE(JSON_EXTRACT(config, '$.seed_key')) = ?", - sop.ID, "start", "pet-doctor-v16-start-presentation-optional-pet", - ).Count(&alreadySeeded).Error; err != nil { - return err - } - if alreadySeeded > 0 { - return nil - } - nodes := petNodes() edges := petEdges() if err := tx.Where("sop_id = ? AND tenant_id = ?", sop.ID, tenantID).Delete(&model.SOPEdge{}).Error; err != nil { @@ -336,7 +286,7 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64) error { return fmt.Errorf("create edge %s -> %s: %w", definition.Source, definition.Target, err) } } - payload, _ := json.Marshal(map[string]interface{}{"scenario": "宠物医生问诊问药"}) + payload, _ := json.Marshal(map[string]interface{}{"scenario": "宠物医生问诊问药", "engine": "scriptkit"}) return tx.Create(&model.AuditLog{ TenantID: tenantID, UserID: userID, Action: "seed", Resource: "sop", ResourceID: sop.ID, Payload: datatypes.JSON(payload), }).Error @@ -345,27 +295,27 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64) error { func petNodes() []nodeDefinition { return []nodeDefinition{ {Key: "start", Type: "start", Title: "开始", Content: "确认订单和客户信息,使用开场话术开始沟通。", Config: map[string]interface{}{ - "seed_key": "pet-doctor-v16-start-presentation-optional-pet", + "seed_key": "pet-doctor-scriptkit", "presentation": map[string]interface{}{ "summary_field_keys": []string{"order_id", "customer_name", "pet_name", "pet_type"}, "item_field_keys": []string{"product_images", "product_names", "product_ids"}, "image_field_keys": []string{"product_images"}, - "opening_title": "开场话术", - "opening_template": "您好,{{input.customer_name}}。我是宠物健康顾问,看到您购买了{{input.product_names}},想回访了解一下使用情况,也了解一下宠物目前的身体状况。", }, }}, - {Key: "pet_info", Type: "form", Title: "收集宠物信息", Content: "核对系统已带入的宠物信息,并根据客户回答补充名称、种类、年龄、体重和性别。", Config: map[string]interface{}{"field_keys": []string{"pet_name", "pet_type", "pet_age", "pet_weight", "pet_sex"}, "required_field_keys": []string{"pet_name", "pet_type"}}}, - {Key: "symptom", Type: "knowledge", Title: "确认宠物症状与疾病", Content: "商品标签是系统推断的候选症状;请结合客户回答确认,并可从场景知识中补充其他伴随症状。", Config: map[string]interface{}{"knowledge_selector": map[string]interface{}{"derived_field": "matched_symptoms", "candidate_scope": "all", "knowledge_types": []string{"symptom"}, "relation_types": []string{"recommended_copy", "possible_disease"}, "relation_labels": map[string]string{"recommended_copy": "沟通话术", "possible_disease": "可能疾病"}}, "knowledge_collection": map[string]interface{}{"selection_title": "确认实际症状和疾病", "selection_hint": "系统推断项优先展示,也可搜索场景知识补充伴随症状", "steps": []map[string]interface{}{{"field_key": "confirmed_symptoms", "name": "客户确认症状", "root": true, "candidate_scope": "all", "knowledge_types": []string{"symptom"}, "required": true, "multiple": true}, {"field_key": "confirmed_diseases", "name": "确认疾病分型", "from_field": "confirmed_symptoms", "relation_type": "possible_disease", "required": true, "multiple": true}}}}}, - {Key: "recommend_product", Type: "knowledge", Title: "推荐方案与商品", Content: "根据已确认的疾病分型,向客户讲解推荐话术、建议方案和可使用的商品。", Config: map[string]interface{}{"knowledge_selector": map[string]interface{}{"answer_field": "confirmed_diseases", "knowledge_types": []string{"disease"}, "relation_types": []string{"recommended_copy", "recommended_plan", "recommended_product"}, "relation_labels": map[string]string{"recommended_copy": "推荐话术", "recommended_plan": "建议方案", "recommended_product": "推荐商品"}}}}, - {Key: "finish", Type: "finish", Title: "结束", Content: "本次话术执行完成,请保存最终推荐结果。", Config: map[string]interface{}{}}, + {Key: "opening", Type: "stage", Title: "开场", Content: "展示开场话术,并核对补充宠物名称、种类等基础档案。", Config: map[string]interface{}{"stage_key": "opening", "field_keys": []string{"pet_name", "pet_type", "pet_age", "pet_weight", "pet_sex"}, "required_field_keys": []string{"pet_name", "pet_type"}}}, + {Key: "symptom", Type: "stage", Title: "症状确认", Content: "按维度权重逐个确认症状:高权重直接问细节,模糊区间用确认性问题升维降维,全部为零时用兜底话术引导并支持手动勾选。", Config: map[string]interface{}{"stage_key": "symptom"}}, + {Key: "diagnosis", Type: "stage", Title: "拟诊确认", Content: "根据症状权重联动疾病方向,用确认性话术做升维降维。", Config: map[string]interface{}{"stage_key": "diagnosis"}}, + {Key: "recommend", Type: "stage", Title: "药品推荐与注意事项", Content: "按疾病维度权重匹配推荐方案、商品与注意事项话术。", Config: map[string]interface{}{"stage_key": "recommend"}}, + {Key: "finish", Type: "finish", Title: "结束", Content: "本次沟通已完成,提交最终推荐结果。", Config: map[string]interface{}{}}, } } func petEdges() []edgeDefinition { return []edgeDefinition{ - {Source: "start", Target: "pet_info", Condition: map[string]interface{}{}}, - {Source: "pet_info", Target: "symptom", Condition: map[string]interface{}{}}, - {Source: "symptom", Target: "recommend_product", Condition: map[string]interface{}{}}, - {Source: "recommend_product", Target: "finish", Condition: map[string]interface{}{}}, + {Source: "start", Target: "opening", Condition: map[string]interface{}{}, Priority: 10}, + {Source: "opening", Target: "symptom", Condition: map[string]interface{}{}, Priority: 20}, + {Source: "symptom", Target: "diagnosis", Condition: map[string]interface{}{}, Priority: 30}, + {Source: "diagnosis", Target: "recommend", Condition: map[string]interface{}{}, Priority: 40}, + {Source: "recommend", Target: "finish", Condition: map[string]interface{}{}, Priority: 50}, } } diff --git a/scripts/seed-pet-doctor/main_test.go b/scripts/seed-pet-doctor/main_test.go index bf3ae9b..0dc230e 100644 --- a/scripts/seed-pet-doctor/main_test.go +++ b/scripts/seed-pet-doctor/main_test.go @@ -13,46 +13,26 @@ func TestPetDoctorSOPPassesPublishValidation(t *testing.T) { fields := petFieldModels() nodes := petNodeModels(petNodes()) edges := petEdgeModels(petEdges()) - if len(fields) != 13 || len(nodes) != 5 || len(edges) != 4 { + if len(fields) != 11 || len(nodes) != 6 || len(edges) != 5 { t.Fatalf("unexpected definition size: fields=%d nodes=%d edges=%d", len(fields), len(nodes), len(edges)) } - problems := sop.ValidateForPublish("start", nodes, edges, sop.ValidationContext{ - Fields: fields, - KnowledgeItems: []model.KnowledgeItem{ - {Base: model.Base{ID: 1}, ItemKey: "consultation_boundary", Type: "guidance", Status: "active"}, - {Base: model.Base{ID: 2}, ItemKey: "emergency_guidance", Type: "guidance", Status: "active"}, - {Base: model.Base{ID: 3}, ItemKey: "medication_safety", Type: "guidance", Status: "active"}, - {Base: model.Base{ID: 4}, ItemKey: "safety_net", Type: "guidance", Status: "active"}, - {Base: model.Base{ID: 5}, ItemKey: "soft_stool", Type: "symptom", Status: "active"}, - {Base: model.Base{ID: 6}, ItemKey: "poor_appetite", Type: "symptom", Status: "active"}, - {Base: model.Base{ID: 7}, ItemKey: "ask_stool", Type: "copy", Status: "active"}, - {Base: model.Base{ID: 8}, ItemKey: "gi_discomfort", Type: "disease", Status: "active"}, - {Base: model.Base{ID: 9}, ItemKey: "gut_plan", Type: "plan", Status: "active"}, - {Base: model.Base{ID: 10}, ItemKey: "gut_product", Type: "product", Status: "active"}, - }, - KnowledgeRelations: []model.KnowledgeRelation{ - {FromKnowledgeID: 5, ToKnowledgeID: 7, RelationType: "recommended_copy"}, - {FromKnowledgeID: 5, ToKnowledgeID: 8, RelationType: "possible_disease"}, - {FromKnowledgeID: 5, ToKnowledgeID: 9, RelationType: "recommended_plan"}, - {FromKnowledgeID: 8, ToKnowledgeID: 7, RelationType: "recommended_copy"}, - {FromKnowledgeID: 8, ToKnowledgeID: 9, RelationType: "recommended_plan"}, - {FromKnowledgeID: 8, ToKnowledgeID: 10, RelationType: "recommended_product"}, - }, + Fields: fields, + StageKeys: []string{"opening", "symptom", "diagnosis", "recommend"}, }) if len(problems) > 0 { t.Fatalf("pet doctor SOP should be publishable, got: %v", problems) } } -func TestPetDoctorSOPIsFiveStepLinearFlow(t *testing.T) { - wantNodes := []string{"start", "pet_info", "symptom", "recommend_product", "finish"} +func TestPetDoctorSOPIsSixStepLinearFlow(t *testing.T) { + wantNodes := []string{"start", "opening", "symptom", "diagnosis", "recommend", "finish"} for index, node := range petNodes() { if node.Key != wantNodes[index] { t.Fatalf("node %d = %s, want %s", index, node.Key, wantNodes[index]) } } - wantEdges := [][2]string{{"start", "pet_info"}, {"pet_info", "symptom"}, {"symptom", "recommend_product"}, {"recommend_product", "finish"}} + wantEdges := [][2]string{{"start", "opening"}, {"opening", "symptom"}, {"symptom", "diagnosis"}, {"diagnosis", "recommend"}, {"recommend", "finish"}} for index, edge := range petEdges() { if edge.Source != wantEdges[index][0] || edge.Target != wantEdges[index][1] { t.Fatalf("edge %d = %s -> %s", index, edge.Source, edge.Target) @@ -60,15 +40,6 @@ func TestPetDoctorSOPIsFiveStepLinearFlow(t *testing.T) { } } -func TestPetKnowledgeKeyIsStableAndTypeScoped(t *testing.T) { - if petKnowledgeKey("symptom", "腹泻") != petKnowledgeKey("symptom", " 腹泻 ") { - t.Fatal("key should ignore surrounding whitespace") - } - if petKnowledgeKey("symptom", "腹泻") == petKnowledgeKey("disease", "腹泻") { - t.Fatal("key should include knowledge type") - } -} - func petFieldModels() []model.ScenarioField { definitions := petFieldDefinitions() fields := make([]model.ScenarioField, 0, len(definitions)) diff --git a/scripts/seed-pet-doctor/package_builder.go b/scripts/seed-pet-doctor/package_builder.go new file mode 100644 index 0000000..4429842 --- /dev/null +++ b/scripts/seed-pet-doctor/package_builder.go @@ -0,0 +1,602 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit" + "github.com/xuri/excelize/v2" +) + +// Data sources for the script package. +const petScriptSource = "症状-疾病-话术.json" +const petQuestionSource = "症状确认话术.json" +const petSmallCategorySource = "症状小类-大类.json" +const petSmallAskScriptSource = "症状小类询问话术.json" +const petPreGuideSource = "症状确认前引导话术.json" +const petClassificationSource = "症状分类.json" +const petKnowledgeSource = "症状-疾病-方案.xlsx" + +type petScriptEntry struct { + Title string `json:"title"` + Template string `json:"template"` +} + +type petClassification struct { + Symptoms []struct { + Type string `json:"type"` + Subs []string `json:"subs"` + } `json:"symptoms"` + Diagnosis []struct { + Type string `json:"type"` + Subs []struct { + SubType string `json:"subType"` + } `json:"subs"` + } `json:"diagnosis"` +} + +type petCombination struct { + Name string `json:"name"` + Weight int `json:"weight"` + Products []struct { + SKUCode string `json:"sku_code"` + ProductName string `json:"product_name"` + } `json:"products"` + Script string `json:"script"` +} + +type petScriptDisease struct { + Combinations []petCombination `json:"combinations"` +} + +type petKnowledgeRow struct { + Symptom string + Disease string + SKUCode string + ProductName string +} + +// petQuestionBank is the customer-facing copy bank for the script package. +// Every question lives in the JSON data file, not in Go code. +type petQuestionBank struct { + OpeningGreeting string `json:"opening_greeting"` + DiagnosisFallback string `json:"diagnosis_fallback"` + RecommendFallback string `json:"recommend_fallback"` + SymptomScreeningTemplate string `json:"symptom_screening_template"` + SymptomConfirmOptions struct { + Yes string `json:"yes"` + No string `json:"no"` + } `json:"symptom_confirm_options"` + DiseaseConfirmOptions struct { + Yes string `json:"yes"` + No string `json:"no"` + } `json:"disease_confirm_options"` + Symptoms map[string]petSymptomQuestions `json:"symptoms"` + + DiseaseConfirmTemplate string `json:"disease_confirm_template"` + DiseaseConfirmedCopyTemplate string `json:"disease_confirmed_copy_template"` + DiseasePlanIntro string `json:"disease_plan_intro"` + DiseaseCareNote string `json:"disease_care_note"` + Diseases map[string]petDiseasePhrase `json:"diseases"` +} + +type petSymptomQuestions struct { + Confirm string `json:"confirm"` + Short string `json:"short"` + Choices []petChoiceQuestion `json:"choices"` +} + +type petChoiceQuestion struct { + Title string `json:"title"` + Question string `json:"question"` + Multiple bool `json:"multiple"` + Options []petChoiceOption `json:"options"` +} + +type petChoiceOption struct { + Key string `json:"key"` + Label string `json:"label"` +} + +type petDiseasePhrase struct { + Phrase string `json:"phrase"` +} + +// scriptKeyGenerator produces stable ASCII keys for dimension values and +// scripts. Chinese display names live in the Name field. +type scriptKeyGenerator struct { + counters map[string]int +} + +func (g *scriptKeyGenerator) key(prefix string) string { + g.counters[prefix]++ + return fmt.Sprintf("%s_%03d", prefix, g.counters[prefix]) +} + +// buildScriptPackage builds the 问诊问药 script package from the docs files. +func buildScriptPackage(scriptFile, knowledgeFile, smallCategoryFile, smallAskScriptFile, preGuideFile, classificationFile, questionFile string) (scriptkit.PackageInput, error) { + bank, err := readQuestionBank(questionFile) + if err != nil { + return scriptkit.PackageInput{}, err + } + classification, err := readClassification(classificationFile) + if err != nil { + return scriptkit.PackageInput{}, err + } + smallToBig, err := readStringMap(smallCategoryFile) + if err != nil { + return scriptkit.PackageInput{}, err + } + smallAsk, err := readPetScriptMap(smallAskScriptFile) + if err != nil { + return scriptkit.PackageInput{}, err + } + preGuide, err := readPreGuide(preGuideFile) + if err != nil { + return scriptkit.PackageInput{}, err + } + combinations, combinationSymptoms, err := readCombinations(scriptFile) + if err != nil { + return scriptkit.PackageInput{}, err + } + rows, err := readPetKnowledge(knowledgeFile) + if err != nil { + return scriptkit.PackageInput{}, err + } + + keys := &scriptKeyGenerator{counters: map[string]int{}} + input := scriptkit.PackageInput{ + Name: "宠物问诊问药话术包", + StartStageKey: "opening", + Dimensions: []scriptkit.DimensionInput{}, + Stages: []scriptkit.StageInput{}, + Linkages: []scriptkit.LinkageInput{}, + Adapters: []scriptkit.AdapterInput{}, + } + + // 症状大类来自小类映射、诊断分组、组合话术和 SKU 表的并集。 + bigSet := map[string]bool{} + for _, big := range smallToBig { + if big != "" { + bigSet[big] = true + } + } + for _, diagnosis := range classification.Diagnosis { + if diagnosis.Type != "" { + bigSet[diagnosis.Type] = true + } + } + for big := range combinationSymptoms { + bigSet[big] = true + } + for _, row := range rows { + if row.Symptom != "" { + bigSet[row.Symptom] = true + } + } + bigNames := make([]string, 0, len(bigSet)) + for name := range bigSet { + bigNames = append(bigNames, name) + } + sort.Strings(bigNames) + bigKey := map[string]string{} + bigDimension := scriptkit.DimensionInput{DimKey: "symptom", Name: "症状", SortOrder: 1, Values: []scriptkit.ValueInput{}} + for _, name := range bigNames { + key := keys.key("symptom") + bigKey[name] = key + bigDimension.Values = append(bigDimension.Values, scriptkit.ValueInput{ValueKey: key, Name: name, InitialWeight: 0, SortOrder: len(bigDimension.Values)}) + } + input.Dimensions = append(input.Dimensions, bigDimension) + + subSet := map[string]bool{} + for sub := range smallToBig { + if sub != "" { + subSet[sub] = true + } + } + for _, symptom := range classification.Symptoms { + for _, sub := range symptom.Subs { + if sub != "" { + subSet[sub] = true + } + } + } + subNames := make([]string, 0, len(subSet)) + for name := range subSet { + subNames = append(subNames, name) + } + sort.Strings(subNames) + subKey := map[string]string{} + subDimension := scriptkit.DimensionInput{DimKey: "symptom_sub", Name: "症状小类", SortOrder: 2, Values: []scriptkit.ValueInput{}} + for _, name := range subNames { + key := keys.key("symptom_sub") + subKey[name] = key + subDimension.Values = append(subDimension.Values, scriptkit.ValueInput{ValueKey: key, Name: name, InitialWeight: 0, SortOrder: len(subDimension.Values)}) + } + input.Dimensions = append(input.Dimensions, subDimension) + + diseaseNames := make([]string, 0) + diseaseKey := map[string]string{} + for _, diagnosis := range classification.Diagnosis { + for _, sub := range diagnosis.Subs { + if sub.SubType == "" { + continue + } + diseaseNames = append(diseaseNames, sub.SubType) + } + } + sort.Strings(diseaseNames) + diseaseDimension := scriptkit.DimensionInput{DimKey: "disease", Name: "疾病分型", SortOrder: 3, Values: []scriptkit.ValueInput{}} + for _, name := range diseaseNames { + key := keys.key("disease") + diseaseKey[name] = key + diseaseDimension.Values = append(diseaseDimension.Values, scriptkit.ValueInput{ValueKey: key, Name: name, InitialWeight: 0, SortOrder: len(diseaseDimension.Values)}) + } + input.Dimensions = append(input.Dimensions, diseaseDimension) + + // 每个症状都必须配置确认题和选择题;每个疾病都必须配置口语化说法。 + var missingSymptoms, missingDiseases []string + for _, name := range bigNames { + questions, ok := bank.Symptoms[name] + if !ok || strings.TrimSpace(questions.Confirm) == "" || len(questions.Choices) == 0 { + missingSymptoms = append(missingSymptoms, name) + } + } + for _, name := range diseaseNames { + if _, ok := bank.Diseases[name]; !ok { + missingDiseases = append(missingDiseases, name) + } + } + if len(missingSymptoms) > 0 { + return scriptkit.PackageInput{}, fmt.Errorf("话术库 %s 缺少症状确认题/选择题: %v", questionFile, missingSymptoms) + } + if len(missingDiseases) > 0 { + return scriptkit.PackageInput{}, fmt.Errorf("话术库 %s 缺少疾病口语化说法: %v", questionFile, missingDiseases) + } + + // Linkages: 小类 -> 大类, 大类 -> 疾病. 激活阈值 5:只有已确认的症状 + // (权重达到 5 以上)才会把疾病带入拟诊阶段;模糊提示(权重 3)只触发 + // 症状确认题,不会直接带出疾病。 + linkages := make([]scriptkit.LinkageInput, 0) + for sub, big := range smallToBig { + if subKey[sub] == "" || bigKey[big] == "" { + continue + } + linkages = append(linkages, scriptkit.LinkageInput{FromDimensionValueKey: subKey[sub], ToDimensionValueKey: bigKey[big], RelationType: "contributes_to", Contribution: 8, ActivationThreshold: 5, SortOrder: len(linkages)}) + } + for _, diagnosis := range classification.Diagnosis { + for _, sub := range diagnosis.Subs { + if bigKey[diagnosis.Type] == "" || diseaseKey[sub.SubType] == "" { + continue + } + linkages = append(linkages, scriptkit.LinkageInput{FromDimensionValueKey: bigKey[diagnosis.Type], ToDimensionValueKey: diseaseKey[sub.SubType], RelationType: "contributes_to", Contribution: 5, ActivationThreshold: 5, SortOrder: len(linkages)}) + } + } + sort.SliceStable(linkages, func(i, j int) bool { return linkages[i].SortOrder < linkages[j].SortOrder }) + input.Linkages = linkages + + // Adapters: symptom tags and order SKUs initialize symptom weights. + // SKU 只提示相关症状(权重 3,触发症状确认题),不会直接命中疾病—— + // 疾病必须由已确认的症状联动带出。 + adapters := make([]scriptkit.AdapterInput, 0) + for _, name := range bigNames { + adapters = append(adapters, scriptkit.AdapterInput{SourceField: "input.input_symptom_tags", MatchValue: name, TargetDimensionValueKey: bigKey[name], Weight: 8, SortOrder: len(adapters)}) + } + for _, name := range subNames { + adapters = append(adapters, scriptkit.AdapterInput{SourceField: "input.input_symptom_tags", MatchValue: name, TargetDimensionValueKey: subKey[name], Weight: 8, SortOrder: len(adapters)}) + } + for _, row := range rows { + if strings.TrimSpace(row.SKUCode) == "" { + continue + } + if bigKey[row.Symptom] != "" { + adapters = append(adapters, scriptkit.AdapterInput{SourceField: "derived.matched_skus", MatchValue: row.SKUCode, TargetDimensionValueKey: bigKey[row.Symptom], Weight: 3, SortOrder: len(adapters)}) + } + } + sort.SliceStable(adapters, func(i, j int) bool { return adapters[i].SortOrder < adapters[j].SortOrder }) + input.Adapters = adapters + + // Stage 1: 开场. 开场只做问候和宠物档案补充,不显示任何维度(症状只在症状节点显示)。 + opening := scriptkit.StageInput{ + StageKey: "opening", Name: "开场", Purpose: "确认顾客与宠物信息,并补充宠物档案", PrimaryDimensionKey: "", SortOrder: 1, Scripts: []scriptkit.ScriptInput{ + {ScriptKey: "opening_greeting", Name: "开场话术", ScriptType: "template", Content: bank.OpeningGreeting, SortOrder: 10}, + }, + } + input.Stages = append(input.Stages, opening) + + // Stage 2: 症状确认。候选症状超过 3 个时先做一道多选筛选题, + // 筛完再对选中的症状按由浅入深的顺序做选择题。 + symptomStage := scriptkit.StageInput{StageKey: "symptom", Name: "症状确认", Purpose: "确认宠物当前的主要症状", PrimaryDimensionKey: "symptom", SortOrder: 2, Scripts: []scriptkit.ScriptInput{}} + for index, guide := range preGuide { + symptomStage.Scripts = append(symptomStage.Scripts, scriptkit.ScriptInput{ScriptKey: keys.key("symptom_guide"), Name: "引导话术" + guide.Title, ScriptType: "fallback", Content: guide.Template, SortOrder: 100 + index}) + } + screenOptions := make([]scriptkit.OptionInput, 0, len(bigNames)) + for _, name := range bigNames { + short := bank.Symptoms[name].Short + if short == "" { + short = name + } + screenOptions = append(screenOptions, scriptkit.OptionInput{OptionKey: bigKey[name], Label: short, TargetDimensionValueKey: bigKey[name], Effect: "set", EffectValue: 10, SortOrder: len(screenOptions) + 1}) + } + symptomStage.Scripts = append(symptomStage.Scripts, scriptkit.ScriptInput{ + ScriptKey: keys.key("symptom_screen"), Name: "症状筛选", ScriptType: "screen", + Content: bank.SymptomScreeningTemplate, Multiple: true, + SortOrder: 50, Options: screenOptions, + }) + for _, name := range bigNames { + questions := bank.Symptoms[name] + confirm := scriptkit.ScriptInput{ + ScriptKey: keys.key("symptom_confirm"), Name: "确认" + name + "症状", ScriptType: "confirm", + Content: questions.Confirm, + DimensionValueKey: bigKey[name], ConfirmThreshold: 3, ShowThreshold: 5, SortOrder: 1000, + Options: []scriptkit.OptionInput{ + {OptionKey: "yes", Label: bank.SymptomConfirmOptions.Yes, TargetDimensionValueKey: bigKey[name], Effect: "set", EffectValue: 8, SortOrder: 1}, + {OptionKey: "no", Label: bank.SymptomConfirmOptions.No, TargetDimensionValueKey: bigKey[name], Effect: "zero", EffectValue: 0, SortOrder: 2}, + }, + } + symptomStage.Scripts = append(symptomStage.Scripts, confirm) + for choiceIndex, choice := range questions.Choices { + options := make([]scriptkit.OptionInput, 0, len(choice.Options)) + for optionIndex, option := range choice.Options { + options = append(options, scriptkit.OptionInput{OptionKey: option.Key, Label: option.Label, Effect: "set", EffectValue: 0, SortOrder: optionIndex + 1}) + } + scriptKey := keys.key("symptom_choice") + symptomStage.Scripts = append(symptomStage.Scripts, scriptkit.ScriptInput{ + ScriptKey: scriptKey, Name: name + choice.Title, ScriptType: "choice", + Content: choice.Question, DimensionValueKey: bigKey[name], + ConfirmThreshold: 3, ShowThreshold: 5, CollectFieldKey: scriptKey, + Multiple: choice.Multiple, + SortOrder: 1100 + choiceIndex, Options: options, + }) + } + } + for _, name := range subNames { + for index, ask := range smallAsk[name] { + symptomStage.Scripts = append(symptomStage.Scripts, scriptkit.ScriptInput{ScriptKey: keys.key("symptom_sub_ask"), Name: name + ask.Title, ScriptType: "info", Content: ask.Template, DimensionValueKey: subKey[name], ConfirmThreshold: 3, ShowThreshold: 5, SortOrder: 5000 + index}) + } + } + input.Stages = append(input.Stages, symptomStage) + + // Stage 3: 拟诊确认. 候选疾病(由已确认症状带出,权重5)先显示确认题; + // 客户确认后(权重10)才显示该疾病的确认话术,可同时确认多个疾病。 + diagnosisStage := scriptkit.StageInput{StageKey: "diagnosis", Name: "拟诊确认", Purpose: "结合症状确认可能的疾病方向(可确认多个),确认后再进入推荐", PrimaryDimensionKey: "disease", SortOrder: 3, Scripts: []scriptkit.ScriptInput{ + {ScriptKey: "diagnosis_fallback", Name: "拟诊兜底话术", ScriptType: "fallback", Content: bank.DiagnosisFallback, SortOrder: 100}, + }} + for _, name := range diseaseNames { + phrase := bank.Diseases[name].Phrase + confirm := scriptkit.ScriptInput{ + ScriptKey: keys.key("disease_confirm"), Name: "确认" + name + "方向", ScriptType: "confirm", + Content: strings.ReplaceAll(bank.DiseaseConfirmTemplate, "{{disease_phrase}}", phrase), + DimensionValueKey: diseaseKey[name], ConfirmThreshold: 5, ShowThreshold: 10, SortOrder: 1000, + Options: []scriptkit.OptionInput{ + {OptionKey: "yes", Label: bank.DiseaseConfirmOptions.Yes, TargetDimensionValueKey: diseaseKey[name], Effect: "set", EffectValue: 10, SortOrder: 1}, + {OptionKey: "no", Label: bank.DiseaseConfirmOptions.No, TargetDimensionValueKey: diseaseKey[name], Effect: "zero", EffectValue: 0, SortOrder: 2}, + }, + } + diagnosisStage.Scripts = append(diagnosisStage.Scripts, confirm) + diagnosisStage.Scripts = append(diagnosisStage.Scripts, scriptkit.ScriptInput{ + ScriptKey: keys.key("disease_copy"), Name: name + "疾病确认话术", ScriptType: "message", + Content: strings.ReplaceAll(bank.DiseaseConfirmedCopyTemplate, "{{disease_phrase}}", phrase), + DimensionValueKey: diseaseKey[name], ConfirmThreshold: 5, ShowThreshold: 10, SortOrder: 1100, + }) + diagnosisStage.Scripts = append(diagnosisStage.Scripts, scriptkit.ScriptInput{ + ScriptKey: keys.key("disease_copy"), Name: name + "方案引入话术", ScriptType: "message", + Content: bank.DiseasePlanIntro, + DimensionValueKey: diseaseKey[name], ConfirmThreshold: 5, ShowThreshold: 10, SortOrder: 1101, + }) + } + input.Stages = append(input.Stages, diagnosisStage) + + // Stage 4: 药品推荐与注意事项. 只有客户确认过的疾病(权重10)才会推荐。 + recommendStage := scriptkit.StageInput{StageKey: "recommend", Name: "药品推荐与注意事项", Purpose: "按已确认的疾病方向推荐方案、商品和注意事项", PrimaryDimensionKey: "disease", SortOrder: 4, Scripts: []scriptkit.ScriptInput{ + {ScriptKey: "recommend_fallback", Name: "推荐兜底话术", ScriptType: "fallback", Content: bank.RecommendFallback, SortOrder: 100}, + }} + for _, name := range diseaseNames { + order := 1000 + combos := combinations[name] + sort.SliceStable(combos, func(i, j int) bool { return combos[i].Weight > combos[j].Weight }) + for _, combination := range combos { + products := make([]scriptkit.ProductInput, 0, len(combination.Products)) + for _, product := range combination.Products { + if strings.TrimSpace(product.SKUCode) == "" { + continue + } + products = append(products, scriptkit.ProductInput{SKUCode: product.SKUCode, ProductName: product.ProductName}) + } + recommendStage.Scripts = append(recommendStage.Scripts, scriptkit.ScriptInput{ + ScriptKey: keys.key("recommend_combination"), Name: name + combination.Name, ScriptType: "message", + Content: strings.ReplaceAll(combination.Script, "{{purchased_products}}", "{{input.product_names}}"), + DimensionValueKey: diseaseKey[name], ConfirmThreshold: 5, ShowThreshold: 10, + Products: products, SortOrder: order, + }) + order++ + } + recommendStage.Scripts = append(recommendStage.Scripts, scriptkit.ScriptInput{ + ScriptKey: keys.key("recommend_note"), Name: name + "注意事项", ScriptType: "message", + Content: bank.DiseaseCareNote, + DimensionValueKey: diseaseKey[name], ConfirmThreshold: 5, ShowThreshold: 10, SortOrder: order, + }) + } + input.Stages = append(input.Stages, recommendStage) + + // Verify generated keys are valid before returning. + if err := scriptkit.ValidatePackageInput(input); err != nil { + return scriptkit.PackageInput{}, fmt.Errorf("generated package is invalid: %w", err) + } + return input, nil +} + +func readQuestionBank(filename string) (petQuestionBank, error) { + raw, err := os.ReadFile(filename) + if err != nil { + return petQuestionBank{}, fmt.Errorf("read pet question bank %s: %w", filename, err) + } + var bank petQuestionBank + if err := json.Unmarshal(raw, &bank); err != nil { + return petQuestionBank{}, fmt.Errorf("parse pet question bank %s: %w", filename, err) + } + if bank.SymptomConfirmOptions.Yes == "" { + bank.SymptomConfirmOptions.Yes = "有" + } + if bank.SymptomConfirmOptions.No == "" { + bank.SymptomConfirmOptions.No = "没有" + } + if bank.DiseaseConfirmOptions.Yes == "" { + bank.DiseaseConfirmOptions.Yes = "对,按这个判断" + } + if bank.DiseaseConfirmOptions.No == "" { + bank.DiseaseConfirmOptions.No = "感觉不太像" + } + if bank.SymptomScreeningTemplate == "" { + return petQuestionBank{}, fmt.Errorf("pet question bank %s is missing symptom_screening_template", filename) + } + if bank.DiseaseConfirmTemplate == "" || bank.DiseaseConfirmedCopyTemplate == "" { + return petQuestionBank{}, fmt.Errorf("pet question bank %s is missing disease templates", filename) + } + return bank, nil +} + +func readClassification(filename string) (petClassification, error) { + raw, err := os.ReadFile(filename) + if err != nil { + return petClassification{}, fmt.Errorf("read pet classification %s: %w", filename, err) + } + var wrapper struct { + ConsultPayload string `json:"consultPayload"` + } + if err := json.Unmarshal(raw, &wrapper); err != nil { + return petClassification{}, fmt.Errorf("parse pet classification wrapper %s: %w", filename, err) + } + var result petClassification + if err := json.Unmarshal([]byte(wrapper.ConsultPayload), &result); err != nil { + return petClassification{}, fmt.Errorf("parse pet classification %s: %w", filename, err) + } + return result, nil +} + +func readStringMap(filename string) (map[string]string, error) { + raw, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("read pet string map %s: %w", filename, err) + } + var result map[string]string + if err := json.Unmarshal(raw, &result); err != nil { + return nil, fmt.Errorf("parse pet string map %s: %w", filename, err) + } + return result, nil +} + +func readPetScriptMap(filename string) (map[string][]petScriptEntry, error) { + raw, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("read pet script map %s: %w", filename, err) + } + var source map[string][]petScriptEntry + if err := json.Unmarshal(raw, &source); err != nil { + return nil, fmt.Errorf("parse pet script map %s: %w", filename, err) + } + return source, nil +} + +func readPreGuide(filename string) ([]petScriptEntry, error) { + raw, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("read pet pre-symptom guide %s: %w", filename, err) + } + var source struct { + PreSymptomScripts []petScriptEntry `json:"pre_symptom_scripts"` + } + if err := json.Unmarshal(raw, &source); err != nil { + return nil, fmt.Errorf("parse pet pre-symptom guide %s: %w", filename, err) + } + return source.PreSymptomScripts, nil +} + +// readCombinations loads 疾病 -> 组合方案话术 and the symptom keys of the source. +func readCombinations(filename string) (map[string][]petCombination, map[string]bool, error) { + raw, err := os.ReadFile(filename) + if err != nil { + return nil, nil, fmt.Errorf("read pet script combinations %s: %w", filename, err) + } + var source map[string]map[string]petScriptDisease + if err := json.Unmarshal(raw, &source); err != nil { + return nil, nil, fmt.Errorf("parse pet script combinations %s: %w", filename, err) + } + result := map[string][]petCombination{} + symptoms := map[string]bool{} + for symptom, diseases := range source { + symptoms[symptom] = true + for disease, definition := range diseases { + result[disease] = append(result[disease], definition.Combinations...) + } + } + return result, symptoms, nil +} + +func readPetKnowledge(filename string) ([]petKnowledgeRow, error) { + book, err := excelize.OpenFile(filename) + if err != nil { + return nil, fmt.Errorf("open pet knowledge %s: %w", filename, err) + } + defer book.Close() + sheets := book.GetSheetList() + if len(sheets) == 0 { + return nil, fmt.Errorf("pet knowledge %s has no worksheet", filename) + } + iterator, err := book.Rows(sheets[0]) + if err != nil { + return nil, fmt.Errorf("read pet knowledge worksheet: %w", err) + } + defer iterator.Close() + columns := map[string]int{} + result := make([]petKnowledgeRow, 0) + rowNumber := 0 + for iterator.Next() { + rowNumber++ + values, err := iterator.Columns() + if err != nil { + return nil, fmt.Errorf("read pet knowledge row %d: %w", rowNumber, err) + } + if rowNumber == 1 { + for index, value := range values { + columns[strings.TrimSpace(value)] = index + } + for _, required := range []string{"症状", "疾病", "方案", "SKU_CODE", "商品标题"} { + if _, ok := columns[required]; !ok { + return nil, fmt.Errorf("pet knowledge is missing column %q", required) + } + } + continue + } + row := petKnowledgeRow{ + Symptom: excelValue(values, columns["症状"]), + Disease: excelValue(values, columns["疾病"]), + SKUCode: excelValue(values, columns["SKU_CODE"]), + ProductName: excelValue(values, columns["商品标题"]), + } + if row.Symptom == "" && row.Disease == "" { + continue + } + if row.Symptom == "" || row.Disease == "" { + return nil, fmt.Errorf("pet knowledge row %d must contain 症状 and 疾病", rowNumber) + } + result = append(result, row) + } + if err := iterator.Error(); err != nil { + return nil, fmt.Errorf("iterate pet knowledge: %w", err) + } + if len(result) == 0 { + return nil, fmt.Errorf("pet knowledge contains no data") + } + return result, nil +} + +func excelValue(values []string, index int) string { + if index >= len(values) { + return "" + } + return strings.TrimSpace(values[index]) +} diff --git a/sdk/dist/index.d.ts b/sdk/dist/index.d.ts index e825bb2..2e6ba8a 100644 --- a/sdk/dist/index.d.ts +++ b/sdk/dist/index.d.ts @@ -42,27 +42,54 @@ export type NodePresentation = { content: string; }; }; -export type KnowledgeCollectionOption = { - value: string; - label: string; - parents?: string[]; - suggested?: boolean; -}; -export type KnowledgeCollectionStep = { - field_key: string; +export type DimensionValue = { + value_key: string; name: string; + weight: number; + hot: boolean; +}; +export type DimensionState = { + dim_key: string; + name: string; + values: DimensionValue[]; +}; +export type ScriptOption = { + option_key: string; + label: string; +}; +export type ScriptProduct = { + sku_code: string; + product_name: string; +}; +export type ScriptItem = { + script_key: string; + name: string; + script_type: string; + content: string; + dimension_value_key?: string; + weight?: number; + confirm_threshold?: number; + show_threshold?: number; + options?: ScriptOption[]; + products?: ScriptProduct[]; + feedback: boolean; required: boolean; multiple: boolean; - from_field?: string; - options: KnowledgeCollectionOption[]; }; -export type KnowledgeCollection = { - context_fields: NodeField[]; - context_title?: string; - context_hint?: string; - selection_title?: string; - selection_hint?: string; - steps: KnowledgeCollectionStep[]; +export type StageForm = { + fields: NodeField[]; +}; +export type StageState = { + stage_key: string; + name: string; + purpose: string; + dimensions: DimensionState[]; + scripts: ScriptItem[]; + fallback?: string; + form?: StageForm; + screening_required: boolean; + can_next: boolean; + can_next_reason?: string; }; export type NodeView = { node_key: string; @@ -71,8 +98,8 @@ export type NodeView = { content: string; fields?: NodeField[]; presentation?: NodePresentation; + stage?: StageState; outputs?: unknown[]; - collection?: KnowledgeCollection; }; export type ScenarioState = { run_id: number; @@ -98,6 +125,21 @@ export type FinishOptions = { result?: string; finalResult?: Record | null; }; +export type ScriptAnswer = { + script_key: string; + option_keys?: string[]; + value?: string; +}; +export type AnswerOptions = { + scriptKey?: string; + optionKeys?: string[]; + value?: string; + scriptAnswers?: ScriptAnswer[]; + dimensionSelects?: Record; + answers?: ScenarioInput; + dimensionValueKey?: string; + selected?: boolean; +}; export declare class SalesScenario { private readonly options; private token; @@ -106,9 +148,14 @@ export declare class SalesScenario { constructor(options: CreateOptions); start(): Promise; getState(): ScenarioState; + getStage(): StageState; getCurrentNode(): Promise; + /** Answer stage questions without advancing. Answer locally on the client and submit the whole stage once: pass every answered script in scriptAnswers together with dimensionSelects and the content form in answers. */ + answer(options?: AnswerOptions): Promise; submit(values: ScenarioInput): Promise; next(): Promise; + back(): Promise; + feedback(scriptKey: string, feedbackType: 'like' | 'report' | 'unreasonable', note?: string): Promise; finish(options?: FinishOptions): Promise; reset(): Promise; destroy(): void; diff --git a/sdk/dist/index.iife.js b/sdk/dist/index.iife.js index 929e33f..6b9b8fd 100644 --- a/sdk/dist/index.iife.js +++ b/sdk/dist/index.iife.js @@ -43,9 +43,19 @@ var IqudooSalesScenario = (() => { if (!this.state) throw new Error("SDK has not started"); return this.state; } + getStage() { + const stage = this.getState().node.stage; + if (!stage) throw new Error("current node is not a script stage"); + return stage; + } async getCurrentNode() { return this.update(`/public/runs/${this.getState().run_id}/current`); } + /** Answer stage questions without advancing. Answer locally on the client and submit the whole stage once: pass every answered script in scriptAnswers together with dimensionSelects and the content form in answers. */ + async answer(options = {}) { + const state = await this.update(`/public/runs/${this.getState().run_id}/answer`, { method: "POST", body: JSON.stringify({ node_key: this.getState().node.node_key, script_key: options.scriptKey || "", option_keys: options.optionKeys || [], value: options.value || "", script_answers: options.scriptAnswers || [], dimension_selects: options.dimensionSelects || {}, answers: options.answers || {}, dimension_value_key: options.dimensionValueKey || "", selected: options.selected ?? false }) }); + return state; + } async submit(values) { const state = await this.update(`/public/runs/${this.getState().run_id}/submit`, { method: "POST", body: JSON.stringify({ node_key: this.getState().node.node_key, answers: values }) }); this.emit("form_submit", { values, state }); @@ -54,6 +64,13 @@ var IqudooSalesScenario = (() => { async next() { return this.update(`/public/runs/${this.getState().run_id}/next`, { method: "POST" }); } + async back() { + return this.update(`/public/runs/${this.getState().run_id}/back`, { method: "POST" }); + } + async feedback(scriptKey, feedbackType, note = "") { + await this.request(`/public/runs/${this.getState().run_id}/feedback`, { method: "POST", body: JSON.stringify({ node_key: this.getState().node.node_key, script_key: scriptKey, feedback_type: feedbackType, note }) }); + return true; + } async finish(options = {}) { const state = await this.update(`/public/runs/${this.getState().run_id}/finish`, { method: "POST", body: JSON.stringify({ result: options.result || "", final_result: options.finalResult ?? null }) }); this.emit("finish", state); diff --git a/sdk/dist/index.js b/sdk/dist/index.js index ec9b355..04bb174 100644 --- a/sdk/dist/index.js +++ b/sdk/dist/index.js @@ -10,9 +10,15 @@ export class SalesScenario { this.token = state.session_token; this.setState(state); this.emit('ready', state); return state; } getState() { if (!this.state) throw new Error('SDK has not started'); return this.state; } + getStage() { const stage = this.getState().node.stage; if (!stage) + throw new Error('current node is not a script stage'); return stage; } async getCurrentNode() { return this.update(`/public/runs/${this.getState().run_id}/current`); } + /** Answer stage questions without advancing. Answer locally on the client and submit the whole stage once: pass every answered script in scriptAnswers together with dimensionSelects and the content form in answers. */ + async answer(options = {}) { const state = await this.update(`/public/runs/${this.getState().run_id}/answer`, { method: 'POST', body: JSON.stringify({ node_key: this.getState().node.node_key, script_key: options.scriptKey || '', option_keys: options.optionKeys || [], value: options.value || '', script_answers: options.scriptAnswers || [], dimension_selects: options.dimensionSelects || {}, answers: options.answers || {}, dimension_value_key: options.dimensionValueKey || '', selected: options.selected ?? false }) }); return state; } async submit(values) { const state = await this.update(`/public/runs/${this.getState().run_id}/submit`, { method: 'POST', body: JSON.stringify({ node_key: this.getState().node.node_key, answers: values }) }); this.emit('form_submit', { values, state }); return state; } async next() { return this.update(`/public/runs/${this.getState().run_id}/next`, { method: 'POST' }); } + async back() { return this.update(`/public/runs/${this.getState().run_id}/back`, { method: 'POST' }); } + async feedback(scriptKey, feedbackType, note = '') { await this.request(`/public/runs/${this.getState().run_id}/feedback`, { method: 'POST', body: JSON.stringify({ node_key: this.getState().node.node_key, script_key: scriptKey, feedback_type: feedbackType, note }) }); return true; } async finish(options = {}) { const state = await this.update(`/public/runs/${this.getState().run_id}/finish`, { method: 'POST', body: JSON.stringify({ result: options.result || '', final_result: options.finalResult ?? null }) }); this.emit('finish', state); return state; } async reset() { return this.update(`/public/runs/${this.getState().run_id}/reset`, { method: 'POST' }); } destroy() { this.handlers.clear(); this.token = ''; this.state = undefined; } diff --git a/sdk/src/index.ts b/sdk/src/index.ts index ed56f2c..c7ad671 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -5,14 +5,20 @@ export type ResultSchema = { fields:ResultField[] } export type NodeField = { key:string; name:string; type:string; required:boolean; options:unknown[]; validation:Record } export type PresentationField = { key:string; label:string; kind:'text'|'image'; value:unknown } export type NodePresentation = { summary:PresentationField[]; items:Array<{fields:PresentationField[]}>; opening?:{title:string; content:string} } -export type KnowledgeCollectionOption = { value:string; label:string; parents?:string[]; suggested?:boolean } -export type KnowledgeCollectionStep = { field_key:string; name:string; required:boolean; multiple:boolean; from_field?:string; options:KnowledgeCollectionOption[] } -export type KnowledgeCollection = { context_fields:NodeField[]; context_title?:string; context_hint?:string; selection_title?:string; selection_hint?:string; steps:KnowledgeCollectionStep[] } -export type NodeView = { node_key:string; type:string; title:string; content:string; fields?:NodeField[]; presentation?:NodePresentation; outputs?:unknown[]; collection?:KnowledgeCollection } +export type DimensionValue = { value_key:string; name:string; weight:number; hot:boolean } +export type DimensionState = { dim_key:string; name:string; values:DimensionValue[] } +export type ScriptOption = { option_key:string; label:string } +export type ScriptProduct = { sku_code:string; product_name:string } +export type ScriptItem = { script_key:string; name:string; script_type:string; content:string; dimension_value_key?:string; weight?:number; confirm_threshold?:number; show_threshold?:number; options?:ScriptOption[]; products?:ScriptProduct[]; feedback:boolean; required:boolean; multiple:boolean } +export type StageForm = { fields:NodeField[] } +export type StageState = { stage_key:string; name:string; purpose:string; dimensions:DimensionState[]; scripts:ScriptItem[]; fallback?:string; form?:StageForm; screening_required:boolean; can_next:boolean; can_next_reason?:string } +export type NodeView = { node_key:string; type:string; title:string; content:string; fields?:NodeField[]; presentation?:NodePresentation; stage?:StageState; outputs?:unknown[] } export type ScenarioState = { run_id:number; external_ref:string; status:string; final_result?:Record|null; result_schema:ResultSchema; session_token?:string; node:NodeView; outputs:OutputField[] } export type SDKEvent = 'ready'|'node_change'|'form_submit'|'finish'|'error' export type CreateOptions = { baseURL?:string; publicKey:string; scenarioKey:string; sopId?:number; input?:ScenarioInput; initialValues?:ScenarioInput; externalRef?:string } export type FinishOptions = { result?:string; finalResult?:Record|null } +export type ScriptAnswer = { script_key:string; option_keys?:string[]; value?:string } +export type AnswerOptions = { scriptKey?:string; optionKeys?:string[]; value?:string; scriptAnswers?:ScriptAnswer[]; dimensionSelects?:Record; answers?:ScenarioInput; dimensionValueKey?:string; selected?:boolean } export class SalesScenario { private token='' @@ -21,9 +27,14 @@ export class SalesScenario { constructor(private readonly options:CreateOptions){} async start(){const state=await this.request(`/public/scenarios/${encodeURIComponent(this.options.scenarioKey)}/runs`,{method:'POST',body:JSON.stringify({public_key:this.options.publicKey,sop_id:this.options.sopId,input:this.options.input||{},initial_values:this.options.initialValues||{},external_ref:this.options.externalRef||''})});if(state.session_token)this.token=state.session_token;this.setState(state);this.emit('ready',state);return state} getState(){if(!this.state)throw new Error('SDK has not started');return this.state} + getStage(){const stage=this.getState().node.stage;if(!stage)throw new Error('current node is not a script stage');return stage} async getCurrentNode(){return this.update(`/public/runs/${this.getState().run_id}/current`)} + /** Answer stage questions without advancing. Answer locally on the client and submit the whole stage once: pass every answered script in scriptAnswers together with dimensionSelects and the content form in answers. */ + async answer(options:AnswerOptions={}){const state=await this.update(`/public/runs/${this.getState().run_id}/answer`,{method:'POST',body:JSON.stringify({node_key:this.getState().node.node_key,script_key:options.scriptKey||'',option_keys:options.optionKeys||[],value:options.value||'',script_answers:options.scriptAnswers||[],dimension_selects:options.dimensionSelects||{},answers:options.answers||{},dimension_value_key:options.dimensionValueKey||'',selected:options.selected??false})});return state} async submit(values:ScenarioInput){const state=await this.update(`/public/runs/${this.getState().run_id}/submit`,{method:'POST',body:JSON.stringify({node_key:this.getState().node.node_key,answers:values})});this.emit('form_submit',{values,state});return state} async next(){return this.update(`/public/runs/${this.getState().run_id}/next`,{method:'POST'})} + async back(){return this.update(`/public/runs/${this.getState().run_id}/back`,{method:'POST'})} + async feedback(scriptKey:string,feedbackType:'like'|'report'|'unreasonable',note=''){await this.request<{recorded:boolean}>(`/public/runs/${this.getState().run_id}/feedback`,{method:'POST',body:JSON.stringify({node_key:this.getState().node.node_key,script_key:scriptKey,feedback_type:feedbackType,note})});return true} async finish(options:FinishOptions={}){const state=await this.update(`/public/runs/${this.getState().run_id}/finish`,{method:'POST',body:JSON.stringify({result:options.result||'',final_result:options.finalResult??null})});this.emit('finish',state);return state} async reset(){return this.update(`/public/runs/${this.getState().run_id}/reset`,{method:'POST'})} destroy(){this.handlers.clear();this.token='';this.state=undefined} diff --git a/seed-pet-doctor b/seed-pet-doctor new file mode 100755 index 0000000..aa98078 Binary files /dev/null and b/seed-pet-doctor differ diff --git a/web/src/components/ScenarioKnowledgeTab.vue b/web/src/components/ScenarioKnowledgeTab.vue deleted file mode 100644 index 1c0b854..0000000 --- a/web/src/components/ScenarioKnowledgeTab.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/web/src/components/ScriptPackageTab.vue b/web/src/components/ScriptPackageTab.vue new file mode 100644 index 0000000..379a78e --- /dev/null +++ b/web/src/components/ScriptPackageTab.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/web/src/layouts/AppLayout.vue b/web/src/layouts/AppLayout.vue index c57897c..73bf87d 100644 --- a/web/src/layouts/AppLayout.vue +++ b/web/src/layouts/AppLayout.vue @@ -53,8 +53,8 @@ function logout() {
- 销冠 SOP - 经验执行系统 + 销话通 + AI 追单话术系统
diff --git a/web/src/types/index.ts b/web/src/types/index.ts index a53194a..aaa0abf 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -3,19 +3,23 @@ export interface User { user_id: number; tenant_id: number; role_code: string; p export interface Scenario extends BaseEntity { tenant_id: number; scenario_key:string; public_key:string; allowed_origins:string[]; name: string; industry: string; role_name: string; goal: string; trigger_text: string; visibility: string; status: string; created_by: number; input_schema:Record; output_schema:Record; result_schema:Record } export interface ScenarioField extends BaseEntity { scenario_id: number; field_key: string; field_name: string; source_path:string; field_type: string; required: boolean; options: unknown[]; validation: Record; sort_order: number } export interface SOP extends BaseEntity { scenario_id: number; name: string; description: string; status: string; start_node_key?: string } -export interface KnowledgeItemView { key:string; name:string; type:string; content:Record } -export interface KnowledgeGroup extends KnowledgeItemView { relations:Record; suggested?:boolean } -export interface PublicFieldView { key:string; name:string; type:string; required:boolean; options:string[]; validation:Record } -export interface KnowledgeCollectionOption { value:string; label:string; parents?:string[]; suggested?:boolean } -export interface KnowledgeCollectionStep { field_key:string; name:string; required:boolean; multiple:boolean; from_field?:string; options:KnowledgeCollectionOption[] } -export interface KnowledgeCollection { context_fields:PublicFieldView[]; context_title?:string; context_hint?:string; selection_title?:string; selection_hint?:string; steps:KnowledgeCollectionStep[] } +export interface DimensionValue { value_key:string; name:string; weight:number; hot:boolean } +export interface DimensionState { dim_key:string; name:string; values:DimensionValue[] } +export interface ScriptOption { option_key:string; label:string } +export interface ScriptProduct { sku_code:string; product_name:string } +export interface ScriptItem { script_key:string; name:string; script_type:string; content:string; dimension_value_key?:string; weight?:number; confirm_threshold?:number; show_threshold?:number; options?:ScriptOption[]; products?:ScriptProduct[]; feedback:boolean; required:boolean; multiple:boolean } +export interface StageForm { fields:PublicFieldView[] } +export interface StageState { stage_key:string; name:string; purpose:string; dimensions:DimensionState[]; scripts:ScriptItem[]; fallback?:string; form?:StageForm; screening_required:boolean; can_next:boolean; can_next_reason?:string } +export interface PublicFieldView { key:string; name:string; type:string; required:boolean; options:unknown[]; validation:Record } export interface PresentationField { key:string; label:string; kind:'text'|'image'; value:unknown } export interface NodePresentation { summary:PresentationField[]; items:Array<{fields:PresentationField[]}>; opening?:{title:string; content:string} } -export interface SOPNode extends BaseEntity { node_key: string; type: string; title: string; content: string; config: Record; position_x: number; position_y: number; presentation?:NodePresentation; outputs?:KnowledgeGroup[]; collection?:KnowledgeCollection } +export interface SOPNode extends BaseEntity { node_key: string; type: string; title: string; content: string; config: Record; position_x: number; position_y: number; presentation?:NodePresentation; stage?:StageState } export interface SOPEdge extends BaseEntity { source_node_key: string; target_node_key: string; condition: Record; priority: number } -export interface SOPRun extends BaseEntity { sop_id: number; operator_id:number; external_ref?:string; current_node_key: string; status: string; answers: Record; input:Record; derived:Record; outputs:unknown[]; result: string; final_result?:Record|null; started_at: string; completed_at?: string; sop_name?: string; scenario_name?:string; operator_name?:string } -export interface RunEvent extends BaseEntity { run_id:number; node_key:string; action:string; payload:Record; node_title:string; node_type:string; node_content:string; outputs?:KnowledgeGroup[] } +export interface SOPRun extends BaseEntity { sop_id: number; operator_id:number; external_ref?:string; current_node_key: string; status: string; answers: Record; input:Record; derived:Record; script_state?:Record; outputs:unknown[]; result: string; final_result?:Record|null; started_at: string; completed_at?: string; sop_name?: string; scenario_name?:string; operator_name?:string } +export interface RunEvent extends BaseEntity { run_id:number; node_key:string; action:string; payload:Record; node_title:string; node_type:string; node_content:string } export interface RunFeedback extends BaseEntity { run_id:number; user_id:number; score:number; comment:string; user_name:string } +export interface RunDimension extends BaseEntity { run_id:number; dimension_id:number; value_key:string; weight:number } +export interface ScriptFeedback extends BaseEntity { run_id:number; stage_id:number; script_id:number; feedback_type:string; operator_id:number; note:string } export interface RunDetail extends SOPRun { scenario_name:string; operator_name:string } export interface Role extends BaseEntity { name:string; code:string; permissions:string[] } export interface Member { id:number; user_id:number; username:string; display_name:string; role_code:string; role_name:string; status:string } diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue index a3e90cc..1eace91 100644 --- a/web/src/views/DashboardView.vue +++ b/web/src/views/DashboardView.vue @@ -9,9 +9,16 @@ const router = useRouter() const auth = useAuthStore() const loading = ref(true) const summary = ref({ scenarios: 0, published_sops: 0, runs: 0, completed_runs: 0 }) +interface DimensionStat { value_key:string; name:string; dim_key:string; runs:number; average_weight:number } +interface FeedbackStat { script_key:string; name:string; feedback_type:string; count:number } +const scriptkit = ref<{ dimensions:DimensionStat[]; feedback:FeedbackStat[] }>({ dimensions: [], feedback: [] }) +const feedbackTypeText = (type:string)=>({ like:'有帮助', report:'不准确', unreasonable:'不合理' } as Record)[type]||type onMounted(async () => { - try { summary.value = await api.get('/dashboard/summary') } finally { loading.value = false } + try { + summary.value = await api.get('/dashboard/summary') + scriptkit.value = await api.get('/dashboard/scriptkit') + } catch { /* 看板统计失败不阻塞页面 */ } finally { loading.value = false } }) @@ -29,22 +36,44 @@ onMounted(async () => {
{{ summary.runs }}累计执行
{{ summary.runs ? Math.round(summary.completed_runs / summary.runs * 100) : 0 }}%
{{ summary.completed_runs }}完成执行
- -
-
-
常用入口
- - - - -
-
-
平台原则
-
一句好话术不应只被收藏,它应该知道何时出现、下一步去哪,以及是否真的有效。
-
创建配置执行复盘
-
-
+
+
+
常用入口
+ + + + +
+
+
平台原则
+
一句好话术不应只被收藏,它应该知道何时出现、下一步去哪,以及是否真的有效。
+
创建配置执行复盘
+
+
+ +
+
+
维度权重复盘 · 高频维度值
+ + + + +
+
+
话术反馈统计
+ + + + +
+
+ @@ -62,6 +91,8 @@ onMounted(async () => { .doctrine-panel { background: #202825; border-color: #202825; color: white; }.doctrine-panel .panel-kicker { color: #89a099; } blockquote { margin: 30px 0 42px; font-family: "Noto Serif SC", serif; font-size: 22px; line-height: 1.65; } .flow-note { display: flex; align-items: center; color: #9cafaa; font-size: 12px; }.flow-note i { flex: 1; height: 1px; margin: 0 10px; background: #4a5a54; } -@media (max-width: 900px) { .metric-strip { grid-template-columns: repeat(2,1fr); }.metric:nth-child(2) { border-right: 0; }.metric:nth-child(-n+2) { border-bottom: 1px solid var(--line); }.work-grid { grid-template-columns: 1fr; } } +.analytics-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-top: 18px; } +.analytics-panel { padding: 22px; }.analytics-panel code { padding: 2px 5px; color: #17654f; background: #eef5f2; border-radius: 3px; }.panel-empty { display: block; padding: 26px 0; color: var(--muted); font-size: 12px; text-align: center; } +@media (max-width: 900px) { .metric-strip { grid-template-columns: repeat(2,1fr); }.metric:nth-child(2) { border-right: 0; }.metric:nth-child(-n+2) { border-bottom: 1px solid var(--line); }.work-grid, .analytics-grid { grid-template-columns: 1fr; } } @media (max-width: 520px) { .metric { min-height: 100px; padding: 16px; }.metric b { font-size: 24px; }.metric-icon, .completion { display: none; } } diff --git a/web/src/views/ExecuteView.vue b/web/src/views/ExecuteView.vue index 42d3ff8..e3d96dd 100644 --- a/web/src/views/ExecuteView.vue +++ b/web/src/views/ExecuteView.vue @@ -1,17 +1,17 @@ diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index 965c8cc..369e43e 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -9,7 +9,11 @@ import { useAuthStore } from '@/stores/auth' const router = useRouter() const auth = useAuthStore() const loading = ref(false) -const form = reactive({ username: 'admin', password: 'admin123' }) +const isDevelopment = import.meta.env.DEV +const form = reactive({ + username: isDevelopment ? 'admin' : '', + password: isDevelopment ? 'admin123' : '' +}) async function submit() { loading.value = true @@ -53,7 +57,7 @@ async function submit() { 进入平台 - + diff --git a/web/src/views/RunDetailView.vue b/web/src/views/RunDetailView.vue index fc77d1c..639c5ef 100644 --- a/web/src/views/RunDetailView.vue +++ b/web/src/views/RunDetailView.vue @@ -1,22 +1,25 @@