diff --git a/hindsight-api-slim/hindsight_api/engine/retain/fact_extraction.py b/hindsight-api-slim/hindsight_api/engine/retain/fact_extraction.py index 9d4aae789..2fbd972e9 100644 --- a/hindsight-api-slim/hindsight_api/engine/retain/fact_extraction.py +++ b/hindsight-api-slim/hindsight_api/engine/retain/fact_extraction.py @@ -1314,6 +1314,8 @@ def _build_request_body(llm_config, config, prompt: str, user_message: str, resp def _coerce_fact_response(response: Any) -> dict[str, Any] | None: """Accept the schema wrapper, or a recoverable top-level facts array.""" if isinstance(response, dict): + if "parameter" in response and isinstance(response["parameter"], dict): + return response["parameter"] return response if isinstance(response, list) and all(isinstance(item, dict) for item in response): return {"facts": response} diff --git a/hindsight-api-slim/tests/test_coerce_fact_response_parameter_wrapper.py b/hindsight-api-slim/tests/test_coerce_fact_response_parameter_wrapper.py new file mode 100644 index 000000000..b25a98c7d --- /dev/null +++ b/hindsight-api-slim/tests/test_coerce_fact_response_parameter_wrapper.py @@ -0,0 +1,66 @@ +""" +Test that _coerce_fact_response unwraps the ``{"parameter": {...}}`` envelope +that Vertex AI Claude returns for tool-use structured output via litellm. + +Without this fix, retains via ``litellm`` + ``vertex_ai/claude-*`` silently +produce 0 facts because the extracted facts sit inside ``response["parameter"]`` +and ``response.get("facts", [])`` returns an empty list. +""" + +from hindsight_api.engine.retain.fact_extraction import _coerce_fact_response + + +def test_unwraps_parameter_envelope(): + """Vertex AI Claude wraps tool output in {"parameter": {"facts": [...]}}.""" + response = { + "parameter": { + "facts": [ + {"what": "version is 1.0", "who": "user", "fact_type": "world"}, + ] + } + } + result = _coerce_fact_response(response) + assert "facts" in result + assert len(result["facts"]) == 1 + assert result["facts"][0]["what"] == "version is 1.0" + + +def test_passthrough_normal_response(): + """Normal {"facts": [...]} responses pass through unchanged.""" + response = {"facts": [{"what": "something", "fact_type": "world"}]} + result = _coerce_fact_response(response) + assert result is response + + +def test_passthrough_empty_facts(): + """Empty facts list passes through.""" + response = {"facts": []} + result = _coerce_fact_response(response) + assert result is response + + +def test_coerces_bare_list(): + """A bare list of fact dicts is wrapped into {"facts": [...]}.""" + response = [{"what": "a"}, {"what": "b"}] + result = _coerce_fact_response(response) + assert result == {"facts": [{"what": "a"}, {"what": "b"}]} + + +def test_rejects_non_dict_non_list(): + """Non-dict, non-list values return None.""" + assert _coerce_fact_response("bad") is None + assert _coerce_fact_response(42) is None + assert _coerce_fact_response(None) is None + + +def test_parameter_with_nested_structure(): + """Parameter envelope with additional metadata is still unwrapped.""" + response = { + "parameter": { + "facts": [{"what": "a"}], + "metadata": {"extra": True}, + } + } + result = _coerce_fact_response(response) + assert len(result["facts"]) == 1 + assert result.get("metadata") == {"extra": True} diff --git a/skills/hindsight-docs/references/openapi.json b/skills/hindsight-docs/references/openapi.json index e8e14cb5a..ffad05bfa 100644 --- a/skills/hindsight-docs/references/openapi.json +++ b/skills/hindsight-docs/references/openapi.json @@ -10,7 +10,7 @@ "name": "Apache 2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0.html" }, - "version": "0.8.4" + "version": "0.8.5" }, "paths": { "/health": {