feat(s51): hitl CLI — python -m portfolio_optimiser.hitl pending|route

Gate: pytest tests/test_hitl.py -k cli → 4 passed.
This commit is contained in:
Kjell Tore Guttormsen 2026-07-15 19:39:49 +02:00
commit c4c55fd417
2 changed files with 133 additions and 1 deletions

View file

@ -31,11 +31,12 @@ S5.1 scope. The static import-graph probe guards hitl's OWN logic against a MAF-
from __future__ import annotations from __future__ import annotations
import json import json
import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, ValidationError, model_validator
# Mirrored INLINE from verdicts.py (NOT imported — verdicts.py:29 pulls agent_framework). The inbox # Mirrored INLINE from verdicts.py (NOT imported — verdicts.py:29 pulls agent_framework). The inbox
# predicate must match load_verdicts_from_dir EXACTLY, else pending would count as judged a file the # predicate must match load_verdicts_from_dir EXACTLY, else pending would count as judged a file the
@ -239,3 +240,56 @@ def _load_json_dict(file: Path) -> dict[str, Any] | None:
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
return None return None
return data if isinstance(data, dict) else None return data if isinstance(data, dict) else None
# --- CLI: python -m portfolio_optimiser.hitl pending|route (Step 4) --------------------------------
def main(argv: list[str] | None = None) -> int:
"""CLI entry: ``python -m portfolio_optimiser.hitl pending|route`` — the operator inspection tool.
``pending`` prints one ``run_id verdict_id outcome_type`` line per un-judged proposal; ``route``
prints ``run_id verdict_id <expert|UNROUTABLE> [dim:<id>][ AMBIGUOUS]``. Output is sorted /
deterministic. A config-load error structured ``hitl: <reason>`` on stderr + rc 1 (never a
traceback). rc 0 on success."""
import argparse
parser = argparse.ArgumentParser(
prog="portfolio_optimiser.hitl",
description="HITL-inspeksjon (S5.1): vis ventende forslag (outbox uten inbox-dom) og rut dem "
"til fagekspert etter kostnadskode-prefiks. Leser mapper; ingen modellkall, ingen skriving.",
)
sub = parser.add_subparsers(dest="command", required=True)
p_pending = sub.add_parser("pending", help="list proposals still awaiting an expert verdict")
p_pending.add_argument("--outbox-dir", required=True, help="run outbox (proposal/outcome files)")
p_pending.add_argument("--verdict-dir", required=True, help="expert verdict inbox")
p_route = sub.add_parser("route", help="route pending proposals to experts by cost-code prefix")
p_route.add_argument("--outbox-dir", required=True, help="run outbox (proposal/outcome files)")
p_route.add_argument("--verdict-dir", required=True, help="expert verdict inbox")
p_route.add_argument("--routing-config", required=True, help="dimension→expert routing config")
args = parser.parse_args(argv)
if args.command == "pending":
for proposal in pending(args.outbox_dir, args.verdict_dir):
print(f"{proposal.run_id} {proposal.verdict_id} {proposal.outcome_type}")
return 0
try:
config = load_routing_config(args.routing_config)
except (FileNotFoundError, ValidationError, ValueError) as exc:
print(f"hitl: {exc}", file=sys.stderr)
return 1
for routed in route(args.outbox_dir, args.verdict_dir, config):
p = routed.pending
if routed.expert is None:
print(f"{p.run_id} {p.verdict_id} → UNROUTABLE")
else:
suffix = " AMBIGUOUS" if routed.ambiguous else ""
print(f"{p.run_id} {p.verdict_id}{routed.expert} [dim:{routed.dimension_id}]{suffix}")
return 0
if __name__ == "__main__": # pragma: no cover - console entry
raise SystemExit(main())

View file

