Files
Eric 1549169735@qq.com da3f16e6db update
2026-09-13 20:08:13 +08:00

322 lines
16 KiB
Go
Raw Permalink 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"
"git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit"
"github.com/google/uuid"
"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")
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})
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 := 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, knowledgeFile, questionFile, smallCategoryFile, smallAskScriptFile, preGuideFile, classificationFile 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
}
packageInput, err := buildScriptPackage(scriptFile, knowledgeFile, smallCategoryFile, smallAskScriptFile, preGuideFile, classificationFile, questionFile)
if 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
}
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
// 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": "宠物主人通过门店、电话或在线渠道咨询症状、用药或护理商品时进入本场景。",
"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)
}
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{"公", "母", "未知"}},
}
}
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"},
}}
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",
"start_node_key": "start",
}).Error; err != nil {
return err
}
nodes := petNodes()
edges := petEdges()
if err := tx.Where("sop_id = ? AND tenant_id = ?", sop.ID, tenantID).Delete(&model.SOPEdge{}).Error; err != nil {
return err
}
if err := tx.Where("sop_id = ? AND tenant_id = ?", sop.ID, tenantID).Delete(&model.SOPNode{}).Error; err != nil {
return err
}
for index, definition := range nodes {
configJSON, _ := json.Marshal(definition.Config)
node := model.SOPNode{
TenantID: tenantID, SOPID: sop.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, SOPID: sop.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)
}
}
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
}
func petNodes() []nodeDefinition {
return []nodeDefinition{
{Key: "start", Type: "start", Title: "开始", Content: "确认订单和客户信息,使用开场话术开始沟通。", Config: map[string]interface{}{
"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"},
},
}},
{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: "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},
}
}