merge: dev@5c3261c6 into feature/taskrun-status-card-2026-09-21
This commit is contained in:
@@ -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
@@ -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
+1
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"
|
||||
}
|
||||
}
|
||||
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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, "报告") {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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, "未命中") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 叙事增强;默认仅确定性回退"},
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user