|
|
|
@@ -0,0 +1,396 @@
|
|
|
|
|
# 实现计划: 采样参数透传(issue #4)
|
|
|
|
|
|
|
|
|
|
- **设计**: `research-wiki/designs/2026-07-31-sampling-params-design.md`(2026-07-31 人类批准)
|
|
|
|
|
- **分支**: `feat/issue-4-sampling-params`
|
|
|
|
|
- **目标**: 让下游能固定解码参数(`temperature`/`seed`/`max_tokens`),且不破坏缓存隔离与遥测诚实性。
|
|
|
|
|
- **方案概述**: `chat()` 增 keyword-only `overlay` 参数(调用级),`SourceConfig` 增 `extra_body` 字段(配置级)。`ChatRequest` 增 `sampling` 快照字段作为跨洋葱层恒定读取点,供缓存 key 与遥测消费。遥测端口 20 → 21 字段。
|
|
|
|
|
- **技术**: Python 3.11+,frozen dataclass,`MappingProxyType`,sqlite3 / asyncpg DDL 幂等补列。
|
|
|
|
|
|
|
|
|
|
**保真校验**: 本计划不涉及 `reference/` 参考实现迁移,保真校验不适用。
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 1. 文件结构
|
|
|
|
|
|
|
|
|
|
| 文件 | 职责变更 |
|
|
|
|
|
|---|---|
|
|
|
|
|
| `src/polygateway/types.py` | 新增 `validate_request_overlay()` 与 `merge_sampling()` 两个纯函数;`ChatRequest.sampling` 字段;`SourceConfig.extra_body` 字段与构造期校验 |
|
|
|
|
|
| `src/polygateway/client.py` | `chat()` 增 `overlay` 参数;`model_fingerprint` 计算纳入 `extra_body` |
|
|
|
|
|
| `src/polygateway/middleware/cache.py` | `build_cache_key()` 增 `sampling` 入参并纳入 key |
|
|
|
|
|
| `src/polygateway/transports/openai_compat.py` | `_build_payload` 在 thinking profile 之后、overlay 之前应用 `source.extra_body` |
|
|
|
|
|
| `src/polygateway/config.py` | `_SOURCE_FIELDS` 增 `EXTRA_BODY`;`_cast` 增 `json` 分支 |
|
|
|
|
|
| `src/polygateway/ports.py` | `TelemetryRecorder.record_llm_call` 增第 21 参 `sampling` |
|
|
|
|
|
| `src/polygateway/middleware/telemetry.py` | 三个 emit 入口按设计表格产出 `sampling`;`_record` 透传 |
|
|
|
|
|
| `src/polygateway/telemetry/sqlite.py` | DDL / `_BACKFILL_COLUMNS` / `_COLUMNS` 增 `sampling` |
|
|
|
|
|
| `src/polygateway/telemetry/postgres.py` | DDL / `_BACKFILL` / `_COLUMNS` 增 `sampling` |
|
|
|
|
|
| `src/polygateway/ocr.py` / `embedding.py` | 构造期剥离 `extra_body` + warning(决策 G) |
|
|
|
|
|
| `src/polygateway/providers.py` | minimax/openai 空 thinking profile 补后果注释(决策 F) |
|
|
|
|
|
| `.env.example` / `README.md` / `CHANGELOG.md` / `research-wiki/ARCHITECTURE.md` | 文档同步(设计 §6) |
|
|
|
|
|
|
|
|
|
|
**各任务需新增的 import**(现状核实,不加即 NameError):
|
|
|
|
|
|
|
|
|
|
| 文件 | 需新增 |
|
|
|
|
|
|---|---|
|
|
|
|
|
| `types.py` | `from collections.abc import Mapping`、`from types import MappingProxyType`、`import json`。**该文件无 `from __future__ import annotations`**,注解在类体求值,`Mapping` 必须真导入 |
|
|
|
|
|
| `client.py` | `import json`、`import hashlib` |
|
|
|
|
|
| `config.py` | `import json` |
|
|
|
|
|
| `ocr.py` / `embedding.py` | `import dataclasses`(现只有 `from dataclasses import dataclass`)、`from loguru import logger`(若未导入) |
|
|
|
|
|
| `middleware/telemetry.py` | `merge_sampling`/`canonical_sampling_json` 需**运行时**导入(现对 `polygateway.types` 只在 `TYPE_CHECKING` 下导入) |
|
|
|
|
|
|
|
|
|
|
**关键接口**(跨任务消费,此处定死):
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
# types.py —— 两个纯函数 + 两个字段
|
|
|
|
|
_PROTECTED_OVERLAY_KEYS = frozenset({"model", "messages", "stream", "stream_options"})
|
|
|
|
|
|
|
|
|
|
def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict[str, Any]:
|
|
|
|
|
"""校验采样参数覆盖层并返回浅拷贝;origin 用于错误信息定位来源。
|
|
|
|
|
|
|
|
|
|
保护键会击穿治理(model→成本算错、messages→缓存与遥测口径失真、
|
|
|
|
|
stream/stream_options→绕过看门狗与 usage 帧);值必须 JSON 可序列化,
|
|
|
|
|
否则会在 CacheMW 的降级 try 之外抛裸 TypeError(设计 §决策 B)。
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def merge_sampling(extra_body: Mapping[str, Any], sampling: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
|
|
"""合并配置级与调用级采样参数(调用级优先);两者皆空返回空 dict。"""
|
|
|
|
|
|
|
|
|
|
def canonical_sampling_json(merged: Mapping[str, Any]) -> str | None:
|
|
|
|
|
"""遥测列与缓存 key 共用的序列化口径;空 mapping → None。"""
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class ChatRequest:
|
|
|
|
|
...
|
|
|
|
|
overlay: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
sampling: Mapping[str, Any] = field(default_factory=dict) # 新增
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class SourceConfig:
|
|
|
|
|
...
|
|
|
|
|
extra_body: Mapping[str, Any] = field(default_factory=dict) # 新增,__post_init__ 转 MappingProxyType
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
# middleware/cache.py —— 签名扩展(sampling 为 keyword-only)
|
|
|
|
|
# 默认值用 None 而非 {}: dict 字面量作默认参数会被 ruff B006 拦下
|
|
|
|
|
def build_cache_key(
|
|
|
|
|
model_fingerprint: str,
|
|
|
|
|
messages: list[dict[str, Any]],
|
|
|
|
|
namespace: str,
|
|
|
|
|
salt: str | None,
|
|
|
|
|
*,
|
|
|
|
|
sampling: Mapping[str, Any] | None = None,
|
|
|
|
|
) -> str: ...
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
# client.py —— chat() 新签名
|
|
|
|
|
async def chat(
|
|
|
|
|
self, messages: list[dict[str, Any]], *,
|
|
|
|
|
session_id: str | None = None, parent_call_id: str | None = None,
|
|
|
|
|
cache_salt: str | None = None, cache_namespace: str | None = None,
|
|
|
|
|
structured: type[BaseModel] | Literal["json"] | None = None,
|
|
|
|
|
stream: bool = True,
|
|
|
|
|
overlay: Mapping[str, Any] | None = None, # 新增
|
|
|
|
|
) -> LLMResponse: ...
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 2. 任务清单
|
|
|
|
|
|
|
|
|
|
任务按依赖排序;每个任务一次提交、独立可验证。每个任务合并前必须出示**先失败后通过**的测试证据(先写测试跑红,再实现跑绿)。
|
|
|
|
|
|
|
|
|
|
统一验证命令前缀:`conda run -n PolyGateway --no-capture-output pytest`。
|
|
|
|
|
|
|
|
|
|
> **共享后端纪律**: 涉及 Redis/Postgres 的 integration 测试严禁与其他会话并跑(含 git 钩子触发的测试)。Task 7、Task 11 受此约束。
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### - [ ] Task 1: `types.py` 内核 —— 校验与合并纯函数 + 两个新字段
|
|
|
|
|
|
|
|
|
|
**文件**: 改 `src/polygateway/types.py`;测试 `tests/unit/test_types.py`
|
|
|
|
|
|
|
|
|
|
**实现行为**:
|
|
|
|
|
|
|
|
|
|
1. `validate_request_overlay(overlay, *, origin)`,**校验顺序即下列顺序**:
|
|
|
|
|
- 键必须是 `str`,否则 `ValueError`(canonical JSON 要求)。**必须排在序列化试探之前**——`{1: "a", "b": 2}` 在 `sort_keys=True` 下抛的是 `TypeError: '<' not supported between 'str' and 'int'`,若先试序列化会被误报成"值不可 JSON 序列化",指错方向;
|
|
|
|
|
- 命中 `_PROTECTED_OVERLAY_KEYS` 任一键 → `ValueError`,信息含 origin、违规键名、以及**为什么**(如 `stream` 会绕过流式看门狗);
|
|
|
|
|
- 对整个 mapping 做 `json.dumps(..., sort_keys=True)` 试序列化,`TypeError` → 转 `ValueError` 并指出该值不可 JSON 序列化(信息提示改用 `float(x)` 等原生类型);
|
|
|
|
|
- 返回 `dict(overlay)` 浅拷贝。
|
|
|
|
|
2. `merge_sampling(extra_body, sampling)` → `{**extra_body, **sampling}`(调用级优先)。
|
|
|
|
|
3. `canonical_sampling_json(merged)` → 空则 `None`,否则 `json.dumps(merged, sort_keys=True, ensure_ascii=False)`。
|
|
|
|
|
4. `ChatRequest` 增 `sampling` 字段(见 §1 关键接口)。
|
|
|
|
|
5. `SourceConfig` 增 `extra_body` 字段;`__post_init__` 新增 `_validate_extra_body()`:调 `validate_request_overlay(self.extra_body, origin=f"SourceConfig({self.name}).extra_body")`,再 `object.__setattr__(self, "extra_body", MappingProxyType(dict(...)))`(frozen dataclass 需用 `object.__setattr__`)。
|
|
|
|
|
|
|
|
|
|
**已知后果(必须显式接受,不是疏漏)**: `SourceConfig` 加 mapping 字段后**不再 hashable**(`hash()` → `TypeError`),且因 `MappingProxyType` 不可 pickle,`dataclasses.asdict()` / `copy.deepcopy()` 也会失败。
|
|
|
|
|
|
|
|
|
|
- 不可 hash 是**加任何 mapping 字段的固有代价**,与是否用 `MappingProxyType` 无关(裸 `dict` 同样不可 hash),无法规避;
|
|
|
|
|
- 库内当前无调用点会踩:`asdict` 只用于 `LLMResponse`/`EmbeddingResponse`(`cache.py:140`),全库无 `set(sources)` 或以源作 dict key 的写法;
|
|
|
|
|
- 保留 `MappingProxyType` 而非裸 dict,是因为决策 E 的只读约束值得这个代价;下游要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace(source, ...)`(已验证可行,会重跑 `__post_init__` 重新包 proxy,不递归)。
|
|
|
|
|
|
|
|
|
|
**验收标准**: 四个保护键各自触发 `ValueError` 且信息含原因;非 str 键报的是"键必须是 str"而非"不可序列化";`{"temperature": object()}` 类不可序列化值报 `ValueError` 而非 `TypeError`;合法 `{"temperature": 0, "seed": 42}` 通过并返回独立副本(改原 dict 不影响返回值);`SourceConfig.extra_body` 构造后为 `MappingProxyType` 且不可改。
|
|
|
|
|
|
|
|
|
|
**测试要求**: 新增 `tests/unit/test_types.py::TestSamplingValidation`,覆盖上述每条。不可序列化值用 `object()` 实例即可,不引入 numpy 依赖。**另加一条锁定测试**:`pytest.raises(TypeError): hash(source_config)`,把"不再 hashable"钉成有意行为——否则将来有人踩到时会以为是 bug 并"修"回去。
|
|
|
|
|
|
|
|
|
|
**验证**: `pytest tests/unit/test_types.py -v` → 全 PASS
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### - [ ] Task 2: `config.py` —— `EXTRA_BODY` env 解析
|
|
|
|
|
|
|
|
|
|
**文件**: 改 `src/polygateway/config.py`;测试 `tests/unit/test_config.py`
|
|
|
|
|
|
|
|
|
|
**实现行为**:
|
|
|
|
|
- `_SOURCE_FIELDS` 增 `"EXTRA_BODY": ("extra_body", "json")`;
|
|
|
|
|
- `_cast` 增 `json` 分支:`json.loads` 失败 → `ValueError`(沿用既有 `配置 {key} 解析失败: {exc}` 包装);解析结果**非 dict** → `ValueError`,信息说明必须是 JSON 对象(而非数组/标量)。
|
|
|
|
|
|
|
|
|
|
**验收标准**: `LLM__QWEN__1__EXTRA_BODY={"temperature":0}` → `SourceConfig.extra_body == {"temperature": 0}`;`{invalid` → `ValueError`;`[1,2]` → `ValueError`;`{"model":"x"}` → `ValueError`(经 Task 1 的 `SourceConfig.__post_init__` 保护键校验)。
|
|
|
|
|
|
|
|
|
|
**测试要求**: 新增 4 个 case 覆盖上述。**注意**: 这里同时验证了 Task 1 的校验确实挂在装配路径上。
|
|
|
|
|
|
|
|
|
|
**验证**: `pytest tests/unit/test_config.py -v` → 全 PASS
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### - [ ] Task 3: `chat()` 入口 + transport 应用 + fingerprint
|
|
|
|
|
|
|
|
|
|
**文件**: 改 `src/polygateway/client.py`、`src/polygateway/transports/openai_compat.py`;测试 `tests/unit/test_client.py`、`tests/unit/test_openai_compat.py`
|
|
|
|
|
|
|
|
|
|
**实现行为**:
|
|
|
|
|
|
|
|
|
|
1. `chat()` 增 `overlay` 参数(见 §1 签名)。进洋葱**之前**:
|
|
|
|
|
```python
|
|
|
|
|
validated = validate_request_overlay(overlay or {}, origin="chat(overlay=...)")
|
|
|
|
|
```
|
|
|
|
|
同一份 `validated` 对象同时填 `ChatRequest.overlay` 与 `.sampling`(设计决策 E:一次拷贝、两个字段指向同一快照,不做两份独立拷贝)。
|
|
|
|
|
2. `_build_payload`:在 thinking profile 之后、`payload.update(overlay)` 之前插入 `payload.update(source.extra_body)`。**顺序即优先级,不可调换**。
|
|
|
|
|
3. `model_fingerprint`(`client.py:117`)改为:
|
|
|
|
|
```python
|
|
|
|
|
fingerprint = ",".join(sorted({s.model for s in sources}))
|
|
|
|
|
marks = sorted({json.dumps([s.model, dict(s.extra_body)], sort_keys=True, ensure_ascii=False)
|
|
|
|
|
for s in sources if s.extra_body})
|
|
|
|
|
if marks:
|
|
|
|
|
fingerprint += "|" + hashlib.sha256("".join(marks).encode()).hexdigest()
|
|
|
|
|
```
|
|
|
|
|
全源 `extra_body` 皆空时字面量与旧实现**逐字相同**。`dict(...)` 是因为 `MappingProxyType` 不能直接进 `json.dumps`。
|
|
|
|
|
|
|
|
|
|
**验收标准**: 配置 `temperature=0` + 调用级 `temperature=1` → payload 中为 1;结构化注入的 `response_format` 覆盖调用级同名键;保护键在 `chat()` 入口即 `ValueError`(未进洋葱,可用 mock handler 断言未被调用);全源无 `extra_body` 时 fingerprint 与旧值逐字相同;有 `extra_body` 时不同;改源 `name` 不改变 fingerprint。
|
|
|
|
|
|
|
|
|
|
**测试要求**: 覆盖设计 §5 测试 #3、#4(chat 侧)、#5、#6(拷贝语义:调用方在 `chat()` 返回后修改自己的 dict,不影响已构造的 request)、#8(不可 JSON 序列化的值在 `chat()` 入口即 `ValueError`,断言洋葱 handler 未被调用)。
|
|
|
|
|
|
|
|
|
|
**验证**: `pytest tests/unit/test_client.py tests/unit/test_openai_compat.py -v` → 全 PASS
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### - [ ] Task 4: 缓存 key 纳入 `sampling`
|
|
|
|
|
|
|
|
|
|
**文件**: 改 `src/polygateway/middleware/cache.py`;测试 `tests/unit/test_cache.py`
|
|
|
|
|
|
|
|
|
|
**实现行为**:
|
|
|
|
|
- `build_cache_key` 增 keyword-only `sampling` 参数(见 §1 签名),非空时以 `"sampling"` 键并入 `key_obj`(**仅非空参与**,与 `salt` 的"仅非 None"不同——见设计决策 A 末段);
|
|
|
|
|
- `CacheMW.__call__` 传 `sampling=request.sampling`(**不是 `request.overlay`**——后者在此层虽尚未被结构化注入污染,但读 `sampling` 才是语义正确且不依赖层序巧合的写法)。
|
|
|
|
|
|
|
|
|
|
**验收标准**:
|
|
|
|
|
- 同 messages、不同 `seed` → 两个不同 key,第二次 miss(**issue 场景的直接回归**);
|
|
|
|
|
- 空 `sampling` 时 key 与旧实现**逐字相同**——测试须先把旧实现的 key 值固化为常量再比对(现有 `tests/unit/test_cache.py:39-54` 只有相等/不等断言,无 golden hash 可依);
|
|
|
|
|
- 同 `sampling` 不同键序 → 同一 key(canonical 序列化)。
|
|
|
|
|
|
|
|
|
|
**测试要求**: 覆盖设计 §5 测试 #1、#2。golden hash 的取法:在改动前先运行一次现有 `build_cache_key` 打印结果,写死进测试。
|
|
|
|
|
|
|
|
|
|
**验证**: `pytest tests/unit/test_cache.py -v` → 全 PASS
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### - [ ] Task 5: 地基不变式回归(承重)
|
|
|
|
|
|
|
|
|
|
**文件**: 测试 `tests/unit/test_structured.py`(或就近的洋葱集成测试文件)
|
|
|
|
|
|
|
|
|
|
**实现行为**: 纯测试任务,不改产品代码。
|
|
|
|
|
|
|
|
|
|
落点:`tests/unit/test_structured.py` 里既有的 `ScriptedTerminal` 恰好站在 RetryMW 的位置(`client.py:91` 的 `terminal = RetryMW(...)`,StructuredMW 是最内中间件),扩写它即可,**无需搭全洋葱**。
|
|
|
|
|
|
|
|
|
|
断言:走结构化重问阶梯(强制至少重问一次,用先返回坏 JSON 再返回好 JSON 的 scripted terminal)后——
|
|
|
|
|
1. terminal 每次收到的 `request.sampling` 与**构造 `ChatRequest` 时传入的 `sampling`** 逐字相同;
|
|
|
|
|
2. 同一时刻 `request.overlay` **含** `response_format`(证明两者确实分叉,`sampling` 不是冗余字段)。
|
|
|
|
|
|
|
|
|
|
**为什么单列一个任务**: 决策 C 与 D 都建立在"`sampling` 跨层恒定"之上,而这条目前只靠"`dataclasses.replace` 恰好保留未提及字段"的约定成立,无任何机械执法。这条测试同时钉死决策 A 的"库内中间件永不修改"与决策 E 的只读约束。缺它则约束被破坏时无人发现。
|
|
|
|
|
|
|
|
|
|
**验收标准**: 该测试在故意把 `structured.py` 的 `replace` 改成重建 `ChatRequest`(丢掉 `sampling`)时**必须变红**——实施时须实际验证这一点,否则测试是空的。
|
|
|
|
|
|
|
|
|
|
**验证**: `pytest tests/unit/test_structured.py -v` → 全 PASS,且上述"故意破坏"实验红过一次
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### - [ ] Task 6: 遥测端口扩至 21 字段 + 三入口口径
|
|
|
|
|
|
|
|
|
|
**文件**: 改 `src/polygateway/ports.py`、`src/polygateway/middleware/telemetry.py`;测试 `tests/unit/test_telemetry.py`
|
|
|
|
|
|
|
|
|
|
**实现行为**:
|
|
|
|
|
|
|
|
|
|
1. `ports.TelemetryRecorder.record_llm_call` 增第 21 参 `sampling: str | None`(排在 `model_reported` 之后)。
|
|
|
|
|
2. `TelemetryEmitter._record` 增同名参数并透传给 recorder。
|
|
|
|
|
3. 三个入口按设计决策 D 的表格产出(**不含**结构化注入的 `response_format`):
|
|
|
|
|
|
|
|
|
|
| 入口 | `sampling` 取值 |
|
|
|
|
|
|---|---|
|
|
|
|
|
| `emit_attempt` | `canonical_sampling_json(merge_sampling(source.extra_body, request.sampling))` |
|
|
|
|
|
| `emit_cache_hit` | `canonical_sampling_json(request.sampling)` |
|
|
|
|
|
| `emit_terminal_failure` | `canonical_sampling_json(request.sampling)` |
|
|
|
|
|
|
|
|
|
|
后两者无 `source` 可言(由最外层 TelemetryMW 调用),与 `model`/`provider`/`source_name` 在终态行置空是同一先例。
|
|
|
|
|
|
|
|
|
|
**关键约束**: `sampling` 必须由 emitter **内部推导**,**不得**作为新必填参数由调用者传入——否则 `ocr.py:418` 与 `embedding.py:372` 立刻 TypeError。
|
|
|
|
|
|
|
|
|
|
**验收标准**: 三个入口各自的 `sampling` 值符合上表;`response_format` **三行都不出现**;`request.sampling` 与 `source.extra_body` 皆空时为 `None`。
|
|
|
|
|
|
|
|
|
|
**测试要求**: 覆盖设计 §5 测试 #9。用 fake recorder 捕获 kwargs 断言。
|
|
|
|
|
|
|
|
|
|
**验证**: `pytest tests/unit/test_telemetry.py -v` → 全 PASS
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### - [ ] Task 7: 两个遥测后端落列 + 幂等补列
|
|
|
|
|
|
|
|
|
|
**文件**: 改 `src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`;测试 `tests/unit/test_telemetry.py`、`tests/integration/test_postgres_telemetry.py`
|
|
|
|
|
|
|
|
|
|
**实现行为**(逐字沿用 issue #3 建立的套路):
|
|
|
|
|
|
|
|
|
|
- **sqlite.py**: DDL 在 `model_reported` **之后**加 `sampling TEXT`;`_BACKFILL_COLUMNS` 追加 `("sampling", "TEXT")`;`_COLUMNS` 末尾追加 `"sampling"`。
|
|
|
|
|
- **postgres.py**: DDL 同位置加 `sampling TEXT`;`_BACKFILL` 追加 `("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT")`;`_COLUMNS` 末尾追加。
|
|
|
|
|
- 两处 `record_llm_call(**fields)` 按 `_COLUMNS` 取值,**无需改动**。
|
|
|
|
|
|
|
|
|
|
**硬约束**: 新列必须排在 `created_at` **之后**(两文件既有注释已说明理由:旧表只能 ALTER 追加到末尾,新建库若插在前面,两条路径物理列序分叉)。补列一律**先探测缺列再 ALTER**;失败只逐行降级,**绝不置结构性失能标志**(postgres 的 `_failed`)。
|
|
|
|
|
|
|
|
|
|
**连带必改**(不改则直接红):
|
|
|
|
|
|
|
|
|
|
| 位置 | 改什么 | 不改的后果 |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| `tests/unit/test_telemetry.py:78-102` 的 `_record_minimal()` | `fields` dict 加 `"sampling": None` | **两侧所有落库测试全红**:`sqlite.py:126` / `postgres.py:161` 的 `row = tuple(fields[col] for col in _COLUMNS)` **在 try 之外**,`_COLUMNS` 加列后抛裸 `KeyError: 'sampling'` 冒泡出 `record_llm_call` |
|
|
|
|
|
| `tests/integration/test_postgres_telemetry.py:88-109` 的 `_record_minimal()` | 同上 | 同上 |
|
|
|
|
|
| `tests/unit/test_telemetry.py:18` 的 `_EXPECTED_COLUMNS` | 追加 `"sampling"` | 列序断言红 |
|
|
|
|
|
| `tests/integration/test_postgres_telemetry.py:22` 的 `_EXPECTED_COLUMNS` | 追加(另见 `:210,231` 引用点) | 列序断言红 |
|
|
|
|
|
| `tests/unit/test_ports.py:95-119` 的 `_DummyRecorder.record_llm_call` | 显式 20 参签名同步为 21 | **不会红**(`runtime_checkable` 的 isinstance 只查方法存在不查签名),但会与端口脱节,顺带同步 |
|
|
|
|
|
| `sqlite.py:123` docstring、`test_telemetry.py:1` 文案 | "20 字段" → "21 字段" | 无功能影响,文案与事实脱节 |
|
|
|
|
|
|
|
|
|
|
**验收标准**: 新建库列序正确;对**已存在的 20 列旧表**能幂等补列且补后列序与新建库一致;重复初始化不报错;补列失败(模拟只有 INSERT 权限)时仅 warning、后续写入不被禁用。
|
|
|
|
|
|
|
|
|
|
**测试要求**: 覆盖设计 §5 测试 #11、#12。Postgres 部分是 integration,**须独占 PG `polygateway` 库时序,严禁并跑**。
|
|
|
|
|
|
|
|
|
|
**验证**:
|
|
|
|
|
- `pytest tests/unit/test_telemetry.py -v` → 全 PASS
|
|
|
|
|
- `pytest tests/integration/test_postgres_telemetry.py -v` → 全 PASS(确认无其他会话在用 PG)
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### - [ ] Task 8: 决策 G —— OCR/embedding 构造期剥离 + warning
|
|
|
|
|
|
|
|
|
|
**文件**: 改 `src/polygateway/ocr.py`、`src/polygateway/embedding.py`;测试 `tests/unit/test_ocr_client.py`、`tests/unit/test_embedding.py`
|
|
|
|
|
|
|
|
|
|
**实现行为**: 两个 `__init__` 在既有校验块(`quota_full` 域校验附近)之后、`self._sources = list(sources)` 之前:
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
stripped = []
|
|
|
|
|
for src in sources:
|
|
|
|
|
if src.extra_body:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"{} 路径暂不支持 extra_body,源 {} 的该配置已被忽略"
|
|
|
|
|
"(需要 dimensions 等参数请提 issue): {}",
|
|
|
|
|
<"embedding"|"OCR">, src.name, dict(src.extra_body),
|
|
|
|
|
)
|
|
|
|
|
src = dataclasses.replace(src, extra_body={})
|
|
|
|
|
stripped.append(src)
|
|
|
|
|
self._sources = stripped
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**剥离不是顺手清理,是承重的**: 不剥离则 Task 6 的 `merge_sampling(source.extra_body, ...)` 会让遥测**记录一个从未发出的参数**——`monkey_ocr.py:225,247` 只发 multipart `files=`(根本没有 JSON body),`openai_compat.py:343` 的 embed payload 硬编码 `{"model","input"}`。那是数据造假而非参数失效。替代方案(emitter 内特判调用方身份)违「遥测调用点收敛单一 helper」铁律,已否决。
|
|
|
|
|
|
|
|
|
|
**验收标准**: 带 `extra_body` 的源 → 装配**成功**(不抛异常)、记一条 warning、`client._sources` 上 `extra_body` 为空;该路径遥测 `sampling` 列为 `None`;不带 `extra_body` 时无 warning。
|
|
|
|
|
|
|
|
|
|
**测试要求**: 覆盖设计 §5 测试 #10。**后半段(遥测 `sampling` 为 None)是防遥测造假的真正断言,不可省**——只断言"装配成功 + 有 warning"是不够的。用 `caplog`/loguru 捕获断言 warning 存在。
|
|
|
|
|
|
|
|
|
|
**验证**: `pytest tests/unit/test_ocr_client.py tests/unit/test_embedding.py -v` → 全 PASS
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### - [ ] Task 9: 决策 F —— 空 thinking profile 的后果注释
|
|
|
|
|
|
|
|
|
|
**文件**: 改 `src/polygateway/providers.py`
|
|
|
|
|
|
|
|
|
|
**实现行为**: 给 `openai`(`:46-51`)与 `minimax`(`:53-58`)两个 profile 各补一句**后果**说明:`enable_thinking=False` 对本 provider 不产生任何效果,需要关闭推理请用 `SourceConfig.extra_body`。
|
|
|
|
|
|
|
|
|
|
**注意**: `:52` 那条既有注释(「OpenAI 兼容基线,无已知注入差异」)在词法上属于紧随其后的 **minimax** 条目,`openai` 条目**没有**任何注释。补的是"后果"而非重复"为何为空"——不要写出与既有注释重复或矛盾的内容。
|
|
|
|
|
|
|
|
|
|
**验收标准**: 两个 profile 都能让读者明白 `enable_thinking=False` 对它们无效。纯注释变更,无行为变化。
|
|
|
|
|
|
|
|
|
|
**测试要求**: 无(纯注释)。此任务不单独提交,与 Task 10 合并提交。
|
|
|
|
|
|
|
|
|
|
**验证**: `make lint` → PASS
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### - [ ] Task 10: 文档同步(设计 §6 清单)
|
|
|
|
|
|
|
|
|
|
**文件**: 改 `.env.example`、`README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md`
|
|
|
|
|
|
|
|
|
|
| 目标 | 具体改动 |
|
|
|
|
|
|---|---|
|
|
|
|
|
| `.env.example` | 在 `LLM__QWEN__1__TRUST_ENV` 注释行(`:20`)后加 `# LLM__QWEN__1__EXTRA_BODY={"temperature":0}` 及说明(JSON 对象串;保护键会报错;OCR/EMBED scope 会被忽略并 warning)。`client.py:251` docstring 声明本文件是键名清单事实源,漏写等于新键无处可查 |
|
|
|
|
|
| `README.md:83` | 该行逐一列举 `chat()` 关键字参数,补 `overlay` 及一句用途 |
|
|
|
|
|
| ARCH §5.2 | `chat()` 签名定稿段追加 `overlay` 要点(带默认值的 keyword-only,不破坏"调用点零改动"承诺) |
|
|
|
|
|
| ARCH §7.5 | key 公式补 `sampling` 项 + 两条已知副作用(seed 进 key 导致该路径必 miss;`model_fingerprint` 是集合级指纹,同 scope 各源 `extra_body` 不同时仍可能跨源命中) |
|
|
|
|
|
| ARCH §7.7(`:451`) | 该节逐字段枚举 `SourceConfig` 构成(`name/provider/.../enable_thinking`),补 `extra_body` |
|
|
|
|
|
| ARCH §7.8(`:463`) | 必录字段 20 → 21,补 `sampling` 及其列语义(不含 `response_format`) |
|
|
|
|
|
| ARCH §9(`:519-527`) | 配置面键族事实源,登记 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY` |
|
|
|
|
|
| `CHANGELOG.md` | 公共 API 新增(`chat(overlay=)`、`SourceConfig.extra_body`)+ 遥测端口扩列 |
|
|
|
|
|
|
|
|
|
|
**Gitea Wiki 同步**(`docs-convention.md` §2,CLAUDE.md §6 标为硬门)。本次同时命中该表两行:
|
|
|
|
|
|
|
|
|
|
| 命中行 | 必同步页 |
|
|
|
|
|
|---|---|
|
|
|
|
|
| 新公共 API / 新能力 | 对应指南页(新增「固定解码参数」内容,落在 `指南-遥测与成本` 或新页)+ `参考-公共API`(`chat()` 签名、`SourceConfig.extra_body`)+ `_Sidebar.md` + CHANGELOG |
|
|
|
|
|
| 新增配置键 | `参考-配置键`(登记 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`)+ 相关指南页的配置片段 + `.env.example` |
|
|
|
|
|
|
|
|
|
|
指南页必须写明三条坑:① `seed` 逐次变化时该路径缓存**必 miss**;② `model_fingerprint` 是集合级指纹,同 scope 各源 `extra_body` 不同时仍可能跨源命中(要逐源可复现需每源独享 scope 或 namespace);③ OCR/EMBED scope 的 `EXTRA_BODY` 会被忽略并 warning。
|
|
|
|
|
|
|
|
|
|
**验收标准**: 每条都能在文件中指到具体位置;ARCH 的改动与设计文档不矛盾;wiki 两行清单逐页落实。
|
|
|
|
|
|
|
|
|
|
**测试要求**: 无(纯文档)。与 Task 9 合并提交。
|
|
|
|
|
|
|
|
|
|
**验证**: `make lint` → PASS
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### - [ ] Task 11: 全链路集成验证与合并前检查
|
|
|
|
|
|
|
|
|
|
**文件**: 测试 `tests/integration/`(就近文件或新增)
|
|
|
|
|
|
|
|
|
|
**实现行为**: 端到端断言采样参数经 `chat()` → 选源 → transport payload 到达请求体(设计 §5 测试 #13),用 fake HTTP 层捕获实际 payload。
|
|
|
|
|
|
|
|
|
|
**合并前门(逐条出示证据)**:
|
|
|
|
|
1. `make ci` → 全绿(`make lint` + `make test` + 覆盖率)
|
|
|
|
|
2. import-linter 契约无新违规(校验函数落最内层 `types.py`,分层关系不变)
|
|
|
|
|
3. 设计 §5 的 14 条测试全部有对应实现,逐条对应到具体测试函数名
|
|
|
|
|
4. 派**全新上下文** verifier subagent 独立验证(CLAUDE.md §3.2 里程碑级/跨多文件硬门)
|
|
|
|
|
|
|
|
|
|
**验证**:
|
|
|
|
|
- `make ci` → 全 PASS(**不要**在外面套 `conda run`:`Makefile` 每条 target 内部已是 `conda run -n PolyGateway ...`,嵌套会让内层输出被缓冲)
|
|
|
|
|
- verifier 报告无 blocking 问题
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 3. 提交节奏
|
|
|
|
|
|
|
|
|
|
| 提交 | 内容 |
|
|
|
|
|
|---|---|
|
|
|
|
|
| 1 | Task 1(types 内核) |
|
|
|
|
|
| 2 | Task 2(env 解析) |
|
|
|
|
|
| 3 | Task 3(chat 入口 + transport + fingerprint) |
|
|
|
|
|
| 4 | Task 4(缓存 key) |
|
|
|
|
|
| 5 | Task 5(地基不变式测试) |
|
|
|
|
|
| 6 | Task 6(遥测三入口) |
|
|
|
|
|
| 7 | Task 7(两后端落列) |
|
|
|
|
|
| 8 | Task 8(决策 G) |
|
|
|
|
|
| 9 | Task 9 + 10(注释与文档) |
|
|
|
|
|
| 10 | Task 11(集成验证,如有修补) |
|
|
|
|
|
|
|
|
|
|
每次提交调 `commit` skill。Task 1-4 是 issue 诉求的最小闭环;Task 5-8 是设计中"issue 未提但必须处理"的部分,**不可跳过**。
|