feat(major2): run_project owns the review sink, keys it per approach and writes it from a finally [skip-docs]
Ordre 20260904T173146Z-8102814273-from-portfolio-optimiser, steg 4 av 10.
run_project faar proposal_reviewer (keyword-only, None => byte-identisk kjoering), eier
sinken expert_reviews ved siden av parse_failures, og skriver
{run_id}-proposal-reviews.json fra den EKSISTERENDE genererings-finally-en.
Skriveregelen er IFF en reviewer ble gitt, OGSAA naar lista er tom (D4). Begge halvdeler
er baerende og trekker hver sin vei: write_debate_tools skriver ubetinget fordi DER er det
tomme tilfellet regresjonen; her maa en reviewer-LOES kjoering la utboksen staa byte-identisk
(to eksisterende tester pinner et EKSAKT fire-navns-listing), mens en reviewer som ble tilbudt
og aldri konsultert er et faktum artefaktet maa kunne SI.
Noeklingen: med mandat er hver post noeklet - kjoeringens eget forslag paa OWN_PROPOSAL_ID -
og None betyr kun EN ting: det fantes intet mandat. RunResult.expert_revisions bygges FRA
sinken, aldri ved siden av (kø-(p)).
RODT foer impl: 8 armer.
REGRESJON FANGET AV FULL SUITE OG RETTET HER: steg 3s _FeedbackAwareChatClient kopierte
_inner_get_response-kroppen og gjorde test_scripted_client_consolidation
::test_inner_get_response_collapsed_to_two_sites roed. Doblen overstyrer naa _next_reply i
stedet - basen har alt lagt DENNE kallets prompt i received_texts naar den ber om et svar,
saa sommen holder uten en tredje kopi av kroppen, og registeret i vakten trenger ingen ny
oppfoering. Aa registrere fila som foreign lineage var ikke mulig og heller ikke riktig:
Group B bruker ScriptedChatClient, som den vakten nekter for nettopp den lista.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
bbf4d3b5b6
commit
59f3fce20a
3 changed files with 410 additions and 27 deletions
|
|
@ -248,6 +248,34 @@ def write_plan_review(
|
|||
return path
|
||||
|
||||
|
||||
def write_proposal_reviews(
|
||||
outbox_dir: str,
|
||||
run_id: str,
|
||||
*,
|
||||
payload: Mapping[str, Any],
|
||||
) -> Path:
|
||||
"""Write ``{run_id}-proposal-reviews.json`` — what a human answered about the proposals this
|
||||
run put on the table (MAJOR-2) — and return its path.
|
||||
|
||||
Takes an already-rendered plain mapping (``proposal_review.proposal_reviews_payload``) for the
|
||||
reason ``write_plan_review`` does: the renderer lives in the module that owns the type, and
|
||||
this layer stays MAF-free. The writer prepends ``run_id`` exactly as its sibling does, so the
|
||||
on-disk object is ``{"reviews": [...], "run_id": ...}`` under ``_dump``'s sorted keys.
|
||||
|
||||
**The write rule is: iff a reviewer was given, INCLUDING when the list is empty** (D4).
|
||||
Both halves are load-bearing and they pull in opposite directions. ``write_debate_tools``
|
||||
writes unconditionally because THERE the empty case is the regression; here a reviewer-LESS
|
||||
run must leave the outbox byte-identical, and two existing tests pin an exact four-name
|
||||
listing on such a run. But a reviewer that was offered and never consulted — no candidate ever
|
||||
validated — is a fact this artefact must be able to STATE, not something an operator has to
|
||||
infer from a missing file. "Iff a reviewer was given" is the only rule that keeps both."""
|
||||
directory = Path(outbox_dir)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{run_id}-proposal-reviews.json"
|
||||
path.write_text(_dump({"run_id": run_id, **dict(payload)}), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def write_run_config(
|
||||
config_dir: str,
|
||||
run_id: str,
|
||||
|
|
|
|||
|
|
@ -101,6 +101,11 @@ from portfolio_optimiser.mcp_tools import (
|
|||
service_labels,
|
||||
tool_server_index,
|
||||
)
|
||||
from portfolio_optimiser.proposal_review import (
|
||||
ProposalReview,
|
||||
ProposalReviewer,
|
||||
proposal_reviews_payload,
|
||||
)
|
||||
from portfolio_optimiser.provenance import ProvenanceStamp
|
||||
from portfolio_optimiser.reference_domain import Project, load_reference_projects
|
||||
from portfolio_optimiser.tracing import TracingConfigError, configure_tracing, tracing_notice
|
||||
|
|
@ -213,6 +218,19 @@ class RunResult:
|
|||
#: The RESULT of each call is deliberately absent — that is the base's content, i.e. the very
|
||||
#: thing too big to ride along (``ToolCall``'s own rule, MAJOR-1).
|
||||
debate_tool_calls: tuple[ToolCall, ...] = ()
|
||||
#: What a human answered about each candidate the validator accepted, in order (MAJOR-2).
|
||||
#: Built FROM the caller-owned sink ``run_project`` hands to ``generate_via_llm``, never
|
||||
#: accumulated beside it: two containers holding one fact drift (kø-(p)), and a drifted pair
|
||||
#: would let this result and the written artefact describe different runs.
|
||||
#:
|
||||
#: KEYED per approach, unlike ``refinements`` above — a human wrote these words about ONE
|
||||
#: specific candidate, and an artefact that cannot say which is one nobody can act on. EMPTY
|
||||
#: is an honest POSITIVE statement ("nobody was asked, or nothing validated"), so it defaults,
|
||||
#: exactly as ``skipped_links`` does.
|
||||
#:
|
||||
#: Carried HERE and on neither other carrier: ``ProvenanceStamp`` describes the gate that
|
||||
#: judged ONE candidate, and ``DryRunReport`` returns above generation entirely.
|
||||
expert_revisions: tuple[ProposalReview, ...] = ()
|
||||
|
||||
@property
|
||||
def verdict_key(self) -> str:
|
||||
|
|
@ -832,6 +850,12 @@ async def run_project(
|
|||
embedder: Embedder | None = None,
|
||||
mandate: Mandate | None = None,
|
||||
mcp_servers: tuple[McpServerConfig, ...] = (),
|
||||
#: MAJOR-2: the synchronous HITL door onto a candidate the deterministic validator has just
|
||||
#: ACCEPTED. ``None`` (the default) is byte-identical to a pre-MAJOR-2 run — same prompts, same
|
||||
#: outbox files, same golden transcript. Given one, it is called once per validated attempt of
|
||||
#: every commissioned approach, and a ``revise`` buys ONE more attempt out of the budget the
|
||||
#: loop already has. It mints no verdict and gates nothing (F2).
|
||||
proposal_reviewer: ProposalReviewer | None = None,
|
||||
) -> RunResult | DryRunReport:
|
||||
"""Run the vertical slice for ONE project. ``client_factory`` is the test-injection seam
|
||||
(defaults to the real backend). ``verdict_input`` carries the expert decision/rationale
|
||||
|
|
@ -1110,8 +1134,24 @@ async def run_project(
|
|||
# only shape that still holds the evidence afterwards. Concatenated across commissioned
|
||||
# approaches rather than keyed per approach, mirroring ``RunResult.refinements``' honesty limit.
|
||||
parse_failures: list[ParseFailure] = []
|
||||
# MAJOR-2: what a human answered about each validated candidate. A caller-owned sink for the
|
||||
# same measured reason ``parse_failures`` is one, one notch sharper: the round ledger can fire
|
||||
# on the very attempt a revise bought, and on that path ``generate_via_llm`` returns nothing —
|
||||
# so the run whose record matters most is exactly the one a return value cannot reach.
|
||||
expert_reviews: list[ProposalReview] = []
|
||||
|
||||
async def _evaluate(approach: Approach | None) -> ValidatedProposal | Rejection:
|
||||
# Which candidate the expert is being asked about. With a mandate every entry is keyed —
|
||||
# the run's own proposal by ``OWN_PROPOSAL_ID``, the first-class row ``_evaluate_mandate``
|
||||
# already uses — and ``None`` means only one thing: there was no mandate at all. Recording
|
||||
# ``None`` for the own proposal would make it indistinguishable from a non-mandate run's
|
||||
# entry, which is the property keying exists for.
|
||||
if approach is not None:
|
||||
review_key: tuple[str | None, str | None] = (approach.id, approach.label)
|
||||
elif mandate is not None:
|
||||
review_key = (OWN_PROPOSAL_ID, "the system's own proposal")
|
||||
else:
|
||||
review_key = (None, project.id)
|
||||
generated = await generate_via_llm(
|
||||
proposer_client,
|
||||
project,
|
||||
|
|
@ -1120,6 +1160,13 @@ async def run_project(
|
|||
baseline=baseline,
|
||||
approach=approach,
|
||||
parse_failures=parse_failures,
|
||||
reviewer=proposal_reviewer,
|
||||
reviews=expert_reviews,
|
||||
review_key=review_key,
|
||||
# D3: the reasoning gate's own answer, READ-ONLY. An expert deciding whether to spend
|
||||
# an attempt is helped by knowing it; it never enters the record, because the two
|
||||
# falsifiers are never blended.
|
||||
checker_verdict=checker_decision,
|
||||
)
|
||||
refinements.extend(generated.refinements)
|
||||
return generated.outcome
|
||||
|
|
@ -1143,6 +1190,19 @@ async def run_project(
|
|||
run_id,
|
||||
failures=[{"text": f.text, "error": f.error} for f in parse_failures],
|
||||
)
|
||||
# Same ``finally``, different write rule: IFF a reviewer was given, including when the
|
||||
# list is empty (D4). A reviewer-less run must leave the outbox byte-identical, while a
|
||||
# reviewer that was offered and never consulted is a fact the artefact must be able to
|
||||
# state rather than one an operator infers from an absent file.
|
||||
if outbox_dir is not None and proposal_reviewer is not None:
|
||||
assert run_id is not None # narrowed by the step-0 guard (no wall-clock default)
|
||||
outbox.write_proposal_reviews(
|
||||
outbox_dir,
|
||||
run_id,
|
||||
payload=proposal_reviews_payload(
|
||||
expert_reviews, key_of=lambda p: verdict_key(_features_of(p))
|
||||
),
|
||||
)
|
||||
proposal = validator_outcome.proposal
|
||||
|
||||
# 6. First-class provenance stamp (authoritative; independent of MAF Annotation).
|
||||
|
|
@ -1299,6 +1359,7 @@ async def run_project(
|
|||
skipped_links=skipped_links,
|
||||
unkeyed_verdicts=unkeyed_verdicts,
|
||||
debate_tool_calls=tuple(debate_tool_calls),
|
||||
expert_revisions=tuple(expert_reviews),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue