feat(s42): --live-dry-run CLI flag + dry-run summary

This commit is contained in:
Kjell Tore Guttormsen 2026-07-15 18:08:28 +02:00
commit d7313593bc
2 changed files with 97 additions and 2 deletions

View file

@ -610,6 +610,7 @@ def main(argv: list[str] | None = None) -> int:
"""Single-command console entry: run the slice for one project against a docs folder."""
import argparse
import asyncio
import sys
parser = argparse.ArgumentParser(description="portfolio-optimiser vertical slice")
parser.add_argument("project_id")
@ -626,10 +627,47 @@ def main(argv: list[str] | None = None) -> int:
)
parser.add_argument("--decision", default="approved", choices=["approved", "rejected"])
parser.add_argument("--rationale", default="reviewed by expert")
parser.add_argument(
"--live-dry-run",
action="store_true",
help="offline drill: build contracts/clients/budget, STOP before the first model call",
)
args = parser.parse_args(argv)
# S4.2: main() currently drives only full runs (no --live-dry-run flag yet — that is Step 3,
# which replaces this cast with an isinstance(DryRunReport) branch + AZURE-refusal try/except).
if args.live_dry_run:
# S4.2 drill (comparison protocol §4 pkt 2/3): walk the offline path, STOP before the first
# model call, print the run-config. A misconfigured profile (e.g. AZURE with a
# REPLACE-WITH-* placeholder) makes resolve_model raise inside the eager factory build —
# refuse cleanly (mirror preflight.main) instead of tracebacking; S4.1 is the config gate.
try:
report = asyncio.run(
run_project(
args.project_id,
args.profile,
docs_dir=args.docs_dir,
bundle_dir=args.bundle_dir,
verdict_dir=args.verdict_dir,
verdict_input={"decision": args.decision, "rationale": args.rationale},
live_dry_run=True,
)
)
except ValueError as exc:
print(
f"live-dry-run refused: {exc}\n"
"kjør 'python -m portfolio_optimiser.preflight --profile azure' først "
"(S4.1 offline config-gate)",
file=sys.stderr,
)
return 1
assert isinstance(report, DryRunReport) # live_dry_run=True always returns a DryRunReport
print(
f"{args.project_id}: LIVE-DRY-RUN OK (profile={report.profile}, "
f"models={report.resolved_models}, max_rounds={report.max_rounds}, "
f"max_tokens={report.max_tokens}, top_k={report.top_k}) — "
"ingen modellkall gjort (stoppet før første debate.run)"
)
return 0
result = cast(
RunResult,
asyncio.run(

View file

@ -0,0 +1,57 @@
"""S4.2 CLI surface for ``python -m portfolio_optimiser.run <proj> --live-dry-run``.
The success arm proves the CLI wiring + rc + summary (the zero-chat-call guarantee itself is proven
at ``run_project`` level in ``test_live_dry_run_loadbearing.py`` ``main()`` exposes no
``client_factory`` seam). The refusal arm proves a misconfigured profile (the AZURE placeholder map)
yields a STRUCTURED refusal + ``rc 1`` pointing at preflight, never a raw traceback.
"""
from __future__ import annotations
from pathlib import Path
from portfolio_optimiser import run
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
def test_cli_live_dry_run_ok(capsys) -> None:
"""Success arm: LOCAL profile (offline client construction) + ``--live-dry-run`` → rc 0 and a
summary naming the profile + a no-model-call note. No socket (stops before ``debate.run``)."""
rc = run.main(
[
"BYGG-KONTOR-NORD",
"--docs-dir",
str(BUNDLE_DIR),
"--bundle-dir",
str(BUNDLE_DIR),
"--live-dry-run",
]
)
assert rc == 0
out = capsys.readouterr().out
assert "LIVE-DRY-RUN OK" in out
assert "local" in out
assert "ingen modellkall" in out.lower()
def test_cli_live_dry_run_azure_placeholder_refuses(capsys) -> None:
"""Refusal arm: AZURE profile with the default bundled placeholder map → ``resolve_model`` raises
inside the eager factory build; ``main()`` catches it and prints a structured refusal to stderr
+ returns rc 1 (pointing at preflight), never a traceback. Offline (pure map read)."""
rc = run.main(
[
"BYGG-KONTOR-NORD",
"--docs-dir",
str(BUNDLE_DIR),
"--bundle-dir",
str(BUNDLE_DIR),
"--profile",
"azure",
"--live-dry-run",
]
)
assert rc == 1
err = capsys.readouterr().err
assert "refused" in err.lower()
assert "preflight" in err.lower()