"""HITL verdict routing + pending tracking (method-spec §5; S5.1; paritetsrad 22; K9). The operator's view of the long feedback loop: which proposals still AWAIT an expert verdict, and who should judge each. A pure file-based id-join across the three layers this module READS — it never writes any of them (role split §3 Step 7: the expert writes the inbox, the system reads it; notification is K10's job, never this). * **outbox** (K5) — each completed run persists a ``{run_id}`` PAIR; the ``{run_id}-outcome.json`` carries the join key ``verdict_id`` (minted the SAME way the inbox mints a verdict id, ``mint_verdict_id`` over the candidate features — pinned in K5's test, reused here). Read verbatim, never re-minted. * **inbox** (§4.2/§5) — expert verdict files; a proposal is SETTLED once a §4.2-valid verdict for its id is loaded (``load_inbox`` polices the decision vocabulary — a skipped/unknown decision never settles anything). * **promoted** (§6) — a promoted verdict in a bundle's context layer carries its ``verdict_id`` in frontmatter and settles the proposal too. Optional (``bundle_dirs``) so the core join is exactly the two layers outbox↔inbox. A proposal is *pending* when its ``verdict_id`` is in NONE of the settled sets. Routing maps the proposal's ``measure`` (a config-string key NOW — K13 formalizes the dimension catalog) to an expert; an unmatched measure falls to the optional ``default_expert``, else the proposal is UNROUTED (nobody configured to judge it). The routing config is schema-validated fail-fast (§10) — a malformed table never reaches a routed report. Run: uv run python -m portfolio_optimiser_claude.hitl pending --outbox --inbox uv run python -m portfolio_optimiser_claude.hitl route --outbox --inbox \ --routing [--bundle ...] """ from __future__ import annotations import argparse import json from dataclasses import dataclass from pathlib import Path from typing import Annotated, Any, Sequence from pydantic import BaseModel, Field, ValidationError, model_validator from portfolio_optimiser_claude.inbox import load_inbox from portfolio_optimiser_claude.ir import SavingsProposal from portfolio_optimiser_claude.notify import ( EgressNotPermitted, Notification, Notifier, Transport, add_notify_args, build_notifiers, emit, notify_config_from_args, ) from portfolio_optimiser_claude.okf import navigate_bundle _OUTCOME_SUFFIX = "-outcome.json" _PROPOSAL_SUFFIX = "-proposal.json" _VERDICT_TYPE = "verdict" # An expert id is a non-empty config string (K13 gives it a formal catalog). _ExpertId = Annotated[str, Field(min_length=1)] @dataclass(frozen=True) class PendingProposal: """One outbox proposal awaiting a verdict — the join key plus routing fields. ``measure`` is the routing dimension read as a config string today (K13 formalizes the dimension catalog); ``verdict_id`` is the persisted K5 join key, read verbatim from the outcome file (never re-minted here). """ run_id: str verdict_id: str project_id: str measure: str claimed_saving_nok: float @dataclass(frozen=True) class RoutedProposal: """A pending proposal plus its assigned expert (``None`` = UNROUTED).""" proposal: PendingProposal expert: str | None class RoutingContract(BaseModel): """nøkkel→ekspert routing table (§10, fail-fast before any routed report). Keys are opaque config strings NOW (K13 formalizes the dimension catalog); values are non-empty expert ids. The table must be non-empty (an empty table could never route anyone). ``default_expert`` is the optional fall-through for an unmatched key; absent means an unmatched proposal is UNROUTED. """ routes: dict[str, _ExpertId] = Field(min_length=1) default_expert: _ExpertId | None = None @model_validator(mode="after") def _keys_non_empty(self) -> RoutingContract: for key in self.routes: if not key.strip(): raise ValueError("routing key must be a non-empty string") return self def load_routing(raw: dict[str, Any]) -> RoutingContract: """Validate the routing config at startup (§10) — ``ValidationError`` fail-fast.""" return RoutingContract(**raw) def load_outbox_proposals(outbox_dir: Path) -> list[PendingProposal]: """Read every persisted outbox pair into a ``PendingProposal`` (sorted by run_id). Each ``{run_id}-outcome.json`` supplies the join key ``verdict_id`` and the ``run_id``; its sibling ``{run_id}-proposal.json`` supplies the routing/display fields. A pair that cannot be read as a well-formed unit (bad JSON, missing field, absent sibling) is SKIPPED — a defensive read boundary over the system's own deterministic output, never a crash. Nothing is written. """ if not outbox_dir.is_dir(): return [] proposals: list[PendingProposal] = [] for outcome_path in sorted(outbox_dir.glob(f"*{_OUTCOME_SUFFIX}")): stem = outcome_path.name[: -len(_OUTCOME_SUFFIX)] proposal_path = outcome_path.with_name(f"{stem}{_PROPOSAL_SUFFIX}") try: record = json.loads(outcome_path.read_text("utf-8")) run_id = str(record["run_id"]) verdict_id = str(record["verdict_id"]) proposal = SavingsProposal.model_validate(json.loads(proposal_path.read_text("utf-8"))) except (OSError, KeyError, ValueError, ValidationError): continue # a broken pair is skipped — read boundary, never a raise proposals.append( PendingProposal( run_id=run_id, verdict_id=verdict_id, project_id=proposal.project_id, measure=proposal.measure, claimed_saving_nok=proposal.claimed_saving_nok, ) ) return sorted(proposals, key=lambda p: p.run_id) def _promoted_verdict_ids(bundle_dir: Path) -> set[str]: """The verdict ids of PROMOTED verdicts in a bundle (§6) — read-only. A promoted file carries ``type: verdict`` plus a ``verdict_id`` in its frontmatter; a hand-authored seed verdict has no ``verdict_id`` and is not a judgment of an outbox proposal, so it is ignored. """ return { vid for concept in navigate_bundle(bundle_dir) if concept.type == _VERDICT_TYPE and (vid := concept.frontmatter.get("verdict_id")) } def settled_verdict_ids(inbox_dir: Path, *, bundle_dirs: Sequence[Path] = ()) -> set[str]: """Every verdict id already settled: §4.2-valid inbox verdicts ∪ promoted verdicts. Inbox ids come THROUGH ``load_inbox`` (decision-vocabulary policed — a skipped unknown decision never settles a proposal; a capacity breach still fails fast). Promoted ids come from each bundle's context layer. Read-only. """ ids = {document.id for document in load_inbox(inbox_dir)} for bundle_dir in bundle_dirs: ids |= _promoted_verdict_ids(bundle_dir) return ids def pending_proposals( outbox_dir: Path, inbox_dir: Path, *, bundle_dirs: Sequence[Path] = () ) -> list[PendingProposal]: """Outbox proposals whose ``verdict_id`` is in NONE of the settled sets (the id-join). The load-bearing seam: an outbox proposal survives ONLY if it is not settled. Drop the ``not in settled`` filter and a judged proposal is still listed — the loop's whole point (surface only the outstanding) collapses. """ settled = settled_verdict_ids(inbox_dir, bundle_dirs=bundle_dirs) return [ proposal for proposal in load_outbox_proposals(outbox_dir) if proposal.verdict_id not in settled ] def route_pending( pending: Sequence[PendingProposal], routing: RoutingContract ) -> list[RoutedProposal]: """Assign an expert to each pending proposal by its ``measure`` (config-string key). An unmatched measure falls to ``default_expert`` (``None`` when absent → UNROUTED). No I/O — a pure mapping over the already-loaded pending set. """ return [ RoutedProposal( proposal=proposal, expert=routing.routes.get(proposal.measure, routing.default_expert), ) for proposal in pending ] def _bundle_dirs(values: list[str] | None) -> list[Path]: return [Path(value) for value in (values or [])] def _cmd_pending(args: argparse.Namespace, notifiers: Sequence[Notifier]) -> int: pending = pending_proposals( Path(args.outbox), Path(args.inbox), bundle_dirs=_bundle_dirs(args.bundle) ) print(f"PENDING — {len(pending)} proposal(s) awaiting a verdict:") for proposal in pending: print( f" {proposal.run_id} verdict_id={proposal.verdict_id} " f"measure={proposal.measure!r} project={proposal.project_id}" ) # K10: notification is egress, NOT writing any of the three read layers — # the read-only invariant (§3 Step 7) holds (proven by the seam test's # before/after byte snapshot of outbox+inbox). emit( notifiers, Notification( event="hitl.pending", summary=f"{len(pending)} proposal(s) awaiting a verdict", fields={"pending": len(pending)}, ), ) return 0 def _cmd_route(args: argparse.Namespace, notifiers: Sequence[Notifier]) -> int: try: routing = load_routing(json.loads(Path(args.routing).read_text("utf-8"))) except (OSError, ValueError, ValidationError) as exc: print(f"ROUTE FAILED — invalid routing config (fail-fast, §10): {exc}") return 1 pending = pending_proposals( Path(args.outbox), Path(args.inbox), bundle_dirs=_bundle_dirs(args.bundle) ) routed = route_pending(pending, routing) print(f"ROUTE — {len(routed)} pending proposal(s), assigned expert per measure:") for item in routed: expert = item.expert if item.expert is not None else "UNROUTED (no route, no default)" print( f" {item.proposal.run_id} measure={item.proposal.measure!r} " f"verdict_id={item.proposal.verdict_id} -> {expert}" ) unrouted = sum(1 for item in routed if item.expert is None) emit( notifiers, Notification( event="hitl.route", summary=f"{len(routed)} pending proposal(s) routed, {unrouted} UNROUTED", fields={"pending": len(routed), "unrouted": unrouted}, ), ) return 0 def main(argv: list[str] | None = None, *, notifier_transport: Transport | None = None) -> int: """The thin CLI: ``pending`` lists outstanding proposals; ``route`` adds experts. ``pending`` is a pure report (exit 0). ``route`` loads the routing config fail-fast — a malformed/missing config exits non-zero WITHOUT touching any layer (§10). Neither subcommand writes any of the three read layers. An optional notify seam (K10) delivers a summary event; a ``--notify-webhook`` without ``--allow-webhook-egress`` is refused fail-fast (§8) BEFORE any read. ``notifier_transport`` is the injected webhook transport (canned in the suite, ``default_webhook_transport`` on the CLI path — no socket in tests). """ parser = argparse.ArgumentParser( description=( "Show which proposals still await an expert verdict (pending) and who " "should judge each (route) — a read-only file-based id-join across the " "outbox, the inbox, and promoted verdicts. This never writes any layer." ) ) subparsers = parser.add_subparsers(dest="command", required=True) def _add_common(sub: argparse.ArgumentParser) -> None: sub.add_argument("--outbox", required=True, help="outbox dir (run_id-named pairs, K5)") sub.add_argument("--inbox", required=True, help="inbox dir (expert verdict files, §5)") sub.add_argument( "--bundle", action="append", help="bundle dir whose promoted verdicts also settle a proposal (repeatable)", ) add_notify_args(sub) pending_parser = subparsers.add_parser("pending", help="list proposals awaiting a verdict") _add_common(pending_parser) pending_parser.set_defaults(func=_cmd_pending) route_parser = subparsers.add_parser("route", help="assign an expert to each pending proposal") _add_common(route_parser) route_parser.add_argument( "--routing", required=True, help="routing config JSON (nøkkel→ekspert, schema-validated)" ) route_parser.set_defaults(func=_cmd_route) args = parser.parse_args(argv) # K10 (§8): build the notify sinks BEFORE any read — a --notify-webhook # without --allow-webhook-egress is refused fail-fast here (the opt-in is a # run argument, never a config field). try: notifiers = build_notifiers( notify_config_from_args(args), webhook_transport=notifier_transport ) except EgressNotPermitted as exc: parser.error(str(exc)) result: int = args.func(args, notifiers) return result if __name__ == "__main__": raise SystemExit(main())