F4 gjorde "be om svar, BRUKE svarene" naabar, men bare SYNKRONT: terminal_plan_reviewer
blokkerer loekka paa et menneske ved en terminal, saa svaret maa komme mens prosessen lever.
Maalbilde §3s tidsskala er den andre - eksperten svarer dager senere, i en prosess som aldri
saa kjoeringen.
--checkpoint-dir PARKERER reviewen (FileCheckpointStorage + {run_id}-plan-review.json) og
avslutter; --resume <run_id> leser svaret fra --review-inbox i en fersk interpreter. Det
eneste som krysser prosessgrensen er disk.
MAALT FELLE (Verifiseringsloven ansikt 4): list_checkpoints (_checkpoint.py:386-388) svelger
en blokkert deserialisering til en logger.warning og returnerer TOM liste. Uten BEGGE
MagenticPlanReviewRequest/Response i allowed_checkpoint_types feiler en resume som et FRAVAER,
ikke som en feil. _ALLOWED_CHECKPOINT_TYPES har derfor EN kopi, checkpoint_storage er eneste
konstruksjonssted, og en tom listing ved park raiser CheckpointUnreadable i stedet for aa
skrive et spoersmaal ingen kan besvare.
Budsjettet og revisjons-capen spenner over suspensjonen (meter.charge(parked.tokens_spent) +
trace.ledger.extend), ellers faar hver park et helt budsjett paa nytt. Fail-closed paa
ekspertens egen fil: request_id-mismatch, ord utenfor vokabularet og revise uten innhold
refuseres alle ved navn. hitl.pending_plan_reviews er registeret over hvem som venter.
Load-bearing MAALT: 17 tester, TRETTEN mutasjoner alle roede mot HELE suiten, groenn kontroll
1059 passed / 5 skipped, golden demo-transcript.stdout byte-uendret
(ea8c534773acdbe41ae68f2c55724d69aaf8be4f).
EN MUTASJON FALSIFISERTE SUITEN (vakuoes-gate-klassen, tiende gang): detach av
trace.plan_reviews.extend(parked.plan_reviews) lot HELE suiten staa groenn - capen leser
parked.plan_reviews DIREKTE, saa den binder uansett, og de to foerste legene er identiske
under begge implementasjoner. Gaten maatte bli det TREDJE leget, der artefaktet ellers taper
dag 1s revisjon og to ulike planer deler indeks 1. Ny test skrevet mot mutasjonen foerst.
Aerlighets-grenser: hostet flate NEKTER fortsatt (synkron review ville blokkert baade
requesten og event-loekka som svarer /readiness); en park midt i loepet etter en stall har
ingen naabar sti under det skriptede manuset, saa carry-overen som betjener den drives gjennom
en CRAFTED parkert tilstand.
Ordre 20260825T114645Z-6622513622-from-portfolio-optimiser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
464 lines
22 KiB
Python
464 lines
22 KiB
Python
"""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 → <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):
|
|
# 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())
|