diff --git a/docs/交付说明-NL_develop-给架构师.md b/docs/交付说明-NL_develop-给架构师.md new file mode 100644 index 0000000..d13756b --- /dev/null +++ b/docs/交付说明-NL_develop-给架构师.md @@ -0,0 +1,357 @@ +# 交付说明 · 客服 Agent + RAG + 画像(`NL_develop` → `qyqy_develop`) + +> **收件人**:架构师(`qyqy_develop` 维护者) +> **来源分支**:`NL_develop` @ `96a6e01` **合并目标**:`qyqy_develop` +> **基线**:本分支已包含 `qyqy_develop` 的 `d2cdbba`(即合并时你的最新版本,落后 0) +> **一句话**:以**你已有的客服实现为骨架**,把本线独有的**画像问答出口**、**知识库文档管理三端点**、 +> **合规语境豁免**嫁接进去,并修掉合并过程中暴露的 3 个"单测全绿但真机必挂"的问题。 +> +> 📌 **本文档是给评审者看的**,与给接手开发的 `docs/superpowers/handoff/2026-09-11-交接文档-客服Agent与RAG收尾.md` +> 定位不同:那份讲"怎么继续开发",这份讲"**动了你什么、为什么、要不要你点头**"。 + +--- + +## 1. 请你重点看的四件事(都在 §4 有逐条说明) + +| # | 事项 | 为什么要你确认 | +|---|---|---| +| 1 | **改了 `AgentGovernance.review()` 的协议签名**(新增可选 `agent_type`) | 这是**公共契约**,会影响所有实现该协议的替身/子类;我同步改了 5 处测试替身 | +| 2 | **`knowledge_search_service.py` 的字段映射**(`doc_id`→`knowledge_id`、`content`→`snippet`) | 你的实现是按 `tools/load_knowledge_milvus.py` 的 schema 写的,而现库集合**是另一套字段** —— 详见 §4.2,这里有个需要你裁决的口径问题 | +| 3 | **删除了 5 份编号文档**(`docs/04`/`06`/`10`/`13`/`99`)+ 1 份过程产物 | 你分支上仍在,我的分支删了;若你希望保留,请在 review 时驳回这一项 | +| 4 | **`docs/26-JWT密钥管理与轮换.md` 是新文件** | 我新写的(原 `21`→`25`→`26` 两次让号,因为编号被占) | + +--- + +## 2. 净差异总量(相对 `qyqy_develop` 的 `d2cdbba`) + +``` +92 项:新增 50 / 修改 35 / 删除 6 / 重命名 1 +``` + +| 类别 | 数量 | 说明 | +|---|---|---| +| 新增 `app/` 模块 | **16** | 画像生成与投影、知识入库/检索/管理、Milvus 读写适配、文档解析与落盘、合规语境 | +| 新增 `tests/` | **17** | 与服务一一对应;另有 1 个 MySQL 集成测试 | +| 新增 `tools/` | **5** | 知识集合建表、种子导入、画像演示数据、合规种子、QA 素材解析 | +| 新增文档 | **13** | 交接文档、工作报告、验收证据(从被 gitignore 的 `.superpowers/` 复制过来)、ARCHIVE 归档、设计 spec | +| 修改 | 35 | 其中 **7 个是你的核心文件**(见 §4) | +| 删除 | 6 | 5 份编号文档 + 1 份过程产物(见 §1.3) | + +**未新增任何数据库表、未新增迁移**:`fin_knowledge_meta` 等本就在 `docs/00` 基线内,本次只补 ORM 映射。 +`.venv\Scripts\python.exe tools\audit_schema.py` → `51 business tables, no missing or unexpected tables`。 + +--- + +## 3. 本线交付了什么(按功能,附真机证据) + +### 3.1 画像问答出口(你的实现里没有这个出口) + +- 新增 `app/service/customer_profile_service.py`:`query_customer_profile` 只读工具。 + 权限 `memory:read:self`;作用域校验 `self`/`own_customers`/`all`;审计 `memory.profile_read`; + **`fields` 参数拒绝任何 PII 字段**;快照缺失/为空**失败关闭**(不返回空对象假装成功)。 +- 新增 `app/core/profile_projection.py`:画像字段白名单投影,**PII 全剔除**,`assessment_expired` 重算。 +- 新增 `app/service/profile_generation_service.py` + `app/repository/profile_repository.py`: + 一次 MySQL 事务内 **新快照 + 2 条同步事件 + 旧快照 `is_current` 置 0**(符合 `docs/00` §6.4.6)。 +- 修掉 `app/service/public_platform_service.py` 的 `memory()` **恒返回 `{}`** 静默故障 → 现返回投影。 +- 在 `customer_service.py` 中新增**出口零**:`is_profile_question()` 确定性关键词识别(不经意图分类), + 命中即查本人权威字段;失败关闭为转人工。 + +**真机证据**: +``` +客户 9101(C5)→ succeeded 「您的风险测评等级是 进取型(C5)。」 +客户 9001(测评过期)→ succeeded 「…保守型(C1)。注意:您的风险测评已过有效期,需要重新完成测评…」 +规则类问题「风险等级怎么划分」→ 未被画像分支截走,走知识检索 +跨客户读取 → GenericResourceNotFoundError: 客户不可访问 +``` + +### 3.2 知识库文档管理三端点(老师验收标准第 7 条) + +`app/api/controllers/knowledge_management.py` + `app/service/knowledge_management_service.py` + +``` +POST /api/v1/knowledge/upload → 201(切分 → 逐块写 fin_knowledge_meta → 投向量同步事件) +GET /api/v1/knowledge/list → 200(只返 content_preview 前 200 字,不做正文旁路) +DELETE /api/v1/knowledge/{id} → 200(status='expired' + 投向量删除事件;不存在 → 404) +``` + +**真机证据**(含向量的**真实进出**,不只是返回码): +``` +客户 9001 上传 → 403 ✅ 权限隔离正确 +管理员上传 → 201 ids=['369'] +消费同步事件 → Milvus 出现 ✅ +列表 → 200 命中本次上传 1 项 ✅ +删除 → 200 {"status":"expired","vector_delete_event":"knowledge.vector_delete_requested"} +消费删除事件 → Milvus 向量消失(轮询 2s)✅ +不存在 id → 404 ✅ +``` + +> 📌 路径口径:老师草稿写的是 `POST /api/knowledge/upload`(无 `/v1`),实现为 `/api/v1/knowledge/upload`。 +> `docs/05` §8.3 与 §19 目录(K002/K003/K004)均以带 `/v1` 的路径为权威。 + +### 3.3 合规语境豁免(恢复政策问答) + +`app/core/compliance_context.py`(新增)+ `app/service/agent/governance.py`(改) + +**问题**:零容忍词库是朴素子串匹配,它分不清"作出承诺"与"禁止承诺/谈论该表述"。 +政策原文里含「严禁承诺保本保收益」「禁止使用『保证收益』」时,平台会**用自己的规则拦下自己的合规教材** —— +实测「基金销售有哪些合规要求」的回答从 1097 字被替换成 83 字的"该内容需要人工核实"。 + +**修法**:按**句界**限定窗口(12 字符)的否定/元语言线索豁免。第一版没限句界, +把 `严禁承诺保本。但这只基金保本` 错误豁免了 —— 已收紧为句内才认,并有 17 条单测锁定。 + +### 3.4 知识向量链路(入库 → 同步 → 检索) + +- `app/service/document_parser.py`(txt/md/docx,512 字符切块 / 64 重叠) +- `app/service/knowledge_ingest_service.py` → 写 `fin_knowledge_meta` + 投 `knowledge.vector_sync_requested` +- `app/worker/knowledge_vector_worker.py` → 消费事件 → `MilvusKnowledgeWriter` 写 Milvus +- `app/service/knowledge_retrieval_service.py` → 读路径(Milvus 失败降级 MySQL LIKE,并如实标 `degraded`) +- 读写**物理隔离**(写适配器与召回客户端不共用连接) + +**顺带修掉的 4 个真机静默故障**(都是"单测绿、真机挂"): + +| # | 位置 | 缺陷 | 原后果 | +|---|---|---|---| +| 1 | `milvus_knowledge_writer.py` | 写字段名 `vector`,集合 schema 是 `embedding` | **所有**向量写入失败 | +| 2 | `knowledge_vector_worker.py` | Milvus VARCHAR 上限是 **UTF-8 字节**,未按字节截断 | `title` 346 字节 > 256 → 整批失败 | +| 3 | 同上 | 乱序到达的旧同步事件会把已删向量**复活** | 已删知识又出现在检索结果 | +| 4 | `knowledge_tool.py` | `async with _session_factory()` 少一层调用 | 工具 100% 抛错 | + +--- + +## 4. 我对**你的代码**做的修改(逐文件说明) + +### 4.1 `app/service/agent/base.py`(+6 −1)⚠️ 公共契约 + +```python +# 原来 +result = await governance.review(result, context, config, memories) +# 现在 +result = await governance.review( + result, context, config, memories, agent_type=self.definition.agent_type +) +``` + +**理由**:门禁 F5(面向客户输出 100% 附固定话术)只该对**面向客户**的 Agent 生效。 +你的风控 Agent 输出是字段化摘要(预警编号/级别/建议动作),被追加一句面向投资者的免责声明后, +`tests/contract/test_risk_agent_contract.py` 直接失败(实测)。`agent_type` 从**定义**取最可靠 +(不需要查库、与发布配置无关)。 + +**协议同步**(`governance.py`):`AgentGovernance.review` 与 `PlatformGovernance.review` 均新增 +关键字参数 `agent_type: str = ""`,**带默认值**,所以旧的调用点不会 TypeError。 +判定口径是**确认式白名单**:只有 `CUSTOMER_FACING_AGENT_TYPES = {customer_service, fund_query_demo}` +才注入;空串(未声明)**不注入**——理由见代码注释(测试替身刻意不连库,若默认注入则每个替身测试 +都会被塞进话术,用测试噪声换假安全感)。 + +**受影响的测试替身已同步更新(5 处)**:`tests/conftest.py`、`tests/contract/test_fund_query_demo_agent_contract.py`、 +`tests/contract/test_risk_agent_contract.py`、`tests/unit/service/test_agent_governance.py` +(`test_risk_agent_contract.py` 里那个 `StubGovernance` 也补了转发 `agent_type`)。 + +### 4.2 `app/service/knowledge_search_service.py`(+79 −47)⚠️ 需你裁决 + +**现象**:你的 `search_knowledge` 在本环境**零召回**且**整条链路失败** —— Milvus 报 +`field doc_id not exist` / `field visibility not exist`,三个集合全失败 → `degraded=True` → +客服对**所有**知识问题一律"引导人工"(实测连「基金申购后多久确认」都 `failed`)。 + +**原因**:你的实现按 `tools/load_knowledge_milvus.py` 的 schema 读取 +(`doc_id` / `content` / `chapter` / `section` / `doc_no` / `visibility`), +而**现库三个集合的真实字段**是(`describe_collection` 实测): + +``` +knowledge_id | title | snippet | tags | version | intent | embedding +``` + +那个建库脚本自述"**临时脚本,跑完即删**",所需的 `knowledge/_chunks.jsonl` **不在仓库里**; +**本环境从未按它建过集合**(现有 106/177/73 行数据是用另一套 schema 灌进去的)。 + +**我的处理**:**只改读取侧**,不改检索逻辑、不改 `KnowledgeHit` 的对外形状 +(`doc_id` 字段名保留,你的 `_parent_of`、去重、父子块选择、字面召回逻辑一行没动): + +```python +_FIELD_ALIASES = { # 逻辑名 → 现库真实字段名 + "doc_id": "knowledge_id", + "content": "snippet", + "title": "title", "tags": "tags", "version": "version", "intent": "intent", +} +_HAS_VISIBILITY = False # 现库无此字段:拼 visibility == "public" 会让整次检索失败 +``` + +**代价(必须让你知道)**:`_HAS_VISIBILITY=False` ⇒ 检索层**没有**内部资料硬隔离。 +当前 356 行均为对外知识(已核对),所以不影响现状;但**你原设计的"反洗钱手册标 internal、 +客服侧过滤"这条安全能力在本环境是关闭的**。 + +**三个选项,请你选一个**: + +| 选项 | 做法 | 代价 | +|---|---|---| +| **A(当前采用)** | 保持字段映射,`visibility` 过滤关闭 | 内部资料隔离靠"入库侧只放对外知识"保证 | +| B | 按 `load_knowledge_milvus.py` 重建三集合并**重灌 356 行** | 需要恢复 `_chunks.jsonl`(不在仓库)+ 重新向量化 + 重建索引 | +| C | 给现库集合**新增** `visibility`/`chapter`/`section`/`doc_no` 字段并回填 | 需要一个 migration + 回填脚本;好处是 A 的代价消失 | + +我选 A 是因为它**当天可用、可逆**(改回映射即可切换),且不引入迁移。 + +### 4.3 `app/service/agent/implementations/customer_service.py`(+137 −9)⚠️ 你的骨架 + 我的出口 + +**保留你的全部实现**(三档置信阈值、适当性裁决、闲聊、话题矩阵、`_topic_of`/`_search_query`/ +`_suitability_text` 等方法**一字未改**——你的 3 个测试文件全绿即为证)。 + +**新增**: +1. **出口零:画像问答**(`handle()` 最前面加一次 `is_profile_question()` 判定); +2. `allowed_tools` 增加 `query_customer_profile`(代码上限); +3. **删掉 Agent 自己拼的 `DISCLAIMER` 常量及 3 处 `f"...\n{DISCLAIMER}"`** —— 见 §4.5。 + +> `PROFILE_WHITELIST_INTENT = INTENT_FAQ`:画像工具调用复用**已发布**的 `faq` 意图 key。 +> 换新意图码会让 `allowed_tools_by_intent` 缺 key → 交集为空 → `AGENT_PERMISSION_DENIED`。 + +### 4.4 `app/service/model_gateway.py`(+53 −35)⚠️ 我改了你的映射表 + +**你的版本**把 `intent_classification` 映射成同名的 `intent_classification`;**现库没有任何端点声明该能力** +(实测 `embedding-primary=["embedding"]`、`chat-primary=["chat"]`)⇒ 筛选得空集,靠你新加的 +"空集 → 回退全部端点"才不至于失败。我改为映射到 `chat`(意图分类走 `/chat/completions`)。 + +**同时并入你新增的 5 个风控 `task_type`**,与我的映射合并成**同一张表**(`REQUIRED_CAPABILITY_BY_TASK_TYPE`, +`TASK_CAPABILITY` 保留为别名指向它 —— **两个名字不要各存一份内容**,那正是"修复被后续合并悄悄回退"的成因)。 +你新加的"未登记 task_type 要告警留痕"我保留了(并补上了被你删掉的 `logger` 定义)。 + +### 4.5 `app/service/agent/governance.py`(+141 −5) + +| 改动 | 说明 | +|---|---| +| 语境豁免 | `_first_violation` 走 `app/core/compliance_context.py`,见 §3.3 | +| 客服热线白名单 | `CUSTOMER_SERVICE_HOTLINE = "15936583816"` 不再被脱敏成 `[手机号已脱敏]`(实测:客户拿不到联系方式) | +| 免责声明限定范围 | `agent_type` 判定,见 §4.1 | +| **免责声明重复的修复** | 见下 | + +**免责声明曾出现两条**(Agent 自己拼一句 + 治理层追加权威话术): +``` +## 第三章 客户风险等级划分 +(以上内容由智能客服依据公司公开资料整理,不构成投资建议) ← Agent 拼的 +本内容仅为投资分析参考,不构成任何直接投资建议… ← 治理层追加的 +``` +**修法**:话术**只由治理层注入**。理由:合规文案属**发布配置**,改文案不该改代码; +且治理层无法判断"业务是不是已经加过了"(它只认自己追加过的形状)。 +副作用:`customer_service.py` 的 `DISCLAIMER` 常量被删除 → 你的 `test_customer_service_topic_matrix.py` +原本 `from ...customer_service import DISCLAIMER`,已改为从 `governance` 取 `FALLBACK_DISCLAIMER` +(该测试只用它拼"治理后"的正文形状,断言对象是 `_topic_of`,语义不变)。 + +### 4.6 `app/service/agent/bootstrap.py`(+40 −0) + +- 补回 `get_milvus_knowledge_writer()`(知识向量**写**适配器装配入口;我的 Worker 依赖它, + 返回 `None` 表示显式降级 —— 语义与你的 `projection_cleaner` 一致)。 +- 注册 `query_customer_profile` 工具(`required_permission="memory:read:self"`)。 + +**你新增的平台验证探针**(`PlatformProbeAgent` + 两个探针工具)全部保留,与我的改动无交集。 + +### 4.7 `app/worker/runtime.py`(+50 −0) + +- 新增知识向量同步的装配:`knowledge_writer` / `knowledge_embedder` / `knowledge_endpoint_resolver` + 三个注入点 + 降级告警只打一次。**你新增的 `relationships`(图关系服务)保留**,两段是并集。 + +### 4.8 其他被修改的你的文件(改动很小) + +| 文件 | 改动 | +|---|---| +| `app/core/knowledge_contracts.py` | **两条链路的契约并存**:你的 `KnowledgeSearchInput` + 本线的 `ALLOWED_COLLECTIONS`/`VECTOR_DIM`/`intent_for_qa_id`/`KnowledgeQuery`/`KnowledgeHit`/`KnowledgeSearchResult`。合并时一度误删后者,导致 **23 个测试模块收集失败**,已恢复并在文件头注明"删任何一半前先看两份引用点" | +| `app/main.py` | 挂载知识库管理路由(`knowledge_management_router`) | +| `tests/conftest.py` | 替身 `review` 转发 `agent_type` | +| `tools/publish_customer_service_config.py` | 见 §4.9 | +| `tests/unit/service/test_knowledge_keyword_recall.py` | 替身字段名改成现库 schema(否则测试假绿:业务读到空 `snippet` 静默丢弃,断言又只查 `doc_id`) | + +### 4.9 配置发布(**这是运行期必须的一步,请留意**) + +工具白名单是**失败关闭**的:`ToolExecutor` 取「代码 `allowed_tools` ∩ 发布配置 +`agent_tools/:`」的交集,缺配置时交集为空 ⇒ 任何工具调用都被拒。 + +**问题**:合并后代码上限变成 `search_knowledge` / `check_suitability` / `query_customer_profile`, +而生效版本 `186` 的白名单是 `query_knowledge` ⇒ **交集为空** ⇒ 所有知识问题 `AGENT_PERMISSION_DENIED`。 + +**已发布并激活 `config_release id=216`**: + +``` +customer_service:faq = [search_knowledge, query_customer_profile] +customer_service:product_inquiry = [search_knowledge] +customer_service:policy_explain = [search_knowledge] +customer_service:suitability_check = [search_knowledge, check_suitability] +``` + +**顺带修了发布脚本 `tools/publish_customer_service_config.py` 的一个坑**: +`config_release` 是**整版本替换**语义,脚本会把当前生效版本的配置项原样搬进新版本 —— +但**同 key 的继承项必须被本次新定义覆盖**,否则旧值(含已从代码上限移除的 `query_knowledge`) +会被 admin 端的子集校验 422 拦下,**整次发布失败**,而报错只有"配置超出 Agent 工具上限", +看不出是继承造成的。已改为"同 key 覆盖"并打印被替换的旧值。 + +> ⚠️ **注意**:`fund_query_demo:fund_quote` 这条白名单在合并前的生效版本里**就已经不存在**了 +> (我发布时打印的"当前生效版本配置项"只有 1 条)。这不是本次引入的,但会让 +> `FundQueryDemoAgent` 的工具调用失败 —— 如果你那边有依赖它的验收项,请确认是否要补发。 + +--- + +## 5. 验证证据(均可复现) + +```powershell +# 测试(合并后基线) +.\.venv\Scripts\python.exe -m pytest -q +# → 1 failed, 1013 passed, 2 skipped +# 唯一失败 tests/unit/repository/test_fund_readonly_contract.py 是既有的空集缺陷(与本线无关) + +# 结构审计(证明未动 docs/00 基线) +.\.venv\Scripts\python.exe tools\audit_schema.py +# → schema audit passed: 51 business tables, no missing or unexpected tables + +# 文档守卫(编号无冲突) +.\.venv\Scripts\python.exe tools\check_authoritative_docs.py +# → checked 23 documents, no number collision + +# 静态检查(基线由 151 → 181;181 个里只有 11 个落在本线碰过的文件上,见下) +.\.venv\Scripts\python.exe -m mypy app +``` + +**mypy 181 个错的归属**(`mypy app | 按文件计数` 实测): + +| 归属 | 文件 | 错数 | +|---|---|---| +| **架构师侧**(本线未改) | `app/model/fund.py` 105、`app/model/risk.py` 30、`app/model/configuration.py` 14、`app/model/session.py` 9、`app/model/platform.py` 5、`app/model/episode.py` 4、`app/model/conversation.py` 2、`app/repository/platform_repository.py` 1 | **170** | +| 本线新增文件 | `app/service/knowledge_retrieval_service.py` 5、`app/api/controllers/knowledge_management.py` 3 | **8** | +| 双方都改过的文件 | `app/service/model_gateway.py` 2、`app/worker/runtime.py` 1 | **3** | + +即:**170 个是合并带入的既有问题,11 个落在本线改过的文件上**(新增文件只占 8 个)。若要清零,我可以单独开一个提交处理这 11 个。 + +**真机链路**(真实 MySQL / Redis / Milvus / DashScope / HTTP,非单测替身): + +| 场景 | 结果 | +|---|---| +| 知识问答「基金申购后多久确认」 | `succeeded`,**免责声明仅 1 条** | +| 知识问答「风险等级怎么划分」 | `succeeded`(政策集合真实命中) | +| 画像问答 9101 / 9001(测评过期) | `succeeded`,过期被**明说** | +| 知识库三端点 | 403 / 201 / 200 / 200+expired / 404 全绿 | +| 跨客户读画像 | 被拒(`客户不可访问`) | + +--- + +## 6. 需要你裁决 / 知晓的事项汇总 + +1. **`AgentGovernance.review()` 新增 `agent_type` 参数** —— 公共契约变更,虽带默认值。 + 若你更希望走别的判定途径(例如让 Agent 自己声明 `customer_facing` 属性),告诉我,我改。 +2. **`knowledge_search_service.py` 的字段映射** —— §4.2 的 A/B/C 三选一,**当前是 A**。 +3. **删除 5 份编号文档 + 1 份过程产物** —— 若你希望保留,请驳回这一项(它们在无人引用, + 但删除会出现在 PR diff 里)。 +4. **`docs/26-JWT密钥管理与轮换.md` 是新增文件** —— 原 `docs/21`→`docs/25`→`docs/26` 两次让号。 +5. **`config_release 216` 已在共享库生效** —— 若你那边有其它 Agent 依赖旧的 `query_knowledge` + 工具名,会一并受影响。 +6. **`fund_query_demo:fund_quote` 白名单缺失**(§4.9 末尾)—— 可能影响 `FundQueryDemoAgent` 验收。 + +--- + +## 7. 已知限制(未做,不是遗漏) + +1. **`memory_sync_outbox` 没有消费者** ⇒ 画像**没有真的同步到 Milvus / Neo4j**(事件写入了,没人消费)。 + ⚠️ 你最近那批提交里有"记忆→画像→图全自动触发",**这两处可能重叠**,请先确认再动手。 +2. **`mypy` 由 151 → 181** —— 其中 **170 个是合并带入的既有问题**(`app/model/` 下 7 个文件 + + `platform_repository.py`),落在本线碰过的文件上的只有 **11 个**(新增文件占 8 个)。 + 若你希望本线把这 11 个清掉,我单独开一个提交处理(不动你的模型层)。 +3. **Redis 限流降级**:容器以 `--requirepass 123456` 启动而 `.env` 无密码 ⇒ 每次请求一条 + `AuthenticationError` 堆栈、限流形同虚设(不阻断业务)。属本地环境配置,未擅自修改。 +4. **`docs/04`/`06`/`10`/`13`/`99` 若保留**,则 `AGENTS.md` 里"已删除"的说明需要同步改回。 + +--- + +*本说明由 2026-09-11 的收尾会话产出。若你发现与代码不一致,**以代码与 `docs/05` 为准**。*