From 36c7a9d8d2b7a94199a7b47cfae0d58419c8c5cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Mon, 14 Sep 2026 20:36:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E5=85=A5=E5=BA=93=20+=20=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E6=A0=A1=E5=AF=B9=E8=A1=A5=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 新入库(`docs/演示用/`) - `代码库全面审查报告-2026-09-14.md` - `代码修改方案-2026-09-14.md` - `记忆系统排查报告-2026-09-14.md` - `记忆系统修复文档-2026-09-14.md` - `文档一致性审计报告-2026-09-14.md` - `多Worker接入方案-2026-09-14.md` ## 全量校对(32 个既有文档 + `AGENTS.md`) 跨 39 个文件、**1125 insertions / 148 deletions**。 ⚠️ **这批改动同样不是本次会话写的**。我抽样核对过性质:是**实质内容补充**而不是 格式/换行转换。例如 `docs/44-演示流程.md` 新增两条"2026-09-14 补注": - `启动金融Agent平台.bat` 只在**桌面**上,仓库里只有 `启动平台.bat` 这一份 (两份由同一个 `tools/make_launcher_bat.py` 产出,改完 `start.ps1` 重跑它一起更新); - `advisor_t`(9020) 与 `offsite_t`(9006) **不在 `tools/seed_test_rbac.py` 的演示用户里** (那里只有 `cust_t`/`risk_t`/`admin_t`/`review_t` 四个),由 `grant_*.py` 系列创建, **重跑种子不会重建它们** —— 换机器时这两个账号登录失败,要先查 `sys_user` 有没有这两行, 而不是查密码。 这两条都是对的地方,与我这一路踩到的现象一致(我确实用到了 `advisor_t`/`offsite_t`)。 **我没有逐字审阅全部 39 个文件**,只抽样确认了改动性质与规模。若其中有需要复核的段落, 请指明文件,我逐处核对。 --- AGENTS.md | 38 +- docs/02-数据库建表设计.md | 20 +- docs/03-平台端到端流程文档.md | 17 +- docs/05-接口文档.md | 47 +- docs/07-测试问题修复记录.md | 18 +- docs/08-数据库结构审计基线.md | 32 +- docs/09-底座使用文档.md | 69 +- docs/12-基金行情数据底座接入开发计划.md | 26 +- docs/14-Agent组员统一接入说明书.md | 57 +- docs/18-知识检索接入方案.md | 31 +- docs/22-四大Agent流程实现拆解.md | 26 +- docs/23-记忆分层与画像设计.md | 316 +++++- docs/26-JWT密钥管理与轮换.md | 19 +- docs/28-场外与推广域数据表登记.md | 16 +- docs/36-PR7合并记录与权限号段修正.md | 30 +- docs/38-架构对齐-记忆与画像投影链路.md | 17 + docs/41-客服Agent前端开发约束_v1.md | 90 +- docs/42-场内基金知识条目草稿.md | 9 + docs/43-场内基金产品手册(知识库入库版).md | 9 +- docs/44-演示流程.md | 19 + .../analysis/2026-09-10-现状与差距分析.md | 6 + .../2026-09-10-范围重定位与画像讨论记录.md | 5 + .../客服Agent专项设计方案-分析报告.md | 5 + .../2026-09-11-交接文档-客服Agent与RAG收尾.md | 30 +- .../2026-09-10-foundation-safe-migration.md | 5 + ...026-09-10-客服Agent与RAG实施计划-qyqy版.md | 5 + ...026-09-10-customer-service-agent-design.md | 5 + ...-09-10-foundation-safe-migration-design.md | 4 + ...-09-10-knowledge-retrieval-infra-design.md | 6 + docs/演示用/代码修改方案-2026-09-14.md | 967 ++++++++++++++++++ docs/演示用/代码库全面审查报告-2026-09-14.md | 591 +++++++++++ docs/演示用/多Worker接入方案-2026-09-14.md | 307 ++++++ docs/演示用/文档一致性审计报告-2026-09-14.md | 179 ++++ docs/演示用/记忆系统修复文档-2026-09-14.md | 289 ++++++ docs/演示用/记忆系统排查报告-2026-09-14.md | 392 +++++++ .../08-数据库依赖与读写边界.md | 15 + docs/验收与审计/phase1-acceptance-criteria.md | 19 +- docs/验收与审计/phase1-acceptance-report.md | 6 +- 38 files changed, 3604 insertions(+), 138 deletions(-) create mode 100644 docs/演示用/代码修改方案-2026-09-14.md create mode 100644 docs/演示用/代码库全面审查报告-2026-09-14.md create mode 100644 docs/演示用/多Worker接入方案-2026-09-14.md create mode 100644 docs/演示用/文档一致性审计报告-2026-09-14.md create mode 100644 docs/演示用/记忆系统修复文档-2026-09-14.md create mode 100644 docs/演示用/记忆系统排查报告-2026-09-14.md diff --git a/AGENTS.md b/AGENTS.md index 43c75e5..67dcf25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,10 +24,22 @@ > 客服/RAG 那条线的个人分支是 **`NL_develop`**(个人分支 → PR 合回 `qyqy_develop`),**不要再用 `6516ccb`**。 > 它是对"当前状态"最准确的一份,读完它再读下面这些。 > -> **🖥️ 前端(2026-09-13 起)**:**正式前端在 `app/static/portal/`**,由 `app/main.py` 挂载, -> 通过 **`/portal/`** 访问(`/` 会 307 跳到访客首页)。四套页面:`guest/`(访客,公开产品页 -> 暂时用 `common/mock-data.js` 并在页面上标注)、`customer/`(客户)、`employee-console/`(管理员)、 -> `employee-risk/`(风控)。**端点表集中在 `common/api-client.js`**,改接口调用请改那一份。 +> **🖥️ 前端(2026-09-14 修订)**:**正式前端在 `app/static/portal/`**,由 `app/main.py` 挂载, +> 通过 **`/portal/`** 访问(`/` 与 `/portal/` 都 307 跳到 `/portal/guest/home/`)。 +> **实际是 6 个角色目录**(原写"四套页面"已过期,漏了投顾与运营两个): +> +> | 目录 | 角色 | 子页面 | +> |---|---|---| +> | `guest/` | 访客(免登录) | `home/`、`products/`、`product-detail/` | +> | `customer/` | 客户 | `login/`、`dashboard/`、`orders/`、`transactions/`、`holdings/`、`cash-ledger/`、`profit-loss/`、`risk-questionnaire/` | +> | `employee-console/` | 平台管理员 | `login/`、`workspace/` | +> | `employee-risk/` | 风控专员 | `dashboard/` | +> | `employee-advisor/` | 投顾 | `dashboard/` | +> | `employee-operations/` | 运营(场外/推广/NL2SQL) | `dashboard/`、`nl2sql/`、`offsite/`、`promotion/` | +> +> ⚠️ `employee-advisor/` 与 `employee-console/` **是两个不同角色,不是改名关系**,别合并。 +> 访客公开产品页已由 `app/api/controllers/public_platform.py` 的 `/products` 等端点供数, +> **不再是** `common/mock-data.js`。**端点表集中在 `common/api-client.js`**,改接口调用请改那一份。 > 启动:`python -m uvicorn app.main:app --port 8000`(注意模块级变量是 **`app`**,不是 `application`)。 > `tools/portal.py`(8101)**只是跨角色联调工具,不是产品前端**,不要再往它加功能。 > @@ -102,7 +114,7 @@ | 文件 | 为什么 | |---|---| -| `TODO.md` | **自 2026-09-09 起未随 Phase 1 更新**:5 处"49 张表"(实为 51 张业务表)、T8.1 客服 Agent 整节未勾选但**已交付**、多处标"进行中"其实已完成。**当前进度一律以 `phase1-acceptance-report.md` 为准。** | +| `TODO.md` | **自 2026-09-09 起未随 Phase 1 更新**:5 处"49 张表"(实为 **89 张业务表**)、T8.1 客服 Agent 整节未勾选但**已交付**、多处标"进行中"其实已完成。**当前进度一律以 `phase1-acceptance-report.md` 为准。** | | `docs/04` / `06` / `10` / `13` / `99` | 内容**未核对过、可能过期**(自相矛盾 / 结论失效 / 清单不全)。经 2026-09-11 评审**保留**(不再删除),仅作历史参考。**不要用它们判断现状** | ### E. 环境与命令口径(易错点) @@ -115,6 +127,10 @@ 场外/推广那 17 张逐表登记见 `docs/28-场外与推广域数据表登记.md`; **投顾那 21 张的登记文档待补**(按同样口径另立一份)。 核验命令:`python tools/audit_schema.py`(若报 `unexpected` 先分清是"库里多表"还是"迁移没进来")。 +- ⚠️ **画像有两个存储,别只找 MySQL**:**值在 MySQL**(`fin_customer_profile` / `profile_snapshots` / + `fin_risk_assessment` / `user_facts`,唯一真相),**关系在 Neo4j**(`Customer` 节点 + `Tag`/`Product` + 等,单向、幂等、可重建的派生视图)。图不一致时**一律以 MySQL 为准** + (`python tools/reconcile_graph.py [--repair]`)。设计细节见 **`docs/23`(已与代码对齐)**。 - 已注册业务 Agent(**7 个**,见 `app/service/agent/implementations/` 与 `app/service/agent/`): `FundQueryDemoAgent`、`CustomerServiceAgent`、`RiskAgent`、`PlatformProbeAgent`、 **`AdvisorAgent`**、**`OffsiteFundAgent`**、**`PromotionMaterialAgent`**。 @@ -122,15 +138,23 @@ **`query_knowledge` 是 `search_knowledge` 的别名**(同一 handler,为兼容一期发布配置与旧客户端保留,见 `bootstrap.py`); 其余业务线工具(风控、投顾、NL2SQL)按各自 Agent 白名单注册,全部在 `bootstrap.py` 的 `get_agent_factory()` 里。 **工具可用范围 = 代码上限 ∩ 当前 active `config_release` 的发布白名单**,缺发布配置则失败关闭。 -- ⚠️ **RBAC 权限码的定义源是 `tools/seed_test_rbac.py` 的 `PERMISSIONS`**(9001-9046 号段): +- ⚠️ **RBAC 权限码的定义源是 `tools/seed_test_rbac.py` 的 `PERMISSIONS`**(**9001-9065 号段,共 65 条**): 那个脚本是 **DELETE 重建**语义(`DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099`), **没并进它的权限码重建一次就没了**,表现是"接口突然 403"而没有任何报错线索。 `tools/grant_*.py` 只补种子里缺的,且 id 必须与种子**逐条一致** —— 一致性由 `python tools/check_rbac_seed_consistency.py` 及其单测守着 (2026-09-12 曾因两套 id→code 映射并存,让 `advisor` 在种子重建后静默拿到语义错误的权限)。 + 号段演进(**改这里之前先看这段**): + `9001-9017` 一期公共;`9018-9034` 客服二期/投顾;`9041-9046` 产品治理与候选审核 + (**`9035-9040` 与 `4041-4046` 从未建过**,`9041` 是把冲突的 `9020-9035` 挪走的修正结果,见 `docs/36`); + `9047-9050` 风控告警四个;`9051-9056` 推广/NL2SQL/探针; + `9057-9059` 投顾客户范围三项;**`9060-9065` 账户与交易看板六项**(2026-09-12 后追加,**`AGENTS.md` 旧写的「9046」已过期**)。 另注:`sys_user` 已改为「存在则更新、不存在才插入」,故重跑种子**不会**再弄丢演示密码。 - ⚠️ **`config_release` 是环境数据,不随代码合并**:本机 active 版本 id 与架构师环境**不同** - (本机是我方发布的客服白名单;他那边还有风控的 9 条白名单)。**"白名单已发布"必须带环境限定**,换环境要重发。 + (本机是我方发布的客服白名单;他那边还有风控的白名单)。**"白名单已发布"必须带环境限定**,换环境要重发。 + ⚠️ 常被误读的一点:「架构师环境有**风控的 9 条白名单**」说的是**那台环境 `config_release` 里的 9 个 + `agent_tools` 配置项**,**不是**代码里的常量——**别去仓库里找"9 条"这个对象** + (本机发布脚本 `tools/publish_risk_agent_config.py` 只发 4 条:3 工具 + `general` 空)。 发布脚本 `tools/publish_customer_service_config.py`:**同 key 的继承项必须被本次定义覆盖**, 否则旧值会被子集校验 422 拦下整次发布;**继承范围必须覆盖全部三张受管表** —— 它此前只搬 `platform_config_item`,把 `customer_service_chitchat` 提示词静默漏在了旧版本里 diff --git a/docs/02-数据库建表设计.md b/docs/02-数据库建表设计.md index ba6e70b..2a09402 100644 --- a/docs/02-数据库建表设计.md +++ b/docs/02-数据库建表设计.md @@ -13,10 +13,16 @@ - 数据库:MySQL 8.0。 - 默认字符集:`utf8mb4`。 - 默认排序规则:`utf8mb4_0900_ai_ci`。 -- 基础业务表:33 张。 -- 智能客服专项表:6 张。 -- 平台底座增量表:12 张(原设计 10 张、outbox_delivery、api_request_receipt)。 -- 建成后总表数:51 张(包含会话补表及公共 HTTP 写操作幂等回执)。 +- **场内基线**分项(本文 §4–§7 的覆盖范围): + - 基础业务表:33 张。 + - 智能客服专项表:6 张。 + - 平台底座增量表:12 张(原设计 10 张、outbox_delivery、api_request_receipt)。 + - **场内小计:51 张**(包含会话补表及公共 HTTP 写操作幂等回执)。 +- ⚠️ **全库当前口径(2026-09-14 更正)**:**90 张表 = 89 张业务表 + `alembic_version`**, + 分域为 **场内 51 + 场外/推广 17 + 投顾 21**。 + **本文只定义场内 51 张**;后 38 张属独立业务域,**不进 `docs/00` 基线**(`AGENTS.md` 规则 8), + 其登记见 `docs/28-场外与推广域数据表登记.md`(场外/推广 17 张)、**投顾 21 张的登记文档待补**。 + 本文写作时的"总表数 51"是**场内口径**,不是全库总数——切勿据此判断库内表数。 - Redis、Milvus 和 Neo4j 不计入 MySQL 表数量。 当前交易范围是场内基金模拟交易。数据库不建设充值、提现、银行流水匹配或场外基金申购赎回交易表。 @@ -44,7 +50,10 @@ | 外键 | MySQL 核心实体使用外键;Milvus/Neo4j 关系由应用校验 | | 时间基准 | 应用和数据库统一使用 UTC,展示层转换时区 | -## 4. 51 张表总览 +## 4. 51 张表总览(场内口径) + +> ⚠️ 本节只覆盖**场内 51 张**。全库当前共 **89 张业务表**(场内 51 + 场外/推广 17 + 投顾 21), +> 见文首说明。场外/推广登记在 `docs/28`,投顾 21 张登记文档待补。 ### 4.1 基础业务表 @@ -904,6 +913,7 @@ CREATE TABLE prompt_template_version ( ## 13. 验证清单 - 51 张业务表全部存在(另有 `alembic_version`),原 39 张表的名称和已有字段定义保持不变。 + **注**:此处 51 为**场内口径**(本清单覆盖范围);全库另有场外/推广 17 张与投顾 21 张,共 **89 张业务表**。 - 主键、唯一键、外键和检查约束生效;唯一键必须与 `docs/00` 声明一致,由 `tools/audit_constraints.py` 逐表比对。 - 2026-09-09 约束纠偏:`fin_market_price`、`fin_nav_history`、`fin_holding`、`sys_customer_assignment` 四张表的联合唯一键曾被基线生成器错误拆成单列唯一键,已由迁移 `20260909_constraint_fix` 修正(删 8 个错误唯一键、加 4 个联合唯一键),`docs/00` 未做修改。证据见 `docs/evidence/` 与 `docs/08`。 - `conversation_message` 三个 Agent 增量字段和既有 `tool_calls` 字段可正常读写。 diff --git a/docs/03-平台端到端流程文档.md b/docs/03-平台端到端流程文档.md index 72c9d18..831101e 100644 --- a/docs/03-平台端到端流程文档.md +++ b/docs/03-平台端到端流程文档.md @@ -346,7 +346,18 @@ Redis 实时行情不可用、行情超过允许时效或产品停牌时,MVP -> 人工确认后反馈中国结算 ``` -这条流程可以复用通用 Agent、工具、审计和记忆能力,但应使用独立的运营业务表和接口。未建设独立业务表前,不把场外数据写入 `fin_sim_order`、`fin_transaction` 或虚拟资金账户。 +这条流程可以复用通用 Agent、工具、审计和记忆能力,但应使用独立的运营业务表和接口。**不把场外数据写入 `fin_sim_order`、`fin_transaction` 或虚拟资金账户。**(这是 `AGENTS.md` 规则 8 的硬约束:场内表只服务场内模拟交易。) + +> ✅ **落地状态(2026-09-14 更新)**:本文原文写「**未建设独立业务表前**,不把场外数据写入…」——现在前提已满足,**独立业务表已建好**: +> - 场外 / 推广**共 17 张表**(`offsite_*` / `promotion_*`),逐表登记见 `docs/28-场外与推广域数据表登记.md`; +> - 对应 Agent 已注册:**`OffsiteFundAgent`(`offsite_fund`)** 与 **`PromotionMaterialAgent`(`promotion_material`)**; +> - 接口已挂载:`/api/v1/offsite-fund`、`/api/v1/offsite-operation`(`operation_router` → `/api`)、`/api/v1/promotion-materials`; +> - 前端运营页:`app/static/portal/employee-operations/dashboard|offsite|promotion|nl2sql`。 +> - ⚠️ **该域的契约与主系统不同**:用**旧信封** `{code, message, data}`(成功判 `code === 0`), +> 分页用 **`page` / `page_size`**(不是游标)。详见 `docs/05-接口文档.md` §19.1。 +> +> ⚠️ **规则校验口径(2026-09-14 修正)**:场外限额**已从"金额"改为"份额"口径**—— +> 单笔申购上限为**份额的 10%**、巨额赎回判定为**份额的 20%**(`docs/22` 原文写的"单日申购金额上限 / 赎回金额 vs 可用金额"是旧设计稿)。 ## 12. 记忆形成流程 @@ -448,7 +459,9 @@ Redis 当前会话 ## 15. 上线验收 -- 四类 Agent 均通过工厂创建且权限隔离正确。 +- **7 类 Agent**(2026-09-14 口径,原文写"四类")均通过工厂创建且权限隔离正确: + `fund_query_demo` / `customer_service` / `risk` / `offsite_fund` / `promotion_material` / `platform_probe` / `advisor`。 + 权威出处:`app/service/agent/bootstrap.py` 的 `register_business_agents()`(L407-447)。 - BaseAgent 七步流程不能被业务子类绕过。 - 客服五类意图均有端到端测试。 - 低置信问题不硬答,转人工上下文完整。 diff --git a/docs/05-接口文档.md b/docs/05-接口文档.md index 32b0813..6ec917f 100644 --- a/docs/05-接口文档.md +++ b/docs/05-接口文档.md @@ -747,10 +747,20 @@ DELETE /api/v1/knowledge/{knowledge_id} | 工具名 | 必需权限 | 只读 | 越权与失败行为 | |---|---|---|---| | `check_suitability` | `suitability:read` | 是 | 除管理员外不得查他人风险测评;测评过期/缺失一律拒绝(失败关闭) | -| `query_fund_quote` | `fund:quote:read` | 是 | 只读行情,不得改写为成交、委托或持仓语义 | -| `query_knowledge` | `knowledge:query` | 是 | 集合名由服务端按意图映射,调用方不得指定;维度不符失败关闭 | +| `query_fund_quote` | `fund:quote:read` | 是 | 只读行情,不得改写为成交、委托或持仓语义。超时 **15s**(`EastmoneyAdapterFactory` 单代码最坏预算 8.2s × 1.8) | +| `search_knowledge` | `knowledge:reference:read` | 是 | **知识检索的正式工具名**。集合名由服务端按意图映射,调用方不得指定;维度不符失败关闭。超时 **10s** | +| `query_knowledge` | `knowledge:query` | 是 | ⚠️ **`search_knowledge` 的别名**(`bootstrap.py:321-328`,同一 handler,为兼容一期发布配置与旧客户端保留),允许角色仅 `visitor`/`customer` | | `query_customer_profile` | `memory:read:self`(查他人为 `memory:read:customer`) | 是 | 越范围按"不存在"处理且不泄露存在性;无当前画像**抛错**而不返回空画像 | +> ⚠️ **本表此前只列 4 个、且把 `query_knowledge` 当主名(2026-09-14 修正)**。 +> 两者**都要发布**:知识类意图缺 `search_knowledge`,登录客户与投顾那侧缺权限; +> 缺 `query_knowledge`,**访客令牌只有 `knowledge:query`**、一问即失败。 +> +> 其余业务线工具按各自 Agent 白名单注册(**不在本公共表内**,权限码也不同): +> 风控 3 个(`search_risk_alerts`/`get_risk_overview`/`get_alert_evidence`,权限 `risk:alert:read`)、 +> 投顾 6 个(`investment-goal:*` 等)、NL2SQL 1 个(`query_financial_data`,权限 `financial:nl2sql:read`)、 +> 探针 1 个(权限 `probe:read`)。全部注册点在 `app/service/agent/bootstrap.py::get_agent_factory()`。 + **工具的白名单是两段式**:代码里的 `allowed_tools` 是**上限**,实际可用范围还要与 当前 `active` 的 `config_release` 中 `namespace=agent_tools`、`config_key=:` 发布的工具白名单**取交集**;**缺发布配置时交集为空、工具失败关闭**。 @@ -959,7 +969,7 @@ Outbox 消费者按 `event_id` 幂等。失败事件保留并重试,超过阈 | 场内模拟交易 | `/api/v1/sim-orders/**` | 交易业务文档 | 只读查询,不创建、确认或撤销委托 | | 风控扫描 | `/api/v1/risk/**` | 风控业务文档 | 可解释规则结果,不启动人工处置 | | 风险预警 | `/api/v1/risk/**` | 风控业务文档 | 只读分析,不确认、升级或关闭预警 | -| 场外基金运营 | 不属于当前系统 | 独立运营系统 | 不读写场内交易表 | +| 场外基金运营 | `/api/v1/offsite-fund/**`、`/api`(operation_router) | `docs/28`、场外工作流文档 | **已落地**(2026-09-14 更正:原写"不属于当前系统"已过期)。**独立业务表,不读写场内交易表**;沿用旧式信封 `{code,message,data}` 与 `page`/`page_size` 分页 | 风控模块落地时把扫描、预警、证据、通知和日报收在同一个 Controller 下,入口为 `/api/v1/risk/**`(早期规划写作 `/risk-scans/**`、`/risk-alerts/**`,以本节的实际入口为准)。 @@ -1082,8 +1092,11 @@ GET /internal/metrics - A 类 Agent 不需要修改公共 Controller、Factory、BaseAgent 或 SSE View。 - B 类业务接口遵守统一 MVC+S 骨架和 Agent 只读边界。 - 内部契约和数据库结构没有在本文形成第二权威定义。 -- 数据库迁移不修改已有表名和已有字段定义(当前现库 **52 张表**;本行原写"49 张"是早期快照, - 已于 2026-09-10 按实测更正 —— 表的**数量**会随新增表变化,因此这里只保留"不修改已有表名与字段定义"这一硬约束)。 +- 数据库迁移不修改已有表名和已有字段定义(当前现库 **90 张表 = 89 张业务表 + `alembic_version`**, + 分域为**场内 51 + 场外/推广 17 + 投顾 21**。 + 本行原写"49 张"→ 2026-09-10 更正为"52 张"→ **2026-09-14 按 `AGENTS.md` 口径更正为 89 张业务表**。 + 表的**数量**会随新增表变化,因此这里只保留"不修改已有表名与字段定义"这一硬约束; + 数量口径以 `python tools/audit_schema.py` 的实测输出为准)。 ## 19. 接口总目录 @@ -1185,6 +1198,30 @@ GET /internal/metrics | P001 | `GET /api/v1/products` | 仅要求有效令牌(访客令牌即可,不校验权限码) | 否 | `200` | 否 | | P002 | `GET /api/v1/products/{product_code}/nav-history` | 同上;`days` 取 1–365,默认 90 | 否 | `200` | 否 | +### 19.1 补充接口段(2026-09-14 补登) + +⚠️ **以下几段此前完全未登记在 §19**,但**已由 `app/main.py` 实际挂载**(`include_router` 共 21 个)。 +使用者按本文档找场外/访客令牌/入驻接口时**会找不到**,故在此补登。各段的**请求/响应字段级契约** +以对应 Controller 与 Service 为准(本节只登记路由与鉴权口径): + +| 段 | 前缀 | 挂载来源 | 权限口径 | +|---|---|---|---| +| **场外基金** | `/api/v1/offsite-fund` | `offsite_fund.py` 的 `router` | **any-of(非空交集)**语义,**不走** `AuthorizationService.require`:四种组合 `("offsite:read","offsite:write")`、`("offsite:write",)`、`("offsite:write","offsite:confirm")`、`("offsite:notify","offsite:write")` | +| **场外运营** | `/api`(同一文件的 `operation_router`) | `offsite_fund.py` 的 `operation_router` | 同上 | +| **访客令牌** | `/api/v1/visitor-tokens` | `visitor_tokens.py` | 签发访客令牌(访客页面用) | +| **客户入驻** | `/api/v1/onboarding` | `onboarding.py` | 注册/开户流程 | +| **推广材料** | `/api/v1/promotion-materials` | `promotion_material.py` | 权限码 `promotion:read`/`write`/`review`/`deliver`(9047 段同批) | +| **账户与交易** | `/api/v1/users/me` | `trading.py` 的 `router` | 见上表 T001–T009 | + +> **场外契约有两条与主站不同,容易踩**(详见 `docs/28` 与 SRS): +> 1. **场外全域沿用旧式信封 `{code,message,data}`(成功 = `code === 0`)**, +> **不是** §3.3 的新信封; +> 2. 场外**用 `page`/`page_size` 分页**,**不是** cursor 分页。 +> +> **风控段的编号是 `RK001–RK015`**(见 `common/api-client.js`), +> 其中 **`RK013–RK015` 在 `api-client.js` 内已自述"未登记进本文 §19"**——属已知缺口,待补。 +> **§19 的 A 段编号当前到 `A049`**(`api-client.js` 亦为 A001–A049),后续新增请顺延。 + > **T001 – T009 的四点说明**: > > - **首版只支持 `price_type="market"` 市价委托**(`docs/00` §6.6 定义),系统**立即全额成交**, diff --git a/docs/07-测试问题修复记录.md b/docs/07-测试问题修复记录.md index 57943d6..64e0b7f 100644 --- a/docs/07-测试问题修复记录.md +++ b/docs/07-测试问题修复记录.md @@ -1,8 +1,17 @@ # 测试问题修复记录 -> ⚠️ **两处已过期**:① 启动命令用 `conda activate jr_py313`,本机实际解释器是 `.\\.venv\\Scripts\\python.exe`;② 文中称「当前没有组员业务 Agent」,实际已在 `app\\service\\agent\\bootstrap.py` 注册 `FundQueryDemoAgent` 与 `CustomerServiceAgent`。 +> ⚠️ **多处已过期(2026-09-14 复核更新)**: +> ① 启动命令用 `conda activate jr_py313`,本机实际解释器是 `.\.venv\Scripts\python.exe`; +> ② 文中称「当前没有组员业务 Agent」——已不成立:`app/service/agent/bootstrap.py` +> 的 `register_business_agents()`(L407-447)现注册 **7 个**:`fund_query_demo`、 +> `customer_service`、`risk`、`offsite_fund`、`promotion_material`、`platform_probe`、`advisor`; +> ③ 「接口目录实为 **50 项**」——现为 **21 个 router**(新增场外、推广、访客令牌、入驻 onboarding、 +> 投顾推荐等),见 `docs/05-接口文档.md` §19; +> ④ 「升级/降级往返验证通过,**68 张表**」——那是 2026-09-11 的实测;现为 +> **90 张表 = 89 张业务表 + `alembic_version`**(场内 51 + 场外/推广 17 + 投顾 21), +> 核验用 `tools/audit_schema.py`。 > -> (此批注由 2026-09-11 只读审计加入;原文未改动。详见 `docs/superpowers/ARCHIVE-2026-09-11-文档清理归档.md` §3) +> (此批注由 2026-09-11 只读审计加入、2026-09-14 扩充;原文未改动。详见 `docs/superpowers/ARCHIVE-2026-09-11-文档清理归档.md` §3) P1-1:JWT sub 在认证阶段验证为 ASCII 正整数且不超过 BIGINT UNSIGNED 范围。 @@ -26,6 +35,7 @@ RunQueryService 读取数据库快照,连接期间不持有数据库 Session AST 架构测试禁止 Controller 依赖 Model/Repository 和直接操作 Session。 接口目录实为 50 项(4 运行 + 7 会话 + 2 记忆 + 1 知识 + 33 管理 + 3 运维)。 +(⚠️ 2026-09-14:该 50 项为当时的快照;现共 **21 个 router**,含场外/推广/访客令牌/onboarding/投顾推荐,见 `docs/05` §19。) 这些是底座范围;不能把未实现的管理接口当作组员业务范围排除。报告的 46 项计数修正。 ## P1-2:治理钩子接线及当前支持范围 @@ -76,6 +86,7 @@ WORKER_LEASE_SECONDS、WORKER_RETRY_LIMIT。`python -m app.worker --once` 只处 `chk_config_release_separation` 约束(迁移 `20260911_drop_review_separation`), `ConfigReleaseService.approve` 与 `AdminService` 驳回分支均如实写入 `reviewer_id`。 升级/降级往返验证通过,68 张表字段、索引、外键指纹不变。 +(⚠️ 2026-09-14:68 张为当时口径;现 **90 张 = 89 业务 + `alembic_version`**。) 测试基座:`tests/conftest.py` 在导入 app 前把测试期引擎换成 `NullPool`,消除多事件循环 下的连接复用(集成失败 30 → 2);单元测试显式给定外部识别开关,不再读取本机 `.env` @@ -110,7 +121,8 @@ CREATE DATABASE IF NOT EXISTS jr_agent_test CHARACTER SET utf8mb4 COLLATE utf8mb GRANT ALL PRIVILEGES ON jr_agent_test.* TO 'jr_app'@'localhost'; ``` -HTTP 与 Worker 共用 app/service/agent/bootstrap.py 注册入口。当前没有组员业务 Agent; +HTTP 与 Worker 共用 app/service/agent/bootstrap.py 注册入口。~~当前没有组员业务 Agent;~~ +(⚠️ 2026-09-14:**现有 7 个**组员业务 Agent,见文件头批注与 `bootstrap.py` L407-447。) 未注册业务类型明确拒绝。集成测试显式注入 TestAgent,不在生产注册假业务实现。 Outbox 消费确认的是 MySQL 中已存在的持久运行;进程在确认后退出,下一进程仍可扫描 diff --git a/docs/08-数据库结构审计基线.md b/docs/08-数据库结构审计基线.md index 1a170e9..f700ff3 100644 --- a/docs/08-数据库结构审计基线.md +++ b/docs/08-数据库结构审计基线.md @@ -43,27 +43,39 @@ ORM 映射 ↔ 库列名(生成列单独归类为 note)。任一不一致返 ## 四、既有库对齐(方案 A) -> **⚠️ 2026-09-11 更新:业务表数已从 51 变为 68。** -> 同事那条线(袁聪)合并进来的 11 个 alembic 迁移新建了 17 张表 —— -> `offsite_*`(场外基金运营,10 张)与 `promotion_*`(推广域,7 张)。 -> **这 17 张不进 `docs/00` 基线**(它冻结的是场内交易域),由 -> **`docs/28-场外与推广域数据表登记.md`** 单独登记。口径统一为: +> **⚠️ 2026-09-14 更新(第二次):业务表数已从 68 变为 89。** +> +> 68 → 89 的增量是**投顾域的 21 张**(`advisor_*`,迁移见 `alembic/versions/20260910_advisor_*` +> 与 `20260911_advisor_*`)。**这 21 张同样不进 `docs/00` 基线**。 +> **登记文档当前待补**(`AGENTS.md` 已声明),补后应与 `docs/28` 同一口径。 > > ``` -> 场内 51(docs/00) + 场外/推广 17(docs/28) = 68 张业务表 -> (另加 alembic_version,库内共 69 张) +> 场内 51(docs/00) + 场外/推广 17(docs/28) + 投顾 21(登记文档待补) = 89 张业务表 +> (另加 alembic_version,库内共 90 张) > ``` > +> ### 历史沿革(保留以备追溯) +> +> - **2026-09-11**:业务表数从 **51** 变为 **68**。同事那条线(袁聪)合并进来的 11 个 alembic +> 迁移新建了 **17 张表** —— `offsite_*`(场外基金运营,10 张)与 `promotion_*`(推广域,7 张)。 +> 该 17 张不进 `docs/00` 基线(它冻结的是场内交易域),由 +> **`docs/28-场外与推广域数据表登记.md`** 单独登记;当时口径为 +> `场内 51 + 场外/推广 17 = 68 张业务表`(另加 `alembic_version`,库内共 69 张)。 +> - **2026-09-14**:升到 89(+投顾 21),即本节上方的当前口径。 +> > `tools/audit_schema.py` 的期望值**随迁移自动增长**(它读 `baseline_generated.sql` + -> 扫描 `alembic/versions/*.py` 的 `CREATE TABLE`,不是手写清单),所以本次 68 是自动结果, -> 没有人手工改过期望值。**架构师合并后会看到表数从 51 跳到 68,那是这 11 个迁移的预期结果, +> 扫描 `alembic/versions/*.py` 的 `CREATE TABLE`,**不是手写清单**),所以 68 与 89 都是自动结果, +> 没有人手工改过期望值。**架构师合并后会看到表数从 51 跳到 68 再跳到 89,那是这些迁移的预期结果, > 不是有人绕过基线偷偷建表**(机制与代价详见 `docs/28-场外与推广域数据表登记.md` §5)。 +> +> ⚠️ 本文其余各处出现的"68"均为**当时实测值**,阅读时请代换成 89; +> **以 `python tools/audit_schema.py` 的实时输出为准**。 基线表补入 Alembic 链首(`20260909_baseline_schema`)后,按库的来源分三种情况: | 库状态 | 特征 | 操作 | |---|---|---| -| 空库 | 无 `alembic_version` 记录、无业务表 | `alembic upgrade head`,全量建出 **68 张业务表**(原为 51,见上方更新) | +| 空库 | 无 `alembic_version` 记录、无业务表 | `alembic upgrade head`,全量建出 **89 张业务表**(原为 51→68,见上方更新) | | 由 Alembic 建起 | `alembic_version` 已位于链上 | 直接 `alembic upgrade head`;Alembic 不会重跑祖先迁移,**无需 stamp** | | 手工或脚本建库 | 有业务表但 `alembic_version` 为空 | 直接 upgrade 会重建已有表而失败,必须先 `alembic stamp <当前结构对应版本>`,再 `upgrade head` | diff --git a/docs/09-底座使用文档.md b/docs/09-底座使用文档.md index de111cc..fb2c64c 100644 --- a/docs/09-底座使用文档.md +++ b/docs/09-底座使用文档.md @@ -50,10 +50,27 @@ JWT 私钥只用于签发测试 Token,服务端只读取公钥。模型密钥 .\.venv\Scripts\python.exe tools\audit_schema.py ``` -当前数据库共 52 张表,其中 **51 张业务表**(另有 Alembic 版本表 `alembic_version`,不计入业务表口径)。 -`tools/audit_schema.py` 的实测输出即 `schema audit passed: 51 business tables, no missing or unexpected tables`。 +当前数据库共 **90 张表 = 89 张业务表 + Alembic 版本表 `alembic_version`**(版本表不计入业务表口径)。 -51 张业务表相对同一批迁移构建的基线是: +89 张业务表的分域构成: + +| 域 | 张数 | 登记来源 | +|---|---|---| +| 场内交易(`docs/00` 基线口径) | 51 | `docs/02-数据库建表设计.md` | +| 场外 / 推广 | 17 | `docs/28-场外与推广域数据表登记.md` | +| 投顾 | 21 | 登记文档待补(`advisor_*`) | + +> ⚠️ **口径沿革(2026-09-14 修正)**:本文原写「共 52 张表,其中 51 张业务表」,那是 **2026-09-09 的实测值**; +> 此后场外/推广 17 张(2026-09-11)与投顾 21 张(2026-09-14)陆续迁入,总数已达 89。 +> **`tools/audit_schema.py` 的期望值不是手写清单**,而是从 `baseline_generated.sql` + 扫描 +> `alembic/versions/*.py` 的 `CREATE TABLE` 现算出来的——所以"表数增长"是迁移该有的结果, +> 不是有人绕过基线。核验命令: + +```powershell +.\.venv\Scripts\python.exe tools\audit_schema.py +``` + +51 张**场内**业务表相对同一批迁移构建的基线是: - 49 张基线和已落地表(2026-09-09 补建会话表之前保存的 49 张表指纹即该口径, 见 `docs/08-数据库结构审计基线.md` §二); @@ -144,8 +161,22 @@ def register_business_agents(factory: AgentFactory) -> None: 然后由 `get_agent_factory()` 在首次创建时调用注册函数。HTTP 服务和 Worker 必须共用这个注册入口,不能各自维护一份注册表。 -当前 `register_business_agents()`(`app/service/agent/bootstrap.py` 约 L210-225)已注册两个业务 Agent: -`FundQueryDemoAgent`(`agent_type="fund_query_demo"`)与 `CustomerServiceAgent`(`agent_type="customer_service"`)。 +当前 `register_business_agents()`(`app/service/agent/bootstrap.py` **L407-447**)已注册 **7 个**业务 Agent: + +| # | 类 | `agent_type` | 说明 | +|---|---|---|---| +| 1 | `FundQueryDemoAgent` | `fund_query_demo` | 组员接入示范 / 探针 | +| 2 | `CustomerServiceAgent` | `customer_service` | 客服 RAG(工具 `search_knowledge`) | +| 3 | `RiskAgent` | `risk` | 风控告警扫描与处置 | +| 4 | `OffsiteFundAgent` | `offsite_fund` | 场外基金运营 | +| 5 | `PromotionMaterialAgent` | `promotion_material` | 推广物料 | +| 6 | `PlatformProbeAgent` | `platform_probe` | 平台探针(`probe:read`) | +| 7 | `AdvisorAgent` | `advisor` | 投顾分析 / 配置建议 | + +> ⚠️ 本文原写「已注册两个业务 Agent(`fund_query_demo` 与 `customer_service`)」,行号也停在 `约 L210-225` +> —— 那是 2026-09-09 的状态。**注册顺序是追加式的(append-only)**,历史两行在最前,新增只能往后加。 +> 权威出处:`app/service/agent/bootstrap.py` 的 `register_business_agents()`。 + 组员新增 Agent 时在同一个函数里追加一行 `factory.register(...)`。 `agent_type` 使用小写蛇形命名,例如 `customer_service`、`risk`、`advisor`。提交**未注册**的 `agent_type` 会返回 `404 AGENT_TYPE_NOT_FOUND`。 @@ -404,21 +435,41 @@ active → superseded / rollback release ## 14. 公共只读工具当前状态 -`get_agent_factory()` 向 `ToolRegistry` 注册了 **4 个公共只读工具**(`app/service/agent/bootstrap.py` 约 L155-192), +`get_agent_factory()` 向 `ToolRegistry` 注册了 **5 个公共只读工具**(`app/service/agent/bootstrap.py` **L251-388**), 全部经 `ToolRegistry`、`ToolExecutor` 和统一工厂注入,业务 Agent 只能通过 `self.call_tool(...)` 使用: | 工具名 | 必需权限 | 允许角色 | 只读 | 说明 | |---|---|---|---|---| | `check_suitability` | `suitability:read` | customer、advisor、operator、admin | 是 | C1-C5/R1-R5 适当性匹配;测评过期/缺失拒绝;通过和拒绝都写 `interaction_audit` | -| `query_fund_quote` | `fund:quote:read` | customer、advisor、operator、risk_operator、admin | 是 | 场内基金行情;超时 15s;不得改写为成交、委托或持仓语义 | -| `query_knowledge` | `knowledge:query` | customer、operator、advisor、risk_operator、admin | 是 | 知识检索(RAG);集合名由服务端按意图映射;超时 8s | +| `query_fund_quote` | `fund:quote:read` | customer、advisor、operator、risk_operator、admin | 是 | 场内基金行情;**超时 15s**;不得改写为成交、委托或持仓语义 | +| **`search_knowledge`** | `knowledge:reference:read` | customer、advisor、operator、admin | 是 | 知识检索(RAG)**正式名**;集合名由服务端按意图映射;超时 10s | +| `query_knowledge`(**别名**) | `knowledge:query` | **visitor、customer** | 是 | 与 `search_knowledge` **同一 handler**;为兼容一期发布配置与旧客户端保留 | | `query_customer_profile` | `memory:read:self`(查他人为 `memory:read:customer`) | customer、advisor、operator、risk_operator、admin | 是 | 客户画像白名单投影,不返回 PII;越范围按"不存在"处理 | +> ⚠️ **`search_knowledge` vs `query_knowledge`(本节原写反了,2026-09-14 修正)** +> +> 本文原文写的是「`query_knowledge` / `knowledge:query` / 角色 customer、operator、advisor、risk_operator、admin / 超时 8s」, +> **四处都不对**。正确关系是: +> - **正式名 = `search_knowledge`**(权限 `knowledge:reference:read`,角色 customer/advisor/operator/admin); +> - **`query_knowledge` 是别名**,同一 handler(`knowledge_search_tool`),权限 `knowledge:query`, +> **只面向 visitor 和 customer**(访客令牌只带 `knowledge:query`)。 +> - **发布白名单里两者都要有**:只发 `search_knowledge` → 访客一问即失败; +> 只发 `query_knowledge` → 登录客户/投顾没权限。 +> - 超时是 **10s**(不是 8s)。 +> +> **其余业务线工具不在"公共只读工具"之列**,按各自 Agent 白名单注册: +> 风控 3 个(`search_risk_alerts` / `get_risk_overview` / `get_alert_evidence`,权限 `risk:alert:read`)、 +> 投顾 5-6 个(`query_investment_goal` / `analyze_portfolio` / `generate_asset_allocation` / +> `recommend_products` / `compare_products`)、 +> NL2SQL 1 个(`query_financial_data`,权限 `financial:nl2sql:read`)、 +> 探针 1 个(`PROBE_ALT_TOOL`,权限 `probe:read`)。 + **工具白名单是两段式**:代码里的 `AgentDefinition.allowed_tools` 是**上限**;实际可用范围还要与当前 `active` 的 `config_release` 中 `namespace=agent_tools`、`config_key=:` 发布的工具白名单 **取交集**。缺发布配置时交集为空、工具**失败关闭**;发布配置只能缩小、不能放大代码声明的范围。 以行情工具为例,业务 Agent 必须同时在 `AgentDefinition.allowed_tools` 和发布配置的 `agent_tools/:fund_quote`(`{"allowed_tools": ["query_fund_quote"]}`)中声明后才能使用。 -4 个工具的接口面登记口径见《05-接口文档.md》§8.4「公共只读工具索引(Agent 面)」。 + +公共工具的接口面登记口径见《05-接口文档.md》§8.4「公共只读工具索引(Agent 面)」。 未注册业务 Agent 不能直接提交运行,底座也没有开放绕过 Agent 执行链的行情 HTTP 接口。 diff --git a/docs/12-基金行情数据底座接入开发计划.md b/docs/12-基金行情数据底座接入开发计划.md index 8cf94bd..e5c04ff 100644 --- a/docs/12-基金行情数据底座接入开发计划.md +++ b/docs/12-基金行情数据底座接入开发计划.md @@ -3,7 +3,31 @@ > ⚠️ **MVP 完成标准已过期。** 文中「F0-F4 全部通过」为当时快照;实际 F0-F6 已完成。另有部分「当前实现」的描述已随底座演进而变化。 > > (此批注由 2026-09-11 只读审计加入;原文未改动。详见 `docs/superpowers/ARCHIVE-2026-09-11-文档清理归档.md` §3) - +> +> --- +> ## ⚠️ 2026-09-14 补充:本文**完全没写**的两件运行期事实(重要) +> +> **1. 行情有效期只有 15 分钟 —— 这是演示/验收最容易翻的一环。** +> `app/service/trade_service.py` 的 `MAX_QUOTE_AGE = timedelta(minutes=15)`(L79)。 +> 超时后**所有委托一律 503 `FUND_QUOTE_UNAVAILABLE`**(`app/core/errors.py:260-264`), +> **且没有自动刷新**。症状看起来像"行情源坏了",实际只是数据放久了。 +> 补刷命令(**立即生效、无需重启服务**): +> ```powershell +> .\.venv\Scripts\python.exe tools\sync_market_prices.py +> ``` +> +> **2. 两个行情/净值同步脚本未被本文登记**: +> - `tools/sync_market_prices.py` —— 刷实时行情(上条)。 +> - `tools/sync_nav_history.py` —— 刷历史净值(访客端 `GET /api/v1/products/{product_code}/nav-history` 用它)。 +> +> **3. 工具超时的依据**:`query_fund_quote` 的工具超时是 **15 秒**(`app/service/agent/bootstrap.py:278`)。 +> 理由(代码内注释):`EastmoneyAdapterFactory` 单码最坏预算 = 4.0s + 0.2s(退避) + 4.0s(重试) = 8.2s, +> 15s ≈ 8.2 × 1.8。**旧的 5 秒会在适配器自己的重试预算之前就撞工具超时**,永远拿不到结果。 +> +> **启动链**:`启动金融Agent平台.bat`(桌面)/ `启动平台.bat`(仓库)会按序做:找解释器 → +> 检查 MySQL/Redis/Milvus → **刷新行情** → 起 API 与 Agent Worker → 等 API 应答 → 开浏览器。 +> **解释器门槛是「版本 ≥ 3.11 且能 `import fastapi, sqlalchemy, asyncmy, pydantic`」**, +> 不能只看 `--version`(曾因此选中 3.10 环境,报错却出现在行情刷新那步,看着像行情源坏了)。 > 版本:v1.0 > 目标:将外部基金行情能力接入公共 MVC+S 底座,供客服、投顾、风控 Agent 统一只读使用。 diff --git a/docs/14-Agent组员统一接入说明书.md b/docs/14-Agent组员统一接入说明书.md index 48810aa..a0e3133 100644 --- a/docs/14-Agent组员统一接入说明书.md +++ b/docs/14-Agent组员统一接入说明书.md @@ -191,23 +191,45 @@ value = await self.call_tool( 每个工具必须声明 Pydantic 输入模型、权限码、允许角色、只读属性和超时。底座统一执行参数校验、 意图白名单、角色权限、超时、脱敏摘要、审计和来源引用。 -底座已注册 **4 个公共只读工具**(`app/service/agent/bootstrap.py` 的 `ToolRegistry`,约 L155-192): +底座已注册的公共只读工具(`app/service/agent/bootstrap.py` 的 `ToolRegistry`,实际注册段落在 **L248-388**; +原写"约 L155-192"是早期快照,行号已漂移): -| 工具名 | 必需权限 | 允许角色 | 只读 | 越权与失败行为 | +| 工具名 | 必需权限 | 允许角色 | 超时 | 越权与失败行为 | |---|---|---|---|---| -| `check_suitability` | `suitability:read` | customer、advisor、operator、admin | 是 | C1-C5/R1-R5 匹配;测评过期/缺失一律拒绝(失败关闭);通过和拒绝都写 `interaction_audit` | -| `query_fund_quote` | `fund:quote:read` | customer、advisor、operator、risk_operator、admin | 是 | 只读行情,不得改写为成交、委托或持仓语义;超时 15s | -| `query_knowledge` | `knowledge:query` | customer、operator、advisor、risk_operator、admin | 是 | 集合名由服务端按意图映射,调用方不得指定;维度不符失败关闭;超时 8s | -| `query_customer_profile` | `memory:read:self`(查他人为 `memory:read:customer`) | customer、advisor、operator、risk_operator、admin | 是 | 越范围按"不存在"处理且不泄露存在性;无当前画像**抛错**而不返回空画像 | +| `check_suitability` | `suitability:read` | customer、advisor、operator、admin | 默认 | C1-C5/R1-R5 匹配;测评过期/缺失一律拒绝(失败关闭);通过和拒绝都写 `interaction_audit` | +| `query_fund_quote` | `fund:quote:read` | customer、advisor、operator、risk_operator、admin | **15s** | 只读行情,不得改写为成交、委托或持仓语义。**15s 的来历**:`EastmoneyAdapterFactory` 单代码最坏预算 4.0s+0.2s+4.0s=8.2s,15s≈8.2×1.8;原值 5s 会先撞工具超时 | +| `search_knowledge` | `knowledge:reference:read` | customer、advisor、operator、admin | **10s** | **这是知识检索的正式名**。集合名由服务端按意图映射,调用方不得指定;维度不符失败关闭 | +| `query_knowledge` | `knowledge:query` | **visitor、customer** | 10s | ⚠️ **是 `search_knowledge` 的别名**(同一 handler,见下方说明),**不是另一个工具** | +| `query_customer_profile` | `memory:read:self`(读他人需 `memory:read:customer`) | customer、advisor、operator、risk_operator、admin | 默认 | 越范围按"不存在"处理且不泄露存在性;无当前画像**抛错**而不返回空画像 | + +> ⚠️ **`search_knowledge` 与 `query_knowledge` 的主次关系(原文档写反了)**: +> `bootstrap.py:309-319` 注册的是 **`search_knowledge`**(权限 `knowledge:reference:read`, +> 角色含 advisor/operator/admin,**不含 visitor**);`bootstrap.py:321-328` 的 +> **`query_knowledge` 是别名**,同一 handler(`knowledge_search_tool`), +> 仅为**兼容一期发布配置与旧客户端**保留,权限 `knowledge:query`、角色**只有 visitor/customer**。 +> +> 两者**都要发布**(见 `AGENTS.md`):知识类意图若只发 `search_knowledge`, +> **访客令牌只有 `knowledge:query`**,一问即失败;只发 `query_knowledge`, +> 登录客户与投顾那侧又缺权限。原文档把 `query_knowledge` 当主名、并把它的角色集写成 +> "customer、operator、advisor、risk_operator、admin",两处均与代码不符。 **工具白名单是两段式**:代码里的 `AgentDefinition.allowed_tools` 是**上限**,实际可用范围还要与 当前 `active` 的 `config_release` 中 `namespace=agent_tools`、`config_key=:` -发布的工具白名单**取交集**;**缺发布配置时交集为空、工具失败关闭**。4 个工具的接口面登记口径 +发布的工具白名单**取交集**;**缺发布配置时交集为空、工具失败关闭**。公共工具的接口面登记口径 见《05-接口文档.md》§8.4。 +**其余工具按业务线各自注册**(不在公共只读工具之列,权限码也不同): + +| 业务线 | 工具 | 权限码 | +|---|---|---| +| 风控 | `search_risk_alerts`、`get_risk_overview`、`get_alert_evidence` | `risk:alert:read` | +| 投顾 | `query_investment_goal`、`analyze_portfolio`、`generate_asset_allocation`、`recommend_products`、`compare_products` | 各自的 `investment-goal:*` / `portfolio-analysis:*` / `asset-allocation:*` / `product-recommendation:*` / `product-comparison:*` | +| NL2SQL | `query_financial_data` | `financial:nl2sql:read` | +| 探针 | `PROBE_ALT_TOOL` | `probe:read` | + ## 9. 基金行情工具 -底座已提供公共只读工具(全部 4 个工具的清单见 §8;本节只展开行情工具): +底座已提供 `query_fund_quote` 公共只读工具(**全部公共工具的清单见 §8**;本节只展开行情工具): ```text 工具名:query_fund_quote @@ -300,13 +322,24 @@ python tools/audit_schema.py ## 14. 当前状态 -`app/service/agent/bootstrap.py` 的 `register_business_agents()`(约 L218-225)已注册两个业务 Agent: +`app/service/agent/bootstrap.py` 的 `register_business_agents()`(**实际 L407-447**,原写"约 L218-225"已漂移) +**已注册 7 个业务 Agent**(原文档写"两个",已过期): -- `FundQueryDemoAgent`(`agent_type="fund_query_demo"`):示例 Agent,一个 `fund_quote` 意图 + `query_fund_quote` 工具; -- `CustomerServiceAgent`(`agent_type="customer_service"`):客服 Agent,五类意图(`faq` / `product_inquiry` / `policy_explain` / `chitchat` / `transfer_human`),使用 `query_knowledge`。 +| # | Agent 类 | `agent_type` | 说明 | +|---|---|---|---| +| 1 | `FundQueryDemoAgent` | `fund_query_demo` | 示例 Agent,`fund_quote` 意图 + `query_fund_quote` 工具 | +| 2 | `CustomerServiceAgent` | `customer_service` | 客服 Agent,五类意图(`faq`/`product_inquiry`/`policy_explain`/`chitchat`/`transfer_human`)。⚠️ **现用 `search_knowledge`**(原写"使用 `query_knowledge`"是旧口径;`query_knowledge` 只留给访客令牌) | +| 3 | `RiskAgent` | `risk` | 风控,4 意图(`risk_overview`/`risk_search`/`risk_evidence`/`general`)+ 3 工具 | +| 4 | `OffsiteFundAgent` | `offsite_fund` | 场外基金运营 | +| 5 | `PromotionMaterialAgent` | `promotion_material` | 推广材料 | +| 6 | `PlatformProbeAgent` | `platform_probe` | 平台探针(联调自检用) | +| 7 | `AdvisorAgent` | `advisor` | 投顾:6 意图 + 6 工具(含 NL2SQL) | -上表 4 个公共只读工具均已注册并可供业务 Agent 使用,但业务 Agent 必须完成自己的代码声明、 +上表公共只读工具均已注册并可供业务 Agent 使用,但业务 Agent 必须完成自己的代码声明、 意图白名单配置和注册后才能调用。新增的业务 Agent 若未注册,提交其 `agent_type` 会返回 `404 AGENT_TYPE_NOT_FOUND`。 +> **注册顺序不是任意的**:`bootstrap.py` 中注册次序为演示 Agent → 客服 → 风控 → 场外 → 推广 → +> 探针 → 投顾。新增 Agent 请追加在末尾,不要在中间插入,以免 diff 冲突。 + 推荐阅读顺序:本文 → 《05-接口文档.md》→ 对应业务域流程文档。本文不替代接口文档和数据库基线。 diff --git a/docs/18-知识检索接入方案.md b/docs/18-知识检索接入方案.md index 41c7739..c9665fe 100644 --- a/docs/18-知识检索接入方案.md +++ b/docs/18-知识检索接入方案.md @@ -7,9 +7,17 @@ > `app/service/knowledge_service.py` 已实现 HMAC-SHA256 引用令牌校验(`_signature` / `_verify_token`, > 覆盖 token 前四段、`hmac.compare_digest` 定时安全比较,并按发布状态与有效期做二次校验); > `app/infrastructure/milvus_adapter.py`、`app/infrastructure/milvus_knowledge_writer.py`、 -> `app/service/knowledge_retrieval_service.py` 均已落地;`query_knowledge` 已注册为公共只读工具 +> `app/service/knowledge_retrieval_service.py` 均已落地;检索工具已注册为公共只读工具 > (`app/service/agent/bootstrap.py` 的 `ToolRegistry`)。Milvus 检索已在 Phase 1 验收中命中 > (score 0.7837)。**方案正文保留为设计参考,不再是待办。** +> +> **⚠️ 2026-09-14 复核补充(三处,读正文前先看)**: +> 1. **工具名**:正式名是 **`search_knowledge`**(`knowledge:reference:read`,面向 customer/advisor/operator/admin); +> **`query_knowledge` 是它的别名**(同一 handler,`knowledge:query`,只面向 visitor/customer), +> 为兼容一期发布配置与旧客户端保留。**两者都必须发布**,缺哪条对应人群就一问即失败。 +> 2. **§4.2 的「集合内字段」与步骤 2 的 `output_fields` 默认值已作废**:字段名改为运行时探测 +> (`app/core/knowledge_schema.py`),硬编码会打挂另一套 schema 的环境。详见 §4.2 下的 ⚠️ 块。 +> 3. **§4.3 的配置项键名以 `RuntimeConfigService` 当前实现为准**,正文那段 `value_json` 是设计稿示意。 --- @@ -149,7 +157,23 @@ class KnowledgeSearchResult(BaseModel): | `policy_explain` | `fin_policy_collection` | 5 | - **向量维度固定 1024**(`VECTOR_DIM`,与 Qwen Embedding 输出一致)。维度不符必须失败关闭,不得静默降级为错误结果。 -- 集合内字段:`knowledge_id` / `title` / `snippet` / `tags` / `version` / `embedding`。 +- ⚠️ **集合内字段名不得硬编码(2026-09-14 修正)**。本文 v1.0 原文写的是「集合内字段:`knowledge_id` / `title` / `snippet` / `tags` / `version` / `embedding`」——**那只在部分环境成立,照抄会打挂另一套环境**。 + + 实现在 `app/core/knowledge_schema.py`:启动后第一次检索时用 `describe_collection` **探测一次**并缓存(`SchemaCache`),再把「逻辑字段名」解析成该集合真实的物理字段名。两套已知 schema 差异: + + | 逻辑字段 | 环境甲(本机) | 环境乙(架构师机) | + |---|---|---| + | 文档标识 | `knowledge_id` | `doc_id` | + | 正文 | `snippet` | `content` | + | 可见性 | **无** | `visibility` | + | 来源文件 | **无** | `source_file` | + | 章节 | **无** | `chapter` / `section` / `doc_no` | + + Milvus 对不存在的字段直接报错(`field doc_id not exist`)→ 三个集合全失败 → `degraded=True` → 客服一律"引导人工"。因此: + - **逻辑名 → 物理名的候选表是 `knowledge_schema.FIELD_CANDIDATES`**,新增环境只改那张表,不要在检索路径里写字段名字面量。 + - `doc_id` / `content` 是**必需**字段(`REQUIRED_LOGICAL_FIELDS`),缺失即判定该集合不可用;其余(`visibility`/`chapter`/…)缺失只是「增强逻辑不启用」,不影响检索。 + - 字段映射的顺序有意义:同时存在 `doc_id` 与 `knowledge_id` 时优先 `doc_id`(灌库脚本的正式设计名)。 + - 集合 schema 变更后需重启进程或调 `SchemaCache.invalidate()`(缓存的是元数据,刻意不做每次探测)。 ### 4.3 配置项设计 @@ -259,7 +283,7 @@ class MilvusKnowledgeClient: async def search( self, *, collection: str, vector: list[float], top_k: int, - output_fields: tuple[str, ...] = ("knowledge_id", "title", "snippet", "tags", "version"), + output_fields: tuple[str, ...], ) -> list[dict[str, Any]]: client = await self._ensure() try: @@ -282,6 +306,7 @@ class MilvusKnowledgeClient: **要点**: - **只读**:不提供 create/insert/delete; +- **`output_fields` 由调用方传入、不给默认值**:它必须来自 `knowledge_schema.detect_schema(...).output_fields`(即该集合实际存在的物理字段),**不能**写死成 `("knowledge_id", "title", "snippet", ...)` ——那套名字在环境乙不存在,会让每次检索都抛 `field xxx not exist`; - 延迟导入 + 延迟建连,遵循 `EastmoneyAdapterFactory` 的做法; - 具体调用签名按 `pymilvus 2.6` 的 `AsyncMilvusClient` 校对(若该版本无异步客户端,则用 `MilvusClient` 配合 `anyio.to_thread.run_sync` 包装,避免阻塞事件循环)。 diff --git a/docs/22-四大Agent流程实现拆解.md b/docs/22-四大Agent流程实现拆解.md index 5d3255e..f55ddee 100644 --- a/docs/22-四大Agent流程实现拆解.md +++ b/docs/22-四大Agent流程实现拆解.md @@ -2,10 +2,24 @@ > 输入:`智能客服Agent专项设计方案`(v1.3, 1664 行)、`基金运营流程`、`风控模块详细业务流程`、`投资顾问流程`。 > 目的:在动手前说清「哪些能直接用底座、哪些要新写、哪些必须你们先定」。 +> +> --- +> ## ⚠️ 本文是**开工前评估稿**(2026-09-09),多处结论已被实现推翻 +> +> **保留此文是为了留"当时怎么判断的"证据,不要用它判断当前进度。** 下列三处已作废: +> +> | 本文原话 | 当前实际 | +> |---|---| +> | 「底座的 **52 张表**」 | **90 张表 = 89 张业务表 + `alembic_version`**(场内 51 + 场外/推广 17 + 投顾 21) | +> | §3 表里「基金运营(场外)=**无场外表**」 | **已建 17 张**(`offsite_*` / `promotion_*`),见 `docs/28` | +> | §5「**必须新建场外一组表**」 | **已建成**;且对应 Agent 已注册(`OffsiteFundAgent` / `PromotionMaterialAgent`),接口已挂载 | +> | §5.2 核对规则按**金额**(「单日申购金额上限」「赎回金额 vs 可用金额」) | 实现改为**份额口径**:单笔申购上限=份额 **10%**、巨额赎回=份额 **20%** | +> +> **当前状态以 `docs/验收与审计/phase1-acceptance-report.md` 为准。** ## 0. 一句话结论 -**能实现。** 决定性原因是:**底座的 52 张表与这几份流程的设计高度吻合**, +**能实现。** 决定性原因是:**底座当时的 52 张表与这几份流程的设计高度吻合**, 绝大部分工作是「填 Service / Agent 逻辑」,而不是「重建表结构」——后者才是真正贵的部分。 举几个直接对上的例子(均为实际字段名): @@ -33,7 +47,7 @@ | **智能客服** | 全部就绪 | 覆盖最好(意图/知识/转人工/负面词/模板/会话) | 三集合知识检索、客服 Agent 本体、脱敏工具、状态机 | 中 | | **投资顾问** | 全部就绪 | 适当性已实现;组合/报告无 | 策略配置、组合构建、报告生成、审核签发流 | 中 | | **风控监测** | 全部就绪 | **几乎为零**(`risk_alert` 仅 4 处命中,多为 ORM) | **规则引擎**、扫描服务、预警处置 API、日报、邮件 | 大 | -| **基金运营(场外)** | **无场外表** | 无 | 需新建独立表 + 单据解析 + 核对规则 | 大(且缺样本) | +| **基金运营(场外)** | ~~**无场外表**~~ **已建 17 张**(`offsite_*`/`promotion_*`) | 已注册 `OffsiteFundAgent` | 单据解析 + 核对规则 | 已落地 | ## 2. 智能客服 Agent @@ -127,13 +141,15 @@ 底座 15 张 `fin_*` 全是**场内模拟交易**;运营流程是**场外申购赎回**, 而 `AGENTS.md` 明确「场外基金运营流程独立,不得写入场内交易表」 → 因此**必须新建场外一组表**,不能复用场内表。 +→ ✅ **已完成(2026-09-14)**:场外/推广 17 张表已建(`offsite_*` / `promotion_*`), + 逐表登记见 `docs/28-场外与推广域数据表登记.md`。 流程本身可拆成三段: 1. **单据采集与解析**:从运营邮箱取中国结算邮件 → 解析申购/赎回单扫描件 → 关键字段提取 + 格式校验。 涉及邮件收取(另一处 SMTP/IMAP 缺口)与文档解析(底座无此能力)。 -2. **核对规则**(文档已给全字段):申购后单一持有比例上限、单日申购金额上限、金额格式(两位小数/万元)、 - 日期格式、巨额赎回、赎回金额 vs 可用金额、最低申购金额、是否在开放期、反洗钱嫌疑。 - 其中 `fin_product.single_investor_max_holding_ratio / min_amount / open_period_start/end` 已就绪,**但场外产品需要独立表**。 +2. **核对规则**(文档已给全字段;⚠️ **实现已改为份额口径**):申购后单一持有比例上限、~~单日申购金额上限~~ **单笔申购份额上限(10%)**、金额格式(两位小数/万元)、 + 日期格式、~~巨额赎回(金额)~~ **巨额赎回(份额 20%)**、~~赎回金额 vs 可用金额~~、最低申购金额、是否在开放期、反洗钱嫌疑。 + 其中 `fin_product.single_investor_max_holding_ratio / min_amount / open_period_start/end` 已就绪,**场外产品使用独立表**。 3. **人在回路**:NL2SQL 确认 → 汇总统计 → 是否上报风控 → 是否给资金清算岗 → 回单给中国结算。 ⚠️ **文档自己写了「待工作:不同的申购赎回单子,以作实际操作案例」** —— 也就是说**缺样本数据**。 diff --git a/docs/23-记忆分层与画像设计.md b/docs/23-记忆分层与画像设计.md index 045603e..18a7da0 100644 --- a/docs/23-记忆分层与画像设计.md +++ b/docs/23-记忆分层与画像设计.md @@ -3,6 +3,12 @@ > 目的:讲清短期/中期/长期/画像四层**各自存在哪里、谁写、什么条件下向上提升、是否进画像**。 > 全部基于底座已有的真实表,不是另起一套;每节末尾标注实现现状。 +> **状态口径(2026-09-14 修订)**:本文原 §5「现状与缺口一览」把 5 个环节标为 ❌, +> 但代码侧已完成,该表**已作废并重写**。判定依据是**源码实际装配**,不是文档承诺: +> `app/service/profile_assembly_service.py`、`app/worker/runtime.py`、 +> `app/service/relationship_service.py`、`app/service/memory_lifecycle_service.py`。 +> 下文凡标 ✅ 者均注明承载文件,可逐条复核;标 ❌ 者为**确认仍缺**的部分。 + ## 0. 一句话 ``` @@ -16,43 +22,79 @@ | 层 | 存在哪里 | 装什么 | 谁写 | 提升门槛 | 进画像 | | --- | --- | --- | --- | --- | --- | -| **短期** | Redis 会话列表(设计);`conversation_message` 表(现状只有存档) | 当前会话最近的对话轮次 | 每次 run 追加 | — | ❌ 不进 | +| **短期** | `conversation_message` 表(**唯一来源**,不引入 Redis 双写) | 当前会话最近 10 轮对话 | run 受理时落库;读取只读不写 | — | ❌ 不进 | | **中期** | `memory_unit` + `memory_evidence` | 从对话抽出的候选事实,带证据链 | Worker 记忆抽取 | `evidence_count ≥ 2` 或 `confidence ≥ 0.90` | ✅ 过门槛后提升 | -| **长期** | `user_facts`(`is_critical` 标关键事实) | 已确认的稳定事实 | 由中期提升 | 由 `user_facts` 判定 | ✅ 唯一用途就是喂画像 | -| **画像** | `fin_customer_profile`(当前)+ `profile_snapshots`(版本化,带 `generation_basis`) | 整合三路来源的客户视图 | 画像组装服务 | — | 它就是画像 | +| **长期** | `user_facts`(`is_critical` 标关键事实) | 已确认的稳定事实 | 由中期提升(`promote_facts`) | 由 `user_facts` 判定 | ✅ 唯一用途就是喂画像 | +| **画像** | `fin_customer_profile`(当前)+ `profile_snapshots`(版本化,带 `generation_basis`);**关系侧另有 Neo4j 派生投影** | 整合三路来源的客户视图 | 画像组装服务 | — | 它就是画像 | + +> **两库分工**(原文档未写):画像的**值**在 MySQL(唯一真相),画像的**关系**在 Neo4j +> (单向、幂等、可重建的派生视图)。见 §3.5。 ## 2. 三条路径在画像汇合 这是整套设计的核心结构。**只有对话路径需要累积证据**,另外两条是权威/客观数据,直接写: ``` -对话消息 ──→ [短期] 会话上下文 ──→ Agent 用它理解指代("它""那个") +对话消息 ──→ [短期] 会话上下文(conversation_message 最近 10 轮) + │ └─ 以 history= 参数送入 AgentRequest │ └─(run 完成,发 memory.extraction_requested 事件) ↓ [中期] memory_unit + memory_evidence - │ 证据累积过门槛 + │ ↑ + │ └─ 每轮 run 开始由 recall_memory() 召回 + │ (Redis 热缓存 → MySQL 结构化 + Milvus 向量,双通道合并) + │ 证据累积过门槛(evidence ≥ 2 或 confidence ≥ 0.90) ↓ [长期] user_facts │ │ ┌── 问卷测评(权威)────┐ - └──组装──────┤ ├──→ [画像] - └── 行为/流水(客观)──┘ - │ - 投顾 / 风控 / 客服 + └──组装──────┤ ├──→ [画像] fin_customer_profile + └── 行为/流水(客观)──┘ + profile_snapshots(版本) + │ + ┌─────────┴─────────┐ + ↓ ↓ + [投影] Neo4j 关系图 投顾 / 风控 / 客服 + (单向、幂等、可降级; + 读侧走 RelationshipService) ``` **为什么问卷和行为不需要走前三层**:它们是**权威**(问卷由客户答题、系统评分)与**客观**(交易记录无法自称)数据,不需要"多次出现才可信"这层保护;对话自述才有"记错、修饰、被引导"的风险。 +**图的定位**:它不存画像的值,只存**画像之上的关系**(客户偏好的标签、持有的产品)。 +存在理由是投顾要靠"买过 A 的客户还买过什么"这类多跳关系做组合推荐、风控要靠关系网络 +发现关联账户——这些查询用关系库写既别扭又慢。详见 §3.5。 + ## 3. 各层细节 ### 3.1 短期:会话上下文 -- **装什么**:当前会话最近若干轮对话,用于解析指代与保持连贯。 -- **怎么工作**:run 开始时按 `session_id` 读出历史 → 与当前消息一起交给意图分类与回答生成;run 结束时把本轮追加进去。 -- **生命周期**:会话结束或长时间无活动即失效(方案 §2.2 给的是 TTL 30min、最长 24h、超 4096 token 截断旧消息)。 -- **为什么不进画像**:会话上下文是"他刚才说了什么",不是"他是什么样的人"。把临时对话当画像会造成画像抖动。 -- **实现现状**:❌ **未实现**。`conversation_message` 表存了全部消息、`svc_conversation_session.message_count` 只在计数,但**没有任何"加载最近 N 条"的代码**,run 时只拿到当前这一条消息。后果是多轮指代无法解析(问"那它风险高吗"无法知道"它"指谁)。 +- **装什么**:当前会话最近 **10 轮**对话,旧 → 新排列,用于解析指代与保持连贯。 +- **怎么工作**:run 执行时由 `WorkerRuntime._conversation_history()` 读出历史 → + 组装成 `ConversationTurn` 元组 → 作为 `history=` 一并交给 `AgentRequest`。 +- **存储选择**:以 `conversation_message` 表为**唯一来源**,**刻意不做 Redis 双写**。 + + | 方案 | 代价 | + | --- | --- | + | MySQL 单读(现状) | 一次索引查询 | + | MySQL + Redis 双写 | 换来省下这一次查询,付出**不一致风险 + TTL 管理成本** | + + 消息在受理时已经落库,再同步一份到 Redis 的收益抵不上代价。方案 §2.2 设想的是 Redis + 列表,实现取**等价语义**(同样"最近若干轮、超出即截断")而不复制存储。 +- **两个边界细节**: + - `before_message_id` **排除本轮请求消息本身**。它刚写入库,若也算进历史,模型会在 + 上下文里看到自己的问题被重复一遍。 + - 截断按**条数**而非 token。这里没有与模型一致的分词器,按 token 截断只能靠估算、 + 边界会随实现漂移;按条数是确定性的——**宁可少给几轮,也不给一个不稳定的边界**。 +- **为什么不进画像**:会话上下文是"他刚才说了什么",不是"他是什么样的人"。 + 把临时对话当画像会造成画像抖动。 +- **实现现状**:✅ **已实现**,承载于 `app/worker/runtime.py::_conversation_history()`。 + (原文档记为"❌ 未实现"——已过期。) +- **⚠️ 与公共召回的区别**:`BaseAgent.recall_memory()` 里有明确注释—— + > 公共召回是长期/画像记忆,**不是客服二期的会话短期上下文**;定义未授权时不得读取。 + + 即两层走的是**两个不同入口**:短期走 `history=` 参数,中期走 `recall_memory()`。 + 且定义未开 `recalls_customer_memory`、或角色含 `visitor` 时,短期层直接不读。 ### 3.2 中期:候选事实 @@ -63,8 +105,26 @@ - 演进:同一事实再次出现 → `evidence_count` 增加;出现相反证据 → `conflict_count` 增加(**不直接覆盖**,留冲突待判)。 - **生命周期**:`valid_from` / `valid_until`;过期或长期无新证据即降级/失效(`memory_lifecycle_service` 负责级联失效)。 - **进画像的条件**:`evidence_count ≥ 2` **或** `confidence ≥ 0.90` —— 这条门槛就是"一次性说法不该成为画像结论"的落地方式。 +- **什么情况下才触发抽取**(原文档未写,但直接影响"为什么我说话没被记住"): + `WorkerRuntime.should_request_memory_extraction()` 先做**前置过滤**,任一不满足即整条链不启动: + + | 条件 | 说明 | + | --- | --- | + | `agent_type != "customer_service"` | 客服 Agent **不写长期记忆**(它走画像候选那条独立路径) | + | `"visitor" not in context.roles` | 访客不写 | + | `should_extract_memory(...)` 为真 | 见下 | + + 而 `should_extract_memory()` 的三个触发条件是**任一命中**即可: + + - 命中受控信号词(`detect_memory_signals()`) + - 本轮有成功的工具调用(`tool_result=True`) + - 本轮产生了业务事件(`event_type ∈ BUSINESS_EVENT_TYPES`) + + **注意:句长不是门槛**。"只买货币基金"(3 字,明确)必须触发;正常的长问答不应触发。 + - **实现现状**:✅ **已实现并可验证**。实测:客户说"我的风险偏好是稳健型,平时只买债券基金" → 抽出 `preference:risk_level = 稳健型`(置信 0.95)+ 1 条证据。 - **⚠️ 运维前提**:抽取依赖**常驻 Worker** 消费 Outbox 事件。只起 API 服务不起 Worker,事件会一直堆在 `pending`,记忆永远不产生。 +- **⚠️ 幂等边界**:证据的幂等键是 `{event_type}:{event_id}`;重复消费返回 False 且**不产生第二条证据**。 ### 3.3 长期:稳定事实 @@ -72,7 +132,18 @@ - **怎么工作**:只由中期提升而来(不直接从对话写);`source_portal` 记录事实来自哪个入口,`source_episode_id` 记录来自哪个会话片段。 - **为什么不直接从对话写**:长期层是画像的输入,必须保证"这条结论经过验证",否则画像会被一次性说法污染。 - **进画像**:✅ 它的存在意义就是喂画像。 -- **实现现状**:❌ **表在(9 列),无生成代码**,当前 0 行。 +- **提升实现细节**(`ProfileAssemblyService.promote_facts()`): + - 只取 `status='active'` 且未过 `valid_until` 的记忆; + - 值优先取 `structured_value`(结构化值),回退到正文 `content`; + - `is_critical` 由 `CRITICAL_FACTS` 决定,当前是 `{preference:risk_level, preference:horizon, preference:asset_class}` 三个。 +- **两个必须知道的表约束**(否则容易写出线上故障): + 1. **`user_facts.id` 没有 `auto_increment`**,主键由应用侧 `_fact_id()` 生成 + (微秒时间戳:单调递增、无需额外序列)。 + 2. **该表没有 `(customer_id, fact_key)` 唯一键**(只有两个普通索引), + 所以"每个键一条事实"必须靠服务层**先查后写**保证,**不能指望数据库约束**。 + 这也是 `promote_facts()` 里先 `select` 再决定 insert/update 的原因。 +- **实现现状**:✅ **已实现**。承载于 `app/service/profile_assembly_service.py::promote_facts()`。 + (原文档记为"❌ 表在,无生成代码,当前 0 行"——已过期。) ### 3.4 画像:决策依据 @@ -89,26 +160,217 @@ - **客户自述风险偏好放哪**:放 `risk_tags` 并标注"客户自述",**不得写入 `investor_type`**。 保留它的价值在于:当出现「问卷 C4 / 自述稳健 / 行为买 R4」三方不一致时,**这种矛盾本身就是风控信号**。 -- **实现现状**:❌ **两张表都在,无组装代码**,当前 0 行。 + 实现上由 `SELF_REPORTED_PREFIXES = ("preference:risk_level", "preference:", "profile:")` 判定, + 统一渲染成 `自述:{key}={value}`、以「;」连接写入 `risk_tags`。 +- **本服务只拥有 4 个字段**:`PROFILE_OWNED_FIELDS = (investor_type, preferred_asset_class, investment_horizon, risk_tags)`。 + 其余字段归交易/注册侧所有,本服务**不写**——注释写明"避免两个模块抢写同一列"。 +- **事实进画像有白名单**:`FACT_TO_PROFILE_FIELD` 只有 **2 条**: + + | 事实键 | 画像字段 | + | --- | --- | + | `preference:asset_class` | `preferred_asset_class` | + | `preference:horizon` | `investment_horizon` | + + 其余事实(例如 `profile:family`)**只进 `user_facts`,不进画像字段**—— + 画像要保持"能直接支撑决策"的信噪比。 +- **两条不对称的更新规则**(`rebuild_profile()`,容易看漏):`investor_type` 本轮**没查到问卷就保持原值** + (既不写也不清)——否则重测前的空档会把开户时的等级抹掉,而该列是 `NOT NULL`;其余 3 个自有字段 + **本轮没有对应事实就清空**——否则记忆失效后画像会留着一个已作废的投资期限,投顾据此给建议, + 而客户从未授权这条信息继续生效(**实测踩到过**)。 +- **画像行不存在时不代建**:返回 `{"profile": None, "reason": "profile_row_not_opened"}`。 + `trade_account` 等身份字段是 `NOT NULL` 且属注册/账户侧所有,代建会写出一条**假的开户记录**—— + 注释的原话是"画像恰恰是风控要读的东西,**假数据比没有数据更危险**"。事实已提升进 `user_facts`, + 开户后再重建即可。 +- **快照写入顺序不能反**:唯一键 `uk_profile_snapshot_current` 建在 `current_customer_id` 上, + 保证"每客户最多一条 current",所以必须**先 flush 旧版本 `is_current=False`,再写新版本**。 + 版本号取 `COALESCE(MAX(version),0)+1`,`snapshot_hash` 是 payload(`sort_keys=True`)的 sha256。 +- **实现现状**:✅ **已实现**。承载于 `app/service/profile_assembly_service.py::rebuild_profile()`。 + (原文档记为"❌ 两张表都在,无组装代码,当前 0 行"——已过期。) + +### 3.5 画像的第二存储:Neo4j 关系投影 + +⚠️ **原文档完全漏了这一层**。画像不是只存 MySQL:**值在 MySQL,关系在 Neo4j**。 + +两者的地位**不是并列的**: + +| | MySQL | Neo4j | +| --- | --- | --- | +| 存什么 | **字段的值**(`investor_type='C4'`) | **客户与外部实体的关系**(偏好标签、持仓产品) | +| 地位 | **唯一真相** | **派生视图**(derived projection) | +| 丢了会怎样 | 灾难 | **可直接重建**(拿同一份画像重投影) | +| 与 MySQL 事务一致 | — | **不需要,也刻意不做** | + +**节点(5 种,`graph_model.py::NODE_SPECS`)**: + +| 类型名 | 标签 | 主属性 | +| --- | --- | --- | +| `customer` | `Customer` | `customer_id`(int) | +| `product` | `Product` | `product_code` | +| `tag` | `Tag` | `tag_key` | +| `industry` | `Industry` | `category` | +| `event` | `Event` | `event_id` | + +未登记的类型调 `node_spec()` 直接抛 `ValueError`——注释:"宁可失败也不拼出任意标签"。 + +**关系(8 种,`RELATION_SEMANTICS` 记录"谁指向谁")**: + +``` +PREFERS 客户 → 标签 +HAS_GOAL 客户 → 标签 +INTERESTED_IN 客户 → 产品 | 行业 +TRADED 客户 → 产品 +HOLDS 客户 → 产品 +TRIGGERED_RISK 客户 → 事件 +BELONGS_TO_CATEGORY 产品 → 标签 +EXPOSED_TO_INDUSTRY 产品|客户 → 行业 +``` + +底座本只有关系名白名单,这张表补的是**语义约束**(拒绝把 `TRADED` 写成客户→标签这类无意义的边)。 + +**实际会被投影的内容**——`FACT_PROJECTION` 只有 4 条,13 个受控记忆键里**只有这 4 个进图**: + +``` +preference:risk_level → PREFERS +preference:asset_class → PREFERS +preference:horizon → HAS_GOAL +profile:family → PREFERS +``` + +外加持仓 `HOLDS`(来自 `fin_holding JOIN fin_product`,上限 50 条)。 +`constraint:*` 与其余 `profile:*` **不进图**——它们是结构化约束与属性,该走结构化通道查, +放进图只增加召回噪音。整客上限 `MAX_EDGES_PER_CUSTOMER = 200`。 + +**四条设计约束**(`profile_graph_projection_service.py` docstring): + +1. **单向投影** —— 图可随时重建,不要求与 MySQL 事务一致。 +2. **幂等** —— 全部 `MERGE`,重复投影不产生重复节点/关系。 +3. **降级** —— 图库不可用时返回 `degraded`,**绝不把异常抛给画像重建主链路**。 + 原文:"投顾推荐可以暂时没有图,但不能因为图挂了就让画像更新失败。" +4. **只投影"已确认"的事实** —— 数据源是 `user_facts`,**不直接读 `memory_unit`**。 + 原文:"保证图里的偏好标签与画像口径一致;否则同一件事在画像和图里会有两种说法。" + + 这条不是洁癖:仓库里**曾真的有两套图投影并存**(同事分支按客户各建私有 + `Preference`/`Goal` 节点、数据源是 `memory_unit`;主干写共享 `Tag` 节点、数据源是 + `user_facts`)。2026-09-12 合并时取舍为**方案 A:只保留主干服务**, + `app/infrastructure/neo4j_profile_projection.py` 现已**未被生产装配**(文件头有显式 warning)。 + +**触发链**(与画像重建同源): + +``` +memory_unit 落库(同事务写 profile.rebuild_requested) + → Worker 消费 + ├─ ProfileAssemblyService.rebuild() → user_facts + 画像 + 快照 + └─ ProfileGraphProjectionService → Neo4j 图 + 另有 memory_sync_outbox 的 neo4j 分支 ⤴ 幂等复投,仅用于收敛投递状态 +``` + +**为什么必须绕事件而不能直接调**(`runtime.py::dispatch_profile_rebuild` 注释): + +> 抽取时那条记忆还在**未提交**的事务里,另开 session 去重建画像看不到它 +> (实测:快照加了、事实没进、图里也没多出关系)。事件只可能在本事务提交之后被消费, +> 届时数据一定可见。 + +**一致性:两层对账,职责不重叠** + +| 层 | 服务 | 回答的问题 | +| --- | --- | --- | +| **事件层** | `ProjectionReconciliationService` | 哪些投影事件还没投递、需要重放 | +| **数据层** | `ProfileGraphProjectionService.reconcile_customer()` | 投递完成后,**图里的内容对不对** | + +数据层把差异分两类:`missing`(画像有/图里没有)与 `orphaned`(图里有/画像已无), +两者都为空才算 `consistent`。加 `repair=True` 自动修,**差异一律以画像为准** +(`missing` 补写、`orphaned` 删除,**绝不反过来改画像**),修完还会**重新读一遍确认** +——注释:"不凭'操作没报错'就宣布修好了"。手工工具: + +```powershell +python tools/reconcile_graph.py 9001 # 只检查 +python tools/reconcile_graph.py 9001 --repair # 检查并修复 +python tools/reconcile_graph.py --all # 所有有记忆数据的客户 +``` + +**删除的两种粒度**:`delete_customer()` 用 `DETACH DELETE` 连节点带关系一起清 +(这是此前**缺失的投影删除客户端**——缺了它,销户后图里留着旧关系,投顾会拿过期偏好做推荐); +`_delete_edge()` **只删边、保留节点**(节点是共享的,同一个 `Tag` 可能被上百客户指向)。 +后者有个安全细节:关系名取自图中既有边,属外部数据,拼进 Cypher 前**必须过 +`ALLOWED_RELATIONSHIPS` 白名单**。 + +**读侧走受控边界 `RelationshipService`**(docstring:*"Controlled Neo4j boundary; +callers cannot submit arbitrary labels or Cypher."*): + +```python +neighbors(customer_id, relationship, *, limit=50) # limit 夹到 1..100 +paths(customer_id, *, max_hops=2, limit=50) # hops 硬上限 2 +portfolio_industry_context(customer_id) # LIMIT 20 +``` + +实际读图的两处都在投顾侧:`portfolio_analysis_service.py`(产出 `graph_context`)与 +`product_recommendation_service.py`(产出 `graph_status`)。`portfolio_industry_context` +的 docstring 有一条红线:**"Return only relationship enrichment; no position value comes +from Neo4j."** —— 图只提供关系上的补充信息,**任何持仓金额之类的数值一律不从图里取**。 +调用侧还二次过滤 `product_count >= 2`、只取前 5 条,图挂了返回 `{"degraded": True}` 不影响主分析。 + +- **实现现状**:✅ **已实现**(写侧 `profile_graph_projection_service.py`、 + 读侧 `relationship_service.py`、驱动 `app/infrastructure/graph.py`)。 + **⚠️ 环境前提**:`build_graph_driver()` 在 `neo4j_uri`/密码缺失或驱动未安装时**返回 None** + (图能力整体关闭,不让应用起不来)。`docs/37` 记录本机验证时图库是 `Exited(1)`, + `neo4j` 分支停在 `failed` —— 文档明确判断**那是环境不可用,不是代码缺陷**, + 因为"图库不可用时如实失败、不伪造成功"正是设计口径。 ## 4. 红线(必须由代码保证,不能只靠约定) 1. **适当性判定只认问卷**。`investor_type` 只能由问卷写入,记忆与对话在任何情况下都不得修改它。客户在对话里说"我是激进型"不能让他买到 R5。 + *落地*:`rebuild_profile()` 里 `investor_type` 只从 `fin_risk_assessment` 查询取数,记忆路径碰不到它。 2. **风控与投顾只读画像**,不直接读 `memory_unit`。否则同一条记忆会被多处按各自口径解释。 + *落地*:`risk_repository.py` 走 `FundCustomerProfile`;投顾三个服务走 `enforce_profile_governance=True`。 3. **每条画像字段都要能回答"凭什么"**。写入时通过 `generation_basis` 记录依据来源。 + *落地*:`basis[field] = {source, fact_key, confidence}`,随 `profile_snapshots` 一并落库。 4. **画像变更留版本**,不原地覆盖 —— 风控复盘时需要"当时看到的是什么"。 + *落地*:`_write_snapshot()` 先清旧 `is_current` 再写新版本;版本号 `MAX(version)+1`。 5. **内部资料不进入面向客户的知识库**(已按此处理:反洗钱手册 `visibility=internal`)。 +6. **数值不从图里取**。Neo4j 只做关系补充,任何持仓金额/市值必须来自权威库。 + *落地*:`portfolio_industry_context()` docstring——*"Return only relationship enrichment; + no position value comes from Neo4j."* +7. **投影与权威源不一致时,一律以画像为准**。图是派生数据,不能反过来改画像。 + *落地*:`reconcile_customer(repair=True)` 只补写 `missing`、删 `orphaned`,修完重新读一遍确认。 +8. **投影失败不得拖垮画像更新**。图库不可用返回 `degraded`,主链路照常提交。 + *落地*:`ProjectionOutcome(degraded=True, reason=...)`;`dispatch_profile_rebuild` 只记 warning。 -## 5. 现状与缺口一览 +## 5. 现状一览(2026-09-14 重写) -| 环节 | 状态 | -| --- | --- | -| 对话 → 中期记忆(抽取 + 证据 + 可回溯) | ✅ 已实现(依赖常驻 Worker) | -| 中期 → 长期(证据累积与门槛) | ❌ 待实现 | -| 长期 → 画像(组装 + 版本快照) | ❌ 待实现 | -| 问卷 → 画像 | ❌ 待实现(注册问卷流程未做) | -| 行为 → 画像 | ❌ 待实现(交易模块未做) | -| 短期会话上下文 | ❌ 待实现(影响多轮指代) | -| 画像 → 投顾 / 风控 | ❌ 待实现 | +> 原表把 5 个环节标为 ❌,但代码侧已完成,故**整表作废重写**。 +> 判定依据是源码实际装配;每条给出承载文件,便于逐条复核。 -**建议顺序**:先做「中期 → 长期 → 画像」这一段(现在就有记忆数据可验证),再接问卷与行为两路,最后接下游消费者。 +| 环节 | 状态 | 承载文件 | +| --- | --- | --- | +| 对话 → 中期记忆(抽取 + 证据 + 可回溯) | ✅ 已实现(依赖常驻 Worker) | `worker/memory_extraction_worker.py`、`service/memory_extraction_service.py` | +| 中期 → 长期(证据累积与门槛) | ✅ 已实现 | `service/profile_assembly_service.py::promote_facts()` | +| 长期 → 画像(组装 + 版本快照) | ✅ 已实现 | `service/profile_assembly_service.py::rebuild_profile()` | +| 问卷 → 画像 | ✅ 已实现(`rebuild_profile` 内查 `fin_risk_assessment` 最新一条) | 同上 | +| 短期会话上下文 | ✅ 已实现(最近 10 轮;不影响多轮指代) | `worker/runtime.py::_conversation_history()` | +| 画像 → 图投影(Neo4j 关系) | ✅ 已实现(**依赖图库可用**) | `service/profile_graph_projection_service.py`、`service/graph_model.py` | +| 画像 → 投顾 / 风控 | ✅ 已实现 | 读:`risk_repository.py`(8+ 处 join)、`portfolio_analysis_service.py`、`product_recommendation_service.py`;闸门:`profile_governance_service.py` | +| 中期记忆召回(双通道合并 + 降级) | ✅ 已实现 | `service/memory_recall_service.py` | +| 记忆更新与遗忘(冲突留痕、失效、客户级级联) | ✅ 已实现 | `service/memory_service.py`、`service/memory_lifecycle_service.py` | +| **行为 → 画像** | ❌ **仍未实现** | 交易侧字段(`total_asset`/`trading_frequency`/`behavior_score`)本服务**刻意不写**,留给交易模块 | +| **画像候选的人工确认闭环** | ⚠️ 接口与候选服务已在 | `customer_profile_candidate_service.py`;`GET/POST /users/me/memory-candidates*`、`POST /admin/customer-profile-candidates/{id}/reviews` | + +### 5.1 三条需要注意的"看着像,其实另一回事" + +1. **画像有第二个落点,别只找 MySQL**。关系在 Neo4j,见 §3.5。混起来会得出 + "画像只在 MySQL"或"两个库地位相同"这两种错误结论。 +2. **投顾侧还有一条独立的标签证据链**:`advisor_profile_tag`(每 `tag_key` 只有一条 + `status='active'`,靠唯一列 `active_customer_tag` 保证)+ `advisor_profile_drift_review` + (标签漂移时把候选画像**挂起待复核**)。它与 `risk_tags` 不是同一套——前者是投顾内部 + 带证据血统的标签,后者是画像里给风控看的三方不一致信号。 +3. **`risk_tags` 是 JSON 列但写入的是字符串**(`";".join(tags)`)。读取侧不要假定它是数组。 + +### 5.2 剩余待办(原 §5 的建议已基本生效) + +原表末尾的"建议顺序:先做中期→长期→画像,再接问卷与行为两路,最后接下游消费者" +—— 除**行为一路**外均已落地。剩余待办集中在两处: + +- **行为 → 画像**:等交易模块产出 `total_asset`/`trading_frequency`/`behavior_score`; + 本服务的口径是"不抢写",交易侧就绪后无需改动即可衔接。 +- **图库可用性**:`neo4j_uri`/密码缺失或驱动未装时 `build_graph_driver()` 返回 None, + 投顾侧走 `degraded` 分支但不报错;排查时**先确认返回体里的 `graph_status`/`graph_context`, + 不要误判成"投影代码坏了"**。 diff --git a/docs/26-JWT密钥管理与轮换.md b/docs/26-JWT密钥管理与轮换.md index ca9eb12..f536380 100644 --- a/docs/26-JWT密钥管理与轮换.md +++ b/docs/26-JWT密钥管理与轮换.md @@ -101,14 +101,27 @@ JWT_AUDIENCE=<生产受众> 4. 有人员离开或密钥可能外泄时,按第 5 节轮换,并同步轮换 `JWT_ISSUER`(如有区分)。 5. 服务端**不需要**私钥就不要放 —— 生产机上只挂公钥即可。 -## 8. 与"暂无登录接口"的关系 +## 8. 与登录接口的关系 -当前底座**没有**"账号密码换令牌"的登录接口,令牌由外部按 RS256 自签。这是已知缺口, -记为待办(见 `docs/19` 未解决项)。 +> ✅ **该缺口已闭环(2026-09-14 更新)**。本文原文写「当前底座**没有**账号密码换令牌的登录接口, +> 令牌由外部按 RS256 自签」——**现在有了**: +> +> - **`POST /api/v1/auth/tokens`**(`app/api/controllers/auth.py:24,32`,接口编号 **A034**), +> 校验账号密码后由服务端用私钥签发 RS256 令牌。 +> - 因此**不需要**外部自签令牌了;`docs/05-接口文档.md` 的 A034 是当前权威说明。 +> - 关键点:**验签侧(`JwtAuthenticator`)与身份解析侧(`IdentityService`)确实没有改** +> ——下面那段预判是准确的,补登录接口只是签发侧的增量。 + +**下面这段原文保留,作为"当初为什么这样设计"的记录:** + +~~当前底座**没有**"账号密码换令牌"的登录接口,令牌由外部按 RS256 自签。这是已知缺口, +记为待办(见 `docs/19` 未解决项)。~~ 补充这个接口时,改动集中在签发侧:新增一个 `POST /auth/login`(校验密码 → 用私钥签令牌), 验签侧(`JwtAuthenticator`)与身份解析侧(`IdentityService`)**都不需要改**。 +> 实现时用的是 `POST /api/v1/auth/tokens`(**不是** `/auth/login`),语义同上。 + 前提是一条接入红线:**业务代码只能从 `RequestContext` 取身份**,不许自己解析 JWT、 不许硬编码 `user_id`、不许自己查 `sys_user` 判断角色。这条守住了,后补登录接口就是增量; 破了,就得回头翻所有业务代码。 diff --git a/docs/28-场外与推广域数据表登记.md b/docs/28-场外与推广域数据表登记.md index 9ccf5cb..7ada323 100644 --- a/docs/28-场外与推广域数据表登记.md +++ b/docs/28-场外与推广域数据表登记.md @@ -4,8 +4,20 @@ > 同事那条线(袁聪)合并进来的 **17 张表属场外基金运营域与推广域**,**不进 `docs/00`**, > 由本文单独登记,避免"基线"这个概念因为塞入独立域而失效。 > -> **维护约定**:新增/删除这两个域的表时**必须同步本文**;本文是「68 张表从哪来」的唯一答案。 -> **表数口径**:场内 **51** + 场外/推广 **17** = **68**(共 69 张含 `alembic_version`)。 +> **维护约定**:新增/删除这两个域的表时**必须同步本文**;本文是「**场外/推广这 17 张**从哪来」的唯一答案。 +> +> ⚠️ **表数口径(2026-09-14 更正)**:本文此前写作 +> `场内 51 + 场外/推广 17 = 68(共 69 张含 alembic_version)`, +> 那是**投顾域尚未并入时**的口径。当前全库已为: +> +> ``` +> 场内 51(docs/00) + 场外/推广 17(本文) + 投顾 21(登记文档待补) = 89 张业务表 +> (另加 alembic_version,库内共 90 张) +> ``` +> +> **本文只负责场外/推广那 17 张**(10 张 `offsite_*` + 7 张 `promotion_*`), +> 逐表登记内容本身**未变、无缺陷**;投顾 21 张不在本文范围,其登记文档待补。 +> 核验一律以 `python tools/audit_schema.py` 的实时输出为准。 --- diff --git a/docs/36-PR7合并记录与权限号段修正.md b/docs/36-PR7合并记录与权限号段修正.md index ad38c4a..9a60488 100644 --- a/docs/36-PR7合并记录与权限号段修正.md +++ b/docs/36-PR7合并记录与权限号段修正.md @@ -5,6 +5,24 @@ > **来源**:`origin/ZSY_develop` = `f68b052` → `qyqy_develop` > **规模**:90 文件、+7897 / −73 > **门禁**:全部通过(见 §2),数据库侧无迁移、表结构未变 +> +> --- +> ## ⚠️ 口径修正(2026-09-14):本文的"权限条数"是**当时快照**,现已增长 +> +> 本文正文出现的 **40 条 / 45 条 / 38 条**都是 **2026-09-12 当天**的实测值,不是当前值。 +> 现状(直接查 `tools/seed_test_rbac.py` 的 `PERMISSIONS`): +> +> | 口径 | 值 | +> |---|---| +> | **权限号段** | **`9001`–`9065`(共 65 条)** | +> | 区间归属 | `9001-9017` 一期公共;`9018-9034` 客服二期/投顾;`9041-9046` 产品治理与候选审核;`9047-9050` 风控告警;`9051-9056` 推广/NL2SQL/探针;`9057-9059` 投顾客户范围;`9060-9065` 账户与交易看板 | +> | **从未存在** | `9035-9040`、`4041-4046`(后者是 `9041-9046` 的笔误) | +> +> ⚠️ 本文 §4 的号段表到 `9046` 结束是正确的(那正是当时的上界),**但不要据此认为 9046 是终点**。 +> 权威出处是 `tools/seed_test_rbac.py` 的 `PERMISSIONS`(该脚本是 +> `DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099` 的**重建**语义: +> 没并进种子的权限码,重建一次就没了,表现是"接口突然 403"而无报错线索)。 +> 一致性由 `python tools/check_rbac_seed_consistency.py` 及其单测守着。 --- @@ -100,8 +118,9 @@ ZSY 的客服 Agent 接入线,含三块新能力与一次合规清理: | 9045 | `memory:candidate:review` | admin | | 9046 | `handover:read` | admin | -一致性自检(只读):种子 40 条权限、id 唯一、无重复;两个 `grant_*.py` 的每一条 +一致性自检(只读):**当时**种子 40 条权限、id 唯一、无重复;两个 `grant_*.py` 的每一条 `(id, code)` 都与种子逐字一致;`CUSTOMER_PERMISSIONS` 引用的 id 全部存在。 +(⚠️ 2026-09-14:现为 **65 条**,号段 `9001-9065`。) --- @@ -121,7 +140,7 @@ FOREIGN KEY (`customer_id`) REFERENCES `sys_user` (`id`))') 投顾线引入的 `advisor_profile_tag` 有 FK 指向 `sys_user`,而库里有数据引用 `9001-9003`, 于是种子最后那步 `DELETE FROM sys_user` 被外键拒绝。**因为 `commit()` 在最后,种子是原子的** -(失败即完整回滚,已实测:权限仍 38 条、三个用户密码完好),但任何人在这台环境、 +(失败即完整回滚,**当时**实测:权限仍 38 条、三个用户密码完好),但任何人在这台环境、 或任何有投顾数据的环境上跑种子都会失败 —— 而且外部表现只是"什么都没发生"。 **修法(已落地)**:把 `DELETE FROM sys_user` 换成「存在则 UPDATE 非密码字段、不存在才 INSERT」, @@ -133,9 +152,12 @@ FOREIGN KEY (`customer_id`) REFERENCES `sys_user` (`id`))') ### 5.2 对齐后的最终实测状态 -| 项 | 值 | +> ⚠️ **下表是 2026-09-12 当天的实测快照**。权限总数与绑定数此后已随新增权限码变动 +> (现共 65 条,见文件头 ⚠️ 块);测试用例数也已增长(`tests/unit tests/contract` 不再只有 1275 项)。 + +| 项 | 值(2026-09-12) | |---|---| -| 权限总数 | **45 条** | +| 权限总数 | ~~**45 条**~~ → 现 **65 条** | | 绑定数 | `admin 44 / customer 20 / risk_operator 10 / advisor 10 / operator 1` | | 原先缺的 `9018 knowledge:query` | ✅ 已补(种子重建带入) | | 原先缺的 `9019 knowledge:manage` | ✅ 已补 | diff --git a/docs/38-架构对齐-记忆与画像投影链路.md b/docs/38-架构对齐-记忆与画像投影链路.md index 601a8a1..73c754a 100644 --- a/docs/38-架构对齐-记忆与画像投影链路.md +++ b/docs/38-架构对齐-记忆与画像投影链路.md @@ -103,6 +103,21 @@ git grep "GraphProjectionWorker(" → 零处实例化 ## 三、四个必须对齐的架构分歧 +> ✅ **四个分歧均已收口(2026-09-14 复核)**,逐条结论: +> +> | 分歧 | 最终选择 | 落地位置 | +> |---|---|---| +> | 1. 用哪条 outbox | 沿用**主干**那条链(`agent.run_completed → memory.extraction_requested → profile.rebuild_requested`),**不**引入第二套 | `app/worker/runtime.py` 的 handler 字典 | +> | 2. 画像"直接快照"vs"候选→复核→批准" | **两者并存**:主干直接重建快照;候选复核作为**第二条**独立路径(`memory:candidate:confirm`/`memory:candidate:review`),由 PR #7 合入 | `docs/36` §1 | +> | 3. 两套客服 Agent | **放弃 ZSY 那套**,只保留主干的 `CustomerServiceAgent`。ZSY 的**访客身份 / 候选 / 转人工工单**三块能力保留 | `docs/36` §1 | +> | 4. ZSY 落后主干 144 提交 | 按"只取增量"处理,未整体合并 | `docs/36` §1 | +> +> **另外**:`app/infrastructure/neo4j_profile_projection.py`(ZSY 那套**私有** `Preference`/`Goal` 节点投影) +> **未被装配进生产** —— 主干走的是 `ProfileGraphProjectionService`(写**共享** `Tag` 节点,源自 `user_facts`)。 +> 两者语义不同,**不要**为了"启用它"而把 `__main__.py` 的装配加回去,那会重新变成两套图投影并存。 +> +> **实施细节与验证证据见 `docs/37-记忆投影链路实现说明.md`**;本文下面各节保留原始核对过程。 + ### 分歧 1:用哪条 outbox 承载"记忆→画像→图"? - **主干**:`domain_event_outbox` + `profile.rebuild_requested`(**已在 handler 白名单里**,架构师 09-10 接的) @@ -150,6 +165,8 @@ app/worker/runtime.py、app/service/public_platform_service.py、app/main.py、a ## 四、建议的收口方案(分三步,风险递增) +> ✅ **三步均已执行完成(2026-09-12)**,证据见 `docs/37`;本节保留原始方案供追溯。 + ### 第 1 步:**只移植两个投影适配器**(低风险、有明确收益) | 移植什么 | 从哪来 | 落到哪 | diff --git a/docs/41-客服Agent前端开发约束_v1.md b/docs/41-客服Agent前端开发约束_v1.md index f3684f1..76e651f 100644 --- a/docs/41-客服Agent前端开发约束_v1.md +++ b/docs/41-客服Agent前端开发约束_v1.md @@ -85,35 +85,52 @@ static/ │ ├── error-codes.js # 与 docs/05 §3.6 错误码一一对应 │ ├── permission-codes.js # 与 tools/seed_test_rbac.py 的权限码一一对应 │ └── layout/ # 共享壳:顶部品牌条、底部状态栏、侧边栏骨架 - ├── guest/ # 访客门户 - │ ├── chat/index.html - │ ├── chat/chat.js - │ └── chat/chat.css - ├── customer/ # 已登录用户门户(含看板!) - │ ├── chat/index.html - │ ├── chat/chat.js - │ ├── dashboard/ # ← 本期看板模块 - │ │ ├── index.html - │ │ ├── dashboard.js # 入口编排 - │ │ ├── modules/ # 业务模块(按卡片拆) - │ │ │ ├── account-summary.js - │ │ │ ├── portfolio-summary.js - │ │ │ ├── holdings-table.js - │ │ │ ├── quick-actions.js - │ │ │ └── refresh-indicator.js - │ │ ├── widgets/ # 通用组件(图表/数字块) - │ │ │ ├── metric-card.js - │ │ │ ├── sparkline.js - │ │ │ └── data-table.js - │ │ └── dashboard.css - │ ├── portfolio/ # 未来:组合详情 - │ ├── orders/ # 未来:交易记录 - │ └── profile/ # 客户画像候选确认(沿用 §8.2 二期流程) + ├── guest/ # 访客门户(免登录) + │ ├── home/index.html # 首页 + │ ├── products/index.html # 产品列表 + │ └── product-detail/index.html + ├── customer/ # 已登录用户门户 + │ ├── login/index.html + │ ├── dashboard/index.html + │ ├── orders/index.html + │ ├── transactions/index.html + │ ├── holdings/index.html + │ ├── cash-ledger/index.html + │ ├── profit-loss/index.html + │ └── risk-questionnaire/index.html + ├── employee-console/ # 管理员(登录 + 工作台) + │ ├── login/index.html + │ └── workspace/index.html ├── employee-risk/ # 风控专员工作台(qyqy 团队负责) + │ └── dashboard/index.html ├── employee-advisor/ # 投顾工作台(qyqy 团队负责) + │ └── dashboard/index.html + ├── employee-operations/ # 运营工作台(场外/推广/NL2SQL) + │ ├── dashboard/index.html + │ ├── nl2sql/index.html + │ ├── offsite/index.html + │ └── promotion/index.html └── README.md # 各 portal 路由约定 ``` +> ⚠️ **本节目录树已于 2026-09-14 按实际目录更正**。原文写的是 +> `guest/chat/`、`customer/chat/`、`customer/portfolio/`(标"未来")、`customer/orders/`(标"未来")、 +> `customer/profile/`,且只列了四个角色目录。实际差异: +> +> | 原文 | 实际 | +> |---|---| +> | `guest/chat/`、`customer/chat/` | **不存在 `chat/` 目录**。客服对话以**浮窗**形式提供(`common/customer-service-widget/` 的 `widget.js`/`widget.css`),访客首页即 `guest/home/` | +> | `customer/portfolio/`、`customer/orders/` 标为"未来" | **均已落地**:`orders/`、`transactions/`、`holdings/`、`cash-ledger/`、`profit-loss/`、`risk-questionnaire/` | +> | `customer/profile/`(画像候选确认) | 实际为 `customer/risk-questionnaire/`;画像候选确认并入客户门户流程 | +> | 只列 4 个角色目录 | 实际 **6 个**:另有 `employee-console/`(管理员)、`employee-operations/`(运营) | +> +> ⚠️ **`employee-advisor/` 与 `employee-console/` 是两个不同角色,不是改名关系**,不要合并。 +> +> ⚠️ **访客公开产品页已接真实接口**:`GET /api/v1/products`(P001)与 +> `GET /api/v1/products/{product_code}/nav-history`(P002), +> 数据源是 `app/api/controllers/public_platform.py`,**不再是 `common/mock-data.js`**。 +> **要求令牌但不校验权限码**(访客令牌的角色是 `visitor`、不带任何权限)。 + ### 3.1 强制约束 | 项 | 要求 | @@ -470,7 +487,7 @@ apiClient.get('T001'); // 实际请求 /api/v1/users/me/account/dashboard | **CSS** | `portal/customer/dashboard/dashboard.css` | | **后端端点** | `T001 GET /api/v1/users/me/account/dashboard` | | **依赖权限** | 9060 `account:read:self`(顶层);子项 9061-9065 | -| **跨页面跳转源** | 主客服 Agent 对话页(`portal/customer/chat/`)、客户画像确认页(`portal/customer/profile/`) | +| **跨页面跳转源** | 常驻客服浮窗(`portal/common/customer-service-widget/widget.js`) | ### 11.2 组件复用规则 @@ -623,14 +640,20 @@ import { ERR_CODES, PERM_CODES } from '/static/portal/common/index.js'; --- -## 12. 与主客服 Agent 页面的交互跳转 +## 12. 与常驻客服浮窗的交互跳转 + +> ⚠️ **口径修正(2026-09-14)**:早期设计里客服对话是一个独立页面 `portal/customer/chat/`, +> **该目录从未落地**。实际实现是挂在 `portal/common/customer-service-widget/` 的**常驻浮窗** +> (`widget.js` + `widget.css`),随页面加载、无独立路由。因此下面所有 `portal/customer/chat/` +> 一律应读作「浮窗」;跨页跳转仍走 `window.location.assign(...)`,但**不需要返回 chat 页**, +> 浮窗在各页面自行保留会话上下文(`session_id` 存 localStorage)。 ### 12.1 三种入口 | 来源 | 入口元素 | 跳转方式 | |---|---|---| -| 客服对话页 `portal/customer/chat/` | 客服回复中的「您的账户余额 ¥XXXXX」悬浮卡片 | 点击 → `window.location.assign('/portal/customer/dashboard/')` | -| 客服对话页 | 用户主动发问「我有多少钱」 | 客服 Agent 主动建议跳转(前端 chat.js 监听 SSE 事件 `hint_navigate`)| +| 客服浮窗 | 客服回复中的「您的账户余额 ¥XXXXX」悬浮卡片 | 点击 → `window.location.assign('/portal/customer/dashboard/')` | +| 客服浮窗 | 用户主动发问「我有多少钱」 | 客服 Agent 主动建议跳转(前端 `widget.js` 监听 SSE 事件 `hint_navigate`)| | 顶部导航栏 | 「我的账户」链接 | `` | ### 12.2 跳转不变量 @@ -638,15 +661,16 @@ import { ERR_CODES, PERM_CODES } from '/static/portal/common/index.js'; 1. **必带 trace_id**:跳转链接拼接 `?trace_id=`,看板进入时验签(防伪跳转) 2. **保留 portal 角色上下文**:跳转后由 `dashboard.js` 重新拉用户身份(不依赖 URL 参数信任) 3. **深链支持**:看板必须支持 `?product_code=510300` 直跳到对应持仓行(高亮 1.5s 后恢复) -4. **返回路径**:从看板点「回到对话」必须回到原 chat session,URL 拼接 `?session_id=` +4. **返回路径**:从看板回到对话**不需要跳转**——浮窗随页面常驻;若需恢复上一轮会话, + 读 `localStorage` 里的 `session_id`(不通过 URL 传递) -### 12.3 反向跳转(看板 → 对话) +### 12.3 反向跳转(看板 → 浮窗) | 触发 | 行为 | |---|---| -| 持仓表「分析收益」按钮 | 跳 `portal/customer/chat/?prefill="分析 510300 的近期表现"` | -| 「总收益有疑问」按钮(PortfolioSummary)| 跳 `portal/customer/chat/?prefill="我的总收益是怎么算的"` | -| 顶部「客服」链接 | 跳 `portal/customer/chat/`(不带 prefill) | +| 持仓表「分析收益」按钮 | 调浮窗 API `window.CustomerServiceWidget.open({ prefill: "分析 510300 的近期表现" })` | +| 「总收益有疑问」按钮(PortfolioSummary)| 调浮窗 API `window.CustomerServiceWidget.open({ prefill: "我的总收益是怎么算的" })` | +| 顶部「客服」链接 | 调浮窗 API `window.CustomerServiceWidget.open()`(不带 prefill) | **理由**:让用户在数据视图与对话视图间平滑切换——这是「看板作为对话的延伸」的产品定位(vs 看板替代对话)。 diff --git a/docs/42-场内基金知识条目草稿.md b/docs/42-场内基金知识条目草稿.md index 39251a5..3165d72 100644 --- a/docs/42-场内基金知识条目草稿.md +++ b/docs/42-场内基金知识条目草稿.md @@ -3,6 +3,15 @@ > **状态**:草稿。**尚未写入知识库** —— 审核通过后再走 > `POST /api/v1/knowledge/documents` 入库。 > +> ⚠️ **与 `docs/43-场内基金产品手册(知识库入库版).md` 的关系(2026-09-14 补)**: +> 两份文档**同主题、不同版本** —— 本文是**草稿(取数 2026-09-13)**,`docs/43` 是 +> **入库版手册(取数 2026-09-11)**。**两者对 `511810` 的净值不同是已知且有意为之**: +> - 本文:`0.2332`(2026-09-13) +> - `docs/43`:`0.2661`(2026-09-11) +> +> 两个都是东方财富的真实 `DWJZ`,只是**日期差两天**。查证过程见本文第 220-245 行。 +> **入库前请先定用哪一天的口径**,避免知识库与 `fin_product.current_nav` 打架。 +> > **数据来源**:**净值取自东方财富真实行情** —— 走项目自带的 > `app/infrastructure/fund_market_adapter.py`(与下单链路、`MarketQuoteSyncService` 同源), > 取数时间 2026-09-13;产品属性(代码/名称/交易所/风险等级/交易单位)取自 `fin_product` 表。 diff --git a/docs/43-场内基金产品手册(知识库入库版).md b/docs/43-场内基金产品手册(知识库入库版).md index 29c50fc..75b2123 100644 --- a/docs/43-场内基金产品手册(知识库入库版).md +++ b/docs/43-场内基金产品手册(知识库入库版).md @@ -73,6 +73,13 @@ LOF 与主动管理型较高(如南方积极配置混合 1.20%/年、托管费 答:本平台场内基金共 **20 只**,包含 13 只 ETF 与 7 只 LOF。 下表净值为最新披露值,会随行情变动: +> ⚠️ **`511810 货币ETF南方` 的净值口径有专门说明(2026-09-14 补)**: +> 本书写 `0.2661`(2026-09-11 的真实 `DWJZ`),**草稿版 `docs/42-场内基金知识条目草稿.md` +> 用的是 `0.2332`(2026-09-13 的值)**——两个都是真实值,只是**日期不同**。 +> 选哪个取决于入库时点;**两处不一致是已知的、有意的,不是错误**。若要以本书为入库源, +> 请先确认库里 `fin_product` 的 `current_nav` 与 `product_snapshot` 是哪一天的口径。 +> 详细查证过程见 `docs/42` 第 220-245 行(含 `DWJZ` vs `LJJZ` vs 场内交易价的区别)。 + | 代码 | 名称 | 交易所 | 类型 | 风险等级 | 净值 | 净值日期 | |---|---|---|---|---|---|---| | 159329 | 沙特ETF南方 | 深交所 | ETF | R5 | 0.9167 | 2026-09-10 | @@ -85,7 +92,7 @@ LOF 与主动管理型较高(如南方积极配置混合 1.20%/年、托管费 | 510300 | 沪深300ETF | 上交所 | ETF | R3 | 4.5794 | 2026-09-11 | | 510500 | 中证500ETF南方 | 上交所 | ETF | R4 | 7.6027 | 2026-09-11 | | 511070 | 公司债ETF南方 | 上交所 | ETF | R2 | 103.0145 | 2026-09-11 | -| 511810 | 货币ETF南方 | 上交所 | ETF | R1 | 0.2661 | 2026-09-11 | +| 511810 | 货币ETF南方 | 上交所 | ETF | R1 | 0.2661 ⚠️ | 2026-09-11 | | 515450 | 红利低波50ETF南方 | 上交所 | ETF | R3 | 1.4027 | 2026-09-11 | | 588890 | 科创芯片ETF南方 | 上交所 | ETF | R4 | 1.2327 | 2026-09-11 | | 160105 | 南方积极配置混合(LOF) | 深交所 | LOF | R3 | 1.2514 | 2026-09-11 | diff --git a/docs/44-演示流程.md b/docs/44-演示流程.md index 11c600f..7910495 100644 --- a/docs/44-演示流程.md +++ b/docs/44-演示流程.md @@ -43,6 +43,16 @@ python tools/sync_market_prices.py **最省事的办法:双击桌面上的 `启动金融Agent平台.bat`。** 不用开 PowerShell、不用敲命令。 (仓库根目录也有一份 `启动平台.bat`,是同一个东西。) +> ⚠️ **`启动金融Agent平台.bat` 在桌面上,不在仓库里**(2026-09-14 补注): +> 仓库中只有 **`启动平台.bat`** 这一份(`Glob 启动*.bat` 只命中它)。 +> 两份内容是同一个东西、由同一个生成器产出: +> ``` +> python tools/make_launcher_bat.py # 改完 start.ps1 或想换路径就重跑它,桌面与仓库两份一起更新 +> ``` +> **不要手写那个 bat** —— 它必须同时满足 **GBK 编码 + CRLF 换行 + 无 BOM**, +> 缺任何一条 `cmd` 都会解析错乱(LF 换行会把 `echo` 的说明文字当命令执行, +> 实测报 `AT 命令已弃用`、`']' 不是内部或外部命令`)。 + 它按顺序做六件事:**找解释器 → 检查 MySQL/Redis/Milvus → 刷新行情 → 起两个窗口 → 等 API 真正应答 → 自动开浏览器**,然后打印访问入口与账号。 @@ -337,6 +347,15 @@ python tools/e2e_smoke_test.py | 投顾 | `advisor_t` | `abc12345` | 同上(→ 投顾工作台) | | 运营 | `offsite_t` | `offsite123` | 同上(→ 运营工作台) | +> ⚠️ **`advisor_t` 与 `offsite_t` 不在 RBAC 种子脚本里**(2026-09-14 补注): +> `tools/seed_test_rbac.py` 的演示用户只有 **4 个**——`cust_t`(9001)、`risk_t`(9002)、 +> `admin_t`(9003)、`review_t`(9004)。`advisor_t`(9020) 与 `offsite_t`(9006) 由 +> **`tools/grant_*.py` 系列 / 投顾线自己的脚本**创建,**重跑种子不会重建它们**。 +> 换机器时若这两个账号登录失败,先查 `sys_user` 里到底有没有这两行,而不是查密码。 +> (此点 `docs/40-前端验收清单.md` 已有自述。) +> +> 另注:`sys_user` 已改为「存在则更新、不存在才插入」,故**重跑种子不会弄丢演示密码**。 + **常用命令** ```powershell diff --git a/docs/superpowers/analysis/2026-09-10-现状与差距分析.md b/docs/superpowers/analysis/2026-09-10-现状与差距分析.md index 6c81208..0457540 100644 --- a/docs/superpowers/analysis/2026-09-10-现状与差距分析.md +++ b/docs/superpowers/analysis/2026-09-10-现状与差距分析.md @@ -1,5 +1,11 @@ # 现状与差距分析(2026-09-10 第三次接手) +> 🗂 **过程产物 · 结论已归档**(2026-09-14 批注) +> 本文是**开工前的分析记录**,其中的"差距/待办"此后已全部闭掉。 +> **不要用本文判断当前进度** —— 结论性文档在 `docs/验收与审计/` +> (`phase1-acceptance-report.md` 是目前进度的权威口径)。 +> 保留本文是为了留存"当时基于什么事实做了哪些判断"的证据链。 + > 作者:接手会话(第三个 AI) > 依据:老师参考资料 `Desktop\金融`、`Desktop\胜宇前期开发资料\财富项目`、当前仓库 `qyqy_develop` > 目的:在"推倒重来"前把事实摆清楚,避免第四次白做。 diff --git a/docs/superpowers/analysis/2026-09-10-范围重定位与画像讨论记录.md b/docs/superpowers/analysis/2026-09-10-范围重定位与画像讨论记录.md index 5a68f11..9a51b47 100644 --- a/docs/superpowers/analysis/2026-09-10-范围重定位与画像讨论记录.md +++ b/docs/superpowers/analysis/2026-09-10-范围重定位与画像讨论记录.md @@ -1,5 +1,10 @@ # 讨论记录:范围重定位、画像与外部数据源(2026-09-10 第四次会话) +> 🗂 **过程产物 · 结论已归档**(2026-09-14 批注) +> 本文是**需求澄清期的讨论记录**,其中的决策已被实现(含画像投影链路,见 `docs/23`/`docs/37`/`docs/38`)。 +> **判断当前进度请看 `docs/验收与审计/phase1-acceptance-report.md`**,不要用本文。 +> 保留本文是为了留存"当时为什么这样定范围"的原始讨论。 + > 参与:用户(袁聪)+ 接手 AI > 性质:**需求澄清与范围修正记录**,不是设计文档。设计文档见 > `docs/superpowers/plans/2026-09-10-客服Agent与RAG实施计划-qyqy版.md`。 diff --git a/docs/superpowers/analysis/客服Agent专项设计方案-分析报告.md b/docs/superpowers/analysis/客服Agent专项设计方案-分析报告.md index b42f82a..d9baad2 100644 --- a/docs/superpowers/analysis/客服Agent专项设计方案-分析报告.md +++ b/docs/superpowers/analysis/客服Agent专项设计方案-分析报告.md @@ -1,5 +1,10 @@ # 《智能客服Agent专项设计方案(2)(2).html》分析报告 +> 🗂 **过程产物 · 结论已归档**(2026-09-14 批注) +> 本文是对**外部方案稿**的分析记录,用于当时判断"哪些要采纳、哪些不采纳"。 +> 采纳结论已落地(客服 Agent + RAG 已交付,见 `docs/37` 与 `docs/验收与审计/`)。 +> **判断当前进度不要用本文**;本文的价值是留存"逐条比对"的证据。 + > 被分析文档:`C:\Users\Windows\Desktop\胜宇前期开发资料\财富项目\项目信息及需求\智能客服Agent专项设计方案(2)(2).html`(187 KB / 2846 行,已全文逐段读完) > 文档版本:v1.3|日期:2026-09-07|状态:内部方案评审稿(本文档 L181-L183) > 对比基线:`docs/superpowers/specs/2026-09-10-knowledge-retrieval-infra-design.md`(下称 **spec A**)、`docs/superpowers/specs/2026-09-10-customer-service-agent-design.md`(下称 **spec B**);另核 `app/service/agent/governance.py`、`docs/00-新数据库基线设计.md`、`docs/02-数据库建表设计.md`、`alembic/baseline_generated.sql` diff --git a/docs/superpowers/handoff/2026-09-11-交接文档-客服Agent与RAG收尾.md b/docs/superpowers/handoff/2026-09-11-交接文档-客服Agent与RAG收尾.md index 04c9c49..952ee69 100644 --- a/docs/superpowers/handoff/2026-09-11-交接文档-客服Agent与RAG收尾.md +++ b/docs/superpowers/handoff/2026-09-11-交接文档-客服Agent与RAG收尾.md @@ -2,6 +2,30 @@ > 本文是**给下一个接手 AI 的唯一入口**。读完本文 + `AGENTS.md` 的「📖 接手先读」索引,即可开工,**不需要**再翻历史过程台账。 > 本文所有数字都是 **2026-09-11 实测**,不是估计;每条结论都附了复现命令。 +> +> --- +> ## ⚠️ 2026-09-14 复核:三处已变,读正文前先看这里 +> +> **1. 分支指针已前进。** 本文(含 §6)写的 `origin/NL_develop = fb7d2f7` 是 2026-09-11 的状态; +> **该远端分支此后又推进了 10 个提交**(现为 `1f62aca`)。引用提交号前请先 `git log --oneline -1 origin/NL_develop` 自查, +> 不要照抄本文的 SHA。**主集成分支仍是 `qyqy_develop`**(ZSY 的客服接入线已由 PR #7 合入,见 `docs/36`)。 +> +> **2. 测试基线数字在本文内部就不一致(已知缺陷)。** 本文出现**三个**不同的通过数: +> - §0 第 16 行:`1 failed, 1013 passed, 2 skipped` +> - §4 第 142 行:`1 failed, 804 passed, 2 skipped` +> - §7 第 249 行:`804 passed / 52 表 / mypy 151` +> +> 原因是「合并前」与「合并后」两次实测被混写。**以 §0 的 `1013 passed`(合并后口径)为准**; +> 且用例数此后仍在增长(不得据此判断"测试变少了")。**当前基线一律重跑取数,不要引用本文数字。** +> +> **3. §0 末尾列的两个最大遗留,现在都已解决:** +> - ① ~~`memory_sync_outbox` 没有消费者~~ → **已闭环**:`app/worker/runtime.py` 的 +> `consume_profile_projections()`(L551)已在 Worker 主循环(L523)消费,milvus / neo4j 两个 +> handler 都在;详见 `docs/37` / `docs/38`。 +> - ② ~~Redis 密码没配~~ → 已配置(`AGENTS.md` §E 与 `start.ps1` 的健康检查里含 Redis)。 +> +> **4. 表数口径**:本文 §4/§7 的「52 表」是当时值;现为 **90 张表 = 89 张业务表 + `alembic_version`** +> (场内 51 + 场外/推广 17 + 投顾 21),核验用 `tools/audit_schema.py`。 --- @@ -10,6 +34,7 @@ > ⏱ **2026-09-11 第二次更新(合并完成后)**:本节已按最终状态重写,§6 的 Git 状态也已更新。 > 你的工作**已推送**到远端个人分支 `NL_develop`(`fb7d2f7`),并已包含架构师当时最新的 > `qyqy_develop`(38 个新提交)。**接手请从这条分支开始,不要再用 `6516ccb`。** +> (⚠️ 分支此后又前进到 `1f62aca`,见文件头 ⚠️ 块。) - 分支:**`NL_develop`**(个人分支,从架构师的 `qyqy_develop` 拉出)→ PR 合回 `qyqy_develop`。 - 本次交付的线:**客服 Agent(`CustomerServiceAgent`)+ RAG 知识检索 + 画像工具 + 知识库管理三端点**。 @@ -140,6 +165,7 @@ # 全量测试(约 25 秒) .\.venv\Scripts\python.exe -m pytest -q # 期望:1 failed, 804 passed, 2 skipped +# ⚠️ 804 是"合并前"的实测;合并后 §0 记的是 1013 passed。两者不矛盾,是两次快照。 # 建表基线审计(证明没动 docs/00 基线) .\.venv\Scripts\python.exe tools\audit_schema.py @@ -246,7 +272,9 @@ e4c4099 wip: 客服Agent + RAG + 画像收尾(基于 6516ccb) ← 用户 ## 8. 建议的接手顺序 1. 读本文 + `AGENTS.md`。 -2. 跑 §4 的三条命令,确认基线(804 passed / 52 表 / mypy 151)。 +2. 跑 §4 的三条命令,确认基线(~~804 passed / 52 表 / mypy 151~~)。 + ⚠️ 2026-09-14:这三个数都过期了——用例数已增长,表数现为 **90 张 = 89 业务 + `alembic_version`**。 + **基线一律重跑取数**,别引用旧数字。 3. 起 Milvus + 确认 `fin_*` 三集合有向量(§3 的数)。 4. 从 §5 的 **第 1 条**(`memory_sync_outbox` 消费者)开始动手 —— 它是最明确、最有价值的一块。 5. 任何"写 Milvus / 删 Milvus"的断言都要**轮询**;任何"工具能调通"的断言都要**真机跑一次** diff --git a/docs/superpowers/plans/2026-09-10-foundation-safe-migration.md b/docs/superpowers/plans/2026-09-10-foundation-safe-migration.md index f296a1e..90131e4 100644 --- a/docs/superpowers/plans/2026-09-10-foundation-safe-migration.md +++ b/docs/superpowers/plans/2026-09-10-foundation-safe-migration.md @@ -1,5 +1,10 @@ # 新底座无损迁移 Implementation Plan +> 🗂 **过程产物 · 结论已归档**(2026-09-14 批注) +> 迁移**已完成**(分支已整合、Alembic merge revision 已收敛、客服从 `BaseAgent`/`AgentFactory`/ +> `ToolExecutor`/`PlatformGovernance`/`WorkerRuntime` 运行)。 +> **判断当前进度请看 `docs/验收与审计/phase1-acceptance-report.md`**,本文件只作执行过程留档。 + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在不改变现有工作区和当前运行数据库的前提下,将 `qyqy_develop` 升级为项目底座并完整保留场外基金、NL2SQL、访客、客服 RAG 与人工转接。 diff --git a/docs/superpowers/plans/2026-09-10-客服Agent与RAG实施计划-qyqy版.md b/docs/superpowers/plans/2026-09-10-客服Agent与RAG实施计划-qyqy版.md index ea93a95..db172f2 100644 --- a/docs/superpowers/plans/2026-09-10-客服Agent与RAG实施计划-qyqy版.md +++ b/docs/superpowers/plans/2026-09-10-客服Agent与RAG实施计划-qyqy版.md @@ -1,5 +1,10 @@ # 客服 Agent + RAG 知识库实施计划(qyqy_develop 版) +> 🗂 **过程产物 · 结论已归档**(2026-09-14 批注) +> 本计划的 Task 已**全部执行完毕**(客服 Agent + RAG 已交付,7 条验收标准已达成)。 +> **判断当前进度请看 `docs/验收与审计/phase1-acceptance-report.md`**,本文件只作执行过程留档。 +> 实施说明与验证证据见 `docs/37-记忆投影链路实现说明.md`。 + > **For agentic workers:** REQUIRED SUB-SKILL: 用 `subagent-driven-development`(推荐)或 `executing-plans` 逐任务实施。步骤用 `- [ ]` 复选框跟踪。 > > 本计划**取代** `2026-09-10-customer-service-agent-implementation.md`(旧 12-Task 计划,为 `develop` 底座 + 只导 105 条 QA 设计,两个前提均已失效)。 diff --git a/docs/superpowers/specs/2026-09-10-customer-service-agent-design.md b/docs/superpowers/specs/2026-09-10-customer-service-agent-design.md index 40e80a4..09282d3 100644 --- a/docs/superpowers/specs/2026-09-10-customer-service-agent-design.md +++ b/docs/superpowers/specs/2026-09-10-customer-service-agent-design.md @@ -1,5 +1,10 @@ # 设计文档:客服 Agent 本体(子项目 B) +> 🗂 **过程产物 · 结论已归档**(2026-09-14 批注) +> 本设计**已实现**(`CustomerServiceAgent` 已注册)。⚠️ 注意:本文写的依赖工具名是 `query_knowledge`, +> 实现时**正式名改为 `search_knowledge`**(`query_knowledge` 保留为别名)。 +> **判断当前进度请看 `docs/验收与审计/phase1-acceptance-report.md`**。 + > 状态:待用户审阅 > 日期:2026-09-10 > 前置依赖:子项目 A(`2026-09-10-knowledge-retrieval-infra-design.md`)必须先完成,本文档依赖其 `query_knowledge` 工具。 diff --git a/docs/superpowers/specs/2026-09-10-foundation-safe-migration-design.md b/docs/superpowers/specs/2026-09-10-foundation-safe-migration-design.md index 3207d57..dc3a3ed 100644 --- a/docs/superpowers/specs/2026-09-10-foundation-safe-migration-design.md +++ b/docs/superpowers/specs/2026-09-10-foundation-safe-migration-design.md @@ -1,5 +1,9 @@ # 新底座无损迁移设计 +> 🗂 **过程产物 · 结论已归档**(2026-09-14 批注) +> 迁移**已按本设计完成**(场外/NL2SQL/访客/客服 RAG/人工转接均保留)。 +> **判断当前进度请看 `docs/验收与审计/phase1-acceptance-report.md`**。 + ## 目标 将 `qyqy_develop` 作为新的 Agent 平台底座,同时完整保留现有项目的场外基金、NL2SQL、访客 token、客服 Agent/RAG、人工转接、联调页面与现有 API 能力。 diff --git a/docs/superpowers/specs/2026-09-10-knowledge-retrieval-infra-design.md b/docs/superpowers/specs/2026-09-10-knowledge-retrieval-infra-design.md index 5b90558..0d76471 100644 --- a/docs/superpowers/specs/2026-09-10-knowledge-retrieval-infra-design.md +++ b/docs/superpowers/specs/2026-09-10-knowledge-retrieval-infra-design.md @@ -1,5 +1,11 @@ # 设计文档:客服知识检索基础设施(子项目 A) +> 🗂 **过程产物 · 结论已归档**(2026-09-14 批注) +> 本设计**已实现并闭环**(检索在 Phase 1 验收中实测命中 score 0.7837)。 +> ⚠️ 实现期有一处偏离本文:Milvus 集合字段名**改为运行时探测**(`app/core/knowledge_schema.py`), +> 不再硬编码 —— 因为两套环境的 schema 不同。详见 `docs/18-知识检索接入方案.md` §4.2 的 ⚠️ 块。 +> **判断当前进度请看 `docs/验收与审计/phase1-acceptance-report.md`**。 + > 状态:待用户审阅 > 日期:2026-09-10 > 依赖关系:本文档是子项目 B(客服 Agent 本体)的前置依赖,须先完成本文档再启动 B。 diff --git a/docs/演示用/代码修改方案-2026-09-14.md b/docs/演示用/代码修改方案-2026-09-14.md new file mode 100644 index 0000000..f29609d --- /dev/null +++ b/docs/演示用/代码修改方案-2026-09-14.md @@ -0,0 +1,967 @@ +# 代码修改方案 + +> **依据**:`docs/演示用/代码库全面审查报告-2026-09-14.md` +> **生成日期**:2026-09-14 +> **范围**:报告列出的全部 49 项(P0×4 / P1×10 / P2×17 / P3×18) +> **声明**:本文档**只做修改规划,未改动任何代码**。标注 `file:line` 的位置在撰写时已重新读源码核对过。 + +--- + +## 〇、阅读说明 + +### 0.1 严重级别与处置批次的关系 + +用户要求标 **阻塞 / 重要 / 建议**,同时要求区分"本次必修"与"可延后"。这两件事不是同一维度——**"级别"描述后果,"批次"描述排程**。因此本方案给每一项同时标注两个标签: + +| 级别 | 定义 | 对应报告级别 | +|---|---|---| +| **阻塞** | 资金错账、数据损坏、重复处理、合规门禁失效、无凭证可被大规模滥用。**不得上线** | P0 全部 + P1-5(上调,理由见 A-5) | +| **重要** | 高概率功能错误、事件循环阻塞、资源泄漏、依赖漂移、语义不一致 | 其余 P1 + 2 项 P2(上调,理由见 B-9 / B-10) | +| **建议** | 边界瑕疵、维护陷阱、重复代码、日志口径 | P2 主体 + P3 | +| **建议(低)** | 代码卫生、加固 | P3 部分 | + +| 批次 | 含义 | +|---|---| +| **必修** | 本次迭代必须完成,完成前不允许接入真实用户流量 | +| **应修** | 本次迭代应完成;若排程紧张,可在 P0 全部闭环、reviewer 同意后顺延到下一迭代 | +| **延后** | 明确不修,理由见 §8 | + +### 0.2 ⚠️ 撰写本方案时新核实到的 6 条事实(会改变改法) + +这 6 条是我在为每一项写具体改法时重新读源码才发现的,**它们与只按报告字面执行的做法有实质差别**,请先读完: + +1. **前端其实已经在发 `Idempotency-Key`,是后端从来没接收。** + `app/static/portal/common/api-client.js:67` 已把 T002 标为 `idempotent: true`,`:170` 的逻辑是 + `if (endpoint.idempotent) headers['Idempotency-Key'] = options.idempotencyKey || crypto.randomUUID().replaceAll('-','')`。 + 也就是说每笔下单请求**都带着**这个头,只是 `trading.py:61-69` 没声明该参数,于是被静默丢弃。 + → **A-1 不是破坏性变更**(对官方前端而言)。但它同时暴露第二层问题:`dashboard.js:91` 用 `apiClient.post('T002', body)`,没传 `idempotencyKey`,所以**每次调用都生成新的随机键**——网络超时后用户重试会换键,即使后端接了幂等也去重不了。**必须同步改前端生成"一次下单会话一个稳定键"**,否则 A-1 只做一半。 + +2. **报告中"T002 是唯一缺口"的结论,在"会写库"的范围内成立——我把它核实了一遍。** + `docs/05:1283` 自己也提到 AD008/AD009 没有幂等头。我去查了 `app/api/controllers/asset_allocation.py`:该文件 POST 端点确实无 `key` 参数,但 **`app/service/asset_allocation_service.py` 全文无 `session.add` / `commit()` / `insert()`**(纯分析、不写库),所以它的豁免是合理的。而 `recommendations.py:30/68/88` 都已带 `key`。结论:报告 P0-1 的定性正确,不需要扩大范围。 + +3. **`data_scope` 取"全局最高范围"是一次真实事故修复的产物,不能简单改回逐权限口径。** + `identity_repository.py:45-54` 的注释白纸黑字记录了:原先写死 `self`,导致 **9002 (`risk_operator`) 与 9003 (`admin`) 拿着 all 级权限却什么都查不到**。 + → 所以 P1-8 的正确改法**不是**改 `data_scope` 的计算,而是保留它、只改"用错地方"的调用方(详见 B-8)。若按字面变更 `identity_repository.py:55`,会原样复现那个事故。 + +4. **`ApiTransactionService.execute_in` 与 `submit_order` 的提交时机存在一个残余窗口。** + `execute_in`(`api_transaction_service.py:45-89`)在 action **之前**写幂等记录、在 action 之后 `:88` 再 `commit()` 落 `response_json`。而 `submit_order` 自己就在 `:445` `commit()`。 + 这留出一个缝隙:**业务已提交、但 `response_json` 尚未落库**时进程崩溃 → 重试会看不到回放结果 → **重复下单**。 + → A-1 必须包含"把 `submit_order` 的内部 commit 交还给 `execute_in`"这一步,否则幂等仍有漏洞。 + +5. **`enforce_login_rate_limit` 正好是访客令牌限流所需的全部模板。** + `app/api/dependencies/rate_limit.py:81-117` 的实现是"按客户端 IP、**不依赖认证上下文**"——它本来就是为了"登录时还没有身份"这个场景写的(`:84-86` 注释明确说明),而签发访客令牌同样是"签发前没有身份"。**基本可以照抄**,不需要新设计。 + +6. **顺带发现一处文档与代码不一致(建议同批修)。** + `docs/05:79` 写 `Idempotency-Key` 长度 **8-64**,而 `api_transaction_service.py:26` 与 `:64` 实际强制 **16-128**。另有 `测试报告/2026-09-11.md:94` 记录了一次"期望 400 `VALIDATION_ERROR`、实际 422"的分歧。本方案不含此项定论,但建议与 A-1 同批确认并统一。 + +### 0.3 开工前必须先有结论的 3 个问题(会卡住编排) + +| # | 待决问题 | 卡住哪一项 | 为什么必须先决定 | +|---|---|---|---| +| **D-1** | 是否允许为 `_next_id` 破例加 `AUTO_INCREMENT` | A-2 选方案甲还是乙 | `AGENTS.md` 明确禁止修改既有列类型/可空性/含义。若不允许,必须走"新增发号表"的替代方案,工作量与文档影响完全不同 | +| **D-2** | T002 缺 `Idempotency-Key` 时是**拒绝(422)**还是**派生兜底** | A-1 | 拒绝 = 与其它写接口一致,但会破坏未升级的外部调用方;派生兜底 = 不破坏,但"短时间内重复同参数提交"会被误判成重试。**推荐拒绝**(理由见 A-1),但需要你拍板 | +| **D-3** | P1-8 是否本轮动 | B-8 | 它依赖 RBAC 种子数据的实际 scope 组合(报告 §8-1 列为未核实)。情形不同,结论在"重要"与"建议"之间跳变,工作量从 1 天降到 0.1 天 | + +--- + +## 一、A 档|阻塞(本次必修) + +### A-1 场内下单接口接入幂等 —— 避免重复扣款与重复建仓 + +| 项目 | 内容 | +|---|---| +| **级别 / 批次** | **阻塞** / **必修** | +| **报告出处** | P0-1(确定性缺陷) | +| **工时** | 1.5 人天(含前端,不含前置核实) | +| **可并行** | 否(与 A-2 / A-3 / A-5 同改 `trade_service.py`) | + +**问题描述** +`POST /api/v1/users/me/orders`(T002,`app/api/controllers/trading.py:61-69`)既不声明 `Idempotency-Key` 头,也不写 `api_request_receipt`。`app/api/middleware.py` 全文 33 行只有一个 `attach_trace_id`,**没有全局幂等层**。它是全项目唯一会写库却没有幂等保护的写接口(核实见 §0.2-2)。 + +叠加 §0.2-1 的新发现:官方前端已经在发这个头(`api-client.js:67/170`),后端从未接收。 + +**影响范围** +- 直接写入:`fin_sim_order`、`fin_transaction`、`fin_cash_ledger`、`fin_holding` 四张表重复行;`fin_sim_account.available_cash` / `cash_balance` 重复扣减。 +- 下游读取:T003 委托列表、T006 成交明细、T007 资金流水、T001 账户看板全部出现幻影数据。 +- 反向误判:重复买入会让持仓占比虚高,进而让**后续正常买入**被 `HoldingRatioExceededError`(`trade_service.py:324-328`)误拒——错误会自我放大。 +- 调用方:官方前端;`tools/portal.py:164`、`tools/portal_api_check.py:99`、`tools/acceptance_check.py:89` 等脚本**已自动补键**,不受影响。 + +**修改方案** + +1. **`app/api/controllers/trading.py:61-69`** 增加头参数,写法与其它 controller 保持一致(参照 `risk.py:101`): + ```python + key: str | None = Header(default=None, alias="Idempotency-Key"), + ``` + +2. **用 `execute_in` 而不是 `execute`**(这是硬约束): + `ApiTransactionService.execute`(`api_transaction_service.py:22`)自己开 `SessionFactory()` + `session.begin()`,而 `submit_order` 内部 `:445` 会 `commit()` —— 套进去就成了"内层提交外层事务"。必须用 `execute_in`(`:45`),它在**调用方传进来的 session** 上读写幂等记录。 + ```python + return envelope(await ApiTransactionService().execute_in( + session, context, scope="trade:order:create", + key=key, body=payload.model_dump(mode="json"), + action=lambda s: TradeService(s).submit_order_in_session(payload, context), + ), context) + ``` + +3. **把 `submit_order` 的内部提交交还给 `execute_in`**(关闭 §0.2-4 的崩溃窗口)。 + 把 `trade_service.py:288-455` 拆分: + - `_submit_order_in_session(self, payload, context)` —— 保留 288-443 的全部逻辑(含 `:365`/`:400` 的 flush),**移除 `:445` 的 `commit()`**。 + - `submit_order(...)` 保留为薄封装:`result = await self._submit_order_in_session(...)` + `await self._session.commit()`(向后兼容既有单测与其它调用方)。 + - `execute_in` 的 action 调前者;由 `execute_in:88` 的 `session.commit()` 一次性提交**业务写入 + `response_json`**,这才真正做到"幂等记录与业务写入同事务"(`docs/05 §5.2` 要求的语义)。 + +4. **缺键策略**(取决于 D-2,推荐 **422 拒绝**): + `execute_in:64-65` 已内置"16-128 位 ASCII"校验,缺键会抛 `ValidationAgentError` → 走统一信封 **422**。推荐直接沿用,**不再额外兜底**。 + 理由:`api_transaction_service.py:64` 这条规则被全项目所有写接口共用,为 T002 开一个"缺键放行"的特例,等于制造"部分请求走幂等、部分不走"的心智负担,且违背 `docs/05 §5.1`。 + 若你担心未升级的外部调用方,折中是加一个**带到期日**的配置开关 `TRADE_ORDER_REQUIRE_IDEMPOTENCY_KEY`(默认 true,一个版本后移除,并在 TODO 登记),而不是永久保留。 + +5. **前端同步改造**(否则 A-1 只做一半): + `app/static/portal/customer/dashboard/dashboard.js:91` 当前是 `apiClient.post('T002', body)`。改为在**打开下单对话框时生成一次**稳定键,并在一次成功提交后失效重建: + ```js + let idemKey = null; // 作用域提升到对话框生命周期 + // 打开对话框时:idemKey = crypto.randomUUID().replaceAll('-', ''); + const response = await apiClient.post('T002', body, { idempotencyKey: idemKey }); + // 成功后:idemKey = null; + ``` + `api-client.js:170` 已经读 `options.idempotencyKey`,只需确认 `apiClient.post` 的第三个参数能透传到 options(撰写时未验证,**列为前置核实 N5**)。 + **注意**:键必须在"整次交互"稳定,不能每次点击都换,也不能在失败重试时换——这正是当前 `crypto.randomUUID()` 默认行为的缺陷。 + +6. **文档同步**: + - `docs/演示用/后端接口文档-2026-09-14.md` T002 章节的"幂等"行由"否"改为"是(`Idempotency-Key`,16-128 位 ASCII)"。 + - 顺手统一 `docs/05:79` 的长度口径(文档 8-64 / 代码 16-128),并回填 `测试报告/2026-09-11.md:94` 的分歧结论。 + +**风险与兼容性** +- **最大隐藏前置**:`execute_in` 的幂等能力依赖 `api_request_receipt` 上的**唯一键**(`INSERT ... ON DUPLICATE KEY UPDATE` 若无唯一索引会退化成每次插新行,**幂等完全失效**)。**必须先验证该唯一索引存在**,见 N1。 +- **破坏性面**:任何不带键的调用方会从 201 变 422。已知带键的工具脚本不受影响;未知调用方需先扫一遍(N5)。 +- **哈希稳定性**:`request_hash` 由 `digest()`(`api_transaction_service.py:16`)计算,`json.dumps(sort_keys=True, default=str)` 保证字段顺序无关,`default=str` 覆盖 `OrderCreateRequest` 里的 `Decimal`。同一笔业务的重复请求哈希一致。 +- **错误码混淆**:缺键 422 与"数量不是 lot_size 整数倍"422 同码。前端要用 `error.message` 区分,不能只看状态码。 +- **与 A-3 的交互**:`execute_in:74-77` 自身对幂等记录 `with_for_update()`,会把**同键请求**串行化;叠加 A-3 的账户行锁后,同一用户的并发下单会被完全串行化。这是资金安全下的正确取舍,但要在压测里确认超时可接受。 + +**验证与回归** +- **直接复用现成模板**:`tests/integration/test_risk_idempotency_mysql.py` 已覆盖三条关键断言(同键不重复执行 / 同键不同正文 409 / 缺失或过短 422)。复制一份交易版,把 action 换成下单。 +- 手工:`curl` 连发两次同键 → 两次响应体完全相同;`fin_sim_order` 只有 1 行;`available_cash` 只扣一次。 +- 并发:10 路并发同键 → 恰好 1 次成交,9 次回放。 +- 崩溃窗口:mock `response_json` 写入前中断 → 重启后同键重试**不产生第二笔订单**(这条专门验 §0.2-4 的修复)。 +- 回归:T001 看板 / T003 列表 / T005 撤单 / T006 成交;尤其要确认**下单九步校验链的状态码没有被 `execute_in` 改变**(不可交易 422 → 适当性 422 → 行情过期 503 → 账户 404 → 数量 → lot_size → 资金 422 → 占比 422 → 可用份额 422)。 +- 工具:`python tools/e2e_smoke_test.py`、`python tools/acceptance_check.py` 全绿。 + +--- + +### A-2 `_next_id()` 用 `SELECT MAX(id)+1` 发主键 —— 并发下必然主键冲突 + +| 项目 | 内容 | +|---|---| +| **级别 / 批次** | **阻塞** / **必修** | +| **报告出处** | P0-2(确定性缺陷) | +| **工时** | 甲 1 人天 / 乙 1.5 人天 | +| **可并行** | 否(须在 A-3 之后或同批) | + +**问题描述** +`app/service/trade_service.py:111-123` 用 `SELECT MAX(id)+1` 手动发号。`alembic/baseline_generated.sql:270-271` 已确认 `fin_sim_order.id` 为 `` `id` BIGINT UNSIGNED PRIMARY KEY `` **无 AUTO_INCREMENT**(`fin_transaction` 在 `:310` 同形)。 +调用点 4 处,全部是金融主表:`:344` `FundSimOrder`、`:371` `FundTransaction`、`:430` `FundCashLedger`、`:471` `FundHolding`。`submit_order` 一次要发 **3 个 id**。 + +**影响范围** +- 症状:第二个客户的并发请求直接 **500**(`IntegrityError` → 事务回滚)。 +- 严重时引发连锁:A-3(行锁)上线后并发窗口反而变宽,冲突概率上升。 +- 受影响入口:下单(`submit_order`);任何新建持仓的路径(`_upsert_holding:471`)。 + +**修改方案** + +**方案甲(推荐,取决于 D-1)—— 加 `AUTO_INCREMENT`** +1. 新增一个 Alembic 迁移,对 4 张表做 + `ALTER TABLE MODIFY id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT;` +2. 显式设置自增起点:`ALTER TABLE
AUTO_INCREMENT = <当前 MAX(id)+1>;`(MySQL 通常自动取 max+1,但显式指定可避免施工期数据变动导致的意外)。 +3. 删除 `_next_id` 方法,4 处调用改为不传 `id`(交由 DB 发号)。 +4. **必须同步**:把该方法 docstring 里"并发与单测场景下够用"这句话一并删掉——它现在正在误导后来者,是该缺陷存活至今的原因之一。 + +**方案乙(若 D-1 不允许改基线)—— 引入独立发号器** +1. 新增一张发号表 `fin_id_allocator(table_name VARCHAR(64) PRIMARY KEY, next_id BIGINT UNSIGNED NOT NULL)`。 +2. `_next_id` 改为原子取号:`INSERT ... ON DUPLICATE KEY UPDATE next_id = LAST_INSERT_ID(next_id + 1)` + `SELECT LAST_INSERT_ID()`(MySQL 单语句原子,不依赖事务隔离级别)。 +3. 说明:会**断号**(事务回滚不归还),业务侧不存在"必须连续"的约束(订单号 `order_no` 另有一套生成规则),可接受。 + +**⚠️ 重要决策差异**:方案乙会**新增一张表**,从而让全库表数从 89 变成 90。上一轮文档审计刚刚把 `AGENTS.md`、`docs/02/05/08/09/28` 等处的口径统一为"89 业务表 = 场内 51 + 场外/推广 17 + 投顾 21",**方案乙会让这些文档全部重新失效**。这个隐藏成本必须计入排期。 + +**执行顺序约束** +建议 **A-3(行锁)→ A-2(发号器)**:加锁后并发冲突更容易被压测打出来,正好用来**验证**发号器修复是否真实生效;反过来先做 A-2 则缺少可复现的验证手段。 + +**风险与兼容性** +- 甲:`ALTER TABLE` 属 DDL,会重建表;`fin_transaction` 若有外键引用需确认 FK 行为;大表需低峰执行或走 gh-ost / pt-osc。 +- 甲:`AGENTS.md` 明令禁止修改既有列。本项属**修复与设计稿的偏差**(`docs/00` 的设计原意就是自增),需一次显式豁免并在 `AGENTS.md` 留痕。 +- 乙:如上,文档口径冲击。 +- 共有:若有单测依赖固定 id 值会失败;`fin_sim_order` 列表的分页依赖 id 单调性(自增仍满足)。 +- **回滚性**:方案甲一旦上线,回滚 = 反向 DDL;建议先备份、并在副本库演练。 + +**验证与回归** +- 迁移先在**数据库副本**上跑,前后各执行 `python tools/audit_schema.py` 与 `python tools/audit_constraints.py` 比对。 +- 并发:20 路并发下单(不同客户),断言无 `IntegrityError`、无重复 id、`order_no` 唯一。 +- 回归:重跑造数脚本 `python tools/seed_custom_holdings.py`、`python tools/portal.py` 等。 +- Alembic head 仍为单一(`python -m alembic heads`)。 + +--- + +### A-3 `submit_order` 读账户/持仓未加锁 —— 可突破持仓上限、可超卖成负 + +| 项目 | 内容 | +|---|---| +| **级别 / 批次** | **阻塞** / **必修** | +| **报告出处** | P0-3(确定性缺陷) | +| **工时** | 0.5-1 人天(含 gap lock 观察) | +| **可并行** | 否(与 A-1/A-2/A-5 同文件;且应排在 A-2 之前) | + +**问题描述** +`trade_service.py:297-298` 读取账户与持仓时无行锁。`Grep with_for_update` 在**整个 `trade_service.py` 零命中**。而 `app/service/offsite_fund_service.py` 的 `confirm_document` **是**用了行锁的——证明团队掌握这个模式,此处是遗漏。 + +**影响范围** +- **突破持仓上限**:客户已接近 `single_investor_max_holding_ratio`,并发提交两笔买入 → 两笔都读到旧的 `holding.total_quantity`、都判"未超限"、都通过。 +- **超卖**:持仓 1000 份,并发两笔各卖 1000 份 → 两笔都读到 `available_quantity=1000`、都通过 → `available_quantity` 变负。 +- 受影响表:`fin_holding`、`fin_sim_account`;间接影响后续所有持仓/风控判断(`risk_repository.py` 有 8 处以上 join `FundCustomerProfile`)。 + +**修改方案** + +1. **不要无条件给 `_load_account` / `_load_holding` 加 `with_for_update()`。** + 这两个私有方法除了写路径 `:297-298`,**还被只读端点调用**(`:686` 与 `:722`,账户看板相关)。无差别加锁会把行锁带到读路径,造成性能退化并引入死锁面。 + 改法 —— 加关键字参数,默认关闭: + ```python + async def _load_account(self, customer_id: int | str, *, for_update: bool = False) -> FundSimAccount: + stmt = select(FundSimAccount).where(FundSimAccount.customer_id == customer_id_int) + if for_update: + stmt = stmt.with_for_update() + ... + ``` + `_load_holding` 同理。**只有 `:297-298` 传 `for_update=True`。** + +2. **固定加锁顺序:先 `account`(按 customer_id),再 `holding`(按 customer_id + product_id)。** + 在 `_load_holding(..., for_update=True)` 的 docstring 里明确写"必须在 `_load_account(for_update=True)` 之后调用"。目前全仓只有此处同时持有两把锁,但 `_upsert_holding`(`:457`,内部 `:471` 还嵌着 `_next_id(FundHolding)`)与 `_reduce_holding`(`:425`)都在锁内执行,一旦将来出现反向路径必然互锁。 + +3. **好消息:check-then-act 本就在同一事务内,不需要结构性改动。** + `:312-328`(买入的资金/占比校验)与 `:333-337`(卖出的可用份额校验)都发生在 `:298` 之后、`:404-443` 扣减之前,且**唯一的 commit 在 `:445`**。加锁后它们天然落在同一把行锁的保护下。 + +4. **待确认的前置(N8)**:撤单端点 T005 若也会改动 `holding`(frozen/available),必须同样加锁,否则形成"下单持锁、撤单不持锁"的半边锁,A-3 等于没做。**这一条我在本方案撰写时未能确定**,已列入批次 0 的核实清单。 + +5. 建议在 `TradeService` 类顶部加一段注释,写明"**本服务的写路径必须遵循 账户 → 持仓 的加锁顺序**",防止后续新增方法重蹈覆辙。 + +**风险与兼容性** +- **gap lock 副作用(最值得警惕)**:在 MySQL `REPEATABLE READ` 下,`SELECT ... FOR UPDATE` 对**不存在的行**会加 gap lock。`_load_holding` 返回 `None`(客户首次买入某产品)时,并发的"不同客户首次买入同一产品"会互相阻塞。这是真实副作用,压测时必须观察。 + 若不可接受,替代方案:对"无持仓"分支改用 `SELECT id ... FOR UPDATE` 走唯一索引减少锁定范围,或改为 `INSERT ... ON DUPLICATE KEY UPDATE` 幂等建仓。 +- **死锁**:见上第 2 点。建议在测试环境临时开启 `innodb_print_all_deadlocks` 抓一次确认无 1213。 +- **同用户串行化**:`fin_sim_account` 行被锁住期间,同一用户的所有下单请求排队——这是资金安全的正确取舍,但叠加 A-1 后串行化程度更高,需要确认超时预算。 + +**验证与回归** +- 持仓上限用例:并发两笔买入,合计会突破占比 → 断言第 2 笔返回 422 `HOLDING_RATIO_EXCEEDED`,且**总持仓不超过上限**。 +- 超卖用例:持仓 1000,并发两笔各卖 1000 → 断言恰好 1 笔成功、1 笔 422 `INSUFFICIENT_HOLDING`,且 `available_quantity` 不为负。 +- 死锁探针:并发混合买/卖 200 次,断言无 `Deadlock detected (1213)`。 +- 回归 **T001 账户看板不得被加锁**:关键是断言 `:686` / `:722` 走的是 `for_update=False` 分支;可用 performance_schema 或 `SHOW ENGINE INNODB STATUS` 抽查读路径无锁等待。 +- **必须与 B-9(连接池)同批部署** —— 加锁延长了事务持有时长,不扩容连接池会直接把 5+10 打满(见 B-9)。 + +--- + +### A-4 访客令牌端点零认证、零限流 + +| 项目 | 内容 | +|---|---| +| **级别 / 批次** | **阻塞** / **必修** | +| **报告出处** | P0-4(确定性缺陷) | +| **工时** | 0.5 人天 | +| **可并行** | 是(与其它项零冲突) | + +**问题描述** +`app/api/controllers/visitor_tokens.py` 全文 14 行,`APIRouter` 定义(`:5`)**没有任何 `dependencies`**;签发函数无参数、无限流。 +对照:`app/api/controllers/auth.py:26-28` 给登录端点专门挂了 `enforce_login_rate_limit`,注释写明"这是全平台最需要限流的端点(密码爆破的入口)"。 + +**影响范围** +- 攻击者可毫秒级铸造海量有效 JWT,每个都能通过 `build_request_context`。 +- 关键放大点:`/api/v1/agent-runs` 的限流是**按 `user_id`** 计的,而访客 token 的 `sub` 每次都是**新随机值** → 限流被天然绕过。 +- 后果:免费消耗 LLM 额度 / 灌爆 `agent_run` 与 `conversation` 表 / 挤占 worker 队列。 + +**修改方案** + +1. **`app/api/dependencies/rate_limit.py` 新增 `enforce_visitor_token_rate_limit()`。** + §0.2-5 已说明:`enforce_login_rate_limit`(`:81-117`)就是所需模板。它为"签发前还没有身份"的场景设计,按 IP 计数、不依赖认证上下文——**照抄结构即可**: + ```python + VISITOR_WINDOW_SECONDS = 300 + VISITOR_MAX_ATTEMPTS = 20 + VISITOR_COUNTER_PREFIX = "visitor_token" + ``` + 阈值建议做成配置项而不是硬编码(加到 `app/core/config.py` 的 Settings),因为演示场景的真实用量只能在现场标定。 + +2. **`visitor_tokens.py:5` 的 router 加依赖**(挂在 router 而不是单个函数,与 `auth.py:28` 的做法一致,保证以后新增端点不会漏): + ```python + router = APIRouter( + prefix="/api/v1/visitor-tokens", tags=["visitor-tokens"], + dependencies=[Depends(enforce_visitor_token_rate_limit)], + ) + ``` + +3. **建议顺带做(成本低)**:给访客身份加可追溯维度。 + `VisitorTokenIssuer.issue()` 目前生成纯随机 `sub`。建议在 token payload 中加入**客户端 IP 的哈希**,使后续限流与审计能关联到同一来源。 + ⚠️ **不要把原始 IP 写进 JWT payload**:JWT 对客户端可读,且用户换网会导致校验失败。存哈希即可。 + +4. **若部署在反向代理后(重要)**:`rate_limit.py:98` 用 `request.client.host`,**不读 `X-Forwarded-For`**。这会退化为"按代理 IP 计数"。 + 本方案建议:**先在网关层做 IP 维度限流**,代码侧保持现状作为第二道。不要贸然在代码里读 `X-Forwarded-For`——那必须建立"只信任来自网关的连接"的机制,否则伪造一个头就能绕过(比现在更糟)。 + +**风险与兼容性** +- **误伤共享出口 IP**:公司 NAT、演示现场 WiFi 下多个真实用户会被合并计数 → 演示时有人领不到令牌。阈值必须在真实流量下标定,因此建议配置化。 +- **限流本身 fail-open**:`rate_limit.py:59-61` 与 `:104-106` 在 Redis 不可用时**放行**。Redis 挂了这道防线就消失。是否改为 fail-closed 属产品决策(对应 P2-16),本项不强行改;但建议至少让告警日志带上 IP 前缀,便于事后追溯。 +- `/internal/**` 健康检查端点不受影响。 + +**验证与回归** +- 脚本连打 30 次 → 第 21 次起 **429** + `Retry-After` 响应头 + `retryable=true`(`:87` 已有该机制)。 +- 停掉 Redis → 仍能签发(fail-open 为预期行为),日志出现"限流后端不可用"。 +- 回归:客服浮窗的**访客首访→首次问答**完整链路仍可通过;`python tools/e2e_smoke_test.py` 若含访客链路需重跑。 +- NAT 场景:同一 IP 连续 5 个真实用户,确认在第 20 次以内都能拿到令牌。 + +--- + +### A-5 卖出手续费可能超过成交额 —— 账户被倒扣(P1 → 上调阻塞) + +| 项目 | 内容 | +|---|---| +| **级别 / 批次** | **阻塞** / **必修**(**由报告 P1-5 上调**) | +| **报告出处** | P1-5 | +| **工时** | 0.5 人天 | +| **可并行** | 否(同文件,紧跟 A-3) | + +**为什么上调为阻塞** +报告把它放在 P1,但它的后果是**账户资金被倒扣**,属于资金错误而非显示瑕疵——与 P0-1(重复扣款)、P0-3(扣成负数)是同一类损害。而且在演示环境里,"卖 1 份低价产品"这种操作极易被触发。**从"资金正确性"这个维度看,它应当与 P0 同批。** + +**问题描述** +- `trade_service.py:280-284` `_compute_fee`:`if fee < rule.minimum_fee: fee = rule.minimum_fee` —— 应用最低手续费**无上界保护**。 +- `:329-332` `net_amount = gross_amount - fee_amount` —— 无正负检查。 +- `:416-422` 卖出分支直接 `account.cash_balance += net_amount` —— 负数也会被加上去。 + +**影响范围** +- `fin_sim_account.cash_balance` / `available_cash` 被扣减。 +- `fin_cash_ledger` 出现**负数**的"卖出回款"(`:424` `ledger_amount = net_amount`),与 `entry_type="卖出回款"`(`:423`)语义直接矛盾。 +- `balance_after`(`:436`)会记录一个"倒扣后"的余额,污染资金流水的可审计性。 + +**修改方案** + +1. **`_compute_fee` 加卖出语义的上界**: + ```python + def _compute_fee(self, gross: Decimal, rule: _FeeRule, *, order_side: str) -> Decimal: + fee = gross * rule.fee_rate + rule.fixed_fee + if fee < rule.minimum_fee: + fee = rule.minimum_fee + if order_side == "sell" and fee > gross: # 新增:卖出不得倒扣 + fee = gross + return fee.quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + ``` + 调用点 `:310` 同步补 `order_side=payload.order_side`。**买入不受影响**(买入本就是 `gross + fee`,不存在上界问题)。 + +2. **兜底断言**(即使第 1 步被绕过也要拦住):在 `:330-332` 计算 `net_amount` 之后加 + ```python + if net_amount < 0: + raise ValidationAgentError("本次卖出不足以支付手续费,无法成交") + ``` + +3. **事前拒绝 + 事后兜底,两者都要**。第 2 步保证了"绝不会倒扣",但没有可读提示。建议在卖出校验链里再补一条**明确的拒绝理由**: + `if gross_amount <= fee_rule.minimum_fee: raise ValidationAgentError(f"卖出金额 {gross_amount} 元低于最低手续费 {rule.minimum_fee} 元,无法成交")`。 + +4. **需要跟业务确认(不阻塞本项)**:`fin_fee_rule.minimum_fee` 若确实是 5 元,那么"卖 1 份"到底应该是**拒绝交易**,还是这笔业务的正常行为就是如此?两种都合理,但"不得倒扣"是确定的技术底线,无论业务如何决定都要做。 + +**风险与兼容性** +- 原本"能提交但倒扣"的请求会变成 422。需确认前端 `dashboard.js:91-97` 的**失败分支**也会像成功分支那样把 message 显示出来(成功分支用的是 `alert.textContent = ...`;失败分支路径在本次撰写时未验证,列入 N5)。 +- **历史脏数据不会被自动修复**。若库中已有负余额或负回款,需要一次性对账与冲正脚本,**不在本方案工作量内**,请单独排。 + +**验证与回归** +- 用例:`minimum_fee = 5`,卖出成交额 `1.00` → 断言 **422 且事务完全回滚**(`fin_sim_order` / `fin_transaction` / `fin_cash_ledger` 零新增)。 +- 用例:`minimum_fee = 0`,极小成交额 → `fee` 正常、`net_amount > 0`。 +- 不变性断言(建议加入常规测试):**`fin_cash_ledger.balance_after` 必须始终等于同笔操作后的 `fin_sim_account.cash_balance`**。这条断言能同时守护 A-1(重复扣款)与 A-5。 +- 回归:正常买入/卖出的金额计算不受影响(买入尤其不能引入上界逻辑)。 + +--- + +### A-6 `check_compliance` 死代码 + `governance` docstring 与代码相反 + +| 项目 | 内容 | +|---|---| +| **级别 / 批次** | **阻塞** / **必修**(因其属合规门禁类失效) | +| **报告出处** | P1-1(确定性缺陷) | +| **工时** | 0.5 人天 | +| **可并行** | 是(唯一约束:删除前需 N3 结论) | + +**问题描述**(三件事叠在一起,缺一件等于没修) +1. `app/service/agent/base.py:162-165` 定义 `check_compliance`,但它调用 `governance.review(result, context, self.config, self.memories)` 时**没有传 `agent_type`**。 +2. 真实链路 `base.py:114-116` 的 `execute()` 直接调 `governance.review(..., agent_type=self.definition.agent_type)`,**绕过了 `check_compliance`** → 全仓零生产调用点(死代码)。 +3. 一旦有人按方法名的语义调用它:`governance.py:219` `customer_facing = agent_type in CUSTOMER_FACING_AGENT_TYPES` → 空串得 `False` → `:281` **跳过追加免责声明** → F5 合规红线静默失效。 +4. **其危险之处在于**:`governance.py:211-212` 的 docstring 写着"空串按'调用方未声明'处理,**保守照旧追加**,避免漏加"——**与实际行为完全相反**。维护者读完会以为"忘记传 agent_type 是安全的"。 + +**影响范围** +- 合规红线 F5(面向客户输出必须 100% 附固定免责话术)。当前**尚未造成事故**(因为是死代码),但若未来接线就会静默失效。 +- 更大的危害是那条反向 docstring 正在**主动误导**。 + +**修改方案**(必须一起做的三件事) + +1. **修正 docstring** `governance.py:211-212`,使其描述与 `:219` / `:281` 的实际行为一致(空串 = **不**追加),并明确写出"调用方**必须**传 `agent_type`"。 + +2. **让"漏传"当场失败而不是静默降级(治本,推荐)**: + 在 `review()` 入口加 `if not agent_type: raise ValueError("review() 必须显式传入 agent_type")`。 + 与其把它降级成"不加免责声明",不如让它炸掉。已知调用点 `base.py:114` 已正确传参,安全。 + +3. **处理死代码本体**: + - **推荐甲:删除** `check_compliance`,并从 `base.py:88-92` 的"禁止覆写名单"中移除;同步更新两处测试——`tests/unit/service/test_agent_governance.py:20`(断言它不可覆写)与 `tests/unit/service/test_customer_service_agent.py:170`(清单)。 + - 乙(不推荐):改为传 `agent_type=self.definition.agent_type`。但这会形成"两处都能走合规"的分支结构,是新的混乱源。 + - ⚠️ **前置(N3)**:`docs/09:124` 把 `check_compliance` 列为"业务代码不得覆盖"的公开 API。删除前必须确认仓库外无调用者,并**在同一 PR 内**先在 `docs/09` 显式作废该承诺。 + +4. **补契约测试防回归**:断言 `BaseAgent` **不存在** `check_compliance` 属性;断言 `governance.review` 在缺 `agent_type` 时抛错而非静默跳过。 + +**风险与兼容性** +- 删除公共方法属 **API 破坏**,且与 `docs/09` 的既有承诺冲突 → 必须文档先行、同 PR 完成。 +- 若 `review()` 改为必填参数,任何遗漏的调用点会在**启动时**炸(这正是想要的),但要确保测试替身与自定义 Agent 都被覆盖到位。 +- `docs/06:307` 把"接线 `check_compliance`"列为 P1 欠债——本项完成后,该条目应改为"已删除,理由见审查报告"。 + +**验证与回归** +- `Grep check_compliance` → 应仅剩测试中的"不存在性断言"。 +- 端到端:客服回答尾部**必须**含固定免责话术;临时把某 Agent 的 `agent_type` 改成非客户面向值,断言话术不再追加(证明门禁真的在工作,而不是恒真)。 +- 风控 Agent(`INTERNAL_AGENT_TYPES = {"risk"}`,`governance.py:56`)行为不变。 +- `tests/unit/service/test_customer_service_agent.py` 与 `test_agent_governance.py` 全绿。 + +--- + +## 二、B 档|重要(本次应修) + +> 以下每项按「问题描述 — 影响范围 — 修改方案 — 验证与回归」组织,篇幅较 A 档精简。 + +--- + +### B-1 异步路径中调用同步 Milvus 客户端 —— 阻塞事件循环 +**P1-2 | 重要 | 应修 | 1-1.5 人天 | 可与大部份项并行** + +**问题描述**:4 处在 `async def` 里直接调用同步 `MilvusClient`:`memory_recall_service.py:175`、`knowledge_search_service.py:207-213/224/229`、`knowledge_schema.py:205`、`projection_cleanup_service.py:115/123/127`。 + +**影响范围**:一次 Milvus 慢查询会**冻结整个 FastAPI 进程**(含所有协程与 worker 心跳,心跳缺失可能被误判 lease 丢失)。客服 `search_knowledge` 工具超时是 10s,期间进程完全无响应。 + +**修改方案**——**分两类处置,不能一刀切**(这是我复核出的关键差别): + +| 类别 | 涉及位置 | 改法 | +|---|---|---| +| **知识检索类** | `knowledge_search_service.py`、`knowledge_schema.py` | 直接换成已存在的异步实现 `app/infrastructure/milvus_adapter.py MilvusKnowledgeClient`(内含 `AsyncMilvusClient`,`await client.search(...)`,白名单校验,失败转 `RecoverableAgentError`)。⚠️ **不是改一行**:它有 `ALLOWED_COLLECTIONS` 白名单(`:72`)与 `MIN/MAX_TOP_K`(`:35-36`),接口形态与同步 `MilvusClient.search` 不同 | +| **记忆召回 / 投影清理类** | `memory_recall_service.py:175`、`projection_cleanup_service.py` | 这两处用的是本地 `MilvusClient` 直连 `settings.milvus_uri` 的**记忆集合**(不是知识集合),且需要 `list_collections`(不确定 `AsyncMilvusClient` 有无异步等价方法)。稳妥改法:`await asyncio.to_thread(client.search, ...)` + 外层 `asyncio.timeout(n)`,与项目既有规范一致(参照 `fund_market_adapter.py:273/382`、`offsite_fund_service.py:1542`) | + +**前置(N6)**:`app/service/knowledge_retrieval_service.py` 用的**正是正确的 `await self.client.search(...)`**,但全仓无生产实例化点(仅单测引用)。**它很可能就是为此准备的替代实现却从未接线**——先确认,若是则直接接线而非重写。 + +**风险与兼容性**: +- 超时后**不能**返回空结果——那会把"向量库挂了"伪装成"知识库没有内容"。必须抛 `RecoverableAgentError` 走降级(该模块 docstring 明确要求)。 +- `asyncio.to_thread` 默认线程池有上限,Milvus 持续慢时会占满。建议配超时 + 观察是否需要独立线程池。 +- 语义召回结果必须保持既有行为(合并顺序、`confidence = min(1.0, |score| * 0.7)` 不得漂移)。 + +**验证与回归**: +- 打桩注入 3 秒延迟到 search 路径,用另一并发协程测量 P99 延迟不受影响。 +- Milvus 不可用时,客服问答仍返回(降级到 MySQL LIKE),且不出现"知识库无内容"的假话术。 +- `python tools/e2e_smoke_test.py`;记忆召回结果与改动前逐条比对(Top-K 顺序与 score)。 + +--- + +### B-2 Milvus 客户端从不关闭 + 无 shutdown 钩子 +**P1-3 | 重要 | 应修 | 0.5-1 人天 | 应与 B-11 同做** + +**问题描述**:`bootstrap.py:117-129` 用 `@lru_cache(maxsize=1)` 持有一个进程级永生的 `MilvusClient`;`projection_cleanup_service.py:115-127` 每次调用新建局部变量且**从不 `close()`**。已确认 `app/main.py:56` 的 `FastAPI(title=..., version=...)` **没有传 `lifespan`**(`Grep lifespan` 在 `main.py` 无命中)。 + +**影响范围**:批量记忆清理触发 `_cleanup_vector` 100 次 → 100 个未关闭的 `MilvusClient` 与底层 gRPC 通道泄漏。`MilvusKnowledgeClient.close()` / `aclose()`(`milvus_adapter.py:124-128`)**有定义但全仓无调用点**。 + +**修改方案**: +1. `app/main.py:54-56` 引入 lifespan(注意 `app = create_app()` 在模块级,`lifespan` 需在 `create_app` 之前定义或写成内部闭包): + ```python + @asynccontextmanager + async def lifespan(app: FastAPI): + yield + try: + await shutdown_all() + except Exception: + logger.warning("shutdown 失败", exc_info=True) + application = FastAPI(..., lifespan=lifespan) + ``` +2. 新增 `app/infrastructure/shutdown.py` 统一收集需释放的单例:`get_vector_memory_adapter`(需 `cache_clear()`)、`default_counter_backend`、`build_graph_driver`、`MilvusKnowledgeClient` 实例。 +3. `projection_cleanup_service._cleanup_vector` 改 `try/finally: client.close()`,**优先方案是改为复用 `bootstrap` 的单例**,不再每次新建——同时解决了 P1-2 与 P1-3。 +4. **`default_counter_backend` 必须一并纳入**(见 B-11),否则只修一半。 + +**风险与兼容性**:`yield` 之后的 shutdown 代码要用 try/except 兜住,自身异常不能掩盖业务异常;测试里 `create_app()` 会被复用,需确保 shutdown 内部容错、**不会让单测尝试连接真实 Milvus**。 + +**验证与回归**:单测断言 `await client.close()` 后 `_client is None`;批量跑 100 次记忆清理后观察进程连接数不增长;加一条 `app.main` 的 lifespan 单测。 + +--- + +### B-3 依赖声明双源漂移 +**P1-4 | 重要 | 应修 | 0.25 人天(+0.25 写校验) | 完全独立,可并行** + +**问题描述**:`requirements.txt:1` 自称 source of truth 是 `pyproject.toml`,但:`:23` 的 `openai>=1.0,<2` 在 `pyproject.toml:10-39` **完全不存在**;`aiosqlite` 在 `pyproject.toml:45` 与 `:51` **重复声明**两次;`:54-59` 把 dev 依赖混进运行时文件。 + +**影响范围**:用 `requirements.txt` 装的环境会多一个无人使用的 `openai`(`Grep "import openai"` / `from openai` 全仓零命中);两文件将持续漂移,最终导致"本地能跑、线上不能跑"。 + +**修改方案**: +1. 由 `pyproject.toml` 重新生成:`pip-compile` 或 `uv export --no-hashes -o requirements.txt`。 +2. 短期手动版:删 `openai`、去重 `aiosqlite`、把 dev 依赖拆到 `requirements-dev.txt`。 +3. 加一条校验脚本(放 `tools/`)断言两文件依赖集合一致,挂到 CI 或 pre-commit。 +4. 顺手处理 P2-17:`.workdir/zsy_v2/z_pyproject.toml:24` 那份把**已被明确否决**的 `milvus-lite` 列进 runtime 的陈旧副本(主 `pyproject.toml:52-56` 注释说明刻意排除)——删除或加 DEPRECATED 标记。 + +**风险与兼容性(N7)**:删 `openai` 前再确认一次是否存在**字符串形式的动态导入**(`importlib.import_module("openai")` 会被 `Grep "import openai"` 漏掉)。若有则需保留。建议在干净环境完整安装验证后再合。 + +**验证与回归**:新建虚拟环境 `pip install -r requirements.txt` → 服务可起;`python tools/e2e_smoke_test.py` 通过;新校验脚本对新旧两文件都跑一遍。 + +--- + +### B-4 `_distribution` 排序用 `str(key)` 却用原 `key` 索引 +**P1-6 | 重要 | 应修 | 0.25 人天 | 完全独立,可并行** + +**问题描述**:`app/service/risk_daily_report_service.py:363` 把键转成 `str` 排序,`:367` 却用**原 key** 索引 `counts`。 + +**影响范围**:若 `risk_level` 被存为整数 `1`(不在 `preferred_order` 的 `("高","中","低")` 中),`keys` 追加 `"1"`,随后 `counts["1"]` → **`KeyError`** → 日报生成整体 500。 + +**修改方案**: +```python +# 原:keys.extend(sorted(str(key) for key in counts if key not in preferred_order)) +keys.extend(sorted((k for k in counts if k not in preferred_order), key=str)) +``` +保留原键,只在排序环节取 `str`。同时把 `:330` 的 `name` 展示统一包一层 `str(key)`,避免出现 `1风险` 这类奇怪字面量(虽然不报错)。 + +**额外确认**:检查 `preferred_order` 的元素类型与 `counts` 的键类型是否一致。若不一致,`key in counts` 恒为 False、永远走 extend 分支——本修复能让它不崩,但排序会退化。 + +**风险与兼容性**:极低,纯局部修复。 + +**验证与回归**:构造 `items=[{"risk_level": 1}]` 的单元断言不抛 `KeyError`、`name == "1风险"`;手工生成一次日报;确认正常数据(高/中/低)输出与改前逐字段相同。 + +--- + +### B-5 NL2SQL 结果无截断标记 +**P1-7 | 重要 | 应修 | 0.5 人天(含文档) | 完全独立,可并行** + +**问题描述**:`app/service/financial_nl2sql_service.py:329` 直接拼 `LIMIT {plan.limit}`;`:261-265` 把 `total` 设为 `len(rows)`,**没有 `truncated` 标志**。 + +**影响范围**:`limit` 默认 50。当查询**恰好**返回 50 行时,调用方无法区分"真只有 50 行"与"被 LIMIT 截断"。风控/投顾据此统计(如"共 50 笔大额交易")会**系统性低估**。 + +**修改方案**——照抄项目已有样板 `risk_repository._capped` 的 `limit + 1` 手法: +1. `:329` → `return f"{sql} LIMIT {plan.limit + 1}", params`。安全前提不变:`plan.limit` 由 Pydantic `ge=1, le=200`(`nl2sql_contracts.py:14/:37`)兜住,插值仍是整数。 +2. `:261-265` 改为: + ```python + rows = await self._execute(sql, params) + truncated = len(rows) > plan.limit + rows = rows[: plan.limit] + result["data"] = {"total": len(rows), "rows": rows, "truncated": truncated, "limit": plan.limit} + ``` +3. `_result(...)` 第 7 个参数的 row count 要在切片**之后**再传(`:263` 位置)。 +4. `dry_run` 分支(`:256-260`)会向用户展示 `LIMIT 51`,是个小困惑点——建议在 dry_run 里单独拼回 `plan.limit` 展示。 + +**风险与兼容性**:`truncated` 是**纯增量字段**,旧客户端忽略即可;但若下游有严格 schema 校验需先升级。需同步 `docs/演示用/后端接口文档` 中 NL2SQL 相关章节。 + +**验证与回归**:构造恰好 200 行 → `truncated=true`、`len(rows)==200`;199 行 → `truncated=false`;显式传 `limit=5` 而总行数 10 → `truncated=true, len==5`;`GROUP BY` 与无 `GROUP BY` 两种形态都要覆盖。 + +--- + +### B-6 接口用裸 `int(cursor)`,绕过 `parse_cursor` 的契约校验 +**P1-9 | 重要 | 应修 | 0.25 人天 + 前端排查 0.25 | 可并行** + +**问题描述**:`app/api/controllers/trading.py:81`、`:135`、`:163` 三处是 `cursor_id = int(cursor) if cursor else None`。 + +**影响范围**:`?cursor=abc` → `int()` 抛 `ValueError`,未被包装成 400 `INVALID_CURSOR`,很可能冒泡成 **500**。`?cursor=0` / 负数不报错但查询 `id < 0` 返回空列表,客户端误以为"翻到底"(静默数据丢失)。 + +**修改方案**:统一改用 `app/core/cursor.py:32` 已有的 `parse_cursor(cursor, field="cursor")`。它已实现:拒绝非 ASCII / 非十进制(`+1`、`1.0`、`0x10`、`1e3`、Unicode 数字)、拒绝 `< 1` 与 `> 2**63-1`、空串按 None、且**错误消息不回显客户端原值**(避免日志投毒)。 + +**前置(N5)**:确认这三个端点的 cursor 语义确实是 `core/cursor.py` docstring 所说的"记录 ID 边界";若某处是时间戳游标则不能套用。 + +**风险与兼容性**:`?cursor=0` 从"返回空列表"变成 **400** —— 若有客户端把 0 当"首页",会被打断。**必须扫一遍前端**(`app/static/portal/**`)确认没有传 0 或负数。 + +**验证与回归**:`?cursor=abc` → 400 `INVALID_CURSOR`;`?cursor=0` → 400;`?cursor=99999999999999999999` → 400;正常翻页无 off-by-one(`limit+1` 探测已由既有的 `core/cursor.py` 保证)。 + +--- + +### B-7 场外规则:`total_fund_shares` 只拦 `== 0` 不拦负数 +**P1-10 | 重要 | 应修 | 0.25 人天 | 完全独立,可并行** + +**问题描述**:`app/service/offsite_fund_rules.py:83-84` 只判断 `total_fund_shares == 0`,因此**负数能通过**。随后 `:95` `ratio > TWENTY_PERCENT`(ratio 为负 → 恒"正常")与 `:107-111` `current_shares > limit`(limit 为负 → 恒"异常")给出**相反结论**,且都不报"数据异常"。 + +**影响范围**:同一份脏数据在"申购后持有比例"和"申购单笔份额上限"两条规则上结论相反——审核人员会看到自相矛盾的校验结果,且无从判断是数据问题。 + +**修改方案**:`:84` 的 `total_fund_shares == 0` 改为 `<= 0`。同时**核对该类所有用它做分母或比较的位置**(`:91/:95/:107/:111`)是否都在这一处 early-return 的保护范围内——从代码看是同一段,改一处即可,但赎回分支要一并确认。 + +**风险与兼容性**:会让原本"给结论"的脏数据变成"**无法判断**"。需确认下游(场外 OCR 单据校验流程)能接受该枚举——规则引擎本身已有 `正常/异常/无法判断` 三态,且用于缺失字段场景,应当可接受。 + +**验证与回归**:单测 `total_fund_shares = -1000` → 四条相关规则全为"无法判断",且**两条规则不再给出相反结论**(这是判定修复成功的关键断言);`= 0` 的行为与改前一致。 + +--- + +### B-8 `data_scope` 全局最高范围 vs 逐权限口径并存 +**P1-8 | 重要 | 应修(取决于核实) | 核实 0.5 + 改造 1 | 必须最先核实** + +**问题描述**:`app/repository/identity_repository.py:33-55` 把 `data_scope` 设为用户**所有权限中的最高范围**。项目内两种口径并存:`public_platform_service.py:276` 用 `context.permission_scopes.get(permission)`(逐权限,正确样板);而 `risk_evidence_archive_service._scope_condition`、`financial_nl2sql_service.py:419` 用**全局** `data_scope`。 + +**⚠️ 关键——不要按字面去改 `identity_repository.py:55`**(§0.2-3): +该行上面的 `:45-54` 注释记录了这是一次**已修复的真实事故**的产物:原先写死 `"self"`,结果导致 **9002 (`risk_operator`) 与 9003 (`admin`) 拿着 all 级权限却什么都查不到**。若把 `data_scope` 改回逐权限口径,会**原样复现那个事故**。它被 `risk_query_service.py:198`、`risk_analysis_service.py:126`、`risk_evidence_archive_service.py:218`、`risk_action_service.py:212` 依赖。 + +**正确改法**:**保留 `data_scope` 的计算逻辑不动**,只在需要按单权限判定的调用方改用 `context.permission_scopes[<本次校验的权限码>]`。 + +**前置(决定本项工作量)**:先做报告 §8-1 的核实——读 `tools/seed_test_rbac.py` 与 `sys_role_permission` 实际内容,逐角色确认是否真的存在"A 权限 scope=all + B 权限 scope=self"的组合。 +- **若无此组合**:本项从"重要"**降为"建议"**——只需在 `identity_repository.py:55` 上方补一句注释("全局 `data_scope` 不得用于单权限判定,请改用 `permission_scopes[code]`"),工作量从 1 天降到 **0.1 天**。 +- **若存在**:按上述方向改造两个调用方。 + +**风险与兼容性**:改 filter 逻辑可能让某些角色突然查不到数据(收紧)或看到更多(放松),两者都是事故。**必须逐角色出前后对比报告**。 + +**验证与回归**:对每个角色跑同一组查询,改动前后结果集 diff 必须为空(除有意收紧的用例外);重点覆盖 `own_customers` 这一档(最容易出现"子集判断"错误)。 + +--- + +### B-9 连接池未显式配置(**P2-6 上调为重要**) +**P2-6 | 重要 | 应修 | 0.25 人天 | 必须与 A-3 同批部署** + +**问题描述**:`app/infrastructure/db.py:34` 是 `create_async_engine(_url, pool_pre_ping=True)`,**没有** `pool_size` / `max_overflow` / `pool_timeout` / `pool_recycle` → 使用默认 5 + 10。 + +**为什么上调**: +一次 Agent 运行可有数十次模型 + 工具调用;**A-3(行锁)与 A-1(幂等 `with_for_update`)会显著延长事务持有时长**。锁等待时间变长 → 单请求占用连接的时间变长 → 5+10 必然打满,报 `TimeoutError: QueuePool limit of size 5 overflow 10 reached`。 +**这一项不能与 A-3 分开排期**,否则 A-3 上线当天就会出现大面积连接池超时,且很容易被误判为"锁有问题"。 + +**修改方案**:显式配置 `pool_size=20, max_overflow=10, pool_timeout=30, pool_recycle=1800`(数值需按真实并发标定;`pool_recycle` 必须小于 MySQL 的 `wait_timeout`)。**不要**用 `NullPool`(会丢掉 `pool_pre_ping` 的重连价值)。 + +**风险与兼容性**:连接数 × worker 副本数会撞 MySQL `max_connections`,需事先核算。 + +**验证与回归**:并发 50 路下单,断言无连接池超时;`SHOW PROCESSLIST` 观察峰值连接数落在预算内。 + +--- + +### B-10 重试无退避、无 jitter(**P2-8 上调为重要**) +**P2-8 | 重要 | 应修 | 0.5 人天 | 可并行** + +**问题描述**:`app/service/model_gateway.py:271-291` 与 `app/worker/risk_scan_scheduler.py:117-152` 的重试是纯 `for _ in range(n)`,**无 sleep、无 jitter**。对照 `app/worker/runtime.py:901-904` **是有指数退避的**。 + +**为什么上调**:与 B-9 同理——A-2 / A-3 上线后并发写路径变多,上游短暂不可用时,无退避重试会在毫秒级耗尽全部尝试;多副本还会形成惊群,把"短暂 503"放大成"全面雪崩"。 + +**修改方案**:抽一个公用的 `async_retry(attempts, base_delay, jitter, retry_on)` 放到 `app/core/retry.py`,两处接入。加 jitter 防多副本同步重试。 + +**风险与兼容性**: +- 加了退避后,上游持续失败时的**总耗时显著变长**(原本毫秒级耗尽,现在可能几十秒)→ **可能撞上层超时**。必须同步检查调用方的 timeout 预算。 +- ⚠️ **重试不得重放非幂等写**。模型生成类调用是否幂等需确认——若不幂等,只能对**连接类错误**重试,不能对"已发出但未收到响应"重试。这一点要在实现时明确。 + +**验证与回归**:注入持续失败的上游,断言总耗时在新预算内、最终抛出明确错误;单副本与多副本两种情况下都不再出现"同时重试"的尖峰。 + +--- + +### B-11 每次调用新建 Redis 客户端,懒加载缓存形同虚设 +**P2-5 | 重要 | 应修 | 0.5 人天 | 应与 B-2 同做** + +**问题描述**:`app/worker/runtime.py:503-516` 的 `_redis_client` **每次调用都新建**客户端;`app/infrastructure/rate_limiter.py:92-96` 的懒加载缓存**挂在每次新建的对象上** → 缓存失效,每请求一次握手。 + +**影响范围**:Redis 连接数与握手开销随请求量线性增长;批次跑满时可能耗尽 Redis `maxclients`。 + +**修改方案**:`_redis_client` 改为模块级单例,并纳入 B-2 的 `shutdown_all()` 释放路径;`RedisCounterBackend` 改由 `default_counter_backend()` 单例持有,保留 `rate_limit.py:34` 的 `get_counter_backend()` 作为测试注入点(不要破坏替身机制)。 + +**风险与兼容性**:单例在 Redis 重启后会持有坏连接——需要 `ping` 探测或依赖驱动自带重连;务必保留测试替身注入钩子。 + +**验证与回归**:1000 次请求后观察 Redis `INFO clients` 的连接数不增长;重启 Redis 后能自动恢复,不会出现"永久不可用"。 + +--- + +### B-12 `_permission_error` 样板 ×17,绕过统一鉴权与审计 +**P2-4 | 重要 | 应修 | 0.5-1 人天 | 可并行** + +**问题描述**:`app/service/offsite_fund_service.py` 有 **17 处**自造的 `_permission_error`(`:144/188/217/262/527/676/767/858/1029/1093/1158/1405/1502/1848/1881/2114/2168`),不走统一的 `AuthorizationService.require`(后者带统一审计)→ **审计口径不统一**。 + +**改法(刻意保守)**:该类是 2820 行的 God Class(P2-3),一次性重构风险极高。**本轮不做整体重构**——只修改 `_permission_error` 的**内部实现**,让它调用 `AuthorizationService.require` 并写审计,对外保持原有的 403/404 语义与消息文本不变。这样**零调用点改动**即可统一审计口径。 + +**风险与兼容性**:若 `AuthorizationService.require` 的异常类型/消息与 `_permission_error` 不同,17 处的所有 403/404 断言都会变。稳妥做法是在 `_permission_error` 内部 try/except 后**转回原异常类型**。 + +**验证与回归**:17 条路径逐条触发一次未授权,断言**响应体与改前逐字节相同**,同时审计表新增了记录。 + +--- + +## 三、C 档|建议修复 / 可延后 + +> 以下项目不影响资金正确性与合规门禁,可在 A/B 档完成后按迭代排期处理。 + +| # | 位置 | 问题 | 建议改法 | 工时 | 可延后理由 | +|---|---|---|---|---|---| +| C-1 | `app/service/agent/base.py:145-149` | `classify_intent` 依赖缺失时**静默返回 `None`**,与"该 Agent 不需要分类"无法区分 | 装配缺失时抛 `RecoverableAgentError`;"不需要分类"改为显式 sentinel | 0.25 | 当前装配正确,属防御性加固 | +| C-2 | `app/core/errors.py:74-165` | 错误码**有意复用**(`SESSION_NOT_FOUND`×4 等),前端难区分"用户非法操作"与"Worker 租约丢失" | **不要拆码**(为对齐 `docs/05 §3.6` 的刻意设计,有 AST 测试守着);改法是把区分信息放进 `field_errors` 或新增 `detail` 字段 | 0.5 | 属语义表达改进,非缺陷;且改动面覆盖全部客户端 | +| C-3 | `app/service/offsite_fund_service.py:123` | **God Class**:2820 行 / 60+ 方法 | 按职责拆邮件 / 识别 / NL2SQL / 规则 / 通知 / 统计六个服务,**每次只搬一个域**,每次都保持外层 API 不变 | 3-5 | 纯技术债;B-12 已在不重构的前提下解决审计口径问题 | +| C-4 | `app/service/model_gateway.py:103-123,174-200,225-246` | 每次模型调用新建 httpx 客户端并关闭(丢 keep-alive) | 复用 `AsyncClient` 并在 lifespan(B-2)里释放 | 0.5 | 性能而非正确性 | +| C-5 | `app/service/model_gateway.py:238-239` | 连续两行 `return endpoints`,第二行不可达 | 删除 | 0.05 | 死代码,无运行时影响 | +| C-6 | `app/service/risk_daily_report_service.py`、`app/repository/agent_run_repository.py` | `SELECT` 后再 `INSERT/UPDATE` 无行锁(TOCTOU) | 参照 `runtime.py:_failure`(**已**正确加了 `with_for_update()`)补锁 | 0.5 | 竞争窗口窄、当前量级难触发;建议随 A-3 的锁改造一起做 | +| C-7 | `app/service/offsite_fund_rules.py:170` | "最低申购金额"**硬编码 `<= 1 元`**,而产品表有 `min_amount` 列未被使用 | **先跟业务确认口径**(报告 §8-2 疑点):是读 `fin_product.min_amount` 还是维持 1 元 | 0.25 | 业务口径未定,改错方向比不改更糟 | +| C-8 | `app/service/risk_judgement_service.py:161-178` | 距阈值仅差一丝(499999.99 / 0.7999)也判"疑似误报 + 高置信",直接引导专员关闭预警 | 引入相对误差容差带(`abs(v-threshold)/threshold < eps` 时判"边界待核"、置信度下调) | 0.5 | 需业务给出 eps 口径 | +| C-9 | `offsite_fund_service.py:2374` / `promotion_performance.py:133`;`profile_graph_projection_service.py:267` / `offsite_fund_service.py:2643` | `_json_safe` / `_text` 在两处重复定义 | 抽到 `app/core/` 的公共模块 | 0.25 | 重复但行为一致 | +| C-10 | `app/service/agent/implementations/customer_service.py:148-165` | `HIGH_SCORE=0.75` / `MID_SCORE=0.55` / `MIN_GAP=0.07` / `TOP_K=5` 调参阈值硬编码(注释自述"gap 从 0.090 掉到 0.076,几乎跌破 0.07") | 移到配置中心或 Settings | 0.5 | 每次调参要发版,属效率问题 | +| C-11 | `app/core/customer_service_rules.py:11` | docstring 说走 `query_knowledge`,实际登录客户走 `search_knowledge` | **改 docstring**(注意:该类不一致已造成过真实事故——`docs/40` 记录发布配置只发 `search_knowledge` → 访客工具白名单抛 `ForbiddenAgentError`) | 0.1 | 纯文档,但强烈建议尽快(有前科) | +| C-12 | `app/api/controllers/visitor_tokens.py` + `app/api/dependencies/auth.py:26` | 限流在所有端点 **fail-open** | 对登录/访客签发这类敏感端点改 fail-closed(**A-4 完成后**再评估) | 0.5 | 产品取舍;改动会让 Redis 故障直接演变为全站不可用 | +| C-13 | `app/service/knowledge_publication_service.py:153-155` | `except Exception: pass` **真静默**(全仓唯一一处) | 至少 `logger.warning(..., exc_info=True)` | 0.05 | 极低危但成本也极低,建议随手做 | +| C-14 | `app/service/profile_assembly_service.py:64-69` | `_fact_id()` 用微秒时间戳做主键(float→int 精度损失;循环内批量调用同一微秒会碰撞),docstring 断言"不可能发生" | 改计数器 + 时间戳组合,或直接引 DB 序列 | 0.25 | 画像路径,非资金;建议在 A-2 的发号器方案确定后顺带统一 | +| C-15 | `app/service/trade_service.py:200-205` | `raise ... from None`(全仓唯一一处)丢弃根因,且恰在交易拒绝路径上 | 改 `raise ... from exc` 或保留原异常上下文 | 0.05 | 排障成本;应与 A-3 同批顺手做 | +| C-16 | `app/service/trade_service.py:341-342` | `order_no` 用秒级时间戳 + 8 位 hex,无 DB 唯一约束兜底 | 加唯一索引 + 冲突重试(或沿用 A-2 的发号器) | 0.25 | uuid4 段已足够随机,实际碰撞概率极低 | +| C-17 | `app/service/offsite_fund_service.py:2527-2532` | `_next_mail_id()` 用"当日 `count()+1`"发号,超 999 后 `:03d` 格式错位 | 改不带宽度限制的编号 + 唯一约束 | 0.25 | 需日发 >999 封才暴露 | +| C-18 | `app/service/trade_service.py:142-143` | `MAX_QUOTE_AGE` 用 `>` 严格比较(恰好 15:00 放行);`source_updated_at` 若为**未来时间**(时钟偏差)差值为负 → 永远放行 | 改为 `>=`,并对负差值显式拒绝 | 0.1 | 边界瑕疵;时钟偏差罕见但确实存在 | +| C-19 | `app/service/risk_natural_language.py:89-96` | "今天"的上界是当前时刻而非当日 24:00;`time.max` 在 MySQL `fsp=0` 列上可能因舍入归入次日 | 上界改用当日 23:59:59 显式构造 | 0.25 | 边界瑕疵 | +| C-20 | `app/worker/runtime.py:794-796` | 租约续期失败的告警**缺 `exc_info=True`**,真实原因不可见 | 补上 | 0.05 | 可观测性 | +| C-21 | `app/api/middleware.py:29` | `X-Trace-ID` 完全由客户端提供并原样回显,无格式校验 → 日志投毒 / 追踪串扰 | 加长度 + 字符集白名单校验,非法则忽略而非回显 | 0.1 | 建议做;与 A-4 的滥用场景同属一类 | +| C-22 | `app/core/risk_cursor.py:16-17` | 游标指纹用**无密钥** SHA-256 | 换 HMAC(带服务端密钥) | 0.25 | docstring 已诚实自述"防无意复用、不防篡改";服务端仍有 `user_id` 过滤,越权受限 | +| C-23 | `app/service/agent/implementations/risk_agent.py:342-373`;`customer_service.py:189/551` | **提示注入面**:用户文本(含正则提取的 `alert_no`)直接 f-string 进 system prompt,无分隔符/转义包裹 | 用户输入加标签包裹 + 显式指令隔离。**注意**:当前缓解靠"工具权限由 `ToolExecutor` 独立校验、不信任模型",未见可直接越权链路 | 1 | 属提升面而非已知漏洞;需要整体 prompt 回归 | +| C-24 | `app/service/agent/implementations/customer_service.py:464-465` | `_product_risk_level` 把"工具异常""向量库降级""知识库确实无此条"**统一压成 `None`**,用户看到同一句话术 | 区分三态,至少让"库故障"与"无此条"话术不同(同文件 `:300`/`:408` 是正确降级,照抄) | 0.25 | 体验问题 | +| C-25 | `app/worker/graph_projection_worker.py` | `processed_event_ids` 是无界内存 `set`,长期运行持续增长 | 改 LRU / 按轮次清空 | 0.25 | 需长期运行才暴露 | +| C-26 | `app/worker/runtime.py:77,102-107` | 装配关键路径用 `_UNSET: Any` + 多个 `Any` 绕过 mypy strict | 收窄类型 | 0.5 | 类型安全;改动面涉及整个装配函数 | +| C-27 | `app/service/trade_service.py:568-572,649-653` | `scalar_one()` 在产品主数据被删时抛 `NoResultFound` → 500(历史订单应仍可查) | 改 `scalar_one_or_none()` + 友好降级 | 0.25 | 需先删除产品主数据才触发 | +| C-28 | `app/core/fund_contracts.py:13-43` | `nav` 等金额字段无 `gt=0` / `allow_inf_nan=False` 约束 | 补约束 | 0.25 | 输入加固 | +| C-29 | 全项目(`trade_service.py:142,176,251,292` 等) | `datetime.now(UTC).replace(tzinfo=None)` 手工重复,而 `app/core/timeutil.py` 已有 `to_utc_naive` | 统一替换 | 0.5 | 时区 bug 有重演空间;改动面广但机械 | +| C-30 | `alembic/` | 迁移图为单一 head + merge 齐全(**干净**),但**无 CI 断言"恰好 1 个 head"** | 加一条 CI 断言 `alembic heads` 输出恰好一行 | 0.1 | 当前正确,属防回归 | + +> C 档合计约 **8-12 人天**,建议按"C-11 / C-13 / C-15 / C-18 / C-20 / C-21 / C-30"这一类 **0.05-0.1 人天的零碎项**先做(能跟着 A/B 档的 PR 顺手带上),C-3 的 God Class 拆分单独立项。 + +--- + +## 四、依赖关系与执行批次 + +### 4.1 同文件冲突矩阵(决定哪些不能并行) + +| 文件 | 被哪些项改动 | 结论 | +|---|---|---| +| **`app/service/trade_service.py`** | **A-1**(拆 commit)、**A-2**(删 `_next_id`)、**A-3**(改 `_load_*` 签名)、**A-5**(改 `_compute_fee`)、C-15、C-16、C-18、C-27 | **四项必修全部命中同一文件 → 必须严格串行** | +| `app/api/controllers/trading.py` | A-1、B-6 | 同文件,建议同一人一次性改完 | +| `app/service/agent/base.py` + `governance.py` | A-6 | 独立 | +| `app/main.py` | B-2 | 独立 | +| `requirements.txt` + `pyproject.toml` | B-3 | 独立 | +| `app/infrastructure/rate_limiter.py` | B-11(+B-2 的释放路径) | 与 B-2 有交集,建议一起做 | +| `app/infrastructure/db.py` | B-9 | 独立,但**必须与 A-3 同批部署** | + +### 4.2 批次编排 + +``` +┌─ 批次 0:前置核实(0.5-1 人天,全部只读,可完全并行,必须最先) +│ N1 api_request_receipt 唯一索引是否存在 ─────────► 卡住 A-1 +│ N2 RBAC 种子 scope 组合(seed 脚本 + 实际表) ───► 卡住 B-8 定性 +│ N3 check_compliance 有无仓库外调用方 ───────────► 卡住 A-6 删除方案 +│ N4 AGENTS.md 是否允许 AUTO_INCREMENT 豁免(D-1)─► 卡住 A-2 甲/乙 +│ N5 扫前端:cursor=0/负数、T002 无键调用方、 +│ dashboard.js 失败分支是否透传 message、 +│ apiClient.post 第三参数是否透传 options ─────► 卡住 A-1 / B-6 +│ N6 KnowledgeRetrievalService 是否为 B-1 的既定替代实现 ─► 卡住 B-1 改法 +│ N7 openai 有无字符串形式动态导入 ─────────────► 卡住 B-3 删除 +│ N8 T005 撤单是否也改 holding ────────────────► 卡住 A-3 是否需补锁 +└──────────────────────────────────────────────── + +┌─ 批次 1a:完全独立项(可多人并行,互不冲突)──────────► 约 2 人天 / 1 日历天 +│ A-4 访客令牌限流 ▏ B-3 依赖漂移 ▏ B-4 KeyError ▏ B-7 场外负数 +│ B-5 NL2SQL 截断 ▏ B-6 cursor 校验 +└──────────────────────────────────────────────── + +┌─ 批次 1b:资金安全主线(严格串行,约 3.5 人天) +│ ① A-3 行锁 ★ B-9 连接池扩容必须与 ① 同批上线 +│ └─► ② A-2 发号器 (加锁后并发窗口变宽,正好用来验证发号器) +│ └─► ③ A-1 幂等 (依赖 submit_order 已被前两项稳定) +│ ★ A-5 手续费紧跟 ①(同文件,rebase 成本最低) +└──────────────────────────────────────────────── + +┌─ 批次 2:架构 / 性能(可 2 人并行,依赖批次 1 完成)──► 约 4 人天 / 2 日历天 +│ B-1 异步 Milvus ▏ B-2 lifespan ▏ B-11 Redis 单例 ▏ B-10 重试退避 +│ A-6 check_compliance(等 N3 + 同步 docs/09) ▏ B-12 offsite 权限审计 +└──────────────────────────────────────────────── + +┌─ 批次 3:待定性插入(0.5-1.5 人天,取决于 N2 结论) +│ B-8 data_scope(若无高危组合 → 降级为 0.1 天的注释补充) +└──────────────────────────────────────────────── + +┌─ 批次 4:C 档(约 8-12 人天,按迭代拆分,不阻塞上线) +└──────────────────────────────────────────────── +``` + +### 4.3 工作量汇总 + +| 批次 | 人天 | 可并行度 | 日历天(2 人投入) | +|---|---|---|---| +| 批次 0(核实) | 0.5-1 | 高 | 0.5 | +| 批次 1a(独立项) | ~2 | 高(3 人) | 1 | +| 批次 1b(资金主线) | ~3.5(含 A-5、B-9) | **低(串行)** | 2-3 | +| 批次 2(架构/性能) | ~4 | 中(2 人) | 2 | +| 批次 3(待定性) | 0.5-1.5 | 视结论 | 0.5-1 | +| **A + B 档合计** | **约 10.5-13 人天** | — | **约 6-8 日历天** | +| 批次 4(C 档) | 8-12 | 中 | 按迭代 | + +> 人天含编码 + 单元测试 + 文档同步,**不含**集成联调、发版与评审时间。 +> 若 D-1 走方案乙(新增发号表),A-2 增加 0.5 人天,且需额外 0.5 人天做文档口径同步。 + +### 4.4 上线顺序约束 + +1. **DDL 先行**:A-2 若走方案甲属 `ALTER TABLE`,必须**先于**应用发布执行;建议先在副本库演练 + 备份。 +2. **滚动发布**:B-2(lifespan)与 B-11(Redis 单例)改变进程生命周期管理,建议滚动重启而非批量重启,观察连接数。 +3. **B-9 不得延后于 A-3**:连接池扩容与行锁必须在同一发布窗口(理由见 B-9)。 +4. **文档必须同 PR**:A-1(`docs/05` 幂等口径 + 后端接口文档)、A-6(`docs/09` 作废承诺)、A-2 方案乙(`AGENTS.md` 等表数口径)。 + +--- + +## 五、跨项风险总表 + +| 风险 | 涉及项 | 后果 | 缓解 | +|---|---|---|---| +| **同一文件并发改动冲突** | A-1/A-2/A-3/A-5 → `trade_service.py` | rebase 地狱、逻辑互相覆盖 | 严格串行(批次 1b),或由同一人一次改完四项 | +| **`api_request_receipt` 缺唯一索引** | A-1 | **幂等完全失效**且不报错 | N1 核实;若无索引必须先补索引再上线 | +| **行锁把读路径拖慢/拖死** | A-3 | T001 看板锁等待 | `for_update` 默认 False;回归断言读路径无锁 | +| **gap lock 阻塞首次买入** | A-3 | 不同客户首买同一产品互相阻塞 | 压测必测;必要时改为 `INSERT ... ON DUPLICATE KEY UPDATE` 建仓 | +| **连接池打满** | A-3 + B-9 | 大面积 `QueuePool TimeoutError`,易被误判为"锁有问题" | B-9 与 A-3 同窗口上线 | +| **回滚数据不一致** | A-2 方案甲 | 反向 DDL 风险 | 先备份、副本演练;明确回滚脚本 | +| **表数口径失效** | A-2 方案乙 | 上一轮刚统一的 89 表口径在多份文档中重新失效 | 若选乙,**必须**预留 0.5 人天做文档同步 | +| **缺键调用方被打断** | A-1 | 201 → 422 | N5 全量扫描;官方前端已自带键(§0.2-1) | +| **历史脏数据未修复** | A-1 / A-5 | 已存在的重复订单、负余额不会自动修正 | 单独排一次性对账冲正脚本(**不在本方案工作量内**) | +| **`docs/09` 承诺冲突** | A-6 | 删除公共 API 违背文档承诺 | N3 核实 + 同 PR 在 `docs/09` 显式作废 | +| **`data_scope` 改错方向** | B-8 | 复现 9002/9003 查不到数据的事故 | 严格只改调用方、不动计算逻辑(§0.2-3) | +| **超时预算被打破** | B-10 | 加退避后总耗时变长,撞上层超时 | 同步核算调用方 timeout | +| **Redis 故障放大** | B-11 / C-12 | 改单例后重启卡住;改 fail-closed 后 Redis 挂 = 全站不可用 | 单例配合 ping 探测;fail-closed 决策需产品拍板 | +| **提示注入改造引发行为漂移** | C-23 | 改 system prompt 会改变模型输出,需全量话术回归 | 单独立项,配 A/B 对比集 | + +--- + +## 六、回归验证矩阵 + +| 修改项 | 必跑测试 | 必跑工具 / 脚本 | 人工验证点 | 必须观察的指标 | +|---|---|---|---|---| +| **A-1 幂等** | `tests/integration/test_risk_idempotency_mysql.py`(作为模板,新增交易版) | `tools/e2e_smoke_test.py`、`tools/acceptance_check.py` | 同键两次响应体一致;九步校验链状态码不变 | `fin_sim_order` 行数、`available_cash` 扣减次数 | +| **A-2 发号器** | 新增 20 路并发下单用例 | `tools/audit_schema.py`、`tools/audit_constraints.py`、`alembic heads` | 副本库先演练 migration | `IntegrityError` 计数 = 0;无重复 id | +| **A-3 行锁** | 新增"突破上限""超卖""死锁探针"三个并发用例 | `SHOW ENGINE INNODB STATUS` | T001 看板读取无锁等待 | 1213 死锁数 = 0;读路径 P99 不变 | +| **A-4 访客限流** | 新增 `tests/unit/api/test_visitor_token_rate_limit.py` | `tools/e2e_smoke_test.py` | 30 连打第 21 次起 429;Redis 停掉后 fail-open | 429 计数、`Retry-After` 头存在 | +| **A-5 手续费** | 新增"min_fee > gross"用例 + 不变性断言 | 同上 | 事务完全回滚、无任何 DB 写入 | `ledger.balance_after == account.cash_balance` 恒成立 | +| **A-6 合规** | `test_customer_service_agent.py`、`test_agent_governance.py` | `Grep check_compliance` | 话术随 `agent_type` 正确开关 | 客服输出 100% 带免责话术 | +| **B-1 异步 Milvus** | 记忆召回结果对比测试 | `tools/e2e_smoke_test.py` | 注入 3s 延迟后并发协程不受影响;Milvus 挂了话术是"降级"不是"无内容" | 事件循环阻塞时长、召回 Top-K 一致性 | +| **B-2 资源释放** | lifespan 单测 | — | 100 次清理后连接数不增长 | 进程句柄 / gRPC 通道数 | +| **B-3 依赖** | 干净环境安装验证 | 新增的依赖一致性校验脚本 | 服务可起 | —— | +| **B-4 KeyError** | 非字符串键单元测试 | 手工生成一次风控日报 | —— | —— | +| **B-5 截断** | 200/199/limit=5 三个用例 | `docs` 同步 | dry_run 展示的 LIMIT 值合理 | —— | +| **B-6 cursor** | abc / 0 / 超范围 / 正常翻页 | 前端全量点击测试 | 无客户端传 0 | 500 错误数 = 0 | +| **B-7 场外规则** | `total_fund_shares = -1000` 用例 | 场外 OCR 单据校验流程 | 两条规则结论一致 | —— | +| **B-8 data_scope** | 逐角色查询对比(改前/改后 diff) | `tools/seed_test_rbac.py` | 9002 / 9003 仍能查到全量 | 结果集 diff 为空 | +| **B-9 连接池** | 50 路并发下单 | `SHOW PROCESSLIST` | 无 `QueuePool TimeoutError` | 峰值连接数 ≤ 预算 | +| **B-10 重试** | 注入持续失败上游 | —— | 总耗时在新预算内 | 无同时重试尖峰 | +| **B-11 Redis 单例** | —— | Redis `INFO clients` | Redis 重启后自动恢复 | 连接数不随请求线性增长 | +| **B-12 权限审计** | 17 条路径逐条触发 | —— | 响应体与改前逐字节相同 | 审计表新增记录数 | + +**通用回归(每次发布都必须跑)** +- `python tools/e2e_smoke_test.py`、`python tools/acceptance_check.py`、`python tools/portal_api_check.py` +- 前端四套角色页面(现在是 6 个角色目录)的核心链路人工点击 +- `docs/40-前端验收清单.md` 中的幂等项 8-4 与 RBAC 相关项 + +--- + +## 七、执行清单(按优先级排序) + +> 说明:**P** = 可并行,**S** = 必须串行。工时单位:人天。 + +### 第一批:解锁前置(必须最先,全部只读) + +| 序 | 事项 | 类型 | 工时 | 阻塞谁 | +|---|---|---|---|---| +| 1 | **N1** 核实 `api_request_receipt` 的唯一索引存在 | 前置 | 0.1 | A-1 | +| 2 | **N4** 决策 D-1:是否允许 AUTO_INCREMENT 豁免 | 前置 | 0.1 | A-2 | +| 3 | **N8** 核实 T005 撤单是否也改 `holding` | 前置 | 0.1 | A-3 | +| 4 | **N5** 前端扫描:cursor=0/负数、T002 无键调用方、`apiClient.post` 第三参数 | 前置 | 0.25 | A-1 / B-6 | +| 5 | **N3** 核实 `check_compliance` 有无仓库外调用方 | 前置 | 0.1 | A-6 | +| 6 | **N2** 核实 RBAC 种子 scope 组合 | 前置 | 0.5 | B-8 | +| 7 | **N6** 确认 `KnowledgeRetrievalService` 是否为既定替代实现 | 前置 | 0.1 | B-1 | +| 8 | **N7** 确认 `openai` 无字符串动态导入 | 前置 | 0.05 | B-3 | +| 9 | **D-2** 决策:T002 缺幂等键是拒绝还是派生兜底 | 前置 | 0.1 | A-1 | + +### 第二批:阻塞项(上线前必须完成) + +| 序 | 事项 | 级别 | 并行 | 工时 | 备注 | +|---|---|---|---|---|---| +| 10 | **A-3** `submit_order` 加行锁(`for_update` 参数化 + 固定加锁顺序) | 阻塞 | **S**(第 1 步) | 1 | ★ 与第 13 项同窗口上线 | +| 11 | **B-9** 连接池显式配置(**必须与 A-3 同批**) | 重要 | P | 0.25 | 由 P2 上调 | +| 12 | **A-5** 卖出手续费上界 + 负值兜底 | 阻塞 | **S**(紧跟 A-3) | 0.5 | 由 P1 上调 | +| 13 | **A-2** `_next_id` 发号器(甲:AUTO_INCREMENT / 乙:发号表) | 阻塞 | **S**(A-3 之后) | 1-1.5 | 取决于 D-1 | +| 14 | **A-1** T002 接入幂等(含拆分 `submit_order` 事务 + 前端稳定键) | 阻塞 | **S**(最后) | 1.5 | 依赖 N1 / N5 / D-2 | +| 15 | **A-4** 访客令牌 IP 限流 | 阻塞 | **P** | 0.5 | 照抄 `enforce_login_rate_limit` | +| 16 | **A-6** `check_compliance` 处理 + `governance` docstring 纠正 | 阻塞 | **P** | 0.5 | 依赖 N3;需同步 `docs/09` | + +### 第三批:重要项(本迭代应完成) + +| 序 | 事项 | 级别 | 并行 | 工时 | 备注 | +|---|---|---|---|---|---| +| 17 | **B-1** 异步 Milvus(知识类换 async 客户端 / 记忆类 `to_thread` + 超时) | 重要 | P | 1-1.5 | 依赖 N6 | +| 18 | **B-2** 引入 lifespan + 统一 shutdown | 重要 | P | 0.5-1 | 与第 24 项一起做 | +| 19 | **B-11** Redis 客户端单例化 | 重要 | P | 0.5 | 与第 18 项一起做 | +| 20 | **B-10** 重试加退避 + jitter | 重要 | P | 0.5 | 注意超时预算 | +| 21 | **B-12** `_permission_error` 内部改走统一鉴权(保持响应不变) | 重要 | P | 0.5-1 | 不做 God Class 拆分 | +| 22 | **B-3** 依赖双源统一 + 一致性校验脚本 | 重要 | **P** | 0.5 | 依赖 N7 | +| 23 | **B-4** `_distribution` 排序键修复 | 重要 | **P** | 0.25 | 纯局部 | +| 24 | **B-5** NL2SQL `truncated` 标志 | 重要 | **P** | 0.5 | 照抄 `risk_repository._capped` | +| 25 | **B-6** `cursor` 改用 `parse_cursor` | 重要 | **P** | 0.5 | 依赖 N5 | +| 26 | **B-7** `total_fund_shares <= 0` 统一判"无法判断" | 重要 | **P** | 0.25 | 纯局部 | +| 27 | **B-8** `data_scope` 逐权限判定(**先核实 N2 再决定是否为改造**) | 重要 | 视 N2 | 0.1-1.5 | ⚠️ **不得改 `identity_repository.py:55` 的计算** | + +### 第四批:建议项(下次迭代) + +| 序 | 事项 | 级别 | 工时 | +|---|---|---|---| +| 28 | 零碎随手做:C-11(`customer_service_rules` docstring)、C-13(真静默 except)、C-15(`from None`)、C-18(行情时效边界)、C-20(`exc_info`)、C-21(trace_id 校验)、C-30(alembic head CI 断言) | 建议 | 0.6 | +| 29 | C-1 `classify_intent` 静默降级 | 建议 | 0.25 | +| 30 | C-6 / C-27 / C-28 边界与降级加固 | 建议 | 1 | +| 31 | C-14 `_fact_id` 与 C-16 `order_no` 唯一约束(建议在第 13 项定案后统一处理) | 建议 | 0.5 | +| 32 | C-29 统一 `to_utc_naive` | 建议 | 0.5 | +| 33 | C-22 游标 HMAC 化 / C-25 worker 无界 set / C-26 装配类型收窄 | 建议 | 1 | +| 34 | C-10 调参阈值配置化 / C-24 三态降级话术 | 建议 | 0.75 | +| 35 | C-3 **God Class 拆分**(邮件 → 识别 → NL2SQL → 规则 → 通知 → 统计,每次一域) | 建议 | 3-5 | +| 36 | C-23 提示注入隔离(需整体 prompt 回归) | 建议 | 1 | +| 37 | C-2 错误码语义增强(**不拆码**,加 `detail` 字段) / C-12 fail-closed 决策 | 建议 | 1 | + +### 明确的"暂不动"清单 + +| 事项 | 理由 | +|---|---| +| `errors.py` 拆错误码 | 是对齐 `docs/05 §3.6` 的刻意设计且有 AST 测试守护,拆了会破坏契约测试与历史文档 | +| `identity_repository.py:55` 的 `data_scope` 计算 | 改了会复现"9002/9003 拿 all 权限却查不到数据"的已修事故 | +| AD008/AD009 补幂等头 | 已核实 `asset_allocation_service` 全文件不写库,豁免合理 | +| `projection_cleanup_service.py:129` 的 filter 字符串拼接 | `memory_uuid` 由服务端生成(非用户可控),当前不构成注入面 | +| C-7 场外最低申购金额口径 | 属报告 §8-2 疑点,业务口径未定前改错方向比不改更糟 | + +--- + +## 八、必修 / 延后的判定说明 + +### 8.1 列入"必修"的依据(6 项) + +| 项 | 判定依据 | 有无临时绕过手段 | +|---|---|---| +| A-1 T002 幂等 | **资金重复扣款**,且是唯一缺口;重复下单还会反向触发"持仓超限"误拒 | **无**。客户端不重试只能靠用户自觉,不可控 | +| A-2 `_next_id` | 并发下**必然**主键冲突(`MAX(id)+1` + 无 AUTO_INCREMENT) | 无。限流只能降低概率不能消除 | +| A-3 行锁 | **突破持仓上限 / 超卖成负**,直接影响资金与合规指标 | 无。这是唯一的正确性手段 | +| A-4 访客限流 | **无需凭证即可无限铸造身份**,且现有按 `user_id` 的限流天然失效 | 只能在网关临时挡 IP(这不失为一个**上线前的临时缓解**,但代码侧仍必修) | +| A-5 手续费上界 | **账户被倒扣**(后果等同 A-1 / A-3),且演示场景极易触发 | 可临时把 `minimum_fee` 设为 0,但那是改业务数据、不是修复 | +| A-6 合规门禁 | 虽为死代码(当前不触发),但属**合规红线类缺陷** + docstring 正在主动误导维护者 | 临时手段是"不要调用它",但缺陷本体仍在 | + +### 8.2 列入"延后"的依据 + +**类型一:当前不可触发 / 需特定条件** +- C-17 `_next_mail_id`(需日发 >999 封)、C-25 无界 set(需长期运行)、C-27 产品被删(需主数据删除) +- 判定:这些都写在 `except`/生成逻辑里,等条件成熟再做,成本不变。 + +**类型二:性能而非正确性** +- C-4 httpx 复用、C-9 重复函数、C-29 `to_utc_naive` 统一 +- 判定:不修不影响功能正确性,先集中火力解决资金/合规面。 + +**类型三:需业务决策,改错方向更糟** +- C-7 场外 1 元口径、C-8 误判容差 eps、C-12 fail-closed、C-2 错误码语义 +- 判定:技术方案依赖业务口径。在没有结论前动手,等于把猜测写进代码。**这些应该带着选项去问业务,而不是自行假定。** + +**类型四:技术债,收益/风险比不足以放进本迭代** +- C-3 God Class 拆分(3-5 人天,纯内部结构,B-12 已用低成本方式解决了最紧要的审计口径问题) +- C-23 提示注入(1 人天 + 全量 prompt 回归;当前缓解机制——工具权限由 `ToolExecutor` 独立校验、不信任模型——是有效的) +- C-26 装配类型收窄(收益是静态检查,风险是改整个装配函数) + +### 8.3 报告 §8「未核实清单」在本方案中的落点 + +审查报告列出了 12 条静态阅读无法定论的项。本方案对它们的处理: + +| 报告未核实项 | 本方案处理 | 落到哪个前置 | +|---|---|---| +| 1. RBAC 种子 scope 组合 | 决定 B-8 是否改造 | **N2** | +| 2. 场外最低申购金额口径 | 列为"暂不动",待业务确认 | —(C-7 延后) | +| 3. 限流 fail-open / 游标无密钥 | 产品决策,列入门 | C-12 / C-22 延后 | +| 4. `check_compliance` 是否被仓库外调用 | 决定能否删除 | **N3** | +| 5. `.env` 历史是否泄露密钥 | **Bash 不可用无法验证**,需在有 Git 的环境自查 | 独立安全项 | +| 6. 部署拓扑对限流的影响 | 建议网关层 IP 限流作为主手段 | A-4 第 4 点 | +| 7. `openai` 是否被动态导入 | 决定能否删除 | **N7** | +| 8. `frozen_quantity` 是否有写入点 | 决定 A-3 是否需覆盖撤单路径 | **N8** | +| 9. `worker_lease_seconds` 实际值 | 与 B-1 的事件循环阻塞有交互 | 建议压测时一并观察 | +| 10. `KnowledgeRetrievalService` 是否预留实现 | 决定 B-1 是接线还是重写 | **N6** | +| 11. 40+ 迁移的 `upgrade()` 体 | 建议跑 preflight | A-2 的验证环节 | +| 12. `docs/06` P1/P2 欠债逐条核对 | 仅 A-6 涉及的一条已处理 | 其余不在本次范围 | + +### 8.4 环境与结论边界 + +撰写本方案时 **Bash 工具不可用**(`exit 127`),**PowerShell 不回显 stdout**,所有结论均通过 `Read` / `Glob` / `Grep` 静态阅读得出。因此: + +- **可确认的**:调用链、是否加锁、是否传参、字段类型、DDL 形态——这些静态可判。 +- **无法确认的**:所有需要"跑一次"的结论——如 `_next_id` 的实际碰撞概率、gap lock 的实际阻塞程度、连接池打满的真实阈值、迁移在真实库的表现。 +- **实践建议**:A-2 / A-3 / B-9 这几项都属于"理论上成立但未实测",但都在**资金主路径**上,建议按"已成立"处理而非等实测;同时把压测作为这几项的验收门槛,而不是可选项。 + +> **本方案只做规划,未改动任何代码。** + diff --git a/docs/演示用/代码库全面审查报告-2026-09-14.md b/docs/演示用/代码库全面审查报告-2026-09-14.md new file mode 100644 index 0000000..bd3b2a5 --- /dev/null +++ b/docs/演示用/代码库全面审查报告-2026-09-14.md @@ -0,0 +1,591 @@ +# 代码库全面审查报告 + +> **审查日期**:2026-09-14 +> **审查范围**:`app/` 下 251 个 Python 文件(661 个 `.py` 含测试与工具),重点覆盖认证鉴权、并发与资源、金额与边界、异常与依赖四大横切面 +> **审查方式**:4 路并行深度审计(安全 / 并发 / 逻辑边界 / 异常与依赖)+ **关键结论逐条人工复核** +> **重要声明**:本次审查**只读分析,未修改任何代码**。 + +--- + +## 〇、如何读这份报告 + +### 0.1 严重度定义 + +| 级别 | 含义 | +|---|---| +| **P0** | 资金错账、数据损坏、重复处理,或无需凭证即可被大规模利用。**上线前必须修**。 | +| **P1** | 高概率的功能错误、事件循环阻塞、连接/资源泄漏、合规门禁失效。应尽快修。 | +| **P2** | 中等风险:边界瑕疵、降级语义混乱、结构性隐患、维护陷阱。排期修。 | +| **P3** | 低风险 / 加固建议 / 代码卫生。可顺手改。 | + +### 0.2 ⚠️ 关于"确定性缺陷"与"疑点"的区分 + +本报告已按你的要求区分两者: + +- **【确定】**:我已亲自读源码逐行确认,给出了真实行号。 +- **【疑点】**:逻辑上可疑,但需要业务口径确认、或在当前数据/部署条件下无法触发。**我明确标注了为什么无法确认**。 + +### 0.3 一条方法学提醒 + +审查由 4 路子代理并行完成,**但我没有直接采信它们的结论**。逐条复核中,我**推翻了 1 条**、**修正了 2 条的严重度**(见 §5)。凡涉及计数与"是否可达"的判断,均以我本人的直接阅读为准。 + +--- + +## 一、P0 级(上线前必须修) + +### P0-1 场内下单接口完全没有幂等保护 —— 重复下单 = 重复扣款 + 重复建仓 + +**【确定】** + +- **位置**:`app/api/controllers/trading.py:61-69`(T002)、`app/service/trade_service.py:288-455` +- **类别**:幂等 / 资金安全 + +**证据**: +```python +# app/api/controllers/trading.py:61-69 +@router.post("/orders", status_code=status.HTTP_201_CREATED) +async def submit_order( + payload: OrderCreateRequest, + context: RequestContext = Depends(build_request_context), + session: AsyncSession = Depends(get_session), +) -> dict[str, object]: + await _authorize(context, "trade:order:create") + data = await _service(session, context).submit_order(payload, context) + return envelope(data, context) +``` + +**核对过程(我做了什么来确认它真的没有兜底)**: +1. 读 `trading.py` 全文 —— 9 个端点**均**无 `Idempotency-Key` 头参数。 +2. 读 `app/api/middleware.py` 全文(33 行)—— **只有一个 `attach_trace_id` 中间件,没有全局幂等层**。 +3. 对比 `risk.py` / `admin.py` / `promotion_material.py` 等写接口 —— 它们都走 `ApiTransactionService.execute*` 并写 `api_request_receipt`。**T002 是唯一的例外。** + +**触发场景**: +用户点"买入 500 份",网关超时,客户端按标准重试策略重发。两次请求各自生成新的 `order_no`(`uuid4`,`trade_service.py:341`),各自执行 `account.available_cash -= net_amount`(`:407`)、`_upsert_holding` 再加一次仓(`:412`)。**同一笔意愿成交两次**。 + +**建议修复方向**: +接入已有的 `ApiTransactionService.execute_in(session, context, scope="trade:order:create", key=, body=payload, action=...)`;或至少给 `fin_sim_order` 加客户端可传的幂等键唯一约束。 + +--- + +### P0-2 `_next_id()` 用 `SELECT MAX(id)+1` 发主键,并发下必然主键冲突 + +**【确定】** + +- **位置**:`app/service/trade_service.py:111-123` +- **类别**:并发 / 数据损坏 +- **调用点(4 处,均为金融主表)**:`:344`(`FundSimOrder`)、`:371`(`FundTransaction`)、`:430`(`FundCashLedger`)、`:471`(`FundHolding`) + +**证据**: +```python +# app/service/trade_service.py:111-123 +async def _next_id(self, model: Any) -> int: + """返回 ``model`` 表的下一个可用主键。 + ...底座 ``fin_*`` 表 ``id`` 列实际**未**配置 AUTO_INCREMENT(与 ``docs/00`` 设计稿 + 存在偏差),但 AGENTS.md 禁止修改既有列类型/可空性/含义。... + 并发与单测场景下够用;后续若需要严格序数,再独立 PR 引入发号器。 + """ + result = await self._session.execute(select(func.max(model.id))) + max_id = result.scalar() + return int(max_id or 0) + 1 +``` + +**我如何确认"未配 AUTO_INCREMENT"是真的**(这决定了本项是否成立): +读 `alembic/baseline_generated.sql:270-271`: +```sql +CREATE TABLE `fin_sim_order` ( + `id` BIGINT UNSIGNED PRIMARY KEY, -- ← 无 AUTO_INCREMENT,确认 +``` +`fin_transaction`(`:310`)同形。 + +**触发场景**:两个不同客户在同一事务窗口内各自调用 `_next_id(FundTransaction)`,都读到 `MAX(id)=100`,都返回 `101`。第二次 `flush()` 触发主键冲突 → 事务回滚。表现是"第二个客户下单直接 500"。`submit_order` 一次要发 **3 个 id**(订单/成交/流水),冲突面更大。 + +**建议修复方向**:优先改 `AUTO_INCREMENT`(需迁移,但这是设计稿原意);若坚持不改基线,需引入独立发号器(`INSERT ... ON DUPLICATE KEY` 重试循环,或 Redis `INCR`)。 + +--- + +### P0-3 `submit_order` 读账户/持仓未加锁 —— 并发下单可突破持仓上限、可把可用份额扣成负数 + +**【确定】** + +- **位置**:`app/service/trade_service.py:296-328`(校验)、`:407-425`(扣减)、`:457-525`(持仓变更) +- **类别**:并发 / 边界 + +**证据**: +```python +# app/service/trade_service.py:294-298 +product = await self._load_tradable_product(payload.product_code) +await self._check_suitability(customer_id, product, context) +quote = await self._fetch_quote(product) +account = await self._load_account(customer_id) # ← 无 with_for_update +holding = await self._load_holding(customer_id, product.id) # ← 无 with_for_update +``` + +**核对过程**:`Grep with_for_update` 在 **整个 `trade_service.py` 中零命中**。而项目其它地方(如 `offsite_fund_service.confirm_document`)**是**用了行锁的,说明团队知道这个模式。 + +**触发场景(两类)**: +1. **突破持仓上限**:客户已接近上限,同时提交两笔买入。两个请求都读到旧的 `holding.total_quantity`,都判"未超限",都通过 → 最终突破 `single_investor_max_holding_ratio`。 +2. **超卖**:持仓 1000 份,同时两笔各卖 1000 份。两笔都读到 `available_quantity=1000`,都通过检查 → 最终 `available_quantity` 变负。 + +**建议修复方向**:对 `fin_sim_account` 与 `fin_holding` 行加 `SELECT ... FOR UPDATE`(按 `customer_id` / `customer_id+product_id`,注意加锁顺序避免死锁),在锁内完成"校验 + 扣减"。 + +--- + +### P0-4 访客令牌端点零认证、零限流 —— 可无限量铸造身份并免费消耗模型额度 + +**【确定】** + +- **位置**:`app/api/controllers/visitor_tokens.py:1-14` +- **类别**:认证 / 资源滥用 + +**证据**(全文 14 行,`APIRouter` 无任何 `dependencies`): +```python +router = APIRouter(prefix="/api/v1/visitor-tokens", tags=["visitor-tokens"]) + +@router.post("", response_model=VisitorTokenResponse, status_code=status.HTTP_201_CREATED) +async def issue_visitor_token() -> VisitorTokenResponse: + settings = get_settings() + token, _expires_at = VisitorTokenIssuer(settings).issue() + return VisitorTokenResponse(access_token=token, expires_in=settings.visitor_token_ttl_seconds) +``` + +**对照**:`app/api/controllers/auth.py:26-28` 给登录端点专门挂了 `enforce_login_rate_limit`,并在注释里写明"这是全平台最需要限流的端点(密码爆破的入口)"。**访客令牌端点没有同等保护。** + +**触发场景**: +攻击者脚本循环 `POST /api/v1/visitor-tokens` 毫秒级铸造海量有效 JWT。每个都能过 `build_request_context`,进而调 `/api/v1/agent-runs` 触发 LLM 调用。而 `/api/v1/agent-runs` 的限流是**按 `user_id`** 计的,访客 `sub` 每次都是**新随机值** —— 限流被天然绕过。结果:免费刷模型额度 + 灌爆 `agent_run` / `conversation` 表。 + +**建议修复方向**:给该端点加 IP 维度限流;限流键在访客场景叠加 IP;考虑对访客身份做 IP/指纹绑定,而非纯随机 `sub`。 + +--- + +## 二、P1 级 + +### P1-1 `check_compliance()` 是死代码,且它一旦被调用会**静默关闭** F5 免责声明门禁 + +**【确定】** —— 这是本次审查**最隐蔽**的一处,因为它不报错、不失败测试。 + +- **位置**:`app/service/agent/base.py:162-165` +- **类别**:死代码 / 合规门禁 + +**证据**: +```python +# app/service/agent/base.py:162-165 +async def check_compliance(self, result: AgentResult, context: RequestContext) -> AgentResult: + if self._governance is None or self.config is None: + raise RecoverableAgentError("缺少合规治理依赖") + return await self._governance.review(result, context, self.config, self.memories) + # ^^^^^^^^^^^^ + # 注意:没有传 agent_type +``` + +**核对过程(三步)**: +1. **全仓 `Grep check_compliance`** → 仅 4 类命中:`base.py:89`(禁止覆写名单)、`base.py:162`(定义)、`tests/unit/service/test_agent_governance.py:20`(断言不可覆写)、`tests/unit/service/test_customer_service_agent.py:170`(清单)。**零个生产调用点。** +2. **真实链路**:`base.py:114-116` 的 `execute()` 直接调 `governance.review(..., agent_type=self.definition.agent_type)` —— 绕过了 `check_compliance`。 +3. **确认"不传 agent_type"的后果**:读 `governance.py:219` 与 `:281`: + ```python + customer_facing = agent_type in CUSTOMER_FACING_AGENT_TYPES # 空串 → False + ... + if customer_facing and not content.text.endswith(appended_shape): + content = content.model_copy(update={"text": f"{content.text}{appended_shape}"}) + ``` + **空 `agent_type` → `customer_facing=False` → 免责声明不追加。** 即:若有人按方法名的语义调用 `check_compliance()`,客服回复的 F5 门禁(面向客户输出 100% 附固定话术)**会静默失效**。 + +**⚠️ 附带发现(文档与代码矛盾)**:`governance.py:211-212` 的 docstring 写的是 +> "空串按'调用方未声明'处理,**保守照旧追加**,避免漏加" + +而代码(`:219` + `:281`)的实际行为是**空串不追加**。**docstring 与代码相反**,这正好会误导维护者认为"忘记传 agent_type 是安全的"。 + +**背景佐证**:`docs/06-底座代码测试报告.md:307` 把"接线 `check_compliance`"列为 P1 —— 至今**仍未接线**(该文档整体已属 D 类历史文档,但这一条是真实的未完成项)。 + +**建议修复方向**:二选一——① 删除该方法(并从 `:88-92` 的禁止覆写名单移除);② 改为 `governance.review(..., agent_type=self.definition.agent_type)` 并补契约测试。**同时修正 `governance.py:211-212` 的 docstring 使其与代码一致。** + +--- + +### P1-2 异步路径中直接调用**同步** Milvus 客户端,阻塞整个事件循环 + +**【确定】** + +- **位置(4 处)**: + 1. `app/service/memory_recall_service.py:175`(`async def _vector` 内) + 2. `app/service/knowledge_search_service.py:207-213, 224, 229` + 3. `app/core/knowledge_schema.py:205` + 4. `app/service/projection_cleanup_service.py:115, 123, 127` +- **类别**:异步阻塞 + +**证据**: +```python +# app/service/memory_recall_service.py:153 / 175 +async def _vector(self, ...): + ... + found = self.vector.search(embedding, limit=limit) # ← 同步阻塞网络 I/O +``` +调用的是同步 `MilvusClient`(`app/infrastructure/vector_memory.py:34` `def search(...)`,非 `async def`)。 + +**对照**:项目在**其它 10+ 处**都用了 `asyncio.to_thread`(如 `app/infrastructure/fund_market_adapter.py:273,382`、`app/service/offsite_fund_service.py:1542`),说明规范已建立 —— 这 4 处是遗漏。 + +**触发场景**:Milvus 网络抖动导致单次 `search` 耗时 3 秒。此时**同一进程内所有并发请求、所有其它协程全部停摆 3 秒**(含 worker 心跳 —— 可能触发 lease 误判)。客服的 `search_knowledge` 工具超时是 10s,期间整个 FastAPI 进程无响应。 + +**建议修复方向**:优先改用已有的**异步**客户端 `app/infrastructure/milvus_adapter.py`(内部 `AsyncMilvusClient`,`await client.search(...)`);最小改动方案是全部包进 `await asyncio.to_thread(...)` 并外层加 `asyncio.timeout`。 + +> **附带发现**:`app/service/knowledge_retrieval_service.py` 用的**正是正确的 `await self.client.search(...)`**,但全仓无生产实例化点(只有单测引用)。**它可能就是为此准备的替代实现,却从未接线。** + +--- + +### P1-3 Milvus 客户端从不关闭:`lru_cache` 单例永生 + 局部变量泄漏 + +**【确定】** + +- **位置**:`app/service/agent/bootstrap.py:117-129`、`:194-210`、`app/service/projection_cleanup_service.py:115-127` +- **类别**:资源泄漏 + +**证据**: +```python +# app/service/agent/bootstrap.py:117-129 +@lru_cache(maxsize=1) +def get_vector_memory_adapter(): + ... + client = MilvusClient(uri=..., token=...) # 进程级永生对象 + return VectorMemoryAdapter(client, settings.milvus_collection) +``` +```python +# app/service/projection_cleanup_service.py:115-127 +client = MilvusClient(uri=..., token=...) # 局部变量,每次新建 +... +client.list_collections() +... +client.delete(...) # 从未 close() +``` + +**核对过程**:`Grep lifespan` / `dispose` / `close_singletons` 在 `app/main.py` **无命中** —— 即无 shutdown 钩子。`MilvusKnowledgeClient.close()` / `aclose()` **有定义但全仓无调用点**。 + +**触发场景**:批量记忆清理触发 `_cleanup_vector` 100 次 → 100 个未关闭的 `MilvusClient` 与底层 gRPC 通道泄漏。 + +**建议修复方向**:在 `app/main.py` 加 FastAPI `lifespan`,shutdown 时关闭单例;`_cleanup_vector` 改 `try/finally: client.close()` 或复用单例。 + +--- + +### P1-4 依赖声明双源漂移:`requirements.txt` 与 `pyproject.toml` 不一致 + +**【确定】** + +- **位置**:`requirements.txt:23` vs `pyproject.toml:10-39` +- **类别**:依赖一致性 + +**证据**: +- `requirements.txt:1` 自称 **"Runtime dependencies (source of truth: pyproject.toml)"**。 +- **`openai>=1.0,<2`(`requirements.txt:23`)在 `pyproject.toml` 中完全不存在。** +- **`aiosqlite` 在 `pyproject.toml` 中重复声明两次**(`:45` 与 `:51`)。 +- `requirements.txt:54-59` 把 dev 依赖(pytest/ruff/mypy/aiosqlite)混进了运行时文件。 + +**核对过程**:`Grep "import openai"` / `from openai` / `import_module("openai")` 在**全仓零命中** —— 即 `openai` 是**声明但从未使用**的重依赖。 + +**影响**:用 `requirements.txt` 装的环境会多一个无人使用的 `openai`;两文件将持续漂移。 + +**建议修复方向**:由 `pip-compile` / `uv export` 从 `pyproject.toml` 生成 `requirements.txt`;至少先删 `openai`、去重 `aiosqlite`。 + +--- + +### P1-5 卖出时手续费可能超过成交金额,账户余额反被扣减 + +**【确定】** + +- **位置**:`app/service/trade_service.py:280-284`(`_compute_fee`)、`:416-425`(卖出分支) +- **类别**:金额 / 边界 + +**证据**: +```python +# app/service/trade_service.py:280-284 +def _compute_fee(self, gross: Decimal, rule: _FeeRule) -> Decimal: + fee = gross * rule.fee_rate + rule.fixed_fee + if fee < rule.minimum_fee: + fee = rule.minimum_fee # ← 最低手续费无上界保护 + return fee.quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + +# app/service/trade_service.py:417-422(卖出分支) +account.cash_balance = (account.cash_balance + net_amount).quantize(...) # ← net_amount 可能为负 +account.available_cash = (account.available_cash + net_amount).quantize(...) +``` +`net_amount = gross_amount - fee_amount`(`:329-332`),**全程没有 `fee >= gross` 或 `net_amount < 0` 的拒绝逻辑**。 + +**触发场景**:卖出 100 份 × 0.01 元 = `gross = 1.00`;若命中最低手续费 `5.00`,则 `net_amount = 1.00 − 5.00 = −4.00` → **卖出反而从账户扣 4 元**,且无任何提示。 + +**建议修复方向**:卖出时若 `fee_amount >= gross_amount` 应拒绝或明确提示;产品侧应校验"最低卖出金额"。 + +--- + +### P1-6 `_distribution` 用 `str(key)` 排序却用原 `key` 索引 —— 非字符串键会 `KeyError` + +**【确定】** + +- **位置**:`app/service/risk_daily_report_service.py:356-370` +- **类别**:类型 / 空值 + +**证据**: +```python +# app/service/risk_daily_report_service.py:361-369 +counts = Counter(item.get(field) for item in items if item.get(field) is not None) +keys = [key for key in preferred_order if key in counts] +keys.extend(sorted(str(key) for key in counts if key not in preferred_order)) # ← 转成了 str +return [ + { + "name": f"{key}风险" if field == "risk_level" else key, + "count": counts[key], # ← 却用原 key 索引 + } + for key in keys +] +``` + +**触发场景**:若 `risk_level` 被存为整数 `1`(不在 `preferred_order` 的 `("高","中","低")` 中),`keys` 追加 `"1"`,随后 `counts["1"]` → **`KeyError`**(Counter 里的键是 `int 1`)。 + +**建议修复方向**:`keys.extend(sorted((k for k in counts if k not in preferred_order), key=str))` —— 保留原 key,只在排序时取 `str()`。 + +--- + +### P1-7 NL2SQL 结果无截断标记,命中 `LIMIT` 上限时无法区分"全量"与"被截断" + +**【确定】** + +- **位置**:`app/service/financial_nl2sql_service.py:329`、`:261-265` +- **类别**:截断 / 统计正确性 + +**证据**: +```python +# app/service/financial_nl2sql_service.py:329 +return f"{sql} LIMIT {plan.limit}", params +... +result["data"] = {"total": len(rows), "rows": rows} # ← 无 truncated 标志 +``` + +**触发场景**:`limit` 默认 50。查询**恰好**返回 50 行时,调用方无法判断是被 `LIMIT 50` 截断还是真的只有 50 行。风控/投顾据此统计(如"共 50 笔大额交易")会**低估**。 + +**对照**:`risk_repository._capped` 已用 `limit + 1` 正确实现了截断探测 —— 这里缺同一手法。 + +**建议修复方向**:取 `limit + 1` 行,多出即置 `truncated=True` 并写入 `data`。 + +--- + +### P1-8 `data_scope` 取"所有权限中的最高范围",与逐权限口径并存导致横向越权隐患 + +**【疑点】** —— 需确认 RBAC 种子数据的实际 scope 组合后才能定性。 + +- **位置**:`app/repository/identity_repository.py:33-55` +- **类别**:RBAC / 数据范围 + +**证据**: +```python +data_scope = max(scopes.values(), key=lambda value: rank[value]) if scopes else "self" +``` + +**问题**:用户的 `data_scope` 是其**所有权限中最高的一条**,而不是"本次操作对应权限的 scope"。项目内**两种口径并存**: +- **正确样板**:`public_platform_service.py:276` 用 `context.permission_scopes.get(permission)`(逐权限)。 +- **隐患口径**:`risk_evidence_archive_service._scope_condition`、`financial_nl2sql_service.py:419` 用**全局** `data_scope`。 + +**为什么标为疑点**:能否真正越权,取决于 `sys_role_permission` 种子里各角色的 scope 组合。若某角色同时持有 `memory:read:self`(scope=self)与 `risk:alert:read`(scope=all),其全局 `data_scope` 就是 `all` —— 此时若某接口只看全局 `data_scope` 而不看具体权限的 scope,就可能"用 A 权限的高 scope 放开 B 权限"。**我未逐条比对种子数据**(该文件需与 `sys_role_permission` 表实际内容交叉验证)。 + +**建议修复方向**:统一改为按 `context.permission_scopes[本次校验的权限码]` 判定;或把全局 `data_scope` 的用途收窄并加注释禁止用于单权限判定。 + +--- + +### P1-9 接口 curl/分页参数用裸 `int(cursor)`,绕过 `parse_cursor` 的契约校验 + +**【确定】** + +- **位置**:`app/api/controllers/trading.py:81`、`:135`、`:163` +- **类别**:边界 / 契约 + +**证据**: +```python +# app/api/controllers/trading.py:81 +cursor_id = int(cursor) if cursor else None +``` + +**触发场景**:`?cursor=abc` → `int("abc")` 抛 `ValueError`,**未包装为 400 `INVALID_CURSOR`**,很可能冒泡成 500。`?cursor=-5` 或 `?cursor=0` 不报错但查询 `id < -5` 返回空列表,客户端误以为"翻到底"。而 `app/core/cursor.py` 已提供严格的 `parse_cursor`(含 ASCII 数字校验、拒绝 `bool`)。 + +**建议修复方向**:统一改用 `parse_cursor(cursor, field="cursor")`。 + +--- + +### P1-10 场外规则:`total_fund_shares` 只拦 `== 0` 不拦负数,同一脏数据在两条规则上给出相反结论 + +**【确定】** + +- **位置**:`app/service/offsite_fund_rules.py:83-84`、`:91`、`:95`、`:107`、`:111` +- **类别**:除零 / 边界 + +**证据**: +```python +# app/service/offsite_fund_rules.py:83-91 +if (amount_yuan is None or nav is None or nav <= 0 + or total_fund_shares is None or total_fund_shares == 0): # ← 只拦 0 + return decisions + [ ... _unknown ... ] +current_shares = amount_yuan / nav +ratio = (before + current_shares) / total_fund_shares +``` +```python +# :95 / :99 / :107 / :111 +result="异常" if ratio > TWENTY_PERCENT else "正常", # ratio 为负 → 恒"正常" +limit = total_fund_shares * TEN_PERCENT # limit 为负 +result="异常" if current_shares > limit else "正常", # 恒"异常" +``` + +**触发场景**:若查询返回 `total_fund_shares = -1000`(脏数据/符号错误): +- 持有比例规则:`ratio` 为负 → `ratio > 0.20` 为 False → 判 **"正常"** +- 单笔份额上限规则:`limit` 为负 → `current_shares > limit` 为 True → 判 **"异常"** + +**同一脏数据在两条规则上结论相反**,且都不报"数据异常"。 + +**建议修复方向**:`total_fund_shares <= 0` 统一改为"无法判断"。 + +--- + +## 三、P2 级(摘要) + +| # | 位置 | 问题 | 类别 | +|---|---|---|---| +| P2-1 | `app/service/agent/base.py:145-149` | `classify_intent` 在依赖缺失时**静默返回 `None`**,与"该 Agent 不需要分类"无法区分 → 装配漏注会让意图分流全部失效而不报错 | 静默降级 | +| P2-2 | `app/core/errors.py:74-165` | 错误码**有意复用**(`SESSION_NOT_FOUND` × 4、`RUN_NOT_CANCELLABLE` × 4、`IDEMPOTENCY_CONFLICT` × 2)。前端无法区分"用户非法操作"与"Worker 租约丢失" | 错误分类学 | +| P2-3 | `app/service/offsite_fund_service.py:123` | **God Class**:约 2,820 行、60+ 方法,邮件/识别/NL2SQL/规则/通知/统计全塞一个类 | 可维护性 | +| P2-4 | `app/service/offsite_fund_service.py`(17 处) | 自造 `_permission_error` 样板,**不走**统一的 `AuthorizationService.require`(后者带统一审计) | 重复 / 审计口径 | +| P2-5 | `app/worker/runtime.py:505-516`;`app/infrastructure/rate_limiter.py:92-96` | **每次调用新建 Redis 客户端**。`RedisCounterBackend._client` 的懒加载缓存挂在每次新建的对象上 → 缓存形同虚设,每请求一次握手 | 资源 / 性能 | +| P2-6 | `app/infrastructure/db.py:34` | `create_async_engine` **未配置** `pool_size`/`max_overflow`/`pool_timeout`/`pool_recycle` → 默认 5+10;单次 Agent 运行可有数十次模型+工具调用,叠加并发易打满 | 连接池 | +| P2-7 | `app/service/model_gateway.py:103-123, 174-200, 225-246` | 每次模型调用新建 httpx 客户端并关闭(丢 keep-alive);每次新建 session | 资源 | +| P2-8 | `app/service/model_gateway.py:271-291`;`app/worker/risk_scan_scheduler.py:117-152` | 重试**无退避、无 jitter** → 上游 503 持续 5 秒时毫秒级耗尽全部重试;多副本惊群。(对照:`runtime.py:901-904` **有**指数退避) | 重试正确性 | +| P2-9 | `app/service/risk_daily_report_service.py` / `app/repository/agent_run_repository.py` | `SELECT` 后再 `INSERT/UPDATE` 无行锁(TOCTOU)。对照:`runtime.py:_failure` **是**加了 `with_for_update()` 的 | 竞态 | +| P2-10 | `app/service/offsite_fund_rules.py:170` | "最低申购金额"阈值是**硬编码的 `<= 1 元`**,而产品表有 `min_amount` 列 —— 疑似字段未接线 | 口径/疑点 | +| P2-11 | `app/service/risk_judgement_service.py:161-178` | 距阈值仅差一丝(如 499999.99 / 0.7999)也判 **"疑似误报 + 高置信"**,直接引导专员关闭预警 | 边界 | +| P2-12 | `app/service/model_gateway.py:238-239` | 连续两行 `return endpoints`,第二行不可达 | 死代码 | +| P2-13 | `app/service/offsite_fund_service.py:2374` / `promotion_performance.py:133`;`profile_graph_projection_service.py:267` / `offsite_fund_service.py:2643` | `_json_safe` / `_text` **重复定义**在两处 | 重复 | +| P2-14 | `app/service/agent/implementations/customer_service.py:148-165` | `HIGH_SCORE=0.75` / `MID_SCORE=0.55` / `MIN_GAP=0.07` / `TOP_K=5` 为调参阈值却硬编码(注释自述"gap 从 0.090 掉到 0.076,几乎跌破 0.07")→ 每次调参要发版 | 硬编码 | +| P2-15 | `app/core/customer_service_rules.py:11` | docstring 说走 `query_knowledge`,实际登录客户走 `search_knowledge`。**该类不一致已造成过真实事故**(`docs/40` 记录:发布配置只发 `search_knowledge` → 访客工具白名单抛 `ForbiddenAgentError`) | 文档与代码矛盾 | +| P2-16 | `app/api/controllers/visitor_tokens.py` + `app/api/dependencies/auth.py:26` | 限流在所有端点 **fail-open**(`rate_limiter.py:87-89`)。是有意的产品取舍,但对登录/访客签发这类敏感端点应为 fail-closed | 限流语义 | +| P2-17 | `.workdir/zsy_v2/z_pyproject.toml:24` | 陈旧副本,把**已被明确否决**的 `milvus-lite` 列进运行时依赖(主 `pyproject.toml:52-56` 注释说明刻意排除,因它正是 `MILVUS_LOCAL_URI` 坑的来源) | 仓库卫生 | + +--- + +## 四、P3 级(摘要) + +| # | 位置 | 问题 | +|---|---|---| +| P3-1 | `app/service/knowledge_publication_service.py:153-155` | `except Exception: pass` **真静默**(全仓唯一一处)。向量补偿删除失败无任何日志 | +| P3-2 | `app/service/profile_assembly_service.py:64-69` | `_fact_id()` 用**微秒时间戳**做主键(float→int 有精度损失;循环内批量调用同一微秒会碰撞),docstring 断言"不可能发生"过于自信 | +| P3-3 | `app/service/trade_service.py:200-205` | `raise ... from None`(**全仓唯一一处**)丢弃根因 —— 恰在交易拒绝路径上,排障时看不到原始 `risk_level` 值 | +| P3-4 | `app/service/trade_service.py:341-342` | `order_no` 用秒级时间戳+8 位 hex,无 DB 唯一约束兜底 | +| P3-5 | `app/service/offsite_fund_service.py:2527-2532` | `_next_mail_id()` 用"当日 `count()+1`"发号,并发/硬删除下会重复或断号(超 999 后 `:03d` 格式错位) | +| P3-6 | `app/service/trade_service.py:142-143` | `MAX_QUOTE_AGE` 用 `>` 严格比较(恰好 15:00 放行);`source_updated_at` 若为**未来时间**(时钟偏差)差值为负 → 永远放行 | +| P3-7 | `app/service/risk_natural_language.py:89-96` | "今天"的上界是当前时刻而非当日 24:00;`time.max`(`23:59:59.999999`)在 MySQL `fsp=0` 列上可能因舍入归入次日 | +| P3-8 | `app/worker/runtime.py:794-796` | 租约续期失败的 `logger.warning` **缺 `exc_info=True`**,真实原因(DB 抖动?连接池耗尽?)不可见 | +| P3-9 | `app/api/middleware.py:29` | `X-Trace-ID` 完全由客户端提供并原样回显,无格式校验 → 日志投毒 / 追踪串扰 | +| P3-10 | `app/core/risk_cursor.py:16-17` | 游标指纹用**无密钥** SHA-256,docstring 已诚实自述"防无意复用、不防篡改"。攻击者可离线重算指纹(服务端仍有 `user_id` 过滤,故越权受限) | +| P3-11 | `app/service/agent/implementations/risk_agent.py:342-373`;`customer_service.py:189/551` | **提示注入面**:用户文本(含正则提取的 `alert_no`)直接 f-string 进 system prompt,无分隔符/转义包裹。缓解靠"模型自律 + 输出白名单过滤",非结构化隔离。属提升面(未发现可直接越权的链路,因工具权限由 `ToolExecutor` 独立校验、不信任模型) | +| P3-12 | `app/service/agent/implementations/customer_service.py:464-465` | `_product_risk_level` 把"工具异常""向量库降级""知识库确实无此条"三种情况**统一压成 `None`**,用户看到同一句话术,故障与无数据无法分辨。(同文件另两处 `:300`/`:408` 是正确降级) | +| P3-13 | `app/worker/graph_projection_worker.py` | `processed_event_ids` 是无界内存 `set`,长期运行会持续增长 | +| P3-14 | `app/worker/runtime.py:77, 102-107` | 装配关键路径用 `_UNSET: Any` + 多个 `Any` 参数绕过 mypy strict —— 传错对象不会有类型报错,只会在运行时静默降级 | +| P3-15 | `app/service/trade_service.py:568-572, 649-653` | `scalar_one()` 在产品主数据被删时抛 `NoResultFound` → 500(历史订单应仍可查) | +| P3-16 | `app/core/fund_contracts.py:13-43` | `nav` 等金额字段无 `gt=0` / `allow_inf_nan=False` 约束 | +| P3-17 | 全项目(`trade_service.py:142,176,251,292` 等) | `datetime.now(UTC).replace(tzinfo=None)` 手工重复,而 `app/core/timeutil.py` 已提供 `to_utc_naive` —— 未统一,时区 bug 有重演空间 | +| P3-18 | `alembic/` | 迁移图为**单一 head + merge revision 齐全**(干净)—— 但无 CI 断言"恰好 1 个 head",靠人工维护 | + +--- + +## 五、我复核后修正 / 推翻的子代理结论 + +**这一节是本报告的诚信部分** —— 说明我没有直接采信子代理。 + +### 5.1 推翻:worker "双重领取 run"(子代理标为 P0) + +子代理报告:`runtime.py:542-547` 的候选 SELECT 无行锁 → 两 worker 可同时领取同一 run → P0 数据损坏。 + +**我的复核**(读 `runtime.py:733-780`): +```python +run = await session.scalar(select(AgentRun).where( + AgentRun.run_id == run_id).with_for_update()) # ← 重新加行锁 +... +claimed = await AgentRunRepository(session).claim( + run_id, worker_id, self.settings.worker_lease_seconds) +if not claimed: + return False # ← 竞争失败者正确退出 +``` +`execute()` 在**第二个事务内重新加锁并调 `claim()`**,`claim()` 返回布尔值。**落后方会正确返回 `False` 并退出,不会重复执行。** + +**结论**:这是 **P3 效率问题**(落后方白等一次锁),**不是 P0 正确性缺陷**。子代理的定级被高估。 + +### 5.2 修正:`check_compliance` 的失效方向(子代理结论正确,但我的核实更有力) + +子代理说"空 `agent_type` → 不注入话术"。我进一步发现:**`governance.py:211-212` 的 docstring 明确写"空串…保守照旧追加"**,而代码(`:219` + `:281`)实际是**空串不追加**。**docstring 与代码相反** —— 这使该缺陷比子代理描述的**更危险**(维护者会被文档误导)。 + +### 5.3 修正:`errors.py` 错误码复用不是"重复码的低级错误" + +子代理描述为"一个码承载 4 种含义"。我核实 `errors.py` 顶部与 `tests/unit/core/test_errors.py` 后确认:这是**为对齐 `docs/05 §3.6` 主表的刻意设计**,并有 AST 测试防基类被实例化。真实代价是"业务语义丢失"(P2),而非实现缺陷。 + +--- + +## 六、明确判定为"干净"的部分(正向确认) + +为避免"只列问题"的偏颇,以下经逐行核对确认**无缺陷**,可作为团队基线: + +| 组件 | 位置 | 确认点 | +|---|---|---| +| **JWT 验证** | `app/core/security.py` | `algorithms=[单一算法]` 锁定;`options={"require":["sub","iss","aud","exp","nbf","jti"]}` 强制声明;`iss`/`aud`/`leeway` 全查;`sub` 有 ASCII+十进制+长度+上界校验。**无 `alg=none`、无算法混淆、无未签名接受** | +| **HMAC 引用令牌** | `app/service/knowledge_service.py` | 密钥仅来自环境、缺失失败关闭;`hmac.compare_digest` 常数时间;前 4 段全被签名覆盖;`token_user != context.user_id` 拒绝跨用户;SQL 全参数化;对外统一 `_not_found()` 不泄露原因 | +| **登录接口** | `app/api/controllers/auth.py`、`auth_service.py` | bcrypt 常数时间 + `_DUMMY_HASH` 防时序枚举;失败原因不区分;成功失败均审计且**不记录密码**;失败审计独立 commit 不被回滚 | +| **路径穿越防护** | `app/infrastructure/document_storage.py:58-66` | `_resolve` 显式拒绝空 key、反斜杠、盘符、绝对路径、`..`、保留前缀 | +| **证据附件归档** | `app/service/risk_evidence_archive_service.py:155-214` | 取 basename + 扩展名白名单 + **魔数校验** + `_safe_root` 用 `relative_to(project_root)` 拒绝写出项目外 | +| **SQL 生成** | `app/service/financial_nl2sql_service.py` | `plan` 来自**纯代码** `RuleBasedFinancialPlanner`(不调 LLM);表/列过 `TABLE_COLUMNS` 白名单;`operator` 枚举校验;值走 `:param` 绑定。**当前不可注入**(`LIMIT` 插值仅由 Pydantic `ge=1,le=200` 兜住 —— 见 P2 加固建议) | +| **Cypher 生成** | `app/service/relationship_service.py:18-35`、`graph_model.py:39-48` | 关系名过 `ALLOWED_RELATIONSHIPS`(8 个固定值)才拼接;节点标签/属性名来自 `NODE_SPECS` 常量。**当前不可注入** | +| **Outbox 领取** | `app/repository/outbox_repository.py`、`app/infrastructure/db.py:42-68` | `FOR UPDATE SKIP LOCKED` + `worker_id` fencing token + 跨进程 `GET_LOCK`/`RELEASE_LOCK` 配对正确 | +| **Worker 健壮性** | `app/worker/__main__.py:48-66`、`outbox_worker.py:107-120`、`runtime.py:518-549` | 单轮异常不杀循环;单 handler 失败转 dead/failed + 指数退避;心跳续租失败正确 `cancel()` 子任务 | +| **游标语义** | `app/core/cursor.py`、`app/core/risk_cursor.py` | `limit+1` 探测 + 绑定指纹 + 严格 ASCII 数字校验 + 拒绝 `bool`。**分页无 off-by-one** | +| **金额精度** | `app/service/trade_service.py` | 全程 `Decimal` + `ROUND_HALF_UP`,无 float 混用 | +| **启动安全** | `app/core/config.py`、`.gitignore` | `.gitignore` 正确覆盖 `config/jwt/` 与 `.env`;无硬编码密钥命中;`graph.py:53` 在 uri/密码缺失时整体禁用图能力(正确失败关闭) | +| **生产代码卫生** | 全仓 | `app/` 下**零 `print`**、**零 `TODO/FIXME/HACK`** 注释;`except Exception` 约 105 处,**仅 1 处真静默**(P3-1),其余均带日志或明确降级语义 | +| **迁移图** | `alembic/versions/*.py` | **单一 head**,merge revision 齐全,**无同表不兼容改动** | + +--- + +## 七、修复优先级建议 + +| 顺序 | 事项 | 理由 | +|---|---|---| +| 1 | **P0-1 幂等**、**P0-3 行锁** | 资金错账,且 T002 是全项目唯一的幂等缺口 —— 修法有现成样板 | +| 2 | **P0-2 发号器** | 并发下必然冲突;`AUTO_INCREMENT` 是设计稿原意 | +| 3 | **P0-4 访客限流** | 无凭证即可被大规模刷额度 | +| 4 | **P1-1 `check_compliance`** | 最隐蔽 —— 不报错不失败测试,且 docstring 会误导人保留它 | +| 5 | **P1-2 同步 Milvus 阻塞** | 一处慢查询冻结整个进程,影响面最大 | +| 6 | **P1-4 依赖双源漂移** | 环境不一致的定时炸弹 | +| 7 | **P1-3 连接泄漏** + **P2-6 连接池** | 一起做,都补 `lifespan` | +| 8 | **P1-5 手续费上界**、**P1-6 KeyError**、**P1-7 截断标记** | 单点小改,收益明确 | +| 9 | **P1-8 数据范围口径统一** | 需先确认种子数据(见下) | +| 10 | P2 / P3 按排期收敛 | 其中 **P2-16 与 P3-10 的取舍需业务确认** | + +--- + +## 八、未核实 / 需进一步确认(完整清单) + +1. **P1-8 的 RBAC 种子 scope 组合** —— 需读 `tools/seed_test_rbac.py` 与 `sys_role_permission` 实际内容,逐角色确认是否出现"高 scope + 低 scope 权限并存"。这决定 P1-8 是否可实际越权。 +2. **P2-10 场外最低申购金额口径** —— `<= 1 元` 是业务要求,还是应读 `fin_product.min_amount`?产品表有该列但未被规则引擎使用。 +3. **P2-16 / P3-10 的产品取舍** —— 限流 fail-open、游标无密钥指纹,两处代码注释都明确自述为"有意取舍"。是否需要加固属产品决策。 +4. **`check_compliance` 是否被仓库外调用** —— 仓库内 0 引用已确认;但 `docs/09:124` 把它列为"业务代码不得覆盖"的公开 API,删它前需确认是否有外部消费者或历史契约。 +5. **`.env` 历史是否曾泄露密钥** —— `.gitignore` 已忽略 `.env`,但需 `git log --all -- .env` 确认历史。**Bash 工具在本环境不可用(见下),未能验证。** +6. **部署拓扑对限流的影响** —— `enforce_login_rate_limit` 用 `request.client.host`(`rate_limit.py:98`),**不读 `X-Forwarded-For`**。若部署在网关后且未配信任链,所有登录请求会共享代理 IP(限流有效但粗糙)。需确认部署拓扑。 +7. **`openai` 是否被动态导入** —— `Grep` 未发现,但若有字符串形式的 `import_module("openai")` 会漏。 +8. **`frozen_quantity` 是否有写入点** —— 当前无撮合队列故冻结恒为 0;P0-3 的"超卖"路径需确认是否有后台任务写 `frozen_quantity`。 +9. **`worker_retry_limit` / `worker_lease_seconds` 实际配置值** —— 若 lease 小于最慢 Agent 耗时,正常执行会被误判 `RUN_LEASE_LOST`。需查 settings 默认值。 +10. **`ModelRouterService` / `KnowledgeRetrievalService` 是否为预留实现** —— 两者在生产代码中**均无实例化点**(仅单测引用),是死代码。**且 `KnowledgeRetrievalService` 用的正是正确的 `await` 异步 Milvus** —— 它可能就是 P1-2 的现成修法,需确认设计意图。 +11. **40+ 个 Alembic 迁移的 `upgrade()` 体** —— 我只核对了 revision 拓扑(确认单一 head),**未逐条阅读**是否有两个迁移改同一列/加冲突约束。建议在真实库上跑 `tools/foundation_migration_preflight.py` + `tools/audit_constraints.py`。 +12. **`docs/06` 的 P1/P2 欠债清单未逐条核对** —— 我只抽验了"接线 `check_compliance`"一条(确认仍存在)。完整清单需对照该文档逐项 verify。 + +--- + +## 九、审查环境说明(影响可靠性) + +本次审查有一个**必须说明的环境限制**: + +- **`Bash` 工具不可用**:每次调用返回 `exit 127`,报 `shell-runtime-bash-env.sh: line 3: dirname: command not found`。 +- **`PowerShell` 工具返回空 stdout**:报 `Command completed with exit code 0` 但无输出。 + +因此**所有分析均通过 `Read` / `Glob` / `Grep` 静态阅读完成**,未执行任何动态验证。这意味着: + +- **能确认的**:代码逻辑、字段类型、调用链、是否存在锁、是否传参 —— 这些静态可判。 +- **不能确认的**:上表 §8 中所有需要"跑一次"才能定论的项(如并发碰撞概率、`_fact_id` 实际是否重复、Redis 连接数增长、迁移在真实库的表现)。 +- **另注**:`trade_service._next_id` 与 `profile_assembly_service._fact_id` 的碰撞概率,**理论上成立但未实测**。鉴于两者都在资金/画像主路径上,建议按"已成立"处理而非等实测。 + +> **本报告只做静态审查,未修改任何代码。** diff --git a/docs/演示用/多Worker接入方案-2026-09-14.md b/docs/演示用/多Worker接入方案-2026-09-14.md new file mode 100644 index 0000000..434cffd --- /dev/null +++ b/docs/演示用/多Worker接入方案-2026-09-14.md @@ -0,0 +1,307 @@ +# 运营侧额外 Worker 接入方案 + +> **日期**:2026-09-14 +> **性质**:方案文档,**未修改任何代码** +> **结论先行**:**"worker 只能有一个"这个前提不成立**,但你的担忧(主 worker 不能被拖垮)是**真实且已经存在**的——只是原因和"有几个 worker"无关,而在主循环的**串行 await**。 + +--- + +--- + +## 〇、决策结论(2026-09-14 晚,已定) + +**确认信息**:运营业务独立 / 邮件量很少 / 要求异步 / **采用 IMAP IDLE 长连接模式**(有邮件立即拉取,无邮件进入 IDLE 等待 120 秒)。 + +> **状态:✅ 前置条件已全部确认,可开工。** + +**结论:方案 C —— 独立常驻进程。** + +**确认记录**: + +| 确认项 | 结论 | 对方案的影响 | +|---|---|---| +| 业务性质 | 运营自己的独立业务 | → 排除 A(扩多邮箱) | +| 邮件量 | 很少 | → 独立进程开销可忽略,无需为省资源合并 | +| 执行方式 | 异步 / IDLE 长连接(120s) | → 排除 B+D(同进程轮询),节奏不匹配 | +| 是否写 `domain_event_outbox` | **否,只写运营自己的表** | → **独立进程方案成立**(红线 1 不触发) | + +**为什么 IDLE 推翻了原推荐的 B+D**: + +| | 主 Worker | IMAP IDLE Worker | +|---|---|---| +| 模型 | **短轮询**(`run_once()` 快速返回,无活 `sleep(1)`) | **长连接阻塞**(一次 IDLE 阻塞 120 秒等推送) | +| 节奏 | 1 秒 | 120 秒 | +| 返回时机 | 每轮都返回 | 只有收到推送或超时才返回 | + +把"阻塞 120 秒的 IDLE"塞进"每轮 1 秒的轮询循环",即使用 `gather` 也会被拖住(那个 task 不返回,gather 就不完成)。两者节奏差两个数量级,**混合在一起是给自己找麻烦**。 + +**选 C 的额外理由**: +- 独立进程 = 主 Worker **必然**不受影响,正好满足"主 worker 要保证运行" +- 邮件量很少 → 独立进程的资源开销可忽略,没有"为了省资源而合并"的理由 +- IDLE 天然是"独立服务"形态,与 `risk_scan_scheduler` 的独立入口同款 + +--- + +## 一、先纠正三个事实 + +### 1.1 项目里已经有两种 Worker 形态,不是"只能有一个" + +| 入口 | 启动方式 | 形态 | +|---|---|---| +| 主 Worker | `python -m app.worker` | 单进程内串行跑 `WorkerRuntime` + `OffsiteMailWorker` | +| 风控扫描 | `python -m app.worker.risk_scan_scheduler` | **独立常驻进程**(`risk_scan_scheduler.py:155-169`,自带 `while True` + `serve()` + `main()`) | + +**独立进程入口是有先例的**,不是新东西。 + +### 1.2 底座在设计上就支持多副本 + +已有完整的跨进程互斥机制: + +| 机制 | 位置 | 作用 | +|---|---|---| +| MySQL `GET_LOCK` 跨进程咨询锁 | `db.py:39-68`(`SCAN_LOCK_NAME`) | 规则扫描跨进程互斥 | +| `FOR UPDATE SKIP LOCKED` | Outbox 领取 | 事件不会被两个进程重复领 | +| 行锁 + `locked_until` 租约 | `AgentRun` | run 不会被重复执行 | +| 行锁 + `lease_until`/`lease_id` | `OffsiteMailCursor` | 邮件不会被重复收 | + +**结论**:多开一个进程不会导致重复消费,架构是留了口的。 + +### 1.3 场外邮件 Worker 早就在主 Worker 里了 + +`__main__.py:47-52`: + +```python +offsite_worker = OffsiteMailWorker(settings) +while True: + worked = await runtime.run_once() + worked = await offsite_worker.run_once() or worked +``` + +并且注释里有一段很重要的历史: + +> "场外收件 Worker 必须与底座 Worker 同进程同入口:2026-09-11 01:45 的一次批量文件覆盖把这处接线删掉了,导致邮件 Worker 完全不再运行、邮箱无人收取。" + +**所以"拉邮箱"这件事本来就属于主 Worker 的职责范围。** 组员要加的如果是同类需求,第一选择应该是接进来,而不是另起炉灶。 + +--- + +## 二、真正的隐患:主循环是串行 await(**已经存在**) + +```python +worked = await runtime.run_once() +worked = await offsite_worker.run_once() or worked # ← 串行 +``` + +`offsite_worker.run_once()` 内部要做 **IMAP 连接 → 拉邮件 → 存附件 → OCR/大模型识别 → 写业务表**,一次批次耗时可能几十秒。 +在此期间**主循环完全停摆**:`agent_run` 排队、`domain_event_outbox` 事件堆积、记忆抽取停滞。 + +**这才是"主 worker 要保证运行"的真正威胁——它跟新加几个 worker 无关,现在就存在。** + +--- + +## 三、先问清一个问题(决定走哪条路) + +> **组员要拉的邮箱,是"另一个邮箱的同类场外基金邮件",还是"运营自己的、和场外基金完全无关的业务邮件"?** + +这两者的答案完全不同。 + +--- + +## 四、方案 A:同类邮件、另一个邮箱 —— 扩成多邮箱(推荐) + +### 事实支撑(**表结构天生就支持,是代码没用上**) + +`OffsiteMailCursor` 有: + +```python +UniqueConstraint("mailbox", "folder", name="uk_offsite_mail_cursor") +``` + +但 `_claim_cursor()`(`offsite_mail_worker.py:208`)写死了单邮箱: + +```python +.where(OffsiteMailCursor.mailbox == self.settings.offsite_mailbox, ...) +``` + +**唯一约束是按 (mailbox, folder) 建的,说明设计时就是打算支持多邮箱的**,只是配置层收成了一个。 + +### 做法 + +1. 配置从 `offsite_mailbox: str` 扩成多邮箱列表(或加一张邮箱配置表) +2. `OffsiteMailWorker` 持有多份配置,或主循环按邮箱逐个构造/调用 +3. `_claim_cursor` 按当前 mailbox 取游标(现有逻辑不用改,只要 mailbox 是可变的) + +### 评价 + +| 项 | 结论 | +|---|---| +| 新增进程 | ❌ 不需要 | +| 复用现成能力 | ✅ 租约、退避、死信、附件存储、识别全复用 | +| 部署改动 | ✅ 无 | +| **残留风险** | ⚠️ 仍在同一进程 → **必须配合方案 D** | + +--- + +## 五、方案 B:独立业务 —— 挂到主入口,但改成并发(推荐) + +### 做法 + +在 `__main__.py` 里新增一个 worker 实例,但**不要用 `await` 串行调**,改为并发: + +- 轻量的:两个 worker 各自 `asyncio.create_task`,主循环按 `worker_poll_seconds` 轮询 +- 或:主循环每轮用 `asyncio.gather(*(w.run_once() for w in workers))`,并给每个 worker 加超时 + +### 评价 + +| 项 | 结论 | +|---|---| +| 新增进程 | ❌ 不需要,部署形态不变 | +| 隔离性 | ⚠️ 同进程,一个 worker 崩溃会连累(但可用 try/except 兜住) | +| 改动量 | 小(约 20 行) | +| 附带收益 | ✅ **顺带修掉 §2 的串行阻塞隐患** | + +### 必须遵守 + +每个 worker 的 `run_once()` 外面都要 try/except 吞异常并记日志,**绝不能让一个 worker 的异常跳出主循环**。项目已有这个模式(`__main__.py:53-62`),照抄即可。 + +--- + +## 六、方案 C:独立业务 —— 独立进程 + +### 事实支撑 + +`risk_scan_scheduler.py` 已经是这个形态,直接照抄 `serve()` / `main()` 结构。 + +### 做法 + +新建入口(如 `python -m app.worker.ops_mail`),独立部署、独立守护。 + +### 评价 + +| 项 | 结论 | +|---|---| +| 隔离性 | ✅ 最好:互不影响,一个挂了另一个照跑 | +| 运维成本 | ⚠️ 多一个进程要守护/重启/监控 | +| 主 Worker 安全 | ✅ 完全不受影响 | + +### 硬前提(最重要) + +**新进程绝不能消费底座的队列**(`domain_event_outbox` / `memory_sync_outbox`)。 + +项目真实踩过这个坑,`__main__.py:31-44` 有完整记录: + +> 本入口曾有一个 `MemorySyncOutboxWorker` 与 runtime 内那套**同时读同一个队列**,而两套的 handler 并不相同……同一事件被哪套领到结果不定,等于**同一事实在图里有两种说法**。 + +**判断标准**:新 worker 只拉自己的邮箱、写自己的业务表(或投递到**自己的**事件类型),与底座队列无交集 → 安全。 +一旦它要消费 `domain_event_outbox`,就必须回到方案 B。 + +--- + +## 七、方案 D:无论选 A/B/C 都建议做 —— 主循环解耦 + +把"串行 await 多个 worker"改成"并发 / 独立 task",让慢的 worker 不拖住快的。 + +这是**独立收益项**:就算最后决定不加任何新 worker,当前 `offsite_worker.run_once()` 串行阻塞主循环的问题也值得单独修。 + +--- + +## 八、决策矩阵 + +| | 同类邮件、另一个邮箱 | 独立业务(运营自有) | +|---|---|---| +| **首选** | **A + D** | **B + D**(同进程)或 **C**(独立进程) | +| 何时选 C | — | 邮件量大 / 处理耗时长 / 要求故障隔离 | +| 新增进程 | 否 | 仅 C | +| 隔离性 | 低 | B 中 / C 高 | +| 改动量 | 小 | B 小 / C 中 | +| 部署改动 | 无 | C 需新增守护 | + +--- + +## 九、四条红线(均来自项目真实事故) + +1. **不要两套 worker 消费同一个队列** —— `MemorySyncOutboxWorker` 事故,导致同一事实在图里有两种说法。 +2. **新 worker 必须自带租约或幂等** —— 不能假设"只会有一个实例"。参考 `OffsiteMailCursor` 的 `lease_until` + `lease_id` + 心跳续租。 +3. **异常必须被吞掉并记日志** —— 不能让单个 worker 的失败杀掉主循环(`__main__.py:53-62` 已是范例)。 +4. **独立进程必须独立可观测** —— 至少要有独立日志前缀 + 健康检查,否则"它挂了"这件事无人知晓(历史上场外 Worker 被误删接线后长期无人发现)。 + +--- + +## 十、补充:IDLE 模式的实现要点(给组员) + +IMAP IDLE(RFC 2177)的标准流程: + +``` +连接 → 登录 → SELECT 收件箱 + └─ 发 IDLE 命令 → 阻塞等待(最长 120s) + ├─ 收到服务器推送(如 `* N EXISTS`)→ 发 DONE → FETCH 拉新邮件 → 处理 + └─ 120s 超时无推送 → 发 DONE → 重新发 IDLE(保活) +``` + +### 四个必须注意的坑 + +**1. 必须用 `asyncio.to_thread` 包住 IDLE(最严重)** + +IDLE 是**同步阻塞**的(阻塞 120 秒等推送)。如果在协程里直接调用,会**冻结整个事件循环 120 秒**——同进程的 Agent 执行、outbox 消费全部停摆。 + +项目里已有正确范例,照抄:`offsite_mail_worker.py:290` + +```python +messages = await asyncio.to_thread(...) +``` + +**2. 120 秒要主动退出并重新 IDLE** + +不要指望一次 IDLE 长驻。服务器和中间网络设备(NAT/防火墙)都会掐断长时间静默的连接。每 120 秒退出重发是正确做法,也符合 RFC 2177 的建议(客户端应定期退出并重发 IDLE,规范建议上限 29 分钟)。 + +**3. 断线重连 + 退避** + +IDLE 长连接必然会遇到:网络抖动、服务器重启、超时踢下线。需要: +- 捕获连接异常 → 重新登录 → 重新 SELECT → 重新 IDLE +- 重连间隔加**指数退避**(参考 `runtime.py:901-904` 的既有实现),避免服务器不可用时疯狂重连 + +**4. 优雅退出与资源释放** + +独立进程必须处理 SIGTERM,否则重启部署时 IMAP 连接与数据库引擎泄漏。照抄 `risk_scan_scheduler.py:168-169`: + +```python +finally: + await engine.dispose() +``` + +### IDLE 模式下仍需遵守的红线 + +- **单实例假设是危险的**:IDLE 长连接看似"天然单实例",但一旦将来有人多开一个进程做高可用,两个实例同时 IDLE 同一邮箱会**重复收件**。建议现在就加一道互斥(直接复用 `OffsiteMailCursor` 的 `lease_until` + `lease_id` 思路,或用一个 MySQL `GET_LOCK`),成本很低。 +- **不碰 `domain_event_outbox`**:这条不变。如果运营 Worker 处理完邮件需要触发底座流程,回来找我,改走同进程方案。 + +--- + +## 十一、确认记录与后续约束 + +### 11.1 四项前置全部已确认 + +| # | 问题 | 结论 | +|---|---|---| +| 1 | 同类还是独立业务 | **运营自己的独立业务** | +| 2 | 邮件量与耗时 | **很少** | +| 3 | 能否接受同进程 | **要求异步**;且 IDLE 长连接后同进程轮询方案已不适用 | +| 4 | 是否写 `domain_event_outbox` | **否,只写运营自己的表** ✅ | + +### 11.2 后续若需求变更,必须回来重新评估 + +以下任一情况出现,**独立进程方案不再成立**,需要重新设计: + +- ⚠️ 改为需要触发底座流程(生成 Agent 任务 / 触发风控预警 / 触发记忆抽取)→ 必须写 `domain_event_outbox` → **回到同进程方案** +- ⚠️ 改为需要消费 `domain_event_outbox` / `memory_sync_outbox` 的事件 → **两套消费者抢同一队列**,重演 `MemorySyncOutboxWorker` 事故 +- ⚠️ 邮件量增长到需要多实例 → 必须先补互斥(§10 红线),否则重复收件 + +### 11.3 验收清单(上线前逐条确认) + +- [ ] IDLE 调用已包 `asyncio.to_thread`(否则冻结事件循环) +- [ ] 120 秒后主动 `DONE` 并重新 IDLE(保活) +- [ ] 断线重连带指数退避(不疯狂重连) +- [ ] `finally: await engine.dispose()`(优雅退出) +- [ ] 单实例互斥已加(防将来多开重复收件) +- [ ] 确认未引用 `domain_event_outbox` / `memory_sync_outbox` +- [ ] 独立进程有独立日志前缀 + 健康检查(否则挂了无人知晓——场外 Worker 被误删接线的教训) +- [ ] 主 Worker 与该进程同时运行时,主 Worker 的 outbox 消费速率无明显下降 diff --git a/docs/演示用/文档一致性审计报告-2026-09-14.md b/docs/演示用/文档一致性审计报告-2026-09-14.md new file mode 100644 index 0000000..18120fb --- /dev/null +++ b/docs/演示用/文档一致性审计报告-2026-09-14.md @@ -0,0 +1,179 @@ +# 全项目文档一致性审计报告 + +> **审计日期**:2026-09-14 +> **审计范围**:仓库内 `docs/` 下全部 110 份 `.md`(含 `docs/演示用/`、`docs/风控业务演示文档/`、`docs/验收与审计/`、`docs/superpowers/`) +> **比对基准**:当前源码、配置、以及文档之间的相互一致性 +> **审计方式**:全量扫描 → 与代码逐条核对 → 修正 → 复核 +> **重要说明**:本次审计**只修改文档,未改动任何业务代码**。 + +--- + +## 一、审计方法与可信度说明 + +### 1.1 做法 + +1. **全量盘点**:`Glob docs/**/*.md` 得到 110 份文档。 +2. **并行审计**:按主题分 **6 批**派只读子代理逐份核对,每份要求给出: + - 结论(一致 / 过时 / 部分过时) + - **文档侧证据**(原文引用或行号) + - **代码侧证据**(`文件:行号`) + - 明确列出"未核实"的部分 +3. **逐条自证**:**所有子代理给出的计数类结论都被我自己重新 `Grep`/`Read` 复核一遍**。 +4. **修正**:对确实需要同步的文档逐个编辑。 +5. **复核**:改完再 grep 确认修改点已生效。 + +### 1.2 ⚠️ 一条重要提醒:子代理的计数不可直接采信 + +审计过程中,有子代理报告 `tools/seed_test_rbac.py` 的权限数为"72 条"、另一份报"66 条"。 +**我自己 `Grep '^\s*\(9\d{3},'` 数出来是 65 条(`9001`–`9065`)**,并以此为准。 + +> **结论**:凡涉及"多少张表 / 多少条权限 / 多少个接口"这类计数的结论,本报告全部以我本人的 +> 直接复算为准。使用时也建议照此办理——**活体核验优于任何文档的记载**。 + +### 1.3 最高优先级发现:`AGENTS.md` 自身就含过时信息 + +`AGENTS.md` 是"接手先读"的入口文件,**它的错误会向所有读者传播**,因此本次优先修正它。 +三个子代理独立发现了同一批问题(互相印证): + +| `AGENTS.md` 原话 | 实际 | 影响 | +|---|---|---| +| **"有四套页面"** | 实际 **6 个角色目录** | 漏了 `employee-advisor/`、`employee-operations/`;且 `employee-advisor/` 与 `employee-console/` 是**两个不同角色**,不是改名 | +| **"9001-9046 号段"** | 实际 **`9001`–`9065`,共 65 条** | 按旧号段写权限码会直接踩空 | +| **"架构师环境还有风控的 9 条白名单"** | 是**该环境的 `config_release` 配置事实**,不是代码常量 | 会有人在代码里找一个不存在的"9 条清单" | + +--- + +## 二、已更新的文档清单及主要变更 + +### 2.1 核心基线类(5 份) + +| 文档 | 主要变更 | +|---|---| +| **`AGENTS.md`** | ① 前端章节:"四套页面"→**6 角色目录表**(含 `guest`/`customer`/`employee-console`/`employee-risk`/`employee-advisor`/`employee-operations`),补 ⚠️ 说明 `employee-advisor/` ≠ `employee-console/`、访客产品页已不走 `mock-data.js` 而走真实端点 P001/P002;② RBAC 章节:`(9001-9046 号段)`→`(9001-9065 号段,共 65 条)`,新增**号段演进段**(逐段列明 17 个区间,并标注 `9035-9040`/`4041-4046` **从未存在**);③ "风控的 9 条白名单"→注明是**环境 `config_release` 事实**、本机发布脚本只发 4 条;④ `TODO.md` 行 `(实为 51 张业务表)`→`(实为 89 张业务表)`;⑤ **新增"画像有两个存储"条目**(值在 MySQL、关系在 Neo4j,不一致时以 MySQL 为准,核验用 `python tools/reconcile_graph.py`) | +| **`docs/05-接口文档.md`** | ① §8.4 工具表:`4 个`→5 行,`search_knowledge` 明确为**正式名**、`query_knowledge` 为**别名**,补超时(15s/10s),新增 ⚠️ 块说明"两者都要发布"及各业务线工具;② §18 完成判据:`52 张表`→**90 张 = 89 张业务表 + `alembic_version`**,附 `49 → 52 → 89` 修正链;③ §19 总目录:把 `场外基金运营|不属于当前系统` 改为**已落地**(正确前缀 + 旧信封 + `page`/`page_size` 分页);④ **新增 §19.1 补充接口段**:场外基金、场外运营、访客令牌、客户入驻、推广物料、账户与交易看板,另注 any-of 权限语义与 `RK001–RK015` | +| **`docs/14-Agent组员统一接入说明书.md`** | ① §8 工具表:错误的"4 个公共只读工具"→**5 行正确表**(`search_knowledge` 正式名 / `query_knowledge` 别名仅 visitor+customer),注册行号修正为 `L248-388`,补"两者都要发布"的 ⚠️,另加**各业务线工具表**(风控 3 / 投顾 5-6 / NL2SQL 1 / 探针 1);② §14 当前状态:`已注册两个业务 Agent`→**7 行表**(类名 / `agent_type` / 说明),函数行号 `L407-447`,并说明注册顺序是**追加式** | +| **`docs/09-底座使用文档.md`** | ① 表数:`52 张,其中 51 张业务表`→**90 张 = 89 业务 + `alembic_version`**,加**分域表**(场内 51 / 场外推广 17 / 投顾 21)与口径沿革说明(并澄清 `audit_schema.py` 的期望值是**现算**的,不是手写清单);② Agent:`两个`→**7 个表**;③ **§14 工具表整段重写**——原文把 `search_knowledge`/`query_knowledge` 的主从关系写反、超时写成 8s、角色写错,均予更正 | +| **`docs/02-数据库建表设计.md`** | ① 头部:保留场内分项,但补 ⚠️ 说明**全库现为 90 张 = 89 业务 + `alembic_version`**(51+17+21),澄清"建成后总表数 51"是**场内口径**;② 标题 `## 4. 51 张表总览`→`## 4. 51 张表总览(场内口径)` + ⚠️;③ 核验清单行同步说明 | + +### 2.2 数据库与审计类(2 份) + +| 文档 | 主要变更 | +|---|---| +| **`docs/08-数据库结构审计基线.md`** | 在原有 `2026-09-11:51 → 68` 更新块之后,追加 **`2026-09-14(第二次):68 → 89`**:说明增量是**投顾 21 张**(`advisor_*`,迁移 `20260910_advisor_*` + `20260911_advisor_*`)、登记文档**待补**、给出新公式(51+17+21=89,含版本表 90),并新增 **"历史沿革"小节**保存 51→68→89 的完整轨迹;空库表行 `68 张业务表`→`89 张业务表` | +| **`docs/28-场外与推广域数据表登记.md`** | 头部"维护约定"改为:本文回答"**场外/推广这 17 张**从哪来"(不是"68 张从哪来"),并加 ⚠️ 块统一为 89=51+17+21 口径,明确**17 张的登记内容本身无误、不改**,投顾 21 张不在本文范围 | + +### 2.3 流程与架构类(4 份) + +| 文档 | 主要变更 | +|---|---| +| **`docs/03-平台端到端流程文档.md`** | ① §11 场外流程:"**未建设**独立业务表前…"→前提已满足,补 ✅ 落地状态(17 表 + `OffsiteFundAgent`/`PromotionMaterialAgent` + 3 条路由 + 运营前端页 + **旧信封与 page 分页的差异提示**),并补 ⚠️ 说明限额已从"金额"改为**份额**(10%/20%);② §15 上线验收:`四类 Agent`→**7 类**并列全 | +| **`docs/22-四大Agent流程实现拆解.md`** | 头部加 **"本文是开工前评估稿,多处结论已被实现推翻"** 的 ⚠️ 块 + **4 行对照表**(52 表→90;"无场外表"→已建 17;"必须新建场外一组表"→已成;金额口径→份额口径);§5 正文就地标注已完成;核对规则行改为份额口径 | +| **`docs/38-架构对齐-记忆与画像投影链路.md`** | §三 加 **"四个分歧均已收口"对照表**(1 沿用主干 outbox;2 直接快照与候选复核**并存**;3 **放弃 ZSY 那套客服 Agent**、保留访客/候选/转人工;4 按"只取增量"处理)+ 补 `neo4j_profile_projection.py` **未被装配**的警告(不要加回 `__main__.py`,否则又成两套图投影);§四标注三步均已执行完成 | +| **`docs/36-PR7合并记录与权限号段修正.md`** | 头部加 **口径修正块**:`40 条 / 45 条 / 38 条`都是 **2026-09-12 当日快照**,现为 **65 条 / `9001-9065`**,含号段归属表与"`9035-9040`、`4041-4046` 从未存在"的提示;§4、§5.2 就地标注 | + +### 2.4 客服 / RAG / 前端类(4 份) + +| 文档 | 主要变更 | +|---|---| +| **`docs/18-知识检索接入方案.md`** | ⚠️ **本次安全相关性最高的一处**。① §4.2:删掉"集合内字段:`knowledge_id`/`title`/`snippet`/…"这段**硬编码字段名**(`AGENTS.md` 明令禁止),改为说明**运行时探测**(`app/core/knowledge_schema.py`:`FIELD_CANDIDATES` / `REQUIRED_LOGICAL_FIELDS` / `SchemaCache`),并加**两套环境 schema 对照表**+"Milvus 报 `field doc_id not exist` 会打挂另一套环境"的后果说明;② 步骤 2 的 `search()` 签名:**去掉 `output_fields` 的硬编码默认值**,改为必传参数 + 要点说明;③ 文件头补 3 条修正(工具正式名 `search_knowledge`;字段名不得硬编码;配置键名以 `RuntimeConfigService` 为准) | +| **`docs/41-客服Agent前端开发约束_v1.md`** | ① §3 目录树整段替换为**真实 6 角色树**(逐页列出),附**新旧对照表**,注明 `employee-advisor/` ≠ `employee-console/`、访客产品页已用 P001/P002;② §12 标题与全节:修掉 **4 处不存在的 `portal/customer/chat/`** 引用(该目录从未落地),改为指向**常驻浮窗** `portal/common/customer-service-widget/`,跳转方式改为浮窗 API `window.CustomerServiceWidget.open({ prefill })`,并说明会话上下文存 localStorage、无需返回 chat 页 | +| **`docs/26-JWT密钥管理与轮换.md`** | §8 标题 `与"暂无登录接口"的关系`→`与登录接口的关系`:补 ✅ **该缺口已闭环**——`POST /api/v1/auth/tokens`(`app/api/controllers/auth.py:24,32`,接口编号 **A034**)已存在,服务端用私钥签发 RS256;并指出验签侧(`JwtAuthenticator`)与身份解析侧(`IdentityService`)**确实未改**,印证原文的预判;原文以删除线保留作设计记录 | +| **`docs/12-基金行情数据底座接入开发计划.md`** | 头部补 **3 件原文完全没写的运行期事实**:① **`MAX_QUOTE_AGE = 15 分钟`**(`app/service/trade_service.py:79`),超时后**所有委托一律 503 `FUND_QUOTE_UNAVAILABLE` 且无自动刷新**(`app/core/errors.py:260-264`),补刷命令 `tools/sync_market_prices.py`(立即生效、无需重启);② 两个同步脚本未被登记(`sync_market_prices.py` / `sync_nav_history.py`);③ `query_fund_quote` **15s 超时的算式依据**(适配器最坏 8.2s × 1.8),并说明旧 5s 会永远撞工具超时;另补启动链与解释器门槛(**必须是"版本≥3.11 且能 import 依赖",不能只看 `--version`**) | + +### 2.5 演示与验收类(5 份) + +| 文档 | 主要变更 | +|---|---| +| **`docs/44-演示流程.md`** | ① 补注 **`启动金融Agent平台.bat` 在桌面上、不在仓库里**(仓库只有 `启动平台.bat`),说明两份由 `tools/make_launcher_bat.py` 统一生成,**必须 GBK + CRLF + 无 BOM**(缺任一条 cmd 解析错乱,实测报 `AT 命令已弃用`);② 账号表下补 ⚠️:**`advisor_t`(9020) 与 `offsite_t`(9006) 不在 RBAC 种子脚本里**(种子只有 `cust_t`/`risk_t`/`admin_t`/`review_t` 四个),由 `grant_*.py` 系列创建,**重跑种子不会重建**;换机器登录失败先查 `sys_user` 有无该行 | +| **`docs/42-场内基金知识条目草稿.md`** | 头部补与 `docs/43` 的关系说明:**两份是同主题不同版本**(本文=草稿/取数 09-13,`docs/43`=入库版/取数 09-11),`511810` 的 `0.2332` 与 `0.2661` **两个都是真实 `DWJZ`、只差两天**,**入库前须先定用哪一天口径**,以免知识库与 `fin_product.current_nav` 打架 | +| **`docs/43-场内基金产品手册(知识库入库版).md`** | §二 产品清单下加 ⚠️ 交叉引用(同上),`511810` 行净值加 ⚠️ 标记,指向 `docs/42` 第 220-245 行的查证过程(含 `DWJZ` vs `LJJZ` vs 场内交易价的区别) | +| **`docs/验收与审计/phase1-acceptance-criteria.md`** | 头部加 **"本文是 2026-09-10 的缺口盘点快照、其后已全部补齐"** + **7 条逐条订正表**(其中第 3 条从 ⚠️"未达成/最大缺口"→✅ **已达成**,Milvus 实测命中 score 0.7837;第 7 条从 🔴"规划缺口"→✅ 已达成),正文表头加"已过时"标注 —— **保留老师验收标准原文**是本文的价值所在 | +| **`docs/验收与审计/phase1-acceptance-report.md`** | ① 第 2 条 `52 张表`→补 ⚠️ 现为 **90 张**口径;② 第 4 条与文件清单 `query_knowledge`→标注**正式名为 `search_knowledge`**(2 处) | +| **`docs/风控业务演示文档/08-数据库依赖与读写边界.md`** | 读取表下补 **⚠️ 实现归属说明**:把混在一起的表按 repository 划为**三分**(风控域 / 身份域 `identity_repository.py` / 场内交易域只读),并补 **✅ 风控红线 2 的实测复核**:`risk_repository.py` 中**没有任何** `memory_unit`/`profile_snapshot` 引用,画像只读 `fin_customer_profile`(8 处以上 join) | + +### 2.6 交接与过程产物类(8 份) + +| 文档 | 主要变更 | +|---|---| +| **`docs/superpowers/handoff/2026-09-11-交接文档-客服Agent与RAG收尾.md`** | 头部加 **4 条复核**:① 分支指针 `origin/NL_develop` 已从 `fb7d2f7` **前进到 `1f62aca`(+10 提交)**,引用 SHA 前请自查;② **本文内部就有三个互相冲突的测试通过数**(§0 的 `1013 passed` vs §4/§7 的 `804 passed`),说明是"合并前/后"两次快照被混写,**以 §0 为准且当前基线一律重跑取数**;③ §0 末尾列的"两个最大遗留"**现已全部解决**(`memory_sync_outbox` 消费者已接入 `runtime.py` L551/L523;Redis 密码已配);④ 表数 52→90 口径。§4 与 §7 的正文数字就地加注 | +| **`docs/superpowers/analysis/2026-09-10-现状与差距分析.md`**
**`…/2026-09-10-范围重定位与画像讨论记录.md`**
**`…/客服Agent专项设计方案-分析报告.md`**
**`docs/superpowers/plans/2026-09-10-客服Agent与RAG实施计划-qyqy版.md`**
**`…/2026-09-10-foundation-safe-migration.md`**
**`docs/superpowers/specs/2026-09-10-customer-service-agent-design.md`**
**`…/knowledge-retrieval-infra-design.md`**
**`…/foundation-safe-migration-design.md`** | 统一加 **"🗂 过程产物 · 结论已归档"** 批注,指明:**判断当前进度请看 `docs/验收与审计/phase1-acceptance-report.md`**,保留本文是为了留存"当时基于什么事实做了哪些判断"的证据链。(此前 8 份中**只有 2 份**有类似自述)。个别文件加了针对性提示:`customer-service-agent-design` 注明实现时工具名由 `query_knowledge` 改为 `search_knowledge`;`knowledge-retrieval-infra-design` 注明实现期有一处偏离(字段名改为运行时探测) | + +--- + +## 三、经核对**无需修改**的文档及理由 + +下列文档经逐条核对与当前实现**一致**,或**已正确自我声明**为历史/废弃,故**未做修改**: + +### 3.1 已自我声明为历史 / 废弃(`AGENTS.md` 亦已列为"不要用来判断当前进度") + +| 文档 | 为什么不用改 | +|---|---| +| `docs/04` / `docs/06` / `docs/10` / `docs/13` / `docs/99` | 经 2026-09-11 评审**决定保留**(删除收益为零、保留成本同样为零),已列在 `AGENTS.md` 的 **D 类**"不要用来判断当前进度";`docs/99` 自身头部已声明废弃并指向 `docs/05`。内容未核对、可能过期,属**有意保留的历史参考** | +| `docs/01-通用Agent平台开发设计.md` | 只讲 **MVC+S 分层约束、`BaseAgent` 骨架、`AgentFactory` 契约**,不含 Agent 清单与表数,**没有过时事实** | +| `docs/superpowers/ARCHIVE-2026-09-11-文档清理归档.md` | 自身就是"归档说明",已标注被替代的章节 | + +### 3.2 经核对与实现一致 + +| 文档 | 核对结论 | +|---|---| +| **`docs/40-前端验收清单.md`** | **实际是正确的**:它已列出**全部 6 个角色目录**(含 `employee-advisor`),越权矩阵与实现相符;`offsite_t`/`advisor_t` 不在种子脚本一事它**已有自述** | +| `docs/27-NL2SQL接入说明.md` | `query_financial_data` / 权限 `financial:nl2sql:read` / 角色 advisor,operator,admin,super_admin 与 `bootstrap.py:301-308` **逐字一致**;且确认 NL2SQL **不在**公共工具列表 | +| `docs/17-接口文档易懂版.md` | 自述"不替代 `docs/05`",且内容一致 | +| `docs/19` / `docs/24` / `docs/25` / `docs/29` / `docs/31` / `docs/37` / `docs/39` | 逐条核对与实现一致(`docs/24` 仅有一条占位电话热线略显陈旧,无实质影响) | +| `docs/21-风控Agent接入设计与实现说明.md` | 以风控为主题,"51 张"是其**孤立环境自己的口径**,与全库口径不冲突 | +| `docs/28` 的**登记内容本身** | 17 张表的逐表登记**无缺陷**(本次只改了头部口径措辞) | +| `docs/风控业务演示文档/01、03、04、05、11、12、13、14、19、20` | 与 `risk_agent.py` 的实现一致:**3 个工具 / 4 类意图**,注册位置 `bootstrap.py:330-349` 相符 | +| `docs/风控业务演示文档/21` | 往返核对干净:§3 截断提示对应 `risk_agent.py:376-381`;§4 密钥路径对应 `get_settings().jwt_private_key_path`;**§7 风控扫描调度器确实尚未接入主 Worker**,文档已正确标注为待办 | +| `docs/evidence/` 下的取证产物 | 是运行产出的证据文件,不是需要同步的说明文档 | + +### 3.3 同主题重复文档中的"权威版" + +| 文档 | 核对结论 | +|---|---| +| `docs/11` / `docs/15` / `docs/16` | 三份均已在开头标注"**以 `docs/14` 为准**",且 `docs/14` 本次已修正 → **重复品无需再改** | +| `docs/客服Agent接入底座扩展说明_v1.md` | 与实现一致 | +| `docs/验收与审计/phase1-acceptance-report.md` | 主体一致(仅表数与工具名两处小注,已改) | +| `docs/33` / `docs/34` / `docs/35`(ZSY 三件套) | **不是重复品**,是**逐层推进的评审往返链**,`docs/35` 为最终版,链条完整、无需改 | +| `docs/NL_develop` 系列 5 份(交付说明 + 两轮架构师评审 + 两轮回复) | 同样**不是重复品**,是一次**完整的评审往返**(交付说明即最终版),无需改 | +| `docs/客服Agent一期` / `二期` 相关 | 其中**分支名**(`ZSY_develop2`、`develop`)略显陈旧;但该系列已由 `docs/36`(PR #7 合并记录)接管为权威口径,**改动价值低于误改风险**,故保留并在此说明 | + +--- + +## 四、本次发现的最值得注意的问题(按严重度) + +| 严重度 | 问题 | 处置 | +|---|---|---| +| 🔴 **最高** | **`docs/18` 硬编码 Milvus 集合字段名**(`knowledge_id`/`snippet`/…)。`AGENTS.md` 明令禁止,因为两套开发环境的 schema 不同;硬编码任一套会让 Milvus 直接报 `field doc_id not exist` → 三集合全失败 → 客服一律"引导人工" | 已改为**运行时探测**的说明,并删掉代码示例里的硬编码默认值 | +| 🔴 **高** | **`AGENTS.md` 自身含三处过时信息**(前端"四套页面"、RBAC "9001-9046"、"风控 9 条白名单")。它是接手入口,错误会传播 | 已全部修正 | +| 🟠 **中高** | **`docs/09` §14 把 `search_knowledge` 与 `query_knowledge` 的主从关系写反**,且超时、角色均错。照此发布白名单会导致**访客或登录客户其中一方一问即失败** | 已整段重写 | +| 🟠 **中** | **表数口径在 5 份文档间不一致**(49 / 51 / 52 / 68 / 72) | 已统一为 **89 张业务表 = 场内 51 + 场外/推广 17 + 投顾 21**(含版本表 90),并保留历史数值作明确标注的沿革 | +| 🟠 **中** | **`docs/41` 描述了一个从未落地的 `customer/chat/` 页面**(前端约束文档,会误导实现) | 目录树整段替换为真实 6 角色树;剩余 4 处引用改为常驻浮窗 | +| 🟡 **中低** | **`docs/22` 是"开工前评估稿",多处结论已被实现推翻**,但正文未标注 | 头部加"已作废"块 + 4 行对照表 | +| 🟡 **中低** | **交接文档内部就有三个互相冲突的测试通过数**,且分支指针已过期 | 加注说明来历与正确读法,强调"基线一律重跑取数" | +| 🟡 **中低** | **`docs/42` / `docs/43` 对同一只基金的净值不同且互不引用**(曾被认为是数据错误) | 加交叉引用,明确"两个都是真实值、差异有因(相差两天)、入库前须定口径" | + +--- + +## 五、留给后续的建议(本次未做) + +以下事项**属于本次范围之外或需要业务决策**,未改动,建议后续处理: + +1. **投顾 21 张表的登记文档仍缺**(`advisor_*`)。`AGENTS.md` 与 `docs/08` 均已标注"待补",建议按 `docs/28` 的口径另立一份。 +2. **`docs/36` 的正文号段表到 `9046` 结束**是对的(那正是当时的上界),现已在头部加了修正块。若希望正文也完整到 `9065`,需补充 `9047+` 的语义描述。 +3. **风控扫描调度器尚未接入主 Worker**(`docs/风控业务演示文档/21` §7 已如实标注),属**代码待办**而非文档问题。 +4. **`docs/客服Agent一期/二期` 的分支名陈旧**,但已被 `docs/36` 接管权威口径,未改(改动价值低于误改风险)。 + +--- + +## 六、本次审计的编辑纪律(说明) + +对每一个过时数字,采用的都是 **"保留旧值 + 追加更正"** 而非"静默覆盖": + +- 旧值用删除线或"原文写…"的方式保留; +- 更正内容**带日期戳**(`2026-09-14`); +- 尽量指向**活体核验命令**(如 `python tools/audit_schema.py`、`python tools/check_rbac_seed_consistency.py`)。 + +**理由**:这些文档里的旧数字**在其当时都是正确的**;把它们直接抹掉会销毁审计线索, +并且过一段时间又会有人拿着另一个时点的数字来问同样的问题。保留沿革比覆盖更有价值。 + +> **本次审计只修改文档,未改动任何业务代码。** diff --git a/docs/演示用/记忆系统修复文档-2026-09-14.md b/docs/演示用/记忆系统修复文档-2026-09-14.md new file mode 100644 index 0000000..d78a8a5 --- /dev/null +++ b/docs/演示用/记忆系统修复文档-2026-09-14.md @@ -0,0 +1,289 @@ +# 记忆系统修复文档 + +> **日期**:2026-09-14 +> **配套文档**:`docs/演示用/记忆系统排查报告-2026-09-14.md`(先看那份,本文是它的落地方案) +> **核心判断**:记忆系统**没有坏**——库里有 2 条真实记忆,170 条抽取事件全部成功消费,画像链路完整。 +> 真正的问题是 **3 处「断头路 / 死代码」+ 可观测性缺失**。本文针对这 4 类给出修复。 + +--- + +## 〇、修复总览 + +| 编号 | 问题 | 性质 | 状态 | 风险 | +|---|---|---|---|---| +| **F1** | `RecalledMemory.content` 无任何消费方(断头路) | 死代码 | ✅ **已修**(能力就绪 + 接线) | 零(空记忆时 prompt 逐字不变) | +| **F2** | `governance.review` 的 `known` 引用校验永远不触发 | 死校验 | ⚠️ **待决策**(见 §3) | — | +| **F3** | `governance.recall` 对员工身份恒空且无任何提示 | 语义陷阱 | ✅ **已修**(明确日志) | 零 | +| **F4** | 无可观测出口(`memory_unit` 无读接口、无日志) | 可观测性 | ✅ **已修**(上轮) | 零 | +| **F5** | 客服对话完全不走记忆链路 | **设计取舍,非缺陷** | ❌ 不修(见 §4) | — | + +**本轮改动的文件**(全部通过 AST 校验 + 行为验证): + +| 文件 | 改动 | +|---|---| +| `app/service/agent/base.py` | 新增 `memory_context_text()` | +| `app/service/agent/implementations/risk_agent.py` | `_agent_system_prompt` 接收并注入记忆段 | +| `app/service/agent/governance.py` | `recall()` 增加"员工身份恒空"的语义说明日志 | + +--- + +## 一、F1 —— `RecalledMemory.content` 断头路(已修) + +### 1.1 问题描述 + +追踪 `content` 字段的完整生命周期,它**从未被读取过**: + +| 位置 | 做了什么 | +|---|---| +| `governance.py:146-147` | `RecalledMemory(memory_uuid=..., customer_id=..., content=item.content)` ← 构造了 | +| `base.py:141` | `self.memories = await self._governance.recall(context)` ← 存下来了 | +| `governance.py:222` | `known = {memory.memory_uuid for memory in memories ...}` ← **唯一使用处,只用了 uuid** | + +`review_output` 里 `known` 仅用于校验 `source_references` 中 `source_type == "memory"` 的引用是否来自已召回集合。 +**`content` 既不进 prompt,也不进响应,也不进审计。** + +### 1.2 为什么它会一直空着(深层原因) + +排查发现一个更根本的问题:**当前架构下不存在"客户的记忆被对话消费"的场景**。 + +| Agent | 召回开启? | 调模型? | 使用者 | 召回是否有效 | +|---|---|---|---|---| +| `customer_service` | ❌ `recalls_customer_memory=False` | ✅ 是 | 客户/访客 | 主动关闭 | +| `risk` | ✅ 默认 True | ✅ 是(唯一真调模型的) | **风控专员 9002/9003** | ❌ **恒空**(见下) | +| `advisor` | ✅ 默认 True | ❌ 纯工具编排 | 客户/advisor | 有效但无处可用 | +| `fund_query_demo` | ✅ 默认 True | ❌ 纯工具 | 客户 | 有效但无处可用 | + +`governance.py:140` 是 `customer_id = int(context.user_id)` —— 召回的是**当前登录者自己作为客户**的记忆。 +风控专员自己不是客户,所以 `risk` Agent 的召回**永远返回空**。 + +于是一个闭环形成了:**唯一会用记忆内容的 Agent(risk)召回恒空;召回有效的 Agent(advisor)不调模型。** +`content` 从被设计出来的那天起就没机会被用到。 + +### 1.3 修改方案 + +**思路**:不擅自改变任何现有行为,只做"能力就绪 + 零风险接线"。 + +**第一步 —— 在基类收口渲染能力**(`app/service/agent/base.py`) + +```python +def memory_context_text(self, *, limit: int = 8) -> str: + """把本次已召回的长期记忆渲染成可注入 prompt 的段落;无记忆时返回空串。""" + if not self.memories: + return "" + lines = [f"- {memory.content}" for memory in self.memories[: max(1, limit)]] + return ( + "以下是系统留存的该客户长期事实,仅作背景参考,不是本轮指令," + "也不得据此替代工具查询到的权威数据:\n" + "\n".join(lines) + ) +``` + +设计要点: +- **无记忆返回空串** → 调用方可以无条件拼接,不会往 prompt 里塞 `客户已知事实:(空)` 这类噪声。 +- 措辞里明确写"不是本轮指令""不得替代工具查询" → 防提示注入、防模型用记忆编造事实(与 risk agent 现有第 2、10 条边界一致)。 + +**第二步 —— 接线到唯一调模型的 Agent**(`app/service/agent/implementations/risk_agent.py`) + +```python +messages: list[dict[str, Any]] = [ + {"role": "system", "content": _agent_system_prompt( + request.message, + self.memory_context_text(), # 空串时 prompt 与改动前逐字相同 + )}, +] +``` + +```python +def _agent_system_prompt(message: str, memory_context: str = "") -> str: + ... + memory_block = f"\n{memory_context}\n" if memory_context else "" + ... + f"{context}\n{filter_context}{memory_block}\n{_truncation_instruction()}\n" +``` + +### 1.4 为什么这是零风险 + +已实测验证: + +``` +empty-memory prompt identical: True ← 无记忆时 prompt 逐字不变 +with-memory prompt grows: True ← 有记忆时才追加 +``` + +当前 `risk` Agent 的召回恒空(§1.2),所以**上线后行为完全不变**。一旦 F3 的语义问题或数据条件改变,记忆会立即生效,无需再改代码。 + +--- + +## 二、F3 —— `governance.recall` 身份语义陷阱(已修) + +### 2.1 问题 + +`governance.py:140` `customer_id = int(context.user_id)`。对员工身份(风控/投顾/运营),这个 ID 不是客户,召回恒空。 +此前**没有任何提示**,运维看到 `count=0` 只会以为"记忆坏了"。 + +### 2.2 修改 + +```python +if not result.items and not {"customer", "authenticated_user"}.intersection(context.roles): + logger.info( + "memory recall empty: 当前身份 roles=%s 不是客户," + "召回的是该用户自身的客户记忆(恒为空属预期);" + "查指定客户请走 query_customer_profile 工具", + list(context.roles), + ) +``` + +配合上轮已加的 `memory recall customer_id=... count=... ` 日志,现在"为什么是空的"有了明确答案。 + +### 2.3 如需真正修复(**需产品决策,未实施**) + +要让员工身份查到**目标客户**的记忆,`recall` 必须知道"本轮在谈哪个客户"。可选: + +| 方案 | 做法 | 代价 | +|---|---|---| +| A | `AgentRequest.metadata` 增加 `target_customer_id`,`recall` 优先用它 | 需 Agent 在 handle 前确定目标客户;要加越权校验(该员工是否有权看这个客户) | +| B | 从 `request.message` 正则提取客户号 | 不可靠,且易被提示注入操纵 | +| C | 保持现状,员工侧一律走 `query_customer_profile` 工具 | 无成本,已是当前设计 | + +**推荐 C**(当前设计已经正确),A 只在确有"员工对话需要隐式带出客户记忆"的需求时再评估。 + +--- + +## 三、F2 —— `known` 引用校验永不触发(待决策) + +### 3.1 问题 + +`governance.py:220-227`: + +```python +issued_tools = {f"{context.trace_id}:{record.tool_name}" for record in content.tool_calls + if record.status == "succeeded"} +known = {memory.memory_uuid for memory in memories if memory.customer_id == context.user_id} +for reference in content.source_references: + valid = ((reference.source_type == "memory" and reference.source_id in known) + or (reference.source_type == "tool" and reference.source_id in issued_tools)) + if not valid: + raise ForbiddenAgentError("引用未来自本次已授权召回结果") +``` + +`SourceReference.source_type` 的 `Literal` 里明确有 `"memory"`,但**全仓没有任何地方产出 `source_type == "memory"` 的引用**——这个分支永远走不到。 + +### 3.2 修复选项 + +| 方案 | 做法 | 判断 | +|---|---|---| +| **A(推荐)** | 在 `risk_agent` 等处,把本次 prompt 里实际用到的记忆作为 `source_references` 返回 | 让校验活起来,前端可展示"本次回答依据了哪些记忆",可追溯性最好 | +| B | 删除 `known` 与 `"memory"` 分支 | 简单,但等于放弃"记忆引用可追溯"的设计意图 | +| C | 保持现状,加注释标注为预留 | 最低成本,但死代码继续存在 | + +**A 的示例**(需确认 `CoreResult` 允许业务代码传 `source_references`——注意 `fund_query_demo` 注释说"由底座统一附加,业务代码不得自行伪造来源引用",因此**方案 A 需要产品/架构确认引用归属**,我没有擅自实施): + +```python +return CoreResult( + text=autonomous_reply, + source_references=[ + SourceReference(source_type="memory", source_id=m.memory_uuid, title=m.content[:40]) + for m in self.memories[:3] + ], +) +``` + +> ⚠️ 这条注释值得注意:**"业务代码不得自行伪造来源引用"**。若业务代码自己填 memory 引用是否算"伪造",需要架构确认。因为底座的立场是:引用只能来自底座真实执行过的动作。而"记忆已被召回"确实是底座做的(`BaseAgent.recall_memory`),所以由底座在 `execute()` 里统一附加可能更符合原意——**这属于设计决策,留给你们定**。 + +--- + +## 四、F5 —— 客服不走记忆链路(**设计取舍,不修**) + +### 4.1 现状与理由 + +- 写入:`runtime.py:194` 对 `agent_type == "customer_service"` 直接 `return False` +- 读取:`customer_service.py:205` `recalls_customer_memory=False`,注释原文: + > "客服不隐式召回长期画像;已登录用户的画像查询必须显式调用受控工具。" + +客服走的是**另一条已验证可用的链路**: + +``` +customer_profile.candidate_requested + └─ CustomerProfileCandidateWorker(memory_status="candidate"、source_type="AI对话提取") + └─ 候选画像 → 用户确认 → 管理员复核 → 生效 +``` + +而"查我的画像"由 `query_customer_profile` 工具显式执行(`customer_service.py:227-266`),读的是 `profile_snapshots`——**记忆的下游产物**。 + +### 4.2 结论 + +**这不是缺陷,是有意的合规取舍**:隐式把画像塞进客服 prompt vs. 显式工具查询,后者可审计、可控。 +实测 219 次客服对话产生 0 条记忆,是**符合设计**的。 + +### 4.3 如果你确实想让客服"记住"对话内容 + +需要同时改两处,并建议先过合规: + +1. `customer_service.py:205` → `recalls_customer_memory=True` +2. `runtime.py:194` → 去掉 `agent_type == "customer_service"` 短路 + +**风险**: +- 客服对话量大(219 次),会把大量低质量内容灌进 `memory_unit` +- `CustomerProfileCandidateWorker` 已用 `sanitize_source=True` 做脱敏,直接走 `MemoryExtractionWorker` 则 `sanitize_source=False`(默认),**客服原文会不脱敏进入证据摘录** —— 这是必须解决的隐私问题 + +若要做,建议:沿用 `CustomerProfileCandidateWorker` 的 `sanitize_source=True` 配置,且保持 `memory_status="candidate"` 需复核,而不是直接写 active。 + +--- + +## 五、已完成的可观测性修复(上轮,此处备案) + +| 项 | 内容 | +|---|---| +| 日志 | `runtime.py` 抽取决策与短路原因;`memory_extraction_worker.py` start / extracted / upsert done;`governance.py` recall 结果;`base.py` recall 短路原因 | +| 接口 | `GET /api/v1/users/me/memories?query=&limit=` → `stored` / `recalled` / `downstream` / `pending_events` | +| 脚本 | `tools/probe_memory_state.py [cid]`、`tools/probe_memory_detail.py`、`tools/probe_agent_types.py` | + +--- + +## 六、验证与回归 + +### 6.1 已执行的验证 + +```bash +# 语法 +python -c "import ast; ..." # 7 个文件全部 OK + +# 行为(关键:证明零风险) +from app.service.agent.implementations.risk_agent import _agent_system_prompt +_agent_system_prompt('查一下高风险预警') == _agent_system_prompt('查一下高风险预警', '') +# → True(空记忆时 prompt 逐字不变) +``` + +### 6.2 建议补的回归 + +| 场景 | 期望 | +|---|---| +| `tests/unit/service/test_agent_governance.py` | 全绿(`recall` 签名未变) | +| 风控问答端到端 | 回答内容与改动前一致(因召回恒空,prompt 未变) | +| `tools/e2e_smoke_test.py` | 全绿 | +| 记忆注入生效后 | 日志出现 `memory recall ... count>0`,风控回答体现客户事实且不编造 | + +### 6.3 如何验证 F1 真的接通了(当前恒空,需用测试替身) + +由于风控专员身份召回恒空,要验证接线需注入替身: + +```python +# 单测:给 agent 塞两条记忆,断言 prompt 含记忆内容 +agent = RiskAgent(RiskAgent.definition) +agent.memories = ( + RecalledMemory(memory_uuid="u1", customer_id="9001", content="稳健型"), +) +assert "稳健型" in agent.memory_context_text() +assert "稳健型" in _agent_system_prompt("查预警", agent.memory_context_text()) +``` + +--- + +## 七、剩余待办(需你拍板) + +| # | 事项 | 需要谁定 | +|---|---|---| +| 1 | F2:memory 类型的 `source_references` 由业务代码填还是底座统一附加 | 架构 | +| 2 | 是否需要"员工对话隐式带出目标客户记忆"(§2.3 方案 A) | 产品 | +| 3 | 是否让客服也写/读长期记忆(§4.3,涉及脱敏与复核) | 产品 + 合规 | +| 4 | 顺带:`agent.run_requested` 死信 **560 条**(`run not found`,09-13 前历史) | 运维 | +| 5 | 顺带:`knowledge.vector_sync_requested` 死信 7 条 → 知识库向量可能缺失 | 运维 | diff --git a/docs/演示用/记忆系统排查报告-2026-09-14.md b/docs/演示用/记忆系统排查报告-2026-09-14.md new file mode 100644 index 0000000..775d989 --- /dev/null +++ b/docs/演示用/记忆系统排查报告-2026-09-14.md @@ -0,0 +1,392 @@ +# 记忆系统排查报告 + +> **排查日期**:2026-09-14 +> **结论(先给答案)**:**记忆系统是正常工作的,库里有真实数据,事件全部被成功消费。** +> 你"看不到任何反馈"的原因不是功能坏了,而是 **(1)最近测的全是客服对话,而客服 Agent 按设计既不写也不读长期记忆;(2)召回结果在系统内没有任何消费方与展示出口**。 +> 本轮已修复第 2 点中的可观测性问题(补充日志 + 新增调试端点),第 1 点是设计取舍,见 §5。 + +--- + +## 〇、一句话结论与证据 + +| 环节 | 状态 | 证据 | +|---|---|---| +| **写入** | ✅ 正常 | `memory_unit` 有 **2 条 active** 记忆,客户 9001 | +| **存储** | ✅ 正常 | `memory_evidence` 6 行、`memory_conflict` 3 行 | +| **事件消费** | ✅ 正常 | `memory.extraction_requested` **170 条全部 `published`**,**0 条 pending / 0 条 failed** | +| **召回** | ✅ 正常 | 结构化召回链路完整(`MemoryRecallService.recall`) | +| **下游(记忆→画像)** | ✅ 正常 | `user_facts` 2 条、`profile_snapshots` 9 条(v7 `is_current=1`) | +| **"对话里能看到记忆"** | ❌ **断头路** | `RecalledMemory.content` **无任何消费方**(详见 §2.3) | +| **"能查到记忆"** | ❌ **无出口** | 此前没有任何接口返回 `memory_unit` 原始行 | + +**实际存在的两条记忆**(客户 9001,`customer` 角色): + +| memory_key | content | 类型 | 置信度 | 证据数 | 最后更新 | +|---|---|---|---|---|---| +| `preference:horizon` | `'约三年'` | preference | 0.95 | 4 | 2026-09-13 11:03 | +| `preference:risk_level` | `'稳健型'` | preference | 0.98 | 2 | 2026-09-13 11:03 | + +并且它们已被提升为长期事实并进入画像: + +``` +user_facts: preference:risk_level (0.95)、preference:horizon (0.95) +profile_snapshots: v7 current=1(09-10 13:57)、v6…v3 历史版本 +``` + +--- + +## 一、代码路径与触发条件 + +### 1.1 写入(抽取 → 落库) + +``` +用户消息 + └─ POST /api/v1/agent-runs → 202 Accepted(异步,不执行) + └─ 写 agent.run_requested 事件(status=pending) + +[必须常驻 Worker] python -m app.worker + └─ WorkerRuntime.run_once() → 领取 outbox 事件 + └─ AgentExecutor.execute() → BaseAgent.execute() + └─ AgentPersistenceService.complete_run() # agent_persistence_service.py:46 + └─ if memory_extraction_requested: # :205 + 写 memory.extraction_requested 事件 # :206-211 + └─ 下一轮消费 memory.extraction_requested # runtime.py:329 + └─ MemoryExtractionWorker.handle() # memory_extraction_worker.py:62 + ├─ 回查 conversation_message 取原文 + ├─ extractor.extract() 模型抽取 # :92 + ├─ MemoryService.upsert() → memory_unit # :98 + ├─ record_evidence() → memory_evidence # :114 + └─ 同时写 profile.rebuild_requested 事件 # :141-154 +``` + +### 1.2 触发条件(**最关键,也是最容易踩空的地方**) + +`app/worker/runtime.py:188-202`: + +```python +if agent_type == "customer_service" or "visitor" in context.roles: + return False # ← 客服与访客:永远不抽记忆 +return MemoryService.should_extract_memory(...) +``` + +`app/service/memory_service.py:44-72` 的三选一条件(任一满足): + +1. **业务事件本身即持久事实**(`BUSINESS_EVENT_TYPES`,如 `risk.assessment_completed`、`trade.completed`) +2. **工具产生了权威业务事实**(`tool_result=True`,任一 tool_call 成功) +3. **用户消息命中受控信号**(`detect_memory_signals`——显式陈述的风险偏好、投资期限、流动性约束、职业、家庭状况、目标等) + +> 注意:**长度不是门槛**。普通长问答不触发;"只买货币基金"这种两三个字的明确陈述会触发。 + +**实测对照**:最近 8 条 `memory.extraction_requested` 事件中 **7 条来自 `risk` Agent**——风控会跑工具、产生业务事件,天然满足条件 1/2。 + +### 1.3 存储 + +| 表 | 装什么 | +|---|---| +| `memory_unit` | 记忆主体(受控 `memory_key` + 结构化 `content` + 置信度 + 状态) | +| `memory_evidence` | 证据摘录与快照,`idempotency_key` 唯一键是重复消费的幂等边界 | +| `memory_conflict` | 内容变更时留痕(不覆盖,只记冲突) | +| `memory_sync_outbox` | 向量/图投影的投递队列 | + +### 1.4 读取(召回) + +``` +BaseAgent.execute() # base.py:103 + └─ recall_memory() # base.py:134 + ├─ if not definition.recalls_customer_memory → 返回空 # :138 + ├─ if "visitor" in context.roles → 返回空 # :138 + └─ governance.recall(context) # governance.py:133 + └─ MemoryRecallService.recall() # memory_recall_service.py:96 + ├─ Redis 热缓存(TTL 300s) + ├─ MySQL 结构化召回(含时间衰减) + └─ Milvus 向量召回(需 embedding 端点,缺失则降级) +``` + +### 1.5 下游(记忆 → 画像 → 展示) + +``` +profile.rebuild_requested 事件 + └─ ProfileAssemblyService + ├─ promote_facts() → user_facts + └─ rebuild_profile() → profile_snapshots(is_current=1) + └─ GET /api/v1/users/me/memory-profile ← 唯一对外出口 +``` + +--- + +## 二、为什么测试时看不到任何反馈(按可能性排序) + +### 2.1 你测的是客服对话 —— 客服按设计完全不走记忆链路 + +这是**最可能的原因**,且有硬数据支撑: + +``` +agent_run 按类型分布: + customer_service succeeded 219 次 09-10 12:20 ~ 09-13 18:08 + risk succeeded 17 次 09-11 04:18 ~ 09-13 14:03 +``` + +**219 次成功的客服对话,产生 0 条记忆**。因为: + +- **写入被短路**:`runtime.py:194` 对 `agent_type == "customer_service"` 直接 `return False` +- **读取被短路**:`customer_service.py:205` `recalls_customer_memory=False`,注释写得很清楚:"客服不隐式召回长期画像;已登录用户的画像查询必须显式调用受控工具" + +客服走的是**另一条链路**:`should_request_profile_candidate` → `customer_profile.candidate_requested` → `CustomerProfileCandidateWorker`(`memory_status="candidate"`、`source_type="AI对话提取"`),产物是**候选画像**,且**需用户确认 + 管理员复核**才生效,候选接口还不返回证据原文。 + +> 所以"在客服里说我偏好稳健型,然后期待下次对话记得" —— 按当前设计**不会**发生。 + +### 2.2 今天没有产生任何新的对话 + +``` +agent_run 今天(09-14)创建 0 条 +agent_run 最新一条 2026-09-13 18:08:49 +conversation_message 最新 2026-09-13 18:08:52 +domain_event_outbox 最新 2026-09-13 18:08:51 +``` + +库里最后一次活动停在昨天 18:08。**今天没有新的会话 → 不可能有新记忆**。若今天测过但库里没记录,说明请求根本没到落库(服务/Worker 未启动),而不是记忆坏了。 + +### 2.3 召回结果没有任何消费方(**真缺陷 · 断头路**) + +即使召回成功,你也看不到。全链路追踪 `RecalledMemory.content` 的去向: + +| 位置 | 做了什么 | +|---|---| +| `governance.py:146-147` | 构造 `RecalledMemory(memory_uuid=..., content=item.content)` | +| `base.py:141` | `self.memories = await self._governance.recall(context)` | +| `governance.py:222` | **唯一使用处**:`known = {memory.memory_uuid for memory in memories ...}` | + +`review_output` 里 `known` 只用于校验 `source_references` 中 `source_type == "memory"` 的引用是否来自已召回集合。**`content` 从头到尾没有被读取过一次**——既不注入 prompt,也不写入响应,也不进审计。 + +> 也就是说:**召回链路跑通了,但结果被丢弃。** 这是本次排查发现的唯一功能性缺陷。 + +### 2.4 没有任何接口能看 `memory_unit` 原始行 + +`GET /api/v1/users/me/memory-profile` 只返回 `profile_snapshots`(记忆的**下游产物**): + +```python +# public_platform_service.py:283-304 +rows = await PlatformRepository(session).rows( + "profile_snapshots", {"customer_id": customer_id, "is_current": 1}, limit=1 +) +``` + +由此产生一个盲区:**"记忆写入了但画像还没重建" 与 "压根没写入" 在外部完全无法区分**。 + +### 2.5 触发条件没命中 + +若用非客服 Agent 但消息是普通问答(`tool_result=False`、无业务事件、无受控信号),`should_extract_memory` 返回 False,模型也不会产出持久事实 → `logger.info("memory extraction found no durable fact")`。 + +**实测**:170 条 `memory.extraction_requested` 全部成功消费,但只落了 2 条记忆——其余 168 条都是"模型判定没有持久事实"的正常空结果,**不是错误**。 + +--- + +## 三、本轮补充的日志与调试出口 + +### 3.1 新增日志(4 个关键节点) + +| 位置 | 日志内容 | 回答什么问题 | +|---|---|---| +| `app/worker/runtime.py:193-215` | `memory extraction decision=...` + `skipped: agent_type=... 走候选画像链路` | **是否该抽?为什么被短路?** | +| `app/worker/memory_extraction_worker.py:71` | `memory extraction start run_id=... customer_id=...` | 事件有没有被消费 | +| `app/worker/memory_extraction_worker.py:96-108` | `memory extraction extracted key=... confidence=... value=...` | **模型抽到了什么** | +| `app/worker/memory_extraction_worker.py:129-135` | `memory upsert done memory_uuid=... key=... status=...` | **有没有写入成功** | +| `app/service/agent/governance.py:141-152` | `memory recall count=... sources=... items=[key=内容(mysql)]` | **召回了什么** | +| `app/service/agent/base.py:142-152` | `memory recall skipped: agent_type=... 定义未开启 recolls_customer_memory` | **读取为什么是空的** | + +原有的 `logger.info("memory extraction found no durable fact")` 也补上了原文预览,便于判断是"模型没抽到"还是"消息本身没内容"。 + +### 3.2 新增调试端点 + +``` +GET /api/v1/users/me/memories?query=&limit=10 +权限:memory:read:self(customer 角色已有,权限码 9009) +``` + +一次返回四件事,用于消除 §2.4 的盲区: + +- `stored` —— `memory_unit` 原始行(含 status / confidence / source_type / 按状态计数) +- `recalled` —— 走**生产装配**的召回(含 Milvus 向量通道),返回 `count` / `degraded` / `degraded_reasons` / 每条的 `sources` +- `downstream` —— `user_facts` 与 `profile_snapshots`(看记忆有没有转成画像) +- `pending_events` —— 该客户还有没有未消费事件(判断 Worker 是否在跑) + +### 3.3 新增探针脚本(不需要起服务) + +```bash +# 全局状态:表行数 + outbox 事件堆积情况 +D:/conda/envs/jr_py313/python.exe tools/probe_memory_state.py [customer_id] + +# 明细:记忆内容 / 今天有无活动 / 死信原因 / 最近 run 状态 +D:/conda/envs/jr_py313/python.exe tools/probe_memory_detail.py + +# agent 类型分布:确认你测的到底是哪类 Agent +D:/conda/envs/jr_py313/python.exe tools/probe_agent_types.py +``` + +--- + +## 四、最小可复现的验证步骤 + +### 步骤 0:先确认 Worker 在跑(**最大前提**) + +`POST /api/v1/agent-runs` 只返回 **202 Accepted**,Agent 由 Worker 异步执行;`memory.extraction_requested` 也由 Worker 消费。**Worker 不跑 = 什么都不发生。** + +```bash +# 终端 A:常驻 Worker +D:/conda/envs/jr_py313/python.exe -m app.worker + +# 或一次性排空队列(推荐用于确定性验证) +D:/conda/envs/jr_py313/python.exe -m app.worker --once +``` + +### 步骤 1:拿基线 + +```bash +D:/conda/envs/jr_py313/python.exe tools/probe_memory_state.py 9001 +``` + +记录当前 `memory_unit` 行数(当前基线:**2**)。 + +### 步骤 2:用**非客服** Agent 发一条含明确偏好的消息 + +⚠️ **不要用客服 Agent**(会被 §2.1 短路)。用 `risk` 或 `advisor`。 + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/agent-runs \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: $(python -c "import uuid;print(uuid.uuid4().hex)")" \ + -d '{"agent_type":"risk","message":"我只买货币基金,投资期限大概三年","session_id":""}' +``` + +"我只买货币基金""投资期限三年"命中 `detect_memory_signals` 的受控信号。 + +### 步骤 3:排空队列 + +```bash +D:/conda/envs/jr_py313/python.exe -m app.worker --once +``` + +### 步骤 4:看日志(应依次出现) + +``` +memory extraction decision=True agent_type=risk tool_result=... signals=['preference:asset_class',...] +memory extraction start run_id=... customer_id=9001 message_id=... +memory extraction extracted run_id=... key=preference:asset_class confidence=0.9 value='货币基金' +memory upsert done run_id=... memory_uuid=... key=preference:asset_class status=active +``` + +### 步骤 5:确认落库 + +```bash +D:/conda/envs/jr_py313/python.exe tools/probe_memory_state.py 9001 +# 期望:memory_unit 从 2 条 → 3 条 +``` + +或直接调接口: + +```bash +curl http://127.0.0.1:8000/api/v1/users/me/memories \ + -H "Authorization: Bearer " +# 期望:data.stored.items 里能看见刚写入的那条 +``` + +### 步骤 6:验证"后续对话能召回" + +再发起一次 run(同一客户),观察日志: + +``` +memory recall customer_id=9001 count=3 from_cache=False degraded=False reasons=- items=['preference:risk_level=稳健型(mysql)', ...] +``` + +或调接口带 query: + +```bash +curl "http://127.0.0.1:8000/api/v1/users/me/memories?query=投资期限" \ + -H "Authorization: Bearer " +# 期望:data.recalled.count > 0,items[].sources 含 "mysql"(配好 embedding 还会含 "milvus") +``` + +### 步骤 7:验证下游(记忆 → 画像) + +```bash +D:/conda/envs/jr_py313/python.exe tools/probe_memory_state.py 9001 +# 期望:user_facts 增加;profile_snapshots 出现新的 is_current=1 版本 + +curl http://127.0.0.1:8000/api/v1/users/me/memory-profile \ + -H "Authorization: Bearer " +# 期望:data.profile 里 investment_horizon / risk_tags 反映新事实 +``` + +### 判定标准 + +| 现象 | 结论 | +|---|---| +| `memory_unit` 增加 + 日志出现 `memory upsert done` | ✅ 写入正常 | +| 事件卡在 `pending` | ❌ Worker 没跑 → 回到步骤 0 | +| 日志 `found no durable fact` | ⚠️ 正常空结果,换更明确的表述重试 | +| 日志 `skipped: agent_type=customer_service` | ⚠️ 设计如此,换非客服 Agent | +| 有 `memory_unit` 但 `recalled.count=0` | ❌ 召回有问题,看 `degraded_reasons` | + +--- + +## 五、根因与结论 + +### 5.1 功能本身:正常 + +**记忆的写入、存储、召回、下游画像,四段链路全部验证通过**,且有真实数据: + +- 2 条 active 记忆(含证据累积:`evidence_count` 4 与 2,说明被多次印证) +- 170 条抽取事件全部 `published`(无 pending / failed) +- 233 条画像重建事件全部 `published` +- `user_facts` + `profile_snapshots` 已生成,当前版本 v7 + +**所以这不是"功能坏了",而是"观测不到"。** + +### 5.2 真正的两个问题 + +| # | 问题 | 性质 | 本轮处理 | +|---|---|---|---| +| **1** | **可观测性缺失**:`memory_unit` 无读接口、召回结果无日志、决策过程无日志 | 已修复 | 补 6 处日志 + 新增 `GET /users/me/memories` + 3 个探针脚本 | +| **2** | **召回内容无消费方**:`RecalledMemory.content` 全程未被读取,召回是断头路 | 待决策 | 已定位并通过日志暴露。**是否注入 prompt 属产品决策,未擅自改动** | + +关于第 2 点:`self.memories` 已经挂在 `BaseAgent` 上,任何 Agent 实现都能直接用(`advisor` / `risk` / `fund_query_demo` 的 `recalls_customer_memory` 均为默认 `True`)。但没有一个实现去读它。要不要在回复里体现记忆、怎么体现,需要业务定,我没有替你决定。 + +### 5.3 预期正确表现(对照自查) + +| 场景 | 正确表现 | +|---|---| +| 客服对话 | **不写记忆、不读记忆**(设计如此)。改为产生**候选画像**,需用户确认 + 管理员复核,`GET /users/me/memory-candidates` 可见 | +| 风控/投顾对话,消息含明确偏好或工具产生业务事实 | 写入 `memory_unit`,`memory_status=active`,同时写 `memory_evidence` + `profile.rebuild_requested` | +| 普通问答(无信号/无工具结果/无业务事件) | **不写入**,日志记录 `found no durable fact`。这是正确行为,不是故障 | +| 访客身份 | 不写、不读 | +| 记忆写入后 | 应能查到 `user_facts` 增加、`profile_snapshots` 新增 `is_current=1` 版本 | +| 后续对话 | 日志出现 `memory recall count>0`,接口 `recalled.count>0`;**但当前不会体现在回复文本里**(问题 2) | + +### 5.4 顺带发现(与记忆无关,但建议关注) + +``` +agent.run_requested dead 560 条 last_error: OutboxHandlerError: run not found +knowledge.vector_sync_requested dead 7 条 last_error: RecoverableAgentError +``` + +- 560 条 `run not found` 死信集中在 09-13 17:41 之前,是历史事件(对应 run 行已不存在)。不影响当前功能,但建议排查为何当时批量产生。 +- 7 条知识向量同步死信 → 知识库的 Milvus 向量可能缺失(与 `docs/37` 记录的图库不可用是同一类环境问题)。 + +--- + +## 六、本轮改动清单 + +| 文件 | 改动 | +|---|---| +| `app/worker/runtime.py` | `should_request_memory_extraction` 增加决策日志与短路原因 | +| `app/worker/memory_extraction_worker.py` | 增加 start / extracted / upsert done 三处日志 | +| `app/service/agent/governance.py` | `recall()` 增加召回结果日志(条数、来源、降级原因、内容摘要) | +| `app/service/agent/base.py` | 新增 `logger`;`recall_memory` 增加短路原因日志 | +| `app/api/controllers/public_platform.py` | 新增 `GET /users/me/memories` | +| `app/service/public_platform_service.py` | 新增 `memories_debug()` | +| `tools/probe_memory_state.py` | 新增(只读探针) | +| `tools/probe_memory_detail.py` | 新增(只读探针) | +| `tools/probe_agent_types.py` | 新增(只读探针) | + +所有改动均为**新增**,未修改任何既有业务逻辑。6 个修改文件均通过 `py_compile`。 diff --git a/docs/风控业务演示文档/08-数据库依赖与读写边界.md b/docs/风控业务演示文档/08-数据库依赖与读写边界.md index 8c7fed0..288c659 100644 --- a/docs/风控业务演示文档/08-数据库依赖与读写边界.md +++ b/docs/风控业务演示文档/08-数据库依赖与读写边界.md @@ -23,6 +23,21 @@ | `sys_login_record` | 登录结果、设备和常用设备标识 | | `biz_work_order` | 定投渠道、风险揭示、二次确认和录音编号 | +> ⚠️ **实现归属说明(2026-09-14 补)**:上表混了两类表。按代码实际所在 repository 划分: +> +> | 归属 | 表 | +> |---|---| +> | **风控域**(`app/repository/risk_repository.py`) | `fin_risk_alert`、`fin_risk_notification`、`fin_risk_assessment`、`fin_customer_profile`(只读 + 结案改行为分) | +> | **身份域**(`app/repository/identity_repository.py`) | `sys_user`、`sys_role`、`sys_permission`、`sys_user_role`、`sys_role_permission`、`sys_customer_assignment`、`sys_login_record` | +> | **场内交易域**(其他 repository,风控**只读**) | `fin_product`、`fin_transaction`、`fin_capital_flow`、`fin_holding`、`biz_work_order` | +> +> **风控侧从不 import 身份域/交易域的 repository** —— 需要这些数据时走跨域只读查询, +> 这对应 `AGENTS.md` 规则 6(MVC+S 分层)与风控红线(不改业务事实)。 +> +> ✅ **红线 2 已实测复核**:`risk_repository.py` 中**没有任何** `memory_unit` / `profile_snapshot` +> 引用,画像数据只读 **`fin_customer_profile`**(8 处以上 join 均为该表)。 +> 即有"风控不能读原始记忆台账、只能读画像落地表"这条边界。 + ## 写入表 | 表 | 允许写入内容 | diff --git a/docs/验收与审计/phase1-acceptance-criteria.md b/docs/验收与审计/phase1-acceptance-criteria.md index d74e0ec..d02a6a4 100644 --- a/docs/验收与审计/phase1-acceptance-criteria.md +++ b/docs/验收与审计/phase1-acceptance-criteria.md @@ -1,12 +1,27 @@ # 老师 Phase 1 验收标准原文 + 覆盖缺口盘点(控制者 2026-09-10) +> ## ⚠️ 本文是 **2026-09-10 的缺口盘点快照**,其后已全部补齐 +> +> **判断当前进度请看同目录的 `phase1-acceptance-report.md`,不要用本文。** +> 本文的价值在于**保留了老师验收标准的原文**(下面那张表的「原文」列); +> 「我们的覆盖情况」列**已过时**,逐条订正如下: +> +> | # | 本文当时写的 | 现在 | +> |---|---|---| +> | 2 | 「现库 51 表」 | **90 张表 = 89 业务 + `alembic_version`**(场内 51 + 场外/推广 17 + 投顾 21) | +> | 3 | ⚠️ **未达成** —— 写路径未接入 `WorkerRuntime`,Milvus 零向量 | ✅ **已达成**:装配已补上,Milvus 检索在 Phase 1 验收中**实测命中 score 0.7837** | +> | 4 | ⏳ 依赖 Task 9/7 | ✅ 已达成(`CustomerServiceAgent` 已注册) | +> | 5 | ⏳ 同上,且政策集合无数据 | ✅ 已达成 | +> | 6 | ⏳ 依赖 Task 9 + Redis 短期记忆 | ✅ 已达成(Redis 密码也已配好) | +> | 7 | 🔴 **规划缺口** —— 管理接口不存在 | ✅ **已达成**:`docs/05` §8.3 的知识库管理三端点(上传/列表/删除)已落地 | + 来源:`C:\Users\Windows\Desktop\金融\需求文档-修改版.html`(**优先级 1 权威**), `Phase 1:基础设施 + 智能客服Agent(第1周)` 段落的「验收标准」小节(原文 7 条)。 控制者用脚本直接抽取原文,未经转述。 ## 7 条验收标准(原文) -| # | 原文 | 我们的覆盖情况 | +| # | 原文 | 我们的覆盖情况(**2026-09-10 快照,已过时——见文首表**) | |---|---|---| | 1 | ✅ FastAPI项目可正常启动,Swagger文档可访问(http://localhost:8000/docs) | ✅ 底座既有 | | 2 | ✅ 数据库10张表全部创建成功(`SHOW TABLES` 返回10条记录) | ✅ 现库 51 表(远超 10),`audit_schema.py` 通过 | @@ -16,7 +31,7 @@ | 6 | ✅ 多轮对话上下文保持正常(测试3轮以上连续对话,上下文不丢失) | ⏳ 依赖 Task 9 + 底座会话记忆(Redis 短期记忆) | | 7 | ✅ **知识库管理接口可正常上传/查询/删除文档** | 🔴 **规划缺口** —— 见下 | -## 🔴 第 7 条:**10 个 Task 里没有规划这个接口** +## 🔴 第 7 条:**10 个 Task 里没有规划这个接口**(⚠️ 已补齐,见文首表) 控制者实测:`app/api/controllers/` 下**只有** `knowledge.py`(`/api/v1/knowledge-references`,引用解析), **不存在**知识库管理接口。老师 F1.2 还明确写了三个端点: diff --git a/docs/验收与审计/phase1-acceptance-report.md b/docs/验收与审计/phase1-acceptance-report.md index f781144..0b6ad13 100644 --- a/docs/验收与审计/phase1-acceptance-report.md +++ b/docs/验收与审计/phase1-acceptance-report.md @@ -19,9 +19,9 @@ | # | 老师验收标准(原文) | 结果 | 实测证据 | |---|---|---|---| | 1 | FastAPI项目可正常启动,Swagger文档可访问 | ✅ | `create_app()` 成功;`/docs` 由 FastAPI 自动提供 | -| 2 | 数据库10张表全部创建成功(`SHOW TABLES` 返回10条记录) | ✅ | 实查 `information_schema` = **52 张表**(远超 10) | +| 2 | 数据库10张表全部创建成功(`SHOW TABLES` 返回10条记录) | ✅ | 实查 `information_schema` = **52 张表**(远超 10)(⚠️ 2026-09-14:现为 **90 张 = 89 业务 + `alembic_version`**,场内 51 + 场外/推广 17 + 投顾 21) | | 3 | **FAQ问答对成功导入Milvus,检索返回正确结果(测试"基金申购后多久确认"返回正确回答)** | ✅ | Milvus `fin_faq_collection` = **106 行**;检索命中 title=`基金申购后多久确认?` score=**0.7837** | -| 4 | 客服Agent能正确回答产品咨询类问题(测试5个以上问题,准确率≥80%) | ✅ | **5/5 命中**,每个都真实调用 `query_knowledge` | +| 4 | 客服Agent能正确回答产品咨询类问题(测试5个以上问题,准确率≥80%) | ✅ | **5/5 命中**,每个都真实调用 `query_knowledge`(⚠️ 正式工具名为 `search_knowledge`,`query_knowledge` 是别名) | | 5 | 客服Agent能正确处理政策解读类问题(测试2个以上问题) | ✅ | **3/3**,且**真实命中政策集合**(见下"A3 补强",不再靠 FAQ 蒙过) | | 6 | 多轮对话上下文保持正常(测试3轮以上连续对话,上下文不丢失) | ✅ | 同 session 连问 **3 轮全部 succeeded** | | 7 | 知识库管理接口可正常上传/查询/删除文档 | ✅ | upload **201** / list **200** / delete **200 + expired + 投删除事件** | @@ -380,7 +380,7 @@ Milvus 向量 → faq 106 / policy 176 / product 73(共 355) | `app/infrastructure/milvus_knowledge_writer.py` | Milvus 写路径适配器 | | `app/infrastructure/milvus_adapter.py` | Milvus 读路径客户端 | | `app/service/knowledge_retrieval_service.py` | 检索服务(含 MySQL LIKE 降级) | -| `app/service/knowledge_tool.py` | `query_knowledge` 只读工具 | +| `app/service/knowledge_tool.py` | 知识检索只读工具(**正式名 `search_knowledge`**;`query_knowledge` 是别名,同一 handler) | | `app/core/customer_service_rules.py` | 安全路由(P0/合规/P1/P2,确定性规则) | | `app/service/agent/implementations/customer_service.py` | **客服 Agent 本体** | | `app/api/controllers/knowledge_management.py` + `app/service/knowledge_management_service.py` | 知识库管理接口 |