feat(intent): 定义核心意图契约并实现基于上下文约束的规则意图识别器

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-07 21:34:39 +08:00
co-authored by Cursor
parent e2759394ca
commit d0ca8bed54
3 changed files with 429 additions and 0 deletions
+278
View File
@@ -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]
}
}
}
}
+102
View File
@@ -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)
}
}
+49
View File
@@ -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"`
}