feat(#62): IMP-46 cache fingerprint forwarding u1~u4 (router kwarg + step12 forward + 8 scenarios)
This commit is contained in:
@@ -50,6 +50,7 @@ def route_ai_fallback(
|
|||||||
internal_region: dict[str, Any],
|
internal_region: dict[str, Any],
|
||||||
mdx_text: str,
|
mdx_text: str,
|
||||||
client: AiFallbackClient | None = None,
|
client: AiFallbackClient | None = None,
|
||||||
|
fingerprints: dict | None = None,
|
||||||
) -> AiFallbackProposal | None:
|
) -> AiFallbackProposal | None:
|
||||||
"""Route a fallback request through cache → prompt → client → validate.
|
"""Route a fallback request through cache → prompt → client → validate.
|
||||||
|
|
||||||
@@ -57,13 +58,18 @@ def route_ai_fallback(
|
|||||||
not ``ai_adaptation_required`` — both gates short-circuit BEFORE any
|
not ``ai_adaptation_required`` — both gates short-circuit BEFORE any
|
||||||
prompt/client work, so the normal-path AI call count stays at 0
|
prompt/client work, so the normal-path AI call count stays at 0
|
||||||
(PZ-1).
|
(PZ-1).
|
||||||
|
|
||||||
|
``fingerprints`` is forwarded into ``read_proposal`` so that
|
||||||
|
contract / partial / catalog SHA mismatches invalidate stale cache
|
||||||
|
entries (IMP-46 #62 Axis R). When ``None`` the cache layer skips
|
||||||
|
fingerprint comparison (legacy behaviour).
|
||||||
"""
|
"""
|
||||||
if not settings.ai_fallback_enabled:
|
if not settings.ai_fallback_enabled:
|
||||||
return None
|
return None
|
||||||
route = v4_result.get("route") or v4_result.get("imp05_route_hint")
|
route = v4_result.get("route") or v4_result.get("imp05_route_hint")
|
||||||
if route != V4_ROUTE_AI_ADAPTATION:
|
if route != V4_ROUTE_AI_ADAPTATION:
|
||||||
return None
|
return None
|
||||||
cached = read_proposal(cache_key)
|
cached = read_proposal(cache_key, fingerprints=fingerprints)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
validate_proposal(
|
validate_proposal(
|
||||||
cached,
|
cached,
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ def gather_step12_ai_repair_proposals(
|
|||||||
figma_partial_json=figma_partial_json,
|
figma_partial_json=figma_partial_json,
|
||||||
internal_region=internal_region,
|
internal_region=internal_region,
|
||||||
mdx_text=mdx_text,
|
mdx_text=mdx_text,
|
||||||
|
fingerprints=fingerprints,
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001 — record + continue, no AI re-raise
|
except Exception as exc: # noqa: BLE001 — record + continue, no AI re-raise
|
||||||
record["ai_called"] = True
|
record["ai_called"] = True
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ def test_router_returns_none_when_route_not_ai_adaptation(monkeypatch):
|
|||||||
def test_router_returns_cached_when_cache_hit(monkeypatch):
|
def test_router_returns_cached_when_cache_hit(monkeypatch):
|
||||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||||
cached = _make_proposal()
|
cached = _make_proposal()
|
||||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key: cached)
|
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: cached)
|
||||||
client = MagicMock(spec=AiFallbackClient)
|
client = MagicMock(spec=AiFallbackClient)
|
||||||
result = route_ai_fallback(**_call_kwargs(), client=client)
|
result = route_ai_fallback(**_call_kwargs(), client=client)
|
||||||
assert result is cached
|
assert result is cached
|
||||||
@@ -99,16 +99,97 @@ def test_router_validates_cached_proposal(monkeypatch):
|
|||||||
proposal_kind=ProposalKind.BUILDER_OPTIONS_PATCH,
|
proposal_kind=ProposalKind.BUILDER_OPTIONS_PATCH,
|
||||||
payload={"unknown_key": "x"},
|
payload={"unknown_key": "x"},
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key: bad_cached)
|
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: bad_cached)
|
||||||
client = MagicMock(spec=AiFallbackClient)
|
client = MagicMock(spec=AiFallbackClient)
|
||||||
with pytest.raises(AiFallbackValidationError):
|
with pytest.raises(AiFallbackValidationError):
|
||||||
route_ai_fallback(**_call_kwargs(), client=client)
|
route_ai_fallback(**_call_kwargs(), client=client)
|
||||||
client.request_proposal.assert_not_called()
|
client.request_proposal.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_forwards_fingerprints_and_misses_on_mismatch(monkeypatch):
|
||||||
|
"""Scope: router-level (IMP-46 #62 Axis R u2).
|
||||||
|
|
||||||
|
When caller supplies ``fingerprints`` and the cache layer returns
|
||||||
|
``None`` (simulating a strict-equality mismatch against the stored
|
||||||
|
contract/partial/catalog SHA), the router proceeds to call the
|
||||||
|
client. The exact ``fingerprints`` dict supplied by the caller must
|
||||||
|
appear in the kwargs passed to ``read_proposal``.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def _spy_read_proposal(key, *, fingerprints=None):
|
||||||
|
captured["key"] = key
|
||||||
|
captured["fingerprints"] = fingerprints
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(router_mod, "read_proposal", _spy_read_proposal)
|
||||||
|
proposal = _make_proposal()
|
||||||
|
client = MagicMock(spec=AiFallbackClient)
|
||||||
|
client.request_proposal.return_value = proposal
|
||||||
|
supplied = {"contract_sha": "aaa", "partial_sha": "bbb", "catalog_sha": "ccc"}
|
||||||
|
result = route_ai_fallback(
|
||||||
|
**_call_kwargs(), client=client, fingerprints=supplied
|
||||||
|
)
|
||||||
|
assert result is proposal
|
||||||
|
assert captured["fingerprints"] == supplied
|
||||||
|
client.request_proposal.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_forwards_fingerprints_and_hits_on_match(monkeypatch):
|
||||||
|
"""Scope: router-level (IMP-46 #62 Axis R u2).
|
||||||
|
|
||||||
|
When caller supplies ``fingerprints`` and the cache layer returns a
|
||||||
|
cached proposal (simulating strict-equality match against stored
|
||||||
|
SHA bundle), the router short-circuits without calling the client.
|
||||||
|
The forwarded kwarg must equal the caller-supplied dict exactly.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||||
|
cached = _make_proposal()
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def _spy_read_proposal(key, *, fingerprints=None):
|
||||||
|
captured["fingerprints"] = fingerprints
|
||||||
|
return cached
|
||||||
|
|
||||||
|
monkeypatch.setattr(router_mod, "read_proposal", _spy_read_proposal)
|
||||||
|
client = MagicMock(spec=AiFallbackClient)
|
||||||
|
supplied = {"contract_sha": "xxx", "partial_sha": "yyy", "catalog_sha": "zzz"}
|
||||||
|
result = route_ai_fallback(
|
||||||
|
**_call_kwargs(), client=client, fingerprints=supplied
|
||||||
|
)
|
||||||
|
assert result is cached
|
||||||
|
assert captured["fingerprints"] == supplied
|
||||||
|
client.request_proposal.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_forwards_fingerprints_none_for_legacy_callers(monkeypatch):
|
||||||
|
"""Scope: router-level (IMP-46 #62 Axis R u2).
|
||||||
|
|
||||||
|
Legacy callers that omit the ``fingerprints`` kwarg must result in
|
||||||
|
``read_proposal`` being invoked with ``fingerprints=None`` (cache
|
||||||
|
layer skips fingerprint comparison — legacy no-invalidation
|
||||||
|
behaviour preserved).
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||||
|
cached = _make_proposal()
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def _spy_read_proposal(key, *, fingerprints=None):
|
||||||
|
captured["fingerprints"] = fingerprints
|
||||||
|
return cached
|
||||||
|
|
||||||
|
monkeypatch.setattr(router_mod, "read_proposal", _spy_read_proposal)
|
||||||
|
client = MagicMock(spec=AiFallbackClient)
|
||||||
|
result = route_ai_fallback(**_call_kwargs(), client=client)
|
||||||
|
assert result is cached
|
||||||
|
assert captured["fingerprints"] is None
|
||||||
|
client.request_proposal.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_router_calls_client_and_returns_validated_proposal(monkeypatch):
|
def test_router_calls_client_and_returns_validated_proposal(monkeypatch):
|
||||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
|
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None)
|
||||||
proposal = _make_proposal()
|
proposal = _make_proposal()
|
||||||
client = MagicMock(spec=AiFallbackClient)
|
client = MagicMock(spec=AiFallbackClient)
|
||||||
client.request_proposal.return_value = proposal
|
client.request_proposal.return_value = proposal
|
||||||
@@ -121,7 +202,7 @@ def test_router_calls_client_and_returns_validated_proposal(monkeypatch):
|
|||||||
|
|
||||||
def test_router_propagates_validation_error(monkeypatch):
|
def test_router_propagates_validation_error(monkeypatch):
|
||||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
|
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None)
|
||||||
bad = AiFallbackProposal(
|
bad = AiFallbackProposal(
|
||||||
proposal_kind=ProposalKind.BUILDER_OPTIONS_PATCH,
|
proposal_kind=ProposalKind.BUILDER_OPTIONS_PATCH,
|
||||||
payload={"unknown_key": "x"},
|
payload={"unknown_key": "x"},
|
||||||
@@ -134,7 +215,7 @@ def test_router_propagates_validation_error(monkeypatch):
|
|||||||
|
|
||||||
def test_router_propagates_budget_exceeded(monkeypatch):
|
def test_router_propagates_budget_exceeded(monkeypatch):
|
||||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
|
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None)
|
||||||
client = MagicMock(spec=AiFallbackClient)
|
client = MagicMock(spec=AiFallbackClient)
|
||||||
client.request_proposal.side_effect = AiFallbackBudgetExceeded("over")
|
client.request_proposal.side_effect = AiFallbackBudgetExceeded("over")
|
||||||
with pytest.raises(AiFallbackBudgetExceeded):
|
with pytest.raises(AiFallbackBudgetExceeded):
|
||||||
@@ -143,7 +224,7 @@ def test_router_propagates_budget_exceeded(monkeypatch):
|
|||||||
|
|
||||||
def test_router_propagates_circuit_open(monkeypatch):
|
def test_router_propagates_circuit_open(monkeypatch):
|
||||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
|
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None)
|
||||||
client = MagicMock(spec=AiFallbackClient)
|
client = MagicMock(spec=AiFallbackClient)
|
||||||
client.request_proposal.side_effect = AiFallbackCircuitOpen("tripped")
|
client.request_proposal.side_effect = AiFallbackCircuitOpen("tripped")
|
||||||
with pytest.raises(AiFallbackCircuitOpen):
|
with pytest.raises(AiFallbackCircuitOpen):
|
||||||
|
|||||||
@@ -500,3 +500,105 @@ def test_production_non_provisional_reject_skipped_before_route_gate(monkeypatch
|
|||||||
assert records[0]["skip_reason"] == "not_provisional"
|
assert records[0]["skip_reason"] == "not_provisional"
|
||||||
assert records[0]["ai_called"] is False
|
assert records[0]["ai_called"] is False
|
||||||
router.assert_not_called()
|
router.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# IMP-46 u4 — Step 12 ↔ router fingerprints forwarding (integration scope)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Locks the producer→consumer wiring added in u3: Step 12 builds the
|
||||||
|
# fingerprints dict at step12.py:179-185, stamps it onto record["fingerprints"]
|
||||||
|
# at step12.py:185, and now (post-u3) forwards the SAME object into
|
||||||
|
# route_ai_fallback via the fingerprints= kwarg at step12.py:203. These tests
|
||||||
|
# assert end-to-end forwarding (router receives exactly record["fingerprints"]
|
||||||
|
# for AI-eligible units; router untouched and record["fingerprints"] is None
|
||||||
|
# for skipped / non-AI records).
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_receives_exactly_record_fingerprints_for_ai_eligible(monkeypatch):
|
||||||
|
"""Integration scope: route_ai_fallback fingerprints kwarg == record['fingerprints']."""
|
||||||
|
router = MagicMock(return_value=None)
|
||||||
|
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||||
|
contract = {"frame_id": "fid_123", "payload": {"k": "v"}, "sub_zones": []}
|
||||||
|
partial = {"deeper": [9, 8, 7], "shallow": "x"}
|
||||||
|
catalog_value = "c0ffee00" * 8
|
||||||
|
recs = _call(
|
||||||
|
[_ai_unit()],
|
||||||
|
get_contract_fn=lambda _t: contract,
|
||||||
|
figma_partial_loader=lambda _t: partial,
|
||||||
|
catalog_sha_loader=lambda: catalog_value,
|
||||||
|
)
|
||||||
|
record_fingerprints = recs[0]["fingerprints"]
|
||||||
|
router.assert_called_once()
|
||||||
|
forwarded = router.call_args.kwargs["fingerprints"]
|
||||||
|
# Strict equality: same keys, same SHA values — Step 12 producer is wired
|
||||||
|
# to the router consumer with no transformation between them.
|
||||||
|
assert forwarded == record_fingerprints
|
||||||
|
assert forwarded == {
|
||||||
|
"contract_sha": hashlib.sha256(
|
||||||
|
json.dumps(contract, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
||||||
|
).hexdigest(),
|
||||||
|
"partial_sha": hashlib.sha256(
|
||||||
|
json.dumps(partial, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
||||||
|
).hexdigest(),
|
||||||
|
"catalog_sha": catalog_value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_fingerprints_kwarg_is_present_even_with_default_catalog(monkeypatch):
|
||||||
|
"""Integration scope: fingerprints kwarg is supplied (not omitted) even when catalog_sha defaults to ''."""
|
||||||
|
router = MagicMock(return_value=None)
|
||||||
|
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||||
|
_call([_ai_unit()])
|
||||||
|
router.assert_called_once()
|
||||||
|
# The fingerprints kwarg must be present in the call (not relying on the
|
||||||
|
# router's default None) — proves step12.py:203 forwards explicitly.
|
||||||
|
assert "fingerprints" in router.call_args.kwargs
|
||||||
|
forwarded = router.call_args.kwargs["fingerprints"]
|
||||||
|
assert isinstance(forwarded, dict)
|
||||||
|
assert set(forwarded.keys()) == {"contract_sha", "partial_sha", "catalog_sha"}
|
||||||
|
assert forwarded["catalog_sha"] == "" # default sentinel, still forwarded
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_not_called_and_fingerprints_none_for_non_provisional(monkeypatch):
|
||||||
|
"""Integration scope: non-provisional unit → router untouched, record['fingerprints'] is None."""
|
||||||
|
router = MagicMock(return_value=None)
|
||||||
|
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||||
|
recs = _call([FakeUnit(label="restructure", provisional=False)])
|
||||||
|
router.assert_not_called()
|
||||||
|
assert recs[0]["ai_called"] is False
|
||||||
|
assert recs[0]["skip_reason"] == "not_provisional"
|
||||||
|
assert recs[0]["fingerprints"] is None
|
||||||
|
assert recs[0]["cache_key"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_not_called_and_fingerprints_none_for_non_ai_route(monkeypatch):
|
||||||
|
"""Integration scope: light_edit (non-AI route) → router untouched, record['fingerprints'] is None."""
|
||||||
|
router = MagicMock(return_value=None)
|
||||||
|
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||||
|
recs = _call([FakeUnit(label="light_edit", provisional=True)])
|
||||||
|
router.assert_not_called()
|
||||||
|
assert recs[0]["ai_called"] is False
|
||||||
|
assert recs[0]["skip_reason"] == (
|
||||||
|
"route_not_ai_adaptation:deterministic_minor_adjustment"
|
||||||
|
)
|
||||||
|
assert recs[0]["fingerprints"] is None
|
||||||
|
assert recs[0]["cache_key"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_units_router_receives_fingerprints_only_for_ai_eligible(monkeypatch):
|
||||||
|
"""Integration scope: in a mixed batch, only the AI-eligible unit forwards fingerprints to router."""
|
||||||
|
router = MagicMock(return_value=None)
|
||||||
|
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||||
|
units = [
|
||||||
|
FakeUnit(label="restructure", provisional=False), # not_provisional
|
||||||
|
FakeUnit(label="light_edit", provisional=True), # non-AI route
|
||||||
|
_ai_unit(), # AI-eligible
|
||||||
|
]
|
||||||
|
recs = _call(units)
|
||||||
|
# Exactly one router invocation — the AI-eligible unit.
|
||||||
|
router.assert_called_once()
|
||||||
|
forwarded = router.call_args.kwargs["fingerprints"]
|
||||||
|
assert forwarded == recs[2]["fingerprints"]
|
||||||
|
# Skipped records carry None.
|
||||||
|
assert recs[0]["fingerprints"] is None
|
||||||
|
assert recs[1]["fingerprints"] is None
|
||||||
|
|||||||
Reference in New Issue
Block a user