feat(major2): proposal-review types, record, error and renderer in a MAF-free module [skip-docs]
Ordre 20260904T173146Z-8102814273-from-portfolio-optimiser, steg 1 av 10. Vokabularet for MAJOR-2-doeren: forespoerselen en ekspert vises, svaret de gir, posten som registreres, feilen en stillhet reiser, og de to rendererne. D5(c): egen MAF-fri modul. Maalt import-grense - generate.py er kallstedet og importerer agent_framework core, men ikke explore; explore.py (F4-soesknenes hjem) importerer agent_framework.orchestrations. Typene der ville enten dratt orkestrerings-importen inn i genererings-stien eller definert typen to ganger. Gatet av tests/test_okf.py::test_okf_is_maf_free. Klassen ER kanalen: ProposalReviewInputError er en RuntimeError og IKKE en ValueError - begge halvdeler assertert, fordi issubclass(X, RuntimeError) alene staar groenn paa en klasse som arver begge. verdict_key INJISERES i renderen (key_of), fordi verdicts.py importerer MAF og run er en sykel herfra - _features_of forblir eneste hjem for regelen. Notice-en sier fra ogsaa paa NULL anmeldelser naar en reviewer VAR tilbudt: et bevisst avvik fra announce-regelens null-er-stillhet-halvdel, fordi en operatoer som ga --proposal-review og ser ingenting ikke kan skille "ingen kandidat ble validert" fra "doeren hang". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
bcf3337d8a
commit
ce4c15e6cb
3 changed files with 423 additions and 0 deletions
217
src/portfolio_optimiser/proposal_review.py
Normal file
217
src/portfolio_optimiser/proposal_review.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"""MAJOR-2 — the synchronous HITL door onto the proposal that is ON THE TABLE.
|
||||
|
||||
The framework already loops the MACHINE's own falsification back into the same run (Step 5,
|
||||
``generate._build_messages(prior_rejection=…)``) and lands a HUMAN's verdict in the *next* run
|
||||
(Step 7's verdict inbox). Between those two there was nothing: no person could speak to a
|
||||
concrete candidate while it still stood. This module is that seam's vocabulary — the request a
|
||||
reviewer is shown, the answer they give, the record of what they said, the error a silence
|
||||
raises, and the two renderers that carry it to disk and to stdout.
|
||||
|
||||
**A MAF-free module, and that placement is measured** (D5(c)). ``generate.py`` is the call site
|
||||
and imports ``agent_framework`` core but not ``explore``; ``explore.py`` — where the F4 plan-review
|
||||
siblings live — imports ``agent_framework.orchestrations``. Defining these types there would
|
||||
either pull the orchestration import into the generation path or define the type twice. A new
|
||||
home imported by both ``generate`` and ``run`` costs no new import edge, and is guarded by
|
||||
``tests/test_okf.py::test_okf_is_maf_free``.
|
||||
|
||||
**Mirrored on F4 by SHAPE, never shared with it.** The terminal door added beside these types
|
||||
is a sibling of ``explore.terminal_plan_reviewer``: streams resolved at call time, the same
|
||||
closed vocabulary, the same re-ask loop, the same rule that end of input is an error and never
|
||||
a sign-off. A common "terminal reviewer" abstraction over the two is the single-use
|
||||
generalisation this repo refuses until a third door exists.
|
||||
|
||||
What the door does NOT do: ``approve`` mints no ``Verdict`` (F2 — a verdict still arrives
|
||||
through ``--decision/--rationale`` or the Step-7 inbox), and a ``revise`` is not a rejection *by
|
||||
the human* — it never writes ``provenance.validator_decision`` and never touches the checker
|
||||
gate. The expert is a third voice that gates nothing except whether one more attempt is spent;
|
||||
what the validator then rules is the validator's (D6).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only, keeps the runtime import surface minimal
|
||||
from portfolio_optimiser.ir import SavingsProposal
|
||||
from portfolio_optimiser.validator import ValidatedProposal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProposalReviewRequest:
|
||||
"""What the reviewer is shown about ONE validated candidate.
|
||||
|
||||
It carries no ``ProvenanceStamp``, and that is a measurement rather than an omission (brief
|
||||
Q1): the stamp is built in ``run.py`` AFTER generation returns, while this request arises
|
||||
INSIDE ``generate_via_llm``'s attempt loop — the only place a ``revise`` can still buy an
|
||||
attempt under the existing bound. A reviewer handed the stamp could not sit where the answer
|
||||
can still be used, and the draft's ``(proposal, verdict, provenance)`` triple is therefore
|
||||
not constructible without the new loop the brief forbids.
|
||||
|
||||
``approach_id`` / ``approach_label`` say WHICH candidate is being asked about: with a
|
||||
commissioned mandate one expert can be asked N times at one terminal, and two candidates that
|
||||
cannot be told apart are two answers that cannot be trusted. ``project_id`` is carried for the
|
||||
same reason one level up — under the multi-base dispatcher the same terminal serves several
|
||||
bases.
|
||||
|
||||
``attempts_remaining`` is what a ``revise`` can ACTUALLY buy: the smaller of the attempt
|
||||
budget's headroom and the shared round ledger's (Step 3). A number computed from
|
||||
``max_attempts`` alone would be a claim the terminal makes about itself that the ledger then
|
||||
refutes — the Fase-3 class.
|
||||
|
||||
``checker_verdict`` is the run-level reasoning gate's answer, read-only (D3). It helps an
|
||||
expert decide whether to spend an attempt; it is NOT part of the decision type and never
|
||||
enters the record, because the two falsifiers are never blended.
|
||||
"""
|
||||
|
||||
project_id: str
|
||||
approach_id: str | None
|
||||
approach_label: str
|
||||
attempt: int
|
||||
attempts_remaining: int
|
||||
proposal: ValidatedProposal
|
||||
checker_verdict: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProposalReviewDecision:
|
||||
"""The reviewer's answer. ``feedback is None`` means approve.
|
||||
|
||||
Two named constructors, the ``explore.PlanReviewDecision`` shape: a bare ``revise`` is
|
||||
refused at CONSTRUCTION, so no surface has to invent for itself whether an empty revision is
|
||||
a decision. Validation, never repair.
|
||||
"""
|
||||
|
||||
feedback: str | None
|
||||
|
||||
@staticmethod
|
||||
def approve() -> ProposalReviewDecision:
|
||||
return ProposalReviewDecision(feedback=None)
|
||||
|
||||
@staticmethod
|
||||
def revise(feedback: str) -> ProposalReviewDecision:
|
||||
if not feedback.strip():
|
||||
raise ValueError("a revision must say what to change; use approve() to take it as is")
|
||||
return ProposalReviewDecision(feedback=feedback)
|
||||
|
||||
|
||||
#: The synchronous HITL seam (MAJOR-2). Given the request, answer it. Called in-process from
|
||||
#: inside the attempt loop, so generation blocks on it exactly as a human at a terminal would —
|
||||
#: which is also why the hosted surface refuses the door outright.
|
||||
ProposalReviewer = Callable[[ProposalReviewRequest], ProposalReviewDecision]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProposalReview:
|
||||
"""One review round trip, recorded VERBATIM.
|
||||
|
||||
``honoured`` means **the attempt this revise bought actually FETCHED a reply** — not "was
|
||||
bought". A revise recorded and then cut by the round ledger before its follow-up fetched
|
||||
reads ``False``, because at that point the expert asked for a change the run never made; the
|
||||
flag is set only after the bought attempt's fetch returns. An ``approve`` is trivially
|
||||
honoured: nothing was asked for.
|
||||
|
||||
``proposal`` is the ``ValidatedProposal`` the expert was LOOKING at, kept whole rather than
|
||||
reduced to a key here, because the key derivation lives in ``run`` (see
|
||||
``proposal_reviews_payload``). Keeping it is what lets the artefact still show the validated
|
||||
candidate when a later ruling replaces it (D6).
|
||||
"""
|
||||
|
||||
approach_id: str | None
|
||||
attempt: int
|
||||
decision: Literal["approve", "revise"]
|
||||
feedback: str
|
||||
honoured: bool
|
||||
proposal: ValidatedProposal
|
||||
|
||||
|
||||
class ProposalReviewInputError(RuntimeError):
|
||||
"""A proposal review was left without an answer: the input ended mid-review.
|
||||
|
||||
**The class IS the channel, decided by three measurements** (brief § Constraints). The F4
|
||||
sibling ``PlanReviewInputError`` is a ``RuntimeError`` that no arm catches, so it escapes as
|
||||
a traceback. A ``ValueError`` would land on ``run.py``'s refusal tuple and print
|
||||
``run refused: …`` — but the argv was fine and the run had already spent tokens, so "refused"
|
||||
mislabels it; worse, ``_fetch_parsed`` catches ``ValueError`` one frame away as a *parse
|
||||
failure*, so a ``ValueError``-shaped human-input error raised inside that frame would be
|
||||
appended verbatim to ``parse_failures`` and the model re-called until the round ledger fired.
|
||||
|
||||
So: a ``RuntimeError``, caught BY NAME at the CLI's full-run dispatch, printed on a distinct
|
||||
``run stopped:`` line with rc 1 — no traceback, and nothing near the parse catch-all. F4's
|
||||
own traceback is an asymmetry this door states rather than fixes.
|
||||
"""
|
||||
|
||||
|
||||
#: The closed answer vocabulary of the terminal door. Two words, matched structurally.
|
||||
_APPROVE_ANSWER: Final = "approve"
|
||||
_REVISE_ANSWER: Final = "revise"
|
||||
|
||||
|
||||
def proposal_reviews_payload(
|
||||
reviews: Sequence[ProposalReview],
|
||||
*,
|
||||
key_of: Callable[[SavingsProposal], str],
|
||||
) -> dict[str, Any]:
|
||||
"""The ONE renderer of the review record for the outbox (the ``tool_call_payload`` form: a
|
||||
pure function in the owning module, consumed by ``outbox``, which stays MAF-free).
|
||||
|
||||
Structured fields, never a rendered string (kø-(y)): "which candidate", "what was said" and
|
||||
"did the run act on it" are three different operative questions and a sentence fuses them.
|
||||
|
||||
``key_of`` is INJECTED rather than derived here, and that is a measurement:
|
||||
``verdicts.verdict_key`` takes a ``ProposalFeatures``, the only derivation from a
|
||||
``SavingsProposal`` is ``run._features_of``, and this module can import neither ``verdicts``
|
||||
(it imports MAF) nor ``run`` (a cycle). Injection keeps ``_features_of`` the single home of
|
||||
that rule (kø-(p)) instead of growing a second one here.
|
||||
|
||||
``p50`` travels beside the key for D6's sake: when an honoured revise's follow-up is rejected
|
||||
and the attempts run out, the run carries the ``Rejection`` — and this is where an operator
|
||||
can still see that a validated candidate existed and that a human asked for it to change.
|
||||
"""
|
||||
return {
|
||||
"reviews": [
|
||||
{
|
||||
"approach_id": review.approach_id,
|
||||
"attempt": review.attempt,
|
||||
"decision": review.decision,
|
||||
"feedback": review.feedback,
|
||||
"honoured": review.honoured,
|
||||
"verdict_key": key_of(review.proposal.proposal),
|
||||
"p50": review.proposal.p50,
|
||||
}
|
||||
for review in reviews
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def proposal_review_notice(reviews: Sequence[ProposalReview], *, offered: bool) -> str | None:
|
||||
"""The ONE renderer for the review line on stdout — ``None`` when no reviewer was offered.
|
||||
|
||||
It takes the already-resolved reviews and the already-resolved flag, never argv, so the
|
||||
printed line and ``RunResult.expert_revisions`` descend from the same fact
|
||||
(``cost_baseline_notice``'s precedent).
|
||||
|
||||
**Zero reviews with a reviewer present still SPEAKS**, and that is a deliberate departure
|
||||
from the announce rule's zero-is-silence half: an operator who passed ``--proposal-review``
|
||||
and sees nothing cannot tell "no candidate was ever validated, so nobody was asked" from
|
||||
"the door hung". The departure is stated in the invariant row rather than left to be
|
||||
discovered as an inconsistency.
|
||||
|
||||
``verdict_notice`` is untouched beside it: "no expert verdict given" stays true under F2,
|
||||
because an ``approve`` here is "stop asking; I take it as is", never an expert verdict.
|
||||
"""
|
||||
if not offered:
|
||||
return None
|
||||
if not reviews:
|
||||
return " proposal review offered, never consulted (no candidate validated)"
|
||||
answers = len(reviews)
|
||||
candidates = len({review.approach_id for review in reviews})
|
||||
approvals = sum(1 for review in reviews if review.decision == _APPROVE_ANSWER)
|
||||
revisions = answers - approvals
|
||||
unhonoured = sum(1 for review in reviews if not review.honoured)
|
||||
tail = f" ({unhonoured} not honoured)" if unhonoured else ""
|
||||
return (
|
||||
f" proposal review: {answers} answer(s) across {candidates} candidate(s) — "
|
||||
f"{approvals} approve, {revisions} revise{tail}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue