This commit is contained in:
Eric 1549169735@qq.com
2026-09-13 20:08:13 +08:00
parent 7cc06976ab
commit da3f16e6db
65 changed files with 3855 additions and 2153 deletions

View File

@@ -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)"

View File

@@ -39,7 +39,5 @@ multitable:
sops: 0
sop_nodes: 0
sop_edges: 0
knowledge_items: 0
knowledge_relations: 0
runs: 0
feedback: 0

View File

@@ -33,7 +33,5 @@ multitable:
sops: 69
sop_nodes: 70
sop_edges: 71
knowledge_items: 72
knowledge_relations: 73
runs: 75
feedback: 76

View File

@@ -39,7 +39,5 @@ multitable:
sops: 48
sop_nodes: 49
sop_edges: 50
knowledge_items: 64
knowledge_relations: 65
runs: 52
feedback: 53

View File

@@ -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:

View File

@@ -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 {

View File

@@ -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,
}

View File

@@ -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)
}
}

View 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})
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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
}

View File

@@ -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))
}
}

View File

@@ -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}
}

View File

@@ -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
View 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"`
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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})
}

View File

@@ -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(&currentNode).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(&currentNode).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
View 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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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
View 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
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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
View 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
}

View File

@@ -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 {

View 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
}
}

View 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)
}
}

View 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
}

View 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
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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])
}

View File

@@ -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},
}
}

View File

@@ -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))

View 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
View File

@@ -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;

View File

@@ -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
View File

@@ -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; }

View File

@@ -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

Binary file not shown.

View File

@@ -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>

View 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>

View File

@@ -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">

View File

@@ -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 }

View File

@@ -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>

View File

@@ -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||'')&&currentConfig.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&&currentConfig.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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>