From 0bf4977d279a8318527d9756b72adfca59631c52 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:07:50 +0000 Subject: [PATCH 01/20] docs(ops): claim workflow scope weighting on a feature branch Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 1 + .../2026-09-21-workflow-scope-weighting.md | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 .doc/ops/short-term/2026-09-21-workflow-scope-weighting.md diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index a2f48ec0..6213a1e1 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -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 | diff --git a/.doc/ops/short-term/2026-09-21-workflow-scope-weighting.md b/.doc/ops/short-term/2026-09-21-workflow-scope-weighting.md new file mode 100644 index 00000000..7df15009 --- /dev/null +++ b/.doc/ops/short-term/2026-09-21-workflow-scope-weighting.md @@ -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` 为空(实例已关闭)拒绝;键落在当前实例但不可见则拒绝;键不属于当前实例、且调用方已能查看该实例文档时允许只读。 From fac57043030ee6c7d751b2c1060d6a6ba1cc8486 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:07:50 +0000 Subject: [PATCH 02/20] feat(workflowscope): score document hits by workflow relation weight Keep WORKFLOW_SCOPE_MODE=strict as the default so cross-workflow mentions stay blocked until weighted mode is turned on. Co-authored-by: Cursor --- internal/workflowscope/flags.go | 55 +++++++ internal/workflowscope/relation.go | 55 +++++++ internal/workflowscope/score.go | 212 +++++++++++++++++++++++++++ internal/workflowscope/score_test.go | 122 +++++++++++++++ 4 files changed, 444 insertions(+) create mode 100644 internal/workflowscope/flags.go create mode 100644 internal/workflowscope/relation.go create mode 100644 internal/workflowscope/score.go create mode 100644 internal/workflowscope/score_test.go diff --git a/internal/workflowscope/flags.go b/internal/workflowscope/flags.go new file mode 100644 index 00000000..4f804304 --- /dev/null +++ b/internal/workflowscope/flags.go @@ -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() +} diff --git a/internal/workflowscope/relation.go b/internal/workflowscope/relation.go new file mode 100644 index 00000000..1617dc01 --- /dev/null +++ b/internal/workflowscope/relation.go @@ -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 +} diff --git a/internal/workflowscope/score.go b/internal/workflowscope/score.go new file mode 100644 index 00000000..d73e9c75 --- /dev/null +++ b/internal/workflowscope/score.go @@ -0,0 +1,212 @@ +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} +} + +// 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) +} diff --git a/internal/workflowscope/score_test.go b/internal/workflowscope/score_test.go new file mode 100644 index 00000000..a2061039 --- /dev/null +++ b/internal/workflowscope/score_test.go @@ -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) + } +} From 31dde45b72b0394ee5fc680062964fa986421dc8 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:10:55 +0000 Subject: [PATCH 03/20] docs(ops): register upload parse ux dedup task Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 1 + .../2026-09-21-upload-parse-ux-dedup.md | 66 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 .doc/ops/short-term/2026-09-21-upload-parse-ux-dedup.md diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index a2f48ec0..1bdbeda3 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -14,6 +14,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor | 2026-09-21-upload-parse-ux-dedup | feature/upload-parse-ux-2026-09-21 | .worktrees/upload-parse-ux-2026-09-21 | server+web+storage | 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 | diff --git a/.doc/ops/short-term/2026-09-21-upload-parse-ux-dedup.md b/.doc/ops/short-term/2026-09-21-upload-parse-ux-dedup.md new file mode 100644 index 00000000..734982a3 --- /dev/null +++ b/.doc/ops/short-term/2026-09-21-upload-parse-ux-dedup.md @@ -0,0 +1,66 @@ +--- +title: "上传文档解析提示 / 去重 / CoW 收口" +status: in_progress +created: 2026-09-21 +updated: 2026-09-21 +owner: Cursor +branch: feature/upload-parse-ux-2026-09-21 +worktree: .worktrees/upload-parse-ux-2026-09-21 +base_dev_sha: 10aee3feb478c550adc9959f2d3a8d17cd5b48a0 +write_scope: + - internal/server/ + - internal/storage/ + - internal/materialindex/ + - web/src/ + - .doc/ops/ + - .doc/audit/ + - .doc/test-reports/ +head_sha: <准入时冻结> +deployment: pending +cleanup: pending +mutex: server+web+storage +--- + +# 上传文档解析提示 / 去重 / CoW 收口 + +## 目标 + +在已合入 dev 的 MinerU 解析管线(`dev@7f150831`)之上,补齐:解析状态提示闭环(轮询、失败原因、重试)、上传去重提示(暴露 CAS `IsDeduplicated`)、CoW 写时复制契约固化(不可变 blob 守卫测试 + 审计记录)。 + +## 范围 + +- `internal/server/asset_handlers.go`:上传响应加 `deduplicated`;列表批量返回 `parse_state`+`parse_error`;新增 `POST .../assets/{id}/reparse`;不支持类型 `skipped` 语义 +- `internal/materialindex/pipeline.go`:`GetParseStates` → `GetParseInfo`(含 error_message);`ResetForReparse` +- `web/src/`:`useParseStatePolling` hook、FilePreviewPanel 徽标轮询与重试按钮、附件去重提示、DocsSection 状态列 +- 测试:CAS CoW 契约、handler、前端单测 + +## 非目标 + +- 不改 MinerU 客户端与解析管线核心逻辑(已验收) +- 不做跨项目逻辑记录合并(物理一份 + 提示方案,逻辑引用保留) +- 不动 test/main 晋升;不改 production_ready + +## 步骤 + +1. [ ] 后端:dedup/parse_error 暴露 + reparse 接口 + skipped 语义 +2. [ ] 前端:轮询 hook + 状态徽标 + 去重提示 + 重试按钮 +3. [ ] CoW 契约测试 + handler 测试 + 前端测试 +4. [ ] 文档:审计记录(CoW 现状)、验收报告 +5. [ ] 集成 dev + 部署 + 实测验收 + +## 验收标准 + +- [ ] 上传重复文件响应含 deduplicated=true,前端提示复用 +- [ ] parse_state 轮询 pending→running→done 自动刷新,无需手动重加载 +- [ ] failed 展示错误原因,reparse 后恢复解析 +- [ ] CAS 同 sha 重复写入不产生新物理文件的契约测试通过 +- [ ] go vet / go test -short / web npm test / npm run build 全过 + +## 相关链接 + +- 前序任务: [2026-09-21-mineru-doc-parsing.md](./2026-09-21-mineru-doc-parsing.md) +- Test report: ../../test-reports/2026-09-21-upload-parse-ux-dedup-test-report.md + +## 证据 / 备注 + +(命令输出、commit、文件路径) From 976f8d30b8ca3dc108294dfaf64a20a408bfdfe4 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:11:47 +0000 Subject: [PATCH 04/20] feat(server): allow weighted cross-workflow reads after a view check Exact out-of-workflow documents can be summarized when WORKFLOW_SCOPE_MODE=weighted and the caller can still view the active instance; writes stay blocked. Co-authored-by: Cursor --- internal/coordinator/context.go | 25 +++++ internal/coordinator/document_summary.go | 30 ++++- internal/coordinator/engine.go | 4 +- internal/intent/types.go | 1 + internal/server/chat_document_reader.go | 109 +++++++++++++++++-- internal/server/chat_document_reader_test.go | 46 ++++++++ internal/server/stage_access_helpers.go | 28 +++++ internal/workflowscope/score.go | 15 +++ 8 files changed, 242 insertions(+), 16 deletions(-) diff --git a/internal/coordinator/context.go b/internal/coordinator/context.go index 18267a34..4f2864bd 100644 --- a/internal/coordinator/context.go +++ b/internal/coordinator/context.go @@ -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 diff --git a/internal/coordinator/document_summary.go b/internal/coordinator/document_summary.go index 04bcd231..752fe055 100644 --- a/internal/coordinator/document_summary.go +++ b/internal/coordinator/document_summary.go @@ -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 { diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index 6524b319..5f5c580e 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -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, diff --git a/internal/intent/types.go b/internal/intent/types.go index e655e0da..57491da4 100644 --- a/internal/intent/types.go +++ b/internal/intent/types.go @@ -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" diff --git a/internal/server/chat_document_reader.go b/internal/server/chat_document_reader.go index ca32bdc6..932d95e4 100644 --- a/internal/server/chat_document_reader.go +++ b/internal/server/chat_document_reader.go @@ -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") diff --git a/internal/server/chat_document_reader_test.go b/internal/server/chat_document_reader_test.go index 9db568f4..c277d0d1 100644 --- a/internal/server/chat_document_reader_test.go +++ b/internal/server/chat_document_reader_test.go @@ -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") + } +} diff --git a/internal/server/stage_access_helpers.go b/internal/server/stage_access_helpers.go index 3dfdefd1..ea1d8968 100644 --- a/internal/server/stage_access_helpers.go +++ b/internal/server/stage_access_helpers.go @@ -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, diff --git a/internal/workflowscope/score.go b/internal/workflowscope/score.go index d73e9c75..a0f1c3c5 100644 --- a/internal/workflowscope/score.go +++ b/internal/workflowscope/score.go @@ -156,6 +156,21 @@ func bestOf(candidates []Candidate, active string, scoreOf func(Candidate) float 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 From 0972900215770c8ff4ff3400c74c73aabd4b0380 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:15:51 +0000 Subject: [PATCH 05/20] feat(intent): score slot filling against the active workflow Weighted mode keeps an in-scope document when a cross-workflow name is only mentioned beside it, while exact cross-workflow writes stay blocked and exact reads record the owning workflow. Co-authored-by: Cursor --- internal/intent/doc_workflow_scope.go | 160 ++++++++++++++++++--- internal/intent/doc_workflow_scope_test.go | 55 +++++++ internal/intent/recognizer.go | 8 ++ internal/intent/workflow_matcher.go | 5 + 4 files changed, 210 insertions(+), 18 deletions(-) diff --git a/internal/intent/doc_workflow_scope.go b/internal/intent/doc_workflow_scope.go index a82af48b..9705584a 100644 --- a/internal/intent/doc_workflow_scope.go +++ b/internal/intent/doc_workflow_scope.go @@ -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,102 @@ 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 { + for _, verb := range []string{"查看", "看看", "读取"} { + if strings.Contains(input, verb) { + return true + } + } + return false +} diff --git a/internal/intent/doc_workflow_scope_test.go b/internal/intent/doc_workflow_scope_test.go index 5818a77b..47b92bf3 100644 --- a/internal/intent/doc_workflow_scope_test.go +++ b/internal/intent/doc_workflow_scope_test.go @@ -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"} diff --git a/internal/intent/recognizer.go b/internal/intent/recognizer.go index fed5044d..015d7e7f 100644 --- a/internal/intent/recognizer.go +++ b/internal/intent/recognizer.go @@ -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 { diff --git a/internal/intent/workflow_matcher.go b/internal/intent/workflow_matcher.go index a3302a14..32daa7c0 100644 --- a/internal/intent/workflow_matcher.go +++ b/internal/intent/workflow_matcher.go @@ -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, "报告") { From 63f94ff23bb07d79e746eba842c0b74dcff87be1 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:18:20 +0000 Subject: [PATCH 06/20] feat(server): expose dedup hint, parse error and reparse endpoint Co-authored-by: Cursor --- internal/materialindex/pipeline.go | 52 ++++++++++- internal/server/asset_handlers.go | 143 ++++++++++++++++++++++++----- internal/server/server.go | 1 + 3 files changed, 167 insertions(+), 29 deletions(-) diff --git a/internal/materialindex/pipeline.go b/internal/materialindex/pipeline.go index bc9e1943..8cf895a9 100644 --- a/internal/materialindex/pipeline.go +++ b/internal/materialindex/pipeline.go @@ -150,14 +150,30 @@ func (p *Pipeline) GetParseState(ctx context.Context, blobSHA string) string { return rec.State } +// ParseInfo is the user-facing parse status for one blob. +type ParseInfo struct { + State string `json:"state"` + ErrorMessage string `json:"error_message,omitempty"` +} + // GetParseStates batch-loads parse states keyed by blob sha. func (p *Pipeline) GetParseStates(ctx context.Context, blobSHAs []string) map[string]string { - out := make(map[string]string, len(blobSHAs)) + infos := p.GetParseInfos(ctx, blobSHAs) + out := make(map[string]string, len(infos)) + for sha, info := range infos { + out[sha] = info.State + } + return out +} + +// GetParseInfos batch-loads parse state + error message keyed by blob sha. +func (p *Pipeline) GetParseInfos(ctx context.Context, blobSHAs []string) map[string]ParseInfo { + out := make(map[string]ParseInfo, len(blobSHAs)) if p == nil || p.pool == nil || len(blobSHAs) == 0 { return out } rows, err := p.pool.Query(ctx, ` - SELECT blob_sha256, state FROM asset_parses + SELECT blob_sha256, state, COALESCE(error_message, '') FROM asset_parses WHERE engine = $1 AND blob_sha256 = ANY($2) `, engineMinerU, blobSHAs) if err != nil { @@ -165,14 +181,40 @@ func (p *Pipeline) GetParseStates(ctx context.Context, blobSHAs []string) map[st } defer rows.Close() for rows.Next() { - var sha, state string - if rows.Scan(&sha, &state) == nil { - out[sha] = state + var sha, state, errMsg string + if rows.Scan(&sha, &state, &errMsg) == nil { + out[sha] = ParseInfo{State: state, ErrorMessage: errMsg} } } return out } +// ResetForReparse rolls a failed/exhausted parse back to pending so the next +// EnqueueAsync/EnsureParsed cycle retries MinerU. Returns false when the blob +// is unknown or still in progress (pending/running/done). +func (p *Pipeline) ResetForReparse(ctx context.Context, blobSHA string) bool { + if p == nil || p.pool == nil || blobSHA == "" { + return false + } + tag, err := p.pool.Exec(ctx, ` + UPDATE asset_parses SET state = $2, attempts = 0, error_message = '', updated_at = now() + WHERE blob_sha256 = $1 AND engine = $3 AND state NOT IN ($4, $5) + `, blobSHA, statePending, engineMinerU, stateRunning, stateDone) + if err != nil { + return false + } + if tag.RowsAffected() > 0 { + return true + } + // No row updated: either the record is done/running (must not reset) or it + // never existed. When it never existed, allow a fresh parse to start. + rec, err := p.getRecord(ctx, blobSHA) + if err != nil || rec != nil { + return false + } + return true +} + // EnsureParsed waits for or triggers MinerU parse. On miss/disable/fail returns // fallback text from squad.ExtractTextWithAnchors (may be empty). func (p *Pipeline) EnsureParsed(ctx context.Context, fileName string, blobSHA string, data []byte) (markdown string, fromMinerU bool, err error) { diff --git a/internal/server/asset_handlers.go b/internal/server/asset_handlers.go index 18d68155..106acb93 100644 --- a/internal/server/asset_handlers.go +++ b/internal/server/asset_handlers.go @@ -8,17 +8,21 @@ import ( "strings" "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/materialindex" + "chat-agent-system/internal/mineru" "chat-agent-system/internal/upload" ) type projectAssetResponse struct { - AssetID int64 `json:"asset_id"` - Filename string `json:"filename"` - SHA256 string `json:"sha256"` - MimeType string `json:"mime_type"` - SizeBytes int64 `json:"size_bytes"` - ProjectID int64 `json:"project_id"` - ParseState string `json:"parse_state,omitempty"` + AssetID int64 `json:"asset_id"` + Filename string `json:"filename"` + SHA256 string `json:"sha256"` + MimeType string `json:"mime_type"` + SizeBytes int64 `json:"size_bytes"` + ProjectID int64 `json:"project_id"` + ParseState string `json:"parse_state,omitempty"` + ParseError string `json:"parse_error,omitempty"` + Deduplicated bool `json:"deduplicated,omitempty"` } func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request) { @@ -80,21 +84,79 @@ func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request } parseState := "" - if matIdx := bizdev.Snapshot(s.svc).MaterialIndex; matIdx != nil { - matIdx.EnqueueAsync(ref.FileName, ref.BlobSHA256, data, projectID) - if matIdx.Enabled() { - parseState = "pending" + if matIdx := bizdev.Snapshot(s.svc).MaterialIndex; matIdx != nil && matIdx.Enabled() { + if mineru.SupportsExt(ref.FileName) { + matIdx.EnqueueAsync(ref.FileName, ref.BlobSHA256, data, projectID) + // Report the real state: same-content uploads hit an existing parse + // (possibly already done) instead of faking "pending". + if state := matIdx.GetParseState(r.Context(), ref.BlobSHA256); state != "" { + parseState = state + } else { + parseState = "pending" + } + } else { + parseState = "skipped" } } writeJSONResponse(w, http.StatusOK, projectAssetResponse{ - AssetID: ref.ID, - Filename: ref.FileName, - SHA256: ref.BlobSHA256, - MimeType: mimeType, - SizeBytes: int64(len(data)), - ProjectID: projectID, - ParseState: parseState, + AssetID: ref.ID, + Filename: ref.FileName, + SHA256: ref.BlobSHA256, + MimeType: mimeType, + SizeBytes: int64(len(data)), + ProjectID: projectID, + ParseState: parseState, + Deduplicated: ref.IsDeduplicated, + }) +} + +// handleReparseProjectAsset resets a failed material parse and re-enqueues MinerU. +func (s *Server) handleReparseProjectAsset(w http.ResponseWriter, r *http.Request) { + projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || projectID <= 0 { + writeSafeError(w, r, http.StatusBadRequest, "invalid project id") + return + } + assetID, err := strconv.ParseInt(r.PathValue("asset_id"), 10, 64) + if err != nil || assetID <= 0 { + writeSafeError(w, r, http.StatusBadRequest, "invalid asset id") + return + } + if _, ok := s.requireProjectAccess(w, r, projectID); !ok { + return + } + cas := s.cas + if cas == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "storage unavailable") + return + } + data, ref, err := cas.ReadRefBytes(r.Context(), assetID) + if err != nil || ref == nil { + writeSafeError(w, r, http.StatusNotFound, "asset not found") + return + } + if ref.ProjectID != projectID { + writeSafeError(w, r, http.StatusNotFound, "asset not found") + return + } + matIdx := bizdev.Snapshot(s.svc).MaterialIndex + if matIdx == nil || !matIdx.Enabled() { + writeSafeError(w, r, http.StatusServiceUnavailable, "parse engine unavailable") + return + } + if !mineru.SupportsExt(ref.FileName) { + writeSafeError(w, r, http.StatusBadRequest, "file type not supported by parser") + return + } + if !matIdx.ResetForReparse(r.Context(), ref.BlobSHA256) { + writeSafeError(w, r, http.StatusConflict, "parse is in progress or already complete") + return + } + matIdx.EnqueueAsync(ref.FileName, ref.BlobSHA256, data, projectID) + writeJSONResponse(w, http.StatusOK, map[string]any{ + "asset_id": ref.ID, + "parse_state": "pending", }) } @@ -124,20 +186,53 @@ func (s *Server) handleListProjectAssets(w http.ResponseWriter, r *http.Request) shas = append(shas, ref.BlobSHA256) } } - states := map[string]string{} + infos := map[string]materialindex.ParseInfo{} if matIdx := bizdev.Snapshot(s.svc).MaterialIndex; matIdx != nil { - states = matIdx.GetParseStates(r.Context(), shas) + infos = matIdx.GetParseInfos(r.Context(), shas) } - out := make([]projectAssetResponse, 0, len(refs)) + seenSHA := make(map[string]bool, len(refs)) + shaCount := make(map[string]int, len(refs)) for _, ref := range refs { - out = append(out, projectAssetResponse{ + if ref != nil && ref.BlobSHA256 != "" { + shaCount[ref.BlobSHA256]++ + } + } + out := make([]projectAssetResponse, 0, len(refs)) + firstRefOfSHA := make(map[string]int64, len(refs)) + for _, ref := range refs { + if ref != nil && ref.BlobSHA256 != "" { + if _, ok := firstRefOfSHA[ref.BlobSHA256]; !ok { + firstRefOfSHA[ref.BlobSHA256] = ref.ID + } + } + } + for _, ref := range refs { + info := infos[ref.BlobSHA256] + item := projectAssetResponse{ AssetID: ref.ID, Filename: ref.FileName, SHA256: ref.BlobSHA256, ProjectID: ref.ProjectID, - ParseState: states[ref.BlobSHA256], - }) + ParseState: info.State, + ParseError: info.ErrorMessage, + } + // 同项目内相同内容多次引用:除首条外全部标注重复(物理只存一份)。 + if shaCount[ref.BlobSHA256] > 1 && firstRefOfSHA[ref.BlobSHA256] != ref.ID { + item.Deduplicated = true + } + if item.ParseState == "" && ref.BlobSHA256 != "" && !seenSHA[ref.BlobSHA256] { + // 内容已入库但从未触发解析(如早于 MinerU 接入的历史资产): + // 列表读取时自动补触发,前端轮询即可看到 pending→running→done。 + seenSHA[ref.BlobSHA256] = true + if matIdx := bizdev.Snapshot(s.svc).MaterialIndex; matIdx != nil && matIdx.Enabled() && mineru.SupportsExt(ref.FileName) { + if data, _, err := cas.ReadRefBytes(r.Context(), ref.ID); err == nil { + matIdx.EnqueueAsync(ref.FileName, ref.BlobSHA256, data, projectID) + item.ParseState = "pending" + } + } + } + out = append(out, item) } writeJSONResponse(w, http.StatusOK, out) } diff --git a/internal/server/server.go b/internal/server/server.go index 4df7a335..e49b3584 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -246,6 +246,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /api/v1/projects/{id}/assets", s.handleUploadProjectAsset) mux.HandleFunc("GET /api/v1/projects/{id}/assets", s.handleListProjectAssets) mux.HandleFunc("GET /api/v1/projects/{id}/assets/{asset_id}/raw", s.handleGetProjectAssetRaw) + mux.HandleFunc("POST /api/v1/projects/{id}/assets/{asset_id}/reparse", s.handleReparseProjectAsset) // 6. 多级上下文与偏好记忆管理 mux.HandleFunc("GET /api/v1/context/user", s.handleGetUserContext) From fd4b95030fc9ab9283ea069a64aa7811636506bb Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:22:13 +0000 Subject: [PATCH 07/20] feat(web): score write-path matches by workflow relation The document list now reports WORKFLOW_SCOPE_MODE, and weighted mode only blocks a cross-workflow write when that title outscores the active workflow. Co-authored-by: Cursor --- internal/server/project_doc_handlers.go | 2 + web/src/App.tsx | 4 ++ web/src/api/projectDocuments.ts | 2 + web/src/engine/templateMatch.test.ts | 33 +++++++++ web/src/engine/templateMatch.ts | 94 ++++++++++++++++++++++++- web/src/hooks/useSendMessage.ts | 4 ++ web/src/hooks/useServerDocumentSync.ts | 20 ++++-- web/src/hooks/useTaskRunAttempts.ts | 3 + 8 files changed, 156 insertions(+), 6 deletions(-) diff --git a/internal/server/project_doc_handlers.go b/internal/server/project_doc_handlers.go index efda8b9a..f38c6625 100644 --- a/internal/server/project_doc_handlers.go +++ b/internal/server/project_doc_handlers.go @@ -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()), }) } diff --git a/web/src/App.tsx b/web/src/App.tsx index 8d2a2f2d..534fb5bc 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -369,6 +369,7 @@ export default function App() { const [projectWorkflowsState, setProjectWorkflowsState] = useState< Record >({}); + const [workflowScopeMode, setWorkflowScopeMode] = useState<'strict' | 'weighted'>('strict'); const [projectLineages, setProjectLineages] = useState([]); 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, diff --git a/web/src/api/projectDocuments.ts b/web/src/api/projectDocuments.ts index 32f61d8a..1b5e0514 100644 --- a/web/src/api/projectDocuments.ts +++ b/web/src/api/projectDocuments.ts @@ -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', }; } diff --git a/web/src/engine/templateMatch.test.ts b/web/src/engine/templateMatch.test.ts index 6aff9962..3f340ba2 100644 --- a/web/src/engine/templateMatch.test.ts +++ b/web/src/engine/templateMatch.test.ts @@ -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); +}); diff --git a/web/src/engine/templateMatch.ts b/web/src/engine/templateMatch.ts index 67ed3d67..797c863b 100644 --- a/web/src/engine/templateMatch.ts +++ b/web/src/engine/templateMatch.ts @@ -42,7 +42,47 @@ const WORKFLOW_LABELS: Record = { software_delivery: '软件交付迭代流程', }; -/** 交付物池外的他流文档别名(激活 main 却点名研发/产品经理文档时用于拦截)。 */ +/** 与 internal/workflowscope 保持同一组常数,避免前后端对「他流能否压过激活流」判断分叉。 */ +const CATALOG_ORDER: Record = { + 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): 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 }; } diff --git a/web/src/hooks/useSendMessage.ts b/web/src/hooks/useSendMessage.ts index fa339f64..f97b666b 100644 --- a/web/src/hooks/useSendMessage.ts +++ b/web/src/hooks/useSendMessage.ts @@ -97,6 +97,8 @@ export interface SendMessageInput { currentProjectDeliverables: DeliverableItem[]; /** 当前项目激活工作流键;用于文档任务匹配池收窄。 */ activeWorkflowKey?: string; + /** 来自文档列表的 WORKFLOW_SCOPE_MODE;缺省 strict。 */ + workflowScopeMode?: 'strict' | 'weighted'; sessions: ProjectSession[]; setDeliverables: Dispatch>; setDocCache: Dispatch>>; @@ -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) => [ diff --git a/web/src/hooks/useServerDocumentSync.ts b/web/src/hooks/useServerDocumentSync.ts index 5de4103e..3cca7a55 100644 --- a/web/src/hooks/useServerDocumentSync.ts +++ b/web/src/hooks/useServerDocumentSync.ts @@ -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; editorHydrationGateRef: MutableRefObject; 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]); } diff --git a/web/src/hooks/useTaskRunAttempts.ts b/web/src/hooks/useTaskRunAttempts.ts index c09ec1a6..ffae04b2 100644 --- a/web/src/hooks/useTaskRunAttempts.ts +++ b/web/src/hooks/useTaskRunAttempts.ts @@ -70,6 +70,7 @@ export interface TaskRunAttemptsInput { visibleDeliverables: DeliverableItem[]; currentProjectDeliverables: DeliverableItem[]; activeWorkflowKey?: string; + workflowScopeMode?: 'strict' | 'weighted'; deliverablesRef: MutableRefObject; activeDocIdRef: MutableRefObject; editorHydrationGateRef: MutableRefObject; @@ -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, { From 4af4a9de13512f43d63ca53f82112affc05dc590 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:26:03 +0000 Subject: [PATCH 08/20] =?UTF-8?q?fix(intent):=20keep=20draft=20requests=20?= =?UTF-8?q?that=20end=20in=20=E7=9C=8B=E7=9C=8B=20on=20the=20write=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「出一版…看看」是写完给看,不是只读查看;查看和读取仍走只读作用域裁决。 Co-authored-by: Cursor --- internal/intent/doc_workflow_scope.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/internal/intent/doc_workflow_scope.go b/internal/intent/doc_workflow_scope.go index 9705584a..7143e48d 100644 --- a/internal/intent/doc_workflow_scope.go +++ b/internal/intent/doc_workflow_scope.go @@ -526,10 +526,17 @@ func readDocScopeCandidate(input string, snap *ContextSnapshot) *CandidateIntent } func hasDocViewVerb(input string) bool { - for _, verb := range []string{"查看", "看看", "读取"} { - if strings.Contains(input, verb) { - return true + 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 false + return true } From 8b84ee4ecf554c644f9ec7b1de433413c157c156 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:26:03 +0000 Subject: [PATCH 09/20] chore(config): declare WORKFLOW_SCOPE_MODE with a strict default The flag reaches all three compose app services and the sysadmin catalog, and unknown values stay on the existing hard block. Co-authored-by: Cursor --- .env.example | 2 ++ docker-compose.dev.yml | 1 + docker-compose.main.yml | 1 + docker-compose.test.yml | 1 + internal/app/app.go | 2 ++ internal/config/compose_flag_declaration_test.go | 9 +++++++++ internal/sysadmin/features.go | 1 + 7 files changed, 17 insertions(+) diff --git a/.env.example b/.env.example index aa8ef6a2..3346f0b5 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2bad3431..b89d5e98 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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} diff --git a/docker-compose.main.yml b/docker-compose.main.yml index a08bd1c3..6ca74df3 100644 --- a/docker-compose.main.yml +++ b/docker-compose.main.yml @@ -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" diff --git a/docker-compose.test.yml b/docker-compose.test.yml index d8162095..2ed26c65 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -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} diff --git a/internal/app/app.go b/internal/app/app.go index a089a90d..820a3eaa 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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()), diff --git a/internal/config/compose_flag_declaration_test.go b/internal/config/compose_flag_declaration_test.go index 038a237d..1c6f958d 100644 --- a/internal/config/compose_flag_declaration_test.go +++ b/internal/config/compose_flag_declaration_test.go @@ -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) { diff --git a/internal/sysadmin/features.go b/internal/sysadmin/features.go index bdac3489..21444826 100644 --- a/internal/sysadmin/features.go +++ b/internal/sysadmin/features.go @@ -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 叙事增强;默认仅确定性回退"}, From 776e069e2f9d83edc3ba6fab8a9fa0af37a9cf1b Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:30:31 +0000 Subject: [PATCH 10/20] docs(ops): mark the integration slot for workflow scope weighting Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 6213a1e1..ec085e52 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -185,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` From 0272d13574e7b0c2e03efbe4d302b469638d07a7 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:37:39 +0000 Subject: [PATCH 11/20] fix(server): treat a template-only ledger as a readable in-scope document A missing ledger body was reported as not found, so an in-scope view of a template document never reached the template reply. Co-authored-by: Cursor --- internal/server/project_doc_handlers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/server/project_doc_handlers.go b/internal/server/project_doc_handlers.go index f38c6625..056ca907 100644 --- a/internal/server/project_doc_handlers.go +++ b/internal/server/project_doc_handlers.go @@ -157,7 +157,7 @@ func (s *Server) loadControlledDocumentContent(ctx context.Context, docID string if err != nil && !errors.Is(err, docquery.ErrLedgerMissing) { return nil, err } - if view != nil && view.Markdown != "" { + if view != nil && (view.Markdown != "" || view.IsTemplateOnly) { return view.ToDocumentContent(), nil } return s.gov.ReadDocumentContent(ctx, docID, s.repoRoot()) From 3bde2634baf2fd2ee34f481892b50ff060f47567 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:44:50 +0000 Subject: [PATCH 12/20] docs(test): record workflow scope weighting acceptance Co-authored-by: Cursor --- .../2026-09-21-workflow-scope-weighting.md | 26 ++--- .doc/ops/CURRENT.md | 10 +- ...21-workflow-scope-weighting-test-report.md | 100 ++++++++++++++++++ .../health.json | 1 + .../strict-docs.json | 41 +++++++ .../strict-summary.json | 53 ++++++++++ .../strict-view_research.json | 1 + .../strict-write_proposal.json | 1 + .../weighted-summary.json | 53 ++++++++++ .../weighted-view_proposal.json | 1 + .../weighted-view_research.json | 1 + .../weighted-write_proposal.json | 1 + 12 files changed, 271 insertions(+), 18 deletions(-) rename .doc/{ops/short-term => archive/ops}/2026-09-21-workflow-scope-weighting.md (78%) create mode 100644 .doc/test-reports/2026-09-21-workflow-scope-weighting-test-report.md create mode 100644 .doc/test-reports/assets/2026-09-21-workflow-scope-weighting/health.json create mode 100644 .doc/test-reports/assets/2026-09-21-workflow-scope-weighting/strict-docs.json create mode 100644 .doc/test-reports/assets/2026-09-21-workflow-scope-weighting/strict-summary.json create mode 100644 .doc/test-reports/assets/2026-09-21-workflow-scope-weighting/strict-view_research.json create mode 100644 .doc/test-reports/assets/2026-09-21-workflow-scope-weighting/strict-write_proposal.json create mode 100644 .doc/test-reports/assets/2026-09-21-workflow-scope-weighting/weighted-summary.json create mode 100644 .doc/test-reports/assets/2026-09-21-workflow-scope-weighting/weighted-view_proposal.json create mode 100644 .doc/test-reports/assets/2026-09-21-workflow-scope-weighting/weighted-view_research.json create mode 100644 .doc/test-reports/assets/2026-09-21-workflow-scope-weighting/weighted-write_proposal.json diff --git a/.doc/ops/short-term/2026-09-21-workflow-scope-weighting.md b/.doc/archive/ops/2026-09-21-workflow-scope-weighting.md similarity index 78% rename from .doc/ops/short-term/2026-09-21-workflow-scope-weighting.md rename to .doc/archive/ops/2026-09-21-workflow-scope-weighting.md index 7df15009..a4308657 100644 --- a/.doc/ops/short-term/2026-09-21-workflow-scope-weighting.md +++ b/.doc/archive/ops/2026-09-21-workflow-scope-weighting.md @@ -1,6 +1,6 @@ --- title: "工作流作用域权重化" -status: in_progress +status: done created: 2026-09-21 updated: 2026-09-21 owner: Cursor @@ -8,8 +8,8 @@ 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 +head_sha: 7723a5515948788602b342cdda1137c90fee311b +deployment: deployed cleanup: pending mutex: intent+web+docgov --- @@ -35,19 +35,19 @@ mutex: intent+web+docgov ## 步骤 -1. [ ] 打分内核与开关 -2. [ ] 服务端只读跨流放行与查看权限 -3. [ ] 意图填槽改打分 -4. [ ] 前端模板匹配改打分 -5. [ ] 开关声明、真实验收、报告、合回 dev +1. [x] 打分内核与开关 +2. [x] 服务端只读跨流放行与查看权限 +3. [x] 意图填槽改打分 +4. [x] 前端模板匹配改打分 +5. [x] 开关声明、真实验收、报告、合回 dev ## 验收标准 -- [ ] strict 模式与现有跨流拦截断言一致 -- [ ] weighted 下激活流精确名压过他流短语,不再误拦 -- [ ] weighted 下只读精确点名他流文档可放行并注明归属;写动作仍拦截 -- [ ] 无查看权限 / 实例已关闭时跨流只读拒绝 -- [ ] 空激活键不过滤 +- [x] strict 模式与现有跨流拦截断言一致 +- [x] weighted 下激活流精确名压过他流短语,不再误拦 +- [x] weighted 下只读精确点名他流文档可放行并注明归属;写动作仍拦截 +- [x] 无查看权限 / 实例已关闭时跨流只读拒绝 +- [x] 空激活键不过滤 ## 相关链接 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index ec085e52..f2fc9d65 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,16 +5,16 @@ ## 焦点 -1. **意图确认优先与阶段感知**:弱写「整理需求」→ intake 确认卡片。见 [报告](../test-reports/2026-09-21-intent-confirm-stage-aware-test-report.md)。test/main 未晋升。 -2. **任务执行状态卡**:竖向时间线 `TaskRunCard` + `task_phase_snapshot` 刷新不退化。见 [报告](../test-reports/2026-09-21-taskrun-status-card-persistence-test-report.md)。test/main 未晋升。 -3. **意图填槽工作流作用域**:激活流匹配池收窄 + 跨流拦截引导,已合入 `dev`。见 [报告](../test-reports/2026-09-21-intent-workflow-scoping-test-report.md)。test/main 未晋升。 +1. **工作流作用域权重**:`WORKFLOW_SCOPE_MODE` 默认 `strict`;`weighted` 按关系权重打分,只读跨流注明归属,写动作仍拦截。见 [报告](../test-reports/2026-09-21-workflow-scope-weighting-test-report.md)。test/main 未晋升。 +2. **意图确认优先与阶段感知**:弱写「整理需求」→ intake 确认卡片。见 [报告](../test-reports/2026-09-21-intent-confirm-stage-aware-test-report.md)。test/main 未晋升。 +3. **任务执行状态卡**:竖向时间线 `TaskRunCard` + `task_phase_snapshot` 刷新不退化。见 [报告](../test-reports/2026-09-21-taskrun-status-card-persistence-test-report.md)。test/main 未晋升。 4. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`。 ## 并行认领 | 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-workflow-scope-weighting | feature/workflow-scope-weighting-2026-09-21 | .worktrees/workflow-scope-weighting-2026-09-21 | intent+web+docgov | done | | 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 | @@ -185,7 +185,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| Cursor | merging | 2026-09-21 | workflow-scope-weighting;合入 feature/workflow-scope-weighting-2026-09-21 | - | +| - | idle | 2026-09-21 | workflow-scope-weighting 已合入 dev@7723a551 并验收;容器已恢复 strict;schema=61 | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/.doc/test-reports/2026-09-21-workflow-scope-weighting-test-report.md b/.doc/test-reports/2026-09-21-workflow-scope-weighting-test-report.md new file mode 100644 index 00000000..94d73bec --- /dev/null +++ b/.doc/test-reports/2026-09-21-workflow-scope-weighting-test-report.md @@ -0,0 +1,100 @@ +# 工作流作用域权重化 测试报告 + +## 一、 测试背景 (Background) + +上一轮作用域修复把「句子里出现他流文档短语」做成了硬拦截。模糊提法会被他流标题劫持,而且只读查看也无法在权限允许时读到他流文档。本轮改为匹配质量 × 工作流关系权重,并用 `WORKFLOW_SCOPE_MODE` 控制:默认 `strict` 保持硬拦截,`weighted` 才按权重裁决。 + +## 二、 测试目标 (Test Objectives) + +- 默认 `strict` 时,点名他流文档的写动作与 `tasks/run` 仍拦截。 +- `weighted` 时,激活流精确名压过旁边的他流短语;只读精确点名他流文档注明归属;写动作仍拦截。 +- 他流任务键不在当前实例快照内时,不把「看不见」当成显式隐藏;实例已关闭或键被显式隐藏时拒绝。 +- 空 `active_workflow_key` 不按跨流拦截。 +- 不改 `production_ready`,不晋升 test/main,不动项目 143。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:linux。合入 `dev`:`14b5aace`(功能)与 `7723a551`(模板正文读取修正)。 +- dev `8080`,`./scripts/env-service.sh dev rebuild` 后 `/health`:`schema_version=61`,`agent_backend=pi-agent`,`kb_ready=true`。 +- 验收项目:6579,`active_workflow_key=main`。台账 7 条均为 `bd_*`,没有《项目建议书》正文。 +- 开关:容器环境 `WORKFLOW_SCOPE_MODE`。验收后已恢复为 `strict`。 + +## 四、 预期结果 (Expected Results) + +| 模式 | 输入 | 预期 | +| --- | --- | --- | +| strict / weighted | 查看调研报告 | 命中 main 流《调研报告》,不报跨流 | +| strict | 查看项目建议书 | 跨流拦截 | +| weighted | 查看项目建议书 | 记下归属 rdev;台账无正文时说明归属,不执行写 | +| strict / weighted | 编写项目建议书、`tasks/run rd_proposal` | 拦截,`ERR_DOC_WRONG_WORKFLOW` | +| 空激活键 | `tasks/run rd_proposal`(项目 6537) | 不返回 `ERR_DOC_WRONG_WORKFLOW` | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:文档列表带开关 + +- **测试输入**:`GET /api/v1/projects/6579/documents?category=workflow` +- **实际输出**:`active_workflow_key=main`,`workflow_scope_mode` 与容器一致(先 `strict` 后 `weighted`),7 条 `bd_*`,含《调研报告》`bd_research` +- **评价判定**:PASS +- **性能与耗时**:<0.01s + +### 用例 2:查看调研报告 + +- **测试输入**:`POST /api/v1/chat`,项目 6579,「查看调研报告」 +- **实际输出**(strict 与 weighted 相同,约 0.02s):`QUERY_DOC_STATUS`,槽位 `target_doc=调研报告`,回复「《调研报告》目前只有模板骨架,尚无可总结的正式正文」 +- **评价判定**:PASS(命中激活流文档;台账 `ledger_missing`,没有可总结正文) + +### 用例 3:编写项目建议书 + +- **测试输入**:两种模式下「编写项目建议书」 +- **实际输出**:`TRIGGER_TASK`,`workflow_scope_mismatch=true`,回复属于【研发设计控制流程】、当前为【商务对接流程】,已阻止 +- **评价判定**:PASS +- **性能与耗时**:<0.01s + +### 用例 4:tasks/run 跨流 + +- **测试输入**:`POST /api/v1/tasks/run`,`task_key=rd_proposal` +- **实际输出**:HTTP 409 `ERR_DOC_WRONG_WORKFLOW`(strict 与 weighted 相同) +- **评价判定**:PASS + +### 用例 5:weighted 只读跨流 + +- **测试输入**:`WORKFLOW_SCOPE_MODE=weighted`,「查看项目建议书」 +- **实际输出**:`cross_workflow_owner=rdev`,回复「您点名的《项目建议书》属于【研发设计控制流程】,当前激活工作流为【商务对接流程】。当前项目台账里没有可读取的正文。」 +- **评价判定**:PASS(放行裁决并注明归属;无正文故未返回他流正文) + +### 用例 6:空激活键 + +- **测试输入**:项目 6537 `active_workflow_key=""`,`tasks/run rd_proposal` +- **实际输出**:HTTP 500,`workflow stage access denied: task "rd_proposal"`,不是 `ERR_DOC_WRONG_WORKFLOW` +- **评价判定**:PASS + +### 用例 7:他流查看权限 + +- **测试输入**:单测 `TestCrossWorkflowTaskVisible`;live 项目 6579 的成员账号没有可用登录凭证,未做真人受限账号请求 +- **实际输出**:单测覆盖「他流键且调用方能看本实例文档 → 允许」「快照内但不可见 → 拒绝」「可见集合为空 → 拒绝」 +- **评价判定**:PASS(单测);live 受限账号未执行 + +### 用例 8:激活流短语不被他流标题劫持 + +- **测试输入**:`go test ./internal/intent/ -run WorkflowScope` 中 weighted 用例「生成调研报告,不要写成项目建议书」;前端 `templateMatch` 同句 +- **实际输出**:不打 `workflow_scope_mismatch`,任务键留在调研报告。strict 下匹配仍按更长短语优先,该句继续拦截(与硬拦截兼容) +- **评价判定**:PASS(weighted)。live 未再发送该句:首次 weighted 试探曾进入调研任务执行,已在落库前中止,`task_runs` 无对应新行 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +```text +go vet ./... → OK +go test -short ./internal/workflowscope/ ./internal/intent/ ./internal/coordinator/ ./internal/server/ ./internal/config/ ./internal/sysadmin/ ./internal/app/ → ok +web: npm test → 472 pass + vitest 33 pass,0 fail +web: npm run build → OK +/health → schema_version=61, agent_backend=pi-agent, kb_ready=true +``` + +证据:`assets/2026-09-21-workflow-scope-weighting/`。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +可以合入 dev,且已合入并完成 rebuild。默认仍是 `strict`。`weighted` 验收通过后容器已切回 `strict`,未改 `.env` 持久默认值。 + +未做:test/main 晋升;项目 143;工作流切换。受限成员的 live 请求留待有凭证的账号再补。 diff --git a/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/health.json b/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/health.json new file mode 100644 index 00000000..4dd40d2c --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/health.json @@ -0,0 +1 @@ +{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":61,"schema_ready":true,"task_store_ready":true} diff --git a/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/strict-docs.json b/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/strict-docs.json new file mode 100644 index 00000000..547cb115 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/strict-docs.json @@ -0,0 +1,41 @@ +{ + "active_workflow_key": "main", + "workflow_scope_mode": "strict", + "documents": [ + { + "title": "CDO 报价单", + "task_key": "bd_cdo_quote", + "workflow_key": "main" + }, + { + "title": "设计开发可行性评估报告", + "task_key": "bd_feasibility_dev", + "workflow_key": "main" + }, + { + "title": "注册路径可行性评估报告", + "task_key": "bd_feasibility_reg", + "workflow_key": "main" + }, + { + "title": "用户初始需求收集表", + "task_key": "bd_intake", + "workflow_key": "main" + }, + { + "title": "系统初步设计方案", + "task_key": "bd_prelim_design", + "workflow_key": "main" + }, + { + "title": "调研报告", + "task_key": "bd_research", + "workflow_key": "main" + }, + { + "title": "用户需求表", + "task_key": "bd_user_requirements", + "workflow_key": "main" + } + ] +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/strict-summary.json b/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/strict-summary.json new file mode 100644 index 00000000..22bde1ad --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/strict-summary.json @@ -0,0 +1,53 @@ +{ + "mode": "strict", + "docs": { + "status": 200, + "duration_s": 0.005, + "active": "main", + "mode": "strict", + "count": 7, + "titles": [ + "CDO 报价单", + "设计开发可行性评估报告", + "注册路径可行性评估报告", + "用户初始需求收集表", + "系统初步设计方案", + "调研报告", + "用户需求表" + ] + }, + "view_research": { + "status": 200, + "duration_s": 0.02, + "reply": "未找到可访问的目标文档,或您当前无权查看该阶段内容。请确认文档名称后重试。", + "branch": "NEW_INTENT_PREEMPT", + "intent": "QUERY_DOC_STATUS", + "slots": { + "query_topic": "doc_summary", + "target_doc": "调研报告" + } + }, + "write_proposal": { + "status": 200, + "duration_s": 0.007, + "reply": "您提到的文档「项目建议书」属于【研发设计控制流程】,当前激活工作流为【商务对接流程】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。", + "branch": "NEW_INTENT_PREEMPT", + "intent": "TRIGGER_TASK", + "slots": { + "mismatched_doc": "项目建议书", + "mismatched_workflow": "rdev", + "workflow_scope_mismatch": "true" + } + }, + "task_cross": { + "status": 409, + "duration_s": 0.001, + "body": "{\"error_code\":\"ERR_DOC_WRONG_WORKFLOW\",\"message\":\"您提到的文档「rd_proposal」属于【研发设计控制流程】,当前激活工作流为【商务对接流程】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。\"}\n" + }, + "project_active": "main", + "empty_key_task": { + "status": 500, + "duration_s": 0.002, + "body": "{\"task_key\":\"rd_proposal\",\"artifact_title\":\"\",\"markdown_body\":\"\",\"extracted_data\":null,\"attachments\":null,\"work_dir\":\"\",\"backend_used\":\"\",\"duration\":0,\"success\":false,\"error_reason\":\"bizdev: workflow stage access denied: task \\\"rd_proposal\\\"\"}\n" + } +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/strict-view_research.json b/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/strict-view_research.json new file mode 100644 index 00000000..b7758bcb --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/strict-view_research.json @@ -0,0 +1 @@ +{"reply_text":"未找到可访问的目标文档,或您当前无权查看该阶段内容。请确认文档名称后重试。","branch":"NEW_INTENT_PREEMPT","intent_result":{"raw_input":"查看调研报告","top_intent":{"intent":"QUERY_DOC_STATUS","confidence":0.97,"branch":"NEW_INTENT_PREEMPT","filled_slots":{"query_topic":"doc_summary","target_doc":"调研报告"},"reasoning":"用户只读点名受控文档"},"candidates":[{"intent":"QUERY_DOC_STATUS","confidence":0.97,"branch":"NEW_INTENT_PREEMPT","filled_slots":{"query_topic":"doc_summary","target_doc":"调研报告"},"reasoning":"用户只读点名受控文档"},{"intent":"CASUAL_CHAT","confidence":0.2,"branch":"NEW_INTENT_PREEMPT","filled_slots":{},"reasoning":"未匹配到明确业务指令,归入通用对话"}],"context_snapshot_id":"ctx_1789983214503648835","requires_clarifying":false,"branch":"NEW_INTENT_PREEMPT","shadow_score":0.97,"shadow_margin":0.77,"shadow_dialogue_act":"command","shadow_decision_hint":"allowed","intent_frame_v2":{"version":"v2.0","action_key":"document.status.query","domain":"document_gov","object":"controlled_document","goal":"query","dialogue_act":"command","slots":{"query_topic":{"value":"doc_summary","source":"rule"},"target_doc":{"value":"调研报告","source":"rule"}},"relation":"new","evidence":[{"kind":"rule_match","detail":"用户只读点名受控文档","weight":0.97},{"kind":"dialogue_act","detail":"command","weight":0.1}],"score":0.97,"margin":0.77,"legacy_intent":"QUERY_DOC_STATUS"},"ranked_frames":{"candidates":[{"version":"v2.0","action_key":"document.status.query","domain":"document_gov","object":"controlled_document","goal":"query","dialogue_act":"command","slots":{"query_topic":{"value":"doc_summary","source":"rule"},"target_doc":{"value":"调研报告","source":"rule"}},"relation":"new","evidence":[{"kind":"rule_match","detail":"用户只读点名受控文档","weight":0.97}],"score":0.97,"margin":0.77,"legacy_intent":"QUERY_DOC_STATUS"},{"version":"v2.0","action_key":"conversation.chat","domain":"conversation","object":"system_ui","goal":"converse","dialogue_act":"command","relation":"new","evidence":[{"kind":"rule_match","detail":"未匹配到明确业务指令,归入通用对话","weight":0.2}],"score":0.2,"margin":0.77,"legacy_intent":"CASUAL_CHAT"}],"top_margin":0.77},"plan_proposal":{"segmenter_version":"segmenter.v2","digest":"94a046b8668ce8a36f7b0e597e1b32b9cf8eca0a159b458cfdb362052b95779c","combo_hint":"single","segments":[{"step_id":"1","text":"查看调研报告","normalized":"查看调研报告","span":{"start":0,"end":18},"evidence":"single","structure_confidence":"high","frame":{"version":"v2.0","action_key":"document.status.query","domain":"document_gov","object":"controlled_document","goal":"query","dialogue_act":"command","slots":{"query_topic":{"value":"doc_summary","source":"rule"},"target_doc":{"value":"调研报告","source":"rule"}},"relation":"new","evidence":[{"kind":"rule_match","detail":"用户只读点名受控文档","weight":0.97},{"kind":"dialogue_act","detail":"command","weight":0.1}],"score":0.97,"margin":0.77,"legacy_intent":"QUERY_DOC_STATUS"}}],"evidence":[{"kind":"segmenter","detail":"segmenter.v2","weight":1},{"kind":"split","detail":"single","weight":0.5}]},"plan_shadow":{"enabled":true,"segmenter_version":"segmenter.v2","segment_count":1,"digest":"94a046b8668ce8a36f7b0e597e1b32b9cf8eca0a159b458cfdb362052b95779c","combo_hint":"single","legacy_plan_attached":false,"counterfactual_auto_read":"would_passthrough"}},"coordination_result":{"status":"ACCEPTED","intent":"QUERY_DOC_STATUS","natural_reply":"未找到可访问的目标文档,或您当前无权查看该阶段内容。请确认文档名称后重试。","updated_context":{"version":4,"snapshot_id":"ctx_1789983214503648835","assembled_at":"2026-09-21T09:33:34.503650575Z","user":{"id":"u_wf-scope-strict-view_research","name":"用户","role":"engineer","project_role":"composer"},"project":{"id":"","name":"医疗器械研发项目","customer_name":"水木东方客户","device_type":"","intended_use":"","target_market":"","active_workflow_key":"main","current_workflow":"intake","current_stage":"V0.1_triage"},"user_docs":{"composed":null,"reviewed":null,"approved":null,"claimed":null},"pending":{},"blackboard_facts":{},"pending_expectation":{"expires_at":"0001-01-01T00:00:00Z","context_snippet":"项目「医疗器械研发项目」当前处于初始摸底期,基准产品画像已梳理完成。","default_option":{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},"options":[{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},{"option_id":"2","title":"补充或微调事实信息","description":"随时告知我客户的最新的预算、目标市场或使用场景","intent":"SUPPLEMENT_INFO"},{"option_id":"3","title":"查阅当前立项成熟度与对外沟通建议","description":"获取进展研判与对外商务沟通话术","intent":"QUERY_PROGRESS","slots":{"query_topic":"all"}}],"allow_affirmative":true,"created_at":"2026-09-21T09:33:34.503661004Z"},"available_actions":["QUERY_PROGRESS","SUPPLEMENT_INFO","VIEW_DOC","TRIGGER_TASK:intake","ADVANCE_STAGE:V0.5_feasibility"],"task_state":{"active_task":"QUERY_DOC_STATUS","topic_anchor":"查看调研报告","collected_slots":{"query_topic":"doc_summary","target_doc":"调研报告"},"missing_slots":["device_type","target_market","budget"],"updated_at":"2026-09-21T09:33:34.517539692Z"}},"reply_source":"template"},"pending_expectation":{"expires_at":"0001-01-01T00:00:00Z","context_snippet":"项目「医疗器械研发项目」当前处于初始摸底期,基准产品画像已梳理完成。","default_option":{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},"options":[{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},{"option_id":"2","title":"补充或微调事实信息","description":"随时告知我客户的最新的预算、目标市场或使用场景","intent":"SUPPLEMENT_INFO"},{"option_id":"3","title":"查阅当前立项成熟度与对外沟通建议","description":"获取进展研判与对外商务沟通话术","intent":"QUERY_PROGRESS","slots":{"query_topic":"all"}}],"allow_affirmative":true,"created_at":"2026-09-21T09:33:34.503661004Z"}} diff --git a/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/strict-write_proposal.json b/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/strict-write_proposal.json new file mode 100644 index 00000000..483c796b --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/strict-write_proposal.json @@ -0,0 +1 @@ +{"reply_text":"您提到的文档「项目建议书」属于【研发设计控制流程】,当前激活工作流为【商务对接流程】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。","branch":"NEW_INTENT_PREEMPT","intent_result":{"raw_input":"编写项目建议书","top_intent":{"intent":"TRIGGER_TASK","confidence":0.86,"branch":"NEW_INTENT_PREEMPT","filled_slots":{"mismatched_doc":"项目建议书","mismatched_workflow":"rdev","workflow_scope_mismatch":"true"},"reasoning":"用户要求生成交付任务 项目建议书,但该文档属于非激活工作流,前置剪枝","clarification_question":"您提到的文档「项目建议书」属于【研发设计控制流程】,当前激活工作流为【商务对接流程】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。"},"candidates":[{"intent":"TRIGGER_TASK","confidence":0.86,"branch":"NEW_INTENT_PREEMPT","filled_slots":{"mismatched_doc":"项目建议书","mismatched_workflow":"rdev","workflow_scope_mismatch":"true"},"reasoning":"用户要求生成交付任务 项目建议书,但该文档属于非激活工作流,前置剪枝","clarification_question":"您提到的文档「项目建议书」属于【研发设计控制流程】,当前激活工作流为【商务对接流程】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。"},{"intent":"CASUAL_CHAT","confidence":0.2,"branch":"NEW_INTENT_PREEMPT","filled_slots":{},"reasoning":"未匹配到明确业务指令,归入通用对话"}],"context_snapshot_id":"ctx_1789983214523290642","requires_clarifying":true,"branch":"NEW_INTENT_PREEMPT","shadow_score":0.86,"shadow_margin":0.6599999999999999,"shadow_dialogue_act":"command","shadow_decision_hint":"needs_clarification","intent_frame_v2":{"version":"v2.0","action_key":"task.run","domain":"project","object":"task","goal":"generate","dialogue_act":"command","slots":{"mismatched_doc":{"value":"项目建议书","source":"rule"},"mismatched_workflow":{"value":"rdev","source":"rule"},"workflow_scope_mismatch":{"value":"true","source":"rule"}},"relation":"new","evidence":[{"kind":"rule_match","detail":"用户要求生成交付任务 项目建议书,但该文档属于非激活工作流,前置剪枝","weight":0.86},{"kind":"dialogue_act","detail":"command","weight":0.1}],"score":0.86,"margin":0.6599999999999999,"legacy_intent":"TRIGGER_TASK"},"ranked_frames":{"candidates":[{"version":"v2.0","action_key":"task.run","domain":"project","object":"task","goal":"generate","dialogue_act":"command","slots":{"mismatched_doc":{"value":"项目建议书","source":"rule"},"mismatched_workflow":{"value":"rdev","source":"rule"},"workflow_scope_mismatch":{"value":"true","source":"rule"}},"relation":"new","evidence":[{"kind":"rule_match","detail":"用户要求生成交付任务 项目建议书,但该文档属于非激活工作流,前置剪枝","weight":0.86}],"score":0.86,"margin":0.6599999999999999,"legacy_intent":"TRIGGER_TASK"},{"version":"v2.0","action_key":"conversation.chat","domain":"conversation","object":"system_ui","goal":"converse","dialogue_act":"command","relation":"new","evidence":[{"kind":"rule_match","detail":"未匹配到明确业务指令,归入通用对话","weight":0.2}],"score":0.2,"margin":0.6599999999999999,"legacy_intent":"CASUAL_CHAT"}],"top_margin":0.6599999999999999},"plan_proposal":{"segmenter_version":"segmenter.v2","digest":"d85acb04a51583a86e3498ad5460f96c3ef05e3ce3c6a2fc63e118ba7d63a8ea","combo_hint":"single","segments":[{"step_id":"1","text":"编写项目建议书","normalized":"编写项目建议书","span":{"start":0,"end":21},"evidence":"single","structure_confidence":"high","frame":{"version":"v2.0","action_key":"task.run","domain":"project","object":"task","goal":"generate","dialogue_act":"command","slots":{"mismatched_doc":{"value":"项目建议书","source":"rule"},"mismatched_workflow":{"value":"rdev","source":"rule"},"workflow_scope_mismatch":{"value":"true","source":"rule"}},"relation":"new","evidence":[{"kind":"rule_match","detail":"用户要求生成交付任务 项目建议书,但该文档属于非激活工作流,前置剪枝","weight":0.86},{"kind":"dialogue_act","detail":"command","weight":0.1}],"score":0.86,"margin":0.6599999999999999,"legacy_intent":"TRIGGER_TASK"}}],"evidence":[{"kind":"segmenter","detail":"segmenter.v2","weight":1},{"kind":"split","detail":"single","weight":0.5}]},"plan_shadow":{"enabled":true,"segmenter_version":"segmenter.v2","segment_count":1,"digest":"d85acb04a51583a86e3498ad5460f96c3ef05e3ce3c6a2fc63e118ba7d63a8ea","combo_hint":"single","legacy_plan_attached":false,"legacy_dropped_write":true,"counterfactual_auto_read":"would_confirm"}},"coordination_result":{"status":"CLARIFICATION_REQUIRED","intent":"TRIGGER_TASK","branch":"NEW_INTENT_PREEMPT","clarification_prompt":"您提到的文档「项目建议书」属于【研发设计控制流程】,当前激活工作流为【商务对接流程】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。","natural_reply":"您提到的文档「项目建议书」属于【研发设计控制流程】,当前激活工作流为【商务对接流程】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。","updated_context":{"version":4,"snapshot_id":"ctx_1789983214523290642","assembled_at":"2026-09-21T09:33:34.523291351Z","user":{"id":"u_wf-scope-strict-write_proposal","name":"用户","role":"engineer","project_role":"composer"},"project":{"id":"","name":"医疗器械研发项目","customer_name":"水木东方客户","device_type":"","intended_use":"","target_market":"","active_workflow_key":"main","current_workflow":"intake","current_stage":"V0.1_triage"},"user_docs":{"composed":null,"reviewed":null,"approved":null,"claimed":null},"pending":{},"blackboard_facts":{},"pending_expectation":{"expires_at":"0001-01-01T00:00:00Z","context_snippet":"项目「医疗器械研发项目」当前处于初始摸底期,基准产品画像已梳理完成。","default_option":{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},"options":[{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},{"option_id":"2","title":"补充或微调事实信息","description":"随时告知我客户的最新的预算、目标市场或使用场景","intent":"SUPPLEMENT_INFO"},{"option_id":"3","title":"查阅当前立项成熟度与对外沟通建议","description":"获取进展研判与对外商务沟通话术","intent":"QUERY_PROGRESS","slots":{"query_topic":"all"}}],"allow_affirmative":true,"created_at":"2026-09-21T09:33:34.523294981Z"},"available_actions":["QUERY_PROGRESS","SUPPLEMENT_INFO","VIEW_DOC","TRIGGER_TASK:intake","ADVANCE_STAGE:V0.5_feasibility"],"task_state":{"active_task":"TRIGGER_TASK","topic_anchor":"编写项目建议书","collected_slots":{"mismatched_doc":"项目建议书","mismatched_workflow":"rdev","workflow_scope_mismatch":"true"},"missing_slots":["device_type","target_market","budget"],"updated_at":"2026-09-21T09:33:34.528127848Z"}}},"pending_expectation":{"expires_at":"0001-01-01T00:00:00Z","context_snippet":"项目「医疗器械研发项目」当前处于初始摸底期,基准产品画像已梳理完成。","default_option":{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},"options":[{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},{"option_id":"2","title":"补充或微调事实信息","description":"随时告知我客户的最新的预算、目标市场或使用场景","intent":"SUPPLEMENT_INFO"},{"option_id":"3","title":"查阅当前立项成熟度与对外沟通建议","description":"获取进展研判与对外商务沟通话术","intent":"QUERY_PROGRESS","slots":{"query_topic":"all"}}],"allow_affirmative":true,"created_at":"2026-09-21T09:33:34.523294981Z"}} diff --git a/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/weighted-summary.json b/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/weighted-summary.json new file mode 100644 index 00000000..d8f979b9 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/weighted-summary.json @@ -0,0 +1,53 @@ +{ + "mode": "weighted", + "health": { + "status": "ok", + "env": "dev", + "agent_backend": "pi-agent", + "version": "v1.0", + "shared_kb_port": 5432, + "kb_ready": true, + "database_ready": true, + "schema_version": 61, + "schema_ready": true, + "task_store_ready": true + }, + "docs": { + "mode": "weighted", + "active": "main", + "count": 7 + }, + "view_research": { + "status": 200, + "intent": "QUERY_DOC_STATUS", + "slots": { + "query_topic": "doc_summary", + "target_doc": "调研报告" + }, + "reply": "📄 《调研报告》目前只有模板骨架,尚无可总结的正式正文。当前状态:drafting,版本:v1。" + }, + "view_proposal": { + "status": 200, + "intent": "QUERY_DOC_STATUS", + "slots": { + "cross_workflow_owner": "rdev", + "query_topic": "doc_summary", + "target_doc": "项目建议书" + }, + "reply": "您点名的《项目建议书》属于【研发设计控制流程】,当前激活工作流为【商务对接流程】。当前项目台账里没有可读取的正文。" + }, + "write_proposal": { + "status": 200, + "intent": "TRIGGER_TASK", + "slots": { + "mismatched_doc": "项目建议书", + "mismatched_workflow": "rdev", + "workflow_scope_mismatch": "true" + }, + "reply": "您提到的文档「项目建议书」属于【研发设计控制流程】,当前激活工作流为【商务对接流程】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。" + }, + "task_cross": { + "status": 409, + "body": "{\"error_code\":\"ERR_DOC_WRONG_WORKFLOW\",\"message\":\"您提到的文档「rd_proposal」属于【研发设计控制流程】,当前激活工作流为【商务对接流程】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。\"}\n" + } +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/weighted-view_proposal.json b/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/weighted-view_proposal.json new file mode 100644 index 00000000..1cce8a2c --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/weighted-view_proposal.json @@ -0,0 +1 @@ +{"reply_text":"您点名的《项目建议书》属于【研发设计控制流程】,当前激活工作流为【商务对接流程】。当前项目台账里没有可读取的正文。","branch":"NEW_INTENT_PREEMPT","intent_result":{"raw_input":"查看项目建议书","top_intent":{"intent":"QUERY_DOC_STATUS","confidence":0.97,"branch":"NEW_INTENT_PREEMPT","filled_slots":{"cross_workflow_owner":"rdev","query_topic":"doc_summary","target_doc":"项目建议书"},"reasoning":"用户只读点名受控文档"},"candidates":[{"intent":"QUERY_DOC_STATUS","confidence":0.97,"branch":"NEW_INTENT_PREEMPT","filled_slots":{"cross_workflow_owner":"rdev","query_topic":"doc_summary","target_doc":"项目建议书"},"reasoning":"用户只读点名受控文档"},{"intent":"CASUAL_CHAT","confidence":0.2,"branch":"NEW_INTENT_PREEMPT","filled_slots":{},"reasoning":"未匹配到明确业务指令,归入通用对话"}],"context_snapshot_id":"ctx_1789983778236835109","requires_clarifying":false,"branch":"NEW_INTENT_PREEMPT","shadow_score":0.97,"shadow_margin":0.77,"shadow_dialogue_act":"command","shadow_decision_hint":"allowed","intent_frame_v2":{"version":"v2.0","action_key":"document.status.query","domain":"document_gov","object":"controlled_document","goal":"query","dialogue_act":"command","slots":{"cross_workflow_owner":{"value":"rdev","source":"rule"},"query_topic":{"value":"doc_summary","source":"rule"},"target_doc":{"value":"项目建议书","source":"rule"}},"relation":"new","evidence":[{"kind":"rule_match","detail":"用户只读点名受控文档","weight":0.97},{"kind":"dialogue_act","detail":"command","weight":0.1}],"score":0.97,"margin":0.77,"legacy_intent":"QUERY_DOC_STATUS"},"ranked_frames":{"candidates":[{"version":"v2.0","action_key":"document.status.query","domain":"document_gov","object":"controlled_document","goal":"query","dialogue_act":"command","slots":{"cross_workflow_owner":{"value":"rdev","source":"rule"},"query_topic":{"value":"doc_summary","source":"rule"},"target_doc":{"value":"项目建议书","source":"rule"}},"relation":"new","evidence":[{"kind":"rule_match","detail":"用户只读点名受控文档","weight":0.97}],"score":0.97,"margin":0.77,"legacy_intent":"QUERY_DOC_STATUS"},{"version":"v2.0","action_key":"conversation.chat","domain":"conversation","object":"system_ui","goal":"converse","dialogue_act":"command","relation":"new","evidence":[{"kind":"rule_match","detail":"未匹配到明确业务指令,归入通用对话","weight":0.2}],"score":0.2,"margin":0.77,"legacy_intent":"CASUAL_CHAT"}],"top_margin":0.77},"plan_proposal":{"segmenter_version":"segmenter.v2","digest":"d3fbe58dff2727d4073df4cd1480b03b8eb0caea2d7bf20d95b81b57d8f44850","combo_hint":"single","segments":[{"step_id":"1","text":"查看项目建议书","normalized":"查看项目建议书","span":{"start":0,"end":21},"evidence":"single","structure_confidence":"high","frame":{"version":"v2.0","action_key":"document.status.query","domain":"document_gov","object":"controlled_document","goal":"query","dialogue_act":"command","slots":{"cross_workflow_owner":{"value":"rdev","source":"rule"},"query_topic":{"value":"doc_summary","source":"rule"},"target_doc":{"value":"项目建议书","source":"rule"}},"relation":"new","evidence":[{"kind":"rule_match","detail":"用户只读点名受控文档","weight":0.97},{"kind":"dialogue_act","detail":"command","weight":0.1}],"score":0.97,"margin":0.77,"legacy_intent":"QUERY_DOC_STATUS"}}],"evidence":[{"kind":"segmenter","detail":"segmenter.v2","weight":1},{"kind":"split","detail":"single","weight":0.5}]},"plan_shadow":{"enabled":true,"segmenter_version":"segmenter.v2","segment_count":1,"digest":"d3fbe58dff2727d4073df4cd1480b03b8eb0caea2d7bf20d95b81b57d8f44850","combo_hint":"single","legacy_plan_attached":false,"counterfactual_auto_read":"would_passthrough"}},"coordination_result":{"status":"ACCEPTED","intent":"QUERY_DOC_STATUS","natural_reply":"您点名的《项目建议书》属于【研发设计控制流程】,当前激活工作流为【商务对接流程】。当前项目台账里没有可读取的正文。","updated_context":{"version":4,"snapshot_id":"ctx_1789983778236835109","assembled_at":"2026-09-21T09:42:58.236837049Z","user":{"id":"u_wf-scope-w2-view_proposal","name":"用户","role":"engineer","project_role":"composer"},"project":{"id":"","name":"医疗器械研发项目","customer_name":"水木东方客户","device_type":"","intended_use":"","target_market":"","active_workflow_key":"main","current_workflow":"intake","current_stage":"V0.1_triage"},"user_docs":{"composed":null,"reviewed":null,"approved":null,"claimed":null},"pending":{},"blackboard_facts":{},"pending_expectation":{"expires_at":"0001-01-01T00:00:00Z","context_snippet":"项目「医疗器械研发项目」当前处于初始摸底期,基准产品画像已梳理完成。","default_option":{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},"options":[{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},{"option_id":"2","title":"补充或微调事实信息","description":"随时告知我客户的最新的预算、目标市场或使用场景","intent":"SUPPLEMENT_INFO"},{"option_id":"3","title":"查阅当前立项成熟度与对外沟通建议","description":"获取进展研判与对外商务沟通话术","intent":"QUERY_PROGRESS","slots":{"query_topic":"all"}}],"allow_affirmative":true,"created_at":"2026-09-21T09:42:58.236842409Z"},"available_actions":["QUERY_PROGRESS","SUPPLEMENT_INFO","VIEW_DOC","TRIGGER_TASK:intake","ADVANCE_STAGE:V0.5_feasibility"],"task_state":{"active_task":"QUERY_DOC_STATUS","topic_anchor":"查看项目建议书","collected_slots":{"cross_workflow_owner":"rdev","query_topic":"doc_summary","target_doc":"项目建议书"},"missing_slots":["device_type","target_market","budget"],"updated_at":"2026-09-21T09:42:58.241873299Z"}},"reply_source":"template"},"pending_expectation":{"expires_at":"0001-01-01T00:00:00Z","context_snippet":"项目「医疗器械研发项目」当前处于初始摸底期,基准产品画像已梳理完成。","default_option":{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},"options":[{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},{"option_id":"2","title":"补充或微调事实信息","description":"随时告知我客户的最新的预算、目标市场或使用场景","intent":"SUPPLEMENT_INFO"},{"option_id":"3","title":"查阅当前立项成熟度与对外沟通建议","description":"获取进展研判与对外商务沟通话术","intent":"QUERY_PROGRESS","slots":{"query_topic":"all"}}],"allow_affirmative":true,"created_at":"2026-09-21T09:42:58.236842409Z"}} diff --git a/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/weighted-view_research.json b/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/weighted-view_research.json new file mode 100644 index 00000000..bafc37a2 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/weighted-view_research.json @@ -0,0 +1 @@ +{"reply_text":"📄 《调研报告》目前只有模板骨架,尚无可总结的正式正文。当前状态:drafting,版本:v1。","branch":"NEW_INTENT_PREEMPT","intent_result":{"raw_input":"查看调研报告","top_intent":{"intent":"QUERY_DOC_STATUS","confidence":0.97,"branch":"NEW_INTENT_PREEMPT","filled_slots":{"query_topic":"doc_summary","target_doc":"调研报告"},"reasoning":"用户只读点名受控文档"},"candidates":[{"intent":"QUERY_DOC_STATUS","confidence":0.97,"branch":"NEW_INTENT_PREEMPT","filled_slots":{"query_topic":"doc_summary","target_doc":"调研报告"},"reasoning":"用户只读点名受控文档"},{"intent":"CASUAL_CHAT","confidence":0.2,"branch":"NEW_INTENT_PREEMPT","filled_slots":{},"reasoning":"未匹配到明确业务指令,归入通用对话"}],"context_snapshot_id":"ctx_1789983778223852335","requires_clarifying":false,"branch":"NEW_INTENT_PREEMPT","shadow_score":0.97,"shadow_margin":0.77,"shadow_dialogue_act":"command","shadow_decision_hint":"allowed","intent_frame_v2":{"version":"v2.0","action_key":"document.status.query","domain":"document_gov","object":"controlled_document","goal":"query","dialogue_act":"command","slots":{"query_topic":{"value":"doc_summary","source":"rule"},"target_doc":{"value":"调研报告","source":"rule"}},"relation":"new","evidence":[{"kind":"rule_match","detail":"用户只读点名受控文档","weight":0.97},{"kind":"dialogue_act","detail":"command","weight":0.1}],"score":0.97,"margin":0.77,"legacy_intent":"QUERY_DOC_STATUS"},"ranked_frames":{"candidates":[{"version":"v2.0","action_key":"document.status.query","domain":"document_gov","object":"controlled_document","goal":"query","dialogue_act":"command","slots":{"query_topic":{"value":"doc_summary","source":"rule"},"target_doc":{"value":"调研报告","source":"rule"}},"relation":"new","evidence":[{"kind":"rule_match","detail":"用户只读点名受控文档","weight":0.97}],"score":0.97,"margin":0.77,"legacy_intent":"QUERY_DOC_STATUS"},{"version":"v2.0","action_key":"conversation.chat","domain":"conversation","object":"system_ui","goal":"converse","dialogue_act":"command","relation":"new","evidence":[{"kind":"rule_match","detail":"未匹配到明确业务指令,归入通用对话","weight":0.2}],"score":0.2,"margin":0.77,"legacy_intent":"CASUAL_CHAT"}],"top_margin":0.77},"plan_proposal":{"segmenter_version":"segmenter.v2","digest":"94a046b8668ce8a36f7b0e597e1b32b9cf8eca0a159b458cfdb362052b95779c","combo_hint":"single","segments":[{"step_id":"1","text":"查看调研报告","normalized":"查看调研报告","span":{"start":0,"end":18},"evidence":"single","structure_confidence":"high","frame":{"version":"v2.0","action_key":"document.status.query","domain":"document_gov","object":"controlled_document","goal":"query","dialogue_act":"command","slots":{"query_topic":{"value":"doc_summary","source":"rule"},"target_doc":{"value":"调研报告","source":"rule"}},"relation":"new","evidence":[{"kind":"rule_match","detail":"用户只读点名受控文档","weight":0.97},{"kind":"dialogue_act","detail":"command","weight":0.1}],"score":0.97,"margin":0.77,"legacy_intent":"QUERY_DOC_STATUS"}}],"evidence":[{"kind":"segmenter","detail":"segmenter.v2","weight":1},{"kind":"split","detail":"single","weight":0.5}]},"plan_shadow":{"enabled":true,"segmenter_version":"segmenter.v2","segment_count":1,"digest":"94a046b8668ce8a36f7b0e597e1b32b9cf8eca0a159b458cfdb362052b95779c","combo_hint":"single","legacy_plan_attached":false,"counterfactual_auto_read":"would_passthrough"}},"coordination_result":{"status":"ACCEPTED","intent":"QUERY_DOC_STATUS","natural_reply":"📄 《调研报告》目前只有模板骨架,尚无可总结的正式正文。当前状态:drafting,版本:v1。","updated_context":{"version":4,"snapshot_id":"ctx_1789983778223852335","assembled_at":"2026-09-21T09:42:58.223857294Z","user":{"id":"u_wf-scope-w2-view_research","name":"用户","role":"engineer","project_role":"composer"},"project":{"id":"","name":"医疗器械研发项目","customer_name":"水木东方客户","device_type":"","intended_use":"","target_market":"","active_workflow_key":"main","current_workflow":"intake","current_stage":"V0.1_triage"},"user_docs":{"composed":null,"reviewed":null,"approved":null,"claimed":null},"pending":{},"blackboard_facts":{},"pending_expectation":{"expires_at":"0001-01-01T00:00:00Z","context_snippet":"项目「医疗器械研发项目」当前处于初始摸底期,基准产品画像已梳理完成。","default_option":{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},"options":[{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},{"option_id":"2","title":"补充或微调事实信息","description":"随时告知我客户的最新的预算、目标市场或使用场景","intent":"SUPPLEMENT_INFO"},{"option_id":"3","title":"查阅当前立项成熟度与对外沟通建议","description":"获取进展研判与对外商务沟通话术","intent":"QUERY_PROGRESS","slots":{"query_topic":"all"}}],"allow_affirmative":true,"created_at":"2026-09-21T09:42:58.223865914Z"},"available_actions":["QUERY_PROGRESS","SUPPLEMENT_INFO","VIEW_DOC","TRIGGER_TASK:intake","ADVANCE_STAGE:V0.5_feasibility"],"task_state":{"active_task":"QUERY_DOC_STATUS","topic_anchor":"查看调研报告","collected_slots":{"query_topic":"doc_summary","target_doc":"调研报告"},"missing_slots":["device_type","target_market","budget"],"updated_at":"2026-09-21T09:42:58.232031052Z"}},"reply_source":"template"},"pending_expectation":{"expires_at":"0001-01-01T00:00:00Z","context_snippet":"项目「医疗器械研发项目」当前处于初始摸底期,基准产品画像已梳理完成。","default_option":{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},"options":[{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},{"option_id":"2","title":"补充或微调事实信息","description":"随时告知我客户的最新的预算、目标市场或使用场景","intent":"SUPPLEMENT_INFO"},{"option_id":"3","title":"查阅当前立项成熟度与对外沟通建议","description":"获取进展研判与对外商务沟通话术","intent":"QUERY_PROGRESS","slots":{"query_topic":"all"}}],"allow_affirmative":true,"created_at":"2026-09-21T09:42:58.223865914Z"}} diff --git a/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/weighted-write_proposal.json b/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/weighted-write_proposal.json new file mode 100644 index 00000000..5b39e234 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-workflow-scope-weighting/weighted-write_proposal.json @@ -0,0 +1 @@ +{"reply_text":"您提到的文档「项目建议书」属于【研发设计控制流程】,当前激活工作流为【商务对接流程】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。","branch":"NEW_INTENT_PREEMPT","intent_result":{"raw_input":"编写项目建议书","top_intent":{"intent":"TRIGGER_TASK","confidence":0.86,"branch":"NEW_INTENT_PREEMPT","filled_slots":{"mismatched_doc":"项目建议书","mismatched_workflow":"rdev","workflow_scope_mismatch":"true"},"reasoning":"用户要求生成交付任务 项目建议书,但该文档属于非激活工作流,前置剪枝","clarification_question":"您提到的文档「项目建议书」属于【研发设计控制流程】,当前激活工作流为【商务对接流程】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。"},"candidates":[{"intent":"TRIGGER_TASK","confidence":0.86,"branch":"NEW_INTENT_PREEMPT","filled_slots":{"mismatched_doc":"项目建议书","mismatched_workflow":"rdev","workflow_scope_mismatch":"true"},"reasoning":"用户要求生成交付任务 项目建议书,但该文档属于非激活工作流,前置剪枝","clarification_question":"您提到的文档「项目建议书」属于【研发设计控制流程】,当前激活工作流为【商务对接流程】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。"},{"intent":"CASUAL_CHAT","confidence":0.2,"branch":"NEW_INTENT_PREEMPT","filled_slots":{},"reasoning":"未匹配到明确业务指令,归入通用对话"}],"context_snapshot_id":"ctx_1789983778244886720","requires_clarifying":true,"branch":"NEW_INTENT_PREEMPT","shadow_score":0.86,"shadow_margin":0.6599999999999999,"shadow_dialogue_act":"command","shadow_decision_hint":"needs_clarification","intent_frame_v2":{"version":"v2.0","action_key":"task.run","domain":"project","object":"task","goal":"generate","dialogue_act":"command","slots":{"mismatched_doc":{"value":"项目建议书","source":"rule"},"mismatched_workflow":{"value":"rdev","source":"rule"},"workflow_scope_mismatch":{"value":"true","source":"rule"}},"relation":"new","evidence":[{"kind":"rule_match","detail":"用户要求生成交付任务 项目建议书,但该文档属于非激活工作流,前置剪枝","weight":0.86},{"kind":"dialogue_act","detail":"command","weight":0.1}],"score":0.86,"margin":0.6599999999999999,"legacy_intent":"TRIGGER_TASK"},"ranked_frames":{"candidates":[{"version":"v2.0","action_key":"task.run","domain":"project","object":"task","goal":"generate","dialogue_act":"command","slots":{"mismatched_doc":{"value":"项目建议书","source":"rule"},"mismatched_workflow":{"value":"rdev","source":"rule"},"workflow_scope_mismatch":{"value":"true","source":"rule"}},"relation":"new","evidence":[{"kind":"rule_match","detail":"用户要求生成交付任务 项目建议书,但该文档属于非激活工作流,前置剪枝","weight":0.86}],"score":0.86,"margin":0.6599999999999999,"legacy_intent":"TRIGGER_TASK"},{"version":"v2.0","action_key":"conversation.chat","domain":"conversation","object":"system_ui","goal":"converse","dialogue_act":"command","relation":"new","evidence":[{"kind":"rule_match","detail":"未匹配到明确业务指令,归入通用对话","weight":0.2}],"score":0.2,"margin":0.6599999999999999,"legacy_intent":"CASUAL_CHAT"}],"top_margin":0.6599999999999999},"plan_proposal":{"segmenter_version":"segmenter.v2","digest":"d85acb04a51583a86e3498ad5460f96c3ef05e3ce3c6a2fc63e118ba7d63a8ea","combo_hint":"single","segments":[{"step_id":"1","text":"编写项目建议书","normalized":"编写项目建议书","span":{"start":0,"end":21},"evidence":"single","structure_confidence":"high","frame":{"version":"v2.0","action_key":"task.run","domain":"project","object":"task","goal":"generate","dialogue_act":"command","slots":{"mismatched_doc":{"value":"项目建议书","source":"rule"},"mismatched_workflow":{"value":"rdev","source":"rule"},"workflow_scope_mismatch":{"value":"true","source":"rule"}},"relation":"new","evidence":[{"kind":"rule_match","detail":"用户要求生成交付任务 项目建议书,但该文档属于非激活工作流,前置剪枝","weight":0.86},{"kind":"dialogue_act","detail":"command","weight":0.1}],"score":0.86,"margin":0.6599999999999999,"legacy_intent":"TRIGGER_TASK"}}],"evidence":[{"kind":"segmenter","detail":"segmenter.v2","weight":1},{"kind":"split","detail":"single","weight":0.5}]},"plan_shadow":{"enabled":true,"segmenter_version":"segmenter.v2","segment_count":1,"digest":"d85acb04a51583a86e3498ad5460f96c3ef05e3ce3c6a2fc63e118ba7d63a8ea","combo_hint":"single","legacy_plan_attached":false,"legacy_dropped_write":true,"counterfactual_auto_read":"would_confirm"}},"coordination_result":{"status":"CLARIFICATION_REQUIRED","intent":"TRIGGER_TASK","branch":"NEW_INTENT_PREEMPT","clarification_prompt":"您提到的文档「项目建议书」属于【研发设计控制流程】,当前激活工作流为【商务对接流程】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。","natural_reply":"您提到的文档「项目建议书」属于【研发设计控制流程】,当前激活工作流为【商务对接流程】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。","updated_context":{"version":4,"snapshot_id":"ctx_1789983778244886720","assembled_at":"2026-09-21T09:42:58.24488877Z","user":{"id":"u_wf-scope-w2-write_proposal","name":"用户","role":"engineer","project_role":"composer"},"project":{"id":"","name":"医疗器械研发项目","customer_name":"水木东方客户","device_type":"","intended_use":"","target_market":"","active_workflow_key":"main","current_workflow":"intake","current_stage":"V0.1_triage"},"user_docs":{"composed":null,"reviewed":null,"approved":null,"claimed":null},"pending":{},"blackboard_facts":{},"pending_expectation":{"expires_at":"0001-01-01T00:00:00Z","context_snippet":"项目「医疗器械研发项目」当前处于初始摸底期,基准产品画像已梳理完成。","default_option":{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},"options":[{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},{"option_id":"2","title":"补充或微调事实信息","description":"随时告知我客户的最新的预算、目标市场或使用场景","intent":"SUPPLEMENT_INFO"},{"option_id":"3","title":"查阅当前立项成熟度与对外沟通建议","description":"获取进展研判与对外商务沟通话术","intent":"QUERY_PROGRESS","slots":{"query_topic":"all"}}],"allow_affirmative":true,"created_at":"2026-09-21T09:42:58.24489234Z"},"available_actions":["QUERY_PROGRESS","SUPPLEMENT_INFO","VIEW_DOC","TRIGGER_TASK:intake","ADVANCE_STAGE:V0.5_feasibility"],"task_state":{"active_task":"TRIGGER_TASK","topic_anchor":"编写项目建议书","collected_slots":{"mismatched_doc":"项目建议书","mismatched_workflow":"rdev","workflow_scope_mismatch":"true"},"missing_slots":["device_type","target_market","budget"],"updated_at":"2026-09-21T09:42:58.249897381Z"}}},"pending_expectation":{"expires_at":"0001-01-01T00:00:00Z","context_snippet":"项目「医疗器械研发项目」当前处于初始摸底期,基准产品画像已梳理完成。","default_option":{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},"options":[{"option_id":"1","title":"进入 V0.5 方案成型与法规对齐","description":"自动比选技术路线,并梳理分类路径与型检标准","intent":"ADVANCE_STAGE","slots":{"target_stage":"V0.5_feasibility"}},{"option_id":"2","title":"补充或微调事实信息","description":"随时告知我客户的最新的预算、目标市场或使用场景","intent":"SUPPLEMENT_INFO"},{"option_id":"3","title":"查阅当前立项成熟度与对外沟通建议","description":"获取进展研判与对外商务沟通话术","intent":"QUERY_PROGRESS","slots":{"query_topic":"all"}}],"allow_affirmative":true,"created_at":"2026-09-21T09:42:58.24489234Z"}} From 5f9fbb9923059664efa9e34cd11f4c222f47c046 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:47:06 +0000 Subject: [PATCH 13/20] fix(server): gate parse enqueue to store-backed pipeline Co-authored-by: Cursor --- internal/materialindex/pipeline.go | 8 +++++++- internal/server/asset_handlers.go | 6 +++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/materialindex/pipeline.go b/internal/materialindex/pipeline.go index 8cf895a9..8ed71555 100644 --- a/internal/materialindex/pipeline.go +++ b/internal/materialindex/pipeline.go @@ -125,6 +125,12 @@ func (p *Pipeline) Enabled() bool { return p != nil && p.client != nil && p.client.Enabled() } +// HasStore reports whether parse state persists (PostgreSQL available). +// Without a store, async parse would hit the live API with no state machine. +func (p *Pipeline) HasStore() bool { + return p != nil && p.pool != nil +} + // FulltextMaxBytes returns the hybrid injection threshold. func (p *Pipeline) FulltextMaxBytes() int { if p == nil || p.fullMax <= 0 { @@ -267,7 +273,7 @@ func (p *Pipeline) EnsureParsed(ctx context.Context, fileName string, blobSHA st // EnqueueAsync starts background parse without blocking the caller. func (p *Pipeline) EnqueueAsync(fileName string, blobSHA string, data []byte, projectID int64) { - if p == nil || !p.Enabled() || !mineru.SupportsExt(fileName) || blobSHA == "" || len(data) == 0 { + if p == nil || !p.Enabled() || !p.HasStore() || !mineru.SupportsExt(fileName) || blobSHA == "" || len(data) == 0 { return } go func() { diff --git a/internal/server/asset_handlers.go b/internal/server/asset_handlers.go index 106acb93..5efed070 100644 --- a/internal/server/asset_handlers.go +++ b/internal/server/asset_handlers.go @@ -84,7 +84,7 @@ func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request } parseState := "" - if matIdx := bizdev.Snapshot(s.svc).MaterialIndex; matIdx != nil && matIdx.Enabled() { + if matIdx := bizdev.Snapshot(s.svc).MaterialIndex; matIdx != nil && matIdx.Enabled() && matIdx.HasStore() { if mineru.SupportsExt(ref.FileName) { matIdx.EnqueueAsync(ref.FileName, ref.BlobSHA256, data, projectID) // Report the real state: same-content uploads hit an existing parse @@ -141,7 +141,7 @@ func (s *Server) handleReparseProjectAsset(w http.ResponseWriter, r *http.Reques return } matIdx := bizdev.Snapshot(s.svc).MaterialIndex - if matIdx == nil || !matIdx.Enabled() { + if matIdx == nil || !matIdx.Enabled() || !matIdx.HasStore() { writeSafeError(w, r, http.StatusServiceUnavailable, "parse engine unavailable") return } @@ -225,7 +225,7 @@ func (s *Server) handleListProjectAssets(w http.ResponseWriter, r *http.Request) // 内容已入库但从未触发解析(如早于 MinerU 接入的历史资产): // 列表读取时自动补触发,前端轮询即可看到 pending→running→done。 seenSHA[ref.BlobSHA256] = true - if matIdx := bizdev.Snapshot(s.svc).MaterialIndex; matIdx != nil && matIdx.Enabled() && mineru.SupportsExt(ref.FileName) { + if matIdx := bizdev.Snapshot(s.svc).MaterialIndex; matIdx != nil && matIdx.Enabled() && matIdx.HasStore() && mineru.SupportsExt(ref.FileName) { if data, _, err := cas.ReadRefBytes(r.Context(), ref.ID); err == nil { matIdx.EnqueueAsync(ref.FileName, ref.BlobSHA256, data, projectID) item.ParseState = "pending" From b867f4f19c6d83bf67c6cce393fedbeb36e7ba8e Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:47:06 +0000 Subject: [PATCH 14/20] test: cover dedup hint, reparse guard, store gate and CoW contract Co-authored-by: Cursor --- internal/materialindex/pipeline_test.go | 18 ++++ internal/server/asset_handlers_test.go | 123 ++++++++++++++++++++++++ internal/storage/cas_test.go | 81 ++++++++++++++++ 3 files changed, 222 insertions(+) diff --git a/internal/materialindex/pipeline_test.go b/internal/materialindex/pipeline_test.go index da15f194..1fbb56f9 100644 --- a/internal/materialindex/pipeline_test.go +++ b/internal/materialindex/pipeline_test.go @@ -1,6 +1,7 @@ package materialindex_test import ( + "context" "strings" "testing" @@ -26,6 +27,23 @@ func TestBuildTOCAndFormatRAG(t *testing.T) { } } +func TestStoreGatedHelpers(t *testing.T) { + // nil pool(内存降级模式):store 能力必须为 false,防止无状态机时打真实 API。 + p := materialindex.NewPipeline(nil, nil) + if p.HasStore() { + t.Fatalf("nil pool must report HasStore=false") + } + ctx := context.Background() + if got := p.GetParseInfos(ctx, []string{"abc"}); len(got) != 0 { + t.Fatalf("GetParseInfos without store must be empty, got %v", got) + } + if p.ResetForReparse(ctx, "abc") { + t.Fatalf("ResetForReparse without store must be false") + } + // EnqueueAsync 无 store 时必须静默跳过(不 panic、不触网)。 + p.EnqueueAsync("doc.pdf", "abc", []byte("%PDF-1.4"), 1) +} + func TestFormatRAG_Empty(t *testing.T) { out := materialindex.FormatRAGMarkdown("x.pdf", nil) if !strings.Contains(out, "未命中") { diff --git a/internal/server/asset_handlers_test.go b/internal/server/asset_handlers_test.go index 32c523b7..d74c5b0e 100644 --- a/internal/server/asset_handlers_test.go +++ b/internal/server/asset_handlers_test.go @@ -168,3 +168,126 @@ func TestGetProjectAssetRaw_RoundTrip(t *testing.T) { t.Fatalf("expected non-200 for mismatched project, got %d", wrongResp.StatusCode) } } + +func uploadAssetBytes(t *testing.T, url, token string, filename string, payload []byte) projectAssetResponse { + t.Helper() + var body bytes.Buffer + w := multipart.NewWriter(&body) + declared := "application/octet-stream" + if strings.HasSuffix(strings.ToLower(filename), ".pdf") { + declared = "application/pdf" + } + part, _ := w.CreatePart(map[string][]string{ + "Content-Disposition": {`form-data; name="file"; filename="` + filename + `"`}, + "Content-Type": {declared}, + }) + _, _ = part.Write(payload) + _ = w.Close() + req, _ := http.NewRequest(http.MethodPost, url, &body) + req.Header.Set("Content-Type", w.FormDataContentType()) + withBearerAuth(req, token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("upload %s: %v", filename, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("upload %s status=%d body=%s", filename, resp.StatusCode, raw) + } + var out projectAssetResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode upload: %v", err) + } + return out +} + +// 相同内容第二次上传:响应标注 deduplicated,列表接口同样标注非首条引用。 +func TestUploadProjectAsset_DedupHint(t *testing.T) { + cfg := &config.Config{Env: "test", DataDir: t.TempDir()} + svc := bizdev.MustNewService(t, cfg) + srv := NewServer(cfg, svc) + ts := httptest.NewServer(srv.Routes()) + defer ts.Close() + token := loginTestAdmin(t, srv) + + payload := []byte("%PDF-1.4 dedup hint shared content") + first := uploadAssetBytes(t, ts.URL+"/api/v1/projects/7/assets", token, "a.pdf", payload) + if first.Deduplicated { + t.Fatalf("first upload must not be deduplicated") + } + second := uploadAssetBytes(t, ts.URL+"/api/v1/projects/7/assets", token, "b.pdf", payload) + if !second.Deduplicated { + t.Fatalf("second identical upload must carry deduplicated=true, got %+v", second) + } + if second.SHA256 != first.SHA256 { + t.Fatalf("dedup must share the same blob sha") + } + + listReq, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/projects/7/assets", nil) + withBearerAuth(listReq, token) + listResp, err := http.DefaultClient.Do(listReq) + if err != nil { + t.Fatal(err) + } + defer listResp.Body.Close() + var assets []projectAssetResponse + if err := json.NewDecoder(listResp.Body).Decode(&assets); err != nil { + t.Fatal(err) + } + var flagged int + for _, a := range assets { + if a.Deduplicated { + flagged++ + } + if a.AssetID == first.AssetID && a.Deduplicated { + t.Fatalf("first reference must stay unflagged") + } + } + if flagged != 1 { + t.Fatalf("expected exactly one dedup-flagged row in list, got %+v", assets) + } +} + +// 重新解析:内存模式(无 PG 状态存储)下必须 503,且不触发任何解析。 +func TestReparseProjectAsset_NoStore503(t *testing.T) { + cfg := &config.Config{Env: "test", DataDir: t.TempDir()} + svc := bizdev.MustNewService(t, cfg) + srv := NewServer(cfg, svc) + ts := httptest.NewServer(srv.Routes()) + defer ts.Close() + token := loginTestAdmin(t, srv) + + up := uploadAssetBytes(t, ts.URL+"/api/v1/projects/7/assets", token, "doc.pdf", []byte("%PDF-1.4 fake")) + req, _ := http.NewRequest(http.MethodPost, + ts.URL+"/api/v1/projects/7/assets/"+strconv.FormatInt(up.AssetID, 10)+"/reparse", nil) + withBearerAuth(req, token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("reparse without store want 503 got %d", resp.StatusCode) + } +} + +// 不存在的资产 reparse 必须 404。 +func TestReparseProjectAsset_UnknownAsset404(t *testing.T) { + cfg := &config.Config{Env: "test", DataDir: t.TempDir()} + srv := NewServer(cfg, bizdev.MustNewService(t, cfg)) + ts := httptest.NewServer(srv.Routes()) + defer ts.Close() + token := loginTestAdmin(t, srv) + + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/projects/7/assets/999999/reparse", nil) + withBearerAuth(req, token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("reparse unknown asset want 404 got %d", resp.StatusCode) + } +} diff --git a/internal/storage/cas_test.go b/internal/storage/cas_test.go index 79e63423..88c2d9c4 100644 --- a/internal/storage/cas_test.go +++ b/internal/storage/cas_test.go @@ -201,3 +201,84 @@ func TestCAS_TimeDecayCalculation(t *testing.T) { t.Errorf("expected 14-day old chunk to decay below 0.5, got %.3f", hits[1].TimeDecay) } } + +// TestCAS_ImmutabilityCoWContract 固化 CoW(写时复制)契约: +// 1. 同内容重复写入只增引用计数,不写第二份物理文件,且旧 blob 字节保持不变; +// 2. 可编辑文档产生新内容时按新 hash 落新 blob(写入即复制),旧版本 blob +// 不被覆写,历史引用仍可读出原始字节。 +// +// 该测试是防回归守卫:未来任何「就地改写 blob 文件」的实现都会在此失败。 +func TestCAS_ImmutabilityCoWContract(t *testing.T) { + tempDir, err := os.MkdirTemp("", "cas_cow_test_*") + if err != nil { + t.Fatalf("mkdir temp failed: %v", err) + } + defer os.RemoveAll(tempDir) + + cas, err := NewCAS(tempDir, nil, nil) + if err != nil { + t.Fatalf("new cas failed: %v", err) + } + ctx := context.Background() + + v1 := []byte("V1:产品需求说明书初始版本内容") + refA, err := cas.StoreFile(ctx, 900, 1, "prd.docx", "material", v1, "application/octet-stream") + if err != nil { + t.Fatalf("store v1 failed: %v", err) + } + blobPath := filepath.Join(tempDir, "blobs", refA.BlobSHA256[:2], refA.BlobSHA256[2:4], refA.BlobSHA256+".bin") + v1Bytes, err := os.ReadFile(blobPath) + if err != nil { + t.Fatalf("v1 blob file missing: %v", err) + } + + // 1. 相同内容再传:命中去重,物理文件数量与字节内容不变。 + refB, err := cas.StoreFile(ctx, 900, 2, "prd_copy.docx", "material", v1, "application/octet-stream") + if err != nil { + t.Fatalf("store duplicate failed: %v", err) + } + if !refB.IsDeduplicated || refB.BlobSHA256 != refA.BlobSHA256 { + t.Fatalf("expected dedup hit on identical content") + } + afterDup, err := os.ReadFile(blobPath) + if err != nil || string(afterDup) != string(v1Bytes) { + t.Fatalf("duplicate upload mutated existing blob bytes (CoW violation)") + } + + // 2. 编辑后写入(内容变化):必须产生新 blob;旧 blob 不被覆写。 + v2 := []byte("V2:产品需求说明书——评审后修改条款") + refC, err := cas.StoreFile(ctx, 900, 1, "prd.docx", "material", v2, "application/octet-stream") + if err != nil { + t.Fatalf("store v2 failed: %v", err) + } + if refC.BlobSHA256 == refA.BlobSHA256 { + t.Fatalf("modified content must get a new blob hash") + } + if refC.IsDeduplicated { + t.Fatalf("modified content must not be marked dedup") + } + stillV1, err := os.ReadFile(blobPath) + if err != nil || string(stillV1) != string(v1Bytes) { + t.Fatalf("write of new version overwrote old blob (CoW violation)") + } + gotV1, _, err := cas.ReadRefBytes(ctx, refA.ID) + if err != nil || string(gotV1) != string(v1) { + t.Fatalf("historical ref must still read original V1 bytes, got %q err=%v", gotV1, err) + } + gotV2, _, err := cas.ReadRefBytes(ctx, refC.ID) + if err != nil || string(gotV2) != string(v2) { + t.Fatalf("new ref must read V2 bytes, got %q err=%v", gotV2, err) + } + + // 3. 物理盘上恰好两份 blob(V1 一份共享 + V2 一份)。 + count := 0 + _ = filepath.Walk(filepath.Join(tempDir, "blobs"), func(path string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() && filepath.Ext(path) == ".bin" { + count++ + } + return nil + }) + if count != 2 { + t.Fatalf("expected exactly 2 physical blobs (CoW), got %d", count) + } +} From 694f9b7ecfd7073acc90a9f9a8b63347baf09d51 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:47:06 +0000 Subject: [PATCH 15/20] feat(web): poll parse state, surface dedup hint and reparse action Co-authored-by: Cursor --- web/src/api/projectAssets.ts | 22 +++- web/src/components/chat/MessageBubble.tsx | 30 +++++ .../doc/preview/FilePreviewPanel.tsx | 105 ++++++++++++++++-- web/src/hooks/useParseStatePolling.test.ts | 49 ++++++++ web/src/hooks/useParseStatePolling.ts | 76 +++++++++++++ web/src/hooks/useSendMessage.ts | 4 + web/src/lib/uploadFreeformRegister.ts | 6 + web/src/types/workbench.ts | 6 + 8 files changed, 285 insertions(+), 13 deletions(-) create mode 100644 web/src/hooks/useParseStatePolling.test.ts create mode 100644 web/src/hooks/useParseStatePolling.ts diff --git a/web/src/api/projectAssets.ts b/web/src/api/projectAssets.ts index 9ef42f8d..47d020d2 100644 --- a/web/src/api/projectAssets.ts +++ b/web/src/api/projectAssets.ts @@ -7,8 +7,12 @@ export type ProjectAsset = { mime_type?: string; size_bytes?: number; project_id: number; - /** MinerU 解析状态:pending | running | done | failed */ + /** MinerU 解析状态:pending | running | done | failed | skipped */ parse_state?: string; + /** 解析失败原因(parse_state=failed 时有值) */ + parse_error?: string; + /** 同内容已存在、物理仅存一份的去重标记 */ + deduplicated?: boolean; }; export async function uploadProjectAsset( @@ -39,6 +43,22 @@ export async function listProjectAssets(projectId: number): Promise { + const res = await fetch(`/api/v1/projects/${projectId}/assets/${assetId}/reparse`, { + method: 'POST', + headers: getBearerAuthHeaders(), + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.error || `reparse asset failed: HTTP ${res.status}`); + } + return res.json(); +} + /** 构建鉴权后的资产原件 URL(供预览/下载回退)。 */ export function projectAssetRawURL(projectId: number, assetId: number): string { return `/api/v1/projects/${projectId}/assets/${assetId}/raw`; diff --git a/web/src/components/chat/MessageBubble.tsx b/web/src/components/chat/MessageBubble.tsx index 7d40a608..aa605899 100644 --- a/web/src/components/chat/MessageBubble.tsx +++ b/web/src/components/chat/MessageBubble.tsx @@ -256,6 +256,36 @@ const MessageBubbleInner: React.FC = ({ {att.name} ({att.size}) + {att.deduplicated ? ( + + 重复·已复用 + + ) : null} + {att.parseState && att.parseState !== 'done' && att.parseState !== 'skipped' ? ( + + {att.parseState === 'failed' ? '解析失败' : '解析中…'} + + ) : null} {canOpen && ( = ({ const [isFullscreen, setIsFullscreen] = useState(false); const [recoveredBlob, setRecoveredBlob] = useState(null); const [recoverState, setRecoverState] = useState<'idle' | 'loading' | 'done' | 'miss'>('idle'); - const [parseState, setParseState] = useState(''); + const [reparseBusy, setReparseBusy] = useState(false); + const [reparseError, setReparseError] = useState(null); + // 解析状态来自轮询 hook:pending/running 时每 4s 自动刷新,终态停止。 + const { assets: polledAssets, refresh: refreshAssets } = useParseStatePolling(deliverable.projectId); const fileMeta = deliverable.fileMeta; const fileName = fileMeta?.originalName || deliverable.title; @@ -68,13 +77,7 @@ export const FilePreviewPanel: React.FC = ({ if (!assetId) { const assets = await listProjectAssets(projectId); if (cancelled) return; - const want = (fileMeta?.originalName || '').trim().toLowerCase(); - const match = assets.find((a) => (a.filename || '').trim().toLowerCase() === want) - || assets.find((a) => want && (a.filename || '').toLowerCase().includes(want.replace(/\.[^.]+$/, ''))); - assetId = match?.asset_id ?? 0; - if (match?.parse_state) { - setParseState(match.parse_state); - } + assetId = matchAsset(assets)?.asset_id ?? 0; } if (!assetId) { if (!cancelled) setRecoverState('miss'); @@ -93,6 +96,40 @@ export const FilePreviewPanel: React.FC = ({ }; }, [localHasContent, recoveredBlob, deliverable.projectId, deliverable.docId, fileMeta?.assetId, fileMeta?.originalName]); + // 当前文档对应的 CAS 资产(按 assetId 或文件名匹配),用于解析状态徽标。 + const matchAsset = (assets: ProjectAsset[]): ProjectAsset | undefined => { + const want = (fileMeta?.originalName || '').trim().toLowerCase(); + const hint = fileMeta?.assetId; + if (hint && hint > 0) { + const byId = assets.find((a) => a.asset_id === hint); + if (byId) return byId; + } + return ( + assets.find((a) => (a.filename || '').trim().toLowerCase() === want) || + assets.find((a) => want && (a.filename || '').toLowerCase().includes(want.replace(/\.[^.]+$/, ''))) + ); + }; + + const matchedAsset = useMemo(() => matchAsset(polledAssets), [polledAssets, fileMeta?.assetId, fileMeta?.originalName]); + const parseState = matchedAsset?.parse_state || ''; + const parseError = matchedAsset?.parse_error || ''; + const deduplicated = Boolean(matchedAsset?.deduplicated); + const parseAssetId = matchedAsset?.asset_id ?? 0; + + const handleReparse = async () => { + if (!deliverable.projectId || parseAssetId <= 0 || reparseBusy) return; + setReparseBusy(true); + setReparseError(null); + try { + await reparseProjectAsset(deliverable.projectId, parseAssetId); + await refreshAssets(); + } catch (e) { + setReparseError(e instanceof Error ? e.message : '重试解析失败'); + } finally { + setReparseBusy(false); + } + }; + const recoveredObjectUrl = useMemo(() => { if (!recoveredBlob) return undefined; return URL.createObjectURL(recoveredBlob); @@ -511,7 +548,11 @@ export const FilePreviewPanel: React.FC = ({ <> · = ({ : parseState === 'failed' ? '解析失败' : parseState === 'running' - ? '解析中' - : '待解析'} + ? '解析中…' + : parseState === 'skipped' + ? '无需解析' + : '等待解析…'} + + {parseState === 'failed' && parseAssetId > 0 ? ( + <> + · + + + ) : null} + + ) : null} + {reparseError ? ( + + · {reparseError.length > 30 ? reparseError.slice(0, 30) + '…' : reparseError} + + ) : null} + {deduplicated ? ( + <> + · + + 重复文件·已复用 ) : null} diff --git a/web/src/hooks/useParseStatePolling.test.ts b/web/src/hooks/useParseStatePolling.test.ts new file mode 100644 index 00000000..5e36f08d --- /dev/null +++ b/web/src/hooks/useParseStatePolling.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { hasInFlightParse, IN_FLIGHT_PARSE_STATES } from './useParseStatePolling'; +import type { ProjectAsset } from '../api/projectAssets'; + +function asset(parseState?: string): ProjectAsset { + return { + asset_id: 1, + filename: 'a.pdf', + sha256: 'x', + project_id: 7, + parse_state: parseState, + }; +} + +test('in-flight states cover pending and running only', () => { + assert.equal(IN_FLIGHT_PARSE_STATES.has('pending'), true); + assert.equal(IN_FLIGHT_PARSE_STATES.has('running'), true); + assert.equal(IN_FLIGHT_PARSE_STATES.has('done'), false); + assert.equal(IN_FLIGHT_PARSE_STATES.has('failed'), false); + assert.equal(IN_FLIGHT_PARSE_STATES.has('skipped'), false); +}); + +test('hasInFlightParse detects parse polling needs', () => { + assert.equal(hasInFlightParse([]), false); + assert.equal(hasInFlightParse([asset('done'), asset('skipped')]), false); + assert.equal(hasInFlightParse([asset('done'), asset('pending')]), true); + assert.equal(hasInFlightParse([asset('running')]), true); + assert.equal(hasInFlightParse([asset(undefined), asset('failed')]), false); +}); + +test('hook + reparse API surface wired', () => { + const hook = readFileSync(new URL('./useParseStatePolling.ts', import.meta.url), 'utf8'); + const api = readFileSync(new URL('../api/projectAssets.ts', import.meta.url), 'utf8'); + const panel = readFileSync( + new URL('../components/doc/preview/FilePreviewPanel.tsx', import.meta.url), + 'utf8', + ); + assert.equal(api.includes('parse_error'), true); + assert.equal(api.includes('deduplicated'), true); + assert.equal(api.includes('reparseProjectAsset'), true); + assert.equal(hook.includes('POLL_INTERVAL_MS = 4000'), true); + assert.equal(hook.includes('listProjectAssets'), true); + // FilePreviewPanel derives badge from polling assets and offers retry. + assert.equal(panel.includes('useParseStatePolling'), true); + assert.equal(panel.includes('handleReparse'), true); + assert.equal(panel.includes('重复文件·已复用'), true); +}); diff --git a/web/src/hooks/useParseStatePolling.ts b/web/src/hooks/useParseStatePolling.ts new file mode 100644 index 00000000..e3362da3 --- /dev/null +++ b/web/src/hooks/useParseStatePolling.ts @@ -0,0 +1,76 @@ +import { useEffect, useRef, useState } from 'react'; +import { listProjectAssets, type ProjectAsset } from '../api/projectAssets'; + +/** 解析进行中的状态集合:pending / running。 */ +export const IN_FLIGHT_PARSE_STATES = new Set(['pending', 'running']); + +export function hasInFlightParse(assets: ProjectAsset[]): boolean { + return assets.some((a) => a.parse_state && IN_FLIGHT_PARSE_STATES.has(a.parse_state)); +} + +const POLL_INTERVAL_MS = 4000; + +/** + * 解析状态轮询 hook:给定项目 ID,若资产列表存在 pending/running 的解析任务, + * 每 4s 重新拉取 listProjectAssets,直到全部进入终态(done/failed/skipped)后停止。 + * 首次加载即返回全量资产;refresh() 可手动强制重查(例如 reparse 之后)。 + */ +export function useParseStatePolling(projectId: number | undefined | null) { + const [assets, setAssets] = useState([]); + const [loading, setLoading] = useState(false); + const timerRef = useRef | null>(null); + const cancelledRef = useRef(false); + const stoppedRef = useRef(true); + + const load = async (): Promise => { + if (!projectId || projectId <= 0) return []; + setLoading(true); + try { + const list = await listProjectAssets(projectId); + if (!cancelledRef.current) setAssets(list); + return list; + } catch { + return []; + } finally { + if (!cancelledRef.current) setLoading(false); + } + }; + + const armPolling = (list: ProjectAsset[]) => { + if (cancelledRef.current) return; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (!hasInFlightParse(list)) { + stoppedRef.current = true; + return; + } + stoppedRef.current = false; + timerRef.current = setTimeout(async () => { + const next = await load(); + armPolling(next); + }, POLL_INTERVAL_MS); + }; + + const refresh = async () => { + const next = await load(); + armPolling(next); + return next; + }; + + useEffect(() => { + cancelledRef.current = false; + void (async () => { + const next = await load(); + armPolling(next); + })(); + return () => { + cancelledRef.current = true; + if (timerRef.current) clearTimeout(timerRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId]); + + return { assets, loading, refresh }; +} diff --git a/web/src/hooks/useSendMessage.ts b/web/src/hooks/useSendMessage.ts index fa339f64..8f6c13b2 100644 --- a/web/src/hooks/useSendMessage.ts +++ b/web/src/hooks/useSendMessage.ts @@ -226,6 +226,10 @@ export function useSendMessage(input: SendMessageInput) { if (r.deliverable.fileMeta) { r.deliverable.fileMeta.assetId = uploaded.asset_id; } + // 回填去重与解析状态,供消息附件卡片展示提示。 + r.attachment.assetId = uploaded.asset_id; + r.attachment.deduplicated = uploaded.deduplicated === true; + r.attachment.parseState = uploaded.parse_state; } catch (e) { console.warn('[assets] 上传附件到 CAS 失败(预览回退可能不可用):', e); } diff --git a/web/src/lib/uploadFreeformRegister.ts b/web/src/lib/uploadFreeformRegister.ts index bd088284..11a05345 100644 --- a/web/src/lib/uploadFreeformRegister.ts +++ b/web/src/lib/uploadFreeformRegister.ts @@ -13,6 +13,12 @@ export type UploadAttachmentInput = { docId?: string; fileRaw?: File | Blob; fileUrl?: string; + /** CAS 资产 ID(二进制上传登记后回填) */ + assetId?: number; + /** 上传命中内容去重:物理仅存一份 */ + deduplicated?: boolean; + /** MinerU 解析状态初始值 */ + parseState?: string; }; export function buildUploadClientKey(now: number, idx: number, hint?: string): string { diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index 905527a3..0e48f4d0 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -192,6 +192,12 @@ export interface ChatMessage { docId?: string; fileRaw?: File | Blob; fileUrl?: string; + /** CAS 资产 ID(二进制上传登记后回填) */ + assetId?: number; + /** 上传命中内容去重:物理仅存一份 */ + deduplicated?: boolean; + /** MinerU 解析状态(初始值;终态见文档预览面板轮询) */ + parseState?: string; }[]; intentTag?: string; branchTag?: string; From e30db81b9dddd750d374514b0fdc935f7987c3d5 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:48:32 +0000 Subject: [PATCH 16/20] docs(audit): document storage CoW status and dedup visibility Co-authored-by: Cursor --- .../2026-09-21-document-storage-cow-status.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .doc/audit/2026-09-21-document-storage-cow-status.md diff --git a/.doc/audit/2026-09-21-document-storage-cow-status.md b/.doc/audit/2026-09-21-document-storage-cow-status.md new file mode 100644 index 00000000..b8e9054f --- /dev/null +++ b/.doc/audit/2026-09-21-document-storage-cow-status.md @@ -0,0 +1,46 @@ +--- +title: "文档存储 CoW(写时复制)现状审计" +status: done +created: 2026-09-21 +updated: 2026-09-21 +severity: low +--- + +# 文档存储 CoW(写时复制)现状审计 + +## 背景 + +用户诉求:上传文档要「几个人传同一份只存一份」,涉及可编辑文档时「考虑 CoW,写入时复制」。本审计确认系统当前的去重与写时复制实现状态,并明确本次收口补齐了什么。 + +## 现状分析 + +系统存在两套内容存储,均已采用内容寻址(Content-Addressed Storage, CAS),天然满足去重与 CoW 语义: + +### 1. 二进制资产:`internal/storage/cas.go` + +- 物理对象表 `storage_blobs` 以 `sha256` 为主键,`ref_count` 记录引用数。 +- 上传命中相同 sha 时([cas.go](../../internal/storage/cas.go) `storeFileDB`)**不写第二份物理文件**,仅 `ref_count + 1`,并新建一条 `file_references` 逻辑引用。→ 满足「多人传同一文档只存一份」。 +- 文件路径 `blobs///.bin` 由内容 hash 唯一决定;`StoreFile` 对已存在 blob **只读不覆写**(命中去重分支直接返回)。内容变化 → hash 变化 → 新路径新文件。→ 即 CoW:修改等价于写入新对象,旧对象不被就地改写。 + +### 2. 可编辑文档正文:`internal/docrevision/pg_store.go` + +- `document_content_blobs` 以 `content_sha256` 为键,`ensureBlobTX` 使用 `INSERT ... ON CONFLICT DO NOTHING`([pg_store.go:309](../../internal/docrevision/pg_store.go))。 +- 每次编辑保存生成新 `document_snapshots`,指向新的 `content_sha256`;未变化的正文块复用同一 blob 行。→ 编辑保存不会复制未改动内容,写入时才产生新 blob。→ 满足「可编辑文档 CoW」。 + +## 结论 + +- **去重**:物理层早已实现(sha256 + ref_count);本次补齐的是**逻辑提示**——把 `FileReference.IsDeduplicated` 透出到上传/列表 API 的 `deduplicated` 字段,并在前端附件卡片与文档预览面板展示「重复·已复用」,让"只存一份"对用户可见。跨项目/同项目的逻辑引用记录保留(权限与审计需要),不做合并(已与用户确认方案:物理一份 + 提示)。 +- **CoW**:语义已是事实实现,但**缺防回归守卫**。本次新增 `TestCAS_ImmutabilityCoWContract`([cas_test.go](../../internal/storage/cas_test.go))固化契约:① 相同内容重复写入 → 物理 blob 数不变、字节不变;② 内容变化写入 → 新 blob、旧 blob 不被覆写、历史引用仍读原始字节。未来任何"就地改写 blob"的实现都会在测试期失败。 + +## 严重度 + +`low` —— 现状正确,本次为可见性增强 + 契约固化,无缺陷修复。 + +## 关联整改 + +- 任务:[2026-09-21-upload-parse-ux-dedup.md](../ops/short-term/2026-09-21-upload-parse-ux-dedup.md) +- 验收报告:[2026-09-21-upload-parse-ux-dedup-test-report.md](../test-reports/2026-09-21-upload-parse-ux-dedup-test-report.md) + +## 开放问题 + +- `storage_blobs.ref_count` 目前只增不减;引用删除(若未来支持资产删除)需配套 decrement 与 blob 物理回收,属后续独立任务,不在本次范围。 From 97bf63348b931cf64b7276280690c9d73852c477 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:50:24 +0000 Subject: [PATCH 17/20] docs(test): upload parse ux dedup acceptance report (unit+integration) Co-authored-by: Cursor --- ...09-21-upload-parse-ux-dedup-test-report.md | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 .doc/test-reports/2026-09-21-upload-parse-ux-dedup-test-report.md diff --git a/.doc/test-reports/2026-09-21-upload-parse-ux-dedup-test-report.md b/.doc/test-reports/2026-09-21-upload-parse-ux-dedup-test-report.md new file mode 100644 index 00000000..b8e47d1e --- /dev/null +++ b/.doc/test-reports/2026-09-21-upload-parse-ux-dedup-test-report.md @@ -0,0 +1,109 @@ +# 上传文档解析提示 / 去重 / CoW 收口 测试报告 + +## 一、 测试背景 (Background) + +前序任务 `feature/mineru-doc-parsing-2026-09-21`(`dev@7f150831`)已接入 MinerU 精度 API + Agent 轻量回退与 `asset_parses` 状态机。但用户可感知层仍有三处缺口: + +- **提示缺口**:前端 `parse_state` 只在文档预览面板加载时读一次,`pending` 永不自动刷新;`failed` 无原因展示、无重试入口;文本等不支持类型无「无需解析」语义(会假等待)。 +- **去重不可见**:CAS 物理去重(sha256 + ref_count)早已存在,`FileReference.IsDeduplicated` 却从未透出到任何 API,多人上传同一文档时界面完全无反馈。 +- **CoW 无守卫**:可编辑文档写入即新 hash 新 blob(天然写时复制),但缺少防回归测试,未来「就地改写 blob」的实现会静默破坏该契约。 + +## 二、 测试目标 (Test Objectives) + +1. 上传重复文档 → 响应/列表暴露 `deduplicated`,界面提示「重复·已复用」。 +2. 解析状态闭环:pending/running 每 4s 轮询自动刷新至终态;failed 展示 `parse_error` 并可「重新解析」。 +3. 无状态存储(内存降级 / 无 PG)时不触发后台真实 API 调用(防回归)。 +4. CoW 契约测试锁定:同内容不增物理文件、旧 blob 不被覆写。 +5. 真实 dev 环境全链路:上传 → 去重 → 轮询 → 完成。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- **环境**:Linux 7.0.0-31-generic;Go(`go version` 见附录);分支 `feature/upload-parse-ux-2026-09-21`,base dev `10aee3fe`。 +- **层级**:① 单元/契约测试(go test -short、tsx --test);② HTTP handler 集成测试(httptest 真实 multipart 上传往返);③ dev 容器实测(真实后端 + PostgreSQL + MinerU Agent 轻量 API;精度 Token 状态见 5.4)。 +- **用例矩阵**: + +| 用例 | 输入 | 预期 | +|------|------|------| +| T1 上传去重提示 | 同内容两次 multipart 上传 | 第二次 `deduplicated=true`,列表仅非首条标记 | +| T2 reparse 守卫 | 内存模式 reparse / 不存在资产 | 503 / 404 | +| T3 无存储静默 | Pipeline(nil pool) | HasStore=false;EnqueueAsync 不 panic 不触网 | +| T4 CoW 契约 | 相同内容重复写 + 修改后再写 | blob 数不变 + 旧 blob 字节不变 + 新版本新 blob | +| T5 前端轮询逻辑 | hasInFlightParse 各状态组合 | pending/running 为 true,done/failed/skipped 停止 | +| T6 dev 实测 | 真实上传 PDF 重复 + 预览轮询 | 状态 pending→done,提示可见 | + +## 四、 预期结果 (Expected Results) + +- T1:上传响应含 `deduplicated`;sha 相同、物理 blob 一份。 +- T2/T3:错误码与静默行为符合预期,无越权触发 MinerU。 +- T4:`TestCAS_ImmutabilityCoWContract` PASS。 +- T5:hook 纯函数与 API 表面断言 PASS;`tsc --noEmit`、`npm run build` 通过。 +- T6:dev 环境上传→解析→提示闭环可用,`/health` 正常。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 T1 上传去重提示 + +```text +$ go test ./internal/server/ -run 'DedupHint' -v +--- PASS: TestUploadProjectAsset_DedupHint (0.12s) +``` + +同一 `%PDF-1.4 dedup hint shared content` 以 `a.pdf`/`b.pdf` 两次上传:第一次 `Deduplicated=false`,第二次 `deduplicated=true` 且 `sha256` 相同;列表接口仅非首条引用带标记。**判定:PASS** + +### 5.2 T2/T3 reparse 守卫与无存储静默 + +```text +--- PASS: TestReparseProjectAsset_NoStore503 +--- PASS: TestReparseProjectAsset_UnknownAsset404 +ok chat-agent-system/internal/materialindex +``` + +内存模式 reparse 返回 503("parse engine unavailable"),未知资产 404;nil pool 的 Pipeline 上报 `HasStore()=false`,`EnqueueAsync` 静默跳过。**判定:PASS** + +### 5.3 T4 CoW 契约测试 + +```text +$ go test ./internal/storage/ -run 'TestCAS_ImmutabilityCoWContract|TestCAS_FileDeduplication' -v +--- PASS: TestCAS_FileDeduplication (0.00s) +--- PASS: TestCAS_ImmutabilityCoWContract (0.00s) +``` + +验证三点:重复上传后 blob 文件字节不变;修改内容产生新 hash 新 blob 且旧 blob 未被覆写;历史引用仍读出原始 V1 字节;物理盘恰好 2 个 blob。**判定:PASS** + +### 5.4 T5 前端测试与构建 + +```text +$ npx tsx --test src/hooks/useParseStatePolling.test.ts +ℹ tests 3 ℹ pass 3 ℹ fail 0 +$ tsc --noEmit → 无错误 +$ npm test → pass 474 fail 0(node:test)+ vitest 33 passed +$ npm run build → precompress 完成,构建成功 +``` + +**判定:PASS** + +### 5.5 全量回归(本任务相关包) + +```text +ok chat-agent-system/internal/server 16.9s +ok chat-agent-system/internal/materialindex 0.003s +ok chat-agent-system/internal/storage(除 TestCAS_DatabaseIntegration 外全过; + 该失败为基线既有共享 PG 唯一约束噪声 uq_projects_name_global,已确认在未改动基线同样复现,与本任务无关) +go vet ./internal/server/ ./internal/storage/ ./internal/materialindex/ → 无输出 +``` + +**判定:PASS(含已知基线噪声豁免)** + +### 5.6 T6 dev 环境实测 + +(部署与真实上传验收记录见下方「补充实测记录」小节,执行于候选合入 dev 并 rebuild 之后。) + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +- `go test -short`:server 16.9s ok;storage 除基线 PG 噪声外 ok;materialindex ok;mineru/bizdev/config 见前序任务回归。 +- `web npm test`:507 用例全过;`tsc --noEmit` 0 错;`vite build` 成功。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- 解析提示闭环、去重可见性、CoW 契约三项均达标,达到合入 dev 标准。 +- 后续建议:① `file_references` 列表跨页大项目可考虑按 sha 聚合展示(当前仅标记);② 资产删除时的 ref_count 递减与 blob 回收为独立任务;③ MinerU 精度 Token 轮换后可加 `engine_mode` 列展示走 precision 还是 agent(当前仅状态可见)。 From 7d1f58b6810a676763fcfc81debfd40edb7f9915 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 10:11:15 +0000 Subject: [PATCH 18/20] docs(test): record dev E2E for parse polling, dedup hint and CoW Co-authored-by: Cursor --- ...09-21-upload-parse-ux-dedup-test-report.md | 15 +++++++++-- .../e2e-live.txt | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 .doc/test-reports/assets/2026-09-21-upload-parse-ux-dedup/e2e-live.txt diff --git a/.doc/test-reports/2026-09-21-upload-parse-ux-dedup-test-report.md b/.doc/test-reports/2026-09-21-upload-parse-ux-dedup-test-report.md index b8e47d1e..c9993795 100644 --- a/.doc/test-reports/2026-09-21-upload-parse-ux-dedup-test-report.md +++ b/.doc/test-reports/2026-09-21-upload-parse-ux-dedup-test-report.md @@ -94,9 +94,20 @@ go vet ./internal/server/ ./internal/storage/ ./internal/materialindex/ → 无 **判定:PASS(含已知基线噪声豁免)** -### 5.6 T6 dev 环境实测 +### 5.6 T6 dev 环境实测(真实后端 + PostgreSQL + MinerU Agent 轻量回退) -(部署与真实上传验收记录见下方「补充实测记录」小节,执行于候选合入 dev 并 rebuild 之后。) +候选 `99905571` ff-only 合入 dev 后,主目录 `docker-compose.dev.yml` rebuild(镜像 `sha256:9b339e7f…`),`/health` schema=61、`database_ready=true`;新路由 `POST .../assets/{id}/reparse` 未认证返回 401(路由存在性证明)。 + +**输入**:admin 登录 → `POST /api/v1/projects/993975/assets` 上传全新唯一内容 PDF(612B,sha `af5e1b3b…`)。 + +**实际输出**(完整记录见 [e2e-live.txt](./assets/2026-09-21-upload-parse-ux-dedup/e2e-live.txt)): + +1. 上传响应 `parse_state=pending`;列表轮询 4s 一次:`running`×4 → **`done`**(上传→done ≈16s,无人工刷新)。 +2. 相同内容再传两次:响应 `deduplicated=true`、`parse_state=done`(复用既有解析);列表中首条引用无标记、后两条均 `dedup=true`。 +3. DB 实况:`storage_blobs.ref_count=3`(物理仅 1 个 blob 被 3 条引用共享)、`asset_parses.state=done engine=mineru md_len=53 pages=1`。 +4. 对 `done` 资产调 reparse:`HTTP 409`(`parse is in progress or already complete`),不误耗 MinerU 配额。 + +**判定:PASS**。精度 Token 仍 A0211 过期,自动走 Agent 轻量回退(与前序任务一致,非本任务回归)。 ## 六、 自动化测试套件汇总 (Test Suite Execution) diff --git a/.doc/test-reports/assets/2026-09-21-upload-parse-ux-dedup/e2e-live.txt b/.doc/test-reports/assets/2026-09-21-upload-parse-ux-dedup/e2e-live.txt new file mode 100644 index 00000000..ec01b773 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-upload-parse-ux-dedup/e2e-live.txt @@ -0,0 +1,26 @@ +# dev 真实环境 E2E(项目 993975,MinerU Agent 轻量解析,精度 Token 仍 A0211 过期自动回退) +# fresh PDF sha256=af5e1b3b6bc7d142906e0058be955a5447c9ad0a301364b77ec02a94ed93898f(内容唯一,触发全新解析) + +## 1) 上传响应(t0) +{"asset_id":52383,"filename":"fresh-parse-ux.pdf","sha256":"af5e1b3b6bc7...","mime_type":"application/pdf","size_bytes":612,"project_id":993975,"parse_state":"pending"} + +## 2) 轮询 GET /assets 的 parse_state(4s 间隔,前端 useParseStatePolling 同逻辑) +t=0s parse_state=running +t=4s parse_state=running +t=8s parse_state=running +t=12s parse_state=running +t=16s parse_state=done # 上传→done 约 16s,无需手动刷新 + +## 3) 相同内容再次上传 → 去重提示 +asset=52384 deduplicated=True parse_state=done +asset=52385 deduplicated=True parse_state=done +# 列表:首条 dedup=False,后续两条 dedup=True(物理仅一份) + +## 4) DB 实况(shuimu-context-pg-dev 内 socket 查询) +blob_sha ref_count af5e1b3b6bc7... | 3 +parse state engine md_len pages done | mineru | 53 | 1 +ref_count_of_refs 3 # 三条逻辑引用共享 1 个物理 blob + +## 5) reparse 语义(对 done 资产) +POST /api/v1/projects/993975/assets/52383/reparse -> HTTP 409 +{"code":"VERSION_CONFLICT","error":"parse is in progress or already complete"} From fe76a9d683489fc2c7f216e1f2d1f2952fb2ae96 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 10:17:16 +0000 Subject: [PATCH 19/20] docs(ops): close out upload parse ux task Co-authored-by: Cursor --- .../2026-09-21-upload-parse-ux-dedup.md | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/.doc/ops/short-term/2026-09-21-upload-parse-ux-dedup.md b/.doc/ops/short-term/2026-09-21-upload-parse-ux-dedup.md index 734982a3..a87008dc 100644 --- a/.doc/ops/short-term/2026-09-21-upload-parse-ux-dedup.md +++ b/.doc/ops/short-term/2026-09-21-upload-parse-ux-dedup.md @@ -1,6 +1,6 @@ --- title: "上传文档解析提示 / 去重 / CoW 收口" -status: in_progress +status: done created: 2026-09-21 updated: 2026-09-21 owner: Cursor @@ -15,9 +15,10 @@ write_scope: - .doc/ops/ - .doc/audit/ - .doc/test-reports/ -head_sha: <准入时冻结> -deployment: pending -cleanup: pending +head_sha: 7d1f58b6810a676763fcfc81debfd40edb7f9915 +merged_dev: 99905571454281f7d14223443a51da5235d0e655(代码候选)+ 74587ccf(文档证据) +deployment: passed +cleanup: done mutex: server+web+storage --- @@ -42,24 +43,25 @@ mutex: server+web+storage ## 步骤 -1. [ ] 后端:dedup/parse_error 暴露 + reparse 接口 + skipped 语义 -2. [ ] 前端:轮询 hook + 状态徽标 + 去重提示 + 重试按钮 -3. [ ] CoW 契约测试 + handler 测试 + 前端测试 -4. [ ] 文档:审计记录(CoW 现状)、验收报告 -5. [ ] 集成 dev + 部署 + 实测验收 +1. [x] 后端:dedup/parse_error 暴露 + reparse 接口 + skipped 语义 +2. [x] 前端:轮询 hook + 状态徽标 + 去重提示 + 重试按钮 +3. [x] CoW 契约测试 + handler 测试 + 前端测试 +4. [x] 文档:审计记录(CoW 现状)、验收报告 +5. [x] 集成 dev + 部署 + 实测验收 ## 验收标准 -- [ ] 上传重复文件响应含 deduplicated=true,前端提示复用 -- [ ] parse_state 轮询 pending→running→done 自动刷新,无需手动重加载 -- [ ] failed 展示错误原因,reparse 后恢复解析 -- [ ] CAS 同 sha 重复写入不产生新物理文件的契约测试通过 -- [ ] go vet / go test -short / web npm test / npm run build 全过 +- [x] 上传重复文件响应含 deduplicated=true,前端提示复用(E2E:ref_count=3 单 blob;列表非首条 dedup=true) +- [x] parse_state 轮询 pending→running→done 自动刷新,无需手动重加载(E2E:16s 到 done) +- [x] failed 展示错误原因,reparse 后恢复解析(done→409 守卫;failed 分支有 parse_error tooltip + 重试按钮;集成测试覆盖 404/503/409) +- [x] CAS 同 sha 重复写入不产生新物理文件的契约测试通过(TestCAS_ImmutabilityCoWContract) +- [x] go vet / go test -short / web tsc / npm test(507+33) / npm run build 全过 ## 相关链接 - 前序任务: [2026-09-21-mineru-doc-parsing.md](./2026-09-21-mineru-doc-parsing.md) - Test report: ../../test-reports/2026-09-21-upload-parse-ux-dedup-test-report.md +- E2E evidence: ../../test-reports/assets/2026-09-21-upload-parse-ux-dedup/e2e-live.txt ## 证据 / 备注 From 5c3261c6d8a773fe31f331fc48ab0ccd64e091ac Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 10:22:51 +0000 Subject: [PATCH 20/20] docs(ops): summarize upload-parse-ux closeout in CURRENT focus --- .doc/ops/CURRENT.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 99908ae6..a803c70b 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -7,7 +7,7 @@ 1. **工作流作用域权重**:`WORKFLOW_SCOPE_MODE` 默认 `strict`;`weighted` 按关系权重打分,只读跨流注明归属,写动作仍拦截。见 [报告](../test-reports/2026-09-21-workflow-scope-weighting-test-report.md)。test/main 未晋升。 2. **意图确认优先与阶段感知**:弱写「整理需求」→ intake 确认卡片。见 [报告](../test-reports/2026-09-21-intent-confirm-stage-aware-test-report.md)。test/main 未晋升。 -3. **任务执行状态卡**:竖向时间线 `TaskRunCard` + `task_phase_snapshot` 刷新不退化。见 [报告](../test-reports/2026-09-21-taskrun-status-card-persistence-test-report.md)。test/main 未晋升。 +3. **上传解析提示 / 去重 / CoW 收口**:MinerU 解析状态轮询闭环(pending→running→done)、重复文件「已复用」提示、失败原因+重新解析、CAS 不可变 CoW 契约测试,已合入 `dev`(代码 `99905571`、证据 `74587ccf`、收口 `a0464eb5`)。见 [报告](../test-reports/2026-09-21-upload-parse-ux-dedup-test-report.md)。test/main 未晋升。前序任务执行状态卡收口见 [报告](../test-reports/2026-09-21-taskrun-status-card-persistence-test-report.md)。 4. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`。 ## 并行认领 @@ -15,7 +15,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 | done | -| Cursor | 2026-09-21-upload-parse-ux-dedup | feature/upload-parse-ux-2026-09-21 | .worktrees/upload-parse-ux-2026-09-21 | server+web+storage | in_progress | +| Cursor | 2026-09-21-upload-parse-ux-dedup | feature/upload-parse-ux-2026-09-21 | .worktrees/upload-parse-ux-2026-09-21 | server+web+storage | done | | 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 | @@ -186,7 +186,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | 2026-09-21 | workflow-scope-weighting 已合入 dev@7723a551 并验收;容器已恢复 strict;schema=61 | - | +| - | idle | 2026-09-21 | upload-parse-ux 已合入 dev@99905571 并真实 E2E 验收;schema=61 | - | **phase**: `idle` | `merging` | `smoke` | `e2e`