From bfdaf86bf889f7510e153c1822d747672522541f Mon Sep 17 00:00:00 2001 From: administrator Date: Wed, 12 Aug 2026 18:55:17 +0800 Subject: [PATCH 1/2] fix(explorer): coerce decision timestamp to str to prevent 422 on /api/decisions ContextGraph stores decision timestamps as POSIX floats (e.g. 1786513069.69), but DecisionResponse.timestamp is typed Optional[str]. Pydantic strict validation rejects the float and the whole /api/decisions endpoint returns HTTP 422 "Invalid input", which breaks the Decisions workspace in the Knowledge Explorer entirely (no decision can be listed). Coerce the value to str (preserving None) in _node_to_decision so the response validates. Verified: /api/decisions now returns 200 and the 3 sample decisions render in the Decisions workspace. --- semantica/explorer/routes/decisions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/semantica/explorer/routes/decisions.py b/semantica/explorer/routes/decisions.py index e9df4493..9d84ca5f 100644 --- a/semantica/explorer/routes/decisions.py +++ b/semantica/explorer/routes/decisions.py @@ -23,7 +23,7 @@ def _node_to_decision(node: dict) -> DecisionResponse: reasoning=properties.get("reasoning", ""), outcome=properties.get("outcome", ""), confidence=float(properties.get("confidence", 0.0) or 0.0), - timestamp=properties.get("timestamp"), + timestamp=None if properties.get("timestamp") is None else str(properties.get("timestamp")), metadata=properties, ) From b0b67bae71571df8f03f9c7e8e5667681a4bff54 Mon Sep 17 00:00:00 2001 From: administrator Date: Wed, 12 Aug 2026 22:24:41 +0800 Subject: [PATCH 2/2] test(explorer): cover decision timestamp coercion in _node_to_decision Regression tests for the 422 fix in _node_to_decision. Covers the cases that produced HTTP 422 (float / int timestamps from ContextGraph) and the ones that must keep working (None, already-string, missing key). Verified the suite catches the regression: with the fix reverted, the float / int / nan / inf cases fail with the same ValidationError that caused the 422; with the fix applied all 6 pass. --- .../explorer/test_decision_route_timestamp.py | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 tests/explorer/test_decision_route_timestamp.py diff --git a/tests/explorer/test_decision_route_timestamp.py b/tests/explorer/test_decision_route_timestamp.py new file mode 100644 index 00000000..2ff774d0 --- /dev/null +++ b/tests/explorer/test_decision_route_timestamp.py @@ -0,0 +1,92 @@ +"""Unit tests for the explorer decision route adapter. + +Regression coverage for the ``/api/decisions`` 422 bug: a decision node +whose ``timestamp`` is stored as a POSIX float (the format +``ContextGraph.record_decision`` writes) must not break Pydantic +validation of ``DecisionResponse`` (typed ``Optional[str]``). +""" + +import math + +from semantica.explorer.routes.decisions import _node_to_decision +from semantica.explorer.schemas import DecisionResponse + + +def _decision_node(timestamp): + """Build a minimal decision node dict shaped like ContextGraph.to_dict().""" + return { + "id": "d-1", + "type": "decision", + "properties": { + "category": "loan_underwriting", + "scenario": "A-7291 review", + "reasoning": "DTI within policy", + "outcome": "approved", + "confidence": 0.94, + "timestamp": timestamp, + }, + } + + +def test_timestamp_float_is_coerced_to_str(): + """A POSIX-float timestamp (what record_decision stores) must validate. + + Before the fix this raised a ValidationError (422 on the endpoint). + """ + node = _decision_node(timestamp=1786513069.694965) + + decision = _node_to_decision(node) + + assert isinstance(decision, DecisionResponse) + assert decision.timestamp == "1786513069.694965" + # round-trips through Pydantic strict str validation + assert decision.confidence == 0.94 + assert decision.outcome == "approved" + + +def test_timestamp_int_is_coerced_to_str(): + """Integer timestamps (some stores serialize without sub-second precision) + are handled by the same coercion path.""" + decision = _node_to_decision(_decision_node(timestamp=1786513069)) + + assert decision.timestamp == "1786513069" + + +def test_timestamp_none_is_preserved(): + """A missing timestamp must stay None, not become the string 'None'.""" + decision = _node_to_decision(_decision_node(timestamp=None)) + + assert decision.timestamp is None + + +def test_timestamp_str_passes_through(): + """An already-string timestamp is left intact.""" + decision = _node_to_decision(_decision_node(timestamp="2026-08-12T10:04:20")) + + assert decision.timestamp == "2026-08-12T10:04:20" + + +def test_timestamp_missing_key_defaults_to_none(): + """A decision node without a timestamp key at all should not raise.""" + node = { + "id": "d-2", + "type": "decision", + "properties": {"category": "x", "outcome": "y"}, + } + + decision = _node_to_decision(node) + + assert decision.timestamp is None + + +def test_float_timestamp_not_nan_or_inf(): + """Sanity guard: degenerate float values still coerce to a finite string + rather than crashing validation.""" + for value in (float("nan"), float("inf")): + node = _decision_node(timestamp=value) + decision = _node_to_decision(node) + assert isinstance(decision.timestamp, str) + if math.isnan(value): + assert "nan" in decision.timestamp.lower() + else: + assert "inf" in decision.timestamp.lower()