Files
iqudo-top1/scripts/seed-pet-doctor/main.go
2026-08-08 21:50:38 +08:00

333 lines
17 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"
"time"
"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
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")
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); err != nil {
log.Fatal("seed pet doctor scenario", zap.Error(err))
}
log.Info("pet doctor scenario is ready")
}
func seed(db *gorm.DB) 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
}
knowledgeIDs, err := seedKnowledgeCards(tx, tenant.ID, user.ID, scenario.ID)
if err != nil {
return err
}
if err := seedSOP(tx, tenant.ID, user.ID, scenario.ID, knowledgeIDs); 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 := []fieldDefinition{
{Key: "pet_name", Name: "宠物名称", Type: "text", Required: true},
{Key: "pet_type", Name: "宠物种类", Type: "select", Required: true, Options: []string{"犬", "猫", "其他"}},
{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: "symptom", Name: "主要症状", Type: "textarea", Required: true},
{Key: "symptom_duration", Name: "症状持续时间", Type: "text", Required: true},
{Key: "breathing_difficulty", Name: "是否呼吸困难", Type: "boolean", Required: true},
{Key: "active_bleeding", Name: "是否持续出血", Type: "boolean", Required: true},
{Key: "convulsion", Name: "是否抽搐或意识异常", Type: "boolean", Required: true},
{Key: "unable_to_urinate", Name: "是否无法排尿", Type: "boolean", Required: true},
{Key: "toxin_exposure", Name: "是否可能误食毒物或异物", Type: "boolean", Required: true},
{Key: "has_emergency_sign", Name: "是否存在其他急症表现", Type: "boolean", Required: true},
{Key: "appetite", Name: "食欲情况", Type: "select", Options: []string{"正常", "下降", "完全不吃"}},
{Key: "spirit_status", Name: "精神状态", Type: "select", Options: []string{"正常", "较差", "嗜睡或无法站立"}},
{Key: "vomiting", Name: "是否呕吐", Type: "boolean"},
{Key: "diarrhea", Name: "是否腹泻", Type: "boolean"},
{Key: "medication_history", Name: "近期用药和保健品", Type: "textarea"},
{Key: "allergy_history", Name: "药物过敏史", Type: "textarea"},
{Key: "wants_medication", Name: "是否咨询具体用药", Type: "boolean", Required: true},
}
for index, definition := range definitions {
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
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
if err != nil {
return fmt.Errorf("seed field %s: %w", definition.Key, err)
}
}
return nil
}
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": "对乙酰氨基酚、布洛芬等常见人用药可能对宠物造成严重伤害;如已误服,应按急症处理。",
},
}
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)
var version model.KnowledgeCardVersion
err = tx.Where("knowledge_card_id = ? AND version = ?", card.ID, 1).Assign(model.KnowledgeCardVersion{
TenantID: tenantID, Content: datatypes.JSON(contentJSON), Status: "published",
}).FirstOrCreate(&version, model.KnowledgeCardVersion{KnowledgeCardID: card.ID, Version: 1}).Error
if err != nil {
return nil, fmt.Errorf("seed knowledge card version %s: %w", title, err)
}
result[title] = card.ID
}
return result, nil
}
func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[string]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 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 v.status = ? AND n.node_key IN ?", sop.ID, "published", []string{"emergency_screen", "emergency_escalate", "medication_safety"},
).Count(&seededPublished).Error; err != nil {
return err
}
if seededPublished == 3 {
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
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}
if err := tx.Create(&version).Error; err != nil {
return err
}
} else if err != nil {
return err
}
nodes := petNodes(knowledgeIDs["问诊用药安全原则"])
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)
}
}
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 {
return err
}
if err := tx.Model(&version).Updates(map[string]interface{}{
"status": "published", "start_node_key": "start", "published_at": &now, "reviewed_by": userID,
}).Error; err != nil {
return err
}
payload, _ := json.Marshal(map[string]interface{}{"scenario": "宠物医生问诊问药", "version": version.Version})
return tx.Create(&model.AuditLog{
TenantID: tenantID, UserID: userID, Action: "seed", Resource: "sop", ResourceID: sop.ID, Payload: datatypes.JSON(payload),
}).Error
}
func petNodes(knowledgeCardID uint64) []nodeDefinition {
return []nodeDefinition{
{Key: "start", Type: "start", Title: "开始", Config: map[string]interface{}{"seed_key": "pet-doctor-v1"}},
{Key: "opening", Type: "message", Title: "说明问诊流程", Content: "您好,我会先记录宠物的基本信息和症状,并优先确认是否存在需要立即就医的情况。线上沟通不能替代医生检查。", Config: map[string]interface{}{}},
{Key: "basic_info", Type: "form", Title: "采集宠物基本信息", Content: "请确认宠物名称、种类、年龄、体重、性别和绝育情况。", Config: map[string]interface{}{"field_keys": []string{"pet_name", "pet_type", "pet_age", "pet_weight", "pet_sex", "is_neutered"}}},
{Key: "chief_complaint", Type: "form", Title: "记录主诉", Content: "请让宠物主人按时间顺序描述最主要的症状和持续时间。", Config: map[string]interface{}{"field_keys": []string{"symptom", "symptom_duration"}}},
{Key: "emergency_screen", Type: "form", Title: "筛查急症红旗", Content: "逐项确认当前是否存在以下急症表现。任意一项为“是”都应优先转诊。", Config: map[string]interface{}{"field_keys": []string{"breathing_difficulty", "active_bleeding", "convulsion", "unable_to_urinate", "toxin_exposure", "has_emergency_sign"}}},
{Key: "emergency_check", Type: "condition", Title: "判断是否需要立即转诊", Content: "系统根据急症筛查结果自动分流。", Config: map[string]interface{}{}},
{Key: "emergency_escalate", Type: "escalate", Title: "立即急诊或转诊", Content: "存在急症红旗,请停止常规线上问诊,建议立即前往最近的宠物急诊,并提前联系接诊机构。不要自行喂药、催吐或强行喂食。", Config: map[string]interface{}{"action": "urgent_referral"}},
{Key: "history_collection", Type: "form", Title: "补充状态与病史", Content: "继续了解食欲、精神状态、消化道表现、近期用药和药物过敏史。", Config: map[string]interface{}{"field_keys": []string{"appetite", "spirit_status", "vomiting", "diarrhea", "medication_history", "allergy_history"}}},
{Key: "medication_intent", Type: "question", Title: "确认用药诉求", Content: "请确认宠物主人是否正在咨询具体药物、剂量或疗程。", Config: map[string]interface{}{"field_key": "wants_medication", "required": true}},
{Key: "medication_safety", Type: "knowledge", Title: "说明用药安全原则", Content: "用药需要结合物种、年龄、体重、既往病史和必要检查,由宠物医生评估后确定。请勿自行使用人用药、增加剂量或混用药物。", Config: map[string]interface{}{"knowledge_card_id": knowledgeCardID}},
{Key: "assessment", Type: "message", Title: "提交医生评估", Content: "信息已记录。请由宠物医生结合体格检查和必要检验判断病因及治疗方案;如症状加重或出现新的急症表现,应立即就医。", Config: map[string]interface{}{}},
{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": "unable_to_urinate", "operator": "equals", "value": true},
map[string]interface{}{"field": "toxin_exposure", "operator": "equals", "value": true},
map[string]interface{}{"field": "has_emergency_sign", "operator": "equals", "value": true},
}
return []edgeDefinition{
{Source: "start", Target: "opening", Condition: map[string]interface{}{}},
{Source: "opening", 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_escalate", Condition: map[string]interface{}{"any": emergencyRules}, Priority: 0},
{Source: "emergency_check", Target: "history_collection", Condition: map[string]interface{}{}, Priority: 100},
{Source: "history_collection", Target: "medication_intent", Condition: map[string]interface{}{}},
{Source: "medication_intent", Target: "medication_safety", Condition: map[string]interface{}{"field": "wants_medication", "operator": "equals", "value": true}, Priority: 0},
{Source: "medication_intent", Target: "assessment", Condition: map[string]interface{}{}, Priority: 100},
{Source: "medication_safety", Target: "assessment", Condition: map[string]interface{}{}},
{Source: "assessment", Target: "finish", Condition: map[string]interface{}{}},
}
}