feat: judge whether reasoning actually happened from multiple signals

reasoning_tokens=None has been carrying two meanings at once, no
reasoning and no report, and the library resolved the ambiguity by
quietly claiming the first. ThinkingObservation splits them: UNKNOWN
says the call left no signal, ABSENT says the provider reported zero.

The verdict ranks evidence by hardness. Reasoning prose is the fact
itself; reasoning_tokens is a report about the fact, so a missing
report cannot overrule prose that is right there. The prose check
strips first, since a gateway that returns whitespace is not evidence.

The enum lives in types.py, not in the new thinking.py, because
LLMResponse is typed on it and the innermost layer must not import a
decision module.
This commit is contained in:
2026-08-25 23:40:39 -04:00
parent 85bcc23a6b
commit e90bb3d6a4
5 changed files with 141 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
"""推理这件事的全部**决策**: 请求侧注入形态、响应侧结果裁定、二者的对账。
与 `providers.py` 的分工: 那里是**注册表**(provider 长什么样,静态声明的存放
与查找),这里是**决策**(拿声明和响应做判断)。P7"决策逻辑与状态存储分离"
本模块**不定义** `ThinkingObservation` —— 它是 `LLMResponse` 的字段类型,归最
内层 `types.py`;定义在这里会让 `types.py` 反向 import 决策模块(依赖铁律)。
"""
from polygateway.types import ThinkingObservation
def observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> ThinkingObservation:
"""由多信号裁定推理是否发生;判据按**证据硬度**排序(issue #16/#17)。
推理正文是事实本身,`reasoning_tokens` 是对事实的转述——转述缺失时事实仍然
作数。2026-08-25 实测: MiniMax 这一路已不再返回
`usage.completion_tokens_details`,而同一次调用里库拿得到 185 字符推理正文;
只认 token 数的判据会把这种情形误判成"没推理"
正文判据取 `strip()` 而非 truthy: 网关响应是外部输入,纯空白串不是证据(P5)。
判不出来时返回 `UNKNOWN` 而非 `ABSENT`——**不许把"没看见"说成"没发生"**。
"""
if thinking.strip():
return ThinkingObservation.OBSERVED
if reasoning_tokens is None:
return ThinkingObservation.UNKNOWN
return ThinkingObservation.OBSERVED if reasoning_tokens > 0 else ThinkingObservation.ABSENT
+22
View File
@@ -10,6 +10,7 @@ import math
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from enum import StrEnum
from types import MappingProxyType
from typing import Any
@@ -166,6 +167,27 @@ def canonical_sampling_json(merged: Mapping[str, Any]) -> str | None:
return json.dumps(dict(merged), sort_keys=True, ensure_ascii=False)
class ThinkingObservation(StrEnum):
"""一次调用中"推理是否真的发生"的裁定结果(issue #16/#17)。
三态**不可折叠为布尔**: `UNKNOWN` 是"本次无任何信号,判不出来",与
`ABSENT`("上游明确上报了未推理")语义不同。把前者折叠进后者,正是
`reasoning_tokens=None` 制造的那个歧义——库据此静默宣称"没推理",而实际
可能推理了且已计费(MiniMax-M3 非流式实测: completion 53 vs 关闭档 3,
推理正文与 usage 明细双双不回传)。
裁定由 `thinking.observe_thinking` 做,本类只是取值域。**枚举定义在最内层
而非决策层**: 它是 `LLMResponse` 的字段类型,放进 `thinking.py` 会让
`types.py` 反向 import 决策模块(P7 依赖铁律)。
取值进遥测落库,改名即造成历史数据断层。
"""
OBSERVED = "observed"
ABSENT = "absent"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class LLMResponse:
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""