Files
iqudo-top1/scripts/seed-pet-doctor/main.go
2026-08-18 16:27:02 +08:00

392 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"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"
"go.uber.org/zap"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type fieldDefinition struct {
Key string
Name string
SourcePath string
Type string
Required bool
Options []string
Validation map[string]interface{}
}
type nodeDefinition struct {
Key string
Type string
Title string
Content string
Config map[string]interface{}
}
type edgeDefinition struct {
Source string
Target string
Condition map[string]interface{}
Priority int
}
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})
if err != nil {
fmt.Fprintf(os.Stderr, "load configuration: %v\n", err)
os.Exit(1)
}
log, err := logger.New(cfg.App.Env, cfg.App.Name+"-seed")
if err != nil {
fmt.Fprintf(os.Stderr, "create logger: %v\n", err)
os.Exit(1)
}
defer log.Sync()
db, err := database.Open(cfg.Database, log)
if err != nil {
log.Fatal("connect database", zap.Error(err))
}
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, scriptFile string) error {
return db.Transaction(func(tx *gorm.DB) error {
tenant, user, err := seedOwner(tx)
if err != nil {
return err
}
scenario, err := seedScenario(tx, tenant.ID, user.ID)
if err != nil {
return err
}
if err := seedFields(tx, tenant.ID, scenario.ID); err != nil {
return err
}
if err := seedInputSchema(tx, tenant.ID, scenario.ID); err != nil {
return err
}
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
})
}
func seedOwner(tx *gorm.DB) (model.Tenant, model.User, error) {
var tenant model.Tenant
if err := tx.Where("slug = ?", "default").First(&tenant).Error; err != nil {
return tenant, model.User{}, fmt.Errorf("find default tenant: %w", err)
}
var user model.User
if err := tx.Table("users u").Select("u.*").Joins("JOIN tenant_members tm ON tm.user_id = u.id").Joins("JOIN roles r ON r.id = tm.role_id").Where("tm.tenant_id = ? AND r.code = ? AND u.status = ?", tenant.ID, "admin", "active").Order("u.id").First(&user).Error; err != nil {
return tenant, user, fmt.Errorf("find administrator: %w", err)
}
return tenant, user, nil
}
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
if err != nil {
return scenario, fmt.Errorf("find or create scenario: %w", err)
}
updates := map[string]interface{}{
"industry": "宠物医疗", "role_name": "宠物医生、助理、客服",
"goal": "标准化收集宠物基本信息、主诉和病史,优先识别急症,在医生评估前提供安全且一致的沟通指引。",
"trigger_text": "宠物主人通过门店、电话或在线渠道咨询症状、检查、用药或是否需要就医时进入本场景。",
"visibility": "tenant", "status": "active",
}
if err := tx.Model(&scenario).Updates(updates).Error; err != nil {
return scenario, fmt.Errorf("update scenario: %w", err)
}
return scenario, nil
}
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(`[]`)
}
validation, _ := json.Marshal(definition.Validation)
if definition.Validation == nil {
validation = []byte(`{}`)
}
var field model.ScenarioField
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 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
}
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 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,
}).Error
if err != nil {
return fmt.Errorf("find or create SOP: %w", err)
}
if err := tx.Model(&sop).Updates(map[string]interface{}{
"description": "根据订单商品携带的症状标签展示关联症状、话术和可能疾病,辅助销售完成商品推荐并记录结果。",
"status": "published",
}).Error; err != nil {
return err
}
var version model.SOPVersion
err = tx.Where("sop_id = ? AND tenant_id = ?", sop.ID, tenantID).Order("version DESC").First(&version).Error
if err == gorm.ErrRecordNotFound {
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()
edges := petEdges()
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil {
return err
}
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPNode{}).Error; err != nil {
return err
}
for index, definition := range nodes {
configJSON, _ := json.Marshal(definition.Config)
node := model.SOPNode{
TenantID: tenantID, SOPVersionID: version.ID, NodeKey: definition.Key, Type: definition.Type,
Title: definition.Title, Content: definition.Content, Config: datatypes.JSON(configJSON), PositionX: 0, PositionY: index * 120,
}
if err := tx.Create(&node).Error; err != nil {
return fmt.Errorf("create node %s: %w", definition.Key, err)
}
}
for _, definition := range edges {
conditionJSON, _ := json.Marshal(definition.Condition)
edge := model.SOPEdge{
TenantID: tenantID, SOPVersionID: version.ID, SourceNodeKey: definition.Source, TargetNodeKey: definition.Target,
Condition: datatypes.JSON(conditionJSON), Priority: definition.Priority,
}
if err := tx.Create(&edge).Error; err != nil {
return fmt.Errorf("create edge %s -> %s: %w", definition.Source, definition.Target, err)
}
}
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",
}).Error; err != nil {
return err
}
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() []nodeDefinition {
return []nodeDefinition{
{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 {
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{}{}},
}
}