This commit is contained in:
Eric 1549169735@qq.com
2026-09-13 20:08:13 +08:00
parent 7cc06976ab
commit da3f16e6db
65 changed files with 3855 additions and 2153 deletions

View File

@@ -0,0 +1,321 @@
package scriptkit
import (
"testing"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
)
func buildTestPackage() *Package {
// symptom dimension: vomiting, diarrhea
// disease dimension: gastritis, enteritis
pkg := &Package{Package: model.ScriptPackage{Base: model.Base{ID: 1}, StartStageKey: "symptom"}}
pkg.Dimensions = []model.PackageDimension{
{Base: model.Base{ID: 1}, PackageID: 1, DimKey: "symptom", Name: "症状"},
{Base: model.Base{ID: 2}, PackageID: 1, DimKey: "disease", Name: "疾病"},
}
pkg.Values = []model.DimensionValue{
{Base: model.Base{ID: 11}, PackageID: 1, DimensionID: 1, ValueKey: "vomiting", Name: "呕吐"},
{Base: model.Base{ID: 12}, PackageID: 1, DimensionID: 1, ValueKey: "diarrhea", Name: "腹泻"},
{Base: model.Base{ID: 21}, PackageID: 1, DimensionID: 2, ValueKey: "gastritis", Name: "胃肠炎"},
{Base: model.Base{ID: 22}, PackageID: 1, DimensionID: 2, ValueKey: "enteritis", Name: "肠炎"},
}
pkg.Stages = []model.PackageStage{
{Base: model.Base{ID: 31}, PackageID: 1, StageKey: "symptom", Name: "症状确认", PrimaryDimensionID: 1},
{Base: model.Base{ID: 32}, PackageID: 1, StageKey: "diagnosis", Name: "拟诊确认", PrimaryDimensionID: 2},
}
vomiting := uint64(11)
diarrhea := uint64(12)
pkg.Scripts = []model.StageScript{
{Base: model.Base{ID: 101}, PackageID: 1, StageID: 31, ScriptKey: "vomiting_info", Name: "呕吐细节", ScriptType: "info", Content: "吐的是什么?", DimensionValueID: &vomiting, ShowThreshold: 5, ConfirmThreshold: 3},
{Base: model.Base{ID: 102}, PackageID: 1, StageID: 31, ScriptKey: "vomiting_confirm", Name: "确认呕吐", ScriptType: "confirm", Content: "有呕吐吗?", DimensionValueID: &vomiting, ShowThreshold: 5, ConfirmThreshold: 3},
{Base: model.Base{ID: 103}, PackageID: 1, StageID: 31, ScriptKey: "diarrhea_confirm", Name: "确认腹泻", ScriptType: "confirm", Content: "有腹泻吗?", DimensionValueID: &diarrhea, ShowThreshold: 5, ConfirmThreshold: 3},
{Base: model.Base{ID: 104}, PackageID: 1, StageID: 31, ScriptKey: "symptom_fallback", Name: "兜底", ScriptType: "fallback", Content: "哪里不舒服?"},
{Base: model.Base{ID: 105}, PackageID: 1, StageID: 32, ScriptKey: "gastritis_confirm", Name: "确认胃肠炎", ScriptType: "confirm", Content: "更像胃肠炎吗?", DimensionValueID: u64(21), ShowThreshold: 10, ConfirmThreshold: 5},
{Base: model.Base{ID: 106}, PackageID: 1, StageID: 32, ScriptKey: "gastritis_msg", Name: "胃肠炎话术", ScriptType: "message", Content: "更符合胃肠炎方向。", DimensionValueID: u64(21), ShowThreshold: 10, ConfirmThreshold: 5},
}
pkg.Options = []model.ScriptOption{
{Base: model.Base{ID: 201}, PackageID: 1, ScriptID: 102, OptionKey: "yes", Label: "有", TargetDimensionValueID: &vomiting, Effect: "set", EffectValue: 8},
{Base: model.Base{ID: 202}, PackageID: 1, ScriptID: 102, OptionKey: "no", Label: "没有", TargetDimensionValueID: &vomiting, Effect: "zero"},
{Base: model.Base{ID: 203}, PackageID: 1, ScriptID: 103, OptionKey: "yes", Label: "有", TargetDimensionValueID: &diarrhea, Effect: "set", EffectValue: 8},
{Base: model.Base{ID: 204}, PackageID: 1, ScriptID: 103, OptionKey: "no", Label: "没有", TargetDimensionValueID: &diarrhea, Effect: "zero"},
{Base: model.Base{ID: 205}, PackageID: 1, ScriptID: 105, OptionKey: "yes", Label: "对,是这样", TargetDimensionValueID: u64(21), Effect: "set", EffectValue: 10},
{Base: model.Base{ID: 206}, PackageID: 1, ScriptID: 105, OptionKey: "no", Label: "不是这样", TargetDimensionValueID: u64(21), Effect: "zero"},
}
pkg.Linkages = []model.DimensionLinkage{
{Base: model.Base{ID: 301}, PackageID: 1, FromDimensionValueID: 11, ToDimensionValueID: 21, RelationType: "contributes_to", Contribution: 5},
{Base: model.Base{ID: 302}, PackageID: 1, FromDimensionValueID: 12, ToDimensionValueID: 21, RelationType: "contributes_to", Contribution: 5},
{Base: model.Base{ID: 303}, PackageID: 1, FromDimensionValueID: 12, ToDimensionValueID: 22, RelationType: "contributes_to", Contribution: 5},
}
pkg.Adapters = []model.PackageAdapter{
{PackageID: 1, SourceField: "input.tags", MatchValue: "呕吐", TargetDimensionValueID: 11, Weight: 8},
{PackageID: 1, SourceField: "input.tags", MatchValue: "腹泻", TargetDimensionValueID: 12, Weight: 3},
}
return pkg
}
func u64(value uint64) *uint64 { return &value }
func TestFuzzyWeightShowsConfirmationOnly(t *testing.T) {
pkg := buildTestPackage()
ctx := RenderContext{Input: map[string]interface{}{"tags": []interface{}{"腹泻"}}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, ctx)
if weights[12] != 3 {
t.Fatalf("diarrhea weight = %d, want 3", weights[12])
}
stage, _ := pkg.StageByKey("symptom")
view := pkg.BuildStageView(stage, weights, ctx, ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, nil)
if len(view.Scripts) != 1 || view.Scripts[0].ScriptKey != "diarrhea_confirm" {
t.Fatalf("fuzzy weight should only show the confirmation script, got %#v", view.Scripts)
}
}
func TestScreeningRequiredWithMoreThanThreeCandidates(t *testing.T) {
a, b, c, d := uint64(11), uint64(12), uint64(13), uint64(14)
pkg := &Package{Package: model.ScriptPackage{Base: model.Base{ID: 1}, StartStageKey: "symptom"}}
pkg.Dimensions = []model.PackageDimension{{Base: model.Base{ID: 1}, PackageID: 1, DimKey: "symptom", Name: "症状"}}
pkg.Values = []model.DimensionValue{
{Base: model.Base{ID: a}, PackageID: 1, DimensionID: 1, ValueKey: "a", Name: "A", InitialWeight: 3},
{Base: model.Base{ID: b}, PackageID: 1, DimensionID: 1, ValueKey: "b", Name: "B", InitialWeight: 3},
{Base: model.Base{ID: c}, PackageID: 1, DimensionID: 1, ValueKey: "c", Name: "C", InitialWeight: 3},
{Base: model.Base{ID: d}, PackageID: 1, DimensionID: 1, ValueKey: "d", Name: "D", InitialWeight: 3},
}
pkg.Stages = []model.PackageStage{{Base: model.Base{ID: 31}, PackageID: 1, StageKey: "symptom", Name: "症状确认", PrimaryDimensionID: 1}}
pkg.Scripts = []model.StageScript{
{Base: model.Base{ID: 101}, PackageID: 1, StageID: 31, ScriptKey: "symptom_screen", Name: "症状筛选", ScriptType: "screen", Content: "有什么表现?", Multiple: true},
{Base: model.Base{ID: 102}, PackageID: 1, StageID: 31, ScriptKey: "confirm_a", Name: "确认A", ScriptType: "confirm", Content: "有A吗", DimensionValueID: &a, ShowThreshold: 5, ConfirmThreshold: 3},
{Base: model.Base{ID: 103}, PackageID: 1, StageID: 31, ScriptKey: "choice_a", Name: "A细节", ScriptType: "choice", Content: "A多久了", DimensionValueID: &a, ShowThreshold: 5, ConfirmThreshold: 3},
}
pkg.Options = []model.ScriptOption{
{Base: model.Base{ID: 201}, PackageID: 1, ScriptID: 101, OptionKey: "a", Label: "A", TargetDimensionValueID: &a, Effect: "set", EffectValue: 10},
{Base: model.Base{ID: 202}, PackageID: 1, ScriptID: 101, OptionKey: "b", Label: "B", TargetDimensionValueID: &b, Effect: "set", EffectValue: 10},
{Base: model.Base{ID: 203}, PackageID: 1, ScriptID: 101, OptionKey: "c", Label: "C", TargetDimensionValueID: &c, Effect: "set", EffectValue: 10},
{Base: model.Base{ID: 204}, PackageID: 1, ScriptID: 101, OptionKey: "d", Label: "D", TargetDimensionValueID: &d, Effect: "set", EffectValue: 10},
{Base: model.Base{ID: 205}, PackageID: 1, ScriptID: 102, OptionKey: "yes", Label: "有", TargetDimensionValueID: &a, Effect: "set", EffectValue: 8},
{Base: model.Base{ID: 206}, PackageID: 1, ScriptID: 102, OptionKey: "no", Label: "没有", TargetDimensionValueID: &a, Effect: "zero"},
}
stage, _ := pkg.StageByKey("symptom")
ctx := RenderContext{Input: map[string]interface{}{}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
emptyState := ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), emptyState, ctx)
view := pkg.BuildStageView(stage, weights, ctx, emptyState, nil)
if !view.ScreeningRequired {
t.Fatalf("4 candidates should require the screening question, got %#v", view)
}
// 阶段视图带出完整题目集合:筛选题 + 每个候选的确认题与细节题,
// 客户端本地作答、结束时一次性提交。
keys := map[string]bool{}
for _, script := range view.Scripts {
keys[script.ScriptKey] = true
}
if !keys["symptom_screen"] || !keys["confirm_a"] || !keys["choice_a"] {
t.Fatalf("stage view should carry the complete question set, got %v", keys)
}
// 筛选:只选 A、B -> A/B 确认(10)C/D 归零;筛选不再要求。
state := ScriptState{ScriptAnswers: map[string][]string{"symptom_screen": {"a", "b"}}, DimensionSelects: map[string]bool{"a": true, "b": true, "c": false, "d": false}}
weights = pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
if weights[a] != 10 || weights[b] != 10 || weights[c] != 0 || weights[d] != 0 {
t.Fatalf("screening weights = %v", weights)
}
view = pkg.BuildStageView(stage, weights, ctx, state, nil)
if view.ScreeningRequired {
t.Fatalf("screening should not be required after selection, got %#v", view)
}
for _, script := range view.Scripts {
if script.ScriptKey == "confirm_a" {
t.Fatalf("confirmed value should not carry its confirm question, got %#v", view.Scripts)
}
if script.ScriptKey == "choice_a" && script.Weight != 10 {
t.Fatalf("selected value details should carry weight 10, got %#v", script)
}
}
// 候选 <= 3 时不要求筛选。
lessState := ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{"c": false, "d": false}}
lessWeights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), lessState, ctx)
lessView := pkg.BuildStageView(stage, lessWeights, ctx, lessState, nil)
if lessView.ScreeningRequired {
t.Fatalf("screening should not be required with <=3 candidates, got %#v", lessView)
}
}
func TestFuzzyHintDoesNotPropagateDisease(t *testing.T) {
pkg := buildTestPackage()
ctx := RenderContext{Input: map[string]interface{}{"tags": []interface{}{"腹泻"}}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, ctx)
if weights[12] != 3 {
t.Fatalf("diarrhea weight = %d, want 3", weights[12])
}
if weights[21] != 0 || weights[22] != 0 {
t.Fatalf("fuzzy hint must not pull diseases in: %v", weights)
}
}
func TestStrongWeightShowsInfoScripts(t *testing.T) {
pkg := buildTestPackage()
ctx := RenderContext{Input: map[string]interface{}{"tags": []interface{}{"呕吐"}}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, ctx)
stage, _ := pkg.StageByKey("symptom")
view := pkg.BuildStageView(stage, weights, ctx, ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, nil)
if len(view.Scripts) != 1 || view.Scripts[0].ScriptKey != "vomiting_info" {
t.Fatalf("strong weight should show the info script, got %#v", view.Scripts)
}
}
func TestAllZeroShowsFallback(t *testing.T) {
pkg := buildTestPackage()
ctx := RenderContext{Input: map[string]interface{}{}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, ctx)
stage, _ := pkg.StageByKey("symptom")
view := pkg.BuildStageView(stage, weights, ctx, ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, nil)
if len(view.Scripts) != 1 || view.Scripts[0].Type != "fallback" {
t.Fatalf("all zero weights should show fallback, got %#v", view.Scripts)
}
if view.Fallback != "哪里不舒服?" {
t.Fatalf("fallback = %q", view.Fallback)
}
}
func TestConfirmYesSetsWeightAndPropagatesDisease(t *testing.T) {
pkg := buildTestPackage()
ctx := RenderContext{Input: map[string]interface{}{}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
state := ScriptState{ScriptAnswers: map[string][]string{"vomiting_confirm": {"yes"}}, DimensionSelects: map[string]bool{}}
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
if weights[11] != 8 {
t.Fatalf("vomiting weight = %d, want 8", weights[11])
}
if weights[21] != 5 {
t.Fatalf("gastritis weight = %d, want 5 via linkage", weights[21])
}
// 候选疾病先带出确认题和确认后话术(完整题目集合,客户端本地作答后
// 一次性提交;确认后话术在本地确认后再展示)。
stage, _ := pkg.StageByKey("diagnosis")
view := pkg.BuildStageView(stage, weights, ctx, state, nil)
keys := map[string]bool{}
for _, script := range view.Scripts {
keys[script.ScriptKey] = true
}
if !keys["gastritis_confirm"] || !keys["gastritis_msg"] {
t.Fatalf("candidate disease should carry confirm question and follow-up copy, got %v", keys)
}
// 确认疾病后(对,是这样 -> 权重10确认题隐藏只保留该疾病话术。
state.ScriptAnswers["gastritis_confirm"] = []string{"yes"}
weights = pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
if weights[21] != 10 {
t.Fatalf("confirmed gastritis weight = %d, want 10", weights[21])
}
view = pkg.BuildStageView(stage, weights, ctx, state, nil)
if len(view.Scripts) != 1 || view.Scripts[0].ScriptKey != "gastritis_msg" {
t.Fatalf("confirmed disease should keep only its copy, got %#v", view.Scripts)
}
}
func TestMultipleDiseasesCanBeConfirmed(t *testing.T) {
pkg := buildTestPackage()
ctx := RenderContext{Input: map[string]interface{}{}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
// 两个症状都确认(呕吐、腹泻),再确认两个疾病方向。
state := ScriptState{ScriptAnswers: map[string][]string{
"vomiting_confirm": {"yes"},
"diarrhea_confirm": {"yes"},
"gastritis_confirm": {"yes"},
}, DimensionSelects: map[string]bool{"enteritis": true}}
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
if weights[21] != 10 {
t.Fatalf("gastritis weight = %d, want 10 after confirmation", weights[21])
}
if weights[22] < 10 {
t.Fatalf("enteritis weight = %d, want >= 10 after confirmation", weights[22])
}
}
func TestConfirmNoZeroesWeight(t *testing.T) {
pkg := buildTestPackage()
ctx := RenderContext{Input: map[string]interface{}{"tags": []interface{}{"腹泻"}}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
state := ScriptState{ScriptAnswers: map[string][]string{"diarrhea_confirm": {"no"}}, DimensionSelects: map[string]bool{}}
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
if weights[12] != 0 {
t.Fatalf("diarrhea weight = %d, want 0", weights[12])
}
}
func TestDirectSelectSetsHighWeight(t *testing.T) {
pkg := buildTestPackage()
ctx := RenderContext{Input: map[string]interface{}{}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
state := ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{"vomiting": true}}
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
if weights[11] != SelectWeight {
t.Fatalf("selected weight = %d, want %d", weights[11], SelectWeight)
}
}
func TestRecomputeIsDeterministic(t *testing.T) {
pkg := buildTestPackage()
ctx := RenderContext{Input: map[string]interface{}{"tags": []interface{}{"腹泻"}}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
state := ScriptState{ScriptAnswers: map[string][]string{"diarrhea_confirm": {"yes"}}, DimensionSelects: map[string]bool{}}
first := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
for range 5 {
again := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
if !weightsEqual(first, again) {
t.Fatalf("recompute is not deterministic: %v vs %v", first, again)
}
}
}
func TestRenderTemplate(t *testing.T) {
ctx := RenderContext{
Input: map[string]interface{}{"customer_name": "王女士", "products": []interface{}{"商品A", "商品B"}},
Derived: map[string]interface{}{},
Form: map[string]interface{}{"note": "已接受"},
}
got := RenderTemplate("您好{{input.customer_name}},看到{{input.products}},备注{{form.note}}。", ctx)
if got != "您好王女士看到商品A、商品B备注已接受。" {
t.Fatalf("RenderTemplate = %q", got)
}
}
func TestDiagnosisRequiresConfirmedDiseaseBeforeAdvancing(t *testing.T) {
pkg := buildTestPackage()
ctx := RenderContext{Input: map[string]interface{}{}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
// 症状确认带出候选疾病权重5但还没有确认任何疾病方向。
state := ScriptState{ScriptAnswers: map[string][]string{"vomiting_confirm": {"yes"}}, DimensionSelects: map[string]bool{}}
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
stage, _ := pkg.StageByKey("diagnosis")
view := pkg.BuildStageView(stage, weights, ctx, state, nil)
if view.CanNext {
t.Fatalf("diagnosis should require a confirmed disease before advancing, got can_next=%v", view.CanNext)
}
if view.CanNextReason == "" {
t.Fatalf("diagnosis block should carry a reason, got %q", view.CanNextReason)
}
// 确认疾病后权重10才能进入药品推荐。
state.ScriptAnswers["gastritis_confirm"] = []string{"yes"}
weights = pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
view = pkg.BuildStageView(stage, weights, ctx, state, nil)
if !view.CanNext {
t.Fatalf("confirmed disease should allow advancing, got can_next=%v reason=%q", view.CanNext, view.CanNextReason)
}
}
func TestStageViewOnlyShowsPrimaryDimension(t *testing.T) {
pkg := buildTestPackage()
ctx := RenderContext{Input: map[string]interface{}{"tags": []interface{}{"呕吐"}}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
state := ScriptState{ScriptAnswers: map[string][]string{"vomiting_confirm": {"yes"}}, DimensionSelects: map[string]bool{}}
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
// 症状阶段症状与疾病联动权重5都有值但只显示主维度症状。
stage, _ := pkg.StageByKey("symptom")
view := pkg.BuildStageView(stage, weights, ctx, state, nil)
if len(view.Dimensions) != 1 || view.Dimensions[0].DimKey != "symptom" {
t.Fatalf("symptom stage should only show the symptom dimension, got %#v", view.Dimensions)
}
// 拟诊阶段:只显示疾病维度,不显示症状。
diagStage, _ := pkg.StageByKey("diagnosis")
diagView := pkg.BuildStageView(diagStage, weights, ctx, state, nil)
if len(diagView.Dimensions) != 1 || diagView.Dimensions[0].DimKey != "disease" {
t.Fatalf("diagnosis stage should only show the disease dimension, got %#v", diagView.Dimensions)
}
// 没有主维度的阶段(开场)不显示任何维度。
openStage := model.PackageStage{Base: model.Base{ID: 33}, PackageID: 1, StageKey: "opening", Name: "开场", PrimaryDimensionID: 0}
openView := pkg.BuildStageView(openStage, weights, ctx, state, nil)
if len(openView.Dimensions) != 0 {
t.Fatalf("opening stage should show no dimensions, got %#v", openView.Dimensions)
}
}