Files
iqudo-top1/internal/scriptkit/engine_test.go
Eric 1549169735@qq.com da3f16e6db update
2026-09-13 20:08:13 +08:00

322 lines
19 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 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)
}
}