feat(fase2a): wire run_project(outbox_dir, run_id) → outbox-skriving, load-bearing (S2.1)
This commit is contained in:
parent
79c6e44f92
commit
a706184bdd
2 changed files with 137 additions and 3 deletions
|
|
@ -48,7 +48,7 @@ from portfolio_optimiser.ir import SavingsProposal
|
||||||
from portfolio_optimiser.provenance import ProvenanceStamp
|
from portfolio_optimiser.provenance import ProvenanceStamp
|
||||||
from portfolio_optimiser.reference_domain import Project, load_reference_projects
|
from portfolio_optimiser.reference_domain import Project, load_reference_projects
|
||||||
from portfolio_optimiser.validator import Rejection, ValidatedProposal
|
from portfolio_optimiser.validator import Rejection, ValidatedProposal
|
||||||
from portfolio_optimiser import okf
|
from portfolio_optimiser import okf, outbox
|
||||||
from portfolio_optimiser.verdicts import (
|
from portfolio_optimiser.verdicts import (
|
||||||
ExpeLContextProvider,
|
ExpeLContextProvider,
|
||||||
ProposalFeatures,
|
ProposalFeatures,
|
||||||
|
|
@ -221,6 +221,8 @@ async def run_project(
|
||||||
dimension: Dimension | None = None,
|
dimension: Dimension | None = None,
|
||||||
store: VerdictStore | None = None,
|
store: VerdictStore | None = None,
|
||||||
verdict_dir: str | None = None,
|
verdict_dir: str | None = None,
|
||||||
|
outbox_dir: str | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
client_factory: Callable[[str], BaseChatClient] | None = None,
|
client_factory: Callable[[str], BaseChatClient] | None = None,
|
||||||
max_rounds: int = 3,
|
max_rounds: int = 3,
|
||||||
max_tokens: int = 100_000,
|
max_tokens: int = 100_000,
|
||||||
|
|
@ -238,8 +240,21 @@ async def run_project(
|
||||||
files (plain JSON, R2 raw layer) MERGED into the store BEFORE the Step-1 fold, so a verdict
|
files (plain JSON, R2 raw layer) MERGED into the store BEFORE the Step-1 fold, so a verdict
|
||||||
dropped after an earlier run is consumed by this separate, later run — the long feedback loop,
|
dropped after an earlier run is consumed by this separate, later run — the long feedback loop,
|
||||||
fully resumable across runs separated in time. The system READS this folder; it does not write
|
fully resumable across runs separated in time. The system READS this folder; it does not write
|
||||||
to it (the expert/persona writes, målbilde §3). Raises ``pydantic.ValidationError`` on a bad
|
to it (the expert/persona writes, målbilde §3). ``outbox_dir`` (Fase 2a, Steg 7 output layer,
|
||||||
contract and ``BudgetExceeded`` when the token/round cap is crossed."""
|
målbilde §3, R2) is the RAW OUTBOX: when set, the run's proposal + outcome artefacts are written
|
||||||
|
there via ``outbox.write_outbox`` (``run_id`` is then REQUIRED — no wall-clock/uuid default, for
|
||||||
|
byte-determinism). The outbox dir should be DISTINCT from any ``verdict_dir`` inbox: writing the
|
||||||
|
outbox into a folder later read as an inbox would re-ingest raw agent output and bypass the
|
||||||
|
Step-8 promotion gate (self-contamination) — documented here, not enforced. Raises
|
||||||
|
``pydantic.ValidationError`` on a bad contract and ``BudgetExceeded`` when the token/round cap is
|
||||||
|
crossed, and ``ValueError`` when ``outbox_dir`` is set without a ``run_id``."""
|
||||||
|
# 0. Fail-fast: an outbox write is byte-deterministic and keyed on run_id — no wall-clock default.
|
||||||
|
if outbox_dir is not None and run_id is None:
|
||||||
|
raise ValueError(
|
||||||
|
"run_id is required when outbox_dir is set (no wall-clock/uuid default — the outbox "
|
||||||
|
"artefacts are byte-deterministic and keyed on run_id)"
|
||||||
|
)
|
||||||
|
|
||||||
# 1. Fail-fast: validate ALL contracts (incl. the verdict-feedback shape) before any client.
|
# 1. Fail-fast: validate ALL contracts (incl. the verdict-feedback shape) before any client.
|
||||||
load_contracts(
|
load_contracts(
|
||||||
{"docs_dir": docs_dir, "top_k": top_k},
|
{"docs_dir": docs_dir, "top_k": top_k},
|
||||||
|
|
@ -382,6 +397,20 @@ async def run_project(
|
||||||
if notify is not None:
|
if notify is not None:
|
||||||
notify(verdict)
|
notify(verdict)
|
||||||
|
|
||||||
|
# S2.1 outbox (RAW output layer, målbilde §3): persist the run's proposal + outcome artefacts
|
||||||
|
# when configured. Wired ONLY here — no new consumer (S5.1/S5.2 are Non-Goals this bolk). run_id
|
||||||
|
# is guaranteed non-None by the fail-fast guard at the top.
|
||||||
|
if outbox_dir is not None:
|
||||||
|
assert run_id is not None # narrowed by the step-0 guard; keeps the type checker honest
|
||||||
|
outbox.write_outbox(
|
||||||
|
outbox_dir,
|
||||||
|
run_id,
|
||||||
|
outcome=outcome,
|
||||||
|
provenance=stamp,
|
||||||
|
checker_verdict=checker_decision,
|
||||||
|
verdict_id=verdict.id,
|
||||||
|
)
|
||||||
|
|
||||||
return RunResult(
|
return RunResult(
|
||||||
outcome=outcome,
|
outcome=outcome,
|
||||||
provenance=stamp,
|
provenance=stamp,
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,43 @@ both-arms (T-2.1a) checks below.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
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.ir import AffectedItem, SavingsProposal
|
||||||
from portfolio_optimiser.outbox import write_outbox
|
from portfolio_optimiser.outbox import write_outbox
|
||||||
from portfolio_optimiser.provenance import Citation, ProvenanceStamp
|
from portfolio_optimiser.provenance import Citation, ProvenanceStamp
|
||||||
from portfolio_optimiser.retrieval import TextSpan
|
from portfolio_optimiser.retrieval import TextSpan
|
||||||
|
from portfolio_optimiser.run import run_project
|
||||||
from portfolio_optimiser.validator import Rejection, ValidatedProposal
|
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(
|
_PROPOSAL = SavingsProposal(
|
||||||
project_id="P1",
|
project_id="P1",
|
||||||
measure="LED-retrofit av kontorbelysning",
|
measure="LED-retrofit av kontorbelysning",
|
||||||
|
|
@ -110,3 +139,79 @@ def test_outbox_registered_maf_free() -> None:
|
||||||
from tests.test_okf import _MAF_FREE_MODULES
|
from tests.test_okf import _MAF_FREE_MODULES
|
||||||
|
|
||||||
assert "outbox.py" in _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,
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue