update
This commit is contained in:
1
Makefile
1
Makefile
@@ -36,6 +36,7 @@ package-linux-amd64-prod: BUILD_DIR := bin/linux-amd64-prod
|
||||
package-linux-amd64-prod: PACKAGE_FILE := bin/iqudo-top1-linux-amd64-prod.tar.gz
|
||||
package-linux-amd64-prod: build
|
||||
mkdir -p $(BUILD_DIR)
|
||||
rm -f $(PACKAGE_FILE)
|
||||
tar -czf $(PACKAGE_FILE) -C $(BUILD_DIR) main configs
|
||||
@echo "created $(PACKAGE_FILE)"
|
||||
|
||||
|
||||
@@ -39,7 +39,5 @@ multitable:
|
||||
sops: 0
|
||||
sop_nodes: 0
|
||||
sop_edges: 0
|
||||
knowledge_items: 0
|
||||
knowledge_relations: 0
|
||||
runs: 0
|
||||
feedback: 0
|
||||
|
||||
@@ -33,7 +33,5 @@ multitable:
|
||||
sops: 69
|
||||
sop_nodes: 70
|
||||
sop_edges: 71
|
||||
knowledge_items: 72
|
||||
knowledge_relations: 73
|
||||
runs: 75
|
||||
feedback: 76
|
||||
|
||||
@@ -39,7 +39,5 @@ multitable:
|
||||
sops: 48
|
||||
sop_nodes: 49
|
||||
sop_edges: 50
|
||||
knowledge_items: 64
|
||||
knowledge_relations: 65
|
||||
runs: 52
|
||||
feedback: 53
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -82,8 +82,6 @@ type MultiTableTables struct {
|
||||
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"`
|
||||
}
|
||||
@@ -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,29 +122,6 @@ 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"`
|
||||
@@ -157,7 +134,7 @@ type SOPRun struct {
|
||||
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"`
|
||||
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"`
|
||||
|
||||
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"
|
||||
@@ -24,7 +23,6 @@ type DetailEvent struct {
|
||||
NodeType string `json:"node_type"`
|
||||
NodeContent string `json:"node_content"`
|
||||
NodeConfig datatypes.JSON `json:"-"`
|
||||
Outputs []KnowledgeGroup `json:"outputs,omitempty" gorm:"-"`
|
||||
}
|
||||
|
||||
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", "执行记录知识快照不可用")
|
||||
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
|
||||
}
|
||||
events[i].Outputs = outputs
|
||||
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" {
|
||||
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 {
|
||||
if node.Type == "stage" {
|
||||
if err := advanceFromStage(tx, &run, node); err != nil {
|
||||
return err
|
||||
}
|
||||
return advancePublicRun(tx, &run)
|
||||
} else if node.Type == "question" || node.Type == "choice" || node.Type == "form" {
|
||||
return errPublicInputNeeded
|
||||
} else if err := advanceRunNode(tx, &run); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
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 {
|
||||
if keys, ok := config["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 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
|
||||
}
|
||||
fieldKey, keyOK := value.(string)
|
||||
if !keyOK || fieldKey == "" {
|
||||
problems = append(problems, fmt.Sprintf("节点“%s”包含无效的表单字段", node.Title))
|
||||
continue
|
||||
}
|
||||
if _, exists := fieldMap[fieldKey]; !exists {
|
||||
problems = append(problems, fmt.Sprintf("节点“%s”引用的字段 %s 不存在", node.Title, fieldKey))
|
||||
continue
|
||||
}
|
||||
collected[fieldKey] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"}},
|
||||
StageKeys: []string{"safety"},
|
||||
}
|
||||
return nodes, edges, context
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@ import (
|
||||
|
||||
func main() {
|
||||
configDir := flag.String("config-dir", "configs", "configuration directory")
|
||||
environment := flag.String("env", "", "runtime environment: development, test, prod")
|
||||
flag.Parse()
|
||||
cfg, err := config.Load(config.LoadOptions{ConfigDir: *configDir})
|
||||
cfg, err := config.Load(config.LoadOptions{ConfigDir: *configDir, Environment: *environment})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "load configuration: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"github.com/xuri/excelize/v2"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const petKnowledgeSource = "症状-疾病-方案.xlsx"
|
||||
const petScriptSource = "症状-疾病-话术.json"
|
||||
|
||||
func resetPetKnowledge(tx *gorm.DB, tenantID, scenarioID uint64) error {
|
||||
var ids []uint64
|
||||
if err := tx.Model(&model.KnowledgeItem{}).Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Pluck("id", &ids).Error; err != nil {
|
||||
return fmt.Errorf("list old pet knowledge: %w", err)
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
if err := tx.Where("tenant_id = ? AND scenario_id = ? AND (from_knowledge_id IN ? OR to_knowledge_id IN ?)", tenantID, scenarioID, ids, ids).Delete(&model.KnowledgeRelation{}).Error; err != nil {
|
||||
return fmt.Errorf("delete old pet knowledge relations: %w", err)
|
||||
}
|
||||
if err := tx.Where("tenant_id = ? AND scenario_id = ? AND id IN ?", tenantID, scenarioID, ids).Delete(&model.KnowledgeItem{}).Error; err != nil {
|
||||
return fmt.Errorf("delete old pet knowledge items: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type petScriptDisease struct {
|
||||
Plans []struct {
|
||||
Plan string `json:"plan"`
|
||||
Products []struct {
|
||||
SKUCode string `json:"sku_code"`
|
||||
ProductName string `json:"product_name"`
|
||||
} `json:"products"`
|
||||
} `json:"plans"`
|
||||
Combinations []struct {
|
||||
Name string `json:"name"`
|
||||
Weight int `json:"weight"`
|
||||
Products []struct {
|
||||
SKUCode string `json:"sku_code"`
|
||||
ProductName string `json:"product_name"`
|
||||
} `json:"products"`
|
||||
Script string `json:"script"`
|
||||
} `json:"combinations"`
|
||||
}
|
||||
|
||||
func importPetScripts(tx *gorm.DB, tenantID, scenarioID uint64, filename string) error {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read pet scripts %s: %w", filename, err)
|
||||
}
|
||||
var source map[string]map[string]petScriptDisease
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
return fmt.Errorf("parse pet scripts %s: %w", filename, err)
|
||||
}
|
||||
for symptom, diseases := range source {
|
||||
for disease, definition := range diseases {
|
||||
diseaseKey := petKnowledgeKey("disease", disease)
|
||||
if err := upsertPetScriptItems(tx, tenantID, scenarioID, symptom, disease, diseaseKey, definition); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertPetScriptItems(tx *gorm.DB, tenantID, scenarioID uint64, symptom, disease, diseaseKey string, definition petScriptDisease) error {
|
||||
symptomKey := petKnowledgeKey("symptom", symptom)
|
||||
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, symptomKey, symptom, "symptom", []byte(`{"source":"`+petScriptSource+`"}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, diseaseKey, disease, "disease", []byte(`{"source":"`+petScriptSource+`"}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertPetRelation(tx, scenarioID, symptomKey, "possible_disease", diseaseKey, 3000); err != nil {
|
||||
return err
|
||||
}
|
||||
for index, combination := range definition.Combinations {
|
||||
copyName := fmt.Sprintf("%s-%s话术", disease, combination.Name)
|
||||
copyKey := petKnowledgeKey("copy", "json_"+symptom+"_"+disease+"_"+combination.Name)
|
||||
content, _ := json.Marshal(map[string]interface{}{"source": petScriptSource, "template": strings.ReplaceAll(combination.Script, "{{purchased_products}}", "{{input.product_names}}"), "combination": combination.Name, "weight": combination.Weight})
|
||||
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, copyKey, copyName, "copy", content); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_copy", copyKey, 3000+index); err != nil {
|
||||
return err
|
||||
}
|
||||
for productIndex, product := range combination.Products {
|
||||
if strings.TrimSpace(product.SKUCode) == "" {
|
||||
continue
|
||||
}
|
||||
productKey := petKnowledgeKey("product", product.SKUCode)
|
||||
content, _ := json.Marshal(map[string]interface{}{"source": petScriptSource, "sku_code": product.SKUCode, "product_name": product.ProductName})
|
||||
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, productKey, product.ProductName, "product", content); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_product", productKey, 3400+index*100+productIndex); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for index, plan := range definition.Plans {
|
||||
if strings.TrimSpace(plan.Plan) == "" {
|
||||
continue
|
||||
}
|
||||
planKey := petKnowledgeKey("plan", plan.Plan)
|
||||
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, planKey, plan.Plan, "plan", []byte(`{"source":"`+petScriptSource+`"}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_plan", planKey, 3200+index); err != nil {
|
||||
return err
|
||||
}
|
||||
for productIndex, product := range plan.Products {
|
||||
if strings.TrimSpace(product.SKUCode) == "" {
|
||||
continue
|
||||
}
|
||||
productKey := petKnowledgeKey("product", product.SKUCode)
|
||||
content, _ := json.Marshal(map[string]interface{}{"source": petScriptSource, "sku_code": product.SKUCode, "product_name": product.ProductName})
|
||||
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, productKey, product.ProductName, "product", content); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_product", productKey, 3600+index*100+productIndex); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertPetKnowledgeItem(tx *gorm.DB, tenantID, scenarioID uint64, key, name, typeName string, content []byte) error {
|
||||
row := model.KnowledgeItem{TenantID: tenantID, ScenarioID: scenarioID, ItemKey: key, Name: name, Type: typeName, Content: datatypes.JSON(content), Status: "active", SortOrder: 3000}
|
||||
return tx.Where("tenant_id = ? AND scenario_id = ? AND item_key = ?", tenantID, scenarioID, key).Assign(row).FirstOrCreate(&row).Error
|
||||
}
|
||||
|
||||
func upsertPetRelation(tx *gorm.DB, scenarioID uint64, fromKey, relation, toKey string, order int) error {
|
||||
var from, to model.KnowledgeItem
|
||||
if err := tx.Where("scenario_id = ? AND item_key = ?", scenarioID, fromKey).First(&from).Error; err != nil {
|
||||
return fmt.Errorf("find script relation source %s: %w", fromKey, err)
|
||||
}
|
||||
if err := tx.Where("scenario_id = ? AND item_key = ?", scenarioID, toKey).First(&to).Error; err != nil {
|
||||
return fmt.Errorf("find script relation target %s: %w", toKey, err)
|
||||
}
|
||||
row := model.KnowledgeRelation{TenantID: from.TenantID, ScenarioID: scenarioID, FromKnowledgeID: from.ID, RelationType: relation, ToKnowledgeID: to.ID, Condition: datatypes.JSON([]byte(`{}`)), SortOrder: order}
|
||||
return tx.Where("scenario_id = ? AND from_knowledge_id = ? AND relation_type = ? AND to_knowledge_id = ?", scenarioID, from.ID, relation, to.ID).Assign(row).FirstOrCreate(&row).Error
|
||||
}
|
||||
|
||||
type petKnowledgeRow struct {
|
||||
Symptom string
|
||||
Disease string
|
||||
Plan string
|
||||
SKUCode string
|
||||
ProductName string
|
||||
}
|
||||
|
||||
func importPetKnowledge(tx *gorm.DB, tenantID, scenarioID uint64, filename string) error {
|
||||
rows, err := readPetKnowledge(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return upsertPetKnowledge(tx, tenantID, scenarioID, rows)
|
||||
}
|
||||
|
||||
func readPetKnowledge(filename string) ([]petKnowledgeRow, error) {
|
||||
book, err := excelize.OpenFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open pet knowledge %s: %w", filename, err)
|
||||
}
|
||||
defer book.Close()
|
||||
sheets := book.GetSheetList()
|
||||
if len(sheets) == 0 {
|
||||
return nil, fmt.Errorf("pet knowledge %s has no worksheet", filename)
|
||||
}
|
||||
iterator, err := book.Rows(sheets[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pet knowledge worksheet: %w", err)
|
||||
}
|
||||
defer iterator.Close()
|
||||
|
||||
columns := map[string]int{}
|
||||
result := make([]petKnowledgeRow, 0)
|
||||
rowNumber := 0
|
||||
for iterator.Next() {
|
||||
rowNumber++
|
||||
values, err := iterator.Columns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pet knowledge row %d: %w", rowNumber, err)
|
||||
}
|
||||
if rowNumber == 1 {
|
||||
for index, value := range values {
|
||||
columns[strings.TrimSpace(value)] = index
|
||||
}
|
||||
for _, required := range []string{"症状", "疾病", "方案", "SKU_CODE", "商品标题"} {
|
||||
if _, ok := columns[required]; !ok {
|
||||
return nil, fmt.Errorf("pet knowledge is missing column %q", required)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
row := petKnowledgeRow{
|
||||
Symptom: excelValue(values, columns["症状"]),
|
||||
Disease: excelValue(values, columns["疾病"]),
|
||||
Plan: excelValue(values, columns["方案"]),
|
||||
SKUCode: excelValue(values, columns["SKU_CODE"]),
|
||||
ProductName: excelValue(values, columns["商品标题"]),
|
||||
}
|
||||
if row.Symptom == "" && row.Disease == "" && row.Plan == "" {
|
||||
continue
|
||||
}
|
||||
if row.Symptom == "" || row.Disease == "" {
|
||||
return nil, fmt.Errorf("pet knowledge row %d must contain 场景 and 分型", rowNumber)
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
if err := iterator.Error(); err != nil {
|
||||
return nil, fmt.Errorf("iterate pet knowledge: %w", err)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("pet knowledge contains no data")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func excelValue(values []string, index int) string {
|
||||
if index >= len(values) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(values[index])
|
||||
}
|
||||
|
||||
func upsertPetKnowledge(tx *gorm.DB, tenantID, scenarioID uint64, rows []petKnowledgeRow) error {
|
||||
type itemDefinition struct {
|
||||
name, typeName string
|
||||
content map[string]interface{}
|
||||
}
|
||||
items := map[string]itemDefinition{}
|
||||
type relationDefinition struct{ from, relation, to string }
|
||||
relations := map[relationDefinition]bool{}
|
||||
for _, row := range rows {
|
||||
symptomKey := petKnowledgeKey("symptom", row.Symptom)
|
||||
diseaseKey := petKnowledgeKey("disease", row.Disease)
|
||||
items[symptomKey] = itemDefinition{name: row.Symptom, typeName: "symptom"}
|
||||
items[diseaseKey] = itemDefinition{name: row.Disease, typeName: "disease"}
|
||||
relations[relationDefinition{symptomKey, "possible_disease", diseaseKey}] = true
|
||||
for index, template := range []string{
|
||||
"结合客户描述,目前更符合“%s”这一分型。建议先向客户说明判断依据,再介绍对应方案。",
|
||||
"针对“%s”,这边建议按下面的方案进行护理和商品搭配;如症状持续或加重,应及时就医。",
|
||||
} {
|
||||
copyName := fmt.Sprintf("%s推荐话术%d", row.Disease, index+1)
|
||||
copyKey := petKnowledgeKey("copy", copyName)
|
||||
items[copyKey] = itemDefinition{name: copyName, typeName: "copy", content: map[string]interface{}{"template": fmt.Sprintf(template, row.Disease)}}
|
||||
relations[relationDefinition{diseaseKey, "recommended_copy", copyKey}] = true
|
||||
}
|
||||
if row.Plan != "" {
|
||||
planKey := petKnowledgeKey("plan", row.Plan)
|
||||
items[planKey] = itemDefinition{name: row.Plan, typeName: "plan"}
|
||||
relations[relationDefinition{diseaseKey, "recommended_plan", planKey}] = true
|
||||
// The current SOP view expands one relation level from a symptom.
|
||||
relations[relationDefinition{symptomKey, "recommended_plan", planKey}] = true
|
||||
}
|
||||
if row.SKUCode != "" {
|
||||
productName := row.ProductName
|
||||
if productName == "" {
|
||||
productName = row.SKUCode
|
||||
}
|
||||
productKey := petKnowledgeKey("product", row.SKUCode)
|
||||
items[productKey] = itemDefinition{name: productName, typeName: "product", content: map[string]interface{}{"sku_code": row.SKUCode, "product_name": productName}}
|
||||
relations[relationDefinition{diseaseKey, "recommended_product", productKey}] = true
|
||||
}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(items))
|
||||
for key := range items {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
ids := make(map[string]uint64, len(keys))
|
||||
for order, key := range keys {
|
||||
definition := items[key]
|
||||
payload := map[string]interface{}{"source": petKnowledgeSource}
|
||||
for name, value := range definition.content {
|
||||
payload[name] = value
|
||||
}
|
||||
content, _ := json.Marshal(payload)
|
||||
row := model.KnowledgeItem{TenantID: tenantID, ScenarioID: scenarioID, ItemKey: key, Name: definition.name, Type: definition.typeName, Content: datatypes.JSON(content), Status: "active", SortOrder: 1000 + order}
|
||||
if err := tx.Where("tenant_id = ? AND scenario_id = ? AND item_key = ?", tenantID, scenarioID, key).Assign(row).FirstOrCreate(&row).Error; err != nil {
|
||||
return fmt.Errorf("upsert imported knowledge item %s: %w", key, err)
|
||||
}
|
||||
ids[key] = row.ID
|
||||
}
|
||||
|
||||
relationList := make([]relationDefinition, 0, len(relations))
|
||||
for relation := range relations {
|
||||
relationList = append(relationList, relation)
|
||||
}
|
||||
sort.Slice(relationList, func(i, j int) bool {
|
||||
left, right := relationList[i], relationList[j]
|
||||
return left.from+left.relation+left.to < right.from+right.relation+right.to
|
||||
})
|
||||
for order, definition := range relationList {
|
||||
row := model.KnowledgeRelation{TenantID: tenantID, ScenarioID: scenarioID, FromKnowledgeID: ids[definition.from], RelationType: definition.relation, ToKnowledgeID: ids[definition.to], Condition: datatypes.JSON([]byte(`{}`)), SortOrder: 1000 + order}
|
||||
if err := tx.Where("scenario_id = ? AND from_knowledge_id = ? AND relation_type = ? AND to_knowledge_id = ?", scenarioID, row.FromKnowledgeID, row.RelationType, row.ToKnowledgeID).Assign(row).FirstOrCreate(&row).Error; err != nil {
|
||||
return fmt.Errorf("upsert imported knowledge relation: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func petKnowledgeKey(typeName, name string) string {
|
||||
digest := sha256.Sum256([]byte(typeName + "\x00" + strings.TrimSpace(name)))
|
||||
return "xlsx_" + typeName + "_" + hex.EncodeToString(digest[:6])
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/database"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/logger"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
@@ -45,6 +47,12 @@ func main() {
|
||||
configDir := flag.String("config-dir", "configs", "configuration directory")
|
||||
environment := flag.String("env", "", "runtime environment")
|
||||
scriptFile := flag.String("script-file", filepath.Join("..", "docs", "症状-疾病-话术.json"), "pet script knowledge JSON file")
|
||||
knowledgeFile := flag.String("knowledge-file", filepath.Join("..", "docs", "症状-疾病-方案.xlsx"), "pet SKU knowledge Excel file")
|
||||
questionFile := flag.String("question-file", filepath.Join("..", "docs", "症状确认话术.json"), "pet colloquial question bank JSON file")
|
||||
smallCategoryFile := flag.String("small-category-file", filepath.Join("..", "docs", "症状小类-大类.json"), "pet symptom small category mapping JSON file")
|
||||
smallAskScriptFile := flag.String("small-ask-script-file", filepath.Join("..", "docs", "症状小类询问话术.json"), "pet symptom small ask script JSON file")
|
||||
preGuideFile := flag.String("pre-guide-file", filepath.Join("..", "docs", "症状确认前引导话术.json"), "pet pre-symptom guide script JSON file")
|
||||
classificationFile := flag.String("classification-file", filepath.Join("..", "docs", "症状分类.json"), "pet symptom classification JSON file")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load(config.LoadOptions{Environment: *environment, ConfigDir: *configDir})
|
||||
@@ -63,13 +71,16 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal("connect database", zap.Error(err))
|
||||
}
|
||||
if err := seed(db, *scriptFile); err != nil {
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
log.Fatal("synchronize database schema", zap.Error(err))
|
||||
}
|
||||
if err := seed(db, *scriptFile, *knowledgeFile, *questionFile, *smallCategoryFile, *smallAskScriptFile, *preGuideFile, *classificationFile); err != nil {
|
||||
log.Fatal("seed pet doctor scenario", zap.Error(err))
|
||||
}
|
||||
log.Info("pet doctor scenario is ready")
|
||||
}
|
||||
|
||||
func seed(db *gorm.DB, scriptFile string) error {
|
||||
func seed(db *gorm.DB, scriptFile, knowledgeFile, questionFile, smallCategoryFile, smallAskScriptFile, preGuideFile, classificationFile string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
tenant, user, err := seedOwner(tx)
|
||||
if err != nil {
|
||||
@@ -88,11 +99,12 @@ func seed(db *gorm.DB, scriptFile string) error {
|
||||
if err := seedRulesAndOutput(tx, tenant.ID, scenario.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resetPetKnowledge(tx, tenant.ID, scenario.ID); err != nil {
|
||||
packageInput, err := buildScriptPackage(scriptFile, knowledgeFile, smallCategoryFile, smallAskScriptFile, preGuideFile, classificationFile, questionFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := importPetScripts(tx, tenant.ID, scenario.ID, scriptFile); err != nil {
|
||||
return err
|
||||
if err := scriptkit.SavePackage(tx, tenant.ID, user.ID, scenario.ID, packageInput); err != nil {
|
||||
return fmt.Errorf("save pet script package: %w", err)
|
||||
}
|
||||
if err := seedSOP(tx, tenant.ID, user.ID, scenario.ID); err != nil {
|
||||
return err
|
||||
@@ -115,18 +127,34 @@ func seedOwner(tx *gorm.DB) (model.Tenant, model.User, error) {
|
||||
|
||||
func seedScenario(tx *gorm.DB, tenantID, userID uint64) (model.Scenario, error) {
|
||||
var scenario model.Scenario
|
||||
err := tx.Where("tenant_id = ? AND name = ?", tenantID, "宠物医生问诊问药").FirstOrCreate(&scenario, model.Scenario{
|
||||
TenantID: tenantID, Name: "宠物医生问诊问药", CreatedBy: userID,
|
||||
}).Error
|
||||
// FirstOrCreate 会把结构体非零字段并入查询条件;公开标识放进 Attrs,
|
||||
// 避免随机 public_key 让每次匹配失败、重复创建同名场景。
|
||||
err := tx.Where("tenant_id = ? AND name = ?", tenantID, "宠物医生问诊问药").
|
||||
Attrs(model.Scenario{
|
||||
ScenarioKey: "scenario-1",
|
||||
PublicKey: "pk_" + uuid.NewString(),
|
||||
AllowedOrigins: datatypes.JSON([]byte(`[]`)),
|
||||
InputSchema: datatypes.JSON([]byte(`{"fields":[]}`)),
|
||||
OutputSchema: datatypes.JSON([]byte(`{"fields":[]}`)),
|
||||
ResultSchema: datatypes.JSON([]byte(`{"fields":[]}`)),
|
||||
}).
|
||||
FirstOrCreate(&scenario, model.Scenario{TenantID: tenantID, Name: "宠物医生问诊问药", CreatedBy: userID}).Error
|
||||
if err != nil {
|
||||
return scenario, fmt.Errorf("find or create scenario: %w", err)
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"industry": "宠物医疗", "role_name": "宠物医生、助理、客服",
|
||||
"goal": "标准化收集宠物基本信息、主诉和病史,优先识别急症,在医生评估前提供安全且一致的沟通指引。",
|
||||
"trigger_text": "宠物主人通过门店、电话或在线渠道咨询症状、检查、用药或是否需要就医时进入本场景。",
|
||||
"goal": "通过话术包和维度权重驱动问诊:确认宠物基本信息与主要症状,联动拟诊疾病方向,推荐方案、商品与注意事项,并全程记录话术反馈用于复盘。",
|
||||
"trigger_text": "宠物主人通过门店、电话或在线渠道咨询症状、用药或护理商品时进入本场景。",
|
||||
"visibility": "tenant", "status": "active",
|
||||
}
|
||||
// 保持公开标识稳定:已有接入方依赖 scenario-1;缺失时才生成,不覆盖已有值。
|
||||
if scenario.ScenarioKey == "" {
|
||||
updates["scenario_key"] = "scenario-1"
|
||||
}
|
||||
if scenario.PublicKey == "" {
|
||||
updates["public_key"] = "pk_" + uuid.NewString()
|
||||
}
|
||||
if err := tx.Model(&scenario).Updates(updates).Error; err != nil {
|
||||
return scenario, fmt.Errorf("update scenario: %w", err)
|
||||
}
|
||||
@@ -186,85 +214,18 @@ func petFieldDefinitions() []fieldDefinition {
|
||||
{Key: "pet_age", Name: "宠物年龄", Type: "text", Validation: map[string]interface{}{"max_length": 30}},
|
||||
{Key: "pet_weight", Name: "宠物体重(kg)", Type: "number", Validation: map[string]interface{}{"min": 0.1, "max": 200}},
|
||||
{Key: "pet_sex", Name: "宠物性别", Type: "select", Options: []string{"公", "母", "未知"}},
|
||||
{Key: "confirmed_symptoms", Name: "客户确认症状", Type: "array", Required: true},
|
||||
{Key: "confirmed_diseases", Name: "确认疾病分型", Type: "array", Required: true},
|
||||
}
|
||||
}
|
||||
|
||||
func seedKnowledgeGraph(tx *gorm.DB, tenantID, scenarioID uint64) error {
|
||||
definitions := map[string]struct {
|
||||
name string
|
||||
typeName string
|
||||
content map[string]interface{}
|
||||
}{
|
||||
"consultation_boundary": {name: "线上问诊边界", typeName: "guidance", content: map[string]interface{}{
|
||||
"standard_copy": "线上沟通用于收集信息和判断紧急程度,不能替代体格检查、检验和影像检查。医生需要结合完整病史及检查结果后才能给出诊断和治疗方案。",
|
||||
"forbidden_copy": "不要仅凭文字、照片或单个症状作出确定诊断,也不要承诺某种处理一定有效。",
|
||||
"risk_note": "信息不完整、症状持续加重或无法准确观察时,应建议尽快到院评估。",
|
||||
}},
|
||||
"emergency_guidance": {name: "急症红旗征象", typeName: "guidance", content: map[string]interface{}{
|
||||
"standard_copy": "出现呼吸困难、持续出血、抽搐或意识异常、严重外伤、无法排尿、持续呕吐或干呕、剧烈疼痛、腹部明显膨大、疑似中暑、无法站立、误食毒物或异物等情况时,应立即建议就近急诊或转诊,并提前联系接诊机构。",
|
||||
"forbidden_copy": "不要承诺在家观察一定安全;不要在线给出能够替代急诊检查的判断。",
|
||||
"risk_note": "急症分支优先级最高,不得因继续询问常规病史而延误就医。",
|
||||
}},
|
||||
"medication_safety": {name: "问诊用药安全原则", typeName: "guidance", content: map[string]interface{}{
|
||||
"standard_copy": "用药需要结合物种、年龄、体重、既往病史、正在使用的药物和必要检查,由宠物医生评估后确定。请勿自行增加剂量、混用药物或使用人用药。",
|
||||
"forbidden_copy": "未完成医生评估前,不给出具体处方药名称、剂量和疗程承诺。",
|
||||
"risk_note": "对乙酰氨基酚、布洛芬等常见人用药可能对宠物造成严重伤害;如已误服,应按急症处理。",
|
||||
}},
|
||||
"safety_net": {name: "观察与复诊提示", typeName: "guidance", content: map[string]interface{}{
|
||||
"standard_copy": "请按医生要求记录精神、食欲、饮水、排尿、排便、呕吐次数及症状变化。若症状加重、出现新的急症表现,或在医生建议的观察时间内未改善,应尽快复诊。",
|
||||
"forbidden_copy": "不要用固定天数替代医生根据病情给出的复诊时间,也不要因一次短暂好转自行停药。",
|
||||
"risk_note": "离开本次沟通前,应让宠物主人复述下一步安排和需要立即就医的触发条件。",
|
||||
}},
|
||||
"soft_stool": {name: "软便", typeName: "symptom", content: map[string]interface{}{"label": "soft_stool"}},
|
||||
"poor_appetite": {name: "食欲下降", typeName: "symptom", content: map[string]interface{}{"label": "poor_appetite"}},
|
||||
"ask_stool": {name: "软便追问话术", typeName: "copy", content: map[string]interface{}{"template": "{{input.customer_name}}您好,想了解一下{{input.pet_name}}近期排便的频率和形态,是否伴随呕吐、精神变差或便中带血?"}},
|
||||
"recommend_gut": {name: "肠胃护理推荐话术", typeName: "copy", content: map[string]interface{}{"template": "结合{{input.pet_name}}目前的表现,可以先向您介绍肠胃护理方向的产品和日常喂养注意事项,具体用药仍需宠物医生评估。"}},
|
||||
"gi_discomfort": {name: "肠胃不适风险", typeName: "disease", content: map[string]interface{}{"risk_level": "normal", "label": "gi_discomfort"}},
|
||||
"parasite_risk": {name: "寄生虫风险", typeName: "disease", content: map[string]interface{}{"risk_level": "medium", "label": "parasite_risk"}},
|
||||
}
|
||||
index := 0
|
||||
for key, definition := range definitions {
|
||||
content, _ := json.Marshal(definition.content)
|
||||
row := model.KnowledgeItem{TenantID: tenantID, ScenarioID: scenarioID, ItemKey: key, Name: definition.name, Type: definition.typeName, Content: datatypes.JSON(content), Status: "active", SortOrder: index}
|
||||
if err := tx.Where("tenant_id = ? AND scenario_id = ? AND item_key = ?", tenantID, scenarioID, key).Assign(row).FirstOrCreate(&row).Error; err != nil {
|
||||
return fmt.Errorf("seed knowledge item %s: %w", key, err)
|
||||
}
|
||||
index++
|
||||
}
|
||||
relations := []struct{ from, relation, to string }{
|
||||
{"soft_stool", "recommended_copy", "ask_stool"}, {"soft_stool", "recommended_copy", "recommend_gut"},
|
||||
{"soft_stool", "possible_disease", "gi_discomfort"}, {"soft_stool", "possible_disease", "parasite_risk"},
|
||||
{"poor_appetite", "recommended_copy", "recommend_gut"}, {"poor_appetite", "possible_disease", "gi_discomfort"},
|
||||
}
|
||||
ids := map[string]uint64{}
|
||||
var items []model.KnowledgeItem
|
||||
if err := tx.Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Find(&items).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
ids[item.ItemKey] = item.ID
|
||||
}
|
||||
for order, relation := range relations {
|
||||
fromID, fromOK := ids[relation.from]
|
||||
toID, toOK := ids[relation.to]
|
||||
if !fromOK || !toOK {
|
||||
return fmt.Errorf("knowledge relation references missing item: %s -> %s", relation.from, relation.to)
|
||||
}
|
||||
row := model.KnowledgeRelation{TenantID: tenantID, ScenarioID: scenarioID, FromKnowledgeID: fromID, RelationType: relation.relation, ToKnowledgeID: toID, Condition: datatypes.JSON([]byte(`{}`)), SortOrder: order}
|
||||
if err := tx.Where("scenario_id = ? AND from_knowledge_id = ? AND relation_type = ? AND to_knowledge_id = ?", scenarioID, fromID, relation.relation, toID).Assign(row).FirstOrCreate(&row).Error; err != nil {
|
||||
return fmt.Errorf("seed knowledge relation: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedRulesAndOutput(tx *gorm.DB, tenantID, scenarioID uint64) error {
|
||||
rule := model.ScenarioRule{TenantID: tenantID, ScenarioID: scenarioID, RuleKey: "derive-matched-symptoms", Name: "从商品标签提取症状", Condition: datatypes.JSON([]byte(`{"field":"input.input_symptom_tags","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"set","field":"matched_symptoms","value_from":"input.input_symptom_tags"}]`)), Priority: 100, Status: "active"}
|
||||
if err := tx.Where("scenario_id = ? AND rule_key = ?", scenarioID, rule.RuleKey).Assign(rule).FirstOrCreate(&rule).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
skuRule := model.ScenarioRule{TenantID: tenantID, ScenarioID: scenarioID, RuleKey: "derive-matched-skus", Name: "按 SKU_CODE 匹配话术包", Condition: datatypes.JSON([]byte(`{"field":"input.product_ids","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"set","field":"matched_skus","value_from":"input.product_ids"}]`)), Priority: 110, Status: "active"}
|
||||
if err := tx.Where("scenario_id = ? AND rule_key = ?", scenarioID, skuRule.RuleKey).Assign(skuRule).FirstOrCreate(&skuRule).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]interface{}{"fields": []map[string]interface{}{
|
||||
{"key": "matched_symptoms", "name": "命中症状", "type": "array", "source": "derived", "source_field": "matched_symptoms"},
|
||||
{"key": "product_names", "name": "订单商品", "type": "array", "source": "input", "source_field": "product_names"},
|
||||
@@ -291,23 +252,12 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64) error {
|
||||
return fmt.Errorf("find or create SOP: %w", err)
|
||||
}
|
||||
if err := tx.Model(&sop).Updates(map[string]interface{}{
|
||||
"description": "根据订单商品携带的症状标签展示关联症状、话术和可能疾病,辅助销售完成商品推荐并记录结果。",
|
||||
"description": "以话术包和维度权重驱动:开场补充宠物档案,症状确认逐题问答与勾选,拟诊确认联动疾病方向,最后按疾病权重推荐方案、商品与注意事项。",
|
||||
"status": "published",
|
||||
"start_node_key": "start",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var alreadySeeded int64
|
||||
if err := tx.Model(&model.SOPNode{}).Where(
|
||||
"sop_id = ? AND node_key = ? AND JSON_UNQUOTE(JSON_EXTRACT(config, '$.seed_key')) = ?",
|
||||
sop.ID, "start", "pet-doctor-v16-start-presentation-optional-pet",
|
||||
).Count(&alreadySeeded).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadySeeded > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
nodes := petNodes()
|
||||
edges := petEdges()
|
||||
if err := tx.Where("sop_id = ? AND tenant_id = ?", sop.ID, tenantID).Delete(&model.SOPEdge{}).Error; err != nil {
|
||||
@@ -336,7 +286,7 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64) error {
|
||||
return fmt.Errorf("create edge %s -> %s: %w", definition.Source, definition.Target, err)
|
||||
}
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]interface{}{"scenario": "宠物医生问诊问药"})
|
||||
payload, _ := json.Marshal(map[string]interface{}{"scenario": "宠物医生问诊问药", "engine": "scriptkit"})
|
||||
return tx.Create(&model.AuditLog{
|
||||
TenantID: tenantID, UserID: userID, Action: "seed", Resource: "sop", ResourceID: sop.ID, Payload: datatypes.JSON(payload),
|
||||
}).Error
|
||||
@@ -345,27 +295,27 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64) error {
|
||||
func petNodes() []nodeDefinition {
|
||||
return []nodeDefinition{
|
||||
{Key: "start", Type: "start", Title: "开始", Content: "确认订单和客户信息,使用开场话术开始沟通。", Config: map[string]interface{}{
|
||||
"seed_key": "pet-doctor-v16-start-presentation-optional-pet",
|
||||
"seed_key": "pet-doctor-scriptkit",
|
||||
"presentation": map[string]interface{}{
|
||||
"summary_field_keys": []string{"order_id", "customer_name", "pet_name", "pet_type"},
|
||||
"item_field_keys": []string{"product_images", "product_names", "product_ids"},
|
||||
"image_field_keys": []string{"product_images"},
|
||||
"opening_title": "开场话术",
|
||||
"opening_template": "您好,{{input.customer_name}}。我是宠物健康顾问,看到您购买了{{input.product_names}},想回访了解一下使用情况,也了解一下宠物目前的身体状况。",
|
||||
},
|
||||
}},
|
||||
{Key: "pet_info", Type: "form", Title: "收集宠物信息", Content: "核对系统已带入的宠物信息,并根据客户回答补充名称、种类、年龄、体重和性别。", Config: map[string]interface{}{"field_keys": []string{"pet_name", "pet_type", "pet_age", "pet_weight", "pet_sex"}, "required_field_keys": []string{"pet_name", "pet_type"}}},
|
||||
{Key: "symptom", Type: "knowledge", Title: "确认宠物症状与疾病", Content: "商品标签是系统推断的候选症状;请结合客户回答确认,并可从场景知识中补充其他伴随症状。", Config: map[string]interface{}{"knowledge_selector": map[string]interface{}{"derived_field": "matched_symptoms", "candidate_scope": "all", "knowledge_types": []string{"symptom"}, "relation_types": []string{"recommended_copy", "possible_disease"}, "relation_labels": map[string]string{"recommended_copy": "沟通话术", "possible_disease": "可能疾病"}}, "knowledge_collection": map[string]interface{}{"selection_title": "确认实际症状和疾病", "selection_hint": "系统推断项优先展示,也可搜索场景知识补充伴随症状", "steps": []map[string]interface{}{{"field_key": "confirmed_symptoms", "name": "客户确认症状", "root": true, "candidate_scope": "all", "knowledge_types": []string{"symptom"}, "required": true, "multiple": true}, {"field_key": "confirmed_diseases", "name": "确认疾病分型", "from_field": "confirmed_symptoms", "relation_type": "possible_disease", "required": true, "multiple": true}}}}},
|
||||
{Key: "recommend_product", Type: "knowledge", Title: "推荐方案与商品", Content: "根据已确认的疾病分型,向客户讲解推荐话术、建议方案和可使用的商品。", Config: map[string]interface{}{"knowledge_selector": map[string]interface{}{"answer_field": "confirmed_diseases", "knowledge_types": []string{"disease"}, "relation_types": []string{"recommended_copy", "recommended_plan", "recommended_product"}, "relation_labels": map[string]string{"recommended_copy": "推荐话术", "recommended_plan": "建议方案", "recommended_product": "推荐商品"}}}},
|
||||
{Key: "finish", Type: "finish", Title: "结束", Content: "本次话术执行完成,请保存最终推荐结果。", Config: map[string]interface{}{}},
|
||||
{Key: "opening", Type: "stage", Title: "开场", Content: "展示开场话术,并核对补充宠物名称、种类等基础档案。", Config: map[string]interface{}{"stage_key": "opening", "field_keys": []string{"pet_name", "pet_type", "pet_age", "pet_weight", "pet_sex"}, "required_field_keys": []string{"pet_name", "pet_type"}}},
|
||||
{Key: "symptom", Type: "stage", Title: "症状确认", Content: "按维度权重逐个确认症状:高权重直接问细节,模糊区间用确认性问题升维降维,全部为零时用兜底话术引导并支持手动勾选。", Config: map[string]interface{}{"stage_key": "symptom"}},
|
||||
{Key: "diagnosis", Type: "stage", Title: "拟诊确认", Content: "根据症状权重联动疾病方向,用确认性话术做升维降维。", Config: map[string]interface{}{"stage_key": "diagnosis"}},
|
||||
{Key: "recommend", Type: "stage", Title: "药品推荐与注意事项", Content: "按疾病维度权重匹配推荐方案、商品与注意事项话术。", Config: map[string]interface{}{"stage_key": "recommend"}},
|
||||
{Key: "finish", Type: "finish", Title: "结束", Content: "本次沟通已完成,提交最终推荐结果。", Config: map[string]interface{}{}},
|
||||
}
|
||||
}
|
||||
|
||||
func petEdges() []edgeDefinition {
|
||||
return []edgeDefinition{
|
||||
{Source: "start", Target: "pet_info", Condition: map[string]interface{}{}},
|
||||
{Source: "pet_info", Target: "symptom", Condition: map[string]interface{}{}},
|
||||
{Source: "symptom", Target: "recommend_product", Condition: map[string]interface{}{}},
|
||||
{Source: "recommend_product", Target: "finish", Condition: map[string]interface{}{}},
|
||||
{Source: "start", Target: "opening", Condition: map[string]interface{}{}, Priority: 10},
|
||||
{Source: "opening", Target: "symptom", Condition: map[string]interface{}{}, Priority: 20},
|
||||
{Source: "symptom", Target: "diagnosis", Condition: map[string]interface{}{}, Priority: 30},
|
||||
{Source: "diagnosis", Target: "recommend", Condition: map[string]interface{}{}, Priority: 40},
|
||||
{Source: "recommend", Target: "finish", Condition: map[string]interface{}{}, Priority: 50},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,46 +13,26 @@ func TestPetDoctorSOPPassesPublishValidation(t *testing.T) {
|
||||
fields := petFieldModels()
|
||||
nodes := petNodeModels(petNodes())
|
||||
edges := petEdgeModels(petEdges())
|
||||
if len(fields) != 13 || len(nodes) != 5 || len(edges) != 4 {
|
||||
if len(fields) != 11 || len(nodes) != 6 || len(edges) != 5 {
|
||||
t.Fatalf("unexpected definition size: fields=%d nodes=%d edges=%d", len(fields), len(nodes), len(edges))
|
||||
}
|
||||
|
||||
problems := sop.ValidateForPublish("start", nodes, edges, sop.ValidationContext{
|
||||
Fields: fields,
|
||||
KnowledgeItems: []model.KnowledgeItem{
|
||||
{Base: model.Base{ID: 1}, ItemKey: "consultation_boundary", Type: "guidance", Status: "active"},
|
||||
{Base: model.Base{ID: 2}, ItemKey: "emergency_guidance", Type: "guidance", Status: "active"},
|
||||
{Base: model.Base{ID: 3}, ItemKey: "medication_safety", Type: "guidance", Status: "active"},
|
||||
{Base: model.Base{ID: 4}, ItemKey: "safety_net", Type: "guidance", Status: "active"},
|
||||
{Base: model.Base{ID: 5}, ItemKey: "soft_stool", Type: "symptom", Status: "active"},
|
||||
{Base: model.Base{ID: 6}, ItemKey: "poor_appetite", Type: "symptom", Status: "active"},
|
||||
{Base: model.Base{ID: 7}, ItemKey: "ask_stool", Type: "copy", Status: "active"},
|
||||
{Base: model.Base{ID: 8}, ItemKey: "gi_discomfort", Type: "disease", Status: "active"},
|
||||
{Base: model.Base{ID: 9}, ItemKey: "gut_plan", Type: "plan", Status: "active"},
|
||||
{Base: model.Base{ID: 10}, ItemKey: "gut_product", Type: "product", Status: "active"},
|
||||
},
|
||||
KnowledgeRelations: []model.KnowledgeRelation{
|
||||
{FromKnowledgeID: 5, ToKnowledgeID: 7, RelationType: "recommended_copy"},
|
||||
{FromKnowledgeID: 5, ToKnowledgeID: 8, RelationType: "possible_disease"},
|
||||
{FromKnowledgeID: 5, ToKnowledgeID: 9, RelationType: "recommended_plan"},
|
||||
{FromKnowledgeID: 8, ToKnowledgeID: 7, RelationType: "recommended_copy"},
|
||||
{FromKnowledgeID: 8, ToKnowledgeID: 9, RelationType: "recommended_plan"},
|
||||
{FromKnowledgeID: 8, ToKnowledgeID: 10, RelationType: "recommended_product"},
|
||||
},
|
||||
StageKeys: []string{"opening", "symptom", "diagnosis", "recommend"},
|
||||
})
|
||||
if len(problems) > 0 {
|
||||
t.Fatalf("pet doctor SOP should be publishable, got: %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetDoctorSOPIsFiveStepLinearFlow(t *testing.T) {
|
||||
wantNodes := []string{"start", "pet_info", "symptom", "recommend_product", "finish"}
|
||||
func TestPetDoctorSOPIsSixStepLinearFlow(t *testing.T) {
|
||||
wantNodes := []string{"start", "opening", "symptom", "diagnosis", "recommend", "finish"}
|
||||
for index, node := range petNodes() {
|
||||
if node.Key != wantNodes[index] {
|
||||
t.Fatalf("node %d = %s, want %s", index, node.Key, wantNodes[index])
|
||||
}
|
||||
}
|
||||
wantEdges := [][2]string{{"start", "pet_info"}, {"pet_info", "symptom"}, {"symptom", "recommend_product"}, {"recommend_product", "finish"}}
|
||||
wantEdges := [][2]string{{"start", "opening"}, {"opening", "symptom"}, {"symptom", "diagnosis"}, {"diagnosis", "recommend"}, {"recommend", "finish"}}
|
||||
for index, edge := range petEdges() {
|
||||
if edge.Source != wantEdges[index][0] || edge.Target != wantEdges[index][1] {
|
||||
t.Fatalf("edge %d = %s -> %s", index, edge.Source, edge.Target)
|
||||
@@ -60,15 +40,6 @@ func TestPetDoctorSOPIsFiveStepLinearFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetKnowledgeKeyIsStableAndTypeScoped(t *testing.T) {
|
||||
if petKnowledgeKey("symptom", "腹泻") != petKnowledgeKey("symptom", " 腹泻 ") {
|
||||
t.Fatal("key should ignore surrounding whitespace")
|
||||
}
|
||||
if petKnowledgeKey("symptom", "腹泻") == petKnowledgeKey("disease", "腹泻") {
|
||||
t.Fatal("key should include knowledge type")
|
||||
}
|
||||
}
|
||||
|
||||
func petFieldModels() []model.ScenarioField {
|
||||
definitions := petFieldDefinitions()
|
||||
fields := make([]model.ScenarioField, 0, len(definitions))
|
||||
|
||||
602
scripts/seed-pet-doctor/package_builder.go
Normal file
602
scripts/seed-pet-doctor/package_builder.go
Normal file
@@ -0,0 +1,602 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// Data sources for the script package.
|
||||
const petScriptSource = "症状-疾病-话术.json"
|
||||
const petQuestionSource = "症状确认话术.json"
|
||||
const petSmallCategorySource = "症状小类-大类.json"
|
||||
const petSmallAskScriptSource = "症状小类询问话术.json"
|
||||
const petPreGuideSource = "症状确认前引导话术.json"
|
||||
const petClassificationSource = "症状分类.json"
|
||||
const petKnowledgeSource = "症状-疾病-方案.xlsx"
|
||||
|
||||
type petScriptEntry struct {
|
||||
Title string `json:"title"`
|
||||
Template string `json:"template"`
|
||||
}
|
||||
|
||||
type petClassification struct {
|
||||
Symptoms []struct {
|
||||
Type string `json:"type"`
|
||||
Subs []string `json:"subs"`
|
||||
} `json:"symptoms"`
|
||||
Diagnosis []struct {
|
||||
Type string `json:"type"`
|
||||
Subs []struct {
|
||||
SubType string `json:"subType"`
|
||||
} `json:"subs"`
|
||||
} `json:"diagnosis"`
|
||||
}
|
||||
|
||||
type petCombination struct {
|
||||
Name string `json:"name"`
|
||||
Weight int `json:"weight"`
|
||||
Products []struct {
|
||||
SKUCode string `json:"sku_code"`
|
||||
ProductName string `json:"product_name"`
|
||||
} `json:"products"`
|
||||
Script string `json:"script"`
|
||||
}
|
||||
|
||||
type petScriptDisease struct {
|
||||
Combinations []petCombination `json:"combinations"`
|
||||
}
|
||||
|
||||
type petKnowledgeRow struct {
|
||||
Symptom string
|
||||
Disease string
|
||||
SKUCode string
|
||||
ProductName string
|
||||
}
|
||||
|
||||
// petQuestionBank is the customer-facing copy bank for the script package.
|
||||
// Every question lives in the JSON data file, not in Go code.
|
||||
type petQuestionBank struct {
|
||||
OpeningGreeting string `json:"opening_greeting"`
|
||||
DiagnosisFallback string `json:"diagnosis_fallback"`
|
||||
RecommendFallback string `json:"recommend_fallback"`
|
||||
SymptomScreeningTemplate string `json:"symptom_screening_template"`
|
||||
SymptomConfirmOptions struct {
|
||||
Yes string `json:"yes"`
|
||||
No string `json:"no"`
|
||||
} `json:"symptom_confirm_options"`
|
||||
DiseaseConfirmOptions struct {
|
||||
Yes string `json:"yes"`
|
||||
No string `json:"no"`
|
||||
} `json:"disease_confirm_options"`
|
||||
Symptoms map[string]petSymptomQuestions `json:"symptoms"`
|
||||
|
||||
DiseaseConfirmTemplate string `json:"disease_confirm_template"`
|
||||
DiseaseConfirmedCopyTemplate string `json:"disease_confirmed_copy_template"`
|
||||
DiseasePlanIntro string `json:"disease_plan_intro"`
|
||||
DiseaseCareNote string `json:"disease_care_note"`
|
||||
Diseases map[string]petDiseasePhrase `json:"diseases"`
|
||||
}
|
||||
|
||||
type petSymptomQuestions struct {
|
||||
Confirm string `json:"confirm"`
|
||||
Short string `json:"short"`
|
||||
Choices []petChoiceQuestion `json:"choices"`
|
||||
}
|
||||
|
||||
type petChoiceQuestion struct {
|
||||
Title string `json:"title"`
|
||||
Question string `json:"question"`
|
||||
Multiple bool `json:"multiple"`
|
||||
Options []petChoiceOption `json:"options"`
|
||||
}
|
||||
|
||||
type petChoiceOption struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type petDiseasePhrase struct {
|
||||
Phrase string `json:"phrase"`
|
||||
}
|
||||
|
||||
// scriptKeyGenerator produces stable ASCII keys for dimension values and
|
||||
// scripts. Chinese display names live in the Name field.
|
||||
type scriptKeyGenerator struct {
|
||||
counters map[string]int
|
||||
}
|
||||
|
||||
func (g *scriptKeyGenerator) key(prefix string) string {
|
||||
g.counters[prefix]++
|
||||
return fmt.Sprintf("%s_%03d", prefix, g.counters[prefix])
|
||||
}
|
||||
|
||||
// buildScriptPackage builds the 问诊问药 script package from the docs files.
|
||||
func buildScriptPackage(scriptFile, knowledgeFile, smallCategoryFile, smallAskScriptFile, preGuideFile, classificationFile, questionFile string) (scriptkit.PackageInput, error) {
|
||||
bank, err := readQuestionBank(questionFile)
|
||||
if err != nil {
|
||||
return scriptkit.PackageInput{}, err
|
||||
}
|
||||
classification, err := readClassification(classificationFile)
|
||||
if err != nil {
|
||||
return scriptkit.PackageInput{}, err
|
||||
}
|
||||
smallToBig, err := readStringMap(smallCategoryFile)
|
||||
if err != nil {
|
||||
return scriptkit.PackageInput{}, err
|
||||
}
|
||||
smallAsk, err := readPetScriptMap(smallAskScriptFile)
|
||||
if err != nil {
|
||||
return scriptkit.PackageInput{}, err
|
||||
}
|
||||
preGuide, err := readPreGuide(preGuideFile)
|
||||
if err != nil {
|
||||
return scriptkit.PackageInput{}, err
|
||||
}
|
||||
combinations, combinationSymptoms, err := readCombinations(scriptFile)
|
||||
if err != nil {
|
||||
return scriptkit.PackageInput{}, err
|
||||
}
|
||||
rows, err := readPetKnowledge(knowledgeFile)
|
||||
if err != nil {
|
||||
return scriptkit.PackageInput{}, err
|
||||
}
|
||||
|
||||
keys := &scriptKeyGenerator{counters: map[string]int{}}
|
||||
input := scriptkit.PackageInput{
|
||||
Name: "宠物问诊问药话术包",
|
||||
StartStageKey: "opening",
|
||||
Dimensions: []scriptkit.DimensionInput{},
|
||||
Stages: []scriptkit.StageInput{},
|
||||
Linkages: []scriptkit.LinkageInput{},
|
||||
Adapters: []scriptkit.AdapterInput{},
|
||||
}
|
||||
|
||||
// 症状大类来自小类映射、诊断分组、组合话术和 SKU 表的并集。
|
||||
bigSet := map[string]bool{}
|
||||
for _, big := range smallToBig {
|
||||
if big != "" {
|
||||
bigSet[big] = true
|
||||
}
|
||||
}
|
||||
for _, diagnosis := range classification.Diagnosis {
|
||||
if diagnosis.Type != "" {
|
||||
bigSet[diagnosis.Type] = true
|
||||
}
|
||||
}
|
||||
for big := range combinationSymptoms {
|
||||
bigSet[big] = true
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Symptom != "" {
|
||||
bigSet[row.Symptom] = true
|
||||
}
|
||||
}
|
||||
bigNames := make([]string, 0, len(bigSet))
|
||||
for name := range bigSet {
|
||||
bigNames = append(bigNames, name)
|
||||
}
|
||||
sort.Strings(bigNames)
|
||||
bigKey := map[string]string{}
|
||||
bigDimension := scriptkit.DimensionInput{DimKey: "symptom", Name: "症状", SortOrder: 1, Values: []scriptkit.ValueInput{}}
|
||||
for _, name := range bigNames {
|
||||
key := keys.key("symptom")
|
||||
bigKey[name] = key
|
||||
bigDimension.Values = append(bigDimension.Values, scriptkit.ValueInput{ValueKey: key, Name: name, InitialWeight: 0, SortOrder: len(bigDimension.Values)})
|
||||
}
|
||||
input.Dimensions = append(input.Dimensions, bigDimension)
|
||||
|
||||
subSet := map[string]bool{}
|
||||
for sub := range smallToBig {
|
||||
if sub != "" {
|
||||
subSet[sub] = true
|
||||
}
|
||||
}
|
||||
for _, symptom := range classification.Symptoms {
|
||||
for _, sub := range symptom.Subs {
|
||||
if sub != "" {
|
||||
subSet[sub] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
subNames := make([]string, 0, len(subSet))
|
||||
for name := range subSet {
|
||||
subNames = append(subNames, name)
|
||||
}
|
||||
sort.Strings(subNames)
|
||||
subKey := map[string]string{}
|
||||
subDimension := scriptkit.DimensionInput{DimKey: "symptom_sub", Name: "症状小类", SortOrder: 2, Values: []scriptkit.ValueInput{}}
|
||||
for _, name := range subNames {
|
||||
key := keys.key("symptom_sub")
|
||||
subKey[name] = key
|
||||
subDimension.Values = append(subDimension.Values, scriptkit.ValueInput{ValueKey: key, Name: name, InitialWeight: 0, SortOrder: len(subDimension.Values)})
|
||||
}
|
||||
input.Dimensions = append(input.Dimensions, subDimension)
|
||||
|
||||
diseaseNames := make([]string, 0)
|
||||
diseaseKey := map[string]string{}
|
||||
for _, diagnosis := range classification.Diagnosis {
|
||||
for _, sub := range diagnosis.Subs {
|
||||
if sub.SubType == "" {
|
||||
continue
|
||||
}
|
||||
diseaseNames = append(diseaseNames, sub.SubType)
|
||||
}
|
||||
}
|
||||
sort.Strings(diseaseNames)
|
||||
diseaseDimension := scriptkit.DimensionInput{DimKey: "disease", Name: "疾病分型", SortOrder: 3, Values: []scriptkit.ValueInput{}}
|
||||
for _, name := range diseaseNames {
|
||||
key := keys.key("disease")
|
||||
diseaseKey[name] = key
|
||||
diseaseDimension.Values = append(diseaseDimension.Values, scriptkit.ValueInput{ValueKey: key, Name: name, InitialWeight: 0, SortOrder: len(diseaseDimension.Values)})
|
||||
}
|
||||
input.Dimensions = append(input.Dimensions, diseaseDimension)
|
||||
|
||||
// 每个症状都必须配置确认题和选择题;每个疾病都必须配置口语化说法。
|
||||
var missingSymptoms, missingDiseases []string
|
||||
for _, name := range bigNames {
|
||||
questions, ok := bank.Symptoms[name]
|
||||
if !ok || strings.TrimSpace(questions.Confirm) == "" || len(questions.Choices) == 0 {
|
||||
missingSymptoms = append(missingSymptoms, name)
|
||||
}
|
||||
}
|
||||
for _, name := range diseaseNames {
|
||||
if _, ok := bank.Diseases[name]; !ok {
|
||||
missingDiseases = append(missingDiseases, name)
|
||||
}
|
||||
}
|
||||
if len(missingSymptoms) > 0 {
|
||||
return scriptkit.PackageInput{}, fmt.Errorf("话术库 %s 缺少症状确认题/选择题: %v", questionFile, missingSymptoms)
|
||||
}
|
||||
if len(missingDiseases) > 0 {
|
||||
return scriptkit.PackageInput{}, fmt.Errorf("话术库 %s 缺少疾病口语化说法: %v", questionFile, missingDiseases)
|
||||
}
|
||||
|
||||
// Linkages: 小类 -> 大类, 大类 -> 疾病. 激活阈值 5:只有已确认的症状
|
||||
// (权重达到 5 以上)才会把疾病带入拟诊阶段;模糊提示(权重 3)只触发
|
||||
// 症状确认题,不会直接带出疾病。
|
||||
linkages := make([]scriptkit.LinkageInput, 0)
|
||||
for sub, big := range smallToBig {
|
||||
if subKey[sub] == "" || bigKey[big] == "" {
|
||||
continue
|
||||
}
|
||||
linkages = append(linkages, scriptkit.LinkageInput{FromDimensionValueKey: subKey[sub], ToDimensionValueKey: bigKey[big], RelationType: "contributes_to", Contribution: 8, ActivationThreshold: 5, SortOrder: len(linkages)})
|
||||
}
|
||||
for _, diagnosis := range classification.Diagnosis {
|
||||
for _, sub := range diagnosis.Subs {
|
||||
if bigKey[diagnosis.Type] == "" || diseaseKey[sub.SubType] == "" {
|
||||
continue
|
||||
}
|
||||
linkages = append(linkages, scriptkit.LinkageInput{FromDimensionValueKey: bigKey[diagnosis.Type], ToDimensionValueKey: diseaseKey[sub.SubType], RelationType: "contributes_to", Contribution: 5, ActivationThreshold: 5, SortOrder: len(linkages)})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(linkages, func(i, j int) bool { return linkages[i].SortOrder < linkages[j].SortOrder })
|
||||
input.Linkages = linkages
|
||||
|
||||
// Adapters: symptom tags and order SKUs initialize symptom weights.
|
||||
// SKU 只提示相关症状(权重 3,触发症状确认题),不会直接命中疾病——
|
||||
// 疾病必须由已确认的症状联动带出。
|
||||
adapters := make([]scriptkit.AdapterInput, 0)
|
||||
for _, name := range bigNames {
|
||||
adapters = append(adapters, scriptkit.AdapterInput{SourceField: "input.input_symptom_tags", MatchValue: name, TargetDimensionValueKey: bigKey[name], Weight: 8, SortOrder: len(adapters)})
|
||||
}
|
||||
for _, name := range subNames {
|
||||
adapters = append(adapters, scriptkit.AdapterInput{SourceField: "input.input_symptom_tags", MatchValue: name, TargetDimensionValueKey: subKey[name], Weight: 8, SortOrder: len(adapters)})
|
||||
}
|
||||
for _, row := range rows {
|
||||
if strings.TrimSpace(row.SKUCode) == "" {
|
||||
continue
|
||||
}
|
||||
if bigKey[row.Symptom] != "" {
|
||||
adapters = append(adapters, scriptkit.AdapterInput{SourceField: "derived.matched_skus", MatchValue: row.SKUCode, TargetDimensionValueKey: bigKey[row.Symptom], Weight: 3, SortOrder: len(adapters)})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(adapters, func(i, j int) bool { return adapters[i].SortOrder < adapters[j].SortOrder })
|
||||
input.Adapters = adapters
|
||||
|
||||
// Stage 1: 开场. 开场只做问候和宠物档案补充,不显示任何维度(症状只在症状节点显示)。
|
||||
opening := scriptkit.StageInput{
|
||||
StageKey: "opening", Name: "开场", Purpose: "确认顾客与宠物信息,并补充宠物档案", PrimaryDimensionKey: "", SortOrder: 1, Scripts: []scriptkit.ScriptInput{
|
||||
{ScriptKey: "opening_greeting", Name: "开场话术", ScriptType: "template", Content: bank.OpeningGreeting, SortOrder: 10},
|
||||
},
|
||||
}
|
||||
input.Stages = append(input.Stages, opening)
|
||||
|
||||
// Stage 2: 症状确认。候选症状超过 3 个时先做一道多选筛选题,
|
||||
// 筛完再对选中的症状按由浅入深的顺序做选择题。
|
||||
symptomStage := scriptkit.StageInput{StageKey: "symptom", Name: "症状确认", Purpose: "确认宠物当前的主要症状", PrimaryDimensionKey: "symptom", SortOrder: 2, Scripts: []scriptkit.ScriptInput{}}
|
||||
for index, guide := range preGuide {
|
||||
symptomStage.Scripts = append(symptomStage.Scripts, scriptkit.ScriptInput{ScriptKey: keys.key("symptom_guide"), Name: "引导话术" + guide.Title, ScriptType: "fallback", Content: guide.Template, SortOrder: 100 + index})
|
||||
}
|
||||
screenOptions := make([]scriptkit.OptionInput, 0, len(bigNames))
|
||||
for _, name := range bigNames {
|
||||
short := bank.Symptoms[name].Short
|
||||
if short == "" {
|
||||
short = name
|
||||
}
|
||||
screenOptions = append(screenOptions, scriptkit.OptionInput{OptionKey: bigKey[name], Label: short, TargetDimensionValueKey: bigKey[name], Effect: "set", EffectValue: 10, SortOrder: len(screenOptions) + 1})
|
||||
}
|
||||
symptomStage.Scripts = append(symptomStage.Scripts, scriptkit.ScriptInput{
|
||||
ScriptKey: keys.key("symptom_screen"), Name: "症状筛选", ScriptType: "screen",
|
||||
Content: bank.SymptomScreeningTemplate, Multiple: true,
|
||||
SortOrder: 50, Options: screenOptions,
|
||||
})
|
||||
for _, name := range bigNames {
|
||||
questions := bank.Symptoms[name]
|
||||
confirm := scriptkit.ScriptInput{
|
||||
ScriptKey: keys.key("symptom_confirm"), Name: "确认" + name + "症状", ScriptType: "confirm",
|
||||
Content: questions.Confirm,
|
||||
DimensionValueKey: bigKey[name], ConfirmThreshold: 3, ShowThreshold: 5, SortOrder: 1000,
|
||||
Options: []scriptkit.OptionInput{
|
||||
{OptionKey: "yes", Label: bank.SymptomConfirmOptions.Yes, TargetDimensionValueKey: bigKey[name], Effect: "set", EffectValue: 8, SortOrder: 1},
|
||||
{OptionKey: "no", Label: bank.SymptomConfirmOptions.No, TargetDimensionValueKey: bigKey[name], Effect: "zero", EffectValue: 0, SortOrder: 2},
|
||||
},
|
||||
}
|
||||
symptomStage.Scripts = append(symptomStage.Scripts, confirm)
|
||||
for choiceIndex, choice := range questions.Choices {
|
||||
options := make([]scriptkit.OptionInput, 0, len(choice.Options))
|
||||
for optionIndex, option := range choice.Options {
|
||||
options = append(options, scriptkit.OptionInput{OptionKey: option.Key, Label: option.Label, Effect: "set", EffectValue: 0, SortOrder: optionIndex + 1})
|
||||
}
|
||||
scriptKey := keys.key("symptom_choice")
|
||||
symptomStage.Scripts = append(symptomStage.Scripts, scriptkit.ScriptInput{
|
||||
ScriptKey: scriptKey, Name: name + choice.Title, ScriptType: "choice",
|
||||
Content: choice.Question, DimensionValueKey: bigKey[name],
|
||||
ConfirmThreshold: 3, ShowThreshold: 5, CollectFieldKey: scriptKey,
|
||||
Multiple: choice.Multiple,
|
||||
SortOrder: 1100 + choiceIndex, Options: options,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, name := range subNames {
|
||||
for index, ask := range smallAsk[name] {
|
||||
symptomStage.Scripts = append(symptomStage.Scripts, scriptkit.ScriptInput{ScriptKey: keys.key("symptom_sub_ask"), Name: name + ask.Title, ScriptType: "info", Content: ask.Template, DimensionValueKey: subKey[name], ConfirmThreshold: 3, ShowThreshold: 5, SortOrder: 5000 + index})
|
||||
}
|
||||
}
|
||||
input.Stages = append(input.Stages, symptomStage)
|
||||
|
||||
// Stage 3: 拟诊确认. 候选疾病(由已确认症状带出,权重5)先显示确认题;
|
||||
// 客户确认后(权重10)才显示该疾病的确认话术,可同时确认多个疾病。
|
||||
diagnosisStage := scriptkit.StageInput{StageKey: "diagnosis", Name: "拟诊确认", Purpose: "结合症状确认可能的疾病方向(可确认多个),确认后再进入推荐", PrimaryDimensionKey: "disease", SortOrder: 3, Scripts: []scriptkit.ScriptInput{
|
||||
{ScriptKey: "diagnosis_fallback", Name: "拟诊兜底话术", ScriptType: "fallback", Content: bank.DiagnosisFallback, SortOrder: 100},
|
||||
}}
|
||||
for _, name := range diseaseNames {
|
||||
phrase := bank.Diseases[name].Phrase
|
||||
confirm := scriptkit.ScriptInput{
|
||||
ScriptKey: keys.key("disease_confirm"), Name: "确认" + name + "方向", ScriptType: "confirm",
|
||||
Content: strings.ReplaceAll(bank.DiseaseConfirmTemplate, "{{disease_phrase}}", phrase),
|
||||
DimensionValueKey: diseaseKey[name], ConfirmThreshold: 5, ShowThreshold: 10, SortOrder: 1000,
|
||||
Options: []scriptkit.OptionInput{
|
||||
{OptionKey: "yes", Label: bank.DiseaseConfirmOptions.Yes, TargetDimensionValueKey: diseaseKey[name], Effect: "set", EffectValue: 10, SortOrder: 1},
|
||||
{OptionKey: "no", Label: bank.DiseaseConfirmOptions.No, TargetDimensionValueKey: diseaseKey[name], Effect: "zero", EffectValue: 0, SortOrder: 2},
|
||||
},
|
||||
}
|
||||
diagnosisStage.Scripts = append(diagnosisStage.Scripts, confirm)
|
||||
diagnosisStage.Scripts = append(diagnosisStage.Scripts, scriptkit.ScriptInput{
|
||||
ScriptKey: keys.key("disease_copy"), Name: name + "疾病确认话术", ScriptType: "message",
|
||||
Content: strings.ReplaceAll(bank.DiseaseConfirmedCopyTemplate, "{{disease_phrase}}", phrase),
|
||||
DimensionValueKey: diseaseKey[name], ConfirmThreshold: 5, ShowThreshold: 10, SortOrder: 1100,
|
||||
})
|
||||
diagnosisStage.Scripts = append(diagnosisStage.Scripts, scriptkit.ScriptInput{
|
||||
ScriptKey: keys.key("disease_copy"), Name: name + "方案引入话术", ScriptType: "message",
|
||||
Content: bank.DiseasePlanIntro,
|
||||
DimensionValueKey: diseaseKey[name], ConfirmThreshold: 5, ShowThreshold: 10, SortOrder: 1101,
|
||||
})
|
||||
}
|
||||
input.Stages = append(input.Stages, diagnosisStage)
|
||||
|
||||
// Stage 4: 药品推荐与注意事项. 只有客户确认过的疾病(权重10)才会推荐。
|
||||
recommendStage := scriptkit.StageInput{StageKey: "recommend", Name: "药品推荐与注意事项", Purpose: "按已确认的疾病方向推荐方案、商品和注意事项", PrimaryDimensionKey: "disease", SortOrder: 4, Scripts: []scriptkit.ScriptInput{
|
||||
{ScriptKey: "recommend_fallback", Name: "推荐兜底话术", ScriptType: "fallback", Content: bank.RecommendFallback, SortOrder: 100},
|
||||
}}
|
||||
for _, name := range diseaseNames {
|
||||
order := 1000
|
||||
combos := combinations[name]
|
||||
sort.SliceStable(combos, func(i, j int) bool { return combos[i].Weight > combos[j].Weight })
|
||||
for _, combination := range combos {
|
||||
products := make([]scriptkit.ProductInput, 0, len(combination.Products))
|
||||
for _, product := range combination.Products {
|
||||
if strings.TrimSpace(product.SKUCode) == "" {
|
||||
continue
|
||||
}
|
||||
products = append(products, scriptkit.ProductInput{SKUCode: product.SKUCode, ProductName: product.ProductName})
|
||||
}
|
||||
recommendStage.Scripts = append(recommendStage.Scripts, scriptkit.ScriptInput{
|
||||
ScriptKey: keys.key("recommend_combination"), Name: name + combination.Name, ScriptType: "message",
|
||||
Content: strings.ReplaceAll(combination.Script, "{{purchased_products}}", "{{input.product_names}}"),
|
||||
DimensionValueKey: diseaseKey[name], ConfirmThreshold: 5, ShowThreshold: 10,
|
||||
Products: products, SortOrder: order,
|
||||
})
|
||||
order++
|
||||
}
|
||||
recommendStage.Scripts = append(recommendStage.Scripts, scriptkit.ScriptInput{
|
||||
ScriptKey: keys.key("recommend_note"), Name: name + "注意事项", ScriptType: "message",
|
||||
Content: bank.DiseaseCareNote,
|
||||
DimensionValueKey: diseaseKey[name], ConfirmThreshold: 5, ShowThreshold: 10, SortOrder: order,
|
||||
})
|
||||
}
|
||||
input.Stages = append(input.Stages, recommendStage)
|
||||
|
||||
// Verify generated keys are valid before returning.
|
||||
if err := scriptkit.ValidatePackageInput(input); err != nil {
|
||||
return scriptkit.PackageInput{}, fmt.Errorf("generated package is invalid: %w", err)
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func readQuestionBank(filename string) (petQuestionBank, error) {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return petQuestionBank{}, fmt.Errorf("read pet question bank %s: %w", filename, err)
|
||||
}
|
||||
var bank petQuestionBank
|
||||
if err := json.Unmarshal(raw, &bank); err != nil {
|
||||
return petQuestionBank{}, fmt.Errorf("parse pet question bank %s: %w", filename, err)
|
||||
}
|
||||
if bank.SymptomConfirmOptions.Yes == "" {
|
||||
bank.SymptomConfirmOptions.Yes = "有"
|
||||
}
|
||||
if bank.SymptomConfirmOptions.No == "" {
|
||||
bank.SymptomConfirmOptions.No = "没有"
|
||||
}
|
||||
if bank.DiseaseConfirmOptions.Yes == "" {
|
||||
bank.DiseaseConfirmOptions.Yes = "对,按这个判断"
|
||||
}
|
||||
if bank.DiseaseConfirmOptions.No == "" {
|
||||
bank.DiseaseConfirmOptions.No = "感觉不太像"
|
||||
}
|
||||
if bank.SymptomScreeningTemplate == "" {
|
||||
return petQuestionBank{}, fmt.Errorf("pet question bank %s is missing symptom_screening_template", filename)
|
||||
}
|
||||
if bank.DiseaseConfirmTemplate == "" || bank.DiseaseConfirmedCopyTemplate == "" {
|
||||
return petQuestionBank{}, fmt.Errorf("pet question bank %s is missing disease templates", filename)
|
||||
}
|
||||
return bank, nil
|
||||
}
|
||||
|
||||
func readClassification(filename string) (petClassification, error) {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return petClassification{}, fmt.Errorf("read pet classification %s: %w", filename, err)
|
||||
}
|
||||
var wrapper struct {
|
||||
ConsultPayload string `json:"consultPayload"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrapper); err != nil {
|
||||
return petClassification{}, fmt.Errorf("parse pet classification wrapper %s: %w", filename, err)
|
||||
}
|
||||
var result petClassification
|
||||
if err := json.Unmarshal([]byte(wrapper.ConsultPayload), &result); err != nil {
|
||||
return petClassification{}, fmt.Errorf("parse pet classification %s: %w", filename, err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readStringMap(filename string) (map[string]string, error) {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pet string map %s: %w", filename, err)
|
||||
}
|
||||
var result map[string]string
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse pet string map %s: %w", filename, err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readPetScriptMap(filename string) (map[string][]petScriptEntry, error) {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pet script map %s: %w", filename, err)
|
||||
}
|
||||
var source map[string][]petScriptEntry
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
return nil, fmt.Errorf("parse pet script map %s: %w", filename, err)
|
||||
}
|
||||
return source, nil
|
||||
}
|
||||
|
||||
func readPreGuide(filename string) ([]petScriptEntry, error) {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pet pre-symptom guide %s: %w", filename, err)
|
||||
}
|
||||
var source struct {
|
||||
PreSymptomScripts []petScriptEntry `json:"pre_symptom_scripts"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
return nil, fmt.Errorf("parse pet pre-symptom guide %s: %w", filename, err)
|
||||
}
|
||||
return source.PreSymptomScripts, nil
|
||||
}
|
||||
|
||||
// readCombinations loads 疾病 -> 组合方案话术 and the symptom keys of the source.
|
||||
func readCombinations(filename string) (map[string][]petCombination, map[string]bool, error) {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read pet script combinations %s: %w", filename, err)
|
||||
}
|
||||
var source map[string]map[string]petScriptDisease
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse pet script combinations %s: %w", filename, err)
|
||||
}
|
||||
result := map[string][]petCombination{}
|
||||
symptoms := map[string]bool{}
|
||||
for symptom, diseases := range source {
|
||||
symptoms[symptom] = true
|
||||
for disease, definition := range diseases {
|
||||
result[disease] = append(result[disease], definition.Combinations...)
|
||||
}
|
||||
}
|
||||
return result, symptoms, nil
|
||||
}
|
||||
|
||||
func readPetKnowledge(filename string) ([]petKnowledgeRow, error) {
|
||||
book, err := excelize.OpenFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open pet knowledge %s: %w", filename, err)
|
||||
}
|
||||
defer book.Close()
|
||||
sheets := book.GetSheetList()
|
||||
if len(sheets) == 0 {
|
||||
return nil, fmt.Errorf("pet knowledge %s has no worksheet", filename)
|
||||
}
|
||||
iterator, err := book.Rows(sheets[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pet knowledge worksheet: %w", err)
|
||||
}
|
||||
defer iterator.Close()
|
||||
columns := map[string]int{}
|
||||
result := make([]petKnowledgeRow, 0)
|
||||
rowNumber := 0
|
||||
for iterator.Next() {
|
||||
rowNumber++
|
||||
values, err := iterator.Columns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pet knowledge row %d: %w", rowNumber, err)
|
||||
}
|
||||
if rowNumber == 1 {
|
||||
for index, value := range values {
|
||||
columns[strings.TrimSpace(value)] = index
|
||||
}
|
||||
for _, required := range []string{"症状", "疾病", "方案", "SKU_CODE", "商品标题"} {
|
||||
if _, ok := columns[required]; !ok {
|
||||
return nil, fmt.Errorf("pet knowledge is missing column %q", required)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
row := petKnowledgeRow{
|
||||
Symptom: excelValue(values, columns["症状"]),
|
||||
Disease: excelValue(values, columns["疾病"]),
|
||||
SKUCode: excelValue(values, columns["SKU_CODE"]),
|
||||
ProductName: excelValue(values, columns["商品标题"]),
|
||||
}
|
||||
if row.Symptom == "" && row.Disease == "" {
|
||||
continue
|
||||
}
|
||||
if row.Symptom == "" || row.Disease == "" {
|
||||
return nil, fmt.Errorf("pet knowledge row %d must contain 症状 and 疾病", rowNumber)
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
if err := iterator.Error(); err != nil {
|
||||
return nil, fmt.Errorf("iterate pet knowledge: %w", err)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("pet knowledge contains no data")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func excelValue(values []string, index int) string {
|
||||
if index >= len(values) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(values[index])
|
||||
}
|
||||
83
sdk/dist/index.d.ts
vendored
83
sdk/dist/index.d.ts
vendored
@@ -42,27 +42,54 @@ export type NodePresentation = {
|
||||
content: string;
|
||||
};
|
||||
};
|
||||
export type KnowledgeCollectionOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
parents?: string[];
|
||||
suggested?: boolean;
|
||||
};
|
||||
export type KnowledgeCollectionStep = {
|
||||
field_key: string;
|
||||
export type DimensionValue = {
|
||||
value_key: string;
|
||||
name: string;
|
||||
weight: number;
|
||||
hot: boolean;
|
||||
};
|
||||
export type DimensionState = {
|
||||
dim_key: string;
|
||||
name: string;
|
||||
values: DimensionValue[];
|
||||
};
|
||||
export type ScriptOption = {
|
||||
option_key: string;
|
||||
label: string;
|
||||
};
|
||||
export type ScriptProduct = {
|
||||
sku_code: string;
|
||||
product_name: string;
|
||||
};
|
||||
export type ScriptItem = {
|
||||
script_key: string;
|
||||
name: string;
|
||||
script_type: string;
|
||||
content: string;
|
||||
dimension_value_key?: string;
|
||||
weight?: number;
|
||||
confirm_threshold?: number;
|
||||
show_threshold?: number;
|
||||
options?: ScriptOption[];
|
||||
products?: ScriptProduct[];
|
||||
feedback: boolean;
|
||||
required: boolean;
|
||||
multiple: boolean;
|
||||
from_field?: string;
|
||||
options: KnowledgeCollectionOption[];
|
||||
};
|
||||
export type KnowledgeCollection = {
|
||||
context_fields: NodeField[];
|
||||
context_title?: string;
|
||||
context_hint?: string;
|
||||
selection_title?: string;
|
||||
selection_hint?: string;
|
||||
steps: KnowledgeCollectionStep[];
|
||||
export type StageForm = {
|
||||
fields: NodeField[];
|
||||
};
|
||||
export type StageState = {
|
||||
stage_key: string;
|
||||
name: string;
|
||||
purpose: string;
|
||||
dimensions: DimensionState[];
|
||||
scripts: ScriptItem[];
|
||||
fallback?: string;
|
||||
form?: StageForm;
|
||||
screening_required: boolean;
|
||||
can_next: boolean;
|
||||
can_next_reason?: string;
|
||||
};
|
||||
export type NodeView = {
|
||||
node_key: string;
|
||||
@@ -71,8 +98,8 @@ export type NodeView = {
|
||||
content: string;
|
||||
fields?: NodeField[];
|
||||
presentation?: NodePresentation;
|
||||
stage?: StageState;
|
||||
outputs?: unknown[];
|
||||
collection?: KnowledgeCollection;
|
||||
};
|
||||
export type ScenarioState = {
|
||||
run_id: number;
|
||||
@@ -98,6 +125,21 @@ export type FinishOptions = {
|
||||
result?: string;
|
||||
finalResult?: Record<string, unknown> | null;
|
||||
};
|
||||
export type ScriptAnswer = {
|
||||
script_key: string;
|
||||
option_keys?: string[];
|
||||
value?: string;
|
||||
};
|
||||
export type AnswerOptions = {
|
||||
scriptKey?: string;
|
||||
optionKeys?: string[];
|
||||
value?: string;
|
||||
scriptAnswers?: ScriptAnswer[];
|
||||
dimensionSelects?: Record<string, boolean>;
|
||||
answers?: ScenarioInput;
|
||||
dimensionValueKey?: string;
|
||||
selected?: boolean;
|
||||
};
|
||||
export declare class SalesScenario {
|
||||
private readonly options;
|
||||
private token;
|
||||
@@ -106,9 +148,14 @@ export declare class SalesScenario {
|
||||
constructor(options: CreateOptions);
|
||||
start(): Promise<ScenarioState>;
|
||||
getState(): ScenarioState;
|
||||
getStage(): StageState;
|
||||
getCurrentNode(): Promise<ScenarioState>;
|
||||
/** Answer stage questions without advancing. Answer locally on the client and submit the whole stage once: pass every answered script in scriptAnswers together with dimensionSelects and the content form in answers. */
|
||||
answer(options?: AnswerOptions): Promise<ScenarioState>;
|
||||
submit(values: ScenarioInput): Promise<ScenarioState>;
|
||||
next(): Promise<ScenarioState>;
|
||||
back(): Promise<ScenarioState>;
|
||||
feedback(scriptKey: string, feedbackType: 'like' | 'report' | 'unreasonable', note?: string): Promise<boolean>;
|
||||
finish(options?: FinishOptions): Promise<ScenarioState>;
|
||||
reset(): Promise<ScenarioState>;
|
||||
destroy(): void;
|
||||
|
||||
17
sdk/dist/index.iife.js
vendored
17
sdk/dist/index.iife.js
vendored
@@ -43,9 +43,19 @@ var IqudooSalesScenario = (() => {
|
||||
if (!this.state) throw new Error("SDK has not started");
|
||||
return this.state;
|
||||
}
|
||||
getStage() {
|
||||
const stage = this.getState().node.stage;
|
||||
if (!stage) throw new Error("current node is not a script stage");
|
||||
return stage;
|
||||
}
|
||||
async getCurrentNode() {
|
||||
return this.update(`/public/runs/${this.getState().run_id}/current`);
|
||||
}
|
||||
/** Answer stage questions without advancing. Answer locally on the client and submit the whole stage once: pass every answered script in scriptAnswers together with dimensionSelects and the content form in answers. */
|
||||
async answer(options = {}) {
|
||||
const state = await this.update(`/public/runs/${this.getState().run_id}/answer`, { method: "POST", body: JSON.stringify({ node_key: this.getState().node.node_key, script_key: options.scriptKey || "", option_keys: options.optionKeys || [], value: options.value || "", script_answers: options.scriptAnswers || [], dimension_selects: options.dimensionSelects || {}, answers: options.answers || {}, dimension_value_key: options.dimensionValueKey || "", selected: options.selected ?? false }) });
|
||||
return state;
|
||||
}
|
||||
async submit(values) {
|
||||
const state = await this.update(`/public/runs/${this.getState().run_id}/submit`, { method: "POST", body: JSON.stringify({ node_key: this.getState().node.node_key, answers: values }) });
|
||||
this.emit("form_submit", { values, state });
|
||||
@@ -54,6 +64,13 @@ var IqudooSalesScenario = (() => {
|
||||
async next() {
|
||||
return this.update(`/public/runs/${this.getState().run_id}/next`, { method: "POST" });
|
||||
}
|
||||
async back() {
|
||||
return this.update(`/public/runs/${this.getState().run_id}/back`, { method: "POST" });
|
||||
}
|
||||
async feedback(scriptKey, feedbackType, note = "") {
|
||||
await this.request(`/public/runs/${this.getState().run_id}/feedback`, { method: "POST", body: JSON.stringify({ node_key: this.getState().node.node_key, script_key: scriptKey, feedback_type: feedbackType, note }) });
|
||||
return true;
|
||||
}
|
||||
async finish(options = {}) {
|
||||
const state = await this.update(`/public/runs/${this.getState().run_id}/finish`, { method: "POST", body: JSON.stringify({ result: options.result || "", final_result: options.finalResult ?? null }) });
|
||||
this.emit("finish", state);
|
||||
|
||||
6
sdk/dist/index.js
vendored
6
sdk/dist/index.js
vendored
@@ -10,9 +10,15 @@ export class SalesScenario {
|
||||
this.token = state.session_token; this.setState(state); this.emit('ready', state); return state; }
|
||||
getState() { if (!this.state)
|
||||
throw new Error('SDK has not started'); return this.state; }
|
||||
getStage() { const stage = this.getState().node.stage; if (!stage)
|
||||
throw new Error('current node is not a script stage'); return stage; }
|
||||
async getCurrentNode() { return this.update(`/public/runs/${this.getState().run_id}/current`); }
|
||||
/** Answer stage questions without advancing. Answer locally on the client and submit the whole stage once: pass every answered script in scriptAnswers together with dimensionSelects and the content form in answers. */
|
||||
async answer(options = {}) { const state = await this.update(`/public/runs/${this.getState().run_id}/answer`, { method: 'POST', body: JSON.stringify({ node_key: this.getState().node.node_key, script_key: options.scriptKey || '', option_keys: options.optionKeys || [], value: options.value || '', script_answers: options.scriptAnswers || [], dimension_selects: options.dimensionSelects || {}, answers: options.answers || {}, dimension_value_key: options.dimensionValueKey || '', selected: options.selected ?? false }) }); return state; }
|
||||
async submit(values) { const state = await this.update(`/public/runs/${this.getState().run_id}/submit`, { method: 'POST', body: JSON.stringify({ node_key: this.getState().node.node_key, answers: values }) }); this.emit('form_submit', { values, state }); return state; }
|
||||
async next() { return this.update(`/public/runs/${this.getState().run_id}/next`, { method: 'POST' }); }
|
||||
async back() { return this.update(`/public/runs/${this.getState().run_id}/back`, { method: 'POST' }); }
|
||||
async feedback(scriptKey, feedbackType, note = '') { await this.request(`/public/runs/${this.getState().run_id}/feedback`, { method: 'POST', body: JSON.stringify({ node_key: this.getState().node.node_key, script_key: scriptKey, feedback_type: feedbackType, note }) }); return true; }
|
||||
async finish(options = {}) { const state = await this.update(`/public/runs/${this.getState().run_id}/finish`, { method: 'POST', body: JSON.stringify({ result: options.result || '', final_result: options.finalResult ?? null }) }); this.emit('finish', state); return state; }
|
||||
async reset() { return this.update(`/public/runs/${this.getState().run_id}/reset`, { method: 'POST' }); }
|
||||
destroy() { this.handlers.clear(); this.token = ''; this.state = undefined; }
|
||||
|
||||
@@ -5,14 +5,20 @@ export type ResultSchema = { fields:ResultField[] }
|
||||
export type NodeField = { key:string; name:string; type:string; required:boolean; options:unknown[]; validation:Record<string,unknown> }
|
||||
export type PresentationField = { key:string; label:string; kind:'text'|'image'; value:unknown }
|
||||
export type NodePresentation = { summary:PresentationField[]; items:Array<{fields:PresentationField[]}>; opening?:{title:string; content:string} }
|
||||
export type KnowledgeCollectionOption = { value:string; label:string; parents?:string[]; suggested?:boolean }
|
||||
export type KnowledgeCollectionStep = { field_key:string; name:string; required:boolean; multiple:boolean; from_field?:string; options:KnowledgeCollectionOption[] }
|
||||
export type KnowledgeCollection = { context_fields:NodeField[]; context_title?:string; context_hint?:string; selection_title?:string; selection_hint?:string; steps:KnowledgeCollectionStep[] }
|
||||
export type NodeView = { node_key:string; type:string; title:string; content:string; fields?:NodeField[]; presentation?:NodePresentation; outputs?:unknown[]; collection?:KnowledgeCollection }
|
||||
export type DimensionValue = { value_key:string; name:string; weight:number; hot:boolean }
|
||||
export type DimensionState = { dim_key:string; name:string; values:DimensionValue[] }
|
||||
export type ScriptOption = { option_key:string; label:string }
|
||||
export type ScriptProduct = { sku_code:string; product_name:string }
|
||||
export type ScriptItem = { script_key:string; name:string; script_type:string; content:string; dimension_value_key?:string; weight?:number; confirm_threshold?:number; show_threshold?:number; options?:ScriptOption[]; products?:ScriptProduct[]; feedback:boolean; required:boolean; multiple:boolean }
|
||||
export type StageForm = { fields:NodeField[] }
|
||||
export type StageState = { stage_key:string; name:string; purpose:string; dimensions:DimensionState[]; scripts:ScriptItem[]; fallback?:string; form?:StageForm; screening_required:boolean; can_next:boolean; can_next_reason?:string }
|
||||
export type NodeView = { node_key:string; type:string; title:string; content:string; fields?:NodeField[]; presentation?:NodePresentation; stage?:StageState; outputs?:unknown[] }
|
||||
export type ScenarioState = { run_id:number; external_ref:string; status:string; final_result?:Record<string, unknown>|null; result_schema:ResultSchema; session_token?:string; node:NodeView; outputs:OutputField[] }
|
||||
export type SDKEvent = 'ready'|'node_change'|'form_submit'|'finish'|'error'
|
||||
export type CreateOptions = { baseURL?:string; publicKey:string; scenarioKey:string; sopId?:number; input?:ScenarioInput; initialValues?:ScenarioInput; externalRef?:string }
|
||||
export type FinishOptions = { result?:string; finalResult?:Record<string, unknown>|null }
|
||||
export type ScriptAnswer = { script_key:string; option_keys?:string[]; value?:string }
|
||||
export type AnswerOptions = { scriptKey?:string; optionKeys?:string[]; value?:string; scriptAnswers?:ScriptAnswer[]; dimensionSelects?:Record<string, boolean>; answers?:ScenarioInput; dimensionValueKey?:string; selected?:boolean }
|
||||
|
||||
export class SalesScenario {
|
||||
private token=''
|
||||
@@ -21,9 +27,14 @@ export class SalesScenario {
|
||||
constructor(private readonly options:CreateOptions){}
|
||||
async start(){const state=await this.request<ScenarioState>(`/public/scenarios/${encodeURIComponent(this.options.scenarioKey)}/runs`,{method:'POST',body:JSON.stringify({public_key:this.options.publicKey,sop_id:this.options.sopId,input:this.options.input||{},initial_values:this.options.initialValues||{},external_ref:this.options.externalRef||''})});if(state.session_token)this.token=state.session_token;this.setState(state);this.emit('ready',state);return state}
|
||||
getState(){if(!this.state)throw new Error('SDK has not started');return this.state}
|
||||
getStage(){const stage=this.getState().node.stage;if(!stage)throw new Error('current node is not a script stage');return stage}
|
||||
async getCurrentNode(){return this.update(`/public/runs/${this.getState().run_id}/current`)}
|
||||
/** Answer stage questions without advancing. Answer locally on the client and submit the whole stage once: pass every answered script in scriptAnswers together with dimensionSelects and the content form in answers. */
|
||||
async answer(options:AnswerOptions={}){const state=await this.update(`/public/runs/${this.getState().run_id}/answer`,{method:'POST',body:JSON.stringify({node_key:this.getState().node.node_key,script_key:options.scriptKey||'',option_keys:options.optionKeys||[],value:options.value||'',script_answers:options.scriptAnswers||[],dimension_selects:options.dimensionSelects||{},answers:options.answers||{},dimension_value_key:options.dimensionValueKey||'',selected:options.selected??false})});return state}
|
||||
async submit(values:ScenarioInput){const state=await this.update(`/public/runs/${this.getState().run_id}/submit`,{method:'POST',body:JSON.stringify({node_key:this.getState().node.node_key,answers:values})});this.emit('form_submit',{values,state});return state}
|
||||
async next(){return this.update(`/public/runs/${this.getState().run_id}/next`,{method:'POST'})}
|
||||
async back(){return this.update(`/public/runs/${this.getState().run_id}/back`,{method:'POST'})}
|
||||
async feedback(scriptKey:string,feedbackType:'like'|'report'|'unreasonable',note=''){await this.request<{recorded:boolean}>(`/public/runs/${this.getState().run_id}/feedback`,{method:'POST',body:JSON.stringify({node_key:this.getState().node.node_key,script_key:scriptKey,feedback_type:feedbackType,note})});return true}
|
||||
async finish(options:FinishOptions={}){const state=await this.update(`/public/runs/${this.getState().run_id}/finish`,{method:'POST',body:JSON.stringify({result:options.result||'',final_result:options.finalResult??null})});this.emit('finish',state);return state}
|
||||
async reset(){return this.update(`/public/runs/${this.getState().run_id}/reset`,{method:'POST'})}
|
||||
destroy(){this.handlers.clear();this.token='';this.state=undefined}
|
||||
|
||||
BIN
seed-pet-doctor
Executable file
BIN
seed-pet-doctor
Executable file
Binary file not shown.
@@ -1,29 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { CodeOutlined, SaveOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { api, apiMessage } from '@/api/client'
|
||||
import type { ScenarioField } from '@/types'
|
||||
|
||||
interface KnowledgeItem { id?:number; key?:string; item_key?:string; name:string; type:string; content:Record<string,unknown>; status:string; sort_order:number }
|
||||
interface KnowledgeRelation { from?:string; to?:string; relation_type:string; from_knowledge_id?:number; to_knowledge_id?:number; condition?:Record<string,unknown>; sort_order:number }
|
||||
const props = defineProps<{ scenarioId:number; fields:ScenarioField[]; editable:boolean }>()
|
||||
const loading=ref(false);const saving=ref(false);const jsonText=ref('{\n "items": [],\n "relations": []\n}')
|
||||
const parsed=computed(()=>{try{return JSON.parse(jsonText.value)}catch{return null}})
|
||||
async function load(){loading.value=true;try{const data=await api.get<{items:KnowledgeItem[];relations:KnowledgeRelation[]}>(`/scenarios/${props.scenarioId}/knowledge-graph`);const keyById=new Map(data.items.map(item=>[item.id,item.item_key]));jsonText.value=JSON.stringify({items:data.items.map(item=>({key:item.item_key,name:item.name,type:item.type,content:item.content||{},status:item.status,sort_order:item.sort_order})),relations:data.relations.map(rel=>({from:keyById.get(rel.from_knowledge_id),relation_type:rel.relation_type,to:keyById.get(rel.to_knowledge_id),condition:rel.condition||{},sort_order:rel.sort_order}))},null,2)}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
|
||||
watch(()=>props.scenarioId,load,{immediate:true})
|
||||
async function save(){if(!parsed.value)return message.warning('知识 JSON 格式不正确');saving.value=true;try{await api.put(`/scenarios/${props.scenarioId}/knowledge-graph`,parsed.value);message.success('场景知识已保存');await load()}catch(error){message.error(apiMessage(error))}finally{saving.value=false}}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="surface knowledge-editor">
|
||||
<header><div><span class="icon"><CodeOutlined /></span><div><b>场景知识 JSON</b><small>定义知识实体与关系;知识类型和关系类型均由场景决定</small></div></div><a-button v-if="editable" type="primary" :loading="saving" @click="save"><SaveOutlined />保存知识</a-button></header>
|
||||
<a-alert type="info" show-icon message="通用格式:items + relations。宠物场景也支持 symptoms[].diseases[],一个症状可关联多个疾病。" />
|
||||
<a-spin :spinning="loading"><a-textarea v-model:value="jsonText" class="json-input" :disabled="!editable" :auto-size="{minRows:22,maxRows:38}" spellcheck="false" /></a-spin>
|
||||
<footer><span>{{ parsed ? 'JSON 格式有效' : 'JSON 格式错误' }}</span><span>{{ fields.length }} 个场景输入字段可供规则和话术引用</span></footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.knowledge-editor{padding:18px}.knowledge-editor header{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:14px}.knowledge-editor header>div{display:flex;align-items:center;gap:10px}.knowledge-editor header div div{display:flex;flex-direction:column}.knowledge-editor small{margin-top:3px;color:var(--muted)}.icon{width:34px;height:34px;display:grid;place-items:center;color:var(--green);background:#e8f3ef;border-radius:4px}.json-input{margin-top:14px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.6}.knowledge-editor footer{display:flex;justify-content:space-between;margin-top:10px;color:var(--muted);font-size:11px}@media(max-width:700px){.knowledge-editor header{align-items:flex-start;flex-direction:column}.knowledge-editor footer{gap:8px;flex-direction:column}}
|
||||
</style>
|
||||
34
web/src/components/ScriptPackageTab.vue
Normal file
34
web/src/components/ScriptPackageTab.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { api, apiMessage } from '@/api/client'
|
||||
|
||||
const props = defineProps<{ scenarioId:number; editable:boolean }>()
|
||||
const loading=ref(false);const saving=ref(false)
|
||||
const jsonText=ref('')
|
||||
const statusText=ref('')
|
||||
|
||||
async function load(){loading.value=true;try{const data=await api.get<Record<string,unknown>>(`/scenarios/${props.scenarioId}/script-package`);statusText.value=`话术包 #${data.id||'-'} · ${data.status||'active'}`;const {id:_id,status:_status,...document}=data as Record<string,unknown>;jsonText.value=JSON.stringify(document,null,2)}catch(error){if(!apiMessage(error).includes('不存在'))message.error(apiMessage(error))}finally{loading.value=false}}
|
||||
function parseJSON(){try{return JSON.parse(jsonText.value)}catch{message.warning('话术包 JSON 格式不正确');return null}}
|
||||
async function save(){const payload=parseJSON();if(!payload)return;saving.value=true;try{await api.put(`/scenarios/${props.scenarioId}/script-package`,payload);message.success('话术包已保存');await load()}catch(error){message.error(apiMessage(error))}finally{saving.value=false}}
|
||||
load()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="surface scriptkit-editor">
|
||||
<header>
|
||||
<div><span class="icon">包</span><div><b>话术包</b><small>维度、维度值、阶段、话术、选项、阈值、联动与入场适配</small></div></div>
|
||||
<a-button v-if="editable" type="primary" :loading="saving" @click="save">保存话术包</a-button>
|
||||
</header>
|
||||
<p class="status-line">{{ statusText }}</p>
|
||||
<a-textarea v-model:value="jsonText" :disabled="!editable" class="json-input" :auto-size="{minRows:18,maxRows:60}" spellcheck="false" placeholder='{"name":"...","start_stage_key":"opening","dimensions":[...],"stages":[...],"linkages":[...],"adapters":[...]}' />
|
||||
<footer>
|
||||
<span>话术包是问诊问药的核心配置:运行时按维度权重匹配话术,答案触发升维/降维并联动后续维度。</span>
|
||||
<span v-if="!editable">只读</span>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scriptkit-editor{padding:18px}.scriptkit-editor header{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:14px}.scriptkit-editor header>div{display:flex;align-items:center;gap:10px}.scriptkit-editor header div div{display:flex;flex-direction:column}.scriptkit-editor small{margin-top:3px;color:var(--muted)}.icon{width:34px;height:34px;display:grid;place-items:center;color:var(--green);background:#e8f3ef;border-radius:4px;font-weight:700}.status-line{margin:0 0 8px;color:var(--muted);font-size:12px}.json-input{margin-top:14px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.6}.scriptkit-editor footer{display:flex;justify-content:space-between;margin-top:10px;color:var(--muted);font-size:11px}@media(max-width:700px){.scriptkit-editor header{align-items:flex-start;flex-direction:column}.scriptkit-editor footer{gap:8px;flex-direction:column}}
|
||||
</style>
|
||||
@@ -53,8 +53,8 @@ function logout() {
|
||||
<div class="brand" :class="{ compact: collapsed }">
|
||||
<div class="brand-mark"><span></span><span></span><span></span></div>
|
||||
<div v-if="!collapsed" class="brand-copy">
|
||||
<strong>销冠 SOP</strong>
|
||||
<small>经验执行系统</small>
|
||||
<strong>销话通</strong>
|
||||
<small>AI 追单话术系统</small>
|
||||
</div>
|
||||
</div>
|
||||
<a-menu mode="inline" theme="dark" :selected-keys="selectedKeys" @click="navigate">
|
||||
|
||||
@@ -3,19 +3,23 @@ export interface User { user_id: number; tenant_id: number; role_code: string; p
|
||||
export interface Scenario extends BaseEntity { tenant_id: number; scenario_key:string; public_key:string; allowed_origins:string[]; name: string; industry: string; role_name: string; goal: string; trigger_text: string; visibility: string; status: string; created_by: number; input_schema:Record<string,unknown>; output_schema:Record<string,unknown>; result_schema:Record<string,unknown> }
|
||||
export interface ScenarioField extends BaseEntity { scenario_id: number; field_key: string; field_name: string; source_path:string; field_type: string; required: boolean; options: unknown[]; validation: Record<string, unknown>; sort_order: number }
|
||||
export interface SOP extends BaseEntity { scenario_id: number; name: string; description: string; status: string; start_node_key?: string }
|
||||
export interface KnowledgeItemView { key:string; name:string; type:string; content:Record<string,any> }
|
||||
export interface KnowledgeGroup extends KnowledgeItemView { relations:Record<string,KnowledgeItemView[]>; suggested?:boolean }
|
||||
export interface PublicFieldView { key:string; name:string; type:string; required:boolean; options:string[]; validation:Record<string,unknown> }
|
||||
export interface KnowledgeCollectionOption { value:string; label:string; parents?:string[]; suggested?:boolean }
|
||||
export interface KnowledgeCollectionStep { field_key:string; name:string; required:boolean; multiple:boolean; from_field?:string; options:KnowledgeCollectionOption[] }
|
||||
export interface KnowledgeCollection { context_fields:PublicFieldView[]; context_title?:string; context_hint?:string; selection_title?:string; selection_hint?:string; steps:KnowledgeCollectionStep[] }
|
||||
export interface DimensionValue { value_key:string; name:string; weight:number; hot:boolean }
|
||||
export interface DimensionState { dim_key:string; name:string; values:DimensionValue[] }
|
||||
export interface ScriptOption { option_key:string; label:string }
|
||||
export interface ScriptProduct { sku_code:string; product_name:string }
|
||||
export interface ScriptItem { script_key:string; name:string; script_type:string; content:string; dimension_value_key?:string; weight?:number; confirm_threshold?:number; show_threshold?:number; options?:ScriptOption[]; products?:ScriptProduct[]; feedback:boolean; required:boolean; multiple:boolean }
|
||||
export interface StageForm { fields:PublicFieldView[] }
|
||||
export interface StageState { stage_key:string; name:string; purpose:string; dimensions:DimensionState[]; scripts:ScriptItem[]; fallback?:string; form?:StageForm; screening_required:boolean; can_next:boolean; can_next_reason?:string }
|
||||
export interface PublicFieldView { key:string; name:string; type:string; required:boolean; options:unknown[]; validation:Record<string,unknown> }
|
||||
export interface PresentationField { key:string; label:string; kind:'text'|'image'; value:unknown }
|
||||
export interface NodePresentation { summary:PresentationField[]; items:Array<{fields:PresentationField[]}>; opening?:{title:string; content:string} }
|
||||
export interface SOPNode extends BaseEntity { node_key: string; type: string; title: string; content: string; config: Record<string, any>; position_x: number; position_y: number; presentation?:NodePresentation; outputs?:KnowledgeGroup[]; collection?:KnowledgeCollection }
|
||||
export interface SOPNode extends BaseEntity { node_key: string; type: string; title: string; content: string; config: Record<string, any>; position_x: number; position_y: number; presentation?:NodePresentation; stage?:StageState }
|
||||
export interface SOPEdge extends BaseEntity { source_node_key: string; target_node_key: string; condition: Record<string, any>; priority: number }
|
||||
export interface SOPRun extends BaseEntity { sop_id: number; operator_id:number; external_ref?:string; current_node_key: string; status: string; answers: Record<string, any>; input:Record<string,any>; derived:Record<string,any>; outputs:unknown[]; result: string; final_result?:Record<string,unknown>|null; started_at: string; completed_at?: string; sop_name?: string; scenario_name?:string; operator_name?:string }
|
||||
export interface RunEvent extends BaseEntity { run_id:number; node_key:string; action:string; payload:Record<string,any>; node_title:string; node_type:string; node_content:string; outputs?:KnowledgeGroup[] }
|
||||
export interface SOPRun extends BaseEntity { sop_id: number; operator_id:number; external_ref?:string; current_node_key: string; status: string; answers: Record<string, any>; input:Record<string,any>; derived:Record<string,any>; script_state?:Record<string,any>; outputs:unknown[]; result: string; final_result?:Record<string,unknown>|null; started_at: string; completed_at?: string; sop_name?: string; scenario_name?:string; operator_name?:string }
|
||||
export interface RunEvent extends BaseEntity { run_id:number; node_key:string; action:string; payload:Record<string,any>; node_title:string; node_type:string; node_content:string }
|
||||
export interface RunFeedback extends BaseEntity { run_id:number; user_id:number; score:number; comment:string; user_name:string }
|
||||
export interface RunDimension extends BaseEntity { run_id:number; dimension_id:number; value_key:string; weight:number }
|
||||
export interface ScriptFeedback extends BaseEntity { run_id:number; stage_id:number; script_id:number; feedback_type:string; operator_id:number; note:string }
|
||||
export interface RunDetail extends SOPRun { scenario_name:string; operator_name:string }
|
||||
export interface Role extends BaseEntity { name:string; code:string; permissions:string[] }
|
||||
export interface Member { id:number; user_id:number; username:string; display_name:string; role_code:string; role_name:string; status:string }
|
||||
|
||||
@@ -9,9 +9,16 @@ const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(true)
|
||||
const summary = ref({ scenarios: 0, published_sops: 0, runs: 0, completed_runs: 0 })
|
||||
interface DimensionStat { value_key:string; name:string; dim_key:string; runs:number; average_weight:number }
|
||||
interface FeedbackStat { script_key:string; name:string; feedback_type:string; count:number }
|
||||
const scriptkit = ref<{ dimensions:DimensionStat[]; feedback:FeedbackStat[] }>({ dimensions: [], feedback: [] })
|
||||
const feedbackTypeText = (type:string)=>({ like:'有帮助', report:'不准确', unreasonable:'不合理' } as Record<string,string>)[type]||type
|
||||
|
||||
onMounted(async () => {
|
||||
try { summary.value = await api.get('/dashboard/summary') } finally { loading.value = false }
|
||||
try {
|
||||
summary.value = await api.get('/dashboard/summary')
|
||||
scriptkit.value = await api.get('/dashboard/scriptkit')
|
||||
} catch { /* 看板统计失败不阻塞页面 */ } finally { loading.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -29,15 +36,14 @@ onMounted(async () => {
|
||||
<div class="metric"><span class="metric-icon amber"><PlayCircleOutlined /></span><div><b>{{ summary.runs }}</b><small>累计执行</small></div></div>
|
||||
<div class="metric"><span class="completion">{{ summary.runs ? Math.round(summary.completed_runs / summary.runs * 100) : 0 }}%</span><div><b>{{ summary.completed_runs }}</b><small>完成执行</small></div></div>
|
||||
</section>
|
||||
</a-skeleton>
|
||||
|
||||
<section class="work-grid">
|
||||
<div class="surface action-panel">
|
||||
<div class="panel-kicker">常用入口</div>
|
||||
<button v-if="auth.can('scenario.edit')" @click="router.push('/scenarios')"><span>配置新的业务场景<small>定义字段、目标和触发条件</small></span><ArrowRightOutlined /></button>
|
||||
<button v-if="auth.can('sop.execute')" @click="router.push('/execute')"><span>开始执行 SOP<small>根据客户回答逐步推进</small></span><ArrowRightOutlined /></button>
|
||||
<button v-if="auth.can('knowledge.edit')" @click="router.push('/scenarios')"><span>配置场景知识卡<small>在所属场景内维护输入关联与话术</small></span><ArrowRightOutlined /></button>
|
||||
<button v-if="auth.can('runs.view_all')||auth.can('runs.view_own')" @click="router.push('/runs')"><span>复盘执行记录<small>查看节点轨迹、回答与反馈</small></span><ArrowRightOutlined /></button>
|
||||
<button v-if="auth.can('scriptkit.edit')" @click="router.push('/scenarios')"><span>配置话术包<small>在所属场景内维护维度、权重、话术与联动</small></span><ArrowRightOutlined /></button>
|
||||
<button v-if="auth.can('runs.view_all')||auth.can('runs.view_own')" @click="router.push('/runs')"><span>复盘执行记录<small>查看节点轨迹、维度变化、回答与反馈</small></span><ArrowRightOutlined /></button>
|
||||
</div>
|
||||
<div class="surface doctrine-panel">
|
||||
<div class="panel-kicker">平台原则</div>
|
||||
@@ -45,6 +51,29 @@ onMounted(async () => {
|
||||
<div class="flow-note"><span>创建</span><i></i><span>配置</span><i></i><span>执行</span><i></i><span>复盘</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="analytics-grid">
|
||||
<div class="surface analytics-panel">
|
||||
<div class="panel-kicker">维度权重复盘 · 高频维度值</div>
|
||||
<a-table :data-source="scriptkit.dimensions.slice(0,10)" :pagination="false" size="small" row-key="value_key" :columns="[{title:'维度',dataIndex:'dim_key',width:110},{title:'维度值',dataIndex:'name'},{title:'覆盖执行',dataIndex:'runs',width:100,align:'right'},{title:'平均权重',dataIndex:'average_weight',width:100,align:'right'}]">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex==='dim_key'"><code>{{ record.dim_key }}</code></template>
|
||||
<template v-else-if="column.dataIndex==='average_weight'"><b>{{ Number(record.average_weight).toFixed(1) }}</b></template>
|
||||
</template>
|
||||
<template #emptyText><span class="panel-empty">执行后自动沉淀维度权重数据</span></template>
|
||||
</a-table>
|
||||
</div>
|
||||
<div class="surface analytics-panel">
|
||||
<div class="panel-kicker">话术反馈统计</div>
|
||||
<a-table :data-source="scriptkit.feedback.slice(0,10)" :pagination="false" size="small" row-key="script_key" :columns="[{title:'话术',dataIndex:'name'},{title:'反馈类型',dataIndex:'feedback_type',width:110},{title:'次数',dataIndex:'count',width:80,align:'right'}]">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex==='feedback_type'"><a-tag :color="record.feedback_type==='like'?'green':record.feedback_type==='report'?'orange':'red'">{{ feedbackTypeText(record.feedback_type) }}</a-tag></template>
|
||||
</template>
|
||||
<template #emptyText><span class="panel-empty">话术旁的点赞/举报/不合理会汇总到这里</span></template>
|
||||
</a-table>
|
||||
</div>
|
||||
</section>
|
||||
</a-skeleton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -62,6 +91,8 @@ onMounted(async () => {
|
||||
.doctrine-panel { background: #202825; border-color: #202825; color: white; }.doctrine-panel .panel-kicker { color: #89a099; }
|
||||
blockquote { margin: 30px 0 42px; font-family: "Noto Serif SC", serif; font-size: 22px; line-height: 1.65; }
|
||||
.flow-note { display: flex; align-items: center; color: #9cafaa; font-size: 12px; }.flow-note i { flex: 1; height: 1px; margin: 0 10px; background: #4a5a54; }
|
||||
@media (max-width: 900px) { .metric-strip { grid-template-columns: repeat(2,1fr); }.metric:nth-child(2) { border-right: 0; }.metric:nth-child(-n+2) { border-bottom: 1px solid var(--line); }.work-grid { grid-template-columns: 1fr; } }
|
||||
.analytics-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-top: 18px; }
|
||||
.analytics-panel { padding: 22px; }.analytics-panel code { padding: 2px 5px; color: #17654f; background: #eef5f2; border-radius: 3px; }.panel-empty { display: block; padding: 26px 0; color: var(--muted); font-size: 12px; text-align: center; }
|
||||
@media (max-width: 900px) { .metric-strip { grid-template-columns: repeat(2,1fr); }.metric:nth-child(2) { border-right: 0; }.metric:nth-child(-n+2) { border-bottom: 1px solid var(--line); }.work-grid, .analytics-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 520px) { .metric { min-height: 100px; padding: 16px; }.metric b { font-size: 24px; }.metric-icon, .completion { display: none; } }
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { CheckCircleOutlined, CopyOutlined, PlayCircleOutlined, SafetyCertificateOutlined, StopOutlined } from '@ant-design/icons-vue'
|
||||
import { CheckCircleOutlined, CopyOutlined, DislikeOutlined, LikeOutlined, PlayCircleOutlined, SafetyCertificateOutlined, StopOutlined, WarningOutlined } from '@ant-design/icons-vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import type { FormInstance } from 'ant-design-vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, apiMessage } from '@/api/client'
|
||||
import type { KnowledgeCollectionStep, PresentationField, PublicFieldView, ScenarioField, SOPNode, SOPRun } from '@/types'
|
||||
import type { PresentationField, PublicFieldView, ScenarioField, ScriptItem, SOPNode, SOPRun } from '@/types'
|
||||
|
||||
interface AvailableSOP { id:number; name:string; description:string; scenario_id:number; scenario_name:string }
|
||||
interface ResultSchema { fields:Array<Record<string,any>> }
|
||||
interface RunView { run:SOPRun; node:SOPNode; fields:ScenarioField[]; result_schema?:ResultSchema }
|
||||
const petDoctorDemoInput={
|
||||
order:{id:'ORDER-DEMO-001',items:[{product_id:'SKU-DEMO-001',product_name:'肠胃护理商品',image_url:'https://placehold.co/160x160/f3f6f4/17654f?text=Pet+Product',symptom_tags:['腹泻']}]},
|
||||
order:{id:'ORDER-DEMO-001',items:[{product_id:'1301138735118749696',product_name:'拜达尔 阿莫西林克拉维酸钾片 50mg/8片 猫犬专用',image_url:'https://placehold.co/160x160/f3f6f4/17654f?text=Pet+Product'}]},
|
||||
customer:{name:'王女士'},
|
||||
pet:{name:'团子',species:'猫'},
|
||||
}
|
||||
@@ -25,31 +25,132 @@ const answerForm=ref<FormInstance>()
|
||||
const feedback=reactive({score:5,comment:''});const feedbackSent=ref(false)
|
||||
const currentConfig=computed(()=>active.value?.node.config||{})
|
||||
const currentFields=computed(()=>{if(!active.value)return[];const node=active.value.node;if(node.type==='question'||node.type==='choice')return active.value.fields.filter(f=>f.field_key===currentConfig.value.field_key);if(node.type==='form')return active.value.fields.filter(f=>(currentConfig.value.field_keys||[]).includes(f.field_key));return[]})
|
||||
const collectionFields=computed(()=>active.value?.node.collection?.context_fields||[])
|
||||
const collectionSteps=computed(()=>active.value?.node.collection?.steps||[])
|
||||
const visibleKnowledgeGroups=computed(()=>{const groups=active.value?.node.outputs||[];const root=collectionSteps.value[0];if(!root)return groups;return groups.filter(group=>group.suggested||isSelected(root.field_key,group.name))})
|
||||
const stage=computed(()=>active.value?.node.stage)
|
||||
const stageDimensions=computed(()=>stage.value?.dimensions||[])
|
||||
const stageScripts=computed(()=>stage.value?.scripts||[])
|
||||
const stageFormFields=computed(()=>stage.value?.form?.fields||[])
|
||||
const startPresentation=computed(()=>active.value?.node.type==='start'?active.value.node.presentation:undefined)
|
||||
|
||||
// 阶段内只改本地草稿,点击“继续下一步”时一次性提交全部作答。
|
||||
const draftAnswers=reactive<Record<string,string[]>>({})
|
||||
const draftSelects=reactive<Record<string,boolean>>({})
|
||||
function initStageDrafts(){
|
||||
Object.keys(draftAnswers).forEach(key=>delete draftAnswers[key])
|
||||
Object.keys(draftSelects).forEach(key=>delete draftSelects[key])
|
||||
const state=(active.value?.run.script_state as any)||{}
|
||||
const savedAnswers=state.script_answers||{}
|
||||
const savedSelects=state.dimension_selects||{}
|
||||
for(const key of Object.keys(savedAnswers))draftAnswers[key]=Array.isArray(savedAnswers[key])?savedAnswers[key]:[]
|
||||
for(const key of Object.keys(savedSelects))draftSelects[key]=savedSelects[key]===true
|
||||
}
|
||||
function hasDraftAnswer(script:ScriptItem){return script.script_key in draftAnswers}
|
||||
function isDimensionSelected(valueKey:string){return draftSelects[valueKey]===true}
|
||||
|
||||
// 症状筛选题与本地作答后的渐进展示。
|
||||
const screenScript=computed(()=>stageScripts.value.find(item=>item.script_type==='screen'))
|
||||
const screeningPending=computed(()=>Boolean(stage.value?.screening_required&&screenScript.value&&!hasDraftAnswer(screenScript.value)))
|
||||
const screenSelectedKeys=computed(()=>new Set(draftAnswers[screenScript.value?.script_key||'']||[]))
|
||||
function findConfirmScript(valueKey:string){return stageScripts.value.find(item=>item.script_type==='confirm'&&item.dimension_value_key===valueKey)}
|
||||
function locallyExcluded(valueKey:string){
|
||||
if(draftSelects[valueKey]===false)return true
|
||||
if(screenScript.value&&hasDraftAnswer(screenScript.value)&&!screenSelectedKeys.value.has(valueKey))return true
|
||||
const confirm=findConfirmScript(valueKey)
|
||||
if(confirm&&(draftAnswers[confirm.script_key]||[]).includes('no'))return true
|
||||
return false
|
||||
}
|
||||
function locallyConfirmed(valueKey:string){
|
||||
if(draftSelects[valueKey]===true)return true
|
||||
if(screenScript.value&&hasDraftAnswer(screenScript.value)&&screenSelectedKeys.value.has(valueKey))return true
|
||||
const confirm=findConfirmScript(valueKey)
|
||||
if(confirm&&(draftAnswers[confirm.script_key]||[]).includes('yes'))return true
|
||||
return false
|
||||
}
|
||||
function scriptVisible(script:ScriptItem):boolean{
|
||||
if(script.script_type==='screen')return screeningPending.value
|
||||
if(screeningPending.value)return false
|
||||
const key=script.dimension_value_key
|
||||
if(!key)return true
|
||||
if(locallyExcluded(key))return false
|
||||
const weight=script.weight||0
|
||||
const confirmed=locallyConfirmed(key)||Boolean(script.show_threshold&&weight>=script.show_threshold)
|
||||
switch(script.script_type){
|
||||
case 'confirm':return !confirmed&&weight>0&&weight<(script.show_threshold||999)
|
||||
case 'choice':return confirmed
|
||||
case 'info':return weight>0&&weight>=(script.show_threshold||1)
|
||||
case 'message':return confirmed
|
||||
default:return true
|
||||
}
|
||||
}
|
||||
const visibleStageScripts=computed(()=>stageScripts.value.filter(scriptVisible))
|
||||
|
||||
function presentationText(value:unknown){if(Array.isArray(value))return value.join('、');if(value===null||value===undefined||value==='')return '-';return String(value)}
|
||||
function itemImage(item:{fields:PresentationField[]}){return item.fields.find(field=>field.kind==='image')}
|
||||
function itemDetails(item:{fields:PresentationField[]}){return item.fields.filter(field=>field.kind!=='image')}
|
||||
function hideBrokenImage(event:Event){const image=event.target as HTMLImageElement;image.style.display='none';image.parentElement?.classList.add('no-image')}
|
||||
async function copyOpening(){const content=startPresentation.value?.opening?.content;if(!content)return;try{await navigator.clipboard.writeText(content);message.success('开场话术已复制')}catch{message.warning('复制失败,请手动选择话术') }}
|
||||
function collectionOptions(step:KnowledgeCollectionStep){if(!step.from_field)return step.options;const value=answers[step.from_field];const parents=Array.isArray(value)?value:value?[value]:[];return step.options.filter(option=>(option.parents||[]).some(parent=>parents.includes(parent)))}
|
||||
function collectionBlocked(step:KnowledgeCollectionStep){if(!step.from_field)return false;const value=answers[step.from_field];return !Array.isArray(value)||value.length===0}
|
||||
function onCollectionChange(step:KnowledgeCollectionStep){let clear=false;for(const item of collectionSteps.value){if(clear){const allowed=new Set(collectionOptions(item).map(option=>option.value));const current=Array.isArray(answers[item.field_key])?answers[item.field_key]:[];answers[item.field_key]=current.filter((value:string)=>allowed.has(value))}if(item.field_key===step.field_key)clear=true}}
|
||||
async function copyText(content:string){if(!content)return;try{await navigator.clipboard.writeText(content);message.success('话术已复制')}catch{message.warning('复制失败,请手动选择话术') }}
|
||||
async function recordScriptUsage(script:ScriptItem, scene:string){if(!active.value)return;try{await api.post(`/runs/${active.value.run.id}/script-usage`,{node_key:active.value.node.node_key,script_key:script.script_key,script_title:script.name,template:script.content,scene});message.success('话术已记录')}catch(error){message.error(apiMessage(error))}}
|
||||
function scriptTypeLabel(type:string){const labels:Record<string,string>={confirm:'确认性提问',info:'信息收集',choice:'选择题',screen:'症状筛选',template:'开场话术',fallback:'兜底话术',message:'提示话术'};return labels[type]||type}
|
||||
function publicFieldRules(field:PublicFieldView){return field.required?[{required:true,message:`请填写${field.name}`,trigger:['change','blur']}]:[]}
|
||||
function collectionStepRules(step:KnowledgeCollectionStep){return step.required?[{required:true,type:step.multiple?'array':'string',message:`请选择${step.name}`,trigger:'change'}]:[]}
|
||||
function collectionStep(index:number){return collectionSteps.value[index]}
|
||||
function selectedValues(fieldKey:string){return Array.isArray(answers[fieldKey])?answers[fieldKey]:[]}
|
||||
function isSelected(fieldKey:string,value:string){return selectedValues(fieldKey).includes(value)}
|
||||
function toggleKnowledge(step:KnowledgeCollectionStep,value:string,checked:boolean){const current=selectedValues(step.field_key);answers[step.field_key]=checked?[...new Set([...current,value])]:current.filter((item:string)=>item!==value);onCollectionChange(step)}
|
||||
function relationLabel(relation:string){return currentConfig.value.knowledge_selector?.relation_labels?.[relation]||relation}
|
||||
async function load(){loading.value=true;try{sops.value=(await api.get<{items:AvailableSOP[]}>('/available-sops')).items;const runID=Number(route.query.run||0);if(runID>0){active.value=await api.get<RunView>(`/runs/${runID}`);await prepareNodeAnswers()}else{active.value=null;await clearAnswers()}}catch(error){message.error(apiMessage(error));await router.replace({query:{}})}finally{loading.value=false}}
|
||||
function selectScriptOption(script:ScriptItem,optionKey:string){if(script.script_type==='choice'||script.script_type==='screen'||script.multiple){toggleChoice(script,optionKey);return}draftAnswers[script.script_key]=[optionKey]}
|
||||
function optionSelected(script:ScriptItem,optionKey:string){return (draftAnswers[script.script_key]||[]).includes(optionKey)}
|
||||
function selectedOptionLabels(script:ScriptItem){return (draftAnswers[script.script_key]||[]).map(key=>script.options?.find(option=>option.option_key===key)?.label||key)}
|
||||
function isScriptAnswered(script:ScriptItem){return hasDraftAnswer(script)}
|
||||
interface ScriptGroup{key:string;name:string;weight:number;scripts:ScriptItem[];answeredCount:number;total:number}
|
||||
const scriptGroups=computed(()=>{
|
||||
const groups:ScriptGroup[]=[]
|
||||
const flat:ScriptItem[]=[]
|
||||
const byKey=new Map<string,ScriptGroup>()
|
||||
for(const script of visibleStageScripts.value){
|
||||
const key=script.dimension_value_key||''
|
||||
if(!key){flat.push(script);continue}
|
||||
let name=key, weight=script.weight||0
|
||||
for(const dim of stageDimensions.value){const value=dim.values.find(item=>item.value_key===key);if(value){name=value.name;weight=value.weight;break}}
|
||||
let group=byKey.get(key)
|
||||
if(!group){group={key,name,weight,scripts:[],answeredCount:0,total:0};byKey.set(key,group);groups.push(group)}
|
||||
group.scripts.push(script)
|
||||
}
|
||||
for(const group of groups){
|
||||
group.total=group.scripts.length
|
||||
group.answeredCount=group.scripts.filter(script=>isScriptAnswered(script)).length
|
||||
}
|
||||
groups.sort((a,b)=>(b.weight-a.weight)||a.name.localeCompare(b.name,'zh'))
|
||||
return {groups,flat}
|
||||
})
|
||||
const groupPrefs=reactive<Record<string,boolean>>({})
|
||||
const scriptPrefs=reactive<Record<string,boolean>>({})
|
||||
function isGroupCollapsed(group:ScriptGroup){const pref=groupPrefs[group.key];if(pref!==undefined)return pref;return group.total>0&&group.answeredCount===group.total}
|
||||
function toggleGroup(group:ScriptGroup){groupPrefs[group.key]=!isGroupCollapsed(group)}
|
||||
function isScriptCollapsed(script:ScriptItem){if(!isScriptAnswered(script))return false;const pref=scriptPrefs[script.script_key];return pref!==undefined?pref:true}
|
||||
function toggleScript(script:ScriptItem){scriptPrefs[script.script_key]=!isScriptCollapsed(script)}
|
||||
function toggleChoice(script:ScriptItem,optionKey:string){const current=draftAnswers[script.script_key]||[];const next=current.includes(optionKey)?current.filter(key=>key!==optionKey):[...current,optionKey];draftAnswers[script.script_key]=next}
|
||||
function toggleDimension(valueKey:string){draftSelects[valueKey]=!(draftSelects[valueKey]===true)}
|
||||
async function scriptFeedback(script:ScriptItem,type:'like'|'report'|'unreasonable'){if(!active.value)return;try{await api.post(`/runs/${active.value.run.id}/script-feedback`,{node_key:active.value.node.node_key,script_key:script.script_key,feedback_type:type});message.success('反馈已记录')}catch(error){message.error(apiMessage(error))}}
|
||||
const stageFormHint=computed(()=>{if(!stage.value)return'';if(screeningPending.value)return '请先完成症状筛选,再继续作答';if(stage.value.stage_key==='diagnosis'&&!stageHasConfirmedDisease())return '请至少确认一个疾病方向,确认后才能进入药品推荐';if(stage.value.form)return '填写必填信息后,点击“继续下一步”统一提交';return '完成下方作答后,点击“继续下一步”统一提交'})
|
||||
function stageHasConfirmedDisease(){if(!stage.value||stage.value.stage_key!=='diagnosis')return true;const keys=new Set(stageDimensions.value.flatMap(dim=>dim.values.map(value=>value.value_key)));for(const key of keys){if(locallyConfirmed(key))return true}return false}
|
||||
async function load(){loading.value=true;try{sops.value=(await api.get<{items:AvailableSOP[]}>('/available-sops')).items;const runID=Number(route.query.run||0);if(runID>0){active.value=await api.get<RunView>(`/runs/${runID}`);await prepareNodeAnswers();initStageDrafts()}else{active.value=null;await clearAnswers()}}catch(error){message.error(apiMessage(error));await router.replace({query:{}})}finally{loading.value=false}}
|
||||
async function clearAnswers(){Object.keys(answers).forEach(k=>delete answers[k]);await nextTick();answerForm.value?.clearValidate()}
|
||||
async function prepareNodeAnswers(){await clearAnswers();if(!active.value)return;for(const field of currentFields.value){const value=active.value.run.answers?.[field.field_key];if(value!==undefined&&value!==null)answers[field.field_key]=value}}
|
||||
async function prepareNodeAnswers(){await clearAnswers();if(!active.value)return;for(const field of currentFields.value){const value=active.value.run.answers?.[field.field_key];if(value!==undefined&&value!==null)answers[field.field_key]=value}for(const field of stageFormFields.value){const value=active.value.run.answers?.[field.key];if(value!==undefined&&value!==null)answers[field.key]=value}}
|
||||
async function openStart(item:AvailableSOP){selectedSOP.value=item;startForm.external_ref=`business:${Date.now()}`;startForm.input_text=item.scenario_name==='宠物医生问诊问药'?JSON.stringify(petDoctorDemoInput,null,2):'{}';startForm.initial_values={};startOpen.value=true}
|
||||
async function start(){if(!selectedSOP.value)return;let input:Record<string,any>;try{input=JSON.parse(startForm.input_text)}catch{return message.warning('业务数据 JSON 格式不正确')}submitting.value=true;try{active.value=await api.post<RunView>('/runs',{sop_id:selectedSOP.value.id,input,external_ref:startForm.external_ref,initial_values:startForm.initial_values});startOpen.value=false;await router.replace({query:{run:String(active.value.run.id)}});await prepareNodeAnswers();Object.assign(feedback,{score:5,comment:''});feedbackSent.value=false;resultSaved.value=Boolean(active.value.run.final_result)}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
||||
async function next(){if(!active.value)return;try{await answerForm.value?.validate()}catch{return}for(const step of collectionSteps.value){if(step.required&&!selectedValues(step.field_key).length)return message.warning(`请选择${step.name}`)}submitting.value=true;try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/answer`,{node_key:active.value.node.node_key,answers:{...answers}});await prepareNodeAnswers()}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
||||
async function start(){if(!selectedSOP.value)return;let input:Record<string,any>;try{input=JSON.parse(startForm.input_text)}catch{return message.warning('业务数据 JSON 格式不正确')}submitting.value=true;try{active.value=await api.post<RunView>('/runs',{sop_id:selectedSOP.value.id,input,external_ref:startForm.external_ref,initial_values:startForm.initial_values});startOpen.value=false;await router.replace({query:{run:String(active.value.run.id)}});await prepareNodeAnswers();initStageDrafts();Object.assign(feedback,{score:5,comment:''});feedbackSent.value=false;resultSaved.value=Boolean(active.value.run.final_result)}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
||||
async function submitStage():Promise<boolean>{
|
||||
if(!active.value||!stage.value)return false
|
||||
if(stage.value.form){try{await answerForm.value?.validate()}catch{message.warning('请先完成本阶段必填信息');return false}}
|
||||
// 拟诊阶段必须至少确认一个疾病方向,否则药品推荐节点没有药物推荐。
|
||||
if(!stageHasConfirmedDisease()){message.warning('请至少确认一个疾病方向,确认后才能进入药品推荐');return false}
|
||||
// 只提交当前阶段的话术与维度勾选,避免把上一阶段留下的答案混进来。
|
||||
const stageScriptKeys=new Set(stageScripts.value.map(item=>item.script_key))
|
||||
const scriptAnswers=Object.keys(draftAnswers)
|
||||
.filter(scriptKey=>stageScriptKeys.has(scriptKey))
|
||||
.map(scriptKey=>({script_key:scriptKey,option_keys:draftAnswers[scriptKey]}))
|
||||
const stageValueKeys=new Set(stageDimensions.value.flatMap(dim=>dim.values.map(value=>value.value_key)))
|
||||
const dimensionSelects:Record<string,boolean>={}
|
||||
for(const [key,selected] of Object.entries(draftSelects)){if(stageValueKeys.has(key))dimensionSelects[key]=selected}
|
||||
active.value=await api.post<RunView>(`/runs/${active.value.run.id}/answer`,{node_key:active.value.node.node_key,script_answers:scriptAnswers,dimension_selects:dimensionSelects,answers:{...answers}})
|
||||
active.value=await api.post<RunView>(`/runs/${active.value.run.id}/next`)
|
||||
return true
|
||||
}
|
||||
async function next(){if(!active.value)return;if(active.value.node.type==='stage'){submitting.value=true;try{if(await submitStage()){await prepareNodeAnswers();initStageDrafts();message.success('本阶段回答已统一提交')}}catch(error){message.error(apiMessage(error))}finally{submitting.value=false};return}if(currentFields.value.length){try{await answerForm.value?.validate()}catch{return}}submitting.value=true;try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/answer`,{node_key:active.value.node.node_key,answers:{...answers}});await prepareNodeAnswers()}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
||||
async function back(){if(!active.value)return;submitting.value=true;try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/back`);await prepareNodeAnswers();initStageDrafts();message.success('已返回上一步')}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
||||
function finishManually(){
|
||||
if(!active.value)return
|
||||
Modal.confirm({
|
||||
@@ -68,6 +169,7 @@ function finishManually(){
|
||||
function inputFor(field:ScenarioField){return field.field_type}
|
||||
function validationNumber(field:ScenarioField,key:string){const value=field.validation?.[key];return typeof value==='number'?value:undefined}
|
||||
function fieldRequired(field:ScenarioField){return field.required||(currentConfig.value.required_field_keys||[]).includes(field.field_key)||(['question','choice'].includes(active.value?.node.type||'')&¤tConfig.value.required===true)}
|
||||
function stageFieldRequired(field:PublicFieldView){return field.required}
|
||||
function fieldRules(field:ScenarioField){
|
||||
const rules:any[]=[]
|
||||
if(fieldRequired(field))rules.push({required:true,message:`请填写${field.field_name}`,trigger:['change','blur']})
|
||||
@@ -87,13 +189,14 @@ function fieldRules(field:ScenarioField){
|
||||
async function sendFeedback(){if(!active.value)return;submitting.value=true;try{await api.post(`/runs/${active.value.run.id}/feedback`,feedback);feedbackSent.value=true;message.success('反馈已提交')}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
||||
async function saveFinalResult(){if(!active.value)return;let finalResult:Record<string,any>|null;try{finalResult=finalResultText.value.trim()?JSON.parse(finalResultText.value):null}catch{return message.warning('最终结果 JSON 格式不正确')}submitting.value=true;try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/finish`,{result:active.value.run.result||'finish',final_result:finalResult});resultSaved.value=true;message.success('最终结果已保存到本次执行')}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
||||
async function reset(){active.value=null;await router.replace({query:{}});await load()}
|
||||
watch(()=>active.value?.node.node_key,()=>{Object.keys(groupPrefs).forEach(key=>delete groupPrefs[key]);Object.keys(scriptPrefs).forEach(key=>delete scriptPrefs[key]);initStageDrafts()})
|
||||
watch(()=>route.query.run,()=>load())
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell execution-shell">
|
||||
<div class="page-heading"><div><h1>执行话术</h1><p>选择 SOP,系统会根据客户回答给出下一步。</p></div></div>
|
||||
<div class="page-heading"><div><h1>执行话术</h1><p>选择 SOP,系统会根据客户回答和维度权重给出下一步话术。</p></div></div>
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="!active" class="sop-catalog">
|
||||
<button v-for="item in sops" :key="item.id" class="sop-entry surface" @click="openStart(item)"><span class="entry-seq">{{ String(item.id).padStart(2,'0') }}</span><div><small>{{ item.scenario_name }}</small><h2>{{ item.name }}</h2><p>{{ item.description || '按标准步骤执行这套话术流程。' }}</p></div><span class="play"><PlayCircleOutlined /></span></button>
|
||||
@@ -102,7 +205,7 @@ onMounted(load)
|
||||
<div v-else class="run-workspace">
|
||||
<aside class="run-context">
|
||||
<span class="run-label">RUN-{{ String(active.run.id).padStart(5,'0') }}</span>
|
||||
<h2>{{ active.run.status==='completed'?'执行已结束':'执行进行中' }}</h2><p>客户回答会自动保存在当前执行记录中。</p>
|
||||
<h2>{{ active.run.status==='completed'?'执行已结束':'执行进行中' }}</h2><p>阶段内先作答,点击“继续下一步”统一提交。</p>
|
||||
<div class="context-meta"><span>当前节点</span><b>{{ active.node.title }}</b></div><div class="context-meta"><span>已采集字段</span><b>{{ Object.keys(active.run.answers || {}).length }}</b></div>
|
||||
<div class="privacy-note"><SafetyCertificateOutlined /><span>敏感信息请遵循企业数据规范</span></div>
|
||||
</aside>
|
||||
@@ -120,20 +223,90 @@ onMounted(load)
|
||||
<div><span v-for="field in itemDetails(item)" :key="field.key"><small>{{ field.label }}</small><b>{{ presentationText(field.value) }}</b></span></div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="startPresentation.opening" class="opening-copy">
|
||||
<div class="presentation-heading"><b>{{ startPresentation.opening.title }}</b><a-tooltip title="复制话术"><a-button type="text" aria-label="复制开场话术" @click="copyOpening"><CopyOutlined /></a-button></a-tooltip></div>
|
||||
<p>{{ startPresentation.opening.content }}</p>
|
||||
</section>
|
||||
<section v-if="stage" class="stage-panel">
|
||||
<div class="stage-heading"><b>{{ stage.name }}</b><span>{{ stage.purpose }}</span></div>
|
||||
<div v-for="dimension in stageDimensions" :key="dimension.dim_key" class="dimension-block">
|
||||
<div class="dimension-head"><b>{{ dimension.name }}</b><span>点击勾选确认,权重越高越靠前</span></div>
|
||||
<div class="dimension-chips">
|
||||
<button v-for="value in dimension.values" :key="value.value_key" class="dimension-chip" :class="{hot:value.hot,selected:isDimensionSelected(value.value_key)}" @click="toggleDimension(value.value_key)"><b>{{ value.name }}</b><span class="heat">{{ value.weight }}</span></button>
|
||||
<span v-if="!dimension.values.length" class="dimension-empty">暂无维度值</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="visibleStageScripts.length" class="stage-scripts">
|
||||
<div class="stage-heading sub"><b>当前匹配话术</b><span>按症状/疾病分组,本地作答、结束统一提交</span></div>
|
||||
<article v-for="script in scriptGroups.flat" :key="script.script_key" class="stage-script" :class="`type-${script.script_type}`">
|
||||
<div class="script-head"><b>{{ script.name }}</b><span>{{ scriptTypeLabel(script.script_type) }}</span></div>
|
||||
<p class="script-content">{{ script.content }}</p>
|
||||
<div v-if="script.options?.length" class="script-options">
|
||||
<template v-if="script.script_type==='choice'||script.script_type==='screen'">
|
||||
<a-button v-for="option in script.options" :key="option.option_key" size="small" :type="optionSelected(script,option.option_key)?'primary':'default'" @click="toggleChoice(script,option.option_key)">{{ option.label }}</a-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-button v-for="option in script.options" :key="option.option_key" size="small" :type="optionSelected(script,option.option_key)?'primary':'default'" @click="selectScriptOption(script,option.option_key)">{{ option.label }}</a-button>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="script.script_type==='choice'||script.script_type==='screen'" class="choice-selection">{{ script.multiple?'可多选':'单选' }}<template v-if="selectedOptionLabels(script).length"> · 已选:{{ selectedOptionLabels(script).join('、') }}</template></p>
|
||||
<div class="script-actions">
|
||||
<a-button size="small" @click="copyText(script.content)"><CopyOutlined />复制</a-button>
|
||||
<a-button size="small" @click="recordScriptUsage(script,`stage:${stage.stage_key}`)">使用</a-button>
|
||||
<span class="script-feedback"><a-tooltip title="话术有帮助"><a-button size="small" type="text" @click="scriptFeedback(script,'like')"><LikeOutlined /></a-button></a-tooltip><a-tooltip title="话术不准确"><a-button size="small" type="text" @click="scriptFeedback(script,'report')"><DislikeOutlined /></a-button></a-tooltip><a-tooltip title="话术不合理"><a-button size="small" type="text" @click="scriptFeedback(script,'unreasonable')"><WarningOutlined /></a-button></a-tooltip></span>
|
||||
</div>
|
||||
</article>
|
||||
<section v-for="group in scriptGroups.groups" :key="group.key" class="script-group" :class="{collapsed:isGroupCollapsed(group)}">
|
||||
<button class="group-head" type="button" @click="toggleGroup(group)">
|
||||
<span class="group-arrow">{{ isGroupCollapsed(group)?'▸':'▾' }}</span>
|
||||
<b>{{ group.name }}</b>
|
||||
<span class="group-meta">{{ group.answeredCount }}/{{ group.total }} 已答 · 权重 {{ group.weight }}</span>
|
||||
</button>
|
||||
<div v-if="!isGroupCollapsed(group)" class="group-body">
|
||||
<article v-for="script in group.scripts" :key="script.script_key" class="stage-script" :class="[`type-${script.script_type}`,{answered:isScriptAnswered(script)}]">
|
||||
<button v-if="isScriptAnswered(script)" class="answered-fold" type="button" @click="toggleScript(script)">
|
||||
<span class="group-arrow">{{ isScriptCollapsed(script)?'▸':'▾' }}</span>
|
||||
<b>{{ script.name }}</b>
|
||||
<span class="answered-summary">{{ selectedOptionLabels(script).join('、') || '已答' }}</span>
|
||||
</button>
|
||||
<template v-if="!isScriptCollapsed(script)">
|
||||
<div class="script-head"><b>{{ script.name }}</b><span>{{ scriptTypeLabel(script.script_type) }}</span></div>
|
||||
<p class="script-content">{{ script.content }}</p>
|
||||
<div v-if="script.products?.length" class="script-products">
|
||||
<div v-for="product in script.products" :key="product.sku_code" class="product-card"><b>{{ product.product_name }}</b><small>SKU:{{ product.sku_code }}</small></div>
|
||||
</div>
|
||||
<div v-if="script.options?.length" class="script-options">
|
||||
<template v-if="script.script_type==='choice'||script.script_type==='screen'">
|
||||
<a-button v-for="option in script.options" :key="option.option_key" size="small" :type="optionSelected(script,option.option_key)?'primary':'default'" @click="toggleChoice(script,option.option_key)">{{ option.label }}</a-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-button v-for="option in script.options" :key="option.option_key" size="small" :type="optionSelected(script,option.option_key)?'primary':'default'" @click="selectScriptOption(script,option.option_key)">{{ option.label }}</a-button>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="script.script_type==='choice'||script.script_type==='screen'" class="choice-selection">{{ script.multiple?'可多选':'单选' }}<template v-if="selectedOptionLabels(script).length"> · 已选:{{ selectedOptionLabels(script).join('、') }}</template></p>
|
||||
<div class="script-actions">
|
||||
<a-button size="small" @click="copyText(script.content)"><CopyOutlined />复制</a-button>
|
||||
<a-button size="small" @click="recordScriptUsage(script,`stage:${stage.stage_key}`)">使用</a-button>
|
||||
<span class="script-feedback"><a-tooltip title="话术有帮助"><a-button size="small" type="text" @click="scriptFeedback(script,'like')"><LikeOutlined /></a-button></a-tooltip><a-tooltip title="话术不准确"><a-button size="small" type="text" @click="scriptFeedback(script,'report')"><DislikeOutlined /></a-button></a-tooltip><a-tooltip title="话术不合理"><a-button size="small" type="text" @click="scriptFeedback(script,'unreasonable')"><WarningOutlined /></a-button></a-tooltip></span>
|
||||
</div>
|
||||
</template>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="visibleKnowledgeGroups.length" class="knowledge-groups">
|
||||
<article v-for="group in visibleKnowledgeGroups" :key="group.key" class="knowledge-group">
|
||||
<div class="knowledge-head"><a-checkbox v-if="active.node.collection&&collectionStep(0)" :checked="isSelected(collectionStep(0).field_key,group.name)" @change="toggleKnowledge(collectionStep(0),group.name,$event.target.checked)"><b>{{ group.name }}</b></a-checkbox><b v-else>{{ group.name }}</b><span>{{ group.suggested?'系统推断':group.type }}</span></div>
|
||||
<div v-for="(items,relation) in group.relations" :key="relation" class="relation-block"><small>{{ relationLabel(String(relation)) }}</small><div v-for="item in items" :key="item.key" class="standard-copy selectable-knowledge"><a-checkbox v-if="active.node.collection&&collectionStep(1)?.from_field===collectionStep(0)?.field_key&¤tConfig.knowledge_collection?.steps?.[1]?.relation_type===relation" :checked="isSelected(collectionStep(1).field_key,item.name)" :disabled="!isSelected(collectionStep(0).field_key,group.name)" @change="toggleKnowledge(collectionStep(1),item.name,$event.target.checked)"><b>{{ item.name }}</b></a-checkbox><b v-else>{{ item.name }}</b><p>{{ item.content.template || item.content.standard_copy || item.content.product_name || item.name }}</p><small v-if="item.content.sku_code">SKU:{{ item.content.sku_code }}</small></div></div>
|
||||
</article>
|
||||
</div>
|
||||
<a-form v-if="stage.form" ref="answerForm" :model="answers" layout="vertical" class="answer-form">
|
||||
<div class="stage-heading sub"><b>{{ stage.name }}信息补充</b><span>填写后点击“继续下一步”统一提交</span></div>
|
||||
<a-form-item v-for="field in stageFormFields" :key="field.key" :name="field.key" :label="field.name" :required="stageFieldRequired(field)" :rules="publicFieldRules(field)">
|
||||
<a-input v-if="field.type==='text'" v-model:value="answers[field.key]" :maxlength="Number(field.validation?.max_length||0)||undefined" />
|
||||
<a-textarea v-else-if="field.type==='textarea'" v-model:value="answers[field.key]" :rows="3" />
|
||||
<a-input-number v-else-if="field.type==='number'" v-model:value="answers[field.key]" style="width:100%" />
|
||||
<a-radio-group v-else-if="field.type==='boolean'" v-model:value="answers[field.key]"><a-radio :value="true">是</a-radio><a-radio :value="false">否</a-radio></a-radio-group>
|
||||
<a-select v-else-if="field.type==='select'" v-model:value="answers[field.key]" :options="(field.options||[]).map((value:unknown)=>({value:String(value),label:String(value)}))" />
|
||||
<a-input v-else v-model:value="answers[field.key]" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<p class="stage-purpose">本阶段目的:{{ stage.purpose }}</p>
|
||||
</section>
|
||||
<div v-if="active.run.status==='completed'" class="completed-state"><CheckCircleOutlined /><h3>{{ active.run.result==='manual'?'已人工结束':active.node.type==='escalate'?'已转交处理':'本次执行已完成' }}</h3><p>{{ active.run.result==='manual'?'本次执行由操作人员在当前节点结束。':active.node.content }}</p><section class="result-collector"><div><b>最终结果</b><span>格式由当前场景定义,也可以留空</span></div><pre>{{ JSON.stringify(active.result_schema||{fields:[]},null,2) }}</pre><a-textarea v-model:value="finalResultText" :rows="10" spellcheck="false"/><a-button type="primary" :loading="submitting" @click="saveFinalResult">{{ resultSaved?'更新最终结果':'保存最终结果' }}</a-button></section><div v-if="!feedbackSent" class="quick-feedback"><span>这套 SOP 是否清晰好用?</span><a-rate v-model:value="feedback.score"/><a-textarea v-model:value="feedback.comment" :rows="2" :maxlength="2000" placeholder="可填写需要改进的步骤"/><a-button type="primary" :loading="submitting" @click="sendFeedback">提交反馈</a-button></div><div v-else class="feedback-thanks">反馈已记录,将用于后续优化。</div><a-button @click="reset">执行另一套 SOP</a-button></div>
|
||||
<template v-else>
|
||||
<a-form ref="answerForm" :model="answers" layout="vertical" class="answer-form">
|
||||
<a-form v-if="currentFields.length&&!stage" ref="answerForm" :model="answers" layout="vertical" class="answer-form">
|
||||
<a-form-item v-for="field in currentFields" :key="field.id" :name="field.field_key" :label="field.field_name" :required="fieldRequired(field)" :rules="fieldRules(field)">
|
||||
<a-radio-group v-if="active.node.type==='choice'" v-model:value="answers[field.field_key]" class="choice-group"><a-radio-button v-for="option in currentConfig.options||[]" :key="option" :value="option">{{ option }}</a-radio-button></a-radio-group>
|
||||
<a-input v-else-if="inputFor(field)==='text'" v-model:value="answers[field.field_key]" :maxlength="validationNumber(field,'max_length')" show-count />
|
||||
@@ -145,22 +318,8 @@ onMounted(load)
|
||||
<a-date-picker v-else-if="inputFor(field)==='date'" v-model:value="answers[field.field_key]" value-format="YYYY-MM-DD" style="width:100%" />
|
||||
<a-input v-else v-model:value="answers[field.field_key]" />
|
||||
</a-form-item>
|
||||
<template v-if="active.node.collection">
|
||||
<div class="collection-heading"><b>{{ active.node.collection.context_title||'补充信息' }}</b><span>{{ active.node.collection.context_hint }}</span></div>
|
||||
<a-form-item v-for="field in collectionFields" :key="field.key" :name="field.key" :label="field.name" :required="field.required" :rules="publicFieldRules(field)">
|
||||
<a-input v-if="field.type==='text'" v-model:value="answers[field.key]" />
|
||||
<a-textarea v-else-if="field.type==='textarea'" v-model:value="answers[field.key]" :rows="3" />
|
||||
<a-input-number v-else-if="field.type==='number'" v-model:value="answers[field.key]" style="width:100%" />
|
||||
<a-select v-else-if="field.type==='select'" v-model:value="answers[field.key]" :options="(field.options||[]).map(value=>({value,label:value}))" />
|
||||
</a-form-item>
|
||||
<div class="collection-heading selection-summary"><b>{{ active.node.collection.selection_title||'确认选择' }}</b><span>{{ active.node.collection.selection_hint }}</span></div>
|
||||
<a-form-item v-for="step in collectionSteps" :key="`selector-${step.field_key}`" :name="step.field_key" :label="step.name" :required="step.required" :rules="collectionStepRules(step)">
|
||||
<a-select v-model:value="answers[step.field_key]" :mode="step.multiple?'multiple':undefined" show-search allow-clear :disabled="collectionBlocked(step)" :placeholder="collectionBlocked(step)?'请先选择上一级':`搜索并选择${step.name}`" :options="collectionOptions(step).map(option=>({value:option.value,label:option.suggested?`${option.label}(系统推断)`:option.label}))" @change="onCollectionChange(step)" />
|
||||
</a-form-item>
|
||||
<div class="selected-summary" v-for="step in collectionSteps" :key="step.field_key"><span>{{ step.name }}</span><b>{{ selectedValues(step.field_key).join('、')||'尚未选择' }}</b></div>
|
||||
</template>
|
||||
</a-form>
|
||||
<div class="run-actions"><span>{{ currentFields.length||active.node.collection ? '填写并确认后继续下一步' : '确认当前话术已完成' }}</span><div><a-button danger :disabled="submitting" @click="finishManually"><StopOutlined />人工结束</a-button><a-button type="primary" size="large" :loading="submitting" @click="next">继续下一步</a-button></div></div>
|
||||
<div class="run-actions"><span>{{ stage ? stageFormHint : (currentFields.length?'填写并确认后继续下一步':'确认当前话术已完成') }}</span><div><a-button v-if="active.node.type!=='start'" :disabled="submitting" @click="back">上一步</a-button><a-button danger :disabled="submitting" @click="finishManually"><StopOutlined />人工结束</a-button><a-button type="primary" size="large" :disabled="submitting||(stage&&stage.stage_key==='diagnosis'&&!stageHasConfirmedDisease())" :loading="submitting" @click="next">继续下一步</a-button></div></div>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
@@ -172,12 +331,12 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.execution-shell{max-width:1220px}.sop-catalog{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.sop-entry{min-height:150px;display:grid;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;padding:22px;text-align:left;cursor:pointer}.sop-entry:hover{border-color:#9ac8b8;box-shadow:0 8px 24px rgba(32,40,37,.07)}.entry-seq{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small{color:var(--green);font-weight:650}.sop-entry h2{margin:8px 0 7px;font-family:"Noto Serif SC",serif;font-size:20px}.sop-entry p{margin:0;color:var(--muted);line-height:1.6}.play{width:38px;height:38px;display:grid;place-items:center;color:white;background:#202825;border-radius:4px;font-size:18px}.run-workspace{display:grid;grid-template-columns:270px minmax(0,1fr);gap:18px}.run-context{align-self:start;padding:24px;color:white;background:#202825;border-radius:6px;position:sticky;top:86px}.run-label{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2{margin:12px 0 8px;font-family:"Noto Serif SC",serif}.run-context>p{margin:0 0 28px;color:#aebdb7;line-height:1.6}.context-meta{display:flex;align-items:center;justify-content:space-between;padding:13px 0;border-top:1px solid #3a4742}.context-meta span{color:#9eada7;font-size:12px}.privacy-note{display:flex;gap:8px;margin-top:28px;color:#889b93;font-size:11px}.conversation{min-height:590px;padding:30px 34px}.conversation-progress{display:flex;align-items:center;gap:8px;color:#66736d;font-size:12px}.conversation-progress span{width:8px;height:8px;border-radius:50%;background:#d08736;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content{padding:44px 0 26px}.node-content small{color:var(--green);font-size:10px;font-weight:800}.node-content h1{margin:8px 0 22px;font-family:"Noto Serif SC",serif;font-size:28px}.node-content blockquote{margin:0;padding:18px 20px;color:#29342f;background:#f2f6f4;border-left:3px solid var(--green);font-size:17px;line-height:1.8}.answer-form{max-width:680px}.choice-group{display:flex;flex-wrap:wrap}.run-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:24px -34px -30px;padding:18px 34px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.run-actions>div{display:flex;align-items:center;gap:9px}.completed-state{padding:40px 0;text-align:center}.completed-state>span{color:var(--green);font-size:50px}.completed-state h3{margin:14px 0 8px;font-family:"Noto Serif SC",serif;font-size:24px}.completed-state p{margin:0 0 24px;color:var(--muted)}.quick-feedback{max-width:620px;display:grid;grid-template-columns:180px 1fr auto;align-items:center;gap:12px;margin:0 auto 18px;padding:16px;text-align:left;background:#f5f8f6;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.quick-feedback>span{font-size:12px;font-weight:650}.quick-feedback :deep(.ant-rate){font-size:18px}.quick-feedback :deep(.ant-input){grid-column:1/3}.feedback-thanks{max-width:520px;margin:0 auto 18px;padding:12px;color:var(--green);background:#edf6f2;font-size:12px}
|
||||
.knowledge-head{display:flex;align-items:center;justify-content:space-between;padding:12px 0}.knowledge-head span{color:var(--muted);font-size:11px}.standard-copy{padding:14px 16px;border-left:3px solid var(--green);background:#f2f7f5}.standard-copy small{font-size:10px;font-weight:750}.standard-copy p{margin:6px 0 0;line-height:1.7}
|
||||
.knowledge-groups{display:grid;gap:14px;margin-bottom:24px}.knowledge-group{padding:0 16px 16px;border:1px solid var(--line);border-left:3px solid var(--green);background:#fff}.relation-block{margin-top:10px}.relation-block>small{display:block;margin-bottom:6px;color:var(--muted);font-weight:700}.relation-block .standard-copy{margin-top:6px}.relation-block .standard-copy b{font-size:12px}
|
||||
.start-presentation{display:grid;gap:22px;margin-bottom:28px}.start-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.start-summary>div{min-width:0;display:flex;flex-direction:column;gap:5px;padding:14px 16px}.start-summary>div:nth-child(odd){border-right:1px solid var(--line)}.start-summary small,.order-product small{color:var(--muted);font-size:10px}.start-summary b,.order-product b{overflow-wrap:anywhere;font-size:13px}.presentation-heading{min-height:36px;display:flex;align-items:center;justify-content:space-between;gap:12px}.presentation-heading>span{color:var(--muted);font-size:11px}.order-products{display:grid;gap:8px}.order-product{min-height:82px;display:grid;grid-template-columns:72px minmax(0,1fr);gap:14px;align-items:center;padding:10px 0;border-bottom:1px solid var(--line)}.order-product.no-image{grid-template-columns:1fr}.order-product img{width:72px;height:72px;object-fit:cover;border:1px solid var(--line);border-radius:4px;background:#f4f6f5}.order-product>div{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(100px,1fr);gap:12px}.order-product span{min-width:0;display:flex;flex-direction:column;gap:5px}.opening-copy{padding:16px 18px;border-left:3px solid var(--green);background:#edf6f2}.opening-copy .presentation-heading{min-height:28px}.opening-copy p{margin:9px 0 0;color:#21302a;font-size:16px;line-height:1.8}.opening-copy :deep(.ant-btn){color:var(--green)}
|
||||
.collection-heading{display:flex;justify-content:space-between;gap:16px;margin:26px 0 16px;padding-bottom:10px;border-bottom:1px solid var(--line)}.collection-heading span{color:var(--muted);font-size:11px}
|
||||
.selectable-knowledge :deep(.ant-checkbox-wrapper){display:flex;align-items:center}.selected-summary{display:grid;grid-template-columns:130px 1fr;gap:12px;padding:9px 0;border-bottom:1px solid var(--line)}.selected-summary span{color:var(--muted);font-size:12px}.selected-summary b{font-size:13px}.selection-summary{margin-top:20px}
|
||||
.execution-shell{max-width:1220px}.sop-catalog{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.sop-entry{min-height:150px;display:grid;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;padding:22px;text-align:left;cursor:pointer}.sop-entry:hover{border-color:#9ac8b8;box-shadow:0 8px 24px rgba(32,40,37,.07)}.entry-seq{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small{color:var(--green);font-weight:650}.sop-entry h2{margin:8px 0 7px;font-family:"Noto Serif SC",serif;font-size:20px}.sop-entry p{margin:0;color:var(--muted);line-height:1.6}.play{width:38px;height:38px;display:grid;place-items:center;color:white;background:#202825;border-radius:4px;font-size:18px}.run-workspace{display:grid;grid-template-columns:270px minmax(0,1fr);gap:18px}.run-context{align-self:start;padding:24px;color:white;background:#202825;border-radius:6px;position:sticky;top:86px}.run-label{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2{margin:12px 0 8px;font-family:"Noto Serif SC",serif}.run-context>p{margin:0 0 28px;color:#aebdb7;line-height:1.6}.context-meta{display:flex;align-items:center;justify-content:space-between;padding:13px 0;border-top:1px solid #3a4742}.context-meta span{color:#9eada7;font-size:12px}.privacy-note{display:flex;gap:8px;margin-top:28px;color:#889b93;font-size:11px}.conversation{min-height:590px;padding:30px 34px}.conversation-progress{display:flex;align-items:center;gap:8px;color:#66736d;font-size:12px}.conversation-progress span{width:8px;height:8px;border-radius:50%;background:#d08736;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content{padding:44px 0 26px}.node-content small{color:var(--green);font-size:10px;font-weight:800}.node-content h1{margin:8px 0 22px;font-family:"Noto Serif SC",serif;font-size:28px}.node-content blockquote{margin:0;padding:18px 20px;color:#29342f;background:#f2f6f4;border-left:3px solid var(--green);font-size:13px;line-height:1.7}
|
||||
.stage-panel{display:grid;gap:20px;margin-bottom:26px}.stage-heading{display:flex;align-items:baseline;justify-content:space-between;gap:14px;padding-bottom:10px;border-bottom:1px solid var(--line)}.stage-heading b{font-size:15px}.stage-heading span{color:var(--muted);font-size:11px}.stage-heading.sub{margin-top:8px}
|
||||
.dimension-block{padding:14px 16px;border:1px solid var(--line);background:#fbfdfc}.dimension-head{display:flex;justify-content:space-between;margin-bottom:10px}.dimension-head b{font-size:13px}.dimension-head span{color:var(--muted);font-size:11px}.dimension-chips{display:flex;flex-wrap:wrap;gap:8px}.dimension-chip{display:inline-flex;align-items:center;gap:7px;padding:6px 11px;font-size:12px;color:#4a5a53;background:#fff;border:1px solid var(--line);border-radius:20px;cursor:pointer}.dimension-chip .heat{display:inline-grid;place-items:center;min-width:18px;height:18px;padding:0 4px;color:#9aa8a1;background:#eef2f0;border-radius:10px;font-size:10px}.dimension-chip.hot{color:#fff;background:#287a60;border-color:#287a60}.dimension-chip.hot .heat{color:#287a60;background:#fff}.dimension-chip.selected{outline:2px solid #d08736;outline-offset:1px}.dimension-empty{color:var(--muted);font-size:12px}
|
||||
.stage-scripts{display:grid;gap:12px}.script-group{border:1px solid var(--line);border-left:3px solid var(--green);background:#fff;overflow:hidden}.script-group.collapsed{border-left-color:#c8d4ce;background:#fbfcfc}.group-head{width:100%;display:flex;align-items:center;gap:10px;padding:12px 14px;text-align:left;background:transparent;border:0;cursor:pointer}.group-head:hover{background:#f4f8f6}.group-arrow{color:var(--green);font-size:11px}.group-head b{font-size:14px}.group-meta{margin-left:auto;color:var(--muted);font-size:11px}.group-body{display:grid;gap:10px;padding:2px 12px 12px;border-top:1px solid #eef2f0}.stage-script{padding:14px 16px;border:1px solid var(--line);border-left:3px solid var(--green);background:#fff}.stage-script.type-confirm{border-left-color:#d08736;background:#fffdf7}.stage-script.type-fallback{border-left-color:#8a97a0;background:#f6f8f8}.stage-script.type-template{border-left-color:#4f7fae;background:#f5f9fd}.stage-script.answered{border-left-color:#b9c9c2;background:#fafcfb}.answered-fold{width:100%;display:flex;align-items:center;gap:10px;padding:4px 2px;text-align:left;background:transparent;border:0;cursor:pointer}.answered-fold:hover{border-radius:4px;background:#f0f5f3}.answered-fold b{font-size:13px}.answered-summary{margin-left:auto;max-width:60%;overflow:hidden;color:#6d7b74;font-size:11px;text-overflow:ellipsis;white-space:nowrap}.script-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.script-head b{font-size:13px}.script-head span{color:var(--muted);font-size:10px}.script-head .dim-key{font-style:normal;margin-left:8px}.script-content{margin:10px 0 0;color:#24312b;font-size:14px;line-height:1.8;white-space:pre-wrap}.script-options{margin-top:12px;display:flex;flex-wrap:wrap;gap:8px}.choice-selection{margin:8px 0 0;color:var(--muted);font-size:11px}.script-products{margin-top:12px;display:grid;gap:8px}.product-card{padding:10px 12px;background:#f2f7f5;border-left:3px solid var(--green);border-radius:4px}.product-card b{font-size:13px}.product-card small{margin-left:10px;color:var(--muted);font-size:11px}.script-actions{display:flex;gap:8px;align-items:center;margin-top:12px}.script-feedback{margin-left:auto;display:flex;gap:2px}.stage-purpose{margin:0;padding-top:14px;color:var(--muted);font-size:11px;border-top:1px dashed var(--line)}
|
||||
.start-presentation{display:grid;gap:22px;margin-bottom:28px}.start-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.start-summary>div{min-width:0;display:flex;flex-direction:column;gap:5px;padding:14px 16px}.start-summary>div:nth-child(odd){border-right:1px solid var(--line)}.start-summary small,.order-product small{color:var(--muted);font-size:10px}.start-summary b,.order-product b{overflow-wrap:anywhere;font-size:13px}.presentation-heading{min-height:36px;display:flex;align-items:center;justify-content:space-between;gap:12px}.presentation-heading>span{color:var(--muted);font-size:11px}.order-products{display:grid;gap:8px}.order-product{min-height:82px;display:grid;grid-template-columns:72px minmax(0,1fr);gap:14px;align-items:center;padding:10px 0;border-bottom:1px solid var(--line)}.order-product.no-image{grid-template-columns:1fr}.order-product img{width:72px;height:72px;object-fit:cover;border:1px solid var(--line);border-radius:4px;background:#f4f6f5}.order-product>div{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(100px,1fr);gap:12px}.order-product span{min-width:0;display:flex;flex-direction:column;gap:5px}
|
||||
.json-input,.result-collector :deep(textarea){font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.result-collector{max-width:700px;margin:0 auto 22px;padding:18px;text-align:left;background:#f5f8f6;border:1px solid var(--line)}.result-collector>div{display:flex;justify-content:space-between;margin-bottom:10px}.result-collector>div span{color:var(--muted);font-size:11px}.result-collector pre{max-height:160px;overflow:auto;padding:10px;background:#202825;color:#dbe8e3;font-size:11px;white-space:pre-wrap}.result-collector button{margin-top:10px}
|
||||
.answer-form{margin-top:20px}
|
||||
@media(max-width:800px){.sop-catalog{grid-template-columns:1fr}.run-workspace{grid-template-columns:1fr}.run-context{position:static}.conversation{padding:22px}.run-actions{margin:24px -22px -22px;padding:16px 22px}.run-actions span{display:none}.quick-feedback{grid-template-columns:1fr}.quick-feedback :deep(.ant-input){grid-column:auto}.start-summary{grid-template-columns:1fr}.start-summary>div:nth-child(odd){border-right:0}.start-summary>div+div{border-top:1px solid var(--line)}.order-product>div{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,11 @@ import { useAuthStore } from '@/stores/auth'
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const form = reactive({ username: 'admin', password: 'admin123' })
|
||||
const isDevelopment = import.meta.env.DEV
|
||||
const form = reactive({
|
||||
username: isDevelopment ? 'admin' : '',
|
||||
password: isDevelopment ? 'admin123' : ''
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
loading.value = true
|
||||
@@ -53,7 +57,7 @@ async function submit() {
|
||||
</a-form-item>
|
||||
<a-button type="primary" html-type="submit" size="large" block :loading="loading">进入平台</a-button>
|
||||
</a-form>
|
||||
<div class="login-note"><span></span>本地开发账号已预填</div>
|
||||
<div v-if="isDevelopment" class="login-note"><span></span>本地开发账号已预填</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ArrowLeftOutlined, CheckCircleOutlined, ClockCircleOutlined, SafetyCertificateOutlined } from '@ant-design/icons-vue'
|
||||
import { ArrowLeftOutlined, CheckCircleOutlined, SafetyCertificateOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, apiMessage } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { RunDetail, RunEvent, RunFeedback, ScenarioField } from '@/types'
|
||||
import type { RunDetail, RunDimension, RunEvent, RunFeedback, ScenarioField, ScriptFeedback } from '@/types'
|
||||
|
||||
interface DetailResponse{run:RunDetail;events:RunEvent[];feedback:RunFeedback[];fields:ScenarioField[]}
|
||||
interface DetailResponse{run:RunDetail;events:RunEvent[];feedback:RunFeedback[];fields:ScenarioField[];dimensions:RunDimension[];script_feedback:ScriptFeedback[]}
|
||||
const route=useRoute();const router=useRouter();const auth=useAuthStore();const loading=ref(true);const detail=ref<DetailResponse|null>(null);const submitting=ref(false);const feedback=reactive({score:5,comment:''})
|
||||
const fieldMap=computed(()=>new Map((detail.value?.fields||[]).map(field=>[field.field_key,field.field_name])))
|
||||
const answers=computed(()=>Object.entries(detail.value?.run.answers||{}))
|
||||
const finalResult=computed(()=>detail.value?.run.final_result||null)
|
||||
const dimensionWeights=computed(()=>(detail.value?.dimensions||[]).filter(item=>item.weight>0).sort((a,b)=>b.weight-a.weight))
|
||||
const feedbackTypeText=computed(()=>({like:'有帮助',report:'不准确',unreasonable:'不合理'} as Record<string,string>))
|
||||
const canFeedback=computed(()=>auth.can('runs.feedback')&&detail.value?.run.status==='completed'&&!detail.value.feedback.some(item=>item.user_id===auth.user?.user_id))
|
||||
function resultText(v:string){return{finish:'正常结束',escalate:'转人工 / 转诊',manual:'人工结束'}[v]||v||'进行中'}
|
||||
function actionText(v:string){return{start:'开始执行',enter:'进入节点',answer:'提交回答',finish:'结束执行',final_result:'保存最终结果'}[v]||v}
|
||||
function actionText(v:string){return{start:'开始执行',enter:'进入节点',answer:'提交回答',next:'进入下一步',back:'返回上一步',script_used:'使用话术',script_feedback:'话术反馈',reset:'重置执行',finish:'结束执行',final_result:'保存最终结果'}[v]||v}
|
||||
function valueText(value:any){if(value===true)return'是';if(value===false)return'否';if(Array.isArray(value))return value.join('、');if(value===null||value===undefined||value==='')return'-';return String(value)}
|
||||
function answerEntries(event:RunEvent){const values=event.payload?.answers||{};return Object.entries(values)}
|
||||
function eventExtra(event:RunEvent){if(event.action==='answer'&&event.payload?.script_key){return `话术 ${event.payload.script_key} · 选项 ${(event.payload.option_keys||[]).join('、')||'-'}`}if(event.action==='answer'&&event.payload?.dimension_value_key){return `勾选维度值 ${event.payload.dimension_value_key}=${event.payload.selected}`}if(event.action==='script_feedback'){return `话术 ${event.payload.script_key} · ${feedbackTypeText.value[event.payload.feedback_type]||event.payload.feedback_type}`}return ''}
|
||||
async function load(){loading.value=true;try{detail.value=await api.get<DetailResponse>(`/runs/${route.params.id}/detail`)}catch(error){message.error(apiMessage(error));router.replace('/runs')}finally{loading.value=false}}
|
||||
async function submitFeedback(){submitting.value=true;try{await api.post(`/runs/${route.params.id}/feedback`,feedback);message.success('反馈已提交');await load()}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
||||
onMounted(load)
|
||||
@@ -36,10 +39,10 @@ onMounted(load)
|
||||
<main class="surface timeline-panel">
|
||||
<div class="section-title"><span>执行轨迹</span><small>按实际发生顺序记录</small></div>
|
||||
<a-timeline class="run-timeline">
|
||||
<a-timeline-item v-for="event in detail.events" :key="event.id" :color="event.action==='finish'||event.node_type==='escalate'?'red':event.action==='answer'?'blue':'green'">
|
||||
<a-timeline-item v-for="event in detail.events" :key="event.id" :color="event.action==='finish'||event.node_type==='escalate'?'red':['answer','next','back'].includes(event.action)?'blue':'green'">
|
||||
<div class="event-head"><b>{{ event.node_title||event.node_key }}</b><a-tag>{{ actionText(event.action) }}</a-tag><time>{{ new Date(event.created_at).toLocaleString('zh-CN') }}</time></div>
|
||||
<p v-if="event.action==='enter'&&event.node_content">{{ event.node_content }}</p>
|
||||
<div v-for="group in event.outputs||[]" :key="group.key" class="event-knowledge"><div><b>{{ group.name }}</b><small>{{ group.type }}</small></div><div v-for="(items,relation) in group.relations" :key="relation"><p><b>{{ relation }}</b></p><p v-for="item in items" :key="item.key">{{ item.content.template || item.content.standard_copy || item.name }}</p></div></div>
|
||||
<p v-if="eventExtra(event)" class="event-extra">{{ eventExtra(event) }}</p>
|
||||
<div v-if="answerEntries(event).length" class="event-answers"><span v-for="[key,value] in answerEntries(event)" :key="key"><small>{{ fieldMap.get(key)||key }}</small><b>{{ valueText(value) }}</b></span></div>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
@@ -47,8 +50,10 @@ onMounted(load)
|
||||
|
||||
<aside class="detail-aside">
|
||||
<section class="surface summary-panel"><div class="section-title"><span>执行信息</span></div><dl><div><dt>状态</dt><dd><a-tag :color="detail.run.status==='completed'?'green':'orange'">{{ detail.run.status==='completed'?'已完成':'进行中' }}</a-tag></dd></div><div><dt>开始时间</dt><dd>{{ new Date(detail.run.started_at).toLocaleString('zh-CN') }}</dd></div><div><dt>完成时间</dt><dd>{{ detail.run.completed_at?new Date(detail.run.completed_at).toLocaleString('zh-CN'):'-' }}</dd></div><div><dt>当前节点</dt><dd>{{ detail.run.current_node_key }}</dd></div></dl></section>
|
||||
<section class="surface answer-panel"><div class="section-title"><span>维度权重</span><small>{{ dimensionWeights.length }} 个有效值</small></div><div v-if="dimensionWeights.length" class="answer-list"><div v-for="item in dimensionWeights" :key="`${item.dimension_id}-${item.value_key}`"><span>{{ item.value_key }}</span><b>{{ item.weight }}</b></div></div><div v-else class="aside-empty">暂无有效维度</div></section>
|
||||
<section class="surface answer-panel"><div class="section-title"><span>已采集信息</span><small>{{ answers.length }} 项</small></div><div v-if="answers.length" class="answer-list"><div v-for="[key,value] in answers" :key="key"><span>{{ fieldMap.get(key)||key }}</span><b>{{ valueText(value) }}</b></div></div><div v-else class="aside-empty">尚未采集字段</div></section>
|
||||
<section class="surface answer-panel"><div class="section-title"><span>最终结果</span></div><pre v-if="finalResult" class="final-result">{{ JSON.stringify(finalResult,null,2) }}</pre><div v-else class="aside-empty">本次执行未收集最终结果</div></section>
|
||||
<section v-if="detail.script_feedback.length" class="surface answer-panel"><div class="section-title"><span>话术反馈</span><small>{{ detail.script_feedback.length }} 条</small></div><div class="answer-list"><div v-for="item in detail.script_feedback" :key="item.id"><span>话术 #{{ item.script_id }}</span><b>{{ feedbackTypeText[item.feedback_type]||item.feedback_type }}</b></div></div></section>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
@@ -64,7 +69,5 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.run-detail-page{max-width:1320px}.back-link{height:auto;margin:0 0 14px;padding:0}.detail-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:20px}.run-code{color:var(--green);font-family:monospace;font-size:12px;font-weight:750}.detail-heading h1{margin:7px 0 5px;font-family:"Noto Serif SC",serif;font-size:28px}.detail-heading p{margin:0;color:var(--muted)}.result-block{min-width:210px;display:flex;align-items:center;gap:12px;padding:15px 17px;border-left:3px solid}.result-block>span:first-child{font-size:25px}.result-block>span:last-child{display:flex;flex-direction:column}.result-block small{font-size:10px}.result-block b{margin-top:3px}.result-block.success{color:#17664f;background:#eaf4f0;border-color:#25866a}.result-block.warning{color:#8d5b16;background:#fbf2e3;border-color:#c08026}.detail-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px}.timeline-panel{min-height:560px;padding:22px 24px}.section-title{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:22px}.section-title span{font-weight:700}.section-title small{color:var(--muted);font-size:11px}.run-timeline{padding-top:5px}.event-head{display:flex;align-items:center;gap:9px;min-height:27px}.event-head time{margin-left:auto;color:#8b9691;font-size:11px}.run-timeline p{margin:8px 0 0;color:#56635d;line-height:1.65}.event-answers{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:10px}.event-answers span{display:flex;justify-content:space-between;gap:10px;padding:8px 10px;background:#f4f7f5;border-left:2px solid #a6cbbd}.event-answers small{color:var(--muted)}.event-answers b{font-size:12px}.detail-aside{display:flex;flex-direction:column;gap:16px}.summary-panel,.answer-panel{padding:20px}.summary-panel dl{margin:0}.summary-panel dl>div{display:flex;justify-content:space-between;gap:15px;padding:10px 0;border-bottom:1px solid #edf0ee}.summary-panel dl>div:last-child{border-bottom:0}.summary-panel dt{color:var(--muted);font-size:12px}.summary-panel dd{margin:0;text-align:right;font-size:12px}.answer-list>div{display:flex;flex-direction:column;padding:10px 0;border-bottom:1px solid #edf0ee}.answer-list>div:last-child{border-bottom:0}.answer-list span{color:var(--muted);font-size:11px}.answer-list b{margin-top:4px;font-size:13px}.aside-empty{padding:24px 0;color:#929d98;text-align:center;font-size:12px}.feedback-band{margin-top:16px;padding:22px 24px}.feedback-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.feedback-list article{padding:14px 0;border-top:1px solid var(--line)}.feedback-list article>div{display:flex;align-items:center;justify-content:space-between}.feedback-list :deep(.ant-rate){font-size:15px}.feedback-list p{margin:9px 0;color:#54615b}.feedback-list time{color:#929d98;font-size:11px}.feedback-form{display:grid;grid-template-columns:220px minmax(0,1fr) auto;align-items:center;gap:14px;padding-top:16px;border-top:1px solid var(--line)}.feedback-form>div{display:flex;flex-direction:column;gap:6px}.feedback-form>div>span{font-size:12px;font-weight:650}.feedback-form :deep(.ant-rate){font-size:20px}@media(max-width:900px){.detail-layout{grid-template-columns:1fr}.detail-aside{display:grid;grid-template-columns:1fr 1fr}.feedback-form{grid-template-columns:1fr}.feedback-list{grid-template-columns:1fr}}@media(max-width:620px){.detail-heading{align-items:flex-start;flex-direction:column}.result-block{width:100%}.detail-aside{grid-template-columns:1fr}.timeline-panel{padding:18px}.event-head{align-items:flex-start;flex-wrap:wrap}.event-head time{width:100%;margin:0}.event-answers{grid-template-columns:1fr}}
|
||||
.event-knowledge{margin-top:10px;padding:12px 14px;background:#f2f7f5;border-left:3px solid var(--green)}.event-knowledge>div{display:flex;justify-content:space-between;gap:12px}.event-knowledge small{color:var(--muted)}.event-knowledge .risk-note{padding-top:8px;border-top:1px solid #dce9e4;color:#8d5b16}
|
||||
.final-result{max-height:280px;overflow:auto;margin:0;padding:12px;color:#33413b;background:#f4f7f5;font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
.run-detail-page{max-width:1320px}.back-link{height:auto;margin:0 0 14px;padding:0}.detail-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:20px}.run-code{color:var(--green);font-family:monospace;font-size:12px;font-weight:750}.detail-heading h1{margin:7px 0 5px;font-family:"Noto Serif SC",serif;font-size:28px}.detail-heading p{margin:0;color:var(--muted)}.result-block{min-width:210px;display:flex;align-items:center;gap:12px;padding:15px 17px;border-left:3px solid}.result-block>span:first-child{font-size:25px}.result-block>span:last-child{display:flex;flex-direction:column}.result-block small{font-size:10px}.result-block b{margin-top:3px}.result-block.success{color:#17664f;background:#eaf4f0;border-color:#25866a}.result-block.warning{color:#8d5b16;background:#fbf2e3;border-color:#c08026}.detail-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px}.timeline-panel{min-height:560px;padding:22px 24px}.section-title{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:22px}.section-title span{font-weight:700}.section-title small{color:var(--muted);font-size:11px}.run-timeline{padding-top:5px}.event-head{display:flex;align-items:center;gap:9px;min-height:27px}.event-head time{margin-left:auto;color:#8b9691;font-size:11px}.run-timeline p{margin:8px 0 0;color:#56635d;line-height:1.65}.event-extra{margin-top:6px;padding:7px 10px;color:#7c5a20;background:#fbf2e3;border-left:2px solid #c08026;font-size:12px}.event-answers{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:10px}.event-answers span{display:flex;justify-content:space-between;gap:10px;padding:8px 10px;background:#f4f7f5;border-left:2px solid #a6cbbd}.event-answers small{color:var(--muted)}.event-answers b{font-size:12px}.detail-aside{display:flex;flex-direction:column;gap:16px}.summary-panel,.answer-panel{padding:20px}.summary-panel dl{margin:0}.summary-panel dl>div{display:flex;justify-content:space-between;gap:15px;padding:10px 0;border-bottom:1px solid #edf0ee}.summary-panel dl>div:last-child{border-bottom:0}.summary-panel dt{color:var(--muted);font-size:12px}.summary-panel dd{margin:0;font-size:12px;text-align:right}.answer-list{display:grid;gap:8px}.answer-list>div{display:flex;justify-content:space-between;gap:12px;padding:9px 11px;background:#f4f7f5;border-left:2px solid #a6cbbd}.answer-list span{min-width:0;color:var(--muted);font-size:12px;overflow-wrap:anywhere}.answer-list b{font-size:12px;text-align:right;overflow-wrap:anywhere}.aside-empty{padding:20px 0;color:#98a39e;font-size:12px;text-align:center}.feedback-band{padding:22px 24px}.feedback-list{display:grid;gap:10px}.feedback-list article{padding:12px 14px;background:#f6f8f7;border-left:3px solid var(--green)}.feedback-list article>div{display:flex;justify-content:space-between}.feedback-list p{margin:8px 0 4px;color:#4c5a54}.feedback-list time{color:#96a19c;font-size:11px}.feedback-form{display:grid;gap:10px;max-width:560px}.feedback-form>div{display:flex;align-items:center;gap:14px}.feedback-form .ant-btn{width:130px;justify-self:end}.final-result{max-height:280px;overflow:auto;margin:0;padding:12px;color:#33413b;background:#f4f7f5;font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;overflow-wrap:anywhere}@media(max-width:900px){.detail-layout{grid-template-columns:1fr}.detail-heading{align-items:flex-start;flex-direction:column}}
|
||||
</style>
|
||||
|
||||
@@ -23,7 +23,7 @@ const formRequiredFieldOptions = computed(() => {
|
||||
return fields.value.filter(field=>keys.includes(field.field_key)).map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))
|
||||
})
|
||||
const canEdit = computed(() => auth.can('sop.edit'))
|
||||
const nodeTypes = [{value:'message',label:'标准话术'},{value:'question',label:'单项提问'},{value:'form',label:'信息表单'},{value:'choice',label:'选择判断'},{value:'condition',label:'条件节点'},{value:'knowledge',label:'知识卡'},{value:'escalate',label:'转人工/转诊'},{value:'finish',label:'结束'}]
|
||||
const nodeTypes = [{value:'message',label:'标准话术'},{value:'question',label:'单项提问'},{value:'form',label:'信息表单'},{value:'choice',label:'选择判断'},{value:'condition',label:'条件节点'},{value:'stage',label:'话术阶段'},{value:'escalate',label:'转人工/转诊'},{value:'finish',label:'结束'}]
|
||||
interface ConditionRuleDraft { field:string; operator:string; value:string }
|
||||
const newConditionRule = ():ConditionRuleDraft => ({ field:'', operator:'equals', value:'' })
|
||||
const edgeDraft = reactive({ target_node_key: '', conditional: false, mode: 'all', rules: [newConditionRule()] as ConditionRuleDraft[] })
|
||||
@@ -32,7 +32,7 @@ async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ sop:SOP; edit:{start_node_key:string}; nodes:SOPNode[]; edges:SOPEdge[] }>(`/sops/${id}`)
|
||||
sop.value=data.sop; graph.value=data.edit; nodes.value=data.nodes.map(n=>({...n,config:{...(n.config||{}),...(n.type==='knowledge'?{knowledge_selector:n.config?.knowledge_selector||{derived_field:'',knowledge_types:[],knowledge_keys:[],relation_types:[]}}:{}),...(n.type==='start'?{presentation:n.config?.presentation||{summary_field_keys:[],item_field_keys:[],image_field_keys:[],opening_title:'开场话术',opening_template:''}}:{}),...(n.type==='form'?{required_field_keys:n.config?.required_field_keys||[]}:{})}})); edges.value=data.edges.map(e=>({...e,condition:e.condition||{}})); selectedKey.value = nodes.value[0]?.node_key || ''
|
||||
sop.value=data.sop; graph.value=data.edit; nodes.value=data.nodes.map(n=>({...n,config:{...(n.config||{}),...(n.type==='stage'?{stage_key:n.config?.stage_key||''}:{}),...(n.type==='start'?{presentation:n.config?.presentation||{summary_field_keys:[],item_field_keys:[],image_field_keys:[],opening_title:'开场话术',opening_template:''}}:{}),...(n.type==='form'?{required_field_keys:n.config?.required_field_keys||[]}:{})}})); edges.value=data.edges.map(e=>({...e,condition:e.condition||{}})); selectedKey.value = nodes.value[0]?.node_key || ''
|
||||
const scenarioData = await api.get<{ fields:ScenarioField[] }>(`/scenarios/${data.sop.scenario_id}`)
|
||||
fields.value=scenarioData.fields
|
||||
} catch(error) { message.error(apiMessage(error)) } finally { loading.value=false }
|
||||
@@ -66,7 +66,6 @@ function addConditionRule(){edgeDraft.rules.push(newConditionRule())}
|
||||
function removeConditionRule(index:number){if(edgeDraft.rules.length>1)edgeDraft.rules.splice(index,1)}
|
||||
function parseValue(value:string){if(value==='true')return true;if(value==='false')return false;if(value!==''&&!Number.isNaN(Number(value)))return Number(value);return value}
|
||||
function csvValues(value:unknown){return Array.isArray(value)?value.join(', '):''}
|
||||
function setSelectorList(key:string,value:string){if(!selected.value)return;selected.value.config.knowledge_selector ||= {};selected.value.config.knowledge_selector[key]=value.split(',').map(item=>item.trim()).filter(Boolean)}
|
||||
function conditionText(condition:Record<string,any>):string{if(!condition||Object.keys(condition).length===0)return '默认路径';if(Array.isArray(condition.any))return `任一:${condition.any.map(conditionText).join(';')}`;if(Array.isArray(condition.all))return `全部:${condition.all.map(conditionText).join(';')}`;if(!condition.field)return '未识别条件';const names:Record<string,string>={equals:'等于',not_equals:'不等于',contains:'包含',greater_than:'大于',less_than:'小于',in:'属于列表',exists:'已填写',not_exists:'未填写'};const fieldName=fields.value.find(field=>field.field_key===condition.field)?.field_name||condition.field;const value=Array.isArray(condition.value)?condition.value.join('、'):(condition.value??'');return `${fieldName} ${names[condition.operator]||condition.operator} ${value}`}
|
||||
async function save():Promise<boolean>{saving.value=true;try{await api.put(`/sops/${id}/graph`,{start_node_key:graph.value?.start_node_key||'start',nodes:nodes.value.map(({node_key,type,title,content,config,position_x,position_y})=>({node_key,type,title,content,config,position_x,position_y})),edges:edges.value.map(({source_node_key,target_node_key,condition,priority})=>({source_node_key,target_node_key,condition,priority}))});message.success('SOP 已保存并生效');await load();return true}catch(error){message.error(apiMessage(error));return false}finally{saving.value=false}}
|
||||
async function validate(){try{const data=await api.post<{valid:boolean;problems:string[]}>(`/sops/${id}/validate`);data.valid?message.success('流程校验通过'):Modal.warning({title:'流程配置不完整',content:data.problems.join(';')})}catch(error){message.error(apiMessage(error))}}
|
||||
@@ -100,10 +99,10 @@ onMounted(load)
|
||||
<template v-if="['question','choice'].includes(selected.type)"><div class="property-grid"><a-form-item label="写入场景字段"><a-select v-model:value="selected.config.field_key" :disabled="!canEdit" show-search option-filter-prop="label" placeholder="选择需要采集的字段" :options="fields.map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))" /></a-form-item><a-form-item label="是否必填"><a-switch v-model:checked="selected.config.required" :disabled="!canEdit" /></a-form-item></div></template>
|
||||
<template v-if="selected.type==='form'"><a-form-item label="表单采集字段"><a-select v-model:value="selected.config.field_keys" mode="multiple" :disabled="!canEdit" show-search option-filter-prop="label" placeholder="选择本步骤需要采集的字段" :options="fields.map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))" /></a-form-item><a-form-item label="节点必填字段"><a-select v-model:value="selected.config.required_field_keys" mode="multiple" :disabled="!canEdit" show-search option-filter-prop="label" placeholder="选择必须在本节点完成的字段" :options="formRequiredFieldOptions" /></a-form-item></template>
|
||||
<a-form-item v-if="selected.type==='choice'" label="可选项(每行一个)"><a-textarea :value="(selected.config.options||[]).join('\n')" :disabled="!canEdit" :rows="4" @change="selected.config.options=($event.target as HTMLTextAreaElement).value.split('\n').filter(Boolean)" /></a-form-item>
|
||||
<template v-if="selected.type==='knowledge'">
|
||||
<a-form-item label="派生知识字段"><a-input v-model:value="selected.config.knowledge_selector.derived_field" :disabled="!canEdit" placeholder="例如 matched_symptoms;值为知识 key 数组" /></a-form-item>
|
||||
<div class="property-grid"><a-form-item label="根知识类型"><a-input :value="csvValues(selected.config.knowledge_selector.knowledge_types)" :disabled="!canEdit" placeholder="symptom" @change="setSelectorList('knowledge_types',($event.target as HTMLInputElement).value)" /></a-form-item><a-form-item label="固定知识 key"><a-input :value="csvValues(selected.config.knowledge_selector.knowledge_keys)" :disabled="!canEdit" placeholder="多个 key 用逗号分隔" @change="setSelectorList('knowledge_keys',($event.target as HTMLInputElement).value)" /></a-form-item></div>
|
||||
<a-form-item label="允许返回的关系类型"><a-input :value="csvValues(selected.config.knowledge_selector.relation_types)" :disabled="!canEdit" placeholder="recommended_copy, possible_disease" @change="setSelectorList('relation_types',($event.target as HTMLInputElement).value)" /></a-form-item>
|
||||
<template v-if="selected.type==='stage'">
|
||||
<a-form-item label="话术包阶段标识"><a-input v-model:value="selected.config.stage_key" :disabled="!canEdit" placeholder="例如 opening、symptom、diagnosis、recommend" /></a-form-item>
|
||||
<a-form-item label="内容补充表单字段"><a-select v-model:value="selected.config.field_keys" mode="multiple" :disabled="!canEdit" show-search option-filter-prop="label" placeholder="选择本阶段需要补充采集的字段" :options="fields.map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))" /></a-form-item>
|
||||
<a-form-item label="阶段必填字段"><a-select v-model:value="selected.config.required_field_keys" mode="multiple" :disabled="!canEdit" show-search option-filter-prop="label" placeholder="必须填写完成后才能进入下一步" :options="formRequiredFieldOptions" /></a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
<div class="transition-head"><div><b>下一步路径</b><span>条件路径优先匹配,默认路径始终排在最后</span></div></div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { message, Modal } from 'ant-design-vue'
|
||||
import { api, apiMessage } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { Scenario, ScenarioField, SOP } from '@/types'
|
||||
import ScenarioKnowledgeTab from '@/components/ScenarioKnowledgeTab.vue'
|
||||
import ScriptPackageTab from '@/components/ScriptPackageTab.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -26,7 +26,7 @@ const scenarioForm = reactive({ name: '', industry: '', role_name: '', goal: '',
|
||||
const fieldForm = reactive({ field_key: '', field_name: '', source_path: '', field_type: 'text', required: false, options_text: '', min: undefined as number | undefined, max: undefined as number | undefined, min_length: undefined as number | undefined, max_length: undefined as number | undefined })
|
||||
const sopForm = reactive({ name: '', description: '' })
|
||||
const contractText = ref('{\n "output_schema": {"fields": []},\n "result_schema": {"fields": []},\n "rules": [],\n "allowed_origins": []\n}')
|
||||
const previewText = ref('{\n "input": {},\n "initial_values": {},\n "knowledge_selector": {\n "derived_field": "",\n "knowledge_types": [],\n "knowledge_keys": [],\n "relation_types": []\n }\n}')
|
||||
const previewText = ref('{\n "input": {},\n "initial_values": {},\n "stage_key": "opening"\n}')
|
||||
const previewResult = ref<any>(null)
|
||||
const previewing = ref(false)
|
||||
const statusColor = computed(() => scenario.value?.status === 'active' ? 'green' : 'default')
|
||||
@@ -212,14 +212,14 @@ onMounted(()=>{load();loadContract()})
|
||||
<div v-else class="empty-state"><BranchesOutlined /><p>还没有 SOP。创建一套流程,将经验变成连续动作。</p></div>
|
||||
</section>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane v-if="auth.can('knowledge.view')" key="knowledge" tab="知识卡">
|
||||
<ScenarioKnowledgeTab :scenario-id="scenarioId" :fields="fields" :editable="auth.can('knowledge.edit') && auth.can('scenario.edit')" />
|
||||
<a-tab-pane v-if="auth.can('scriptkit.view')" key="scriptkit" tab="话术包">
|
||||
<ScriptPackageTab :scenario-id="scenarioId" :editable="auth.can('scriptkit.edit') && auth.can('scenario.edit')" />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="contract" tab="规则与输出">
|
||||
<section class="surface contract-editor"><div class="toolbar"><div><b>场景运行契约</b><span class="toolbar-note">定义派生规则、过程输出、最终结果格式和允许接入的域名</span></div><a-button v-if="auth.can('scenario.edit')" type="primary" :loading="saving" @click="saveContract"><SaveOutlined />保存契约</a-button></div><div class="sdk-identifiers"><span>scenarioKey <code>{{ scenario?.scenario_key }}</code></span><span>publicKey <code>{{ scenario?.public_key }}</code></span></div><a-textarea v-model:value="contractText" :disabled="!auth.can('scenario.edit')" class="contract-json" :auto-size="{minRows:20,maxRows:36}" spellcheck="false" /></section>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="preview" tab="预览">
|
||||
<section class="preview-layout"><div class="surface preview-input"><div class="toolbar"><div><b>样例业务数据</b><span class="toolbar-note">使用真实映射、规则、知识和输出服务运行</span></div><a-button type="primary" :loading="previewing" @click="previewScenario">运行预览</a-button></div><a-textarea v-model:value="previewText" class="contract-json" :auto-size="{minRows:24,maxRows:40}" spellcheck="false" /></div><div class="surface preview-output"><div class="toolbar"><div><b>运行结果</b><span class="toolbar-note">输入映射、派生事实、命中规则、知识和输出</span></div></div><pre v-if="previewResult">{{ JSON.stringify(previewResult,null,2) }}</pre><a-empty v-else description="运行预览后查看结果" /></div></section>
|
||||
<section class="preview-layout"><div class="surface preview-input"><div class="toolbar"><div><b>样例业务数据</b><span class="toolbar-note">使用真实映射、规则、话术包和输出服务运行</span></div><a-button type="primary" :loading="previewing" @click="previewScenario">运行预览</a-button></div><a-textarea v-model:value="previewText" class="contract-json" :auto-size="{minRows:24,maxRows:40}" spellcheck="false" /></div><div class="surface preview-output"><div class="toolbar"><div><b>运行结果</b><span class="toolbar-note">输入映射、派生事实、命中规则、阶段话术和输出</span></div></div><pre v-if="previewResult">{{ JSON.stringify(previewResult,null,2) }}</pre><a-empty v-else description="运行预览后查看结果" /></div></section>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-skeleton>
|
||||
|
||||
Reference in New Issue
Block a user