diff --git a/internal/intent/recognizer.go b/internal/intent/recognizer.go new file mode 100644 index 00000000..21754825 --- /dev/null +++ b/internal/intent/recognizer.go @@ -0,0 +1,278 @@ +package intent + +import ( + "context" + "fmt" + "regexp" + "strings" + + "chat-agent-system/internal/bizdev" +) + +// Recognizer 意图识别器接口 +type Recognizer interface { + Recognize(ctx context.Context, input string, userCtx *ContextSnapshot) (*IntentRecognitionResult, error) +} + +// RuleBasedRecognizer 规则与上下文约束结合的轻量高效意图识别器 +type RuleBasedRecognizer struct{} + +func NewRecognizer() Recognizer { + return &RuleBasedRecognizer{} +} + +func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userCtx *ContextSnapshot) (*IntentRecognitionResult, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return &IntentRecognitionResult{ + RawInput: input, + TopIntent: &CandidateIntent{ + Intent: IntentCasualChat, + Confidence: 1.0, + Reasoning: "输入为空", + }, + Candidates: []CandidateIntent{ + {Intent: IntentCasualChat, Confidence: 1.0, Reasoning: "空输入"}, + }, + }, nil + } + + snapID := "" + currentStage := bizdev.StageV01_Triage + if userCtx != nil { + snapID = userCtx.SnapshotID + if userCtx.CurrentStage != "" { + currentStage = bizdev.MaturityStage(userCtx.CurrentStage) + } + } + + var candidates []CandidateIntent + + // 1. 检验 ADVANCE_STAGE (推进阶段) + if cand := matchAdvanceStage(trimmed, currentStage); cand != nil { + candidates = append(candidates, *cand) + } + + // 2. 检验 SUPPLEMENT_INFO (补充信息/参数变更) + if cand := matchSupplementInfo(trimmed); cand != nil { + candidates = append(candidates, *cand) + } + + // 3. 检验 QUERY_PROGRESS (查询进度/参谋诊断/话术) + if cand := matchQueryProgress(trimmed); cand != nil { + candidates = append(candidates, *cand) + } + + // 4. 检验 TRIGGER_TASK (特定任务触发) + if cand := matchTriggerTask(trimmed); cand != nil { + candidates = append(candidates, *cand) + } + + // 5. 检验 CASUAL_CHAT (寒暄/通用咨询) + chatCand := CandidateIntent{ + Intent: IntentCasualChat, + Confidence: 0.2, + FilledSlots: make(map[string]string), + Reasoning: "常规日常问候或未匹配特定业务指令", + } + if isGreetingOrHelp(trimmed) { + chatCand.Confidence = 0.85 + chatCand.Reasoning = "匹配到问候语或通用帮助请求" + } + candidates = append(candidates, chatCand) + + // 对 candidates 按置信度排序 + sortCandidates(candidates) + + top := &candidates[0] + requiresClarifying := false + if top.Confidence < 0.6 || len(top.MissingSlots) > 0 { + requiresClarifying = true + } + + return &IntentRecognitionResult{ + RawInput: trimmed, + TopIntent: top, + Candidates: candidates, + ContextSnapshotID: snapID, + RequiresClarifying: requiresClarifying, + }, nil +} + +func matchAdvanceStage(input string, currentStage bizdev.MaturityStage) *CandidateIntent { + patterns := []string{ + "进入下一阶段", "推进到下一阶段", "推进阶段", "进入方案", "进入论证", + "方案论证", "可行性论证", "开始方案", "进入报价", "开始报价", "商业锁定", + "进入v0.5", "推进到v0.5", "进入v1.0", "推进到v1.0", "可以进入", + "没有问题.*进入", "没问题.*推进", "通过.*下一阶段", "开始推进", + } + + for _, p := range patterns { + if matched, _ := regexp.MatchString("(?i)"+p, input); matched { + var target bizdev.MaturityStage + if strings.Contains(input, "报价") || strings.Contains(input, "v1.0") || strings.Contains(input, "V1.0") || strings.Contains(input, "商业锁定") { + target = bizdev.StageV10_Quoting + } else if strings.Contains(input, "方案") || strings.Contains(input, "论证") || strings.Contains(input, "可行性") || strings.Contains(input, "v0.5") || strings.Contains(input, "V0.5") { + target = bizdev.StageV05_Feasibility + } 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 { + target = bizdev.StageV10_Quoting + } else { + target = bizdev.StageV10_Quoting + } + } + + reason := fmt.Sprintf("匹配推进指令 '%s',目标成熟度阶段为 %s", p, target) + + return &CandidateIntent{ + Intent: IntentAdvanceStage, + Confidence: 0.92, + FilledSlots: map[string]string{ + SlotTargetStage: string(target), + }, + Reasoning: reason, + } + } + } + return nil +} + +func matchSupplementInfo(input string) *CandidateIntent { + patterns := []string{ + "补充", "更新", "客户说", "客户反馈", "修改为", "改为", "增加", + "市场是", "参数是", "用途是", "设备是", "预算是", "时间是", "资料", "附件", + } + + matchedAny := false + for _, p := range patterns { + if strings.Contains(input, p) { + matchedAny = true + break + } + } + if !matchedAny { + 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{"用途是", "用途为", "预期用途"}) + } + + return &CandidateIntent{ + Intent: IntentSupplementInfo, + Confidence: 0.88, + FilledSlots: slots, + Reasoning: "用户输入中包含参数变更或新材料事实补充", + } +} + +func matchQueryProgress(input string) *CandidateIntent { + patterns := []string{ + "进展", "进度", "走到哪", "到哪一步", "状态", "情况", "评估到哪", + "风险", "坑", "注意什么", "话术", "怎么说", "怎么跟客户说", "微信", "草稿", "参谋", + } + + for _, p := range patterns { + if strings.Contains(input, p) { + topic := "all" + if strings.Contains(input, "风险") || strings.Contains(input, "坑") { + topic = "risk" + } else if strings.Contains(input, "话术") || strings.Contains(input, "微信") || strings.Contains(input, "怎么说") { + topic = "talk_script" + } else if strings.Contains(input, "进展") || strings.Contains(input, "进度") || strings.Contains(input, "步") { + topic = "progress" + } + + return &CandidateIntent{ + Intent: IntentQueryProgress, + Confidence: 0.90, + FilledSlots: map[string]string{ + SlotQueryTopic: topic, + }, + Reasoning: fmt.Sprintf("用户在询问项目状态、风险或对外沟通话术 (主题: %s)", topic), + } + } + } + return nil +} + +func matchTriggerTask(input string) *CandidateIntent { + taskMap := map[string]string{ + "需求收集表": "intake", + "需求表": "intake", + "调研报告": "research", + "调研": "research", + "法规评估": "feasibility_reg", + "注册评估": "feasibility_reg", + "初步设计": "prelim_design", + "设计方案": "prelim_design", + "报价单": "cdo_quote", + "报价": "cdo_quote", + } + + for kw, tKey := range taskMap { + if strings.Contains(input, kw) && (strings.Contains(input, "生成") || strings.Contains(input, "做") || strings.Contains(input, "跑") || strings.Contains(input, "出")) { + return &CandidateIntent{ + Intent: IntentTriggerTask, + Confidence: 0.86, + FilledSlots: map[string]string{ + SlotTargetTask: tKey, + }, + Reasoning: fmt.Sprintf("用户要求显式生成交付任务: %s (%s)", kw, tKey), + } + } + } + return nil +} + +func isGreetingOrHelp(input string) bool { + greetings := []string{"你好", "您好", "在吗", "hi", "hello", "你是谁", "帮助", "能做什么", "怎么用"} + for _, g := range greetings { + if strings.Contains(strings.ToLower(input), g) { + return true + } + } + 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++ { + if c[j].Confidence > c[i].Confidence { + c[i], c[j] = c[j], c[i] + } + } + } +} diff --git a/internal/intent/recognizer_test.go b/internal/intent/recognizer_test.go new file mode 100644 index 00000000..952a2289 --- /dev/null +++ b/internal/intent/recognizer_test.go @@ -0,0 +1,102 @@ +package intent_test + +import ( + "context" + "testing" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/intent" +) + +func TestIntentRecognition_QueryProgress(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + SnapshotID: "ctx_test_1", + CurrentStage: string(bizdev.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 intent QUERY_PROGRESS, got %s", res.TopIntent.Intent) + } + if res.TopIntent.Confidence < 0.8 { + t.Errorf("expected confidence >= 0.8, got %v", res.TopIntent.Confidence) + } + if len(res.Candidates) == 0 { + t.Errorf("expected candidate intents list") + } +} + +func TestIntentRecognition_SupplementInfo(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + SnapshotID: "ctx_test_2", + } + + res, err := rec.Recognize(context.Background(), "客户刚才补充说,目标市场除了国内还想考虑欧洲 CE 认证", ctx) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + + if res.TopIntent.Intent != intent.IntentSupplementInfo { + t.Errorf("expected intent SUPPLEMENT_INFO, got %s", res.TopIntent.Intent) + } + if res.TopIntent.FilledSlots[intent.SlotFactKey] != "target_market" { + t.Errorf("expected slot target_market, got %s", res.TopIntent.FilledSlots[intent.SlotFactKey]) + } +} + +func TestIntentRecognition_AdvanceStage(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + SnapshotID: "ctx_test_3", + CurrentStage: string(bizdev.StageV01_Triage), + } + + res, err := rec.Recognize(context.Background(), "摸底表我看过了,没问题,可以进入方案论证阶段了", ctx) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + + 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_feasibility, got %s", res.TopIntent.FilledSlots[intent.SlotTargetStage]) + } +} + +func TestIntentRecognition_TriggerTask(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + SnapshotID: "ctx_test_4", + } + + res, err := rec.Recognize(context.Background(), "先帮我出一版初步报价单看看", ctx) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + + if res.TopIntent.Intent != intent.IntentTriggerTask { + t.Errorf("expected intent TRIGGER_TASK, got %s", res.TopIntent.Intent) + } + if res.TopIntent.FilledSlots[intent.SlotTargetTask] != "cdo_quote" { + t.Errorf("expected target task cdo_quote, got %s", res.TopIntent.FilledSlots[intent.SlotTargetTask]) + } +} + +func TestIntentRecognition_CasualChat(t *testing.T) { + rec := intent.NewRecognizer() + res, err := rec.Recognize(context.Background(), "你好,请问你是谁?", nil) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + + if res.TopIntent.Intent != intent.IntentCasualChat { + t.Errorf("expected intent CASUAL_CHAT, got %s", res.TopIntent.Intent) + } +} diff --git a/internal/intent/types.go b/internal/intent/types.go new file mode 100644 index 00000000..125fa38f --- /dev/null +++ b/internal/intent/types.go @@ -0,0 +1,49 @@ +package intent + +// 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" // 问候、闲聊或通用帮助引导 +) + +// 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"` // 槽位缺失时的澄清追问 +} + +// IntentRecognitionResult 意图识别完整输出(多输出策略) +type IntentRecognitionResult struct { + RawInput string `json:"raw_input"` + TopIntent *CandidateIntent `json:"top_intent"` + Candidates []CandidateIntent `json:"candidates"` + ContextSnapshotID string `json:"context_snapshot_id,omitempty"` + RequiresClarifying bool `json:"requires_clarifying"` +} + +// 常见槽位 Key 常量定义 +const ( + SlotTargetStage = "target_stage" // 目标阶段 (如 V0.1_triage, V0.5_feasibility, V1.0_quoting) + SlotTargetTask = "target_task" // 目标任务 (如 intake, research, cdo_quote) + SlotFactKey = "fact_key" // 事实键 (如 target_market, device_type) + SlotFactValue = "fact_value" // 事实值 + SlotSupplementDoc = "supplement_doc" // 补充内容文本 + SlotQueryTopic = "query_topic" // 查询主题 (如 progress, risk, talk_script, quotation) +) + +// 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"` +}