feat(fase2a): wire run_project(outbox_dir, run_id) → outbox-skriving, load-bearing (S2.1)

This commit is contained in:
Kjell Tore Guttormsen 2026-07-15 07:22:41 +02:00
commit a706184bdd
2 changed files with 137 additions and 3 deletions

View file

@ -10,14 +10,43 @@ both-arms (T-2.1a) checks below.
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from agent_framework import BaseChatClient
from conftest import SyntheticUsageChatClient
from portfolio_optimiser.ir import AffectedItem, SavingsProposal
from portfolio_optimiser.outbox import write_outbox
from portfolio_optimiser.provenance import Citation, ProvenanceStamp
from portfolio_optimiser.retrieval import TextSpan
from portfolio_optimiser.run import run_project
from portfolio_optimiser.validator import Rejection, ValidatedProposal
# --- Step 6 (run_project wiring) fixtures: the shared bundle + a role-aware scripted factory ---
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
# A VALIDATOR-VALID BYGG-KONTOR-NORD proposal (degenerate Monte Carlo P90 = 0.30 x 300000 = 90000
# >= claimed 30000 -> validates), so the only possible rejecter is the checker.
_VALID_PROPOSER_REPLY = (
'{"measure":"LED-retrofit av kontorbelysning","affected_items":'
'[{"code":"ENERGI-TOTAL-EL","quantity":300000,"unit_cost":1.0}],"claimed_saving_nok":30000}'
)
_VERDICT_INPUT = {"decision": "approved", "rationale": "expert reviewed (sim)"}
def _role_factory(proposer_reply: str, checker_reply: str) -> Callable[[str], BaseChatClient]:
"""A role-aware synthetic factory: the checker speaks its verdict, the proposer its proposal."""
def factory(role: str) -> BaseChatClient:
return SyntheticUsageChatClient(
default_reply=checker_reply if role == "checker" else proposer_reply
)
return factory
_PROPOSAL = SavingsProposal(
project_id="P1",
measure="LED-retrofit av kontorbelysning",
@ -110,3 +139,79 @@ def test_outbox_registered_maf_free() -> None:
from tests.test_okf import _MAF_FREE_MODULES
assert "outbox.py" in _MAF_FREE_MODULES
async def test_run_project_writes_outbox_validated_arm(tmp_path) -> None:
"""T-2.1a (via run_project, validated): a run with an ``outbox_dir`` + ``run_id`` writes both
artefacts; the outcome file records ``validated``. Detach the ``write_outbox`` call in
``run_project`` the files are absent RED."""
factory = _role_factory(_VALID_PROPOSER_REPLY, "VERDICT: APPROVE")
result = await run_project(
"BYGG-KONTOR-NORD",
"local",
docs_dir=str(BUNDLE_DIR),
bundle_dir=str(BUNDLE_DIR),
verdict_input=_VERDICT_INPUT,
client_factory=factory,
outbox_dir=str(tmp_path),
run_id="run-validated",
)
assert isinstance(result.outcome, ValidatedProposal)
proposal_file = tmp_path / "run-validated-proposal.json"
outcome_file = tmp_path / "run-validated-outcome.json"
assert proposal_file.is_file()
assert outcome_file.is_file()
assert '"outcome_type": "validated"' in outcome_file.read_text(encoding="utf-8")
async def test_run_project_writes_outbox_rejected_arm(tmp_path) -> None:
"""T-2.1a (via run_project, rejected): a checker ``VERDICT: REJECT`` flips an otherwise-validated
proposal to a Rejection, and the outbox outcome file records ``rejected``."""
factory = _role_factory(
_VALID_PROPOSER_REPLY, "VERDICT: REJECT - payback exceeds horizon (sim)"
)
result = await run_project(
"BYGG-KONTOR-NORD",
"local",
docs_dir=str(BUNDLE_DIR),
bundle_dir=str(BUNDLE_DIR),
verdict_input=_VERDICT_INPUT,
client_factory=factory,
outbox_dir=str(tmp_path),
run_id="run-rejected",
)
assert isinstance(result.outcome, Rejection)
outcome_file = tmp_path / "run-rejected-outcome.json"
assert '"outcome_type": "rejected"' in outcome_file.read_text(encoding="utf-8")
async def test_run_project_without_outbox_dir_writes_nothing(tmp_path) -> None:
"""T-2.1c (control): a run WITHOUT ``outbox_dir`` writes no artefacts — proving the writes above
are caused by the seam, not incidental."""
factory = _role_factory(_VALID_PROPOSER_REPLY, "VERDICT: APPROVE")
await run_project(
"BYGG-KONTOR-NORD",
"local",
docs_dir=str(BUNDLE_DIR),
bundle_dir=str(BUNDLE_DIR),
verdict_input=_VERDICT_INPUT,
client_factory=factory,
)
assert list(tmp_path.iterdir()) == []
async def test_run_project_outbox_dir_requires_run_id(tmp_path) -> None:
"""T-2.1e: an ``outbox_dir`` with ``run_id=None`` raises (no wall-clock/uuid default — the
artefacts are byte-deterministic and keyed on run_id)."""
factory = _role_factory(_VALID_PROPOSER_REPLY, "VERDICT: APPROVE")
with pytest.raises(ValueError):
await run_project(
"BYGG-KONTOR-NORD",
"local",
docs_dir=str(BUNDLE_DIR),
bundle_dir=str(BUNDLE_DIR),
verdict_input=_VERDICT_INPUT,
client_factory=factory,
outbox_dir=str(tmp_path),
run_id=None,
)