322 lines
16 KiB
Go
322 lines
16 KiB
Go
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},
|
||
}
|
||
}
|