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