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]).
This commit is contained in:
@@ -283,7 +283,9 @@ class ChatCompletionAccumulator:
|
||||
"""文本模式取 index 缺省或为 0 的首个 choice,不混入其他 choice(n>1)。"""
|
||||
for raw in choices:
|
||||
choice = _object(raw, "choice")
|
||||
if choice.get("index", 0) == 0:
|
||||
index = choice.get("index")
|
||||
# null 视缺省(=0);bool 不得冒充 0(`False == 0` 为真)
|
||||
if index is None or (type(index) is int and index == 0):
|
||||
finish = choice.get("finish_reason")
|
||||
if isinstance(finish, str) and "finish_reason" not in self.metadata:
|
||||
self.metadata["finish_reason"] = finish
|
||||
@@ -333,24 +335,26 @@ class ChatCompletionAccumulator:
|
||||
return grew
|
||||
|
||||
def _take_tools(self, tools: Any) -> bool:
|
||||
if self.mode != "openai":
|
||||
return isinstance(tools, list) and self._each_tool(tools, self._text_tool_progress)
|
||||
if tools is None:
|
||||
if self.mode == "openai":
|
||||
self.message.setdefault("tool_calls", None)
|
||||
self.message.setdefault("tool_calls", None)
|
||||
return False
|
||||
if not isinstance(tools, list):
|
||||
if self.mode == "openai":
|
||||
raise _invalid("tool_calls 必须为数组")
|
||||
return False
|
||||
if self.mode == "openai" and self.message.get("tool_calls") is None:
|
||||
raise _invalid("tool_calls 必须为数组")
|
||||
present = [tool for tool in tools if tool is not None]
|
||||
if self.message.get("tool_calls") is None and (present or not tools):
|
||||
# 显式 [] 原样保留;[null] 不造空数组(回传空 tool_calls 会被上游拒)
|
||||
self.message["tool_calls"] = []
|
||||
return self._each_tool(present, lambda raw: self._take_tool(_object(raw, "tool")))
|
||||
|
||||
@staticmethod
|
||||
def _each_tool(tools: list[Any], take: Any) -> bool:
|
||||
"""逐个非 null 工具增量聚合,返回本帧是否有进展。"""
|
||||
progress = False
|
||||
for raw in tools:
|
||||
if raw is None:
|
||||
continue
|
||||
if self.mode == "openai":
|
||||
progress |= self._take_tool(_object(raw, "tool"))
|
||||
else:
|
||||
progress |= self._text_tool_progress(raw)
|
||||
if raw is not None:
|
||||
progress |= take(raw)
|
||||
return progress
|
||||
|
||||
def _text_tool_progress(self, raw: Any) -> bool:
|
||||
@@ -404,7 +408,9 @@ def _strip_null_tools(message: dict[str, Any]) -> dict[str, Any]:
|
||||
"""null 工具元素剔除: 与流式跳过同口径,原样回传 [null] 会被上游拒。"""
|
||||
tools = message.get("tool_calls")
|
||||
if isinstance(tools, list) and None in tools:
|
||||
return {**message, "tool_calls": [tool for tool in tools if tool is not None]}
|
||||
kept = [tool for tool in tools if tool is not None]
|
||||
# 全为 null 视同 tool_calls:null,不造会被上游拒收的空数组
|
||||
return {**message, "tool_calls": kept or None}
|
||||
return message
|
||||
|
||||
|
||||
@@ -429,7 +435,11 @@ def check_completion_body(body: Any) -> tuple[dict[str, Any], str | None]:
|
||||
finish = choice.get("finish_reason")
|
||||
if finish is not None and not isinstance(finish, str):
|
||||
raise _invalid("finish_reason 必须为字符串或 null")
|
||||
return _strip_null_tools(_object(choice.get("message"), "message")), finish
|
||||
message = _object(choice.get("message"), "message")
|
||||
if message.get("role") != "assistant":
|
||||
# 形态失败先于结局判定,否则缺 role 的截断工具会被归成 ResultInvalid
|
||||
raise _invalid("role 必须为 assistant")
|
||||
return _strip_null_tools(message), finish
|
||||
|
||||
|
||||
def build_completion(
|
||||
|
||||
@@ -66,6 +66,7 @@ _THINK_PATTERN = re.compile(r"<think>(.*?)</think>", re.DOTALL)
|
||||
# 方言告警节流上限与 finish 值截断(设计 §5.4): 键由上游决定,必须有界
|
||||
_WARN_CAP = 1024
|
||||
_FINISH_WARN_CHARS = 64
|
||||
_PATH_WARN_CHARS = 128
|
||||
|
||||
# —— SSE 纯函数(VT llm.py 同款)——
|
||||
|
||||
@@ -845,7 +846,8 @@ class OpenAICompatTransport:
|
||||
self, source: SourceConfig, conflicts: frozenset[str], finish_reason: str | None
|
||||
) -> None:
|
||||
"""方言告警: 冲突删键与未登记 finish 各按 (源, 路径/值) 只喊一次。"""
|
||||
for path in sorted(conflicts):
|
||||
for raw_path in sorted(conflicts):
|
||||
path = raw_path[:_PATH_WARN_CHARS] # 键名由上游决定,长度同样须有界
|
||||
self._warn_once(
|
||||
source.name, f"field:{path}", f"字段 {path} 合并冲突已删除,保真结果缺该字段"
|
||||
)
|
||||
|
||||
@@ -551,6 +551,8 @@ class _OutputSummary:
|
||||
body_output: bool
|
||||
tools_present: bool
|
||||
tools_ok: bool
|
||||
null_arguments: bool = False
|
||||
"""工具快照仅因 `function.arguments` 全程 null 而不成立(无参函数方言,审查 I2)。"""
|
||||
|
||||
|
||||
def _summarize_text(content: str, thinking: str) -> _OutputSummary:
|
||||
@@ -585,9 +587,27 @@ def _summarize_message(message: Mapping[str, Any], projected_content: str) -> _O
|
||||
body_output=projected_content.strip() != "" or (tools_present and tools_ok),
|
||||
tools_present=tools_present,
|
||||
tools_ok=tools_ok,
|
||||
null_arguments=not tools_ok and _tool_snapshot_ok(_fill_null_arguments(message)),
|
||||
)
|
||||
|
||||
|
||||
def _fill_null_arguments(message: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""仅供判定: 把 null arguments 视作空串后再验快照,区分"只缺参数"与其他缺陷。
|
||||
|
||||
不改写交付值——保真结果原样;此函数的产物只用于分类。
|
||||
"""
|
||||
tools = message.get("tool_calls")
|
||||
if not isinstance(tools, list):
|
||||
return dict(message)
|
||||
filled = []
|
||||
for tool in tools:
|
||||
fn = tool.get("function") if isinstance(tool, dict) else None
|
||||
if isinstance(fn, dict) and "arguments" in fn and fn["arguments"] is None:
|
||||
tool = {**tool, "function": {**fn, "arguments": ""}}
|
||||
filled.append(tool)
|
||||
return {**message, "tool_calls": filled}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Verdict:
|
||||
"""结局判定;`deliver` 为真时另两字段为 None。"""
|
||||
@@ -613,6 +633,9 @@ def _judge_broken(
|
||||
if mode == "openai" and summary.tools_present and not summary.tools_ok:
|
||||
if finish == "truncated":
|
||||
return _Verdict(False, "result_invalid", "tool_truncated")
|
||||
if summary.null_arguments:
|
||||
# 无参函数只发 arguments:null 是确定性方言: 重拉同样结果,不重试也不改写
|
||||
return _Verdict(False, "result_invalid", "tool_arguments_null")
|
||||
return _Verdict(False, "transient", "tool_incomplete")
|
||||
return None
|
||||
|
||||
@@ -667,9 +690,10 @@ def _is_cacheable(
|
||||
if completion is None:
|
||||
return False
|
||||
raw = completion.message.get("content")
|
||||
return _summarize_message(
|
||||
completion.message, raw if isinstance(raw, str) else ""
|
||||
).body_output
|
||||
summary = _summarize_message(completion.message, raw if isinstance(raw, str) else "")
|
||||
# 原文与投影都须有正文: 原文空不信任旧投影;投影空(think 标签剥净)不固化空回答
|
||||
has_tools = summary.tools_present and summary.tools_ok
|
||||
return summary.body_output and (content.strip() != "" or has_tools)
|
||||
return content.strip() != ""
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user