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) } }