merge: feature/trace-panel-to-chat-2026-09-21
This commit is contained in:
+4
-4
@@ -5,15 +5,15 @@
|
|||||||
|
|
||||||
## 焦点
|
## 焦点
|
||||||
|
|
||||||
1. **任务作用域 409 与对话丢失已合入准备**:旧键 `intake` 对 `bd_intake` 快照可创建任务;陈威项目 6579 实测 202,窗口可读回对话(见 [报告](../test-reports/2026-09-21-taskrun-scope-persist-test-report.md))。test/main 未晋升。
|
1. **需求与影响已改为对话**:常驻面板已从 dev 页面移除;新待复核与下游影响写入已有项目会话。见 [报告](../test-reports/2026-09-21-trace-panel-to-chat-test-report.md)。test/main 未晋升。
|
||||||
2. **任务错误透传已合入 dev**:`RunRecord` snake_case 与前端真实失败原因(见 [报告](../test-reports/2026-09-21-taskrun-error-transparency-test-report.md))。test/main 未晋升。
|
2. **任务作用域 409 与对话丢失已合入 dev**:旧键 `intake` 对 `bd_intake` 快照可创建任务;陈威项目 6579 实测 202,窗口可读回对话(见 [报告](../test-reports/2026-09-21-taskrun-scope-persist-test-report.md))。test/main 未晋升。
|
||||||
2. **阶段回溯与影响闭环已合入 dev**:T1–T3 与 B1–B3 在 `dev`。空追踪 JSON `null` 已修复,登录后显示「已索引 0 份文档 · 条目 0」(见 [报告](../test-reports/2026-09-21-trace-panel-null-crash-test-report.md))。test/main 未晋升。不改 `production_ready`,不签 D7 对话 ADR。
|
|
||||||
3. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`。
|
3. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`。
|
||||||
|
|
||||||
## 并行认领
|
## 并行认领
|
||||||
|
|
||||||
| owner | task | branch | worktree | mutex | status |
|
| owner | task | branch | worktree | mutex | status |
|
||||||
|-------|------|--------|----------|-------|--------|
|
|-------|------|--------|----------|-------|--------|
|
||||||
|
| Cursor | 2026-09-21-trace-panel-to-chat | feature/trace-panel-to-chat-2026-09-21 | - | trace-impact+web | done |
|
||||||
| Cursor | 2026-09-21-taskrun-scope-persist | fix/taskrun-scope-persist-2026-09-21 | - | projectinstance+taskrun | done |
|
| Cursor | 2026-09-21-taskrun-scope-persist | fix/taskrun-scope-persist-2026-09-21 | - | projectinstance+taskrun | done |
|
||||||
| Cursor | 2026-09-21-taskrun-error-transparency | fix/taskrun-error-transparency-2026-09-21 | - | chat+web | done |
|
| Cursor | 2026-09-21-taskrun-error-transparency | fix/taskrun-error-transparency-2026-09-21 | - | chat+web | done |
|
||||||
| Cursor | 2026-09-21-trace-panel-null-crash | fix/trace-panel-null-crash-2026-09-21 | - | trace-index+web | done |
|
| Cursor | 2026-09-21-trace-panel-null-crash | fix/trace-panel-null-crash-2026-09-21 | - | trace-index+web | done |
|
||||||
@@ -174,7 +174,7 @@
|
|||||||
|
|
||||||
| holder | phase | since | scope | queue |
|
| holder | phase | since | scope | queue |
|
||||||
|--------|-------|-------|-------|-------|
|
|--------|-------|-------|-------|-------|
|
||||||
| - | idle | 2026-09-21 | 任务作用域键归一化已在 dev 容器验收;`/health` schema_version=59;项目 6579 任务 `run_1789967333594919025` 为 running | - |
|
| - | idle | 2026-09-21 | 需求与影响面板已从 dev 前端包移除;`/health` schema_version=59;pi-agent 为 MiniMax-M2.7-highspeed | - |
|
||||||
|
|
||||||
**phase**: `idle` | `merging` | `smoke` | `e2e`
|
**phase**: `idle` | `merging` | `smoke` | `e2e`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
title: "需求与影响改为对话输出"
|
||||||
|
status: done
|
||||||
|
created: 2026-09-21
|
||||||
|
updated: 2026-09-21
|
||||||
|
owner: Cursor
|
||||||
|
branch: feature/trace-panel-to-chat-2026-09-21
|
||||||
|
worktree: "-"
|
||||||
|
mutex: trace-impact+web
|
||||||
|
---
|
||||||
|
|
||||||
|
# 需求与影响改为对话输出
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
聊天栏不再常驻「需求与影响」。待复核标记和新的下游影响通过助手消息给出;用户也可以说「分析影响」。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- 移除 `ProjectTracePanel`
|
||||||
|
- 新的 recheck mark 写入操作者已有项目会话,卡片类型 `RECHECK_NOTICE`,「无需变更」仍调用原 clear 接口
|
||||||
|
- `document.downstream_impact_detected` 向文档编制人的已有会话写一条助手消息
|
||||||
|
- 影响意图增加「影响哪些 / 改动影响 / 变更影响」
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 无会话时不新建会话
|
||||||
|
- 不改阶段推进必须确认的门禁
|
||||||
|
- 不晋升 test/main
|
||||||
|
|
||||||
|
## 步骤
|
||||||
|
|
||||||
|
1. [x] 面板移除与卡片
|
||||||
|
2. [x] 后端通知与意图
|
||||||
|
3. [x] dev rebuild 与页面确认
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- [x] 已选项目时聊天区顶部没有「需求与影响」
|
||||||
|
- [x] 新标记会进入已有会话;重复分析不重复发
|
||||||
|
- [x] 「这次改动影响哪些文档」识别为影响分析
|
||||||
|
|
||||||
|
## 相关链接
|
||||||
|
|
||||||
|
- Test report: `.doc/test-reports/2026-09-21-trace-panel-to-chat-test-report.md`
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
| 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) | 全路由 inventory + session 中断负例 + 410 冻结 + R13 基线已签 | `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) | 全路由 inventory + session 中断负例 + 410 冻结 + R13 基线已签 | `authz` |
|
||||||
| 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | 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) | 项目 143 只读;不终止 | `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) | 项目 143 只读;不终止 | `docgov` |
|
||||||
| 文档版本 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` |
|
| 文档版本 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` |
|
||||||
| 阶段回溯与影响闭环 | 五表追踪索引、影响报告、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) | 已合入 dev。dev schema 59。test/main 未升。未复核不拦截推进。不签 D7 对话 ADR,不改 `production_ready` | `trace-index` |
|
| 阶段回溯与影响闭环 | 五表追踪索引、影响报告、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) | 已合入 dev。常驻「需求与影响」面板已退役,新待复核与下游影响改走已有项目会话。dev schema 59。test/main 未升。阶段推进确认门禁仍在。不签 D7 对话 ADR,不改 `production_ready` | `trace-index` |
|
||||||
| PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | partial | 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);[脚手架](../test-reports/2026-09-16-pu7-pi-narrative-test-report.md);[W3 真 PI](../test-reports/2026-09-17-w3-pi-change-report-narrative-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md);[最小化输入](../ops/short-term/2026-09-20-pi-narrative-clipped-diff.md) | flag 默认 off。`facts.json` 含截断后的 hunk 前后文和影响说明,以及完整 hunk/impact 计数与省略数;摘要 digest 仍不含正文。叙事请求已带 `tool_profile=reasoning`。运行中的 runner 尚未切到 `--no-tools`。不打开开关 | `docgov` |
|
| PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | partial | 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);[脚手架](../test-reports/2026-09-16-pu7-pi-narrative-test-report.md);[W3 真 PI](../test-reports/2026-09-17-w3-pi-change-report-narrative-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md);[最小化输入](../ops/short-term/2026-09-20-pi-narrative-clipped-diff.md) | flag 默认 off。`facts.json` 含截断后的 hunk 前后文和影响说明,以及完整 hunk/impact 计数与省略数;摘要 digest 仍不含正文。叙事请求已带 `tool_profile=reasoning`。运行中的 runner 尚未切到 `--no-tools`。不打开开关 | `docgov` |
|
||||||
| 前端工作台 | 三栏 + 块文档 + SSE;错误卡/进度/busy;版本冲突信封;demo seed 默认关;终止 preview | done | non_blocker | [workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[双标签](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/screenshots/tab-b-busy.png);[demo](../test-reports/2026-09-16-demo-seed-pollution-test-report.md);[B4](../test-reports/2026-09-16-frontend-gate-b4-test-report.md)(项目 6363) | **before**:空「缺口」却 partial → **after**:本行 done。其他 409 消费方信封复用另列 hardening backlog | `web` |
|
| 前端工作台 | 三栏 + 块文档 + SSE;错误卡/进度/busy;版本冲突信封;demo seed 默认关;终止 preview | done | non_blocker | [workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[双标签](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/screenshots/tab-b-busy.png);[demo](../test-reports/2026-09-16-demo-seed-pollution-test-report.md);[B4](../test-reports/2026-09-16-frontend-gate-b4-test-report.md)(项目 6363) | **before**:空「缺口」却 partial → **after**:本行 done。其他 409 消费方信封复用另列 hardening backlog | `web` |
|
||||||
| PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1 | done | non_blocker | [piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md) | 文档生成仍使用读写和搜索工具。`tool_profile=reasoning` 的 argv 是 `--no-tools` 且不加载搜索扩展;未知 profile 拒绝启动。dev 挂载了 `pi-args.mjs`,但本轮未重启容器 | `pi-agent` |
|
| PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1 | done | non_blocker | [piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md) | 文档生成仍使用读写和搜索工具。`tool_profile=reasoning` 的 argv 是 `--no-tools` 且不加载搜索扩展;未知 profile 拒绝启动。dev 挂载了 `pi-args.mjs`,但本轮未重启容器 | `pi-agent` |
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# 需求与影响对话化 测试报告
|
||||||
|
|
||||||
|
## 一、 测试背景 (Background)
|
||||||
|
|
||||||
|
聊天栏顶部常驻「需求与影响」。没有索引时也占位置,待复核只能在这块面板里点「无需变更」。产品要求改成助手在对话里提醒,并且只在需要时出现。
|
||||||
|
|
||||||
|
## 二、 测试目标 (Test Objectives)
|
||||||
|
|
||||||
|
- 已选项目时,聊天区不再挂载追踪面板。
|
||||||
|
- 新产生的待复核标记写入操作者已有的项目会话;没有会话时不新建;同一标记不重复播报。
|
||||||
|
- 「分析影响 / 改动影响哪些文档 / 变更影响」识别为影响分析。
|
||||||
|
- 下游影响事件向已有会话写一条助手消息。
|
||||||
|
- 阶段推进仍要确认。dev 容器健康,真实 PI 模型仍是 MiniMax-M2.7-highspeed。
|
||||||
|
|
||||||
|
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||||
|
|
||||||
|
- OS:Linux。Go 模块 `chat-agent-system`。前端 `web/` Node 测试与容器内 `npm run build`。
|
||||||
|
- 后端:dev 容器 `shuimu-agent-app-dev`、`shuimu-agent-events-dev`,HTTP `127.0.0.1:8080`。PI:`shuimu-pi-agent-dev`,`127.0.0.1:18865`。
|
||||||
|
- 层级:`traceimpact` / `docnotify` / `intent` 单测;前端 `npm test`;dev 镜像重建后检查已发布的 JS。
|
||||||
|
- 项目 6579 当前 `project_trace_items` 为 0、`stage_recheck_marks` 为 0。不为了造标记去改这份数据,标记注入用单测覆盖。
|
||||||
|
|
||||||
|
## 四、 预期结果 (Expected Results)
|
||||||
|
|
||||||
|
- 页面包中不再出现 `ProjectTracePanel`、「已索引」、「份文档 · 条目」。
|
||||||
|
- 页面包包含 `RECHECK_NOTICE` 与 `recheck_no_change`。
|
||||||
|
- `App.tsx` 仍挂载 `StageAdvanceBanner`。
|
||||||
|
- `/health` 的 `schema_version` 为 59,`agent_backend` 为 `pi-agent`。
|
||||||
|
|
||||||
|
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||||
|
|
||||||
|
### 用例 1:单测
|
||||||
|
|
||||||
|
- **测试输入 (Input)**:`go test -count=1 ./internal/traceimpact/ ./internal/app/docnotify/ ./internal/intent/ ./internal/app/ ./internal/server/`
|
||||||
|
- **实际输出 (Actual Output)**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ok chat-agent-system/internal/traceimpact 0.003s
|
||||||
|
ok chat-agent-system/internal/app/docnotify 0.002s
|
||||||
|
ok chat-agent-system/internal/intent 0.007s
|
||||||
|
ok chat-agent-system/internal/app 0.003s [no tests to run]
|
||||||
|
ok chat-agent-system/internal/server 0.128s
|
||||||
|
```
|
||||||
|
|
||||||
|
覆盖:新标记只通知一次、无会话不写消息、有会话写入助手消息、「请分析影响 / 这次改动影响哪些文档 / 帮我做一下变更影响」识别为影响分析。
|
||||||
|
|
||||||
|
- **评价判定 (Evaluation / Verdict)**:PASS
|
||||||
|
- **性能与耗时 (Duration & Latency)**:上述包合计不到 1 秒。
|
||||||
|
|
||||||
|
### 用例 2:前端测试
|
||||||
|
|
||||||
|
- **测试输入 (Input)**:`cd web && npm test`
|
||||||
|
- **实际输出 (Actual Output)**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ trace panel is not mounted above the chat stream (1.076438ms)
|
||||||
|
ℹ tests 442
|
||||||
|
ℹ suites 37
|
||||||
|
ℹ pass 442
|
||||||
|
ℹ fail 0
|
||||||
|
```
|
||||||
|
|
||||||
|
- **评价判定 (Evaluation / Verdict)**:PASS
|
||||||
|
- **性能与耗时 (Duration & Latency)**:整套约 3 秒。
|
||||||
|
|
||||||
|
### 用例 3:dev 重建与已发布页面
|
||||||
|
|
||||||
|
- **测试输入 (Input)**:`./scripts/env-service.sh dev rebuild`,然后请求首页与主包 `index-DEguWhkN.js`。
|
||||||
|
- **实际输出 (Actual Output)**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Image agent-dev-app Built
|
||||||
|
Image agent-dev-events Built
|
||||||
|
Container shuimu-agent-app-dev Started
|
||||||
|
Container shuimu-agent-events-dev Started
|
||||||
|
==> [dev] 镜像与容器已更新
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /health`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","schema_version":59,"schema_ready":true,"task_store_ready":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET http://127.0.0.1:18865/health`:`ok=true`,`model_id=MiniMax-M2.7-highspeed`。
|
||||||
|
|
||||||
|
主包计数:`ProjectTracePanel` 0,`已索引` 0,`份文档 · 条目` 0,`RECHECK_NOTICE` 2,`待复核` 5,`recheck_no_change` 1。浏览器打开 `http://127.0.0.1:8080/` 进入登录页,标题为「水木东方 · 医疗器械现代 AI 协同工作台」,服务可打开。未登录,因此没有进入已选项目的聊天栏。
|
||||||
|
|
||||||
|
- **评价判定 (Evaluation / Verdict)**:PASS。常驻面板文案已不在发布包中;待复核卡片类型在包中。
|
||||||
|
- **性能与耗时 (Duration & Latency)**:重建约 23 秒。
|
||||||
|
|
||||||
|
### 用例 4:项目 6579 不造标记
|
||||||
|
|
||||||
|
- **测试输入 (Input)**:查询 `project_trace_items` 与 `stage_recheck_marks`。
|
||||||
|
- **实际输出 (Actual Output)**:条目 0,未关闭标记 0。会话 4518(用户 398)与 4517(用户 9)仍在。
|
||||||
|
- **评价判定 (Evaluation / Verdict)**:PASS(按约定不改这份数据)。新标记注入由用例 1 证明。
|
||||||
|
- **性能与耗时 (Duration & Latency)**:查询不到 1 秒。
|
||||||
|
|
||||||
|
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||||
|
|
||||||
|
本次改动相关包与前端套件全部通过,见用例 1、2。未再跑会写入 dev 库的全量 `go test -short ./...`(上一轮已记录与本次无关的既有失败)。容器内 `npm run build` 成功,产物即当前 8080 页面。
|
||||||
|
|
||||||
|
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||||
|
|
||||||
|
达到合入 dev 的标准。阶段推进确认条仍留在 `StageAdvanceBanner`。项目一旦有追踪索引,新的待复核会进操作者已有会话;没有会话则跳过。test/main 未晋升。
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"chat-agent-system/internal/app/docproject"
|
"chat-agent-system/internal/app/docproject"
|
||||||
"chat-agent-system/internal/app/intentflow"
|
"chat-agent-system/internal/app/intentflow"
|
||||||
"chat-agent-system/internal/config"
|
"chat-agent-system/internal/config"
|
||||||
|
"chat-agent-system/internal/ctxmgr"
|
||||||
"chat-agent-system/internal/db"
|
"chat-agent-system/internal/db"
|
||||||
"chat-agent-system/internal/docgov"
|
"chat-agent-system/internal/docgov"
|
||||||
"chat-agent-system/internal/events"
|
"chat-agent-system/internal/events"
|
||||||
@@ -75,6 +76,7 @@ func main() {
|
|||||||
)
|
)
|
||||||
_ = disp.Register(events.ConsumerNotify, (&docnotify.EventConsumer{
|
_ = disp.Register(events.ConsumerNotify, (&docnotify.EventConsumer{
|
||||||
Notify: notify, Docs: gov, Env: cfg.Env,
|
Notify: notify, Docs: gov, Env: cfg.Env,
|
||||||
|
Chat: ctxmgr.NewContextManager(pool, nil, ctxmgr.TimeDecayConfig{}),
|
||||||
}).HandleLease)
|
}).HandleLease)
|
||||||
_ = disp.Register(events.ConsumerWorkflowAdvance, (&docadvance.EventConsumer{
|
_ = disp.Register(events.ConsumerWorkflowAdvance, (&docadvance.EventConsumer{
|
||||||
Inst: instSvc, Docs: gov, PW: pwSvc, Notify: notify, Env: cfg.Env,
|
Inst: instSvc, Docs: gov, PW: pwSvc, Notify: notify, Env: cfg.Env,
|
||||||
|
|||||||
+2
-1
@@ -196,6 +196,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
|
|||||||
traceSvc := traceimpact.Open(pool)
|
traceSvc := traceimpact.Open(pool)
|
||||||
traceSvc.SetStageReopener(traceStage{inst: instSvc})
|
traceSvc.SetStageReopener(traceStage{inst: instSvc})
|
||||||
traceSvc.SetDocUnlocker(traceUnlock{gov: parts.Gov})
|
traceSvc.SetDocUnlocker(traceUnlock{gov: parts.Gov})
|
||||||
|
bindRecheckChat(traceSvc, parts.Ctx)
|
||||||
if instSvc != nil {
|
if instSvc != nil {
|
||||||
instSvc.SetRecheckGate(traceRecheck{svc: traceSvc})
|
instSvc.SetRecheckGate(traceRecheck{svc: traceSvc})
|
||||||
}
|
}
|
||||||
@@ -255,7 +256,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
|
|||||||
instSvc.SetAuthorOnlyPolicySource(&sysadminAuthorOnlyPolicyAdapter{svc: rt.SysAdmin})
|
instSvc.SetAuthorOnlyPolicySource(&sysadminAuthorOnlyPolicyAdapter{svc: rt.SysAdmin})
|
||||||
svc.SetInstanceDutySource(&projectInstanceDutyAdapter{inst: instSvc})
|
svc.SetInstanceDutySource(&projectInstanceDutyAdapter{inst: instSvc})
|
||||||
}
|
}
|
||||||
rt.DocNotify = &docnotify.Subscriber{Notify: rt.Notify, Docs: parts.Gov}
|
rt.DocNotify = &docnotify.Subscriber{Notify: rt.Notify, Docs: parts.Gov, Chat: parts.Ctx}
|
||||||
rt.DocNotify.SkipWrite = func(ctx context.Context) bool {
|
rt.DocNotify.SkipWrite = func(ctx context.Context) bool {
|
||||||
return rt.WriterLive(ctx, events.ConsumerNotify)
|
return rt.WriterLive(ctx, events.ConsumerNotify)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package docnotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"chat-agent-system/internal/ctxmgr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PostProjectNotice writes an assistant message into the actor's latest project session.
|
||||||
|
// It does not create a session. Missing chat, user, or session is a no-op.
|
||||||
|
func PostProjectNotice(ctx context.Context, chat *ctxmgr.ContextManager, projectID, userID int64, content string, meta map[string]any) error {
|
||||||
|
if chat == nil || projectID <= 0 || userID <= 0 || content == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sessions, err := chat.ListChatSessions(ctx, ctxmgr.SessionFilter{UserID: userID, ProjectID: projectID, Limit: 1})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(sessions) == 0 || sessions[0].SessionID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if meta == nil {
|
||||||
|
meta = map[string]any{}
|
||||||
|
}
|
||||||
|
if _, ok := meta["project_id"]; !ok {
|
||||||
|
meta["project_id"] = projectID
|
||||||
|
}
|
||||||
|
_, err = chat.RecordUserMessageWithID(ctx, sessions[0].SessionID, 0, "assistant", content, "IMPACT_ANALYZE", "", meta)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("post project notice: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downstreamChatBody(docID, impacted string) string {
|
||||||
|
switch {
|
||||||
|
case docID != "" && impacted != "":
|
||||||
|
return fmt.Sprintf("文档 %s 的变更可能影响 %s。需要时可以说「分析影响」。", docID, impacted)
|
||||||
|
case docID != "":
|
||||||
|
return fmt.Sprintf("文档 %s 有变更,下游内容可能受影响。需要时可以说「分析影响」。", docID)
|
||||||
|
default:
|
||||||
|
return "有文档变更,下游内容可能受影响。需要时可以说「分析影响」。"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package docnotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"chat-agent-system/internal/ctxmgr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPostProjectNoticeSkipsWithoutSession(t *testing.T) {
|
||||||
|
chat := ctxmgr.NewContextManager(nil, nil, ctxmgr.TimeDecayConfig{})
|
||||||
|
if err := PostProjectNotice(t.Context(), chat, 7, 3, "上游有变更", nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
history, err := chat.GetChatHistory(t.Context(), "sess-missing", 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(history) != 0 {
|
||||||
|
t.Fatalf("must not create a session, history=%d", len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostProjectNoticeWritesActorSession(t *testing.T) {
|
||||||
|
chat := ctxmgr.NewContextManager(nil, nil, ctxmgr.TimeDecayConfig{})
|
||||||
|
if _, err := chat.EnsureChatSession(t.Context(), "sess-p7-u3", 3, 7, "项目对话"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := PostProjectNotice(t.Context(), chat, 7, 3, "文档 d 的变更可能影响 down。", map[string]any{
|
||||||
|
"interactive_card": map[string]any{"cardType": "RECHECK_NOTICE"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
history, err := chat.GetChatHistory(t.Context(), "sess-p7-u3", 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(history) != 1 || history[0].Role != "assistant" {
|
||||||
|
t.Fatalf("history=%+v", history)
|
||||||
|
}
|
||||||
|
if history[0].Metadata["interactive_card"] == nil {
|
||||||
|
t.Fatalf("metadata=%+v", history[0].Metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownstreamChatBody(t *testing.T) {
|
||||||
|
got := downstreamChatBody("doc-a", "doc-b")
|
||||||
|
if !strings.Contains(got, "doc-a") || !strings.Contains(got, "doc-b") || !strings.Contains(got, "分析影响") {
|
||||||
|
t.Fatalf("body=%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
|
"chat-agent-system/internal/ctxmgr"
|
||||||
"chat-agent-system/internal/events"
|
"chat-agent-system/internal/events"
|
||||||
"chat-agent-system/internal/notification"
|
"chat-agent-system/internal/notification"
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
type EventConsumer struct {
|
type EventConsumer struct {
|
||||||
Notify *notification.Service
|
Notify *notification.Service
|
||||||
Docs DocumentLookup
|
Docs DocumentLookup
|
||||||
|
Chat *ctxmgr.ContextManager
|
||||||
Env string
|
Env string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +59,14 @@ func (c *EventConsumer) HandleLease(ctx context.Context, lease events.Lease, tx
|
|||||||
return events.ErrQuarantine
|
return events.ErrQuarantine
|
||||||
}
|
}
|
||||||
p := notification.DocumentEventPayload{ProjectID: projectID, DocID: docID}
|
p := notification.DocumentEventPayload{ProjectID: projectID, DocID: docID}
|
||||||
return c.Notify.EmitVisibleDocumentEvent(ctx, tx, lease.EventID, kind, title, body, recipient, p)
|
if err := c.Notify.EmitVisibleDocumentEvent(ctx, tx, lease.EventID, kind, title, body, recipient, p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if lease.EventName == events.EventDownstreamImpact && c.Chat != nil {
|
||||||
|
impacted, _ := payload["impacted_doc_id"].(string)
|
||||||
|
_ = PostProjectNotice(ctx, c.Chat, projectID, recipient, downstreamChatBody(docID, impacted), nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func IntendedNotifyDigest(eventID, kind string, recipient int64) string {
|
func IntendedNotifyDigest(eventID, kind string, recipient int64) string {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"chat-agent-system/internal/ctxmgr"
|
||||||
"chat-agent-system/internal/docgov"
|
"chat-agent-system/internal/docgov"
|
||||||
"chat-agent-system/internal/docrevision"
|
"chat-agent-system/internal/docrevision"
|
||||||
"chat-agent-system/internal/notification"
|
"chat-agent-system/internal/notification"
|
||||||
@@ -19,6 +20,7 @@ type DocumentLookup interface {
|
|||||||
type Subscriber struct {
|
type Subscriber struct {
|
||||||
Notify notificationEmitter
|
Notify notificationEmitter
|
||||||
Docs DocumentLookup
|
Docs DocumentLookup
|
||||||
|
Chat *ctxmgr.ContextManager
|
||||||
// SkipWrite is true when the durable notify consumer is live.
|
// SkipWrite is true when the durable notify consumer is live.
|
||||||
// Bind stays registered while mode=off; the closure must not dual-write after switch.
|
// Bind stays registered while mode=off; the closure must not dual-write after switch.
|
||||||
SkipWrite func(ctx context.Context) bool
|
SkipWrite func(ctx context.Context) bool
|
||||||
@@ -88,7 +90,10 @@ func (s *Subscriber) Handle(ctx context.Context, eventType string, payload map[s
|
|||||||
_, err := s.Notify.EmitDocumentEvent(ctx, eventType, notification.TypeDocDownstreamImpact,
|
_, err := s.Notify.EmitDocumentEvent(ctx, eventType, notification.TypeDocDownstreamImpact,
|
||||||
"下游文档受影响", "上游文档变更,请复核关联文档。",
|
"下游文档受影响", "上游文档变更,请复核关联文档。",
|
||||||
fmt.Sprintf("doc_impact:%s:%s:%d", docID, impacted, recipient), recipient, p)
|
fmt.Sprintf("doc_impact:%s:%s:%d", docID, impacted, recipient), recipient, p)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return PostProjectNotice(ctx, s.Chat, projectID, recipient, downstreamChatBody(docID, impacted), nil)
|
||||||
case notification.EventDocChangeReportDegraded:
|
case notification.EventDocChangeReportDegraded:
|
||||||
if v, ok := payload["report_id"].(int64); ok {
|
if v, ok := payload["report_id"].(int64); ok {
|
||||||
p.ReportID = v
|
p.ReportID = v
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"chat-agent-system/internal/app/docnotify"
|
||||||
"chat-agent-system/internal/coordinator"
|
"chat-agent-system/internal/coordinator"
|
||||||
|
"chat-agent-system/internal/ctxmgr"
|
||||||
"chat-agent-system/internal/docgov"
|
"chat-agent-system/internal/docgov"
|
||||||
"chat-agent-system/internal/projectinstance"
|
"chat-agent-system/internal/projectinstance"
|
||||||
"chat-agent-system/internal/traceimpact"
|
"chat-agent-system/internal/traceimpact"
|
||||||
@@ -60,15 +62,54 @@ func (t traceUnlock) ReopenApprovedDocs(ctx context.Context, projectID int64, do
|
|||||||
|
|
||||||
type traceChat struct{ svc *traceimpact.Service }
|
type traceChat struct{ svc *traceimpact.Service }
|
||||||
|
|
||||||
func (t traceChat) AnalyzeImpact(ctx context.Context, projectID, _ int64, text string) (string, error) {
|
func (t traceChat) AnalyzeImpact(ctx context.Context, projectID, userID int64, text string) (string, error) {
|
||||||
if t.svc == nil {
|
if t.svc == nil {
|
||||||
return "", fmt.Errorf("trace: unavailable")
|
return "", fmt.Errorf("trace: unavailable")
|
||||||
}
|
}
|
||||||
|
ctx = traceimpact.WithActor(ctx, userID)
|
||||||
rep, err := t.svc.Analyze(ctx, projectID, traceimpact.DocRef{DocID: "chat", Markdown: text, Visible: true}, nil)
|
rep, err := t.svc.Analyze(ctx, projectID, traceimpact.DocRef{DocID: "chat", Markdown: text, Visible: true}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return rep.Summary, nil
|
return formatImpactReply(rep), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatImpactReply(rep *traceimpact.Report) string {
|
||||||
|
if rep == nil || len(rep.Impacts) == 0 {
|
||||||
|
if rep.Summary != "" {
|
||||||
|
return rep.Summary
|
||||||
|
}
|
||||||
|
return "目前没有已确认的影响。可以说「分析影响」并带上条目编号,例如 UR-001。"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
if rep.Summary != "" {
|
||||||
|
b.WriteString(rep.Summary)
|
||||||
|
b.WriteString("。")
|
||||||
|
}
|
||||||
|
limit := len(rep.Impacts)
|
||||||
|
if limit > 8 {
|
||||||
|
limit = 8
|
||||||
|
}
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
imp := rep.Impacts[i]
|
||||||
|
label := imp.ItemKey
|
||||||
|
if label == "" {
|
||||||
|
label = imp.DocID
|
||||||
|
}
|
||||||
|
if label == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString("\n- ")
|
||||||
|
b.WriteString(label)
|
||||||
|
if imp.Reason != "" {
|
||||||
|
b.WriteString(":")
|
||||||
|
b.WriteString(imp.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rep.Impacts) > limit {
|
||||||
|
fmt.Fprintf(&b, "\n另有 %d 条未展开。", len(rep.Impacts)-limit)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func projectIDFromControlledDoc(docID string) int64 {
|
func projectIDFromControlledDoc(docID string) int64 {
|
||||||
@@ -86,6 +127,42 @@ func projectIDFromControlledDoc(docID string) int64 {
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func bindRecheckChat(svc *traceimpact.Service, chat *ctxmgr.ContextManager) {
|
||||||
|
if svc == nil || chat == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svc.SetRecheckHook(func(ctx context.Context, mark traceimpact.RecheckMark) {
|
||||||
|
userID := traceimpact.ActorID(ctx)
|
||||||
|
reason := mark.Reason
|
||||||
|
if reason == "" {
|
||||||
|
reason = mark.SourceDoc
|
||||||
|
}
|
||||||
|
content := fmt.Sprintf("阶段 %s 待复核(%s)。需要细节可以说「分析影响」。", mark.StageKey, reason)
|
||||||
|
card := map[string]any{
|
||||||
|
"cardId": fmt.Sprintf("recheck-%d", mark.ID),
|
||||||
|
"cardType": "RECHECK_NOTICE",
|
||||||
|
"title": "阶段待复核",
|
||||||
|
"proposedContent": content,
|
||||||
|
"status": "active",
|
||||||
|
"ttlTurns": 8,
|
||||||
|
"actions": []any{
|
||||||
|
map[string]any{
|
||||||
|
"actionId": "recheck_no_change",
|
||||||
|
"label": "无需变更",
|
||||||
|
"style": "primary",
|
||||||
|
"payload": map[string]string{
|
||||||
|
"project_id": strconv.FormatInt(mark.ProjectID, 10),
|
||||||
|
"mark_id": strconv.FormatInt(mark.ID, 10),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_ = docnotify.PostProjectNotice(ctx, chat, mark.ProjectID, userID, content, map[string]any{
|
||||||
|
"interactive_card": card,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
func (t traceChat) ProposeRevision(ctx context.Context, projectID, userID int64, text string, confirmed bool) (string, error) {
|
func (t traceChat) ProposeRevision(ctx context.Context, projectID, userID int64, text string, confirmed bool) (string, error) {
|
||||||
if t.svc == nil {
|
if t.svc == nil {
|
||||||
return "", fmt.Errorf("trace: unavailable")
|
return "", fmt.Errorf("trace: unavailable")
|
||||||
|
|||||||
@@ -108,6 +108,48 @@ func (m *ContextManager) FinalizeAssistantTaskMessage(ctx context.Context, messa
|
|||||||
return errors.New("message not found")
|
return errors.New("message not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetInteractiveCardStatus marks a stored chat card resolved or otherwise closed.
|
||||||
|
func (m *ContextManager) SetInteractiveCardStatus(ctx context.Context, messageID int64, status, summary string) error {
|
||||||
|
if m == nil || messageID <= 0 || status == "" {
|
||||||
|
return errors.New("invalid card status update")
|
||||||
|
}
|
||||||
|
if m.pool != nil || hasUoW(ctx) {
|
||||||
|
_, err := execChat(ctx, m.pool, `
|
||||||
|
UPDATE chat_messages
|
||||||
|
SET metadata = jsonb_set(
|
||||||
|
jsonb_set(COALESCE(metadata, '{}'::jsonb), '{interactive_card,status}', to_jsonb($2::text), true),
|
||||||
|
'{interactive_card,resolutionSummary}', to_jsonb($3::text), true)
|
||||||
|
WHERE id = $1
|
||||||
|
`, messageID, status, summary)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set interactive card status: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for sid, msgs := range m.messages {
|
||||||
|
for i := range msgs {
|
||||||
|
if msgs[i].ID != messageID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
card, _ := msgs[i].Metadata["interactive_card"].(map[string]any)
|
||||||
|
if card == nil {
|
||||||
|
card = map[string]any{}
|
||||||
|
}
|
||||||
|
card["status"] = status
|
||||||
|
card["resolutionSummary"] = summary
|
||||||
|
if msgs[i].Metadata == nil {
|
||||||
|
msgs[i].Metadata = map[string]any{}
|
||||||
|
}
|
||||||
|
msgs[i].Metadata["interactive_card"] = card
|
||||||
|
m.messages[sid][i] = msgs[i]
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("message not found")
|
||||||
|
}
|
||||||
|
|
||||||
func hasUoW(ctx context.Context) bool {
|
func hasUoW(ctx context.Context) bool {
|
||||||
_, ok := uow.ScopeFrom(ctx)
|
_, ok := uow.ScopeFrom(ctx)
|
||||||
return ok
|
return ok
|
||||||
|
|||||||
@@ -1240,7 +1240,8 @@ func isGreetingOrHelp(input string) bool {
|
|||||||
|
|
||||||
func matchImpactIntent(input string) *CandidateIntent {
|
func matchImpactIntent(input string) *CandidateIntent {
|
||||||
propose := strings.Contains(input, "修订提案") || strings.Contains(input, "生成修订") || strings.Contains(input, "提议修改文档")
|
propose := strings.Contains(input, "修订提案") || strings.Contains(input, "生成修订") || strings.Contains(input, "提议修改文档")
|
||||||
analyze := strings.Contains(input, "分析影响") || strings.Contains(input, "影响分析") || strings.Contains(input, "波及哪些")
|
analyze := strings.Contains(input, "分析影响") || strings.Contains(input, "影响分析") || strings.Contains(input, "波及哪些") ||
|
||||||
|
strings.Contains(input, "影响哪些") || strings.Contains(input, "改动影响") || strings.Contains(input, "变更影响")
|
||||||
if !propose && !analyze {
|
if !propose && !analyze {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1249,8 +1250,8 @@ func matchImpactIntent(input string) *CandidateIntent {
|
|||||||
intent = IntentRevisePropose
|
intent = IntentRevisePropose
|
||||||
}
|
}
|
||||||
return &CandidateIntent{
|
return &CandidateIntent{
|
||||||
Intent: intent,
|
Intent: intent,
|
||||||
Confidence: 0.9,
|
Confidence: 0.9,
|
||||||
FilledSlots: map[string]string{},
|
FilledSlots: map[string]string{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -773,3 +773,16 @@ func TestIntentRecognition_PermissionMismatch_NotHijackedBySupplement(t *testing
|
|||||||
t.Errorf("expected branch NEW_INTENT_PREEMPT for permission mismatch, got %s", res.Branch)
|
t.Errorf("expected branch NEW_INTENT_PREEMPT for permission mismatch, got %s", res.Branch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIntentRecognition_ImpactPhrases(t *testing.T) {
|
||||||
|
rec := intent.NewRecognizer()
|
||||||
|
for _, text := range []string{"请分析影响", "这次改动影响哪些文档", "帮我做一下变更影响"} {
|
||||||
|
res, err := rec.Recognize(context.Background(), text, &intent.ContextSnapshot{SnapshotID: "impact"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.TopIntent.Intent != intent.IntentImpactAnalyze {
|
||||||
|
t.Fatalf("%q intent=%s", text, res.TopIntent.Intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func (s *Server) handleProjectTraceIndex(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleProjectTraceAnalyze(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleProjectTraceAnalyze(w http.ResponseWriter, r *http.Request) {
|
||||||
projectID, _, ok := s.traceProject(w, r)
|
projectID, user, ok := s.traceProject(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ func (s *Server) handleProjectTraceAnalyze(w http.ResponseWriter, r *http.Reques
|
|||||||
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
|
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rep, err := s.rt.Trace.Analyze(r.Context(), projectID, body.DocRef, body.Others)
|
rep, err := s.rt.Trace.Analyze(traceimpact.WithActor(r.Context(), user.ID), projectID, body.DocRef, body.Others)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
|
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
|
||||||
return
|
return
|
||||||
@@ -176,7 +176,8 @@ func (s *Server) handleProjectTraceRecheckClear(w http.ResponseWriter, r *http.R
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
|
MessageID int64 `json:"message_id"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF {
|
||||||
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
|
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
|
||||||
@@ -202,6 +203,9 @@ func (s *Server) handleProjectTraceRecheckClear(w http.ResponseWriter, r *http.R
|
|||||||
writeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": "TRACE_RECHECK_CLEAR_FAILED", "message": err.Error()})
|
writeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": "TRACE_RECHECK_CLEAR_FAILED", "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if body.MessageID > 0 && s.ctxMgr != nil {
|
||||||
|
_ = s.ctxMgr.SetInteractiveCardStatus(r.Context(), body.MessageID, "resolved", "已标记为无需变更")
|
||||||
|
}
|
||||||
writeJSONResponse(w, http.StatusOK, map[string]string{"status": "cleared"})
|
writeJSONResponse(w, http.StatusOK, map[string]string{"status": "cleared"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ type DocUnlocker interface {
|
|||||||
|
|
||||||
// Service is the project trace index and the stage-backtrack ledger.
|
// Service is the project trace index and the stage-backtrack ledger.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store Store
|
store Store
|
||||||
reopen StageReopener
|
reopen StageReopener
|
||||||
unlock DocUnlocker
|
unlock DocUnlocker
|
||||||
|
onRecheck func(ctx context.Context, mark RecheckMark)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(store Store) *Service {
|
func NewService(store Store) *Service {
|
||||||
@@ -40,6 +41,30 @@ func NewMemoryService() *Service {
|
|||||||
func (s *Service) SetStageReopener(r StageReopener) { s.reopen = r }
|
func (s *Service) SetStageReopener(r StageReopener) { s.reopen = r }
|
||||||
func (s *Service) SetDocUnlocker(u DocUnlocker) { s.unlock = u }
|
func (s *Service) SetDocUnlocker(u DocUnlocker) { s.unlock = u }
|
||||||
|
|
||||||
|
// SetRecheckHook is called once when a new open recheck mark is stored.
|
||||||
|
// Repeat analyzes of the same source do not call it again.
|
||||||
|
func (s *Service) SetRecheckHook(fn func(ctx context.Context, mark RecheckMark)) {
|
||||||
|
if s != nil {
|
||||||
|
s.onRecheck = fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type actorCtxKey struct{}
|
||||||
|
|
||||||
|
// WithActor records who triggered this analysis so a recheck notice can reach their chat.
|
||||||
|
func WithActor(ctx context.Context, userID int64) context.Context {
|
||||||
|
if userID <= 0 {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, actorCtxKey{}, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActorID is the user from WithActor, or 0.
|
||||||
|
func ActorID(ctx context.Context) int64 {
|
||||||
|
id, _ := ctx.Value(actorCtxKey{}).(int64)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
// IndexDocument projects explicit keys into items and same-line pairs into confirmed edges.
|
// IndexDocument projects explicit keys into items and same-line pairs into confirmed edges.
|
||||||
// Untyped IDs become candidate items only.
|
// Untyped IDs become candidate items only.
|
||||||
func (s *Service) IndexDocument(ctx context.Context, projectID int64, doc DocRef) (IndexState, error) {
|
func (s *Service) IndexDocument(ctx context.Context, projectID int64, doc DocRef) (IndexState, error) {
|
||||||
@@ -186,10 +211,22 @@ func (s *Service) Analyze(ctx context.Context, projectID int64, source DocRef, o
|
|||||||
Reason: fmt.Sprintf("%s 经已确认关系受本次修改影响", it.BusinessKey),
|
Reason: fmt.Sprintf("%s 经已确认关系受本次修改影响", it.BusinessKey),
|
||||||
})
|
})
|
||||||
if target.StageStatus == "completed" && target.StageKey != "" {
|
if target.StageStatus == "completed" && target.StageKey != "" {
|
||||||
_, _ = s.store.PutMark(ctx, RecheckMark{
|
already := false
|
||||||
|
if existing, listErr := s.store.ListOpenMarks(ctx, projectID, target.StageKey); listErr == nil {
|
||||||
|
for _, m := range existing {
|
||||||
|
if m.SourceDoc == source.DocID {
|
||||||
|
already = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saved, putErr := s.store.PutMark(ctx, RecheckMark{
|
||||||
ProjectID: projectID, StageKey: target.StageKey, SourceDoc: source.DocID,
|
ProjectID: projectID, StageKey: target.StageKey, SourceDoc: source.DocID,
|
||||||
Status: MarkOpen, Reason: "上游或同项目条目已变化",
|
Status: MarkOpen, Reason: "上游或同项目条目已变化",
|
||||||
})
|
})
|
||||||
|
if putErr == nil && !already && s.onRecheck != nil {
|
||||||
|
s.onRecheck(ctx, saved)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ func TestAnalyze_PendingIsNotNoImpact(t *testing.T) {
|
|||||||
func TestAnalyze_ConfirmedDownstreamAndRecheck(t *testing.T) {
|
func TestAnalyze_ConfirmedDownstreamAndRecheck(t *testing.T) {
|
||||||
svc := NewMemoryService()
|
svc := NewMemoryService()
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
var notices int
|
||||||
|
svc.SetRecheckHook(func(_ context.Context, mark RecheckMark) {
|
||||||
|
notices++
|
||||||
|
if mark.StageKey != "V0.5" || mark.ID <= 0 {
|
||||||
|
t.Fatalf("mark %+v", mark)
|
||||||
|
}
|
||||||
|
})
|
||||||
if _, err := svc.IndexDocument(ctx, 7, DocRef{DocID: "up", Markdown: "UR-001 用户需要记录"}); err != nil {
|
if _, err := svc.IndexDocument(ctx, 7, DocRef{DocID: "up", Markdown: "UR-001 用户需要记录"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -98,6 +105,17 @@ func TestAnalyze_ConfirmedDownstreamAndRecheck(t *testing.T) {
|
|||||||
if err := svc.ClearRecheck(ctx, marks[0].ID, "auto"); err == nil {
|
if err := svc.ClearRecheck(ctx, marks[0].ID, "auto"); err == nil {
|
||||||
t.Fatal("auto clear must fail")
|
t.Fatal("auto clear must fail")
|
||||||
}
|
}
|
||||||
|
if notices != 1 {
|
||||||
|
t.Fatalf("recheck hook calls=%d", notices)
|
||||||
|
}
|
||||||
|
if _, err := svc.Analyze(ctx, 7, DocRef{DocID: "up", Markdown: "UR-001 改为必须记录"}, []DocRef{{
|
||||||
|
DocID: "down", StageKey: "V0.5", StageStatus: "completed", Visible: true,
|
||||||
|
}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if notices != 1 {
|
||||||
|
t.Fatalf("repeat analyze must not notify again, calls=%d", notices)
|
||||||
|
}
|
||||||
if err := svc.ClearRecheck(ctx, marks[0].ID, ClearNoChange); err != nil {
|
if err := svc.ClearRecheck(ctx, marks[0].ID, ClearNoChange); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
import { DocumentLineage } from './types/projectWorkflow';
|
import { DocumentLineage } from './types/projectWorkflow';
|
||||||
import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice';
|
import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice';
|
||||||
import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner';
|
import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner';
|
||||||
import { ProjectTracePanel } from './components/workflow/ProjectTracePanel';
|
|
||||||
import { OnDemand } from './components/lazy/OnDemand';
|
import { OnDemand } from './components/lazy/OnDemand';
|
||||||
import { fetchProjectSettings } from './api/projectSettings';
|
import { fetchProjectSettings } from './api/projectSettings';
|
||||||
import { LoginView } from './components/auth/LoginView';
|
import { LoginView } from './components/auth/LoginView';
|
||||||
@@ -435,7 +434,6 @@ export default function App() {
|
|||||||
isCreator={currentWfState.creatorId === currentUser.id}
|
isCreator={currentWfState.creatorId === currentUser.id}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentProjectId > 0 && <ProjectTracePanel projectId={currentProjectId} />}
|
|
||||||
<StaleDocumentNotice
|
<StaleDocumentNotice
|
||||||
lineages={projectLineages}
|
lineages={projectLineages}
|
||||||
onTriggerRecheck={() => {
|
onTriggerRecheck={() => {
|
||||||
|
|||||||
@@ -49,11 +49,16 @@ export async function fetchProjectTrace(projectId: number): Promise<ProjectTrace
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearRecheckMark(projectId: number, markId: number, reason: 'no_change' | 'revision_started'): Promise<void> {
|
export async function clearRecheckMark(
|
||||||
|
projectId: number,
|
||||||
|
markId: number,
|
||||||
|
reason: 'no_change' | 'revision_started',
|
||||||
|
messageId?: number,
|
||||||
|
): Promise<void> {
|
||||||
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/trace/recheck/${markId}/clear`, {
|
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/trace/recheck/${markId}/clear`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getBearerAuthHeaders('application/json'),
|
headers: getBearerAuthHeaders('application/json'),
|
||||||
body: JSON.stringify({ reason }),
|
body: JSON.stringify({ reason, message_id: messageId || 0 }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`关闭待复核失败: ${res.status}`);
|
throw new Error(`关闭待复核失败: ${res.status}`);
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ export function getCardTypeMeta(type: InteractiveCardType) {
|
|||||||
badgeColor: 'var(--info)',
|
badgeColor: 'var(--info)',
|
||||||
badgeBorder: 'rgba(43, 108, 201, 0.2)',
|
badgeBorder: 'rgba(43, 108, 201, 0.2)',
|
||||||
};
|
};
|
||||||
|
case 'RECHECK_NOTICE':
|
||||||
|
return {
|
||||||
|
label: '待复核',
|
||||||
|
badgeText: '待复核',
|
||||||
|
icon: AlertTriangle,
|
||||||
|
accentColor: 'var(--amber-500)',
|
||||||
|
badgeBg: 'var(--amber-50)',
|
||||||
|
badgeColor: 'var(--amber-600)',
|
||||||
|
badgeBorder: 'rgba(242, 169, 59, 0.3)',
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
label: '协同卡片',
|
label: '协同卡片',
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { clearRecheckMark, fetchProjectTrace, ProjectTraceView } from '../../api/projectTrace';
|
|
||||||
|
|
||||||
interface ProjectTracePanelProps {
|
|
||||||
projectId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProjectTracePanel: React.FC<ProjectTracePanelProps> = ({ projectId }) => {
|
|
||||||
const [view, setView] = useState<ProjectTraceView | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setView(await fetchProjectTrace(projectId));
|
|
||||||
setError(null);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setError(err instanceof Error ? err.message : '加载失败');
|
|
||||||
}
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <div style={boxStyle}>需求与影响暂时不可用:{error}</div>;
|
|
||||||
}
|
|
||||||
if (!view) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={boxStyle}>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>需求与影响</div>
|
|
||||||
<div style={{ color: 'var(--text-muted)' }}>
|
|
||||||
已索引 {view.index.length} 份文档 · 条目 {view.items.length}
|
|
||||||
{view.hidden_count > 0 ? ` · 另有 ${view.hidden_count} 条不可见` : ''}
|
|
||||||
</div>
|
|
||||||
{view.recheck_marks.length > 0 && (
|
|
||||||
<ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
|
|
||||||
{view.recheck_marks.map((mark) => (
|
|
||||||
<li key={mark.id}>
|
|
||||||
阶段 {mark.stage_key} 待复核({mark.reason || mark.source_doc_id})
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
style={{ marginLeft: 8 }}
|
|
||||||
onClick={() => {
|
|
||||||
clearRecheckMark(projectId, mark.id, 'no_change').then(load).catch((err: unknown) => {
|
|
||||||
setError(err instanceof Error ? err.message : '关闭失败');
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
无需变更
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
{view.items.length > 0 && (
|
|
||||||
<ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
|
|
||||||
{view.items.slice(0, 8).map((item) => (
|
|
||||||
<li key={item.id}>
|
|
||||||
{item.business_key}
|
|
||||||
{item.statement ? ` · ${item.statement}` : ''}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const boxStyle: React.CSSProperties = {
|
|
||||||
margin: '8px 12px 0',
|
|
||||||
padding: '10px 12px',
|
|
||||||
borderRadius: 8,
|
|
||||||
border: '1px solid var(--border, #e2e8f0)',
|
|
||||||
background: 'var(--bg-subtle, #f8fafc)',
|
|
||||||
fontSize: 12,
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
test('trace panel is not mounted above the chat stream', () => {
|
||||||
|
const app = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8');
|
||||||
|
assert.equal(app.includes('ProjectTracePanel'), false);
|
||||||
|
const card = readFileSync(new URL('../chat/InteractiveCard.tsx', import.meta.url), 'utf8');
|
||||||
|
assert.equal(card.includes("case 'RECHECK_NOTICE'"), true);
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
import { clearRecheckMark } from '../api/projectTrace';
|
||||||
import {
|
import {
|
||||||
fetchIntentPlan,
|
fetchIntentPlan,
|
||||||
submitIntentPlanDecision,
|
submitIntentPlanDecision,
|
||||||
@@ -69,6 +70,32 @@ export function useCardActions(input: CardActionsInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!targetCard) return;
|
||||||
|
|
||||||
|
if (targetCard.cardType === 'RECHECK_NOTICE' && actionId === 'recheck_no_change') {
|
||||||
|
const projectId = Number(payload?.project_id || currentProjectId);
|
||||||
|
const markId = Number(payload?.mark_id);
|
||||||
|
const serverId = messages.find((m) => m.interactiveCard?.cardId === cardId)?.serverId;
|
||||||
|
void clearRecheckMark(projectId, markId, 'no_change', serverId)
|
||||||
|
.then(() => {
|
||||||
|
setMessages((prev) => resolveCardInMessages(prev, cardId, '已标记为无需变更', currentUser.name));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: 'recheck-fail-' + Date.now(),
|
||||||
|
sessionId: activeSessionId,
|
||||||
|
role: 'assistant',
|
||||||
|
authorName: '项目助手',
|
||||||
|
content: '没能关闭这条待复核,请稍后再试。',
|
||||||
|
timestamp: new Date().toTimeString().substring(0, 5),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isPlanConfirmCard(targetCard, payload)) {
|
if (isPlanConfirmCard(targetCard, payload)) {
|
||||||
const planId = String(payload?.plan_id || targetCard?.planId || '');
|
const planId = String(payload?.plan_id || targetCard?.planId || '');
|
||||||
const expectedRevision = Number(payload?.expected_revision || targetCard?.planRevision || 0);
|
const expectedRevision = Number(payload?.expected_revision || targetCard?.planRevision || 0);
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ export type InteractiveCardType =
|
|||||||
| 'PLAN_CONFIRM'
|
| 'PLAN_CONFIRM'
|
||||||
| 'SLOT_FILLING'
|
| 'SLOT_FILLING'
|
||||||
| 'DIFF_PROPOSAL'
|
| 'DIFF_PROPOSAL'
|
||||||
| 'REMINDER_NOTICE';
|
| 'REMINDER_NOTICE'
|
||||||
|
| 'RECHECK_NOTICE';
|
||||||
|
|
||||||
export type CardActionStatus =
|
export type CardActionStatus =
|
||||||
| 'active'
|
| 'active'
|
||||||
|
|||||||
Reference in New Issue
Block a user