435 lines
15 KiB
Go
435 lines
15 KiB
Go
package intent
|
||
|
||
import (
|
||
"fmt"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"chat-agent-system/internal/maturity"
|
||
"chat-agent-system/internal/squad"
|
||
)
|
||
|
||
// isProgressInquiry 为真时,带任务名词的疑问句应让行 QUERY_PROGRESS,而不是 TRIGGER_TASK。
|
||
func isProgressInquiry(input string) bool {
|
||
if !isInterrogative(input) {
|
||
return false
|
||
}
|
||
markers := []string{"进度", "进展", "怎么样了", "状态如何", "写完", "完成了吗", "完成没有", "好了吗", "好了没有"}
|
||
hit := false
|
||
for _, marker := range markers {
|
||
if strings.Contains(input, marker) {
|
||
hit = true
|
||
break
|
||
}
|
||
}
|
||
if !hit {
|
||
return false
|
||
}
|
||
for _, verb := range []string{"开始", "重新", "再来", "重跑", "再生成", "重新生成"} {
|
||
if strings.Contains(input, verb) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// isInterrogative 判断输入是否疑问句:句尾问号/「吗」「呢」,或句中出现
|
||
// 「怎么/如何/什么/多少/哪/是否/大概/多久/为什么」等疑问词。
|
||
// 用于把「查询/咨询」类输入从祈使句「指令」类意图(阶段推进等)中排除。
|
||
func isInterrogative(input string) bool {
|
||
trimmed := strings.TrimSpace(input)
|
||
if trimmed == "" {
|
||
return false
|
||
}
|
||
lastRune, _ := utf8.DecodeLastRuneInString(trimmed)
|
||
if lastRune == '?' || lastRune == '?' || lastRune == '吗' || lastRune == '呢' {
|
||
return true
|
||
}
|
||
questionWords := []string{"怎么", "如何", "什么", "多少", "哪些", "哪一", "是否",
|
||
"大概", "多久", "为什么", "是不是", "有没有", "何时", "什么时候", "什么样的"}
|
||
for _, w := range questionWords {
|
||
if strings.Contains(input, w) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func matchAdvanceStage(input string, currentStage maturity.MaturityStage) *CandidateIntent {
|
||
// 疑问句不构成推进指令:「…做过类似的…吗?报价大概怎么定的?」属于案例/知识查询,
|
||
// 曾被 "做.*报价" 过宽匹配误判为 ADVANCE_STAGE(见 2026-09-25 意图误判修复)。
|
||
// 疑问标记:句尾问号/语气词「吗」「呢」,或「怎么/什么/多少/哪/是否/大概」等疑问词。
|
||
if isInterrogative(input) {
|
||
return nil
|
||
}
|
||
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 maturity.MaturityStage
|
||
if strings.Contains(input, "报价") || strings.Contains(input, "v1.0") || strings.Contains(input, "V1.0") || strings.Contains(input, "商业锁定") {
|
||
target = maturity.StageV10_Quoting
|
||
} else if strings.Contains(input, "方案") || strings.Contains(input, "论证") || strings.Contains(input, "可行性") || strings.Contains(input, "v0.5") || strings.Contains(input, "V0.5") {
|
||
target = maturity.StageV05_Feasibility
|
||
} else if strings.Contains(input, "摸底") || strings.Contains(input, "初筛") || strings.Contains(input, "v0.1") || strings.Contains(input, "V0.1") {
|
||
target = maturity.StageV01_Triage
|
||
} else {
|
||
if currentStage == maturity.StageV01_Triage {
|
||
target = maturity.StageV05_Feasibility
|
||
} else if currentStage == maturity.StageV05_Feasibility {
|
||
target = maturity.StageV10_Quoting
|
||
} else {
|
||
target = maturity.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
|
||
slots[SlotSupplementAction] = inferSupplementSubtype(input, slots)
|
||
|
||
return &CandidateIntent{
|
||
Intent: IntentSupplementInfo,
|
||
Confidence: 0.88,
|
||
FilledSlots: slots,
|
||
Reasoning: "用户输入中包含参数变更或新材料事实补充",
|
||
}
|
||
}
|
||
|
||
func matchQueryProgress(input string) *CandidateIntent {
|
||
if matchTriggerTask(input, nil) != nil {
|
||
return nil
|
||
}
|
||
if isWorkflowControlInput(input) || isRunControlInput(input) {
|
||
return nil
|
||
}
|
||
patterns := []string{
|
||
"测试方案", "验证方案", "测试", "验证", "型检", "型式检验", "难点", "特别注意", "注意事项", "比较难",
|
||
"进展", "进度", "走到哪", "到哪一步", "状态", "情况", "评估到哪",
|
||
"风险", "坑", "注意什么", "话术", "怎么说", "怎么跟客户说", "微信", "草稿",
|
||
"探头功耗", "功耗", "供电", "怎么设计", "设计方案", "临床要不要", "怎么做", "查一下", "看下",
|
||
"成熟度", "汇报一下", "项目怎么样",
|
||
}
|
||
|
||
for _, p := range patterns {
|
||
if strings.Contains(input, p) {
|
||
topic := "all"
|
||
if strings.Contains(input, "测试") || strings.Contains(input, "验证") || strings.Contains(input, "型检") || strings.Contains(input, "难点") || strings.Contains(input, "特别注意") || strings.Contains(input, "注意事项") || strings.Contains(input, "比较难") {
|
||
topic = "testing_analysis"
|
||
} else 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"
|
||
}
|
||
|
||
confidence := 0.90
|
||
if topic == "testing_analysis" {
|
||
confidence = 0.95
|
||
}
|
||
|
||
return &CandidateIntent{
|
||
Intent: IntentQueryProgress,
|
||
Confidence: confidence,
|
||
FilledSlots: map[string]string{
|
||
SlotQueryTopic: topic,
|
||
},
|
||
Reasoning: fmt.Sprintf("用户在询问项目状态、技术测试方案或对外沟通话术 (主题: %s)", topic),
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func hasTaskWriteVerb(input string) bool {
|
||
for _, verb := range []string{"生成", "做", "跑", "出", "编写", "编制", "起草", "制定", "拟定"} {
|
||
if strings.Contains(input, verb) {
|
||
return true
|
||
}
|
||
}
|
||
// 「定」「拟」只在祈使前缀后算写动词。「帮我决定」里的「定」不算。
|
||
return hasBareWriteImperative(input, "定") || hasBareWriteImperative(input, "拟")
|
||
}
|
||
|
||
func hasBareWriteImperative(input, verb string) bool {
|
||
for _, marker := range []string{"帮我" + verb, "请" + verb, "给我" + verb, "帮忙" + verb} {
|
||
idx := strings.Index(input, marker)
|
||
if idx < 0 {
|
||
continue
|
||
}
|
||
rest := input[idx+len(marker):]
|
||
if verb == "定" && strings.HasPrefix(rest, "决") {
|
||
continue
|
||
}
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// hasWeakTaskWriteVerb 弱写动词:倾向整理/梳理,默认需确认后再启动正式任务。
|
||
func hasWeakTaskWriteVerb(input string) bool {
|
||
for _, verb := range []string{"整理", "梳理", "汇总", "归纳", "收集"} {
|
||
if strings.Contains(input, verb) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isRequirementsTopic(input string) bool {
|
||
if strings.Contains(input, "需求") || strings.Contains(input, "摸底") {
|
||
return true
|
||
}
|
||
return strings.Contains(input, "用户") && (hasTaskWriteVerb(input) || hasWeakTaskWriteVerb(input))
|
||
}
|
||
|
||
// hasURSStrongMarker URS / 用户需求规格书级强标记(非「初始/摸底」时才算)。
|
||
func hasURSStrongMarker(input string) bool {
|
||
if hasIntakeSemanticMarker(input) {
|
||
return false
|
||
}
|
||
return strings.Contains(input, "URS") ||
|
||
strings.Contains(input, "用户需求规格") ||
|
||
strings.Contains(input, "用户需求表")
|
||
}
|
||
|
||
// stageAwareRequirementsTask 按当前阶段消解需求类任务键,与前端 resolveRequirementsTaskKey 对齐:
|
||
// V0.1 / 空阶段且无 URS 强标记 → intake;含 URS 强标记 → user_requirements。
|
||
func stageAwareRequirementsTask(input string, userCtx *ContextSnapshot) string {
|
||
if hasIntakeSemanticMarker(input) {
|
||
return "intake"
|
||
}
|
||
stage := ""
|
||
if userCtx != nil {
|
||
stage = userCtx.CurrentStage
|
||
}
|
||
if hasURSStrongMarker(input) {
|
||
return "user_requirements"
|
||
}
|
||
// 含糊「需求」在初期默认摸底表,避免误触 V0.5 门禁失败。
|
||
if stage == "" || stage == string(maturity.StageV01_Triage) {
|
||
return "intake"
|
||
}
|
||
if strings.Contains(input, "用户需求") || strings.Contains(input, "需求表") {
|
||
return "user_requirements"
|
||
}
|
||
return "intake"
|
||
}
|
||
|
||
func requirementsTriggerPhrase(taskKey string) string {
|
||
switch taskKey {
|
||
case "user_requirements":
|
||
return "用户需求表"
|
||
default:
|
||
return "用户初始需求收集表"
|
||
}
|
||
}
|
||
|
||
func matchTriggerTask(input string, userCtx *ContextSnapshot) *CandidateIntent {
|
||
// 疑问句里的「编写进度怎么样了」是在问状态,不是再启动一份交付物。
|
||
// 显式启动词(开始/重新/重跑)仍走任务触发。
|
||
if isProgressInquiry(input) {
|
||
return nil
|
||
}
|
||
type phrase struct {
|
||
text string
|
||
key string
|
||
}
|
||
taskMap := map[string]string{
|
||
"需求收集表": "intake",
|
||
"需求表": "intake",
|
||
"调研报告": "research",
|
||
"调研": "research",
|
||
"法规评估": "feasibility_reg",
|
||
"注册评估": "feasibility_reg",
|
||
"初步设计": "prelim_design",
|
||
"设计方案": "prelim_design",
|
||
"报价单": "cdo_quote",
|
||
"报价": "cdo_quote",
|
||
}
|
||
phrases := make([]phrase, 0, len(taskMap)+60)
|
||
for kw, tKey := range taskMap {
|
||
phrases = append(phrases, phrase{text: kw, key: tKey})
|
||
}
|
||
for _, item := range squad.RDevTriggerPhrases() {
|
||
phrases = append(phrases, phrase{text: item.Text, key: item.TaskKey})
|
||
}
|
||
sort.Slice(phrases, func(i, j int) bool {
|
||
li := utf8.RuneCountInString(phrases[i].text)
|
||
lj := utf8.RuneCountInString(phrases[j].text)
|
||
if li != lj {
|
||
return li > lj
|
||
}
|
||
return phrases[i].text < phrases[j].text
|
||
})
|
||
|
||
strong := hasTaskWriteVerb(input)
|
||
weak := hasWeakTaskWriteVerb(input)
|
||
if !strong && !weak {
|
||
return nil
|
||
}
|
||
|
||
// 需求类话题优先阶段感知消解,避免研发短别名「用户需求」截走商务流初期表述。
|
||
if isRequirementsTopic(input) {
|
||
taskKey := stageAwareRequirementsTask(input, userCtx)
|
||
phrase := requirementsTriggerPhrase(taskKey)
|
||
if cand := scopedTriggerTaskCandidate(input, phrase, taskKey, userCtx); cand != nil {
|
||
return cand
|
||
}
|
||
cand := triggerTaskCandidate(phrase, taskKey)
|
||
if !strong && weak {
|
||
cand.Confidence = 0.78
|
||
cand.FilledSlots[SlotNeedsConfirmation] = "true"
|
||
cand.Reasoning = fmt.Sprintf("弱写动词触发需求整理,阶段感知消解为 %s,需用户确认后执行", taskKey)
|
||
}
|
||
return cand
|
||
}
|
||
|
||
if !strong {
|
||
// 弱写动词仅覆盖需求类宾语;其他话题仍走对话兜底。
|
||
return nil
|
||
}
|
||
|
||
if workflowScopeUsesWeights() {
|
||
flat := make([]struct{ text, key string }, 0, len(phrases))
|
||
for _, item := range phrases {
|
||
flat = append(flat, struct{ text, key string }{text: item.text, key: item.key})
|
||
}
|
||
return weightedTriggerTask(input, flat, userCtx)
|
||
}
|
||
|
||
for _, item := range phrases {
|
||
if strings.Contains(input, item.text) {
|
||
if cand := scopedTriggerTaskCandidate(input, item.text, item.key, userCtx); cand != nil {
|
||
return cand
|
||
}
|
||
return triggerTaskCandidate(item.text, item.key)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// scopedTriggerTaskCandidate 若目标任务键不属于当前激活工作流,返回跨流拦截候选;
|
||
// 无激活工作流(存量单流 / planning 态)或归属未知时返回 nil,维持原路由。
|
||
func scopedTriggerTaskCandidate(input, phrase, taskKey string, userCtx *ContextSnapshot) *CandidateIntent {
|
||
active := ""
|
||
if userCtx != nil {
|
||
active = userCtx.ActiveWorkflowKey
|
||
}
|
||
owners, ok := docScopeForPhrase(phrase)
|
||
if !ok {
|
||
if owner := taskKeyWorkflowOwner(taskKey); owner != "" {
|
||
owners = []string{owner}
|
||
ok = true
|
||
}
|
||
}
|
||
if !ok || active == "" || containsWorkflow(owners, active) {
|
||
return nil
|
||
}
|
||
return workflowScopeCandidate(phrase, owners, active)
|
||
}
|
||
|
||
func workflowScopeCandidate(phrase string, owners []string, activeWorkflow string) *CandidateIntent {
|
||
slots := make(map[string]string)
|
||
markWorkflowScopeMismatch(slots, phrase, owners)
|
||
return &CandidateIntent{
|
||
Intent: IntentTriggerTask,
|
||
Confidence: 0.86,
|
||
Branch: BranchNewIntentPreempt,
|
||
FilledSlots: slots,
|
||
Reasoning: fmt.Sprintf("用户要求生成交付任务 %s,但该文档属于非激活工作流,前置剪枝", phrase),
|
||
ClarificationQuestion: workflowScopeGuidanceQuestion(phrase, owners, activeWorkflow),
|
||
}
|
||
}
|
||
|
||
func containsWorkflow(workflows []string, key string) bool {
|
||
for _, wf := range workflows {
|
||
if wf == key {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func hasIntakeSemanticMarker(input string) bool {
|
||
return strings.Contains(input, "首轮") ||
|
||
strings.Contains(input, "初始") ||
|
||
strings.Contains(input, "需求摸底") ||
|
||
strings.Contains(input, "摸底")
|
||
}
|
||
|
||
func triggerTaskCandidate(phrase, taskKey string) *CandidateIntent {
|
||
return &CandidateIntent{
|
||
Intent: IntentTriggerTask,
|
||
Confidence: 0.86,
|
||
FilledSlots: map[string]string{
|
||
SlotTargetTask: taskKey,
|
||
},
|
||
Reasoning: fmt.Sprintf("用户要求显式生成交付任务: %s (%s)", phrase, taskKey),
|
||
}
|
||
}
|
||
|
||
func matchImpactIntent(input string) *CandidateIntent {
|
||
propose := strings.Contains(input, "修订提案") || strings.Contains(input, "生成修订") || strings.Contains(input, "提议修改文档")
|
||
analyze := strings.Contains(input, "分析影响") || strings.Contains(input, "影响分析") || strings.Contains(input, "波及哪些") ||
|
||
strings.Contains(input, "影响哪些") || strings.Contains(input, "改动影响") || strings.Contains(input, "变更影响")
|
||
if !propose && !analyze {
|
||
return nil
|
||
}
|
||
intent := IntentImpactAnalyze
|
||
if propose {
|
||
intent = IntentRevisePropose
|
||
}
|
||
return &CandidateIntent{
|
||
Intent: intent,
|
||
Confidence: 0.9,
|
||
FilledSlots: map[string]string{},
|
||
}
|
||
}
|