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

@@ -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"},
},
Fields: fields,
StageKeys: []string{"opening", "symptom", "diagnosis", "recommend"},
})
if len(problems) > 0 {
t.Fatalf("pet doctor SOP should be publishable, got: %v", problems)
}
}
func TestPetDoctorSOPIsFiveStepLinearFlow(t *testing.T) {
wantNodes := []string{"start", "pet_info", "symptom", "recommend_product", "finish"}
func TestPetDoctorSOPIsSixStepLinearFlow(t *testing.T) {
wantNodes := []string{"start", "opening", "symptom", "diagnosis", "recommend", "finish"}
for index, node := range petNodes() {
if node.Key != wantNodes[index] {
t.Fatalf("node %d = %s, want %s", index, node.Key, wantNodes[index])
}
}
wantEdges := [][2]string{{"start", "pet_info"}, {"pet_info", "symptom"}, {"symptom", "recommend_product"}, {"recommend_product", "finish"}}
wantEdges := [][2]string{{"start", "opening"}, {"opening", "symptom"}, {"symptom", "diagnosis"}, {"diagnosis", "recommend"}, {"recommend", "finish"}}
for index, edge := range petEdges() {
if edge.Source != wantEdges[index][0] || edge.Target != wantEdges[index][1] {
t.Fatalf("edge %d = %s -> %s", index, edge.Source, edge.Target)
@@ -60,15 +40,6 @@ func TestPetDoctorSOPIsFiveStepLinearFlow(t *testing.T) {
}
}
func TestPetKnowledgeKeyIsStableAndTypeScoped(t *testing.T) {
if petKnowledgeKey("symptom", "腹泻") != petKnowledgeKey("symptom", " 腹泻 ") {
t.Fatal("key should ignore surrounding whitespace")
}
if petKnowledgeKey("symptom", "腹泻") == petKnowledgeKey("disease", "腹泻") {
t.Fatal("key should include knowledge type")
}
}
func petFieldModels() []model.ScenarioField {
definitions := petFieldDefinitions()
fields := make([]model.ScenarioField, 0, len(definitions))

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