feat(s53): --dimension-config/--outbox-dir/--run-id CLI flags + full-run structured refusal
main() single-project path now parses --dimension-config (fail-fast via load_dimension),
--outbox-dir, and --run-id (deliberate 7th companion flag: determinism invariant forbids a
wall-clock run_id default). All three threaded into BOTH run_project call sites. Full-run
branch wrapped in a structured-refusal (catch ValueError/FileNotFoundError/ValidationError ->
'run refused: {exc}' on stderr, rc 1, no traceback); the EXISTING dry-run handler widened to
the same tuple (pydantic ValidationError is not a ValueError subclass; load_dimension's
FileNotFoundError would otherwise traceback — Pass-2 #1). --outbox-dir carries a loud help=
note it must differ from --verdict-dir (self-contamination footgun; documented, not enforced).
RED-first: 4 CLI tests failed on unrecognized args, green after. 7 passed (incl. live-dry-run
regression). ruff + mypy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNNiJRk1sSwxgVLS5AobT1
This commit is contained in:
parent
124b7aedde
commit
663639d376
2 changed files with 173 additions and 15 deletions
|
|
@ -31,6 +31,7 @@ from decimal import ROUND_HALF_UP, Decimal
|
||||||
from typing import Any, Literal, cast
|
from typing import Any, Literal, cast
|
||||||
|
|
||||||
from agent_framework import BaseChatClient, SessionContext
|
from agent_framework import BaseChatClient, SessionContext
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from portfolio_optimiser.backends import Profile, get_backend, resolve_model
|
from portfolio_optimiser.backends import Profile, get_backend, resolve_model
|
||||||
from portfolio_optimiser.budget import Budget, BudgetMiddleware, TokenMeter
|
from portfolio_optimiser.budget import Budget, BudgetMiddleware, TokenMeter
|
||||||
|
|
@ -42,7 +43,7 @@ from portfolio_optimiser.datasource import (
|
||||||
make_retrieval_tool,
|
make_retrieval_tool,
|
||||||
retrieve_chunks,
|
retrieve_chunks,
|
||||||
)
|
)
|
||||||
from portfolio_optimiser.dimension import Dimension, admits
|
from portfolio_optimiser.dimension import Dimension, admits, load_dimension
|
||||||
from portfolio_optimiser.generate import generate_via_llm
|
from portfolio_optimiser.generate import generate_via_llm
|
||||||
from portfolio_optimiser.ir import SavingsProposal
|
from portfolio_optimiser.ir import SavingsProposal
|
||||||
from portfolio_optimiser.provenance import ProvenanceStamp
|
from portfolio_optimiser.provenance import ProvenanceStamp
|
||||||
|
|
@ -625,6 +626,26 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
help="async verdict inbox: a folder of dropped expert verdicts, ingested before generation "
|
help="async verdict inbox: a folder of dropped expert verdicts, ingested before generation "
|
||||||
"(the long loop — a verdict that landed after an earlier run is consumed by this run)",
|
"(the long loop — a verdict that landed after an earlier run is consumed by this run)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dimension-config",
|
||||||
|
default=None,
|
||||||
|
help="fail-fast dimension scope config (JSON): scopes the run to one cost axis; a "
|
||||||
|
"missing or malformed file refuses the run (authoritative startup config, not a RAW inbox)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--outbox-dir",
|
||||||
|
default=None,
|
||||||
|
help="RAW outbox dir for the run's proposal/outcome artefacts (REQUIRES --run-id). MUST "
|
||||||
|
"differ from --verdict-dir: writing the outbox into a folder later read as an inbox "
|
||||||
|
"re-ingests raw agent output past the Step-8 promotion gate (self-contamination) — "
|
||||||
|
"documented, deliberately NOT CLI-enforced",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--run-id",
|
||||||
|
default=None,
|
||||||
|
help="stable run id for --outbox-dir artefacts (required when --outbox-dir is set; no "
|
||||||
|
"wall-clock/uuid default — the outbox artefacts are byte-deterministic)",
|
||||||
|
)
|
||||||
parser.add_argument("--decision", default="approved", choices=["approved", "rejected"])
|
parser.add_argument("--decision", default="approved", choices=["approved", "rejected"])
|
||||||
parser.add_argument("--rationale", default="reviewed by expert")
|
parser.add_argument("--rationale", default="reviewed by expert")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
|
@ -647,11 +668,16 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
docs_dir=args.docs_dir,
|
docs_dir=args.docs_dir,
|
||||||
bundle_dir=args.bundle_dir,
|
bundle_dir=args.bundle_dir,
|
||||||
verdict_dir=args.verdict_dir,
|
verdict_dir=args.verdict_dir,
|
||||||
|
dimension=(
|
||||||
|
load_dimension(args.dimension_config) if args.dimension_config else None
|
||||||
|
),
|
||||||
|
outbox_dir=args.outbox_dir,
|
||||||
|
run_id=args.run_id,
|
||||||
verdict_input={"decision": args.decision, "rationale": args.rationale},
|
verdict_input={"decision": args.decision, "rationale": args.rationale},
|
||||||
live_dry_run=True,
|
live_dry_run=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except (ValueError, FileNotFoundError, ValidationError) as exc:
|
||||||
# Structured refusal (rc 1, no traceback) for ANY offline-path ValueError. The
|
# Structured refusal (rc 1, no traceback) for ANY offline-path ValueError. The
|
||||||
# azure-preflight remediation is only meaningful for the AZURE config gate (S4.1), so
|
# azure-preflight remediation is only meaningful for the AZURE config gate (S4.1), so
|
||||||
# scope it to that profile — a LOCAL-profile ValueError (unknown project_id, empty
|
# scope it to that profile — a LOCAL-profile ValueError (unknown project_id, empty
|
||||||
|
|
@ -673,19 +699,30 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
result = cast(
|
try:
|
||||||
RunResult,
|
result = cast(
|
||||||
asyncio.run(
|
RunResult,
|
||||||
run_project(
|
asyncio.run(
|
||||||
args.project_id,
|
run_project(
|
||||||
args.profile,
|
args.project_id,
|
||||||
docs_dir=args.docs_dir,
|
args.profile,
|
||||||
bundle_dir=args.bundle_dir,
|
docs_dir=args.docs_dir,
|
||||||
verdict_dir=args.verdict_dir,
|
bundle_dir=args.bundle_dir,
|
||||||
verdict_input={"decision": args.decision, "rationale": args.rationale},
|
verdict_dir=args.verdict_dir,
|
||||||
)
|
dimension=(
|
||||||
),
|
load_dimension(args.dimension_config) if args.dimension_config else None
|
||||||
)
|
),
|
||||||
|
outbox_dir=args.outbox_dir,
|
||||||
|
run_id=args.run_id,
|
||||||
|
verdict_input={"decision": args.decision, "rationale": args.rationale},
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except (ValueError, FileNotFoundError, ValidationError) as exc:
|
||||||
|
# Structured refusal (rc 1, no traceback) for the full-run path: run_project's fail-fast
|
||||||
|
# loaders (contracts, load_dimension, outbox run_id guard) surface here as one clean line.
|
||||||
|
print(f"run refused: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
kind = type(result.outcome).__name__
|
kind = type(result.outcome).__name__
|
||||||
print(f"{args.project_id}: {kind} (verdict id={result.verdict.id}, decision={args.decision})")
|
print(f"{args.project_id}: {kind} (verdict id={result.verdict.id}, decision={args.decision})")
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
121
tests/test_run_cli.py
Normal file
121
tests/test_run_cli.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
"""S5.3 CLI-parity tests for ``run.main()`` single-project flags (Steps 2/4/5).
|
||||||
|
|
||||||
|
In-process ``run.main([argv])`` + rc + ``capsys`` substring asserts (never subprocess),
|
||||||
|
mirroring ``tests/test_live_dry_run.py``. Every arm is offline — it stops before the first
|
||||||
|
model call (``debate.run``), so no socket/network is exercised (brief NFR). The bundle
|
||||||
|
fixture ``shared/examples/bygg-energi-mikro`` (project ``BYGG-KONTOR-NORD``) supplies citable
|
||||||
|
content so the dry-run reaches its offline return.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from portfolio_optimiser import run
|
||||||
|
from portfolio_optimiser.dimension import Dimension
|
||||||
|
|
||||||
|
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
|
||||||
|
_PID = "BYGG-KONTOR-NORD"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_model_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Hermetic env (verbatim from ``test_live_dry_run.py``): clear the S4.1 out-of-tree overrides
|
||||||
|
so these CLI assertions read the BUNDLED map/config, not the operator's Foundry environment."""
|
||||||
|
monkeypatch.delenv("PORTFOLIO_MODEL_MAP", raising=False)
|
||||||
|
monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_dimension(tmp_path: Path) -> Path:
|
||||||
|
"""A valid dimension scope config on disk (loaded fail-fast by ``load_dimension``)."""
|
||||||
|
dim = Dimension(
|
||||||
|
id="energi",
|
||||||
|
label="Energi",
|
||||||
|
allowed_measure_types=frozenset({"energy_efficiency"}),
|
||||||
|
)
|
||||||
|
p = tmp_path / "dim.json"
|
||||||
|
p.write_text(dim.model_dump_json(), encoding="utf-8")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# --- Step 2: --dimension-config / --outbox-dir / --run-id wiring + structured refusal -------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_dimension_config_flag_parses_offline(tmp_path, capsys) -> None:
|
||||||
|
"""(a) ``--dimension-config <valid>`` + ``--live-dry-run`` → rc 0 (flag parsed, loader invoked,
|
||||||
|
offline — stops before any model call)."""
|
||||||
|
rc = run.main(
|
||||||
|
[
|
||||||
|
_PID,
|
||||||
|
"--docs-dir",
|
||||||
|
str(BUNDLE_DIR),
|
||||||
|
"--bundle-dir",
|
||||||
|
str(BUNDLE_DIR),
|
||||||
|
"--dimension-config",
|
||||||
|
str(_write_dimension(tmp_path)),
|
||||||
|
"--live-dry-run",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert rc == 0
|
||||||
|
assert "LIVE-DRY-RUN OK" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_outbox_dir_with_run_id_writes_runconfig_offline(tmp_path) -> None:
|
||||||
|
"""(b) ``--outbox-dir`` + ``--run-id`` + ``--live-dry-run`` → rc 0 AND ``<tmp>/r1-runconfig.json``
|
||||||
|
written offline (via ``write_run_config``, before the dry-run return)."""
|
||||||
|
outbox = tmp_path / "out"
|
||||||
|
outbox.mkdir()
|
||||||
|
rc = run.main(
|
||||||
|
[
|
||||||
|
_PID,
|
||||||
|
"--docs-dir",
|
||||||
|
str(BUNDLE_DIR),
|
||||||
|
"--bundle-dir",
|
||||||
|
str(BUNDLE_DIR),
|
||||||
|
"--outbox-dir",
|
||||||
|
str(outbox),
|
||||||
|
"--run-id",
|
||||||
|
"r1",
|
||||||
|
"--live-dry-run",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert rc == 0
|
||||||
|
assert (outbox / "r1-runconfig.json").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_outbox_dir_without_run_id_refuses(tmp_path, capsys) -> None:
|
||||||
|
"""(c) RED guard: ``--outbox-dir`` WITHOUT ``--run-id`` → rc 1 structured refusal. ``run_project``'s
|
||||||
|
step-0 fail-fast (no wall-clock default) surfaces through the CLI refusal wrapper, no traceback."""
|
||||||
|
outbox = tmp_path / "out"
|
||||||
|
outbox.mkdir()
|
||||||
|
rc = run.main(
|
||||||
|
[
|
||||||
|
_PID,
|
||||||
|
"--docs-dir",
|
||||||
|
str(BUNDLE_DIR),
|
||||||
|
"--outbox-dir",
|
||||||
|
str(outbox),
|
||||||
|
"--live-dry-run",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert rc == 1
|
||||||
|
assert "refused" in capsys.readouterr().err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dimension_config_missing_file_refuses(capsys) -> None:
|
||||||
|
"""(d) ``--dimension-config <nonexistent>`` → rc 1 structured refusal. ``load_dimension`` raises
|
||||||
|
``FileNotFoundError``, caught by the WIDENED dry-run handler (not just ``ValueError`` — Pass-2 #1)."""
|
||||||
|
rc = run.main(
|
||||||
|
[
|
||||||
|
_PID,
|
||||||
|
"--docs-dir",
|
||||||
|
str(BUNDLE_DIR),
|
||||||
|
"--dimension-config",
|
||||||
|
"/nonexistent-dim-config.json",
|
||||||
|
"--live-dry-run",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert rc == 1
|
||||||
|
assert "refused" in capsys.readouterr().err.lower()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue