merge: dev@5c3261c6 into feature/taskrun-status-card-2026-09-21

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