From 037e7a011efed5e1dc09ed325da811072be067b4 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 31 Jul 2026 07:09:52 -0400 Subject: [PATCH] docs: fold the plan review findings into the plan --- ...31-response-observability-fields-design.md | 2 +- ...026-07-31-response-observability-fields.md | 32 +++++++++++++------ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/research-wiki/designs/2026-07-31-response-observability-fields-design.md b/research-wiki/designs/2026-07-31-response-observability-fields-design.md index d284099..e19cd38 100644 --- a/research-wiki/designs/2026-07-31-response-observability-fields-design.md +++ b/research-wiki/designs/2026-07-31-response-observability-fields-design.md @@ -143,7 +143,7 @@ Issue 称「两者的数据都已经存在于 `TransportResult.raw` 里」。核 | `src/polygateway/pricing.py` | `ModelPrice` +可选档;`cost()` +可选参;`from_file` 校验;订正唯一换算点注释 | | `src/polygateway/ports.py` | `TelemetryRecorder` 18 → 20 字段 | | `src/polygateway/telemetry/{sqlite,postgres}.py` | DDL +2 列;`_COLUMNS` +2;初始化期幂等补列 | -| `tests/` | 两处手写 18 键 dict 的 helper 必须同步(`unit/test_telemetry.py:78-98`、`integration/test_postgres_telemetry.py:105`——漏改即红,是唯一天然拦截点);`unit/test_ports.py:96` 的全签名 fake 同步;新增契约测试 | +| `tests/` | 四处天然拦截点必须同步(漏改即红): 两个 `_record_minimal` 手写 18 键 dict(`unit/test_telemetry.py:76` 起、`integration/test_postgres_telemetry.py:81-105`)与两个 `_EXPECTED_COLUMNS` 列序断言(`unit/test_telemetry.py:18-40`、`integration/test_postgres_telemetry.py:22-41`);`unit/test_ports.py:96` 的全签名 fake 同步(它**不会**红,Protocol 的 isinstance 不校验签名);新增契约测试 | | `research-wiki/ARCHITECTURE.md` | §5.1 字段表 + 遥测表定义 + 「18 字段冻结」表述 | | 「18 字段冻结」的其余措辞点 | `ports.py:248`、`pricing.py:6`(币种说明里引用了该数字)、`telemetry/sqlite.py:87`、`tests/unit/test_telemetry.py:1` | | Wiki 站 + `CHANGELOG.md` | 按 `docs-convention.md` §2 清单同步(公共行为变更,版本 bump 不得裸发) | diff --git a/research-wiki/plans/2026-07-31-response-observability-fields.md b/research-wiki/plans/2026-07-31-response-observability-fields.md index 11e3407..36d324f 100644 --- a/research-wiki/plans/2026-07-31-response-observability-fields.md +++ b/research-wiki/plans/2026-07-31-response-observability-fields.md @@ -118,6 +118,8 @@ def cost(self, model: str, prompt_tokens: int, completion_tokens: int, `from_file` 的 fail-loud 扩展:条目出现 `cached_input_per_1m` 键时必须可转 float 且非负,否则 `ValueError`;不出现该键 = 合法(旧价格表零改动)。`__post_init__` 同步校验非负。 +顺带订正 `PricingTable` docstring(`pricing.py:36`)那句「cost() 是全库唯一换算点(经 TelemetryEmitter)」——实际有 `TelemetryEmitter`(`middleware/telemetry.py:137`)与 `embedding.py:419` 两个调用点(设计 §6 行为审计已声明)。**只改这一行注释,不做任何结构重构**。 + **验收**: `embedding.py:419` 的三参调用形态**一行不改**仍可用;未配缓存档时,任意输入下 `cost()` 结果与改前逐位相等。 **测试**(`tests/unit/test_pricing.py`): ① 配缓存档 + 命中 → 成本严格低于全额且等于手算值;② 未配缓存档 + 命中 → 与不传该参数结果相等;③ `cached > prompt` → 结果等于全部按缓存价、非负、有 warning;④ `cached_prompt_tokens=None/0` → 全额;⑤ 三参旧调用签名可用;⑥ 价格表含 `cached_input_per_1m: -1` 或 `"x"` → `from_file` 抛 `ValueError`;⑦ 无该键的旧价格表照常加载。 @@ -134,27 +136,39 @@ def cost(self, model: str, prompt_tokens: int, completion_tokens: int, 1. `TelemetryRecorder.record_llm_call`(`ports.py:250-271`)在 `cost` 之后追加 `cached_prompt_tokens: int | None` 与 `model_reported: str | None`,**不设默认值**(设计 §5:库外无第三方实现者)。同步该 Protocol 的「18 字段冻结」docstring。 -2. 两个后端的 `_DDL` 加列(SQLite `INTEGER`/`TEXT`;PG `INTEGER`/`TEXT`,均可空、无默认值),`_COLUMNS` 在 `"cost"` 之后追加同名两项(顺序必须与 DDL 一致——`_INSERT` 由 `_COLUMNS` 拼装)。 +2. 两个后端的 `_DDL` 加列(SQLite `INTEGER`/`TEXT`;PG `INTEGER`/`TEXT`,均可空、无默认值)。**新列在 DDL 里必须放在 `created_at` 之后(即表的最末尾),不得插在 `cost` 之后**——旧表走 `ALTER TABLE ADD COLUMN` 只能追加到末尾,若新建库把新列插在 `created_at` 前面,两条路径的物理列序就会分叉,而 `tests/integration/test_postgres_telemetry.py:117-128` 的 `test_schema_has_frozen_columns_in_order` 按 `ordinal_position` 逐位断言,且该表是与真实批跑共享的表、**严禁 DROP/TRUNCATE**(文件头隔离纪律),分叉后没有合规修法。 + + `_COLUMNS` 在 `"cost"` 之后追加同名两项即可——`_INSERT` 是显式列名拼装(`sqlite.py:62-65`/`postgres.py:67-71`),`_COLUMNS` 只需与自身的 `row = tuple(...)` 自洽,**与 DDL 物理列序无关**。 3. **幂等补列**,按设计 D1 纪律执行: -- **SQLite**(`sqlite.py:74-84`):补列代码必须放在 `self._conn = conn` **之后**、用**独立 try**。先 `PRAGMA table_info(llm_calls)` 取现有列名集合,缺哪列补哪列;捕获 `sqlite3.Error` 时,消息含 `duplicate column` 视为成功(多进程共库的 TOCTOU),其余记 warning。**绝不允许**因补列失败把 `self._conn` 置回 `None`——那会让整个 recorder 永久 no-op。 +- **SQLite**(`sqlite.py:74-84`):补列代码必须放在 `self._conn = conn` **之后**、用**独立 try**,且**首行必须守卫 `if self._conn is None: return`**——初始化 try 吞掉失败时 `self._conn` 仍是 `None`(局部 `conn` 甚至未绑定),无守卫的补列块会抛 `AttributeError`/`NameError`,这两者不被 `sqlite3.Error` 捕获,会直接逃出 `__init__`,打破「初始化失败静默降级」的对外契约(既有测试 `tests/unit/test_telemetry.py:132-135` `test_unwritable_path_degrades_silently` 会红)。守卫之后:`PRAGMA table_info(llm_calls)` 取现有列名集合,缺哪列补哪列;捕获 `sqlite3.Error` 时消息含 `duplicate column` 视为成功(多进程共库的 TOCTOU),其余记 warning。**绝不允许**因补列失败把 `self._conn` 置回 `None`——那会让整个 recorder 永久 no-op。 - **Postgres**(`postgres.py:_ensure_ready` 内、`_DDL` 执行之后):两条 `ALTER TABLE llm_calls ADD COLUMN IF NOT EXISTS ...`,共享既有 `_init_lock` 与 `except asyncio.CancelledError: raise` 结构。 **验收(降级语义不得改变)**: SQLite 侧 `except` 不得加宽(取消天然穿透);PG 侧 `CancelledError` 分支保持在最前;写入失败仍是逐行 warning 丢弃,不冒泡。 -**同步改测试 fake**: `tests/unit/test_ports.py:96`(唯一写全签名的 fake)、`tests/unit/test_telemetry.py:78-98` 与 `tests/integration/test_postgres_telemetry.py:105` 两处手写 18 键 dict 的 `_record_minimal` helper——这三处漏改即红,是本次唯一的天然拦截点。 +**必须同步改的测试(共 5 处)**: + +| 位置 | 内容 | 漏改会怎样 | +|---|---|---| +| `tests/unit/test_telemetry.py:76` 起 `_record_minimal` | 手写 18 键 dict | **红**(`KeyError`) | +| `tests/integration/test_postgres_telemetry.py:81-105` `_record_minimal` | 同上 | **红** | +| `tests/unit/test_telemetry.py:18-40` `_EXPECTED_COLUMNS` | 19 项列序断言(含 `created_at`) | **红**;新列追加到 `created_at` **之后** | +| `tests/integration/test_postgres_telemetry.py:22-41` `_EXPECTED_COLUMNS` | 同上 | **红**;同上 | +| `tests/unit/test_ports.py:96` `_DummyRecorder` | 唯一写全签名的 fake | **不会红**(它只被 `:131` 的 `isinstance` 使用,`runtime_checkable` Protocol 只校验方法名不校验签名),但仍应同步以免误导后来者 | + +前四处是本次仅有的天然拦截点;端口加参数**不会**带来编译期保护(本仓无 mypy,其余 8 个 fake 全是 `**fields`)。 **测试**: -- `tests/unit/test_telemetry.py`(SQLite):① 20 字段写入后可读回两个新列的值(含 `None`);② **旧表升级**——先用 18 列 DDL 手工建表,再实例化 `SQLiteRecorder`,写入成功且新列有值;③ 旧表升级路径下 `_conn` 不为 `None`(补列异常不得使 recorder 失能)。 +- `tests/unit/test_telemetry.py`(SQLite):① 20 字段写入后可读回两个新列的值(含 `None`);② **旧表升级**——先用 18 列 DDL 手工建表,再实例化 `SQLiteRecorder`,写入成功且新列有值;③ **补列失败路径**(设计 §8 第 ③ 条,最危险的分支,不可用成功路径顶替)——构造一个 ALTER 必然失败的场景(把 `llm_calls` 建成同名 view,或注入在 ALTER 上抛 `sqlite3.OperationalError` 的连接),断言构造**不抛异常**、`recorder._conn` 仍非 `None`、后续 `record_llm_call` 不抛(降级为逐行 warning);④ 初始化路径不可写时仍静默降级(`test_unwritable_path_degrades_silently` 保持绿)。 - `tests/integration/test_postgres_telemetry.py`:① 20 字段写入 PG 并 `SELECT` 回读;② 18 列旧表经初始化后自动补列并写入成功。 **验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_telemetry.py tests/unit/test_ports.py -v` → PASS;PG 部分 `conda run -n PolyGateway --no-capture-output pytest tests/integration/test_postgres_telemetry.py -v` → PASS。**PG/Redis 属共享后端,严禁与其他会话或钩子测试并跑**,起跑前确认无并发占用。 -**提交**: `feat: widen the telemetry contract to twenty fields with column backfill` +**提交**: **与 T6 合并为一次提交**,不得单独落地。理由:T5 落地后 emitter 仍只传 18 键,后端的 `row = tuple(fields[col] for col in _COLUMNS)` 会抛 `KeyError`,被 `_record` 的 `except Exception`(`middleware/telemetry.py:164-165`)吞成 warning → **该 commit 处于全量遥测静默丢失的状态**,且现有测试无一能捕获。提交信息见 T6。 -## T6. Emitter 搬运与契约测试 +## T6. Emitter 搬运与契约测试(与 T5 同一次提交) - [ ] **改**: `src/polygateway/middleware/telemetry.py` @@ -176,7 +190,7 @@ cost 换算(`:137`)改为把 `cached_prompt_tokens` 传进 `self._pricing.cost(. **验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_telemetry.py -v` → PASS;随后 `make ci` 全绿(含 ruff 与 import-linter)。 -**提交**: `feat: record the observability fields through the telemetry emitter` +**提交**(含 T5 全部改动): `feat: record the observability fields end to end through telemetry` ## T7. 文档同步与发版 @@ -184,8 +198,8 @@ cost 换算(`:137`)改为把 `cached_prompt_tokens` 传进 `self._pricing.cost(. **行为**: -1. `ARCHITECTURE.md` §5.1 新增字段表补两行;遥测表定义补两列;「18 字段冻结」表述改 20。补一条度量口径警示(与 cost 缺口同款):**统计供应商缓存命中率必须带 `WHERE cache_hit = false`**,否则缓存回放行会被重复计入。 -2. 其余「18 字段冻结」措辞点一并订正:`ports.py:248`、`pricing.py:6`(币种说明里引用了该数字)、`telemetry/sqlite.py:87`、`tests/unit/test_telemetry.py:1`。 +1. `ARCHITECTURE.md`:§5.1 新增字段表补两行;**§7.8「必录字段」的行内清单**(`:452`)补两项——该文件不含字面「18 字段冻结」,§7.8 与 D8(`:202`)才是遥测字段的落点;§7.8 末条「`pricing.py` 维护 model →(input 单价, output 单价)表」同步第三档。补一条度量口径警示(与 cost 缺口同款):**统计供应商缓存命中率必须带 `WHERE cache_hit = false`**,否则缓存回放行会被重复计入。 +2. 代码里的「18 字段/18 列」措辞共 **6 处**,全部订正(`grep -rn "18 字段\|18 列" src/ tests/` 可复核):`ports.py:248`、`middleware/telemetry.py:31`、`pricing.py:6`、`telemetry/sqlite.py:87`、`telemetry/postgres.py:9`(「18 列 schema 与 SQLite 版同名同序」)、`tests/unit/test_telemetry.py:1`。 3. `.env.example:56` 是仓内**唯一**的价格表格式说明(无独立模板文件),补 `cached_input_per_1m` 可选档与「不填即全额计价、库不猜折扣率」的说明。 4. 版本 bump `1.0.3` → `1.1.0`,**两处必须同步**(`pyproject.toml:7` 与 `src/polygateway/__init__.py:34`;`tests/unit/test_package.py:11` 会断言二者相等)。 5. `CHANGELOG.md` 顶部新增 `## 1.1.0` 段,沿用既有写法(先讲问题、再讲变更、点明下游要读什么):两个新字段的语义与 `None`/`0` 之别、`cache_hit` 与供应商 prompt cache 的区分、遥测表新增两列与自动补列、价格表可选缓存档、度量口径的 `cache_hit = false` 约束。