fix(linsight): recover ask_user questions crammed into a dict's question value

Live case (session b28d0dc6, model deepseek-v4-flash, input "skill test"):
the task-mode ask_user clarify card rendered a raw-JSON blob as its single
question title with zero options. Root cause is model-side — deepseek-v4-flash
did NOT escape the inner quotes in a question text (你想要的"skill test"是指什么?),
so its OpenAI-compatible function-call serializer corrupted the whole `questions`
array. The arg parser then produced a WELL-FORMED outer list/dict but crammed the
entire 3-question array into the FIRST dict's `question` VALUE (dropping the
opening `[{"question"` while keeping the `: "` separator). The trigger is
intermittent: it only fires when a question's text itself contains quotes (here
echoed from the literal user input) — which is why prior DeepSeek clarifications
without quotes rendered fine.

The existing recovery (854d45974) only re-parsed malformed STRING list-elements,
so this dict-value shape fell through unchanged. Extend the recovery to:
- re-expand a dict whose `question` value is itself a serialized questions array
  (gated by a quoted-JSON-key signature so ordinary prose is never mangled);
- reconstruct a blob that RETAINED the `: "` separator via `[{"question"` so the
  first question comes back clean (no leading `: "` noise);
- run the same crammed-array recovery on a malformed TOP-LEVEL string (still
  degrades to [] for arbitrary prose — reason-only park).

