diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index d919e781..8cdcf862 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -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-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` | -| 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` | | 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` | diff --git a/.doc/system-design/2026-09-25-doc-review-lifecycle-design.md b/.doc/system-design/2026-09-25-doc-review-lifecycle-design.md new file mode 100644 index 00000000..85016dbe --- /dev/null +++ b/.doc/system-design/2026-09-25-doc-review-lifecycle-design.md @@ -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 归档链路。 diff --git a/.doc/test-reports/2026-09-25-doc-review-lifecycle-test-report.md b/.doc/test-reports/2026-09-25-doc-review-lifecycle-test-report.md new file mode 100644 index 00000000..d9608260 --- /dev/null +++ b/.doc/test-reports/2026-09-25-doc-review-lifecycle-test-report.md @@ -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。 diff --git a/.doc/test-reports/assets/2026-09-26-doc-review-lifecycle-land/revalidation-summary.txt b/.doc/test-reports/assets/2026-09-26-doc-review-lifecycle-land/revalidation-summary.txt new file mode 100644 index 00000000..f9a0f222 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-26-doc-review-lifecycle-land/revalidation-summary.txt @@ -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 收据留存。 diff --git a/deploy/postgres/goose/00073_2026-09-25-document-pending-supplements.sql b/deploy/postgres/goose/00073_2026-09-25-document-pending-supplements.sql new file mode 100644 index 00000000..ee092f5b --- /dev/null +++ b/deploy/postgres/goose/00073_2026-09-25-document-pending-supplements.sql @@ -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; diff --git a/internal/app/app.go b/internal/app/app.go index 55d0939b..6a0e0021 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -20,6 +20,7 @@ import ( "chat-agent-system/internal/auth" "chat-agent-system/internal/authz" "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/chat" "chat-agent-system/internal/config" "chat-agent-system/internal/coordinator" "chat-agent-system/internal/ctxmgr" @@ -32,11 +33,11 @@ import ( "chat-agent-system/internal/kb" "chat-agent-system/internal/maintenance" "chat-agent-system/internal/notification" - "chat-agent-system/internal/supplementimpact" "chat-agent-system/internal/observability" "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/storage" + "chat-agent-system/internal/supplementimpact" "chat-agent-system/internal/sysadmin" "chat-agent-system/internal/taskrun" "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 // construct it once; HTTP adapters must not re-wire domain services. type Runtime struct { - Cfg *config.Config - Biz *bizdev.Service - Engine *coordinator.CoordinatorEngine - Auth *auth.Service - PW *projectworkflow.Service - TaskRuns *taskrun.Manager - Authorizer *authz.Authorizer - Authz *authz.Service - WFDef *workflowdef.Service - Inst *projectinstance.Service - Notify *notification.Service - Resolver *workspace.Resolver - WSCap *workspace.Capability - Commands *taskrun.Commands - Rec intent.Recognizer - Stageflow *stageflow.UseCase - DocNotify *docnotify.Subscriber - ChatLog chatflow.ChatLog - Assembler chatflow.ContextAssembler - Docflow *docflow.Commands - IntentPlans *intentflow.Plans - Pool *pgxpool.Pool - Gov *docgov.GovernanceEngine - Rev *docrevision.Service - Trace *traceimpact.Service + Cfg *config.Config + Biz *bizdev.Service + Engine *coordinator.CoordinatorEngine + Auth *auth.Service + PW *projectworkflow.Service + TaskRuns *taskrun.Manager + Authorizer *authz.Authorizer + Authz *authz.Service + WFDef *workflowdef.Service + Inst *projectinstance.Service + Notify *notification.Service + Resolver *workspace.Resolver + WSCap *workspace.Capability + Commands *taskrun.Commands + Rec intent.Recognizer + Stageflow *stageflow.UseCase + DocNotify *docnotify.Subscriber + ChatLog chatflow.ChatLog + Assembler chatflow.ContextAssembler + Docflow *docflow.Commands + IntentPlans *intentflow.Plans + Pool *pgxpool.Pool + Gov *docgov.GovernanceEngine + Rev *docrevision.Service + Trace *traceimpact.Service SupplementImpact *supplementimpact.Service mutateOwner *MutateOwner - Ctx *ctxmgr.ContextManager - CAS *storage.CAS - FreeDoc *freedoc.Service - KB *kb.Client - SysAdmin *sysadmin.Service - Maintenance *maintenance.Service + ChatReopens chat.ReopenService + Ctx *ctxmgr.ContextManager + CAS *storage.CAS + FreeDoc *freedoc.Service + KB *kb.Client + SysAdmin *sysadmin.Service + Maintenance *maintenance.Service mu sync.Mutex running bool @@ -330,6 +332,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) { rt.DocNotify.SkipWrite = func(ctx context.Context) bool { return rt.WriterLive(ctx, events.ConsumerNotify) } + rt.ChatReopens = rt.WireDocDialogueTriggers() envName := "" if cfg != nil { envName = cfg.Env @@ -400,21 +403,21 @@ func sysadminFeatureValues(cfg *config.Config) func() map[string]string { pflags := intentflow.CurrentFeatureFlags() sflags := ctxmgr.CurrentSummaryFlags() values := map[string]string{ - "INTENT_V2_SHADOW": strconv.FormatBool(iflags.IntentV2Shadow), - "INTENT_V2_DECISION": strconv.FormatBool(iflags.IntentV2Decision), - "INTENT_V2_DECISION_TIER": string(iflags.V2DecisionTier), - "WORKFLOW_INTENTS_ENABLED": strconv.FormatBool(iflags.WorkflowIntentsEnabled), - "MULTI_INTENT_ENABLED": strconv.FormatBool(iflags.MultiIntentEnabled), - "MULTI_INTENT_PLAN_TIER": string(pflags.PlanTier), + "INTENT_V2_SHADOW": strconv.FormatBool(iflags.IntentV2Shadow), + "INTENT_V2_DECISION": strconv.FormatBool(iflags.IntentV2Decision), + "INTENT_V2_DECISION_TIER": string(iflags.V2DecisionTier), + "WORKFLOW_INTENTS_ENABLED": strconv.FormatBool(iflags.WorkflowIntentsEnabled), + "MULTI_INTENT_ENABLED": strconv.FormatBool(iflags.MultiIntentEnabled), + "MULTI_INTENT_PLAN_TIER": string(pflags.PlanTier), "INTENT_PLAN_PROJECT_ALLOWLIST": formatProjectAllowlist(pflags.ProjectAllowlist), - "MULTI_INTENT_U9_SEGMENT": strconv.FormatBool(pflags.U9Segment), - "MULTI_INTENT_U10_MUTATE": strconv.FormatBool(pflags.U10Mutate), - "MULTI_INTENT_U11_DOCGOV": strconv.FormatBool(pflags.U11DocGov), - "MULTI_INTENT_U12_RUN_CONTROL": strconv.FormatBool(pflags.U12RunControl), - "WORKFLOW_SCOPE_MODE": string(workflowscope.CurrentMode()), - "SEMANTIC_SUMMARY_SHADOW": strconv.FormatBool(sflags.Shadow), - "SEMANTIC_SUMMARY_REPLACE": strconv.FormatBool(sflags.Replace), - "PI_CHANGE_REPORT_NARRATIVE": strconv.FormatBool(docrevision.PIChangeReportNarrativeEnabled()), + "MULTI_INTENT_U9_SEGMENT": strconv.FormatBool(pflags.U9Segment), + "MULTI_INTENT_U10_MUTATE": strconv.FormatBool(pflags.U10Mutate), + "MULTI_INTENT_U11_DOCGOV": strconv.FormatBool(pflags.U11DocGov), + "MULTI_INTENT_U12_RUN_CONTROL": strconv.FormatBool(pflags.U12RunControl), + "WORKFLOW_SCOPE_MODE": string(workflowscope.CurrentMode()), + "SEMANTIC_SUMMARY_SHADOW": strconv.FormatBool(sflags.Shadow), + "SEMANTIC_SUMMARY_REPLACE": strconv.FormatBool(sflags.Replace), + "PI_CHANGE_REPORT_NARRATIVE": strconv.FormatBool(docrevision.PIChangeReportNarrativeEnabled()), } if cfg != nil { values["AGENT_BACKEND"] = cfg.AgentBackend diff --git a/internal/app/chat_doc_dialogue.go b/internal/app/chat_doc_dialogue.go new file mode 100644 index 00000000..aab6e051 --- /dev/null +++ b/internal/app/chat_doc_dialogue.go @@ -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 +} diff --git a/internal/app/docnotify/consumer.go b/internal/app/docnotify/consumer.go index ee3422c0..46f0d24e 100644 --- a/internal/app/docnotify/consumer.go +++ b/internal/app/docnotify/consumer.go @@ -5,6 +5,7 @@ import ( "encoding/json" "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/docgov" "chat-agent-system/internal/events" "chat-agent-system/internal/notification" @@ -44,16 +45,21 @@ func (c *EventConsumer) HandleLease(ctx context.Context, lease events.Lease, tx } var recipient int64 projectID := lease.ProjectID + var doc *docgov.DocumentRecord if c.Docs != nil && docID != "" { - doc, err := c.Docs.GetDocument(ctx, docID) - if err != nil || doc == nil { + d, err := c.Docs.GetDocument(ctx, docID) + if err != nil || d == nil { return events.ErrQuarantine } - if doc.ProjectID != lease.ProjectID { + if d.ProjectID != lease.ProjectID { return events.ErrQuarantine } + doc = d + projectID = d.ProjectID + } + recipient = RecipientForDocEvent(doc, lease.EventName) + if recipient <= 0 && doc != nil { recipient = doc.AuthorID - projectID = doc.ProjectID } if recipient <= 0 { return events.ErrQuarantine @@ -89,6 +95,14 @@ func notifyCopy(eventName string) (kind, title, body string) { return notification.TypeDocProposalRequired, "文档变更待确认", "已认领文档有变更提案,请确认后由您发起修改。" case events.EventDocUnclaimedEdit: 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: return "", "", "" } diff --git a/internal/app/docnotify/lifecycle.go b/internal/app/docnotify/lifecycle.go new file mode 100644 index 00000000..ea1268a1 --- /dev/null +++ b/internal/app/docnotify/lifecycle.go @@ -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]) + "…" +} diff --git a/internal/app/docnotify/lifecycle_test.go b/internal/app/docnotify/lifecycle_test.go new file mode 100644 index 00000000..ea60529d --- /dev/null +++ b/internal/app/docnotify/lifecycle_test.go @@ -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) + } +} diff --git a/internal/app/docnotify/subscriber.go b/internal/app/docnotify/subscriber.go index 15f45dfc..cd7f1c9a 100644 --- a/internal/app/docnotify/subscriber.go +++ b/internal/app/docnotify/subscriber.go @@ -54,13 +54,17 @@ func (s *Subscriber) Handle(ctx context.Context, eventType string, payload map[s docID, _ := payload["doc_id"].(string) projectID, _ := payload["project_id"].(int64) risk, _ := payload["risk"].(string) - var recipient int64 + var doc *docgov.DocumentRecord if s.Docs != nil && docID != "" { - if doc, err := s.Docs.GetDocument(ctx, docID); err == nil && doc != nil && doc.AuthorID > 0 { - recipient = doc.AuthorID - projectID = doc.ProjectID + if d, err := s.Docs.GetDocument(ctx, docID); err == nil && d != nil { + doc = d + projectID = d.ProjectID } } + recipient := RecipientForDocEvent(doc, eventType) + if recipient == 0 && doc != nil { + recipient = doc.AuthorID + } if recipient == 0 { 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) 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: return nil } diff --git a/internal/app/intentflow/expansion.go b/internal/app/intentflow/expansion.go index 81afbeb5..2fa4633a 100644 --- a/internal/app/intentflow/expansion.go +++ b/internal/app/intentflow/expansion.go @@ -193,13 +193,18 @@ var mutateActionKeys = map[string]struct{}{ } var docGovActionKeys = map[string]struct{}{ - "document.claim": {}, - "document.review.submit": {}, - "document.review.approve": {}, - "document.review.reject": {}, - "document.final.approve": {}, - "document.final.reject": {}, - "document.proposal.decide": {}, + "document.claim": {}, + "document.review.submit": {}, + "document.review.approve": {}, + "document.review.reject": {}, + "document.final.approve": {}, + "document.final.reject": {}, + "document.proposal.decide": {}, + "document.comment.add": {}, + "document.comment.resolve": {}, + "document.reopen.request": {}, + "document.reopen.approve": {}, + "document.pending_supplements.query": {}, } 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. // 基线键(claim / review.submit)随 U11 总 flag 放量;P8 逐键键(review approve/reject、 // final.*、proposal.decide)须额外打开对应 LIVE_* flag 才进入 live 集合。 +// 2026-09-25 三权流转对话触发键(comment/reopen/pending)随基线集合接入, +// 同样受 U11 总 flag 控制。 func DocGovLiveActionKeys() map[string]struct{} { keys := map[string]struct{}{ - "document.claim": {}, - "document.review.submit": {}, + "document.claim": {}, + "document.review.submit": {}, + "document.comment.add": {}, + "document.comment.resolve": {}, + "document.reopen.request": {}, + "document.reopen.approve": {}, + "document.pending_supplements.query": {}, } f := CurrentFeatureFlags() if f.U11LiveReview { diff --git a/internal/app/trace_bridge.go b/internal/app/trace_bridge.go index 1a2aa2f8..cda42815 100644 --- a/internal/app/trace_bridge.go +++ b/internal/app/trace_bridge.go @@ -47,9 +47,13 @@ func (t traceUnlock) ReopenApprovedDocs(ctx context.Context, projectID int64, do if t.gov == nil { return nil } + actorName := "reopen" + if actorID > 0 { + actorName = fmt.Sprintf("user:%d", actorID) + } for _, key := range docTaskKeys { 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) { continue } diff --git a/internal/app/wire_supplement_impact.go b/internal/app/wire_supplement_impact.go index 9f09ca79..5d77b89b 100644 --- a/internal/app/wire_supplement_impact.go +++ b/internal/app/wire_supplement_impact.go @@ -6,7 +6,10 @@ import ( "log/slog" "time" + "chat-agent-system/internal/app/docnotify" + "chat-agent-system/internal/docgov" "chat-agent-system/internal/intent" + "chat-agent-system/internal/notification" "chat-agent-system/internal/supplementimpact" ) @@ -23,11 +26,37 @@ func (r *Runtime) wireSupplementImpact() { Analyze: r.Trace, Notify: r.Notify, Propose: r.Gov, + Pending: r.Gov, } r.SupplementImpact = svc if r.Biz != nil && r.Notify != nil { 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 { r.Engine.SetOnSupplement(func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string) { 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) 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{ CardID: b.BatchID, diff --git a/internal/chat/action_executor.go b/internal/chat/action_executor.go index c92f54e8..01c3eb86 100644 --- a/internal/chat/action_executor.go +++ b/internal/chat/action_executor.go @@ -42,6 +42,7 @@ type ActionServices struct { TaskRuns TaskStarter IntentPlans IntentPlans Workflow WorkflowRunner + Reopens ReopenService 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) { if action == nil || s.Actions == nil { 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 + 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": if s.Actions.TaskRuns == nil { 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) case "workflow.run": 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": return fmt.Sprintf("续跑任务已启动(run_id=%s)。您可在任务面板查看进度。", result.RunID) case "taskrun.restart": diff --git a/internal/chat/action_executor_test.go b/internal/chat/action_executor_test.go index 673c4b91..0a97028e 100644 --- a/internal/chat/action_executor_test.go +++ b/internal/chat/action_executor_test.go @@ -2,6 +2,8 @@ package chat import ( "context" + "fmt" + "strings" "testing" "chat-agent-system/internal/bizdev" @@ -28,7 +30,7 @@ func TestExecuteAction_DocGovReviewApprove(t *testing.T) { _, _ = docGov.SubmitForReview(ctx, docID, 1, docgov.RoleAuthor, "张工") sess := &Session{ - UserID: 2, + UserID: 2, Actions: &ActionServices{DocGov: docGov}, } action := &coordinator.PendingAction{ @@ -52,3 +54,107 @@ func TestExecuteAction_DocGovReviewApprove(t *testing.T) { 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) + } +} diff --git a/internal/chat/ports.go b/internal/chat/ports.go index 864ccb6f..5acd651f 100644 --- a/internal/chat/ports.go +++ b/internal/chat/ports.go @@ -80,3 +80,18 @@ type TaskStartRequest struct { ProjectID int64 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) +} diff --git a/internal/coordinator/confirm_gate_test.go b/internal/coordinator/confirm_gate_test.go index 307b51fb..e9ea32f1 100644 --- a/internal/coordinator/confirm_gate_test.go +++ b/internal/coordinator/confirm_gate_test.go @@ -20,7 +20,7 @@ func TestConfirmGate_WeakVerbNeedsConfirmation(t *testing.T) { Confidence: 0.78, Branch: intent.BranchNewIntentPreempt, FilledSlots: map[string]string{ - intent.SlotTargetTask: "intake", + intent.SlotTargetTask: "intake", intent.SlotNeedsConfirmation: "true", }, } diff --git a/internal/coordinator/dialogue_llm_test.go b/internal/coordinator/dialogue_llm_test.go index f5a625b0..77fa4034 100644 --- a/internal/coordinator/dialogue_llm_test.go +++ b/internal/coordinator/dialogue_llm_test.go @@ -394,7 +394,6 @@ func TestDialogueLLM_AmbiguousSmalltalkDoesNotUseStageSalesTemplate(t *testing.T } } - type stubRealtimeSearcher struct { markdown string err error diff --git a/internal/coordinator/docgov_dialogue_test.go b/internal/coordinator/docgov_dialogue_test.go new file mode 100644 index 00000000..49775453 --- /dev/null +++ b/internal/coordinator/docgov_dialogue_test.go @@ -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 +} diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index 470e8171..8930fece 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -27,15 +27,18 @@ const ( // PendingAction 声明对话层需执行的真实动作(由 Session 持有服务后执行)。 type PendingAction struct { - Kind string // docgov.claim | docgov.submit_review | docgov.review_approve | docgov.approver_approve | docgov.reject | workflow.run | taskrun.resume | taskrun.restart - DocID string - WorkflowKey string - RunID string - WorkDir string - Reason string - Comment string - Rollback vocabulary.DocumentState - TaskKey string + 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 + WorkflowKey string + RunID string + WorkDir string + Reason string + Comment string + Rollback vocabulary.DocumentState + TaskKey string + Role string + Section string + RequestID int64 } // CoordinationResult 协调层给对话层的回执 @@ -77,11 +80,13 @@ type CoordinatorEngine struct { kbClient *kb.Client impact ImpactActions projectAccess ProjectAccess + docResolver DocResolver + pendingSupps PendingSupplementReader + reopenLister ReopenLister // OnSupplement is optional; composition typically wires supplementimpact.TriggerAsync. // 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 - docResolver DocResolver } // StageTaskRunner is the application owner for ExecuteTask / ExecuteStage. @@ -117,7 +122,6 @@ func (e *CoordinatorEngine) SetInstanceService(svc *projectinstance.Service) { } } - // SetRealtimeSearcher 注入对话路径的实时检索(天气/新闻等);nil 时跳过强制检索。 func (e *CoordinatorEngine) SetRealtimeSearcher(s RealtimeSearcher) { 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. func (e *CoordinatorEngine) SetOnSupplement(fn func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string)) { 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. func (e *CoordinatorEngine) HasDocResolver() bool { return e != nil && e.docResolver != nil @@ -321,6 +339,21 @@ func (e *CoordinatorEngine) ProcessIntent( case intent.IntentDocGovSubmitReview: 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: coordRes, coordErr = e.handleDocGovProposalDecide(ctx, top, userCtx) diff --git a/internal/coordinator/engine_docgov.go b/internal/coordinator/engine_docgov.go index 7b16ecc9..86a04460 100644 --- a/internal/coordinator/engine_docgov.go +++ b/internal/coordinator/engine_docgov.go @@ -4,6 +4,7 @@ import ( "chat-agent-system/internal/contracts/vocabulary" "context" "fmt" + "strconv" "strings" "time" @@ -275,7 +276,7 @@ func (e *CoordinatorEngine) handleDocGovClaim(ctx context.Context, cand *intent. }, nil } -// handleDocGovSubmitReview 解析门 → drafting 校验 → 确认卡 → 拍板后 PendingAction +// handleDocGovSubmitReview 解析门 → drafting 校验 → CONFIRMATION 卡(含锁定后果)→ 拍板后 PendingAction func (e *CoordinatorEngine) handleDocGovSubmitReview(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { doc, gate := e.resolveTargetDocGate(ctx, cand, userCtx) if gate != nil { @@ -300,7 +301,7 @@ func (e *CoordinatorEngine) handleDocGovSubmitReview(ctx context.Context, cand * "受控文档送审确认", doc.DocID, title, "", "编制完成,提交审核并锁定正文", - "送审后文档进入审核锁定:非审核职责人不可改正文。", + "送审后文档进入审核锁定:非审核职责人不可改正文;锁定期间补充将进入积压台账,待退回编制后再消化。", "confirm_submit_review", "确认送审", map[string]string{"task_key": doc.TaskKey}, ) @@ -455,6 +456,85 @@ func (e *CoordinatorEngine) handleDocGovProposalDecide(ctx context.Context, cand }, 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 userCtx.InteractiveCard != nil { userCtx.dismissCard("cancelled") diff --git a/internal/coordinator/engine_docgov_comments.go b/internal/coordinator/engine_docgov_comments.go new file mode 100644 index 00000000..c5082793 --- /dev/null +++ b/internal/coordinator/engine_docgov_comments.go @@ -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 +} diff --git a/internal/coordinator/engine_docgov_reopen.go b/internal/coordinator/engine_docgov_reopen.go new file mode 100644 index 00000000..8a28972d --- /dev/null +++ b/internal/coordinator/engine_docgov_reopen.go @@ -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 +} diff --git a/internal/coordinator/engine_test.go b/internal/coordinator/engine_test.go index 322f3129..a92d6e8a 100644 --- a/internal/coordinator/engine_test.go +++ b/internal/coordinator/engine_test.go @@ -526,7 +526,7 @@ func TestCoordinatorEngine_DocGovActions(t *testing.T) { t.Errorf("expected claim confirmation card, reply=%q", resClaim.NaturalReply) } - // 2. DocGovSubmitReview + // 2. DocGovSubmitReview — 两步确认,首轮挂卡不立即执行 recSubmit := &intent.IntentRecognitionResult{ TopIntent: &intent.CandidateIntent{ Intent: intent.IntentDocGovSubmitReview, @@ -548,6 +548,12 @@ func TestCoordinatorEngine_DocGovActions(t *testing.T) { if resSubmit.InteractiveCard == nil { 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 reviewerCtx := &coordinator.UserProjectContext{ diff --git a/internal/coordinator/memory_composer_test.go b/internal/coordinator/memory_composer_test.go index 70244f9f..02fb180c 100644 --- a/internal/coordinator/memory_composer_test.go +++ b/internal/coordinator/memory_composer_test.go @@ -134,7 +134,6 @@ func TestMemoryComposer_HierarchyAndPriority(t *testing.T) { } } - func TestMemoryComposer_RealtimeSnippetAndUnavailable(t *testing.T) { composer := coordinator.NewMemoryComposer() fixed := time.Date(2026, 9, 23, 10, 41, 0, 0, time.FixedZone("CST", 8*3600)) diff --git a/internal/coordinator/p0_fake_success_test.go b/internal/coordinator/p0_fake_success_test.go index 9b041097..98a8fc78 100644 --- a/internal/coordinator/p0_fake_success_test.go +++ b/internal/coordinator/p0_fake_success_test.go @@ -106,9 +106,9 @@ func TestHandleDocGovSubmitReview_ConfirmThenPending(t *testing.T) { decide := &intent.CandidateIntent{ Intent: intent.IntentDocGovProposalDecide, FilledSlots: map[string]string{ - "target_action": "confirm_submit_review", - intent.SlotTargetDoc: "doc_proj9_intake", - "task_key": "intake", + "target_action": "confirm_submit_review", + intent.SlotTargetDoc: "doc_proj9_intake", + "task_key": "intake", }, } res2, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{TopIntent: decide}, userCtx) diff --git a/internal/coordinator/router.go b/internal/coordinator/router.go index 9681672d..826606eb 100644 --- a/internal/coordinator/router.go +++ b/internal/coordinator/router.go @@ -152,7 +152,9 @@ func RouteIntent(it intent.IntentType, profile TaskProfile) RouteDecision { case intent.IntentSecurityForbidden, intent.IntentDocGovClaim, intent.IntentDocGovSubmitReview, intent.IntentDocGovReviewApprove, 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.IntentQueryUserRights, intent.IntentQueryWorkflowStatus, intent.IntentQueryDocStatus, intent.IntentHelpCapabilities, intent.IntentUnsupportedAction, intent.IntentRunStatusQuery, diff --git a/internal/coordinator/router_test.go b/internal/coordinator/router_test.go index 47690836..b7ebbc7c 100644 --- a/internal/coordinator/router_test.go +++ b/internal/coordinator/router_test.go @@ -64,7 +64,8 @@ func TestRouteIntent_R0Equivalence(t *testing.T) { intent.IntentSecurityForbidden, intent.IntentDeclineExpectation, intent.IntentDocGovClaim, intent.IntentDocGovSubmitReview, intent.IntentDocGovReviewApprove, 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 { t.Errorf("%s should stay TEMPLATE, got %s", it, d.Channel) diff --git a/internal/docgov/comment_matrix_test.go b/internal/docgov/comment_matrix_test.go new file mode 100644 index 00000000..6b73bb3b --- /dev/null +++ b/internal/docgov/comment_matrix_test.go @@ -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) + } +} diff --git a/internal/docgov/engine.go b/internal/docgov/engine.go index 3204a648..489476f0 100644 --- a/internal/docgov/engine.go +++ b/internal/docgov/engine.go @@ -20,6 +20,8 @@ type GovernanceEngine struct { memAudits map[string][]*AuditLog onApprovedHook 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) OnFinalArtifactTX func(ctx context.Context, tx pgx.Tx, doc *DocumentRecord, markdown []byte, snapshotID int64) error @@ -27,6 +29,29 @@ type GovernanceEngine struct { 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 注册文档终审批准时的回调钩子 func (e *GovernanceEngine) SetOnApprovedHook(hook func(ctx context.Context, doc *DocumentRecord)) { e.mu.Lock() diff --git a/internal/docgov/engine_pending.go b/internal/docgov/engine_pending.go new file mode 100644 index 00000000..b08fb6bc --- /dev/null +++ b/internal/docgov/engine_pending.go @@ -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) + } +} diff --git a/internal/docgov/engine_proposal.go b/internal/docgov/engine_proposal.go index 65f5450e..b122bbab 100644 --- a/internal/docgov/engine_proposal.go +++ b/internal/docgov/engine_proposal.go @@ -196,8 +196,23 @@ func (e *GovernanceEngine) DecideModificationProposal( 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( ctx context.Context, docID string, @@ -215,6 +230,21 @@ func (e *GovernanceEngine) AddComment( g := e.acquireDocLock(docID) 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) if trimmed == "" { return nil, fmt.Errorf("comment content cannot be empty") @@ -233,6 +263,19 @@ func (e *GovernanceEngine) AddComment( 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) if e.pool != nil { @@ -253,6 +296,84 @@ func (e *GovernanceEngine) AddComment( 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 { now := time.Now() comment := &DocumentComment{ diff --git a/internal/docgov/engine_review_flow.go b/internal/docgov/engine_review_flow.go index b7ac06c3..7108f50b 100644 --- a/internal/docgov/engine_review_flow.go +++ b/internal/docgov/engine_review_flow.go @@ -9,6 +9,18 @@ import ( "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) func (e *GovernanceEngine) SubmitForReview(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string) (*DocumentRecord, error) { 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 { return nil, err } + g.Release() + e.emitLifecycle(ctx, "submit_review", doc, actorID, actorName, "") 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 { return nil, err } + g.Release() + e.emitLifecycle(ctx, "review_approve", doc, actorID, actorName, comment) 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 { 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 } @@ -130,8 +152,13 @@ func (e *GovernanceEngine) ApproverApprove(ctx context.Context, docID string, ac md, _ := e.LedgerMarkdown(ctx, doc.ReviewSnapshotID) e.OnFinalArtifact(ctx, doc, []byte(md), doc.ReviewSnapshotID) } + e.emitLifecycle(ctx, "approver_approve", doc, actorID, actorName, comment) g = e.acquireDocLock(docID) 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 @@ -158,6 +185,14 @@ func (e *GovernanceEngine) ApproverReject(ctx context.Context, docID string, act if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil { 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 } @@ -177,5 +212,11 @@ func (e *GovernanceEngine) ReopenForRevision(ctx context.Context, docID string, if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil { 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 } diff --git a/internal/docgov/lock_split.go b/internal/docgov/lock_split.go index 217aea66..8cff6749 100644 --- a/internal/docgov/lock_split.go +++ b/internal/docgov/lock_split.go @@ -9,7 +9,7 @@ import ( // 全局锁只保护「分锁表」本身(短临界区);文档间操作互不阻塞。 // 锁序约定:docLocks.meta → docID(如需多文档,按 docID 字典序)。 type docLockIndex struct { - meta sync.Mutex + meta sync.Mutex locks map[string]*sync.Mutex } diff --git a/internal/intent/dialogue_act.go b/internal/intent/dialogue_act.go index e78b5fd3..e413283c 100644 --- a/internal/intent/dialogue_act.go +++ b/internal/intent/dialogue_act.go @@ -10,11 +10,11 @@ import ( type DialogueAct string const ( - DialogueActAsk DialogueAct = "ask" - DialogueActCommand DialogueAct = "command" - DialogueActConfirm DialogueAct = "confirm" - DialogueActDecline DialogueAct = "decline" - DialogueActOther DialogueAct = "other" + DialogueActAsk DialogueAct = "ask" + DialogueActCommand DialogueAct = "command" + DialogueActConfirm DialogueAct = "confirm" + DialogueActDecline DialogueAct = "decline" + DialogueActOther DialogueAct = "other" ) // detectDialogueAct 识别查询/命令/确认/拒绝语气(规则轻量版) @@ -85,6 +85,7 @@ func isWriteIntent(t IntentType) bool { case IntentAdvanceStage, IntentTriggerTask, IntentTriggerAdhocTask, IntentSupplementInfo, IntentDocGovClaim, IntentDocGovSubmitReview, IntentDocGovReviewApprove, IntentDocGovReviewReject, IntentDocGovFinalApprove, IntentDocGovFinalReject, IntentDocGovProposalDecide, + IntentDocGovCommentAdd, IntentDocGovCommentResolve, IntentDocGovReopenRequest, IntentDocGovReopenApprove, IntentConfirmAction, IntentExecuteStageAdvance, IntentSwitchWorkflow, IntentTriggerWorkflow, IntentRunResume, IntentRunRestart, @@ -104,7 +105,7 @@ func IsQueryLegacyIntent(t IntentType) bool { func isQueryIntent(t IntentType) bool { switch t { case IntentQueryProgress, IntentQueryRegulations, IntentQuerySimilarCases, - IntentQueryUserRights, IntentQueryRejectReason, + IntentQueryUserRights, IntentQueryRejectReason, IntentQueryPendingSupplements, IntentQueryWorkflowStatus, IntentQueryDocStatus, IntentHelpCapabilities, IntentRunStatusQuery, IntentImpactAnalyze: return true diff --git a/internal/intent/hard_negative_test.go b/internal/intent/hard_negative_test.go index b5ef18cb..a8a22f11 100644 --- a/internal/intent/hard_negative_test.go +++ b/internal/intent/hard_negative_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "chat-agent-system/internal/maturity" "chat-agent-system/internal/intent" + "chat-agent-system/internal/maturity" ) type goldenFile struct { @@ -148,12 +148,16 @@ func assertGoldenExpect(t *testing.T, caseID string, exp *goldenExpect, res *int } if exp.MustNotWrite { writeIntents := map[intent.IntentType]bool{ - intent.IntentDocGovClaim: true, - intent.IntentDocGovSubmitReview: true, - intent.IntentDocGovReviewApprove: true, - intent.IntentDocGovReviewReject: true, - intent.IntentDocGovFinalApprove: true, - intent.IntentDocGovFinalReject: true, + intent.IntentDocGovClaim: true, + intent.IntentDocGovSubmitReview: true, + intent.IntentDocGovReviewApprove: true, + intent.IntentDocGovReviewReject: true, + intent.IntentDocGovFinalApprove: true, + intent.IntentDocGovFinalReject: true, + intent.IntentDocGovCommentAdd: true, + intent.IntentDocGovCommentResolve: true, + intent.IntentDocGovReopenRequest: true, + intent.IntentDocGovReopenApprove: true, } if writeIntents[res.TopIntent.Intent] { t.Errorf("[%s] must not route to write intent %s", caseID, res.TopIntent.Intent) diff --git a/internal/intent/recognizer_matchers_docgov.go b/internal/intent/recognizer_matchers_docgov.go index ef442fbe..513f499c 100644 --- a/internal/intent/recognizer_matchers_docgov.go +++ b/internal/intent/recognizer_matchers_docgov.go @@ -2,6 +2,7 @@ package intent import ( "fmt" + "regexp" "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, "批准生效") || - 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 slots := make(map[string]string) 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 } + +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 + } +} diff --git a/internal/intent/recognizer_test.go b/internal/intent/recognizer_test.go index 0bc5f4fd..78f58015 100644 --- a/internal/intent/recognizer_test.go +++ b/internal/intent/recognizer_test.go @@ -602,7 +602,6 @@ func TestIntentRecognition_FinalReject_MissingReason(t *testing.T) { } } - func TestIntentRecognition_SecurityFalsePositives_NotBlocked(t *testing.T) { rec := intent.NewRecognizer() testInputs := []struct { @@ -905,3 +904,105 @@ func TestIntentRecognition_ConsultativeStillAmbiguous(t *testing.T) { 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") + } +} diff --git a/internal/intent/registry.go b/internal/intent/registry.go index 91c50810..b1436c1e 100644 --- a/internal/intent/registry.go +++ b/internal/intent/registry.go @@ -16,7 +16,7 @@ const ( // ActionAlias 自然语言别名 type ActionAlias struct { - Phrase string `json:"phrase"` + Phrase string `json:"phrase"` Acts []DialogueAct `json:"acts,omitempty"` } @@ -29,30 +29,30 @@ type SlotSpec struct { // ActionSpec 可识别、可授权、可执行的动作单一事实源 type ActionSpec struct { - Key string `json:"key"` - Domain string `json:"domain"` - Object string `json:"object"` - Goal GoalType `json:"goal"` - Risk RiskLevel `json:"risk"` + Key string `json:"key"` + Domain string `json:"domain"` + Object string `json:"object"` + Goal GoalType `json:"goal"` + Risk RiskLevel `json:"risk"` - Aliases []ActionAlias `json:"aliases,omitempty"` - DialogueActs []DialogueAct `json:"dialogue_acts,omitempty"` + Aliases []ActionAlias `json:"aliases,omitempty"` + DialogueActs []DialogueAct `json:"dialogue_acts,omitempty"` - RequiredSlots []SlotSpec `json:"required_slots,omitempty"` - OptionalSlots []SlotSpec `json:"optional_slots,omitempty"` + RequiredSlots []SlotSpec `json:"required_slots,omitempty"` + OptionalSlots []SlotSpec `json:"optional_slots,omitempty"` - CapabilityKey string `json:"capability_key,omitempty"` - AllowedStates []string `json:"allowed_states,omitempty"` - RequiresConfirm bool `json:"requires_confirm,omitempty"` - IdempotencyMode string `json:"idempotency_mode,omitempty"` + CapabilityKey string `json:"capability_key,omitempty"` + AllowedStates []string `json:"allowed_states,omitempty"` + RequiresConfirm bool `json:"requires_confirm,omitempty"` + IdempotencyMode string `json:"idempotency_mode,omitempty"` - LegacyIntent IntentType `json:"legacy_intent"` + LegacyIntent IntentType `json:"legacy_intent"` } // ActionRegistry 动作注册表 type ActionRegistry struct { - mu sync.RWMutex - byKey map[string]*ActionSpec + mu sync.RWMutex + byKey map[string]*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.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.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: "case.query", Domain: "knowledge", Object: "artifact", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentQuerySimilarCases, CapabilityKey: "QUERY_SIMILAR_CASES", DialogueActs: []DialogueAct{DialogueActAsk}}, diff --git a/internal/intent/registry_test.go b/internal/intent/registry_test.go index 48a7631a..5b0db775 100644 --- a/internal/intent/registry_test.go +++ b/internal/intent/registry_test.go @@ -10,7 +10,9 @@ func TestDefaultRegistry_CoversAllLegacyIntents(t *testing.T) { IntentDocGovClaim, IntentDocGovSubmitReview, IntentDocGovReviewApprove, IntentDocGovReviewReject, IntentDocGovFinalApprove, IntentDocGovFinalReject, IntentDocGovProposalDecide, + IntentDocGovCommentAdd, IntentDocGovCommentResolve, IntentDocGovReopenRequest, IntentDocGovReopenApprove, IntentQueryRegulations, IntentQuerySimilarCases, IntentQueryUserRights, IntentQueryRejectReason, + IntentQueryPendingSupplements, IntentConfirmAction, IntentExecuteStageAdvance, IntentSecurityForbidden, IntentSwitchWorkflow, IntentTriggerWorkflow, IntentQueryWorkflowStatus, IntentQueryDocStatus, IntentHelpCapabilities, IntentUnsupportedAction, diff --git a/internal/intent/types.go b/internal/intent/types.go index b493c869..2d6425ec 100644 --- a/internal/intent/types.go +++ b/internal/intent/types.go @@ -24,12 +24,17 @@ const ( IntentDocGovFinalApprove IntentType = "DOCGOV_FINAL_APPROVE" IntentDocGovFinalReject IntentType = "DOCGOV_FINAL_REJECT" 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" - IntentQuerySimilarCases IntentType = "QUERY_SIMILAR_CASES" - IntentQueryUserRights IntentType = "QUERY_USER_RIGHTS" - IntentQueryRejectReason IntentType = "QUERY_REJECT_REASON" // 查询受控文档驳回/退回原因 + IntentQueryRegulations IntentType = "QUERY_REGULATIONS" + IntentQuerySimilarCases IntentType = "QUERY_SIMILAR_CASES" + IntentQueryUserRights IntentType = "QUERY_USER_RIGHTS" + IntentQueryRejectReason IntentType = "QUERY_REJECT_REASON" // 查询受控文档驳回/退回原因 + IntentQueryPendingSupplements IntentType = "QUERY_PENDING_SUPPLEMENTS" // 协同拍板与阶段执行动作 IntentConfirmAction IntentType = "CONFIRM_ACTION" // 用户拍板确认交互卡片 @@ -145,13 +150,16 @@ const ( SlotQueryTopic = "query_topic" // 查询主题 (如 progress, risk, talk_script, quotation) // 法定文档治理与权限槽位 - SlotTargetDoc = "target_doc" + SlotTargetDoc = "target_doc" // TargetDocLatestUpload 是「刚刚/最近上传」指代的哨兵目标,由服务端解析为项目最新 CAS 资产。 TargetDocLatestUpload = "@latest_upload" - SlotRejectReason = "reject_reason" - SlotTargetSection = "target_section" - SlotProposalAction = "proposal_action" - SlotForbiddenTarget = "forbidden_target" + SlotRejectReason = "reject_reason" + SlotTargetSection = "target_section" + SlotProposalAction = "proposal_action" + SlotForbiddenTarget = "forbidden_target" + SlotCommentContent = "comment_content" + SlotCommentType = "comment_type" // suggestion | issue | approval + SlotReopenReason = "reopen_reason" // 路由层槽位 SlotTaskBrief = "task_brief" // 自由任务简报(升级建议携带的用户原始诉求) diff --git a/internal/notification/digest.go b/internal/notification/digest.go index 1f6b2d2f..ac7ec8ea 100644 --- a/internal/notification/digest.go +++ b/internal/notification/digest.go @@ -9,37 +9,40 @@ import ( ) const ( - DigestKindProposalRequired = "proposal_required" - DigestKindUnclaimedEdit = "unclaimed_edit" - SeverityActionRequired = "action_required" - SeverityInfo = "info" - maxDigestItems = 20 + DigestKindProposalRequired = "proposal_required" + DigestKindUnclaimedEdit = "unclaimed_edit" + DigestKindPendingSupplements = "pending_supplements" + DigestKindReviewRequested = "review_requested" + DigestKindApprovalRequested = "approval_requested" + SeverityActionRequired = "action_required" + SeverityInfo = "info" + maxDigestItems = 20 ) // DigestItem is one occurrence folded into a daily digest. type DigestItem struct { - At time.Time `json:"at"` - ActorID int64 `json:"actor_id"` - ActorName string `json:"actor_name"` - Summary string `json:"summary"` - ProposalID string `json:"proposal_id,omitempty"` - ChangeCount int `json:"change_count,omitempty"` + At time.Time `json:"at"` + ActorID int64 `json:"actor_id"` + ActorName string `json:"actor_name"` + Summary string `json:"summary"` + ProposalID string `json:"proposal_id,omitempty"` + ChangeCount int `json:"change_count,omitempty"` } // UpsertDigestParams drives ON CONFLICT DO UPDATE aggregation. type UpsertDigestParams struct { - UserID int64 - ProjectID int64 - DigestKind string - DocID string - DocTitle string - Severity string - ActorID int64 - ActorName string - Summary string - ProposalID string - ChangeCount int - WindowDate time.Time // truncated to date; zero → today UTC + UserID int64 + ProjectID int64 + DigestKind string + DocID string + DocTitle string + Severity string + ActorID int64 + ActorName string + Summary string + ProposalID string + ChangeCount int + WindowDate time.Time // truncated to date; zero → today UTC } // UpsertDocumentDigest aggregates same-day notifications per (user, kind, doc). @@ -140,6 +143,12 @@ func typeForDigestKind(kind string) string { return TypeDocProposalRequired case DigestKindUnclaimedEdit: return TypeDocUnclaimedEditApplied + case DigestKindPendingSupplements: + return TypeDocPendingSupplements + case DigestKindReviewRequested: + return TypeDocReviewRequested + case DigestKindApprovalRequested: + return TypeDocApprovalRequested default: return kind } @@ -170,6 +179,19 @@ func digestCopy(p UpsertDigestParams, count int) (title, body string) { } return fmt.Sprintf("《%s》今日已有 %d 次更新", titleDoc, 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: return titleDoc, summary } diff --git a/internal/notification/document_events.go b/internal/notification/document_events.go index 0b0ef62d..2920d192 100644 --- a/internal/notification/document_events.go +++ b/internal/notification/document_events.go @@ -14,16 +14,27 @@ const ( EventDocHumanRevisionConflict = "document.human_revision_conflict" EventDocDownstreamImpact = "document.downstream_impact_detected" EventDocChangeReportDegraded = "document.change_report_degraded" - EventDocProposalRequired = "document.proposal_required" - EventDocUnclaimedEditApplied = "document.unclaimed_edit_applied" + EventDocProposalRequired = "document.proposal_required" + 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" TypeDocAgentVersionCommitted = "document_agent_version_committed" TypeDocDownstreamImpact = "document_downstream_impact" TypeDocChangeReportDegraded = "document_change_report_degraded" TypeDocApproved = "document_approved" - TypeDocProposalRequired = "document_proposal_required" - TypeDocUnclaimedEditApplied = "document_unclaimed_edit_applied" + TypeDocProposalRequired = "document_proposal_required" + 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). diff --git a/internal/server/chat_stream_get_message_test.go b/internal/server/chat_stream_get_message_test.go index 37182501..70f5e163 100644 --- a/internal/server/chat_stream_get_message_test.go +++ b/internal/server/chat_stream_get_message_test.go @@ -34,7 +34,7 @@ func TestChatStream_GetMessageQueryRejected(t *testing.T) { } // TestChatStream_GetWithoutMessageNotLeak 无 message 的 GET 不触发 B11 分支 -//(进入后续 message cannot be empty 的统一拒绝,同样不产生 SSE 头)。 +// (进入后续 message cannot be empty 的统一拒绝,同样不产生 SSE 头)。 func TestChatStream_GetWithoutMessageNotLeak(t *testing.T) { cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"} svc := bizdev.MustNewService(t, cfg) diff --git a/internal/server/handlers_docgov.go b/internal/server/handlers_docgov.go index 6d24524a..e20a06e2 100644 --- a/internal/server/handlers_docgov.go +++ b/internal/server/handlers_docgov.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "strings" "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) } +// 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 查询文档全生命周期审计轨迹流 func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Request) { docID := r.URL.Query().Get("doc_id") diff --git a/internal/server/notification_handlers.go b/internal/server/notification_handlers.go index 2ce03cee..09abe4da 100644 --- a/internal/server/notification_handlers.go +++ b/internal/server/notification_handlers.go @@ -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). type userNotificationView struct { - ID int64 `json:"id"` - UserID int64 `json:"user_id"` - ProjectID int64 `json:"project_id"` - EventID int64 `json:"event_id,omitempty"` - Type string `json:"type"` - Title string `json:"title"` - Body string `json:"body"` - Status string `json:"status"` - Severity string `json:"severity,omitempty"` - DigestID int64 `json:"digest_id,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + ProjectID int64 `json:"project_id"` + EventID int64 `json:"event_id,omitempty"` + Type string `json:"type"` + Title string `json:"title"` + Body string `json:"body"` + Status string `json:"status"` + Severity string `json:"severity,omitempty"` + DigestID int64 `json:"digest_id,omitempty"` + CreatedAt time.Time `json:"created_at"` Payload map[string]any `json:"payload,omitempty"` } diff --git a/internal/server/runtime_accessors.go b/internal/server/runtime_accessors.go index 50876a38..d576782f 100644 --- a/internal/server/runtime_accessors.go +++ b/internal/server/runtime_accessors.go @@ -30,28 +30,28 @@ import ( // 生产路径恒为空。显式置 nil 用 ptr 形(authorizerPtr 等)以区分「未设置」与「设为 nil」。 type serverTestOverrides struct { - svc *bizdev.Service - pwSvc *projectworkflow.Service - engine *coordinator.CoordinatorEngine - authSvc *auth.Service - taskRuns *taskrun.Manager - authorizer *authz.Authorizer - authzSvc *authz.Service - wfDefSvc *workflowdef.Service - instSvc *projectinstance.Service - notifySvc *notification.Service - resolver *workspace.Resolver - wsCap *workspace.Capability - cmds *taskrun.Commands - stageflow *stageflow.UseCase - gov *docgov.GovernanceEngine - rev *docrevision.Service - ctxMgr *ctxmgr.ContextManager - cas *storage.CAS - freeDoc *freedoc.Service - kb *kb.Client - sysadmin *sysadmin.Service - maintenance *maintenance.Service + svc *bizdev.Service + pwSvc *projectworkflow.Service + engine *coordinator.CoordinatorEngine + authSvc *auth.Service + taskRuns *taskrun.Manager + authorizer *authz.Authorizer + authzSvc *authz.Service + wfDefSvc *workflowdef.Service + instSvc *projectinstance.Service + notifySvc *notification.Service + resolver *workspace.Resolver + wsCap *workspace.Capability + cmds *taskrun.Commands + stageflow *stageflow.UseCase + gov *docgov.GovernanceEngine + rev *docrevision.Service + ctxMgr *ctxmgr.ContextManager + cas *storage.CAS + freeDoc *freedoc.Service + kb *kb.Client + sysadmin *sysadmin.Service + maintenance *maintenance.Service // 显式置 nil 的位标记(区分「未设置」与「设为 nil」,fail-closed 测试用)。 authorizerNil bool diff --git a/internal/server/server.go b/internal/server/server.go index a7eabba8..517fccf4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -34,10 +34,10 @@ import ( // Pool 与领域服务装配归属 app.Runtime;本结构仅保留 HTTP 适配所需句柄。 // 残留具体领域字段见 short-term 任务记录(增量收口,本批先去掉 *pgxpool.Pool)。 type Server struct { - rt *app.Runtime - cfg *config.Config - sessions map[string]*chat.Session - sessionsMu sync.RWMutex + rt *app.Runtime + cfg *config.Config + sessions map[string]*chat.Session + sessionsMu sync.RWMutex supplementImpact SupplementImpactService // intentPlansOverride 仅测试注入用(SetIntentPlansForTest);为空回落 rt.IntentPlans。 intentPlansOverride intentflow.Service @@ -48,12 +48,12 @@ type Server struct { reminderLoop backgroundLoop vaultLoop backgroundLoop chatTurnSweepLoop backgroundLoop - chatLog chatflow.ChatLog - assembler chatflow.ContextAssembler - listener net.Listener - lastMaintToken string - wsReg *workspace.FileRegistry - httpServer *http.Server + chatLog chatflow.ChatLog + assembler chatflow.ContextAssembler + listener net.Listener + lastMaintToken string + wsReg *workspace.FileRegistry + httpServer *http.Server planAccessFn func(ctx context.Context, projectID, userID 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") } s := &Server{ - rt: rt, - cfg: rt.Cfg, - sessions: make(map[string]*chat.Session), - wsReg: workspaceRegistryFor(rt.Cfg), + rt: rt, + cfg: rt.Cfg, + sessions: make(map[string]*chat.Session), + wsReg: workspaceRegistryFor(rt.Cfg), // 登录/注册限流:10 次 / 5 分钟 / (IP+username) 滑动窗口 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("GET /api/v1/docgov/comments", s.handleDocGovListComments) 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("POST /api/v1/docgov/content", s.handleDocGovSaveContent) mux.HandleFunc("POST /api/v1/docgov/lease/acquire", s.handleDocGovAcquireLease) @@ -618,6 +619,9 @@ func (s *Server) getOrCreateSession( Workflow: adaptChatWorkflow(s.pwSvc()), Environment: env, } + if s.rt != nil { + sess.Actions.Reopens = s.rt.ChatReopens + } if s.vaultIngest != nil { ingest := s.vaultIngest sess.OnFinalArtifact = func(ctx context.Context, projectID int64, logical, eventID string, msgID int64, policy string, payload []byte) { diff --git a/internal/server/supplement_impact_adapter.go b/internal/server/supplement_impact_adapter.go index cff92cc2..7044b71a 100644 --- a/internal/server/supplement_impact_adapter.go +++ b/internal/server/supplement_impact_adapter.go @@ -50,8 +50,8 @@ func mapSupplementBatch(b *supplementimpact.Batch) *SupplementImpactBatchView { out.Items = append(out.Items, SupplementImpactItemView{ DocID: it.DocID, Title: it.Title, ItemKey: it.ItemKey, ImpactType: it.ImpactType, Depth: it.Depth, Reason: it.Reason, AuthorClaimedSnapshot: it.AuthorClaimedSnapshot, - BusySnapshot: it.BusySnapshot, ChangeSummary: it.ChangeSummary, Selected: it.Selected, - Outcome: it.Outcome, ProposalID: it.ProposalID, + BusySnapshot: it.BusySnapshot, LockedSnapshot: it.LockedSnapshot, ChangeSummary: it.ChangeSummary, + Selected: it.Selected, Outcome: it.Outcome, ProposalID: it.ProposalID, }) } return out diff --git a/internal/server/supplement_impact_handlers.go b/internal/server/supplement_impact_handlers.go index ab749290..37c53bac 100644 --- a/internal/server/supplement_impact_handlers.go +++ b/internal/server/supplement_impact_handlers.go @@ -42,6 +42,7 @@ type SupplementImpactItemView struct { Reason string `json:"reason,omitempty"` AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"` BusySnapshot bool `json:"busy_snapshot"` + LockedSnapshot bool `json:"locked_snapshot"` ChangeSummary string `json:"change_summary,omitempty"` Selected bool `json:"selected"` Outcome string `json:"outcome,omitempty"` diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index cac46819..f59a2756 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -455,11 +455,11 @@ func (s *Server) preflightTaskDocumentBusy(ctx context.Context, req createTaskRu expiresAt = *doc.LeaseExpiresAt } display := docgov.LeaseHolderDisplay(doc.LeaseHolderType, doc.LeaseHolderID, "") - exp := time.Time{} - if doc.LeaseExpiresAt != nil { - exp = *doc.LeaseExpiresAt - } - reason := docgov.FormatDocumentBusyReason(doc.Title, display, exp) + exp := time.Time{} + if doc.LeaseExpiresAt != nil { + exp = *doc.LeaseExpiresAt + } + reason := docgov.FormatDocumentBusyReason(doc.Title, display, exp) return &docgov.DocumentBusyError{ DocID: doc.DocID, HolderType: doc.LeaseHolderType, diff --git a/internal/server/trace_handlers.go b/internal/server/trace_handlers.go index c9af3dd3..30a0b77f 100644 --- a/internal/server/trace_handlers.go +++ b/internal/server/trace_handlers.go @@ -1,10 +1,13 @@ package server import ( + "context" "encoding/json" + "fmt" "io" "net/http" "strconv" + "strings" "chat-agent-system/internal/auth" "chat-agent-system/internal/traceimpact" @@ -148,11 +151,6 @@ func (s *Server) handleProjectTraceReopenApprove(w http.ResponseWriter, r *http. if !ok { 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) if err != nil || requestID <= 0 { 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":"申请不属于该项目"}`) 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) if err != nil { 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) } +// 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 { if docID == "" || docID == "chat" { return true diff --git a/internal/supplementimpact/http_adapter.go b/internal/supplementimpact/http_adapter.go index 39138b55..1cbe2234 100644 --- a/internal/supplementimpact/http_adapter.go +++ b/internal/supplementimpact/http_adapter.go @@ -8,19 +8,19 @@ import ( // HTTPBatchView is the JSON shape expected by server supplement-impact handlers. type HTTPBatchView struct { - BatchID string `json:"batch_id"` - ProjectID int64 `json:"project_id"` - SessionID string `json:"session_id,omitempty"` - InitiatorUserID int64 `json:"initiator_user_id"` - SourceKind string `json:"source_kind,omitempty"` - SourceSummary string `json:"source_summary,omitempty"` - CoverageStatus string `json:"coverage_status,omitempty"` - Status string `json:"status"` - DecisionNonce string `json:"decision_nonce"` - DecisionDigest string `json:"decision_digest,omitempty"` - Revision int64 `json:"revision"` - ExpiresAt time.Time `json:"expires_at"` - Items []HTTPItemView `json:"items,omitempty"` + BatchID string `json:"batch_id"` + ProjectID int64 `json:"project_id"` + SessionID string `json:"session_id,omitempty"` + InitiatorUserID int64 `json:"initiator_user_id"` + SourceKind string `json:"source_kind,omitempty"` + SourceSummary string `json:"source_summary,omitempty"` + CoverageStatus string `json:"coverage_status,omitempty"` + Status string `json:"status"` + DecisionNonce string `json:"decision_nonce"` + DecisionDigest string `json:"decision_digest,omitempty"` + Revision int64 `json:"revision"` + ExpiresAt time.Time `json:"expires_at"` + Items []HTTPItemView `json:"items,omitempty"` } type HTTPItemView struct { @@ -32,6 +32,7 @@ type HTTPItemView struct { Reason string `json:"reason,omitempty"` AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"` BusySnapshot bool `json:"busy_snapshot"` + LockedSnapshot bool `json:"locked_snapshot"` ChangeSummary string `json:"change_summary,omitempty"` Selected bool `json:"selected"` Outcome string `json:"outcome,omitempty"` @@ -90,8 +91,8 @@ func toHTTPView(b *Batch) *HTTPBatchView { v.Items = append(v.Items, HTTPItemView{ DocID: it.DocID, Title: it.Title, ItemKey: it.ItemKey, ImpactType: it.ImpactType, Depth: it.Depth, Reason: it.Reason, AuthorClaimedSnapshot: it.AuthorClaimedSnapshot, - BusySnapshot: it.BusySnapshot, ChangeSummary: it.ChangeSummary, Selected: it.Selected, - Outcome: it.Outcome, ProposalID: it.ProposalID, + BusySnapshot: it.BusySnapshot, LockedSnapshot: it.LockedSnapshot, ChangeSummary: it.ChangeSummary, + Selected: it.Selected, Outcome: it.Outcome, ProposalID: it.ProposalID, }) } return v diff --git a/internal/supplementimpact/pg_store.go b/internal/supplementimpact/pg_store.go index fa78debc..577b0768 100644 --- a/internal/supplementimpact/pg_store.go +++ b/internal/supplementimpact/pg_store.go @@ -45,12 +45,12 @@ func (p *PGStore) InsertBatch(ctx context.Context, b *Batch) error { err = tx.QueryRow(ctx, ` INSERT INTO supplement_impact_items ( 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 - ) 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 `, 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) if err != nil { return err @@ -77,7 +77,7 @@ func (p *PGStore) GetBatch(ctx context.Context, batchID string) (*Batch, error) } rows, err := p.pool.Query(ctx, ` 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 FROM supplement_impact_items WHERE batch_id = $1 ORDER BY id `, batchID) @@ -89,7 +89,7 @@ func (p *PGStore) GetBatch(ctx context.Context, batchID string) (*Batch, error) var it Item if err := rows.Scan( &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, ); err != nil { return nil, err diff --git a/internal/supplementimpact/service.go b/internal/supplementimpact/service.go index bc6a110f..bb6ac6cb 100644 --- a/internal/supplementimpact/service.go +++ b/internal/supplementimpact/service.go @@ -41,14 +41,20 @@ type Proposer interface { 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. type Service struct { - Store Store - Docs DocLookup - Analyze Analyzer - Notify Notifier - Propose Proposer - Now func() time.Time + Store Store + Docs DocLookup + Analyze Analyzer + Notify Notifier + Propose Proposer + Pending PendingLedger + Now func() 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.AuthorClaimedSnapshot = doc.AuthorClaimed item.AuthorIDSnapshot = doc.AuthorID + item.ReviewerIDSnapshot = doc.ReviewerID + item.ApproverIDSnapshot = doc.ApproverID + item.CurrentStateSnapshot = string(doc.CurrentState) item.BusySnapshot = doc.HasActiveLease(now) + item.LockedSnapshot = docIsLifecycleLocked(doc) } } b.Items = append(b.Items, item) @@ -126,6 +136,16 @@ func (s *Service) CreateFromSupplement(ctx context.Context, projectID, userID in 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. func (s *Service) Decide(ctx context.Context, cmd DecisionCommand) (*Batch, error) { 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] { 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 { _ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeSkippedBusy, "", "") continue @@ -187,6 +213,57 @@ func (s *Service) Decide(ctx context.Context, cmd DecisionCommand) (*Batch, erro 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 { if it.AuthorClaimedSnapshot { 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, DigestKind: notification.DigestKindProposalRequired, DocID: it.DocID, DocTitle: it.Title, 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, }) } @@ -216,7 +293,7 @@ func (s *Service) executeItem(ctx context.Context, b *Batch, it *Item) error { UserID: it.AuthorIDSnapshot, ProjectID: b.ProjectID, DigestKind: notification.DigestKindUnclaimedEdit, DocID: it.DocID, DocTitle: it.Title, 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, }) } diff --git a/internal/supplementimpact/service_test.go b/internal/supplementimpact/service_test.go index 45c42ed2..9d851881 100644 --- a/internal/supplementimpact/service_test.go +++ b/internal/supplementimpact/service_test.go @@ -17,7 +17,9 @@ func (s stubAnalyzer) Analyze(_ context.Context, _ int64, _ traceimpact.DocRef, 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) { return s.docs[id], nil @@ -81,7 +83,7 @@ func TestDecideClaimedProducesProposal(t *testing.T) { }, }}, 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, 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) + } +} diff --git a/internal/supplementimpact/types.go b/internal/supplementimpact/types.go index 3e031820..8f83adb0 100644 --- a/internal/supplementimpact/types.go +++ b/internal/supplementimpact/types.go @@ -11,60 +11,64 @@ const ( StatusExpired = "expired" StatusCancelled = "cancelled" - OutcomeApplied = "applied" - OutcomeProposalPending = "proposal_pending" - OutcomeProposalRejected = "proposal_rejected" - OutcomeSkippedBusy = "skipped_busy" - OutcomeSkippedLocked = "skipped_locked" - OutcomeFailed = "failed" + OutcomeApplied = "applied" + OutcomeProposalPending = "proposal_pending" + OutcomeProposalRejected = "proposal_rejected" + OutcomeSkippedBusy = "skipped_busy" + OutcomeSkippedLocked = "skipped_locked" + OutcomeFailed = "failed" BatchTTL = 24 * time.Hour ) // Batch is one supplement → impact analysis confirmation unit. type Batch struct { - BatchID string `json:"batch_id"` - ProjectID int64 `json:"project_id"` - SessionID string `json:"session_id"` - InitiatorUserID int64 `json:"initiator_user_id"` - SourceKind string `json:"source_kind"` - SourceSummary string `json:"source_summary"` - CoverageStatus string `json:"coverage_status"` - Status string `json:"status"` - DecisionNonce string `json:"decision_nonce"` - DecisionDigest string `json:"decision_digest"` - Revision int64 `json:"revision"` - ExpiresAt time.Time `json:"expires_at"` - Items []Item `json:"items,omitempty"` + BatchID string `json:"batch_id"` + ProjectID int64 `json:"project_id"` + SessionID string `json:"session_id"` + InitiatorUserID int64 `json:"initiator_user_id"` + SourceKind string `json:"source_kind"` + SourceSummary string `json:"source_summary"` + CoverageStatus string `json:"coverage_status"` + Status string `json:"status"` + DecisionNonce string `json:"decision_nonce"` + DecisionDigest string `json:"decision_digest"` + Revision int64 `json:"revision"` + ExpiresAt time.Time `json:"expires_at"` + Items []Item `json:"items,omitempty"` } // Item is one affected document inside a batch. type Item struct { - ID int64 `json:"id"` - BatchID string `json:"batch_id"` - DocID string `json:"doc_id"` - Title string `json:"title"` - ItemKey string `json:"item_key"` - ImpactType string `json:"impact_type"` - Depth int `json:"depth"` - Reason string `json:"reason"` - AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"` - AuthorIDSnapshot int64 `json:"author_id_snapshot"` - BusySnapshot bool `json:"busy_snapshot"` - ChangeSummary string `json:"change_summary"` - Selected bool `json:"selected"` - Outcome string `json:"outcome"` - ProposalID string `json:"proposal_id"` - RunID string `json:"run_id"` + ID int64 `json:"id"` + BatchID string `json:"batch_id"` + DocID string `json:"doc_id"` + Title string `json:"title"` + ItemKey string `json:"item_key"` + ImpactType string `json:"impact_type"` + Depth int `json:"depth"` + Reason string `json:"reason"` + AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"` + AuthorIDSnapshot int64 `json:"author_id_snapshot"` + BusySnapshot bool `json:"busy_snapshot"` + LockedSnapshot bool `json:"locked_snapshot"` + ReviewerIDSnapshot int64 `json:"reviewer_id_snapshot,omitempty"` + ApproverIDSnapshot int64 `json:"approver_id_snapshot,omitempty"` + CurrentStateSnapshot string `json:"current_state_snapshot,omitempty"` + 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). type DecisionCommand struct { - BatchID string - ActorUserID int64 - Kind string // confirm | cancel - SelectedDocIDs []string - DecisionNonce string - DecisionDigest string - ExpectedRevision int64 + BatchID string + ActorUserID int64 + Kind string // confirm | cancel + SelectedDocIDs []string + DecisionNonce string + DecisionDigest string + ExpectedRevision int64 } diff --git a/internal/traceimpact/pg_store.go b/internal/traceimpact/pg_store.go index 654517d3..dd578e74 100644 --- a/internal/traceimpact/pg_store.go +++ b/internal/traceimpact/pg_store.go @@ -222,6 +222,28 @@ func (p *pgStore) GetReopen(ctx context.Context, id int64) (ReopenRequest, error 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) { if mark.Status == "" { mark.Status = MarkOpen diff --git a/internal/traceimpact/service.go b/internal/traceimpact/service.go index c97f8ff3..eb84daf3 100644 --- a/internal/traceimpact/service.go +++ b/internal/traceimpact/service.go @@ -506,6 +506,17 @@ func (s *Service) GetReopen(ctx context.Context, id int64) (ReopenRequest, error 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. var ErrNeedsConfirm = fmt.Errorf("traceimpact: confirmation required") diff --git a/internal/traceimpact/store.go b/internal/traceimpact/store.go index b28ef2bb..0f94dec8 100644 --- a/internal/traceimpact/store.go +++ b/internal/traceimpact/store.go @@ -20,6 +20,7 @@ type Store interface { ListDecisions(ctx context.Context, projectID int64) ([]Decision, error) PutReopen(ctx context.Context, req ReopenRequest) (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) ListOpenMarks(ctx context.Context, projectID int64, stageKey string) ([]RecheckMark, 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 } +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) { m.mu.Lock() defer m.mu.Unlock() diff --git a/web/src/App.tsx b/web/src/App.tsx index 72ef1ad3..312d26de 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1393,12 +1393,14 @@ export default function App() { handleRejectReview, handleApprove, handleRejectApprove, + handleRequestReopen, } = useDocumentLifecycleActions({ docAST, setDocAST, activeDocId, currentUser, activeSessionId, + projectId: currentProjectId, setDeliverables, setMessages, }); @@ -1698,6 +1700,7 @@ export default function App() { handleRejectReview={handleRejectReview} handleApprove={handleApprove} handleRejectApprove={handleRejectApprove} + handleRequestReopen={handleRequestReopen} editorSession={editorSession} handleSaveBlockEdit={handleSaveBlockEdit} closeBlockEditor={closeBlockEditor} diff --git a/web/src/api/docgov.ts b/web/src/api/docgov.ts index 411b5797..970d1e66 100644 --- a/web/src/api/docgov.ts +++ b/web/src/api/docgov.ts @@ -180,3 +180,45 @@ export function mapLeaseToAstEditLease(doc: { 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 { + 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 { + return postDocGov('/api/v1/docgov/comments', payload); +} + +export async function resolveDocGovComment( + commentId: number, + payload: { doc_id: string; resolve?: boolean }, +): Promise { + return postDocGov(`/api/v1/docgov/comments/${commentId}/resolve`, payload); +} diff --git a/web/src/api/projectTrace.ts b/web/src/api/projectTrace.ts index 2320e7dc..cc239be4 100644 --- a/web/src/api/projectTrace.ts +++ b/web/src/api/projectTrace.ts @@ -63,3 +63,56 @@ export async function clearRecheckMark( 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 { + 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 { + 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(); +} diff --git a/web/src/components/doc/CommentSidePanel.tsx b/web/src/components/doc/CommentSidePanel.tsx index b0fb95b4..2f558d0c 100644 --- a/web/src/components/doc/CommentSidePanel.tsx +++ b/web/src/components/doc/CommentSidePanel.tsx @@ -8,6 +8,7 @@ interface CommentSidePanelProps { block: DocumentBlock; currentRole: UserRole; currentUserName: string; + docLifecycleState?: string; onAddComment: (comment: Omit) => void; onClose: () => void; } @@ -16,6 +17,7 @@ export const CommentSidePanel: React.FC = ({ block, currentRole, currentUserName, + docLifecycleState = 'drafting', onAddComment, onClose, }) => { @@ -23,7 +25,7 @@ export const CommentSidePanel: React.FC = ({ const [type, setType] = useState<'suggestion' | 'issue' | 'approval'>('suggestion'); const comments = block.comments || []; - const canCompose = canAddBlockComment(currentRole); + const canCompose = canAddBlockComment(currentRole, docLifecycleState); const handleSend = () => { if (!canCompose || !content.trim()) return; @@ -32,7 +34,7 @@ export const CommentSidePanel: React.FC = ({ onAddComment({ authorName: currentUserName, - authorRole, + authorRole: authorRole === 'author' ? 'reviewer' : authorRole, type, content: content.trim(), resolved: false, diff --git a/web/src/components/doc/DocGovActionBar.tsx b/web/src/components/doc/DocGovActionBar.tsx index d11b8284..c7ade995 100644 --- a/web/src/components/doc/DocGovActionBar.tsx +++ b/web/src/components/doc/DocGovActionBar.tsx @@ -29,6 +29,7 @@ interface DocGovActionBarProps { onRejectApprove: (reason: string) => void; onShareToProject?: (docId: string) => void; onRevokeShare?: (docId: string) => void; + onRequestReopen?: () => void; } export const DocGovActionBar: React.FC = ({ @@ -45,6 +46,7 @@ export const DocGovActionBar: React.FC = ({ onRejectApprove, onShareToProject, onRevokeShare, + onRequestReopen, }) => { const [rejectModalOpen, setRejectModalOpen] = useState(false); const [rejectReason, setRejectReason] = useState(''); @@ -637,7 +639,7 @@ export const DocGovActionBar: React.FC = ({ style={{ display: 'flex', alignItems: 'center', - gap: '6px', + gap: '8px', color: 'var(--success)', fontSize: '12.5px', fontWeight: 600, @@ -645,6 +647,28 @@ export const DocGovActionBar: React.FC = ({ > 受控归档交付物 (已沉淀为全局标杆经验) + {onRequestReopen && + (currentUserId === ast.authorId || + currentUserId === ast.approverId || + currentUserId === ast.creatorId) && ( + + )} )} diff --git a/web/src/components/doc/DocumentPane.tsx b/web/src/components/doc/DocumentPane.tsx index e1ccf26c..accb8a69 100644 --- a/web/src/components/doc/DocumentPane.tsx +++ b/web/src/components/doc/DocumentPane.tsx @@ -70,6 +70,7 @@ export interface DocumentPaneProps { handleRejectReview: (reason: string) => void | Promise; handleApprove: () => void | Promise; handleRejectApprove: (reason: string) => void | Promise; + handleRequestReopen?: () => void | Promise; editorSession: BlockEditorSession | null; handleSaveBlockEdit: (updated: DocumentBlock) => void | Promise; closeBlockEditor: () => void; @@ -122,6 +123,7 @@ export function DocumentPane(props: DocumentPaneProps) { handleRejectReview, handleApprove, handleRejectApprove, + handleRequestReopen, editorSession, handleSaveBlockEdit, closeBlockEditor, @@ -390,6 +392,7 @@ isCurrentProjectExited ? ( onRejectApprove={handleRejectApprove} onShareToProject={handleShareToProject} onRevokeShare={handleRevokeShare} + onRequestReopen={handleRequestReopen} /> )} @@ -413,6 +416,7 @@ isCurrentProjectExited ? ( block={commentingBlock} currentRole={currentDocRole} currentUserName={currentUser.name} + docLifecycleState={docAST.lifecycleState} onAddComment={handleAddComment} onClose={() => setCommentingBlock(null)} /> diff --git a/web/src/hooks/useBlockEditing.ts b/web/src/hooks/useBlockEditing.ts index 2015ac19..5c8a99cd 100644 --- a/web/src/hooks/useBlockEditing.ts +++ b/web/src/hooks/useBlockEditing.ts @@ -10,6 +10,7 @@ import type { BlockEditorSession } from '../lib/blockSavePolicy'; import type { BlockComment, DocumentAST, DocumentBlock } from '../types/block'; import type { UserProfile, UserRole } from '../types/docgov'; import type { DeliverableItem } from '../types/workbench'; +import { addDocGovComment } from '../api/docgov'; type Gate = ReturnType; @@ -124,14 +125,29 @@ export function useBlockEditing(input: BlockEditingInput): BlockEditingActions { }; const handleAddComment = (comment: Omit) => { - if (!commentingBlock || !canAddBlockComment(currentDocRole)) return; - const newCmt: BlockComment = { - ...comment, - id: 'cmt_' + Math.random().toString(36).substring(2, 8), - createdAt: new Date().toTimeString().substring(0, 5), - }; - commentingBlock.comments = [...(commentingBlock.comments || []), newCmt]; - setDocAST({ ...docAST }); + if (!commentingBlock || !canAddBlockComment(currentDocRole, docAST.lifecycleState || 'drafting')) return; + void (async () => { + try { + const saved = await addDocGovComment({ + doc_id: activeDocId, + target_section: commentingBlock.id, + comment_type: comment.type || 'suggestion', + 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 { diff --git a/web/src/hooks/useDocumentLifecycleActions.ts b/web/src/hooks/useDocumentLifecycleActions.ts index 948178e4..8c0848f1 100644 --- a/web/src/hooks/useDocumentLifecycleActions.ts +++ b/web/src/hooks/useDocumentLifecycleActions.ts @@ -7,6 +7,8 @@ import { submitForReview, type DocGovRecord, } from '../api/docgov'; +import { requestReopen } from '../api/projectTrace'; +import { fetchProjectSettings } from '../api/projectSettings'; import type { DocumentAST } from '../types/block'; import type { DocLifecycleState, UserProfile } from '../types/docgov'; import type { ChatMessage, DeliverableItem } from '../types/workbench'; @@ -17,6 +19,8 @@ export interface DocumentLifecycleInput { activeDocId: string; currentUser: UserProfile; activeSessionId: string; + projectId?: number; + workflowInstanceId?: number; setDeliverables: Dispatch>; setMessages: Dispatch>; } @@ -28,6 +32,7 @@ export interface DocumentLifecycleActions { handleRejectReview: (reason: string) => Promise; handleApprove: () => Promise; handleRejectApprove: (reason: string) => Promise; + handleRequestReopen: () => Promise; } export function useDocumentLifecycleActions(input: DocumentLifecycleInput): DocumentLifecycleActions { @@ -37,6 +42,8 @@ export function useDocumentLifecycleActions(input: DocumentLifecycleInput): Docu activeDocId, currentUser, activeSessionId, + projectId, + workflowInstanceId, setDeliverables, setMessages, } = 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 { handleClaim, handleSubmitReview, @@ -233,5 +289,6 @@ export function useDocumentLifecycleActions(input: DocumentLifecycleInput): Docu handleRejectReview, handleApprove, handleRejectApprove, + handleRequestReopen, }; } diff --git a/web/src/lib/commentPolicy.test.ts b/web/src/lib/commentPolicy.test.ts index 17881a73..28c3dd0e 100644 --- a/web/src/lib/commentPolicy.test.ts +++ b/web/src/lib/commentPolicy.test.ts @@ -2,21 +2,36 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { canAddBlockComment, + canResolveComment, resolveCommentAuthorRole, } from './commentPolicy'; describe('commentPolicy', () => { - it('lets author/reviewer/approver annotate and keeps their comment role', () => { - assert.equal(canAddBlockComment('author'), true); - assert.equal(canAddBlockComment('reviewer'), true); - assert.equal(canAddBlockComment('approver'), true); - assert.equal(resolveCommentAuthorRole('author'), 'reviewer'); + it('applies stage matrix for add', () => { + assert.equal(canAddBlockComment('author', 'drafting'), true); + assert.equal(canAddBlockComment('reviewer', 'drafting'), true); + assert.equal(canAddBlockComment('approver', 'drafting'), false); + 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('approver'), 'approver'); }); - it('keeps viewer read-only: no add entry and no reviewer/approver cast', () => { - assert.equal(canAddBlockComment('viewer'), false); - assert.equal(resolveCommentAuthorRole('viewer'), null); + it('gates resolve by stage', () => { + assert.equal(canResolveComment('author', 'drafting'), true); + assert.equal(canResolveComment('author', 'reviewing'), false); + assert.equal(canResolveComment('reviewer', 'reviewing'), true); }); }); diff --git a/web/src/lib/commentPolicy.ts b/web/src/lib/commentPolicy.ts index de1d1a83..4d45b01b 100644 --- a/web/src/lib/commentPolicy.ts +++ b/web/src/lib/commentPolicy.ts @@ -1,15 +1,42 @@ 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 只能读批注,不能打开新增入口或提交。 */ -export function canAddBlockComment(role: UserRole | string): boolean { - return role === 'author' || role === 'reviewer' || role === 'approver'; +/** Stage-scoped comment matrix (viewer always read-only). */ +export function canAddBlockComment( + 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 { if (role === 'approver') return 'approver'; - if (role === 'reviewer' || role === 'author') return 'reviewer'; + if (role === 'reviewer') return 'reviewer'; + if (role === 'author') return 'author'; return null; } + +export function canResolveComment( + role: UserRole | string, + state: DocLifecycleState, +): boolean { + if (role === 'reviewer') return true; + return role === 'author' && state === 'drafting'; +}