feat: simplify SOP to immediate-effect configuration

This commit is contained in:
Eric 1549169735@qq.com
2026-08-18 16:27:02 +08:00
parent c5ab886b70
commit 8de48fb05e
150 changed files with 6764 additions and 1626 deletions

View File

@@ -0,0 +1,320 @@
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

@@ -5,14 +5,12 @@ import (
"flag"
"fmt"
"os"
"reflect"
"time"
"path/filepath"
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
"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"
sopservice "git.iwork-ai.com/xdc/iqudo-top1/internal/sop"
"go.uber.org/zap"
"gorm.io/datatypes"
"gorm.io/gorm"
@@ -21,6 +19,7 @@ import (
type fieldDefinition struct {
Key string
Name string
SourcePath string
Type string
Required bool
Options []string
@@ -45,6 +44,7 @@ type edgeDefinition struct {
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")
flag.Parse()
cfg, err := config.Load(config.LoadOptions{Environment: *environment, ConfigDir: *configDir})
@@ -63,13 +63,13 @@ func main() {
if err != nil {
log.Fatal("connect database", zap.Error(err))
}
if err := seed(db); err != nil {
if err := seed(db, *scriptFile); err != nil {
log.Fatal("seed pet doctor scenario", zap.Error(err))
}
log.Info("pet doctor scenario is ready")
}
func seed(db *gorm.DB) error {
func seed(db *gorm.DB, scriptFile string) error {
return db.Transaction(func(tx *gorm.DB) error {
tenant, user, err := seedOwner(tx)
if err != nil {
@@ -82,11 +82,19 @@ func seed(db *gorm.DB) error {
if err := seedFields(tx, tenant.ID, scenario.ID); err != nil {
return err
}
knowledgeIDs, err := seedKnowledgeCards(tx, tenant.ID, user.ID, scenario.ID)
if err != nil {
if err := seedInputSchema(tx, tenant.ID, scenario.ID); err != nil {
return err
}
if err := seedSOP(tx, tenant.ID, user.ID, scenario.ID, knowledgeIDs); err != nil {
if err := seedRulesAndOutput(tx, tenant.ID, scenario.ID); err != nil {
return err
}
if err := resetPetKnowledge(tx, tenant.ID, scenario.ID); err != nil {
return err
}
if err := importPetScripts(tx, tenant.ID, scenario.ID, scriptFile); err != nil {
return err
}
if err := seedSOP(tx, tenant.ID, user.ID, scenario.ID); err != nil {
return err
}
return nil
@@ -127,7 +135,9 @@ func seedScenario(tx *gorm.DB, tenantID, userID uint64) (model.Scenario, error)
func seedFields(tx *gorm.DB, tenantID, scenarioID uint64) error {
definitions := petFieldDefinitions()
keys := make([]string, 0, len(definitions))
for index, definition := range definitions {
keys = append(keys, definition.Key)
options, _ := json.Marshal(definition.Options)
if definition.Options == nil {
options = []byte(`[]`)
@@ -137,150 +147,142 @@ func seedFields(tx *gorm.DB, tenantID, scenarioID uint64) error {
validation = []byte(`{}`)
}
var field model.ScenarioField
err := tx.Where("scenario_id = ? AND field_key = ?", scenarioID, definition.Key).Assign(model.ScenarioField{
TenantID: tenantID, ScenarioID: scenarioID, FieldName: definition.Name, FieldType: definition.Type,
Required: definition.Required, Options: datatypes.JSON(options), Validation: datatypes.JSON(validation), SortOrder: index,
}).FirstOrCreate(&field, model.ScenarioField{FieldKey: definition.Key}).Error
attributes := map[string]interface{}{
"tenant_id": tenantID, "scenario_id": scenarioID, "field_name": definition.Name, "source_path": definition.SourcePath,
"field_type": definition.Type, "required": definition.Required, "options": datatypes.JSON(options),
"validation": datatypes.JSON(validation), "sort_order": index,
}
err := tx.Where("scenario_id = ? AND field_key = ?", scenarioID, definition.Key).Assign(attributes).FirstOrCreate(&field, model.ScenarioField{FieldKey: definition.Key}).Error
if err != nil {
return fmt.Errorf("seed field %s: %w", definition.Key, err)
}
}
return tx.Where("tenant_id = ? AND scenario_id = ? AND field_key NOT IN ?", tenantID, scenarioID, keys).Delete(&model.ScenarioField{}).Error
}
func seedInputSchema(tx *gorm.DB, tenantID, scenarioID uint64) error {
fields := make([]model.ScenarioField, 0)
if err := tx.Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Order("sort_order,id").Find(&fields).Error; err != nil {
return err
}
items := make([]map[string]interface{}, 0, len(fields))
for _, field := range fields {
items = append(items, map[string]interface{}{"key": field.FieldKey, "name": field.FieldName, "type": field.FieldType, "source_path": field.SourcePath, "required": field.Required, "options": json.RawMessage(field.Options), "validation": json.RawMessage(field.Validation)})
}
raw, _ := json.Marshal(map[string]interface{}{"fields": items})
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", scenarioID, tenantID).Update("input_schema", datatypes.JSON(raw)).Error
}
func petFieldDefinitions() []fieldDefinition {
return []fieldDefinition{
{Key: "order_id", Name: "订单号", Type: "text", SourcePath: "order.id"},
{Key: "customer_name", Name: "客户称呼", Type: "text", SourcePath: "customer.name"},
{Key: "product_ids", Name: "订单商品 ID", Type: "array", SourcePath: "order.items[*].product_id"},
{Key: "product_names", Name: "订单商品名称", Type: "array", SourcePath: "order.items[*].product_name"},
{Key: "product_images", Name: "订单商品图片", Type: "array", SourcePath: "order.items[*].image_url"},
{Key: "input_symptom_tags", Name: "商品症状标签", Type: "array", SourcePath: "order.items[*].symptom_tags[*]"},
{Key: "pet_name", Name: "宠物名称", Type: "text", SourcePath: "pet.name", Validation: map[string]interface{}{"max_length": 50}},
{Key: "pet_type", Name: "宠物种类", Type: "select", SourcePath: "pet.species", Options: []string{"犬", "猫", "其他"}},
{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 petFieldDefinitions() []fieldDefinition {
return []fieldDefinition{
{Key: "consultation_channel", Name: "咨询渠道", Type: "select", Required: true, Options: []string{"到店", "电话", "在线图文", "视频"}},
{Key: "visit_goal", Name: "本次咨询目的", Type: "select", Required: true, Options: []string{"症状咨询", "用药咨询", "复诊随访", "检查报告解读", "其他"}},
{Key: "pet_name", Name: "宠物名称", Type: "text", Required: true, Validation: map[string]interface{}{"max_length": 50}},
{Key: "pet_type", Name: "宠物种类", Type: "select", Required: true, Options: []string{"犬", "猫", "其他"}},
{Key: "pet_breed", Name: "品种", Type: "text", Validation: map[string]interface{}{"max_length": 50}},
{Key: "pet_age", Name: "年龄(岁)", Type: "number", Validation: map[string]interface{}{"min": 0, "max": 50}},
{Key: "pet_weight", Name: "体重kg", Type: "number", Validation: map[string]interface{}{"min": 0.01, "max": 200}},
{Key: "pet_sex", Name: "性别", Type: "select", Options: []string{"公", "母", "未知"}},
{Key: "is_neutered", Name: "是否绝育", Type: "boolean"},
{Key: "pregnancy_status", Name: "妊娠或哺乳情况", Type: "select", Options: []string{"不适用", "否", "可能妊娠", "妊娠中", "哺乳中", "不清楚"}},
{Key: "symptom", Name: "主要症状", Type: "textarea", Required: true, Validation: map[string]interface{}{"max_length": 1000}},
{Key: "symptom_duration", Name: "症状开始时间及持续时长", Type: "text", Required: true, Validation: map[string]interface{}{"max_length": 100}},
{Key: "symptom_trend", Name: "症状变化趋势", Type: "select", Required: true, Options: []string{"好转", "稳定", "反复", "加重", "不清楚"}},
{Key: "prior_visit", Name: "本次问题是否已就诊", Type: "boolean"},
{Key: "breathing_difficulty", Name: "是否呼吸困难", Type: "boolean", Required: true},
{Key: "active_bleeding", Name: "是否持续出血", Type: "boolean", Required: true},
{Key: "convulsion", Name: "是否抽搐或意识异常", Type: "boolean", Required: true},
{Key: "severe_trauma", Name: "是否遭遇严重外伤或高处坠落", Type: "boolean", Required: true},
{Key: "unable_to_urinate", Name: "是否无法排尿", Type: "boolean", Required: true},
{Key: "toxin_exposure", Name: "是否可能误食毒物或异物", Type: "boolean", Required: true},
{Key: "repeated_vomiting", Name: "是否持续呕吐或干呕", Type: "boolean", Required: true},
{Key: "severe_pain", Name: "是否剧烈疼痛或腹部明显膨大", Type: "boolean", Required: true},
{Key: "heatstroke_sign", Name: "是否疑似中暑或体温异常升高", Type: "boolean", Required: true},
{Key: "unable_to_stand", Name: "是否虚弱到无法站立", Type: "boolean", Required: true},
{Key: "has_emergency_sign", Name: "是否存在其他危及生命的表现", Type: "boolean", Required: true},
{Key: "appetite", Name: "食欲情况", Type: "select", Options: []string{"正常", "下降", "完全不吃"}},
{Key: "water_intake", Name: "饮水情况", Type: "select", Options: []string{"正常", "增多", "减少", "完全不喝", "不清楚"}},
{Key: "spirit_status", Name: "精神状态", Type: "select", Options: []string{"正常", "较差", "嗜睡或无法站立"}},
{Key: "vomiting", Name: "是否呕吐", Type: "boolean"},
{Key: "diarrhea", Name: "是否腹泻", Type: "boolean"},
{Key: "urination_status", Name: "排尿情况", Type: "select", Options: []string{"正常", "增多", "减少", "频繁少量", "带血", "不清楚"}},
{Key: "stool_status", Name: "排便情况", Type: "select", Options: []string{"正常", "稀软", "水样", "带血或黑便", "便秘", "不清楚"}},
{Key: "measured_temperature", Name: "实测体温(℃)", Type: "number", Validation: map[string]interface{}{"min": 30, "max": 45}},
{Key: "chronic_disease_history", Name: "既往疾病及手术史", Type: "textarea", Validation: map[string]interface{}{"max_length": 1000}},
{Key: "vaccination_status", Name: "免疫情况", Type: "select", Options: []string{"按期完成", "未完成", "逾期", "不清楚", "不适用"}},
{Key: "medication_history", Name: "近期用药、驱虫药和保健品", Type: "textarea", Validation: map[string]interface{}{"max_length": 1000}},
{Key: "allergy_history", Name: "已知药物过敏或不良反应史", Type: "textarea", Validation: map[string]interface{}{"max_length": 500}},
{Key: "wants_medication", Name: "是否咨询具体用药", Type: "boolean", Required: true},
{Key: "medication_request_type", Name: "用药咨询类型", Type: "select", Options: []string{"新症状想用药", "询问现有处方用法", "续方或复购", "漏服或多服", "服药后异常", "其他"}},
{Key: "requested_medication", Name: "咨询的药品名称及规格", Type: "text", Validation: map[string]interface{}{"max_length": 200}},
{Key: "prescribed_by_vet", Name: "是否由宠物医生开具", Type: "boolean"},
{Key: "has_taken_medication", Name: "是否已经使用该药", Type: "boolean"},
{Key: "dose_and_time", Name: "已用剂量、次数及最后用药时间", Type: "textarea", Validation: map[string]interface{}{"max_length": 500}},
{Key: "human_medication_exposure", Name: "是否使用或误食人用药", Type: "boolean", Required: true},
{Key: "suspected_overdose", Name: "是否可能过量或重复用药", Type: "boolean", Required: true},
{Key: "adverse_reaction", Name: "用药后是否出现异常反应", Type: "boolean", Required: true},
{Key: "adverse_reaction_detail", Name: "异常反应描述", Type: "textarea", Validation: map[string]interface{}{"max_length": 1000}},
}
}
func seedKnowledgeCards(tx *gorm.DB, tenantID, userID, scenarioID uint64) (map[string]uint64, error) {
definitions := map[string]map[string]interface{}{
"线上问诊边界": {
"standard_copy": "线上沟通用于收集信息和判断紧急程度,不能替代体格检查、检验和影像检查。医生需要结合完整病史及检查结果后才能给出诊断和治疗方案。",
"forbidden_copy": "不要仅凭文字、照片或单个症状作出确定诊断,也不要承诺某种处理一定有效。",
"risk_note": "信息不完整、症状持续加重或无法准确观察时,应建议尽快到院评估。",
},
"急症红旗征象": {
"standard_copy": "出现呼吸困难、持续出血、抽搐或意识异常、严重外伤、无法排尿、持续呕吐或干呕、剧烈疼痛、腹部明显膨大、疑似中暑、无法站立、误食毒物或异物等情况时,应立即建议就近急诊或转诊,并提前联系接诊机构。",
"forbidden_copy": "不要承诺在家观察一定安全;不要在线给出能够替代急诊检查的判断。",
"risk_note": "急症分支优先级最高,不得因继续询问常规病史而延误就医。",
},
"问诊用药安全原则": {
"standard_copy": "用药需要结合物种、年龄、体重、既往病史、正在使用的药物和必要检查,由宠物医生评估后确定。请勿自行增加剂量、混用药物或使用人用药。",
"forbidden_copy": "未完成医生评估前,不给出具体处方药名称、剂量和疗程承诺。",
"risk_note": "对乙酰氨基酚、布洛芬等常见人用药可能对宠物造成严重伤害;如已误服,应按急症处理。",
},
"观察与复诊提示": {
"standard_copy": "请按医生要求记录精神、食欲、饮水、排尿、排便、呕吐次数及症状变化。若症状加重、出现新的急症表现,或在医生建议的观察时间内未改善,应尽快复诊。",
"forbidden_copy": "不要用固定天数替代医生根据病情给出的复诊时间,也不要因一次短暂好转自行停药。",
"risk_note": "离开本次沟通前,应让宠物主人复述下一步安排和需要立即就医的触发条件。",
},
}
result := make(map[string]uint64, len(definitions))
for title, content := range definitions {
var card model.KnowledgeCard
err := tx.Where("tenant_id = ? AND scenario_id = ? AND title = ?", tenantID, scenarioID, title).FirstOrCreate(&card, model.KnowledgeCard{
TenantID: tenantID, ScenarioID: scenarioID, Title: title, Status: "published", CreatedBy: userID,
}).Error
if err != nil {
return nil, fmt.Errorf("seed knowledge card %s: %w", title, err)
}
if err := tx.Model(&card).Update("status", "published").Error; err != nil {
return nil, err
}
contentJSON, _ := json.Marshal(content)
if err := publishSeedKnowledgeVersion(tx, tenantID, card.ID, contentJSON); err != nil {
return nil, fmt.Errorf("seed knowledge card version %s: %w", title, err)
}
result[title] = card.ID
}
return result, nil
}
func publishSeedKnowledgeVersion(tx *gorm.DB, tenantID, cardID uint64, contentJSON []byte) error {
var latest model.KnowledgeCardVersion
err := tx.Where("knowledge_card_id = ? AND tenant_id = ?", cardID, tenantID).Order("version DESC").First(&latest).Error
if err == gorm.ErrRecordNotFound {
return tx.Create(&model.KnowledgeCardVersion{
TenantID: tenantID, KnowledgeCardID: cardID, Version: 1,
Content: datatypes.JSON(contentJSON), Status: "published",
}).Error
}
if err != 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
}
if sameJSON(latest.Content, contentJSON) {
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ? AND id <> ? AND status = ?", cardID, tenantID, latest.ID, "published").Update("status", "superseded").Error; err != nil {
return err
}
return tx.Model(&latest).Update("status", "published").Error
}
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ? AND status = ?", cardID, tenantID, "published").Update("status", "superseded").Error; err != nil {
return err
}
return tx.Create(&model.KnowledgeCardVersion{
TenantID: tenantID, KnowledgeCardID: cardID, Version: latest.Version + 1,
Content: datatypes.JSON(contentJSON), Status: "published",
}).Error
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"},
}}
result := map[string]interface{}{"fields": []map[string]interface{}{
{"key": "recommended_products", "name": "成功推荐商品", "type": "array", "items": map[string]interface{}{"type": "object", "fields": []map[string]interface{}{
{"key": "product_id", "name": "商品 ID", "type": "string", "required": true},
{"key": "product_name", "name": "商品名称", "type": "string", "required": true},
{"key": "quantity", "name": "推荐数量", "type": "integer"},
}}},
{"key": "result_note", "name": "结果备注", "type": "text"},
}}
outputRaw, _ := json.Marshal(out)
resultRaw, _ := json.Marshal(result)
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", scenarioID, tenantID).Updates(map[string]interface{}{"output_schema": datatypes.JSON(outputRaw), "result_schema": datatypes.JSON(resultRaw)}).Error
}
func sameJSON(left, right []byte) bool {
var leftValue interface{}
var rightValue interface{}
if json.Unmarshal(left, &leftValue) != nil || json.Unmarshal(right, &rightValue) != nil {
return false
}
return reflect.DeepEqual(leftValue, rightValue)
}
func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[string]uint64) error {
func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64) error {
var sop model.SOP
err := tx.Where("tenant_id = ? AND scenario_id = ? AND name = ?", tenantID, scenarioID, "宠物问诊问药标准 SOP").FirstOrCreate(&sop, model.SOP{
TenantID: tenantID, ScenarioID: scenarioID, Name: "宠物问诊问药标准 SOP", CreatedBy: userID,
@@ -289,38 +291,36 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[
return fmt.Errorf("find or create SOP: %w", err)
}
if err := tx.Model(&sop).Updates(map[string]interface{}{
"description": "标准化采集宠物主诉和病史,通过急症及用药风险双重分流,形成医生评估、转诊和复诊闭环。",
"description": "根据订单商品携带的症状标签展示关联症状、话术和可能疾病,辅助销售完成商品推荐并记录结果。",
"status": "published",
}).Error; err != nil {
return err
}
var seededPublished int64
if err := tx.Table("sop_nodes n").Joins("JOIN sop_versions v ON v.id = n.sop_version_id").Where(
"v.sop_id = ? AND n.node_key = ? AND JSON_UNQUOTE(JSON_EXTRACT(n.config, '$.seed_key')) = ?",
sop.ID, "start", "pet-doctor-v5",
).Count(&seededPublished).Error; err != nil {
return err
}
if seededPublished > 0 {
return nil
}
var version model.SOPVersion
err = tx.Where("sop_id = ? AND tenant_id = ? AND status = ?", sop.ID, tenantID, "draft").Order("version DESC").First(&version).Error
err = tx.Where("sop_id = ? AND tenant_id = ?", sop.ID, tenantID).Order("version DESC").First(&version).Error
if err == gorm.ErrRecordNotFound {
var maxVersion int
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ?", sop.ID).Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).Error; err != nil {
return err
}
version = model.SOPVersion{TenantID: tenantID, SOPID: sop.ID, Version: maxVersion + 1, Status: "draft", StartNodeKey: "start", CreatedBy: userID}
version = model.SOPVersion{TenantID: tenantID, SOPID: sop.ID, Version: 1, Status: "published", StartNodeKey: "start", CreatedBy: userID}
if err := tx.Create(&version).Error; err != nil {
return err
}
} else if err != nil {
return err
}
var alreadySeeded int64
if err := tx.Model(&model.SOPNode{}).Where(
"sop_version_id = ? AND node_key = ? AND JSON_UNQUOTE(JSON_EXTRACT(config, '$.seed_key')) = ?",
version.ID, "start", "pet-doctor-v16-start-presentation-optional-pet",
).Count(&alreadySeeded).Error; err != nil {
return err
}
if alreadySeeded > 0 {
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND id <> ?", sop.ID, version.ID).Update("status", "superseded").Error; err != nil {
return err
}
return tx.Model(&version).Updates(map[string]interface{}{"status": "published", "start_node_key": "start"}).Error
}
nodes := petNodes(knowledgeIDs)
nodes := petNodes()
edges := petEdges()
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil {
return err
@@ -348,91 +348,44 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[
return fmt.Errorf("create edge %s -> %s: %w", definition.Source, definition.Target, err)
}
}
if err := sopservice.BindKnowledgeVersions(tx, tenantID, version.ID); err != nil {
return fmt.Errorf("bind knowledge versions: %w", err)
}
now := time.Now()
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND id <> ? AND status = ?", sop.ID, version.ID, "published").Update("status", "superseded").Error; err != nil {
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND id <> ?", sop.ID, version.ID).Update("status", "superseded").Error; err != nil {
return err
}
if err := tx.Model(&version).Updates(map[string]interface{}{
"status": "published", "start_node_key": "start", "published_at": &now, "reviewed_by": userID,
"status": "published", "start_node_key": "start",
}).Error; err != nil {
return err
}
payload, _ := json.Marshal(map[string]interface{}{"scenario": "宠物医生问诊问药", "version": version.Version})
payload, _ := json.Marshal(map[string]interface{}{"scenario": "宠物医生问诊问药"})
return tx.Create(&model.AuditLog{
TenantID: tenantID, UserID: userID, Action: "seed", Resource: "sop", ResourceID: sop.ID, Payload: datatypes.JSON(payload),
}).Error
}
func petNodes(knowledgeIDs map[string]uint64) []nodeDefinition {
func petNodes() []nodeDefinition {
return []nodeDefinition{
{Key: "start", Type: "start", Title: "开始", Config: map[string]interface{}{"seed_key": "pet-doctor-v5"}},
{Key: "opening", Type: "message", Title: "说明问诊流程与边界", Content: "您好,我会先记录本次咨询目的、宠物基本信息和症状,优先排查是否需要立即就医,再将完整信息提交给宠物医生评估。线上沟通不能替代医生检查。", Config: map[string]interface{}{}},
{Key: "consultation_context", Type: "form", Title: "确认咨询背景", Content: "请确认本次咨询渠道和主要目的,便于医生判断沟通重点。", Config: map[string]interface{}{"field_keys": []string{"consultation_channel", "visit_goal"}}},
{Key: "basic_info", Type: "form", Title: "采集宠物基本信息", Content: "请确认宠物名称、种类、品种、年龄、体重、性别、绝育以及妊娠或哺乳情况;不确定的信息可以留空。", Config: map[string]interface{}{"field_keys": []string{"pet_name", "pet_type", "pet_breed", "pet_age", "pet_weight", "pet_sex", "is_neutered", "pregnancy_status"}}},
{Key: "chief_complaint", Type: "form", Title: "记录主诉与变化", Content: "请让宠物主人按时间顺序描述主要症状、开始时间、变化趋势,以及是否已经就诊。", Config: map[string]interface{}{"field_keys": []string{"symptom", "symptom_duration", "symptom_trend", "prior_visit"}}},
{Key: "emergency_screen", Type: "form", Title: "筛查急症红旗", Content: "请逐项询问,不要仅凭主人主动描述判断。任意一项为“是”都应停止常规问诊并优先转诊。", Config: map[string]interface{}{"field_keys": []string{"breathing_difficulty", "active_bleeding", "convulsion", "severe_trauma", "unable_to_urinate", "toxin_exposure", "repeated_vomiting", "severe_pain", "heatstroke_sign", "unable_to_stand", "has_emergency_sign"}}},
{Key: "emergency_check", Type: "condition", Title: "判断是否需要立即转诊", Content: "系统根据急症筛查结果自动分流。", Config: map[string]interface{}{"risk_level": "high"}},
{Key: "emergency_guidance", Type: "knowledge", Title: "告知急症处置原则", Content: "请按知识卡向宠物主人说明急症风险和立即就医要求。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["急症红旗征象"]}},
{Key: "emergency_escalate", Type: "escalate", Title: "立即急诊或转诊", Content: "请停止常规线上问诊,建议立即前往最近的宠物急诊并提前联系接诊机构。保持宠物安静和通风,除接诊医生明确指导外,不要自行喂药、催吐或强行喂食,并携带可疑药物、毒物包装及既往资料。", Config: map[string]interface{}{"action": "urgent_referral"}},
{Key: "current_status", Type: "form", Title: "补充当前生命状态", Content: "继续了解食欲、饮水、精神、呕吐、腹泻、排尿、排便和实测体温。未测体温时请留空,不要凭触摸估计。", Config: map[string]interface{}{"field_keys": []string{"appetite", "water_intake", "spirit_status", "vomiting", "diarrhea", "urination_status", "stool_status", "measured_temperature"}}},
{Key: "medical_history", Type: "form", Title: "补充既往史", Content: "请记录既往疾病和手术、免疫情况、近期所有药物及保健品,以及已知药物过敏或不良反应。", Config: map[string]interface{}{"field_keys": []string{"chronic_disease_history", "vaccination_status", "medication_history", "allergy_history"}}},
{Key: "medication_intent", Type: "question", Title: "确认用药诉求", Content: "确认宠物主人是否正在咨询具体药物、剂量、疗程、续方、漏服或用药后异常。", Config: map[string]interface{}{"field_key": "wants_medication", "required": true}},
{Key: "medication_details", Type: "form", Title: "采集用药详情", Content: "请核对药名和规格、咨询类型、是否由宠物医生开具、是否已经使用,以及已用剂量和时间。无法确认时请查看处方或药品包装,不要猜测。", Config: map[string]interface{}{"field_keys": []string{"medication_request_type", "requested_medication", "prescribed_by_vet", "has_taken_medication", "dose_and_time"}}},
{Key: "medication_risk_screen", Type: "form", Title: "筛查用药风险", Content: "请确认是否涉及人用药、可能过量或重复用药,以及用药后异常;如有异常,请记录出现时间和具体表现。", Config: map[string]interface{}{"field_keys": []string{"human_medication_exposure", "suspected_overdose", "adverse_reaction", "adverse_reaction_detail"}}},
{Key: "medication_risk_check", Type: "condition", Title: "判断是否存在用药急症风险", Content: "系统根据用药风险信息自动分流。", Config: map[string]interface{}{"risk_level": "high"}},
{Key: "medication_escalate", Type: "escalate", Title: "立即转医生或急诊评估", Content: "存在误用人用药、可能过量或用药后异常。请立即转宠物医生评估;如出现呼吸困难、意识异常、抽搐、虚脱或症状快速加重,应立即前往急诊。请保留药品包装、处方和实际用药时间记录。", Config: map[string]interface{}{"action": "medication_risk_referral"}},
{Key: "medication_safety", Type: "knowledge", Title: "说明用药安全原则", Content: "请按知识卡说明用药边界,在医生完成评估前不要承诺具体药名、剂量或疗程。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["问诊用药安全原则"]}},
{Key: "consultation_boundary", Type: "knowledge", Title: "说明线上问诊边界", Content: "请说明线上沟通的作用和限制,并确认宠物主人理解下一步需要医生评估。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["线上问诊边界"]}},
{Key: "assessment", Type: "message", Title: "提交医生评估并明确下一步", Content: "信息已记录。请由宠物医生结合病史、体格检查和必要检验判断后续方案;向宠物主人明确是立即到院、预约就诊、按原处方处理还是居家观察,并记录医生给出的时间要求。", Config: map[string]interface{}{}},
{Key: "safety_net", Type: "knowledge", Title: "确认观察和复诊提示", Content: "请按知识卡说明观察指标、复诊时间和立即就医触发条件,并请宠物主人复述确认。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["观察与复诊提示"]}},
{Key: "finish", Type: "finish", Title: "完成问诊记录", Content: "本次初步问诊信息已记录,请按宠物医生给出的时间和方式安排检查、复诊或治疗;情况变化时及时重新联系或立即就医。", Config: map[string]interface{}{}},
{Key: "start", Type: "start", Title: "开始", Content: "确认订单和客户信息,使用开场话术开始沟通。", Config: map[string]interface{}{
"seed_key": "pet-doctor-v16-start-presentation-optional-pet",
"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{}{}},
}
}
func petEdges() []edgeDefinition {
emergencyRules := []interface{}{
map[string]interface{}{"field": "breathing_difficulty", "operator": "equals", "value": true},
map[string]interface{}{"field": "active_bleeding", "operator": "equals", "value": true},
map[string]interface{}{"field": "convulsion", "operator": "equals", "value": true},
map[string]interface{}{"field": "severe_trauma", "operator": "equals", "value": true},
map[string]interface{}{"field": "unable_to_urinate", "operator": "equals", "value": true},
map[string]interface{}{"field": "toxin_exposure", "operator": "equals", "value": true},
map[string]interface{}{"field": "repeated_vomiting", "operator": "equals", "value": true},
map[string]interface{}{"field": "severe_pain", "operator": "equals", "value": true},
map[string]interface{}{"field": "heatstroke_sign", "operator": "equals", "value": true},
map[string]interface{}{"field": "unable_to_stand", "operator": "equals", "value": true},
map[string]interface{}{"field": "has_emergency_sign", "operator": "equals", "value": true},
}
medicationRiskRules := []interface{}{
map[string]interface{}{"field": "human_medication_exposure", "operator": "equals", "value": true},
map[string]interface{}{"field": "suspected_overdose", "operator": "equals", "value": true},
map[string]interface{}{"field": "adverse_reaction", "operator": "equals", "value": true},
}
return []edgeDefinition{
{Source: "start", Target: "opening", Condition: map[string]interface{}{}},
{Source: "opening", Target: "consultation_context", Condition: map[string]interface{}{}},
{Source: "consultation_context", Target: "basic_info", Condition: map[string]interface{}{}},
{Source: "basic_info", Target: "chief_complaint", Condition: map[string]interface{}{}},
{Source: "chief_complaint", Target: "emergency_screen", Condition: map[string]interface{}{}},
{Source: "emergency_screen", Target: "emergency_check", Condition: map[string]interface{}{}},
{Source: "emergency_check", Target: "emergency_guidance", Condition: map[string]interface{}{"any": emergencyRules}, Priority: 0},
{Source: "emergency_check", Target: "current_status", Condition: map[string]interface{}{}, Priority: 100},
{Source: "emergency_guidance", Target: "emergency_escalate", Condition: map[string]interface{}{}},
{Source: "current_status", Target: "medical_history", Condition: map[string]interface{}{}},
{Source: "medical_history", Target: "medication_intent", Condition: map[string]interface{}{}},
{Source: "medication_intent", Target: "medication_details", Condition: map[string]interface{}{"field": "wants_medication", "operator": "equals", "value": true}, Priority: 0},
{Source: "medication_intent", Target: "consultation_boundary", Condition: map[string]interface{}{}, Priority: 100},
{Source: "medication_details", Target: "medication_risk_screen", Condition: map[string]interface{}{}},
{Source: "medication_risk_screen", Target: "medication_risk_check", Condition: map[string]interface{}{}},
{Source: "medication_risk_check", Target: "medication_escalate", Condition: map[string]interface{}{"any": medicationRiskRules}, Priority: 0},
{Source: "medication_risk_check", Target: "medication_safety", Condition: map[string]interface{}{}, Priority: 100},
{Source: "medication_safety", Target: "consultation_boundary", Condition: map[string]interface{}{}},
{Source: "consultation_boundary", Target: "assessment", Condition: map[string]interface{}{}},
{Source: "assessment", Target: "safety_net", Condition: map[string]interface{}{}},
{Source: "safety_net", Target: "finish", Condition: map[string]interface{}{}},
{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{}{}},
}
}

View File

@@ -11,25 +11,33 @@ import (
func TestPetDoctorSOPPassesPublishValidation(t *testing.T) {
fields := petFieldModels()
knowledgeIDs := map[string]uint64{
"线上问诊边界": 1,
"急症红旗征象": 2,
"问诊用药安全原则": 3,
"观察与复诊提示": 4,
}
nodes := petNodeModels(petNodes(knowledgeIDs))
nodes := petNodeModels(petNodes())
edges := petEdgeModels(petEdges())
if len(fields) != 47 || len(nodes) != 21 || len(edges) != 21 {
if len(fields) != 13 || len(nodes) != 5 || len(edges) != 4 {
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,
PublishedKnowledgeCardIDs: map[uint64]bool{
1: true,
2: true,
3: true,
4: true,
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"},
},
})
if len(problems) > 0 {
@@ -37,31 +45,27 @@ func TestPetDoctorSOPPassesPublishValidation(t *testing.T) {
}
}
func TestPetDoctorSOPHasBothRiskEscalations(t *testing.T) {
nodes := petNodes(map[string]uint64{
"线上问诊边界": 1,
"急症红旗征象": 2,
"问诊用药安全原则": 3,
"观察与复诊提示": 4,
})
escalations := map[string]bool{}
for _, node := range nodes {
if node.Type == "escalate" {
escalations[node.Key] = true
func TestPetDoctorSOPIsFiveStepLinearFlow(t *testing.T) {
wantNodes := []string{"start", "pet_info", "symptom", "recommend_product", "finish"}
for index, node := range petNodes() {
if node.Key != wantNodes[index] {
t.Fatalf("node %d = %s, want %s", index, node.Key, wantNodes[index])
}
}
for _, key := range []string{"emergency_escalate", "medication_escalate"} {
if !escalations[key] {
t.Fatalf("missing risk escalation node %s", key)
wantEdges := [][2]string{{"start", "pet_info"}, {"pet_info", "symptom"}, {"symptom", "recommend_product"}, {"recommend_product", "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)
}
}
}
func TestSameJSONIgnoresObjectOrderAndWhitespace(t *testing.T) {
left := []byte(`{"standard_copy":"安全提示","risk_note":"立即就医"}`)
right := []byte(`{ "risk_note": "立即就医", "standard_copy": "安全提示" }`)
if !sameJSON(left, right) {
t.Fatal("equivalent JSON objects should match")
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")
}
}
@@ -74,6 +78,7 @@ func petFieldModels() []model.ScenarioField {
fields = append(fields, model.ScenarioField{
FieldKey: definition.Key,
FieldName: definition.Name,
SourcePath: definition.SourcePath,
FieldType: definition.Type,
Required: definition.Required,
Options: datatypes.JSON(options),