514 lines
17 KiB
Go
514 lines
17 KiB
Go
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]
|
||
}
|
||
}
|
||
}
|
||
}
|