portfolio-optimiser-claude/src/portfolio_optimiser_claude/hitl.py
Kjell Tore Guttormsen a2acfc0f98 feat(portfolio): K10 — notification/notifier seam, opt-in webhook egress (parity row 23) [skip-docs]
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
2026-07-24 20:16:56 +02:00

322 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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())