merge: fix/intent-workflow-scoping-2026-09-21
Scope intent slot filling and document matching to the active workflow; cross-workflow mentions are blocked with switch guidance. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+2
-2
@@ -13,7 +13,7 @@
|
||||
|
||||
| owner | task | branch | worktree | mutex | status |
|
||||
|-------|------|--------|----------|-------|--------|
|
||||
| Cursor | 2026-09-21-intent-workflow-scoping | fix/intent-workflow-scoping-2026-09-21 | -(迁移前旧任务,保留现场) | intent+web+docgov | in_progress |
|
||||
| Cursor | 2026-09-21-intent-workflow-scoping | fix/intent-workflow-scoping-2026-09-21 | - | intent+web+docgov | in_progress |
|
||||
| Codex | 2026-09-21-agent-workflow-governance-adoption | feature/agent-workflow-governance-plan | .worktrees/feature-agent-workflow-governance-plan | agent-workflow-governance | in_progress |
|
||||
| Codex | 2026-09-21-agent-workflow-governance-plan | feature/agent-workflow-governance-plan | .worktrees/feature-agent-workflow-governance-plan | agent-workflow-governance-plan | done |
|
||||
| Cursor | 2026-09-21-workflow-doc-key-unification | fix/workflow-doc-key-unification-2026-09-21 | .worktrees/fix-workflow-doc-key-unification-2026-09-21 | docgov+web | done |
|
||||
@@ -180,7 +180,7 @@
|
||||
|
||||
| holder | phase | since | scope | queue |
|
||||
|--------|-------|-------|-------|-------|
|
||||
| Codex | merging | 2026-09-21 | Agent worktree 规范采纳;仅文档候选集成,主目录旧任务现场保留;deployment=not_required | - |
|
||||
| Cursor | merging | 2026-09-21 | intent-workflow-scoping:合入 fix 分支并 rebuild 冒烟(接续 Codex 文档候选集成完成后) | - |
|
||||
|
||||
**phase**: `idle` | `merging` | `smoke` | `e2e`
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: "意图填槽工作流作用域修复"
|
||||
status: in_progress
|
||||
created: 2026-09-21
|
||||
updated: 2026-09-21
|
||||
owner: Cursor
|
||||
branch: fix/intent-workflow-scoping-2026-09-21
|
||||
worktree: "-"
|
||||
mutex: intent+web+docgov
|
||||
---
|
||||
|
||||
# 意图填槽工作流作用域修复
|
||||
|
||||
## 目标
|
||||
|
||||
意图识别填槽(target_doc / target_task)只命中当前激活工作流的文档与任务;显式点名其他工作流的文档时拦截并引导切换工作流,不误触、不执行。
|
||||
|
||||
## 背景
|
||||
|
||||
用户反馈:填「要修改的文档」等槽位时可能命中其他工作流的文档。根因链(2026-09-21 调研):
|
||||
|
||||
1. `intent.ContextSnapshot` 无 workflow 字段,`ToSnapshot()` 丢弃 `Project.ActiveWorkflowKey`;server 默认上下文硬编码 `CurrentWorkflow: "intake"`
|
||||
2. `extractDocName` 词表全是 main(bd_)流文档名,无按流归属约束
|
||||
3. 消费端匹配池 `ListWorkflowDocumentsByProject` 仅按 project_id 过滤(跨工作流全量),`selectReadableWorkflowDocument` 双向 contains 模糊匹配取首个命中
|
||||
4. 前端 `resolveTaskTemplateForMessage` 匹配池 `currentProjectDeliverables` 仅按 projectId 过滤;`activeWorkflowKey` 只用于徽标展示
|
||||
|
||||
## 范围
|
||||
|
||||
- 批次1 `feat(intent)`:`ContextSnapshot` 增加 `ActiveWorkflowKey`;`ToSnapshot` 下传;docgov 命中他流文档名时打 `workflow_scope_mismatch` 标记不误填
|
||||
- 批次2 `feat(coordinator+server+projectinstance)`:`ActiveWorkflowTaskKeys` 辅助查询;文档匹配池收窄到激活流;`ErrDocWrongWorkflow` 拦截引导;TRIGGER_TASK 任务键归属校验;enrich 时注入真实 `active_workflow_key`
|
||||
- 批次3 `feat(web)`:`DeliverableItem.workflowKey`;文档列表 API 附带 `workflow_key` 与 `active_workflow_key`;templateMatch 匹配池按激活流过滤,他流点名返回 wrongWorkflow 提示
|
||||
- 批次4:真实环境验收 + 测试报告 + 合回 dev
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不迁移 `document_records` 表(不加 workflow_instance_id 列)
|
||||
- 不改 `production_ready`、不晋升 test/main、不动项目 143
|
||||
- 不改 SWITCH_WORKFLOW 意图本身行为
|
||||
- UI 文档展示列表保持全量(仅匹配池收窄)
|
||||
|
||||
## 渐进安全策略
|
||||
|
||||
`active_workflow_key` 为空或查不到激活实例(存量单流项目、planning 态)→ 不过滤,保持现状行为。
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [ ] 批次1:intent 快照与工作流词表归属
|
||||
2. [ ] 批次2:服务端匹配池收窄与拦截引导
|
||||
3. [ ] 批次3:前端模板匹配池收窄
|
||||
4. [ ] 批次4:真实环境验收、报告归档、合回 dev
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 激活 main 流说「修改调研报告」→ 命中 main 流文档(正例)
|
||||
- [ ] 激活 main 流说「编写系统需求规格书」(pm/rd 流文档)→ 拦截 + 引导切换(反例)
|
||||
- [ ] 激活 pm/rdev 流后同名操作 → 正常执行(切换后正例)
|
||||
- [ ] 存量无 active_workflow_key 项目 → 行为不变(回归)
|
||||
- [ ] `go vet ./...`、`go test -short ./...`、`web: npm test && npm run build` 全绿
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Plan: `~/.cursor/plans/意图填槽工作流作用域修复_dba2ab4c.plan.md`
|
||||
- Handoff:
|
||||
- Test report: `.doc/test-reports/2026-09-21-intent-workflow-scoping-test-report.md`
|
||||
|
||||
## 证据 / 备注
|
||||
|
||||
用户在 dev 商务对接实测发现跨工作流误命中。用户在方案确认时选定:严格拦截 + 引导切换;等四问题修复 mutex 释放后独立分支推进。
|
||||
@@ -3,6 +3,7 @@ package coordinator
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -75,6 +76,24 @@ type ReadableDocument struct {
|
||||
// 协调层不得直接枚举文档,以免绕过阶段可见性规则。
|
||||
type DocumentSummaryReader func(ctx context.Context, target string) (*ReadableDocument, error)
|
||||
|
||||
// ErrWrongWorkflow 表示目标文档存在但不属于当前激活工作流。
|
||||
// 服务端文档读取器按激活流收窄匹配池后,用该错误携带「请先切换工作流」引导话术,
|
||||
// 协调层识别后原样回复,不执行任何读/写动作。
|
||||
type ErrWrongWorkflow struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ErrWrongWorkflow) Error() string { return e.Message }
|
||||
|
||||
// WrongWorkflowGuidance 若错误链上是跨工作流拦截,返回引导话术;否则空串。
|
||||
func WrongWorkflowGuidance(err error) string {
|
||||
var target *ErrWrongWorkflow
|
||||
if errors.As(err, &target) && target != nil && strings.TrimSpace(target.Message) != "" {
|
||||
return target.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// UserProjectContext 用户项目级全量统一上下文 (对话系统与执行系统共同消费)
|
||||
type UserProjectContext struct {
|
||||
mu sync.Mutex
|
||||
@@ -190,6 +209,7 @@ func (ctx *UserProjectContext) ToSnapshot() *intent.ContextSnapshot {
|
||||
SnapshotID: ctx.SnapshotID,
|
||||
ProjectName: ctx.Project.Name,
|
||||
CurrentStage: string(ctx.Project.CurrentStage),
|
||||
ActiveWorkflowKey: ctx.Project.ActiveWorkflowKey,
|
||||
AvailableActions: ctx.AvailableActions,
|
||||
PendingExpectation: ctx.PendingExpectation,
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ func (e *CoordinatorEngine) handleDocumentSummary(
|
||||
|
||||
doc, err := userCtx.DocumentSummaryReader(ctx, target)
|
||||
if err != nil || doc == nil {
|
||||
if guidance := WrongWorkflowGuidance(err); guidance != "" {
|
||||
return documentSummaryReply(userCtx, guidance, ReplySourceTemplate), nil
|
||||
}
|
||||
return documentSummaryReply(userCtx, "未找到可访问的目标文档,或您当前无权查看该阶段内容。请确认文档名称后重试。", ReplySourceTemplate), nil
|
||||
}
|
||||
if doc.IsTemplateOnly {
|
||||
|
||||
@@ -928,11 +928,50 @@ func (e *CoordinatorEngine) handleAdvanceStage(ctx context.Context, cand *intent
|
||||
}
|
||||
|
||||
func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
|
||||
// 跨工作流作用域:识别器已打 mismatch 标记时直接引导切换,不执行任务。
|
||||
if cand != nil && cand.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" {
|
||||
active := ""
|
||||
if userCtx != nil {
|
||||
active = userCtx.Project.ActiveWorkflowKey
|
||||
}
|
||||
guidance := cand.ClarificationQuestion
|
||||
if guidance == "" {
|
||||
workflows := []string{}
|
||||
if v := cand.FilledSlots[intent.SlotMismatchedWorkflow]; v != "" {
|
||||
workflows = strings.Split(v, ",")
|
||||
}
|
||||
guidance = intent.WorkflowScopeGuidanceQuestion(cand.FilledSlots[intent.SlotMismatchedDoc], workflows, active)
|
||||
}
|
||||
return &CoordinationResult{
|
||||
Status: StatusClarificationRequired,
|
||||
Intent: cand.Intent,
|
||||
Branch: cand.Branch,
|
||||
ClarificationPrompt: guidance,
|
||||
NaturalReply: guidance,
|
||||
UpdatedContext: userCtx,
|
||||
}, nil
|
||||
}
|
||||
|
||||
taskKey := cand.FilledSlots[intent.SlotTargetTask]
|
||||
if taskKey == "" {
|
||||
taskKey = "intake"
|
||||
}
|
||||
|
||||
// 消费端二次校验:任务键不属于激活工作流时拦截(防御识别器漏标)。
|
||||
if userCtx != nil && userCtx.Project.ActiveWorkflowKey != "" {
|
||||
if owner := intent.TaskKeyWorkflowOwner(taskKey); owner != "" && owner != userCtx.Project.ActiveWorkflowKey {
|
||||
guidance := intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, userCtx.Project.ActiveWorkflowKey)
|
||||
return &CoordinationResult{
|
||||
Status: StatusClarificationRequired,
|
||||
Intent: cand.Intent,
|
||||
Branch: cand.Branch,
|
||||
ClarificationPrompt: guidance,
|
||||
NaturalReply: guidance,
|
||||
UpdatedContext: userCtx,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
|
||||
userCtx.applyPending(nextExp)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ type WorkflowDocumentSummary struct {
|
||||
ProjectID int64 `json:"project_id"`
|
||||
TaskKey string `json:"task_key"`
|
||||
Title string `json:"title"`
|
||||
WorkflowKey string `json:"workflow_key,omitempty"`
|
||||
LifecycleState DocumentState `json:"lifecycle_state"`
|
||||
IsTemplateOnly bool `json:"is_template_only"`
|
||||
ContentSHA256 string `json:"content_sha256"`
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
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) != ""
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package intent_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/intent"
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------------
|
||||
// 工作流作用域填槽:target_doc / target_task 只命中激活工作流;跨流点名拦截引导
|
||||
// ----------------------------------------------------------------------------------
|
||||
|
||||
func TestWorkflowScope_TriggerTaskInActiveFlow(t *testing.T) {
|
||||
rec := intent.NewRecognizer()
|
||||
ctx := &intent.ContextSnapshot{SnapshotID: "s_bd", ActiveWorkflowKey: "main"}
|
||||
|
||||
// 激活商务流:调研报告属 main,正常填 task
|
||||
res, err := rec.Recognize(context.Background(), "帮我生成调研报告", ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.TopIntent.Intent != intent.IntentTriggerTask {
|
||||
t.Fatalf("want TRIGGER_TASK, got %s", res.TopIntent.Intent)
|
||||
}
|
||||
if got := res.TopIntent.FilledSlots[intent.SlotTargetTask]; got != "research" {
|
||||
t.Fatalf("in-scope research want research, got %q", got)
|
||||
}
|
||||
if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] != "" {
|
||||
t.Fatalf("in-scope task must not carry mismatch marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowScope_TriggerTaskCrossFlowBlocked(t *testing.T) {
|
||||
rec := intent.NewRecognizer()
|
||||
ctx := &intent.ContextSnapshot{SnapshotID: "s_bd2", ActiveWorkflowKey: "main"}
|
||||
|
||||
// 激活商务流却说「编写项目建议书」(研发流 rd_proposal)→ 拦截引导
|
||||
res, err := rec.Recognize(context.Background(), "编写项目建议书", ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slots := res.TopIntent.FilledSlots
|
||||
if slots[intent.SlotWorkflowScopeMismatch] != "true" {
|
||||
t.Fatalf("cross-flow write must be blocked, slots=%v", slots)
|
||||
}
|
||||
if slots[intent.SlotTargetTask] != "" || slots[intent.SlotTargetDoc] != "" {
|
||||
t.Fatalf("cross-flow must not fill target_doc/target_task, got %v", slots)
|
||||
}
|
||||
if !strings.Contains(res.TopIntent.ClarificationQuestion, "切换") {
|
||||
t.Fatalf("guidance should mention 切换, got %q", res.TopIntent.ClarificationQuestion)
|
||||
}
|
||||
if !strings.Contains(res.TopIntent.ClarificationQuestion, "研发设计控制流程") {
|
||||
t.Fatalf("guidance should name owning flow, got %q", res.TopIntent.ClarificationQuestion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowScope_TriggerTaskAfterSwitch(t *testing.T) {
|
||||
rec := intent.NewRecognizer()
|
||||
ctx := &intent.ContextSnapshot{SnapshotID: "s_rd", ActiveWorkflowKey: "rdev"}
|
||||
|
||||
// 激活研发流后同名操作 → 正常执行
|
||||
res, err := rec.Recognize(context.Background(), "编写项目建议书", ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.TopIntent.Intent != intent.IntentTriggerTask {
|
||||
t.Fatalf("want TRIGGER_TASK after switch, got %s", res.TopIntent.Intent)
|
||||
}
|
||||
if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" {
|
||||
t.Fatalf("must not block in owning flow, slots=%v", res.TopIntent.FilledSlots)
|
||||
}
|
||||
if got := res.TopIntent.FilledSlots[intent.SlotTargetTask]; got != "rd_proposal" {
|
||||
t.Fatalf("want rd_proposal, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowScope_DocGovWriteCrossFlowBlocked(t *testing.T) {
|
||||
rec := intent.NewRecognizer()
|
||||
ctx := &intent.ContextSnapshot{
|
||||
SnapshotID: "s_pm",
|
||||
ActiveWorkflowKey: "product_manager",
|
||||
UserDocRole: "reviewer",
|
||||
}
|
||||
|
||||
// 激活产品经理流,却驳回商务流文档「调研报告」→ 治理写动作被前置剪枝拦截
|
||||
res, err := rec.Recognize(context.Background(), "把调研报告审核驳回", ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.TopIntent.Intent == intent.IntentDocGovReviewReject {
|
||||
t.Fatalf("cross-flow governance reject must be blocked, got %s", res.TopIntent.Intent)
|
||||
}
|
||||
if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] != "true" {
|
||||
t.Fatalf("expected mismatch marker, slots=%v", res.TopIntent.FilledSlots)
|
||||
}
|
||||
if !res.RequiresClarifying {
|
||||
t.Fatalf("cross-flow block must require clarification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowScope_LegacyNoActiveWorkflowUnchanged(t *testing.T) {
|
||||
rec := intent.NewRecognizer()
|
||||
// 存量单流 / planning 态:无激活工作流 → 不过滤,行为与修复前一致
|
||||
ctx := &intent.ContextSnapshot{SnapshotID: "s_legacy"}
|
||||
|
||||
res, err := rec.Recognize(context.Background(), "编写项目建议书", ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" {
|
||||
t.Fatalf("no active workflow must never block, got %v", res.TopIntent.FilledSlots)
|
||||
}
|
||||
if res.TopIntent.Intent != intent.IntentTriggerTask {
|
||||
t.Fatalf("want TRIGGER_TASK passthrough, got %s", res.TopIntent.Intent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowScope_SnapshotCarriesActiveWorkflowKey(t *testing.T) {
|
||||
// ContextSnapshot 现在必须能携带激活工作流键,供消费端与识别器共同约束。
|
||||
snap := intent.ContextSnapshot{ActiveWorkflowKey: "main"}
|
||||
if snap.ActiveWorkflowKey != "main" {
|
||||
t.Fatalf("ActiveWorkflowKey must be settable, got %q", snap.ActiveWorkflowKey)
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,7 @@ func (r *RuleBasedRecognizer) recognizeCore(ctx context.Context, input string, u
|
||||
}
|
||||
|
||||
// 3.3 交付任务触发 (TRIGGER_TASK)
|
||||
if cand := matchTriggerTask(trimmed); cand != nil {
|
||||
if cand := matchTriggerTask(trimmed, userCtx); cand != nil {
|
||||
cand.Branch = BranchNewIntentPreempt
|
||||
candidates = append(candidates, *cand)
|
||||
}
|
||||
@@ -415,8 +415,16 @@ func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent
|
||||
// 0. 询问驳回原因:归属 DECISION_QUERY,不得路由为 DOCGOV_*_REJECT
|
||||
if (strings.Contains(input, "驳回") || strings.Contains(input, "退回")) && isRejectReasonQuery(input) {
|
||||
slots := make(map[string]string)
|
||||
if doc := resolveTargetDoc(input, userCtx); doc != "" {
|
||||
slots[SlotTargetDoc] = doc
|
||||
fillTargetDocSlot(slots, input, userCtx)
|
||||
if q := scopeMismatchClarify(slots, activeWorkflowKeyOf(userCtx)); q != "" {
|
||||
return &CandidateIntent{
|
||||
Intent: IntentQueryRejectReason,
|
||||
Confidence: 0.96,
|
||||
Branch: BranchNewIntentPreempt,
|
||||
FilledSlots: slots,
|
||||
Reasoning: "用户询问的受控文档属于非激活工作流,前置剪枝为只读询问并给出切换引导",
|
||||
ClarificationQuestion: q,
|
||||
}
|
||||
}
|
||||
return &CandidateIntent{
|
||||
Intent: IntentQueryRejectReason,
|
||||
@@ -426,6 +434,11 @@ func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent
|
||||
}
|
||||
}
|
||||
|
||||
// 跨工作流作用域前置剪枝:治理写动作显式点名他流文档时,拦截并引导切换,不落入任何 DOCGOV_* 写意图。
|
||||
if cand := docGovScopePreempt(input, userCtx); cand != nil {
|
||||
return cand
|
||||
}
|
||||
|
||||
// 1. 驳回 (Review Reject or Final Reject)
|
||||
if strings.Contains(input, "驳回") {
|
||||
isFinal := false
|
||||
@@ -1130,7 +1143,7 @@ func matchSupplementInfo(input string) *CandidateIntent {
|
||||
}
|
||||
|
||||
func matchQueryProgress(input string) *CandidateIntent {
|
||||
if matchTriggerTask(input) != nil {
|
||||
if matchTriggerTask(input, nil) != nil {
|
||||
return nil
|
||||
}
|
||||
if isWorkflowControlInput(input) || isRunControlInput(input) {
|
||||
@@ -1186,7 +1199,7 @@ func hasTaskWriteVerb(input string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func matchTriggerTask(input string) *CandidateIntent {
|
||||
func matchTriggerTask(input string, userCtx *ContextSnapshot) *CandidateIntent {
|
||||
type phrase struct {
|
||||
text string
|
||||
key string
|
||||
@@ -1224,16 +1237,64 @@ func matchTriggerTask(input string) *CandidateIntent {
|
||||
}
|
||||
// 「初始 / 首轮 / 摸底」是 V0.1 摸底,不能被更短的研发别名「用户需求」截走。
|
||||
if hasIntakeSemanticMarker(input) && strings.Contains(input, "需求") {
|
||||
if cand := scopedTriggerTaskCandidate(input, "需求表", "intake", userCtx); cand != nil {
|
||||
return cand
|
||||
}
|
||||
return triggerTaskCandidate("需求表", "intake")
|
||||
}
|
||||
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, "初始") ||
|
||||
|
||||
@@ -157,6 +157,11 @@ const (
|
||||
// 工作流槽位
|
||||
SlotTargetWorkflow = "target_workflow" // product_manager | test_manager | main
|
||||
|
||||
// 工作流作用域标记:填槽显式点名了非激活工作流的文档时打标引导切换,不误填 target_doc
|
||||
SlotWorkflowScopeMismatch = "workflow_scope_mismatch" // "true"
|
||||
SlotMismatchedWorkflow = "mismatched_workflow" // 文档归属工作流 key(多个用逗号连接)
|
||||
SlotMismatchedDoc = "mismatched_doc" // 被显式点名的他流文档标题
|
||||
|
||||
// 长任务/运行槽位
|
||||
SlotWorkDir = "work_dir"
|
||||
SlotRunAction = "run_action" // status | resume | restart
|
||||
@@ -194,6 +199,7 @@ type ContextSnapshot struct {
|
||||
SnapshotID string `json:"snapshot_id,omitempty"`
|
||||
ProjectName string `json:"project_name,omitempty"`
|
||||
CurrentStage string `json:"current_stage,omitempty"`
|
||||
ActiveWorkflowKey string `json:"active_workflow_key,omitempty"`
|
||||
ActiveDocID string `json:"active_doc_id,omitempty"`
|
||||
DocLifecycleState string `json:"doc_lifecycle_state,omitempty"`
|
||||
UserDocRole string `json:"user_doc_role,omitempty"`
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
)
|
||||
|
||||
// ActiveWorkflowScope 返回项目当前激活工作流的键与文档任务键集合。
|
||||
//
|
||||
// 语义(供意图填槽与文档正文读取收窄作用域):
|
||||
// - 从未激活(无 live 实例)→ workflowKey 为空、taskKeys 为 nil → 调用方不得过滤(legacy / planning)
|
||||
// - live 系实例 → workflowKey 取自 projects.active_workflow_key(回落实例快照定义键),
|
||||
// taskKeys 收集全部启用文档任务,并补齐旧键/新键双向别名
|
||||
// - completed / terminated 不属于激活:active_workflow_key 已在终止时清空,视为未激活
|
||||
// - 存储错误原样上抛(fail-closed)
|
||||
func (s *Service) ActiveWorkflowScope(ctx context.Context, projectID int64) (workflowKey string, taskKeys map[string]bool, err error) {
|
||||
if s == nil || s.store == nil || projectID <= 0 {
|
||||
return "", nil, nil
|
||||
}
|
||||
_, activeKey, _, _, err := s.store.GetProjectMeta(ctx, projectID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if activeKey == "" {
|
||||
// 未激活:不过滤,保持现状行为。
|
||||
return "", nil, nil
|
||||
}
|
||||
live, err := s.store.GetLiveInstance(ctx, projectID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if live == nil {
|
||||
// 有激活键但无 live 快照:仅返回键供话术,任务集合交由调用方按空处理(不过滤)。
|
||||
return activeKey, nil, nil
|
||||
}
|
||||
if live.ConfigSnapshot.WorkflowDefinitionKey != "" {
|
||||
workflowKey = live.ConfigSnapshot.WorkflowDefinitionKey
|
||||
} else {
|
||||
workflowKey = activeKey
|
||||
}
|
||||
taskKeys = make(map[string]bool)
|
||||
for _, st := range live.ConfigSnapshot.Stages {
|
||||
if !st.Enabled {
|
||||
continue
|
||||
}
|
||||
for _, dt := range st.DocTasks {
|
||||
if !dt.Enabled || dt.TaskKey == "" {
|
||||
continue
|
||||
}
|
||||
taskKeys[dt.TaskKey] = true
|
||||
// 补齐旧键/新键双向别名,冻结实例与发布清单互指。
|
||||
if canonical := workflowdef.CanonicalTaskKey(dt.TaskKey); canonical != dt.TaskKey {
|
||||
taskKeys[canonical] = true
|
||||
}
|
||||
if legacy := workflowdef.ImplementationTaskKey(dt.TaskKey); legacy != dt.TaskKey {
|
||||
taskKeys[legacy] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return workflowKey, taskKeys, nil
|
||||
}
|
||||
|
||||
// TaskKeyInScope 判断任务键是否落在激活工作流任务集合内。
|
||||
// taskKeys 为空(未激活 / 无快照)时一律放行,保持现状。
|
||||
func TaskKeyInScope(taskKeys map[string]bool, taskKey string) bool {
|
||||
if len(taskKeys) == 0 {
|
||||
return true
|
||||
}
|
||||
return mapHasTaskKey(taskKeys, taskKey)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package projectinstance_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/projectinstance"
|
||||
)
|
||||
|
||||
func TestActiveWorkflowScope_NoLiveInstanceReturnsEmpty(t *testing.T) {
|
||||
store := projectinstance.NewMemoryStore()
|
||||
if err := projectinstance.SeedProjectMeta(store, 99, projectinstance.LifecyclePlanning, "", 1, 0); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
svc := projectinstance.NewService(store, projectinstance.TestResolver(), nil, nil)
|
||||
key, tasks, err := svc.ActiveWorkflowScope(context.Background(), 99)
|
||||
if err != nil {
|
||||
t.Fatalf("scope: %v", err)
|
||||
}
|
||||
if key != "" || tasks != nil {
|
||||
t.Fatalf("unactivated project must return empty scope, got key=%q tasks=%v", key, tasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskKeyInScope_EmptyAllowsAll(t *testing.T) {
|
||||
if !projectinstance.TaskKeyInScope(nil, "anything") {
|
||||
t.Fatal("nil scope must allow")
|
||||
}
|
||||
if !projectinstance.TaskKeyInScope(map[string]bool{}, "anything") {
|
||||
t.Fatal("empty scope must allow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskKeyInScope_CanonicalAlias(t *testing.T) {
|
||||
scope := map[string]bool{"bd_research": true, "research": true}
|
||||
if !projectinstance.TaskKeyInScope(scope, "research") {
|
||||
t.Fatal("legacy alias research should be in scope")
|
||||
}
|
||||
if !projectinstance.TaskKeyInScope(scope, "bd_research") {
|
||||
t.Fatal("canonical bd_research should be in scope")
|
||||
}
|
||||
if projectinstance.TaskKeyInScope(scope, "rd_proposal") {
|
||||
t.Fatal("rd_proposal must be out of scope")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"chat-agent-system/internal/chat"
|
||||
"chat-agent-system/internal/coordinator"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/intent"
|
||||
"chat-agent-system/internal/projectinstance"
|
||||
)
|
||||
|
||||
// bindSessionDocumentSummaryReader 将正文访问绑定到已认证的用户与项目。
|
||||
@@ -40,7 +42,13 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth
|
||||
return nil, err
|
||||
}
|
||||
docs = s.filterWorkflowDocsByStageAccess(ctx, projectID, userID, docs)
|
||||
doc, err := selectReadableWorkflowDocument(docs, target)
|
||||
// 工作流作用域:仅在激活工作流的文档池内解析正文;跨流点名返回可解释的引导错误。
|
||||
// 激活键由 enrichSessionWorkflowStage 每轮刷新;任务集合按 live 实例快照解析。
|
||||
workflowKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(ctx, projectID)
|
||||
if sess.UserCtx != nil && sess.UserCtx.Project.ActiveWorkflowKey != "" {
|
||||
workflowKey = sess.UserCtx.Project.ActiveWorkflowKey
|
||||
}
|
||||
doc, err := selectReadableWorkflowDocument(docs, target, workflowKey, scopeTaskKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -60,21 +68,163 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth
|
||||
}
|
||||
}
|
||||
|
||||
func selectReadableWorkflowDocument(docs []docgov.WorkflowDocumentSummary, target string) (*docgov.WorkflowDocumentSummary, error) {
|
||||
// activeWorkflowScopeForDocs 读取项目激活工作流键与任务集合;失败时返回空(= 不过滤)。
|
||||
func (s *Server) activeWorkflowScopeForDocs(ctx context.Context, projectID int64) (string, map[string]bool) {
|
||||
if s == nil || s.instSvc == nil || !s.instSvc.Available() || projectID <= 0 {
|
||||
return "", nil
|
||||
}
|
||||
workflowKey, taskKeys, err := s.instSvc.ActiveWorkflowScope(ctx, projectID)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
return workflowKey, taskKeys
|
||||
}
|
||||
|
||||
// taskKeyOutsideActiveWorkflow 判断任务键是否落在激活工作流之外。
|
||||
// 未激活 / 任务键未知归属时放行(存量兼容);跨流时返回引导话术。
|
||||
func (s *Server) taskKeyOutsideActiveWorkflow(ctx context.Context, projectID int64, taskKey string) (bool, string) {
|
||||
taskKey = strings.TrimSpace(taskKey)
|
||||
if taskKey == "" || projectID <= 0 {
|
||||
return false, ""
|
||||
}
|
||||
workflowKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(ctx, projectID)
|
||||
if workflowKey == "" {
|
||||
return false, ""
|
||||
}
|
||||
if len(scopeTaskKeys) > 0 {
|
||||
if projectinstance.TaskKeyInScope(scopeTaskKeys, taskKey) {
|
||||
return false, ""
|
||||
}
|
||||
owner := intent.TaskKeyWorkflowOwner(taskKey)
|
||||
if owner == "" || owner == workflowKey {
|
||||
return false, ""
|
||||
}
|
||||
return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey)
|
||||
}
|
||||
owner := intent.TaskKeyWorkflowOwner(taskKey)
|
||||
if owner == "" || owner == workflowKey {
|
||||
return false, ""
|
||||
}
|
||||
return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey)
|
||||
}
|
||||
|
||||
// workflowKeyForDocument 为列表项标注归属工作流:优先激活流任务集合,其次任务键前缀推断。
|
||||
func workflowKeyForDocument(taskKey, activeKey string, scopeTaskKeys map[string]bool) string {
|
||||
if activeKey != "" && len(scopeTaskKeys) > 0 && projectinstance.TaskKeyInScope(scopeTaskKeys, taskKey) {
|
||||
return activeKey
|
||||
}
|
||||
if owner := intent.TaskKeyWorkflowOwner(taskKey); owner != "" {
|
||||
return owner
|
||||
}
|
||||
return activeKey
|
||||
}
|
||||
|
||||
// errDocWrongWorkflow 目标文档存在但不属于当前激活工作流。
|
||||
type errDocWrongWorkflow struct {
|
||||
Title string
|
||||
TaskKey string
|
||||
OwnerWorkflow string
|
||||
ActiveWorkflow string
|
||||
}
|
||||
|
||||
func (e *errDocWrongWorkflow) Error() string {
|
||||
if e == nil {
|
||||
return "document belongs to another workflow"
|
||||
}
|
||||
owners := []string{}
|
||||
if e.OwnerWorkflow != "" {
|
||||
owners = []string{e.OwnerWorkflow}
|
||||
}
|
||||
doc := e.Title
|
||||
if doc == "" {
|
||||
doc = e.TaskKey
|
||||
}
|
||||
return intent.WorkflowScopeGuidanceQuestion(doc, owners, e.ActiveWorkflow)
|
||||
}
|
||||
|
||||
func (e *errDocWrongWorkflow) AsCoordinatorErr() error {
|
||||
return &coordinator.ErrWrongWorkflow{Message: e.Error()}
|
||||
}
|
||||
|
||||
// selectReadableWorkflowDocument 在可读文档池中解析目标。
|
||||
// scopeTaskKeys 非空时先在激活流池内匹配;未命中但精确/归一化匹配到被剔除的他流文档时返回 ErrWrongWorkflow。
|
||||
// scopeTaskKeys 为空(未激活 / planning)时保持现状:在全量池内匹配。
|
||||
func selectReadableWorkflowDocument(
|
||||
docs []docgov.WorkflowDocumentSummary,
|
||||
target string,
|
||||
activeWorkflow string,
|
||||
scopeTaskKeys map[string]bool,
|
||||
) (*docgov.WorkflowDocumentSummary, error) {
|
||||
if len(docs) == 0 {
|
||||
return nil, fmt.Errorf("no readable workflow documents")
|
||||
}
|
||||
targetKey := normalizeDocumentLookupKey(target)
|
||||
if targetKey == "" {
|
||||
if len(docs) == 1 {
|
||||
return &docs[0], nil
|
||||
inScope := filterDocsByTaskKeys(docs, scopeTaskKeys)
|
||||
pool := inScope
|
||||
if len(scopeTaskKeys) == 0 {
|
||||
pool = docs
|
||||
}
|
||||
if len(pool) == 1 {
|
||||
return &pool[0], nil
|
||||
}
|
||||
return nil, fmt.Errorf("document target required")
|
||||
}
|
||||
|
||||
inScope := filterDocsByTaskKeys(docs, scopeTaskKeys)
|
||||
pool := inScope
|
||||
if len(scopeTaskKeys) == 0 {
|
||||
pool = docs
|
||||
}
|
||||
if hit := matchDocumentInPool(pool, target, targetKey); hit != nil {
|
||||
return hit, nil
|
||||
}
|
||||
// 激活流池未命中:检查是否精确/归一化命中被剔除的他流文档。
|
||||
if len(scopeTaskKeys) > 0 {
|
||||
outOfScope := filterDocsOutsideTaskKeys(docs, scopeTaskKeys)
|
||||
if hit := matchDocumentInPool(outOfScope, target, targetKey); hit != nil {
|
||||
owner := intent.TaskKeyWorkflowOwner(hit.TaskKey)
|
||||
return nil, (&errDocWrongWorkflow{
|
||||
Title: hit.Title,
|
||||
TaskKey: hit.TaskKey,
|
||||
OwnerWorkflow: owner,
|
||||
ActiveWorkflow: activeWorkflow,
|
||||
}).AsCoordinatorErr()
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("document not found in readable scope")
|
||||
}
|
||||
|
||||
func filterDocsByTaskKeys(docs []docgov.WorkflowDocumentSummary, taskKeys map[string]bool) []docgov.WorkflowDocumentSummary {
|
||||
if len(taskKeys) == 0 {
|
||||
return docs
|
||||
}
|
||||
out := make([]docgov.WorkflowDocumentSummary, 0, len(docs))
|
||||
for _, d := range docs {
|
||||
if projectinstance.TaskKeyInScope(taskKeys, d.TaskKey) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterDocsOutsideTaskKeys(docs []docgov.WorkflowDocumentSummary, taskKeys map[string]bool) []docgov.WorkflowDocumentSummary {
|
||||
if len(taskKeys) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]docgov.WorkflowDocumentSummary, 0)
|
||||
for _, d := range docs {
|
||||
if !projectinstance.TaskKeyInScope(taskKeys, d.TaskKey) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func matchDocumentInPool(docs []docgov.WorkflowDocumentSummary, target, targetKey string) *docgov.WorkflowDocumentSummary {
|
||||
for i := range docs {
|
||||
if target == docs[i].DocID || target == docs[i].TaskKey || target == docs[i].Title {
|
||||
return &docs[i], nil
|
||||
return &docs[i]
|
||||
}
|
||||
}
|
||||
for i := range docs {
|
||||
@@ -85,11 +235,11 @@ func selectReadableWorkflowDocument(docs []docgov.WorkflowDocumentSummary, targe
|
||||
}
|
||||
for _, key := range keys {
|
||||
if key != "" && (key == targetKey || strings.Contains(key, targetKey) || strings.Contains(targetKey, key)) {
|
||||
return &docs[i], nil
|
||||
return &docs[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("document not found in readable scope")
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeDocumentLookupKey(value string) string {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/coordinator"
|
||||
"chat-agent-system/internal/docgov"
|
||||
)
|
||||
|
||||
@@ -11,7 +14,7 @@ func TestSelectReadableWorkflowDocumentMatchesInitialRequirementsAlias(t *testin
|
||||
{DocID: "doc_proj143_intake", TaskKey: "intake", Title: "用户初始需求收集表"},
|
||||
{DocID: "doc_proj143_research", TaskKey: "research", Title: "技术与市场调研报告"},
|
||||
}
|
||||
doc, err := selectReadableWorkflowDocument(docs, "用户初始需求表")
|
||||
doc, err := selectReadableWorkflowDocument(docs, "用户初始需求表", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
@@ -25,7 +28,48 @@ func TestSelectReadableWorkflowDocumentDoesNotGuessWhenTargetMissing(t *testing.
|
||||
{DocID: "doc_a", TaskKey: "intake", Title: "用户初始需求收集表"},
|
||||
{DocID: "doc_b", TaskKey: "research", Title: "调研报告"},
|
||||
}
|
||||
if _, err := selectReadableWorkflowDocument(docs, ""); err == nil {
|
||||
if _, err := selectReadableWorkflowDocument(docs, "", "", nil); err == nil {
|
||||
t.Fatal("expected target-required error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectReadableWorkflowDocumentScopesToActiveWorkflow(t *testing.T) {
|
||||
docs := []docgov.WorkflowDocumentSummary{
|
||||
{DocID: "doc_proj1_research", TaskKey: "bd_research", Title: "调研报告"},
|
||||
{DocID: "doc_proj1_proposal", TaskKey: "rd_proposal", Title: "项目建议书"},
|
||||
}
|
||||
scope := map[string]bool{"bd_research": true, "research": true}
|
||||
|
||||
doc, err := selectReadableWorkflowDocument(docs, "调研报告", "main", scope)
|
||||
if err != nil {
|
||||
t.Fatalf("in-scope select: %v", err)
|
||||
}
|
||||
if doc.TaskKey != "bd_research" {
|
||||
t.Fatalf("want bd_research, got %s", doc.TaskKey)
|
||||
}
|
||||
|
||||
_, err = selectReadableWorkflowDocument(docs, "项目建议书", "main", scope)
|
||||
if err == nil {
|
||||
t.Fatal("expected wrong-workflow error")
|
||||
}
|
||||
var wrong *coordinator.ErrWrongWorkflow
|
||||
if !errors.As(err, &wrong) {
|
||||
t.Fatalf("want ErrWrongWorkflow, got %T %v", err, err)
|
||||
}
|
||||
if !strings.Contains(wrong.Message, "切换") {
|
||||
t.Fatalf("guidance should mention 切换, got %q", wrong.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectReadableWorkflowDocumentNoScopeKeepsLegacy(t *testing.T) {
|
||||
docs := []docgov.WorkflowDocumentSummary{
|
||||
{DocID: "doc_proj1_proposal", TaskKey: "rd_proposal", Title: "项目建议书"},
|
||||
}
|
||||
doc, err := selectReadableWorkflowDocument(docs, "项目建议书", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy passthrough: %v", err)
|
||||
}
|
||||
if doc.TaskKey != "rd_proposal" {
|
||||
t.Fatalf("want rd_proposal, got %s", doc.TaskKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +290,14 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// 工作流作用域:任务键不属于当前激活工作流时拦截并引导切换(不执行)。
|
||||
if blocked, guidance := s.taskKeyOutsideActiveWorkflow(r.Context(), req.ProjectID, req.TaskKey); blocked {
|
||||
writeJSONResponse(w, http.StatusConflict, map[string]string{
|
||||
"error_code": "ERR_DOC_WRONG_WORKFLOW",
|
||||
"message": guidance,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 0. 如果关联了项目,在 projectworkflow 服务中记录运行实例
|
||||
|
||||
@@ -95,7 +95,14 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht
|
||||
return
|
||||
}
|
||||
docs = s.filterWorkflowDocsByStageAccess(r.Context(), projectID, user.ID, docs)
|
||||
writeJSONResponse(w, http.StatusOK, docs)
|
||||
activeKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(r.Context(), projectID)
|
||||
for i := range docs {
|
||||
docs[i].WorkflowKey = workflowKeyForDocument(docs[i].TaskKey, activeKey, scopeTaskKeys)
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, map[string]any{
|
||||
"documents": docs,
|
||||
"active_workflow_key": activeKey,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDocGovGetDocumentContent 读取受控文档 Markdown 正文。
|
||||
|
||||
@@ -145,6 +145,17 @@ func (s *Server) enrichSessionWorkflowStage(ctx context.Context, sess *chat.Sess
|
||||
if err == nil && settings != nil {
|
||||
sess.ActiveStageKey = settings.ActiveStageKey
|
||||
}
|
||||
// 工作流作用域:把项目激活工作流注入会话上下文,供 L1 填槽与消费端收窄。
|
||||
// 查询失败按空处理(= 不过滤),不得沿用上一轮的过期键。
|
||||
if sess.UserCtx != nil {
|
||||
if err == nil && settings != nil {
|
||||
sess.UserCtx.Project.ActiveWorkflowKey = settings.ActiveWorkflowKey
|
||||
} else {
|
||||
sess.UserCtx.Project.ActiveWorkflowKey = ""
|
||||
}
|
||||
}
|
||||
} else if sess.UserCtx != nil {
|
||||
sess.UserCtx.Project.ActiveWorkflowKey = ""
|
||||
}
|
||||
scope, err := s.resolveChatMessageScope(ctx, sess.Project(), sess.ActiveStageKey)
|
||||
if err == nil && scope != nil {
|
||||
|
||||
@@ -1185,6 +1185,7 @@ export default function App() {
|
||||
deliverables,
|
||||
visibleDeliverables,
|
||||
currentProjectDeliverables,
|
||||
activeWorkflowKey: currentWfState.activeKey || undefined,
|
||||
deliverablesRef,
|
||||
activeDocIdRef,
|
||||
editorHydrationGateRef,
|
||||
@@ -1383,6 +1384,7 @@ export default function App() {
|
||||
deliverables,
|
||||
visibleDeliverables,
|
||||
currentProjectDeliverables,
|
||||
activeWorkflowKey: currentWfState.activeKey || undefined,
|
||||
sessions,
|
||||
setDeliverables,
|
||||
setDocCache,
|
||||
|
||||
@@ -7,6 +7,7 @@ export type WorkflowDocumentSummary = {
|
||||
project_id: number;
|
||||
task_key: string;
|
||||
title: string;
|
||||
workflow_key?: string;
|
||||
lifecycle_state: string;
|
||||
is_template_only: boolean;
|
||||
content_sha256?: string;
|
||||
@@ -26,6 +27,11 @@ export type WorkflowDocumentSummary = {
|
||||
duty_policy?: string;
|
||||
};
|
||||
|
||||
export type ProjectWorkflowDocumentsResponse = {
|
||||
documents: WorkflowDocumentSummary[];
|
||||
active_workflow_key?: string;
|
||||
};
|
||||
|
||||
export type ControlledDocConsistency =
|
||||
| 'aligned'
|
||||
| 'missing'
|
||||
@@ -55,7 +61,30 @@ export async function listProjectWorkflowDocuments(
|
||||
throw new Error(`list project workflow documents failed: ${resp.status}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
// 兼容旧数组响应与新 { documents, active_workflow_key } 包装。
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && Array.isArray(data.documents)) return data.documents;
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function listProjectWorkflowDocumentsDetailed(
|
||||
projectId: number,
|
||||
): Promise<ProjectWorkflowDocumentsResponse> {
|
||||
const resp = await fetch(
|
||||
`/api/v1/projects/${projectId}/documents?category=workflow`,
|
||||
{ headers: getBearerAuthHeaders() },
|
||||
);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`list project workflow documents failed: ${resp.status}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (Array.isArray(data)) {
|
||||
return { documents: data };
|
||||
}
|
||||
return {
|
||||
documents: Array.isArray(data?.documents) ? data.documents : [],
|
||||
active_workflow_key: typeof data?.active_workflow_key === 'string' ? data.active_workflow_key : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export class ControlledContentRequestError extends Error {
|
||||
|
||||
@@ -266,3 +266,46 @@ test('word-order variants of the intake sheet stay on intake, not the shorter UR
|
||||
}
|
||||
assert.equal(resolveTaskTemplateForMessage('请更新用户需求表 URS', pool)?.taskKey, 'bd_user_requirements');
|
||||
});
|
||||
|
||||
test('resolveTaskTemplateForMessage blocks cross-workflow document mentions', () => {
|
||||
const mainResearch: DeliverableItem = {
|
||||
...base,
|
||||
taskKey: 'bd_research',
|
||||
title: '调研报告',
|
||||
workflowKey: 'main',
|
||||
};
|
||||
const rdevProposal: DeliverableItem = {
|
||||
...base,
|
||||
taskKey: 'rd_proposal',
|
||||
title: '项目建议书',
|
||||
workflowKey: 'rdev',
|
||||
isTemplateOnly: true,
|
||||
};
|
||||
const pool = [mainResearch, rdevProposal];
|
||||
|
||||
const blocked = resolveTaskTemplateForMessage('编写项目建议书', pool, null, 'main');
|
||||
assert.ok(blocked?.wrongWorkflow);
|
||||
assert.equal(blocked?.wrongWorkflow?.ownerWorkflow, 'rdev');
|
||||
assert.equal(blocked?.taskKey, '');
|
||||
|
||||
const allowed = resolveTaskTemplateForMessage('编写项目建议书', pool, null, 'rdev');
|
||||
assert.equal(allowed?.wrongWorkflow, undefined);
|
||||
assert.equal(allowed?.taskKey, 'rd_proposal');
|
||||
|
||||
const legacy = resolveTaskTemplateForMessage('编写项目建议书', pool, null, undefined);
|
||||
assert.equal(legacy?.wrongWorkflow, undefined);
|
||||
assert.equal(legacy?.taskKey, 'rd_proposal');
|
||||
});
|
||||
|
||||
test('resolveTaskTemplateForMessage keeps in-scope main docs when active is main', () => {
|
||||
const research: DeliverableItem = {
|
||||
...base,
|
||||
taskKey: 'research',
|
||||
title: '调研报告',
|
||||
workflowKey: 'main',
|
||||
isTemplateOnly: true,
|
||||
};
|
||||
const resolved = resolveTaskTemplateForMessage('生成调研报告', [research], null, 'main');
|
||||
assert.equal(resolved?.wrongWorkflow, undefined);
|
||||
assert.equal(resolved?.taskKey, 'research');
|
||||
});
|
||||
|
||||
@@ -24,12 +24,83 @@ export type ResolvedDocumentTask = {
|
||||
template?: DeliverableItem;
|
||||
docId?: string;
|
||||
pinnedToActiveDoc?: boolean;
|
||||
/** 显式点名了非激活工作流的文档时填充;此时不应启动任务。 */
|
||||
wrongWorkflow?: {
|
||||
docTitle: string;
|
||||
ownerWorkflow: string;
|
||||
activeWorkflow: string;
|
||||
};
|
||||
};
|
||||
|
||||
const WORKFLOW_LABELS: Record<string, string> = {
|
||||
main: '商务对接流程',
|
||||
product_manager: '智能产品经理流程',
|
||||
test_manager: '智能测试工程师流程',
|
||||
rdev: '研发设计控制流程',
|
||||
software_bootstrap: '软件项目自举流程',
|
||||
software_delivery: '软件交付迭代流程',
|
||||
};
|
||||
|
||||
/** 交付物池外的他流文档别名(激活 main 却点名研发/产品经理文档时用于拦截)。 */
|
||||
const CROSS_WORKFLOW_ALIASES: { workflow: string; patterns: RegExp[] }[] = [
|
||||
{ workflow: 'rdev', patterns: [/项目建议书/, /设计开发计划任务书/, /系统设计方案/, /用户需求规格书/] },
|
||||
{ workflow: 'product_manager', patterns: [/系统需求规格书/] },
|
||||
{ workflow: 'test_manager', patterns: [/系统验证方案及计划/, /系统验证方案/] },
|
||||
];
|
||||
|
||||
export function workflowDisplayLabel(key: string): string {
|
||||
return WORKFLOW_LABELS[key] || key;
|
||||
}
|
||||
|
||||
function templatePool(deliverables: DeliverableItem[]): DeliverableItem[] {
|
||||
return deliverables.filter((d) => d.isTemplateOnly);
|
||||
}
|
||||
|
||||
/** 激活流匹配池:无激活键或文档未标注 workflowKey 时保持全量(存量兼容)。 */
|
||||
function scopedDeliverables(
|
||||
deliverables: DeliverableItem[],
|
||||
activeWorkflowKey?: string,
|
||||
): DeliverableItem[] {
|
||||
if (!activeWorkflowKey) return deliverables;
|
||||
return deliverables.filter((d) => !d.workflowKey || d.workflowKey === activeWorkflowKey);
|
||||
}
|
||||
|
||||
function detectWrongWorkflowMention(
|
||||
text: string,
|
||||
deliverables: DeliverableItem[],
|
||||
activeWorkflowKey: string,
|
||||
): ResolvedDocumentTask['wrongWorkflow'] | undefined {
|
||||
const outside = deliverables
|
||||
.filter((d) => d.workflowKey && d.workflowKey !== activeWorkflowKey && d.title?.trim())
|
||||
.sort((a, b) => (b.title?.length || 0) - (a.title?.length || 0));
|
||||
for (const d of outside) {
|
||||
if (text.includes(d.title)) {
|
||||
return {
|
||||
docTitle: d.title,
|
||||
ownerWorkflow: d.workflowKey!,
|
||||
activeWorkflow: activeWorkflowKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
for (const alias of CROSS_WORKFLOW_ALIASES) {
|
||||
if (alias.workflow === activeWorkflowKey) continue;
|
||||
for (const p of alias.patterns) {
|
||||
if (p.test(text)) {
|
||||
return {
|
||||
docTitle: text.match(p)?.[0] || alias.patterns[0].source,
|
||||
ownerWorkflow: alias.workflow,
|
||||
activeWorkflow: activeWorkflowKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function wrongWorkflowGuidance(info: NonNullable<ResolvedDocumentTask['wrongWorkflow']>): string {
|
||||
return `您提到的文档「${info.docTitle}」属于【${workflowDisplayLabel(info.ownerWorkflow)}】,当前激活工作流为【${workflowDisplayLabel(info.activeWorkflow)}】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。`;
|
||||
}
|
||||
|
||||
/** 只读动词优先;无明确写动作则不启动文档任务。 */
|
||||
export function hasDocumentWriteIntent(text: string): boolean {
|
||||
const normalized = text.trim();
|
||||
@@ -226,24 +297,35 @@ export function matchTemplateDeliverable(
|
||||
* 合并文案解析与模板匹配。
|
||||
* 指示「当前受控文档」且 active 为已注册 doc_proj 时,钉住该文档 taskKey/docId,
|
||||
* 不被正文领域关键词改路由;显式点名另一交付物仍可切换。
|
||||
* activeWorkflowKey 非空时:匹配池收窄到激活流;显式点名他流文档返回 wrongWorkflow。
|
||||
*/
|
||||
export function resolveTaskTemplateForMessage(
|
||||
text: string,
|
||||
deliverables: DeliverableItem[],
|
||||
activeDoc?: DeliverableItem | null,
|
||||
activeWorkflowKey?: string,
|
||||
): ResolvedDocumentTask | undefined {
|
||||
if (!hasDocumentWriteIntent(text)) return undefined;
|
||||
|
||||
if (activeWorkflowKey) {
|
||||
const wrong = detectWrongWorkflowMention(text, deliverables, activeWorkflowKey);
|
||||
if (wrong) {
|
||||
return { taskKey: '', wrongWorkflow: wrong };
|
||||
}
|
||||
}
|
||||
|
||||
const scoped = scopedDeliverables(deliverables, activeWorkflowKey);
|
||||
|
||||
if (refersToCurrentControlledDocument(text) && isServerRegisteredControlledDoc(activeDoc)) {
|
||||
const pinnedKey = consistentTaskKeyForControlledDoc(activeDoc!);
|
||||
const named = explicitNamedDeliverableTaskKey(text, deliverables, pinnedKey);
|
||||
const named = explicitNamedDeliverableTaskKey(text, scoped, pinnedKey);
|
||||
if (named) {
|
||||
const taskKey = normalizeTaskKeyToProject(named, deliverables);
|
||||
const template = matchTemplateDeliverableByKey(taskKey, deliverables, text);
|
||||
const taskKey = normalizeTaskKeyToProject(named, scoped);
|
||||
const template = matchTemplateDeliverableByKey(taskKey, scoped, text);
|
||||
return { taskKey, template };
|
||||
}
|
||||
if (pinnedKey) {
|
||||
const template = matchTemplateDeliverableByKey(pinnedKey, deliverables, text);
|
||||
const template = matchTemplateDeliverableByKey(pinnedKey, scoped, text);
|
||||
return {
|
||||
taskKey: pinnedKey,
|
||||
template,
|
||||
@@ -254,22 +336,22 @@ export function resolveTaskTemplateForMessage(
|
||||
}
|
||||
|
||||
if (isRequirementsTopic(text) && hasIntakeSemanticMarker(text)) {
|
||||
const taskKey = normalizeTaskKeyToProject('intake', deliverables);
|
||||
const template = matchTemplateDeliverableByKey(taskKey, deliverables, text);
|
||||
const taskKey = normalizeTaskKeyToProject('intake', scoped);
|
||||
const template = matchTemplateDeliverableByKey(taskKey, scoped, text);
|
||||
return { taskKey, template };
|
||||
}
|
||||
|
||||
const named = explicitNamedDeliverableTaskKey(text, deliverables);
|
||||
const named = explicitNamedDeliverableTaskKey(text, scoped);
|
||||
if (named) {
|
||||
const taskKey = normalizeTaskKeyToProject(named, deliverables);
|
||||
const template = matchTemplateDeliverableByKey(taskKey, deliverables, text);
|
||||
const taskKey = normalizeTaskKeyToProject(named, scoped);
|
||||
const template = matchTemplateDeliverableByKey(taskKey, scoped, text);
|
||||
return { taskKey, template };
|
||||
}
|
||||
|
||||
const rawKey = resolveTaskKeyFromMessage(text);
|
||||
if (!rawKey) return undefined;
|
||||
const taskKey = normalizeTaskKeyToProject(rawKey, deliverables);
|
||||
const template = matchTemplateDeliverable(text, deliverables);
|
||||
const taskKey = normalizeTaskKeyToProject(rawKey, scoped);
|
||||
const template = matchTemplateDeliverable(text, scoped);
|
||||
return { taskKey, template };
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
isAffirmativeResponse,
|
||||
} from '../engine/cardActionEngine';
|
||||
import { markdownToBlockAST } from '../engine/markdownToBlock';
|
||||
import { resolveTaskTemplateForMessage } from '../engine/templateMatch';
|
||||
import { resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch';
|
||||
import { createClientTurnId } from '../lib/createClientTurnId';
|
||||
import { canonicalDocIdForTask } from '../lib/controlledDocHydrate';
|
||||
import { holdsDocDuty } from '../lib/docGovActionVisibility';
|
||||
@@ -95,6 +95,8 @@ export interface SendMessageInput {
|
||||
deliverables: DeliverableItem[];
|
||||
visibleDeliverables: DeliverableItem[];
|
||||
currentProjectDeliverables: DeliverableItem[];
|
||||
/** 当前项目激活工作流键;用于文档任务匹配池收窄。 */
|
||||
activeWorkflowKey?: string;
|
||||
sessions: ProjectSession[];
|
||||
setDeliverables: Dispatch<SetStateAction<DeliverableItem[]>>;
|
||||
setDocCache: Dispatch<SetStateAction<Record<string, DocumentAST>>>;
|
||||
@@ -131,6 +133,7 @@ export function useSendMessage(input: SendMessageInput) {
|
||||
deliverables,
|
||||
visibleDeliverables,
|
||||
currentProjectDeliverables,
|
||||
activeWorkflowKey,
|
||||
sessions,
|
||||
setDeliverables,
|
||||
setDocCache,
|
||||
@@ -309,11 +312,27 @@ export function useSendMessage(input: SendMessageInput) {
|
||||
text,
|
||||
currentProjectDeliverables,
|
||||
sendActiveDoc,
|
||||
activeWorkflowKey,
|
||||
);
|
||||
if (resolvedTask?.wrongWorkflow) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: 'ast-wrong-wf-' + Date.now(),
|
||||
sessionId: activeSessionId,
|
||||
role: 'assistant',
|
||||
authorName: '项目助手',
|
||||
content: wrongWorkflowGuidance(resolvedTask.wrongWorkflow!),
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
status: 'completed',
|
||||
},
|
||||
]);
|
||||
return { accepted: true };
|
||||
}
|
||||
const isMockShortcut = isGenDocShortcut || isRejectShortcut || isModifyShortcut || isReviewPassShortcut;
|
||||
|
||||
// P2:模板命中的长任务走真实后端 task stream(等待态 + PhaseEventStrip live + 分型恢复)
|
||||
if (resolvedTask) {
|
||||
if (resolvedTask?.taskKey) {
|
||||
const asstId = 'ast-' + Date.now();
|
||||
const currentProj = sessions.find((s) => s.sessionId === activeSessionId);
|
||||
// 项目五要素暂取会话标题/设备类型;客户名沿用 bizdev-cli 演示默认值,五要素完整建模留待后续
|
||||
|
||||
@@ -11,7 +11,7 @@ import { listProjectWorkflowDocuments } from '../api/projectDocuments';
|
||||
import { createTaskRun, getTaskRun, resumeTaskRun, subscribeTaskRunEvents } from '../api/taskRuns';
|
||||
import { buildTaskRequest, streamTask } from '../api/taskStream';
|
||||
import { applyPhaseEvent } from '../engine/phaseEventMapper';
|
||||
import { resolveTaskTemplateForMessage } from '../engine/templateMatch';
|
||||
import { resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch';
|
||||
import { GOVERNANCE_BLOCKED_HINT, isBlockedGovernanceStatus } from '../lib/blockedGovernanceCopy';
|
||||
import {
|
||||
controlledHydrateCandidateIds,
|
||||
@@ -68,6 +68,7 @@ export interface TaskRunAttemptsInput {
|
||||
deliverables: DeliverableItem[];
|
||||
visibleDeliverables: DeliverableItem[];
|
||||
currentProjectDeliverables: DeliverableItem[];
|
||||
activeWorkflowKey?: string;
|
||||
deliverablesRef: MutableRefObject<DeliverableItem[]>;
|
||||
activeDocIdRef: MutableRefObject<string>;
|
||||
editorHydrationGateRef: MutableRefObject<Gate>;
|
||||
@@ -99,6 +100,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
deliverables,
|
||||
visibleDeliverables,
|
||||
currentProjectDeliverables,
|
||||
activeWorkflowKey,
|
||||
deliverablesRef,
|
||||
activeDocIdRef,
|
||||
editorHydrationGateRef,
|
||||
@@ -803,8 +805,17 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
userText,
|
||||
currentProjectDeliverables,
|
||||
retryActive,
|
||||
activeWorkflowKey,
|
||||
);
|
||||
const retryTask = reResolved
|
||||
if (reResolved?.wrongWorkflow) {
|
||||
chatWindow.updateLiveMessage(messageId, {
|
||||
content: wrongWorkflowGuidance(reResolved.wrongWorkflow),
|
||||
livePhaseLabel: undefined,
|
||||
status: 'completed',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const retryTask = reResolved?.taskKey
|
||||
? {
|
||||
...target.taskRequest,
|
||||
taskKey: reResolved.taskKey,
|
||||
|
||||
@@ -115,6 +115,7 @@ export function mergeWorkflowDocumentsFromServer(
|
||||
docId: s.doc_id,
|
||||
projectId,
|
||||
taskKey: s.task_key,
|
||||
workflowKey: s.workflow_key,
|
||||
title: s.title,
|
||||
stage: 'V0.5',
|
||||
category: 'workflow',
|
||||
@@ -136,6 +137,7 @@ export function mergeWorkflowDocumentsFromServer(
|
||||
|
||||
base.docId = s.doc_id;
|
||||
base.taskKey = s.task_key;
|
||||
base.workflowKey = s.workflow_key || base.workflowKey;
|
||||
base.title = s.title || base.title;
|
||||
base.isTemplateOnly = s.is_template_only;
|
||||
base.lifecycleState = mapLifecycleState(s.lifecycle_state);
|
||||
|
||||
@@ -307,6 +307,8 @@ export interface DeliverableItem {
|
||||
projectId: number;
|
||||
taskKey: string;
|
||||
title: string;
|
||||
/** 文档所属工作流键(main / product_manager / rdev …);缺省表示存量未标注。 */
|
||||
workflowKey?: string;
|
||||
stage: StageKey;
|
||||
category: DeliverableCategory;
|
||||
creatorId: number;
|
||||
|
||||
Reference in New Issue
Block a user