From 986d78cc91a7f57bd5ad2959a3185e88222441d6 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 24 Sep 2026 23:06:23 +0000 Subject: [PATCH 1/2] feat(docgov): complete review lifecycle gaps for lock/notify/comment/reopen Defer locked-period supplements to a pending ledger, route duty-holder notifications, persist stage-scoped comments, and prefer Approver for reopen. --- .doc/status/FEATURE-MATRIX.md | 2 +- .../2026-09-25-doc-review-lifecycle-design.md | 66 +++++++ ...-09-25-doc-review-lifecycle-test-report.md | 69 +++++++ ...026-09-25-document-pending-supplements.sql | 26 +++ internal/app/docnotify/consumer.go | 22 ++- internal/app/docnotify/lifecycle.go | 181 ++++++++++++++++++ internal/app/docnotify/lifecycle_test.go | 47 +++++ internal/app/docnotify/subscriber.go | 32 +++- internal/app/trace_bridge.go | 6 +- internal/app/wire_supplement_impact.go | 31 ++- internal/docgov/comment_matrix_test.go | 40 ++++ internal/docgov/engine.go | 25 +++ internal/docgov/engine_pending.go | 164 ++++++++++++++++ internal/docgov/engine_proposal.go | 112 ++++++++++- internal/docgov/engine_review_flow.go | 41 ++++ internal/notification/digest.go | 36 +++- internal/notification/document_events.go | 11 ++ internal/server/handlers_docgov.go | 42 ++++ internal/server/server.go | 1 + internal/server/supplement_impact_adapter.go | 4 +- internal/server/supplement_impact_handlers.go | 1 + internal/server/trace_handlers.go | 54 +++++- internal/supplementimpact/http_adapter.go | 5 +- internal/supplementimpact/pg_store.go | 10 +- internal/supplementimpact/service.go | 89 ++++++++- internal/supplementimpact/service_test.go | 72 ++++++- internal/supplementimpact/types.go | 4 + web/src/App.tsx | 3 + web/src/api/docgov.ts | 42 ++++ web/src/api/projectTrace.ts | 53 +++++ web/src/components/doc/CommentSidePanel.tsx | 6 +- web/src/components/doc/DocGovActionBar.tsx | 26 ++- web/src/components/doc/DocumentPane.tsx | 4 + web/src/hooks/useBlockEditing.ts | 32 +++- web/src/hooks/useDocumentLifecycleActions.ts | 57 ++++++ web/src/lib/commentPolicy.test.ts | 31 ++- web/src/lib/commentPolicy.ts | 39 +++- 37 files changed, 1420 insertions(+), 66 deletions(-) create mode 100644 .doc/system-design/2026-09-25-doc-review-lifecycle-design.md create mode 100644 .doc/test-reports/2026-09-25-doc-review-lifecycle-test-report.md create mode 100644 deploy/postgres/goose/00072_2026-09-25-document-pending-supplements.sql create mode 100644 internal/app/docnotify/lifecycle.go create mode 100644 internal/app/docnotify/lifecycle_test.go create mode 100644 internal/docgov/comment_matrix_test.go create mode 100644 internal/docgov/engine_pending.go diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index e2d26d8f..fb1f001e 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -63,7 +63,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-25**:锁定期补充积压、流转通知、批注矩阵、重开 Approver 优先已落地;真实 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..f29ad3b4 --- /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 `00072`)独立于 `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..4440891d --- /dev/null +++ b/.doc/test-reports/2026-09-25-doc-review-lifecycle-test-report.md @@ -0,0 +1,69 @@ +# 受控文档三权流转控制完善 测试报告 + +## 一、测试背景 (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 真实 PI E2E 全链 + +- **评价判定**:未运行(待授权环境:编制→送审→锁定批注→驳回→积压消化→再送审→批准→归档→重开) + +## 六、自动化测试套件汇总 (Test Suite Execution) + +| 套件 | 结果 | +|---|---| +| `go test` docgov / supplementimpact / docnotify | PASS | +| `go test ./internal/server -short` | PASS | +| `commentPolicy.test.ts` | PASS | +| 产品 PI E2E | 未运行 | + +## 七、测试结论与后续优化建议 (Conclusion & Next Steps) + +- 单元与短测覆盖 G1–G3 主路径与负例;G4 审批人逻辑已落代码,需在获授权环境用真实成员/退出成员补 HTTP 验收。 +- 合入后建议:跑 goose `00072`、再跑一次真实 PI 全链并回填本节 5.4。 diff --git a/deploy/postgres/goose/00072_2026-09-25-document-pending-supplements.sql b/deploy/postgres/goose/00072_2026-09-25-document-pending-supplements.sql new file mode 100644 index 00000000..ee092f5b --- /dev/null +++ b/deploy/postgres/goose/00072_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/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..c152bf87 --- /dev/null +++ b/internal/app/docnotify/lifecycle.go @@ -0,0 +1,181 @@ +package docnotify + +import ( + "context" + "fmt" + "strings" + + "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/notification" + "chat-agent-system/internal/stream" +) + +// 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/SSE side channels. +type EmitLifecycleParams struct { + Notify notificationEmitter + Digest digestUpserter + Chat *ctxmgr.ContextManager + Streamer *stream.Streamer + 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, + }) + } + + if p.Streamer != nil { + p.Streamer.EmitGovernance(stream.GovernancePayload{ + DocID: p.Doc.DocID, Action: string(p.Action), CurrentState: string(p.Doc.CurrentState), + IsLocked: p.Doc.IsLocked, AuthorClaimed: p.Doc.AuthorClaimed, + NotificationTitle: title, NotificationMsg: body, + }) + } +} + +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/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..0ffdbb6d 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/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 05d68c75..4c762aec 100644 --- a/internal/docgov/engine.go +++ b/internal/docgov/engine.go @@ -20,10 +20,35 @@ 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 } +// 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..b4d277f5 --- /dev/null +++ b/internal/docgov/engine_pending.go @@ -0,0 +1,164 @@ +package docgov + +import ( + "context" + "fmt" + "strings" + "time" +) + +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() +} + +// 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 7157726a..18fbc365 100644 --- a/internal/docgov/engine_proposal.go +++ b/internal/docgov/engine_proposal.go @@ -199,8 +199,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, @@ -218,6 +233,21 @@ func (e *GovernanceEngine) AddComment( e.mu.Lock() defer e.mu.Unlock() + 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") @@ -256,6 +286,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 f312f9ce..11aba9d9 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) { e.mu.Lock() @@ -46,6 +58,9 @@ func (e *GovernanceEngine) SubmitForReview(ctx context.Context, docID string, ac if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil { return nil, err } + e.mu.Unlock() + e.emitLifecycle(ctx, "submit_review", doc, actorID, actorName, "") + e.mu.Lock() return doc, nil } @@ -76,6 +91,9 @@ func (e *GovernanceEngine) ReviewApprove(ctx context.Context, docID string, acto if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil { return nil, err } + e.mu.Unlock() + e.emitLifecycle(ctx, "review_approve", doc, actorID, actorName, comment) + e.mu.Lock() return doc, nil } @@ -105,6 +123,11 @@ func (e *GovernanceEngine) ReviewReject(ctx context.Context, docID string, actor if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil { return nil, err } + pendingNotify := e.onPendingDigest + e.mu.Unlock() + e.DigestPendingOnReturnToDrafting(ctx, doc, actorID, pendingNotify) + e.emitLifecycle(ctx, "review_reject", doc, actorID, actorName, rejectReason) + e.mu.Lock() return doc, nil } @@ -146,6 +169,12 @@ 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) + e.mu.Lock() + } else { + // Same-tx approve: defer lifecycle until after commit via caller; still emit here for inbox. + e.mu.Unlock() + e.emitLifecycle(ctx, "approver_approve", doc, actorID, actorName, comment) e.mu.Lock() } @@ -177,6 +206,13 @@ func (e *GovernanceEngine) ApproverReject(ctx context.Context, docID string, act if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil { return nil, err } + pendingNotify := e.onPendingDigest + e.mu.Unlock() + if doc.CurrentState == StateDrafting { + e.DigestPendingOnReturnToDrafting(ctx, doc, actorID, pendingNotify) + } + e.emitLifecycle(ctx, "approver_reject", doc, actorID, actorName, rejectReason) + e.mu.Lock() return doc, nil } @@ -200,5 +236,10 @@ func (e *GovernanceEngine) ReopenForRevision(ctx context.Context, docID string, if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil { return nil, err } + pendingNotify := e.onPendingDigest + e.mu.Unlock() + e.DigestPendingOnReturnToDrafting(ctx, doc, actorID, pendingNotify) + e.emitLifecycle(ctx, "reopen", doc, actorID, actorName, reason) + e.mu.Lock() return doc, nil } diff --git a/internal/notification/digest.go b/internal/notification/digest.go index 1f6b2d2f..44cdbb2e 100644 --- a/internal/notification/digest.go +++ b/internal/notification/digest.go @@ -8,13 +8,16 @@ import ( "time" ) -const ( - DigestKindProposalRequired = "proposal_required" - DigestKindUnclaimedEdit = "unclaimed_edit" - SeverityActionRequired = "action_required" - SeverityInfo = "info" - maxDigestItems = 20 -) + const ( + 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 { @@ -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..8f56a339 100644 --- a/internal/notification/document_events.go +++ b/internal/notification/document_events.go @@ -16,6 +16,12 @@ const ( EventDocChangeReportDegraded = "document.change_report_degraded" 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" @@ -24,6 +30,11 @@ const ( TypeDocApproved = "document_approved" 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/handlers_docgov.go b/internal/server/handlers_docgov.go index cf97f94f..90382aa2 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/server.go b/internal/server/server.go index 4aa94277..ea18e447 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -319,6 +319,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) 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 54644398..a218484c 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/trace_handlers.go b/internal/server/trace_handlers.go index 8ac6b00f..617f3366 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..56e1d96d 100644 --- a/internal/supplementimpact/http_adapter.go +++ b/internal/supplementimpact/http_adapter.go @@ -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..9ad4cc0b 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 { diff --git a/internal/supplementimpact/service_test.go b/internal/supplementimpact/service_test.go index 45c42ed2..c124b364 100644 --- a/internal/supplementimpact/service_test.go +++ b/internal/supplementimpact/service_test.go @@ -81,7 +81,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 +106,73 @@ 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..be4b4b6f 100644 --- a/internal/supplementimpact/types.go +++ b/internal/supplementimpact/types.go @@ -51,6 +51,10 @@ type Item struct { 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"` diff --git a/web/src/App.tsx b/web/src/App.tsx index e2776d2c..bfc6b263 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1374,12 +1374,14 @@ export default function App() { handleRejectReview, handleApprove, handleRejectApprove, + handleRequestReopen, } = useDocumentLifecycleActions({ docAST, setDocAST, activeDocId, currentUser, activeSessionId, + projectId: currentProjectId, setDeliverables, setMessages, }); @@ -1670,6 +1672,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'; +} From 09fe9723660eb3910b1e1fd4b721bff563cd0ba8 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 25 Sep 2026 00:36:26 +0000 Subject: [PATCH 2/2] feat(chat): dialogue triggers for comments/reopen/pending-supplements with mistouch guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire intent→coordinator DocResolver confirm cards→action_executor kinds for comment, reopen, pending-supplements query, and real submit-review execution. --- ...-09-25-doc-review-lifecycle-test-report.md | 31 +- internal/app/app.go | 3 + internal/app/chat_doc_dialogue.go | 246 ++++++++++++++ internal/app/intentflow/expansion.go | 28 +- internal/chat/action_executor.go | 143 ++++++++ internal/chat/action_executor_test.go | 106 ++++++ internal/chat/ports.go | 15 + internal/coordinator/doc_resolve_gate.go | 176 ++++++++++ internal/coordinator/doc_resolver.go | 166 ++++++++++ internal/coordinator/docgov_dialogue_test.go | 168 ++++++++++ internal/coordinator/engine.go | 58 +++- internal/coordinator/engine_docgov.go | 170 +++++++++- .../coordinator/engine_docgov_comments.go | 107 ++++++ internal/coordinator/engine_docgov_reopen.go | 201 ++++++++++++ internal/coordinator/engine_test.go | 8 +- internal/coordinator/router.go | 4 +- internal/coordinator/router_test.go | 3 +- internal/docgov/engine_pending.go | 45 +++ internal/docgov/engine_proposal.go | 13 + internal/intent/dialogue_act.go | 3 +- internal/intent/hard_negative_test.go | 16 +- internal/intent/recognizer_matchers_docgov.go | 307 +++++++++++++++++- internal/intent/recognizer_test.go | 102 ++++++ internal/intent/registry.go | 5 + internal/intent/registry_test.go | 2 + internal/intent/types.go | 50 +-- internal/server/server.go | 3 + internal/traceimpact/pg_store.go | 22 ++ internal/traceimpact/service.go | 11 + internal/traceimpact/store.go | 13 + 30 files changed, 2159 insertions(+), 66 deletions(-) create mode 100644 internal/app/chat_doc_dialogue.go create mode 100644 internal/coordinator/doc_resolve_gate.go create mode 100644 internal/coordinator/doc_resolver.go create mode 100644 internal/coordinator/docgov_dialogue_test.go create mode 100644 internal/coordinator/engine_docgov_comments.go create mode 100644 internal/coordinator/engine_docgov_reopen.go 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 index 4440891d..c4c0bc6c 100644 --- 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 @@ -50,9 +50,31 @@ ok chat-agent-system/internal/app/docnotify 0.002s - **实际输出**:4 tests pass(stage matrix / viewer / resolve gates) - **评价判定**:PASS -### 5.4 真实 PI E2E 全链 +### 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) @@ -61,9 +83,10 @@ ok chat-agent-system/internal/app/docnotify 0.002s | `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 审批人逻辑已落代码,需在获授权环境用真实成员/退出成员补 HTTP 验收。 -- 合入后建议:跑 goose `00072`、再跑一次真实 PI 全链并回填本节 5.4。 +- 单元与短测覆盖 G1–G3 主路径与负例;G4 审批人逻辑与对话触发四道防线(语法护栏、DocResolver、角色×阶段、确认卡)已落代码。 +- 合入后建议:跑 goose `00072`、再跑一次真实 PI 全链(含对话送审/批注/重开)并回填本节 5.5。 diff --git a/internal/app/app.go b/internal/app/app.go index f855d855..192f067f 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" @@ -78,6 +79,7 @@ type Runtime struct { Trace *traceimpact.Service SupplementImpact *supplementimpact.Service mutateOwner *MutateOwner + ChatReopens chat.ReopenService Ctx *ctxmgr.ContextManager CAS *storage.CAS FreeDoc *freedoc.Service @@ -297,6 +299,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 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/intentflow/expansion.go b/internal/app/intentflow/expansion.go index 9b11d9c5..55ba42be 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{}{ @@ -211,8 +216,13 @@ var runControlActionKeys = map[string]struct{}{ // DocGovLiveActionKeys are the first U11 keys wired to the real docgov engine. var DocGovLiveActionKeys = 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": {}, } // LiveOwners carries production adapters from the composition root (U10–U12). diff --git a/internal/chat/action_executor.go b/internal/chat/action_executor.go index 9470f5cf..0d391285 100644 --- a/internal/chat/action_executor.go +++ b/internal/chat/action_executor.go @@ -19,6 +19,7 @@ type ActionServices struct { DocGov *docgov.GovernanceEngine TaskRuns TaskStarter IntentPlans IntentPlans + Reopens ReopenService Environment string } @@ -44,6 +45,32 @@ func actorName(userCtx *coordinator.UserProjectContext) string { return "用户" } +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") @@ -96,6 +123,103 @@ func (s *Session) executeAction(ctx context.Context, action *coordinator.Pending } return &ActionExecResult{DocState: string(doc.CurrentState), Message: "驳回完成"}, nil + case "docgov.submit_review": + if s.Actions.DocGov == nil { + return nil, fmt.Errorf("docgov engine not available") + } + doc, err := s.Actions.DocGov.GetDocument(ctx, action.DocID) + if err != nil || doc == nil { + return nil, fmt.Errorf("文档不存在或无法读取") + } + if doc.CurrentState != vocabulary.StateDrafting { + return nil, fmt.Errorf("仅编制中(drafting)文档可送审,当前状态:%s", doc.CurrentState) + } + if doc.AuthorID > 0 && doc.AuthorID != uid { + return nil, fmt.Errorf("仅编制责任人可送审") + } + updated, err := s.Actions.DocGov.SubmitForReview(ctx, action.DocID, uid, vocabulary.RoleAuthor, name) + if err != nil { + return nil, err + } + return &ActionExecResult{DocState: string(updated.CurrentState), 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") @@ -153,6 +277,25 @@ func renderActionReply(action *coordinator.PendingAction, result *ActionExecResu return fmt.Sprintf("已确认终审批准!《%s》已被法定受控锁定并正式归档生效(当前状态:%s)。", docName, result.DocState) case "docgov.reject": return fmt.Sprintf("已确认执行驳回操作!《%s》已退回(当前状态:%s),驳回意见已同步下发。", docName, result.DocState) + case "docgov.submit_review": + return fmt.Sprintf("已确认送审!《%s》已进入审核锁定(当前状态:%s)。", docName, result.DocState) + 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..2d11c344 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" @@ -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/doc_resolve_gate.go b/internal/coordinator/doc_resolve_gate.go new file mode 100644 index 00000000..6eca4d53 --- /dev/null +++ b/internal/coordinator/doc_resolve_gate.go @@ -0,0 +1,176 @@ +package coordinator + +import ( + "context" + "fmt" + "strings" + "time" + + "chat-agent-system/internal/contracts/vocabulary" + "chat-agent-system/internal/intent" +) + +// resolveTargetDocGate runs the DocResolver mistouch gate. +// Returns (doc, nil) on unique hit; otherwise a clarification/reject CoordinationResult. +func (e *CoordinatorEngine) resolveTargetDocGate( + ctx context.Context, + cand *intent.CandidateIntent, + userCtx *UserProjectContext, +) (*ResolvedDoc, *CoordinationResult) { + ref := "" + if cand != nil && cand.FilledSlots != nil { + ref = strings.TrimSpace(cand.FilledSlots[intent.SlotTargetDoc]) + } + active := "" + if userCtx != nil && userCtx.InteractiveCard != nil && userCtx.InteractiveCard.CurrentStatus == "active" { + active = userCtx.InteractiveCard.TargetDocID + } + if active == "" && userCtx != nil { + snap := userCtx.ToSnapshot() + if snap != nil { + active = snap.ActiveDocID + } + } + projectID := int64(0) + if userCtx != nil { + projectID = parseDecimalID(userCtx.Project.ID) + } + + if e == nil || e.docResolver == nil { + // Soft fallback for unit tests without resolver: treat ref as DocID. + if ref == "" || ref == "当前受控文档" { + return nil, &CoordinationResult{ + Status: StatusClarificationRequired, + Intent: cand.Intent, + Branch: cand.Branch, + ClarificationPrompt: "请明确要操作的受控文档名称或文档 ID(不会默认选择第一份文档)。", + NaturalReply: "需要您点名目标文档后才能继续,请回复文档标题或文档 ID。", + UpdatedContext: userCtx, + } + } + return &ResolvedDoc{DocID: ref, Title: ref}, nil + } + + doc, ok, candidates, err := e.docResolver.ResolveDoc(ctx, projectID, ref, active) + if err != nil { + return nil, &CoordinationResult{ + Status: StatusRejected, + Intent: cand.Intent, + Branch: cand.Branch, + RejectReason: "DOC_RESOLVE_FAILED", + NaturalReply: fmt.Sprintf("文档解析失败:%v", err), + UpdatedContext: userCtx, + } + } + if ok && doc != nil { + return doc, nil + } + prompt := "未唯一匹配到受控文档。请从下列候选中点名一份(回复标题或文档 ID),系统不会默认选择第一份:" + if len(candidates) == 0 { + prompt = "当前项目没有可匹配的受控文档。请先确认工作流文档已创建,并点名文档标题或 ID。" + } else { + prompt += "\n- " + strings.Join(candidates, "\n- ") + } + return nil, &CoordinationResult{ + Status: StatusClarificationRequired, + Intent: cand.Intent, + Branch: cand.Branch, + ClarificationPrompt: prompt, + NaturalReply: prompt, + UpdatedContext: userCtx, + } +} + +func buildConfirmCard( + cardTypeTitle, docID, docTitle, section, proposed, impact string, + confirmActionID, confirmLabel string, + extraPayload map[string]string, +) (*intent.InteractiveCardPayload, *intent.PendingExpectation) { + payload := map[string]string{ + "target_doc": docID, + "target_action": confirmActionID, + } + for k, v := range extraPayload { + if v != "" { + payload[k] = v + } + } + if section != "" { + payload["target_section"] = section + } + cardID := fmt.Sprintf("card_%s_%d", confirmActionID, time.Now().UnixNano()) + card := &intent.InteractiveCardPayload{ + CardID: cardID, + CardType: "CONFIRMATION", + Title: cardTypeTitle, + TargetDocID: docID, + TargetDocTitle: fmt.Sprintf("《%s》", docTitle), + TargetSectionID: section, + SectionTitle: section, + ProposedContent: proposed, + ImpactRisk: impact, + CurrentStatus: "active", + TTLTurns: 3, + CreatedAt: time.Now(), + Actions: []intent.CardAction{ + { + ActionID: confirmActionID, + Label: confirmLabel, + Style: "primary", + IsAffirmativeDefault: true, + Payload: payload, + }, + { + ActionID: "cancel", + Label: "取消", + Style: "subtle", + Payload: map[string]string{"action": "cancel"}, + }, + }, + } + exp := &intent.PendingExpectation{ + ExpectationID: "exp_" + card.CardID, + ExpectedIntent: intent.IntentDocGovProposalDecide, + TargetAction: confirmActionID, + Prompt: "请确认是否执行该操作", + AffirmativeWord: "确认", + DeclineWord: "取消", + ExpiresAt: time.Now().Add(5 * time.Minute), + Payload: payload, + AllowAffirmative: true, + CreatedAt: time.Now(), + DefaultOption: &intent.ExpectationOption{ + OptionID: confirmActionID, + Title: confirmLabel, + Intent: intent.IntentDocGovProposalDecide, + Slots: payload, + }, + } + return card, exp +} + +func displayDocTitle(doc *ResolvedDoc) string { + if doc == nil { + return "当前受控文档" + } + if doc.Title != "" { + return doc.Title + } + return doc.DocID +} + +func commentRoleAllowed(role string, state vocabulary.DocumentState) bool { + r := strings.ToLower(strings.TrimSpace(role)) + switch state { + case vocabulary.StateDrafting: + return r == "author" || r == "reviewer" || r == "system" || r == "composer" + case vocabulary.StateReviewing: + return r == "reviewer" || r == "author" || r == "system" || r == "composer" + case vocabulary.StateApproving: + return r == "approver" || r == "reviewer" || r == "system" + case vocabulary.StateApproved: + return false + default: + return false + } +} diff --git a/internal/coordinator/doc_resolver.go b/internal/coordinator/doc_resolver.go new file mode 100644 index 00000000..23ad0b4a --- /dev/null +++ b/internal/coordinator/doc_resolver.go @@ -0,0 +1,166 @@ +package coordinator + +import ( + "context" + "fmt" + "strings" + + "chat-agent-system/internal/contracts/vocabulary" +) + +// ResolvedDoc is a uniquely resolved controlled document. +type ResolvedDoc struct { + DocID string + Title string + State vocabulary.DocumentState + TaskKey string +} + +// DocListItem is one candidate from the project document inventory. +type DocListItem struct { + DocID string + Title string + State vocabulary.DocumentState + TaskKey string + Aliases []string +} + +// DocLister lists workflow documents for a project (typically ListWorkflowDocumentsByProject). +type DocLister func(ctx context.Context, projectID int64) ([]DocListItem, error) + +// DocResolver normalizes target_doc references; never silently picks the first match. +type DocResolver interface { + ResolveDoc(ctx context.Context, projectID int64, ref, activeDocID string) (doc *ResolvedDoc, ok bool, candidates []string, err error) +} + +// ListDocResolver resolves against a DocLister inventory. +type ListDocResolver struct { + List DocLister +} + +// ResolveDoc matches by exact doc_id, then unique title/alias; empty/@active uses activeDocID. +// 0 or multiple title matches → ok=false with candidate labels (never default to first). +func (r *ListDocResolver) ResolveDoc(ctx context.Context, projectID int64, ref, activeDocID string) (*ResolvedDoc, bool, []string, error) { + if r == nil || r.List == nil { + return nil, false, nil, fmt.Errorf("doc resolver not configured") + } + ref = strings.TrimSpace(ref) + activeDocID = strings.TrimSpace(activeDocID) + if ref == "" || ref == "@active" || strings.EqualFold(ref, "当前受控文档") || ref == "当前文档" { + if activeDocID == "" { + items, err := r.List(ctx, projectID) + if err != nil { + return nil, false, nil, err + } + return nil, false, candidateLabels(items), nil + } + ref = activeDocID + } + + items, err := r.List(ctx, projectID) + if err != nil { + return nil, false, nil, err + } + if len(items) == 0 { + return nil, false, nil, nil + } + + for _, it := range items { + if strings.EqualFold(it.DocID, ref) { + return toResolved(it), true, nil, nil + } + } + + normRef := normalizeDocLabel(ref) + var hits []DocListItem + for _, it := range items { + if normalizeDocLabel(it.Title) == normRef || normalizeDocLabel(it.TaskKey) == normRef { + hits = append(hits, it) + continue + } + for _, a := range it.Aliases { + if normalizeDocLabel(a) == normRef { + hits = append(hits, it) + break + } + } + // loose contains only when unique later + if strings.Contains(normalizeDocLabel(it.Title), normRef) || strings.Contains(normRef, normalizeDocLabel(it.Title)) { + if normalizeDocLabel(it.Title) != "" && normRef != "" { + already := false + for _, h := range hits { + if h.DocID == it.DocID { + already = true + break + } + } + if !already { + hits = append(hits, it) + } + } + } + } + + if len(hits) == 1 { + return toResolved(hits[0]), true, nil, nil + } + if len(hits) == 0 { + return nil, false, candidateLabels(items), nil + } + return nil, false, candidateLabels(hits), nil +} + +func toResolved(it DocListItem) *ResolvedDoc { + return &ResolvedDoc{DocID: it.DocID, Title: it.Title, State: it.State, TaskKey: it.TaskKey} +} + +func candidateLabels(items []DocListItem) []string { + out := make([]string, 0, len(items)) + for _, it := range items { + label := it.Title + if label == "" { + label = it.DocID + } + if it.DocID != "" && it.Title != "" { + label = fmt.Sprintf("%s(%s)", it.Title, it.DocID) + } + out = append(out, label) + } + return out +} + +func normalizeDocLabel(s string) string { + s = strings.TrimSpace(s) + s = strings.Trim(s, "《》「」\"' ") + s = strings.ToLower(s) + s = strings.ReplaceAll(s, " ", "") + s = strings.ReplaceAll(s, " ", "") + return s +} + +// PendingSupplementView is a read-only pending ledger row for dialogue answers. +type PendingSupplementView struct { + DocID string + DocTitle string + SourceSummary string + ImpactReason string + CreatedAt string +} + +// PendingSupplementReader lists undigested locked-period supplements. +type PendingSupplementReader interface { + ListPendingSupplements(ctx context.Context, projectID int64, docID string) ([]PendingSupplementView, error) +} + +// ReopenListItem is a pending reopen request shown for approve selection. +type ReopenListItem struct { + RequestID int64 + DocTasks []string + Reason string + Status string +} + +// ReopenLister lists pending reopen requests for a project (approve path). +type ReopenLister interface { + ListPendingReopens(ctx context.Context, projectID int64) ([]ReopenListItem, error) +} diff --git a/internal/coordinator/docgov_dialogue_test.go b/internal/coordinator/docgov_dialogue_test.go new file mode 100644 index 00000000..e1e09c26 --- /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 stubDocResolver struct { + doc *coordinator.ResolvedDoc + ok bool + candidates []string + err error +} + +func (s *stubDocResolver) 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 7d853fb4..63f34c76 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -27,14 +27,17 @@ const ( // PendingAction 声明对话层需执行的真实动作(由 Session 持有服务后执行)。 type PendingAction struct { - Kind string // docgov.review_approve | docgov.approver_approve | docgov.reject | taskrun.resume | taskrun.restart - DocID string - RunID string - WorkDir string - Reason string - Comment string - Rollback vocabulary.DocumentState - TaskKey string + Kind string // docgov.review_approve | docgov.approver_approve | docgov.reject | docgov.submit_review | docgov.comment_add | docgov.comment_resolve | docgov.reopen_request | docgov.reopen_approve | taskrun.resume | taskrun.restart + DocID string + RunID string + WorkDir string + Reason string + Comment string + Rollback vocabulary.DocumentState + TaskKey string + Role string + Section string + RequestID int64 } // CoordinationResult 协调层给对话层的回执 @@ -76,6 +79,9 @@ 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) @@ -130,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 { @@ -307,6 +334,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 5a936ae1..ef311dba 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" @@ -276,21 +277,46 @@ func (e *CoordinatorEngine) handleDocGovClaim(ctx context.Context, cand *intent. }, nil } -// handleDocGovSubmitReview 处理受控文档提交审核 +// handleDocGovSubmitReview 解析门 → drafting 校验 → CONFIRMATION 卡(含锁定后果)→ 拍板后 PendingAction func (e *CoordinatorEngine) handleDocGovSubmitReview(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { - docName := cand.FilledSlots[intent.SlotTargetDoc] - if docName == "" { - docName = "当前受控文档" + doc, gate := e.resolveTargetDocGate(ctx, cand, userCtx) + if gate != nil { + return gate, nil } - - reply := fmt.Sprintf("《%s》已成功提交审核!文档正文已进入受控锁定状态,并已通知审核责任人进行合规审查与批注。", docName) - + state := doc.State + if state == "" && userCtx != nil && userCtx.ToSnapshot() != nil { + state = vocabulary.DocumentState(userCtx.ToSnapshot().DocLifecycleState) + } + if state != "" && state != vocabulary.StateDrafting { + return &CoordinationResult{ + Status: StatusRejected, + Intent: cand.Intent, + Branch: cand.Branch, + RejectReason: "SUBMIT_STATE_DENIED", + NaturalReply: fmt.Sprintf("当前文档处于「%s」阶段,仅编制中(drafting)可送审。", state), + UpdatedContext: userCtx, + }, nil + } + title := displayDocTitle(doc) + card, exp := buildConfirmCard( + "受控文档送审确认", + doc.DocID, title, "", + "编制完成,提交审核并锁定正文", + "送审后文档进入审核锁定:非审核职责人不可改正文;锁定期间补充将进入积压台账,待退回编制后再消化。", + "confirm_submit_review", "确认送审", + map[string]string{"task_key": doc.TaskKey}, + ) + userCtx.applyCard(card) + userCtx.applyPending(exp) + reply := fmt.Sprintf("已为《%s》起草送审确认单。送审将锁定正文并通知审核人,请确认无误后点击确认或回复「确认」。", title) return &CoordinationResult{ - Status: StatusAccepted, - Intent: cand.Intent, - Branch: cand.Branch, - NaturalReply: reply, - UpdatedContext: userCtx, + Status: StatusAccepted, + Intent: cand.Intent, + Branch: cand.Branch, + NaturalReply: reply, + InteractiveCard: card, + NextExpectation: exp, + UpdatedContext: userCtx, }, nil } @@ -364,6 +390,100 @@ func (e *CoordinatorEngine) handleDocGovProposalDecide(ctx context.Context, cand }, nil } + if targetAction == "confirm_submit_review" { + payload := mergeDecidePayload(cand, userCtx) + if userCtx.InteractiveCard != nil { + userCtx.dismissCard("resolved") + } + userCtx.applyPending(nil) + taskKey := payload["task_key"] + return &CoordinationResult{ + Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, + NaturalReply: fmt.Sprintf("正在处理《%s》的送审…", docName), + UpdatedContext: userCtx, ClearExpectation: true, + PendingAction: &PendingAction{Kind: "docgov.submit_review", DocID: docName, TaskKey: taskKey}, + }, 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") @@ -396,3 +516,29 @@ func (e *CoordinatorEngine) handleDocGovProposalDecide(ctx context.Context, cand UpdatedContext: userCtx, }, nil } + +func mergeDecidePayload(cand *intent.CandidateIntent, userCtx *UserProjectContext) map[string]string { + out := map[string]string{} + if userCtx != nil && userCtx.InteractiveCard != nil { + for _, a := range userCtx.InteractiveCard.Actions { + if a.IsAffirmativeDefault && a.Payload != nil { + for k, v := range a.Payload { + out[k] = v + } + } + } + } + if userCtx != nil && userCtx.PendingExpectation != nil && userCtx.PendingExpectation.Payload != nil { + for k, v := range userCtx.PendingExpectation.Payload { + out[k] = v + } + } + if cand != nil && cand.FilledSlots != nil { + for k, v := range cand.FilledSlots { + if v != "" { + out[k] = v + } + } + } + return out +} diff --git a/internal/coordinator/engine_docgov_comments.go b/internal/coordinator/engine_docgov_comments.go new file mode 100644 index 00000000..83ec0018 --- /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..985708ca --- /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 4077eba3..92f1510c 100644 --- a/internal/coordinator/engine_test.go +++ b/internal/coordinator/engine_test.go @@ -460,7 +460,7 @@ func TestCoordinatorEngine_DocGovActions(t *testing.T) { t.Errorf("expected claim accepted, got %s", resClaim.Status) } - // 2. DocGovSubmitReview + // 2. DocGovSubmitReview — 两步确认,首轮挂卡不立即执行 recSubmit := &intent.IntentRecognitionResult{ TopIntent: &intent.CandidateIntent{ Intent: intent.IntentDocGovSubmitReview, @@ -476,6 +476,12 @@ func TestCoordinatorEngine_DocGovActions(t *testing.T) { if resSubmit.Status != coordinator.StatusAccepted { t.Errorf("expected submit accepted, got %s", resSubmit.Status) } + 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/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/engine_pending.go b/internal/docgov/engine_pending.go index b4d277f5..b9e774d4 100644 --- a/internal/docgov/engine_pending.go +++ b/internal/docgov/engine_pending.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" "time" + + "github.com/jackc/pgx/v5" ) const ( @@ -109,6 +111,49 @@ func (e *GovernanceEngine) ListPendingSupplements(ctx context.Context, docID str 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) { diff --git a/internal/docgov/engine_proposal.go b/internal/docgov/engine_proposal.go index 18fbc365..b6a3e3e4 100644 --- a/internal/docgov/engine_proposal.go +++ b/internal/docgov/engine_proposal.go @@ -266,6 +266,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 { diff --git a/internal/intent/dialogue_act.go b/internal/intent/dialogue_act.go index e78b5fd3..387fafc9 100644 --- a/internal/intent/dialogue_act.go +++ b/internal/intent/dialogue_act.go @@ -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..27c0d905 100644 --- a/internal/intent/hard_negative_test.go +++ b/internal/intent/hard_negative_test.go @@ -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 03d9e80a..a2cd383f 100644 --- a/internal/intent/recognizer_test.go +++ b/internal/intent/recognizer_test.go @@ -875,3 +875,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..e1f6627a 100644 --- a/internal/intent/registry.go +++ b/internal/intent/registry.go @@ -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..b22c01ce 100644 --- a/internal/intent/types.go +++ b/internal/intent/types.go @@ -16,20 +16,25 @@ const ( IntentCasualChat IntentType = "CASUAL_CHAT" // 问候、闲聊或通用帮助引导 IntentDeclineExpectation IntentType = "DECLINE_EXPECTATION" // 用户明确暂缓/不采纳上轮建议 (非阻塞放弃) - // 法定文档治理流转 - IntentDocGovClaim IntentType = "DOCGOV_CLAIM" - IntentDocGovSubmitReview IntentType = "DOCGOV_SUBMIT_REVIEW" - IntentDocGovReviewApprove IntentType = "DOCGOV_REVIEW_APPROVE" - IntentDocGovReviewReject IntentType = "DOCGOV_REVIEW_REJECT" - IntentDocGovFinalApprove IntentType = "DOCGOV_FINAL_APPROVE" - IntentDocGovFinalReject IntentType = "DOCGOV_FINAL_REJECT" - IntentDocGovProposalDecide IntentType = "DOCGOV_PROPOSAL_DECIDE" + // 法定文档治理流转 + IntentDocGovClaim IntentType = "DOCGOV_CLAIM" + IntentDocGovSubmitReview IntentType = "DOCGOV_SUBMIT_REVIEW" + IntentDocGovReviewApprove IntentType = "DOCGOV_REVIEW_APPROVE" + IntentDocGovReviewReject IntentType = "DOCGOV_REVIEW_REJECT" + 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" // 用户拍板确认交互卡片 @@ -144,14 +149,17 @@ const ( SlotSupplementDoc = "supplement_doc" // 补充内容文本 SlotQueryTopic = "query_topic" // 查询主题 (如 progress, risk, talk_script, quotation) - // 法定文档治理与权限槽位 - SlotTargetDoc = "target_doc" - // TargetDocLatestUpload 是「刚刚/最近上传」指代的哨兵目标,由服务端解析为项目最新 CAS 资产。 - TargetDocLatestUpload = "@latest_upload" - SlotRejectReason = "reject_reason" - SlotTargetSection = "target_section" - SlotProposalAction = "proposal_action" - SlotForbiddenTarget = "forbidden_target" + // 法定文档治理与权限槽位 + SlotTargetDoc = "target_doc" + // TargetDocLatestUpload 是「刚刚/最近上传」指代的哨兵目标,由服务端解析为项目最新 CAS 资产。 + TargetDocLatestUpload = "@latest_upload" + 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/server/server.go b/internal/server/server.go index ea18e447..bc8036c7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -615,6 +615,9 @@ func (s *Server) getOrCreateSession( IntentPlans: app.AdaptChatIntentPlans(s.intentPlans), 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/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()