diff --git a/src/backend/bisheng/linsight/domain/services/agent_factory.py b/src/backend/bisheng/linsight/domain/services/agent_factory.py index 0a14ce38c..54df6ba3b 100644 --- a/src/backend/bisheng/linsight/domain/services/agent_factory.py +++ b/src/backend/bisheng/linsight/domain/services/agent_factory.py @@ -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)) diff --git a/src/backend/test/linsight/test_ask_user_coercion.py b/src/backend/test/linsight/test_ask_user_coercion.py index fd3d263ab..a646d8803 100644 --- a/src/backend/test/linsight/test_ask_user_coercion.py +++ b/src/backend/test/linsight/test_ask_user_coercion.py @@ -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 -------------------------