yetuge a41d0fcec1
fix(parsing): keep literal think tags inside JSON string values (#7907)
* fix(parsing): keep literal think tags inside JSON string values

Structured parsers ran strip_think_blocks over the whole response before
json.loads. A model answer that quotes the tag -- "Explain <think>example
</think>" -- lost the quoted text, and an unclosed literal tag inside a
string cut the JSON document mid-string, so suggestions returned None and
goal evaluation failed to parse.

Strip reasoning only outside JSON string literals: leading blocks are still
removed whole (a JSON example inside reasoning is never taken as the
answer), and blocks after the payload starts are removed only outside
quotes. Suggestions and the goal evaluator both use the shared helper.

* fix(parsing): keep think-tag stripping complete outside the JSON payload

The string-aware walker only covered the payload itself: a reasoning block
after prose and before the payload was copied verbatim, so brackets inside
it became the parsers' find/rfind targets, and an unclosed block in
trailing prose was kept where strip_think_blocks used to truncate.

Find the payload start while stepping over think blocks, strip the head with
strip_think_blocks, keep strip/truncate semantics after the top-level
payload closes, and truncate at an unclosed block outside a string. Adds a
CHANGELOG entry for the user-visible parsing change.
2026-11-06 21:19:52 +08:00

260 lines
9.7 KiB
Python

"""Utilities for normalizing LLM response text before structured parsing."""
from __future__ import annotations
import re
# A word boundary also accepts punctuation in unrelated XML/HTML tag names.
# Keep the exact unfinished prefix for leading-summary cleanup.
# Accept the self-closing delimiter without treating <think/other> as reasoning.
_THINK_OPEN_PREFIX_RE = re.compile(r"<think(?=[\s>]|/>|$)", re.IGNORECASE)
_THINK_CLOSE_PREFIX_RE = re.compile(r"</think", re.IGNORECASE)
def _find_tag_end(text: str, start: int) -> int:
"""Find the closing delimiter outside single- or double-quoted attributes."""
quote = ""
for index in range(start, len(text)):
char = text[index]
if quote:
if char == quote:
quote = ""
elif char in {"'", '"'}:
quote = char
elif char == ">":
return index
return -1
def _find_think_open(text: str, start: int) -> tuple[int, int] | None:
"""Find the next complete opening tag without retrying its suffix at each prefix."""
match = _THINK_OPEN_PREFIX_RE.search(text, start)
if match is None:
return None
end = _find_tag_end(text, match.end())
if end < 0:
return None
return match.start(), end + 1
def _is_self_closing_think_tag(text: str, start: int, end: int) -> bool:
"""Recognize a terminal slash only when it is outside quoted attributes."""
if text[end - 2] != "/":
return False
quote = ""
for char in text[start : end - 1]:
if quote:
if char == quote:
quote = ""
elif char in {"'", '"'}:
quote = char
return not quote
def _find_think_close(text: str, start: int) -> tuple[int, int] | None:
"""Find the first closing tag, allowing whitespace before its final ``>``."""
while match := _THINK_CLOSE_PREFIX_RE.search(text, start):
end = match.end()
while end < len(text) and text[end].isspace():
end += 1
if end < len(text) and text[end] == ">":
return match.start(), end + 1
start = match.end()
return None
def strip_think_blocks(text: str, *, truncate_unclosed: bool = True) -> str:
"""Remove inline reasoning ``<think>`` blocks from a model response.
Complete ``<think>...</think>`` blocks are always removed. Self-closing
``<think/>`` tags are empty blocks and leave following text intact. A dangling,
unclosed ``<think>`` open tag is treated as a model that was truncated
mid-thought: when ``truncate_unclosed`` is True (the default, used by JSON
parsers like suggestions/goal where trailing garbage must be dropped) the
text is cut at that tag. Callers that may legitimately echo a literal
``<think>`` substring in their output (e.g. the input polisher rewriting a
draft that mentions the tag) pass ``truncate_unclosed=False`` so the tag is
preserved instead of silently discarding the rest of the text.
"""
parts: list[str] = []
start = 0
while (opening := _find_think_open(text, start)) is not None:
if _is_self_closing_think_tag(text, *opening):
parts.append(text[start : opening[0]])
start = opening[1]
continue
closing = _find_think_close(text, opening[1])
if closing is None:
if truncate_unclosed:
return ("".join(parts) + text[start : opening[0]]).strip()
break
parts.append(text[start : opening[0]])
start = closing[1]
parts.append(text[start:])
return "".join(parts).strip()
def strip_leading_think_blocks(text: str) -> str:
"""Remove leading reasoning for display summaries, leaving tags in the answer intact.
Unlike structured-response parsing, a displayed answer may explain the
tag in prose or code. Only a leading XML-style reasoning section is
removed; an unfinished section has no answer to summarize. Leading
self-closing tags are removed as empty reasoning sections.
"""
start = 0
while True:
whitespace_start = start
while start < len(text) and text[start].isspace():
start += 1
newline = text.rfind("\n", whitespace_start, start)
indent = text[max(newline + 1, whitespace_start) : start]
if (whitespace_start == 0 or newline >= 0) and ("\t" in indent or indent.startswith(" ")):
break # Root-level indented Markdown code is a literal example.
opening = _THINK_OPEN_PREFIX_RE.match(text, start)
if opening is None:
break
end = _find_tag_end(text, opening.end())
if end >= 0 and _is_self_closing_think_tag(text, start, end + 1):
start = end + 1
continue
closing = _find_think_close(text, end + 1) if end >= 0 else None
if closing is None:
return ""
start = closing[1]
return text[start:].strip()
def strip_markdown_code_fence(text: str) -> str:
"""Remove a single wrapping markdown code fence when present."""
stripped = text.strip()
if not stripped.startswith("```"):
return stripped
lines = stripped.splitlines()
if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].startswith("```"):
return "\n".join(lines[1:-1]).strip()
return stripped
def extract_response_text(content: object) -> str:
"""Extract textual content from common chat-model response content shapes."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") in ("text", "output_text"):
text = block.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts)
if content is None:
return ""
return str(content)
def _json_payload_start(text: str) -> int:
"""Index of the first ``{``/``[`` outside a reasoning block, or -1.
A JSON example inside a reasoning block must not become the payload, so the
scan steps over whole think blocks. An unclosed block before the payload is
a truncated response: there is no structured answer to find.
"""
index = 0
while index < len(text):
char = text[index]
if char == "<":
opening = _THINK_OPEN_PREFIX_RE.match(text, index)
if opening is not None:
end = _find_tag_end(text, opening.end())
if end >= 0:
if _is_self_closing_think_tag(text, index, end + 1):
index = end + 1
continue
closing = _find_think_close(text, end + 1)
if closing is not None:
index = closing[1]
continue
return -1
if char in "{[":
return index
index += 1
return -1
def strip_think_blocks_outside_json_strings(text: str) -> str:
"""Strip reasoning around a JSON response but keep tags inside string values.
Structured parsers used to run :func:`strip_think_blocks` over the whole
response before ``json.loads``. That deleted a literal ``<think>`` inside a
JSON string value; an unclosed one cut the document mid-string and failed
parsing. Tags inside string values are data and are preserved verbatim.
Everything outside the payload keeps :func:`strip_think_blocks` semantics:
the prose before it is stripped (so a JSON example inside a reasoning block
is never mistaken for the answer), a block between the payload's own tokens
is removed, and an unclosed block truncates the remainder as a truncated
model response. After the top-level payload closes, trailing prose goes
through the same strip/truncate rules.
"""
start = _json_payload_start(text)
if start < 0:
return strip_think_blocks(text)
out = [strip_think_blocks(text[:start])]
quote = ""
depth = 0
index = start
while index < len(text):
char = text[index]
if quote:
out.append(char)
if char == "\\" and index + 1 < len(text):
out.append(text[index + 1])
index += 2
continue
if char == quote:
quote = ""
index += 1
continue
if char == '"':
quote = char
out.append(char)
index += 1
continue
if char in "{[":
depth += 1
out.append(char)
index += 1
continue
if char in "}]":
depth -= 1
out.append(char)
index += 1
if depth <= 0:
# The top-level payload is closed: trailing prose is not JSON,
# so it keeps the old strip (and truncate) behavior.
out.append(strip_think_blocks(text[index:]))
break
continue
if char == "<":
opening = _THINK_OPEN_PREFIX_RE.match(text, index)
if opening is not None:
end = _find_tag_end(text, opening.end())
if end >= 0:
if _is_self_closing_think_tag(text, index, end + 1):
index = end + 1
continue
closing = _find_think_close(text, end + 1)
if closing is not None:
index = closing[1]
continue
# Unclosed outside a string: truncated mid-thought, so the
# remainder cannot hold a payload either.
return "".join(out).strip()
out.append(char)
index += 1
return "".join(out).strip()