merge(dev): absorb smalltalk routing into intent split

Keep use-case split of recognizer.go and port IsSmallTalk helpers
into recognizer_helpers.go so residual can re-integrate cleanly.
This commit is contained in:
WeiCHEN
2026-09-23 10:13:29 +08:00
7 changed files with 182 additions and 0 deletions
+29
View File
@@ -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)
}
}
+5
View File
@@ -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))
+1
View File
@@ -47,6 +47,7 @@ func detectDialogueAct(input string) DialogueAct {
askMarkers := []string{
"吗", "呢", "谁", "是否", "有没有", "通过了吗", "了吗", "了没",
"什么状态", "当前状态", "查询", "看看", "问一下", "了解一下",
"怎么样", "怎么", "什么是", "为什么", "哪些", "多少", "好吗",
}
hasAskPunct := strings.Contains(trimmed, "?") || strings.Contains(trimmed, "?")
for _, m := range askMarkers {
+3
View File
@@ -251,6 +251,9 @@ func (r *RuleBasedRecognizer) recognizeCore(ctx context.Context, input string, u
if isGreetingOrHelp(trimmed) {
chatCand.Confidence = 0.85
chatCand.Reasoning = "匹配到问候语或通用帮助请求"
} else if IsSmallTalkOrGeneralQuestion(trimmed) {
chatCand.Confidence = 0.7
chatCand.Reasoning = "通用非业务问句,归入闲聊对话"
}
candidates = append(candidates, chatCand)
+37
View File
@@ -3,6 +3,7 @@ package intent
import (
"regexp"
"strings"
"unicode/utf8"
)
// isSecurityViolation 检查是否命中敏感安全与权限红线
@@ -159,3 +160,39 @@ func sortCandidates(c []CandidateIntent) {
}
}
}
// IsSmallTalkOrGeneralQuestion 判定是否为通用非业务问句(闲聊/常识提问)。
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
}
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
}
+81
View File
@@ -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")
}
}
+26
View File
@@ -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"
}
}
]
}