333 lines
17 KiB
Go
333 lines
17 KiB
Go
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{}{}},
|
||
}
|
||
}
|