@ -36,6 +36,15 @@ _PROVENANCE = ProvenanceStamp(
) )
@pytest.fixture(autouse=True)
def _isolate_model_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Defensive hermetic env (copy of ``test_live_dry_run.py:20-29``): clear the S4.1 out-of-tree
overrides. hitl's CLI never reads these, but importing the package eagerly loads ``run`` — keep
the assertions insensitive to the operator's Foundry-configured environment."""
monkeypatch.delenv("PORTFOLIO_MODEL_MAP", raising=False)
monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False)
def _make_validated(measure: str, codes: list[str], claimed: float = 200.0) -> ValidatedProposal: def _make_validated(measure: str, codes: list[str], claimed: float = 200.0) -> ValidatedProposal:
items = [AffectedItem(code=c, quantity=1000.0, unit_cost=1.0) for c in codes] items = [AffectedItem(code=c, quantity=1000.0, unit_cost=1.0) for c in codes]
proposal = SavingsProposal( proposal = SavingsProposal(
@ -299,3 +308,72 @@ def test_route_measure_constrained_entry_filters(tmp_path: Path) -> None:
hit = tmp_path / "hit" hit = tmp_path / "hit"
_write_proposal(hit, "run-1", verdict_id="v1", measure="scope_reduction", codes=["05.2"]) _write_proposal(hit, "run-1", verdict_id="v1", measure="scope_reduction", codes=["05.2"])
assert hitl.route(str(hit), str(inbox), config)[0].expert == "Ola" assert hitl.route(str(hit), str(inbox), config)[0].expert == "Ola"
# --- CLI: python -m portfolio_optimiser.hitl pending|route ----------------------------------------
def test_cli_pending_lists_unjudged(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""``pending`` subcommand → rc 0 and one greppable ``run_id verdict_id outcome_type`` line per
un-judged proposal."""
outbox = tmp_path / "outbox"
inbox = tmp_path / "inbox"
_write_proposal(outbox, "run-1", verdict_id="v1")
rc = hitl.main(["pending", "--outbox-dir", str(outbox), "--verdict-dir", str(inbox)])
assert rc == 0
out = capsys.readouterr().out
assert "run-1 v1 validated" in out
def test_cli_route_lists_expert_and_unroutable(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""``route`` subcommand → rc 0; a routed proposal carries its expert + ``dim:``, an unmatched one
carries ``UNROUTABLE`` (and no ``dim:``)."""
outbox = tmp_path / "outbox"
inbox = tmp_path / "inbox"
_write_proposal(outbox, "run-1", verdict_id="v1", codes=["05.2"])
_write_proposal(outbox, "run-2", verdict_id="v2", codes=["99.9"])
cfg = _write_config(
tmp_path, {"entries": [{"id": "energi", "allowed_code_prefixes": ["05"], "expert": "Ola"}]}
)
rc = hitl.main(
["route", "--outbox-dir", str(outbox), "--verdict-dir", str(inbox), "--routing-config", str(cfg)]
)
assert rc == 0
out = capsys.readouterr().out
assert "run-1 v1" in out and "Ola" in out and "dim:energi" in out
assert "run-2 v2" in out and "UNROUTABLE" in out
def test_cli_route_malformed_config_returns_rc1(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A malformed routing config → rc 1, a structured ``hitl:`` message to stderr, NO traceback."""
outbox = tmp_path / "outbox"
inbox = tmp_path / "inbox"
_write_proposal(outbox, "run-1", verdict_id="v1")
bad_cfg = _write_config(tmp_path, {"entries": [{"id": "energi"}]}) # missing required expert
rc = hitl.main(
["route", "--outbox-dir", str(outbox), "--verdict-dir", str(inbox), "--routing-config", str(bad_cfg)]
)
assert rc == 1
captured = capsys.readouterr()
assert "hitl:" in captured.err
assert "Traceback" not in captured.err
def test_cli_pending_output_is_deterministic(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Two identical ``pending`` invocations produce byte-identical stdout."""
outbox = tmp_path / "outbox"
inbox = tmp_path / "inbox"
_write_proposal(outbox, "run-b", verdict_id="v2")
_write_proposal(outbox, "run-a", verdict_id="v1")
hitl.main(["pending", "--outbox-dir", str(outbox), "--verdict-dir", str(inbox)])
first = capsys.readouterr().out
hitl.main(["pending", "--outbox-dir", str(outbox), "--verdict-dir", str(inbox)])
second = capsys.readouterr().out
assert first == second
assert first.index("run-a") < first.index("run-b") # sorted