diff --git a/app/service/memory_extraction_service.py b/app/service/memory_extraction_service.py index 0286f3d..31a9fef 100644 --- a/app/service/memory_extraction_service.py +++ b/app/service/memory_extraction_service.py @@ -163,8 +163,20 @@ class MemoryExtractionService: candidate = candidate.removeprefix("```") candidate = candidate.removeprefix("json").removesuffix("```").strip() try: - return _ExtractionPayload.model_validate(json.loads(candidate)) - except (json.JSONDecodeError, ValidationError, TypeError) as exc: + data = json.loads(candidate) + except (json.JSONDecodeError, TypeError) as exc: + raise RecoverableAgentError("模型记忆抽取输出不是有效 JSON") from exc + # 模型有时把结果包在**单元素数组**里,而契约是**对象**。实测出现于 episode 抽取路径: + # [{"memory_key": null, "value": null, "memory_type": null, "confidence": 0}] + # 该形状此前直接进 pydantic 校验、报 `Input should be a valid dictionary`, + # 被记成"输出不是有效 JSON"并反复重试直到 episode 判失败 —— 而它其实是**合法的空结果** + # (`_validate` 已能把"三字段为 null 且 confidence=0"识别为"无持久事实")。 + # 只归一化"恰好一个对象的数组";多元素或元素非对象时**不猜**,仍交给校验失败关闭。 + if isinstance(data, list) and len(data) == 1 and isinstance(data[0], dict): + data = data[0] + try: + return _ExtractionPayload.model_validate(data) + except (ValidationError, TypeError) as exc: raise RecoverableAgentError("模型记忆抽取输出不是有效 JSON") from exc def _validate( diff --git a/tests/unit/service/test_memory_extraction_service.py b/tests/unit/service/test_memory_extraction_service.py index c06a57a..ac93368 100644 --- a/tests/unit/service/test_memory_extraction_service.py +++ b/tests/unit/service/test_memory_extraction_service.py @@ -161,6 +161,39 @@ async def test_declared_empty_extraction_returns_none() -> None: await extract_with(json.dumps({**payload, "confidence": 0.5})) +@pytest.mark.asyncio +async def test_single_element_array_is_normalized() -> None: + """模型把结果包在**单元素数组**里时归一化为对象,而不是当成"无效 JSON"。 + + 背景(2026-09-12 实测):契约是对象,但模型在 episode 抽取路径会返回 + `[{"memory_key": null, "value": null, "memory_type": null, "confidence": 0}]`。 + 该形状此前直接进 pydantic 校验,报 + `Input should be a valid dictionary ... input_type=list`,被记成"输出不是有效 JSON", + 于是反复重试到 episode 判失败 —— 而它其实是**合法的空结果**。 + """ + empty = {"memory_key": None, "value": None, "memory_type": None, "confidence": 0} + # 数组包着的空结果 → 归一化后按"无持久事实"处理,返回 None(不是失败)。 + assert await extract_with(json.dumps([empty])) is None + # 数组包着的**有事实**结果 → 同样归一化,照常解析出来。 + extracted = await extract_with(json.dumps([VALID_PAYLOAD])) + assert extracted is not None + assert extracted.memory_key == VALID_PAYLOAD["memory_key"] + + +@pytest.mark.asyncio +async def test_multi_element_or_non_dict_array_still_fails_closed() -> None: + """只归一化"恰好一个对象的数组";其余形状**不猜**,仍失败关闭。 + + 多元素时无法判断哪一个是答案,猜错会把错误记忆写进库 —— 比失败更糟。 + """ + with pytest.raises(RecoverableAgentError): + await extract_with(json.dumps([VALID_PAYLOAD, VALID_PAYLOAD])) + with pytest.raises(RecoverableAgentError): + await extract_with(json.dumps(["not-a-dict"])) + with pytest.raises(RecoverableAgentError): + await extract_with(json.dumps([])) + + @pytest.mark.asyncio async def test_missing_endpoint_fails_closed_without_calling_model() -> None: service, model = build_service(json.dumps(VALID_PAYLOAD), resolver=StubResolver([]))