diff --git a/docs/NL_develop交付说明-评审意见.md b/docs/NL_develop交付说明-评审意见.md new file mode 100644 index 0000000..8f18200 --- /dev/null +++ b/docs/NL_develop交付说明-评审意见.md @@ -0,0 +1,208 @@ +# 评审意见 · `NL_develop` 交付说明(给架构师的回复) + +> **评审人**:`qyqy_develop` 维护者 +> **被评审**:`交付说明-NL_develop-给架构师.md`,来源分支 `NL_develop` @ `96a6e01`(远端当前 `e342670`) +> **基线核对时间**:2026-09-11 +> **结论**:**代码质量认可,但合并前有三个环境前提必须先对齐,否则合并即故障。** + +--- + +## 0. 先说结论 + +这份交付说明写得很好:每项改动都给了理由、真机证据、以及对"动了别人什么"的逐文件说明, +还主动标注了代价(`_HAS_VISIBILITY=False` 会让内部资料隔离失效)。§4.1 的"确认式白名单 + +空串不注入"、§4.2 的"只改读取侧不改检索逻辑"、§4.5 的"话术只由治理层注入",这几个判断我 +都同意,理由见 §3。 + +**但是**:说明里有三处前提与我这边的实际环境不符,而且它们不是细节,是"合并会当场打挂"级别的: + +| # | 问题 | 后果 | +|---|---|---| +| 1 | 你说的生效版本 **216** 在我这里**不存在**(我这边 active 是 **201**) | 你以为已发布的修复,在我这边没发布 → 画像出口会 `AGENT_PERMISSION_DENIED` | +| 2 | 你说的 Milvus schema(`knowledge_id`/`snippet`/无 `visibility`)与我这边**完全不同** | 你这次改的字段映射,**在我这边会把检索打挂** | +| 3 | 你用 `.venv`,项目约定是 `D:\conda\envs\jr_py313` | "mypy 151→181" 这类结论与我这边不可比(我这边是 0 错) | + +下面逐条给证据和修法。 + +--- + +## 1. 环境不一致(最要紧,先对齐这个) + +### 1.1 配置发布:你说的 216 不在这个库里 + +我这边实测(`python tools/probe_release_state.py`,证据 `docs/evidence/release-state.json`): + +``` +active release = 201 (cs-prompt-d918ca507fc6, "客服闲聊提示词", 2026-09-11 05:25 activated) +最近 5 条 id:201 / 198 / 197 / 196 / 195 ← 不存在 216 +active 配置项:agent_tools 9 条 + prompt 1 条 +``` + +`release.id` 是自增主键,同一个库不可能一边看到 216、一边最高只有 201。 +所以"`config_release 216` 已在**共享库**生效"这句,**对这台环境不成立**。 + +同样地,你说的"合并前生效版本是 186、白名单是 `query_knowledge`"、"`fund_query_demo:fund_quote` +已经不存在",在我这边也不成立 —— 我这边 201 的内容是: + +| config_key | allowed_tools(我这边 201 实测) | +|---|---| +| `customer_service:faq` | `["search_knowledge"]` | +| `customer_service:policy_explain` | `["search_knowledge"]` | +| `customer_service:product_inquiry` | `["search_knowledge"]` | +| `customer_service:suitability_check` | `["search_knowledge", "check_suitability"]` | +| `fund_query_demo:fund_quote` | `["query_fund_quote"]` ← **存在** | +| `risk:general` | `[]` | +| `risk:risk_evidence` | `["get_alert_evidence"]` | +| `risk:risk_overview` | `["get_risk_overview"]` | +| `risk:risk_search` | `["search_risk_alerts"]` | + +### 1.2 ⚠️ 合并后画像出口会立刻断链 + +你在 §4.3 写"`PROFILE_WHITELIST_INTENT = INTENT_FAQ`:画像工具调用复用**已发布**的 `faq` +意图 key" —— 那是基于你的 216(`faq = [search_knowledge, query_customer_profile]`)。 + +**我这边 201 的 `faq` 只有 `["search_knowledge"]`,没有 `query_customer_profile`。** +`ToolExecutor` 取交集、失败关闭 ⇒ 画像出口一调用就被拒。 + +**修法(合并时必须同时做)**:补发一个版本,把 `customer_service:faq` 改成 +`["search_knowledge", "query_customer_profile"]`,其余 8 条原样继承(`config_release` 是整版本 +替换,漏带会清空别人的白名单)。这一步我这边可以出脚本。 + +### 1.3 ⚠️ Milvus 也不是同一个,字段映射改动会打挂检索 + +我这边实测三个集合(`python tools/probe_knowledge_collections.py`,证据 +`docs/evidence/knowledge-collections.json`): + +| 集合 | 行数 | 字段 | +|---|---|---| +| `fin_faq_collection` | **125** | `doc_id`(主键) `title` `content` `chapter` `section` `tags` `doc_no` `version` `effective_date` `expire_date` `source_url` `reviewer` `source_file` `visibility` `embedding` | +| `fin_policy_collection` | **297** | 同上 | +| `fin_product_collection` | **214** | 同上 | + +你说的是 `knowledge_id | title | snippet | tags | version | intent | embedding`、**无** `visibility`、 +行数 **106/177/73** —— **字段名、`visibility` 有无、行数三处全不一样。** + +而我这边 `app/service/knowledge_search_service.py:23-25` 的 + +```python +OUTPUT_FIELDS = ("doc_id", "title", "content", "chapter", "section", + "tags", "doc_no", "version", "source_file", "visibility") +``` + +与我的集合**逐字一致**,`:152` 的 `visibility == "public"` 过滤也在正常工作。 + +**所以 §4.2 不是"A/B/C 三选一"的问题。** 你的 A 方案在这边会: + +1. `doc_id` → `knowledge_id` ⇒ Milvus 报 `field knowledge_id not exist`,**整条检索失败**; +2. `content` → `snippet` ⇒ 同样失败; +3. `_HAS_VISIBILITY = False` ⇒ 把**本来正常工作的**内部资料隔离关掉。 + +**我要的修法不是 A/B/C,而是第四种:把字段映射改成运行时探测。** + +```python +# 启动时 describe_collection 拿真实字段名,建立 逻辑名 → 实际字段名 的映射; +# visibility 有就过滤、没有就跳过;缺哪个逻辑字段就明确报出来,不要硬编码。 +``` + +这样两套 schema **都能跑**,不需要迁移、不需要重灌数据,也不需要改检索逻辑 —— 你在 §4.2 里 +"只改读取侧、不动 `KnowledgeHit` 对外形状"的克制我完全同意,只是落点应该从"硬编码另一套字段名" +换成"探测"。这也顺带解决你担心的"改回去很麻烦":探测之后没有需要改回去的东西。 + +> 顺带:你那边行数 106/177/73 vs 我这边 125/297/214,说明**演示数据也不是同一份**。演示前要统一。 + +### 1.4 解释器不统一,数字不可比 + +你用 `.venv\Scripts\python.exe` —— 这个目录**在项目里不存在**(`.venv` 被 gitignore,那是你机器上的)。 +项目约定与文档用的是 `D:\conda\envs\jr_py313\python.exe`。 + +结果就是 §5 那张"mypy 181 个错"的表跑不到我的基线上:**我这边 `mypy app` 是 `138 source files, +no issues found`,0 个错**。你列的"`app/model/fund.py` 105 个错"在我这里一个都没有。 + +**请先统一解释器再讨论 mypy 数字**,否则每次合并都要重新吵一遍。 + +--- + +## 2. 文档编号:`docs/26` 会和已有的 `docs/21` 重复 + +你说"`docs/26-JWT密钥管理与轮换.md` 是新文件,原 21→25→26 两次让号"。 + +**但我这边 `docs/21-JWT密钥管理与轮换.md` 已经存在。** 也就是说合并后会出现**两份同主题文档** +(21 与 26),这比编号冲突更糟 —— 读者不知道该信哪一份。 + +**建议**:不要新增 26,把内容合并进已有的 `docs/21`。如果你手上那份是旧 21 的修订版, +请在 PR 里说明"以 26 的内容覆盖 21"并**删除 21**,二选一,别并存。 + +--- + +## 3. §6 四项待裁决的答复 + +### 3.1 `AgentGovernance.review()` 新增 `agent_type` —— ✅ 同意 + +风控 Agent 的输出是给风控专员看的字段化摘要(预警编号、级别、建议动作),给它追加一句面向 +投资者的免责声明**本身就是错的** —— 你实测 `tests/contract/test_risk_agent_contract.py` 因此 +失败,那是真实症状,不是测试太严。 + +判定口径选"**确认式白名单**"(只有 `customer_service` / `fund_query_demo` 注入,空串不注入) +比"默认注入"安全,理由(测试替身不连库,默认注入会污染每个替身测试)我认同。 +"带默认值所以旧调用点不会 TypeError"也处理得对。 + +**补充一个要求**:请确认 `agent_type` 同时写进了治理环节的审计记录。治理层改写了对外输出, +事后必须能追溯到是哪个 Agent 触发的。 + +### 3.2 字段映射 —— ⚠️ 不是 A/B/C,见 §1.3(改为运行时探测) + +### 3.3 删除 `docs/04`/`06`/`10`/`13`/`99` —— ❌ 驳回,请保留 + +我这五个文件**全都在**(`docs/04-开发文档评审报告.md`、`docs/06-底座代码测试报告.md`、 +`docs/10-业务域接入评估.md`、`docs/13-基金行情工具业务接入清单.md`、 +`docs/99-已废弃-公共Agent平台接口规范.md`)。 + +删除它们收益为零(你说"无人引用",但保留的成本同样为零),代价是:PR diff 里出现 5 个删除、 +还要同步改 `AGENTS.md` 里"已删除"的说明、并且丢掉了 `docs/10` 这份业务域接入评估。 +**请把这一项从 PR 里去掉。** + +### 3.4 `docs/26` 编号 —— ⚠️ 见 §2,建议并入 `docs/21` + +--- + +## 4. 关于你 §7.1 的提醒(`memory_sync_outbox`) + +你提醒"你最近那批提交里有'记忆→画像→图全自动触发',`memory_sync_outbox` 没有消费者, +这两处可能重叠,请先确认再动手"。 + +我 grep 过了:`MemorySyncOutbox` 在全仓**只有模型定义**,**既没有生产者、也没有消费者**。 +所以你说的"事件写入了、没人消费"在我这边不成立(根本没写进去)。**不重叠。** + +但你的观察方向是对的,而且比你想的更严重:我另外发现 `app/worker/graph_projection_worker.py` +的 `GraphProjectionWorker` **没有任何实例化点**。**这两件事大概率是同一个问题** —— 组件写好了、 +线没接。这条链路整体未接线,麻烦你那边也确认下你的分支上是什么状态。 + +--- + +## 5. 合并顺序建议 + +1. **先对齐环境**:确认你我连的是不是同一个 MySQL 与同一个 Milvus。**所有"配置已发布""数据是 + 某某 schema"的结论都建立在这上面**,不对齐的话下面每一步都要返工; +2. 统一解释器(`D:\conda\envs\jr_py313\python.exe`); +3. `§1.3` 的字段映射改成运行时探测,`§3.3` 的删除项撤回,`§2` 的编号并入 21; +4. 合代码; +5. **合并后立刻补发配置**(`customer_service:faq` 加 `query_customer_profile`,§1.2),否则画像 + 出口不可用; +6. 统一演示数据(行数差异见 §1.3)。 + +--- + +## 6. 我这边的状态(供你参考) + +- 分支 `qyqy_develop_1` 已包含 `origin/qyqy_develop` 的 `d2cdbba`,并且**多 8 个提交**(含组员 + RM2 线并入的 4 个),尚未推送; +- 门禁:`ruff` 干净 / `mypy app` 138 文件 0 错 / **703** unit+contract / **33** integration; +- 我这一轮改的公共部分(供你判断冲突面):`ApiTransactionService.execute_in`(幂等,复用调用方 + 事务)、`app/api/views/envelope.py`(§3.3 信封,风控+会话+知识引用三处共用)、 + `app/api/dependencies/negotiation.py`(SSE `Accept` 协商)、`risk_cursor.py` 的游标绑定、 + `OutboxHandlerError`(worker 失败原因可诊断且不泄露异常消息); +- 你 `§4.2` 说的"公共 SSE 协商""公共鉴权基座"这类问题,**确实归我这边修**,已经在做了 —— + 分工没有空档。 + +有异议随时提,尤其是 §1.3:如果你那边确认过现库字段就是 `knowledge_id`/`snippet`, +那说明我们连的不是同一个 Milvus,那就先把这个对齐了再谈代码。 diff --git a/docs/NL_develop评审回复-架构师答复.md b/docs/NL_develop评审回复-架构师答复.md new file mode 100644 index 0000000..3be72ac --- /dev/null +++ b/docs/NL_develop评审回复-架构师答复.md @@ -0,0 +1,257 @@ +# 架构师答复 · 第二轮(对《评审意见回复》的回应) + +> **致**:`NL_develop` 维护者 +> **被回复**:`评审意见回复-NL_develop.md`、`接手文档-NL_develop-给架构师.md` +> **基线核对时间**:2026-09-11 晚 **本机**:`qyqy_develop_1` +> **结论**:**你的整改我全部认可,两处让号都批准,`docs/00` 我不动。另有一条技术纠正(mypy 根因)和一条 +> "你的合并会顺带修好我这边一个既有故障"。** + +--- + +## 0. 先说一条我欠你的:`docs/21` 重号是我这边的既有故障 + +你说"`docs/21` 这个号已被你的《风控业务第二版迁移清单》占用,所以我让到 26"。 + +**我核实了,你说得对,而且比你说的更严重**——我这台 `docs/` 顶层**就是重号的**: + +``` +$ python tools/check_authoritative_docs.py +docs 顶层重号 {'21': ['21-JWT密钥管理与轮换.md', '21-风控业务第二版迁移清单.md']} +``` + +**这个守卫脚本在我这里当前是失败的**,而我此前一直没跑到它(我只跑 ruff / mypy / pytest)。 +也就是说:`docs/21` 早在你让号之前就已经是两份了,**你的让号顺手修好了我一个既有故障**。 + +⇒ **`docs/26` 让号:批准。** 理由不只是"你的文件引用少",而是**这是唯一能让我这边守卫变绿的方案**。 + +同理 **`docs/27`(NL2SQL,原 15)批准** —— 手册被 `docs/16`/`docs/17`/`AGENTS.md` 三处引用, +依据充分。 + +--- + +## 1. 技术纠正:mypy 184 错的根因不是"缺 SQLAlchemy 2.0 类型信息" + +你查到"装 `sqlalchemy2-stubs` 后 181→43、卸载回 184",并据此判断主因是缺类型信息。 +**方向对了一半,但结论反了。** 我这边实测: + +``` +mypy 1.20.2 (compiled: yes) +sqlalchemy 2.0.52 +$ pip show sqlalchemy2-stubs → WARNING: Package(s) not found + +$ python -m mypy app +Success: no issues found in 138 source files ← 0 错 +``` + +**`sqlalchemy2-stubs` 没有装,SQLAlchemy 也是 2.0.52,而我这边 0 错。** + +关键在于 `pyproject.toml` 的约束: + +```toml +"sqlalchemy>=2.0,<3", +"mypy>=1.14,<2", +[tool.mypy] +python_version = "3.13" +strict = true +``` + +**SQLAlchemy 2.0 自带 `py.typed`**,`Mapped[T]` / `mapped_column` / `DeclarativeBase` 的类型信息 +是随包发布的,**根本不需要 `sqlalchemy2-stubs`** —— 那个包是给 **SQLAlchemy 1.4** 用的。 +你自己也观察到了装上之后"还会换一批新错(`mapped_column`/`DeclarativeBase` 不存在)", +那正是它在按 1.4 的 API 描述去核对 2.0 代码。 + +**所以那 184 错的真实来源是:你的 `.venv` 没满足 `pyproject.toml` 的约束**,最可能是 +SQLAlchemy 版本低于 2.0(也可能是 mypy 低于 1.14)。请这样确认: + +```powershell +.\.venv\Scripts\python.exe -c "import sqlalchemy; print(sqlalchemy.__version__)" +.\.venv\Scripts\python.exe -m mypy --version +``` + +若 SQLAlchemy < 2.0,按 `pyproject.toml` / `requirements.txt` 重建环境(`pip install -e .`), +**mypy 应该会落到和我一样的量级**。 + +**两个具体请求**: + +1. **不要把 `sqlalchemy2-stubs` 写进依赖**——它会让 2.0 代码按 1.4 的类型定义报错; +2. 你 §1.4 说"不再拿这个数字当结论"是对的,但**别把它归因成"代码质量无从判断"**: + 环境对齐后这个数字是可信的,我这边就是 0 错。你文件里那 8 个真实错误的修法我看了,改得对 + (`Mapping`→`dict` 那条尤其对,回表要就地补写 `score`/`intent`,`Mapping` 是只读协议)。 + +> 顺带:`model_gateway.py` 那 2 个和 `runtime.py` 那 1 个你选择"不用 `cast` 掩盖"—— +> 我同意先放着。环境修好后大概率自己就消失了。 + +--- + +## 2. 三件待裁决的事 + +### 2.1 画像工具白名单补发 —— ✅ 归我,我出脚本 + +接受你的理由(`config_release` 是环境数据、不随代码合并,谁的环境谁发布)。合并进来后我发一版: + +``` +customer_service:faq = [search_knowledge, query_customer_profile] ← 唯一变化 +其余 8 条原样继承 +``` + +你提醒的"**同 key 的继承项必须被本次新定义覆盖**"这个坑我记下了——这正是 +`config_release` 整版本替换语义最容易踩的地方,谢谢。脚本我会按"同 key 覆盖 + 打印被替换的旧值"写。 + +### 2.2 `memory_sync_outbox` / `GraphProjectionWorker` 归属 —— ✅ 归我 + +**判定依据**(不是分工方便,是证据): + +| | 我这台 | 你这台 | +|---|---|---| +| `MemorySyncOutbox` 生产者 | **无** | 有(`profile_repository.py:142`) | +| 表内数据 | 空 | 2 行待处理 | +| 消费端 | 无 | 有定义、**无实例化点** | +| `GraphProjectionWorker` | 无实例化点 | 无实例化点 | + +**生产者在你那边,我这边什么都没有** ⇒ 缺的是**消费端**,而消费端属 `app/worker/` 装配层, +正是我这条线在维护(我改过 `runtime.py` 的 `knowledge_writer` 那类注入点)。 + +**所以**:生产者保留你的,**消费端(`GraphProjectionWorker` 实例化 + 与 `runtime.py` 装配 + +`ProjectionReconciliationService` 接上)归我**。你 §6.2 说"我先不动手"是对的,**继续保持不动**。 + +**时机**:合并之后我再接。现在两边都别动,避免各接一根线。 + +### 2.3 两处让号 —— ✅ 都批准 + +见 §0(`docs/26`)与 §0 末(`docs/27`)。 + +--- + +## 3. `docs/00` 基线要不要补那 17 张表 —— ❌ 不改 `docs/00`,另立文档 + +我的裁决是**不改进 `docs/00`**,依据是基线规则本身: + +> `AGENTS.md` 规则 8:**当前系统业务功能只针对场内基金模拟交易;场外基金运营流程独立, +> 不得写入场内交易表。** + +同事带来的 17 张表是: + +``` +offsite_* (10) 场外基金运营 +promotion_* ( 7) 推广域 +``` + +**这两个域都不是场内交易域**,而 `docs/00-新数据库基线设计.md` 冻结的正是场内交易域的基线。 +把独立域塞进去,等于让"基线"这个概念失效——下次有人拿 `docs/00` 当"当前全部表"的依据时就错了。 + +**但必须有人管**,否则 68 张表与文档的 51 张永远对不上、审计每次都报差异。所以: + +1. **`docs/00` 不动**(不可变基线,规则 1); +2. **新增一份独立登记**:建议 `docs/28-场外与推广域数据表登记.md`,逐表登记表名、归属域、 + 哪个迁移建的、是否被场内代码引用; +3. **同步 `docs/08-数据库结构审计基线.md`** 的表数口径:不是简单改成 68,而是写成 + 「场内 51 + 场外/推广 17 = 68」,并说明分类依据; +4. **`tools/audit_schema.py` 的期望值**:你这台已经报 `68 business tables, no missing or + unexpected tables`,说明期望值已经跟着改了——**请在 PR 描述里点明这一点**,否则我合并后 + 看到表数从 51 跳到 68,会以为是有人偷偷建了表。 + +**这一条我采纳你的做法,但补一个归属文档**——你的"只提示、不动手"是对的,因为它是不可变基线。 + +--- + +## 4. 你那 3 个 failed:判断都对,但请注意它们不在我这条分支上 + +| 用例 | 你的判断 | 我的意见 | +|---|---|---| +| `test_fund_readonly_contract` | 既有空集缺陷,双方都不修 | ✅ 同意 | +| `test_offsite_document_recognition_adapter` ×2 | 环境相关(httpx 序列化成 `\uXXXX`) | ✅ 判断对,你建议的"断言 `json.loads(body)` 后的字段值"也对——字节级断言本来就不该用来测 JSON | + +**但要提醒一句**:这 2 个用例**在我这条分支上根本不存在**(同事那条线没进来,我这边 +`pytest tests/unit tests/contract` 是 703 passed)。所以它们不是"双方共有的既有失败", +而是**你这条线合并进来之后才会出现**的。合并后我会把它们当**新引入的失败**对待并跟踪, +不会当成"历史遗留"放过。 + +--- + +## 5. 同步给你:我这边的实测状态(供你判断合并冲突面) + +``` +分支:qyqy_develop_1,已含 origin/qyqy_develop 的 d2cdbba,并多 1 个提交(未推送) + +alembic:current = heads = 20260911_risk_rule_index ← 单一 head,只有我自己的迁移 + (不是你说的 20260911_merge_risk_heads —— 同事那 11 个迁移不在我这条线上) +表数: schema audit passed: 51 business tables ← 与你那边的 68 张对不上,见 §3 +门禁: ruff 干净 / mypy app 138 文件 0 错 / 703 unit+contract / 33 integration +文档守卫:❌ 失败(docs/21 重号,见 §0) +``` + +**⇒ 合并后我必须补做 4 件事**(记在这里,免得忘): + +1. `alembic upgrade heads` —— 补跑你那 11 个迁移、建 17 张表。我这边是"表没建、版本号也没跑", + 和你那边"版本号跑了、表没建"是**两种不同的坏状态**,但都靠这一条收敛; +2. 补发配置(§2.1); +3. 接 `memory_sync_outbox` 消费端(§2.2); +4. 补场外/推广域的表登记 + 更新审计基线口径(§3)。 + +--- + +## 6. 对你 §7 两条建议的答复 + +1. **文档体系两套口径** —— 认可你的做法(保留 `docs/04`/`06`/`10`/`13`/`99`,但列入 D 类"不要用来 + 判断当前进度")。理由:你保留了文件(成本为零),又明确标注了它们的可信度(避免误用), + 这比"删掉"和"无差别都读"都好。**不需要改写索引。** +2. **`docs/00` 补表** —— 见 §3,不动 `docs/00`。 + +--- + +## 7. 一句话总结 + +你这轮的整改**没有一条我不同意**(除了 mypy 那条归因,而且那是环境问题不是代码问题)。 +剩下的事按这个顺序就能收敛: + +**你**:环境重建(§1)→ 推送 `NL_develop`; +**我**:跑迁移 → 补发配置 → 接 outbox 消费端 → 补表登记 → 跑全量门禁 → 推送。 + +> 最后回应你 §1.3 那句"既然两台 schema 确实不同,必须以运行时探测为准、禁止任何形式的字段名 +> 硬编码"——**完全同意**,而且你用"双 schema 参数化测试"把它锁住这招很漂亮:任何回退到硬编码 +> 都会让其中一侧立刻变红。比我原来担心的"靠人记住"可靠得多。 + +--- + +## 8. 补充发现(写补发脚本时挖出来的):两个发布脚本会**丢提示词** + +我按你提醒的"同 key 覆盖"去写补发脚本时,顺手核了一下**继承源**,发现一个真问题: + +`tools/publish_customer_service_config.py` 与 `tools/publish_risk_agent_config.py` 的 +`active_config_items()` 都是**自己写 SQL、只查 `platform_config_item`**: + +```sql +SELECT i.namespace, i.config_key, i.value_json, i.schema_version +FROM platform_config_item i JOIN config_release r ON r.id = i.release_id +WHERE r.status = 'active' +``` + +而 `ConfigReleaseService.effective_snapshot()` 的文档字符串写得很清楚 —— 你自己在 +`publish_chitchat_prompt.py` 里也引用过这条: + +> 快照读的是**全部三张**受管表(`platform_config_item` / `prompt_template_version` / +> `model_routing_rule`),**漏读一张就是一次静默失效** —— 这次的事故正是这么来的。 + +**⇒ 用这两个脚本发版,会把生效版本里的提示词一起清掉。**(本环境 201 里有 1 条 +`customer_service_chitchat` v2。) + +而且这个失败**没有任何声音**:`_warn_dropped_items()` 只告警、不阻断激活,Agent 侧 +`_chitchat_prompt` 又有逐字段兜底、会回落代码默认值 —— **功能看着正常**, +和 release 174 那次事故一模一样。 + +**建议**:把这两个脚本的 `active_config_items()` 换成 +`ConfigReleaseService(session).effective_snapshot()`。我已经在新的 +`tools/publish_profile_tool_whitelist.py` 里这么做了,顺带处理了三个细节,供你参考: + +1. **同 key 覆盖**(你已经修了,很好); +2. **JSON 列必须归一化** —— `SELECT *` 读出来的 `value_json` 可能是**字符串**, + 不 `json.loads` 的话 `isinstance(value, dict)` 为假,脚本会**静默**走到"生效版本里没有 + 该 key"那条分支。我第一版就踩了这个,靠加诊断打印才定位到(表现出来像"配置缺失"); +3. **提示词搬家要重分配 `version`**(唯一键含 version),并按 `PromptPayload` 的 9 个字段 + 挑字段 —— 表里的 `checksum`/`created_by`/`created_at` 是服务端生成的不能搬, + 而 `input_schema`/`output_schema` 是 API 收的、漏了就丢。 + +**时序提醒**:admin 端校验「配置 ⊆ 代码 `allowed_tools`」(`app/service/admin_service.py:219-220`), +所以**画像工具那一版必须在你的代码合并进来之后才能发**,否则必然 422,而报错只有 +"配置超出 Agent 工具上限"(看不出是时序问题)。我在脚本里加了前置自检:合并前跑会直接中止 +并说明原因,而不是抛出那个费解的校验错。 diff --git a/docs/evidence/knowledge-collections.json b/docs/evidence/knowledge-collections.json new file mode 100644 index 0000000..4b4c592 --- /dev/null +++ b/docs/evidence/knowledge-collections.json @@ -0,0 +1,304 @@ +{ + "collections": [ + { + "collection": "fin_faq_collection", + "row_count": 125, + "field_names": [ + "doc_id", + "title", + "content", + "chapter", + "section", + "tags", + "doc_no", + "version", + "effective_date", + "expire_date", + "source_url", + "reviewer", + "source_file", + "visibility", + "embedding" + ], + "fields": [ + { + "name": "doc_id", + "type": "21", + "is_primary": true + }, + { + "name": "title", + "type": "21", + "is_primary": false + }, + { + "name": "content", + "type": "21", + "is_primary": false + }, + { + "name": "chapter", + "type": "21", + "is_primary": false + }, + { + "name": "section", + "type": "21", + "is_primary": false + }, + { + "name": "tags", + "type": "21", + "is_primary": false + }, + { + "name": "doc_no", + "type": "21", + "is_primary": false + }, + { + "name": "version", + "type": "21", + "is_primary": false + }, + { + "name": "effective_date", + "type": "21", + "is_primary": false + }, + { + "name": "expire_date", + "type": "21", + "is_primary": false + }, + { + "name": "source_url", + "type": "21", + "is_primary": false + }, + { + "name": "reviewer", + "type": "21", + "is_primary": false + }, + { + "name": "source_file", + "type": "21", + "is_primary": false + }, + { + "name": "visibility", + "type": "21", + "is_primary": false + }, + { + "name": "embedding", + "type": "101", + "is_primary": false + } + ], + "has_visibility": true, + "looks_like_legacy_schema": true + }, + { + "collection": "fin_policy_collection", + "row_count": 297, + "field_names": [ + "doc_id", + "title", + "content", + "chapter", + "section", + "tags", + "doc_no", + "version", + "effective_date", + "expire_date", + "source_url", + "reviewer", + "source_file", + "visibility", + "embedding" + ], + "fields": [ + { + "name": "doc_id", + "type": "21", + "is_primary": true + }, + { + "name": "title", + "type": "21", + "is_primary": false + }, + { + "name": "content", + "type": "21", + "is_primary": false + }, + { + "name": "chapter", + "type": "21", + "is_primary": false + }, + { + "name": "section", + "type": "21", + "is_primary": false + }, + { + "name": "tags", + "type": "21", + "is_primary": false + }, + { + "name": "doc_no", + "type": "21", + "is_primary": false + }, + { + "name": "version", + "type": "21", + "is_primary": false + }, + { + "name": "effective_date", + "type": "21", + "is_primary": false + }, + { + "name": "expire_date", + "type": "21", + "is_primary": false + }, + { + "name": "source_url", + "type": "21", + "is_primary": false + }, + { + "name": "reviewer", + "type": "21", + "is_primary": false + }, + { + "name": "source_file", + "type": "21", + "is_primary": false + }, + { + "name": "visibility", + "type": "21", + "is_primary": false + }, + { + "name": "embedding", + "type": "101", + "is_primary": false + } + ], + "has_visibility": true, + "looks_like_legacy_schema": true + }, + { + "collection": "fin_product_collection", + "row_count": 214, + "field_names": [ + "doc_id", + "title", + "content", + "chapter", + "section", + "tags", + "doc_no", + "version", + "effective_date", + "expire_date", + "source_url", + "reviewer", + "source_file", + "visibility", + "embedding" + ], + "fields": [ + { + "name": "doc_id", + "type": "21", + "is_primary": true + }, + { + "name": "title", + "type": "21", + "is_primary": false + }, + { + "name": "content", + "type": "21", + "is_primary": false + }, + { + "name": "chapter", + "type": "21", + "is_primary": false + }, + { + "name": "section", + "type": "21", + "is_primary": false + }, + { + "name": "tags", + "type": "21", + "is_primary": false + }, + { + "name": "doc_no", + "type": "21", + "is_primary": false + }, + { + "name": "version", + "type": "21", + "is_primary": false + }, + { + "name": "effective_date", + "type": "21", + "is_primary": false + }, + { + "name": "expire_date", + "type": "21", + "is_primary": false + }, + { + "name": "source_url", + "type": "21", + "is_primary": false + }, + { + "name": "reviewer", + "type": "21", + "is_primary": false + }, + { + "name": "source_file", + "type": "21", + "is_primary": false + }, + { + "name": "visibility", + "type": "21", + "is_primary": false + }, + { + "name": "embedding", + "type": "101", + "is_primary": false + } + ], + "has_visibility": true, + "looks_like_legacy_schema": true + } + ] +} \ No newline at end of file diff --git a/docs/evidence/release-state.json b/docs/evidence/release-state.json index 9e3ee0b..850c8aa 100644 --- a/docs/evidence/release-state.json +++ b/docs/evidence/release-state.json @@ -47,5 +47,52 @@ "items": 9 } ], - "active_prompt_versions": 1 + "active_prompt_versions": 1, + "active_agent_tools": [ + { + "config_key": "customer_service:faq", + "value_json": "{\"allowed_tools\": [\"search_knowledge\"]}", + "schema_version": "1" + }, + { + "config_key": "customer_service:policy_explain", + "value_json": "{\"allowed_tools\": [\"search_knowledge\"]}", + "schema_version": "1" + }, + { + "config_key": "customer_service:product_inquiry", + "value_json": "{\"allowed_tools\": [\"search_knowledge\"]}", + "schema_version": "1" + }, + { + "config_key": "customer_service:suitability_check", + "value_json": "{\"allowed_tools\": [\"search_knowledge\", \"check_suitability\"]}", + "schema_version": "1" + }, + { + "config_key": "fund_query_demo:fund_quote", + "value_json": "{\"allowed_tools\": [\"query_fund_quote\"]}", + "schema_version": "1" + }, + { + "config_key": "risk:general", + "value_json": "{\"allowed_tools\": []}", + "schema_version": "1" + }, + { + "config_key": "risk:risk_evidence", + "value_json": "{\"allowed_tools\": [\"get_alert_evidence\"]}", + "schema_version": "1" + }, + { + "config_key": "risk:risk_overview", + "value_json": "{\"allowed_tools\": [\"get_risk_overview\"]}", + "schema_version": "1" + }, + { + "config_key": "risk:risk_search", + "value_json": "{\"allowed_tools\": [\"search_risk_alerts\"]}", + "schema_version": "1" + } + ] } \ No newline at end of file diff --git a/tools/probe_knowledge_collections.py b/tools/probe_knowledge_collections.py new file mode 100644 index 0000000..9e488b8 --- /dev/null +++ b/tools/probe_knowledge_collections.py @@ -0,0 +1,79 @@ +"""只读探查:Milvus 知识集合的真实 schema(用于评审 NL_develop 的字段映射改动)。 + +只做 describe / query,不写入。结果写 `docs/evidence/knowledge-collections.json`: + + python tools/probe_knowledge_collections.py + +要回答的问题(对应交付说明 §4.2): +1. 现库集合的**实际字段名**是什么 —— 是 `doc_id`/`content`/`visibility` 那套, + 还是 `knowledge_id`/`snippet` 那套?他选的字段映射(A 方案)建立在这个前提上; +2. 每个集合有多少行; +3. 有没有 `visibility` 字段 —— 没有的话,检索层的内部资料硬隔离就是关着的。 +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from app.core.config import get_settings + +OUTPUT = Path("docs/evidence/knowledge-collections.json") + + +def _client() -> Any: + from pymilvus import MilvusClient # type: ignore[import-untyped] + + settings = get_settings() + return MilvusClient(uri=settings.milvus_uri, token=settings.milvus_token or None) + + +def collect() -> dict[str, Any]: + client = _client() + report: dict[str, Any] = {"collections": []} + for name in sorted(client.list_collections()): + try: + described = client.describe_collection(name) + fields = [ + { + "name": str(field.get("name")), + "type": str(field.get("type")), + "is_primary": bool(field.get("is_primary", False)), + } + for field in described.get("fields", []) + ] + stats = client.get_collection_stats(name) + row_count = stats.get("row_count") if isinstance(stats, dict) else None + except Exception as exc: # 单个集合探测失败不影响其余 + report["collections"].append( + {"collection": name, "error": f"{type(exc).__name__}: {exc}"} + ) + continue + report["collections"].append( + { + "collection": name, + "row_count": row_count, + "field_names": [field["name"] for field in fields], + "fields": fields, + "has_visibility": any(field["name"] == "visibility" for field in fields), + "looks_like_legacy_schema": any( + field["name"] in {"doc_id", "chapter", "doc_no"} for field in fields + ), + } + ) + return report + + +def main() -> None: + report = collect() + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text( + json.dumps(report, ensure_ascii=False, indent=2, default=str), + encoding="utf-8", + ) + print(f"wrote {OUTPUT}") + + +if __name__ == "__main__": + main() diff --git a/tools/probe_release_state.py b/tools/probe_release_state.py index 290ea86..d65be7e 100644 --- a/tools/probe_release_state.py +++ b/tools/probe_release_state.py @@ -73,6 +73,28 @@ async def collect() -> dict[str, Any]: ) ) ).scalar_one() + # 工具白名单的**具体内容**:它必须与代码里的 allowed_tools 交集非空, + # 否则 ToolExecutor 失败关闭,Agent 任何工具调用都被拒。 + report["active_agent_tools"] = [ + dict(row) + for row in ( + ( + await session.execute( + text( + """ + SELECT i.config_key, i.value_json, i.schema_version + FROM platform_config_item i + JOIN config_release r ON r.id = i.release_id + WHERE r.status = 'active' AND i.namespace = 'agent_tools' + ORDER BY i.config_key + """ + ) + ) + ) + .mappings() + .all() + ) + ] return report diff --git a/tools/publish_profile_tool_whitelist.py b/tools/publish_profile_tool_whitelist.py new file mode 100644 index 0000000..22c3c8d --- /dev/null +++ b/tools/publish_profile_tool_whitelist.py @@ -0,0 +1,337 @@ +"""补发客服工具白名单:把 `query_customer_profile` 加进 `customer_service:faq`。 + +## 为什么必须补发 + +`ToolExecutor` 取「代码 `AgentDefinition.allowed_tools` ∩ 发布配置 +`agent_tools/:`」的**交集**,缺配置即**失败关闭**。 + +`NL_develop` 引入画像问答出口后,客服的代码上限变成 +`(search_knowledge, check_suitability, query_customer_profile)`,而本环境生效版本 **201** 的 +`customer_service:faq` 只有 `["search_knowledge"]` —— 画像出口一调用就会被 +`AGENT_PERMISSION_DENIED` 拒掉,表现为"问等级/画像一律转人工"。 + +## 为什么不能只发这一条 + +`config_release` 是**整版本替换**语义:新版本没带上的配置项**等于被删除**。 +所以要先把当前生效版本在**全部三张受管表**(`platform_config_item` / +`prompt_template_version` / `model_routing_rule`)里的内容搬进新版本,再覆盖本次要改的那一条。 + +本环境 201 里有 **9 条 `agent_tools` + 1 条提示词**;只发白名单会把那条提示词一起清掉, +而 Agent 侧有逐字段兜底、会回落代码默认值 —— **功能看着正常、没有任何告警**, +这个事故在本项目真实发生过一次(提示词挂在 release 174、active 变成 181 后读不到)。 + +## 两个已踩过的坑 + +1. **同 key 的继承项必须被本次新定义覆盖。** 否则旧值(这里是 `["search_knowledge"]`) + 会被 admin 端的子集校验 422 拦下,**整次发布失败**,而报错只有 + "配置超出 Agent 工具上限",看不出是继承造成的。 +2. **提示词搬运必须重分配 `version`。** `prompt_template_version` 的唯一键含 `version`, + 照搬旧行会冲突;同时**必须带上 `input_schema` / `output_schema`**, + `PromptPayload` 只收 9 个字段,漏了就是静默丢失。 + +跑法: + + python tools/publish_profile_tool_whitelist.py --dry-run # 只看要写什么,不动库 + python tools/publish_profile_tool_whitelist.py # 真正发布 + +## ⚠️ 必须在 `NL_develop` 合并进来**之后**才能跑 + +admin 端会校验「配置项 ⊆ 代码 `allowed_tools`」(`app/service/admin_service.py:219-220`): + + if not set(raw) <= set(definition.allowed_tools): + raise ValidationAgentError("配置超出 Agent 工具上限") + +合并前代码上限还是 `(search_knowledge, check_suitability)`,**不含** `query_customer_profile` +⇒ 这一版会被 422 拒绝,而报错只有"配置超出 Agent 工具上限",看起来像白名单写错了。 +所以脚本启动时会先查代码上限并给出明确原因,不让你去猜。 +""" + +import asyncio +import argparse +import datetime as dt +import json +import sys +import uuid +from pathlib import Path +from typing import Any + +import httpx +import jwt +from sqlalchemy import func, select + +from app.core.config import get_settings +from app.infrastructure.db import SessionFactory +from app.main import create_app +from app.model.configuration import PromptTemplateVersion +from app.service.agent.bootstrap import get_agent_factory +from app.service.config_release_service import ConfigReleaseService + +# Windows 控制台常是 GBK:输出里的部分符号会直接抛 UnicodeEncodeError,让脚本在 +# "看起来是逻辑错误"的地方失败(本脚本第一版就踩了)。统一兜住,别让编码问题掩盖真问题。 +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] + +ADMIN = "9003" +AGENT_TYPE = "customer_service" +INTENT_KEY = "customer_service:faq" +PROFILE_TOOL = "query_customer_profile" + +# 本次唯一的变化。其余全部原样继承。 +NEW_FAQ_TOOLS: tuple[str, ...] = ("search_knowledge", PROFILE_TOOL) + +# `PromptPayload` 接受的字段(`app/api/schemas/admin.py:56-65`)。 +# 表里还有 checksum / created_by / created_at,那三个由服务端生成,不能搬。 +PROMPT_API_FIELDS = ( + "prompt_code", "task_type", "agent_type", + "system_prompt", "user_prompt_template", "input_schema", "output_schema", +) + + +def _as_json(value: Any) -> Any: + """`SELECT *` 读出来的 JSON 列,在不同驱动下可能是字符串、也可能已解析。 + + 不归一化的后果很隐蔽:`value_json` 保持字符串时,`isinstance(value, dict)` 为假, + 脚本会走到"生效版本里没有该 key"那条分支 —— **看起来像配置缺失,实际只是没解析**。 + 本脚本第一版就是这么错的。`input_schema` / `output_schema` 同理,而且它们是 + `PromptPayload` 的 `dict | None` 字段,传字符串会直接 422。 + """ + return json.loads(value) if isinstance(value, str) else value + + +def token(subject: str) -> str: + settings = get_settings() + private_key = Path(settings.jwt_private_key_path).read_text(encoding="utf-8") + now = dt.datetime.now(dt.UTC) + return jwt.encode( + { + "sub": subject, "iss": settings.jwt_issuer, "aud": settings.jwt_audience, + "exp": now + dt.timedelta(minutes=30), "nbf": now - dt.timedelta(seconds=5), + "jti": str(uuid.uuid4()), + }, + private_key, + algorithm="RS256", + ) + + +async def snapshot() -> dict[str, list[dict[str, Any]]]: + """当前生效版本在全部受管表里的内容;每行已剥掉 id / release_id。""" + async with SessionFactory() as session: + return await ConfigReleaseService(session).effective_snapshot() + + +async def next_prompt_versions(rows: list[dict[str, Any]]) -> list[int]: + """给每个待搬运的 prompt_code 分配一个新 version。 + + 唯一键含 version,照搬旧行会冲突,所以逐个 prompt_code 取现有最大值 +1。 + """ + assigned: list[int] = [] + async with SessionFactory() as session: + for row in rows: + latest = await session.scalar( + select(func.max(PromptTemplateVersion.version)).where( + PromptTemplateVersion.prompt_code == row["prompt_code"] + ) + ) + assigned.append(int(latest or 0) + 1) + return assigned + + +def code_allows_profile_tool() -> bool: + """代码上限里有没有 `query_customer_profile`。 + + admin 端要求「配置 ⊆ 代码 allowed_tools」,所以**合并之前发这一版必然 422**, + 而报错只是"配置超出 Agent 工具上限"。这里提前判定,把真实原因说出来。 + """ + definition = get_agent_factory().definition(AGENT_TYPE) + return PROFILE_TOOL in set(definition.allowed_tools) + + +def current_faq_tools(items: list[dict[str, Any]]) -> list[str] | None: + for item in items: + if str(item.get("item_key")) == INTENT_KEY: + value = item.get("value_json") + if isinstance(value, dict): + tools = value.get("allowed_tools") + if isinstance(tools, list): + return [str(tool) for tool in tools] + return None + + +def merged_items(items: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[str]]: + """继承全部配置项,并把 `customer_service:faq` 覆盖成本次的新值。 + + 返回 (新列表, 被替换掉的旧值描述)。**同 key 覆盖**是关键:留给继承的旧值会被 + admin 端的子集校验 422 拦下,整次发布失败。 + """ + replaced: list[str] = [] + merged: list[dict[str, Any]] = [] + for item in items: + if str(item.get("item_key")) == INTENT_KEY: + old = current_faq_tools([item]) or [] + replaced.append(f"{INTENT_KEY}: {old} → {list(NEW_FAQ_TOOLS)}") + merged.append({**item, "value_json": {"allowed_tools": list(NEW_FAQ_TOOLS)}}) + else: + merged.append(item) + return merged, replaced + + +def prompt_payloads( + rows: list[dict[str, Any]], versions: list[int], release_id: int +) -> list[dict[str, Any]]: + payloads: list[dict[str, Any]] = [] + for row, version in zip(rows, versions, strict=True): + payload: dict[str, Any] = {"release_id": release_id, "version": version} + for field in PROMPT_API_FIELDS: + value = row.get(field) + payload[field] = _as_json(value) if field.endswith("_schema") else value + payloads.append(payload) + return payloads + + +async def post( + client: httpx.AsyncClient, path: str, *, auth: dict[str, str], + payload: dict[str, object] | None = None, if_match: str | None = None, +) -> httpx.Response: + headers = {**auth, "Idempotency-Key": uuid.uuid4().hex} + if if_match: + headers["If-Match"] = if_match + return await client.post(path, json=payload, headers=headers) + + +async def etag_of(client: httpx.AsyncClient, path: str, auth: dict[str, str]) -> str | None: + return (await client.get(path, headers=auth)).headers.get("ETag") + + +async def main() -> int: + parser = argparse.ArgumentParser(description="补发客服工具白名单(画像工具)") + parser.add_argument("--dry-run", action="store_true", help="只打印将写入的内容,不调用任何写接口") + args = parser.parse_args() + + if not code_allows_profile_tool(): + print("[警告] 代码里的 customer_service.allowed_tools 还没有 " + f"{PROFILE_TOOL}(仍为合并前状态)。") + print(" admin 端会以「配置超出 Agent 工具上限」422 拒绝这一版 —— " + "请先合并 NL_develop。") + if not args.dry_run: + print("[失败] 正式发布已中止。") + return 1 + print(" dry-run 继续,仅预览将要写入的内容。\n") + + snap = await snapshot() + items = [ + { + "namespace": row["namespace"], + "item_key": row["config_key"], + "value_json": _as_json(row["value_json"]), + "schema_version": row["schema_version"], + } + for row in snap["platform_config_item"] + ] + prompts = list(snap["prompt_template_version"]) + rules = list(snap["model_routing_rule"]) + + faq = current_faq_tools(items) + if faq is None: + print(f"[失败] 生效版本里没有 {INTENT_KEY},先跑 tools/publish_customer_service_config.py") + print(f" 快照键:{sorted(snap)}") + print(f" 配置项 {len(items)} 条:{[item.get('item_key') for item in items]}") + return 1 + if PROFILE_TOOL in faq: + print(f"✅ {INTENT_KEY} 已包含 {PROFILE_TOOL}({faq}),无需发布") + return 0 + + merged, replaced = merged_items(items) + versions = await next_prompt_versions(prompts) + + print(f"当前生效版本:配置项 {len(items)} 条、提示词 {len(prompts)} 条、路由规则 {len(rules)} 条") + print("\n本次变更:") + for line in replaced: + print(f" · {line}") + print(f"\n将继承:{len(merged)} 条配置项、{len(prompts)} 条提示词") + for prompt, version in zip(prompts, versions, strict=True): + print(f" · 提示词 {prompt['prompt_code']} v{prompt['version']} → v{version}(重分配)") + + if rules: + # 本环境当前为空;不为空就必须先支持搬运,否则激活后会静默丢规则。 + print(f"\n❌ 生效版本里有 {len(rules)} 条 model_routing_rule,本脚本尚未支持搬运。") + print(" 直接发布会把它们清空 —— 先补上搬运逻辑再跑。") + return 1 + + if args.dry_run: + print("\n[dry-run] 未调用任何写接口。去掉 --dry-run 即真正发布。") + return 0 + + app = create_app() + auth = {"Authorization": f"Bearer {token(ADMIN)}"} + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=60 + ) as client: + created = await post(client, "/api/v1/admin/config-releases", auth=auth, payload={ + "release_no": f"cs-profile-tool-{uuid.uuid4().hex[:12]}", + "title": "客服画像工具白名单", + "change_summary": ( + f"{INTENT_KEY} 增加 {PROFILE_TOOL}(画像问答出口)," + f"并继承既有 {len(merged)} 条配置项与 {len(prompts)} 条提示词" + ), + }) + if created.status_code != 201: + print(f"创建发布版本失败:{created.status_code} {created.text[:200]}") + return 1 + release_id = int(created.json()["data"]["id"]) + print(f"\n新发布版本 id={release_id}") + + base = f"/api/v1/admin/config-releases/{release_id}/platform-config-items" + for item in merged: + response = await post(client, base, auth=auth, payload=item) + if response.status_code != 201: + print(f" 写配置项 {item['item_key']} 失败:{response.status_code} {response.text[:200]}") + return 1 + print(f" 已写入 {len(merged)} 条配置项") + + for payload in prompt_payloads(prompts, versions, release_id): + response = await post(client, "/api/v1/admin/prompt-templates", auth=auth, payload=payload) + if response.status_code not in (200, 201): + print(f" 写提示词 {payload['prompt_code']} 失败:{response.text[:200]}") + return 1 + print(f" 已写入 {len(prompts)} 条提示词") + + release_base = f"/api/v1/admin/config-releases/{release_id}" + submitted = await post(client, f"{release_base}/validations", auth=auth, payload={}, + if_match=await etag_of(client, release_base, auth)) + print(f"\n提交复核:{submitted.status_code}") + if submitted.status_code not in (200, 201): + print(f" 失败:{submitted.text[:300]}") + return 1 + reviewed = await post(client, f"{release_base}/reviews", auth=auth, + payload={"decision": "approved", "comment": "客服画像工具白名单"}, + if_match=await etag_of(client, release_base, auth)) + print(f"审核:{reviewed.status_code}") + activated = await post(client, f"{release_base}/activations", auth=auth, payload={}, + if_match=await etag_of(client, release_base, auth)) + print(f"激活:{activated.status_code}") + if activated.status_code not in (200, 201): + print(f" 失败:{activated.text[:200]}") + return 1 + + after = await snapshot() + restored = current_faq_tools([ + { + "item_key": row["config_key"], + "value_json": _as_json(row["value_json"]), + } + for row in after["platform_config_item"] + ]) + print(f"\n激活后:{INTENT_KEY} = {restored}") + print(f" 配置项 {len(after['platform_config_item'])} 条、" + f"提示词 {len(after['prompt_template_version'])} 条") + if PROFILE_TOOL not in (restored or []): + print("[失败] 激活后白名单里仍没有画像工具,请人工核查") + return 1 + if len(after["prompt_template_version"]) != len(prompts): + print("[失败] 提示词条数变了,可能发生了静默丢失,请人工核查") + return 1 + print("[OK] 完成") + return 0 + + +sys.exit(asyncio.run(main()))