update
This commit is contained in:
@@ -13,8 +13,9 @@ import (
|
||||
|
||||
func main() {
|
||||
configDir := flag.String("config-dir", "configs", "configuration directory")
|
||||
environment := flag.String("env", "", "runtime environment: development, test, prod")
|
||||
flag.Parse()
|
||||
cfg, err := config.Load(config.LoadOptions{ConfigDir: *configDir})
|
||||
cfg, err := config.Load(config.LoadOptions{ConfigDir: *configDir, Environment: *environment})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "load configuration: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"github.com/xuri/excelize/v2"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const petKnowledgeSource = "症状-疾病-方案.xlsx"
|
||||
const petScriptSource = "症状-疾病-话术.json"
|
||||
|
||||
func resetPetKnowledge(tx *gorm.DB, tenantID, scenarioID uint64) error {
|
||||
var ids []uint64
|
||||
if err := tx.Model(&model.KnowledgeItem{}).Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Pluck("id", &ids).Error; err != nil {
|
||||
return fmt.Errorf("list old pet knowledge: %w", err)
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
if err := tx.Where("tenant_id = ? AND scenario_id = ? AND (from_knowledge_id IN ? OR to_knowledge_id IN ?)", tenantID, scenarioID, ids, ids).Delete(&model.KnowledgeRelation{}).Error; err != nil {
|
||||
return fmt.Errorf("delete old pet knowledge relations: %w", err)
|
||||
}
|
||||
if err := tx.Where("tenant_id = ? AND scenario_id = ? AND id IN ?", tenantID, scenarioID, ids).Delete(&model.KnowledgeItem{}).Error; err != nil {
|
||||
return fmt.Errorf("delete old pet knowledge items: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type petScriptDisease struct {
|
||||
Plans []struct {
|
||||
Plan string `json:"plan"`
|
||||
Products []struct {
|
||||
SKUCode string `json:"sku_code"`
|
||||
ProductName string `json:"product_name"`
|
||||
} `json:"products"`
|
||||
} `json:"plans"`
|
||||
Combinations []struct {
|
||||
Name string `json:"name"`
|
||||
Weight int `json:"weight"`
|
||||
Products []struct {
|
||||
SKUCode string `json:"sku_code"`
|
||||
ProductName string `json:"product_name"`
|
||||
} `json:"products"`
|
||||
Script string `json:"script"`
|
||||
} `json:"combinations"`
|
||||
}
|
||||
|
||||
func importPetScripts(tx *gorm.DB, tenantID, scenarioID uint64, filename string) error {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read pet scripts %s: %w", filename, err)
|
||||
}
|
||||
var source map[string]map[string]petScriptDisease
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
return fmt.Errorf("parse pet scripts %s: %w", filename, err)
|
||||
}
|
||||
for symptom, diseases := range source {
|
||||
for disease, definition := range diseases {
|
||||
diseaseKey := petKnowledgeKey("disease", disease)
|
||||
if err := upsertPetScriptItems(tx, tenantID, scenarioID, symptom, disease, diseaseKey, definition); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertPetScriptItems(tx *gorm.DB, tenantID, scenarioID uint64, symptom, disease, diseaseKey string, definition petScriptDisease) error {
|
||||
symptomKey := petKnowledgeKey("symptom", symptom)
|
||||
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, symptomKey, symptom, "symptom", []byte(`{"source":"`+petScriptSource+`"}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, diseaseKey, disease, "disease", []byte(`{"source":"`+petScriptSource+`"}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertPetRelation(tx, scenarioID, symptomKey, "possible_disease", diseaseKey, 3000); err != nil {
|
||||
return err
|
||||
}
|
||||
for index, combination := range definition.Combinations {
|
||||
copyName := fmt.Sprintf("%s-%s话术", disease, combination.Name)
|
||||
copyKey := petKnowledgeKey("copy", "json_"+symptom+"_"+disease+"_"+combination.Name)
|
||||
content, _ := json.Marshal(map[string]interface{}{"source": petScriptSource, "template": strings.ReplaceAll(combination.Script, "{{purchased_products}}", "{{input.product_names}}"), "combination": combination.Name, "weight": combination.Weight})
|
||||
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, copyKey, copyName, "copy", content); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_copy", copyKey, 3000+index); err != nil {
|
||||
return err
|
||||
}
|
||||
for productIndex, product := range combination.Products {
|
||||
if strings.TrimSpace(product.SKUCode) == "" {
|
||||
continue
|
||||
}
|
||||
productKey := petKnowledgeKey("product", product.SKUCode)
|
||||
content, _ := json.Marshal(map[string]interface{}{"source": petScriptSource, "sku_code": product.SKUCode, "product_name": product.ProductName})
|
||||
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, productKey, product.ProductName, "product", content); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_product", productKey, 3400+index*100+productIndex); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for index, plan := range definition.Plans {
|
||||
if strings.TrimSpace(plan.Plan) == "" {
|
||||
continue
|
||||
}
|
||||
planKey := petKnowledgeKey("plan", plan.Plan)
|
||||
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, planKey, plan.Plan, "plan", []byte(`{"source":"`+petScriptSource+`"}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_plan", planKey, 3200+index); err != nil {
|
||||
return err
|
||||
}
|
||||
for productIndex, product := range plan.Products {
|
||||
if strings.TrimSpace(product.SKUCode) == "" {
|
||||
continue
|
||||
}
|
||||
productKey := petKnowledgeKey("product", product.SKUCode)
|
||||
content, _ := json.Marshal(map[string]interface{}{"source": petScriptSource, "sku_code": product.SKUCode, "product_name": product.ProductName})
|
||||
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, productKey, product.ProductName, "product", content); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_product", productKey, 3600+index*100+productIndex); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertPetKnowledgeItem(tx *gorm.DB, tenantID, scenarioID uint64, key, name, typeName string, content []byte) error {
|
||||
row := model.KnowledgeItem{TenantID: tenantID, ScenarioID: scenarioID, ItemKey: key, Name: name, Type: typeName, Content: datatypes.JSON(content), Status: "active", SortOrder: 3000}
|
||||
return tx.Where("tenant_id = ? AND scenario_id = ? AND item_key = ?", tenantID, scenarioID, key).Assign(row).FirstOrCreate(&row).Error
|
||||
}
|
||||
|
||||
func upsertPetRelation(tx *gorm.DB, scenarioID uint64, fromKey, relation, toKey string, order int) error {
|
||||
var from, to model.KnowledgeItem
|
||||
if err := tx.Where("scenario_id = ? AND item_key = ?", scenarioID, fromKey).First(&from).Error; err != nil {
|
||||
return fmt.Errorf("find script relation source %s: %w", fromKey, err)
|
||||
}
|
||||
if err := tx.Where("scenario_id = ? AND item_key = ?", scenarioID, toKey).First(&to).Error; err != nil {
|
||||
return fmt.Errorf("find script relation target %s: %w", toKey, err)
|
||||
}
|
||||
row := model.KnowledgeRelation{TenantID: from.TenantID, ScenarioID: scenarioID, FromKnowledgeID: from.ID, RelationType: relation, ToKnowledgeID: to.ID, Condition: datatypes.JSON([]byte(`{}`)), SortOrder: order}
|
||||
return tx.Where("scenario_id = ? AND from_knowledge_id = ? AND relation_type = ? AND to_knowledge_id = ?", scenarioID, from.ID, relation, to.ID).Assign(row).FirstOrCreate(&row).Error
|
||||
}
|
||||
|
||||
type petKnowledgeRow struct {
|
||||
Symptom string
|
||||
Disease string
|
||||
Plan string
|
||||
SKUCode string
|
||||
ProductName string
|
||||
}
|
||||
|
||||
func importPetKnowledge(tx *gorm.DB, tenantID, scenarioID uint64, filename string) error {
|
||||
rows, err := readPetKnowledge(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return upsertPetKnowledge(tx, tenantID, scenarioID, rows)
|
||||
}
|
||||
|
||||
func readPetKnowledge(filename string) ([]petKnowledgeRow, error) {
|
||||
book, err := excelize.OpenFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open pet knowledge %s: %w", filename, err)
|
||||
}
|
||||
defer book.Close()
|
||||
sheets := book.GetSheetList()
|
||||
if len(sheets) == 0 {
|
||||
return nil, fmt.Errorf("pet knowledge %s has no worksheet", filename)
|
||||
}
|
||||
iterator, err := book.Rows(sheets[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pet knowledge worksheet: %w", err)
|
||||
}
|
||||
defer iterator.Close()
|
||||
|
||||
columns := map[string]int{}
|
||||
result := make([]petKnowledgeRow, 0)
|
||||
rowNumber := 0
|
||||
for iterator.Next() {
|
||||
rowNumber++
|
||||
values, err := iterator.Columns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pet knowledge row %d: %w", rowNumber, err)
|
||||
}
|
||||
if rowNumber == 1 {
|
||||
for index, value := range values {
|
||||
columns[strings.TrimSpace(value)] = index
|
||||
}
|
||||
for _, required := range []string{"症状", "疾病", "方案", "SKU_CODE", "商品标题"} {
|
||||
if _, ok := columns[required]; !ok {
|
||||
return nil, fmt.Errorf("pet knowledge is missing column %q", required)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
row := petKnowledgeRow{
|
||||
Symptom: excelValue(values, columns["症状"]),
|
||||
Disease: excelValue(values, columns["疾病"]),
|
||||
Plan: excelValue(values, columns["方案"]),
|
||||
SKUCode: excelValue(values, columns["SKU_CODE"]),
|
||||
ProductName: excelValue(values, columns["商品标题"]),
|
||||
}
|
||||
if row.Symptom == "" && row.Disease == "" && row.Plan == "" {
|
||||
continue
|
||||
}
|
||||
if row.Symptom == "" || row.Disease == "" {
|
||||
return nil, fmt.Errorf("pet knowledge row %d must contain 场景 and 分型", rowNumber)
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
if err := iterator.Error(); err != nil {
|
||||
return nil, fmt.Errorf("iterate pet knowledge: %w", err)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("pet knowledge contains no data")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func excelValue(values []string, index int) string {
|
||||
if index >= len(values) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(values[index])
|
||||
}
|
||||
|
||||
func upsertPetKnowledge(tx *gorm.DB, tenantID, scenarioID uint64, rows []petKnowledgeRow) error {
|
||||
type itemDefinition struct {
|
||||
name, typeName string
|
||||
content map[string]interface{}
|
||||
}
|
||||
items := map[string]itemDefinition{}
|
||||
type relationDefinition struct{ from, relation, to string }
|
||||
relations := map[relationDefinition]bool{}
|
||||
for _, row := range rows {
|
||||
symptomKey := petKnowledgeKey("symptom", row.Symptom)
|
||||
diseaseKey := petKnowledgeKey("disease", row.Disease)
|
||||
items[symptomKey] = itemDefinition{name: row.Symptom, typeName: "symptom"}
|
||||
items[diseaseKey] = itemDefinition{name: row.Disease, typeName: "disease"}
|
||||
relations[relationDefinition{symptomKey, "possible_disease", diseaseKey}] = true
|
||||
for index, template := range []string{
|
||||
"结合客户描述,目前更符合“%s”这一分型。建议先向客户说明判断依据,再介绍对应方案。",
|
||||
"针对“%s”,这边建议按下面的方案进行护理和商品搭配;如症状持续或加重,应及时就医。",
|
||||
} {
|
||||
copyName := fmt.Sprintf("%s推荐话术%d", row.Disease, index+1)
|
||||
copyKey := petKnowledgeKey("copy", copyName)
|
||||
items[copyKey] = itemDefinition{name: copyName, typeName: "copy", content: map[string]interface{}{"template": fmt.Sprintf(template, row.Disease)}}
|
||||
relations[relationDefinition{diseaseKey, "recommended_copy", copyKey}] = true
|
||||
}
|
||||
if row.Plan != "" {
|
||||
planKey := petKnowledgeKey("plan", row.Plan)
|
||||
items[planKey] = itemDefinition{name: row.Plan, typeName: "plan"}
|
||||
relations[relationDefinition{diseaseKey, "recommended_plan", planKey}] = true
|
||||
// The current SOP view expands one relation level from a symptom.
|
||||
relations[relationDefinition{symptomKey, "recommended_plan", planKey}] = true
|
||||
}
|
||||
if row.SKUCode != "" {
|
||||
productName := row.ProductName
|
||||
if productName == "" {
|
||||
productName = row.SKUCode
|
||||
}
|
||||
productKey := petKnowledgeKey("product", row.SKUCode)
|
||||
items[productKey] = itemDefinition{name: productName, typeName: "product", content: map[string]interface{}{"sku_code": row.SKUCode, "product_name": productName}}
|
||||
relations[relationDefinition{diseaseKey, "recommended_product", productKey}] = true
|
||||
}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(items))
|
||||
for key := range items {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
ids := make(map[string]uint64, len(keys))
|
||||
for order, key := range keys {
|
||||
definition := items[key]
|
||||
payload := map[string]interface{}{"source": petKnowledgeSource}
|
||||
for name, value := range definition.content {
|
||||
payload[name] = value
|
||||
}
|
||||
content, _ := json.Marshal(payload)
|
||||
row := model.KnowledgeItem{TenantID: tenantID, ScenarioID: scenarioID, ItemKey: key, Name: definition.name, Type: definition.typeName, Content: datatypes.JSON(content), Status: "active", SortOrder: 1000 + order}
|
||||
if err := tx.Where("tenant_id = ? AND scenario_id = ? AND item_key = ?", tenantID, scenarioID, key).Assign(row).FirstOrCreate(&row).Error; err != nil {
|
||||
return fmt.Errorf("upsert imported knowledge item %s: %w", key, err)
|
||||
}
|
||||
ids[key] = row.ID
|
||||
}
|
||||
|
||||
relationList := make([]relationDefinition, 0, len(relations))
|
||||
for relation := range relations {
|
||||
relationList = append(relationList, relation)
|
||||
}
|
||||
sort.Slice(relationList, func(i, j int) bool {
|
||||
left, right := relationList[i], relationList[j]
|
||||
return left.from+left.relation+left.to < right.from+right.relation+right.to
|
||||
})
|
||||
for order, definition := range relationList {
|
||||
row := model.KnowledgeRelation{TenantID: tenantID, ScenarioID: scenarioID, FromKnowledgeID: ids[definition.from], RelationType: definition.relation, ToKnowledgeID: ids[definition.to], Condition: datatypes.JSON([]byte(`{}`)), SortOrder: 1000 + order}
|
||||
if err := tx.Where("scenario_id = ? AND from_knowledge_id = ? AND relation_type = ? AND to_knowledge_id = ?", scenarioID, row.FromKnowledgeID, row.RelationType, row.ToKnowledgeID).Assign(row).FirstOrCreate(&row).Error; err != nil {
|
||||
return fmt.Errorf("upsert imported knowledge relation: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func petKnowledgeKey(typeName, name string) string {
|
||||
digest := sha256.Sum256([]byte(typeName + "\x00" + strings.TrimSpace(name)))
|
||||
return "xlsx_" + typeName + "_" + hex.EncodeToString(digest[:6])
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/database"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/logger"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
@@ -45,6 +47,12 @@ func main() {
|
||||
configDir := flag.String("config-dir", "configs", "configuration directory")
|
||||
environment := flag.String("env", "", "runtime environment")
|
||||
scriptFile := flag.String("script-file", filepath.Join("..", "docs", "症状-疾病-话术.json"), "pet script knowledge JSON file")
|
||||
knowledgeFile := flag.String("knowledge-file", filepath.Join("..", "docs", "症状-疾病-方案.xlsx"), "pet SKU knowledge Excel file")
|
||||
questionFile := flag.String("question-file", filepath.Join("..", "docs", "症状确认话术.json"), "pet colloquial question bank JSON file")
|
||||
smallCategoryFile := flag.String("small-category-file", filepath.Join("..", "docs", "症状小类-大类.json"), "pet symptom small category mapping JSON file")
|
||||
smallAskScriptFile := flag.String("small-ask-script-file", filepath.Join("..", "docs", "症状小类询问话术.json"), "pet symptom small ask script JSON file")
|
||||
preGuideFile := flag.String("pre-guide-file", filepath.Join("..", "docs", "症状确认前引导话术.json"), "pet pre-symptom guide script JSON file")
|
||||
classificationFile := flag.String("classification-file", filepath.Join("..", "docs", "症状分类.json"), "pet symptom classification JSON file")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load(config.LoadOptions{Environment: *environment, ConfigDir: *configDir})
|
||||
@@ -63,13 +71,16 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal("connect database", zap.Error(err))
|
||||
}
|
||||
if err := seed(db, *scriptFile); err != nil {
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
log.Fatal("synchronize database schema", zap.Error(err))
|
||||
}
|
||||
if err := seed(db, *scriptFile, *knowledgeFile, *questionFile, *smallCategoryFile, *smallAskScriptFile, *preGuideFile, *classificationFile); err != nil {
|
||||
log.Fatal("seed pet doctor scenario", zap.Error(err))
|
||||
}
|
||||
log.Info("pet doctor scenario is ready")
|
||||
}
|
||||
|
||||
func seed(db *gorm.DB, scriptFile string) error {
|
||||
func seed(db *gorm.DB, scriptFile, knowledgeFile, questionFile, smallCategoryFile, smallAskScriptFile, preGuideFile, classificationFile string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
tenant, user, err := seedOwner(tx)
|
||||
if err != nil {
|
||||
@@ -88,11 +99,12 @@ func seed(db *gorm.DB, scriptFile string) error {
|
||||
if err := seedRulesAndOutput(tx, tenant.ID, scenario.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resetPetKnowledge(tx, tenant.ID, scenario.ID); err != nil {
|
||||
packageInput, err := buildScriptPackage(scriptFile, knowledgeFile, smallCategoryFile, smallAskScriptFile, preGuideFile, classificationFile, questionFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := importPetScripts(tx, tenant.ID, scenario.ID, scriptFile); err != nil {
|
||||
return err
|
||||
if err := scriptkit.SavePackage(tx, tenant.ID, user.ID, scenario.ID, packageInput); err != nil {
|
||||
return fmt.Errorf("save pet script package: %w", err)
|
||||
}
|
||||
if err := seedSOP(tx, tenant.ID, user.ID, scenario.ID); err != nil {
|
||||
return err
|
||||
@@ -115,18 +127,34 @@ func seedOwner(tx *gorm.DB) (model.Tenant, model.User, error) {
|
||||
|
||||
func seedScenario(tx *gorm.DB, tenantID, userID uint64) (model.Scenario, error) {
|
||||
var scenario model.Scenario
|
||||
err := tx.Where("tenant_id = ? AND name = ?", tenantID, "宠物医生问诊问药").FirstOrCreate(&scenario, model.Scenario{
|
||||
TenantID: tenantID, Name: "宠物医生问诊问药", CreatedBy: userID,
|
||||
}).Error
|
||||
// FirstOrCreate 会把结构体非零字段并入查询条件;公开标识放进 Attrs,
|
||||
// 避免随机 public_key 让每次匹配失败、重复创建同名场景。
|
||||
err := tx.Where("tenant_id = ? AND name = ?", tenantID, "宠物医生问诊问药").
|
||||
Attrs(model.Scenario{
|
||||
ScenarioKey: "scenario-1",
|
||||
PublicKey: "pk_" + uuid.NewString(),
|
||||
AllowedOrigins: datatypes.JSON([]byte(`[]`)),
|
||||
InputSchema: datatypes.JSON([]byte(`{"fields":[]}`)),
|
||||
OutputSchema: datatypes.JSON([]byte(`{"fields":[]}`)),
|
||||
ResultSchema: datatypes.JSON([]byte(`{"fields":[]}`)),
|
||||
}).
|
||||
FirstOrCreate(&scenario, model.Scenario{TenantID: tenantID, Name: "宠物医生问诊问药", CreatedBy: userID}).Error
|
||||
if err != nil {
|
||||
return scenario, fmt.Errorf("find or create scenario: %w", err)
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"industry": "宠物医疗", "role_name": "宠物医生、助理、客服",
|
||||
"goal": "标准化收集宠物基本信息、主诉和病史,优先识别急症,在医生评估前提供安全且一致的沟通指引。",
|
||||
"trigger_text": "宠物主人通过门店、电话或在线渠道咨询症状、检查、用药或是否需要就医时进入本场景。",
|
||||
"goal": "通过话术包和维度权重驱动问诊:确认宠物基本信息与主要症状,联动拟诊疾病方向,推荐方案、商品与注意事项,并全程记录话术反馈用于复盘。",
|
||||
"trigger_text": "宠物主人通过门店、电话或在线渠道咨询症状、用药或护理商品时进入本场景。",
|
||||
"visibility": "tenant", "status": "active",
|
||||
}
|
||||
// 保持公开标识稳定:已有接入方依赖 scenario-1;缺失时才生成,不覆盖已有值。
|
||||
if scenario.ScenarioKey == "" {
|
||||
updates["scenario_key"] = "scenario-1"
|
||||
}
|
||||
if scenario.PublicKey == "" {
|
||||
updates["public_key"] = "pk_" + uuid.NewString()
|
||||
}
|
||||
if err := tx.Model(&scenario).Updates(updates).Error; err != nil {
|
||||
return scenario, fmt.Errorf("update scenario: %w", err)
|
||||
}
|
||||
@@ -186,85 +214,18 @@ func petFieldDefinitions() []fieldDefinition {
|
||||
{Key: "pet_age", Name: "宠物年龄", Type: "text", Validation: map[string]interface{}{"max_length": 30}},
|
||||
{Key: "pet_weight", Name: "宠物体重(kg)", Type: "number", Validation: map[string]interface{}{"min": 0.1, "max": 200}},
|
||||
{Key: "pet_sex", Name: "宠物性别", Type: "select", Options: []string{"公", "母", "未知"}},
|
||||
{Key: "confirmed_symptoms", Name: "客户确认症状", Type: "array", Required: true},
|
||||
{Key: "confirmed_diseases", Name: "确认疾病分型", Type: "array", Required: true},
|
||||
}
|
||||
}
|
||||
|
||||
func seedKnowledgeGraph(tx *gorm.DB, tenantID, scenarioID uint64) error {
|
||||
definitions := map[string]struct {
|
||||
name string
|
||||
typeName string
|
||||
content map[string]interface{}
|
||||
}{
|
||||
"consultation_boundary": {name: "线上问诊边界", typeName: "guidance", content: map[string]interface{}{
|
||||
"standard_copy": "线上沟通用于收集信息和判断紧急程度,不能替代体格检查、检验和影像检查。医生需要结合完整病史及检查结果后才能给出诊断和治疗方案。",
|
||||
"forbidden_copy": "不要仅凭文字、照片或单个症状作出确定诊断,也不要承诺某种处理一定有效。",
|
||||
"risk_note": "信息不完整、症状持续加重或无法准确观察时,应建议尽快到院评估。",
|
||||
}},
|
||||
"emergency_guidance": {name: "急症红旗征象", typeName: "guidance", content: map[string]interface{}{
|
||||
"standard_copy": "出现呼吸困难、持续出血、抽搐或意识异常、严重外伤、无法排尿、持续呕吐或干呕、剧烈疼痛、腹部明显膨大、疑似中暑、无法站立、误食毒物或异物等情况时,应立即建议就近急诊或转诊,并提前联系接诊机构。",
|
||||
"forbidden_copy": "不要承诺在家观察一定安全;不要在线给出能够替代急诊检查的判断。",
|
||||
"risk_note": "急症分支优先级最高,不得因继续询问常规病史而延误就医。",
|
||||
}},
|
||||
"medication_safety": {name: "问诊用药安全原则", typeName: "guidance", content: map[string]interface{}{
|
||||
"standard_copy": "用药需要结合物种、年龄、体重、既往病史、正在使用的药物和必要检查,由宠物医生评估后确定。请勿自行增加剂量、混用药物或使用人用药。",
|
||||
"forbidden_copy": "未完成医生评估前,不给出具体处方药名称、剂量和疗程承诺。",
|
||||
"risk_note": "对乙酰氨基酚、布洛芬等常见人用药可能对宠物造成严重伤害;如已误服,应按急症处理。",
|
||||
}},
|
||||
"safety_net": {name: "观察与复诊提示", typeName: "guidance", content: map[string]interface{}{
|
||||
"standard_copy": "请按医生要求记录精神、食欲、饮水、排尿、排便、呕吐次数及症状变化。若症状加重、出现新的急症表现,或在医生建议的观察时间内未改善,应尽快复诊。",
|
||||
"forbidden_copy": "不要用固定天数替代医生根据病情给出的复诊时间,也不要因一次短暂好转自行停药。",
|
||||
"risk_note": "离开本次沟通前,应让宠物主人复述下一步安排和需要立即就医的触发条件。",
|
||||
}},
|
||||
"soft_stool": {name: "软便", typeName: "symptom", content: map[string]interface{}{"label": "soft_stool"}},
|
||||
"poor_appetite": {name: "食欲下降", typeName: "symptom", content: map[string]interface{}{"label": "poor_appetite"}},
|
||||
"ask_stool": {name: "软便追问话术", typeName: "copy", content: map[string]interface{}{"template": "{{input.customer_name}}您好,想了解一下{{input.pet_name}}近期排便的频率和形态,是否伴随呕吐、精神变差或便中带血?"}},
|
||||
"recommend_gut": {name: "肠胃护理推荐话术", typeName: "copy", content: map[string]interface{}{"template": "结合{{input.pet_name}}目前的表现,可以先向您介绍肠胃护理方向的产品和日常喂养注意事项,具体用药仍需宠物医生评估。"}},
|
||||
"gi_discomfort": {name: "肠胃不适风险", typeName: "disease", content: map[string]interface{}{"risk_level": "normal", "label": "gi_discomfort"}},
|
||||
"parasite_risk": {name: "寄生虫风险", typeName: "disease", content: map[string]interface{}{"risk_level": "medium", "label": "parasite_risk"}},
|
||||
}
|
||||
index := 0
|
||||
for key, definition := range definitions {
|
||||
content, _ := json.Marshal(definition.content)
|
||||
row := model.KnowledgeItem{TenantID: tenantID, ScenarioID: scenarioID, ItemKey: key, Name: definition.name, Type: definition.typeName, Content: datatypes.JSON(content), Status: "active", SortOrder: index}
|
||||
if err := tx.Where("tenant_id = ? AND scenario_id = ? AND item_key = ?", tenantID, scenarioID, key).Assign(row).FirstOrCreate(&row).Error; err != nil {
|
||||
return fmt.Errorf("seed knowledge item %s: %w", key, err)
|
||||
}
|
||||
index++
|
||||
}
|
||||
relations := []struct{ from, relation, to string }{
|
||||
{"soft_stool", "recommended_copy", "ask_stool"}, {"soft_stool", "recommended_copy", "recommend_gut"},
|
||||
{"soft_stool", "possible_disease", "gi_discomfort"}, {"soft_stool", "possible_disease", "parasite_risk"},
|
||||
{"poor_appetite", "recommended_copy", "recommend_gut"}, {"poor_appetite", "possible_disease", "gi_discomfort"},
|
||||
}
|
||||
ids := map[string]uint64{}
|
||||
var items []model.KnowledgeItem
|
||||
if err := tx.Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Find(&items).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
ids[item.ItemKey] = item.ID
|
||||
}
|
||||
for order, relation := range relations {
|
||||
fromID, fromOK := ids[relation.from]
|
||||
toID, toOK := ids[relation.to]
|
||||
if !fromOK || !toOK {
|
||||
return fmt.Errorf("knowledge relation references missing item: %s -> %s", relation.from, relation.to)
|
||||
}
|
||||
row := model.KnowledgeRelation{TenantID: tenantID, ScenarioID: scenarioID, FromKnowledgeID: fromID, RelationType: relation.relation, ToKnowledgeID: toID, Condition: datatypes.JSON([]byte(`{}`)), SortOrder: order}
|
||||
if err := tx.Where("scenario_id = ? AND from_knowledge_id = ? AND relation_type = ? AND to_knowledge_id = ?", scenarioID, fromID, relation.relation, toID).Assign(row).FirstOrCreate(&row).Error; err != nil {
|
||||
return fmt.Errorf("seed knowledge relation: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedRulesAndOutput(tx *gorm.DB, tenantID, scenarioID uint64) error {
|
||||
rule := model.ScenarioRule{TenantID: tenantID, ScenarioID: scenarioID, RuleKey: "derive-matched-symptoms", Name: "从商品标签提取症状", Condition: datatypes.JSON([]byte(`{"field":"input.input_symptom_tags","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"set","field":"matched_symptoms","value_from":"input.input_symptom_tags"}]`)), Priority: 100, Status: "active"}
|
||||
if err := tx.Where("scenario_id = ? AND rule_key = ?", scenarioID, rule.RuleKey).Assign(rule).FirstOrCreate(&rule).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
skuRule := model.ScenarioRule{TenantID: tenantID, ScenarioID: scenarioID, RuleKey: "derive-matched-skus", Name: "按 SKU_CODE 匹配话术包", Condition: datatypes.JSON([]byte(`{"field":"input.product_ids","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"set","field":"matched_skus","value_from":"input.product_ids"}]`)), Priority: 110, Status: "active"}
|
||||
if err := tx.Where("scenario_id = ? AND rule_key = ?", scenarioID, skuRule.RuleKey).Assign(skuRule).FirstOrCreate(&skuRule).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]interface{}{"fields": []map[string]interface{}{
|
||||
{"key": "matched_symptoms", "name": "命中症状", "type": "array", "source": "derived", "source_field": "matched_symptoms"},
|
||||
{"key": "product_names", "name": "订单商品", "type": "array", "source": "input", "source_field": "product_names"},
|
||||
@@ -291,23 +252,12 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64) error {
|
||||
return fmt.Errorf("find or create SOP: %w", err)
|
||||
}
|
||||
if err := tx.Model(&sop).Updates(map[string]interface{}{
|
||||
"description": "根据订单商品携带的症状标签展示关联症状、话术和可能疾病,辅助销售完成商品推荐并记录结果。",
|
||||
"description": "以话术包和维度权重驱动:开场补充宠物档案,症状确认逐题问答与勾选,拟诊确认联动疾病方向,最后按疾病权重推荐方案、商品与注意事项。",
|
||||
"status": "published",
|
||||
"start_node_key": "start",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var alreadySeeded int64
|
||||
if err := tx.Model(&model.SOPNode{}).Where(
|
||||
"sop_id = ? AND node_key = ? AND JSON_UNQUOTE(JSON_EXTRACT(config, '$.seed_key')) = ?",
|
||||
sop.ID, "start", "pet-doctor-v16-start-presentation-optional-pet",
|
||||
).Count(&alreadySeeded).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadySeeded > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
nodes := petNodes()
|
||||
edges := petEdges()
|
||||
if err := tx.Where("sop_id = ? AND tenant_id = ?", sop.ID, tenantID).Delete(&model.SOPEdge{}).Error; err != nil {
|
||||
@@ -336,7 +286,7 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64) error {
|
||||
return fmt.Errorf("create edge %s -> %s: %w", definition.Source, definition.Target, err)
|
||||
}
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]interface{}{"scenario": "宠物医生问诊问药"})
|
||||
payload, _ := json.Marshal(map[string]interface{}{"scenario": "宠物医生问诊问药", "engine": "scriptkit"})
|
||||
return tx.Create(&model.AuditLog{
|
||||
TenantID: tenantID, UserID: userID, Action: "seed", Resource: "sop", ResourceID: sop.ID, Payload: datatypes.JSON(payload),
|
||||
}).Error
|
||||
@@ -345,27 +295,27 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64) error {
|
||||
func petNodes() []nodeDefinition {
|
||||
return []nodeDefinition{
|
||||
{Key: "start", Type: "start", Title: "开始", Content: "确认订单和客户信息,使用开场话术开始沟通。", Config: map[string]interface{}{
|
||||
"seed_key": "pet-doctor-v16-start-presentation-optional-pet",
|
||||
"seed_key": "pet-doctor-scriptkit",
|
||||
"presentation": map[string]interface{}{
|
||||
"summary_field_keys": []string{"order_id", "customer_name", "pet_name", "pet_type"},
|
||||
"item_field_keys": []string{"product_images", "product_names", "product_ids"},
|
||||
"image_field_keys": []string{"product_images"},
|
||||
"opening_title": "开场话术",
|
||||
"opening_template": "您好,{{input.customer_name}}。我是宠物健康顾问,看到您购买了{{input.product_names}},想回访了解一下使用情况,也了解一下宠物目前的身体状况。",
|
||||
},
|
||||
}},
|
||||
{Key: "pet_info", Type: "form", Title: "收集宠物信息", Content: "核对系统已带入的宠物信息,并根据客户回答补充名称、种类、年龄、体重和性别。", Config: map[string]interface{}{"field_keys": []string{"pet_name", "pet_type", "pet_age", "pet_weight", "pet_sex"}, "required_field_keys": []string{"pet_name", "pet_type"}}},
|
||||
{Key: "symptom", Type: "knowledge", Title: "确认宠物症状与疾病", Content: "商品标签是系统推断的候选症状;请结合客户回答确认,并可从场景知识中补充其他伴随症状。", Config: map[string]interface{}{"knowledge_selector": map[string]interface{}{"derived_field": "matched_symptoms", "candidate_scope": "all", "knowledge_types": []string{"symptom"}, "relation_types": []string{"recommended_copy", "possible_disease"}, "relation_labels": map[string]string{"recommended_copy": "沟通话术", "possible_disease": "可能疾病"}}, "knowledge_collection": map[string]interface{}{"selection_title": "确认实际症状和疾病", "selection_hint": "系统推断项优先展示,也可搜索场景知识补充伴随症状", "steps": []map[string]interface{}{{"field_key": "confirmed_symptoms", "name": "客户确认症状", "root": true, "candidate_scope": "all", "knowledge_types": []string{"symptom"}, "required": true, "multiple": true}, {"field_key": "confirmed_diseases", "name": "确认疾病分型", "from_field": "confirmed_symptoms", "relation_type": "possible_disease", "required": true, "multiple": true}}}}},
|
||||
{Key: "recommend_product", Type: "knowledge", Title: "推荐方案与商品", Content: "根据已确认的疾病分型,向客户讲解推荐话术、建议方案和可使用的商品。", Config: map[string]interface{}{"knowledge_selector": map[string]interface{}{"answer_field": "confirmed_diseases", "knowledge_types": []string{"disease"}, "relation_types": []string{"recommended_copy", "recommended_plan", "recommended_product"}, "relation_labels": map[string]string{"recommended_copy": "推荐话术", "recommended_plan": "建议方案", "recommended_product": "推荐商品"}}}},
|
||||
{Key: "finish", Type: "finish", Title: "结束", Content: "本次话术执行完成,请保存最终推荐结果。", Config: map[string]interface{}{}},
|
||||
{Key: "opening", Type: "stage", Title: "开场", Content: "展示开场话术,并核对补充宠物名称、种类等基础档案。", Config: map[string]interface{}{"stage_key": "opening", "field_keys": []string{"pet_name", "pet_type", "pet_age", "pet_weight", "pet_sex"}, "required_field_keys": []string{"pet_name", "pet_type"}}},
|
||||
{Key: "symptom", Type: "stage", Title: "症状确认", Content: "按维度权重逐个确认症状:高权重直接问细节,模糊区间用确认性问题升维降维,全部为零时用兜底话术引导并支持手动勾选。", Config: map[string]interface{}{"stage_key": "symptom"}},
|
||||
{Key: "diagnosis", Type: "stage", Title: "拟诊确认", Content: "根据症状权重联动疾病方向,用确认性话术做升维降维。", Config: map[string]interface{}{"stage_key": "diagnosis"}},
|
||||
{Key: "recommend", Type: "stage", Title: "药品推荐与注意事项", Content: "按疾病维度权重匹配推荐方案、商品与注意事项话术。", Config: map[string]interface{}{"stage_key": "recommend"}},
|
||||
{Key: "finish", Type: "finish", Title: "结束", Content: "本次沟通已完成,提交最终推荐结果。", Config: map[string]interface{}{}},
|
||||
}
|
||||
}
|
||||
|
||||
func petEdges() []edgeDefinition {
|
||||
return []edgeDefinition{
|
||||
{Source: "start", Target: "pet_info", Condition: map[string]interface{}{}},
|
||||
{Source: "pet_info", Target: "symptom", Condition: map[string]interface{}{}},
|
||||
{Source: "symptom", Target: "recommend_product", Condition: map[string]interface{}{}},
|
||||
{Source: "recommend_product", Target: "finish", Condition: map[string]interface{}{}},
|
||||
{Source: "start", Target: "opening", Condition: map[string]interface{}{}, Priority: 10},
|
||||
{Source: "opening", Target: "symptom", Condition: map[string]interface{}{}, Priority: 20},
|
||||
{Source: "symptom", Target: "diagnosis", Condition: map[string]interface{}{}, Priority: 30},
|
||||
{Source: "diagnosis", Target: "recommend", Condition: map[string]interface{}{}, Priority: 40},
|
||||
{Source: "recommend", Target: "finish", Condition: map[string]interface{}{}, Priority: 50},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,46 +13,26 @@ func TestPetDoctorSOPPassesPublishValidation(t *testing.T) {
|
||||
fields := petFieldModels()
|
||||
nodes := petNodeModels(petNodes())
|
||||
edges := petEdgeModels(petEdges())
|
||||
if len(fields) != 13 || len(nodes) != 5 || len(edges) != 4 {
|
||||
if len(fields) != 11 || len(nodes) != 6 || len(edges) != 5 {
|
||||
t.Fatalf("unexpected definition size: fields=%d nodes=%d edges=%d", len(fields), len(nodes), len(edges))
|
||||
}
|
||||
|
||||
problems := sop.ValidateForPublish("start", nodes, edges, sop.ValidationContext{
|
||||
Fields: fields,
|
||||
KnowledgeItems: []model.KnowledgeItem{
|
||||
{Base: model.Base{ID: 1}, ItemKey: "consultation_boundary", Type: "guidance", Status: "active"},
|
||||
{Base: model.Base{ID: 2}, ItemKey: "emergency_guidance", Type: "guidance", Status: "active"},
|
||||
{Base: model.Base{ID: 3}, ItemKey: "medication_safety", Type: "guidance", Status: "active"},
|
||||
{Base: model.Base{ID: 4}, ItemKey: "safety_net", Type: "guidance", Status: "active"},
|
||||
{Base: model.Base{ID: 5}, ItemKey: "soft_stool", Type: "symptom", Status: "active"},
|
||||
{Base: model.Base{ID: 6}, ItemKey: "poor_appetite", Type: "symptom", Status: "active"},
|
||||
{Base: model.Base{ID: 7}, ItemKey: "ask_stool", Type: "copy", Status: "active"},
|
||||
{Base: model.Base{ID: 8}, ItemKey: "gi_discomfort", Type: "disease", Status: "active"},
|
||||
{Base: model.Base{ID: 9}, ItemKey: "gut_plan", Type: "plan", Status: "active"},
|
||||
{Base: model.Base{ID: 10}, ItemKey: "gut_product", Type: "product", Status: "active"},
|
||||
},
|
||||
KnowledgeRelations: []model.KnowledgeRelation{
|
||||
{FromKnowledgeID: 5, ToKnowledgeID: 7, RelationType: "recommended_copy"},
|
||||
{FromKnowledgeID: 5, ToKnowledgeID: 8, RelationType: "possible_disease"},
|
||||
{FromKnowledgeID: 5, ToKnowledgeID: 9, RelationType: "recommended_plan"},
|
||||
{FromKnowledgeID: 8, ToKnowledgeID: 7, RelationType: "recommended_copy"},
|
||||
{FromKnowledgeID: 8, ToKnowledgeID: 9, RelationType: "recommended_plan"},
|
||||
{FromKnowledgeID: 8, ToKnowledgeID: 10, RelationType: "recommended_product"},
|
||||
},
|
||||
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))
|
||||
|
||||
602
scripts/seed-pet-doctor/package_builder.go
Normal file
602
scripts/seed-pet-doctor/package_builder.go
Normal file
@@ -0,0 +1,602 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// Data sources for the script package.
|
||||
const petScriptSource = "症状-疾病-话术.json"
|
||||
const petQuestionSource = "症状确认话术.json"
|
||||
const petSmallCategorySource = "症状小类-大类.json"
|
||||
const petSmallAskScriptSource = "症状小类询问话术.json"
|
||||
const petPreGuideSource = "症状确认前引导话术.json"
|
||||
const petClassificationSource = "症状分类.json"
|
||||
const petKnowledgeSource = "症状-疾病-方案.xlsx"
|
||||
|
||||
type petScriptEntry struct {
|
||||
Title string `json:"title"`
|
||||
Template string `json:"template"`
|
||||
}
|
||||
|
||||
type petClassification struct {
|
||||
Symptoms []struct {
|
||||
Type string `json:"type"`
|
||||
Subs []string `json:"subs"`
|
||||
} `json:"symptoms"`
|
||||
Diagnosis []struct {
|
||||
Type string `json:"type"`
|
||||
Subs []struct {
|
||||
SubType string `json:"subType"`
|
||||
} `json:"subs"`
|
||||
} `json:"diagnosis"`
|
||||
}
|
||||
|
||||
type petCombination struct {
|
||||
Name string `json:"name"`
|
||||
Weight int `json:"weight"`
|
||||
Products []struct {
|
||||
SKUCode string `json:"sku_code"`
|
||||
ProductName string `json:"product_name"`
|
||||
} `json:"products"`
|
||||
Script string `json:"script"`
|
||||
}
|
||||
|
||||
type petScriptDisease struct {
|
||||
Combinations []petCombination `json:"combinations"`
|
||||
}
|
||||
|
||||
type petKnowledgeRow struct {
|
||||
Symptom string
|
||||
Disease string
|
||||
SKUCode string
|
||||
ProductName string
|
||||
}
|
||||
|
||||
// petQuestionBank is the customer-facing copy bank for the script package.
|
||||
// Every question lives in the JSON data file, not in Go code.
|
||||
type petQuestionBank struct {
|
||||
OpeningGreeting string `json:"opening_greeting"`
|
||||
DiagnosisFallback string `json:"diagnosis_fallback"`
|
||||
RecommendFallback string `json:"recommend_fallback"`
|
||||
SymptomScreeningTemplate string `json:"symptom_screening_template"`
|
||||
SymptomConfirmOptions struct {
|
||||
Yes string `json:"yes"`
|
||||
No string `json:"no"`
|
||||
} `json:"symptom_confirm_options"`
|
||||
DiseaseConfirmOptions struct {
|
||||
Yes string `json:"yes"`
|
||||
No string `json:"no"`
|
||||
} `json:"disease_confirm_options"`
|
||||
Symptoms map[string]petSymptomQuestions `json:"symptoms"`
|
||||
|
||||
DiseaseConfirmTemplate string `json:"disease_confirm_template"`
|
||||
DiseaseConfirmedCopyTemplate string `json:"disease_confirmed_copy_template"`
|
||||
DiseasePlanIntro string `json:"disease_plan_intro"`
|
||||
DiseaseCareNote string `json:"disease_care_note"`
|
||||
Diseases map[string]petDiseasePhrase `json:"diseases"`
|
||||
}
|
||||
|
||||
type petSymptomQuestions struct {
|
||||
Confirm string `json:"confirm"`
|
||||
Short string `json:"short"`
|
||||
Choices []petChoiceQuestion `json:"choices"`
|
||||
}
|
||||
|
||||
type petChoiceQuestion struct {
|
||||
Title string `json:"title"`
|
||||
Question string `json:"question"`
|
||||
Multiple bool `json:"multiple"`
|
||||
Options []petChoiceOption `json:"options"`
|
||||
}
|
||||
|
||||
type petChoiceOption struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type petDiseasePhrase struct {
|
||||
Phrase string `json:"phrase"`
|
||||
}
|
||||
|
||||
// scriptKeyGenerator produces stable ASCII keys for dimension values and
|
||||
// scripts. Chinese display names live in the Name field.
|
||||
type scriptKeyGenerator struct {
|
||||
counters map[string]int
|
||||
}
|
||||
|
||||
func (g *scriptKeyGenerator) key(prefix string) string {
|
||||
g.counters[prefix]++
|
||||
return fmt.Sprintf("%s_%03d", prefix, g.counters[prefix])
|
||||
}
|
||||
|
||||
// buildScriptPackage builds the 问诊问药 script package from the docs files.
|
||||
func buildScriptPackage(scriptFile, knowledgeFile, smallCategoryFile, smallAskScriptFile, preGuideFile, classificationFile, questionFile string) (scriptkit.PackageInput, error) {
|
||||
bank, err := readQuestionBank(questionFile)
|
||||
if err != nil {
|
||||
return scriptkit.PackageInput{}, err
|
||||
}
|
||||
classification, err := readClassification(classificationFile)
|
||||
if err != nil {
|
||||
return scriptkit.PackageInput{}, err
|
||||
}
|
||||
smallToBig, err := readStringMap(smallCategoryFile)
|
||||
if err != nil {
|
||||
return scriptkit.PackageInput{}, err
|
||||
}
|
||||
smallAsk, err := readPetScriptMap(smallAskScriptFile)
|
||||
if err != nil {
|
||||
return scriptkit.PackageInput{}, err
|
||||
}
|
||||
preGuide, err := readPreGuide(preGuideFile)
|
||||
if err != nil {
|
||||
return scriptkit.PackageInput{}, err
|
||||
}
|
||||
combinations, combinationSymptoms, err := readCombinations(scriptFile)
|
||||
if err != nil {
|
||||
return scriptkit.PackageInput{}, err
|
||||
}
|
||||
rows, err := readPetKnowledge(knowledgeFile)
|
||||
if err != nil {
|
||||
return scriptkit.PackageInput{}, err
|
||||
}
|
||||
|
||||
keys := &scriptKeyGenerator{counters: map[string]int{}}
|
||||
input := scriptkit.PackageInput{
|
||||
Name: "宠物问诊问药话术包",
|
||||
StartStageKey: "opening",
|
||||
Dimensions: []scriptkit.DimensionInput{},
|
||||
Stages: []scriptkit.StageInput{},
|
||||
Linkages: []scriptkit.LinkageInput{},
|
||||
Adapters: []scriptkit.AdapterInput{},
|
||||
}
|
||||
|
||||
// 症状大类来自小类映射、诊断分组、组合话术和 SKU 表的并集。
|
||||
bigSet := map[string]bool{}
|
||||
for _, big := range smallToBig {
|
||||
if big != "" {
|
||||
bigSet[big] = true
|
||||
}
|
||||
}
|
||||
for _, diagnosis := range classification.Diagnosis {
|
||||
if diagnosis.Type != "" {
|
||||
bigSet[diagnosis.Type] = true
|
||||
}
|
||||
}
|
||||
for big := range combinationSymptoms {
|
||||
bigSet[big] = true
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Symptom != "" {
|
||||
bigSet[row.Symptom] = true
|
||||
}
|
||||
}
|
||||
bigNames := make([]string, 0, len(bigSet))
|
||||
for name := range bigSet {
|
||||
bigNames = append(bigNames, name)
|
||||
}
|
||||
sort.Strings(bigNames)
|
||||
bigKey := map[string]string{}
|
||||
bigDimension := scriptkit.DimensionInput{DimKey: "symptom", Name: "症状", SortOrder: 1, Values: []scriptkit.ValueInput{}}
|
||||
for _, name := range bigNames {
|
||||
key := keys.key("symptom")
|
||||
bigKey[name] = key
|
||||
bigDimension.Values = append(bigDimension.Values, scriptkit.ValueInput{ValueKey: key, Name: name, InitialWeight: 0, SortOrder: len(bigDimension.Values)})
|
||||
}
|
||||
input.Dimensions = append(input.Dimensions, bigDimension)
|
||||
|
||||
subSet := map[string]bool{}
|
||||
for sub := range smallToBig {
|
||||
if sub != "" {
|
||||
subSet[sub] = true
|
||||
}
|
||||
}
|
||||
for _, symptom := range classification.Symptoms {
|
||||
for _, sub := range symptom.Subs {
|
||||
if sub != "" {
|
||||
subSet[sub] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
subNames := make([]string, 0, len(subSet))
|
||||
for name := range subSet {
|
||||
subNames = append(subNames, name)
|
||||
}
|
||||
sort.Strings(subNames)
|
||||
subKey := map[string]string{}
|
||||
subDimension := scriptkit.DimensionInput{DimKey: "symptom_sub", Name: "症状小类", SortOrder: 2, Values: []scriptkit.ValueInput{}}
|
||||
for _, name := range subNames {
|
||||
key := keys.key("symptom_sub")
|
||||
subKey[name] = key
|
||||
subDimension.Values = append(subDimension.Values, scriptkit.ValueInput{ValueKey: key, Name: name, InitialWeight: 0, SortOrder: len(subDimension.Values)})
|
||||
}
|
||||
input.Dimensions = append(input.Dimensions, subDimension)
|
||||
|
||||
diseaseNames := make([]string, 0)
|
||||
diseaseKey := map[string]string{}
|
||||
for _, diagnosis := range classification.Diagnosis {
|
||||
for _, sub := range diagnosis.Subs {
|
||||
if sub.SubType == "" {
|
||||
continue
|
||||
}
|
||||
diseaseNames = append(diseaseNames, sub.SubType)
|
||||
}
|
||||
}
|
||||
sort.Strings(diseaseNames)
|
||||
diseaseDimension := scriptkit.DimensionInput{DimKey: "disease", Name: "疾病分型", SortOrder: 3, Values: []scriptkit.ValueInput{}}
|
||||
for _, name := range diseaseNames {
|
||||
key := keys.key("disease")
|
||||
diseaseKey[name] = key
|
||||
diseaseDimension.Values = append(diseaseDimension.Values, scriptkit.ValueInput{ValueKey: key, Name: name, InitialWeight: 0, SortOrder: len(diseaseDimension.Values)})
|
||||
}
|
||||
input.Dimensions = append(input.Dimensions, diseaseDimension)
|
||||
|
||||
// 每个症状都必须配置确认题和选择题;每个疾病都必须配置口语化说法。
|
||||
var missingSymptoms, missingDiseases []string
|
||||
for _, name := range bigNames {
|
||||
questions, ok := bank.Symptoms[name]
|
||||
if !ok || strings.TrimSpace(questions.Confirm) == "" || len(questions.Choices) == 0 {
|
||||
missingSymptoms = append(missingSymptoms, name)
|
||||
}
|
||||
}
|
||||
for _, name := range diseaseNames {
|
||||
if _, ok := bank.Diseases[name]; !ok {
|
||||
missingDiseases = append(missingDiseases, name)
|
||||
}
|
||||
}
|
||||
if len(missingSymptoms) > 0 {
|
||||
return scriptkit.PackageInput{}, fmt.Errorf("话术库 %s 缺少症状确认题/选择题: %v", questionFile, missingSymptoms)
|
||||
}
|
||||
if len(missingDiseases) > 0 {
|
||||
return scriptkit.PackageInput{}, fmt.Errorf("话术库 %s 缺少疾病口语化说法: %v", questionFile, missingDiseases)
|
||||
}
|
||||
|
||||
// Linkages: 小类 -> 大类, 大类 -> 疾病. 激活阈值 5:只有已确认的症状
|
||||
// (权重达到 5 以上)才会把疾病带入拟诊阶段;模糊提示(权重 3)只触发
|
||||
// 症状确认题,不会直接带出疾病。
|
||||
linkages := make([]scriptkit.LinkageInput, 0)
|
||||
for sub, big := range smallToBig {
|
||||
if subKey[sub] == "" || bigKey[big] == "" {
|
||||
continue
|
||||
}
|
||||
linkages = append(linkages, scriptkit.LinkageInput{FromDimensionValueKey: subKey[sub], ToDimensionValueKey: bigKey[big], RelationType: "contributes_to", Contribution: 8, ActivationThreshold: 5, SortOrder: len(linkages)})
|
||||
}
|
||||
for _, diagnosis := range classification.Diagnosis {
|
||||
for _, sub := range diagnosis.Subs {
|
||||
if bigKey[diagnosis.Type] == "" || diseaseKey[sub.SubType] == "" {
|
||||
continue
|
||||
}
|
||||
linkages = append(linkages, scriptkit.LinkageInput{FromDimensionValueKey: bigKey[diagnosis.Type], ToDimensionValueKey: diseaseKey[sub.SubType], RelationType: "contributes_to", Contribution: 5, ActivationThreshold: 5, SortOrder: len(linkages)})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(linkages, func(i, j int) bool { return linkages[i].SortOrder < linkages[j].SortOrder })
|
||||
input.Linkages = linkages
|
||||
|
||||
// Adapters: symptom tags and order SKUs initialize symptom weights.
|
||||
// SKU 只提示相关症状(权重 3,触发症状确认题),不会直接命中疾病——
|
||||
// 疾病必须由已确认的症状联动带出。
|
||||
adapters := make([]scriptkit.AdapterInput, 0)
|
||||
for _, name := range bigNames {
|
||||
adapters = append(adapters, scriptkit.AdapterInput{SourceField: "input.input_symptom_tags", MatchValue: name, TargetDimensionValueKey: bigKey[name], Weight: 8, SortOrder: len(adapters)})
|
||||
}
|
||||
for _, name := range subNames {
|
||||
adapters = append(adapters, scriptkit.AdapterInput{SourceField: "input.input_symptom_tags", MatchValue: name, TargetDimensionValueKey: subKey[name], Weight: 8, SortOrder: len(adapters)})
|
||||
}
|
||||
for _, row := range rows {
|
||||
if strings.TrimSpace(row.SKUCode) == "" {
|
||||
continue
|
||||
}
|
||||
if bigKey[row.Symptom] != "" {
|
||||
adapters = append(adapters, scriptkit.AdapterInput{SourceField: "derived.matched_skus", MatchValue: row.SKUCode, TargetDimensionValueKey: bigKey[row.Symptom], Weight: 3, SortOrder: len(adapters)})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(adapters, func(i, j int) bool { return adapters[i].SortOrder < adapters[j].SortOrder })
|
||||
input.Adapters = adapters
|
||||
|
||||
// Stage 1: 开场. 开场只做问候和宠物档案补充,不显示任何维度(症状只在症状节点显示)。
|
||||
opening := scriptkit.StageInput{
|
||||
StageKey: "opening", Name: "开场", Purpose: "确认顾客与宠物信息,并补充宠物档案", PrimaryDimensionKey: "", SortOrder: 1, Scripts: []scriptkit.ScriptInput{
|
||||
{ScriptKey: "opening_greeting", Name: "开场话术", ScriptType: "template", Content: bank.OpeningGreeting, SortOrder: 10},
|
||||
},
|
||||
}
|
||||
input.Stages = append(input.Stages, opening)
|
||||
|
||||
// Stage 2: 症状确认。候选症状超过 3 个时先做一道多选筛选题,
|
||||
// 筛完再对选中的症状按由浅入深的顺序做选择题。
|
||||
symptomStage := scriptkit.StageInput{StageKey: "symptom", Name: "症状确认", Purpose: "确认宠物当前的主要症状", PrimaryDimensionKey: "symptom", SortOrder: 2, Scripts: []scriptkit.ScriptInput{}}
|
||||
for index, guide := range preGuide {
|
||||
symptomStage.Scripts = append(symptomStage.Scripts, scriptkit.ScriptInput{ScriptKey: keys.key("symptom_guide"), Name: "引导话术" + guide.Title, ScriptType: "fallback", Content: guide.Template, SortOrder: 100 + index})
|
||||
}
|
||||
screenOptions := make([]scriptkit.OptionInput, 0, len(bigNames))
|
||||
for _, name := range bigNames {
|
||||
short := bank.Symptoms[name].Short
|
||||
if short == "" {
|
||||
short = name
|
||||
}
|
||||
screenOptions = append(screenOptions, scriptkit.OptionInput{OptionKey: bigKey[name], Label: short, TargetDimensionValueKey: bigKey[name], Effect: "set", EffectValue: 10, SortOrder: len(screenOptions) + 1})
|
||||
}
|
||||
symptomStage.Scripts = append(symptomStage.Scripts, scriptkit.ScriptInput{
|
||||
ScriptKey: keys.key("symptom_screen"), Name: "症状筛选", ScriptType: "screen",
|
||||
Content: bank.SymptomScreeningTemplate, Multiple: true,
|
||||
SortOrder: 50, Options: screenOptions,
|
||||
})
|
||||
for _, name := range bigNames {
|
||||
questions := bank.Symptoms[name]
|
||||
confirm := scriptkit.ScriptInput{
|
||||
ScriptKey: keys.key("symptom_confirm"), Name: "确认" + name + "症状", ScriptType: "confirm",
|
||||
Content: questions.Confirm,
|
||||
DimensionValueKey: bigKey[name], ConfirmThreshold: 3, ShowThreshold: 5, SortOrder: 1000,
|
||||
Options: []scriptkit.OptionInput{
|
||||
{OptionKey: "yes", Label: bank.SymptomConfirmOptions.Yes, TargetDimensionValueKey: bigKey[name], Effect: "set", EffectValue: 8, SortOrder: 1},
|
||||
{OptionKey: "no", Label: bank.SymptomConfirmOptions.No, TargetDimensionValueKey: bigKey[name], Effect: "zero", EffectValue: 0, SortOrder: 2},
|
||||
},
|
||||
}
|
||||
symptomStage.Scripts = append(symptomStage.Scripts, confirm)
|
||||
for choiceIndex, choice := range questions.Choices {
|
||||
options := make([]scriptkit.OptionInput, 0, len(choice.Options))
|
||||
for optionIndex, option := range choice.Options {
|
||||
options = append(options, scriptkit.OptionInput{OptionKey: option.Key, Label: option.Label, Effect: "set", EffectValue: 0, SortOrder: optionIndex + 1})
|
||||
}
|
||||
scriptKey := keys.key("symptom_choice")
|
||||
symptomStage.Scripts = append(symptomStage.Scripts, scriptkit.ScriptInput{
|
||||
ScriptKey: scriptKey, Name: name + choice.Title, ScriptType: "choice",
|
||||
Content: choice.Question, DimensionValueKey: bigKey[name],
|
||||
ConfirmThreshold: 3, ShowThreshold: 5, CollectFieldKey: scriptKey,
|
||||
Multiple: choice.Multiple,
|
||||
SortOrder: 1100 + choiceIndex, Options: options,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, name := range subNames {
|
||||
for index, ask := range smallAsk[name] {
|
||||
symptomStage.Scripts = append(symptomStage.Scripts, scriptkit.ScriptInput{ScriptKey: keys.key("symptom_sub_ask"), Name: name + ask.Title, ScriptType: "info", Content: ask.Template, DimensionValueKey: subKey[name], ConfirmThreshold: 3, ShowThreshold: 5, SortOrder: 5000 + index})
|
||||
}
|
||||
}
|
||||
input.Stages = append(input.Stages, symptomStage)
|
||||
|
||||
// Stage 3: 拟诊确认. 候选疾病(由已确认症状带出,权重5)先显示确认题;
|
||||
// 客户确认后(权重10)才显示该疾病的确认话术,可同时确认多个疾病。
|
||||
diagnosisStage := scriptkit.StageInput{StageKey: "diagnosis", Name: "拟诊确认", Purpose: "结合症状确认可能的疾病方向(可确认多个),确认后再进入推荐", PrimaryDimensionKey: "disease", SortOrder: 3, Scripts: []scriptkit.ScriptInput{
|
||||
{ScriptKey: "diagnosis_fallback", Name: "拟诊兜底话术", ScriptType: "fallback", Content: bank.DiagnosisFallback, SortOrder: 100},
|
||||
}}
|
||||
for _, name := range diseaseNames {
|
||||
phrase := bank.Diseases[name].Phrase
|
||||
confirm := scriptkit.ScriptInput{
|
||||
ScriptKey: keys.key("disease_confirm"), Name: "确认" + name + "方向", ScriptType: "confirm",
|
||||
Content: strings.ReplaceAll(bank.DiseaseConfirmTemplate, "{{disease_phrase}}", phrase),
|
||||
DimensionValueKey: diseaseKey[name], ConfirmThreshold: 5, ShowThreshold: 10, SortOrder: 1000,
|
||||
Options: []scriptkit.OptionInput{
|
||||
{OptionKey: "yes", Label: bank.DiseaseConfirmOptions.Yes, TargetDimensionValueKey: diseaseKey[name], Effect: "set", EffectValue: 10, SortOrder: 1},
|
||||
{OptionKey: "no", Label: bank.DiseaseConfirmOptions.No, TargetDimensionValueKey: diseaseKey[name], Effect: "zero", EffectValue: 0, SortOrder: 2},
|
||||
},
|
||||
}
|
||||
diagnosisStage.Scripts = append(diagnosisStage.Scripts, confirm)
|
||||
diagnosisStage.Scripts = append(diagnosisStage.Scripts, scriptkit.ScriptInput{
|
||||
ScriptKey: keys.key("disease_copy"), Name: name + "疾病确认话术", ScriptType: "message",
|
||||
Content: strings.ReplaceAll(bank.DiseaseConfirmedCopyTemplate, "{{disease_phrase}}", phrase),
|
||||
DimensionValueKey: diseaseKey[name], ConfirmThreshold: 5, ShowThreshold: 10, SortOrder: 1100,
|
||||
})
|
||||
diagnosisStage.Scripts = append(diagnosisStage.Scripts, scriptkit.ScriptInput{
|
||||
ScriptKey: keys.key("disease_copy"), Name: name + "方案引入话术", ScriptType: "message",
|
||||
Content: bank.DiseasePlanIntro,
|
||||
DimensionValueKey: diseaseKey[name], ConfirmThreshold: 5, ShowThreshold: 10, SortOrder: 1101,
|
||||
})
|
||||
}
|
||||
input.Stages = append(input.Stages, diagnosisStage)
|
||||
|
||||
// Stage 4: 药品推荐与注意事项. 只有客户确认过的疾病(权重10)才会推荐。
|
||||
recommendStage := scriptkit.StageInput{StageKey: "recommend", Name: "药品推荐与注意事项", Purpose: "按已确认的疾病方向推荐方案、商品和注意事项", PrimaryDimensionKey: "disease", SortOrder: 4, Scripts: []scriptkit.ScriptInput{
|
||||
{ScriptKey: "recommend_fallback", Name: "推荐兜底话术", ScriptType: "fallback", Content: bank.RecommendFallback, SortOrder: 100},
|
||||
}}
|
||||
for _, name := range diseaseNames {
|
||||
order := 1000
|
||||
combos := combinations[name]
|
||||
sort.SliceStable(combos, func(i, j int) bool { return combos[i].Weight > combos[j].Weight })
|
||||
for _, combination := range combos {
|
||||
products := make([]scriptkit.ProductInput, 0, len(combination.Products))
|
||||
for _, product := range combination.Products {
|
||||
if strings.TrimSpace(product.SKUCode) == "" {
|
||||
continue
|
||||
}
|
||||
products = append(products, scriptkit.ProductInput{SKUCode: product.SKUCode, ProductName: product.ProductName})
|
||||
}
|
||||
recommendStage.Scripts = append(recommendStage.Scripts, scriptkit.ScriptInput{
|
||||
ScriptKey: keys.key("recommend_combination"), Name: name + combination.Name, ScriptType: "message",
|
||||
Content: strings.ReplaceAll(combination.Script, "{{purchased_products}}", "{{input.product_names}}"),
|
||||
DimensionValueKey: diseaseKey[name], ConfirmThreshold: 5, ShowThreshold: 10,
|
||||
Products: products, SortOrder: order,
|
||||
})
|
||||
order++
|
||||
}
|
||||
recommendStage.Scripts = append(recommendStage.Scripts, scriptkit.ScriptInput{
|
||||
ScriptKey: keys.key("recommend_note"), Name: name + "注意事项", ScriptType: "message",
|
||||
Content: bank.DiseaseCareNote,
|
||||
DimensionValueKey: diseaseKey[name], ConfirmThreshold: 5, ShowThreshold: 10, SortOrder: order,
|
||||
})
|
||||
}
|
||||
input.Stages = append(input.Stages, recommendStage)
|
||||
|
||||
// Verify generated keys are valid before returning.
|
||||
if err := scriptkit.ValidatePackageInput(input); err != nil {
|
||||
return scriptkit.PackageInput{}, fmt.Errorf("generated package is invalid: %w", err)
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func readQuestionBank(filename string) (petQuestionBank, error) {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return petQuestionBank{}, fmt.Errorf("read pet question bank %s: %w", filename, err)
|
||||
}
|
||||
var bank petQuestionBank
|
||||
if err := json.Unmarshal(raw, &bank); err != nil {
|
||||
return petQuestionBank{}, fmt.Errorf("parse pet question bank %s: %w", filename, err)
|
||||
}
|
||||
if bank.SymptomConfirmOptions.Yes == "" {
|
||||
bank.SymptomConfirmOptions.Yes = "有"
|
||||
}
|
||||
if bank.SymptomConfirmOptions.No == "" {
|
||||
bank.SymptomConfirmOptions.No = "没有"
|
||||
}
|
||||
if bank.DiseaseConfirmOptions.Yes == "" {
|
||||
bank.DiseaseConfirmOptions.Yes = "对,按这个判断"
|
||||
}
|
||||
if bank.DiseaseConfirmOptions.No == "" {
|
||||
bank.DiseaseConfirmOptions.No = "感觉不太像"
|
||||
}
|
||||
if bank.SymptomScreeningTemplate == "" {
|
||||
return petQuestionBank{}, fmt.Errorf("pet question bank %s is missing symptom_screening_template", filename)
|
||||
}
|
||||
if bank.DiseaseConfirmTemplate == "" || bank.DiseaseConfirmedCopyTemplate == "" {
|
||||
return petQuestionBank{}, fmt.Errorf("pet question bank %s is missing disease templates", filename)
|
||||
}
|
||||
return bank, nil
|
||||
}
|
||||
|
||||
func readClassification(filename string) (petClassification, error) {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return petClassification{}, fmt.Errorf("read pet classification %s: %w", filename, err)
|
||||
}
|
||||
var wrapper struct {
|
||||
ConsultPayload string `json:"consultPayload"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrapper); err != nil {
|
||||
return petClassification{}, fmt.Errorf("parse pet classification wrapper %s: %w", filename, err)
|
||||
}
|
||||
var result petClassification
|
||||
if err := json.Unmarshal([]byte(wrapper.ConsultPayload), &result); err != nil {
|
||||
return petClassification{}, fmt.Errorf("parse pet classification %s: %w", filename, err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readStringMap(filename string) (map[string]string, error) {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pet string map %s: %w", filename, err)
|
||||
}
|
||||
var result map[string]string
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse pet string map %s: %w", filename, err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readPetScriptMap(filename string) (map[string][]petScriptEntry, error) {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pet script map %s: %w", filename, err)
|
||||
}
|
||||
var source map[string][]petScriptEntry
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
return nil, fmt.Errorf("parse pet script map %s: %w", filename, err)
|
||||
}
|
||||
return source, nil
|
||||
}
|
||||
|
||||
func readPreGuide(filename string) ([]petScriptEntry, error) {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pet pre-symptom guide %s: %w", filename, err)
|
||||
}
|
||||
var source struct {
|
||||
PreSymptomScripts []petScriptEntry `json:"pre_symptom_scripts"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
return nil, fmt.Errorf("parse pet pre-symptom guide %s: %w", filename, err)
|
||||
}
|
||||
return source.PreSymptomScripts, nil
|
||||
}
|
||||
|
||||
// readCombinations loads 疾病 -> 组合方案话术 and the symptom keys of the source.
|
||||
func readCombinations(filename string) (map[string][]petCombination, map[string]bool, error) {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read pet script combinations %s: %w", filename, err)
|
||||
}
|
||||
var source map[string]map[string]petScriptDisease
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse pet script combinations %s: %w", filename, err)
|
||||
}
|
||||
result := map[string][]petCombination{}
|
||||
symptoms := map[string]bool{}
|
||||
for symptom, diseases := range source {
|
||||
symptoms[symptom] = true
|
||||
for disease, definition := range diseases {
|
||||
result[disease] = append(result[disease], definition.Combinations...)
|
||||
}
|
||||
}
|
||||
return result, symptoms, nil
|
||||
}
|
||||
|
||||
func readPetKnowledge(filename string) ([]petKnowledgeRow, error) {
|
||||
book, err := excelize.OpenFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open pet knowledge %s: %w", filename, err)
|
||||
}
|
||||
defer book.Close()
|
||||
sheets := book.GetSheetList()
|
||||
if len(sheets) == 0 {
|
||||
return nil, fmt.Errorf("pet knowledge %s has no worksheet", filename)
|
||||
}
|
||||
iterator, err := book.Rows(sheets[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pet knowledge worksheet: %w", err)
|
||||
}
|
||||
defer iterator.Close()
|
||||
columns := map[string]int{}
|
||||
result := make([]petKnowledgeRow, 0)
|
||||
rowNumber := 0
|
||||
for iterator.Next() {
|
||||
rowNumber++
|
||||
values, err := iterator.Columns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pet knowledge row %d: %w", rowNumber, err)
|
||||
}
|
||||
if rowNumber == 1 {
|
||||
for index, value := range values {
|
||||
columns[strings.TrimSpace(value)] = index
|
||||
}
|
||||
for _, required := range []string{"症状", "疾病", "方案", "SKU_CODE", "商品标题"} {
|
||||
if _, ok := columns[required]; !ok {
|
||||
return nil, fmt.Errorf("pet knowledge is missing column %q", required)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
row := petKnowledgeRow{
|
||||
Symptom: excelValue(values, columns["症状"]),
|
||||
Disease: excelValue(values, columns["疾病"]),
|
||||
SKUCode: excelValue(values, columns["SKU_CODE"]),
|
||||
ProductName: excelValue(values, columns["商品标题"]),
|
||||
}
|
||||
if row.Symptom == "" && row.Disease == "" {
|
||||
continue
|
||||
}
|
||||
if row.Symptom == "" || row.Disease == "" {
|
||||
return nil, fmt.Errorf("pet knowledge row %d must contain 症状 and 疾病", rowNumber)
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
if err := iterator.Error(); err != nil {
|
||||
return nil, fmt.Errorf("iterate pet knowledge: %w", err)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("pet knowledge contains no data")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func excelValue(values []string, index int) string {
|
||||
if index >= len(values) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(values[index])
|
||||
}
|
||||
Reference in New Issue
Block a user