"""S5.1 — HITL pending registry + dimension→expert routing (roadmap E, målbilde §3). An operator inspection tool (mirrors ``preflight.py`` / ``costsim.py``) that makes the async verdict queue visible. After every run the OUTBOX accumulates ``{run_id}-proposal.json`` / ``{run_id}-outcome.json`` (``outbox.write_outbox``); each outcome carries a ``verdict_id`` (the ``_mint_id`` content-hash). A matching expert verdict lands in the INBOX as ``{id}.json`` (``verdicts.write_verdict``). This module derives, from those two folders: - a file-derived **pending registry** — outbox ``verdict_id`` MINUS inbox ``id`` (an id-join), i.e. the proposals still awaiting an expert dom; and - a declarative **``dimension → expert`` routing** — which expert should supply each pending dom, classified by **cost-code prefix** (see routing notes in Step 3 + the plan's Risk #1). Exposed via ``python -m portfolio_optimiser.hitl pending|route``. It is an inspection tool, NOT wired into ``run_project`` — the system READS these folders, the expert/persona WRITES them (målbilde §3 role split). **MAF-free source** (D7-portable): pure stdlib + pydantic. The inbox id-set predicate mirrors ``verdicts.load_verdicts_from_dir`` INLINE rather than importing it — ``verdicts.py`` imports ``agent_framework`` at module top, so importing its reader would pull MAF into this D7-portable logic. Registered in ``tests/test_okf.py``'s ``_MAF_FREE_MODULES`` (direct AST guard) and pinned by a transitive import-graph probe (``test_hitl_loadbearing.py``). **Honesty limitation (CLI needs MAF at runtime):** this module's SOURCE is MAF-clean, but invoking it via the package (``python -m portfolio_optimiser.hitl``) first runs ``__init__.py`` → ``run`` → ``agent_framework`` before hitl's body. The "no agent runtime" framing is therefore source-level (the logic stays portable); a lazy ``__init__`` that would make the CLI itself MAF-free is out of S5.1 scope. The static import-graph probe guards hitl's OWN logic against a MAF-bearing edge. """ from __future__ import annotations import json import sys from dataclasses import dataclass from pathlib import Path from typing import Any from pydantic import BaseModel, Field, ValidationError, model_validator # 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 # real loader drops. Kept in lockstep with verdicts._REQUIRED_VERDICT_KEYS / _INBOX_DECISION_VOCABULARY # / the inner keys verdict_from_dict reads; pinned by the parity tests in test_hitl_loadbearing.py. _REQUIRED_VERDICT_KEYS = {"id", "decision", "rationale", "proposal_features"} _INBOX_DECISION_VOCABULARY = frozenset({"approved", "rejected"}) _REQUIRED_FEATURE_KEYS = {"affected_codes", "measure_type", "claimed_saving_nok"} @dataclass(frozen=True) class PendingProposal: """One outbox proposal still awaiting an expert verdict. ``codes``/``measure`` carry the routing keys (Step 3); ``verdict_id`` is the id-join key against the inbox. ``approach_id`` names the commissioned approach the artefact belongs to (A5), and is ``""`` for an artefact written without a mandate — a run nobody commissioned has no approach to name.""" run_id: str verdict_id: str outcome_type: str measure: str codes: frozenset[str] approach_id: str = "" def _join_key(data: dict[str, Any]) -> tuple[str, str]: """The key one artefact is filed under: ``(run_id, approach_id)``, read from file CONTENT. ``run_id`` alone was the key until A5 gave a run several judgeable approaches. Once it does, a ``run_id``-only key collapses every approach of one run onto a single dict entry (last write wins) and the expert's queue silently reports one candidate where three were evaluated — the S3.2 key-collision class. An artefact with no ``approach_id`` keys on ``""``, which is exactly the pre-A5 behaviour for pre-A5 files.""" approach_id = data.get("approach_id") return str(data["run_id"]), str(approach_id) if isinstance(approach_id, str) else "" def _read_outbox_proposals(outbox_dir: str) -> list[PendingProposal]: """Read the outbox, joining ``{run_id}[-{approach_id}]-proposal.json`` and its ``-outcome.json`` on the ``run_id``/``approach_id`` FIELDS read from file content (never the filename). TOLERANT (RAW layer, contrast ``okf.load_ir_projection``'s fail-fast): a missing dir yields ``[]``; unparseable files, and orphans (a proposal without its outcome or vice-versa), are SKIPPED, never raised — a live run writes the pair non-atomically, so half-written state is realistic.""" directory = Path(outbox_dir) if not directory.is_dir(): return [] proposals: dict[tuple[str, str], dict[str, Any]] = {} for file in sorted(directory.glob("*-proposal.json")): data = _load_json_dict(file) if data is None or "run_id" not in data or not isinstance(data.get("proposal"), dict): continue proposals[_join_key(data)] = data outcomes: dict[tuple[str, str], dict[str, Any]] = {} for file in sorted(directory.glob("*-outcome.json")): data = _load_json_dict(file) if data is None or "run_id" not in data: continue outcomes[_join_key(data)] = data result: list[PendingProposal] = [] for key in proposals.keys() & outcomes.keys(): # inner join — orphans on either side dropped run_id, approach_id = key proposal = proposals[key]["proposal"] outcome = outcomes[key] verdict_id = outcome.get("verdict_id") outcome_type = outcome.get("outcome_type") if not isinstance(verdict_id, str) or not isinstance(outcome_type, str): continue try: codes = frozenset(item["code"] for item in proposal.get("affected_items", [])) except (KeyError, TypeError): continue result.append( PendingProposal( run_id=run_id, verdict_id=verdict_id, outcome_type=outcome_type, measure=str(proposal.get("measure", "")), codes=codes, approach_id=approach_id, ) ) return result def _inbox_verdict_ids(verdict_dir: str) -> set[str]: """Collect the ``id`` of every inbox verdict file that ``load_verdicts_from_dir`` WOULD accept — the same tolerant predicate mirrored inline: a dict carrying all ``_REQUIRED_VERDICT_KEYS``, a ``decision`` in the binary vocabulary ``{approved, rejected}``, and a ``proposal_features`` dict carrying the inner keys ``verdict_from_dict`` reads AND an ``affected_codes`` that ``frozenset(...)`` accepts (a non-iterable — ``null``/int/float/bool — makes the real loader's ``frozenset(pf['affected_codes'])`` raise ``TypeError``, so it is skipped here too). A file that would be skipped or raise in the real loader is skipped here too, so ``pending`` never treats it as a delivered dom.""" directory = Path(verdict_dir) if not directory.is_dir(): return set() ids: set[str] = set() for file in sorted(directory.glob("*.json")): data = _load_json_dict(file) if data is None or not _REQUIRED_VERDICT_KEYS <= data.keys(): continue if data.get("decision") not in _INBOX_DECISION_VOCABULARY: continue features = data.get("proposal_features") if not isinstance(features, dict) or not _REQUIRED_FEATURE_KEYS <= features.keys(): continue # Mirror verdict_from_dict EXACTLY: frozenset(affected_codes) raises TypeError on a # non-iterable (null/int/float/bool), which the real loader skips — so skip it here too. try: frozenset(features["affected_codes"]) except TypeError: continue ids.add(data["id"]) return ids def pending(outbox_dir: str, verdict_dir: str) -> list[PendingProposal]: """The pending registry: outbox proposals whose ``verdict_id`` is NOT yet in the inbox id-set, sorted deterministically by ``(run_id, approach_id, verdict_id)`` — one row per evaluated approach, since each is judged on its own key.""" judged = _inbox_verdict_ids(verdict_dir) unjudged = [p for p in _read_outbox_proposals(outbox_dir) if p.verdict_id not in judged] return sorted(unjudged, key=lambda p: (p.run_id, p.approach_id, p.verdict_id)) # --- U12: the pending PLAN REVIEWS of parked explorations, and the expert's answer --------------- # The same registry shape as ``pending`` above, one time-scale earlier: there the outbox holds a # PROPOSAL awaiting a verdict, here it holds a QUESTION awaiting a decision. Both live in this # MAF-free module because both are read by an operator tool that must not drag the framework in. #: The closed answer vocabulary, identical to the terminal door's (``explore.terminal_plan_reviewer``). #: Two words, matched structurally — a file cannot be re-asked, so anything else is a refusal. _PLAN_REVIEW_ANSWERS = frozenset({"approve", "revise"}) @dataclass(frozen=True) class PendingPlanReview: """One parked exploration still waiting on a human. ``plan`` is carried because a registry that only counted questions could not be used to answer one.""" run_id: str request_id: str index: int plan: str is_stalled: bool @dataclass(frozen=True) class PlanReviewAnswer: """The expert's decision, as read off a file. ``feedback`` is empty exactly when approving — the same encoding ``explore.PlanReviewDecision`` uses, kept plain so this module stays MAF-free and the adapter between them lives at ONE call site.""" run_id: str request_id: str decision: str feedback: str class PlanReviewAnswerError(ValueError): """An answer file that cannot be read as a decision. Fail-closed, and deliberately NOT the tolerant rule the verdict inbox uses. A dropped verdict that will not parse is one opinion missing from a fold; an unreadable plan-review answer is the one thing standing between a suspended run and a plan nobody signed. A ``ValueError`` so the CLI's existing structured-refusal arm surfaces it as ``rc 1`` rather than a traceback.""" def _answer_path(review_dir: str, run_id: str) -> Path: return Path(review_dir) / f"{run_id}-plan-review-answer.json" def read_plan_review_question(outbox_dir: str, run_id: str) -> dict[str, Any] | None: """The open question of ``run_id``, or ``None`` when there is none. Tolerant: an outbox with no such file simply has no parked review.""" return _load_json_dict(Path(outbox_dir) / f"{run_id}-plan-review.json") def load_plan_review_answer(review_dir: str, run_id: str, *, request_id: str) -> PlanReviewAnswer: """Read the expert's answer to ONE named review, fail-closed at every step. ``request_id`` is a required argument rather than something read off the file and trusted: two reviews of one run share a file name, so an answer left over from the previous round would otherwise be applied to a plan the expert never saw. A mismatch is REFUSED by name — it is a stale answer, not an absent one, and the two need different words. A missing file raises rather than returning ``None``: "not answered yet" is the normal state of this door, and the caller asking to resume has already said it believes otherwise.""" path = _answer_path(review_dir, run_id) data = _load_json_dict(path) if data is None: raise PlanReviewAnswerError( f"no answer for plan review {request_id} of run {run_id!r} in {review_dir!r} " f"(expected {path.name}): the review is still waiting on a human" ) found = str(data.get("request_id", "")) if found != request_id: raise PlanReviewAnswerError( f"the answer in {path.name} answers plan review {found!r}, but the open review of run " f"{run_id!r} is {request_id!r}. Refused: an answer to another question is not an " f"answer to this one" ) decision = str(data.get("decision", "")) if decision not in _PLAN_REVIEW_ANSWERS: raise PlanReviewAnswerError( f"{path.name} answers {decision!r}, which is outside the vocabulary " f"{sorted(_PLAN_REVIEW_ANSWERS)}. Refused, never read as a sign-off" ) feedback = str(data.get("feedback", "")) if decision == "revise" and not feedback.strip(): raise PlanReviewAnswerError( f"{path.name} answers 'revise' with nothing to revise: the manager would be asked to " f"replan against an empty instruction. Say what to change, or answer 'approve'" ) return PlanReviewAnswer( run_id=run_id, request_id=request_id, decision=decision, feedback=feedback ) def pending_plan_reviews(outbox_dir: str, review_dir: str) -> list[PendingPlanReview]: """Every parked plan review whose OWN answer has not landed, sorted by ``run_id``. Mirrors ``pending``: an outbox artefact joined against an inbox, with the join on the key each side names. The key here is ``request_id`` — an answer to a different review leaves this one pending rather than quietly clearing it, which is the same fail-closed rule the resume path applies and for the same reason. Tolerant on the READ side (an unreadable file in either folder is not a question and not an answer), fail-closed on the DECIDE side (``load_plan_review_answer``). The registry says who is waiting; it never decides what they said.""" waiting: list[PendingPlanReview] = [] directory = Path(outbox_dir) if not directory.is_dir(): return waiting for file in sorted(directory.glob("*-plan-review.json")): data = _load_json_dict(file) if data is None: continue run_id = str(data.get("run_id", "")) request_id = str(data.get("request_id", "")) if not run_id or not request_id: continue answer = _load_json_dict(_answer_path(review_dir, run_id)) if answer is not None and str(answer.get("request_id", "")) == request_id: continue waiting.append( PendingPlanReview( run_id=run_id, request_id=request_id, index=int(data.get("index", 0)), plan=str(data.get("plan", "")), is_stalled=bool(data.get("is_stalled", False)), ) ) return sorted(waiting, key=lambda p: (p.run_id, p.index)) # --- Routing config: self-contained dimension→expert table (fail-fast) ---------------------------- # A minimal MVP stand-in for the S3.5 dimension catalog (kept DISTINCT — see the plan's Non-Goals). # Field names mirror ``dimension.Dimension`` so the two reconcile cleanly when S3.5 lands. No ``label`` # field: ``_matches`` never builds a ``Dimension``, so a label would be dead single-use surface. class RoutingEntry(BaseModel): """One ``dimension → expert`` routing rule. ``allowed_measure_types`` EMPTY means "any measure" (route by code prefix alone — see ``_matches`` + the plan's Risk #1); a non-empty set restores a measure gate for deployments that want one.""" id: str = Field(min_length=1) allowed_measure_types: frozenset[str] = frozenset() allowed_code_prefixes: frozenset[str] = frozenset() expert: str = Field(min_length=1) class RoutingConfig(BaseModel): """The routing table. Entry ``id``s must be unique — a duplicate would make the sorted-first tie-break ambiguous.""" entries: list[RoutingEntry] @model_validator(mode="after") def _unique_entry_ids(self) -> RoutingConfig: ids = [e.id for e in self.entries] if len(ids) != len(set(ids)): raise ValueError("routing config has duplicate entry ids") return self def load_routing_config(path: str | Path) -> RoutingConfig: """Load + validate the routing config, fail-fast (mirrors ``costsim.load_pricing``): a missing file raises ``FileNotFoundError``; malformed data raises ``pydantic.ValidationError``; a duplicate entry id raises ``ValueError`` (via the after-validator). Top-level ``_``-prefixed keys are ignored (doc/comment convention).""" p = Path(path) if not p.is_file(): raise FileNotFoundError(f"routing config not found: {str(p)!r}") raw = json.loads(p.read_text(encoding="utf-8")) data = {k: v for k, v in raw.items() if not k.startswith("_")} return RoutingConfig(**data) # --- Routing: classify each pending proposal to an expert (Step 3) -------------------------------- def _matches(entry: RoutingEntry, *, measure: str, codes: frozenset[str]) -> bool: """Mirror ``dimension.admits`` BUT with an OPTIONAL measure gate: an empty ``allowed_measure_types`` means "any measure" (route by code prefix alone). ``measure`` is open-vocabulary prose (verified non-discriminating — plan Risk #1), so code prefixes are the reliable domain key; the measure gate is a strict opt-in filter for deployments that set one.""" if entry.allowed_measure_types and measure not in entry.allowed_measure_types: return False if not entry.allowed_code_prefixes: return True return any(code.startswith(prefix) for code in codes for prefix in entry.allowed_code_prefixes) @dataclass(frozen=True) class RoutedProposal: """A pending proposal classified to an expert. ``expert``/``dimension_id`` are ``None`` when no entry admits it (unroutable, still emitted); ``ambiguous`` flags a >1-entry match resolved by the sorted-first ``entry.id`` tie-break.""" pending: PendingProposal expert: str | None dimension_id: str | None ambiguous: bool def route(outbox_dir: str, verdict_dir: str, config: RoutingConfig) -> list[RoutedProposal]: """Classify each pending proposal to the expert who owns its dimension. 0 matching entries → unroutable (emitted with ``expert=None``); 1 → that entry; >1 → the sorted-first ``entry.id`` (deterministic) flagged ``ambiguous``. Order follows ``pending`` (sorted, deterministic).""" routed: list[RoutedProposal] = [] for proposal in pending(outbox_dir, verdict_dir): matches = [ entry for entry in config.entries if _matches(entry, measure=proposal.measure, codes=proposal.codes) ] if not matches: routed.append(RoutedProposal(proposal, expert=None, dimension_id=None, ambiguous=False)) continue winner = min(matches, key=lambda entry: entry.id) routed.append( RoutedProposal( proposal, expert=winner.expert, dimension_id=winner.id, ambiguous=len(matches) > 1 ) ) return routed def _load_json_dict(file: Path) -> dict[str, Any] | None: """Tolerant read: parse ``file`` as JSON and return it only if it is a dict, else ``None`` (an unreadable / non-JSON / non-object file is skipped by every reader here).""" try: data = json.loads(file.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, json.JSONDecodeError): # UnicodeDecodeError: a *.json file hand-saved in Latin-1 (Norwegian æ/ø/å) is invalid UTF-8 — # skip it, not raise (a ValueError subclass, so neither OSError nor JSONDecodeError catches it). return 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 → [dim:][ AMBIGUOUS]``. Output is sorted / deterministic. A config-load error → structured ``hitl: `` 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): # The approach is appended only when there is one: an artefact written without a # mandate has no approach, and printing an empty column would suggest a missing value # rather than a run nobody commissioned by approach. approach = f" [{proposal.approach_id}]" if proposal.approach_id else "" print(f"{proposal.run_id} {proposal.verdict_id} {proposal.outcome_type}{approach}") 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())