Frontend needs no change (one clarify tool_call per recovered question already
renders the multi-page card). 22 unit tests green, incl. the exact live blob as
a fixture and regression guards for prose/placeholder questions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
LineWalker
2026-07-15 11:35:21 +08:00
co-authored by Claude Opus 4.8
parent 6bfedd60f4
commit 58325411d1
2 changed files with 109 additions and 6 deletions
@@ -326,11 +326,17 @@ def _recover_question_items(s: str) -> list[dict]:
if not any(marker in s for marker in ('"question"', '"options"', "{", "[")):
return [{"question": s}]
candidates = [s, "[" + s + "]"]
# Only restore a dropped opening ``[{"question": "`` when the blob actually
# looks like it lost its head (starts mid-value, not with ``{``/``[``). This
# targets the observed failure precisely without corrupting a complete dict
# Only restore a dropped opening ``[{"question"`` when the blob actually looks
# like it lost its head (starts mid-value, not with ``{``/``[``). Two shapes seen:
# - the blob RETAINED the ``: "`` separator (starts ``: "…``) → prepend only
# ``[{"question"`` so the first question comes back CLEAN (observed live with
# deepseek-v4-flash, session b28d0dc6);
# - it also dropped ``: "`` (starts mid-value) → prepend the full ``[{"question": "``.
# The clean-separator variant is tried first so it wins ties over the noisier one.
# This targets the observed failure precisely without corrupting a complete dict
# that merely lacks a ``question`` key into a bogus one.
if not s.startswith(("{", "[")):
candidates.append('[{"question"' + s)
candidates.append('[{"question": "' + s)
best: list[dict] = []
for cand in candidates:
@@ -347,6 +353,19 @@ def _recover_question_items(s: str) -> list[dict]:
return best
def _looks_like_crammed_questions_array(text: str) -> bool:
"""True when a string is itself a serialized ``questions`` array rather than a
natural-language question. The tell-tale is quoted JSON keys — ``"options"``
together with ``"question"`` or ``"multiple"`` — which a real question never
contains verbatim. Gates the Case-D re-expansion in ``_normalize_to_dicts`` (a
dict whose ``question`` VALUE is a crammed array) AND the top-level malformed-
string recovery, so ordinary prose is never mangled: prose that merely mentions
the word ``options`` unquoted, or uses ``{}`` placeholders, does NOT match
because the markers are the quoted-key forms, and a bare non-JSON string still
degrades to ``[]`` (park with reason only) instead of becoming a bogus question."""
return '"options"' in text and ('"question"' in text or '"multiple"' in text)
def _normalize_to_dicts(value: object) -> list[dict]:
"""Structural normalization shared by ``_coerce_questions`` and
``_salvage_options_only``: parse the (possibly stringified, or per-item
@@ -361,18 +380,31 @@ def _normalize_to_dicts(value: object) -> list[dict]:
stripped = value.strip()
if not stripped:
return []
# A malformed top-level string degrades to [] (park with reason only) — the
# observed failure mode is a malformed element INSIDE a list, handled below.
# A malformed top-level string still gets one recovery pass — but ONLY when it
# is clearly a crammed structured array (JSON-key markers); arbitrary prose
# degrades to [] (park with reason only), never a bogus question. The more
# common failure is a malformed element INSIDE a list, handled below.
try:
value = json.loads(stripped)
except (json.JSONDecodeError, ValueError):
return []
return _recover_question_items(stripped) if _looks_like_crammed_questions_array(stripped) else []
if not isinstance(value, list):
return []
out: list[dict] = []
for item in value:
if isinstance(item, dict):
# Case D: a model may cram the WHOLE questions array into a single dict's
# ``question`` VALUE as a serialized (usually unescaped) blob — observed
# live with deepseek-v4-flash (session b28d0dc6): the value carried nested
# ``"options"``/``"question"`` keys and the user saw raw JSON as the title.
# Re-expand it into the real question dicts; otherwise keep the dict as-is.
qtext = str(item.get("question", ""))
if _looks_like_crammed_questions_array(qtext):
recovered = _recover_question_items(qtext)
if recovered:
out.extend(recovered)
continue
out.append(item)
elif isinstance(item, str):
out.extend(_recover_question_items(item))
@@ -126,6 +126,59 @@ def test_coerce_drops_unrecoverable_debris_not_a_blob():
assert _coerce_questions(['{"options": ["a", "b"], "multiple": false}']) == []
# Case D (observed live with deepseek-v4-flash, session b28d0dc6…, "skill test"): the
# OpenAI-compatible arg parser produced a WELL-FORMED outer list/dict, but the whole
# 3-question array got crammed into the FIRST dict's `question` VALUE as a serialized
# blob — it dropped the array's opening `[{"question"` (keeping the `: "` separator) and
# left the inner quotes in 你想要的"skill test"是指什么? unescaped. The pre-fix code kept
# the dict as-is, so the user saw the raw-JSON blob as the (single) question title with
# zero options. The trigger is intermittent: it only fires when a question's text itself
# contains quote characters (here echoed from the literal user input "skill test").
# These are the EXACT bytes captured from the live session's execute-task-detail API.
_LIVE_CRAMMED_IN_DICT_VALUE = (
': "你想要的"skill test"是指什么?", '
'"options": ["测试我的 AI 能力(给我出一道题来评估我的表现)", "生成一套技能测试题/考核方案(用于评估他人)", '
'"帮我做一份个人技能评估或能力自测", "对某个特定领域的技能进行摸底测试"], "multiple": false}, '
'{"question": "如果涉及技能测试内容,测试的领域或技能方向是什么?", '
'"options": ["编程/软件开发", "数据分析/AI/机器学习", "产品/项目管理", "通用职场技能(沟通、协作等)", "其他(请在下方补充)"], '
'"multiple": false}, {"question": "希望的交付格式是?", '
'"options": ["markdown", "html", "docx", "pdf"], "multiple": true}]'
)
def test_coerce_case_d_dict_value_crammed_array_re_expands():
"""The reported fix: a dict whose `question` VALUE is a crammed array must be
re-expanded into the real structured questions (options intact), not passed
through as one raw-JSON title with no options."""
out = _coerce_questions([{"question": _LIVE_CRAMMED_IN_DICT_VALUE, "options": [], "multiple": False}])
assert len(out) == 3
# first question comes back CLEAN — no leading `: "` separator noise
assert out[0]["question"] == '你想要的"skill test"是指什么?'
assert len(out[0]["options"]) == 4
assert out[1]["question"] == "如果涉及技能测试内容,测试的领域或技能方向是什么?"
assert out[2]["question"] == "希望的交付格式是?"
assert out[2]["options"] == ["markdown", "html", "docx", "pdf"]
assert out[2]["multiple"] is True
# invariant regardless of json_repair drift: never a raw-JSON blob as a title
assert all(len(str(q.get("question", ""))) < 200 for q in out)
def test_coerce_case_d_also_recovers_from_top_level_string():
"""The same crammed array arriving as a top-level malformed STRING (not wrapped
in a list/dict) is recovered too, rather than degrading straight to []."""
out = _coerce_questions(_LIVE_CRAMMED_IN_DICT_VALUE)
assert len(out) == 3
assert out[0]["question"] == '你想要的"skill test"是指什么?'
def test_coerce_prose_question_not_mistaken_for_crammed_array():
"""Regression guard: a genuine question dict whose text merely MENTIONS the word
options (unquoted) or uses a {placeholder} must NOT trip the crammed-array
re-expansion — only the quoted JSON-key signature does."""
value = [{"question": "请用 {name} 占位,你的 options 有哪些?", "options": ["x", "y"], "multiple": False}]
assert _coerce_questions(value) == value
# --- ask_user tool: the stringified payload must now park, not ValidationError
@@ -217,6 +270,24 @@ async def test_ask_user_well_formed_list_unchanged():
assert tool_calls[0]["args"]["options"] == ["a", "b"]
async def test_ask_user_case_d_crammed_dict_value_parks_structured():
"""End-to-end for the reported live case (session b28d0dc6): a dict whose
`question` VALUE is a crammed array must park with the 3 real clarify questions —
one clickable tool_call each, options intact — instead of a single raw-JSON title."""
captured = MagicMock(return_value="ok")
questions = [{"question": _LIVE_CRAMMED_IN_DICT_VALUE, "options": [], "multiple": False}]
with patch.object(agent_factory, "interrupt", captured):
await ask_user.ainvoke({"reason": '请求"skill test"非常模糊,请先确认以下问题:', "questions": questions})
captured.assert_called_once()
tool_calls = captured.call_args.args[0]["params"]["tool_calls"]
assert len(tool_calls) == 3
assert all(tc["name"] == "clarify" for tc in tool_calls)
assert tool_calls[0]["args"]["question"] == '你想要的"skill test"是指什么?'
assert len(tool_calls[0]["args"]["options"]) == 4
assert tool_calls[2]["args"]["multiple"] is True
# --- ② _salvage_options_only pure-function behavior -------------------------