update
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
56
internal/dashboard/scriptkit.go
Normal file
56
internal/dashboard/scriptkit.go
Normal file
@@ -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})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
148
internal/model/scriptkit.go
Normal file
148
internal/model/scriptkit.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
11
internal/run/helpers.go
Normal file
11
internal/run/helpers.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
462
internal/run/stage.go
Normal file
462
internal/run/stage.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
99
internal/run/view.go
Normal file
99
internal/run/view.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
697
internal/scriptkit/engine.go
Normal file
697
internal/scriptkit/engine.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
321
internal/scriptkit/engine_test.go
Normal file
321
internal/scriptkit/engine_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
322
internal/scriptkit/handler.go
Normal file
322
internal/scriptkit/handler.go
Normal file
@@ -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
|
||||
}
|
||||
136
internal/scriptkit/validator.go
Normal file
136
internal/scriptkit/validator.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user