From 0079080d8ad594bbbb4bb4c1d924d93a6b263e69 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Wed, 23 Sep 2026 09:54:37 +0800 Subject: [PATCH] fix(intent): route general smalltalk questions to casual chat instead of stage consult template --- internal/coordinator/dialogue_llm_test.go | 29 +++++++ internal/coordinator/engine_consult.go | 5 ++ internal/intent/dialogue_act.go | 1 + internal/intent/recognizer.go | 44 ++++++++++ internal/intent/recognizer_test.go | 81 +++++++++++++++++++ .../intent/testdata/intent-golden-v1.json | 26 ++++++ 6 files changed, 186 insertions(+) diff --git a/internal/coordinator/dialogue_llm_test.go b/internal/coordinator/dialogue_llm_test.go index c1b68836..51255550 100644 --- a/internal/coordinator/dialogue_llm_test.go +++ b/internal/coordinator/dialogue_llm_test.go @@ -298,3 +298,32 @@ func TestDialogueLLM_QueryProgressNoBriefFallsBackToSynthesizedTemplate(t *testi t.Fatalf("expected synthesized baseline brief in fallback result") } } + +// TestDialogueLLM_AmbiguousSmalltalkDoesNotUseStageSalesTemplate 模糊分支若仍收到通用问句, +// 即使 LLM 失败也不得回退到「掂量」阶段推销模板,应改道闲聊问候模板。 +func TestDialogueLLM_AmbiguousSmalltalkDoesNotUseStageSalesTemplate(t *testing.T) { + backend := &scriptedDialogueBackend{fail: true} + engine := NewCoordinatorEngineWithDialogue(nil, backend, t.TempDir()) + userCtx := &UserProjectContext{ + Project: ProjectInfo{Name: "全自动冲击波治疗仪", CurrentStage: maturity.StageV01_Triage}, + } + rec := &intent.IntentRecognitionResult{ + RawInput: "今天天气怎么样", + Branch: intent.BranchAmbiguousFallback, + TopIntent: &intent.CandidateIntent{ + Intent: intent.IntentCasualChat, + Confidence: 0.2, + Branch: intent.BranchAmbiguousFallback, + }, + } + res, err := engine.ProcessIntent(context.Background(), rec, userCtx) + if err != nil { + t.Fatalf("ProcessIntent: %v", err) + } + if strings.Contains(res.NaturalReply, "掂量") { + t.Fatalf("must not use stage-sales consult template, got: %s", res.NaturalReply) + } + if !strings.Contains(res.NaturalReply, "项目助手") { + t.Fatalf("expected casual-chat template fallback, got: %s", res.NaturalReply) + } +} diff --git a/internal/coordinator/engine_consult.go b/internal/coordinator/engine_consult.go index e2e44dac..b37ed58e 100644 --- a/internal/coordinator/engine_consult.go +++ b/internal/coordinator/engine_consult.go @@ -28,6 +28,11 @@ func (e *CoordinatorEngine) handleAmbiguousConsult(ctx context.Context, cand *in return e.handleQueryProgress(ctx, cand, userCtx, rawInput) } + // 通用非业务问句不得吃阶段推销模板;改道闲聊处理器(LLM 优先,失败回退问候模板)。 + if intent.IsSmallTalkOrGeneralQuestion(rawInput) { + return e.handleCasualChat(ctx, userCtx, rawInput) + } + var sb strings.Builder sb.WriteString(fmt.Sprintf("完全理解您想再掂量一下。针对「%s」目前的节点,我帮您快速对照一下:\n\n", projName)) diff --git a/internal/intent/dialogue_act.go b/internal/intent/dialogue_act.go index 4531b32d..e78b5fd3 100644 --- a/internal/intent/dialogue_act.go +++ b/internal/intent/dialogue_act.go @@ -47,6 +47,7 @@ func detectDialogueAct(input string) DialogueAct { askMarkers := []string{ "吗", "呢", "谁", "是否", "有没有", "通过了吗", "了吗", "了没", "什么状态", "当前状态", "查询", "看看", "问一下", "了解一下", + "怎么样", "怎么", "什么是", "为什么", "哪些", "多少", "好吗", } hasAskPunct := strings.Contains(trimmed, "?") || strings.Contains(trimmed, "?") for _, m := range askMarkers { diff --git a/internal/intent/recognizer.go b/internal/intent/recognizer.go index 015d7e7f..a61bd31f 100644 --- a/internal/intent/recognizer.go +++ b/internal/intent/recognizer.go @@ -257,6 +257,10 @@ func (r *RuleBasedRecognizer) recognizeCore(ctx context.Context, input string, u if isGreetingOrHelp(trimmed) { chatCand.Confidence = 0.85 chatCand.Reasoning = "匹配到问候语或通用帮助请求" + } else if IsSmallTalkOrGeneralQuestion(trimmed) { + // 抬高至 ≥0.6,避免落入 AMBIGUOUS_FALLBACK 的阶段推销模板 + chatCand.Confidence = 0.7 + chatCand.Reasoning = "通用非业务问句,归入闲聊对话" } candidates = append(candidates, chatCand) @@ -1407,6 +1411,46 @@ func isGreetingOrHelp(input string) bool { return false } +// IsSmallTalkOrGeneralQuestion 判定是否为通用非业务问句(闲聊/常识提问)。 +// 用于抬高 CASUAL_CHAT 置信度,并在 L2 模糊分支将问句改道到闲聊处理器, +// 避免「今天天气怎么样」等落入阶段推销模板。 +func IsSmallTalkOrGeneralQuestion(input string) bool { + trimmed := strings.TrimSpace(input) + if trimmed == "" || utf8.RuneCountInString(trimmed) < 3 { + return false + } + if hasStrongBusinessLexicon(trimmed) { + return false + } + questionMarkers := []string{ + "怎么样", "好吗", "什么是", "为什么", "怎么", "哪些", "多少", + "吗", "呢", "?", "?", + } + for _, m := range questionMarkers { + if strings.Contains(trimmed, m) { + return true + } + } + return false +} + +// hasStrongBusinessLexicon 粗粒度业务词闸门:含这些词时不当作纯闲聊问句提升置信度, +// 留给业务匹配器或模糊参谋分支处理(业务匹配器置信度通常 ≥0.86,本闸门主要服务 L2 改道)。 +func hasStrongBusinessLexicon(input string) bool { + markers := []string{ + "项目", "阶段", "推进", "法规", "注册", "文档", "审核", "批准", "驳回", + "报价", "需求", "型检", "成熟度", "工作流", "交付", "立项", "送审", "认领", + "方案成型", "V0.1", "V0.5", "V1.0", "v0.1", "v0.5", "v1.0", + "进度", "风险", "预算", "目标市场", "受控", + } + for _, m := range markers { + if strings.Contains(input, m) { + return true + } + } + return false +} + func matchImpactIntent(input string) *CandidateIntent { propose := strings.Contains(input, "修订提案") || strings.Contains(input, "生成修订") || strings.Contains(input, "提议修改文档") analyze := strings.Contains(input, "分析影响") || strings.Contains(input, "影响分析") || strings.Contains(input, "波及哪些") || diff --git a/internal/intent/recognizer_test.go b/internal/intent/recognizer_test.go index 68c35339..03d9e80a 100644 --- a/internal/intent/recognizer_test.go +++ b/internal/intent/recognizer_test.go @@ -794,3 +794,84 @@ func TestIntentRecognition_ImpactPhrases(t *testing.T) { } } } + +func TestIntentRecognition_GeneralQuestionRoutesToCasualNotAmbiguous(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + SnapshotID: "ctx_smalltalk", + CurrentStage: string(maturity.StageV01_Triage), + } + + for _, input := range []string{"今天天气怎么样", "明天天气好吗,适合出门吗"} { + res, err := rec.Recognize(context.Background(), input, ctx) + if err != nil { + t.Fatalf("recognize %q failed: %v", input, err) + } + if res.TopIntent.Intent != intent.IntentCasualChat { + t.Errorf("%q: expected CASUAL_CHAT, got %s", input, res.TopIntent.Intent) + } + if res.TopIntent.Confidence < 0.6 { + t.Errorf("%q: expected confidence >= 0.6, got %v", input, res.TopIntent.Confidence) + } + if res.Branch == intent.BranchAmbiguousFallback { + t.Errorf("%q: must not use AMBIGUOUS_FALLBACK, got %s", input, res.Branch) + } + if res.Branch != intent.BranchNewIntentPreempt { + t.Errorf("%q: expected NEW_INTENT_PREEMPT, got %s", input, res.Branch) + } + } +} + +func TestIntentRecognition_BusinessQueryStillProgressNotSmalltalk(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + SnapshotID: "ctx_biz_q", + CurrentStage: string(maturity.StageV01_Triage), + } + res, err := rec.Recognize(context.Background(), "分析一下测试方案中,有哪些比较难的项目,以及需要特别注意的地方", ctx) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + if res.TopIntent.Intent != intent.IntentQueryProgress { + t.Errorf("expected QUERY_PROGRESS, got %s", res.TopIntent.Intent) + } +} + +func TestIntentRecognition_ShortFragmentsStayAmbiguous(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + SnapshotID: "ctx_frag", + CurrentStage: string(maturity.StageV01_Triage), + } + for _, input := range []string{"嗯", "哦"} { + res, err := rec.Recognize(context.Background(), input, ctx) + if err != nil { + t.Fatalf("recognize %q failed: %v", input, err) + } + if res.Branch != intent.BranchAmbiguousFallback { + t.Errorf("%q: expected AMBIGUOUS_FALLBACK, got %s (intent=%s conf=%.2f)", + input, res.Branch, res.TopIntent.Intent, res.TopIntent.Confidence) + } + } +} + +func TestIntentRecognition_ConsultativeStillAmbiguous(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + SnapshotID: "ctx_consult", + CurrentStage: string(maturity.StageV01_Triage), + } + res, err := rec.Recognize(context.Background(), "这个方案你觉得呢", ctx) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + if res.Branch != intent.BranchAmbiguousFallback { + t.Errorf("expected AMBIGUOUS_FALLBACK for consultative, got %s", res.Branch) + } + if !intent.IsSmallTalkOrGeneralQuestion("今天天气怎么样") { + t.Error("expected weather question to be smalltalk") + } + if intent.IsSmallTalkOrGeneralQuestion("嗯") { + t.Error("short fragment must not count as general question") + } +} diff --git a/internal/intent/testdata/intent-golden-v1.json b/internal/intent/testdata/intent-golden-v1.json index 2951908c..8c655974 100644 --- a/internal/intent/testdata/intent-golden-v1.json +++ b/internal/intent/testdata/intent-golden-v1.json @@ -144,6 +144,32 @@ "expect_phase1": { "must_not_branch": "EXPECTATION_HIT" } + }, + { + "id": "hn-weather-smalltalk", + "input": "今天天气怎么样", + "snapshot": { + "current_stage": "V0.1_triage" + }, + "expect_phase1": { + "legacy_intent": "CASUAL_CHAT", + "branch": "NEW_INTENT_PREEMPT", + "must_not_branch": "AMBIGUOUS_FALLBACK", + "dialogue_act": "ask" + } + }, + { + "id": "hn-weather-outing-smalltalk", + "input": "明天天气好吗,适合出门吗", + "snapshot": { + "current_stage": "V0.1_triage" + }, + "expect_phase1": { + "legacy_intent": "CASUAL_CHAT", + "branch": "NEW_INTENT_PREEMPT", + "must_not_branch": "AMBIGUOUS_FALLBACK", + "dialogue_act": "ask" + } } ] }