diff --git a/research-wiki/plans/2026-08-25-thinking-observability-plan.md b/research-wiki/plans/2026-08-25-thinking-observability-plan.md new file mode 100644 index 0000000..7fd1d7d --- /dev/null +++ b/research-wiki/plans/2026-08-25-thinking-observability-plan.md @@ -0,0 +1,507 @@ +--- +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__`(`__all__` 保持既有的字母序):`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;契约 KEPT;import 成功。 + +- [ ] 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` 约 460-475 行一带、非流式 `_complete_once` 约 543-560 行一带)在构造 `TransportResult` 时调 `observe_thinking(thinking=thinking, reasoning_tokens=...)` 填入。两条路径都要填——**只填一条正是 L5 要抓的那类分叉**。 + +`middleware/retry.py` 组装 `LLMResponse` 处(约 377-392 行一带)透传 `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`:用 `caplog` 断言同一 `(model, direction)` 连调两次只出现一条 warning;换 direction 后再出一条。 + +### 验证 + +```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**:`sqlite.py` / `postgres.py` 的 `record_llm_call` 各加一参并接进取值元组,位置与 `COLUMNS` 严格同序。`sqlite.py:146` 的"24 字段冻结签名"改 25。 + +**emitter**:`_AttemptUsage` 增 `thinking_observation: str = ThinkingObservation.UNKNOWN`,`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 个物理列)` | + +> **不要改 `tests/unit/test_telemetry.py:1787`**:那里的"共 24 字"是 OCR 占位串 `` 的**字符数**,与遥测列数无关。全局替换"24"会误伤它。 + +### 测试要求(先失败后通过) + +`tests/unit/test_ports.py`:现有 `TestTelemetryRecorderSignature` 的 parametrize 列表加入 `thinking_observation`,断言它存在、无默认值、是 KEYWORD_ONLY——改端口前必然红。 + +`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 7:e2e 判据重建 + +**文件**:修改 `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 上方的注入形态注释同步补记复测日期。 + +### 测试要求 + +`tests/unit/test_thinking.py` 既有的能力表用例覆盖(`evidence` 非空、`can_disable` 取值),无新增行为。本任务是事实更新,测试证据由 Task 7 的 e2e 真跑承担。 + +### 验证 + +```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 步点名的失败模式)。同时核对安装命令的版本约束是否需要跟进,以及能力表是否要提及推理裁定这一新行为。 + +**`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。 + +**`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)`。 + +按 CLAUDE.md §4.4.1 发布清单**逐步执行,不得跳步**:README(Task 9 已完成)→ CHANGELOG 定版 → 版本号两处 → 合并 main(`--no-ff`)+ push → 打 tag 并 push → 构建 → 上传 registry → `pip download` 验证并解包确认新代码在内 → 建 Release + 挂仓库 + 核对包页面。 + +合并前另需两道门:`verification-before-completion` 派全新上下文 verifier subagent 独立验证(跨 20+ 文件,属强制档);`requesting-code-review` 整分支审查。 + +合并到 main 后在 main 上重跑 `make lint` 与全套件,**外加 `pytest -m slow`**(约 20-40 分钟,四个 e2e 文件与 Redis 时间语义变体默认被 `-m 'not slow'` 排除,不显式跑等于没跑)。 + +关闭 issue #16 与 #17,附修复说明与本次实测结论(诊断纠正 + 三层根因 + 落地形态)。 + +### 验证 + +```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)。 + +每个任务独立提交,提交前跑该任务的验证命令。任何一步的完成声明必须对应本会话内的工具输出。