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:
parent
62d6b40eae
commit
c4c55fd417
2 changed files with 133 additions and 1 deletions
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue