Files
Agent-System/internal/intent/doc_workflow_scope.go
T
weichenandCursor 0912efdbd9 feat(coordinator+server): narrow document match pool to active workflow
- ActiveWorkflowScope resolves live instance task keys for the project
- chat document reader matches inside the active pool; cross-flow hits
  return ErrWrongWorkflow with switch guidance
- enrichSessionWorkflowStage injects ActiveWorkflowKey into UserCtx
- TRIGGER_TASK (coordinator + task-run API) blocks out-of-scope task keys

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 08:05:44 +00:00

412 lines
13 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 (
"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) != ""
}