merge: feature/docgov-modification-proposal-http
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "docgov 变更提案缺少 HTTP 路由"
|
||||
status: open
|
||||
status: resolved
|
||||
created: 2026-09-17
|
||||
updated: 2026-09-17
|
||||
severity: medium
|
||||
@@ -10,35 +10,23 @@ severity: medium
|
||||
|
||||
## 现象
|
||||
|
||||
引擎侧存在 `docgov.ProposeModification` / `DecideModificationProposal`,但 `internal/server/server.go` 未注册对应 HTTP 路由。前端 `App.tsx`「变更方案提议」(`pendingProposal`)仍为本地模拟,不写库、不审计。
|
||||
|
||||
该缺口与 `duty_policy=author_only` 无关,影响全部受控文档的变更提案闭环。
|
||||
引擎侧存在 `docgov.ProposeModification` / `DecideModificationProposal`,但服务端此前仅注册 `POST /api/v1/docgov/proposal/decide`;前端 `App.tsx`「变更方案提议」(`pendingProposal`)为本地模拟。
|
||||
|
||||
## 根因分析
|
||||
|
||||
U6 排查审批闭环时确认:生命周期三接口(submit-review / review-decision / approver-decision)与 claim 已注册并可真实调用;变更提案路径从未暴露为 HTTP,前后端契约缺失。
|
||||
|
||||
证据:
|
||||
|
||||
- `rg "ProposeModification|DecideModification|/docgov/.*proposal"`(路由层无匹配)
|
||||
- `App.tsx` pendingProposal 本地状态分支
|
||||
生命周期三接口与 claim 已注册;变更提案 **propose** 路径从未暴露为 HTTP。
|
||||
|
||||
## 严重度
|
||||
|
||||
`medium` — 不阻塞编制→审核→批准主链;阻塞「认领后小队修改须提案确认」的真实治理。
|
||||
|
||||
## 关联整改
|
||||
## 处置(resolved)
|
||||
|
||||
- 建议后续单独立项(不并入 author-only remediation U6)
|
||||
- 相关:`.doc/remediation/2026-09-17-document-author-only-plan.md` 发现 3
|
||||
- 新增 `POST /api/v1/docgov/proposal` → `ProposeModification`
|
||||
- 前端 `proposeModification` / `decideModificationProposal` + App 接线
|
||||
- 单测:`TestServer_DocGovProposeAndDecideHTTP`、`web/src/api/docgov.test.ts`
|
||||
- 任务:`.doc/ops/short-term/2026-09-17-docgov-modification-proposal-http.md`
|
||||
|
||||
## 证据
|
||||
|
||||
- U6 计划与测试报告中的「非目标」声明
|
||||
- 本笔记创建于 U6 实施期间
|
||||
|
||||
## 建议下一步
|
||||
|
||||
1. 设计 `POST /api/v1/docgov/propose-modification` 与 decide 路由及 ABAC;
|
||||
2. 前端替换本地 `pendingProposal` 模拟;
|
||||
3. 补真实 PI + 浏览器验收报告。
|
||||
- 合入后真实 HTTP 证据见 `.doc/test-reports/assets/2026-09-17-docgov-modification-proposal-http/`
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
title: "dev 试点:开放编制人兼任总开关"
|
||||
status: done
|
||||
created: 2026-09-17
|
||||
updated: 2026-09-17
|
||||
---
|
||||
|
||||
# dev 试点:开放编制人兼任总开关
|
||||
|
||||
## 结论
|
||||
|
||||
在运行中的 `agent-dev`(`:8080`,schema 52)经 admin CAS 将 `workflow.allow_author_only_approval` 设为 `true`(revision `0 → 1`)。**未**改默认迁移值;**未**晋升 test/main;test/main 仍保持默认 off。
|
||||
|
||||
## 证据(脱敏)
|
||||
|
||||
- [before-enable.json](../../test-reports/assets/2026-09-17-author-only-pilot/before-enable.json)
|
||||
- [put-enable.txt](../../test-reports/assets/2026-09-17-author-only-pilot/put-enable.txt)
|
||||
- [after-enable.json](../../test-reports/assets/2026-09-17-author-only-pilot/after-enable.json)
|
||||
|
||||
## 关联
|
||||
|
||||
- author-only 收口:[../handoffs/2026-09-17-document-author-only-closeout.md](../handoffs/2026-09-17-document-author-only-closeout.md)
|
||||
+5
-4
@@ -5,14 +5,15 @@
|
||||
|
||||
## 焦点
|
||||
|
||||
1. **document-author-only**:U1–U6 **已合入 `dev`**(总开关默认 off;test/main 晋升与开放授权另授)
|
||||
2. **partial/deferred 全量收口**:W1–W4 **done**;W5a **done**;W6 **shadow done**;W7 **盘点 done**。test 已至 `rc-v1.0.0-beta.4`(含 optional-duty null FK)
|
||||
1. **document-author-only**:U1–U6 **已合入并推送 `origin/dev`**;dev 试点总开关 **on**;test/main 仍 off、未晋升
|
||||
2. **docgov 变更提案 HTTP**:`feature/docgov-modification-proposal-http`(mutex `docgov`)in_progress
|
||||
3. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读
|
||||
|
||||
## 并行认领
|
||||
|
||||
| owner | task | branch | worktree | mutex | status |
|
||||
|-------|------|--------|----------|-------|--------|
|
||||
| Cursor | 2026-09-17-docgov-modification-proposal-http | feature/docgov-modification-proposal-http | .worktrees/feature-docgov-modification-proposal-http | docgov | in_progress |
|
||||
| Cursor | 2026-09-17-document-author-only-closeout | feature/document-author-only-plan | .worktrees/feature-document-author-only-plan | document-duty-policy | done |
|
||||
| Cursor | 2026-09-17-rdev-template-depth-s1-continued | feature/rdev-template-depth-s1-continued | - | rdev-templates | planned |
|
||||
| Cursor | 2026-09-17-rdev-workflow-integration | feature/rdev-core | - | workflow-def | done |
|
||||
@@ -129,7 +130,7 @@
|
||||
|
||||
| holder | phase | since | scope | queue |
|
||||
|--------|-------|-------|-------|-------|
|
||||
| - | idle | - | - | - |
|
||||
| Cursor | merging | 2026-09-17 | feature/docgov-modification-proposal-http | - |
|
||||
|
||||
**phase**: `idle` | `merging` | `smoke` | `e2e`
|
||||
|
||||
@@ -139,7 +140,7 @@
|
||||
|
||||
| 环境 | 端口 | schema | 状态 |
|
||||
|------|------|--------|------|
|
||||
| dev | 8080 | **52** | healthy(含 author_only goose 00050–00052;`pi-agent`;events `:8088`;总开关默认 off) |
|
||||
| dev | 8080 | **52** | healthy(含 author_only goose 00050–00052;`pi-agent`;events `:8088`;**试点** `workflow.allow_author_only_approval=true`) |
|
||||
| test | 8081 | **48** | healthy(`rc-v1.0.0-beta.4`;pi-agent `:18866`;events `:8089`;notify/advance/projection live、fav off;health+ready PASS;goose 00048 已迁移) |
|
||||
| main | 8000 | **47** | healthy(app + events + runner + DB;与 dev/test 对齐) |
|
||||
|
||||
|
||||
@@ -13,23 +13,28 @@ to: integrator
|
||||
|
||||
实现与验收任务已全部收口:U1–U6 short-term 已迁入 `archive/ops/`;FEATURE-MATRIX / remediation DoD(除发布末条)已更新;U6 报告已归档。
|
||||
|
||||
**远端**:已 `git-push-dev.sh` 推送 `origin/dev`(含 author-only merge);**未**晋升 test/main。
|
||||
|
||||
**dev 试点**:`workflow.allow_author_only_approval=true`(revision 1);证据见 [pilot assets](../../test-reports/assets/2026-09-17-author-only-pilot/) 与 [ops 笔记](../2026-09-17-author-only-pilot-toggle.md)。
|
||||
|
||||
## 分支
|
||||
|
||||
- `feature/document-author-only-plan`(worktree:`.worktrees/feature-document-author-only-plan`)
|
||||
- 相对 `origin/dev` 约 78 commits(含 U1–U6)
|
||||
- `feature/document-author-only-plan`(worktree:`.worktrees/feature-document-author-only-plan`)— 已合入,待清理
|
||||
- 续作:`feature/docgov-modification-proposal-http`(变更提案 HTTP)
|
||||
|
||||
## 合入 `dev`(已完成)
|
||||
|
||||
- `merge --no-ff feature/document-author-only-plan` → `dev@09a9921`
|
||||
- `merge --no-ff feature/document-author-only-plan` → 后续推送至 `origin/dev@ba92a8b` 一带
|
||||
- 冒烟:`go vet` / `go test -short` 相关包 PASS;`./scripts/env-service.sh dev rebuild`;`GET /health` → `schema_version=52`、`status=ok`
|
||||
- 总开关默认 off;**未** push / 未晋升 test/main / 未开放授权
|
||||
- 总开关默认迁移值仍为 off;**dev 运行态**已 CAS 试点开启;**未**晋升 test/main
|
||||
|
||||
## 明确不在本期
|
||||
## 明确不在本期(已转入续作)
|
||||
|
||||
- 变更提案 HTTP 路由([audit](../../audit/2026-09-17-docgov-modification-proposal-http-gap.md))
|
||||
- test/main 晋升与试点开放总授权
|
||||
- ~~变更提案 HTTP 路由~~ → [short-term](../short-term/2026-09-17-docgov-modification-proposal-http.md) / [audit resolved](../../audit/2026-09-17-docgov-modification-proposal-http-gap.md)
|
||||
- test/main 晋升仍另授
|
||||
|
||||
## 证据
|
||||
|
||||
- [U6 报告](../../test-reports/2026-09-17-document-author-only-u6-test-report.md)
|
||||
- [方案 DoD](../../remediation/2026-09-17-document-author-only-plan.md)
|
||||
- [pilot](../../test-reports/assets/2026-09-17-author-only-pilot/)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: "docgov 变更提案 HTTP + 前端接线"
|
||||
status: in_progress
|
||||
created: 2026-09-17
|
||||
updated: 2026-09-17
|
||||
owner: Cursor
|
||||
branch: feature/docgov-modification-proposal-http
|
||||
worktree: .worktrees/feature-docgov-modification-proposal-http
|
||||
mutex: docgov
|
||||
---
|
||||
|
||||
# docgov 变更提案 HTTP + 前端接线
|
||||
|
||||
## 目标
|
||||
|
||||
暴露 `POST /api/v1/docgov/proposal`,前端「修改」快捷分支与 `ProposalConfirmCard` 走真实 propose/decide,不再本地假装成功。
|
||||
|
||||
## 范围
|
||||
|
||||
- API:`ProposeModification` 路由;decide 补 404/403 映射
|
||||
- 前端:`docgov.ts` + `App.tsx` 接线
|
||||
- 单测 + 真实 HTTP 证据
|
||||
- 关闭 audit 缺口;dev 试点总开关证据归档
|
||||
|
||||
## 非目标
|
||||
|
||||
- 晋升 test/main
|
||||
- 变更提案自动改写受控正文(批准后仍为 UI 演示增补)
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [x] 实现 propose handler + 路由
|
||||
2. [x] 前端 client + App 接线
|
||||
3. [x] handler/client 单测
|
||||
4. [ ] 合入 dev + rebuild + 真实 HTTP
|
||||
5. [ ] push origin/dev;清理 author-only worktree
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] `POST /api/v1/docgov/proposal` 可写库(单测)
|
||||
- [ ] 真实 HTTP claim → propose → decide PASS
|
||||
- [ ] audit 缺口 resolved;CURRENT 认领收敛
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Audit: [../../audit/2026-09-17-docgov-modification-proposal-http-gap.md](../../audit/2026-09-17-docgov-modification-proposal-http-gap.md)
|
||||
- Pilot: [../../test-reports/assets/2026-09-17-author-only-pilot/](../../test-reports/assets/2026-09-17-author-only-pilot/)
|
||||
- Closeout: [../handoffs/2026-09-17-document-author-only-closeout.md](../handoffs/2026-09-17-document-author-only-closeout.md)
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"key": "workflow.allow_author_only_approval",
|
||||
"value": true,
|
||||
"revision": 1,
|
||||
"note": "dev pilot enabled; test/main not promoted"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"key": "workflow.allow_author_only_approval",
|
||||
"value": false,
|
||||
"revision": 0
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"key":"workflow.allow_author_only_approval","label":"允许编制人兼任审核批准","group":"workflow","type":"bool","value":true,"default":false,"writable":true,"requiresRestart":false,"description":"授权新配置:开启后项目创建者可在激活前为文档启用编制人兼任。关闭后禁止新启用/激活兼任规划;已激活实例按冻结职责继续执行,不作为紧急熔断。","revision":1,"updatedAt":"2026-09-17T14:03:22.990984Z","updatedBy":9}
|
||||
|
||||
HTTP 200
|
||||
@@ -70,6 +70,7 @@ func main() {
|
||||
fmt.Println(" • [GET] /api/v1/context/cases/recommend - 往期受控标杆案例与历史报价语义自推荐")
|
||||
fmt.Println(" • [GET] /api/v1/docgov/document - 查询受控文档治理状态看板")
|
||||
fmt.Println(" • [POST] /api/v1/docgov/claim - 查阅并认领受控文档 (正式稿方可认领)")
|
||||
fmt.Println(" • [POST] /api/v1/docgov/proposal - 对已认领文档发起变更提案")
|
||||
fmt.Println(" • [POST] /api/v1/docgov/proposal/decide - 编制负责人拍板同意/拒绝变更提案")
|
||||
fmt.Println(" • [POST] /api/v1/docgov/submit-review - 编制完成提交审核 (文档正文锁定)")
|
||||
fmt.Println(" • [POST] /api/v1/docgov/review-decision - 审核通过或驳回 (驳回必填原因且解锁)")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -735,6 +736,66 @@ func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Req
|
||||
writeJSONResponse(w, http.StatusOK, doc)
|
||||
}
|
||||
|
||||
// ProposeModificationRequest 发起变更提案请求
|
||||
type ProposeModificationRequest struct {
|
||||
DocID string `json:"doc_id"`
|
||||
TriggeredBy string `json:"triggered_by"`
|
||||
ProposedChanges string `json:"proposed_changes"`
|
||||
ImpactAnalysis string `json:"impact_analysis"`
|
||||
RiskAssessment string `json:"risk_assessment"`
|
||||
}
|
||||
|
||||
// handleDocGovProposeModification 对已认领文档发起变更提案(写库 + 审计)
|
||||
func (s *Server) handleDocGovProposeModification(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.requireAuthenticatedUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req ProposeModificationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.DocID) == "" {
|
||||
writeSafeError(w, r, http.StatusBadRequest, "doc_id cannot be empty")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.ProposedChanges) == "" {
|
||||
writeSafeError(w, r, http.StatusBadRequest, "proposed_changes cannot be empty")
|
||||
return
|
||||
}
|
||||
|
||||
if s.gov == nil {
|
||||
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
triggeredBy := strings.TrimSpace(req.TriggeredBy)
|
||||
if triggeredBy == "" {
|
||||
triggeredBy = fmt.Sprintf("user:%d", user.ID)
|
||||
}
|
||||
|
||||
proposal, err := s.gov.ProposeModification(
|
||||
r.Context(),
|
||||
req.DocID,
|
||||
triggeredBy,
|
||||
req.ProposedChanges,
|
||||
req.ImpactAnalysis,
|
||||
req.RiskAssessment,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, docgov.ErrDocumentNotFound) {
|
||||
writeSafeError(w, r, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
writeSafeError(w, r, http.StatusBadRequest, "propose modification failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSONResponse(w, http.StatusOK, proposal)
|
||||
}
|
||||
|
||||
// DecideProposalRequest 拍板变更提案请求
|
||||
type DecideProposalRequest struct {
|
||||
ProposalID string `json:"proposal_id"`
|
||||
@@ -755,6 +816,10 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque
|
||||
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.ProposalID) == "" {
|
||||
writeSafeError(w, r, http.StatusBadRequest, "proposal_id cannot be empty")
|
||||
return
|
||||
}
|
||||
|
||||
if s.gov == nil {
|
||||
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
|
||||
@@ -763,6 +828,14 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
proposal, err := s.gov.DecideModificationProposal(r.Context(), req.ProposalID, user.ID, req.Approve, req.Reason)
|
||||
if err != nil {
|
||||
if errors.Is(err, docgov.ErrProposalNotFound) {
|
||||
writeSafeError(w, r, http.StatusNotFound, "proposal not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, docgov.ErrUnauthorizedActor) {
|
||||
writeSafeError(w, r, http.StatusForbidden, "decide proposal failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeSafeError(w, r, http.StatusBadRequest, "decide proposal failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -276,6 +276,7 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("POST /api/v1/docgov/submit-review", s.handleDocGovSubmitReview)
|
||||
mux.HandleFunc("POST /api/v1/docgov/review-decision", s.handleDocGovReviewDecision)
|
||||
mux.HandleFunc("POST /api/v1/docgov/approver-decision", s.handleDocGovApproverDecision)
|
||||
mux.HandleFunc("POST /api/v1/docgov/proposal", s.handleDocGovProposeModification)
|
||||
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)
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"chat-agent-system/internal/bizdev"
|
||||
"chat-agent-system/internal/chat"
|
||||
"chat-agent-system/internal/config"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/stream"
|
||||
)
|
||||
|
||||
@@ -461,3 +462,63 @@ func TestServer_ContextAndDocGovAPIs(t *testing.T) {
|
||||
t.Fatalf("expected 200 from audit logs, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_DocGovProposeAndDecideHTTP(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
token := loginTestAdmin(t, srv)
|
||||
ctx := context.Background()
|
||||
docID := "doc_proj1_proposal_http"
|
||||
|
||||
if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "feasibility_reg", "法规评估", "V0.1", "", 1, "admin", 2, "r", 3, "a"); err != nil {
|
||||
t.Fatalf("CreateOrGetDocument: %v", err)
|
||||
}
|
||||
if _, err := srv.gov.RecordDocumentViewed(ctx, docID, 1, docgov.RoleAuthor, "admin"); err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
|
||||
proposeBody, _ := json.Marshal(ProposeModificationRequest{
|
||||
DocID: docID,
|
||||
TriggeredBy: "web_ui",
|
||||
ProposedChanges: "补充 EMC 滤波指标",
|
||||
ImpactAnalysis: "影响原理图选型",
|
||||
RiskAssessment: "低风险",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/docgov/proposal", bytes.NewReader(proposeBody))
|
||||
withBearerAuth(req, token)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("propose want 200 got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var prop map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&prop); err != nil {
|
||||
t.Fatalf("decode propose: %v", err)
|
||||
}
|
||||
proposalID, _ := prop["proposal_id"].(string)
|
||||
if proposalID == "" {
|
||||
t.Fatalf("missing proposal_id: %#v", prop)
|
||||
}
|
||||
if status, _ := prop["status"].(string); status != "pending_author_approval" {
|
||||
t.Fatalf("want pending_author_approval got %v", prop["status"])
|
||||
}
|
||||
|
||||
decideBody, _ := json.Marshal(DecideProposalRequest{
|
||||
ProposalID: proposalID,
|
||||
Approve: true,
|
||||
Reason: "同意补充",
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/docgov/proposal/decide", bytes.NewReader(decideBody))
|
||||
withBearerAuth(req, token)
|
||||
w = httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("decide want 200 got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var decided map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&decided); err != nil {
|
||||
t.Fatalf("decode decide: %v", err)
|
||||
}
|
||||
if status, _ := decided["status"].(string); status != "approved_by_author" {
|
||||
t.Fatalf("want approved_by_author got %v", decided["status"])
|
||||
}
|
||||
}
|
||||
|
||||
+158
-43
@@ -55,6 +55,8 @@ import {
|
||||
submitForReview,
|
||||
reviewDecision,
|
||||
approverDecision,
|
||||
proposeModification,
|
||||
decideModificationProposal,
|
||||
mapLeaseToAstEditLease,
|
||||
type DocGovRecord,
|
||||
} from './api/docgov';
|
||||
@@ -1557,11 +1559,13 @@ export default function App() {
|
||||
const [commentingBlock, setCommentingBlock] = useState<DocumentBlock | null>(null);
|
||||
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(null);
|
||||
const [pendingProposal, setPendingProposal] = useState<{
|
||||
proposalId: string;
|
||||
changes: string;
|
||||
impact: string;
|
||||
risk: string;
|
||||
patchBlockId?: string;
|
||||
} | null>(null);
|
||||
const [proposalError, setProposalError] = useState<string | null>(null);
|
||||
|
||||
// 消息流:权威来源为服务端窗口;不再全量持久化到 localStorage
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
@@ -3188,24 +3192,56 @@ export default function App() {
|
||||
|
||||
if (text.includes('修改') || text.includes('更新') || text.includes('调整')) {
|
||||
if (docAST.isClaimed && currentDocRole === 'author') {
|
||||
setPendingProposal({
|
||||
changes: '拟在第2节「监管分类与强制性合规标准矩阵」中,强化补充高频电刀共模抑制比设计指标。',
|
||||
impact: '将直接影响电路原理图物料选型与型式检验 EMC 滤波网络参数。',
|
||||
risk: '低风险。符合 GB 9706.1-2020 强标,提升系统抗扰度,无侵权风险。',
|
||||
});
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: 'ast-' + Date.now(),
|
||||
sessionId: activeSessionId,
|
||||
role: 'assistant',
|
||||
authorName: '水木东方协同参谋',
|
||||
intentTag: '🛡️ 文档认领保护机制拦截',
|
||||
content: `检测到《${docAST.title}》已处于【编制人已认领】状态。根据医疗器械质量体系规范,智能体小队不能擅自修改,已为您生成【变更方案提议】。请您确认是否准许本次修改。`,
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
},
|
||||
]);
|
||||
const changes =
|
||||
'拟在第2节「监管分类与强制性合规标准矩阵」中,强化补充高频电刀共模抑制比设计指标。';
|
||||
const impact = '将直接影响电路原理图物料选型与型式检验 EMC 滤波网络参数。';
|
||||
const risk = '低风险。符合 GB 9706.1-2020 强标,提升系统抗扰度,无侵权风险。';
|
||||
const docId = activeDocIdRef.current;
|
||||
void (async () => {
|
||||
try {
|
||||
setProposalError(null);
|
||||
const prop = await proposeModification({
|
||||
doc_id: docId,
|
||||
triggered_by: 'web_ui',
|
||||
proposed_changes: changes,
|
||||
impact_analysis: impact,
|
||||
risk_assessment: risk,
|
||||
});
|
||||
setPendingProposal({
|
||||
proposalId: prop.proposal_id,
|
||||
changes: prop.proposed_changes || changes,
|
||||
impact: prop.impact_analysis || impact,
|
||||
risk: prop.risk_assessment || risk,
|
||||
});
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: 'ast-' + Date.now(),
|
||||
sessionId: activeSessionId,
|
||||
role: 'assistant',
|
||||
authorName: '水木东方协同参谋',
|
||||
intentTag: '🛡️ 文档认领保护机制拦截',
|
||||
content: `检测到《${docAST.title}》已处于【编制人已认领】状态。根据医疗器械质量体系规范,智能体小队不能擅自修改,已为您生成【变更方案提议】(${prop.proposal_id})。请您确认是否准许本次修改。`,
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setProposalError(message);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: 'ast-' + Date.now(),
|
||||
sessionId: activeSessionId,
|
||||
role: 'assistant',
|
||||
authorName: '水木东方协同参谋',
|
||||
intentTag: '⚠️ 变更提案失败',
|
||||
content: `未能创建变更提案:${message}`,
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
},
|
||||
]);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -3244,32 +3280,106 @@ export default function App() {
|
||||
return { accepted: true };
|
||||
};
|
||||
|
||||
// 编制人接受变更提议
|
||||
// 编制人接受变更提议(经 HTTP decide)
|
||||
const handleAcceptProposal = () => {
|
||||
setPendingProposal(null);
|
||||
const children = docAST.rootBlock.children || [];
|
||||
const pBlock = children.find((c) => c.type === 'paragraph');
|
||||
if (pBlock) {
|
||||
pBlock.content += '\n\n【工程师确认增补】前端光路采集板已加装定制高频共模扼流圈,强标抗扰度提升至最高等级。';
|
||||
pBlock.metadata = {
|
||||
lastModifiedBy: '智能体协同小队 (经张工确认签署)',
|
||||
lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
|
||||
};
|
||||
setDocAST({ ...docAST });
|
||||
}
|
||||
if (!pendingProposal?.proposalId) return;
|
||||
const proposalId = pendingProposal.proposalId;
|
||||
void (async () => {
|
||||
try {
|
||||
setProposalError(null);
|
||||
await decideModificationProposal({
|
||||
proposal_id: proposalId,
|
||||
approve: true,
|
||||
reason: '编制人确认采纳变更提案',
|
||||
});
|
||||
setPendingProposal(null);
|
||||
const children = docAST.rootBlock.children || [];
|
||||
const pBlock = children.find((c) => c.type === 'paragraph');
|
||||
if (pBlock) {
|
||||
pBlock.content +=
|
||||
'\n\n【工程师确认增补】前端光路采集板已加装定制高频共模扼流圈,强标抗扰度提升至最高等级。';
|
||||
pBlock.metadata = {
|
||||
lastModifiedBy: '智能体协同小队 (经张工确认签署)',
|
||||
lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
|
||||
};
|
||||
setDocAST({ ...docAST });
|
||||
}
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: 'ast-p-' + Date.now(),
|
||||
sessionId: activeSessionId,
|
||||
role: 'assistant',
|
||||
authorName: '水木东方协同参谋',
|
||||
intentTag: '✅ 协同变更已生效',
|
||||
content: `变更提议已成功执行并落盘至《${docAST.title}》,受控文档已同步更新。`,
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
},
|
||||
]);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: 'ast-p-' + Date.now(),
|
||||
sessionId: activeSessionId,
|
||||
role: 'assistant',
|
||||
authorName: '水木东方协同参谋',
|
||||
intentTag: '✅ 协同变更已生效',
|
||||
content: `变更提议 ${proposalId} 已批准并落盘至《${docAST.title}》,受控文档已同步更新。`,
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setProposalError(message);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: 'ast-p-err-' + Date.now(),
|
||||
sessionId: activeSessionId,
|
||||
role: 'assistant',
|
||||
authorName: '水木东方协同参谋',
|
||||
intentTag: '⚠️ 批准提案失败',
|
||||
content: `批准变更提案失败:${message}`,
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
},
|
||||
]);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const handleRejectProposal = () => {
|
||||
if (!pendingProposal?.proposalId) {
|
||||
setPendingProposal(null);
|
||||
return;
|
||||
}
|
||||
const proposalId = pendingProposal.proposalId;
|
||||
void (async () => {
|
||||
try {
|
||||
setProposalError(null);
|
||||
await decideModificationProposal({
|
||||
proposal_id: proposalId,
|
||||
approve: false,
|
||||
reason: '编制人拒绝本次变更',
|
||||
});
|
||||
setPendingProposal(null);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: 'ast-p-rej-' + Date.now(),
|
||||
sessionId: activeSessionId,
|
||||
role: 'assistant',
|
||||
authorName: '水木东方协同参谋',
|
||||
intentTag: '🚫 变更提案已拒绝',
|
||||
content: `已拒绝变更提议 ${proposalId},受控文档保持原状。`,
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setProposalError(message);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: 'ast-p-rej-err-' + Date.now(),
|
||||
sessionId: activeSessionId,
|
||||
role: 'assistant',
|
||||
authorName: '水木东方协同参谋',
|
||||
intentTag: '⚠️ 拒绝提案失败',
|
||||
content: `拒绝变更提案失败:${message}`,
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
},
|
||||
]);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
// 块操作:编排层先 PATCH,成功才提交 AST;失败保留编辑器并明确提示
|
||||
@@ -4064,12 +4174,17 @@ export default function App() {
|
||||
</div>
|
||||
{pendingProposal && (
|
||||
<div style={{ padding: '0 16px' }}>
|
||||
{proposalError && (
|
||||
<div style={{ color: 'var(--danger, #b91c1c)', fontSize: 12, marginBottom: 8 }}>
|
||||
{proposalError}
|
||||
</div>
|
||||
)}
|
||||
<ProposalConfirmCard
|
||||
proposedChanges={pendingProposal.changes}
|
||||
impactAnalysis={pendingProposal.impact}
|
||||
potentialRisk={pendingProposal.risk}
|
||||
onAccept={handleAcceptProposal}
|
||||
onReject={() => setPendingProposal(null)}
|
||||
onReject={handleRejectProposal}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
submitForReview,
|
||||
reviewDecision,
|
||||
approverDecision,
|
||||
proposeModification,
|
||||
decideModificationProposal,
|
||||
} from './docgov';
|
||||
|
||||
const sampleRecord = {
|
||||
@@ -108,4 +110,79 @@ describe('docgov lifecycle API client', () => {
|
||||
assert.equal(body.action, 'reject');
|
||||
assert.equal(body.rollback_to, 'drafting');
|
||||
});
|
||||
|
||||
it('proposeModification posts to /api/v1/docgov/proposal', async () => {
|
||||
const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
lastCall = { url: String(input), init };
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
proposal_id: 'prop_1',
|
||||
doc_id: 'doc-1',
|
||||
proposed_changes: '改一处',
|
||||
status: 'pending_author_approval',
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
globalThis.fetch = mockFetch;
|
||||
(globalThis as { window: { fetch: typeof fetch; localStorage: unknown } }).window = {
|
||||
fetch: mockFetch,
|
||||
localStorage: {
|
||||
getItem: () =>
|
||||
JSON.stringify({ token: 'test-token', user: { id: 7, name: 'A' } }),
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const prop = await proposeModification({
|
||||
doc_id: 'doc-1',
|
||||
triggered_by: 'web_ui',
|
||||
proposed_changes: '改一处',
|
||||
impact_analysis: '影响小',
|
||||
risk_assessment: '低风险',
|
||||
});
|
||||
assert.equal(lastCall?.url, '/api/v1/docgov/proposal');
|
||||
assert.equal(lastCall?.init?.method, 'POST');
|
||||
const body = JSON.parse(String(lastCall?.init?.body));
|
||||
assert.equal(body.doc_id, 'doc-1');
|
||||
assert.equal(body.proposed_changes, '改一处');
|
||||
assert.equal(prop.proposal_id, 'prop_1');
|
||||
});
|
||||
|
||||
it('decideModificationProposal posts to /api/v1/docgov/proposal/decide', async () => {
|
||||
const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
lastCall = { url: String(input), init };
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
proposal_id: 'prop_1',
|
||||
doc_id: 'doc-1',
|
||||
proposed_changes: '改一处',
|
||||
status: 'approved_by_author',
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
globalThis.fetch = mockFetch;
|
||||
(globalThis as { window: { fetch: typeof fetch; localStorage: unknown } }).window = {
|
||||
fetch: mockFetch,
|
||||
localStorage: {
|
||||
getItem: () =>
|
||||
JSON.stringify({ token: 'test-token', user: { id: 7, name: 'A' } }),
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const prop = await decideModificationProposal({
|
||||
proposal_id: 'prop_1',
|
||||
approve: true,
|
||||
reason: '同意',
|
||||
});
|
||||
assert.equal(lastCall?.url, '/api/v1/docgov/proposal/decide');
|
||||
const body = JSON.parse(String(lastCall?.init?.body));
|
||||
assert.equal(body.proposal_id, 'prop_1');
|
||||
assert.equal(body.approve, true);
|
||||
assert.equal(prop.status, 'approved_by_author');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,6 +126,47 @@ export async function approverDecision(payload: ApproverDecisionPayload): Promis
|
||||
return postDocGov('/api/v1/docgov/approver-decision', payload);
|
||||
}
|
||||
|
||||
export interface ProposeModificationPayload {
|
||||
doc_id: string;
|
||||
triggered_by?: string;
|
||||
proposed_changes: string;
|
||||
impact_analysis?: string;
|
||||
risk_assessment?: string;
|
||||
}
|
||||
|
||||
export interface DecideModificationProposalPayload {
|
||||
proposal_id: string;
|
||||
approve: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ChangeProposalRecord {
|
||||
id?: number;
|
||||
proposal_id: string;
|
||||
doc_id: string;
|
||||
project_id?: number;
|
||||
triggered_by?: string;
|
||||
proposed_changes: string;
|
||||
impact_analysis?: string;
|
||||
risk_assessment?: string;
|
||||
status: string;
|
||||
author_decision_reason?: string;
|
||||
decided_at?: string | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export async function proposeModification(
|
||||
payload: ProposeModificationPayload,
|
||||
): Promise<ChangeProposalRecord> {
|
||||
return postDocGov('/api/v1/docgov/proposal', payload);
|
||||
}
|
||||
|
||||
export async function decideModificationProposal(
|
||||
payload: DecideModificationProposalPayload,
|
||||
): Promise<ChangeProposalRecord> {
|
||||
return postDocGov('/api/v1/docgov/proposal/decide', payload);
|
||||
}
|
||||
|
||||
export function mapLeaseToAstEditLease(doc: {
|
||||
lease_holder_type?: string;
|
||||
lease_holder_id?: string;
|
||||
|
||||
Reference in New Issue
Block a user