Files
PolyGateway/research-wiki/plans/2026-10-14-issue30-31-completion-verdict.md
T
iomgaa abcdf6930e fix: address completion verdict review findings
- fidelity cacheability also requires a non-empty projected body, so
  think-tag-only answers are no longer cached
- a no-argument tool sent with arguments null only is a deterministic
  dialect: ResultInvalid tool_arguments_null instead of retried
- text mode treats choice index null as 0 and no longer lets False pose
  as 0
- null-only tool_calls elements no longer produce an empty tool_calls
  list; non-stream fidelity checks role before the verdict; warning
  paths are length-bounded
- docs: ARCHITECTURE 7.8 field counts, README cache/watchdog wording,
  CHANGELOG notes, review record in the design, hook note in the plan

Red: 8 new tests failed before this change (e.g.
test_openai_cacheable_requires_projected_body,
test_null_arguments_tool_is_result_invalid_stream,
test_text_mode_choice_index_null_is_zero[None-True]).
2026-10-15 00:28:20 -04:00

28 KiB

type, node_id, title, date
type node_id title date
plan plan:2026-10-14-issue30-31-completion-verdict 统一完成判定实现计划(#30/#31) 2026-10-14

#30/#31 统一完成判定实现计划

目标: 按设计(下称"设计")实现统一完成判定,修复 #30/#31 并发布 1.3.10。

方案概述: 在 types.py 内核放一组纯判定函数(流完整性 → 结局分类 → 可缓存),transport 两条路径、CacheMW 读写共用;聚合器改为 null 优先 + 未知字段无损合并;LLMResponse/TransportResult 追加 finish_reason/stream_end,遥测加 stream_end 列。

技术: Python 3.12、httpx、pydantic、pytest(asyncio)、SQLite/PG 遥测、Redis 缓存。环境 conda run -n PolyGateway。

本计划不涉及参考实现迁移,保真校验不适用。

0. 执行纪律

  • 分支 fix/issue30-31-completion-verdict。每个任务一次提交,经 commit skill;pre-commit 钩子会跑全套件 + ruff + "改动触及函数复杂度 < C",故每个提交点必须全绿,改到的既有高复杂度函数(add_chunk F42、_complete_stream/_complete_once C、_rehydrate C、OpenAIChatCompletion.__post_init__ D)须在同一提交内拆到 B 及以下;钩子(2026-10-14 经人类同意改为只拦"本次改动触及"的块,存量 ≥C 降为告警,提交 5d2ad40)按 radon 块判定,类块(ChatCompletionAccumulator C16、OpenAIChatCompletion D24)同样须降到 B 及以下。不改 OpenAICompatTransport.complete()(C14),新增逻辑一律放在拆出的 helper 中。
  • 每个任务的统一验证(与钩子一致): conda run -n PolyGateway pytest tests/ -q(含 integration,本地有 Redis/PG 时真跑)+ conda run -n PolyGateway ruff check src + conda run -n PolyGateway radon cc -n C <本任务改动文件>(输出必须为空,或仅含本任务未触及的存量块)。各任务列出的子集命令只作开发期快速回路。
  • 测试结果门: 每个任务的新增/改写测试先在未改实现的代码上运行并记录失败输出(粘贴进提交说明正文的"Red:"一行,写明失败用例名与断言),再实现至通过。
  • 后台长跑命令末尾禁止接管道(CLAUDE.md §3)。

1. 文件结构

文件 职责变化
src/polygateway/types.py 新增 StreamEnd、FinishClass、_OutputSummary、_Verdict 与七个纯函数(§2 列出;另 _effective_finish);TransportResult/LLMResponse 末尾追加两字段;OpenAIChatCompletion 接受 tool_calls: None 并复用 _tool_snapshot_ok
src/polygateway/__init__.py 导出 StreamEnd;__version__ → 1.3.10(T6)
src/polygateway/transports/_openai_completion.py 聚合器: null 优先、未知字段合并、标签键、冲突路径、finish/usage 位置记录、reasoning 别名;parse_chat_completion 容忍 null
src/polygateway/transports/openai_compat.py 两条路径改走判定;有界告警节流;finish_reason/stream_end 填充;投影别名
src/polygateway/middleware/retry.py _build_response 搬运两字段
src/polygateway/middleware/cache.py 写/读按 _is_cacheable;stream_end 校验;finish 回退
src/polygateway/ports.py record_llm_call 追加 stream_end
src/polygateway/middleware/telemetry.py _AttemptUsage.stream_end、_record 搬运、两 emit 显式 None
src/polygateway/telemetry/schema.py、sqlite.py、postgres.py 新列 + 补列
tests 新增 tests/unit/test_completion_verdict.py;改写/扩充 test_openai_completion.py、test_openai_fidelity.py、test_openai_compat.py、test_cache.py、test_telemetry.py、test_types.py、test_ports.py、tests/integration/test_postgres_telemetry.py 及各 record_llm_call 替身
文档 README、CHANGELOG、ARCHITECTURE §6.2/§7/§14、1.3.9 设计修订指针、schemas/llm-calls.md、migrations/ 三份、pyproject 版本

2. 跨任务接口(T1 定义,后续任务只消费)

StreamEnd = Literal["done", "eof_after_finish", "salvaged"]
FinishClass = Literal["natural", "truncated", "filtered", "upstream_error", "unknown", "absent"]

def _classify_finish(value: str | None) -> FinishClass: ...
def _tool_snapshot_ok(message: Mapping[str, Any]) -> bool: ...
def _stream_is_complete(
    *, done: bool, finish_reason: str | None, usage_after_finish: bool, usage_measured: bool
) -> StreamEnd | None: ...
@dataclass(frozen=True)
class _OutputSummary:
    any_output: bool      # 设计 §4.3 "任何产物"
    body_output: bool     # 设计 §4.3 "正文产物"
    tools_present: bool   # 至少一个非 null 工具条目(仅保真有意义)
    tools_ok: bool        # tools_present 时 _tool_snapshot_ok 结果;无工具恒 True

@dataclass(frozen=True)
class _Verdict:
    deliver: bool
    error_kind: Literal["transient", "result_invalid"] | None  # deliver 时为 None
    code: str | None      # 设计 §4.3 原因码
def _summarize_text(content: str, thinking: str) -> _OutputSummary: ...
def _summarize_message(message: Mapping[str, Any], projected_content: str) -> _OutputSummary: ...
def _judge_outcome(*, mode: ResponseMode, summary: _OutputSummary, finish: FinishClass) -> _Verdict: ...
def _is_cacheable(
    *, mode: ResponseMode, content: str, finish_reason: str | None,
    stream_end: str | None, completion: OpenAIChatCompletion | None,
) -> bool: ...

缓存读写共用(T1 定义,T4 消费),顶层优先、回退 completion.finish_reason,避免读写口径分叉:

def _effective_finish(finish_reason: Any, completion: OpenAIChatCompletion | None) -> str | None: ...

types.py 无 from __future__ import annotations: StreamEnd/FinishClass 必须定义在 TransportResult/LLMResponse 之前。

聚合器对外只读属性(T2 定义,T3 消费): finish_reason: str | None、finish_seen: bool、usage_after_finish: bool、conflicts: frozenset[str](归一路径)、raw_message() -> dict[str, Any](未校验的合并消息深拷贝,含按 index 排序组装的 tool_calls,与 finish() 同一组装函数,供 _tool_snapshot_ok 判定)、finish() -> OpenAIChatCompletion(保真快照,校验失败抛 Transient,与 1.3.9 同)。ChatDelta 追加字段 reasoning: str(别名片段,默认 "")。

3. 任务

T1 内核判定函数与公共字段

  • 文件: src/polygateway/types.py、src/polygateway/__init__.py;测试 tests/unit/test_completion_verdict.py(新)、tests/unit/test_types.py、tests/unit/test_package.py(导出)。
  • 行为:
    • _classify_finish 按设计 §4.1 表,大小写敏感;None → absent。
    • _tool_snapshot_ok: tool_calls 缺省或 None → True;否则必须是 list,每个非 None 元素满足 1.3.9 规则(非空唯一 id、type == "function"、非空 function.name、字符串 arguments);list 中 None 元素跳过。OpenAIChatCompletion.__post_init__ 改为调用它(并拆分以降复杂度),tool_calls: None 合法且原样保留键。
    • _stream_is_complete: done → "done";否则 finish_reason is not None and usage_after_finish and usage_measured → "eof_after_finish";否则 None。
    • _summarize_text: any = content != "" or thinking != "";body = content.strip() != "";tools_present False、tools_ok True。
    • _summarize_message: any = 递归遍历 message 中除 role、tool_calls 外所有值存在非空字符串,或 tools_present;tools_present = tool_calls 为含非 None 元素的 list;tools_ok = _tool_snapshot_ok(message);body = projected_content.strip() != "" 或 (tools_present 且 tools_ok)。
    • _judge_outcome: 严格按设计 §4.3 九行首匹配;原因码字面量: upstream_finish_error、tool_truncated、tool_incomplete、length_without_content、filtered_without_content、empty_completion。行 2 仅 mode == "openai" 时生效。
    • _is_cacheable: 设计 §6 四条,任一成立 False;stream_end 不在 StreamEnd 域且非 None → False;openai 模式且 completion is None → False。
    • TransportResult、LLMResponse 末尾追加 finish_reason: str | None = None、stream_end: StreamEnd | None = None(放在现有最后一个字段之后,不扰动位置构造);docstring 写清 None 语义与 usage_source 分工。
    • 包根导出 StreamEnd;_has_completion_output 本任务保留不动(T3/T4 迁移后删除)。
  • 测试(新文件): _classify_finish 全值表参数化;_stream_is_complete 真值表(含 usage 早于 finish、usage 非 measured);_judge_outcome 九行 × 两模式参数化(每行至少一例,含"正文非空+坏工具+stop → transient tool_incomplete");_summarize_message 对 refusal-only、reasoning-only、whitespace content、未知嵌套字符串、tool_calls: [None];_is_cacheable 四类否例 + 正例 + 非法 stream_end。test_types.py: OpenAIChatCompletion 接受 tool_calls=None、拒非 list、[None] 合法;两新字段默认 None。
  • 验证: conda run -n PolyGateway pytest tests/unit/test_completion_verdict.py tests/unit/test_types.py tests/unit/test_package.py -q → 全过;make lint 无新增问题。
  • 提交 feat: add kernel completion verdict functions and response fields

T2 聚合器: null 优先、未知字段合并、finish/usage 记录

  • 文件: src/polygateway/transports/_openai_completion.py;测试 tests/unit/test_openai_completion.py。
  • 保留 message、tools 实例属性名与语义(test_openai_completion.py:70,93 直接依赖)。
  • 行为(设计 §5、§4.2):
    • 每帧: 校验 chunk 对象;usage 非 null 时记录为 last_usage,若 finish_seen 或同帧 choice 带非 null finish → usage_after_finish = True。
    • choices: 保真维持单 choice/index 0 校验;文本模式取 index 缺省或为 0 的第一个 choice,其余忽略。
    • finish_reason: 保真走 _identity(非字符串/冲突 → Transient,同 1.3.9);文本模式首个字符串值生效,非字符串忽略。任一模式见到非 null 即 finish_seen = True。
    • null 五层(设计 §5.1): chunk 层 choices: null、choice 层 delta: null 视为缺省;例外: 顶层 error 键(含 error: null)仍由 _check_chat_error 按 error 帧拒绝(不动,保 test_error_before_done_is_failure)。
    • delta 与工具条目、function 三层: 值为 None 的键 → 若目标无此键则 setdefault(key, None)(保真),不做类型校验;tool_calls list 中 None 元素跳过。
    • 已知字段非 None 时逐字沿用 1.3.9 校验与合并(content/reasoning_content 拼接、role 必须 assistant、工具 id/type _identity、name/arguments 拼接)。
    • 未知字段(delta 层除 role/content/reasoning_content/tool_calls;工具层除 index/id/type/function;function 层除 name/arguments): 交给 _merge_unknown(target, key, value, path),规则见设计 §5.2;标签键集合 {"index", "type", "id", "format"};冲突 → 从 target 删除该键、把归一路径(数组下标替换为 [],如 delta.reasoning_details[].format)加入 conflicts 与本尝试"封禁路径"集合,后续同路径值丢弃。_merge_unknown 返回"是否并入非空字符串片段"。
    • reasoning: 在保真模式作为普通未知字符串合并进 message;两模式都把其片段放入 ChatDelta.reasoning。
    • progress: 1.3.9 已知字段口径 或 任一 _merge_unknown 返回 True(文本模式也调用合并函数计算活性,但合并结果不对外)。
    • 新增只读属性见 §2;finish() 改为: 组装 message(含排序后工具)后构造 OpenAIChatCompletion(校验失败仍抛 Transient)。
    • parse_chat_completion 拆为两步: check_completion_body(body) -> tuple[dict, str | None](只校验 body 为对象、恰好一个 choice、index 缺省或为 0、message 为对象、保真下 finish 为字符串或 null,失败抛 Transient;返回原始 message 与 finish)与 build_completion(message, body) -> OpenAIChatCompletion(构造快照,失败抛 Transient)。快照构造前剔除 tool_calls 中的 None 元素(与流式跳过一致,避免回传 [null] 被上游 400);tool_calls: null 原样保留。
    • _merge_unknown 按值类型拆分私有函数(标签键、字符串、标量、对象、index 数组、其他数组),每个 ≤ B。
    • 拆分 add_chunk 为 _take_meta、_take_choice、_take_delta、_take_tools、_take_tool 等私有方法,每个 ≤ B。
  • 测试: 改写 test_invalid_delta_is_domain_error 参数: 删除 {"audio": {}}、{"index":0,"extra":1} 与 null 已知字段参数(改为正例),保留 role 非 assistant、content 非字符串、tool index 非法等仍应拒绝的参数。新增: refusal:null+tool_calls:null 帧成功且快照含 refusal: None;只有 reasoning 的帧 progress=True 且 ChatDelta.reasoning 有值;二次构造的 OpenRouter reasoning_details 分片(每帧重复 format/id,text 分片)合并后 format 不重复、text 拼接完整;工具层嵌套对象 extra_content={"google":{"thought_signature":"s"}} 原样保留;数值冲突 → 键被删、conflicts 含归一路径、调用成功;同路径冲突后再到达的值被丢弃;形态改变(字符串→对象、index 数组→普通数组)即冲突;布尔值冲突;其他数组追加;同帧多个字段/多个未知字符串只产生一次 progress;chunk choices:null、choice delta:null 视缺省;role:null、arguments:null 视缺省;全程只有 role:null → finish() 失败;非流式 tool_calls:[None, 合法工具] 快照只含合法工具;usage_after_finish 四种顺序(finish 前 usage、finish 同帧、finish 后 usage-only 帧、无 usage);文本模式 index=1 的 choice 被忽略;重复 tool id 不拼接(既有用例保持)。
  • 验证: §0 统一验证;快速回路 conda run -n PolyGateway pytest tests/unit/test_openai_completion.py tests/unit/test_openai_fidelity.py tests/unit/test_openai_compat.py -q。T2 期间 transport 仍调用 parse_chat_completion 的组合(保留为两步的薄包装),行为不变。
  • 提交 fix: merge unknown and null stream fields losslessly in completion accumulator

T3 transport 两条路径接入判定

  • 文件: src/polygateway/transports/openai_compat.py;测试 tests/unit/test_openai_fidelity.py、tests/unit/test_openai_compat.py。
  • 行为:
    • _complete_stream: 流结束后先算 usage_measured = _resolve_usage(sink.get("usage") or {})[2] == "measured",再 stream_end = _stream_is_complete(done=..., finish_reason=acc.finish_reason, usage_after_finish=acc.usage_after_finish, usage_measured=...)。
      • None 且 openai → Transient "{源} missing_done: …";None 且 text → 沿 _check_done(零产物按 _summarize_text 的 any 判定 early_eof;retry 抛;salvage → stream_end="salvaged")。
      • salvaged 时 usage 沿 _resolve_stream_usage(..., salvaged=True);done/eof_after_finish 时 salvaged=False。
    • 投影: thinking = 非空 reasoning_content 片段拼接,否则 ChatDelta.reasoning 片段拼接;再走既有 _finalize_text(think 标签)。
    • 判定: openai → summary = _summarize_message(acc.raw_message(), content);text → _summarize_text(content, thinking);verdict = _judge_outcome(mode, summary, _classify_finish(acc.finish_reason));非 deliver → 按 error_kind 抛 TransientError 或 ResultInvalidError,message 固定格式 f"{source.name} {code}: finish_reason={fr!r}, thinking_len={len(thinking)}",带 source_name、operation="chat";ResultInvalidError 显式 raw_text=""(不回显推理内容)。deliver 且 openai → acc.finish() 构造快照。
    • 删除 _reject_empty_completion 与 1.3.9 的"保真缺 DONE"分支;_check_done 只保留文本截断语义。
    • _complete_once: 跳过第一步。保真: 先 check_completion_body 取原始 message 与 finish,用原始 message(剔除 None 工具后)算 _summarize_message 与 _judge_outcome,仅 deliver 时调 build_completion 构造快照——否则坏工具 + length 会在构造时抛 Transient,到不了 tool_truncated。文本: finish 取 choices[0].get("finish_reason"),非字符串记 None;同一判定;stream_end=None;投影别名同上(message.get("reasoning_content") or message.get("reasoning"),仅取字符串)。
    • 两路径 TransportResult 填 finish_reason、stream_end。
    • 告警节流: 实例属性 _warned_fields: set[tuple[str, str]],上限 1024,饱和时记一次 "告警集合已饱和,后续同类告警省略";对 acc.conflicts 中每个路径、以及 _classify_finish == "unknown" 的 finish 值(截断 64 字符,文案写明"该 finish_reason 不缓存")各告警一次;日志只含源名与路径/值,不回显字段内容。
    • 拆分 _complete_stream/_complete_once 至 ≤ B(建议抽 _consume_stream、_verdict_or_raise、_project_text)。
  • 测试(假 SSE,复用文件内既有 helper):
    • #31 验收 1-6(设计 §10),断言 stream_end、usage_source、attempts=1(经 GatewayClient 或 RetryMW 的既有测试设施)。
    • usage 早于 finish + EOF → 失败;finish 后 usage {} → 失败;network_error+usage+EOF → Transient。
    • 文本模式: stop+usage+EOF 无 DONE(missing_done="retry")→ 成功 eof_after_finish;无 finish + EOF → retry 抛、salvage 记 salvaged。
    • #30 验收: 保真 reasoning-only+length → 交付,completion.message["content"] 缺省或空、reasoning_content 完整、finish_reason=="length";文本同输入 → ResultInvalidError 且 message 含 length_without_content;保真 content 空+length(无任何产物)→ 交付;文本同 → ResultInvalid;保真/文本 零增量+DONE(带 stop、不带 finish)→ Transient(test_stream_zero_content_with_done_is_transient 保持不改);非流式 message 带 tool_calls:null+refusal:null → 成功;工具缺 name + length → ResultInvalid tool_truncated;content 非空 + 坏工具 + stop → Transient。
    • 只有 reasoning 字段的流不触发 TTFT 超时(用既有看门狗测试设施设置小 ttft)且文本 thinking 等于拼接值;同时有非空 reasoning_content 时以其为准。
    • 冲突/未知 finish 告警各只一次(loguru sink: logger.add(list.append, level="WARNING"),先例 test_openai_compat.py:877;caplog 抓不到 loguru);饱和用例预先往 _warned_fields 灌 1024 条再触发一次,断言饱和告警一次。
    • 结局表传输层覆盖: 设计 §4.3 九行 × 两模式,每行至少流式一例 + 非流式一例(行 2 只保真);非流式"工具缺 name + length → ResultInvalid tool_truncated";文本 length 用例经 GatewayClient 断言 attempts=1 且熔断窗口失败计数为 0;#30 reasoning-only+length 经 GatewayClient 断言 attempts=1。
    • 改写 test_openai_never_salvages: 断言"无 finish 的 EOF 在 missing_done=salvage 下仍失败",并新增"finish+usage+EOF 成功"作为对照。
  • 验证: conda run -n PolyGateway pytest tests/unit -q → 全过。
  • 提交 fix: judge stream completion by finish reason and trailing usage

T4 retry 搬运与缓存读写判据

  • 文件: src/polygateway/middleware/retry.py、src/polygateway/middleware/cache.py、src/polygateway/types.py(删除 _has_completion_output);测试 tests/unit/test_cache.py、tests/unit/test_retry.py、tests/integration/test_redis_cache.py。
  • 行为:
    • _build_response 搬运 finish_reason、stream_end。
    • 写路径: 序列化前 _is_cacheable(mode=request.response_mode, content=response.content, finish_reason=response.finish_reason, stream_end=response.stream_end, completion=response.completion) 为 False → logger.debug 跳写。
    • 读路径(_rehydrate): 重建 completion 后、fields.update 之前: stored_end = fields.get("stream_end") 非 None 且不在域 → miss+warning;finish = fields.get("finish_reason"),非 None 且非 str → miss;为 None 且 completion 存在 → 回退 completion.finish_reason;_is_cacheable(...) False → miss+warning;命中覆盖加 stream_end=None、finish_reason=finish。拆分 _rehydrate 至 ≤ B。
    • 删除 _has_completion_output 及其 import(全仓 grep 为零)。
  • 读写判定的 finish 统一取 _effective_finish(response.finish_reason, response.completion)(读路径对存储值同样调用)。
  • 测试: 不可缓存六类(salvaged、reasoning-only length 保真、文本 length 带正文、unknown finish、保真 reasoning-only + stop(交付但无正文产物)、content_filter 带正文)均不写入(fake cache backend 断言 set 未调用)且第二次调用 attempts=1;手工注入上述条目 → 读视 miss;非法 stream_end 注入 → miss;1.3.9 旧文本条目(无新键)→ 命中;1.3.9 保真条目(无顶层 finish、completion.finish_reason="length")→ miss;命中返回 stream_end is None、finish_reason 为存储/回退值;retry 测试断言 LLMResponse 两字段来自 TransportResult。Redis integration 一例往返两新字段。
  • 验证: conda run -n PolyGateway pytest tests/unit -q → 全过;conda run -n PolyGateway pytest tests/integration/test_redis_cache.py -q(需本地 Redis)→ 全过。
  • 提交 fix: gate cache writes and reads on a shared cacheability verdict

T5 遥测 stream_end 列与 recorder 端口

  • 文件: src/polygateway/ports.py、src/polygateway/middleware/telemetry.py、src/polygateway/telemetry/schema.py、sqlite.py、postgres.py;测试 tests/unit/test_telemetry.py、tests/unit/test_ports.py、tests/integration/test_postgres_telemetry.py,以及全仓 def record_llm_call 测试替身(grep -rn "def record_llm_call" tests)。
  • 行为:
    • TelemetryRecorder.record_llm_call 在 stall_count 之后追加 keyword-only stream_end: str | None(无默认),docstring 注明 1.3.10。
    • _AttemptUsage 加 stream_end: str | None = None,of() 读 response.stream_end;emit_attempt 把它传给 _record;_record 加同名参数并传给 recorder;emit_cache_hit、emit_terminal_failure 显式 stream_end=None。
    • schema.py: SQLite 与 PG CREATE 末尾 stream_end TEXT;SQLITE_BACKFILL、_PG_BACKFILL_DECLS 追加 ("stream_end", "TEXT");COLUMNS 追加 "stream_end"。sqlite/postgres 若有硬编码列数或参数位次随之更新(以 COLUMNS 为准)。
  • 替身与计数(按审查实测清单): ① 固定字段 fixture tests/unit/test_telemetry.py:142 _record_minimal、tests/integration/test_postgres_telemetry.py:121 _minimal_fields 补 stream_end: None(sqlite 按 fields[col] 取值,缺键 KeyError);② 硬编码计数 38→39 / 列清单追加: test_ports.py:325、test_telemetry.py:53(_EXPECTED_COLUMNS):271、:301-303、:779、:829,test_postgres_telemetry.py:33、:778、:1151、:1352(执行时以 grep 复核行号);③ test_ports.py:112 _DummyRecorder 补参数,stream_end 加入 :332 无默认值参数化列表;④ **fields 替身(14 处)无需改动。源码 docstring "38 字段"同步: ports.py:293、middleware/telemetry.py:373、sqlite.py:146、schema.py:8。形状闸 middleware/telemetry.py:323 _assert_recorder_shape 从协议签名派生,不改闸本身。
  • 测试: 成功流式行写 done/eof_after_finish;非流式、缓存命中、失败、取消、终态行为 NULL;旧库(无列)auto 档补列后写入;manual 档缺列 → 裁剪写入且告警点名 stream_end;形状闸拒绝缺 stream_end 参数的 recorder;PG integration 同上(需本地 PG,沿既有 fixture)。所有替身补参数。
  • 验证: conda run -n PolyGateway pytest tests/unit -q → 全过;conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -q → 全过;conda run -n PolyGateway python -c "import inspect, polygateway.ports as p; print(len(inspect.signature(p.TelemetryRecorder.record_llm_call).parameters) - 1)" 记录字段数供 T6 使用。
  • 提交 feat: record stream end mode in telemetry

T6 文档、版本与另开 issue

  • 文件: README.md、CHANGELOG.md、research-wiki/ARCHITECTURE.md、research-wiki/designs/2026-10-01-issue29-openai-fidelity-design.md(顶部加"§4.1/§4.2/§5/§6 部分规则已由 2026-10-14 设计修订"指针)、research-wiki/schemas/llm-calls.md、research-wiki/migrations/*.md、pyproject.toml、src/polygateway/__init__.py。
  • 内容:
    • CHANGELOG ## 1.3.10(未发布): 修复段(#31 两模式、#30 两部分、非流式 tool_calls:null、reasoning 别名、salvage 不再缓存);行为变更段(文本 length/过滤无正文 → ResultInvalid 不再经 AllSourcesExhausted;未知 finish 不缓存;活性口径增补);破坏性段(record_llm_call 新增必填 stream_end);提醒(工具快照成立+length 时 arguments 可能半截;冲突删键以告警为准;旧缓存污染迁移 namespace/salt)。
    • README: 能力表补完成判定/stream_end;安装约束 1.3.* 核对;遥测字段数用 T5 实测值;活性口径一句。
    • ARCHITECTURE: §6.2 空补全/missing_done 行、§7 usage_source 与 stream_end 映射、§14 协议/失败/缓存行按设计改写。
    • schemas/llm-calls.md: 加 stream_end 列与取值表,修正陈旧计数。
    • migrations/ 三份: 核对下游对 ResultInvalidError 的 catch 点,写一行结论(有/无受影响路径)。
    • 版本 1.3.10 两处一致。
    • docs-convention.md §2 清单逐项过: Gitea Wiki 站点下线,由 README/CHANGELOG/ARCHITECTURE/schemas 承接;.env.example 无配置键变更(写明);research-wiki/ROADMAP.md 状态同步。
    • 另开 Gitea issue(tea CLI): "文本模式 n>1 时多 choice 增量混流";"文本模式有产物无正文 + stop 的证据收集(决定是否改 ResultInvalid)"。
  • 验证: grep -n "1.3.10" pyproject.toml src/polygateway/__init__.py;conda run -n PolyGateway pytest tests/unit/test_package.py -q → 过。
  • 提交 docs: document completion verdict and bump version to 1.3.10

T7 合并前验证、审查与发布

  • make lint、make test 全绿(覆盖率不降);tests/integration 中 Redis/PG 相关全绿。
  • 派全新上下文 verifier subagent(verification-before-completion),逐条对照设计 §4-§7、§10 与本计划验收,输出证据表。
  • requesting-code-review 整分支审查;按 receiving-code-review 核验并修复。
  • slow 子集(按 diff 交集): 动了公开入口与 transport → pytest -m slow tests/e2e/test_smoke_gateway.py tests/e2e/test_openai_tools_live.py;不跑 test_thinking_live.py(未动 thinking 能力表)。另做一次 magpie/glm 真实探针: 记录是否出现 eof_after_finish,作为 #31 外部证据。
  • 按 CLAUDE.md §4.4.1 九步发布 1.3.10(CHANGELOG 定版日期、合并 main --no-ff、main 上重跑、tag、构建、上传、下载验证、Release + 包关联 + 页面核对)。
  • 在 #30/#31 评论验收证据与版本,关闭。

4. 设计覆盖自查

设计条目 任务
§3 公共字段、StreamEnd 导出、tool_calls:null T1
§3 recorder 端口、列 T5
§4.1 纯函数与 finish 分类 T1
§4.2 流完整性、usage 位置与 measured T2(记录)+T3(判定)
§4.3 结局表、原因码、错误格式 T1(判定)+T3(翻译)
§4.4 reasoning 别名 T2+T3
§5.1-5.3 null、合并、标签键、活性 T2
§5.4 告警节流 T3
§6 缓存读写 T4
§7 遥测落点 T5
§8 旧行为审计中的保留项(零增量+DONE、error 帧、多 choice) T3 回归测试
§10 文档、另开 issue、发布 T6+T7

5. 审查修订记录

轮次 意见 处置
P1 B1 非流式先构造快照导致 tool_truncated 不可达 采纳: T2 拆 check_completion_body/build_completion,T3 先判定后构造
P1 S1 T5 替身/计数清单 采纳: T5 逐项列出
P1 S2 类块复杂度、S3 验证与钩子一致 采纳: §0
P1 S4 保留内部属性、S5 五层 null、S6 缓存漏两类、S7 结局表与熔断断言、S8 合并规则测试 采纳: T2/T3/T4
P1 建议 1-8 全部采纳;建议 4 选"剔除 None 工具元素"