mirror of
https://github.com/dataelement/bisheng.git
synced 2026-09-21 12:43:36 +08:00
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:
co-authored by
Claude Opus 4.8
parent
6bfedd60f4
commit
58325411d1
@@ -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 -------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user