S5.2-analog. New notify.py: Notifier protocol + console/file/webhook sinks. The webhook (the one transport that leaves the machine) fires ONLY behind an explicit per-run opt-in flag (--allow-webhook-egress), mirroring ingest-spec §8 (the flag is a run argument, never a config field). Transport is injected — canned in the suite (NULL socket), real transport behind one seam function default_webhook_transport; an AST grep-guard proves no network path exists outside that seam. run.py (both outcomes — a budget stop notifies too) and hitl.py (read-only preserved) share the same opt-in-gated CLI seam, refusing a webhook-without-opt-in before any spend. Payload shape is stack-local (no shared notification spec; divergence documented). Two new load-bearing test files (18 tests): opt-in gate + payload structure + the grep-guard + run/hitl emit wiring + run-level opt-in threading, each detach-proven RED. 544 -> 562 green, full gate clean (ruff+format+mypy strict, 26 src files). README sync (test count x2 + notify.py module note + load-bearing omtale). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiTwaKLesgcwXx2mDviqpt
322 lines
13 KiB
Python
322 lines
13 KiB
Python
"""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 <dir> --inbox <dir>
|
||
uv run python -m portfolio_optimiser_claude.hitl route --outbox <dir> --inbox <dir> \
|
||
--routing <file.json> [--bundle <dir> ...]
|
||
"""
|
||
|
||
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())
|