diff --git a/internal/intent/recognizer.go b/internal/intent/recognizer.go index c611a02e..b9641484 100644 --- a/internal/intent/recognizer.go +++ b/internal/intent/recognizer.go @@ -14,7 +14,7 @@ type Recognizer interface { Recognize(ctx context.Context, input string, userCtx *ContextSnapshot) (*IntentRecognitionResult, error) } -// RuleBasedRecognizer 规则与上下文约束结合的轻量高效意图识别器 +// RuleBasedRecognizer 规则与上下文约束结合的三分支柔性意图消解器 type RuleBasedRecognizer struct{} func NewRecognizer() Recognizer { @@ -26,54 +26,90 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC if trimmed == "" { return &IntentRecognitionResult{ RawInput: input, + Branch: BranchAmbiguousFallback, TopIntent: &CandidateIntent{ Intent: IntentCasualChat, + Branch: BranchAmbiguousFallback, Confidence: 1.0, Reasoning: "输入为空", }, Candidates: []CandidateIntent{ - {Intent: IntentCasualChat, Confidence: 1.0, Reasoning: "空输入"}, + {Intent: IntentCasualChat, Branch: BranchAmbiguousFallback, Confidence: 1.0, Reasoning: "空输入"}, }, }, nil } snapID := "" currentStage := bizdev.StageV01_Triage + var pending *PendingExpectation + if userCtx != nil { snapID = userCtx.SnapshotID if userCtx.CurrentStage != "" { currentStage = bizdev.MaturityStage(userCtx.CurrentStage) } + pending = userCtx.PendingExpectation } + // ------------------------------------------------------------- + // 阶段一:若输入明确是咨询参谋/犹豫(如“这个先放放,你觉得呢”),优先归入分支 3 (参谋主动分忧) + // ------------------------------------------------------------- + if isConsultativeOrHesitant(trimmed) { + res := buildAmbiguousConsultResult(trimmed, snapID, pending) + return res, nil + } + + // ------------------------------------------------------------- + // 阶段二:分支 1 判定——命中历史轮次挂起预期 (肯定 / 选项序号 / 复合带补丁 / 明确撤销) + // ------------------------------------------------------------- + if pending != nil { + if hitCand := matchExpectationHit(trimmed, pending); hitCand != nil { + return &IntentRecognitionResult{ + RawInput: trimmed, + Branch: BranchExpectationHit, + TopIntent: hitCand, + Candidates: []CandidateIntent{*hitCand}, + ContextSnapshotID: snapID, + }, nil + } + } + + // ------------------------------------------------------------- + // 阶段三:分支 2 判定——独立新意图抢占 (用户新需求最高优先级) + // ------------------------------------------------------------- var candidates []CandidateIntent // 1. 检验 ADVANCE_STAGE (推进阶段) if cand := matchAdvanceStage(trimmed, currentStage); cand != nil { + cand.Branch = BranchNewIntentPreempt candidates = append(candidates, *cand) } // 2. 检验 SUPPLEMENT_INFO (补充信息/参数变更) if cand := matchSupplementInfo(trimmed); cand != nil { + cand.Branch = BranchNewIntentPreempt candidates = append(candidates, *cand) } - // 3. 检验 QUERY_PROGRESS (查询进度/参谋诊断/话术) + // 3. 检验 QUERY_PROGRESS (查询进度/技术咨询/参谋诊断/话术) if cand := matchQueryProgress(trimmed); cand != nil { + cand.Branch = BranchNewIntentPreempt candidates = append(candidates, *cand) } // 4. 检验 TRIGGER_TASK (特定任务触发) if cand := matchTriggerTask(trimmed); cand != nil { + cand.Branch = BranchNewIntentPreempt candidates = append(candidates, *cand) } - // 5. 检验 CASUAL_CHAT (寒暄/通用咨询) + // 5. 检验 CASUAL_CHAT (寒暄/通用求助) chatCand := CandidateIntent{ Intent: IntentCasualChat, + Branch: BranchNewIntentPreempt, Confidence: 0.2, FilledSlots: make(map[string]string), - Reasoning: "常规日常问候或未匹配特定业务指令", + Reasoning: "未匹配到明确业务指令,归入通用对话", } if isGreetingOrHelp(trimmed) { chatCand.Confidence = 0.85 @@ -81,17 +117,23 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC } candidates = append(candidates, chatCand) - // 对 candidates 按置信度排序 sortCandidates(candidates) - top := &candidates[0] + + // ------------------------------------------------------------- + // 阶段四:分支 3 判定——模糊/无实际意义兜底 (置信度不足或需澄清) + // ------------------------------------------------------------- + branch := BranchNewIntentPreempt requiresClarifying := false if top.Confidence < 0.6 || len(top.MissingSlots) > 0 { + branch = BranchAmbiguousFallback + top.Branch = BranchAmbiguousFallback requiresClarifying = true } return &IntentRecognitionResult{ RawInput: trimmed, + Branch: branch, TopIntent: top, Candidates: candidates, ContextSnapshotID: snapID, @@ -99,6 +141,219 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC }, nil } +// matchExpectationHit 检验是否命中挂起预期 (分支 1) +func matchExpectationHit(input string, pending *PendingExpectation) *CandidateIntent { + // 1. 否定/暂缓意图 + declineKeywords := []string{"先别", "别推", "算了", "先不", "等等", "暂时不", "先别推", "不要推进", "不要", "不行", "算了吧", "先放一放", "先等等"} + for _, dk := range declineKeywords { + if input == dk || strings.HasPrefix(input, dk) { + return &CandidateIntent{ + Intent: IntentDeclineExpectation, + Confidence: 0.95, + Branch: BranchExpectationHit, + IsDecline: true, + FilledSlots: make(map[string]string), + Reasoning: fmt.Sprintf("用户明确表示暂缓或取消当前挂起建议 ('%s')", input), + } + } + } + + // 2. 选择序号检验 (如 "1", "2", "选1", "选2", "第1个", "第一个", "选项1") + var matchedOpt *ExpectationOption + optIndex := matchOptionIndex(input, pending.Options) + if optIndex >= 0 && optIndex < len(pending.Options) { + matchedOpt = &pending.Options[optIndex] + } + + // 3. 肯定词检验 (如 "好", "行", "可以", "成", "没问题", "同意", "好的", "就按这个", "按你说的办") + affirmativePrefixes := []string{"好", "好的", "行", "行啊", "成", "可以", "没问题", "同意", "就按这个", "按这个", "就按这个办", "听你的", "ok", "yes", "就这么定"} + isAffirmative := false + + for _, aff := range affirmativePrefixes { + if input == aff || strings.HasPrefix(input, aff+",") || strings.HasPrefix(input, aff+",") || strings.HasPrefix(input, aff+"但") || strings.HasPrefix(input, aff+" ") { + isAffirmative = true + break + } + } + + if matchedOpt == nil && isAffirmative { + if pending.DefaultOption != nil { + matchedOpt = pending.DefaultOption + } else if len(pending.Options) > 0 { + matchedOpt = &pending.Options[0] + } + } + + if matchedOpt == nil { + return nil + } + + // 4. 提取增量事实补丁 (Patch Merge) + // 例如: "好,但目标市场改成欧洲CE", "选1,先把预算定为15万" + patchSlots := extractFactSlots(input) + + filledSlots := make(map[string]string) + for k, v := range matchedOpt.Slots { + filledSlots[k] = v + } + for k, v := range patchSlots { + filledSlots[k] = v + } + + confidence := 1.0 + reasoning := fmt.Sprintf("用户明确肯定上一轮参谋建议动作 [%s: %s]", matchedOpt.OptionID, matchedOpt.Title) + if len(patchSlots) > 0 { + confidence = 0.98 + reasoning = fmt.Sprintf("用户肯定上一轮建议动作 [%s: %s],并附带增量事实补丁: %v", matchedOpt.OptionID, matchedOpt.Title, patchSlots) + } else if optIndex >= 0 { + confidence = 0.98 + reasoning = fmt.Sprintf("用户按序号选择了备选动作 [%s: %s]", matchedOpt.OptionID, matchedOpt.Title) + } + + return &CandidateIntent{ + Intent: matchedOpt.Intent, + Confidence: confidence, + Branch: BranchExpectationHit, + FilledSlots: filledSlots, + PatchSlots: patchSlots, + MatchedOption: matchedOpt, + Reasoning: reasoning, + } +} + +// matchOptionIndex 匹配用户输入的选项编号 +func matchOptionIndex(input string, options []ExpectationOption) int { + input = strings.TrimSpace(input) + if len(options) == 0 { + return -1 + } + + // 匹配纯数字或带前缀 + patterns := []struct { + regex string + idx int + }{ + {"^(1|选1|选第1个|第一个|第1个|选项1|选项一)$", 0}, + {"^(2|选2|选第2个|第二个|第2个|选项2|选项二)$", 1}, + {"^(3|选3|选第3个|第三个|第3个|选项3|选项三)$", 2}, + } + + for _, p := range patterns { + if matched, _ := regexp.MatchString("(?i)"+p.regex, input); matched { + return p.idx + } + } + + // 检查是否匹配 OptionID + for idx, opt := range options { + if strings.EqualFold(opt.OptionID, input) { + return idx + } + } + + return -1 +} + +// isConsultativeOrHesitant 检验是否属于犹豫或征询参谋意见 (分支 3) +func isConsultativeOrHesitant(input string) bool { + patterns := []string{ + "你觉得呢", "你怎么看", "有什么建议", "怎么选", "拿不定主意", + "先放放.*你觉得", "这个先放放", "先放一放.*你觉得", "哪个好", "给点意见", + } + for _, p := range patterns { + if matched, _ := regexp.MatchString("(?i)"+p, input); matched { + return true + } + } + return false +} + +// buildAmbiguousConsultResult 构建分支 3 (模糊/分忧引导) 输出 +func buildAmbiguousConsultResult(input, snapID string, pending *PendingExpectation) *IntentRecognitionResult { + clarifyQ := "我为您准备了当前阶段最稳妥的推进选项。请问需要我为您详细分析技术可行性,还是先为您做方案摸底?" + if pending != nil && len(pending.Options) > 0 { + var optDesc strings.Builder + optDesc.WriteString("针对目前的决策节点,我为您梳理了以下选项供您参考:\n") + for _, o := range pending.Options { + optDesc.WriteString(fmt.Sprintf("• [%s] %s: %s\n", o.OptionID, o.Title, o.Description)) + } + clarifyQ = optDesc.String() + } + + top := &CandidateIntent{ + Intent: IntentCasualChat, + Confidence: 0.65, + Branch: BranchAmbiguousFallback, + FilledSlots: make(map[string]string), + Reasoning: "用户表达犹豫或主动征询参谋意见,触发参谋主动分忧与引导", + ClarificationQuestion: clarifyQ, + } + + return &IntentRecognitionResult{ + RawInput: input, + Branch: BranchAmbiguousFallback, + TopIntent: top, + Candidates: []CandidateIntent{*top}, + ContextSnapshotID: snapID, + RequiresClarifying: true, + } +} + +// extractFactSlots 从文本中提取结构化事实槽位 (支持补丁合并与补充信息) +func extractFactSlots(input string) map[string]string { + slots := make(map[string]string) + + // 目标市场 + marketPrefixes := []string{ + "目标市场改成", "目标市场改为", "目标市场是", "目标市场为", + "市场改成", "市场改为", "市场是", "市场为", "目标市场除了国内还想考虑", + } + for _, p := range marketPrefixes { + if idx := strings.Index(input, p); idx != -1 { + val := strings.TrimSpace(input[idx+len(p):]) + val = strings.Trim(val, "::,,。 ") + if val != "" { + slots["target_market"] = val + slots[SlotFactKey] = "target_market" + slots[SlotFactValue] = val + break + } + } + } + + // 预算 + budgetPrefixes := []string{"预算改成", "预算改为", "预算是", "预算为", "预算只有", "预算大概", "预算"} + for _, p := range budgetPrefixes { + if idx := strings.Index(input, p); idx != -1 && slots["budget"] == "" { + val := strings.TrimSpace(input[idx+len(p):]) + val = strings.Trim(val, "::,,。 ") + if val != "" { + slots["budget"] = val + slots[SlotFactKey] = "budget" + slots[SlotFactValue] = val + break + } + } + } + + // 预期用途 / 使用场景 + usePrefixes := []string{"用途是", "用途为", "预期用途是", "场景是", "场景为"} + for _, p := range usePrefixes { + if idx := strings.Index(input, p); idx != -1 && slots["intended_use"] == "" { + val := strings.TrimSpace(input[idx+len(p):]) + val = strings.Trim(val, "::,,。 ") + if val != "" { + slots["intended_use"] = val + slots[SlotFactKey] = "intended_use" + slots[SlotFactValue] = val + break + } + } + } + + return slots +} + func matchAdvanceStage(input string, currentStage bizdev.MaturityStage) *CandidateIntent { patterns := []string{ "进入下一阶段", "推进到下一阶段", "推进阶段", "进入方案", "进入论证", @@ -119,7 +374,6 @@ func matchAdvanceStage(input string, currentStage bizdev.MaturityStage) *Candida } else if strings.Contains(input, "摸底") || strings.Contains(input, "初筛") || strings.Contains(input, "v0.1") || strings.Contains(input, "V0.1") { target = bizdev.StageV01_Triage } else { - // 默认根据当前阶段自然顺推 if currentStage == bizdev.StageV01_Triage { target = bizdev.StageV05_Feasibility } else if currentStage == bizdev.StageV05_Feasibility { @@ -161,23 +415,11 @@ func matchSupplementInfo(input string) *CandidateIntent { return nil } - slots := make(map[string]string) - slots[SlotSupplementDoc] = input - - // 细粒度槽位提取 - if strings.Contains(input, "市场") { - slots[SlotFactKey] = "target_market" - slots[SlotFactValue] = extractValueAfter(input, []string{"市场是", "市场改为", "市场为", "目标市场是", "目标市场为", "市场"}) - } else if strings.Contains(input, "预算") { - slots[SlotFactKey] = "budget" - slots[SlotFactValue] = extractValueAfter(input, []string{"预算是", "预算为", "预算"}) - } else if strings.Contains(input, "时间") || strings.Contains(input, "周期") { - slots[SlotFactKey] = "timeline" - slots[SlotFactValue] = extractValueAfter(input, []string{"时间是", "时间为", "周期是", "周期为"}) - } else if strings.Contains(input, "用途") { - slots[SlotFactKey] = "intended_use" - slots[SlotFactValue] = extractValueAfter(input, []string{"用途是", "用途为", "预期用途"}) + slots := extractFactSlots(input) + if len(slots) == 0 { + slots = make(map[string]string) } + slots[SlotSupplementDoc] = input return &CandidateIntent{ Intent: IntentSupplementInfo, @@ -191,6 +433,7 @@ func matchQueryProgress(input string) *CandidateIntent { patterns := []string{ "进展", "进度", "走到哪", "到哪一步", "状态", "情况", "评估到哪", "风险", "坑", "注意什么", "话术", "怎么说", "怎么跟客户说", "微信", "草稿", "参谋", + "探头功耗", "功耗", "供电", "怎么设计", "设计方案", "临床要不要", "怎么做", "查一下", "看下", } for _, p := range patterns { @@ -202,6 +445,8 @@ func matchQueryProgress(input string) *CandidateIntent { topic = "talk_script" } else if strings.Contains(input, "进展") || strings.Contains(input, "进度") || strings.Contains(input, "步") { topic = "progress" + } else if strings.Contains(input, "功耗") || strings.Contains(input, "供电") || strings.Contains(input, "设计") { + topic = "technical_design" } return &CandidateIntent{ @@ -210,7 +455,7 @@ func matchQueryProgress(input string) *CandidateIntent { FilledSlots: map[string]string{ SlotQueryTopic: topic, }, - Reasoning: fmt.Sprintf("用户在询问项目状态、风险或对外沟通话术 (主题: %s)", topic), + Reasoning: fmt.Sprintf("用户在询问项目状态、技术方案或对外沟通话术 (主题: %s)", topic), } } } @@ -256,19 +501,6 @@ func isGreetingOrHelp(input string) bool { return false } -func extractValueAfter(input string, prefixes []string) string { - for _, p := range prefixes { - if idx := strings.Index(input, p); idx != -1 { - val := strings.TrimSpace(input[idx+len(p):]) - val = strings.Trim(val, "::,,。 ") - if val != "" { - return val - } - } - } - return input -} - func sortCandidates(c []CandidateIntent) { for i := 0; i < len(c)-1; i++ { for j := i + 1; j < len(c); j++ { diff --git a/internal/intent/recognizer_test.go b/internal/intent/recognizer_test.go index 952a2289..c6bff255 100644 --- a/internal/intent/recognizer_test.go +++ b/internal/intent/recognizer_test.go @@ -3,6 +3,7 @@ package intent_test import ( "context" "testing" + "time" "chat-agent-system/internal/bizdev" "chat-agent-system/internal/intent" @@ -100,3 +101,186 @@ func TestIntentRecognition_CasualChat(t *testing.T) { t.Errorf("expected intent CASUAL_CHAT, got %s", res.TopIntent.Intent) } } + +// ---------------------------------------------------------------------------------- +// 三分支柔性消解核心测试集 +// ---------------------------------------------------------------------------------- + +func samplePendingExpectation() *intent.PendingExpectation { + optAdvance := intent.ExpectationOption{ + OptionID: "1", + Title: "推进到 V0.5 方案成型与法规对齐", + Description: "小队将自动开展技术路线比选与分类判定", + Intent: intent.IntentAdvanceStage, + Slots: map[string]string{ + intent.SlotTargetStage: string(bizdev.StageV05_Feasibility), + }, + } + optRefresh := intent.ExpectationOption{ + OptionID: "2", + Title: "局部刷新 V0.1 用户需求表", + Description: "根据新补充材料重新生成初筛画像", + Intent: intent.IntentTriggerTask, + Slots: map[string]string{ + intent.SlotTargetTask: "intake", + }, + } + + return &intent.PendingExpectation{ + ContextSnippet: "当前 V0.1 闪电摸底已就绪,建议推进到 V0.5", + DefaultOption: &optAdvance, + Options: []intent.ExpectationOption{optAdvance, optRefresh}, + AllowAffirmative: true, + CreatedAt: time.Now(), + } +} + +// 用例 1(分支 1 极速兑现):"好", "行", "选1" +func TestThreeBranch_Branch1_DirectAffirmative(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + SnapshotID: "ctx_pending_1", + CurrentStage: string(bizdev.StageV01_Triage), + PendingExpectation: samplePendingExpectation(), + } + + testCases := []string{"好", "行", "可以", "没问题", "同意", "好的", "就按这个办", "选1", "1"} + for _, tc := range testCases { + res, err := rec.Recognize(context.Background(), tc, ctx) + if err != nil { + t.Fatalf("recognize '%s' failed: %v", tc, err) + } + if res.Branch != intent.BranchExpectationHit { + t.Errorf("input '%s': expected branch EXPECTATION_HIT, got %s", tc, res.Branch) + } + if res.TopIntent.Intent != intent.IntentAdvanceStage { + t.Errorf("input '%s': expected intent ADVANCE_STAGE, got %s", tc, res.TopIntent.Intent) + } + if res.TopIntent.FilledSlots[intent.SlotTargetStage] != string(bizdev.StageV05_Feasibility) { + t.Errorf("input '%s': expected target stage V0.5, got %s", tc, res.TopIntent.FilledSlots[intent.SlotTargetStage]) + } + if res.TopIntent.Confidence < 0.95 { + t.Errorf("input '%s': expected confidence >= 0.95, got %v", tc, res.TopIntent.Confidence) + } + } +} + +// 用例 2(分支 1 复合打补丁):"好,但目标市场改成欧洲CE" +func TestThreeBranch_Branch1_AffirmativeWithPatch(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + SnapshotID: "ctx_pending_2", + CurrentStage: string(bizdev.StageV01_Triage), + PendingExpectation: samplePendingExpectation(), + } + + res, err := rec.Recognize(context.Background(), "好,但目标市场改成欧洲CE", ctx) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + + if res.Branch != intent.BranchExpectationHit { + t.Fatalf("expected branch EXPECTATION_HIT, got %s", res.Branch) + } + if res.TopIntent.Intent != intent.IntentAdvanceStage { + t.Errorf("expected intent ADVANCE_STAGE, got %s", res.TopIntent.Intent) + } + if res.TopIntent.FilledSlots[intent.SlotTargetStage] != string(bizdev.StageV05_Feasibility) { + t.Errorf("expected target stage V0.5, got %s", res.TopIntent.FilledSlots[intent.SlotTargetStage]) + } + if res.TopIntent.FilledSlots["target_market"] != "欧洲CE" { + t.Errorf("expected patched target_market '欧洲CE', got '%s'", res.TopIntent.FilledSlots["target_market"]) + } + if res.TopIntent.PatchSlots["target_market"] != "欧洲CE" { + t.Errorf("expected PatchSlots to have target_market '欧洲CE', got '%s'", res.TopIntent.PatchSlots["target_market"]) + } +} + +// 用例 3(分支 1 否定撤销):"先别推,等等" +func TestThreeBranch_Branch1_Decline(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + SnapshotID: "ctx_pending_3", + CurrentStage: string(bizdev.StageV01_Triage), + PendingExpectation: samplePendingExpectation(), + } + + testCases := []string{"先别推,等等", "算了吧", "先别", "先等等"} + for _, tc := range testCases { + res, err := rec.Recognize(context.Background(), tc, ctx) + if err != nil { + t.Fatalf("recognize '%s' failed: %v", tc, err) + } + if res.Branch != intent.BranchExpectationHit { + t.Errorf("input '%s': expected branch EXPECTATION_HIT, got %s", tc, res.Branch) + } + if res.TopIntent.Intent != intent.IntentDeclineExpectation { + t.Errorf("input '%s': expected intent DECLINE_EXPECTATION, got %s", tc, res.TopIntent.Intent) + } + if !res.TopIntent.IsDecline { + t.Errorf("input '%s': expected IsDecline to be true", tc) + } + } +} + +// 用例 4(分支 2 话题突发抢占):有挂起预期时,用户突然问新问题或补新事实 +func TestThreeBranch_Branch2_NewIntentPreempt(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + SnapshotID: "ctx_pending_4", + CurrentStage: string(bizdev.StageV01_Triage), + PendingExpectation: samplePendingExpectation(), + } + + // 抢占场景 A: 突然提问技术问题 + res1, err := rec.Recognize(context.Background(), "查一下探头功耗要怎么设计", ctx) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + if res1.Branch != intent.BranchNewIntentPreempt { + t.Errorf("expected branch NEW_INTENT_PREEMPT, got %s", res1.Branch) + } + if res1.TopIntent.Intent != intent.IntentQueryProgress { + t.Errorf("expected intent QUERY_PROGRESS, got %s", res1.TopIntent.Intent) + } + + // 抢占场景 B: 突然补充事实 + res2, err := rec.Recognize(context.Background(), "客户补充说预算大概15万", ctx) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + if res2.Branch != intent.BranchNewIntentPreempt { + t.Errorf("expected branch NEW_INTENT_PREEMPT, got %s", res2.Branch) + } + if res2.TopIntent.Intent != intent.IntentSupplementInfo { + t.Errorf("expected intent SUPPLEMENT_INFO, got %s", res2.TopIntent.Intent) + } + if res2.TopIntent.FilledSlots["budget"] != "15万" { + t.Errorf("expected budget '15万', got '%s'", res2.TopIntent.FilledSlots["budget"]) + } +} + +// 用例 5(分支 3 模糊分忧):"这个先放放,你觉得呢" +func TestThreeBranch_Branch3_AmbiguousConsult(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + SnapshotID: "ctx_pending_5", + CurrentStage: string(bizdev.StageV01_Triage), + PendingExpectation: samplePendingExpectation(), + } + + res, err := rec.Recognize(context.Background(), "这个先放放,你觉得呢", ctx) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + + if res.Branch != intent.BranchAmbiguousFallback { + t.Errorf("expected branch AMBIGUOUS_FALLBACK, got %s", res.Branch) + } + if res.TopIntent.Intent != intent.IntentCasualChat { + t.Errorf("expected intent CASUAL_CHAT, got %s", res.TopIntent.Intent) + } + if res.TopIntent.ClarificationQuestion == "" { + t.Errorf("expected non-empty clarification question for branch 3") + } +} diff --git a/internal/intent/types.go b/internal/intent/types.go index 125fa38f..0cc97b53 100644 --- a/internal/intent/types.go +++ b/internal/intent/types.go @@ -1,24 +1,60 @@ package intent +import ( + "time" +) + // IntentType 核心意图类别定义 type IntentType string const ( - IntentQueryProgress IntentType = "QUERY_PROGRESS" // 查询进度、成熟度、风险研判或沟通话术 - IntentSupplementInfo IntentType = "SUPPLEMENT_INFO" // 补充/修改材料、事实信息或参数 - IntentAdvanceStage IntentType = "ADVANCE_STAGE" // 确认推进到下一成熟度阶段 (如 V0.1 -> V0.5) - IntentTriggerTask IntentType = "TRIGGER_TASK" // 显式生成或重新执行特定交付物任务 (如 intake, quote) - IntentCasualChat IntentType = "CASUAL_CHAT" // 问候、闲聊或通用帮助引导 + IntentQueryProgress IntentType = "QUERY_PROGRESS" // 查询进度、成熟度、风险研判或沟通话术 + IntentSupplementInfo IntentType = "SUPPLEMENT_INFO" // 补充/修改材料、事实信息或参数 + IntentAdvanceStage IntentType = "ADVANCE_STAGE" // 确认推进到下一成熟度阶段 (如 V0.1 -> V0.5) + IntentTriggerTask IntentType = "TRIGGER_TASK" // 显式生成或重新执行特定交付物任务 (如 intake, quote) + IntentCasualChat IntentType = "CASUAL_CHAT" // 问候、闲聊或通用帮助引导 + IntentDeclineExpectation IntentType = "DECLINE_EXPECTATION" // 用户明确暂缓/不采纳上轮建议 (非阻塞放弃) ) +// ResolutionBranch 三分支意图消解类别 +type ResolutionBranch string + +const ( + BranchExpectationHit ResolutionBranch = "EXPECTATION_HIT" // 分支 1: 命中历史轮次挂起预期 (肯定/选择/带补丁/取消) + BranchNewIntentPreempt ResolutionBranch = "NEW_INTENT_PREEMPT" // 分支 2: 独立新意图抢占 (用户意图最高优先级) + BranchAmbiguousFallback ResolutionBranch = "AMBIGUOUS_FALLBACK" // 分支 3: 模糊/无实际意义兜底 (参谋主动分析引导) +) + +// ExpectationOption 供用户挑选或单字快速兑现的具体选项 +type ExpectationOption struct { + OptionID string `json:"option_id"` // 选项代号 (如 "1", "2", "advance_v05") + Title string `json:"title"` // 选项简述 (如 "推进到 V0.5 方案成型与法规对齐") + Description string `json:"description,omitempty"` // 参谋为用户做的工作说明与收益分析 + Intent IntentType `json:"intent"` // 对应绑定的业务意图 + Slots map[string]string `json:"slots,omitempty"` // 预设槽位键值对 +} + +// PendingExpectation 挂起预期草稿(非阻塞桌角备忘贴纸) +type PendingExpectation struct { + ContextSnippet string `json:"context_snippet"` // 上一轮建议的业务背景简述 + DefaultOption *ExpectationOption `json:"default_option"` // 参谋最推荐的基准选项(用户说“好/行”时默认执行) + Options []ExpectationOption `json:"options"` // 供用户挑选的备选选项列表 + AllowAffirmative bool `json:"allow_affirmative"` // 是否允许用单字肯定(好/行/可以)触发 + CreatedAt time.Time `json:"created_at"` // 挂起时间 +} + // CandidateIntent 候选意图提案(L1 对话层输出,由 L2 协调层终审) type CandidateIntent struct { - Intent IntentType `json:"intent"` - Confidence float64 `json:"confidence"` // 置信度 0.0 - 1.0 - FilledSlots map[string]string `json:"filled_slots"` - MissingSlots []string `json:"missing_slots,omitempty"` - Reasoning string `json:"reasoning"` // 判断依据与文本佐证 - ClarificationQuestion string `json:"clarification_question,omitempty"` // 槽位缺失时的澄清追问 + Intent IntentType `json:"intent"` + Confidence float64 `json:"confidence"` // 置信度 0.0 - 1.0 + Branch ResolutionBranch `json:"branch"` // 归属的三分支判定 + FilledSlots map[string]string `json:"filled_slots"` + MissingSlots []string `json:"missing_slots,omitempty"` + Reasoning string `json:"reasoning"` // 判断依据与文本佐证 + ClarificationQuestion string `json:"clarification_question,omitempty"` // 槽位缺失时的澄清追问 + MatchedOption *ExpectationOption `json:"matched_option,omitempty"` // 分支 1 命中的具体选项 + PatchSlots map[string]string `json:"patch_slots,omitempty"` // 分支 1 复合输入的增量槽位补丁 + IsDecline bool `json:"is_decline,omitempty"` // 是否为明确暂缓/放弃 } // IntentRecognitionResult 意图识别完整输出(多输出策略) @@ -28,6 +64,7 @@ type IntentRecognitionResult struct { Candidates []CandidateIntent `json:"candidates"` ContextSnapshotID string `json:"context_snapshot_id,omitempty"` RequiresClarifying bool `json:"requires_clarifying"` + Branch ResolutionBranch `json:"branch"` // 顶层分支标记 } // 常见槽位 Key 常量定义 @@ -42,8 +79,9 @@ const ( // ContextSnapshot 传递给 L1 意图识别器的轻量上下文快照 type ContextSnapshot struct { - SnapshotID string `json:"snapshot_id,omitempty"` - ProjectName string `json:"project_name,omitempty"` - CurrentStage string `json:"current_stage,omitempty"` - AvailableActions []string `json:"available_actions,omitempty"` + SnapshotID string `json:"snapshot_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + CurrentStage string `json:"current_stage,omitempty"` + AvailableActions []string `json:"available_actions,omitempty"` + PendingExpectation *PendingExpectation `json:"pending_expectation,omitempty"` }