update
This commit is contained in:
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user