Files
Agent-System/internal/intent/recognizer.go
T

514 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
Branch: BranchAmbiguousFallback,
TopIntent: &CandidateIntent{
Intent: IntentCasualChat,
Branch: BranchAmbiguousFallback,
Confidence: 1.0,
Reasoning: "输入为空",
},
Candidates: []CandidateIntent{
{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 (查询进度/技术咨询/参谋诊断/话术)
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 (寒暄/通用求助)
chatCand := CandidateIntent{
Intent: IntentCasualChat,
Branch: BranchNewIntentPreempt,
Confidence: 0.2,
FilledSlots: make(map[string]string),
Reasoning: "未匹配到明确业务指令,归入通用对话",
}
if isGreetingOrHelp(trimmed) {
chatCand.Confidence = 0.85
chatCand.Reasoning = "匹配到问候语或通用帮助请求"
}
candidates = append(candidates, chatCand)
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,
RequiresClarifying: requiresClarifying,
}, 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{
"进入下一阶段", "推进到下一阶段", "推进阶段", "进入方案", "进入论证",
"方案论证", "可行性论证", "开始方案", "进入报价", "开始报价", "商业锁定",
"进入v0.1", "推进到v0.1", "进入v0.5", "推进到v0.5", "进入v1.0", "推进到v1.0", "可以进入",
"没有问题.*进入", "没问题.*推进", "通过.*下一阶段", "开始推进",
"做.*摸底", "开始.*摸底", "先做.*摸底", "进行.*摸底", "做v0.1", "先做v0.1",
"做.*论证", "做.*报价", "开始论证",
}
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 := extractFactSlots(input)
if len(slots) == 0 {
slots = make(map[string]string)
}
slots[SlotSupplementDoc] = input
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"
} else if strings.Contains(input, "功耗") || strings.Contains(input, "供电") || strings.Contains(input, "设计") {
topic = "technical_design"
}
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 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]
}
}
}
}