merge: candidate doc-review-lifecycle-land-2026-09-26 into dev
This commit is contained in:
@@ -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。
|
||||||
+24
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 "", "", ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]) + "…"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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{
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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}},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" // 自由任务简报(升级建议携带的用户原始诉求)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user