diff --git a/hindsight-api-slim/hindsight_api/engine/consolidation/consolidator.py b/hindsight-api-slim/hindsight_api/engine/consolidation/consolidator.py index c525253fe..4f331149e 100644 --- a/hindsight-api-slim/hindsight_api/engine/consolidation/consolidator.py +++ b/hindsight-api-slim/hindsight_api/engine/consolidation/consolidator.py @@ -102,6 +102,15 @@ class _DedupDecision(BaseModel): text: str = "" # the synthesized merged observation (when action == "merge") reason: str = "" + @field_validator("action", mode="before") + @classmethod + def _normalize_action(cls, value: object) -> str: + if isinstance(value, str) and value in {"merge", "keep"}: + return value + + logger.warning("Invalid consolidation dedup action %r; defaulting to keep", value) + return "keep" + _DEDUP_PROMPT = """You reconcile long-term memory observations. A NEW observation is about to be \ stored, and it is highly similar to an EXISTING one: diff --git a/hindsight-api-slim/tests/test_consolidation_dedup.py b/hindsight-api-slim/tests/test_consolidation_dedup.py index aa8417e3a..25121c886 100644 --- a/hindsight-api-slim/tests/test_consolidation_dedup.py +++ b/hindsight-api-slim/tests/test_consolidation_dedup.py @@ -5,6 +5,7 @@ the path stochastically. """ +import logging import types import uuid from dataclasses import dataclass @@ -142,6 +143,38 @@ async def test_dedup_llm_missing_action_defaults_to_keep() -> None: conn.execute.assert_not_called() # missing action is a conservative no-merge +def test_dedup_decision_accepts_exact_valid_actions() -> None: + assert _DedupDecision(action="merge").action == "merge" + assert _DedupDecision(action="keep").action == "keep" + + +def test_dedup_decision_invalid_action_defaults_to_keep(caplog) -> None: + with caplog.at_level(logging.WARNING): + decision = _DedupDecision(action="need_input", reason="model asked for more context") + + assert decision.action == "keep" + assert "need_input" in caplog.text + assert "defaulting to keep" in caplog.text + + +def test_dedup_decision_near_miss_merge_defaults_to_keep(caplog) -> None: + with caplog.at_level(logging.WARNING): + decision = _DedupDecision(action="Merge") + + assert decision.action == "keep" + assert "Merge" in caplog.text + + +def test_dedup_decision_non_scalar_action_defaults_to_keep(caplog) -> None: + with caplog.at_level(logging.WARNING): + list_decision = _DedupDecision(action=[]) + dict_decision = _DedupDecision(action={"value": "merge"}) + + assert list_decision.action == "keep" + assert dict_decision.action == "keep" + assert "defaulting to keep" in caplog.text + + async def test_dedup_llm_merge_folds_into_twin() -> None: kwargs, conn, llm = _ctx() kwargs["create_source_ids"] = [uuid.uuid4(), uuid.uuid4()]