merge: candidate doc-review-lifecycle-land-2026-09-26 into dev

This commit is contained in:
weichen
2026-09-25 20:17:35 +00:00
71 changed files with 3348 additions and 326 deletions
+1 -1
View File
@@ -64,7 +64,7 @@
| workflow-rdev | 研发设计控制(rdev) | 医疗第 4 工作流:13×60 manifest/模板、`squad.rdev_doc.v1` kind 编排、main-completed 硬 precheck、StageKey 动态化;不含前端/真 PI/键名空间化 | partial | deferred_enhancement | [设计](../system-design/project-workflow/07-研发工作流rdev接入-设计与实施计划.md);[收口](../ops/short-term/2026-09-22-rdev-closeout.md);[报告](../test-reports/2026-09-22-rdev-closeout-test-report.md);[P3 历史](../test-reports/2026-09-19-rdev-p3-real-pi-test-report.md) | **after 2026-09-22 收口**:`rd_proposal` 标题门禁 + 法规表首表截止 dry PASS;nightly 脚本已扩四工作流(FullChain×3+FullNone)。P3 真 PI 三样本重跑因缺 `env:dev` **blocked**(不以 stub 冒充)。剩余:获环境后重跑 P3。本行保持 partial | `workflow-def` / `rdev-templates` | | workflow-rdev | 研发设计控制(rdev) | 医疗第 4 工作流:13×60 manifest/模板、`squad.rdev_doc.v1` kind 编排、main-completed 硬 precheck、StageKey 动态化;不含前端/真 PI/键名空间化 | partial | deferred_enhancement | [设计](../system-design/project-workflow/07-研发工作流rdev接入-设计与实施计划.md);[收口](../ops/short-term/2026-09-22-rdev-closeout.md);[报告](../test-reports/2026-09-22-rdev-closeout-test-report.md);[P3 历史](../test-reports/2026-09-19-rdev-p3-real-pi-test-report.md) | **after 2026-09-22 收口**:`rd_proposal` 标题门禁 + 法规表首表截止 dry PASS;nightly 脚本已扩四工作流(FullChain×3+FullNone)。P3 真 PI 三样本重跑因缺 `env:dev` **blocked**(不以 stub 冒充)。剩余:获环境后重跑 P3。本行保持 partial | `workflow-def` / `rdev-templates` |
| workflow-swboot | 纯软件项目自举 | 软件项目创建、Bootstrap、Delivery、受控工程交接 | partial | deferred_enhancement(明确不挡首发) | [设计](../system-design/2026-09-14-software-project-bootstrap-workflow.md);[ADR](../adr/2026-09-16-software-project-bootstrap-keys.md);[L1 报告](../test-reports/2026-09-16-software-project-bootstrap-test-report.md);[W5a](../test-reports/2026-09-17-w5a-software-bootstrap-ui-pi-test-report.md);[红线证据](../test-reports/2026-09-22-redline-evidence-swboot-test-report.md) | **after W5a + 红线证据**:catalog 驱动创建、软件不挂医疗 key。**SWBOOT-10 仍 stub**(L1–L4 证据已补;L5/L6 待授权)。本行不升 done | `workflow-design` | | workflow-swboot | 纯软件项目自举 | 软件项目创建、Bootstrap、Delivery、受控工程交接 | partial | deferred_enhancement(明确不挡首发) | [设计](../system-design/2026-09-14-software-project-bootstrap-workflow.md);[ADR](../adr/2026-09-16-software-project-bootstrap-keys.md);[L1 报告](../test-reports/2026-09-16-software-project-bootstrap-test-report.md);[W5a](../test-reports/2026-09-17-w5a-software-bootstrap-ui-pi-test-report.md);[红线证据](../test-reports/2026-09-22-redline-evidence-swboot-test-report.md) | **after W5a + 红线证据**:catalog 驱动创建、软件不挂医疗 key。**SWBOOT-10 仍 stub**(L1–L4 证据已补;L5/L6 待授权)。本行不升 done | `workflow-design` |
| iam-authz | IAM / 鉴权 | 项目级 API 鉴权 + RBAC;会话/阶段授权 | done | 加固 | [v3 full inventory](../audit/authorization-matrix-v3-full.md);[session 中断](../test-reports/2026-09-16-iam-route-inventory-test-report.md);[fail-closed](../test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md);[410 策略](../audit/2026-09-16-bug11-410-retire-decision.md);[R13](../audit/2026-09-16-r13-data-safety-checklist.md);[deny_only_submit](../test-reports/2026-09-24-non-author-member-access-test-report.md) | 全路由 inventory + session 中断负例 + 410 冻结 + R13 基线已签 **after 2026-09-24 deny_only_submit**:成员可 run/edit/上传/草稿;送审/审核/批准仍职责门禁。[重验](../test-reports/2026-09-24-non-author-member-access-test-report.md) | `authz` | | iam-authz | IAM / 鉴权 | 项目级 API 鉴权 + RBAC;会话/阶段授权 | done | 加固 | [v3 full inventory](../audit/authorization-matrix-v3-full.md);[session 中断](../test-reports/2026-09-16-iam-route-inventory-test-report.md);[fail-closed](../test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md);[410 策略](../audit/2026-09-16-bug11-410-retire-decision.md);[R13](../audit/2026-09-16-r13-data-safety-checklist.md);[deny_only_submit](../test-reports/2026-09-24-non-author-member-access-test-report.md) | 全路由 inventory + session 中断负例 + 410 冻结 + R13 基线已签 **after 2026-09-24 deny_only_submit**:成员可 run/edit/上传/草稿;送审/审核/批准仍职责门禁。[重验](../test-reports/2026-09-24-non-author-member-access-test-report.md) | `authz` |
| docgov | 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | done | non_blocker | [docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[正文 UI](../test-reports/2026-09-14-controlled-content-ui-acceptance.md);[canonical hash](../remediation/2026-09-14-controlled-content-canonical-hash-remediation.md);[键统一](../test-reports/2026-09-21-workflow-doc-key-unification-test-report.md);[deny_only_submit](../test-reports/2026-09-24-non-author-member-access-test-report.md) | 项目 143 只读;不终止。`doc_proj*` 同一发布键不能再并存;[自由协作认领](../test-reports/2026-09-24-free-collab-claim-notify-test-report.md) **after 2026-09-24 deny_only_submit**:成员可 run/edit/上传/草稿;送审/审核/批准仍职责门禁。[重验](../test-reports/2026-09-24-non-author-member-access-test-report.md) | `docgov` | | docgov | 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | done | non_blocker | [docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[正文 UI](../test-reports/2026-09-14-controlled-content-ui-acceptance.md);[canonical hash](../remediation/2026-09-14-controlled-content-canonical-hash-remediation.md);[键统一](../test-reports/2026-09-21-workflow-doc-key-unification-test-report.md);[deny_only_submit](../test-reports/2026-09-24-non-author-member-access-test-report.md);[三权流转](../test-reports/2026-09-25-doc-review-lifecycle-test-report.md) | 项目 143 只读;不终止。`doc_proj*` 同一发布键不能再并存;[自由协作认领](../test-reports/2026-09-24-free-collab-claim-notify-test-report.md) **after 2026-09-24 deny_only_submit**:成员可 run/edit/上传/草稿;送审/审核/批准仍职责门禁。[重验](../test-reports/2026-09-24-non-author-member-access-test-report.md)。**after 2026-09-26 落地**:锁定期补充积压(migration 00073)、流转职责通知、阶段化批注矩阵、重开 Approver 优先已随 doc-review-lifecycle-land 合入 dev;对话触发键(comment/reopen/pending)进入 U11 live 基线集;真实 PI E2E 仍待授权环境补跑。设计见 [三权流转设计](../system-design/2026-09-25-doc-review-lifecycle-design.md) | `docgov` |
| docgov-revision | 文档版本 baseline | 账本、人工修订、确定性 diff+lineage、运行时 UI、负责人通知 | done | non_blocker | [管理](../test-reports/2026-09-13-document-revision-management-test-report.md);[Round20 UI](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[ADR](../adr/2026-09-13-document-version-boundary.md);[proposed/authoritative](../ops/short-term/2026-09-20-proposed-authoritative-reports.md) | **before**:完整 PI narrative 混入 → **after**:迁「PI change-report 叙事」。fallback 报告是本行内显式行为,不是缺口。lineage GET 已按 URL 项目和文档可见性裁剪。test_manager 血缘的下游/上游版本改读文档版本,不再写死 1。Agent 候选报告为 `proposed`,正式提交才写 authoritative 后继并挂到版本;拒绝、替代后旧报告不能再出建议。人工修订、导入和聊天卡仍未分开预估与正式报告,见 [T1](../ops/short-term/2026-09-20-impact-loop-t1.md)。项目追踪索引见下一行 | `docgov` | | docgov-revision | 文档版本 baseline | 账本、人工修订、确定性 diff+lineage、运行时 UI、负责人通知 | done | non_blocker | [管理](../test-reports/2026-09-13-document-revision-management-test-report.md);[Round20 UI](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[ADR](../adr/2026-09-13-document-version-boundary.md);[proposed/authoritative](../ops/short-term/2026-09-20-proposed-authoritative-reports.md) | **before**:完整 PI narrative 混入 → **after**:迁「PI change-report 叙事」。fallback 报告是本行内显式行为,不是缺口。lineage GET 已按 URL 项目和文档可见性裁剪。test_manager 血缘的下游/上游版本改读文档版本,不再写死 1。Agent 候选报告为 `proposed`,正式提交才写 authoritative 后继并挂到版本;拒绝、替代后旧报告不能再出建议。人工修订、导入和聊天卡仍未分开预估与正式报告,见 [T1](../ops/short-term/2026-09-20-impact-loop-t1.md)。项目追踪索引见下一行 | `docgov` |
| trace-impact | 阶段回溯与影响闭环 | 五表追踪索引、影响报告、L1 文档修订、L2 阶段再打开、待复核软标记、对话修订提案 | done | deferred_enhancement(不挡首发) | [设计](../system-design/2026-09-20-stage-backtrack-and-impact-loop-design.md);[Track T](../remediation/2026-09-20-capability-enablement-impact-plan.md);[对话化报告](../test-reports/2026-09-21-trace-panel-to-chat-test-report.md);[T 补强](../ops/short-term/2026-09-22-track-t-strengthen.md) | 已合入 dev。常驻「需求与影响」面板已退役。**2026-09-23 实测** DOCREV 路径 schema **63**;test=`rc-v1.1.0-beta.1` schema 61;main 本轮未晋升。阶段推进确认门禁仍在。不签 D7 对话 ADR;[补充影响批次](../test-reports/2026-09-24-free-collab-claim-notify-test-report.md)| `trace-index` | | trace-impact | 阶段回溯与影响闭环 | 五表追踪索引、影响报告、L1 文档修订、L2 阶段再打开、待复核软标记、对话修订提案 | done | deferred_enhancement(不挡首发) | [设计](../system-design/2026-09-20-stage-backtrack-and-impact-loop-design.md);[Track T](../remediation/2026-09-20-capability-enablement-impact-plan.md);[对话化报告](../test-reports/2026-09-21-trace-panel-to-chat-test-report.md);[T 补强](../ops/short-term/2026-09-22-track-t-strengthen.md) | 已合入 dev。常驻「需求与影响」面板已退役。**2026-09-23 实测** DOCREV 路径 schema **63**;test=`rc-v1.1.0-beta.1` schema 61;main 本轮未晋升。阶段推进确认门禁仍在。不签 D7 对话 ADR;[补充影响批次](../test-reports/2026-09-24-free-collab-claim-notify-test-report.md)| `trace-index` |
| docgov-pi-narrative | PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | done | deferred_enhancement | [Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[go/no-go GO](../ops/research/2026-09-16-wave3-pu6-pu8-go-no-go.md);[pu7](../ops/long-term/2026-09-16-pu7-pi-narrative-contract.md);[W3 真 PI](../test-reports/2026-09-17-w3-pi-change-report-narrative-test-report.md);[红线负例](../test-reports/2026-09-22-redline-evidence-pi-narrative-test-report.md);[收口](../test-reports/2026-09-23-pi-narrative-closeout-test-report.md) | **after 2026-09-23 收口**:修复后真实 MiniMax 复测 PASS(截断差异输入 + reasoning 目标落盘);默认 flag **off**;dev/test 常驻开启另授权 | `docgov` | | docgov-pi-narrative | PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | done | deferred_enhancement | [Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[go/no-go GO](../ops/research/2026-09-16-wave3-pu6-pu8-go-no-go.md);[pu7](../ops/long-term/2026-09-16-pu7-pi-narrative-contract.md);[W3 真 PI](../test-reports/2026-09-17-w3-pi-change-report-narrative-test-report.md);[红线负例](../test-reports/2026-09-22-redline-evidence-pi-narrative-test-report.md);[收口](../test-reports/2026-09-23-pi-narrative-closeout-test-report.md) | **after 2026-09-23 收口**:修复后真实 MiniMax 复测 PASS(截断差异输入 + reasoning 目标落盘);默认 flag **off**;dev/test 常驻开启另授权 | `docgov` |
@@ -0,0 +1,66 @@
# 受控文档三权流转控制完善(审核/批准/归档/重开 + 锁定期补充消化)
> 落地版 · `task_id=doc-review-lifecycle-2026-09-25` · 2026-09-25
## 1. 目标
在既有四态状态机(`drafting→reviewing→approving→approved`)上补齐四条缺口,不重造状态机:
| ID | 缺口 | 处理 |
|---|---|---|
| G1 | 锁定期补充误生成提案后卡死 | 积压账本 + `skipped_locked` |
| G2 | 职责人流转零通知 | 生命周期事件 + 收件人按槽位路由 |
| G3 | 批注不落库 / 权限过宽 | 阶段化矩阵 + resolve API + 前端落库 |
| G4 | 重开无 UI / 审批人仅创建人 | Approver 优先 + 创建人兜底 + 前端入口 |
## 2. 状态机(语义不变,正交积压)
```mermaid
stateDiagram-v2
drafting --> reviewing : submit_review
reviewing --> approving : review_approve
reviewing --> drafting : review_reject
approving --> approved : approver_approve
approving --> reviewing : approver_reject
approving --> drafting : approver_reject
approved --> drafting : reopen_approve
```
积压表 `document_pending_supplements`(goose `00073`)独立于 `document_records` 状态列。回 drafting 时 `DigestPendingOnReturnToDrafting` 标记 digested 并通知 author。
## 3. 锁定期补充闭环
1. `CreateFromSupplement`:`LockedSnapshot = IsLocked || state != drafting`;`BusySnapshot` 仍为编辑租约。
2. `Decide`:锁定项 → `OutcomeSkippedLocked` + `PendingLedger.Record` + 知会当前职责人(reviewing→reviewer,approving/approved→approver,缺位→author)。
3. 回 drafting(驳回/重开)→ 消化积压 + `pending_supplements` digest。
## 4. 流转通知收件人
| 事件 | 收件人 |
|---|---|
| `document.review_requested` | ReviewerID |
| `document.approval_requested` | ApproverID |
| `document.rejected` / `reopened` / `pending_supplements` | AuthorID |
| `document.approved` | AuthorID |
`docnotify.RecipientForDocEvent` 统一解析;lifecycle hook 在 `SubmitForReview` / `Review*` / `Approver*` / `ReopenForRevision` 成功后触发。
## 5. 批注权限矩阵
| 状态 | 可新增批注 |
|---|---|
| drafting | author, reviewer |
| reviewing | reviewer, author(回应) |
| approving | approver, reviewer |
| approved | 禁止新增(`ErrDocumentLocked`) |
| viewer | 恒只读 |
`POST /api/v1/docgov/comments/{id}/resolve`:author 在 drafting 可 resolved;reviewer 可 reopen。
## 6. 重开审批
`handleProjectTraceReopenApprove`:文档 `ApproverID` 且成员仍 active → 可批;全部批准人缺位/退出 → 项目创建人兜底。前端 approved 态提供「申请再修订」。
## 7. 明确不做
- 不新增第五状态、不改 `IsLocked` 语义、不引入批量审批委托、不动 FAV 归档链路。
@@ -0,0 +1,92 @@
# 受控文档三权流转控制完善 测试报告
## 一、测试背景 (Background)
补齐受控文档审核/批准/归档/重开链路与锁定期补充积压闭环(G1–G4),对应设计见 `.doc/system-design/2026-09-25-doc-review-lifecycle-design.md`。
## 二、测试目标 (Test Objectives)
1. 锁定态补充 → `skipped_locked` + 积压落账,不生成提案。
2. 流转通知收件人按职责槽位路由。
3. 批注阶段化权限矩阵(负例:viewer / approving 期 author)。
4. 重开审批:Approver 优先,缺位回退创建人(代码路径;HTTP 集成待环境)。
5. 前端批注策略与 API 形状。
## 三、测试方案与环境 (Test Plan & Environment)
- OS: Linux;Go 短测;前端 `tsx --test`。
- 层级:工程单元 / 包级短测(验证契约「工程工具验收补充」)。
- **真实 PI E2E**:本回合未获授权共享环境与产品 Runner 槽位,记为 **未运行**(不得冒充 PASS)。
## 四、预期结果 (Expected Results)
见各用例断言。
## 五、实际测试结果与判定 (Actual Results & Evaluation)
### 5.1 Go:`internal/docgov` / `supplementimpact` / `app/docnotify`
- **测试输入**:`go test ./internal/docgov/... ./internal/supplementimpact/... ./internal/app/docnotify/... -count=1`
- **实际输出**:
```text
ok chat-agent-system/internal/docgov 0.003s
ok chat-agent-system/internal/supplementimpact 0.002s
ok chat-agent-system/internal/app/docnotify 0.002s
```
- **评价判定**:PASS(含 `TestDecideLockedSkipsAndRecordsPending`、`TestAddCommentStageMatrix`、`TestRecipientForDocEvent`、`TestHandleReviewRequestedRoutesToReviewer`)
- **性能与耗时**:约 1–2s 量级冷启动后单包毫秒级
### 5.2 Go:`internal/server` short
- **测试输入**:`go test ./internal/server/ -count=1 -short`
- **实际输出**:`ok chat-agent-system/internal/server`(约 19s)
- **评价判定**:PASS
### 5.3 前端 commentPolicy
- **测试输入**:`./node_modules/.bin/tsx --test src/lib/commentPolicy.test.ts`
- **实际输出**:4 tests pass(stage matrix / viewer / resolve gates)
- **评价判定**:PASS
### 5.4 对话触发链路(批注 / 重开 / 积压 / 送审真执行)
- **测试输入**:
```bash
go test ./internal/intent/... ./internal/coordinator/... ./internal/chat/ \
-run 'TestIntentRecognition_DocGov_CommentReopenPending|TestListDocResolver|TestHandleDocGov|TestExecuteAction_' -count=1
```
- **实际输出**(摘录):
```text
ok chat-agent-system/internal/intent 0.008s
ok chat-agent-system/internal/coordinator 0.005s
ok chat-agent-system/internal/chat 0.005s
```
覆盖:查询语气/裸词「重开」负例、批注矩阵剪枝、DocResolver 0/多匹配澄清、送审确认卡→拍板 `docgov.submit_review`、comment/reopen action kinds 成功与权限失败路径。
- **评价判定**:PASS(对话触发与误触防护单测)
- **说明**:`TestChatSession_SemanticMemoryRecallAndAsyncTurnRecord` 仍偶发超时,与本变更无关,未纳入本节判定。
### 5.5 真实 PI E2E 全链
- **评价判定**:未运行(待授权环境:编制→送审→锁定批注→驳回→积压消化→再送审→批准→归档→重开;以及对话通道两步确认)
## 六、自动化测试套件汇总 (Test Suite Execution)
| 套件 | 结果 |
|---|---|
| `go test` docgov / supplementimpact / docnotify | PASS |
| `go test ./internal/server -short` | PASS |
| `commentPolicy.test.ts` | PASS |
| `go test` intent / coordinator / chat(对话触发子集) | PASS |
| 产品 PI E2E | 未运行 |
## 七、测试结论与后续优化建议 (Conclusion & Next Steps)
- 单元与短测覆盖 G1–G3 主路径与负例;G4 审批人逻辑与对话触发四道防线(语法护栏、DocResolver、角色×阶段、确认卡)已落代码。
- 合入后建议:跑 goose `00073`、再跑一次真实 PI 全链(含对话送审/批注/重开)并回填本节 5.5。
@@ -0,0 +1,24 @@
# doc-review-lifecycle-land re-validation 证据(2026-09-26)
落地内容:doc-review-lifecycle 分支 986d78cc + 09fe9723(三权流转 + 对话触发)cherry-pick 至 dev 6adf694f 之上,冲突 12 处按 dev 现行结构重解(per-docID 锁、gov()/authorizer() 访问器、U11 live 键函数化、migration 重编号 00072→00073、app/docnotify 去 stream 依赖)。
执行环境:Linux worktree feature/doc-review-lifecycle-land-2026-09-26 @ 985bc55f(MATRIX 收据提交前 HEAD b5a782bb)。
## 命令与结果(真实执行,2026-09-26 20:15 前后)
1. `go build ./internal/...` — PASS
2. `go vet ./...` — clean(无输出)
3. `go test -short ./...` — 全部 `ok`,无 FAIL(原始输出本会话留存,逐包 ok;未运行的真实 PI E2E 见下)
4. `go test ./internal/docgov/... ./internal/supplementimpact/... ./internal/app/docnotify/... ./internal/coordinator/... ./internal/intent/... ./internal/chat/... ./internal/traceimpact/... ./internal/app/intentflow/... -count=1` — 全部 `ok`,无 FAIL
5. `./scripts/arch-check.sh --mode block` — `arch-check block: production gate passed`(stream import 违规已在落地时修复)
6. `cd web && npm test -- --run` — tsx 全量 + vitest 3 files / 34 tests passed
7. `cd web && npm run build` — tsc + vite build + precompress(66 .br / 66 .gz)成功
## 未运行项(沿用原任务报告口径,不冒充 PASS)
- 真实产品 PI Agent Runner E2E(对话送审/批注/重开全链):本回合未获共享环境授权槽位,未运行;合入 dev 后按 §3 在获授权环境做 health/ready + 业务冒烟。
- PG 集成负例:goose 00073 于 dev 环境重启时执行,属合入后运行时验证。
## 证据归档说明
本文件为足够判定的摘要(原始终端输出见任务会话);go test 逐包原始行因 `.log` 全局 ignore 改为摘要登记,命令 digest 由 agentctl validate 收据留存。
@@ -0,0 +1,26 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS document_pending_supplements (
id BIGSERIAL PRIMARY KEY,
doc_id TEXT NOT NULL,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
source_summary TEXT NOT NULL DEFAULT '',
impact_reason TEXT NOT NULL DEFAULT '',
initiator_user_id BIGINT NOT NULL DEFAULT 0,
batch_id TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
digested_at TIMESTAMPTZ,
decided_by BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_dps_status CHECK (status IN ('pending', 'digested', 'discarded'))
);
CREATE INDEX IF NOT EXISTS idx_dps_doc_status ON document_pending_supplements (doc_id, status);
CREATE INDEX IF NOT EXISTS idx_dps_project_status ON document_pending_supplements (project_id, status);
ALTER TABLE supplement_impact_items
ADD COLUMN IF NOT EXISTS locked_snapshot BOOLEAN NOT NULL DEFAULT FALSE;
-- +goose Down
-- record retention: do not drop tables
SELECT 1;
+49 -46
View File
@@ -20,6 +20,7 @@ import (
"chat-agent-system/internal/auth" "chat-agent-system/internal/auth"
"chat-agent-system/internal/authz" "chat-agent-system/internal/authz"
"chat-agent-system/internal/bizdev" "chat-agent-system/internal/bizdev"
"chat-agent-system/internal/chat"
"chat-agent-system/internal/config" "chat-agent-system/internal/config"
"chat-agent-system/internal/coordinator" "chat-agent-system/internal/coordinator"
"chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/ctxmgr"
@@ -32,11 +33,11 @@ import (
"chat-agent-system/internal/kb" "chat-agent-system/internal/kb"
"chat-agent-system/internal/maintenance" "chat-agent-system/internal/maintenance"
"chat-agent-system/internal/notification" "chat-agent-system/internal/notification"
"chat-agent-system/internal/supplementimpact"
"chat-agent-system/internal/observability" "chat-agent-system/internal/observability"
"chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/storage" "chat-agent-system/internal/storage"
"chat-agent-system/internal/supplementimpact"
"chat-agent-system/internal/sysadmin" "chat-agent-system/internal/sysadmin"
"chat-agent-system/internal/taskrun" "chat-agent-system/internal/taskrun"
"chat-agent-system/internal/traceimpact" "chat-agent-system/internal/traceimpact"
@@ -52,39 +53,40 @@ const defaultReminderPollInterval = 15 * time.Minute
// Runtime is the unique composition root. cmd/server, tests and workers // Runtime is the unique composition root. cmd/server, tests and workers
// construct it once; HTTP adapters must not re-wire domain services. // construct it once; HTTP adapters must not re-wire domain services.
type Runtime struct { type Runtime struct {
Cfg *config.Config Cfg *config.Config
Biz *bizdev.Service Biz *bizdev.Service
Engine *coordinator.CoordinatorEngine Engine *coordinator.CoordinatorEngine
Auth *auth.Service Auth *auth.Service
PW *projectworkflow.Service PW *projectworkflow.Service
TaskRuns *taskrun.Manager TaskRuns *taskrun.Manager
Authorizer *authz.Authorizer Authorizer *authz.Authorizer
Authz *authz.Service Authz *authz.Service
WFDef *workflowdef.Service WFDef *workflowdef.Service
Inst *projectinstance.Service Inst *projectinstance.Service
Notify *notification.Service Notify *notification.Service
Resolver *workspace.Resolver Resolver *workspace.Resolver
WSCap *workspace.Capability WSCap *workspace.Capability
Commands *taskrun.Commands Commands *taskrun.Commands
Rec intent.Recognizer Rec intent.Recognizer
Stageflow *stageflow.UseCase Stageflow *stageflow.UseCase
DocNotify *docnotify.Subscriber DocNotify *docnotify.Subscriber
ChatLog chatflow.ChatLog ChatLog chatflow.ChatLog
Assembler chatflow.ContextAssembler Assembler chatflow.ContextAssembler
Docflow *docflow.Commands Docflow *docflow.Commands
IntentPlans *intentflow.Plans IntentPlans *intentflow.Plans
Pool *pgxpool.Pool Pool *pgxpool.Pool
Gov *docgov.GovernanceEngine Gov *docgov.GovernanceEngine
Rev *docrevision.Service Rev *docrevision.Service
Trace *traceimpact.Service Trace *traceimpact.Service
SupplementImpact *supplementimpact.Service SupplementImpact *supplementimpact.Service
mutateOwner *MutateOwner mutateOwner *MutateOwner
Ctx *ctxmgr.ContextManager ChatReopens chat.ReopenService
CAS *storage.CAS Ctx *ctxmgr.ContextManager
FreeDoc *freedoc.Service CAS *storage.CAS
KB *kb.Client FreeDoc *freedoc.Service
SysAdmin *sysadmin.Service KB *kb.Client
Maintenance *maintenance.Service SysAdmin *sysadmin.Service
Maintenance *maintenance.Service
mu sync.Mutex mu sync.Mutex
running bool running bool
@@ -330,6 +332,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
rt.DocNotify.SkipWrite = func(ctx context.Context) bool { rt.DocNotify.SkipWrite = func(ctx context.Context) bool {
return rt.WriterLive(ctx, events.ConsumerNotify) return rt.WriterLive(ctx, events.ConsumerNotify)
} }
rt.ChatReopens = rt.WireDocDialogueTriggers()
envName := "" envName := ""
if cfg != nil { if cfg != nil {
envName = cfg.Env envName = cfg.Env
@@ -400,21 +403,21 @@ func sysadminFeatureValues(cfg *config.Config) func() map[string]string {
pflags := intentflow.CurrentFeatureFlags() pflags := intentflow.CurrentFeatureFlags()
sflags := ctxmgr.CurrentSummaryFlags() sflags := ctxmgr.CurrentSummaryFlags()
values := map[string]string{ values := map[string]string{
"INTENT_V2_SHADOW": strconv.FormatBool(iflags.IntentV2Shadow), "INTENT_V2_SHADOW": strconv.FormatBool(iflags.IntentV2Shadow),
"INTENT_V2_DECISION": strconv.FormatBool(iflags.IntentV2Decision), "INTENT_V2_DECISION": strconv.FormatBool(iflags.IntentV2Decision),
"INTENT_V2_DECISION_TIER": string(iflags.V2DecisionTier), "INTENT_V2_DECISION_TIER": string(iflags.V2DecisionTier),
"WORKFLOW_INTENTS_ENABLED": strconv.FormatBool(iflags.WorkflowIntentsEnabled), "WORKFLOW_INTENTS_ENABLED": strconv.FormatBool(iflags.WorkflowIntentsEnabled),
"MULTI_INTENT_ENABLED": strconv.FormatBool(iflags.MultiIntentEnabled), "MULTI_INTENT_ENABLED": strconv.FormatBool(iflags.MultiIntentEnabled),
"MULTI_INTENT_PLAN_TIER": string(pflags.PlanTier), "MULTI_INTENT_PLAN_TIER": string(pflags.PlanTier),
"INTENT_PLAN_PROJECT_ALLOWLIST": formatProjectAllowlist(pflags.ProjectAllowlist), "INTENT_PLAN_PROJECT_ALLOWLIST": formatProjectAllowlist(pflags.ProjectAllowlist),
"MULTI_INTENT_U9_SEGMENT": strconv.FormatBool(pflags.U9Segment), "MULTI_INTENT_U9_SEGMENT": strconv.FormatBool(pflags.U9Segment),
"MULTI_INTENT_U10_MUTATE": strconv.FormatBool(pflags.U10Mutate), "MULTI_INTENT_U10_MUTATE": strconv.FormatBool(pflags.U10Mutate),
"MULTI_INTENT_U11_DOCGOV": strconv.FormatBool(pflags.U11DocGov), "MULTI_INTENT_U11_DOCGOV": strconv.FormatBool(pflags.U11DocGov),
"MULTI_INTENT_U12_RUN_CONTROL": strconv.FormatBool(pflags.U12RunControl), "MULTI_INTENT_U12_RUN_CONTROL": strconv.FormatBool(pflags.U12RunControl),
"WORKFLOW_SCOPE_MODE": string(workflowscope.CurrentMode()), "WORKFLOW_SCOPE_MODE": string(workflowscope.CurrentMode()),
"SEMANTIC_SUMMARY_SHADOW": strconv.FormatBool(sflags.Shadow), "SEMANTIC_SUMMARY_SHADOW": strconv.FormatBool(sflags.Shadow),
"SEMANTIC_SUMMARY_REPLACE": strconv.FormatBool(sflags.Replace), "SEMANTIC_SUMMARY_REPLACE": strconv.FormatBool(sflags.Replace),
"PI_CHANGE_REPORT_NARRATIVE": strconv.FormatBool(docrevision.PIChangeReportNarrativeEnabled()), "PI_CHANGE_REPORT_NARRATIVE": strconv.FormatBool(docrevision.PIChangeReportNarrativeEnabled()),
} }
if cfg != nil { if cfg != nil {
values["AGENT_BACKEND"] = cfg.AgentBackend values["AGENT_BACKEND"] = cfg.AgentBackend
+246
View File
@@ -0,0 +1,246 @@
package app
import (
"context"
"fmt"
"strings"
"time"
"chat-agent-system/internal/chat"
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/traceimpact"
)
// chatReopenAdapter implements chat.ReopenService and coordinator.ReopenLister.
type chatReopenAdapter struct {
trace *traceimpact.Service
gov *docgov.GovernanceEngine
inst *projectinstance.Service
pw *projectworkflow.Service
}
func (a *chatReopenAdapter) ListPendingReopens(ctx context.Context, projectID int64) ([]chat.ReopenRequestView, error) {
if a == nil || a.trace == nil {
return nil, nil
}
rows, err := a.trace.ListPendingReopens(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]chat.ReopenRequestView, 0, len(rows))
for _, r := range rows {
out = append(out, chat.ReopenRequestView{
ID: r.ID, DocTasks: r.DocTasks, Reason: r.Reason, Status: r.Status,
})
}
return out, nil
}
func (a *chatReopenAdapter) ListPendingReopensForCoord(ctx context.Context, projectID int64) ([]coordinator.ReopenListItem, error) {
views, err := a.ListPendingReopens(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]coordinator.ReopenListItem, 0, len(views))
for _, v := range views {
out = append(out, coordinator.ReopenListItem{
RequestID: v.ID, DocTasks: v.DocTasks, Reason: v.Reason, Status: v.Status,
})
}
return out, nil
}
// Ensure interface satisfaction for coordinator.ReopenLister via thin wrapper.
type coordReopenLister struct{ inner *chatReopenAdapter }
func (c coordReopenLister) ListPendingReopens(ctx context.Context, projectID int64) ([]coordinator.ReopenListItem, error) {
if c.inner == nil {
return nil, nil
}
return c.inner.ListPendingReopensForCoord(ctx, projectID)
}
func (a *chatReopenAdapter) RequestReopenByDoc(ctx context.Context, projectID int64, taskKey, reason string, userID int64) (chat.ReopenRequestView, error) {
if a == nil || a.trace == nil {
return chat.ReopenRequestView{}, fmt.Errorf("reopen service unavailable")
}
taskKey = normalizeTaskKey(projectID, taskKey)
if taskKey == "" || strings.TrimSpace(reason) == "" {
return chat.ReopenRequestView{}, fmt.Errorf("task_key and reason required")
}
creator := false
if a.pw != nil {
creator, _ = a.pw.IsProjectCreator(ctx, projectID, userID)
}
stageKey, instanceID, seats, err := a.resolveStageForTask(ctx, projectID, userID, taskKey)
if err != nil {
return chat.ReopenRequestView{}, err
}
if !traceimpact.MayRequestReopen(userID, creator, []string{taskKey}, seats) {
return chat.ReopenRequestView{}, fmt.Errorf("无权申请重开:须为该文档职责人或项目创建人")
}
stored, err := a.trace.RequestReopen(ctx, traceimpact.ReopenRequest{
ProjectID: projectID, InstanceID: instanceID, StageKey: stageKey,
DocTasks: []string{taskKey}, Reason: reason, RequestedBy: userID,
}, creator)
if err != nil {
return chat.ReopenRequestView{}, err
}
return chat.ReopenRequestView{
ID: stored.ID, DocTasks: stored.DocTasks, Reason: stored.Reason, Status: stored.Status,
}, nil
}
func (a *chatReopenAdapter) ApproveReopenByID(ctx context.Context, projectID, requestID, userID int64) (chat.ReopenRequestView, error) {
if a == nil || a.trace == nil {
return chat.ReopenRequestView{}, fmt.Errorf("reopen service unavailable")
}
existing, err := a.trace.GetReopen(ctx, requestID)
if err != nil {
return chat.ReopenRequestView{}, err
}
if existing.ProjectID != projectID {
return chat.ReopenRequestView{}, fmt.Errorf("申请不属于该项目")
}
creator := false
if a.pw != nil {
creator, _ = a.pw.IsProjectCreator(ctx, projectID, userID)
}
if !a.mayApprove(ctx, projectID, userID, creator, existing) {
return chat.ReopenRequestView{}, fmt.Errorf("无权批准重开:须为文档批准人或(批准人缺位时)项目创建人")
}
stored, err := a.trace.ApproveReopen(ctx, requestID, userID)
if err != nil {
return chat.ReopenRequestView{}, err
}
return chat.ReopenRequestView{
ID: stored.ID, DocTasks: stored.DocTasks, Reason: stored.Reason, Status: stored.Status,
}, nil
}
func (a *chatReopenAdapter) mayApprove(ctx context.Context, projectID, userID int64, creator bool, req traceimpact.ReopenRequest) bool {
hasActiveApprover := false
userIsApprover := false
if a.gov != nil {
for _, taskKey := range req.DocTasks {
doc, err := a.gov.FindByProjectTaskKey(ctx, projectID, taskKey)
if err != nil || doc == nil || doc.ApproverID <= 0 {
continue
}
hasActiveApprover = true
if doc.ApproverID == userID {
userIsApprover = true
}
}
}
if userIsApprover {
return true
}
return !hasActiveApprover && creator
}
func (a *chatReopenAdapter) resolveStageForTask(ctx context.Context, projectID, userID int64, taskKey string) (stageKey string, instanceID int64, seats []traceimpact.DutySeat, err error) {
if a.inst == nil {
return "", 0, nil, fmt.Errorf("instance service unavailable")
}
settings, err := a.inst.GetSettings(ctx, projectID, userID, true)
if err != nil || settings == nil || settings.Plan == nil {
return "", 0, nil, fmt.Errorf("无法解析工作流阶段")
}
if settings.LiveInstance != nil {
instanceID = settings.LiveInstance.ID
}
for _, stage := range settings.Plan.Stages {
for _, task := range stage.DocTasks {
seats = append(seats, traceimpact.DutySeat{
TaskKey: task.TaskKey, AuthorID: task.Duties.AuthorUserID,
ReviewerID: task.Duties.ReviewerUserID, ApproverID: task.Duties.ApproverUserID,
})
if task.TaskKey == taskKey {
stageKey = stage.StageKey
}
}
}
if stageKey == "" {
return "", 0, nil, fmt.Errorf("文档任务 %s 不在当前工作流规划中", taskKey)
}
if instanceID == 0 {
return "", 0, nil, fmt.Errorf("项目尚无激活工作流实例")
}
return stageKey, instanceID, seats, nil
}
func normalizeTaskKey(projectID int64, ref string) string {
ref = strings.TrimSpace(ref)
prefix := fmt.Sprintf("doc_proj%d_", projectID)
if strings.HasPrefix(ref, prefix) {
return strings.TrimPrefix(ref, prefix)
}
return ref
}
// AdaptDocResolver builds a ListDocResolver from GovernanceEngine.
func AdaptDocResolver(gov *docgov.GovernanceEngine) coordinator.DocResolver {
if gov == nil {
return nil
}
return &coordinator.ListDocResolver{
List: func(ctx context.Context, projectID int64) ([]coordinator.DocListItem, error) {
rows, err := gov.ListWorkflowDocumentsByProject(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]coordinator.DocListItem, 0, len(rows))
for _, r := range rows {
out = append(out, coordinator.DocListItem{
DocID: r.DocID, Title: r.Title, State: r.LifecycleState, TaskKey: r.TaskKey,
Aliases: []string{r.TaskKey},
})
}
return out, nil
},
}
}
type pendingSuppAdapter struct{ gov *docgov.GovernanceEngine }
func (p pendingSuppAdapter) ListPendingSupplements(ctx context.Context, projectID int64, docID string) ([]coordinator.PendingSupplementView, error) {
if p.gov == nil {
return nil, nil
}
rows, err := p.gov.ListPendingSupplementsByProject(ctx, projectID, docID)
if err != nil {
return nil, err
}
out := make([]coordinator.PendingSupplementView, 0, len(rows))
for _, r := range rows {
title := r.DocID
if doc, derr := p.gov.GetDocument(ctx, r.DocID); derr == nil && doc != nil && doc.Title != "" {
title = doc.Title
}
created := ""
if !r.CreatedAt.IsZero() {
created = r.CreatedAt.Format(time.RFC3339)
}
out = append(out, coordinator.PendingSupplementView{
DocID: r.DocID, DocTitle: title, SourceSummary: r.SourceSummary,
ImpactReason: r.ImpactReason, CreatedAt: created,
})
}
return out, nil
}
// WireDocDialogueTriggers injects DocResolver / pending supplements / reopen ports onto the coordinator and returns a chat ReopenService.
func (r *Runtime) WireDocDialogueTriggers() chat.ReopenService {
if r == nil || r.Engine == nil {
return nil
}
r.Engine.SetDocResolver(AdaptDocResolver(r.Gov))
r.Engine.SetPendingSupplementReader(pendingSuppAdapter{gov: r.Gov})
adapter := &chatReopenAdapter{trace: r.Trace, gov: r.Gov, inst: r.Inst, pw: r.PW}
r.Engine.SetReopenLister(coordReopenLister{inner: adapter})
return adapter
}
+18 -4
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/events" "chat-agent-system/internal/events"
"chat-agent-system/internal/notification" "chat-agent-system/internal/notification"
@@ -44,16 +45,21 @@ func (c *EventConsumer) HandleLease(ctx context.Context, lease events.Lease, tx
} }
var recipient int64 var recipient int64
projectID := lease.ProjectID projectID := lease.ProjectID
var doc *docgov.DocumentRecord
if c.Docs != nil && docID != "" { if c.Docs != nil && docID != "" {
doc, err := c.Docs.GetDocument(ctx, docID) d, err := c.Docs.GetDocument(ctx, docID)
if err != nil || doc == nil { if err != nil || d == nil {
return events.ErrQuarantine return events.ErrQuarantine
} }
if doc.ProjectID != lease.ProjectID { if d.ProjectID != lease.ProjectID {
return events.ErrQuarantine return events.ErrQuarantine
} }
doc = d
projectID = d.ProjectID
}
recipient = RecipientForDocEvent(doc, lease.EventName)
if recipient <= 0 && doc != nil {
recipient = doc.AuthorID recipient = doc.AuthorID
projectID = doc.ProjectID
} }
if recipient <= 0 { if recipient <= 0 {
return events.ErrQuarantine return events.ErrQuarantine
@@ -89,6 +95,14 @@ func notifyCopy(eventName string) (kind, title, body string) {
return notification.TypeDocProposalRequired, "文档变更待确认", "已认领文档有变更提案,请确认后由您发起修改。" return notification.TypeDocProposalRequired, "文档变更待确认", "已认领文档有变更提案,请确认后由您发起修改。"
case events.EventDocUnclaimedEdit: case events.EventDocUnclaimedEdit:
return notification.TypeDocUnclaimedEditApplied, "文档已更新", "未认领文档已被直接更新(无需您确认)。" return notification.TypeDocUnclaimedEditApplied, "文档已更新", "未认领文档已被直接更新(无需您确认)。"
case notification.EventDocReviewRequested:
return notification.TypeDocReviewRequested, "文档待审核", "有文档已送审,请审核。"
case notification.EventDocApprovalRequested:
return notification.TypeDocApprovalRequested, "文档待批准", "审核已通过,请批准。"
case notification.EventDocRejected:
return notification.TypeDocRejected, "文档已驳回", "文档已被驳回,请查看原因并修订。"
case notification.EventDocReopened:
return notification.TypeDocReopened, "文档已重开", "文档已重开修订,请继续编制。"
default: default:
return "", "", "" return "", "", ""
} }
+171
View File
@@ -0,0 +1,171 @@
package docnotify
import (
"context"
"fmt"
"strings"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/notification"
)
// LifecycleAction identifies a governance transition for routing.
type LifecycleAction string
const (
LifecycleSubmitReview LifecycleAction = "submit_review"
LifecycleReviewApprove LifecycleAction = "review_approve"
LifecycleReviewReject LifecycleAction = "review_reject"
LifecycleApproverApprove LifecycleAction = "approver_approve"
LifecycleApproverReject LifecycleAction = "approver_reject"
LifecycleReopen LifecycleAction = "reopen"
)
// RecipientForDocEvent resolves the duty-holder inbox recipient for a document event.
func RecipientForDocEvent(doc *docgov.DocumentRecord, eventType string) int64 {
if doc == nil {
return 0
}
switch eventType {
case notification.EventDocReviewRequested:
if doc.ReviewerID > 0 {
return doc.ReviewerID
}
case notification.EventDocApprovalRequested:
if doc.ApproverID > 0 {
return doc.ApproverID
}
case notification.EventDocRejected, notification.EventDocReopened, notification.EventDocPendingSupplements:
if doc.AuthorID > 0 {
return doc.AuthorID
}
case notification.EventDocApproved:
if doc.AuthorID > 0 {
return doc.AuthorID
}
}
if doc.AuthorID > 0 {
return doc.AuthorID
}
return 0
}
// EmitLifecycleParams carries optional chat side channels.
type EmitLifecycleParams struct {
Notify notificationEmitter
Digest digestUpserter
Chat *ctxmgr.ContextManager
Action LifecycleAction
Doc *docgov.DocumentRecord
ActorID int64
ActorName string
Detail string
}
type digestUpserter interface {
UpsertDocumentDigest(ctx context.Context, p notification.UpsertDigestParams) error
}
// EmitLifecycle notifies the duty holder for a governance transition.
func EmitLifecycle(ctx context.Context, p EmitLifecycleParams) {
if p.Doc == nil || p.Notify == nil || !p.Notify.Available() {
return
}
eventType, notifType, title, body, severity := lifecycleCopy(p.Action, p.Doc, p.ActorName, p.Detail)
if eventType == "" {
return
}
recipient := RecipientForDocEvent(p.Doc, eventType)
if recipient <= 0 {
return
}
payload := notification.DocumentEventPayload{
ProjectID: p.Doc.ProjectID, DocID: p.Doc.DocID, DocTitle: p.Doc.Title,
Summary: truncateLifecycle(p.Detail, 80),
}
dedupe := fmt.Sprintf("doc_lifecycle:%s:%s:%d:%s", p.Action, p.Doc.DocID, recipient, p.Doc.CurrentState)
_, _ = p.Notify.EmitDocumentEvent(ctx, eventType, notifType, title, body, dedupe, recipient, payload)
if p.Digest != nil && severity == notification.SeverityActionRequired {
kind := digestKindForEvent(eventType)
if kind != "" {
_ = p.Digest.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{
UserID: recipient, ProjectID: p.Doc.ProjectID, DigestKind: kind,
DocID: p.Doc.DocID, DocTitle: p.Doc.Title, Severity: severity,
ActorID: p.ActorID, ActorName: p.ActorName, Summary: truncateLifecycle(p.Detail, 60),
})
}
}
if p.Chat != nil {
_ = PostProjectNotice(ctx, p.Chat, p.Doc.ProjectID, recipient, body, map[string]any{
"doc_id": p.Doc.DocID, "action": string(p.Action), "event": eventType,
})
}
}
func digestKindForEvent(eventType string) string {
switch eventType {
case notification.EventDocReviewRequested:
return notification.DigestKindReviewRequested
case notification.EventDocApprovalRequested:
return notification.DigestKindApprovalRequested
case notification.EventDocPendingSupplements:
return notification.DigestKindPendingSupplements
default:
return ""
}
}
func lifecycleCopy(action LifecycleAction, doc *docgov.DocumentRecord, actorName, detail string) (eventType, notifType, title, body, severity string) {
titleDoc := strings.TrimSpace(doc.Title)
if titleDoc == "" {
titleDoc = doc.DocID
}
actor := strings.TrimSpace(actorName)
if actor == "" {
actor = "成员"
}
reason := strings.TrimSpace(detail)
switch action {
case LifecycleSubmitReview:
return notification.EventDocReviewRequested, notification.TypeDocReviewRequested,
fmt.Sprintf("《%s》待您审核", titleDoc),
fmt.Sprintf("%s 已送审,请审核本文档。", actor),
notification.SeverityActionRequired
case LifecycleReviewApprove:
return notification.EventDocApprovalRequested, notification.TypeDocApprovalRequested,
fmt.Sprintf("《%s》待您批准", titleDoc),
fmt.Sprintf("%s 审核已通过,请批准本文档。", actor),
notification.SeverityActionRequired
case LifecycleReviewReject, LifecycleApproverReject:
msg := fmt.Sprintf("%s 驳回了《%s》。", actor, titleDoc)
if reason != "" {
msg += "原因:" + reason
}
return notification.EventDocRejected, notification.TypeDocRejected,
fmt.Sprintf("《%s》已被驳回", titleDoc), msg,
notification.SeverityActionRequired
case LifecycleApproverApprove:
return notification.EventDocApproved, notification.TypeDocApproved,
fmt.Sprintf("《%s》已批准归档", titleDoc),
fmt.Sprintf("%s 已终审批准,《%s》已归档锁定。", actor, titleDoc),
notification.SeverityInfo
case LifecycleReopen:
return notification.EventDocReopened, notification.TypeDocReopened,
fmt.Sprintf("《%s》已重开修订", titleDoc),
fmt.Sprintf("%s 批准重开修订,《%s》已回编制期。", actor, titleDoc),
notification.SeverityActionRequired
default:
return "", "", "", "", ""
}
}
func truncateLifecycle(s string, n int) string {
r := []rune(strings.TrimSpace(s))
if len(r) <= n {
return string(r)
}
return string(r[:n]) + "…"
}
+47
View File
@@ -0,0 +1,47 @@
package docnotify
import (
"context"
"testing"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/notification"
)
func TestRecipientForDocEvent(t *testing.T) {
doc := &docgov.DocumentRecord{AuthorID: 1, ReviewerID: 2, ApproverID: 3}
cases := []struct {
event string
want int64
}{
{notification.EventDocReviewRequested, 2},
{notification.EventDocApprovalRequested, 3},
{notification.EventDocRejected, 1},
{notification.EventDocReopened, 1},
{notification.EventDocApproved, 1},
{notification.EventDocAgentCandidateReady, 1},
}
for _, tc := range cases {
if got := RecipientForDocEvent(doc, tc.event); got != tc.want {
t.Fatalf("%s: got %d want %d", tc.event, got, tc.want)
}
}
}
func TestHandleReviewRequestedRoutesToReviewer(t *testing.T) {
n := &stubNotify{available: true}
sub := &Subscriber{
Notify: n,
Docs: stubDocs{doc: &docgov.DocumentRecord{
AuthorID: 1, ReviewerID: 7, ApproverID: 9, ProjectID: 4,
}},
}
if err := sub.Handle(context.Background(), notification.EventDocReviewRequested, map[string]any{
"doc_id": "d1",
}); err != nil {
t.Fatal(err)
}
if n.calls != 1 || n.lastType != notification.EventDocReviewRequested {
t.Fatalf("calls=%d type=%s", n.calls, n.lastType)
}
}
+28 -4
View File
@@ -54,13 +54,17 @@ func (s *Subscriber) Handle(ctx context.Context, eventType string, payload map[s
docID, _ := payload["doc_id"].(string) docID, _ := payload["doc_id"].(string)
projectID, _ := payload["project_id"].(int64) projectID, _ := payload["project_id"].(int64)
risk, _ := payload["risk"].(string) risk, _ := payload["risk"].(string)
var recipient int64 var doc *docgov.DocumentRecord
if s.Docs != nil && docID != "" { if s.Docs != nil && docID != "" {
if doc, err := s.Docs.GetDocument(ctx, docID); err == nil && doc != nil && doc.AuthorID > 0 { if d, err := s.Docs.GetDocument(ctx, docID); err == nil && d != nil {
recipient = doc.AuthorID doc = d
projectID = doc.ProjectID projectID = d.ProjectID
} }
} }
recipient := RecipientForDocEvent(doc, eventType)
if recipient == 0 && doc != nil {
recipient = doc.AuthorID
}
if recipient == 0 { if recipient == 0 {
return nil return nil
} }
@@ -102,6 +106,26 @@ func (s *Subscriber) Handle(ctx context.Context, eventType string, payload map[s
"变更报告未完整评估", "变更报告降级,受控文档暂不可送审。", "变更报告未完整评估", "变更报告降级,受控文档暂不可送审。",
fmt.Sprintf("doc_report_degraded:%s:%d:%d", docID, p.ReportID, recipient), recipient, p) fmt.Sprintf("doc_report_degraded:%s:%d:%d", docID, p.ReportID, recipient), recipient, p)
return err return err
case notification.EventDocReviewRequested:
_, err := s.Notify.EmitDocumentEvent(ctx, eventType, notification.TypeDocReviewRequested,
"文档待审核", "有文档已送审,请审核。",
fmt.Sprintf("doc_review_requested:%s:%d", docID, recipient), recipient, p)
return err
case notification.EventDocApprovalRequested:
_, err := s.Notify.EmitDocumentEvent(ctx, eventType, notification.TypeDocApprovalRequested,
"文档待批准", "审核已通过,请批准。",
fmt.Sprintf("doc_approval_requested:%s:%d", docID, recipient), recipient, p)
return err
case notification.EventDocRejected:
_, err := s.Notify.EmitDocumentEvent(ctx, eventType, notification.TypeDocRejected,
"文档已驳回", "文档已被驳回,请查看原因并修订。",
fmt.Sprintf("doc_rejected:%s:%d", docID, recipient), recipient, p)
return err
case notification.EventDocReopened:
_, err := s.Notify.EmitDocumentEvent(ctx, eventType, notification.TypeDocReopened,
"文档已重开", "文档已重开修订,请继续编制。",
fmt.Sprintf("doc_reopened:%s:%d", docID, recipient), recipient, p)
return err
default: default:
return nil return nil
} }
+21 -9
View File
@@ -193,13 +193,18 @@ var mutateActionKeys = map[string]struct{}{
} }
var docGovActionKeys = map[string]struct{}{ var docGovActionKeys = map[string]struct{}{
"document.claim": {}, "document.claim": {},
"document.review.submit": {}, "document.review.submit": {},
"document.review.approve": {}, "document.review.approve": {},
"document.review.reject": {}, "document.review.reject": {},
"document.final.approve": {}, "document.final.approve": {},
"document.final.reject": {}, "document.final.reject": {},
"document.proposal.decide": {}, "document.proposal.decide": {},
"document.comment.add": {},
"document.comment.resolve": {},
"document.reopen.request": {},
"document.reopen.approve": {},
"document.pending_supplements.query": {},
} }
var runControlActionKeys = map[string]struct{}{ var runControlActionKeys = map[string]struct{}{
@@ -212,10 +217,17 @@ var runControlActionKeys = map[string]struct{}{
// DocGovLiveActionKeys are the U11 keys wired to the real docgov engine. // DocGovLiveActionKeys are the U11 keys wired to the real docgov engine.
// 基线键(claim / review.submit)随 U11 总 flag 放量;P8 逐键键(review approve/reject、 // 基线键(claim / review.submit)随 U11 总 flag 放量;P8 逐键键(review approve/reject、
// final.*、proposal.decide)须额外打开对应 LIVE_* flag 才进入 live 集合。 // final.*、proposal.decide)须额外打开对应 LIVE_* flag 才进入 live 集合。
// 2026-09-25 三权流转对话触发键(comment/reopen/pending)随基线集合接入,
// 同样受 U11 总 flag 控制。
func DocGovLiveActionKeys() map[string]struct{} { func DocGovLiveActionKeys() map[string]struct{} {
keys := map[string]struct{}{ keys := map[string]struct{}{
"document.claim": {}, "document.claim": {},
"document.review.submit": {}, "document.review.submit": {},
"document.comment.add": {},
"document.comment.resolve": {},
"document.reopen.request": {},
"document.reopen.approve": {},
"document.pending_supplements.query": {},
} }
f := CurrentFeatureFlags() f := CurrentFeatureFlags()
if f.U11LiveReview { if f.U11LiveReview {
+5 -1
View File
@@ -47,9 +47,13 @@ func (t traceUnlock) ReopenApprovedDocs(ctx context.Context, projectID int64, do
if t.gov == nil { if t.gov == nil {
return nil return nil
} }
actorName := "reopen"
if actorID > 0 {
actorName = fmt.Sprintf("user:%d", actorID)
}
for _, key := range docTaskKeys { for _, key := range docTaskKeys {
docID := fmt.Sprintf("doc_proj%d_%s", projectID, key) docID := fmt.Sprintf("doc_proj%d_%s", projectID, key)
_, err := t.gov.ReopenForRevision(ctx, docID, actorID, "creator", reason) _, err := t.gov.ReopenForRevision(ctx, docID, actorID, actorName, reason)
if err == nil || errors.Is(err, vocabulary.ErrDocumentNotFound) { if err == nil || errors.Is(err, vocabulary.ErrDocumentNotFound) {
continue continue
} }
+30 -1
View File
@@ -6,7 +6,10 @@ import (
"log/slog" "log/slog"
"time" "time"
"chat-agent-system/internal/app/docnotify"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/intent" "chat-agent-system/internal/intent"
"chat-agent-system/internal/notification"
"chat-agent-system/internal/supplementimpact" "chat-agent-system/internal/supplementimpact"
) )
@@ -23,11 +26,37 @@ func (r *Runtime) wireSupplementImpact() {
Analyze: r.Trace, Analyze: r.Trace,
Notify: r.Notify, Notify: r.Notify,
Propose: r.Gov, Propose: r.Gov,
Pending: r.Gov,
} }
r.SupplementImpact = svc r.SupplementImpact = svc
if r.Biz != nil && r.Notify != nil { if r.Biz != nil && r.Notify != nil {
r.Biz.SetDigestNotifier(r.Notify) r.Biz.SetDigestNotifier(r.Notify)
} }
if r.Gov != nil && r.Notify != nil {
r.Gov.SetPendingDigestNotify(func(ctx context.Context, doc *docgov.DocumentRecord, pending []docgov.PendingSupplement) {
if doc == nil || doc.AuthorID <= 0 || len(pending) == 0 {
return
}
summary := pending[0].SourceSummary
if summary == "" {
summary = fmt.Sprintf("%d 条积压补充", len(pending))
}
_ = r.Notify.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{
UserID: doc.AuthorID, ProjectID: doc.ProjectID,
DigestKind: notification.DigestKindPendingSupplements,
DocID: doc.DocID, DocTitle: doc.Title,
Severity: notification.SeverityActionRequired,
Summary: summary,
})
})
r.Gov.SetLifecycleNotify(func(ctx context.Context, action string, doc *docgov.DocumentRecord, actorID int64, actorName, detail string) {
docnotify.EmitLifecycle(ctx, docnotify.EmitLifecycleParams{
Notify: r.Notify, Digest: r.Notify, Chat: r.Ctx,
Action: docnotify.LifecycleAction(action), Doc: doc,
ActorID: actorID, ActorName: actorName, Detail: detail,
})
})
}
if r.Engine != nil { if r.Engine != nil {
r.Engine.SetOnSupplement(func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string) { r.Engine.SetOnSupplement(func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string) {
b, err := svc.CreateFromSupplement(ctx, projectID, userID, sessionID, summary, markdown) b, err := svc.CreateFromSupplement(ctx, projectID, userID, sessionID, summary, markdown)
@@ -56,7 +85,7 @@ func batchToCard(b *supplementimpact.Batch) *intent.InteractiveCardPayload {
} }
body := summary + "\n" + fmt.Sprintf("batch_id=%s\nnonce=%s\nrevision=%d\ncoverage=%s", b.BatchID, b.DecisionNonce, b.Revision, b.CoverageStatus) body := summary + "\n" + fmt.Sprintf("batch_id=%s\nnonce=%s\nrevision=%d\ncoverage=%s", b.BatchID, b.DecisionNonce, b.Revision, b.CoverageStatus)
for _, it := range b.Items { for _, it := range b.Items {
body += fmt.Sprintf("\n- %s | claimed=%v busy=%v | %s", it.DocID, it.AuthorClaimedSnapshot, it.BusySnapshot, it.Title) body += fmt.Sprintf("\n- %s | claimed=%v busy=%v locked=%v | %s", it.DocID, it.AuthorClaimedSnapshot, it.BusySnapshot, it.LockedSnapshot, it.Title)
} }
return &intent.InteractiveCardPayload{ return &intent.InteractiveCardPayload{
CardID: b.BatchID, CardID: b.BatchID,
+121
View File
@@ -42,6 +42,7 @@ type ActionServices struct {
TaskRuns TaskStarter TaskRuns TaskStarter
IntentPlans IntentPlans IntentPlans IntentPlans
Workflow WorkflowRunner Workflow WorkflowRunner
Reopens ReopenService
Environment string Environment string
} }
@@ -93,6 +94,32 @@ func (s *Session) EnsureDocResolver() {
}) })
} }
func mapCommentType(raw string) vocabulary.CommentType {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "issue", "problem", "comment":
return vocabulary.CommentGeneral
case "approval", "approve":
return vocabulary.CommentSuggestion
case "reject", "rejection":
return vocabulary.CommentRejection
default:
return vocabulary.CommentSuggestion
}
}
func mapUserRole(raw string) vocabulary.UserRole {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "reviewer":
return vocabulary.RoleReviewer
case "approver":
return vocabulary.RoleApprover
case "system":
return vocabulary.RoleSystem
default:
return vocabulary.RoleAuthor
}
}
func (s *Session) executeAction(ctx context.Context, action *coordinator.PendingAction, userCtx *coordinator.UserProjectContext) (*ActionExecResult, error) { func (s *Session) executeAction(ctx context.Context, action *coordinator.PendingAction, userCtx *coordinator.UserProjectContext) (*ActionExecResult, error) {
if action == nil || s.Actions == nil { if action == nil || s.Actions == nil {
return nil, fmt.Errorf("action services not configured") return nil, fmt.Errorf("action services not configured")
@@ -215,6 +242,83 @@ func (s *Session) executeAction(ctx context.Context, action *coordinator.Pending
} }
return &ActionExecResult{RunID: created.RunID, Message: "工作流已启动"}, nil return &ActionExecResult{RunID: created.RunID, Message: "工作流已启动"}, nil
case "docgov.comment_add":
if s.Actions.DocGov == nil {
return nil, fmt.Errorf("docgov engine not available")
}
role := mapUserRole(action.Role)
content := action.Comment
if content == "" {
return nil, fmt.Errorf("批注内容不能为空")
}
cType := mapCommentType(action.Reason)
_, err := s.Actions.DocGov.AddComment(ctx, action.DocID, uid, name, role, action.Section, cType, content)
if err != nil {
return nil, err
}
doc, _ := s.Actions.DocGov.GetDocument(ctx, action.DocID)
state := ""
if doc != nil {
state = string(doc.CurrentState)
}
return &ActionExecResult{DocState: state, Message: "批注已添加"}, nil
case "docgov.comment_resolve":
if s.Actions.DocGov == nil {
return nil, fmt.Errorf("docgov engine not available")
}
role := mapUserRole(action.Role)
comms, err := s.Actions.DocGov.ListComments(ctx, action.DocID)
if err != nil {
return nil, err
}
var target *docgov.DocumentComment
for _, c := range comms {
if c == nil || c.Status != "open" {
continue
}
if action.Section != "" && c.TargetSection != action.Section {
continue
}
target = c
break
}
if target == nil {
return nil, fmt.Errorf("未找到可解决的未关闭批注")
}
_, err = s.Actions.DocGov.ResolveComment(ctx, target.ID, uid, role, true)
if err != nil {
return nil, err
}
return &ActionExecResult{Message: fmt.Sprintf("已解决批注 #%d", target.ID)}, nil
case "docgov.reopen_request":
if s.Actions.Reopens == nil {
return nil, fmt.Errorf("reopen service not available")
}
taskKey := action.TaskKey
if taskKey == "" {
taskKey = action.DocID
}
view, err := s.Actions.Reopens.RequestReopenByDoc(ctx, s.ProjectID, taskKey, action.Reason, uid)
if err != nil {
return nil, err
}
return &ActionExecResult{Message: fmt.Sprintf("重开申请已提交(#%d,状态:%s)", view.ID, view.Status)}, nil
case "docgov.reopen_approve":
if s.Actions.Reopens == nil {
return nil, fmt.Errorf("reopen service not available")
}
if action.RequestID <= 0 {
return nil, fmt.Errorf("缺少重开申请编号")
}
view, err := s.Actions.Reopens.ApproveReopenByID(ctx, s.ProjectID, action.RequestID, uid)
if err != nil {
return nil, err
}
return &ActionExecResult{Message: fmt.Sprintf("已批准重开申请 #%d", view.ID)}, nil
case "taskrun.resume", "taskrun.restart": case "taskrun.resume", "taskrun.restart":
if s.Actions.TaskRuns == nil { if s.Actions.TaskRuns == nil {
return nil, fmt.Errorf("task run services not available") return nil, fmt.Errorf("task run services not available")
@@ -278,6 +382,23 @@ func renderActionReply(action *coordinator.PendingAction, result *ActionExecResu
return fmt.Sprintf("已确认执行驳回操作!《%s》已退回(当前状态:%s),驳回意见已同步下发。", docName, result.DocState) return fmt.Sprintf("已确认执行驳回操作!《%s》已退回(当前状态:%s),驳回意见已同步下发。", docName, result.DocState)
case "workflow.run": case "workflow.run":
return fmt.Sprintf("工作流已启动(run_id=%s)。您可在工作流面板查看进度。", result.RunID) return fmt.Sprintf("工作流已启动(run_id=%s)。您可在工作流面板查看进度。", result.RunID)
case "docgov.comment_add":
return fmt.Sprintf("已添加批注到《%s》。", docName)
case "docgov.comment_resolve":
if result.Message != "" {
return result.Message + "。"
}
return fmt.Sprintf("已解决《%s》的批注。", docName)
case "docgov.reopen_request":
if result.Message != "" {
return result.Message + "。"
}
return fmt.Sprintf("已提交《%s》的重开申请。", docName)
case "docgov.reopen_approve":
if result.Message != "" {
return result.Message + "。"
}
return "已批准重开申请。"
case "taskrun.resume": case "taskrun.resume":
return fmt.Sprintf("续跑任务已启动(run_id=%s)。您可在任务面板查看进度。", result.RunID) return fmt.Sprintf("续跑任务已启动(run_id=%s)。您可在任务面板查看进度。", result.RunID)
case "taskrun.restart": case "taskrun.restart":
+107 -1
View File
@@ -2,6 +2,8 @@ package chat
import ( import (
"context" "context"
"fmt"
"strings"
"testing" "testing"
"chat-agent-system/internal/bizdev" "chat-agent-system/internal/bizdev"
@@ -28,7 +30,7 @@ func TestExecuteAction_DocGovReviewApprove(t *testing.T) {
_, _ = docGov.SubmitForReview(ctx, docID, 1, docgov.RoleAuthor, "张工") _, _ = docGov.SubmitForReview(ctx, docID, 1, docgov.RoleAuthor, "张工")
sess := &Session{ sess := &Session{
UserID: 2, UserID: 2,
Actions: &ActionServices{DocGov: docGov}, Actions: &ActionServices{DocGov: docGov},
} }
action := &coordinator.PendingAction{ action := &coordinator.PendingAction{
@@ -52,3 +54,107 @@ func TestExecuteAction_DocGovReviewApprove(t *testing.T) {
t.Fatal("expected non-empty reply") t.Fatal("expected non-empty reply")
} }
} }
func TestExecuteAction_SubmitReviewAndComment(t *testing.T) {
cfg := &config.Config{DataDir: t.TempDir()}
svc := bizdev.MustNewService(t, cfg)
docGov := bizdev.Snapshot(svc).Gov
ctx := context.Background()
docID := "doc_proj1_intake"
_, err := docGov.CreateOrGetDocument(
ctx, docID, 1, "intake", "需求收集表", "V0.1", "",
1, "张工", 2, "李主管", 3, "王总监",
)
if err != nil {
t.Fatalf("create: %v", err)
}
_ = docGov.MarkDocumentContentUpdated(ctx, docID, "hash1", false)
sess := &Session{UserID: 1, ProjectID: 1, Actions: &ActionServices{DocGov: docGov}}
userCtx := &coordinator.UserProjectContext{User: coordinator.UserInfo{ID: "1", Name: "张工"}}
res, err := sess.executeAction(ctx, &coordinator.PendingAction{Kind: "docgov.submit_review", DocID: docID}, userCtx)
if err != nil {
t.Fatalf("submit: %v", err)
}
if res.DocState != string(docgov.StateReviewing) {
t.Fatalf("state=%s", res.DocState)
}
_, err = sess.executeAction(ctx, &coordinator.PendingAction{
Kind: "docgov.comment_add", DocID: docID, Role: "reviewer", Section: "s1",
Comment: "请补充风险", Reason: "suggestion",
}, &coordinator.UserProjectContext{User: coordinator.UserInfo{ID: "2", Name: "李主管"}})
if err != nil {
// author can also comment in reviewing; use reviewer id
t.Fatalf("comment add: %v", err)
}
sess.UserID = 2
_, err = sess.executeAction(ctx, &coordinator.PendingAction{
Kind: "docgov.comment_resolve", DocID: docID, Role: "reviewer", Section: "s1",
}, &coordinator.UserProjectContext{User: coordinator.UserInfo{ID: "2", Name: "李主管"}})
if err != nil {
t.Fatalf("comment resolve: %v", err)
}
}
type stubReopen struct {
reqErr error
aprErr error
}
func (s *stubReopen) ListPendingReopens(context.Context, int64) ([]ReopenRequestView, error) {
return nil, nil
}
func (s *stubReopen) RequestReopenByDoc(context.Context, int64, string, string, int64) (ReopenRequestView, error) {
if s.reqErr != nil {
return ReopenRequestView{}, s.reqErr
}
return ReopenRequestView{ID: 9, Status: "pending"}, nil
}
func (s *stubReopen) ApproveReopenByID(context.Context, int64, int64, int64) (ReopenRequestView, error) {
if s.aprErr != nil {
return ReopenRequestView{}, s.aprErr
}
return ReopenRequestView{ID: 9, Status: "approved"}, nil
}
func TestExecuteAction_ReopenPaths(t *testing.T) {
sess := &Session{
UserID: 1, ProjectID: 1,
Actions: &ActionServices{Reopens: &stubReopen{}},
}
userCtx := &coordinator.UserProjectContext{User: coordinator.UserInfo{ID: "1", Name: "A"}}
res, err := sess.executeAction(context.Background(), &coordinator.PendingAction{
Kind: "docgov.reopen_request", TaskKey: "intake", Reason: "需补充",
}, userCtx)
if err != nil {
t.Fatal(err)
}
if res.Message == "" {
t.Fatal("expected message")
}
res, err = sess.executeAction(context.Background(), &coordinator.PendingAction{
Kind: "docgov.reopen_approve", RequestID: 9,
}, userCtx)
if err != nil {
t.Fatal(err)
}
if res.Message == "" {
t.Fatal("expected approve message")
}
sess.Actions.Reopens = &stubReopen{reqErr: fmt.Errorf("无权申请重开")}
_, err = sess.executeAction(context.Background(), &coordinator.PendingAction{
Kind: "docgov.reopen_request", TaskKey: "intake", Reason: "x",
}, userCtx)
if err == nil {
t.Fatal("expected permission failure")
}
reply := renderActionReply(&coordinator.PendingAction{Kind: "docgov.reopen_request", DocID: "d"}, nil, err)
if reply == "" || !strings.Contains(reply, "无权") {
t.Fatalf("reply=%s", reply)
}
}
+15
View File
@@ -80,3 +80,18 @@ type TaskStartRequest struct {
ProjectID int64 ProjectID int64
SessionID string SessionID string
} }
// ReopenRequestView is the chat-facing projection of a stage reopen request.
type ReopenRequestView struct {
ID int64
DocTasks []string
Reason string
Status string
}
// ReopenService is the chat port for Approver-first document reopen.
type ReopenService interface {
ListPendingReopens(ctx context.Context, projectID int64) ([]ReopenRequestView, error)
RequestReopenByDoc(ctx context.Context, projectID int64, taskKey, reason string, userID int64) (ReopenRequestView, error)
ApproveReopenByID(ctx context.Context, projectID, requestID, userID int64) (ReopenRequestView, error)
}
+1 -1
View File
@@ -20,7 +20,7 @@ func TestConfirmGate_WeakVerbNeedsConfirmation(t *testing.T) {
Confidence: 0.78, Confidence: 0.78,
Branch: intent.BranchNewIntentPreempt, Branch: intent.BranchNewIntentPreempt,
FilledSlots: map[string]string{ FilledSlots: map[string]string{
intent.SlotTargetTask: "intake", intent.SlotTargetTask: "intake",
intent.SlotNeedsConfirmation: "true", intent.SlotNeedsConfirmation: "true",
}, },
} }
@@ -394,7 +394,6 @@ func TestDialogueLLM_AmbiguousSmalltalkDoesNotUseStageSalesTemplate(t *testing.T
} }
} }
type stubRealtimeSearcher struct { type stubRealtimeSearcher struct {
markdown string markdown string
err error err error
@@ -0,0 +1,168 @@
package coordinator_test
import (
"context"
"strings"
"testing"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/intent"
)
type stubDocResolverDialogue struct {
doc *coordinator.ResolvedDoc
ok bool
candidates []string
err error
}
func (s *stubDocResolverDialogue) ResolveDoc(context.Context, int64, string, string) (*coordinator.ResolvedDoc, bool, []string, error) {
return s.doc, s.ok, s.candidates, s.err
}
func TestListDocResolver_ZeroAndMultiMatchClarify(t *testing.T) {
r := &coordinator.ListDocResolver{
List: func(context.Context, int64) ([]coordinator.DocListItem, error) {
return []coordinator.DocListItem{
{DocID: "d1", Title: "需求收集表", State: vocabulary.StateDrafting, TaskKey: "intake"},
{DocID: "d2", Title: "需求评估表", State: vocabulary.StateDrafting, TaskKey: "eval"},
}, nil
},
}
_, ok, cands, err := r.ResolveDoc(context.Background(), 1, "需求", "")
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("expected multi-match not ok")
}
if len(cands) < 2 {
t.Fatalf("expected candidates, got %v", cands)
}
_, ok, cands, err = r.ResolveDoc(context.Background(), 1, "不存在的文档XYZ", "")
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("expected zero unique match")
}
if len(cands) == 0 {
t.Fatal("expected inventory candidates on zero match")
}
doc, ok, _, err := r.ResolveDoc(context.Background(), 1, "需求收集表", "")
if err != nil || !ok || doc == nil || doc.DocID != "d1" {
t.Fatalf("unique title match failed: ok=%v doc=%+v err=%v", ok, doc, err)
}
doc, ok, _, err = r.ResolveDoc(context.Background(), 1, "@active", "d2")
if err != nil || !ok || doc.DocID != "d2" {
t.Fatalf("active resolve failed: %+v", doc)
}
}
func TestHandleDocGovSubmitReview_ConfirmationCard(t *testing.T) {
engine := coordinator.NewCoordinatorEngine(nil)
engine.SetDocResolver(&stubDocResolver{
doc: &coordinator.ResolvedDoc{DocID: "doc-001", Title: "需求收集表", State: vocabulary.StateDrafting, TaskKey: "intake"},
ok: true,
})
userCtx := &coordinator.UserProjectContext{
User: coordinator.UserInfo{ID: "1", Name: "张工", Role: "author", ProjectRole: "author"},
Project: coordinator.ProjectInfo{ID: "10", Name: "测试项目"},
}
res, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{
TopIntent: &intent.CandidateIntent{
Intent: intent.IntentDocGovSubmitReview,
FilledSlots: map[string]string{intent.SlotTargetDoc: "需求收集表"},
},
}, userCtx)
if err != nil {
t.Fatal(err)
}
if res.Status != coordinator.StatusAccepted {
t.Fatalf("status=%s", res.Status)
}
if res.InteractiveCard == nil || res.InteractiveCard.CardType != "CONFIRMATION" {
t.Fatalf("expected CONFIRMATION card, got %+v", res.InteractiveCard)
}
if res.PendingAction != nil {
t.Fatal("must not execute before confirm")
}
// confirm
res2, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{
TopIntent: &intent.CandidateIntent{
Intent: intent.IntentDocGovProposalDecide,
FilledSlots: map[string]string{
"target_action": "confirm_submit_review",
"target_doc": "doc-001",
},
},
}, userCtx)
if err != nil {
t.Fatal(err)
}
if res2.PendingAction == nil || res2.PendingAction.Kind != "docgov.submit_review" {
t.Fatalf("expected submit_review pending, got %+v", res2.PendingAction)
}
if res2.PendingAction.DocID != "doc-001" {
t.Fatalf("doc id=%s", res2.PendingAction.DocID)
}
}
func TestHandleDocGovCommentAdd_ClarifyOnMultiDoc(t *testing.T) {
engine := coordinator.NewCoordinatorEngine(nil)
engine.SetDocResolver(&stubDocResolver{
ok: false,
candidates: []string{"需求收集表(d1)", "需求评估表(d2)"},
})
userCtx := &coordinator.UserProjectContext{
User: coordinator.UserInfo{ID: "1", Role: "author", ProjectRole: "author"},
Project: coordinator.ProjectInfo{ID: "10"},
}
res, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{
TopIntent: &intent.CandidateIntent{
Intent: intent.IntentDocGovCommentAdd,
FilledSlots: map[string]string{
intent.SlotTargetDoc: "需求",
intent.SlotCommentContent: "缺风险分析",
},
},
}, userCtx)
if err != nil {
t.Fatal(err)
}
if res.Status != coordinator.StatusClarificationRequired {
t.Fatalf("expected clarification, got %s", res.Status)
}
if !strings.Contains(res.NaturalReply, "需求收集表") {
t.Fatalf("reply should list candidates: %s", res.NaturalReply)
}
}
func TestHandleQueryPendingSupplements(t *testing.T) {
engine := coordinator.NewCoordinatorEngine(nil)
engine.SetPendingSupplementReader(&stubPendingReader{rows: []coordinator.PendingSupplementView{
{DocID: "d1", DocTitle: "需求表", SourceSummary: "预算改为200万", ImpactReason: "锁定中"},
}})
res, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{
TopIntent: &intent.CandidateIntent{Intent: intent.IntentQueryPendingSupplements},
}, &coordinator.UserProjectContext{Project: coordinator.ProjectInfo{ID: "1"}})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(res.NaturalReply, "预算改为200万") {
t.Fatalf("reply=%s", res.NaturalReply)
}
}
type stubPendingReader struct {
rows []coordinator.PendingSupplementView
}
func (s *stubPendingReader) ListPendingSupplements(context.Context, int64, string) ([]coordinator.PendingSupplementView, error) {
return s.rows, nil
}
+52 -19
View File
@@ -27,15 +27,18 @@ const (
// PendingAction 声明对话层需执行的真实动作(由 Session 持有服务后执行)。 // PendingAction 声明对话层需执行的真实动作(由 Session 持有服务后执行)。
type PendingAction struct { type PendingAction struct {
Kind string // docgov.claim | docgov.submit_review | docgov.review_approve | docgov.approver_approve | docgov.reject | workflow.run | taskrun.resume | taskrun.restart Kind string // docgov.claim | docgov.submit_review | docgov.review_approve | docgov.approver_approve | docgov.reject | docgov.comment_add | docgov.comment_resolve | docgov.reopen_request | docgov.reopen_approve | workflow.run | taskrun.resume | taskrun.restart
DocID string DocID string
WorkflowKey string WorkflowKey string
RunID string RunID string
WorkDir string WorkDir string
Reason string Reason string
Comment string Comment string
Rollback vocabulary.DocumentState Rollback vocabulary.DocumentState
TaskKey string TaskKey string
Role string
Section string
RequestID int64
} }
// CoordinationResult 协调层给对话层的回执 // CoordinationResult 协调层给对话层的回执
@@ -77,11 +80,13 @@ type CoordinatorEngine struct {
kbClient *kb.Client kbClient *kb.Client
impact ImpactActions impact ImpactActions
projectAccess ProjectAccess projectAccess ProjectAccess
docResolver DocResolver
pendingSupps PendingSupplementReader
reopenLister ReopenLister
// OnSupplement is optional; composition typically wires supplementimpact.TriggerAsync. // OnSupplement is optional; composition typically wires supplementimpact.TriggerAsync.
// Nil-safe: unset hooks are skipped. // Nil-safe: unset hooks are skipped.
OnSupplement func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string) OnSupplement func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string)
pendingSupplementCard *intent.InteractiveCardPayload pendingSupplementCard *intent.InteractiveCardPayload
docResolver DocResolver
} }
// StageTaskRunner is the application owner for ExecuteTask / ExecuteStage. // StageTaskRunner is the application owner for ExecuteTask / ExecuteStage.
@@ -117,7 +122,6 @@ func (e *CoordinatorEngine) SetInstanceService(svc *projectinstance.Service) {
} }
} }
// SetRealtimeSearcher 注入对话路径的实时检索(天气/新闻等);nil 时跳过强制检索。 // SetRealtimeSearcher 注入对话路径的实时检索(天气/新闻等);nil 时跳过强制检索。
func (e *CoordinatorEngine) SetRealtimeSearcher(s RealtimeSearcher) { func (e *CoordinatorEngine) SetRealtimeSearcher(s RealtimeSearcher) {
if e != nil { if e != nil {
@@ -132,6 +136,27 @@ func (e *CoordinatorEngine) SetImpactActions(a ImpactActions) {
} }
} }
// SetDocResolver wires the controlled-document resolution gate.
func (e *CoordinatorEngine) SetDocResolver(r DocResolver) {
if e != nil {
e.docResolver = r
}
}
// SetPendingSupplementReader wires locked-period pending supplement queries.
func (e *CoordinatorEngine) SetPendingSupplementReader(r PendingSupplementReader) {
if e != nil {
e.pendingSupps = r
}
}
// SetReopenLister wires pending reopen listing for approve dialogue.
func (e *CoordinatorEngine) SetReopenLister(r ReopenLister) {
if e != nil {
e.reopenLister = r
}
}
// SetOnSupplement wires the optional post-blackboard supplement impact hook. // SetOnSupplement wires the optional post-blackboard supplement impact hook.
func (e *CoordinatorEngine) SetOnSupplement(fn func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string)) { func (e *CoordinatorEngine) SetOnSupplement(fn func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string)) {
if e != nil { if e != nil {
@@ -139,13 +164,6 @@ func (e *CoordinatorEngine) SetOnSupplement(fn func(ctx context.Context, project
} }
} }
// SetDocResolver wires the controlled-document resolution mistouch gate.
func (e *CoordinatorEngine) SetDocResolver(r DocResolver) {
if e != nil {
e.docResolver = r
}
}
// HasDocResolver reports whether a document resolution gate is already wired. // HasDocResolver reports whether a document resolution gate is already wired.
func (e *CoordinatorEngine) HasDocResolver() bool { func (e *CoordinatorEngine) HasDocResolver() bool {
return e != nil && e.docResolver != nil return e != nil && e.docResolver != nil
@@ -321,6 +339,21 @@ func (e *CoordinatorEngine) ProcessIntent(
case intent.IntentDocGovSubmitReview: case intent.IntentDocGovSubmitReview:
coordRes, coordErr = e.handleDocGovSubmitReview(ctx, top, userCtx) coordRes, coordErr = e.handleDocGovSubmitReview(ctx, top, userCtx)
case intent.IntentDocGovCommentAdd:
coordRes, coordErr = e.handleDocGovCommentAdd(ctx, top, userCtx)
case intent.IntentDocGovCommentResolve:
coordRes, coordErr = e.handleDocGovCommentResolve(ctx, top, userCtx)
case intent.IntentDocGovReopenRequest:
coordRes, coordErr = e.handleDocGovReopenRequest(ctx, top, userCtx)
case intent.IntentDocGovReopenApprove:
coordRes, coordErr = e.handleDocGovReopenApprove(ctx, top, userCtx)
case intent.IntentQueryPendingSupplements:
coordRes, coordErr = e.handleQueryPendingSupplements(ctx, top, userCtx)
case intent.IntentDocGovProposalDecide: case intent.IntentDocGovProposalDecide:
coordRes, coordErr = e.handleDocGovProposalDecide(ctx, top, userCtx) coordRes, coordErr = e.handleDocGovProposalDecide(ctx, top, userCtx)
+82 -2
View File
@@ -4,6 +4,7 @@ import (
"chat-agent-system/internal/contracts/vocabulary" "chat-agent-system/internal/contracts/vocabulary"
"context" "context"
"fmt" "fmt"
"strconv"
"strings" "strings"
"time" "time"
@@ -275,7 +276,7 @@ func (e *CoordinatorEngine) handleDocGovClaim(ctx context.Context, cand *intent.
}, nil }, nil
} }
// handleDocGovSubmitReview 解析门 → drafting 校验 → 确认卡 → 拍板后 PendingAction // handleDocGovSubmitReview 解析门 → drafting 校验 → CONFIRMATION 卡(含锁定后果)→ 拍板后 PendingAction
func (e *CoordinatorEngine) handleDocGovSubmitReview(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { func (e *CoordinatorEngine) handleDocGovSubmitReview(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
doc, gate := e.resolveTargetDocGate(ctx, cand, userCtx) doc, gate := e.resolveTargetDocGate(ctx, cand, userCtx)
if gate != nil { if gate != nil {
@@ -300,7 +301,7 @@ func (e *CoordinatorEngine) handleDocGovSubmitReview(ctx context.Context, cand *
"受控文档送审确认", "受控文档送审确认",
doc.DocID, title, "", doc.DocID, title, "",
"编制完成,提交审核并锁定正文", "编制完成,提交审核并锁定正文",
"送审后文档进入审核锁定:非审核职责人不可改正文。", "送审后文档进入审核锁定:非审核职责人不可改正文;锁定期间补充将进入积压台账,待退回编制后再消化。",
"confirm_submit_review", "确认送审", "confirm_submit_review", "确认送审",
map[string]string{"task_key": doc.TaskKey}, map[string]string{"task_key": doc.TaskKey},
) )
@@ -455,6 +456,85 @@ func (e *CoordinatorEngine) handleDocGovProposalDecide(ctx context.Context, cand
}, nil }, nil
} }
if targetAction == "confirm_comment_add" {
payload := mergeDecidePayload(cand, userCtx)
if userCtx.InteractiveCard != nil {
userCtx.dismissCard("resolved")
}
userCtx.applyPending(nil)
return &CoordinationResult{
Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch,
NaturalReply: fmt.Sprintf("正在为《%s》添加批注…", docName),
UpdatedContext: userCtx, ClearExpectation: true,
PendingAction: &PendingAction{
Kind: "docgov.comment_add",
DocID: docName,
Role: payload["role"],
Section: payload["target_section"],
Comment: payload["comment_content"],
Reason: payload["comment_type"],
},
}, nil
}
if targetAction == "confirm_comment_resolve" {
payload := mergeDecidePayload(cand, userCtx)
if userCtx.InteractiveCard != nil {
userCtx.dismissCard("resolved")
}
userCtx.applyPending(nil)
return &CoordinationResult{
Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch,
NaturalReply: fmt.Sprintf("正在解决《%s》的批注…", docName),
UpdatedContext: userCtx, ClearExpectation: true,
PendingAction: &PendingAction{
Kind: "docgov.comment_resolve",
DocID: docName,
Role: payload["role"],
Section: payload["target_section"],
},
}, nil
}
if targetAction == "confirm_reopen_request" {
payload := mergeDecidePayload(cand, userCtx)
if userCtx.InteractiveCard != nil {
userCtx.dismissCard("resolved")
}
userCtx.applyPending(nil)
return &CoordinationResult{
Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch,
NaturalReply: fmt.Sprintf("正在提交《%s》的重开申请…", docName),
UpdatedContext: userCtx, ClearExpectation: true,
PendingAction: &PendingAction{
Kind: "docgov.reopen_request",
DocID: docName,
TaskKey: payload["task_key"],
Reason: payload["reopen_reason"],
Role: payload["role"],
},
}, nil
}
if targetAction == "confirm_reopen_approve" {
payload := mergeDecidePayload(cand, userCtx)
if userCtx.InteractiveCard != nil {
userCtx.dismissCard("resolved")
}
userCtx.applyPending(nil)
reqID, _ := strconv.ParseInt(payload["request_id"], 10, 64)
return &CoordinationResult{
Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch,
NaturalReply: "正在批准重开申请…",
UpdatedContext: userCtx, ClearExpectation: true,
PendingAction: &PendingAction{
Kind: "docgov.reopen_approve",
DocID: docName,
RequestID: reqID,
},
}, nil
}
if targetAction == "cancel" { if targetAction == "cancel" {
if userCtx.InteractiveCard != nil { if userCtx.InteractiveCard != nil {
userCtx.dismissCard("cancelled") userCtx.dismissCard("cancelled")
@@ -0,0 +1,107 @@
package coordinator
import (
"context"
"fmt"
"strings"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/intent"
)
// handleDocGovCommentAdd 解析门 → 批注矩阵剪枝 → CONFIRMATION 卡(含内容预览)
func (e *CoordinatorEngine) handleDocGovCommentAdd(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
doc, gate := e.resolveTargetDocGate(ctx, cand, userCtx)
if gate != nil {
return gate, nil
}
content := strings.TrimSpace(cand.FilledSlots[intent.SlotCommentContent])
section := strings.TrimSpace(cand.FilledSlots[intent.SlotTargetSection])
cType := strings.TrimSpace(cand.FilledSlots[intent.SlotCommentType])
if cType == "" {
cType = "suggestion"
}
if content == "" {
return &CoordinationResult{
Status: StatusClarificationRequired,
Intent: cand.Intent,
Branch: cand.Branch,
ClarificationPrompt: "请补充批注内容后再确认添加。",
NaturalReply: "已定位文档,但缺少批注正文。请说明要添加的意见内容。",
UpdatedContext: userCtx,
}, nil
}
role := effectiveDocRole(userCtx)
if userCtx != nil && userCtx.ToSnapshot() != nil && userCtx.ToSnapshot().UserDocRole != "" {
role = userCtx.ToSnapshot().UserDocRole
}
state := doc.State
if state == "" && userCtx != nil && userCtx.ToSnapshot() != nil {
state = vocabulary.DocumentState(userCtx.ToSnapshot().DocLifecycleState)
}
if state != "" && role != "" && !commentRoleAllowed(role, state) {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: "COMMENT_MATRIX_DENIED",
NaturalReply: fmt.Sprintf("按批注矩阵,角色「%s」在「%s」阶段不可添加批注。", role, state),
UpdatedContext: userCtx,
}, nil
}
title := displayDocTitle(doc)
preview := content
if section != "" {
preview = fmt.Sprintf("[%s] %s", section, content)
}
card, exp := buildConfirmCard(
"添加受控文档批注确认",
doc.DocID, title, section, preview,
"确认后将写入文档批注;服务端会按角色×阶段矩阵二次校验。",
"confirm_comment_add", "确认添加批注",
map[string]string{
"role": role,
"comment_content": content,
"comment_type": cType,
"target_section": section,
},
)
userCtx.applyCard(card)
userCtx.applyPending(exp)
reply := fmt.Sprintf("已为《%s》起草批注确认单。预览:%s。请确认或回复「确认」。", title, preview)
return &CoordinationResult{
Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch,
NaturalReply: reply, InteractiveCard: card, NextExpectation: exp, UpdatedContext: userCtx,
}, nil
}
// handleDocGovCommentResolve 解析门 → CONFIRMATION 卡 → PendingAction
func (e *CoordinatorEngine) handleDocGovCommentResolve(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
doc, gate := e.resolveTargetDocGate(ctx, cand, userCtx)
if gate != nil {
return gate, nil
}
section := strings.TrimSpace(cand.FilledSlots[intent.SlotTargetSection])
role := effectiveDocRole(userCtx)
title := displayDocTitle(doc)
proposed := "将匹配的未解决批注标记为已处理"
if section != "" {
proposed = fmt.Sprintf("将章节「%s」下未解决批注标记为已处理", section)
}
card, exp := buildConfirmCard(
"解决受控文档批注确认",
doc.DocID, title, section, proposed,
"确认后由服务端按角色权限二次校验并更新批注状态。",
"confirm_comment_resolve", "确认解决批注",
map[string]string{"role": role, "target_section": section},
)
userCtx.applyCard(card)
userCtx.applyPending(exp)
return &CoordinationResult{
Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch,
NaturalReply: fmt.Sprintf("已为《%s》起草解决批注确认单。请确认后执行。", title),
InteractiveCard: card, NextExpectation: exp, UpdatedContext: userCtx,
}, nil
}
@@ -0,0 +1,201 @@
package coordinator
import (
"context"
"fmt"
"strconv"
"strings"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/intent"
)
// handleDocGovReopenRequest 须 approved;卡片含原因 → PendingAction reopen_request
func (e *CoordinatorEngine) handleDocGovReopenRequest(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
doc, gate := e.resolveTargetDocGate(ctx, cand, userCtx)
if gate != nil {
return gate, nil
}
reason := strings.TrimSpace(cand.FilledSlots[intent.SlotReopenReason])
if reason == "" {
return &CoordinationResult{
Status: StatusClarificationRequired,
Intent: cand.Intent,
Branch: cand.Branch,
ClarificationPrompt: "请补充重开原因后再申请。",
NaturalReply: "已定位文档,但缺少重开原因。请说明为何需要再修订。",
UpdatedContext: userCtx,
}, nil
}
state := doc.State
if state == "" && userCtx != nil && userCtx.ToSnapshot() != nil {
state = vocabulary.DocumentState(userCtx.ToSnapshot().DocLifecycleState)
}
if state != "" && state != vocabulary.StateApproved {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: "REOPEN_STATE_DENIED",
NaturalReply: fmt.Sprintf("当前文档处于「%s」阶段,仅已批准(approved)文档可申请重开。", state),
UpdatedContext: userCtx,
}, nil
}
role := effectiveDocRole(userCtx)
title := displayDocTitle(doc)
card, exp := buildConfirmCard(
"申请受控文档重开确认",
doc.DocID, title, "", reason,
"确认后将提交重开申请;批准人优先审批(创建人兜底),服务端二次校验职责权限。",
"confirm_reopen_request", "确认申请重开",
map[string]string{
"role": role,
"reopen_reason": reason,
"task_key": doc.TaskKey,
},
)
userCtx.applyCard(card)
userCtx.applyPending(exp)
return &CoordinationResult{
Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch,
NaturalReply: fmt.Sprintf("已为《%s》起草重开申请确认单。原因:%s。请确认或回复「确认」。", title, reason),
InteractiveCard: card, NextExpectation: exp, UpdatedContext: userCtx,
}, nil
}
// handleDocGovReopenApprove 列 pending 重开申请 → 卡片 → PendingAction
func (e *CoordinatorEngine) handleDocGovReopenApprove(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
projectID := int64(0)
if userCtx != nil {
projectID = parseDecimalID(userCtx.Project.ID)
}
var items []ReopenListItem
if e != nil && e.reopenLister != nil {
var err error
items, err = e.reopenLister.ListPendingReopens(ctx, projectID)
if err != nil {
return &CoordinationResult{
Status: StatusRejected, Intent: cand.Intent, Branch: cand.Branch,
RejectReason: "REOPEN_LIST_FAILED", NaturalReply: fmt.Sprintf("无法列出待批重开申请:%v", err),
UpdatedContext: userCtx,
}, nil
}
}
reqID := int64(0)
if cand.FilledSlots != nil {
if raw := strings.TrimSpace(cand.FilledSlots["request_id"]); raw != "" {
reqID, _ = strconv.ParseInt(raw, 10, 64)
}
}
if reqID == 0 && len(items) == 1 {
reqID = items[0].RequestID
}
if reqID == 0 {
if len(items) == 0 {
return &CoordinationResult{
Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch,
NaturalReply: "当前没有待批准的重开申请。", UpdatedContext: userCtx,
}, nil
}
var b strings.Builder
b.WriteString("请指定要批准的重开申请编号(回复 request_id=N):\n")
for _, it := range items {
b.WriteString(fmt.Sprintf("- #%d 文档任务=%v 原因=%s\n", it.RequestID, it.DocTasks, it.Reason))
}
return &CoordinationResult{
Status: StatusClarificationRequired, Intent: cand.Intent, Branch: cand.Branch,
ClarificationPrompt: b.String(), NaturalReply: b.String(), UpdatedContext: userCtx,
}, nil
}
reason := ""
docID := ""
for _, it := range items {
if it.RequestID == reqID {
reason = it.Reason
if len(it.DocTasks) > 0 {
docID = it.DocTasks[0]
}
break
}
}
if docID == "" {
doc, gate := e.resolveTargetDocGate(ctx, cand, userCtx)
if gate != nil && doc == nil {
// still allow approve by request id alone
docID = fmt.Sprintf("reopen#%d", reqID)
} else if doc != nil {
docID = doc.DocID
} else {
docID = fmt.Sprintf("reopen#%d", reqID)
}
}
title := docID
proposed := fmt.Sprintf("批准重开申请 #%d", reqID)
if reason != "" {
proposed += ":" + reason
}
card, exp := buildConfirmCard(
"批准受控文档重开确认",
docID, title, "", proposed,
"确认后将解锁文档至编制态;服务端按批准人优先/创建人兜底二次校验。",
"confirm_reopen_approve", "确认批准重开",
map[string]string{"request_id": strconv.FormatInt(reqID, 10)},
)
userCtx.applyCard(card)
userCtx.applyPending(exp)
return &CoordinationResult{
Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch,
NaturalReply: fmt.Sprintf("已起草批准重开确认单(申请 #%d)。请确认或回复「确认」。", reqID),
InteractiveCard: card, NextExpectation: exp, UpdatedContext: userCtx,
}, nil
}
// handleQueryPendingSupplements 只读直答积压补充
func (e *CoordinatorEngine) handleQueryPendingSupplements(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
projectID := int64(0)
if userCtx != nil {
projectID = parseDecimalID(userCtx.Project.ID)
}
docID := ""
if cand != nil && cand.FilledSlots != nil {
docID = strings.TrimSpace(cand.FilledSlots[intent.SlotTargetDoc])
}
if e == nil || e.pendingSupps == nil {
return &CoordinationResult{
Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch,
NaturalReply: "积压补充查询尚未接到当前环境。", UpdatedContext: userCtx,
}, nil
}
rows, err := e.pendingSupps.ListPendingSupplements(ctx, projectID, docID)
if err != nil {
return &CoordinationResult{
Status: StatusRejected, Intent: cand.Intent, Branch: cand.Branch,
RejectReason: "PENDING_SUPPLEMENT_QUERY_FAILED",
NaturalReply: fmt.Sprintf("查询积压补充失败:%v", err), UpdatedContext: userCtx,
}, nil
}
if len(rows) == 0 {
return &CoordinationResult{
Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch,
NaturalReply: "当前没有未消化的锁定期间积压补充。", UpdatedContext: userCtx,
}, nil
}
var b strings.Builder
b.WriteString(fmt.Sprintf("共有 %d 条待消化积压补充:\n", len(rows)))
for i, r := range rows {
title := r.DocTitle
if title == "" {
title = r.DocID
}
b.WriteString(fmt.Sprintf("%d) 《%s》来源:%s", i+1, title, r.SourceSummary))
if r.ImpactReason != "" {
b.WriteString(";影响:" + r.ImpactReason)
}
b.WriteString("\n")
}
return &CoordinationResult{
Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch,
NaturalReply: b.String(), UpdatedContext: userCtx,
}, nil
}
+7 -1
View File
@@ -526,7 +526,7 @@ func TestCoordinatorEngine_DocGovActions(t *testing.T) {
t.Errorf("expected claim confirmation card, reply=%q", resClaim.NaturalReply) t.Errorf("expected claim confirmation card, reply=%q", resClaim.NaturalReply)
} }
// 2. DocGovSubmitReview // 2. DocGovSubmitReview — 两步确认,首轮挂卡不立即执行
recSubmit := &intent.IntentRecognitionResult{ recSubmit := &intent.IntentRecognitionResult{
TopIntent: &intent.CandidateIntent{ TopIntent: &intent.CandidateIntent{
Intent: intent.IntentDocGovSubmitReview, Intent: intent.IntentDocGovSubmitReview,
@@ -548,6 +548,12 @@ func TestCoordinatorEngine_DocGovActions(t *testing.T) {
if resSubmit.InteractiveCard == nil { if resSubmit.InteractiveCard == nil {
t.Errorf("expected submit confirmation card") t.Errorf("expected submit confirmation card")
} }
if resSubmit.InteractiveCard == nil || resSubmit.InteractiveCard.CardType != "CONFIRMATION" {
t.Errorf("expected submit CONFIRMATION card, got %+v", resSubmit.InteractiveCard)
}
if resSubmit.PendingAction != nil {
t.Errorf("submit must not PendingAction before confirm")
}
// 3. DocGovApprove // 3. DocGovApprove
reviewerCtx := &coordinator.UserProjectContext{ reviewerCtx := &coordinator.UserProjectContext{
@@ -134,7 +134,6 @@ func TestMemoryComposer_HierarchyAndPriority(t *testing.T) {
} }
} }
func TestMemoryComposer_RealtimeSnippetAndUnavailable(t *testing.T) { func TestMemoryComposer_RealtimeSnippetAndUnavailable(t *testing.T) {
composer := coordinator.NewMemoryComposer() composer := coordinator.NewMemoryComposer()
fixed := time.Date(2026, 9, 23, 10, 41, 0, 0, time.FixedZone("CST", 8*3600)) fixed := time.Date(2026, 9, 23, 10, 41, 0, 0, time.FixedZone("CST", 8*3600))
+3 -3
View File
@@ -106,9 +106,9 @@ func TestHandleDocGovSubmitReview_ConfirmThenPending(t *testing.T) {
decide := &intent.CandidateIntent{ decide := &intent.CandidateIntent{
Intent: intent.IntentDocGovProposalDecide, Intent: intent.IntentDocGovProposalDecide,
FilledSlots: map[string]string{ FilledSlots: map[string]string{
"target_action": "confirm_submit_review", "target_action": "confirm_submit_review",
intent.SlotTargetDoc: "doc_proj9_intake", intent.SlotTargetDoc: "doc_proj9_intake",
"task_key": "intake", "task_key": "intake",
}, },
} }
res2, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{TopIntent: decide}, userCtx) res2, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{TopIntent: decide}, userCtx)
+3 -1
View File
@@ -152,7 +152,9 @@ func RouteIntent(it intent.IntentType, profile TaskProfile) RouteDecision {
case intent.IntentSecurityForbidden, case intent.IntentSecurityForbidden,
intent.IntentDocGovClaim, intent.IntentDocGovSubmitReview, intent.IntentDocGovReviewApprove, intent.IntentDocGovClaim, intent.IntentDocGovSubmitReview, intent.IntentDocGovReviewApprove,
intent.IntentDocGovReviewReject, intent.IntentDocGovFinalApprove, intent.IntentDocGovFinalReject, intent.IntentDocGovReviewReject, intent.IntentDocGovFinalApprove, intent.IntentDocGovFinalReject,
intent.IntentDocGovProposalDecide, intent.IntentQueryRejectReason, intent.IntentDocGovProposalDecide, intent.IntentDocGovCommentAdd, intent.IntentDocGovCommentResolve,
intent.IntentDocGovReopenRequest, intent.IntentDocGovReopenApprove,
intent.IntentQueryRejectReason, intent.IntentQueryPendingSupplements,
intent.IntentQueryProgress, intent.IntentQueryRegulations, intent.IntentQuerySimilarCases, intent.IntentQueryProgress, intent.IntentQueryRegulations, intent.IntentQuerySimilarCases,
intent.IntentQueryUserRights, intent.IntentQueryWorkflowStatus, intent.IntentQueryDocStatus, intent.IntentQueryUserRights, intent.IntentQueryWorkflowStatus, intent.IntentQueryDocStatus,
intent.IntentHelpCapabilities, intent.IntentUnsupportedAction, intent.IntentRunStatusQuery, intent.IntentHelpCapabilities, intent.IntentUnsupportedAction, intent.IntentRunStatusQuery,
+2 -1
View File
@@ -64,7 +64,8 @@ func TestRouteIntent_R0Equivalence(t *testing.T) {
intent.IntentSecurityForbidden, intent.IntentDeclineExpectation, intent.IntentSecurityForbidden, intent.IntentDeclineExpectation,
intent.IntentDocGovClaim, intent.IntentDocGovSubmitReview, intent.IntentDocGovReviewApprove, intent.IntentDocGovClaim, intent.IntentDocGovSubmitReview, intent.IntentDocGovReviewApprove,
intent.IntentDocGovReviewReject, intent.IntentDocGovFinalApprove, intent.IntentDocGovFinalReject, intent.IntentDocGovReviewReject, intent.IntentDocGovFinalApprove, intent.IntentDocGovFinalReject,
intent.IntentDocGovProposalDecide, intent.IntentDocGovProposalDecide, intent.IntentDocGovCommentAdd, intent.IntentDocGovCommentResolve,
intent.IntentDocGovReopenRequest, intent.IntentDocGovReopenApprove, intent.IntentQueryPendingSupplements,
} { } {
if d := RouteIntent(it, TaskProfile{}); d.Channel != ChannelTemplate { if d := RouteIntent(it, TaskProfile{}); d.Channel != ChannelTemplate {
t.Errorf("%s should stay TEMPLATE, got %s", it, d.Channel) t.Errorf("%s should stay TEMPLATE, got %s", it, d.Channel)
+40
View File
@@ -0,0 +1,40 @@
package docgov_test
import (
"context"
"testing"
"chat-agent-system/internal/docgov"
)
func TestAddCommentStageMatrix(t *testing.T) {
eng := docgov.NewGovernanceEngine(nil)
doc, err := eng.CreateOrGetDocument(context.Background(), "doc_cmt_matrix", 1, "feasibility_reg", "可行性", "V0.5", "", 1, "A", 2, "R", 3, "P")
if err != nil {
t.Fatal(err)
}
if _, err := eng.AddComment(context.Background(), doc.DocID, 1, "A", docgov.RoleAuthor, "s1", docgov.CommentSuggestion, "作者意见"); err != nil {
t.Fatalf("author drafting: %v", err)
}
if _, err := eng.AddComment(context.Background(), doc.DocID, 9, "V", docgov.UserRole("viewer"), "s1", docgov.CommentSuggestion, "观者"); err == nil {
t.Fatal("viewer should be forbidden in drafting")
}
if _, err := eng.SubmitForReview(context.Background(), doc.DocID, 1, docgov.RoleAuthor, "A"); err != nil {
t.Fatal(err)
}
if _, err := eng.AddComment(context.Background(), doc.DocID, 3, "P", docgov.RoleApprover, "s1", docgov.CommentSuggestion, "批准人"); err == nil {
t.Fatal("approver should be forbidden in reviewing")
}
if _, err := eng.AddComment(context.Background(), doc.DocID, 1, "A", docgov.RoleAuthor, "s1", docgov.CommentSuggestion, "回应"); err != nil {
t.Fatalf("author reviewing: %v", err)
}
if _, err := eng.ReviewApprove(context.Background(), doc.DocID, 2, docgov.RoleReviewer, "R", "ok"); err != nil {
t.Fatal(err)
}
if _, err := eng.AddComment(context.Background(), doc.DocID, 1, "A", docgov.RoleAuthor, "s1", docgov.CommentSuggestion, "作者"); err == nil {
t.Fatal("author should be forbidden in approving")
}
if _, err := eng.AddComment(context.Background(), doc.DocID, 3, "P", docgov.RoleApprover, "s1", docgov.CommentSuggestion, "批准意见"); err != nil {
t.Fatalf("approver approving: %v", err)
}
}
+25
View File
@@ -20,6 +20,8 @@ type GovernanceEngine struct {
memAudits map[string][]*AuditLog memAudits map[string][]*AuditLog
onApprovedHook func(ctx context.Context, doc *DocumentRecord) onApprovedHook func(ctx context.Context, doc *DocumentRecord)
onApprovedHooks []func(ctx context.Context, doc *DocumentRecord) onApprovedHooks []func(ctx context.Context, doc *DocumentRecord)
onPendingDigest PendingSupplementNotify
onLifecycleNotify LifecycleNotify
OnFinalArtifact func(ctx context.Context, doc *DocumentRecord, markdown []byte, snapshotID int64) OnFinalArtifact func(ctx context.Context, doc *DocumentRecord, markdown []byte, snapshotID int64)
OnFinalArtifactTX func(ctx context.Context, tx pgx.Tx, doc *DocumentRecord, markdown []byte, snapshotID int64) error OnFinalArtifactTX func(ctx context.Context, tx pgx.Tx, doc *DocumentRecord, markdown []byte, snapshotID int64) error
@@ -27,6 +29,29 @@ type GovernanceEngine struct {
docLocks *docLockIndex docLocks *docLockIndex
} }
// LifecycleNotify is invoked after a successful governance state transition (outside engine lock).
type LifecycleNotify func(ctx context.Context, action string, doc *DocumentRecord, actorID int64, actorName, detail string)
// SetLifecycleNotify registers the post-transition notification hook.
func (e *GovernanceEngine) SetLifecycleNotify(hook LifecycleNotify) {
if e == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
e.onLifecycleNotify = hook
}
// SetPendingDigestNotify registers a callback when pending supplements are digested on return to drafting.
func (e *GovernanceEngine) SetPendingDigestNotify(hook PendingSupplementNotify) {
if e == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
e.onPendingDigest = hook
}
// SetOnApprovedHook 注册文档终审批准时的回调钩子 // SetOnApprovedHook 注册文档终审批准时的回调钩子
func (e *GovernanceEngine) SetOnApprovedHook(hook func(ctx context.Context, doc *DocumentRecord)) { func (e *GovernanceEngine) SetOnApprovedHook(hook func(ctx context.Context, doc *DocumentRecord)) {
e.mu.Lock() e.mu.Lock()
+209
View File
@@ -0,0 +1,209 @@
package docgov
import (
"context"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
const (
PendingSupplementPending = "pending"
PendingSupplementDigested = "digested"
PendingSupplementDiscarded = "discarded"
)
// PendingSupplement is one locked-period fact supplement deferred for later digestion.
type PendingSupplement struct {
ID int64 `json:"id"`
DocID string `json:"doc_id"`
ProjectID int64 `json:"project_id"`
SourceSummary string `json:"source_summary"`
ImpactReason string `json:"impact_reason"`
InitiatorUserID int64 `json:"initiator_user_id"`
BatchID string `json:"batch_id,omitempty"`
Status string `json:"status"`
DigestedAt *time.Time `json:"digested_at,omitempty"`
DecidedBy int64 `json:"decided_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// PendingSupplementNotify is invoked after pending rows are marked digested.
type PendingSupplementNotify func(ctx context.Context, doc *DocumentRecord, pending []PendingSupplement)
// RecordPendingSupplementInput captures a supplement that cannot be applied while locked.
type RecordPendingSupplementInput struct {
DocID string
ProjectID int64
InitiatorUserID int64
SourceSummary string
ImpactReason string
BatchID string
}
// RecordPendingSupplement persists a deferred supplement for a locked document.
func (e *GovernanceEngine) RecordPendingSupplement(ctx context.Context, in RecordPendingSupplementInput) (*PendingSupplement, error) {
if e == nil {
return nil, fmt.Errorf("docgov: engine required")
}
docID := strings.TrimSpace(in.DocID)
if docID == "" || in.ProjectID <= 0 {
return nil, fmt.Errorf("docgov: pending supplement requires doc_id and project_id")
}
now := time.Now().UTC()
row := &PendingSupplement{
DocID: docID, ProjectID: in.ProjectID, InitiatorUserID: in.InitiatorUserID,
SourceSummary: strings.TrimSpace(in.SourceSummary),
ImpactReason: strings.TrimSpace(in.ImpactReason),
BatchID: strings.TrimSpace(in.BatchID),
Status: PendingSupplementPending,
CreatedAt: now,
}
if e.pool == nil {
return row, nil
}
err := e.pool.QueryRow(ctx, `
INSERT INTO document_pending_supplements (
doc_id, project_id, source_summary, impact_reason, initiator_user_id, batch_id, status, created_at, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8)
RETURNING id, created_at
`, row.DocID, row.ProjectID, row.SourceSummary, row.ImpactReason, row.InitiatorUserID, row.BatchID, row.Status, now).
Scan(&row.ID, &row.CreatedAt)
if err != nil {
return nil, fmt.Errorf("docgov: record pending supplement: %w", err)
}
return row, nil
}
// ListPendingSupplements returns pending rows for a document.
func (e *GovernanceEngine) ListPendingSupplements(ctx context.Context, docID string) ([]PendingSupplement, error) {
if e == nil || e.pool == nil {
return nil, nil
}
docID = strings.TrimSpace(docID)
if docID == "" {
return nil, nil
}
rows, err := e.pool.Query(ctx, `
SELECT id, doc_id, project_id, source_summary, impact_reason, initiator_user_id, batch_id,
status, digested_at, decided_by, created_at
FROM document_pending_supplements
WHERE doc_id = $1 AND status = $2
ORDER BY created_at ASC, id ASC
`, docID, PendingSupplementPending)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]PendingSupplement, 0)
for rows.Next() {
var p PendingSupplement
if err := rows.Scan(
&p.ID, &p.DocID, &p.ProjectID, &p.SourceSummary, &p.ImpactReason, &p.InitiatorUserID, &p.BatchID,
&p.Status, &p.DigestedAt, &p.DecidedBy, &p.CreatedAt,
); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// ListPendingSupplementsByProject returns undigested pending rows for a project (optional doc filter).
func (e *GovernanceEngine) ListPendingSupplementsByProject(ctx context.Context, projectID int64, docID string) ([]PendingSupplement, error) {
if e == nil || e.pool == nil || projectID <= 0 {
return nil, nil
}
docID = strings.TrimSpace(docID)
var rows pgx.Rows
var err error
if docID != "" {
rows, err = e.pool.Query(ctx, `
SELECT id, doc_id, project_id, source_summary, impact_reason, initiator_user_id, batch_id,
status, digested_at, decided_by, created_at
FROM document_pending_supplements
WHERE project_id = $1 AND doc_id = $2 AND status = $3
ORDER BY created_at ASC, id ASC
`, projectID, docID, PendingSupplementPending)
} else {
rows, err = e.pool.Query(ctx, `
SELECT id, doc_id, project_id, source_summary, impact_reason, initiator_user_id, batch_id,
status, digested_at, decided_by, created_at
FROM document_pending_supplements
WHERE project_id = $1 AND status = $2
ORDER BY created_at ASC, id ASC
`, projectID, PendingSupplementPending)
}
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]PendingSupplement, 0)
for rows.Next() {
var p PendingSupplement
if err := rows.Scan(
&p.ID, &p.DocID, &p.ProjectID, &p.SourceSummary, &p.ImpactReason, &p.InitiatorUserID, &p.BatchID,
&p.Status, &p.DigestedAt, &p.DecidedBy, &p.CreatedAt,
); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// DigestPendingSupplements marks all pending rows digested when a document returns to drafting.
// It returns the digested rows so callers can notify the author.
func (e *GovernanceEngine) DigestPendingSupplements(ctx context.Context, docID string, decidedBy int64) ([]PendingSupplement, error) {
if e == nil {
return nil, fmt.Errorf("docgov: engine required")
}
docID = strings.TrimSpace(docID)
if docID == "" {
return nil, nil
}
pending, err := e.ListPendingSupplements(ctx, docID)
if err != nil || len(pending) == 0 {
return pending, err
}
if e.pool == nil {
for i := range pending {
pending[i].Status = PendingSupplementDigested
now := time.Now().UTC()
pending[i].DigestedAt = &now
pending[i].DecidedBy = decidedBy
}
return pending, nil
}
now := time.Now().UTC()
_, err = e.pool.Exec(ctx, `
UPDATE document_pending_supplements
SET status = $2, digested_at = $3, decided_by = $4, updated_at = $3
WHERE doc_id = $1 AND status = $5
`, docID, PendingSupplementDigested, now, decidedBy, PendingSupplementPending)
if err != nil {
return nil, fmt.Errorf("docgov: digest pending supplements: %w", err)
}
for i := range pending {
pending[i].Status = PendingSupplementDigested
pending[i].DigestedAt = &now
pending[i].DecidedBy = decidedBy
}
return pending, nil
}
// DigestPendingOnReturnToDrafting digests pending supplements after a transition back to drafting.
func (e *GovernanceEngine) DigestPendingOnReturnToDrafting(ctx context.Context, doc *DocumentRecord, actorID int64, notify PendingSupplementNotify) {
if e == nil || doc == nil || doc.CurrentState != StateDrafting {
return
}
pending, err := e.DigestPendingSupplements(ctx, doc.DocID, actorID)
if err != nil || len(pending) == 0 {
return
}
if notify != nil {
notify(ctx, doc, pending)
}
}
+123 -2
View File
@@ -196,8 +196,23 @@ func (e *GovernanceEngine) DecideModificationProposal(
return proposal, nil return proposal, nil
} }
// AddComment 添加批注与修改意见 (审核人/批准人/编制人均可批注,但审核批准不能直接改正文) // roleAllowedToComment enforces the stage-scoped comment matrix.
// 有效编辑租约期间禁止批注;生命周期锁定下仍允许批注。 func roleAllowedToComment(state DocumentState, role UserRole) bool {
switch state {
case StateDrafting:
return role == RoleAuthor || role == RoleReviewer || role == RoleSystem
case StateReviewing:
return role == RoleReviewer || role == RoleAuthor || role == RoleSystem
case StateApproving:
return role == RoleApprover || role == RoleReviewer || role == RoleSystem
case StateApproved:
return false
default:
return false
}
}
// AddComment 添加批注与修改意见。有效编辑租约期间禁止批注;生命周期锁定下仍允许批注(按阶段矩阵)。
func (e *GovernanceEngine) AddComment( func (e *GovernanceEngine) AddComment(
ctx context.Context, ctx context.Context,
docID string, docID string,
@@ -215,6 +230,21 @@ func (e *GovernanceEngine) AddComment(
g := e.acquireDocLock(docID) g := e.acquireDocLock(docID)
defer g.Release() defer g.Release()
doc, exists := e.memDocs[docID]
if !exists {
var err error
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
if err != nil || doc == nil {
return nil, ErrDocumentNotFound
}
}
if doc.CurrentState == StateApproved {
return nil, fmt.Errorf("%w: 已批准归档文档禁止新增批注", ErrDocumentLocked)
}
if !roleAllowedToComment(doc.CurrentState, userRole) {
return nil, fmt.Errorf("%w: 当前阶段【%s】不允许角色【%s】新增批注", ErrUnauthorizedActor, doc.CurrentState, userRole)
}
trimmed := strings.TrimSpace(content) trimmed := strings.TrimSpace(content)
if trimmed == "" { if trimmed == "" {
return nil, fmt.Errorf("comment content cannot be empty") return nil, fmt.Errorf("comment content cannot be empty")
@@ -233,6 +263,19 @@ func (e *GovernanceEngine) AddComment(
CreatedAt: now, CreatedAt: now,
} }
if e.pool == nil {
// in-memory: allocate synthetic ids so ResolveComment can address them
var maxID int64
for _, list := range e.memComms {
for _, c := range list {
if c != nil && c.ID > maxID {
maxID = c.ID
}
}
}
comment.ID = maxID + 1
}
e.memComms[docID] = append(e.memComms[docID], comment) e.memComms[docID] = append(e.memComms[docID], comment)
if e.pool != nil { if e.pool != nil {
@@ -253,6 +296,84 @@ func (e *GovernanceEngine) AddComment(
return comment, nil return comment, nil
} }
// ResolveComment marks a comment resolved (author in drafting) or reopens it (reviewer).
func (e *GovernanceEngine) ResolveComment(ctx context.Context, commentID int64, actorID int64, actorRole UserRole, resolve bool) (*DocumentComment, error) {
if e == nil || commentID <= 0 {
return nil, fmt.Errorf("docgov: comment id required")
}
e.mu.Lock()
defer e.mu.Unlock()
var found *DocumentComment
var docID string
for id, list := range e.memComms {
for _, c := range list {
if c != nil && c.ID == commentID {
found = c
docID = id
break
}
}
if found != nil {
break
}
}
if found == nil && e.pool != nil {
var c DocumentComment
var role, cType string
err := e.pool.QueryRow(ctx, `
SELECT id, doc_id, user_id, user_name, user_role, target_section, comment_type, content, status, created_at
FROM document_comments WHERE id = $1
`, commentID).Scan(
&c.ID, &c.DocID, &c.UserID, &c.UserName, &role, &c.TargetSection, &cType, &c.Content, &c.Status, &c.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("docgov: comment not found")
}
c.UserRole = UserRole(role)
c.CommentType = CommentType(cType)
found = &c
docID = c.DocID
e.memComms[docID] = append(e.memComms[docID], found)
}
if found == nil {
return nil, fmt.Errorf("docgov: comment not found")
}
doc, exists := e.memDocs[docID]
if !exists {
var err error
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
if err != nil || doc == nil {
return nil, ErrDocumentNotFound
}
}
newStatus := "resolved"
if !resolve {
newStatus = "open"
if actorRole != RoleReviewer && actorRole != RoleSystem {
return nil, fmt.Errorf("%w: 仅审核人可重开批注", ErrUnauthorizedActor)
}
} else {
switch {
case actorRole == RoleAuthor && doc.CurrentState == StateDrafting:
case actorRole == RoleReviewer || actorRole == RoleSystem:
default:
return nil, fmt.Errorf("%w: 当前阶段不允许将该批注标记为已处理", ErrUnauthorizedActor)
}
}
found.Status = newStatus
if e.pool != nil {
_, err := e.pool.Exec(ctx, `UPDATE document_comments SET status = $1 WHERE id = $2`, newStatus, commentID)
if err != nil {
return nil, fmt.Errorf("docgov: resolve comment: %w", err)
}
}
_ = actorID
return found, nil
}
func (e *GovernanceEngine) addCommentInternal(docID string, userID int64, userName string, userRole UserRole, targetSection string, commentType CommentType, content string) error { func (e *GovernanceEngine) addCommentInternal(docID string, userID int64, userName string, userRole UserRole, targetSection string, commentType CommentType, content string) error {
now := time.Now() now := time.Now()
comment := &DocumentComment{ comment := &DocumentComment{
+41
View File
@@ -9,6 +9,18 @@ import (
"chat-agent-system/internal/app/uow" "chat-agent-system/internal/app/uow"
) )
func (e *GovernanceEngine) emitLifecycle(ctx context.Context, action string, doc *DocumentRecord, actorID int64, actorName, detail string) {
if e == nil || doc == nil {
return
}
e.mu.Lock()
hook := e.onLifecycleNotify
e.mu.Unlock()
if hook != nil {
hook(ctx, action, doc, actorID, actorName, detail)
}
}
// SubmitForReview 编制完成提交审核 (drafting -> reviewing) // SubmitForReview 编制完成提交审核 (drafting -> reviewing)
func (e *GovernanceEngine) SubmitForReview(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string) (*DocumentRecord, error) { func (e *GovernanceEngine) SubmitForReview(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string) (*DocumentRecord, error) {
g := e.acquireDocLock(docID) g := e.acquireDocLock(docID)
@@ -42,6 +54,8 @@ func (e *GovernanceEngine) SubmitForReview(ctx context.Context, docID string, ac
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil { if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
return nil, err return nil, err
} }
g.Release()
e.emitLifecycle(ctx, "submit_review", doc, actorID, actorName, "")
return doc, nil return doc, nil
} }
@@ -68,6 +82,8 @@ func (e *GovernanceEngine) ReviewApprove(ctx context.Context, docID string, acto
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil { if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
return nil, err return nil, err
} }
g.Release()
e.emitLifecycle(ctx, "review_approve", doc, actorID, actorName, comment)
return doc, nil return doc, nil
} }
@@ -93,6 +109,12 @@ func (e *GovernanceEngine) ReviewReject(ctx context.Context, docID string, actor
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil { if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
return nil, err return nil, err
} }
e.mu.RLock()
pendingNotify := e.onPendingDigest
e.mu.RUnlock()
g.Release()
e.DigestPendingOnReturnToDrafting(ctx, doc, actorID, pendingNotify)
e.emitLifecycle(ctx, "review_reject", doc, actorID, actorName, rejectReason)
return doc, nil return doc, nil
} }
@@ -130,8 +152,13 @@ func (e *GovernanceEngine) ApproverApprove(ctx context.Context, docID string, ac
md, _ := e.LedgerMarkdown(ctx, doc.ReviewSnapshotID) md, _ := e.LedgerMarkdown(ctx, doc.ReviewSnapshotID)
e.OnFinalArtifact(ctx, doc, []byte(md), doc.ReviewSnapshotID) e.OnFinalArtifact(ctx, doc, []byte(md), doc.ReviewSnapshotID)
} }
e.emitLifecycle(ctx, "approver_approve", doc, actorID, actorName, comment)
g = e.acquireDocLock(docID) g = e.acquireDocLock(docID)
defer g.Release() defer g.Release()
} else {
// Same-tx approve: defer lifecycle until after commit via caller; still emit here for inbox.
g.Release()
e.emitLifecycle(ctx, "approver_approve", doc, actorID, actorName, comment)
} }
return doc, nil return doc, nil
@@ -158,6 +185,14 @@ func (e *GovernanceEngine) ApproverReject(ctx context.Context, docID string, act
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil { if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
return nil, err return nil, err
} }
e.mu.RLock()
pendingNotify := e.onPendingDigest
e.mu.RUnlock()
g.Release()
if doc.CurrentState == StateDrafting {
e.DigestPendingOnReturnToDrafting(ctx, doc, actorID, pendingNotify)
}
e.emitLifecycle(ctx, "approver_reject", doc, actorID, actorName, rejectReason)
return doc, nil return doc, nil
} }
@@ -177,5 +212,11 @@ func (e *GovernanceEngine) ReopenForRevision(ctx context.Context, docID string,
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil { if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
return nil, err return nil, err
} }
e.mu.RLock()
pendingNotify := e.onPendingDigest
e.mu.RUnlock()
g.Release()
e.DigestPendingOnReturnToDrafting(ctx, doc, actorID, pendingNotify)
e.emitLifecycle(ctx, "reopen", doc, actorID, actorName, reason)
return doc, nil return doc, nil
} }
+1 -1
View File
@@ -9,7 +9,7 @@ import (
// 全局锁只保护「分锁表」本身(短临界区);文档间操作互不阻塞。 // 全局锁只保护「分锁表」本身(短临界区);文档间操作互不阻塞。
// 锁序约定:docLocks.meta → docID(如需多文档,按 docID 字典序)。 // 锁序约定:docLocks.meta → docID(如需多文档,按 docID 字典序)。
type docLockIndex struct { type docLockIndex struct {
meta sync.Mutex meta sync.Mutex
locks map[string]*sync.Mutex locks map[string]*sync.Mutex
} }
+7 -6
View File
@@ -10,11 +10,11 @@ import (
type DialogueAct string type DialogueAct string
const ( const (
DialogueActAsk DialogueAct = "ask" DialogueActAsk DialogueAct = "ask"
DialogueActCommand DialogueAct = "command" DialogueActCommand DialogueAct = "command"
DialogueActConfirm DialogueAct = "confirm" DialogueActConfirm DialogueAct = "confirm"
DialogueActDecline DialogueAct = "decline" DialogueActDecline DialogueAct = "decline"
DialogueActOther DialogueAct = "other" DialogueActOther DialogueAct = "other"
) )
// detectDialogueAct 识别查询/命令/确认/拒绝语气(规则轻量版) // detectDialogueAct 识别查询/命令/确认/拒绝语气(规则轻量版)
@@ -85,6 +85,7 @@ func isWriteIntent(t IntentType) bool {
case IntentAdvanceStage, IntentTriggerTask, IntentTriggerAdhocTask, IntentSupplementInfo, case IntentAdvanceStage, IntentTriggerTask, IntentTriggerAdhocTask, IntentSupplementInfo,
IntentDocGovClaim, IntentDocGovSubmitReview, IntentDocGovReviewApprove, IntentDocGovReviewReject, IntentDocGovClaim, IntentDocGovSubmitReview, IntentDocGovReviewApprove, IntentDocGovReviewReject,
IntentDocGovFinalApprove, IntentDocGovFinalReject, IntentDocGovProposalDecide, IntentDocGovFinalApprove, IntentDocGovFinalReject, IntentDocGovProposalDecide,
IntentDocGovCommentAdd, IntentDocGovCommentResolve, IntentDocGovReopenRequest, IntentDocGovReopenApprove,
IntentConfirmAction, IntentExecuteStageAdvance, IntentConfirmAction, IntentExecuteStageAdvance,
IntentSwitchWorkflow, IntentTriggerWorkflow, IntentSwitchWorkflow, IntentTriggerWorkflow,
IntentRunResume, IntentRunRestart, IntentRunResume, IntentRunRestart,
@@ -104,7 +105,7 @@ func IsQueryLegacyIntent(t IntentType) bool {
func isQueryIntent(t IntentType) bool { func isQueryIntent(t IntentType) bool {
switch t { switch t {
case IntentQueryProgress, IntentQueryRegulations, IntentQuerySimilarCases, case IntentQueryProgress, IntentQueryRegulations, IntentQuerySimilarCases,
IntentQueryUserRights, IntentQueryRejectReason, IntentQueryUserRights, IntentQueryRejectReason, IntentQueryPendingSupplements,
IntentQueryWorkflowStatus, IntentQueryDocStatus, IntentHelpCapabilities, IntentQueryWorkflowStatus, IntentQueryDocStatus, IntentHelpCapabilities,
IntentRunStatusQuery, IntentImpactAnalyze: IntentRunStatusQuery, IntentImpactAnalyze:
return true return true
+11 -7
View File
@@ -10,8 +10,8 @@ import (
"testing" "testing"
"time" "time"
"chat-agent-system/internal/maturity"
"chat-agent-system/internal/intent" "chat-agent-system/internal/intent"
"chat-agent-system/internal/maturity"
) )
type goldenFile struct { type goldenFile struct {
@@ -148,12 +148,16 @@ func assertGoldenExpect(t *testing.T, caseID string, exp *goldenExpect, res *int
} }
if exp.MustNotWrite { if exp.MustNotWrite {
writeIntents := map[intent.IntentType]bool{ writeIntents := map[intent.IntentType]bool{
intent.IntentDocGovClaim: true, intent.IntentDocGovClaim: true,
intent.IntentDocGovSubmitReview: true, intent.IntentDocGovSubmitReview: true,
intent.IntentDocGovReviewApprove: true, intent.IntentDocGovReviewApprove: true,
intent.IntentDocGovReviewReject: true, intent.IntentDocGovReviewReject: true,
intent.IntentDocGovFinalApprove: true, intent.IntentDocGovFinalApprove: true,
intent.IntentDocGovFinalReject: true, intent.IntentDocGovFinalReject: true,
intent.IntentDocGovCommentAdd: true,
intent.IntentDocGovCommentResolve: true,
intent.IntentDocGovReopenRequest: true,
intent.IntentDocGovReopenApprove: true,
} }
if writeIntents[res.TopIntent.Intent] { if writeIntents[res.TopIntent.Intent] {
t.Errorf("[%s] must not route to write intent %s", caseID, res.TopIntent.Intent) t.Errorf("[%s] must not route to write intent %s", caseID, res.TopIntent.Intent)
+305 -2
View File
@@ -2,6 +2,7 @@ package intent
import ( import (
"fmt" "fmt"
"regexp"
"strings" "strings"
) )
@@ -101,9 +102,10 @@ func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent
} }
} }
// 2. 终审批准 (Final Approve) // 2. 终审批准 (Final Approve) — 排除重开语义,避免「批准重开」误入终审
if strings.Contains(input, "终审批准") || strings.Contains(input, "批准发布") || strings.Contains(input, "批准生效") || if strings.Contains(input, "终审批准") || strings.Contains(input, "批准发布") || strings.Contains(input, "批准生效") ||
strings.Contains(input, "同意发布") || (strings.Contains(input, "批准") && !strings.Contains(input, "不")) { strings.Contains(input, "同意发布") || (strings.Contains(input, "批准") && !strings.Contains(input, "不") &&
!strings.Contains(input, "重开") && !strings.Contains(input, "再打开") && !strings.Contains(input, "再修订")) {
targetIntent := IntentDocGovFinalApprove targetIntent := IntentDocGovFinalApprove
slots := make(map[string]string) slots := make(map[string]string)
targetDoc := resolveTargetDoc(input, userCtx) targetDoc := resolveTargetDoc(input, userCtx)
@@ -256,5 +258,306 @@ func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent
} }
} }
// 7. 积压补充查询(只读)— 置于写动作前,避免「积压是什么」被误触
if isPendingSupplementsQuery(input) {
slots := make(map[string]string)
fillTargetDocSlot(slots, input, userCtx)
return &CandidateIntent{
Intent: IntentQueryPendingSupplements,
Confidence: 0.95,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: "用户查询锁定期间积压/待消化补充,路由为只读 QUERY_PENDING_SUPPLEMENTS",
}
}
// 8. 重开批准(须显式「批准/同意」+「重开/再打开/再修订」;裸词「重开」不触发)
if isReopenApprovePhrase(input) {
slots := make(map[string]string)
fillTargetDocSlot(slots, input, userCtx)
confidence := 0.95
reasoning := "用户下达批准重开/再修订指令"
clarifyQ := ""
if userCtx != nil {
if userCtx.UserDocRole != "" && userCtx.UserDocRole != "approver" && userCtx.UserDocRole != "system" {
confidence = 0.90
reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是批准人(UserDocRole: %s),无法批准重开", userCtx.UserDocRole)
clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,批准重开需批准人(或创建人兜底,由服务端二次校验)。", userCtx.UserDocRole)
}
if userCtx.DocLifecycleState != "" && userCtx.DocLifecycleState != "approved" {
confidence = 0.90
reasoning = fmt.Sprintf("前置剪枝:当前文档处于【%s】阶段,重开仅适用于已批准(approved)文档", userCtx.DocLifecycleState)
clarifyQ = fmt.Sprintf("当前文档处于【%s】阶段,仅已批准文档可申请/批准重开。", userCtx.DocLifecycleState)
}
}
return &CandidateIntent{
Intent: IntentDocGovReopenApprove,
Confidence: confidence,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: reasoning,
ClarificationQuestion: clarifyQ,
}
}
// 9. 重开申请(须「申请」+「重开/再修订/再打开」,或显式文档引用 + 申请语义;裸词「重开」不触发)
if isReopenRequestPhrase(input) {
if detectDialogueAct(input) == DialogueActAsk {
slots := make(map[string]string)
fillTargetDocSlot(slots, input, userCtx)
return &CandidateIntent{
Intent: IntentQueryDocStatus,
Confidence: 0.91,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: "查询语气下的重开状态询问,不得路由为写操作 DOCGOV_REOPEN_REQUEST",
ClarificationQuestion: "您是想查询该文档是否已批准、可否申请重开吗?我将检索状态,不会发起重开申请。",
}
}
slots := make(map[string]string)
fillTargetDocSlot(slots, input, userCtx)
if reason := extractReopenReason(input); reason != "" {
slots[SlotReopenReason] = reason
}
confidence := 0.94
reasoning := "用户下达申请再修订/重开指令"
clarifyQ := ""
if slots[SlotReopenReason] == "" {
confidence = 0.90
clarifyQ = "请补充重开原因(例如:申请重开《需求表》,原因:需补充风险分析章节)。"
reasoning = "重开申请缺少原因槽位,需澄清"
}
if userCtx != nil {
role := userCtx.UserDocRole
if role != "" && role != "author" && role != "creator" && role != "system" {
confidence = 0.90
reasoning = fmt.Sprintf("前置剪枝:当前用户角色为【%s】,申请重开通常需作者/职责人(服务端将二次校验)", role)
clarifyQ = fmt.Sprintf("您当前角色为【%s】。申请重开需为文档职责人或项目创建人;确认后由服务端校验。", role)
}
if userCtx.DocLifecycleState != "" && userCtx.DocLifecycleState != "approved" {
confidence = 0.90
reasoning = fmt.Sprintf("前置剪枝:当前文档处于【%s】阶段,仅已批准(approved)可申请重开", userCtx.DocLifecycleState)
clarifyQ = fmt.Sprintf("当前文档处于【%s】阶段,仅已批准文档可申请重开。", userCtx.DocLifecycleState)
}
}
return &CandidateIntent{
Intent: IntentDocGovReopenRequest,
Confidence: confidence,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: reasoning,
ClarificationQuestion: clarifyQ,
}
}
// 10. 批注解决/关闭
if isCommentResolvePhrase(input) {
slots := make(map[string]string)
fillTargetDocSlot(slots, input, userCtx)
if sec := extractSection(input); sec != "" {
slots[SlotTargetSection] = sec
}
return &CandidateIntent{
Intent: IntentDocGovCommentResolve,
Confidence: 0.93,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: "用户下达解决/关闭批注意图",
}
}
// 11. 添加批注(须批注词 + 治理动词共现;查询语气走只读;缺内容澄清)
if isCommentAddPhrase(input) {
slots := make(map[string]string)
fillTargetDocSlot(slots, input, userCtx)
if sec := extractSection(input); sec != "" {
slots[SlotTargetSection] = sec
}
if ct := extractCommentType(input); ct != "" {
slots[SlotCommentType] = ct
} else {
slots[SlotCommentType] = "suggestion"
}
if content := extractCommentContent(input); content != "" {
slots[SlotCommentContent] = content
}
if detectDialogueAct(input) == DialogueActAsk || isCommentStatusQuery(input) {
return &CandidateIntent{
Intent: IntentQueryDocStatus,
Confidence: 0.91,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: "查询语气下的批注询问,不得路由为写操作 DOCGOV_COMMENT_ADD",
ClarificationQuestion: "您是想查询该文档已有批注吗?我将检索批注列表,不会新增批注。",
}
}
confidence := 0.94
reasoning := "用户下达添加批注/意见指令"
clarifyQ := ""
if slots[SlotCommentContent] == "" {
confidence = 0.90
clarifyQ = "请说明要添加的批注内容(例如:给第3节加条意见:风险分析缺少残留风险评价)。"
reasoning = "批注意图缺少内容槽位,需澄清"
}
if userCtx != nil {
if !commentRoleStateAllowed(userCtx.UserDocRole, userCtx.DocLifecycleState) {
confidence = 0.90
reasoning = fmt.Sprintf("前置剪枝:角色【%s】在阶段【%s】不允许批注(按批注矩阵)", userCtx.UserDocRole, userCtx.DocLifecycleState)
clarifyQ = fmt.Sprintf("按批注矩阵,角色【%s】在【%s】阶段不可添加批注。", userCtx.UserDocRole, userCtx.DocLifecycleState)
}
}
return &CandidateIntent{
Intent: IntentDocGovCommentAdd,
Confidence: confidence,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: reasoning,
ClarificationQuestion: clarifyQ,
}
}
return nil return nil
} }
func isPendingSupplementsQuery(input string) bool {
markers := []string{
"待消化的补充", "待消化补充", "积压补充", "锁定期间补充", "锁定期补充",
"积压的补充", "未消化补充", "pending supplement",
}
for _, m := range markers {
if strings.Contains(input, m) {
return true
}
}
if strings.Contains(input, "积压") && (strings.Contains(input, "补充") || strings.Contains(input, "是什么") || strings.Contains(input, "有哪些") || strings.Contains(input, "查询")) {
return true
}
return false
}
func isReopenApprovePhrase(input string) bool {
hasApprove := strings.Contains(input, "批准重开") || strings.Contains(input, "同意重开") ||
strings.Contains(input, "批准再打开") || strings.Contains(input, "同意再打开") ||
strings.Contains(input, "批准再修订") || strings.Contains(input, "同意再修订")
if hasApprove {
return true
}
hasReopen := strings.Contains(input, "重开") || strings.Contains(input, "再打开") || strings.Contains(input, "再修订")
hasApproveVerb := strings.Contains(input, "批准") || strings.Contains(input, "同意")
return hasReopen && hasApproveVerb && !strings.Contains(input, "申请")
}
func isReopenRequestPhrase(input string) bool {
// 裸词「重开」不触发
if strings.TrimSpace(input) == "重开" {
return false
}
explicit := []string{"申请重开", "申请再修订", "申请再打开", "申请重新打开", "请求重开", "请求再修订"}
for _, p := range explicit {
if strings.Contains(input, p) {
return true
}
}
hasReopen := strings.Contains(input, "重开") || strings.Contains(input, "再打开") || strings.Contains(input, "再修订")
hasApply := strings.Contains(input, "申请") || strings.Contains(input, "请求")
if hasReopen && hasApply {
return true
}
// 显式文档引用 + 再打开/再修订(仍须避免裸词)
if hasReopen && extractDocName(input) != "" && (strings.Contains(input, "再") || strings.Contains(input, "申请")) {
return true
}
return false
}
func isCommentResolvePhrase(input string) bool {
markers := []string{"解决批注", "关闭批注", "批注已解决", "批注已处理", "标记批注已解决", "resolve批注"}
for _, m := range markers {
if strings.Contains(input, m) {
return true
}
}
return (strings.Contains(input, "批注") || strings.Contains(input, "意见")) &&
(strings.Contains(input, "解决") || strings.Contains(input, "关闭")) &&
!strings.Contains(input, "添加") && !strings.Contains(input, "加条")
}
func isCommentAddPhrase(input string) bool {
// 批注词必须与治理动词共现,避免「批注」裸词误触
govVerbs := []string{"添加批注", "加条意见", "加条批注", "加上批注", "写条批注", "写个批注", "新增批注", "添加意见", "给.*加.*意见", "批注:", "批注:"}
for _, v := range govVerbs {
if strings.Contains(v, ".*") {
continue
}
if strings.Contains(input, v) {
return true
}
}
hasCommentWord := strings.Contains(input, "批注") || strings.Contains(input, "加条意见") || strings.Contains(input, "添加意见")
hasAction := strings.Contains(input, "添加") || strings.Contains(input, "加条") || strings.Contains(input, "加上") ||
strings.Contains(input, "写") || strings.Contains(input, "新增") || strings.Contains(input, "给")
return hasCommentWord && hasAction
}
func isCommentStatusQuery(input string) bool {
markers := []string{"有哪些批注", "有什么批注", "批注列表", "查看批注", "查一下批注", "我的批注"}
for _, m := range markers {
if strings.Contains(input, m) {
return true
}
}
return false
}
func extractCommentContent(input string) string {
if r := extractRejectReason(input); r != "" {
return r
}
re := regexp.MustCompile(`(?:添加批注|加条意见|加条批注|加上批注|写条批注|写个批注|新增批注|添加意见)[::\s]*(.+)`)
if match := re.FindStringSubmatch(input); len(match) > 1 {
return strings.Trim(strings.TrimSpace(match[1]), ",,。;; ")
}
return ""
}
func extractCommentType(input string) string {
switch {
case strings.Contains(input, "问题") || strings.Contains(input, "issue"):
return "issue"
case strings.Contains(input, "同意") || strings.Contains(input, "approval") || strings.Contains(input, "认可"):
return "approval"
case strings.Contains(input, "建议") || strings.Contains(input, "suggestion"):
return "suggestion"
default:
return ""
}
}
func extractReopenReason(input string) string {
re := regexp.MustCompile(`(?:原因|理由|因为|由于)[::\s]*(.+)`)
if match := re.FindStringSubmatch(input); len(match) > 1 {
return strings.Trim(strings.TrimSpace(match[1]), ",,。;; ")
}
return ""
}
// commentRoleStateAllowed mirrors docgov.roleAllowedToComment for Stage-0 pruning.
func commentRoleStateAllowed(role, state string) bool {
if role == "" || state == "" {
return true // 快照缺失时不剪枝,交给服务端二次校验
}
switch state {
case "drafting":
return role == "author" || role == "reviewer" || role == "system"
case "reviewing":
return role == "reviewer" || role == "author" || role == "system"
case "approving":
return role == "approver" || role == "reviewer" || role == "system"
case "approved":
return false
default:
return false
}
}
+102 -1
View File
@@ -602,7 +602,6 @@ func TestIntentRecognition_FinalReject_MissingReason(t *testing.T) {
} }
} }
func TestIntentRecognition_SecurityFalsePositives_NotBlocked(t *testing.T) { func TestIntentRecognition_SecurityFalsePositives_NotBlocked(t *testing.T) {
rec := intent.NewRecognizer() rec := intent.NewRecognizer()
testInputs := []struct { testInputs := []struct {
@@ -905,3 +904,105 @@ func TestIntentRecognition_ConsultativeStillAmbiguous(t *testing.T) {
t.Error("short fragment must not count as general question") t.Error("short fragment must not count as general question")
} }
} }
func TestIntentRecognition_DocGov_CommentReopenPending(t *testing.T) {
rec := intent.NewRecognizer()
ctxAuthor := &intent.ContextSnapshot{
ActiveDocID: "doc-001",
DocLifecycleState: "drafting",
UserDocRole: "author",
}
resAdd, err := rec.Recognize(context.Background(), "给第3节添加批注:风险分析缺少残留风险评价", ctxAuthor)
if err != nil {
t.Fatalf("comment add: %v", err)
}
if resAdd.TopIntent.Intent != intent.IntentDocGovCommentAdd {
t.Errorf("expected DOCGOV_COMMENT_ADD, got %s", resAdd.TopIntent.Intent)
}
if resAdd.TopIntent.FilledSlots[intent.SlotCommentContent] == "" {
t.Error("expected comment_content slot")
}
resAsk, err := rec.Recognize(context.Background(), "我有哪些批注?", ctxAuthor)
if err != nil {
t.Fatalf("comment ask: %v", err)
}
if resAsk.TopIntent.Intent == intent.IntentDocGovCommentAdd {
t.Errorf("query tone must not route to COMMENT_ADD, got %s", resAsk.TopIntent.Intent)
}
resBare, err := rec.Recognize(context.Background(), "重开", ctxAuthor)
if err != nil {
t.Fatalf("bare reopen: %v", err)
}
if resBare.TopIntent.Intent == intent.IntentDocGovReopenRequest || resBare.TopIntent.Intent == intent.IntentDocGovReopenApprove {
t.Errorf("bare 重开 must not trigger reopen write, got %s", resBare.TopIntent.Intent)
}
ctxApproved := &intent.ContextSnapshot{
ActiveDocID: "doc-001",
DocLifecycleState: "approved",
UserDocRole: "author",
}
resReopen, err := rec.Recognize(context.Background(), "申请重开需求收集表,原因:需补充风险分析", ctxApproved)
if err != nil {
t.Fatalf("reopen request: %v", err)
}
if resReopen.TopIntent.Intent != intent.IntentDocGovReopenRequest {
t.Errorf("expected DOCGOV_REOPEN_REQUEST, got %s", resReopen.TopIntent.Intent)
}
if resReopen.TopIntent.FilledSlots[intent.SlotReopenReason] == "" {
t.Error("expected reopen_reason slot")
}
ctxApprover := &intent.ContextSnapshot{
ActiveDocID: "doc-001",
DocLifecycleState: "approved",
UserDocRole: "approver",
}
resApprove, err := rec.Recognize(context.Background(), "批准重开这份需求收集表", ctxApprover)
if err != nil {
t.Fatalf("reopen approve: %v", err)
}
if resApprove.TopIntent.Intent != intent.IntentDocGovReopenApprove {
t.Errorf("expected DOCGOV_REOPEN_APPROVE, got %s", resApprove.TopIntent.Intent)
}
resPending, err := rec.Recognize(context.Background(), "当前有哪些积压补充?", ctxApproved)
if err != nil {
t.Fatalf("pending: %v", err)
}
if resPending.TopIntent.Intent != intent.IntentQueryPendingSupplements {
t.Errorf("expected QUERY_PENDING_SUPPLEMENTS, got %s", resPending.TopIntent.Intent)
}
// 批注矩阵:approved 阶段剪枝
ctxLocked := &intent.ContextSnapshot{
ActiveDocID: "doc-001",
DocLifecycleState: "approved",
UserDocRole: "author",
}
resMatrix, err := rec.Recognize(context.Background(), "添加批注:不应在已批准阶段出现", ctxLocked)
if err != nil {
t.Fatalf("matrix: %v", err)
}
if resMatrix.TopIntent.Intent != intent.IntentDocGovCommentAdd {
t.Errorf("expected COMMENT_ADD with clarify, got %s", resMatrix.TopIntent.Intent)
}
if resMatrix.TopIntent.ClarificationQuestion == "" {
t.Error("expected clarification for approved-stage comment prune")
}
// 缺内容澄清
resMissing, err := rec.Recognize(context.Background(), "添加批注", ctxAuthor)
if err != nil {
t.Fatalf("missing content: %v", err)
}
if resMissing.TopIntent.Intent != intent.IntentDocGovCommentAdd {
t.Errorf("expected COMMENT_ADD, got %s", resMissing.TopIntent.Intent)
}
if resMissing.TopIntent.ClarificationQuestion == "" {
t.Error("expected clarification when comment content missing")
}
}
+22 -17
View File
@@ -16,7 +16,7 @@ const (
// ActionAlias 自然语言别名 // ActionAlias 自然语言别名
type ActionAlias struct { type ActionAlias struct {
Phrase string `json:"phrase"` Phrase string `json:"phrase"`
Acts []DialogueAct `json:"acts,omitempty"` Acts []DialogueAct `json:"acts,omitempty"`
} }
@@ -29,30 +29,30 @@ type SlotSpec struct {
// ActionSpec 可识别、可授权、可执行的动作单一事实源 // ActionSpec 可识别、可授权、可执行的动作单一事实源
type ActionSpec struct { type ActionSpec struct {
Key string `json:"key"` Key string `json:"key"`
Domain string `json:"domain"` Domain string `json:"domain"`
Object string `json:"object"` Object string `json:"object"`
Goal GoalType `json:"goal"` Goal GoalType `json:"goal"`
Risk RiskLevel `json:"risk"` Risk RiskLevel `json:"risk"`
Aliases []ActionAlias `json:"aliases,omitempty"` Aliases []ActionAlias `json:"aliases,omitempty"`
DialogueActs []DialogueAct `json:"dialogue_acts,omitempty"` DialogueActs []DialogueAct `json:"dialogue_acts,omitempty"`
RequiredSlots []SlotSpec `json:"required_slots,omitempty"` RequiredSlots []SlotSpec `json:"required_slots,omitempty"`
OptionalSlots []SlotSpec `json:"optional_slots,omitempty"` OptionalSlots []SlotSpec `json:"optional_slots,omitempty"`
CapabilityKey string `json:"capability_key,omitempty"` CapabilityKey string `json:"capability_key,omitempty"`
AllowedStates []string `json:"allowed_states,omitempty"` AllowedStates []string `json:"allowed_states,omitempty"`
RequiresConfirm bool `json:"requires_confirm,omitempty"` RequiresConfirm bool `json:"requires_confirm,omitempty"`
IdempotencyMode string `json:"idempotency_mode,omitempty"` IdempotencyMode string `json:"idempotency_mode,omitempty"`
LegacyIntent IntentType `json:"legacy_intent"` LegacyIntent IntentType `json:"legacy_intent"`
} }
// ActionRegistry 动作注册表 // ActionRegistry 动作注册表
type ActionRegistry struct { type ActionRegistry struct {
mu sync.RWMutex mu sync.RWMutex
byKey map[string]*ActionSpec byKey map[string]*ActionSpec
byLegacy map[IntentType]*ActionSpec byLegacy map[IntentType]*ActionSpec
} }
@@ -158,6 +158,11 @@ func registerBuiltinActions(r *ActionRegistry) {
{Key: "document.final.approve", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskHigh, LegacyIntent: IntentDocGovFinalApprove, RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand, DialogueActConfirm}}, {Key: "document.final.approve", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskHigh, LegacyIntent: IntentDocGovFinalApprove, RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand, DialogueActConfirm}},
{Key: "document.final.reject", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskHigh, LegacyIntent: IntentDocGovFinalReject, RequiresConfirm: true, RequiredSlots: []SlotSpec{{Key: SlotRejectReason, Required: true, Label: "驳回理由"}}, DialogueActs: []DialogueAct{DialogueActCommand}}, {Key: "document.final.reject", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskHigh, LegacyIntent: IntentDocGovFinalReject, RequiresConfirm: true, RequiredSlots: []SlotSpec{{Key: SlotRejectReason, Required: true, Label: "驳回理由"}}, DialogueActs: []DialogueAct{DialogueActCommand}},
{Key: "document.proposal.decide", Domain: "document_gov", Object: "controlled_document", Goal: GoalDecide, Risk: RiskMedium, LegacyIntent: IntentDocGovProposalDecide, DialogueActs: []DialogueAct{DialogueActConfirm, DialogueActDecline}}, {Key: "document.proposal.decide", Domain: "document_gov", Object: "controlled_document", Goal: GoalDecide, Risk: RiskMedium, LegacyIntent: IntentDocGovProposalDecide, DialogueActs: []DialogueAct{DialogueActConfirm, DialogueActDecline}},
{Key: "document.comment.add", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskMedium, LegacyIntent: IntentDocGovCommentAdd, CapabilityKey: "DOCGOV_COMMENT_ADD", RequiresConfirm: true, RequiredSlots: []SlotSpec{{Key: SlotCommentContent, Required: true, Label: "批注内容"}}, DialogueActs: []DialogueAct{DialogueActCommand}},
{Key: "document.comment.resolve", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskMedium, LegacyIntent: IntentDocGovCommentResolve, CapabilityKey: "DOCGOV_COMMENT_RESOLVE", RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand}},
{Key: "document.reopen.request", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskHigh, LegacyIntent: IntentDocGovReopenRequest, CapabilityKey: "DOCGOV_REOPEN_REQUEST", RequiresConfirm: true, RequiredSlots: []SlotSpec{{Key: SlotReopenReason, Required: true, Label: "重开原因"}}, DialogueActs: []DialogueAct{DialogueActCommand}},
{Key: "document.reopen.approve", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskHigh, LegacyIntent: IntentDocGovReopenApprove, CapabilityKey: "DOCGOV_REOPEN_APPROVE", RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand, DialogueActConfirm}},
{Key: "document.pending_supplements.query", Domain: "document_gov", Object: "controlled_document", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentQueryPendingSupplements, CapabilityKey: "QUERY_PENDING_SUPPLEMENTS", DialogueActs: []DialogueAct{DialogueActAsk, DialogueActCommand}},
{Key: "regulation.query", Domain: "knowledge", Object: "regulation", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentQueryRegulations, CapabilityKey: "QUERY_REGULATIONS", DialogueActs: []DialogueAct{DialogueActAsk}}, {Key: "regulation.query", Domain: "knowledge", Object: "regulation", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentQueryRegulations, CapabilityKey: "QUERY_REGULATIONS", DialogueActs: []DialogueAct{DialogueActAsk}},
{Key: "case.query", Domain: "knowledge", Object: "artifact", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentQuerySimilarCases, CapabilityKey: "QUERY_SIMILAR_CASES", DialogueActs: []DialogueAct{DialogueActAsk}}, {Key: "case.query", Domain: "knowledge", Object: "artifact", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentQuerySimilarCases, CapabilityKey: "QUERY_SIMILAR_CASES", DialogueActs: []DialogueAct{DialogueActAsk}},
+2
View File
@@ -10,7 +10,9 @@ func TestDefaultRegistry_CoversAllLegacyIntents(t *testing.T) {
IntentDocGovClaim, IntentDocGovSubmitReview, IntentDocGovReviewApprove, IntentDocGovClaim, IntentDocGovSubmitReview, IntentDocGovReviewApprove,
IntentDocGovReviewReject, IntentDocGovFinalApprove, IntentDocGovFinalReject, IntentDocGovReviewReject, IntentDocGovFinalApprove, IntentDocGovFinalReject,
IntentDocGovProposalDecide, IntentDocGovProposalDecide,
IntentDocGovCommentAdd, IntentDocGovCommentResolve, IntentDocGovReopenRequest, IntentDocGovReopenApprove,
IntentQueryRegulations, IntentQuerySimilarCases, IntentQueryUserRights, IntentQueryRejectReason, IntentQueryRegulations, IntentQuerySimilarCases, IntentQueryUserRights, IntentQueryRejectReason,
IntentQueryPendingSupplements,
IntentConfirmAction, IntentExecuteStageAdvance, IntentSecurityForbidden, IntentConfirmAction, IntentExecuteStageAdvance, IntentSecurityForbidden,
IntentSwitchWorkflow, IntentTriggerWorkflow, IntentQueryWorkflowStatus, IntentSwitchWorkflow, IntentTriggerWorkflow, IntentQueryWorkflowStatus,
IntentQueryDocStatus, IntentHelpCapabilities, IntentUnsupportedAction, IntentQueryDocStatus, IntentHelpCapabilities, IntentUnsupportedAction,
+17 -9
View File
@@ -24,12 +24,17 @@ const (
IntentDocGovFinalApprove IntentType = "DOCGOV_FINAL_APPROVE" IntentDocGovFinalApprove IntentType = "DOCGOV_FINAL_APPROVE"
IntentDocGovFinalReject IntentType = "DOCGOV_FINAL_REJECT" IntentDocGovFinalReject IntentType = "DOCGOV_FINAL_REJECT"
IntentDocGovProposalDecide IntentType = "DOCGOV_PROPOSAL_DECIDE" IntentDocGovProposalDecide IntentType = "DOCGOV_PROPOSAL_DECIDE"
IntentDocGovCommentAdd IntentType = "DOCGOV_COMMENT_ADD"
IntentDocGovCommentResolve IntentType = "DOCGOV_COMMENT_RESOLVE"
IntentDocGovReopenRequest IntentType = "DOCGOV_REOPEN_REQUEST"
IntentDocGovReopenApprove IntentType = "DOCGOV_REOPEN_APPROVE"
// 决策与合规查询 // 决策与合规查询
IntentQueryRegulations IntentType = "QUERY_REGULATIONS" IntentQueryRegulations IntentType = "QUERY_REGULATIONS"
IntentQuerySimilarCases IntentType = "QUERY_SIMILAR_CASES" IntentQuerySimilarCases IntentType = "QUERY_SIMILAR_CASES"
IntentQueryUserRights IntentType = "QUERY_USER_RIGHTS" IntentQueryUserRights IntentType = "QUERY_USER_RIGHTS"
IntentQueryRejectReason IntentType = "QUERY_REJECT_REASON" // 查询受控文档驳回/退回原因 IntentQueryRejectReason IntentType = "QUERY_REJECT_REASON" // 查询受控文档驳回/退回原因
IntentQueryPendingSupplements IntentType = "QUERY_PENDING_SUPPLEMENTS"
// 协同拍板与阶段执行动作 // 协同拍板与阶段执行动作
IntentConfirmAction IntentType = "CONFIRM_ACTION" // 用户拍板确认交互卡片 IntentConfirmAction IntentType = "CONFIRM_ACTION" // 用户拍板确认交互卡片
@@ -145,13 +150,16 @@ const (
SlotQueryTopic = "query_topic" // 查询主题 (如 progress, risk, talk_script, quotation) SlotQueryTopic = "query_topic" // 查询主题 (如 progress, risk, talk_script, quotation)
// 法定文档治理与权限槽位 // 法定文档治理与权限槽位
SlotTargetDoc = "target_doc" SlotTargetDoc = "target_doc"
// TargetDocLatestUpload 是「刚刚/最近上传」指代的哨兵目标,由服务端解析为项目最新 CAS 资产。 // TargetDocLatestUpload 是「刚刚/最近上传」指代的哨兵目标,由服务端解析为项目最新 CAS 资产。
TargetDocLatestUpload = "@latest_upload" TargetDocLatestUpload = "@latest_upload"
SlotRejectReason = "reject_reason" SlotRejectReason = "reject_reason"
SlotTargetSection = "target_section" SlotTargetSection = "target_section"
SlotProposalAction = "proposal_action" SlotProposalAction = "proposal_action"
SlotForbiddenTarget = "forbidden_target" SlotForbiddenTarget = "forbidden_target"
SlotCommentContent = "comment_content"
SlotCommentType = "comment_type" // suggestion | issue | approval
SlotReopenReason = "reopen_reason"
// 路由层槽位 // 路由层槽位
SlotTaskBrief = "task_brief" // 自由任务简报(升级建议携带的用户原始诉求) SlotTaskBrief = "task_brief" // 自由任务简报(升级建议携带的用户原始诉求)
+45 -23
View File
@@ -9,37 +9,40 @@ import (
) )
const ( const (
DigestKindProposalRequired = "proposal_required" DigestKindProposalRequired = "proposal_required"
DigestKindUnclaimedEdit = "unclaimed_edit" DigestKindUnclaimedEdit = "unclaimed_edit"
SeverityActionRequired = "action_required" DigestKindPendingSupplements = "pending_supplements"
SeverityInfo = "info" DigestKindReviewRequested = "review_requested"
maxDigestItems = 20 DigestKindApprovalRequested = "approval_requested"
SeverityActionRequired = "action_required"
SeverityInfo = "info"
maxDigestItems = 20
) )
// DigestItem is one occurrence folded into a daily digest. // DigestItem is one occurrence folded into a daily digest.
type DigestItem struct { type DigestItem struct {
At time.Time `json:"at"` At time.Time `json:"at"`
ActorID int64 `json:"actor_id"` ActorID int64 `json:"actor_id"`
ActorName string `json:"actor_name"` ActorName string `json:"actor_name"`
Summary string `json:"summary"` Summary string `json:"summary"`
ProposalID string `json:"proposal_id,omitempty"` ProposalID string `json:"proposal_id,omitempty"`
ChangeCount int `json:"change_count,omitempty"` ChangeCount int `json:"change_count,omitempty"`
} }
// UpsertDigestParams drives ON CONFLICT DO UPDATE aggregation. // UpsertDigestParams drives ON CONFLICT DO UPDATE aggregation.
type UpsertDigestParams struct { type UpsertDigestParams struct {
UserID int64 UserID int64
ProjectID int64 ProjectID int64
DigestKind string DigestKind string
DocID string DocID string
DocTitle string DocTitle string
Severity string Severity string
ActorID int64 ActorID int64
ActorName string ActorName string
Summary string Summary string
ProposalID string ProposalID string
ChangeCount int ChangeCount int
WindowDate time.Time // truncated to date; zero → today UTC WindowDate time.Time // truncated to date; zero → today UTC
} }
// UpsertDocumentDigest aggregates same-day notifications per (user, kind, doc). // UpsertDocumentDigest aggregates same-day notifications per (user, kind, doc).
@@ -140,6 +143,12 @@ func typeForDigestKind(kind string) string {
return TypeDocProposalRequired return TypeDocProposalRequired
case DigestKindUnclaimedEdit: case DigestKindUnclaimedEdit:
return TypeDocUnclaimedEditApplied return TypeDocUnclaimedEditApplied
case DigestKindPendingSupplements:
return TypeDocPendingSupplements
case DigestKindReviewRequested:
return TypeDocReviewRequested
case DigestKindApprovalRequested:
return TypeDocApprovalRequested
default: default:
return kind return kind
} }
@@ -170,6 +179,19 @@ func digestCopy(p UpsertDigestParams, count int) (title, body string) {
} }
return fmt.Sprintf("《%s》今日已有 %d 次更新", titleDoc, count), return fmt.Sprintf("《%s》今日已有 %d 次更新", titleDoc, count),
fmt.Sprintf("最近一次:%s;累计 %d 次更新。本文档尚未被您认领。", actor, count) fmt.Sprintf("最近一次:%s;累计 %d 次更新。本文档尚未被您认领。", actor, count)
case DigestKindPendingSupplements:
if count <= 1 {
return fmt.Sprintf("《%s》有补充信息待消化", titleDoc),
fmt.Sprintf("%s 在文档锁定期间补充了「%s」。文档已回编制期,请审阅并消化积压补充。", actor, summary)
}
return fmt.Sprintf("《%s》累计 %d 条补充待消化", titleDoc, count),
fmt.Sprintf("最近一次:%s。文档已回编制期,请逐条审阅积压补充。", actor)
case DigestKindReviewRequested:
return fmt.Sprintf("《%s》待您审核", titleDoc),
fmt.Sprintf("%s 已送审,请审核本文档。", actor)
case DigestKindApprovalRequested:
return fmt.Sprintf("《%s》待您批准", titleDoc),
fmt.Sprintf("%s 审核已通过,请批准本文档。", actor)
default: default:
return titleDoc, summary return titleDoc, summary
} }
+15 -4
View File
@@ -14,16 +14,27 @@ const (
EventDocHumanRevisionConflict = "document.human_revision_conflict" EventDocHumanRevisionConflict = "document.human_revision_conflict"
EventDocDownstreamImpact = "document.downstream_impact_detected" EventDocDownstreamImpact = "document.downstream_impact_detected"
EventDocChangeReportDegraded = "document.change_report_degraded" EventDocChangeReportDegraded = "document.change_report_degraded"
EventDocProposalRequired = "document.proposal_required" EventDocProposalRequired = "document.proposal_required"
EventDocUnclaimedEditApplied = "document.unclaimed_edit_applied" EventDocUnclaimedEditApplied = "document.unclaimed_edit_applied"
EventDocReviewRequested = "document.review_requested"
EventDocApprovalRequested = "document.approval_requested"
EventDocRejected = "document.rejected"
EventDocReopened = "document.reopened"
EventDocPendingSupplements = "document.pending_supplements"
EventDocApproved = "document.approved"
TypeDocAgentCandidateReady = "document_agent_candidate_ready" TypeDocAgentCandidateReady = "document_agent_candidate_ready"
TypeDocAgentVersionCommitted = "document_agent_version_committed" TypeDocAgentVersionCommitted = "document_agent_version_committed"
TypeDocDownstreamImpact = "document_downstream_impact" TypeDocDownstreamImpact = "document_downstream_impact"
TypeDocChangeReportDegraded = "document_change_report_degraded" TypeDocChangeReportDegraded = "document_change_report_degraded"
TypeDocApproved = "document_approved" TypeDocApproved = "document_approved"
TypeDocProposalRequired = "document_proposal_required" TypeDocProposalRequired = "document_proposal_required"
TypeDocUnclaimedEditApplied = "document_unclaimed_edit_applied" TypeDocUnclaimedEditApplied = "document_unclaimed_edit_applied"
TypeDocReviewRequested = "document_review_requested"
TypeDocApprovalRequested = "document_approval_requested"
TypeDocRejected = "document_rejected"
TypeDocReopened = "document_reopened"
TypeDocPendingSupplements = "document_pending_supplements"
) )
// DocumentEventPayload is a minimal, permission-safe notification body (no markdown excerpts). // DocumentEventPayload is a minimal, permission-safe notification body (no markdown excerpts).
@@ -34,7 +34,7 @@ func TestChatStream_GetMessageQueryRejected(t *testing.T) {
} }
// TestChatStream_GetWithoutMessageNotLeak 无 message 的 GET 不触发 B11 分支 // TestChatStream_GetWithoutMessageNotLeak 无 message 的 GET 不触发 B11 分支
//(进入后续 message cannot be empty 的统一拒绝,同样不产生 SSE 头)。 // (进入后续 message cannot be empty 的统一拒绝,同样不产生 SSE 头)。
func TestChatStream_GetWithoutMessageNotLeak(t *testing.T) { func TestChatStream_GetWithoutMessageNotLeak(t *testing.T) {
cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"} cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}
svc := bizdev.MustNewService(t, cfg) svc := bizdev.MustNewService(t, cfg)
+42
View File
@@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"chat-agent-system/internal/app/docflow" "chat-agent-system/internal/app/docflow"
@@ -383,6 +384,47 @@ func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request)
writeJSONResponse(w, http.StatusOK, comment) writeJSONResponse(w, http.StatusOK, comment)
} }
// handleDocGovResolveComment marks a comment resolved or reopens it.
func (s *Server) handleDocGovResolveComment(w http.ResponseWriter, r *http.Request) {
if s.gov() == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
commentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || commentID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, "invalid comment id")
return
}
var req struct {
DocID string `json:"doc_id"`
Resolve *bool `json:"resolve"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
resolve := true
if req.Resolve != nil {
resolve = *req.Resolve
}
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
docID := strings.TrimSpace(req.DocID)
if docID == "" {
writeSafeError(w, r, http.StatusBadRequest, "doc_id required")
return
}
if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok {
return
}
role := s.resolveDocGovRole(r.Context(), docID, user)
comment, err := s.gov().ResolveComment(r.Context(), commentID, user.ID, role, resolve)
if err != nil {
writeDocGovError(w, err)
return
}
writeJSONResponse(w, http.StatusOK, comment)
}
// handleDocGovListAuditLogs 查询文档全生命周期审计轨迹流 // handleDocGovListAuditLogs 查询文档全生命周期审计轨迹流
func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Request) {
docID := r.URL.Query().Get("doc_id") docID := r.URL.Query().Get("doc_id")
+11 -11
View File
@@ -43,17 +43,17 @@ func (s *Server) handleListProjectNotifications(w http.ResponseWriter, r *http.R
// userNotificationView is the all-scope inbox row (includes severity / digest when migrated). // userNotificationView is the all-scope inbox row (includes severity / digest when migrated).
type userNotificationView struct { type userNotificationView struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
ProjectID int64 `json:"project_id"` ProjectID int64 `json:"project_id"`
EventID int64 `json:"event_id,omitempty"` EventID int64 `json:"event_id,omitempty"`
Type string `json:"type"` Type string `json:"type"`
Title string `json:"title"` Title string `json:"title"`
Body string `json:"body"` Body string `json:"body"`
Status string `json:"status"` Status string `json:"status"`
Severity string `json:"severity,omitempty"` Severity string `json:"severity,omitempty"`
DigestID int64 `json:"digest_id,omitempty"` DigestID int64 `json:"digest_id,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Payload map[string]any `json:"payload,omitempty"` Payload map[string]any `json:"payload,omitempty"`
} }
+22 -22
View File
@@ -30,28 +30,28 @@ import (
// 生产路径恒为空。显式置 nil 用 ptr 形(authorizerPtr 等)以区分「未设置」与「设为 nil」。 // 生产路径恒为空。显式置 nil 用 ptr 形(authorizerPtr 等)以区分「未设置」与「设为 nil」。
type serverTestOverrides struct { type serverTestOverrides struct {
svc *bizdev.Service svc *bizdev.Service
pwSvc *projectworkflow.Service pwSvc *projectworkflow.Service
engine *coordinator.CoordinatorEngine engine *coordinator.CoordinatorEngine
authSvc *auth.Service authSvc *auth.Service
taskRuns *taskrun.Manager taskRuns *taskrun.Manager
authorizer *authz.Authorizer authorizer *authz.Authorizer
authzSvc *authz.Service authzSvc *authz.Service
wfDefSvc *workflowdef.Service wfDefSvc *workflowdef.Service
instSvc *projectinstance.Service instSvc *projectinstance.Service
notifySvc *notification.Service notifySvc *notification.Service
resolver *workspace.Resolver resolver *workspace.Resolver
wsCap *workspace.Capability wsCap *workspace.Capability
cmds *taskrun.Commands cmds *taskrun.Commands
stageflow *stageflow.UseCase stageflow *stageflow.UseCase
gov *docgov.GovernanceEngine gov *docgov.GovernanceEngine
rev *docrevision.Service rev *docrevision.Service
ctxMgr *ctxmgr.ContextManager ctxMgr *ctxmgr.ContextManager
cas *storage.CAS cas *storage.CAS
freeDoc *freedoc.Service freeDoc *freedoc.Service
kb *kb.Client kb *kb.Client
sysadmin *sysadmin.Service sysadmin *sysadmin.Service
maintenance *maintenance.Service maintenance *maintenance.Service
// 显式置 nil 的位标记(区分「未设置」与「设为 nil」,fail-closed 测试用)。 // 显式置 nil 的位标记(区分「未设置」与「设为 nil」,fail-closed 测试用)。
authorizerNil bool authorizerNil bool
+18 -14
View File
@@ -34,10 +34,10 @@ import (
// Pool 与领域服务装配归属 app.Runtime;本结构仅保留 HTTP 适配所需句柄。 // Pool 与领域服务装配归属 app.Runtime;本结构仅保留 HTTP 适配所需句柄。
// 残留具体领域字段见 short-term 任务记录(增量收口,本批先去掉 *pgxpool.Pool)。 // 残留具体领域字段见 short-term 任务记录(增量收口,本批先去掉 *pgxpool.Pool)。
type Server struct { type Server struct {
rt *app.Runtime rt *app.Runtime
cfg *config.Config cfg *config.Config
sessions map[string]*chat.Session sessions map[string]*chat.Session
sessionsMu sync.RWMutex sessionsMu sync.RWMutex
supplementImpact SupplementImpactService supplementImpact SupplementImpactService
// intentPlansOverride 仅测试注入用(SetIntentPlansForTest);为空回落 rt.IntentPlans。 // intentPlansOverride 仅测试注入用(SetIntentPlansForTest);为空回落 rt.IntentPlans。
intentPlansOverride intentflow.Service intentPlansOverride intentflow.Service
@@ -48,12 +48,12 @@ type Server struct {
reminderLoop backgroundLoop reminderLoop backgroundLoop
vaultLoop backgroundLoop vaultLoop backgroundLoop
chatTurnSweepLoop backgroundLoop chatTurnSweepLoop backgroundLoop
chatLog chatflow.ChatLog chatLog chatflow.ChatLog
assembler chatflow.ContextAssembler assembler chatflow.ContextAssembler
listener net.Listener listener net.Listener
lastMaintToken string lastMaintToken string
wsReg *workspace.FileRegistry wsReg *workspace.FileRegistry
httpServer *http.Server httpServer *http.Server
planAccessFn func(ctx context.Context, projectID, userID int64) error planAccessFn func(ctx context.Context, projectID, userID int64) error
planSessionOwnerFn func(ctx context.Context, sessionID string) (int64, error) planSessionOwnerFn func(ctx context.Context, sessionID string) (int64, error)
@@ -83,10 +83,10 @@ func NewFromRuntime(rt *app.Runtime) *Server {
panic("server: nil runtime") panic("server: nil runtime")
} }
s := &Server{ s := &Server{
rt: rt, rt: rt,
cfg: rt.Cfg, cfg: rt.Cfg,
sessions: make(map[string]*chat.Session), sessions: make(map[string]*chat.Session),
wsReg: workspaceRegistryFor(rt.Cfg), wsReg: workspaceRegistryFor(rt.Cfg),
// 登录/注册限流:10 次 / 5 分钟 / (IP+username) 滑动窗口 // 登录/注册限流:10 次 / 5 分钟 / (IP+username) 滑动窗口
loginLimiter: newLoginRateLimiter(10, 5*time.Minute), loginLimiter: newLoginRateLimiter(10, 5*time.Minute),
} }
@@ -288,6 +288,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("POST /api/v1/docgov/proposal/decide", s.handleDocGovDecideProposal) mux.HandleFunc("POST /api/v1/docgov/proposal/decide", s.handleDocGovDecideProposal)
mux.HandleFunc("GET /api/v1/docgov/comments", s.handleDocGovListComments) mux.HandleFunc("GET /api/v1/docgov/comments", s.handleDocGovListComments)
mux.HandleFunc("POST /api/v1/docgov/comments", s.handleDocGovAddComment) mux.HandleFunc("POST /api/v1/docgov/comments", s.handleDocGovAddComment)
mux.HandleFunc("POST /api/v1/docgov/comments/{id}/resolve", s.handleDocGovResolveComment)
mux.HandleFunc("GET /api/v1/docgov/audit-logs", s.handleDocGovListAuditLogs) mux.HandleFunc("GET /api/v1/docgov/audit-logs", s.handleDocGovListAuditLogs)
mux.HandleFunc("POST /api/v1/docgov/content", s.handleDocGovSaveContent) mux.HandleFunc("POST /api/v1/docgov/content", s.handleDocGovSaveContent)
mux.HandleFunc("POST /api/v1/docgov/lease/acquire", s.handleDocGovAcquireLease) mux.HandleFunc("POST /api/v1/docgov/lease/acquire", s.handleDocGovAcquireLease)
@@ -618,6 +619,9 @@ func (s *Server) getOrCreateSession(
Workflow: adaptChatWorkflow(s.pwSvc()), Workflow: adaptChatWorkflow(s.pwSvc()),
Environment: env, Environment: env,
} }
if s.rt != nil {
sess.Actions.Reopens = s.rt.ChatReopens
}
if s.vaultIngest != nil { if s.vaultIngest != nil {
ingest := s.vaultIngest ingest := s.vaultIngest
sess.OnFinalArtifact = func(ctx context.Context, projectID int64, logical, eventID string, msgID int64, policy string, payload []byte) { sess.OnFinalArtifact = func(ctx context.Context, projectID int64, logical, eventID string, msgID int64, policy string, payload []byte) {
+2 -2
View File
@@ -50,8 +50,8 @@ func mapSupplementBatch(b *supplementimpact.Batch) *SupplementImpactBatchView {
out.Items = append(out.Items, SupplementImpactItemView{ out.Items = append(out.Items, SupplementImpactItemView{
DocID: it.DocID, Title: it.Title, ItemKey: it.ItemKey, ImpactType: it.ImpactType, DocID: it.DocID, Title: it.Title, ItemKey: it.ItemKey, ImpactType: it.ImpactType,
Depth: it.Depth, Reason: it.Reason, AuthorClaimedSnapshot: it.AuthorClaimedSnapshot, Depth: it.Depth, Reason: it.Reason, AuthorClaimedSnapshot: it.AuthorClaimedSnapshot,
BusySnapshot: it.BusySnapshot, ChangeSummary: it.ChangeSummary, Selected: it.Selected, BusySnapshot: it.BusySnapshot, LockedSnapshot: it.LockedSnapshot, ChangeSummary: it.ChangeSummary,
Outcome: it.Outcome, ProposalID: it.ProposalID, Selected: it.Selected, Outcome: it.Outcome, ProposalID: it.ProposalID,
}) })
} }
return out return out
@@ -42,6 +42,7 @@ type SupplementImpactItemView struct {
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"` AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"`
BusySnapshot bool `json:"busy_snapshot"` BusySnapshot bool `json:"busy_snapshot"`
LockedSnapshot bool `json:"locked_snapshot"`
ChangeSummary string `json:"change_summary,omitempty"` ChangeSummary string `json:"change_summary,omitempty"`
Selected bool `json:"selected"` Selected bool `json:"selected"`
Outcome string `json:"outcome,omitempty"` Outcome string `json:"outcome,omitempty"`
+5 -5
View File
@@ -455,11 +455,11 @@ func (s *Server) preflightTaskDocumentBusy(ctx context.Context, req createTaskRu
expiresAt = *doc.LeaseExpiresAt expiresAt = *doc.LeaseExpiresAt
} }
display := docgov.LeaseHolderDisplay(doc.LeaseHolderType, doc.LeaseHolderID, "") display := docgov.LeaseHolderDisplay(doc.LeaseHolderType, doc.LeaseHolderID, "")
exp := time.Time{} exp := time.Time{}
if doc.LeaseExpiresAt != nil { if doc.LeaseExpiresAt != nil {
exp = *doc.LeaseExpiresAt exp = *doc.LeaseExpiresAt
} }
reason := docgov.FormatDocumentBusyReason(doc.Title, display, exp) reason := docgov.FormatDocumentBusyReason(doc.Title, display, exp)
return &docgov.DocumentBusyError{ return &docgov.DocumentBusyError{
DocID: doc.DocID, DocID: doc.DocID,
HolderType: doc.LeaseHolderType, HolderType: doc.LeaseHolderType,
+49 -5
View File
@@ -1,10 +1,13 @@
package server package server
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"chat-agent-system/internal/auth" "chat-agent-system/internal/auth"
"chat-agent-system/internal/traceimpact" "chat-agent-system/internal/traceimpact"
@@ -148,11 +151,6 @@ func (s *Server) handleProjectTraceReopenApprove(w http.ResponseWriter, r *http.
if !ok { if !ok {
return return
} }
creator, _ := s.pwSvc().IsProjectCreator(r.Context(), projectID, user.ID)
if !creator {
writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人可批准再打开"}`)
return
}
requestID, err := strconv.ParseInt(r.PathValue("requestID"), 10, 64) requestID, err := strconv.ParseInt(r.PathValue("requestID"), 10, 64)
if err != nil || requestID <= 0 { if err != nil || requestID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid request id"}`) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid request id"}`)
@@ -163,6 +161,11 @@ func (s *Server) handleProjectTraceReopenApprove(w http.ResponseWriter, r *http.
writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_NOT_FOUND","message":"申请不属于该项目"}`) writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_NOT_FOUND","message":"申请不属于该项目"}`)
return return
} }
creator, _ := s.pwSvc().IsProjectCreator(r.Context(), projectID, user.ID)
if !s.callerMayApproveReopen(r.Context(), projectID, user.ID, creator, existing) {
writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"仅原批准人或(批准人缺位时)项目创建人可批准再打开"}`)
return
}
stored, err := s.rt.Trace.ApproveReopen(r.Context(), requestID, user.ID) stored, err := s.rt.Trace.ApproveReopen(r.Context(), requestID, user.ID)
if err != nil { if err != nil {
writeJSONResponse(w, http.StatusConflict, map[string]string{"error": "TRACE_REOPEN_FAILED", "message": err.Error()}) writeJSONResponse(w, http.StatusConflict, map[string]string{"error": "TRACE_REOPEN_FAILED", "message": err.Error()})
@@ -258,6 +261,47 @@ func (s *Server) callerMayRequestReopen(r *http.Request, projectID, userID int64
return traceimpact.MayRequestReopen(userID, false, req.DocTasks, seats) return traceimpact.MayRequestReopen(userID, false, req.DocTasks, seats)
} }
// callerMayApproveReopen: document ApproverID first; if all approvers are missing/exited, fall back to project creator.
func (s *Server) callerMayApproveReopen(ctx context.Context, projectID, userID int64, creator bool, req traceimpact.ReopenRequest) bool {
hasActiveApprover := false
userIsApprover := false
if s.gov() != nil {
for _, taskKey := range req.DocTasks {
docID := fmt.Sprintf("doc_proj%d_%s", projectID, taskKey)
doc, err := s.gov().GetDocument(ctx, docID)
if err != nil || doc == nil || doc.ApproverID <= 0 {
continue
}
if !s.projectMemberActive(ctx, projectID, doc.ApproverID) {
continue
}
hasActiveApprover = true
if doc.ApproverID == userID {
userIsApprover = true
}
}
}
if userIsApprover {
return true
}
if !hasActiveApprover && creator {
return true
}
return false
}
func (s *Server) projectMemberActive(ctx context.Context, projectID, userID int64) bool {
if s.authorizer() == nil || userID <= 0 {
return userID > 0
}
rec, err := s.authorizer().GetProjectMember(ctx, projectID, userID)
if err != nil || rec == nil {
return false
}
status := strings.TrimSpace(rec.Status)
return status == "" || status == "active"
}
func (s *Server) traceDocVisible(r *http.Request, projectID, userID int64, docID string) bool { func (s *Server) traceDocVisible(r *http.Request, projectID, userID int64, docID string) bool {
if docID == "" || docID == "chat" { if docID == "" || docID == "chat" {
return true return true
+16 -15
View File
@@ -8,19 +8,19 @@ import (
// HTTPBatchView is the JSON shape expected by server supplement-impact handlers. // HTTPBatchView is the JSON shape expected by server supplement-impact handlers.
type HTTPBatchView struct { type HTTPBatchView struct {
BatchID string `json:"batch_id"` BatchID string `json:"batch_id"`
ProjectID int64 `json:"project_id"` ProjectID int64 `json:"project_id"`
SessionID string `json:"session_id,omitempty"` SessionID string `json:"session_id,omitempty"`
InitiatorUserID int64 `json:"initiator_user_id"` InitiatorUserID int64 `json:"initiator_user_id"`
SourceKind string `json:"source_kind,omitempty"` SourceKind string `json:"source_kind,omitempty"`
SourceSummary string `json:"source_summary,omitempty"` SourceSummary string `json:"source_summary,omitempty"`
CoverageStatus string `json:"coverage_status,omitempty"` CoverageStatus string `json:"coverage_status,omitempty"`
Status string `json:"status"` Status string `json:"status"`
DecisionNonce string `json:"decision_nonce"` DecisionNonce string `json:"decision_nonce"`
DecisionDigest string `json:"decision_digest,omitempty"` DecisionDigest string `json:"decision_digest,omitempty"`
Revision int64 `json:"revision"` Revision int64 `json:"revision"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
Items []HTTPItemView `json:"items,omitempty"` Items []HTTPItemView `json:"items,omitempty"`
} }
type HTTPItemView struct { type HTTPItemView struct {
@@ -32,6 +32,7 @@ type HTTPItemView struct {
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"` AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"`
BusySnapshot bool `json:"busy_snapshot"` BusySnapshot bool `json:"busy_snapshot"`
LockedSnapshot bool `json:"locked_snapshot"`
ChangeSummary string `json:"change_summary,omitempty"` ChangeSummary string `json:"change_summary,omitempty"`
Selected bool `json:"selected"` Selected bool `json:"selected"`
Outcome string `json:"outcome,omitempty"` Outcome string `json:"outcome,omitempty"`
@@ -90,8 +91,8 @@ func toHTTPView(b *Batch) *HTTPBatchView {
v.Items = append(v.Items, HTTPItemView{ v.Items = append(v.Items, HTTPItemView{
DocID: it.DocID, Title: it.Title, ItemKey: it.ItemKey, ImpactType: it.ImpactType, DocID: it.DocID, Title: it.Title, ItemKey: it.ItemKey, ImpactType: it.ImpactType,
Depth: it.Depth, Reason: it.Reason, AuthorClaimedSnapshot: it.AuthorClaimedSnapshot, Depth: it.Depth, Reason: it.Reason, AuthorClaimedSnapshot: it.AuthorClaimedSnapshot,
BusySnapshot: it.BusySnapshot, ChangeSummary: it.ChangeSummary, Selected: it.Selected, BusySnapshot: it.BusySnapshot, LockedSnapshot: it.LockedSnapshot, ChangeSummary: it.ChangeSummary,
Outcome: it.Outcome, ProposalID: it.ProposalID, Selected: it.Selected, Outcome: it.Outcome, ProposalID: it.ProposalID,
}) })
} }
return v return v
+5 -5
View File
@@ -45,12 +45,12 @@ func (p *PGStore) InsertBatch(ctx context.Context, b *Batch) error {
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
INSERT INTO supplement_impact_items ( INSERT INTO supplement_impact_items (
batch_id, doc_id, title, item_key, impact_type, depth, reason, batch_id, doc_id, title, item_key, impact_type, depth, reason,
author_claimed_snapshot, author_id_snapshot, busy_snapshot, change_summary, author_claimed_snapshot, author_id_snapshot, busy_snapshot, locked_snapshot, change_summary,
selected, outcome, proposal_id, run_id selected, outcome, proposal_id, run_id
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
RETURNING id RETURNING id
`, it.BatchID, it.DocID, it.Title, it.ItemKey, it.ImpactType, it.Depth, it.Reason, `, it.BatchID, it.DocID, it.Title, it.ItemKey, it.ImpactType, it.Depth, it.Reason,
it.AuthorClaimedSnapshot, it.AuthorIDSnapshot, it.BusySnapshot, it.ChangeSummary, it.AuthorClaimedSnapshot, it.AuthorIDSnapshot, it.BusySnapshot, it.LockedSnapshot, it.ChangeSummary,
it.Selected, it.Outcome, it.ProposalID, it.RunID).Scan(&it.ID) it.Selected, it.Outcome, it.ProposalID, it.RunID).Scan(&it.ID)
if err != nil { if err != nil {
return err return err
@@ -77,7 +77,7 @@ func (p *PGStore) GetBatch(ctx context.Context, batchID string) (*Batch, error)
} }
rows, err := p.pool.Query(ctx, ` rows, err := p.pool.Query(ctx, `
SELECT id, batch_id, doc_id, title, item_key, impact_type, depth, reason, SELECT id, batch_id, doc_id, title, item_key, impact_type, depth, reason,
author_claimed_snapshot, author_id_snapshot, busy_snapshot, change_summary, author_claimed_snapshot, author_id_snapshot, busy_snapshot, locked_snapshot, change_summary,
selected, outcome, proposal_id, run_id selected, outcome, proposal_id, run_id
FROM supplement_impact_items WHERE batch_id = $1 ORDER BY id FROM supplement_impact_items WHERE batch_id = $1 ORDER BY id
`, batchID) `, batchID)
@@ -89,7 +89,7 @@ func (p *PGStore) GetBatch(ctx context.Context, batchID string) (*Batch, error)
var it Item var it Item
if err := rows.Scan( if err := rows.Scan(
&it.ID, &it.BatchID, &it.DocID, &it.Title, &it.ItemKey, &it.ImpactType, &it.Depth, &it.Reason, &it.ID, &it.BatchID, &it.DocID, &it.Title, &it.ItemKey, &it.ImpactType, &it.Depth, &it.Reason,
&it.AuthorClaimedSnapshot, &it.AuthorIDSnapshot, &it.BusySnapshot, &it.ChangeSummary, &it.AuthorClaimedSnapshot, &it.AuthorIDSnapshot, &it.BusySnapshot, &it.LockedSnapshot, &it.ChangeSummary,
&it.Selected, &it.Outcome, &it.ProposalID, &it.RunID, &it.Selected, &it.Outcome, &it.ProposalID, &it.RunID,
); err != nil { ); err != nil {
return nil, err return nil, err
+85 -8
View File
@@ -41,14 +41,20 @@ type Proposer interface {
ProposeModification(ctx context.Context, docID, triggeredBy, changes, impact, risk string) (*docgov.ChangeProposal, error) ProposeModification(ctx context.Context, docID, triggeredBy, changes, impact, risk string) (*docgov.ChangeProposal, error)
} }
// PendingLedger records supplements deferred while a document is lifecycle-locked.
type PendingLedger interface {
RecordPendingSupplement(ctx context.Context, in docgov.RecordPendingSupplementInput) (*docgov.PendingSupplement, error)
}
// Service orchestrates supplement → impact → confirm → split execution. // Service orchestrates supplement → impact → confirm → split execution.
type Service struct { type Service struct {
Store Store Store Store
Docs DocLookup Docs DocLookup
Analyze Analyzer Analyze Analyzer
Notify Notifier Notify Notifier
Propose Proposer Propose Proposer
Now func() time.Time Pending PendingLedger
Now func() time.Time
} }
func (s *Service) now() time.Time { func (s *Service) now() time.Time {
@@ -114,7 +120,11 @@ func (s *Service) CreateFromSupplement(ctx context.Context, projectID, userID in
item.Title = doc.Title item.Title = doc.Title
item.AuthorClaimedSnapshot = doc.AuthorClaimed item.AuthorClaimedSnapshot = doc.AuthorClaimed
item.AuthorIDSnapshot = doc.AuthorID item.AuthorIDSnapshot = doc.AuthorID
item.ReviewerIDSnapshot = doc.ReviewerID
item.ApproverIDSnapshot = doc.ApproverID
item.CurrentStateSnapshot = string(doc.CurrentState)
item.BusySnapshot = doc.HasActiveLease(now) item.BusySnapshot = doc.HasActiveLease(now)
item.LockedSnapshot = docIsLifecycleLocked(doc)
} }
} }
b.Items = append(b.Items, item) b.Items = append(b.Items, item)
@@ -126,6 +136,16 @@ func (s *Service) CreateFromSupplement(ctx context.Context, projectID, userID in
return b, nil return b, nil
} }
func docIsLifecycleLocked(doc *docgov.DocumentRecord) bool {
if doc == nil {
return false
}
if doc.IsLocked {
return true
}
return doc.CurrentState != "" && doc.CurrentState != docgov.StateDrafting
}
// Decide confirms or cancels a batch; on confirm, executes selected items. // Decide confirms or cancels a batch; on confirm, executes selected items.
func (s *Service) Decide(ctx context.Context, cmd DecisionCommand) (*Batch, error) { func (s *Service) Decide(ctx context.Context, cmd DecisionCommand) (*Batch, error) {
b, err := s.Store.GetBatch(ctx, cmd.BatchID) b, err := s.Store.GetBatch(ctx, cmd.BatchID)
@@ -164,6 +184,12 @@ func (s *Service) Decide(ctx context.Context, cmd DecisionCommand) (*Batch, erro
if !selected[it.DocID] { if !selected[it.DocID] {
continue continue
} }
if it.LockedSnapshot {
_ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeSkippedLocked, "", "")
s.recordPendingLocked(ctx, b, it)
s.notifyLockedDutyHolder(ctx, b, it)
continue
}
if it.BusySnapshot { if it.BusySnapshot {
_ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeSkippedBusy, "", "") _ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeSkippedBusy, "", "")
continue continue
@@ -187,6 +213,57 @@ func (s *Service) Decide(ctx context.Context, cmd DecisionCommand) (*Batch, erro
return s.Store.GetBatch(ctx, cmd.BatchID) return s.Store.GetBatch(ctx, cmd.BatchID)
} }
func (s *Service) recordPendingLocked(ctx context.Context, b *Batch, it *Item) {
if s == nil || s.Pending == nil || b == nil || it == nil {
return
}
_, _ = s.Pending.RecordPendingSupplement(ctx, docgov.RecordPendingSupplementInput{
DocID: it.DocID,
ProjectID: b.ProjectID,
InitiatorUserID: b.InitiatorUserID,
SourceSummary: b.SourceSummary,
ImpactReason: it.Reason,
BatchID: b.BatchID,
})
}
func dutyHolderForLockedItem(it *Item) int64 {
if it == nil {
return 0
}
switch it.CurrentStateSnapshot {
case string(docgov.StateReviewing):
if it.ReviewerIDSnapshot > 0 {
return it.ReviewerIDSnapshot
}
case string(docgov.StateApproving), string(docgov.StateApproved):
if it.ApproverIDSnapshot > 0 {
return it.ApproverIDSnapshot
}
}
if it.AuthorIDSnapshot > 0 {
return it.AuthorIDSnapshot
}
return 0
}
func (s *Service) notifyLockedDutyHolder(ctx context.Context, b *Batch, it *Item) {
if s == nil || s.Notify == nil || b == nil || it == nil {
return
}
userID := dutyHolderForLockedItem(it)
if userID <= 0 {
return
}
_ = s.Notify.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{
UserID: userID, ProjectID: b.ProjectID,
DigestKind: notification.DigestKindPendingSupplements, DocID: it.DocID, DocTitle: it.Title,
Severity: notification.SeverityInfo,
ActorID: b.InitiatorUserID, ActorName: fmt.Sprintf("user:%d", b.InitiatorUserID),
Summary: b.SourceSummary,
})
}
func (s *Service) executeItem(ctx context.Context, b *Batch, it *Item) error { func (s *Service) executeItem(ctx context.Context, b *Batch, it *Item) error {
if it.AuthorClaimedSnapshot { if it.AuthorClaimedSnapshot {
if s.Propose == nil { if s.Propose == nil {
@@ -203,7 +280,7 @@ func (s *Service) executeItem(ctx context.Context, b *Batch, it *Item) error {
UserID: it.AuthorIDSnapshot, ProjectID: b.ProjectID, UserID: it.AuthorIDSnapshot, ProjectID: b.ProjectID,
DigestKind: notification.DigestKindProposalRequired, DocID: it.DocID, DocTitle: it.Title, DigestKind: notification.DigestKindProposalRequired, DocID: it.DocID, DocTitle: it.Title,
Severity: notification.SeverityActionRequired, Severity: notification.SeverityActionRequired,
ActorID: b.InitiatorUserID, ActorName: fmt.Sprintf("user:%d", b.InitiatorUserID), ActorID: b.InitiatorUserID, ActorName: fmt.Sprintf("user:%d", b.InitiatorUserID),
Summary: b.SourceSummary, ProposalID: prop.ProposalID, Summary: b.SourceSummary, ProposalID: prop.ProposalID,
}) })
} }
@@ -216,7 +293,7 @@ func (s *Service) executeItem(ctx context.Context, b *Batch, it *Item) error {
UserID: it.AuthorIDSnapshot, ProjectID: b.ProjectID, UserID: it.AuthorIDSnapshot, ProjectID: b.ProjectID,
DigestKind: notification.DigestKindUnclaimedEdit, DocID: it.DocID, DocTitle: it.Title, DigestKind: notification.DigestKindUnclaimedEdit, DocID: it.DocID, DocTitle: it.Title,
Severity: notification.SeverityInfo, Severity: notification.SeverityInfo,
ActorID: b.InitiatorUserID, ActorName: fmt.Sprintf("user:%d", b.InitiatorUserID), ActorID: b.InitiatorUserID, ActorName: fmt.Sprintf("user:%d", b.InitiatorUserID),
Summary: b.SourceSummary, Summary: b.SourceSummary,
}) })
} }
+73 -2
View File
@@ -17,7 +17,9 @@ func (s stubAnalyzer) Analyze(_ context.Context, _ int64, _ traceimpact.DocRef,
return s.report, nil return s.report, nil
} }
type stubDocs struct{ docs map[string]*docgov.DocumentRecord } type stubDocs struct {
docs map[string]*docgov.DocumentRecord
}
func (s stubDocs) GetDocument(_ context.Context, id string) (*docgov.DocumentRecord, error) { func (s stubDocs) GetDocument(_ context.Context, id string) (*docgov.DocumentRecord, error) {
return s.docs[id], nil return s.docs[id], nil
@@ -81,7 +83,7 @@ func TestDecideClaimedProducesProposal(t *testing.T) {
}, },
}}, }},
Docs: stubDocs{docs: map[string]*docgov.DocumentRecord{ Docs: stubDocs{docs: map[string]*docgov.DocumentRecord{
"doc_a": {DocID: "doc_a", Title: "需求", AuthorID: 9, AuthorClaimed: true, AuthorClaimedAt: &claimedAt}, "doc_a": {DocID: "doc_a", Title: "需求", AuthorID: 9, AuthorClaimed: true, AuthorClaimedAt: &claimedAt, CurrentState: docgov.StateDrafting},
}}, }},
Propose: prop, Propose: prop,
Notify: notify, Notify: notify,
@@ -106,3 +108,72 @@ func TestDecideClaimedProducesProposal(t *testing.T) {
} }
} }
type stubPending struct {
n int
last docgov.RecordPendingSupplementInput
}
func (s *stubPending) RecordPendingSupplement(_ context.Context, in docgov.RecordPendingSupplementInput) (*docgov.PendingSupplement, error) {
s.n++
s.last = in
return &docgov.PendingSupplement{DocID: in.DocID, Status: docgov.PendingSupplementPending}, nil
}
type capturingNotify struct {
params []notification.UpsertDigestParams
}
func (s *capturingNotify) UpsertDocumentDigest(_ context.Context, p notification.UpsertDigestParams) error {
s.params = append(s.params, p)
return nil
}
func TestDecideLockedSkipsAndRecordsPending(t *testing.T) {
store := supplementimpact.NewMemStore()
pending := &stubPending{}
notify := &capturingNotify{}
svc := &supplementimpact.Service{
Store: store,
Analyze: stubAnalyzer{report: &traceimpact.Report{
CoverageStatus: "confirmed_impact",
Impacts: []traceimpact.Impact{
{DocID: "doc_locked", ItemKey: "UR-1", ImpactType: "confirmed_impact", Depth: 1, Reason: "locked hit"},
},
}},
Docs: stubDocs{docs: map[string]*docgov.DocumentRecord{
"doc_locked": {
DocID: "doc_locked", Title: "锁定文档", AuthorID: 1, ReviewerID: 7,
CurrentState: docgov.StateReviewing, IsLocked: true, AuthorClaimed: true,
},
}},
Pending: pending,
Notify: notify,
}
b, err := svc.CreateFromSupplement(context.Background(), 1, 2, "sess", "锁定期补充", "UR-1 变更")
if err != nil {
t.Fatal(err)
}
if len(b.Items) != 1 || !b.Items[0].LockedSnapshot || b.Items[0].BusySnapshot {
t.Fatalf("want locked snapshot without busy, got %+v", b.Items[0])
}
out, err := svc.Decide(context.Background(), supplementimpact.DecisionCommand{
BatchID: b.BatchID, ActorUserID: 2, Kind: "confirm",
SelectedDocIDs: []string{"doc_locked"}, DecisionNonce: b.DecisionNonce,
DecisionDigest: "d1", ExpectedRevision: b.Revision,
})
if err != nil {
t.Fatal(err)
}
if out.Items[0].Outcome != supplementimpact.OutcomeSkippedLocked {
t.Fatalf("want skipped_locked, got %s", out.Items[0].Outcome)
}
if pending.n != 1 || pending.last.DocID != "doc_locked" {
t.Fatalf("pending ledger not recorded: %+v", pending)
}
if len(notify.params) != 1 || notify.params[0].UserID != 7 {
t.Fatalf("want reviewer notified, got %+v", notify.params)
}
if notify.params[0].DigestKind != notification.DigestKindPendingSupplements {
t.Fatalf("want pending_supplements digest, got %s", notify.params[0].DigestKind)
}
}
+46 -42
View File
@@ -11,60 +11,64 @@ const (
StatusExpired = "expired" StatusExpired = "expired"
StatusCancelled = "cancelled" StatusCancelled = "cancelled"
OutcomeApplied = "applied" OutcomeApplied = "applied"
OutcomeProposalPending = "proposal_pending" OutcomeProposalPending = "proposal_pending"
OutcomeProposalRejected = "proposal_rejected" OutcomeProposalRejected = "proposal_rejected"
OutcomeSkippedBusy = "skipped_busy" OutcomeSkippedBusy = "skipped_busy"
OutcomeSkippedLocked = "skipped_locked" OutcomeSkippedLocked = "skipped_locked"
OutcomeFailed = "failed" OutcomeFailed = "failed"
BatchTTL = 24 * time.Hour BatchTTL = 24 * time.Hour
) )
// Batch is one supplement → impact analysis confirmation unit. // Batch is one supplement → impact analysis confirmation unit.
type Batch struct { type Batch struct {
BatchID string `json:"batch_id"` BatchID string `json:"batch_id"`
ProjectID int64 `json:"project_id"` ProjectID int64 `json:"project_id"`
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
InitiatorUserID int64 `json:"initiator_user_id"` InitiatorUserID int64 `json:"initiator_user_id"`
SourceKind string `json:"source_kind"` SourceKind string `json:"source_kind"`
SourceSummary string `json:"source_summary"` SourceSummary string `json:"source_summary"`
CoverageStatus string `json:"coverage_status"` CoverageStatus string `json:"coverage_status"`
Status string `json:"status"` Status string `json:"status"`
DecisionNonce string `json:"decision_nonce"` DecisionNonce string `json:"decision_nonce"`
DecisionDigest string `json:"decision_digest"` DecisionDigest string `json:"decision_digest"`
Revision int64 `json:"revision"` Revision int64 `json:"revision"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
Items []Item `json:"items,omitempty"` Items []Item `json:"items,omitempty"`
} }
// Item is one affected document inside a batch. // Item is one affected document inside a batch.
type Item struct { type Item struct {
ID int64 `json:"id"` ID int64 `json:"id"`
BatchID string `json:"batch_id"` BatchID string `json:"batch_id"`
DocID string `json:"doc_id"` DocID string `json:"doc_id"`
Title string `json:"title"` Title string `json:"title"`
ItemKey string `json:"item_key"` ItemKey string `json:"item_key"`
ImpactType string `json:"impact_type"` ImpactType string `json:"impact_type"`
Depth int `json:"depth"` Depth int `json:"depth"`
Reason string `json:"reason"` Reason string `json:"reason"`
AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"` AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"`
AuthorIDSnapshot int64 `json:"author_id_snapshot"` AuthorIDSnapshot int64 `json:"author_id_snapshot"`
BusySnapshot bool `json:"busy_snapshot"` BusySnapshot bool `json:"busy_snapshot"`
ChangeSummary string `json:"change_summary"` LockedSnapshot bool `json:"locked_snapshot"`
Selected bool `json:"selected"` ReviewerIDSnapshot int64 `json:"reviewer_id_snapshot,omitempty"`
Outcome string `json:"outcome"` ApproverIDSnapshot int64 `json:"approver_id_snapshot,omitempty"`
ProposalID string `json:"proposal_id"` CurrentStateSnapshot string `json:"current_state_snapshot,omitempty"`
RunID string `json:"run_id"` ChangeSummary string `json:"change_summary"`
Selected bool `json:"selected"`
Outcome string `json:"outcome"`
ProposalID string `json:"proposal_id"`
RunID string `json:"run_id"`
} }
// DecisionCommand is the confirm/cancel envelope (nonce+digest+revision CAS). // DecisionCommand is the confirm/cancel envelope (nonce+digest+revision CAS).
type DecisionCommand struct { type DecisionCommand struct {
BatchID string BatchID string
ActorUserID int64 ActorUserID int64
Kind string // confirm | cancel Kind string // confirm | cancel
SelectedDocIDs []string SelectedDocIDs []string
DecisionNonce string DecisionNonce string
DecisionDigest string DecisionDigest string
ExpectedRevision int64 ExpectedRevision int64
} }
+22
View File
@@ -222,6 +222,28 @@ func (p *pgStore) GetReopen(ctx context.Context, id int64) (ReopenRequest, error
return req, err return req, err
} }
func (p *pgStore) ListPendingReopens(ctx context.Context, projectID int64) ([]ReopenRequest, error) {
rows, err := p.pool.Query(ctx, `
SELECT id, project_id, instance_id, stage_key, doc_task_keys, reason, requested_by, status
FROM stage_reopen_requests
WHERE project_id = $1 AND status = $2
ORDER BY id ASC
`, projectID, ReopenPending)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ReopenRequest
for rows.Next() {
var req ReopenRequest
if err := rows.Scan(&req.ID, &req.ProjectID, &req.InstanceID, &req.StageKey, &req.DocTasks, &req.Reason, &req.RequestedBy, &req.Status); err != nil {
return nil, err
}
out = append(out, req)
}
return out, rows.Err()
}
func (p *pgStore) PutMark(ctx context.Context, mark RecheckMark) (RecheckMark, error) { func (p *pgStore) PutMark(ctx context.Context, mark RecheckMark) (RecheckMark, error) {
if mark.Status == "" { if mark.Status == "" {
mark.Status = MarkOpen mark.Status = MarkOpen
+11
View File
@@ -506,6 +506,17 @@ func (s *Service) GetReopen(ctx context.Context, id int64) (ReopenRequest, error
return s.store.GetReopen(ctx, id) return s.store.GetReopen(ctx, id)
} }
func (s *Service) ListPendingReopens(ctx context.Context, projectID int64) ([]ReopenRequest, error) {
if s == nil || s.store == nil {
return nil, nil
}
out, err := s.store.ListPendingReopens(ctx, projectID)
if err == nil && out == nil {
out = []ReopenRequest{}
}
return out, err
}
// ErrNeedsConfirm is returned when a revision proposal is requested before confirmation. // ErrNeedsConfirm is returned when a revision proposal is requested before confirmation.
var ErrNeedsConfirm = fmt.Errorf("traceimpact: confirmation required") var ErrNeedsConfirm = fmt.Errorf("traceimpact: confirmation required")
+13
View File
@@ -20,6 +20,7 @@ type Store interface {
ListDecisions(ctx context.Context, projectID int64) ([]Decision, error) ListDecisions(ctx context.Context, projectID int64) ([]Decision, error)
PutReopen(ctx context.Context, req ReopenRequest) (ReopenRequest, error) PutReopen(ctx context.Context, req ReopenRequest) (ReopenRequest, error)
GetReopen(ctx context.Context, id int64) (ReopenRequest, error) GetReopen(ctx context.Context, id int64) (ReopenRequest, error)
ListPendingReopens(ctx context.Context, projectID int64) ([]ReopenRequest, error)
PutMark(ctx context.Context, m RecheckMark) (RecheckMark, error) PutMark(ctx context.Context, m RecheckMark) (RecheckMark, error)
ListOpenMarks(ctx context.Context, projectID int64, stageKey string) ([]RecheckMark, error) ListOpenMarks(ctx context.Context, projectID int64, stageKey string) ([]RecheckMark, error)
AckMark(ctx context.Context, id, userID int64) error AckMark(ctx context.Context, id, userID int64) error
@@ -229,6 +230,18 @@ func (m *memStore) GetReopen(_ context.Context, id int64) (ReopenRequest, error)
return req, nil return req, nil
} }
func (m *memStore) ListPendingReopens(_ context.Context, projectID int64) ([]ReopenRequest, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []ReopenRequest
for _, req := range m.reopens {
if req.ProjectID == projectID && req.Status == ReopenPending {
out = append(out, req)
}
}
return out, nil
}
func (m *memStore) PutMark(_ context.Context, mark RecheckMark) (RecheckMark, error) { func (m *memStore) PutMark(_ context.Context, mark RecheckMark) (RecheckMark, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
+3
View File
@@ -1393,12 +1393,14 @@ export default function App() {
handleRejectReview, handleRejectReview,
handleApprove, handleApprove,
handleRejectApprove, handleRejectApprove,
handleRequestReopen,
} = useDocumentLifecycleActions({ } = useDocumentLifecycleActions({
docAST, docAST,
setDocAST, setDocAST,
activeDocId, activeDocId,
currentUser, currentUser,
activeSessionId, activeSessionId,
projectId: currentProjectId,
setDeliverables, setDeliverables,
setMessages, setMessages,
}); });
@@ -1698,6 +1700,7 @@ export default function App() {
handleRejectReview={handleRejectReview} handleRejectReview={handleRejectReview}
handleApprove={handleApprove} handleApprove={handleApprove}
handleRejectApprove={handleRejectApprove} handleRejectApprove={handleRejectApprove}
handleRequestReopen={handleRequestReopen}
editorSession={editorSession} editorSession={editorSession}
handleSaveBlockEdit={handleSaveBlockEdit} handleSaveBlockEdit={handleSaveBlockEdit}
closeBlockEditor={closeBlockEditor} closeBlockEditor={closeBlockEditor}
+42
View File
@@ -180,3 +180,45 @@ export function mapLeaseToAstEditLease(doc: {
expiresAt: doc.lease_expires_at, expiresAt: doc.lease_expires_at,
}; };
} }
export interface DocGovComment {
id: number;
doc_id: string;
user_id?: number;
user_name?: string;
user_role?: string;
target_section?: string;
comment_type?: string;
content: string;
status: string;
created_at?: string;
}
export async function listDocGovComments(docId: string): Promise<DocGovComment[]> {
const res = await apiFetch(
`${API_BASE}/api/v1/docgov/comments?doc_id=${encodeURIComponent(docId)}`,
{ headers: getBearerAuthHeaders() },
);
if (!res.ok) {
const text = await res.text();
throw new Error(text || `list comments failed: ${res.status}`);
}
const data = await res.json();
return Array.isArray(data) ? (data as DocGovComment[]) : [];
}
export async function addDocGovComment(payload: {
doc_id: string;
target_section?: string;
comment_type?: string;
content: string;
}): Promise<DocGovComment> {
return postDocGov('/api/v1/docgov/comments', payload);
}
export async function resolveDocGovComment(
commentId: number,
payload: { doc_id: string; resolve?: boolean },
): Promise<DocGovComment> {
return postDocGov(`/api/v1/docgov/comments/${commentId}/resolve`, payload);
}
+53
View File
@@ -63,3 +63,56 @@ export async function clearRecheckMark(
throw new Error(`关闭待复核失败: ${res.status}`); throw new Error(`关闭待复核失败: ${res.status}`);
} }
} }
export interface ReopenRequestPayload {
instance_id: number;
stage_key: string;
doc_task_keys: string[];
reason: string;
}
export interface ReopenRequestRecord {
id: number;
project_id: number;
instance_id?: number;
stage_key: string;
doc_task_keys?: string[];
reason?: string;
status: string;
requested_by?: number;
}
export async function requestReopen(
projectId: number,
body: ReopenRequestPayload,
): Promise<ReopenRequestRecord> {
const res = await apiFetch(`${API_BASE}/api/v1/projects/${projectId}/trace/reopen`, {
method: 'POST',
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `申请再修订失败: ${res.status}`);
}
return res.json();
}
export async function approveReopen(
projectId: number,
requestId: number,
): Promise<ReopenRequestRecord> {
const res = await apiFetch(
`${API_BASE}/api/v1/projects/${projectId}/trace/reopen/${requestId}/approve`,
{
method: 'POST',
headers: getBearerAuthHeaders('application/json'),
body: '{}',
},
);
if (!res.ok) {
const text = await res.text();
throw new Error(text || `批准再修订失败: ${res.status}`);
}
return res.json();
}
+4 -2
View File
@@ -8,6 +8,7 @@ interface CommentSidePanelProps {
block: DocumentBlock; block: DocumentBlock;
currentRole: UserRole; currentRole: UserRole;
currentUserName: string; currentUserName: string;
docLifecycleState?: string;
onAddComment: (comment: Omit<BlockComment, 'id' | 'createdAt'>) => void; onAddComment: (comment: Omit<BlockComment, 'id' | 'createdAt'>) => void;
onClose: () => void; onClose: () => void;
} }
@@ -16,6 +17,7 @@ export const CommentSidePanel: React.FC<CommentSidePanelProps> = ({
block, block,
currentRole, currentRole,
currentUserName, currentUserName,
docLifecycleState = 'drafting',
onAddComment, onAddComment,
onClose, onClose,
}) => { }) => {
@@ -23,7 +25,7 @@ export const CommentSidePanel: React.FC<CommentSidePanelProps> = ({
const [type, setType] = useState<'suggestion' | 'issue' | 'approval'>('suggestion'); const [type, setType] = useState<'suggestion' | 'issue' | 'approval'>('suggestion');
const comments = block.comments || []; const comments = block.comments || [];
const canCompose = canAddBlockComment(currentRole); const canCompose = canAddBlockComment(currentRole, docLifecycleState);
const handleSend = () => { const handleSend = () => {
if (!canCompose || !content.trim()) return; if (!canCompose || !content.trim()) return;
@@ -32,7 +34,7 @@ export const CommentSidePanel: React.FC<CommentSidePanelProps> = ({
onAddComment({ onAddComment({
authorName: currentUserName, authorName: currentUserName,
authorRole, authorRole: authorRole === 'author' ? 'reviewer' : authorRole,
type, type,
content: content.trim(), content: content.trim(),
resolved: false, resolved: false,
+25 -1
View File
@@ -29,6 +29,7 @@ interface DocGovActionBarProps {
onRejectApprove: (reason: string) => void; onRejectApprove: (reason: string) => void;
onShareToProject?: (docId: string) => void; onShareToProject?: (docId: string) => void;
onRevokeShare?: (docId: string) => void; onRevokeShare?: (docId: string) => void;
onRequestReopen?: () => void;
} }
export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
@@ -45,6 +46,7 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
onRejectApprove, onRejectApprove,
onShareToProject, onShareToProject,
onRevokeShare, onRevokeShare,
onRequestReopen,
}) => { }) => {
const [rejectModalOpen, setRejectModalOpen] = useState(false); const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectReason, setRejectReason] = useState(''); const [rejectReason, setRejectReason] = useState('');
@@ -637,7 +639,7 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '6px', gap: '8px',
color: 'var(--success)', color: 'var(--success)',
fontSize: '12.5px', fontSize: '12.5px',
fontWeight: 600, fontWeight: 600,
@@ -645,6 +647,28 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
> >
<Award size={16} /> <Award size={16} />
<span>受控归档交付物 (已沉淀为全局标杆经验)</span> <span>受控归档交付物 (已沉淀为全局标杆经验)</span>
{onRequestReopen &&
(currentUserId === ast.authorId ||
currentUserId === ast.approverId ||
currentUserId === ast.creatorId) && (
<button
onClick={onRequestReopen}
style={{
marginLeft: '8px',
padding: '5px 10px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
color: 'var(--text)',
fontSize: '11.5px',
fontWeight: 600,
cursor: 'pointer',
}}
title="向原批准人申请将文档重开至编制期"
>
申请再修订
</button>
)}
</div> </div>
)} )}
</div> </div>
+4
View File
@@ -70,6 +70,7 @@ export interface DocumentPaneProps {
handleRejectReview: (reason: string) => void | Promise<void>; handleRejectReview: (reason: string) => void | Promise<void>;
handleApprove: () => void | Promise<void>; handleApprove: () => void | Promise<void>;
handleRejectApprove: (reason: string) => void | Promise<void>; handleRejectApprove: (reason: string) => void | Promise<void>;
handleRequestReopen?: () => void | Promise<void>;
editorSession: BlockEditorSession | null; editorSession: BlockEditorSession | null;
handleSaveBlockEdit: (updated: DocumentBlock) => void | Promise<void>; handleSaveBlockEdit: (updated: DocumentBlock) => void | Promise<void>;
closeBlockEditor: () => void; closeBlockEditor: () => void;
@@ -122,6 +123,7 @@ export function DocumentPane(props: DocumentPaneProps) {
handleRejectReview, handleRejectReview,
handleApprove, handleApprove,
handleRejectApprove, handleRejectApprove,
handleRequestReopen,
editorSession, editorSession,
handleSaveBlockEdit, handleSaveBlockEdit,
closeBlockEditor, closeBlockEditor,
@@ -390,6 +392,7 @@ isCurrentProjectExited ? (
onRejectApprove={handleRejectApprove} onRejectApprove={handleRejectApprove}
onShareToProject={handleShareToProject} onShareToProject={handleShareToProject}
onRevokeShare={handleRevokeShare} onRevokeShare={handleRevokeShare}
onRequestReopen={handleRequestReopen}
/> />
)} )}
@@ -413,6 +416,7 @@ isCurrentProjectExited ? (
block={commentingBlock} block={commentingBlock}
currentRole={currentDocRole} currentRole={currentDocRole}
currentUserName={currentUser.name} currentUserName={currentUser.name}
docLifecycleState={docAST.lifecycleState}
onAddComment={handleAddComment} onAddComment={handleAddComment}
onClose={() => setCommentingBlock(null)} onClose={() => setCommentingBlock(null)}
/> />
+24 -8
View File
@@ -10,6 +10,7 @@ import type { BlockEditorSession } from '../lib/blockSavePolicy';
import type { BlockComment, DocumentAST, DocumentBlock } from '../types/block'; import type { BlockComment, DocumentAST, DocumentBlock } from '../types/block';
import type { UserProfile, UserRole } from '../types/docgov'; import type { UserProfile, UserRole } from '../types/docgov';
import type { DeliverableItem } from '../types/workbench'; import type { DeliverableItem } from '../types/workbench';
import { addDocGovComment } from '../api/docgov';
type Gate = ReturnType<typeof createEditorHydrationGate>; type Gate = ReturnType<typeof createEditorHydrationGate>;
@@ -124,14 +125,29 @@ export function useBlockEditing(input: BlockEditingInput): BlockEditingActions {
}; };
const handleAddComment = (comment: Omit<BlockComment, 'id' | 'createdAt'>) => { const handleAddComment = (comment: Omit<BlockComment, 'id' | 'createdAt'>) => {
if (!commentingBlock || !canAddBlockComment(currentDocRole)) return; if (!commentingBlock || !canAddBlockComment(currentDocRole, docAST.lifecycleState || 'drafting')) return;
const newCmt: BlockComment = { void (async () => {
...comment, try {
id: 'cmt_' + Math.random().toString(36).substring(2, 8), const saved = await addDocGovComment({
createdAt: new Date().toTimeString().substring(0, 5), doc_id: activeDocId,
}; target_section: commentingBlock.id,
commentingBlock.comments = [...(commentingBlock.comments || []), newCmt]; comment_type: comment.type || 'suggestion',
setDocAST({ ...docAST }); content: comment.content,
});
const newCmt: BlockComment = {
...comment,
id: String(saved.id || 'cmt_' + Math.random().toString(36).substring(2, 8)),
createdAt: saved.created_at
? new Date(saved.created_at).toTimeString().substring(0, 5)
: new Date().toTimeString().substring(0, 5),
resolved: saved.status === 'resolved',
};
commentingBlock.comments = [...(commentingBlock.comments || []), newCmt];
setDocAST({ ...docAST });
} catch (err) {
console.error('add comment failed', err);
}
})();
}; };
return { return {
@@ -7,6 +7,8 @@ import {
submitForReview, submitForReview,
type DocGovRecord, type DocGovRecord,
} from '../api/docgov'; } from '../api/docgov';
import { requestReopen } from '../api/projectTrace';
import { fetchProjectSettings } from '../api/projectSettings';
import type { DocumentAST } from '../types/block'; import type { DocumentAST } from '../types/block';
import type { DocLifecycleState, UserProfile } from '../types/docgov'; import type { DocLifecycleState, UserProfile } from '../types/docgov';
import type { ChatMessage, DeliverableItem } from '../types/workbench'; import type { ChatMessage, DeliverableItem } from '../types/workbench';
@@ -17,6 +19,8 @@ export interface DocumentLifecycleInput {
activeDocId: string; activeDocId: string;
currentUser: UserProfile; currentUser: UserProfile;
activeSessionId: string; activeSessionId: string;
projectId?: number;
workflowInstanceId?: number;
setDeliverables: Dispatch<SetStateAction<DeliverableItem[]>>; setDeliverables: Dispatch<SetStateAction<DeliverableItem[]>>;
setMessages: Dispatch<SetStateAction<ChatMessage[]>>; setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
} }
@@ -28,6 +32,7 @@ export interface DocumentLifecycleActions {
handleRejectReview: (reason: string) => Promise<void>; handleRejectReview: (reason: string) => Promise<void>;
handleApprove: () => Promise<void>; handleApprove: () => Promise<void>;
handleRejectApprove: (reason: string) => Promise<void>; handleRejectApprove: (reason: string) => Promise<void>;
handleRequestReopen: () => Promise<void>;
} }
export function useDocumentLifecycleActions(input: DocumentLifecycleInput): DocumentLifecycleActions { export function useDocumentLifecycleActions(input: DocumentLifecycleInput): DocumentLifecycleActions {
@@ -37,6 +42,8 @@ export function useDocumentLifecycleActions(input: DocumentLifecycleInput): Docu
activeDocId, activeDocId,
currentUser, currentUser,
activeSessionId, activeSessionId,
projectId,
workflowInstanceId,
setDeliverables, setDeliverables,
setMessages, setMessages,
} = input; } = input;
@@ -226,6 +233,55 @@ export function useDocumentLifecycleActions(input: DocumentLifecycleInput): Docu
} }
}; };
const handleRequestReopen = async () => {
if (!projectId || projectId <= 0) {
pushDocGovError('申请再修订', new Error('缺少项目上下文'));
return;
}
const reason = window.prompt('请输入再修订原因(将通知原批准人审批)');
if (!reason || !reason.trim()) return;
try {
let instanceId = workflowInstanceId || 0;
let stageKey = docAST.version || '';
try {
const settings = await fetchProjectSettings(projectId);
if (settings?.live_instance?.id) {
instanceId = settings.live_instance.id;
}
if (settings?.active_stage_key) {
stageKey = settings.active_stage_key;
}
} catch {
/* keep fallbacks */
}
if (!stageKey) stageKey = 'V0.5';
const stored = await requestReopen(projectId, {
instance_id: instanceId,
stage_key: stageKey,
doc_task_keys: [docAST.taskKey],
reason: reason.trim(),
});
setMessages((prev) => [
...prev,
{
id: 'reopen-' + Date.now(),
sessionId: activeSessionId,
role: 'system',
content:
stored.status === 'approved'
? `《${docAST.title}》再修订申请已自动批准,文档已回编制期。`
: `已提交《${docAST.title}》再修订申请(#${stored.id}),等待原批准人审批。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
if (stored.status === 'approved') {
setDocAST({ ...docAST, lifecycleState: 'drafting' as DocLifecycleState, isClaimed: docAST.isClaimed });
}
} catch (err) {
pushDocGovError('申请再修订', err);
}
};
return { return {
handleClaim, handleClaim,
handleSubmitReview, handleSubmitReview,
@@ -233,5 +289,6 @@ export function useDocumentLifecycleActions(input: DocumentLifecycleInput): Docu
handleRejectReview, handleRejectReview,
handleApprove, handleApprove,
handleRejectApprove, handleRejectApprove,
handleRequestReopen,
}; };
} }
+23 -8
View File
@@ -2,21 +2,36 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
canAddBlockComment, canAddBlockComment,
canResolveComment,
resolveCommentAuthorRole, resolveCommentAuthorRole,
} from './commentPolicy'; } from './commentPolicy';
describe('commentPolicy', () => { describe('commentPolicy', () => {
it('lets author/reviewer/approver annotate and keeps their comment role', () => { it('applies stage matrix for add', () => {
assert.equal(canAddBlockComment('author'), true); assert.equal(canAddBlockComment('author', 'drafting'), true);
assert.equal(canAddBlockComment('reviewer'), true); assert.equal(canAddBlockComment('reviewer', 'drafting'), true);
assert.equal(canAddBlockComment('approver'), true); assert.equal(canAddBlockComment('approver', 'drafting'), false);
assert.equal(resolveCommentAuthorRole('author'), 'reviewer'); assert.equal(canAddBlockComment('author', 'reviewing'), true);
assert.equal(canAddBlockComment('approver', 'reviewing'), false);
assert.equal(canAddBlockComment('approver', 'approving'), true);
assert.equal(canAddBlockComment('author', 'approving'), false);
assert.equal(canAddBlockComment('reviewer', 'approved'), false);
});
it('keeps viewer read-only', () => {
assert.equal(canAddBlockComment('viewer', 'drafting'), false);
assert.equal(resolveCommentAuthorRole('viewer'), null);
});
it('resolves author role literally', () => {
assert.equal(resolveCommentAuthorRole('author'), 'author');
assert.equal(resolveCommentAuthorRole('reviewer'), 'reviewer'); assert.equal(resolveCommentAuthorRole('reviewer'), 'reviewer');
assert.equal(resolveCommentAuthorRole('approver'), 'approver'); assert.equal(resolveCommentAuthorRole('approver'), 'approver');
}); });
it('keeps viewer read-only: no add entry and no reviewer/approver cast', () => { it('gates resolve by stage', () => {
assert.equal(canAddBlockComment('viewer'), false); assert.equal(canResolveComment('author', 'drafting'), true);
assert.equal(resolveCommentAuthorRole('viewer'), null); assert.equal(canResolveComment('author', 'reviewing'), false);
assert.equal(canResolveComment('reviewer', 'reviewing'), true);
}); });
}); });
+33 -6
View File
@@ -1,15 +1,42 @@
import type { UserRole } from '../types/docgov'; import type { UserRole } from '../types/docgov';
export type CommentAuthorRole = 'reviewer' | 'approver'; export type CommentAuthorRole = 'reviewer' | 'approver' | 'author';
export type DocLifecycleState = 'drafting' | 'reviewing' | 'approving' | 'approved' | string;
/** viewer 只能读批注,不能打开新增入口或提交。 */ /** Stage-scoped comment matrix (viewer always read-only). */
export function canAddBlockComment(role: UserRole | string): boolean { export function canAddBlockComment(
return role === 'author' || role === 'reviewer' || role === 'approver'; role: UserRole | string,
state: DocLifecycleState = 'drafting',
): boolean {
if (role === 'viewer' || role === 'system') {
return role === 'system';
}
switch (state) {
case 'drafting':
return role === 'author' || role === 'reviewer';
case 'reviewing':
return role === 'reviewer' || role === 'author';
case 'approving':
return role === 'approver' || role === 'reviewer';
case 'approved':
return false;
default:
return role === 'author' || role === 'reviewer' || role === 'approver';
}
} }
/** author 的批注按既有语义记为 reviewer;viewer 不得被 cast。 */ /** Persist author role as-is when author comments; otherwise reviewer/approver. */
export function resolveCommentAuthorRole(role: UserRole | string): CommentAuthorRole | null { export function resolveCommentAuthorRole(role: UserRole | string): CommentAuthorRole | null {
if (role === 'approver') return 'approver'; if (role === 'approver') return 'approver';
if (role === 'reviewer' || role === 'author') return 'reviewer'; if (role === 'reviewer') return 'reviewer';
if (role === 'author') return 'author';
return null; return null;
} }
export function canResolveComment(
role: UserRole | string,
state: DocLifecycleState,
): boolean {
if (role === 'reviewer') return true;
return role === 'author' && state === 'drafting';
}