package intent import ( "fmt" "regexp" "sort" "strings" "sync" "unicode/utf8" "chat-agent-system/internal/squad" "chat-agent-system/internal/workflowdef" ) // docWorkflowScope 提供「文档标题/任务键 → 归属工作流」索引, // 使填槽(target_doc / target_task)只命中当前激活工作流; // 显式点名他流文档时打 workflow_scope_mismatch 标记并给出切换引导,不误填。 // docWorkflowIndex 文档标题/别名 → 归属工作流 key 集合(跨流歧义标题会有多个值)。 type docWorkflowIndex struct { phraseToWorkflows map[string][]string // phraseWorkflowTask 供「同名文档分属多流」时按激活流改路由(如 系统需求规格书 → pm_/rd_)。 phraseWorkflowTask map[string]map[string]string sortedPhrases []string nameToKey map[string]string } var ( docScopeOnce sync.Once docScopeIndex *docWorkflowIndex ) // 存量别名与历史叫法 → 规范工作流键(与 internal/workflowdef/keyspace.go 的旧任务键前缀对齐)。 var docScopeWorkflowAliases = map[string]string{ "bd": "main", "urs": "product_manager", "tm": "test_manager", "rd": "rdev", "sw": "software_bootstrap", "main": "main", } // docScopeManualEntries 是清单之外的补充叫法(历史词表与口语别名)。 // 只收录语义明确的标题;模糊短别名(如「需求表」)不在此列,维持原有兜底。 var docScopeManualEntries = []struct { workflow string phrases []string reference string // 借用该任务键的清单标题;可为空 }{ {workflow: "main", phrases: []string{"用户初始需求收集表", "用户初始需求表", "用户需求收集表", "需求收集表", "技术与市场调研报告", "CDO报价单", "初步报价单"}}, {workflow: "product_manager", phrases: []string{"URS", "用户需求规格"}}, {workflow: "test_manager", phrases: []string{"系统验证方案", "验证方案及计划"}}, // 「设计方案」在历史词表里指商务流的系统初步设计方案;研发流的同名文档用全称「系统设计方案」消歧。 {workflow: "main", phrases: []string{"设计方案"}, reference: "bd_prelim_design"}, } // docScopeUnusablePhrase 过短的短语(含 UN/BOM 类记号)易命中无关句子,不入索引。 func docScopeUnusablePhrase(phrase string) bool { return utf8.RuneCountInString(phrase) < 2 } func buildDocWorkflowIndex() *docWorkflowIndex { idx := &docWorkflowIndex{ phraseToWorkflows: map[string][]string{}, phraseWorkflowTask: map[string]map[string]string{}, nameToKey: map[string]string{}, } add := func(workflow, phrase, taskKey string) { phrase = strings.TrimSpace(phrase) if phrase == "" || docScopeUnusablePhrase(phrase) { return } dup := false for _, existing := range idx.phraseToWorkflows[phrase] { if existing == workflow { dup = true break } } if !dup { idx.phraseToWorkflows[phrase] = append(idx.phraseToWorkflows[phrase], workflow) } if taskKey != "" { byWF, ok := idx.phraseWorkflowTask[phrase] if !ok { byWF = map[string]string{} idx.phraseWorkflowTask[phrase] = byWF } if _, exists := byWF[workflow]; !exists { byWF[workflow] = taskKey } } } for _, manifestFile := range workflowdef.V2ManifestFiles { def, err := workflowdef.LoadManifestDefinition(manifestFile) if err != nil || def.WorkflowKey == "" { continue } for _, st := range def.Stages { for _, dt := range st.DocTasks { idx.nameToKey[dt.Key] = def.WorkflowKey add(def.WorkflowKey, dt.Title, dt.Key) if dt.OutputDocTitle != "" && dt.OutputDocTitle != dt.Title { add(def.WorkflowKey, dt.OutputDocTitle, dt.Key) } if meta, ok := squad.LookupRDevTask(dt.Key); ok { add(def.WorkflowKey, meta.Title, dt.Key) for _, alias := range meta.Aliases { add(def.WorkflowKey, alias, dt.Key) } } } } } for _, entry := range docScopeManualEntries { for _, p := range entry.phrases { add(entry.workflow, p, "") } if entry.reference != "" { def, err := workflowdef.LoadManifestDefinition(entry.workflow + ".v2.json") if err == nil { for _, st := range def.Stages { for _, dt := range st.DocTasks { if dt.Key == entry.reference { add(entry.workflow, dt.Title, dt.Key) } } } } } } phrases := make([]string, 0, len(idx.phraseToWorkflows)) for p := range idx.phraseToWorkflows { phrases = append(phrases, p) } sort.Slice(phrases, func(i, j int) bool { li := utf8.RuneCountInString(phrases[i]) lj := utf8.RuneCountInString(phrases[j]) if li != lj { return li > lj } return phrases[i] < phrases[j] }) idx.sortedPhrases = phrases return idx } func getDocWorkflowIndex() *docWorkflowIndex { docScopeOnce.Do(func() { docScopeIndex = buildDocWorkflowIndex() }) return docScopeIndex } // taskKeyWorkflowOwner 按任务键推断归属工作流(旧键与新键均可;未知返回空)。 func taskKeyWorkflowOwner(taskKey string) string { taskKey = strings.TrimSpace(taskKey) if taskKey == "" { return "" } idx := getDocWorkflowIndex() if wf, ok := idx.nameToKey[taskKey]; ok { return wf } if canonical := workflowdef.CanonicalTaskKey(taskKey); canonical != taskKey { if wf, ok := idx.nameToKey[canonical]; ok { return wf } } prefix := taskKey if i := strings.Index(taskKey, "_"); i > 0 { prefix = taskKey[:i] } return docScopeWorkflowAliases[prefix] } // docNameHit 记录文档短语命中的归属集合。 type docNameHit struct { Phrase string Workflows []string } // matchDocPhrasesForScope 在输入中查找命中的文档标题/别名短语(最长优先)。 // 长短语占用的字符区间不再参与更短短语匹配,防止「用户需求规格书」拆出「需求规格」。 func matchDocPhrasesForScope(input string) []docNameHit { idx := getDocWorkflowIndex() used := make([]bool, len(input)) var hits []docNameHit for _, phrase := range idx.sortedPhrases { for start := 0; start < len(input); { pos := strings.Index(input[start:], phrase) if pos < 0 { break } abs := start + pos overlap := false for k := abs; k < abs+len(phrase); k++ { if used[k] { overlap = true break } } for k := abs; k < abs+len(phrase); k++ { used[k] = true } if !overlap { hits = append(hits, docNameHit{Phrase: phrase, Workflows: idx.phraseToWorkflows[phrase]}) } start = abs + len(phrase) } } return hits } // docScopeForPhrase 返回某文档短语的归属工作流集合。 func docScopeForPhrase(phrase string) ([]string, bool) { idx := getDocWorkflowIndex() wfs, ok := idx.phraseToWorkflows[strings.TrimSpace(phrase)] return wfs, ok } // activeScopedTaskKey 同名文档分属多流时,返回当前激活流的任务键(执行键形态)。 func activeScopedTaskKey(phrase, activeWorkflow string) (string, bool) { if activeWorkflow == "" { return "", false } idx := getDocWorkflowIndex() byWF, ok := idx.phraseWorkflowTask[strings.TrimSpace(phrase)] if !ok { return "", false } key, ok := byWF[activeWorkflow] if !ok || key == "" { return "", false } return workflowdef.ImplementationTaskKey(key), true } // docWorkflowScopeMarker 检查输入是否显式点名了非激活工作流的受控文档。 // 返回 (他流文档标题, 归属工作流 key 列表, 是否命中跨流)。 // activeWorkflow 为空(存量单流 / planning 态)时一律不判跨流,保持现状行为。 func docWorkflowScopeMarker(input, activeWorkflow string) (string, []string, bool) { if activeWorkflow == "" { return "", nil, false } for _, hit := range matchDocPhrasesForScope(input) { owns := false for _, wf := range hit.Workflows { if wf == activeWorkflow { owns = true break } } if !owns { return hit.Phrase, hit.Workflows, true } } return "", nil, false } // markWorkflowScopeMismatch 在槽位上打跨流标记。 func markWorkflowScopeMismatch(slots map[string]string, doc string, workflows []string) { if slots == nil || doc == "" { return } slots[SlotWorkflowScopeMismatch] = "true" slots[SlotMismatchedDoc] = doc slots[SlotMismatchedWorkflow] = strings.Join(workflows, ",") } // scopeMismatchClarify 若槽位带跨流标记则返回引导话术,否则空串。 func scopeMismatchClarify(slots map[string]string, activeWorkflow string) string { if slots == nil || slots[SlotWorkflowScopeMismatch] != "true" { return "" } workflows := []string{} if v := slots[SlotMismatchedWorkflow]; v != "" { workflows = strings.Split(v, ",") } return workflowScopeGuidanceQuestion(slots[SlotMismatchedDoc], workflows, activeWorkflow) } // fillTargetDocSlot 按工作流作用域规则填充 target_doc 槽位: // 显式点名他流文档 → 不填 target_doc,改打 workflow_scope_mismatch 标记; // 否则保持原逻辑(显式名 → ActiveDocID 回退)。 func fillTargetDocSlot(slots map[string]string, input string, userCtx *ContextSnapshot) { if slots == nil { return } active := "" if userCtx != nil { active = userCtx.ActiveWorkflowKey } if doc, wfs, mismatched := docWorkflowScopeMarker(input, active); mismatched { markWorkflowScopeMismatch(slots, doc, wfs) return } if doc := extractDocName(input); doc != "" { slots[SlotTargetDoc] = doc return } if userCtx != nil && userCtx.ActiveDocID != "" { slots[SlotTargetDoc] = userCtx.ActiveDocID } } // WorkflowScopeGuidanceQuestion 生成「该文档属于 XX 工作流,请先切换」引导话术。 func WorkflowScopeGuidanceQuestion(doc string, workflows []string, activeWorkflow string) string { return workflowScopeGuidanceQuestion(doc, workflows, activeWorkflow) } // WorkflowDisplayName 返回工作流键的中文展示名。 func WorkflowDisplayName(key string) string { return workflowDisplayName(key) } // TaskKeyWorkflowOwner 按任务键推断归属工作流(旧键与新键均可;未知返回空)。 func TaskKeyWorkflowOwner(taskKey string) string { return taskKeyWorkflowOwner(taskKey) } // workflowScopeGuidanceQuestion 生成「该文档属于 XX 工作流,请先切换」引导话术。 func workflowScopeGuidanceQuestion(doc string, workflows []string, activeWorkflow string) string { names := make([]string, 0, len(workflows)) for _, wf := range workflows { names = append(names, workflowDisplayName(wf)) } owner := "其他工作流" if len(names) > 0 { owner = strings.Join(names, " / ") } return fmt.Sprintf( "您提到的文档「%s」属于【%s】,当前激活工作流为【%s】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。", doc, owner, workflowDisplayName(activeWorkflow), ) } func workflowDisplayName(key string) string { switch key { case "main": return "商务对接流程" case "product_manager": return "智能产品经理流程" case "test_manager": return "智能测试工程师流程" case "rdev": return "研发设计控制流程" case "software_bootstrap": return "软件项目自举流程" case "software_delivery": return "软件交付迭代流程" default: return key } } // activeWorkflowKeyOf 从上下文快照安全读取激活工作流键。 func activeWorkflowKeyOf(userCtx *ContextSnapshot) string { if userCtx == nil { return "" } return userCtx.ActiveWorkflowKey } // docGovActionKeywords 触发受控文档治理写动作的关键词。 var docGovActionKeywords = []string{ "驳回", "退回", "终审批准", "批准", "审核通过", "审阅通过", "评审通过", "准予", "认领", "送审", "提交审核", "发起审核", "提审", } func hasDocGovActionKeyword(input string) bool { for _, k := range docGovActionKeywords { if strings.Contains(input, k) { return true } } return false } // docGovScopePreempt 治理写动作显式点名他流文档时,前置剪枝为跨工作流拦截, // 不落入任何 DOCGOV_* 写意图。无激活工作流(存量单流 / planning 态)时不拦截。 func docGovScopePreempt(input string, userCtx *ContextSnapshot) *CandidateIntent { active := activeWorkflowKeyOf(userCtx) if active == "" || !hasDocGovActionKeyword(input) { return nil } doc, owners, mismatched := docWorkflowScopeMarker(input, active) if !mismatched { return nil } slots := make(map[string]string) markWorkflowScopeMismatch(slots, doc, owners) return &CandidateIntent{ Intent: IntentUnsupportedAction, Confidence: 0.94, Branch: BranchNewIntentPreempt, FilledSlots: slots, Reasoning: fmt.Sprintf("受控文档治理动作显式点名他流文档「%s」,前置剪枝为跨工作流拦截", doc), ClarificationQuestion: workflowScopeGuidanceQuestion(doc, owners, active), } } // docNameIDPattern 匹配显式文档 ID(doc-xxx 或 doc_projN_xxx)。 var docNameIDPattern = regexp.MustCompile(`(doc[_-][a-zA-Z0-9_-]+)`) // hasExplicitDocID 判断输入是否携带显式文档 ID(视为用户精确指向,交由消费端裁决)。 func hasExplicitDocID(input string) bool { return docNameIDPattern.FindString(input) != "" }