merge: feature/workflow-scope-weighting-2026-09-21

This commit is contained in:
weichen
2026-09-21 09:31:12 +00:00
32 changed files with 1137 additions and 41 deletions
+2 -1
View File
@@ -14,6 +14,7 @@
| owner | task | branch | worktree | mutex | status |
|-------|------|--------|----------|-------|--------|
| Cursor | 2026-09-21-workflow-scope-weighting | feature/workflow-scope-weighting-2026-09-21 | .worktrees/workflow-scope-weighting-2026-09-21 | intent+web+docgov | in_progress |
| Cursor | 2026-09-21-mineru-doc-parsing | feature/mineru-doc-parsing-2026-09-21 | .worktrees/mineru-doc-parsing-2026-09-21 | storage+bizdev+kb | done |
| Cursor | 2026-09-21-intent-confirm-stage-aware | fix/intent-confirm-stage-aware-2026-09-21 | .worktrees/intent-confirm-stage-aware-2026-09-21 | intent+web+docgov | done |
| Cursor | 2026-09-21-taskrun-status-card | feature/taskrun-status-card-2026-09-21 | .worktrees/feature-taskrun-status-card-2026-09-21 | chat+web+taskrun | done |
@@ -184,7 +185,7 @@
| holder | phase | since | scope | queue |
|--------|-------|-------|-------|-------|
| - | idle | 2026-09-21 | mineru soft-dev 已验收;intent-confirm 真实 PI 抽样 PASS;schema=61 | - |
| Cursor | merging | 2026-09-21 | workflow-scope-weighting;合入 feature/workflow-scope-weighting-2026-09-21 | - |
**phase**: `idle` | `merging` | `smoke` | `e2e`
@@ -0,0 +1,59 @@
---
title: "工作流作用域权重化"
status: in_progress
created: 2026-09-21
updated: 2026-09-21
owner: Cursor
branch: feature/workflow-scope-weighting-2026-09-21
worktree: .worktrees/workflow-scope-weighting-2026-09-21
base_dev_sha: 10aee3feb478c550adc9959f2d3a8d17cd5b48a0
write_scope: [internal/workflowscope, internal/intent, internal/server, internal/coordinator, internal/config, internal/sysadmin, web/src, docker-compose.dev.yml, docker-compose.test.yml, docker-compose.main.yml, .env.example, .doc]
head_sha:
deployment: pending
cleanup: pending
mutex: intent+web+docgov
---
# 工作流作用域权重化
## 目标
把「句子里出现他流文档短语就硬拦截」改为「匹配质量 × 工作流关系权重」裁决。激活流优先;只有他流精确命中明显压过激活流时才跨流。只读跨流在查看权限通过后放行并注明归属;写动作仍引导到项目设置切换(跨流写在现架构下不可执行)。`WORKFLOW_SCOPE_MODE` 默认 `strict`,保持现状。
## 范围
- 新建 `internal/workflowscope`(开关、关系权重、打分裁决)
- 服务端文档读取、意图填槽、前端模板匹配改用同一裁决
- compose / `.env.example` / sysadmin 登记开关
- dev 真实环境验收与测试报告
## 非目标
- 不迁移 `document_records`,不加 `workflow_instance_id`
- 不改工作流切换 / 激活接口
- 不改 `production_ready`,不晋升 test/main,不动项目 143
## 步骤
1. [ ] 打分内核与开关
2. [ ] 服务端只读跨流放行与查看权限
3. [ ] 意图填槽改打分
4. [ ] 前端模板匹配改打分
5. [ ] 开关声明、真实验收、报告、合回 dev
## 验收标准
- [ ] strict 模式与现有跨流拦截断言一致
- [ ] weighted 下激活流精确名压过他流短语,不再误拦
- [ ] weighted 下只读精确点名他流文档可放行并注明归属;写动作仍拦截
- [ ] 无查看权限 / 实例已关闭时跨流只读拒绝
- [ ] 空激活键不过滤
## 相关链接
- Plan: `~/.cursor/plans/工作流作用域权重化_411b5e35.plan.md`
- Test report: `.doc/test-reports/2026-09-21-workflow-scope-weighting-test-report.md`
## 证据 / 备注
查看权限:治理实例 `ViewableTaskKeys` 只含当前实例已启用文档。他流任务键不在快照内,不能把它当成「显式隐藏」。判定为:解析失败按 `failClosedAuthz` 拒绝;`ViewableTaskKeys` 为空(实例已关闭)拒绝;键落在当前实例但不可见则拒绝;键不属于当前实例、且调用方已能查看该实例文档时允许只读。
+2
View File
@@ -108,6 +108,8 @@ MULTI_INTENT_U9_SEGMENT=false
MULTI_INTENT_U10_MUTATE=false
MULTI_INTENT_U11_DOCGOV=false
MULTI_INTENT_U12_RUN_CONTROL=false
# 工作流作用域:strict 命中他流即拦截;weighted 按关系权重打分,验收通过后再开。
WORKFLOW_SCOPE_MODE=strict
# W2: shadow only. Do not set SEMANTIC_SUMMARY_REPLACE=true; REPLACE is unwired.
SEMANTIC_SUMMARY_SHADOW=false
SEMANTIC_SUMMARY_REPLACE=false
+1
View File
@@ -123,6 +123,7 @@ services:
MULTI_INTENT_U10_MUTATE: ${MULTI_INTENT_U10_MUTATE:-false}
MULTI_INTENT_U11_DOCGOV: ${MULTI_INTENT_U11_DOCGOV:-false}
MULTI_INTENT_U12_RUN_CONTROL: ${MULTI_INTENT_U12_RUN_CONTROL:-false}
WORKFLOW_SCOPE_MODE: ${WORKFLOW_SCOPE_MODE:-strict}
# W2 PU6: shadow semantic summary only. REPLACE stays unwired and is not declared here.
SEMANTIC_SUMMARY_SHADOW: ${SEMANTIC_SUMMARY_SHADOW:-false}
PI_CHANGE_REPORT_NARRATIVE: ${PI_CHANGE_REPORT_NARRATIVE:-false}
+1
View File
@@ -124,6 +124,7 @@ services:
MULTI_INTENT_U10_MUTATE: ${MULTI_INTENT_U10_MUTATE:-false}
MULTI_INTENT_U11_DOCGOV: ${MULTI_INTENT_U11_DOCGOV:-false}
MULTI_INTENT_U12_RUN_CONTROL: ${MULTI_INTENT_U12_RUN_CONTROL:-false}
WORKFLOW_SCOPE_MODE: ${WORKFLOW_SCOPE_MODE:-strict}
SEMANTIC_SUMMARY_SHADOW: ${SEMANTIC_SUMMARY_SHADOW:-false}
PI_CHANGE_REPORT_NARRATIVE: ${PI_CHANGE_REPORT_NARRATIVE:-false}
FAV_DEGRADED_READ: "false"
+1
View File
@@ -121,6 +121,7 @@ services:
MULTI_INTENT_U10_MUTATE: ${MULTI_INTENT_U10_MUTATE:-false}
MULTI_INTENT_U11_DOCGOV: ${MULTI_INTENT_U11_DOCGOV:-false}
MULTI_INTENT_U12_RUN_CONTROL: ${MULTI_INTENT_U12_RUN_CONTROL:-false}
WORKFLOW_SCOPE_MODE: ${WORKFLOW_SCOPE_MODE:-strict}
SEMANTIC_SUMMARY_SHADOW: ${SEMANTIC_SUMMARY_SHADOW:-false}
PI_CHANGE_REPORT_NARRATIVE: ${PI_CHANGE_REPORT_NARRATIVE:-false}
FAV_DEGRADED_READ: ${FAV_DEGRADED_READ:-false}
+2
View File
@@ -38,6 +38,7 @@ import (
"chat-agent-system/internal/taskrun"
"chat-agent-system/internal/traceimpact"
"chat-agent-system/internal/workflowdef"
"chat-agent-system/internal/workflowscope"
"chat-agent-system/internal/workspace"
"github.com/jackc/pgx/v5/pgxpool"
@@ -324,6 +325,7 @@ func sysadminFeatureValues(cfg *config.Config) func() map[string]string {
"MULTI_INTENT_U10_MUTATE": strconv.FormatBool(pflags.U10Mutate),
"MULTI_INTENT_U11_DOCGOV": strconv.FormatBool(pflags.U11DocGov),
"MULTI_INTENT_U12_RUN_CONTROL": strconv.FormatBool(pflags.U12RunControl),
"WORKFLOW_SCOPE_MODE": string(workflowscope.CurrentMode()),
"SEMANTIC_SUMMARY_SHADOW": strconv.FormatBool(sflags.Shadow),
"SEMANTIC_SUMMARY_REPLACE": strconv.FormatBool(sflags.Replace),
"PI_CHANGE_REPORT_NARRATIVE": strconv.FormatBool(docrevision.PIChangeReportNarrativeEnabled()),
@@ -77,6 +77,15 @@ func TestComposeAppDeclaresSemanticSummaryAndNarrativeShadowFlags(t *testing.T)
}
}
func TestComposeAppDeclaresWorkflowScopeMode(t *testing.T) {
for _, name := range composeFilesForFlagCheck {
block := serviceBlockText(t, readComposeFile(t, name), "app")
if !strings.Contains(block, "WORKFLOW_SCOPE_MODE: ${WORKFLOW_SCOPE_MODE:-strict}") {
t.Errorf("%s: app service must declare WORKFLOW_SCOPE_MODE with strict default", name)
}
}
}
// E-6: main must explicitly disable demo-user seeding at the compose layer
// (defense in depth; internal/auth/bootstrap.go already gates on cfg.Env=="main").
func TestComposeMainSeedDemoUsersExplicitFalse(t *testing.T) {
+25
View File
@@ -70,6 +70,10 @@ type ReadableDocument struct {
Version int
Markdown string
IsTemplateOnly bool
// WorkflowKey 是文档归属工作流。CrossWorkflow 为真时正文来自非激活流,仅只读。
WorkflowKey string
ActiveWorkflow string
CrossWorkflow bool
}
// DocumentSummaryReader 由 HTTP 服务端按当前登录用户和项目绑定。
@@ -94,6 +98,27 @@ func WrongWorkflowGuidance(err error) string {
return ""
}
// ErrDocViewDenied 表示跨工作流文档存在,但当前用户无权查看。
type ErrDocViewDenied struct {
Message string
}
func (e *ErrDocViewDenied) Error() string {
if e == nil {
return "document view denied"
}
return e.Message
}
// DocViewDeniedGuidance 若错误链是跨流查看拒绝,返回话术。
func DocViewDeniedGuidance(err error) string {
var target *ErrDocViewDenied
if errors.As(err, &target) && target != nil && strings.TrimSpace(target.Message) != "" {
return target.Message
}
return ""
}
// UserProjectContext 用户项目级全量统一上下文 (对话系统与执行系统共同消费)
type UserProjectContext struct {
mu sync.Mutex
+27 -3
View File
@@ -31,6 +31,17 @@ func (e *CoordinatorEngine) handleDocumentSummary(
if guidance := WrongWorkflowGuidance(err); guidance != "" {
return documentSummaryReply(userCtx, guidance, ReplySourceTemplate), nil
}
if guidance := DocViewDeniedGuidance(err); guidance != "" {
return documentSummaryReply(userCtx, guidance, ReplySourceTemplate), nil
}
if owner := strings.TrimSpace(cand.FilledSlots[intent.SlotCrossWorkflowOwner]); owner != "" {
active := ""
if userCtx != nil {
active = userCtx.Project.ActiveWorkflowKey
}
note := fmt.Sprintf("您点名的《%s》属于【%s】,当前激活工作流为【%s】。当前项目台账里没有可读取的正文。", defaultStr(target, "该文档"), intent.WorkflowDisplayName(owner), intent.WorkflowDisplayName(active))
return documentSummaryReply(userCtx, note, ReplySourceTemplate), nil
}
return documentSummaryReply(userCtx, "未找到可访问的目标文档,或您当前无权查看该阶段内容。请确认文档名称后重试。", ReplySourceTemplate), nil
}
if doc.IsTemplateOnly {
@@ -40,10 +51,23 @@ func (e *CoordinatorEngine) handleDocumentSummary(
return documentSummaryReply(userCtx, fmt.Sprintf("📄 《%s》正文为空,暂时无法总结。", defaultStr(doc.Title, doc.DocID)), ReplySourceTemplate), nil
}
if reply := e.generateDocumentSummaryReply(ctx, doc, userCtx); reply != "" {
return documentSummaryReply(userCtx, reply, ReplySourcePiAgent), nil
reply := ""
source := ReplySourceTemplate
if generated := e.generateDocumentSummaryReply(ctx, doc, userCtx); generated != "" {
reply = generated
source = ReplySourcePiAgent
} else {
reply = fallbackDocumentSummary(doc)
}
return documentSummaryReply(userCtx, fallbackDocumentSummary(doc), ReplySourceTemplate), nil
if doc.CrossWorkflow {
reply = crossWorkflowReadPreface(doc) + "\n\n" + reply
}
return documentSummaryReply(userCtx, reply, source), nil
}
func crossWorkflowReadPreface(doc *ReadableDocument) string {
title := defaultStr(doc.Title, doc.DocID)
return fmt.Sprintf("以下是【%s】的《%s》,当前激活工作流为【%s】。", intent.WorkflowDisplayName(doc.WorkflowKey), title, intent.WorkflowDisplayName(doc.ActiveWorkflow))
}
func documentSummaryReply(userCtx *UserProjectContext, reply, source string) *CoordinationResult {
+3 -1
View File
@@ -22,6 +22,7 @@ import (
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/squad"
"chat-agent-system/internal/stream"
"chat-agent-system/internal/workflowscope"
)
// CoordinationStatus 协调裁决状态
@@ -959,7 +960,8 @@ func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent.
// 消费端二次校验:任务键不属于激活工作流时拦截(防御识别器漏标)。
if userCtx != nil && userCtx.Project.ActiveWorkflowKey != "" {
if owner := intent.TaskKeyWorkflowOwner(taskKey); owner != "" && owner != userCtx.Project.ActiveWorkflowKey {
owner := intent.TaskKeyWorkflowOwner(taskKey)
if workflowscope.BlocksWrite(userCtx.Project.ActiveWorkflowKey, owner, taskKey) {
guidance := intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, userCtx.Project.ActiveWorkflowKey)
return &CoordinationResult{
Status: StatusClarificationRequired,
+149 -18
View File
@@ -10,6 +10,7 @@ import (
"chat-agent-system/internal/squad"
"chat-agent-system/internal/workflowdef"
"chat-agent-system/internal/workflowscope"
)
// docWorkflowScope 提供「文档标题/任务键 → 归属工作流」索引,
@@ -238,26 +239,42 @@ func activeScopedTaskKey(phrase, activeWorkflow string) (string, bool) {
return workflowdef.ImplementationTaskKey(key), true
}
// docWorkflowScopeMarker 检查输入是否显式点名了非激活工作流的受控文档。
// 返回 (他流文档标题, 归属工作流 key 列表, 是否命中跨流)。
// activeWorkflow 为空(存量单流 / planning 态)时一律不判跨流,保持现状行为。
// docWorkflowScopeMarker 检查输入是否应拦截他流文档。
// 权重模式下只有他流精确命中压过激活流才算跨流;strict 下仍是「激活流没有命中才拦截他流精确名」。
func docWorkflowScopeMarker(input, activeWorkflow string) (string, []string, bool) {
if activeWorkflow == "" {
decision := decideInputDocScope(input, activeWorkflow, workflowscope.ActionWrite)
if decision.Kind != workflowscope.KindCrossBlock {
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
}
owner := decision.Winner.Owner
if owner == "" {
return decision.Winner.Label, nil, true
}
return "", nil, false
return decision.Winner.Label, []string{owner}, true
}
func decideInputDocScope(input, active string, action workflowscope.Action) workflowscope.Decision {
hits := matchDocPhrasesForScope(input)
candidates := make([]workflowscope.Candidate, 0, len(hits))
for _, hit := range hits {
owner := ""
if active != "" && !containsWorkflow(hit.Workflows, active) && len(hit.Workflows) > 0 {
owner = hit.Workflows[0]
} else if active != "" {
owner = active
}
quality := workflowscope.QualityExact
if utf8.RuneCountInString(hit.Phrase) < 4 {
quality = 0.5
}
candidates = append(candidates, workflowscope.Candidate{
ID: hit.Phrase,
Label: hit.Phrase,
Owner: owner,
Quality: quality,
})
}
return workflowscope.Decide(candidates, active, action)
}
// markWorkflowScopeMismatch 在槽位上打跨流标记。
@@ -293,8 +310,16 @@ func fillTargetDocSlot(slots map[string]string, input string, userCtx *ContextSn
if userCtx != nil {
active = userCtx.ActiveWorkflowKey
}
if doc, wfs, mismatched := docWorkflowScopeMarker(input, active); mismatched {
markWorkflowScopeMismatch(slots, doc, wfs)
decision := decideInputDocScope(input, active, workflowscope.ActionRead)
switch decision.Kind {
case workflowscope.KindCrossBlock:
markWorkflowScopeMismatch(slots, decision.Winner.Label, ownerList(decision.Winner.Owner))
return
case workflowscope.KindCrossAllow:
slots[SlotTargetDoc] = decision.Winner.Label
if decision.Winner.Owner != "" {
slots[SlotCrossWorkflowOwner] = decision.Winner.Owner
}
return
}
if doc := extractDocName(input); doc != "" {
@@ -409,3 +434,109 @@ var docNameIDPattern = regexp.MustCompile(`(doc[_-][a-zA-Z0-9_-]+)`)
func hasExplicitDocID(input string) bool {
return docNameIDPattern.FindString(input) != ""
}
func ownerList(owner string) []string {
if owner == "" {
return nil
}
return []string{owner}
}
// weightedTriggerTask 在权重模式下同时看激活流和他流短语,避免长他流别名劫持更贴切的激活流文档。
func weightedTriggerTask(input string, phrases []struct{ text, key string }, userCtx *ContextSnapshot) *CandidateIntent {
active := activeWorkflowKeyOf(userCtx)
candidates := make([]workflowscope.Candidate, 0, len(phrases))
for _, item := range phrases {
if !strings.Contains(input, item.text) {
continue
}
owners, ok := docScopeForPhrase(item.text)
if !ok {
if inferred := taskKeyWorkflowOwner(item.key); inferred != "" {
owners = []string{inferred}
ok = true
}
}
owner := ""
if active != "" && ok && !containsWorkflow(owners, active) && len(owners) > 0 {
owner = owners[0]
} else if active != "" {
owner = active
}
quality := workflowscope.QualityExact
if utf8.RuneCountInString(item.text) < 4 {
quality = 0.5
}
candidates = append(candidates, workflowscope.Candidate{
ID: item.key,
Label: item.text,
Owner: owner,
Quality: quality,
})
}
decision := workflowscope.Decide(candidates, active, workflowscope.ActionWrite)
switch decision.Kind {
case workflowscope.KindCrossBlock:
return workflowScopeCandidate(decision.Winner.Label, ownerList(decision.Winner.Owner), active)
case workflowscope.KindInScope:
return triggerTaskCandidate(decision.Winner.Label, decision.Winner.ID)
default:
return nil
}
}
func workflowScopeUsesWeights() bool {
return workflowscope.CurrentMode() == workflowscope.ModeWeighted
}
// readDocScopeCandidate 只读点名受控文档。strict 下他流精确名仍拦截;weighted 下精确名放行并记下归属。
func readDocScopeCandidate(input string, snap *ContextSnapshot) *CandidateIntent {
active := activeWorkflowKeyOf(snap)
decision := decideInputDocScope(input, active, workflowscope.ActionRead)
switch decision.Kind {
case workflowscope.KindNone:
return nil
case workflowscope.KindCrossBlock:
slots := map[string]string{}
markWorkflowScopeMismatch(slots, decision.Winner.Label, ownerList(decision.Winner.Owner))
question := workflowScopeGuidanceQuestion(decision.Winner.Label, ownerList(decision.Winner.Owner), active)
return &CandidateIntent{
Intent: IntentQueryDocStatus,
Confidence: 0.94,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: "只读点名的受控文档属于非激活工作流",
ClarificationQuestion: question,
}
default:
slots := map[string]string{
SlotQueryTopic: "doc_summary",
SlotTargetDoc: decision.Winner.Label,
}
if decision.Kind == workflowscope.KindCrossAllow && decision.Winner.Owner != "" {
slots[SlotCrossWorkflowOwner] = decision.Winner.Owner
}
return &CandidateIntent{
Intent: IntentQueryDocStatus,
Confidence: 0.97,
FilledSlots: slots,
Reasoning: "用户只读点名受控文档",
}
}
}
func hasDocViewVerb(input string) bool {
if strings.Contains(input, "查看") || strings.Contains(input, "读取") {
return true
}
// 「看看」常跟在「出一版…看看」后面,表示写完给看,不是只读查看。
if !strings.Contains(input, "看看") {
return false
}
for _, marker := range []string{"编写", "编制", "生成", "起草", "创建", "撰写", "重写", "更新", "修改", "完善", "补充", "刷新", "重做", "出", "做", "跑"} {
if strings.Contains(input, marker) {
return false
}
}
return true
}
@@ -6,6 +6,7 @@ import (
"testing"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/workflowscope"
)
// ----------------------------------------------------------------------------------
@@ -117,6 +118,60 @@ func TestWorkflowScope_LegacyNoActiveWorkflowUnchanged(t *testing.T) {
}
}
func TestWorkflowScope_WeightedInScopeBeatsCrossPhrase(t *testing.T) {
workflowscope.SetModeForTest(workflowscope.ModeWeighted)
t.Cleanup(workflowscope.ResetModeForTest)
rec := intent.NewRecognizer()
ctx := &intent.ContextSnapshot{SnapshotID: "s_w", ActiveWorkflowKey: "main"}
res, err := rec.Recognize(context.Background(), "生成调研报告,不要写成项目建议书", ctx)
if err != nil {
t.Fatal(err)
}
if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" {
t.Fatalf("in-scope research must not be hijacked, slots=%v", res.TopIntent.FilledSlots)
}
if got := res.TopIntent.FilledSlots[intent.SlotTargetTask]; got != "research" {
t.Fatalf("want research, got %q slots=%v", got, res.TopIntent.FilledSlots)
}
}
func TestWorkflowScope_WeightedReadCrossAllowed(t *testing.T) {
workflowscope.SetModeForTest(workflowscope.ModeWeighted)
t.Cleanup(workflowscope.ResetModeForTest)
rec := intent.NewRecognizer()
ctx := &intent.ContextSnapshot{SnapshotID: "s_r", ActiveWorkflowKey: "main"}
res, err := rec.Recognize(context.Background(), "查看项目建议书", ctx)
if err != nil {
t.Fatal(err)
}
if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" {
t.Fatalf("weighted read must not block, slots=%v", res.TopIntent.FilledSlots)
}
if res.TopIntent.FilledSlots[intent.SlotCrossWorkflowOwner] != "rdev" {
t.Fatalf("want rdev owner, slots=%v", res.TopIntent.FilledSlots)
}
if !strings.Contains(res.TopIntent.FilledSlots[intent.SlotTargetDoc], "项目建议书") {
t.Fatalf("want target doc, slots=%v", res.TopIntent.FilledSlots)
}
}
func TestWorkflowScope_WeightedWriteStillBlocked(t *testing.T) {
workflowscope.SetModeForTest(workflowscope.ModeWeighted)
t.Cleanup(workflowscope.ResetModeForTest)
rec := intent.NewRecognizer()
ctx := &intent.ContextSnapshot{SnapshotID: "s_wb", ActiveWorkflowKey: "main"}
res, err := rec.Recognize(context.Background(), "编写项目建议书", ctx)
if err != nil {
t.Fatal(err)
}
if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] != "true" {
t.Fatalf("weighted write must still block, slots=%v intent=%s", res.TopIntent.FilledSlots, res.TopIntent.Intent)
}
}
func TestWorkflowScope_SnapshotCarriesActiveWorkflowKey(t *testing.T) {
// ContextSnapshot 现在必须能携带激活工作流键,供消费端与识别器共同约束。
snap := intent.ContextSnapshot{ActiveWorkflowKey: "main"}
+8
View File
@@ -1318,6 +1318,14 @@ func matchTriggerTask(input string, userCtx *ContextSnapshot) *CandidateIntent {
return nil
}
if workflowScopeUsesWeights() {
flat := make([]struct{ text, key string }, 0, len(phrases))
for _, item := range phrases {
flat = append(flat, struct{ text, key string }{text: item.text, key: item.key})
}
return weightedTriggerTask(input, flat, userCtx)
}
for _, item := range phrases {
if strings.Contains(input, item.text) {
if cand := scopedTriggerTaskCandidate(input, item.text, item.key, userCtx); cand != nil {
+1
View File
@@ -161,6 +161,7 @@ const (
SlotWorkflowScopeMismatch = "workflow_scope_mismatch" // "true"
SlotMismatchedWorkflow = "mismatched_workflow" // 文档归属工作流 key(多个用逗号连接)
SlotMismatchedDoc = "mismatched_doc" // 被显式点名的他流文档标题
SlotCrossWorkflowOwner = "cross_workflow_owner" // 只读跨流放行时的归属工作流 key
// 长任务/运行槽位
SlotWorkDir = "work_dir"
+5
View File
@@ -185,6 +185,11 @@ func matchQueryDocStatus(input string, snap *ContextSnapshot) *CandidateIntent {
}
}
}
if isSummary || hasDocViewVerb(input) {
if cand := readDocScopeCandidate(input, snap); cand != nil {
return cand
}
}
if isSummary {
target := extractDocName(input)
if target != "" || strings.Contains(input, "文档") || strings.Contains(input, "报告") {
+97 -12
View File
@@ -12,6 +12,7 @@ import (
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/workflowscope"
)
// bindSessionDocumentSummaryReader 将正文访问绑定到已认证的用户与项目。
@@ -37,21 +38,37 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth
if err := s.authorizer.RequireProjectAccessOrAdmin(ctx, projectID, userID, s.isSystemAdminUser(ctx, user)); err != nil {
return nil, err
}
docs, err := s.gov.ListWorkflowDocumentsByProject(ctx, projectID)
listed, err := s.gov.ListWorkflowDocumentsByProject(ctx, projectID)
if err != nil {
return nil, err
}
docs = s.filterWorkflowDocsByStageAccess(ctx, projectID, userID, docs)
docs := s.filterWorkflowDocsByStageAccess(ctx, projectID, userID, listed)
// 工作流作用域:仅在激活工作流的文档池内解析正文;跨流点名返回可解释的引导错误。
// 激活键由 enrichSessionWorkflowStage 每轮刷新;任务集合按 live 实例快照解析。
workflowKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(ctx, projectID)
if sess.UserCtx != nil && sess.UserCtx.Project.ActiveWorkflowKey != "" {
workflowKey = sess.UserCtx.Project.ActiveWorkflowKey
}
if workflowscope.CurrentMode() == workflowscope.ModeWeighted && len(scopeTaskKeys) > 0 {
// 权重模式把被阶段过滤掉的他流台账加回候选,胜出后再做查看权限校验。
docs = append(append([]docgov.WorkflowDocumentSummary{}, docs...), filterDocsOutsideTaskKeys(listed, scopeTaskKeys)...)
}
doc, err := selectReadableWorkflowDocument(docs, target, workflowKey, scopeTaskKeys)
if err != nil {
return nil, err
}
owner := intent.TaskKeyWorkflowOwner(doc.TaskKey)
cross := workflowKey != "" && owner != "" && owner != workflowKey &&
(len(scopeTaskKeys) == 0 || !projectinstance.TaskKeyInScope(scopeTaskKeys, doc.TaskKey))
if cross && !s.crossWorkflowViewAllowed(ctx, projectID, userID, doc.TaskKey) {
title := doc.Title
if title == "" {
title = doc.TaskKey
}
return nil, &coordinator.ErrDocViewDenied{
Message: fmt.Sprintf("您无权查看【%s】的《%s》。", intent.WorkflowDisplayName(owner), title),
}
}
content, err := s.loadControlledDocumentContent(ctx, doc.DocID)
if err != nil {
return nil, err
@@ -64,6 +81,9 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth
Version: doc.Version,
Markdown: content.Markdown,
IsTemplateOnly: doc.IsTemplateOnly || content.IsTemplateOnly,
WorkflowKey: owner,
ActiveWorkflow: workflowKey,
CrossWorkflow: cross,
}, nil
}
}
@@ -96,16 +116,16 @@ func (s *Server) taskKeyOutsideActiveWorkflow(ctx context.Context, projectID int
return false, ""
}
owner := intent.TaskKeyWorkflowOwner(taskKey)
if owner == "" || owner == workflowKey {
return false, ""
if workflowscope.BlocksWrite(workflowKey, owner, taskKey) {
return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey)
}
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)
owner := intent.TaskKeyWorkflowOwner(taskKey)
if workflowscope.BlocksWrite(workflowKey, owner, taskKey) {
return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey)
}
return false, ""
}
// workflowKeyForDocument 为列表项标注归属工作流:优先激活流任务集合,其次任务键前缀推断。
@@ -121,9 +141,9 @@ func workflowKeyForDocument(taskKey, activeKey string, scopeTaskKeys map[string]
// errDocWrongWorkflow 目标文档存在但不属于当前激活工作流。
type errDocWrongWorkflow struct {
Title string
TaskKey string
OwnerWorkflow string
Title string
TaskKey string
OwnerWorkflow string
ActiveWorkflow string
}
@@ -154,6 +174,71 @@ func selectReadableWorkflowDocument(
target string,
activeWorkflow string,
scopeTaskKeys map[string]bool,
) (*docgov.WorkflowDocumentSummary, error) {
if workflowscope.CurrentMode() == workflowscope.ModeWeighted && activeWorkflow != "" && len(scopeTaskKeys) > 0 && strings.TrimSpace(target) != "" {
return selectReadableWeighted(docs, target, activeWorkflow, scopeTaskKeys)
}
return selectReadableStrict(docs, target, activeWorkflow, scopeTaskKeys)
}
func selectReadableWeighted(
docs []docgov.WorkflowDocumentSummary,
target string,
activeWorkflow string,
scopeTaskKeys map[string]bool,
) (*docgov.WorkflowDocumentSummary, error) {
candidates := make([]workflowscope.Candidate, 0, len(docs))
byID := make(map[string]docgov.WorkflowDocumentSummary, len(docs))
for _, doc := range docs {
quality := workflowscope.BestQuality(target, doc.DocID, doc.TaskKey, doc.Title)
if quality <= 0 {
continue
}
owner := ""
if projectinstance.TaskKeyInScope(scopeTaskKeys, doc.TaskKey) {
owner = activeWorkflow
} else if inferred := intent.TaskKeyWorkflowOwner(doc.TaskKey); inferred != "" {
owner = inferred
}
id := doc.DocID
if id == "" {
id = doc.TaskKey
}
candidates = append(candidates, workflowscope.Candidate{
ID: id,
Label: doc.Title,
Owner: owner,
Quality: quality,
})
byID[id] = doc
}
decision := workflowscope.Decide(candidates, activeWorkflow, workflowscope.ActionRead)
switch decision.Kind {
case workflowscope.KindInScope, workflowscope.KindCrossAllow:
doc := byID[decision.Winner.ID]
return &doc, nil
case workflowscope.KindCrossBlock:
doc := byID[decision.Winner.ID]
owner := decision.Winner.Owner
if owner == "" {
owner = intent.TaskKeyWorkflowOwner(doc.TaskKey)
}
return nil, (&errDocWrongWorkflow{
Title: doc.Title,
TaskKey: doc.TaskKey,
OwnerWorkflow: owner,
ActiveWorkflow: activeWorkflow,
}).AsCoordinatorErr()
default:
return nil, fmt.Errorf("document not found in readable scope")
}
}
func selectReadableStrict(
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")
@@ -7,6 +7,8 @@ import (
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/workflowscope"
)
func TestSelectReadableWorkflowDocumentMatchesInitialRequirementsAlias(t *testing.T) {
@@ -73,3 +75,47 @@ func TestSelectReadableWorkflowDocumentNoScopeKeepsLegacy(t *testing.T) {
t.Fatalf("want rd_proposal, got %s", doc.TaskKey)
}
}
func TestSelectReadableWeightedAllowsExactCrossRead(t *testing.T) {
workflowscope.SetModeForTest(workflowscope.ModeWeighted)
t.Cleanup(workflowscope.ResetModeForTest)
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}
doc, err := selectReadableWorkflowDocument(docs, "项目建议书", "main", scope)
if err != nil {
t.Fatalf("weighted cross read: %v", err)
}
if doc.TaskKey != "rd_proposal" {
t.Fatalf("want rd_proposal, got %s", doc.TaskKey)
}
inScope, err := selectReadableWorkflowDocument(docs, "调研报告", "main", scope)
if err != nil || inScope.TaskKey != "bd_research" {
t.Fatalf("in-scope still wins: %v %v", inScope, err)
}
}
func TestCrossWorkflowTaskVisible(t *testing.T) {
open := &projectinstance.StageAccess{
Restricted: true,
ViewableTaskKeys: map[string]bool{"bd_research": true},
}
scope := map[string]bool{"bd_research": true, "bd_hidden": true}
if !crossWorkflowTaskVisible(open, scope, "rd_proposal") {
t.Fatal("foreign key should be readable when the caller can view the active instance")
}
if crossWorkflowTaskVisible(open, scope, "bd_hidden") {
t.Fatal("in-scope but not viewable must be denied")
}
closed := &projectinstance.StageAccess{Restricted: true, ViewableTaskKeys: map[string]bool{}}
if crossWorkflowTaskVisible(closed, scope, "rd_proposal") {
t.Fatal("closed instance must deny cross read")
}
if !crossWorkflowTaskVisible(nil, scope, "rd_proposal") {
t.Fatal("unrestricted access must allow")
}
}
+2
View File
@@ -13,6 +13,7 @@ import (
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docquery"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/workflowscope"
)
func (s *Server) docflowQuery() *docflow.Query {
@@ -102,6 +103,7 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht
writeJSONResponse(w, http.StatusOK, map[string]any{
"documents": docs,
"active_workflow_key": activeKey,
"workflow_scope_mode": string(workflowscope.CurrentMode()),
})
}
+28
View File
@@ -163,6 +163,34 @@ func (s *Server) enrichSessionWorkflowStage(ctx context.Context, sess *chat.Sess
}
}
// crossWorkflowViewAllowed 判断他流文档是否允许当前用户只读。
// 解析失败按 fail-closed 开关拒绝;可见集合为空(实例已关闭)拒绝;
// 任务键属于激活实例但不可见则拒绝;键不在激活实例快照内、且调用方仍能查看本实例文档时允许。
func (s *Server) crossWorkflowViewAllowed(ctx context.Context, projectID, userID int64, taskKey string) bool {
access, err := s.resolveStageAccess(ctx, projectID, userID)
if err != nil {
return !s.failClosedAuthz()
}
_, scope := s.activeWorkflowScopeForDocs(ctx, projectID)
return crossWorkflowTaskVisible(access, scope, taskKey)
}
func crossWorkflowTaskVisible(access *projectinstance.StageAccess, scope map[string]bool, taskKey string) bool {
if access == nil || !access.Restricted {
return true
}
if access.TaskKeyVisible(taskKey) {
return true
}
if len(access.ViewableTaskKeys) == 0 {
return false
}
if len(scope) > 0 && projectinstance.TaskKeyInScope(scope, taskKey) {
return false
}
return true
}
func (s *Server) filterWorkflowDocsByStageAccess(
ctx context.Context,
projectID, userID int64,
+1
View File
@@ -113,6 +113,7 @@ var featureCatalog = []featureSpec{
{key: "MULTI_INTENT_U10_MUTATE", label: "U10 变更适配器", group: "intentflow", fallback: "false", description: "低/中风险 mutate 动作适配器"},
{key: "MULTI_INTENT_U11_DOCGOV", label: "U11 文档治理适配器", group: "intentflow", fallback: "false", description: "docgov 动作适配器"},
{key: "MULTI_INTENT_U12_RUN_CONTROL", label: "U12 运行控制适配器", group: "intentflow", fallback: "false", description: "workflow/run-control 动作适配器"},
{key: "WORKFLOW_SCOPE_MODE", label: "工作流作用域裁决", group: "intent", fallback: "strict", description: "文档命中裁决:strict 命中他流即拦截;weighted 按工作流关系权重打分"},
{key: "SEMANTIC_SUMMARY_SHADOW", label: "语义摘要影子", group: "ctxmgr", fallback: "false", description: "上下文语义摘要影子生成"},
{key: "SEMANTIC_SUMMARY_REPLACE", label: "语义摘要替换", group: "ctxmgr", fallback: "false", description: "语义摘要替换确定性叙事(保留位,未接线)"},
{key: "PI_CHANGE_REPORT_NARRATIVE", label: "PI 变更报告叙事", group: "docrevision", fallback: "false", description: "变更报告 PI 叙事增强;默认仅确定性回退"},
+55
View File
@@ -0,0 +1,55 @@
package workflowscope
import (
"os"
"strings"
"sync"
)
// Mode 是工作流作用域裁决开关。默认 strict,与「命中他流即拦截」的现状一致。
type Mode string
const (
ModeStrict Mode = "strict"
ModeWeighted Mode = "weighted"
)
const EnvWorkflowScopeMode = "WORKFLOW_SCOPE_MODE"
var (
modeMu sync.RWMutex
activeMode = parseMode(os.Getenv(EnvWorkflowScopeMode))
)
func parseMode(raw string) Mode {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "weighted":
return ModeWeighted
case "", "strict":
return ModeStrict
default:
// 未知值 fail-safe,保持现状拦截。
return ModeStrict
}
}
// CurrentMode 返回进程启动时快照的裁决模式。
func CurrentMode() Mode {
modeMu.RLock()
defer modeMu.RUnlock()
return activeMode
}
// SetModeForTest 仅供测试切换模式。
func SetModeForTest(mode Mode) {
modeMu.Lock()
activeMode = parseMode(string(mode))
modeMu.Unlock()
}
// ResetModeForTest 恢复为环境变量(或默认 strict)。
func ResetModeForTest() {
modeMu.Lock()
activeMode = parseMode(os.Getenv(EnvWorkflowScopeMode))
modeMu.Unlock()
}
+55
View File
@@ -0,0 +1,55 @@
package workflowscope
import "chat-agent-system/internal/workflowdef"
const (
// WeightSame 当前激活工作流。
WeightSame = 1.0
// WeightUnknown 文档没有工作流归属(存量行)。
WeightUnknown = 0.80
// WeightAdjacent 同产品域且目录序相邻(|Δorder| == 10)。
WeightAdjacent = 0.60
// WeightSameDomain 同产品域但目录序更远。
WeightSameDomain = 0.50
// WeightCrossDomain 医疗四与软件两之间。
WeightCrossDomain = 0.35
)
// RelationWeight 按「当前激活工作流 ↔ 文档所属工作流」给出关系权重。
// active 为空时不惩罚(调用方应不过滤)。owner 为空视为归属未知。
func RelationWeight(active, owner string) float64 {
if active == "" {
return WeightSame
}
if owner == "" {
return WeightUnknown
}
if active == owner {
return WeightSame
}
if !sameProductDomain(active, owner) {
return WeightCrossDomain
}
activeOrder := workflowdef.OfficialCatalogOrder(active)
ownerOrder := workflowdef.OfficialCatalogOrder(owner)
if activeOrder != 9999 && ownerOrder != 9999 && absInt(activeOrder-ownerOrder) == 10 {
return WeightAdjacent
}
return WeightSameDomain
}
func sameProductDomain(a, b string) bool {
aMed := workflowdef.IsMedicalWorkflowKey(a)
bMed := workflowdef.IsMedicalWorkflowKey(b)
if aMed || bMed {
return aMed && bMed
}
return workflowdef.IsSoftwareWorkflowKey(a) && workflowdef.IsSoftwareWorkflowKey(b)
}
func absInt(v int) int {
if v < 0 {
return -v
}
return v
}
+227
View File
@@ -0,0 +1,227 @@
package workflowscope
import (
"strings"
"unicode"
"unicode/utf8"
)
const (
// QualityExact 标题 / 任务键 / 文档 ID 原文精确命中。
QualityExact = 1.0
// QualityNormalized 归一化后精确命中。
QualityNormalized = 0.85
// CrossMargin 他流要胜出,分数必须高出激活流最佳分这么多。
CrossMargin = 0.15
// MinCrossQuality 模糊包含的质量上限低于此值,因此不能让他流胜出。
MinCrossQuality = 0.85
)
// Action 区分只读放行与写动作拦截。
type Action string
const (
ActionRead Action = "read"
ActionWrite Action = "write"
)
// Kind 是裁决结果。
type Kind string
const (
KindNone Kind = ""
KindInScope Kind = "in_scope"
KindCrossAllow Kind = "cross_allow"
KindCrossBlock Kind = "cross_block"
)
// Candidate 是一条文档命中。Owner 为空表示归属未知,视作可留在池内。
type Candidate struct {
ID string
Label string
Owner string
Quality float64
}
// Decision 携带胜出项。KindNone 表示没有足够把握的命中。
type Decision struct {
Kind Kind
Winner Candidate
Score float64
}
// Decide 在激活工作流约束下选出文档。
// active 为空:不过滤,按质量取最高并记为 InScope。
// strict:先取激活流(含归属未知),没有才在质量达到精确档的他流上 CrossBlock。
// weighted:score = quality × 关系权重;他流仅在分差与精确质量同时满足时胜出。
func Decide(candidates []Candidate, active string, action Action) Decision {
usable := make([]Candidate, 0, len(candidates))
for _, c := range candidates {
if c.Quality <= 0 {
continue
}
usable = append(usable, c)
}
if len(usable) == 0 {
return Decision{}
}
if strings.TrimSpace(active) == "" {
return bestOf(usable, active, func(c Candidate) float64 { return c.Quality }, KindInScope)
}
if CurrentMode() != ModeWeighted {
return decideStrict(usable, active)
}
return decideWeighted(usable, active, action)
}
func decideStrict(candidates []Candidate, active string) Decision {
var inScope []Candidate
var cross []Candidate
for _, c := range candidates {
if c.Owner == "" || c.Owner == active {
inScope = append(inScope, c)
continue
}
cross = append(cross, c)
}
if len(inScope) > 0 {
return bestOf(inScope, active, func(c Candidate) float64 { return c.Quality }, KindInScope)
}
exact := make([]Candidate, 0, len(cross))
for _, c := range cross {
if c.Quality >= MinCrossQuality {
exact = append(exact, c)
}
}
if len(exact) == 0 {
return Decision{}
}
return bestOf(exact, active, func(c Candidate) float64 { return c.Quality }, KindCrossBlock)
}
func decideWeighted(candidates []Candidate, active string, action Action) Decision {
scoreOf := func(c Candidate) float64 {
return c.Quality * RelationWeight(active, c.Owner)
}
var bestIn, bestCross Candidate
inScore, crossScore := -1.0, -1.0
for _, c := range candidates {
s := scoreOf(c)
if c.Owner == "" || c.Owner == active {
if s > inScore {
inScore = s
bestIn = c
}
continue
}
if s > crossScore || (s == crossScore && c.Quality > bestCross.Quality) {
crossScore = s
bestCross = c
}
}
if crossScore < 0 {
return Decision{Kind: KindInScope, Winner: bestIn, Score: inScore}
}
baseline := 0.0
if inScore >= 0 {
baseline = inScore
}
crossWins := bestCross.Quality >= MinCrossQuality && crossScore >= baseline+CrossMargin
if !crossWins {
if inScore >= 0 {
return Decision{Kind: KindInScope, Winner: bestIn, Score: inScore}
}
return Decision{}
}
kind := KindCrossAllow
if action == ActionWrite {
kind = KindCrossBlock
}
return Decision{Kind: kind, Winner: bestCross, Score: crossScore}
}
func bestOf(candidates []Candidate, active string, scoreOf func(Candidate) float64, kind Kind) Decision {
best := candidates[0]
bestScore := scoreOf(best)
for _, c := range candidates[1:] {
s := scoreOf(c)
if s > bestScore {
best = c
bestScore = s
}
}
if kind == KindInScope && active != "" && best.Owner != "" && best.Owner != active {
kind = KindCrossBlock
}
return Decision{Kind: kind, Winner: best, Score: bestScore}
}
// BlocksWrite 报告一个已确定归属的任务键在写路径上是否应拦截。
// 归属未知或未激活时不拦截。
func BlocksWrite(active, owner, id string) bool {
if strings.TrimSpace(active) == "" || strings.TrimSpace(owner) == "" || owner == active {
return false
}
decision := Decide([]Candidate{{
ID: id,
Label: id,
Owner: owner,
Quality: QualityExact,
}}, active, ActionWrite)
return decision.Kind == KindCrossBlock
}
// BestQuality 取 query 对多个字段的最高匹配质量。
func BestQuality(query string, fields ...string) float64 {
best := 0.0
for _, field := range fields {
if q := ContainmentQuality(query, field); q > best {
best = q
}
}
return best
}
// ContainmentQuality 三档质量:原文精确 1、归一化精确 0.85、双向包含 0.5×长度比。
func ContainmentQuality(query, field string) float64 {
query = strings.TrimSpace(query)
field = strings.TrimSpace(field)
if query == "" || field == "" {
return 0
}
if query == field {
return QualityExact
}
nq := NormalizeKey(query)
nf := NormalizeKey(field)
if nq == "" || nf == "" {
return 0
}
if nq == nf {
return QualityNormalized
}
if strings.Contains(nf, nq) || strings.Contains(nq, nf) {
shorter := utf8.RuneCountInString(nq)
longer := utf8.RuneCountInString(nf)
if shorter > longer {
shorter, longer = longer, shorter
}
if longer == 0 {
return 0
}
return 0.5 * float64(shorter) / float64(longer)
}
return 0
}
// NormalizeKey 去掉空白、标点和无区分度的虚词,供模糊比较。
func NormalizeKey(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.NewReplacer("收集", "", "采集", "", "正文", "", "文档", "", "《", "", "》", "").Replace(value)
return strings.Map(func(r rune) rune {
if unicode.IsSpace(r) || unicode.IsPunct(r) {
return -1
}
return r
}, value)
}
+122
View File
@@ -0,0 +1,122 @@
package workflowscope
import "testing"
func TestRelationWeightBands(t *testing.T) {
if got := RelationWeight("main", "main"); got != WeightSame {
t.Fatalf("same=%v", got)
}
if got := RelationWeight("main", ""); got != WeightUnknown {
t.Fatalf("unknown=%v", got)
}
if got := RelationWeight("main", "product_manager"); got != WeightAdjacent {
t.Fatalf("adjacent=%v", got)
}
if got := RelationWeight("main", "rdev"); got != WeightSameDomain {
t.Fatalf("far medical=%v", got)
}
if got := RelationWeight("main", "software_bootstrap"); got != WeightCrossDomain {
t.Fatalf("cross domain=%v", got)
}
if got := RelationWeight("", "rdev"); got != WeightSame {
t.Fatalf("no active must not penalize, got %v", got)
}
}
func TestDecideWeightedInScopeBeatsCrossName(t *testing.T) {
SetModeForTest(ModeWeighted)
t.Cleanup(ResetModeForTest)
dec := Decide([]Candidate{
{ID: "bd_research", Label: "调研报告", Owner: "main", Quality: QualityExact},
{ID: "rd_proposal", Label: "项目建议书", Owner: "rdev", Quality: QualityExact},
}, "main", ActionWrite)
if dec.Kind != KindInScope || dec.Winner.ID != "bd_research" {
t.Fatalf("in-scope exact must win, got %+v", dec)
}
}
func TestDecideWeightedCrossExactReadAllowed(t *testing.T) {
SetModeForTest(ModeWeighted)
t.Cleanup(ResetModeForTest)
dec := Decide([]Candidate{
{ID: "rd_proposal", Label: "项目建议书", Owner: "rdev", Quality: QualityExact},
}, "main", ActionRead)
if dec.Kind != KindCrossAllow || dec.Winner.Owner != "rdev" {
t.Fatalf("read cross exact want allow, got %+v", dec)
}
write := Decide([]Candidate{
{ID: "rd_proposal", Label: "项目建议书", Owner: "rdev", Quality: QualityExact},
}, "main", ActionWrite)
if write.Kind != KindCrossBlock {
t.Fatalf("write cross exact want block, got %+v", write)
}
}
func TestDecideFuzzyCrossNeverWins(t *testing.T) {
SetModeForTest(ModeWeighted)
t.Cleanup(ResetModeForTest)
dec := Decide([]Candidate{
{ID: "rd_proposal", Label: "项目建议书", Owner: "rdev", Quality: 0.4},
}, "main", ActionRead)
if dec.Kind != KindNone {
t.Fatalf("fuzzy cross must not win, got %+v", dec)
}
}
func TestDecideEmptyActiveDoesNotFilter(t *testing.T) {
SetModeForTest(ModeWeighted)
t.Cleanup(ResetModeForTest)
dec := Decide([]Candidate{
{ID: "rd_proposal", Label: "项目建议书", Owner: "rdev", Quality: QualityExact},
}, "", ActionWrite)
if dec.Kind != KindInScope || dec.Winner.ID != "rd_proposal" {
t.Fatalf("empty active must pass through, got %+v", dec)
}
}
func TestDecideStrictKeepsTwoStageBlock(t *testing.T) {
SetModeForTest(ModeStrict)
t.Cleanup(ResetModeForTest)
dec := Decide([]Candidate{
{ID: "bd_research", Label: "调研报告", Owner: "main", Quality: 0.4},
{ID: "rd_proposal", Label: "项目建议书", Owner: "rdev", Quality: QualityExact},
}, "main", ActionRead)
if dec.Kind != KindInScope || dec.Winner.ID != "bd_research" {
t.Fatalf("strict prefers any in-scope hit, got %+v", dec)
}
onlyCross := Decide([]Candidate{
{ID: "rd_proposal", Label: "项目建议书", Owner: "rdev", Quality: QualityExact},
}, "main", ActionRead)
if onlyCross.Kind != KindCrossBlock {
t.Fatalf("strict cross exact must block even for read, got %+v", onlyCross)
}
}
func TestParseModeFailSafe(t *testing.T) {
if parseMode("nope") != ModeStrict {
t.Fatal("unknown mode must fall back to strict")
}
if parseMode("weighted") != ModeWeighted {
t.Fatal("weighted")
}
}
func TestContainmentQualityBands(t *testing.T) {
if ContainmentQuality("调研报告", "调研报告") != QualityExact {
t.Fatal("exact")
}
if ContainmentQuality("用户初始需求表", "用户初始需求收集表") != QualityNormalized {
t.Fatal("normalized")
}
fuzzy := ContainmentQuality("调研", "技术与市场调研报告")
if fuzzy <= 0 || fuzzy >= MinCrossQuality {
t.Fatalf("fuzzy=%v must stay below cross threshold", fuzzy)
}
}
+4
View File
@@ -369,6 +369,7 @@ export default function App() {
const [projectWorkflowsState, setProjectWorkflowsState] = useState<
Record<number, { activeKey: string; version: number; creatorId: number; lifecycleStatus: string }>
>({});
const [workflowScopeMode, setWorkflowScopeMode] = useState<'strict' | 'weighted'>('strict');
const [projectLineages, setProjectLineages] = useState<DocumentLineage[]>([]);
const currentActiveSession =
@@ -872,6 +873,7 @@ export default function App() {
activeDocIdRef,
editorHydrationGateRef,
applyControlledInvalidationPlan,
onWorkflowScopeMode: setWorkflowScopeMode,
});
// 模态弹窗与侧栏
@@ -1186,6 +1188,7 @@ export default function App() {
visibleDeliverables,
currentProjectDeliverables,
activeWorkflowKey: currentWfState.activeKey || undefined,
workflowScopeMode,
deliverablesRef,
activeDocIdRef,
editorHydrationGateRef,
@@ -1385,6 +1388,7 @@ export default function App() {
visibleDeliverables,
currentProjectDeliverables,
activeWorkflowKey: currentWfState.activeKey || undefined,
workflowScopeMode,
sessions,
setDeliverables,
setDocCache,
+2
View File
@@ -30,6 +30,7 @@ export type WorkflowDocumentSummary = {
export type ProjectWorkflowDocumentsResponse = {
documents: WorkflowDocumentSummary[];
active_workflow_key?: string;
workflow_scope_mode?: 'strict' | 'weighted';
};
export type ControlledDocConsistency =
@@ -84,6 +85,7 @@ export async function listProjectWorkflowDocumentsDetailed(
return {
documents: Array.isArray(data?.documents) ? data.documents : [],
active_workflow_key: typeof data?.active_workflow_key === 'string' ? data.active_workflow_key : undefined,
workflow_scope_mode: data?.workflow_scope_mode === 'weighted' ? 'weighted' : 'strict',
};
}
+33
View File
@@ -309,3 +309,36 @@ test('resolveTaskTemplateForMessage keeps in-scope main docs when active is main
assert.equal(resolved?.wrongWorkflow, undefined);
assert.equal(resolved?.taskKey, 'research');
});
test('weighted mode does not let a cross-workflow title hijack an in-scope write', () => {
const research: DeliverableItem = {
...base,
taskKey: 'bd_research',
title: '调研报告',
workflowKey: 'main',
isTemplateOnly: true,
};
const proposal: DeliverableItem = {
...base,
taskKey: 'rd_proposal',
title: '项目建议书',
workflowKey: 'rdev',
isTemplateOnly: true,
};
const pool = [research, proposal];
const kept = resolveTaskTemplateForMessage(
'生成调研报告,不要写成项目建议书',
pool,
null,
'main',
'weighted',
);
assert.equal(kept?.wrongWorkflow, undefined);
assert.equal(kept?.taskKey, 'bd_research');
const blocked = resolveTaskTemplateForMessage('编写项目建议书', pool, null, 'main', 'weighted');
assert.equal(blocked?.wrongWorkflow?.ownerWorkflow, 'rdev');
const fuzzy = resolveTaskTemplateForMessage('编写建议', pool, null, 'main', 'weighted');
assert.equal(fuzzy?.wrongWorkflow, undefined);
});
+92 -2
View File
@@ -42,7 +42,47 @@ const WORKFLOW_LABELS: Record<string, string> = {
software_delivery: '软件交付迭代流程',
};
/** 交付物池外的他流文档别名(激活 main 却点名研发/产品经理文档时用于拦截)。 */
/** 与 internal/workflowscope 保持同一组常数,避免前后端对「他流能否压过激活流」判断分叉。 */
const CATALOG_ORDER: Record<string, number> = {
main: 10,
product_manager: 20,
test_manager: 30,
rdev: 40,
software_bootstrap: 50,
software_delivery: 60,
};
const MEDICAL_WORKFLOWS = new Set(['main', 'product_manager', 'test_manager', 'rdev']);
const SOFTWARE_WORKFLOWS = new Set(['software_bootstrap', 'software_delivery']);
const WEIGHT_SAME = 1;
const WEIGHT_UNKNOWN = 0.8;
const WEIGHT_ADJACENT = 0.6;
const WEIGHT_SAME_DOMAIN = 0.5;
const WEIGHT_CROSS_DOMAIN = 0.35;
const CROSS_MARGIN = 0.15;
const MIN_CROSS_QUALITY = 0.85;
export type WorkflowScopeMode = 'strict' | 'weighted';
export function relationWeight(active: string, owner: string): number {
if (!active) return WEIGHT_SAME;
if (!owner) return WEIGHT_UNKNOWN;
if (active === owner) return WEIGHT_SAME;
const activeMedical = MEDICAL_WORKFLOWS.has(active);
const ownerMedical = MEDICAL_WORKFLOWS.has(owner);
const sameDomain = activeMedical || ownerMedical
? activeMedical && ownerMedical
: SOFTWARE_WORKFLOWS.has(active) && SOFTWARE_WORKFLOWS.has(owner);
if (!sameDomain) return WEIGHT_CROSS_DOMAIN;
const delta = Math.abs((CATALOG_ORDER[active] ?? 9999) - (CATALOG_ORDER[owner] ?? 9999));
if (CATALOG_ORDER[active] != null && CATALOG_ORDER[owner] != null && delta === 10) {
return WEIGHT_ADJACENT;
}
return WEIGHT_SAME_DOMAIN;
}
function mentionQuality(phrase: string): number {
return Array.from(phrase).length >= 4 ? 1 : 0.5;
}
const CROSS_WORKFLOW_ALIASES: { workflow: string; patterns: RegExp[] }[] = [
{ workflow: 'rdev', patterns: [/项目建议书/, /设计开发计划任务书/, /系统设计方案/, /用户需求规格书/] },
{ workflow: 'product_manager', patterns: [/系统需求规格书/] },
@@ -98,6 +138,53 @@ function detectWrongWorkflowMention(
return undefined;
}
function weightedWrongWorkflow(
text: string,
deliverables: DeliverableItem[],
activeWorkflowKey: string,
): ResolvedDocumentTask['wrongWorkflow'] | undefined {
const mentions: { label: string; owner: string; quality: number }[] = [];
for (const item of deliverables) {
const title = item.title?.trim();
if (!title || !text.includes(title)) continue;
const owner = !item.workflowKey || item.workflowKey === activeWorkflowKey
? activeWorkflowKey
: item.workflowKey;
mentions.push({ label: title, owner, quality: mentionQuality(title) });
}
for (const alias of CROSS_WORKFLOW_ALIASES) {
for (const pattern of alias.patterns) {
const hit = text.match(pattern)?.[0];
if (!hit || mentions.some((m) => m.label === hit)) continue;
const owner = alias.workflow === activeWorkflowKey ? activeWorkflowKey : alias.workflow;
mentions.push({ label: hit, owner, quality: mentionQuality(hit) });
}
}
let bestInScope = -1;
let bestCross = -1;
let cross: { label: string; owner: string; quality: number } | undefined;
for (const mention of mentions) {
const score = mention.quality * relationWeight(activeWorkflowKey, mention.owner);
if (!mention.owner || mention.owner === activeWorkflowKey) {
bestInScope = Math.max(bestInScope, score);
continue;
}
if (score > bestCross) {
bestCross = score;
cross = mention;
}
}
const baseline = bestInScope < 0 ? 0 : bestInScope;
if (!cross || cross.quality < MIN_CROSS_QUALITY || bestCross < baseline + CROSS_MARGIN) {
return undefined;
}
return {
docTitle: cross.label,
ownerWorkflow: cross.owner,
activeWorkflow: activeWorkflowKey,
};
}
export function wrongWorkflowGuidance(info: NonNullable<ResolvedDocumentTask['wrongWorkflow']>): string {
return `您提到的文档「${info.docTitle}」属于【${workflowDisplayLabel(info.ownerWorkflow)}】,当前激活工作流为【${workflowDisplayLabel(info.activeWorkflow)}】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。`;
}
@@ -305,11 +392,14 @@ export function resolveTaskTemplateForMessage(
deliverables: DeliverableItem[],
activeDoc?: DeliverableItem | null,
activeWorkflowKey?: string,
scopeMode?: WorkflowScopeMode,
): ResolvedDocumentTask | undefined {
if (!hasDocumentWriteIntent(text)) return undefined;
if (activeWorkflowKey) {
const wrong = detectWrongWorkflowMention(text, deliverables, activeWorkflowKey);
const wrong = scopeMode === 'weighted'
? weightedWrongWorkflow(text, deliverables, activeWorkflowKey)
: detectWrongWorkflowMention(text, deliverables, activeWorkflowKey);
if (wrong) {
return { taskKey: '', wrongWorkflow: wrong };
}
+4
View File
@@ -97,6 +97,8 @@ export interface SendMessageInput {
currentProjectDeliverables: DeliverableItem[];
/** 当前项目激活工作流键;用于文档任务匹配池收窄。 */
activeWorkflowKey?: string;
/** 来自文档列表的 WORKFLOW_SCOPE_MODE;缺省 strict。 */
workflowScopeMode?: 'strict' | 'weighted';
sessions: ProjectSession[];
setDeliverables: Dispatch<SetStateAction<DeliverableItem[]>>;
setDocCache: Dispatch<SetStateAction<Record<string, DocumentAST>>>;
@@ -134,6 +136,7 @@ export function useSendMessage(input: SendMessageInput) {
visibleDeliverables,
currentProjectDeliverables,
activeWorkflowKey,
workflowScopeMode,
sessions,
setDeliverables,
setDocCache,
@@ -313,6 +316,7 @@ export function useSendMessage(input: SendMessageInput) {
currentProjectDeliverables,
sendActiveDoc,
activeWorkflowKey,
workflowScopeMode,
);
if (resolvedTask?.wrongWorkflow) {
setMessages((prev) => [
+16 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import { listFreeformDocs } from '../api/freeformDoc';
import { listProjectWorkflowDocuments } from '../api/projectDocuments';
import { listProjectWorkflowDocumentsDetailed } from '../api/projectDocuments';
import { applyLeaseUpdates, sameLease } from '../lib/deliverableLeaseSync';
import { mergeWorkflowDocumentsFromServer } from '../lib/deliverableSync';
import {
@@ -30,6 +30,8 @@ export interface ServerDocumentSyncInput {
activeDocIdRef: MutableRefObject<string>;
editorHydrationGateRef: MutableRefObject<Gate>;
applyControlledInvalidationPlan: (plan: ControlledInvalidationPlan) => void;
onActiveWorkflowKey?: (key: string) => void;
onWorkflowScopeMode?: (mode: 'strict' | 'weighted') => void;
}
export function useServerDocumentSync(input: ServerDocumentSyncInput): void {
@@ -45,6 +47,8 @@ export function useServerDocumentSync(input: ServerDocumentSyncInput): void {
activeDocIdRef,
editorHydrationGateRef,
applyControlledInvalidationPlan,
onActiveWorkflowKey,
onWorkflowScopeMode,
} = input;
useEffect(() => {
@@ -106,8 +110,11 @@ export function useServerDocumentSync(input: ServerDocumentSyncInput): void {
const syncWorkflowDocs = async () => {
try {
// 成功返回(含空数组)才对账;失败不得调用 merge,以免误清本地真实项。
const workflowDocs = await listProjectWorkflowDocuments(currentProjectId);
const listed = await listProjectWorkflowDocumentsDetailed(currentProjectId);
if (!active) return;
if (listed.active_workflow_key) onActiveWorkflowKey?.(listed.active_workflow_key);
if (listed.workflow_scope_mode) onWorkflowScopeMode?.(listed.workflow_scope_mode);
const workflowDocs = listed.documents;
setDeliverables((prev) =>
mergeWorkflowDocumentsFromServer(prev, workflowDocs, currentProjectId, currentUser),
);
@@ -138,13 +145,18 @@ export function useServerDocumentSync(input: ServerDocumentSyncInput): void {
currentUser.name,
workflowDocsEpoch,
applyControlledInvalidationPlan,
onActiveWorkflowKey,
onWorkflowScopeMode,
]);
useEffect(() => {
if (!session || sessionValidated !== true || currentProjectId <= 0) return;
let cancelled = false;
const syncLeases = async () => {
const docs = await listProjectWorkflowDocuments(currentProjectId).catch(() => []);
const listed = await listProjectWorkflowDocumentsDetailed(currentProjectId).catch(() => null);
const docs = listed?.documents ?? [];
if (listed?.active_workflow_key) onActiveWorkflowKey?.(listed.active_workflow_key);
if (listed?.workflow_scope_mode) onWorkflowScopeMode?.(listed.workflow_scope_mode);
if (cancelled || docs.length === 0) return;
const leases = new Map(
docs.map((doc) => [
@@ -177,5 +189,5 @@ export function useServerDocumentSync(input: ServerDocumentSyncInput): void {
cancelled = true;
window.clearInterval(timer);
};
}, [session, sessionValidated, currentProjectId]);
}, [session, sessionValidated, currentProjectId, onActiveWorkflowKey, onWorkflowScopeMode]);
}
+3
View File
@@ -70,6 +70,7 @@ export interface TaskRunAttemptsInput {
visibleDeliverables: DeliverableItem[];
currentProjectDeliverables: DeliverableItem[];
activeWorkflowKey?: string;
workflowScopeMode?: 'strict' | 'weighted';
deliverablesRef: MutableRefObject<DeliverableItem[]>;
activeDocIdRef: MutableRefObject<string>;
editorHydrationGateRef: MutableRefObject<Gate>;
@@ -102,6 +103,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
visibleDeliverables,
currentProjectDeliverables,
activeWorkflowKey,
workflowScopeMode,
deliverablesRef,
activeDocIdRef,
editorHydrationGateRef,
@@ -843,6 +845,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
currentProjectDeliverables,
retryActive,
activeWorkflowKey,
workflowScopeMode,
);
if (reResolved?.wrongWorkflow) {
chatWindow.updateLiveMessage(messageId, {