Files
Agent-System/internal/intent/recognizer_matchers_task.go
weichen b820e0070d fix(taskrun): 刷新后恢复进行中任务并纠正进度问询
进行中的 run 提前写下消息锚点,并按会话列出活跃任务;进度疑问句改为查询真实状态,交付物已落盘且不是传输失败时不再被空结果否决。
2026-09-28 06:09:42 +00:00

435 lines
15 KiB
Go
Raw Permalink 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 (
"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{},
}
}