Files
PolyGateway/research-wiki/plans/2026-08-25-thinking-observability-plan.md
T
iomgaa bd95a05c30 docs: retract a plan item that was wrong and would have broken deploys
The README's production template does not hand-write its columns; it
derives them with LIKE from the seed table, and the prose right above it
says so. Telling an executor to add a column there would have made
Postgres reject a duplicate, turned TestProductionTemplate red, and
broken deployment for anyone following it.

The claim came from another task's report and went into the plan without
opening the README. A finding relayed across tasks is a lead to verify,
not a fact. What replaces it is a shape assertion — the template must
derive via LIKE and must not inline any column name — which pins the
real risk of someone copying columns in later.

Also adds the Gitea wiki sync the plan had missed: docs-convention makes
a version bump commit illegal on its own.
2026-08-26 00:57:22 -04:00

530 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: plan
node_id: plan:2026-08-25-thinking-observability-plan
title: "推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)"
date: 2026-08-25
---
# 推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1
> 类型:plan|日期:2026-08-25|实现设计:`designs/2026-08-25-thinking-observability-design.md`(已经人类批准)
> 事实基础:`findings/2026-08-25-thinking-observability-regression.md`
> **保真校验不适用**:本计划不涉及 `reference/` 三项目的迁移,推理开关是库自有子系统,不在 ARCHITECTURE.md §1.4 关键资产索引的移植蓝本内。
## 目标
让"这次推理到底发生没发生"成为库的一等返回值,由多信号裁定,判不出来时如实说 UNKNOWN,并与能力表持续对账。
## 方案概述
新增 `ThinkingObservation` 三态枚举(定义在最内层 `types.py`)与裁定纯函数 `observe_thinking`(决策层 `thinking.py`),由 transport 在组装结果时裁定并与请求方向对账,结果随 `LLMResponse` 返回、随遥测落库。同时把推理决策从 `providers.py` 拆进新模块 `thinking.py`,并把公共符号提升到包根导出。
涉及技术:Python 3.12 `StrEnum`、frozen dataclass、`inspect.signature` 冻结测试、import-linter 分层契约、SQLite/PG schema backfill。
## 文件结构
**新建**
| 文件 | 职责 |
|---|---|
| `src/polygateway/thinking.py` | 推理这件事的全部**决策**:能力表、`resolve_thinking`(请求侧注入)、`observe_thinking`(响应侧裁定)、对账告警。**不含 `ThinkingObservation` 定义** |
| `tests/unit/test_thinking.py` | 裁定与对账的单元测试 |
**修改**
| 文件 | 变更 |
|---|---|
| `src/polygateway/types.py` | 新增 `ThinkingObservation``LLMResponse` / `TransportResult` 各增一字段 |
| `src/polygateway/providers.py` | 收缩为纯注册表 |
| `src/polygateway/ports.py` | `record_llm_call` 24 参 → 25 参 |
| `src/polygateway/transports/openai_compat.py` | 裁定 + 对账 |
| `src/polygateway/middleware/retry.py` | 透传 |
| `src/polygateway/middleware/telemetry.py` | `_AttemptUsage` + 三个 `emit_*` + `_record` |
| `src/polygateway/middleware/cache.py` | `_rehydrate` 枚举复活 |
| `src/polygateway/telemetry/schema.py` | 新列 + 两端 DDL + 两份 backfill |
| `src/polygateway/telemetry/sqlite.py``postgres.py` | 实现新参 |
| `src/polygateway/client.py` | import 路径 |
| `src/polygateway/__init__.py` | 包根导出 + 版本号 |
| `pyproject.toml` | import-linter 契约加层 + 版本号 |
| 测试 9 个、文档 5 个 | 见各任务 |
---
## Task 1`ThinkingObservation` 与裁定纯函数
**文件**:创建 `src/polygateway/thinking.py``tests/unit/test_thinking.py`;修改 `src/polygateway/types.py``pyproject.toml`
### 行为
`types.py` 新增(放在 `LLMResponse` 定义**之前**,因为它是其字段类型):
```python
class ThinkingObservation(StrEnum):
"""一次调用中"推理是否真的发生"的裁定结果(issue #16/#17)。
三态不可折叠为布尔: `UNKNOWN` 是"本次无任何信号,判不出来",与
`ABSENT`("上游明确上报未推理")语义不同。把前者折叠进后者,正是
`reasoning_tokens=None` 制造的那个歧义——库据此静默宣称"没推理",
而实际可能推理了且已计费(MiniMax-M3 非流式实测)。
"""
OBSERVED = "observed"
ABSENT = "absent"
UNKNOWN = "unknown"
```
在新建的 `thinking.py` 实现(本任务只放这一个函数,搬迁留给 Task 2):
```python
def observe_thinking(
*, thinking: str, reasoning_tokens: int | None
) -> ThinkingObservation:
"""由多信号裁定推理是否发生;判据按证据硬度排序。
推理正文是事实本身,token 计数是对事实的转述——转述缺失时事实仍然作数。
"""
if thinking.strip():
return ThinkingObservation.OBSERVED
if reasoning_tokens is None:
return ThinkingObservation.UNKNOWN
return (
ThinkingObservation.OBSERVED if reasoning_tokens > 0 else ThinkingObservation.ABSENT
)
```
`pyproject.toml` 的 import-linter 契约 `layers` 插入一层,位置在实现层与 `providers` 之间:
```toml
layers = [
"polygateway.client",
"polygateway.config",
"polygateway.middleware",
"polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured",
"polygateway.thinking",
"polygateway.providers : polygateway.sources",
"polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming",
]
```
层序理由:`thinking.py` 要 import `providers.py``ProviderProfile`(故在其上),被 `transports/``client.py` import(故在其下)。**枚举放 `types.py` 而非 `thinking.py`,正是为了让最内层不反向依赖决策层**——这是本任务最容易做错的一步,写反了 import-linter 会判红。
### 测试要求(先失败后通过)
`tests/unit/test_thinking.py` 覆盖裁定五种输入:正文非空 → OBSERVED;**纯空白正文 + `reasoning_tokens=None` → UNKNOWN**(不得因 truthy 判成 OBSERVED);`reasoning_tokens=5` → OBSERVED`reasoning_tokens=0` → ABSENT`reasoning_tokens=None` 且正文空 → UNKNOWN。再加一条优先级用例:正文非空且 `reasoning_tokens=0` → OBSERVED(正文压倒转述)。
`tests/unit/test_types.py` 加一条:`ThinkingObservation` 定义在 `polygateway.types` 模块内(`ThinkingObservation.__module__ == "polygateway.types"`),防止后续任务把它挪回决策层。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_types.py -v
conda run -n PolyGateway lint-imports
```
预期:新测试全 PASS`lint-imports` 全部契约 KEPT。
- [ ] Task 1 提交:`feat: judge whether reasoning actually happened from multiple signals`
---
## Task 2:把推理决策从 `providers.py` 搬进 `thinking.py`
**文件**:修改 `src/polygateway/thinking.py``src/polygateway/providers.py``src/polygateway/client.py``src/polygateway/transports/openai_compat.py``src/polygateway/__init__.py``tests/unit/test_providers.py``tests/unit/test_package.py`
### 行为
`providers.py` **原样移入** `thinking.py`(纯移动,不改逻辑):`ThinkingUnsupportedError``ThinkingCapability``DEFAULT_CAPABILITIES``get_capability``register_capability``resolve_thinking``_warn_unregistered`
`providers.py` 保留:`ProviderProfile``DEFAULT_PROFILES``get_provider``register_provider`。其模块 docstring 改为只讲注册表职责;`thinking.py` 的模块 docstring 说明它承载推理的全部决策而枚举归 `types.py`
更新 import`client.py``from polygateway.providers import get_capability, get_provider, resolve_thinking` 拆成两行)、`transports/openai_compat.py``client.py``TYPE_CHECKING` 块里 `ThinkingCapability` 的来源。
`__init__.py` 新增包根导出并加进 `__all__`(该列表**不是严格字母序**——`DEFAULT_PROFILES` 现在就排在 `AllSourcesExhausted` 前面;沿用文件既有排列,把新符号插到同类符号附近即可):`ThinkingCapability``ThinkingObservation``ThinkingUnsupportedError``get_capability``register_capability``resolve_thinking`
`tests/unit/test_providers.py` 里针对被搬走符号的测试,整体移入 `tests/unit/test_thinking.py`
### 测试要求(先失败后通过)
`tests/unit/test_package.py` 比照既有 `TelemetryStatus` 用例,加一条断言六个新符号可从包根 import 且在 `__all__` 内——该测试在导出落地前必然红。
搬迁本身的回归证据:搬迁前后 `pytest tests/unit -q` 通过数不减(搬迁是纯移动,任何行为差异都是 bug)。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit -q
conda run -n PolyGateway lint-imports
conda run -n PolyGateway python -c "from polygateway import ThinkingObservation, ThinkingCapability, resolve_thinking; print('ok')"
```
预期:全 PASS;契约 KEPTimport 成功。
- [ ] Task 2 提交:`refactor: give reasoning decisions their own module`
---
## Task 3:字段落到响应类型并贯通调用链
**文件**:修改 `src/polygateway/types.py``src/polygateway/transports/openai_compat.py``src/polygateway/middleware/retry.py`;测试 `tests/unit/test_types.py``tests/unit/test_openai_compat.py``tests/unit/test_retry.py`
### 行为
`TransportResult``LLMResponse` 各新增字段,**必须加在各自字段列表末尾且带默认值**(`LLMResponse` 是被三项目消费的公共类型,只增不删且不得改变既有位置参数顺序):
```python
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
```
`LLMResponse` 侧补 docstring`UNKNOWN` = 本次无信号判不出,**不是**"没推理";非流式路径下部分模型推理已计费却不回传正文(M3 实测 completion 53 vs 关闭档 3),该档即为 `UNKNOWN`
`transports/openai_compat.py` 的两条组装路径(流式 `_complete_stream` 的 463-475 行、非流式 `_complete_once` 的 548-560 行)在构造 `TransportResult` 时调 `observe_thinking(thinking=thinking, reasoning_tokens=...)` 填入。两条路径都要填——**只填一条正是 L5 要抓的那类分叉**。
`middleware/retry.py``_build_response`372-393 行)透传 `thinking_observation=result.thinking_observation`
### 测试要求(先失败后通过)
`tests/unit/test_types.py`:两个类型的默认值均为 `ThinkingObservation.UNKNOWN``LLMResponse` 既有位置构造方式不破(沿用文件内既有的构造用例形态)。
`tests/unit/test_openai_compat.py`:用既有的 SSE / JSON 响应装置,构造三种响应各断言一次——含 `reasoning_content` 增量 → `OBSERVED`;无推理信号 → `UNKNOWN``usage.completion_tokens_details.reasoning_tokens=0``ABSENT`。流式与非流式各一组。
`tests/unit/test_retry.py`:比照既有透传测试,断言 transport 返回的 `thinking_observation` 原样出现在 `LLMResponse` 上。
以上在字段落地前全部红(属性不存在)。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_openai_compat.py tests/unit/test_retry.py -v
```
预期:全 PASS。
- [ ] Task 3 提交:`feat: carry the reasoning verdict through to LLMResponse`
---
## Task 4:对账告警(声明 × 观测)
**文件**:修改 `src/polygateway/thinking.py``src/polygateway/transports/openai_compat.py`;测试 `tests/unit/test_thinking.py``tests/unit/test_openai_compat.py`
### 行为
`thinking.py` 新增对账纯函数,返回告警文案或 `None`(**判定与日志分离**,这样告警内容可被单测直接断言,不必去解析日志):
```python
def reconcile_thinking(
*,
enable_thinking: bool | None,
observation: ThinkingObservation,
capability: ThinkingCapability | None,
model: str,
) -> str | None:
"""把静态声明与运行时观测对账;矛盾返回告警文案,无矛盾返回 None。
能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),而过期的
表现是静默错觉。本函数把它变成可报警事件,代价是一次枚举比较。
"""
```
判定矩阵(设计 §5):
| `enable_thinking` | observation | capability | 返回 |
|---|---|---|---|
| `False` | OBSERVED | 已登记 | 能力表漂移:声明可关闭,实测推理了。附 `capability.evidence``register_capability` 指路 |
| `False` | OBSERVED | `None` | 关闭请求未被满足,且该模型能力未登记。指路实测后 `register_capability` |
| `True` | ABSENT | 任意 | 注入了开启参数,上游明确上报未推理 |
| `True` | UNKNOWN | 任意 | 推理参数已注入但本路径观测不到,无法确认是否生效;若为非流式路径,推理内容可能已计费却不回传 |
| 其余组合(含 `False`×UNKNOWN、`None`×任意) | | | `None` |
`False`×UNKNOWN 返回 `None` 是刻意的:`UNKNOWN` 没有证伪力,拿它报警等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警。
`transports/openai_compat.py` 在组装完 `TransportResult` 后调用它,非 `None``logger.warning`,并按 `(model, enable_thinking)` 节流——新增实例级 `set`,与既有 `_warned_models` 同款形态,**不可复用同一个 set**(那个 set 语义是"未登记能力已告警过",混用会互相压制)。
### 测试要求(先失败后通过)
`tests/unit/test_thinking.py`:矩阵四行各断言返回非 `None` 且文案含模型名;三种不表态组合(`False`×UNKNOWN、`None`×OBSERVED、`True`×OBSERVED)断言返回 `None`;已登记 vs 未登记两行的文案**必须不同**(不得对未登记模型说"能力表声称可关闭")。
`tests/unit/test_openai_compat.py`:断言同一 `(model, direction)` 连调两次只出现一条 warning;换 direction 后再出一条。**不能用 `caplog`**——本项目日志走 loguru,不经标准 `logging`,`caplog` 抓不到;复用 `tests/unit/test_thinking.py``_warnings()`(`logger.add` 收集)。
> `reconcile_thinking` 必须定义在 `ThinkingCapability` **之后**:本模块没有 `from __future__ import annotations`,注解在 `def` 时求值,放在文件上部会 `NameError`。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_openai_compat.py -v
```
预期:全 PASS。
- [ ] Task 4 提交:`feat: warn when the capability table and reality disagree`
---
## Task 5:缓存回放复活枚举
**文件**:修改 `src/polygateway/middleware/cache.py`;测试 `tests/unit/test_cache.py`
### 行为
`_rehydrate``LLMResponse(**fields)`JSON 里的 `"observed"` 会复活成**裸 `str`** 而非枚举实例,类型与注解分叉。在 `fields.update(...)` 之前显式转换:
```python
if "thinking_observation" in fields:
fields["thinking_observation"] = ThinkingObservation(
fields["thinking_observation"]
)
```
非法值(旧版本缓存、人为污染)会抛 `ValueError`,由既有的 `except Exception` 吞成"按未命中回源"并 warning——降级方向正确,不需额外处理。
`_serialize` 无需改动:`StrEnum``str` 子类,`dataclasses.asdict` + `json.dumps` 直接可序列化。
### 测试要求(先失败后通过)
`tests/unit/test_cache.py`:写入一条 `thinking_observation=OBSERVED` 的响应后命中回放,断言 `isinstance(resp.thinking_observation, ThinkingObservation)`(改动前必然红——回放出来的是 `str`);再造一条 `thinking_observation``"bogus"` 的缓存值,断言按未命中回源。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit/test_cache.py -v
```
预期:全 PASS。
- [ ] Task 5 提交:`fix: revive the reasoning verdict as an enum, not a bare string`
---
## Task 6:遥测新增一列(端口 → schema → recorder → emitter
**文件**:修改 `src/polygateway/ports.py``src/polygateway/telemetry/schema.py``src/polygateway/telemetry/sqlite.py``src/polygateway/telemetry/postgres.py``src/polygateway/middleware/telemetry.py`;测试 `tests/unit/test_ports.py``tests/unit/test_telemetry.py``tests/integration/test_postgres_telemetry.py`
### 行为
**端口**`TelemetryRecorder.record_llm_call``thinking_observation: str`**不设默认值**(该 Protocol 的既有纪律,docstring 已写明理由:库外无第三方实现者,带默认值会让 emitter 漏传时静默落默认)。参数加在 `meta` 之后。docstring 的"24 字段冻结"改为 25。
**schema**`SQLITE_DDL` / `PG_DDL` 末尾加 `thinking_observation TEXT``SQLITE_BACKFILL` / `_PG_BACKFILL_DECLS` 各加 `("thinking_observation", "TEXT")``COLUMNS` 末尾加同名项。**新列必须排在最末**——旧表只能 ALTER 追加到末尾,插在中间会让新建库与补列库的物理列序分叉(该纪律的注释就在这两个常量上方)。
**recorder**:两个 recorder 的 `record_llm_call` 都是 `(self, **fields: object)` 形态(**不是**显式参数列表),按 `COLUMNS` / `self._columns``fields` 取值——新列因此**不需要改签名**,只要 `COLUMNS` 里有、emitter 传了,取值就自动到位。要做的是核对两处:取值是否严格按列序、manual 档列裁剪路径是否覆盖新列。`sqlite.py:146` docstring 的"24 字段冻结签名"改 25。
> 端口 `ports.py` 的 Protocol 是**显式 25 参**,而实现是 `**fields`——这不矛盾:Protocol 声明的是调用契约(emitter 必须按名传全),实现选择用 kwargs 收。改端口签名仍然必要,它是 emitter 侧的编译期约束与冻结测试的锚点。
**emitter**`_AttemptUsage``thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN`**内部字段用枚举类型**,裸 `str` 归一化只发生在下沉 recorder 那一步),`of()` 从 response 取;三个 `emit_*` 各传一行(`emit_terminal_failure``ThinkingObservation.UNKNOWN`——无响应可言,默认值本身不撒谎);`_record` 签名增一参并下沉给 recorder。**所有新增字段只经 `_record` 这一个出口抵达 recorder,不新开调用点**(铁律:遥测调用点收敛为单一 helper,该出口已存在)。`middleware/telemetry.py:135` 的"组装 24 字段"改 25。
**recorder 收到的必须是裸 `str`,不是枚举实例**`_AttemptUsage.thinking_observation` 内部用 `ThinkingObservation` 类型,但 `_record` 下沉给 recorder 时取 `.value``StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只会被降级成一条 warning——这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化放在 emitter 侧,与 `tenant_id`/`meta`/`sampling` 由 emitter 定型后再交 recorder 是同一先例(`ports.py` docstring 明载该分工:recorder 只落库,不做语义判断)。
### 数字断言逐处更新(漏一处即红)
| 位置 | 现值 → 新值 |
|---|---|
| `tests/unit/test_telemetry.py:37` `_EXPECTED_COLUMNS` | 末尾加 `thinking_observation` |
| `tests/unit/test_telemetry.py:184` INSERT 占位符串 | 补到 `$25` |
| `tests/unit/test_telemetry.py:210` | `len(COLUMNS) == 24``25` |
| `tests/unit/test_telemetry.py:633` docstring | 物理列 `23 → 25` 改为 `24 → 26` |
| `tests/unit/test_telemetry.py:642` | `== 25``== 26` |
| `tests/unit/test_telemetry.py:645` docstring | `25 个物理列``26 个` |
| `tests/integration/test_postgres_telemetry.py:764` 注释 | `22 → 24 个 recorder 字段(加 created_at 共 25 个物理列)` 改为 `24 → 25 个(共 26 个物理列)` |
> 上表**不完整**——实施时实测另有 6 处漏改会当场把测试跑红:`_FROZEN_SQLITE_INSERT`(计划只点了 PG 那条)、`:586` 的 `_EXPECTED_COLUMNS[:-2]` → `[:-3]`、`TestBackendColumnParity` 的 `COLUMNS[-2:]` 断言、两处 `_CURRENT` 假列表(稳态不发 ALTER 的断言)、`PG_BACKFILL[-1]` 末位断言,以及 integration 侧 `:608` 的 `_PRE_TENANT_COLUMNS` 派生式。另有四处注释/docstring 的字段数会过期。**结论: 不要照表逐条打勾就收工,以"全套件绿"为准**。
> **不要改 `tests/unit/test_telemetry.py:1787`**:那里的"共 24 字"是 OCR 占位串 `<ocr:text image_bytes=3>` 的**字符数**,与遥测列数无关。全局替换"24"会误伤它。
### 测试要求(先失败后通过)
`tests/unit/test_ports.py`:现有 `TestTelemetryRecorderSignature` **并不冻结完整参数列表**——它只 parametrize 了 `["tenant_id", "meta"]` 两项,断言其无默认值且为 KEYWORD_ONLY。把 `thinking_observation` 加进该 parametrize 列表,断言同样三条——改端口前必然红。
`tests/unit/test_telemetry.py`:列数与列序断言(上表);新增一条 round-trip——记录一条 `thinking_observation=OBSERVED` 的调用后从 SQLite 读回该列等于 `"observed"`
`tests/integration/test_postgres_telemetry.py`:既有 backfill 用例覆盖旧表补列后新列存在且可写读。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v
conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v
conda run -n PolyGateway python -c "
import inspect
from polygateway.ports import TelemetryRecorder
p = inspect.signature(TelemetryRecorder.record_llm_call).parameters
print('recorder 参数数(不含 self):', len(p) - 1)"
```
预期:全 PASS;最后一条打印 `25`(README 的字段数断言按此实测值填,见 Task 9)。
- [ ] Task 6 提交:`feat: record the reasoning verdict in telemetry`
---
## Task 7e2e 判据重建
**文件**:修改 `tests/e2e/test_thinking_live.py`
### 行为
`_run_rounds` 的逐轮观测字典增加两个键:`"thinking_observation": resp.thinking_observation``"thinking_chars": len(resp.thinking)`(报告里要能看见证据本身,而不只是结论)。
判据函数改写:
```python
def _reasoning_on(obs: dict) -> bool:
"""开启方向: 观测到推理即为真。
判据从 `reasoning_tokens` 换成三态裁定,因为 MiniMax 这一路已不再上报
`completion_tokens_details`(2026-08-25 findings),而库在同一次调用里
拿得到 185 字符推理正文——旧判据看不见它,四条用例因此假红。
"""
return obs["thinking_observation"] == ThinkingObservation.OBSERVED
def _reasoning_off(obs: dict) -> bool:
"""关闭方向: 只要没观测到推理即算满足。
`UNKNOWN` 计入满足是有意的: 它没有证伪力(设计 §4.1),不能拿它判红。
本判据真正的证伪力在于——模型若偷偷推理了,可观测路径会翻成 OBSERVED。
"""
return obs["thinking_observation"] != ThinkingObservation.OBSERVED
```
**删除 `_ON_MIN_COMPLETION` 常量及其全部引用**:两档 completion 分布实测重叠(关闭档最高 46、开启档最低 13),这个魔数退路从一开始就不成立。
**L5 重新定义**(当前实现断言"非流式开启档多数轮观测到推理",而 M3 非流式推理正文与 ctd 双缺,该断言永远不可能成立):改为断言两件真实成立的事——其一非流式下关闭档与开启档的 `prompt_tokens` 锚点仍然分开(证明参数确实到达模型,判据形态照抄 L2b);其二开启档观测为 `UNKNOWN` 而非 `ABSENT`(证明库如实标记"观测不到"而没有伪装成"没推理")。用例 docstring 写明:M3 非流式推理已计费却不回传正文,这是上游行为,库修不了但必须让它可见。
L3b 的 docstring 补一句不可移植性:minimax 对非法 `reasoning_effort` 返回 200 且照常推理,qwen 对同样的值返回 **HTTP 400**——该反证手法只对不校验值的 provider 成立。
模块顶部的判据纪律段与 `_write_report` 的报告表头同步改写为三态口径。
### 测试要求(先失败后通过)
本任务的证据是真跑:改前 `TestMiniMaxM3` 4 failed / 3 passed,改后全类 PASS。L5 的新断言在 Task 3 之前无法表达(字段不存在),是纯新增覆盖。
### 验证
```bash
conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -m slow -v
```
预期:`TestMiniMaxM3` 7 passed;报告落 `tests/outputs/e2e/`。耗时约 7 分钟、约 137 次真实调用。
- [ ] Task 7 提交:`test: judge reasoning by what the library actually observed`
---
## Task 8:能力表 evidence 刷新
**文件**:修改 `src/polygateway/thinking.py`
### 行为
`DEFAULT_CAPABILITIES``MiniMax-M3``can_disable` **保持 `True`**2026-08-25 复测:`reasoning_effort=none` → prompt 194 = 基线、completion 3、无正文,声明依然成立)。`evidence` 追加复测日期与两条新限制:推理信号在非流式路径不可观测;`enable_thinking` / `thinking:{type:enabled}` 对该模型无效,仅 `reasoning_effort` 是真开关。
`minimax` profile 上方的注入形态注释同步补记复测日期。
### 测试要求
**先失败后通过不适用于本任务,理由须写进提交信息**:本任务只改 `evidence` 字符串与注释,`can_disable` 取值不变,**没有行为变更**,因而没有可先失败的行为断言(`test-driven-development` 的结果门约束的是行为变更)。声明依然成立这一事实,其证据是 2026-08-25 的复测与 Task 7 的 e2e 真跑,不是本任务能自造的单测。
`tests/unit/test_thinking.py` 既有的能力表用例(`evidence` 非空、`can_disable` 取值)须保持绿,作为回归证据。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit/test_thinking.py -q
```
- [ ] Task 8 提交:`docs: refresh the M3 capability evidence with the 08-25 retest`
---
## Task 9:文档同步(构建前必须改完)
**文件**:修改 `README.md``research-wiki/ARCHITECTURE.md``research-wiki/schemas/llm-calls.md``research-wiki/index.md``CHANGELOG.md`
### 行为
**`README.md:21`**`必录 24 字段``25 字段`。数字取 Task 6 验证步骤里 `inspect.signature` 的实测输出,**不凭记忆**(发布清单第 1 步点名的失败模式)。同时核对安装命令的版本约束是否需要跟进,以及能力表是否要提及推理裁定这一新行为。
**`README.md``<!-- pg-template:table -->` 生产部署 DDL 模板**——**本条计划原文是错的,已订正**。
原文断言该模板是"独立于 `schema.py` 手写的另一份 SQL",要求补上 `thinking_observation TEXT`。**事实相反**:该模板不含任何列定义,它是 `CREATE TABLE llm_calls (LIKE llm_calls_seed INCLUDING DEFAULTS, PRIMARY KEY (call_id, created_at)) PARTITION BY RANGE (created_at)`,列全部从上一步 `telemetry_schema_sql('postgres')` 建出的 seed 表派生,README 正文原本就写着"列不在这里重抄一份——抄了就会漂移"。照原文补列会让 PG 报列重复、`TestProductionTemplate` 全红、下游部署直接失败。
(这条错误的来路值得记下来: 它出自另一个任务的实施报告,写进计划时**没有自己打开 README 核实**。跨任务转述的"发现"必须当作待验证的线索,不是事实。)
正确的做法是加一条**形态断言**: 模板必须靠 `LIKE` 派生,且不得内联任何 `COLUMNS` 里的列名。它钉住的是"日后有人把列抄进模板"这个真实风险——比原计划想堵的缺口更贴合实际。断言落在 `tests/integration/test_postgres_telemetry.py``TestProductionTemplate`**不在** `tests/unit/test_telemetry.py`,计划原文也指错了文件)。
**`research-wiki/ARCHITECTURE.md`**:§8 模块结构树补 `thinking.py` 一行并说明职责;§8 依赖纪律段补 `thinking.py` 的层位;D11 段说明推理决策已从 `providers.py` 拆出;§5.1 响应字段表补 `thinking_observation`;§7.8 遥测字段补新列。
**`research-wiki/schemas/llm-calls.md`**:标题与正文的"遥测 22 字段"已过期两轮,订正为 25;补 `thinking_observation` 的列定义与查询口径(示例:按模型统计各观测态占比,用于发现某模型何时开始观测不到推理)。
**`research-wiki/index.md`**:登记本 plan、design 与 finding。
**先失败后通过不适用于本任务**:纯文档同步,无行为变更。其验收是下方 grep 的可见输出——数字与模块名对不上就是没改完。
**`CHANGELOG.md`**:新增 1.3.1 条目。**断裂项置于条目最前**,沿用 1.3.0"请先读这一条"体例(设计 §13:版号既然不承担预警职责,预警由 CHANGELOG 独立扛)。三条必须显式列出——① `polygateway.providers` 的深路径 import 断裂(`ThinkingCapability` / `resolve_thinking` / `get_capability` / `register_capability` / `DEFAULT_CAPABILITIES` / `ThinkingUnsupportedError` 移入 `polygateway.thinking`,同时提升到包根,**推荐改用包根 import**);② `TelemetryRecorder.record_llm_call` 端口签名 24 参 → 25 参,自定义 recorder 实现须同步;③ M3 非流式开启推理时推理内容已计费却不回传,该档观测为 `UNKNOWN`,库现在会告警一次。
### Wiki 注册
```bash
.claude/tools/research_wiki.py add_entity research-wiki/ --type plan --id 2026-08-25-thinking-observability-plan --title "推理可观测性一等化实现计划"
.claude/tools/research_wiki.py add_edge research-wiki/ --from "plan:2026-08-25-thinking-observability-plan" --to "design:2026-08-25-thinking-observability-design" --type implements --evidence "本计划实现该设计的全部落点"
.claude/tools/research_wiki.py rebuild_index research-wiki/
```
### 验证
```bash
grep -n '25 字段' README.md
grep -n 'thinking.py' research-wiki/ARCHITECTURE.md
grep -rn '22 字段' research-wiki/schemas/llm-calls.md # 预期无输出
```
- [ ] Task 9 提交:`docs: sync the field counts and module map to 1.3.1`
---
## Task 10:合并前独立验证与发布 1.3.1
**文件**:修改 `pyproject.toml``src/polygateway/__init__.py`
### 行为
版本号两处改 `1.3.1``pyproject.toml``__init__.py.__version__` 必须一致);`CHANGELOG.md` 的"未发布"定版为 `## 1.3.1(2026-08-25)`
本任务分两段,**中间是一道人类确认门**。
**第一段:分支内可自主完成的验证**——CHANGELOG 定版为 `## 1.3.1(2026-08-25)`;版本号两处改 `1.3.1``verification-before-completion` 派**全新上下文** verifier subagent 独立验证(跨 20+ 文件,属强制档);`requesting-code-review` 整分支审查;在分支上跑 `make ci``pytest -m slow`(约 20-40 分钟——四个 e2e 文件与 Redis 时间语义变体默认被 `-m 'not slow'` 排除,不显式跑等于没跑)。
**Gitea Wiki 文档站同步**(计划原本漏了,Task 9 实施时发现):`research-wiki/docs-convention.md` §2 明写"新公共 API / 新能力 → 对应指南页 + `参考-公共API` + 侧边栏 + CHANGELOG"、"发版(任何版本号) → `Home.md` 版本号与安装命令",且该文件第 26 行是一道门——**版本 bump 的提交不允许单独存在**。本版有 6 个新包根导出、1 个新公共字段、1 个端口签名变更,wiki 必须同步。wiki 是**独立 git 仓库**(需 clone),故拆成两半:**内容在第一段写好待推**,`git push` 归第二段(外发动作)。
**人类确认门**:以上全绿后停下,把验证结果交给人类,**取得明确同意后**才执行第二段。
**第二段:外发且难以撤销的动作,一律等确认**——合并 main`--no-ff`+ push → 打 tag 并 push → 构建 → 上传 registry → `pip download` 验证并解包确认新代码在内 → 建 Release + 挂仓库 + 核对包页面 → 关闭 issue #16 / #17 并附修复说明(诊断纠正 + 三层根因 + 落地形态)。顺序按 CLAUDE.md §4.4.1**不得跳步**:包上传与 tag 一旦推出去就收不回,registry 里的版本号也不能复用。
合并到 main 后须在 main 上**重跑** `make lint` 与全套件外加 `pytest -m slow`——分支上跑过不算,合并本身可能引入差异。
### 验证
```bash
conda run -n PolyGateway make ci
conda run -n PolyGateway pytest -m slow
python -c "import tomllib,pathlib,re
v=tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version']
i=re.search(r'__version__ = \"(.+?)\"', pathlib.Path('src/polygateway/__init__.py').read_text()).group(1)
assert v == i == '1.3.1', (v, i); print('版本号一致:', v)"
```
预期:`make ci` 绿;slow 全绿;版本号一致性检查通过。
- [ ] Task 10 提交:`chore: cut 1.3.1`
---
## 任务依赖
Task 1 → 2 → 3 是硬序(枚举 → 模块就位 → 字段贯通)。Task 4、5、6 都依赖 3,彼此独立可并行。Task 7 依赖 3(需要字段)。Task 8 依赖 2(能力表已搬)。Task 9 依赖 6(字段数实测值)。Task 10 最后。
## 全局纪律
不做计划外的重构与抽象——尤其**不重构遥测组装路径**:`TelemetryEmitter._record` 已经是铁律要求的单一出口,三个 `emit_*` 是三个语义不同的入口,各自组装参数是职责所在(设计 §12)。
每个任务独立提交,提交前跑该任务的验证命令。任何一步的完成声明必须对应本会话内的工具输出。