feat(1b): fang den råe modell-svarteksten ved parse-feil
Fase 1b funn 1 lukket. generate._fetch_parsed kastet hvert uparsebart modellsvar i except: continue, så prosjektets første levende kjøring brant tolv runder på formatfeil og etterlot null tegn av det modellen faktisk sa. Enhver videre betalt kjøring ville vært gjetning. HVOR teksten overflates er avgjort av en måling, ikke av symmetri med Steg 5: meter.tick_round() raiser BudgetExceeded INNE i _fetch_parsed, og uten mandat fanger ingen den, så på nøyaktig den stien fangsten finnes for returnerer generate_via_llm ingenting. Et felt på GenerationResult ville vært blindt for den, og et outbox-artefakt skrevet etter kjøringen likeså. Sinken speiler meter: en kaller-eid akkumulator hvis innhold kalleren holder uansett hvordan løkka endte. Artefaktet skrives fra en finally, ikke except BudgetExceeded, og kun når noe faktisk feilet. Iron Law: testfila rød ved collection FØR modulen fantes. Seks mutasjoner mot HELE suiten, alle røde, hver med sin egen signatur; grønn kontroll 859 passed / 4 skipped (fra 854). Den skarpeste er trunkering som BEHOLDER sentinelen: da faller kun verbatim-asserten, som er det som beviser at den ene testen bærer den egenskapen. Samme økt: mutasjonsmålingen økt 34 utsatte for de to Fase 5-gatene er kjørt. Fire preflight-mutasjoner mot hele suiten, alle røde på riktig test og ingen annen (detach fallbacken 2 røde, snu presedensen, presence i stedet for truthiness, avslaget navngir kun vårt navn). To handover-mutasjoner kjørt MÅLRETTET mot egen testfil under tidspress, ikke mot hele suiten — uttalt, ikke skjult (drop uv.lock, bygg fra arbeidstreet i stedet for tracked files). De to DEPLOY.md-mutasjonene gjenstår: git archive leser HEAD, ikke arbeidstreet, så de krever en midlertidig commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLQd8ojQ9xwxhB8vsETYBs
This commit is contained in:
parent
5bd8e1caa1
commit
e3718908d0
5 changed files with 413 additions and 7 deletions
|
|
@ -44,6 +44,23 @@ class GenerationError(RuntimeError):
|
|||
"""No parseable proposal could be produced within the attempt budget."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParseFailure:
|
||||
"""One model reply that did NOT parse into the typed IR, kept VERBATIM (Fase 1b, funn 1).
|
||||
|
||||
``text`` is the reply exactly as the model produced it — never truncated, stripped or
|
||||
summarised. It is the thing the run PAID for and the only evidence of *why* the reply did not
|
||||
parse; a paraphrase would make the next paid run a guess again, which is the defect this type
|
||||
exists to close. ``error`` names the parse error itself (``json.JSONDecodeError`` vs a pydantic
|
||||
``ValidationError`` are very different diagnoses: leaked prose vs a wrong-shaped object).
|
||||
|
||||
Collected into a CALLER-OWNED sink rather than returned — see ``generate_via_llm``.
|
||||
"""
|
||||
|
||||
text: str
|
||||
error: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GenerationResult:
|
||||
"""What one ``generate_via_llm`` call produced: the outcome, and the falsification history that
|
||||
|
|
@ -163,6 +180,7 @@ async def generate_via_llm(
|
|||
max_attempts: int = 3,
|
||||
baseline: CostBaseline | None = None,
|
||||
approach: Approach | None = None,
|
||||
parse_failures: list[ParseFailure] | None = None,
|
||||
) -> GenerationResult:
|
||||
"""Async LLM path: non-streaming chat -> parse -> validate, with TWO bounded retry kinds,
|
||||
the meter checked in this loop:
|
||||
|
|
@ -189,6 +207,19 @@ async def generate_via_llm(
|
|||
falsified per ATTEMPT like any other rejection — and its reason feeds the next attempt's prompt
|
||||
through the SAME informed-refinement path (Step 5), which is why no new loop appears here.
|
||||
|
||||
``parse_failures`` (Fase 1b, funn 1) is a CALLER-OWNED sink: every reply that fails to parse is
|
||||
appended to it VERBATIM, at the moment it fails. It is an out-parameter and not part of the
|
||||
return value ON PURPOSE, and the reason is measured rather than stylistic. ``meter.tick_round``
|
||||
raises ``BudgetExceeded`` inside the inner fetch loop, so on the path this capture exists for —
|
||||
a model whose replies never parse, which burns the round ledger — this function raises and
|
||||
returns NOTHING. That is exactly the live Fase-1b failure. A field on ``GenerationResult`` (the
|
||||
Step-5 ``refinements`` shape) would be blind to it, as would any artefact written by the caller
|
||||
*after* a successful return. The sink mirrors ``meter`` instead: a caller-owned accumulator this
|
||||
loop mutates, whose contents the caller still holds however the loop ended. Step 5's "a returned
|
||||
value cannot be silently lost by a caller that forgets to pass a collector" governs a value that
|
||||
REACHES the caller; here it does not, so the rule is cited and departed from deliberately. That
|
||||
a caller can forget is answered by a test on the wiring, not by a shape that cannot work.
|
||||
|
||||
Returns a ``GenerationResult``: the ``ValidatedProposal | Rejection`` outcome plus every
|
||||
rejection that was fed back into a later attempt's prompt. Surfacing that history changes
|
||||
nothing about the loop's BOUND — ``max_attempts`` and ``meter.tick_round`` are exactly as
|
||||
|
|
@ -204,7 +235,14 @@ async def generate_via_llm(
|
|||
_charge_usage(meter, reply)
|
||||
try:
|
||||
return _parse_ir(reply.text, project)
|
||||
except (ValidationError, ValueError, TypeError):
|
||||
except (ValidationError, ValueError, TypeError) as exc:
|
||||
# Capture BEFORE the retry: this reply was paid for, and once ``continue`` runs the
|
||||
# only record of what the model actually said is gone (Fase 1b, funn 1). Verbatim —
|
||||
# the operator is diagnosing a format failure, so any shortening removes evidence.
|
||||
if parse_failures is not None:
|
||||
parse_failures.append(
|
||||
ParseFailure(text=reply.text, error=f"{type(exc).__name__}: {exc}")
|
||||
)
|
||||
continue
|
||||
|
||||
last: Rejection | None = None
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ called by ``run_project``, not a public authoring API (contrast ``verdicts.write
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
|
@ -131,6 +132,39 @@ def outcome_payload(
|
|||
}
|
||||
|
||||
|
||||
def write_parse_failures(
|
||||
outbox_dir: str,
|
||||
run_id: str,
|
||||
*,
|
||||
failures: Sequence[Mapping[str, str]],
|
||||
) -> Path:
|
||||
"""Write ``{run_id}-parse-failures.json`` — the raw model replies that did NOT parse into the
|
||||
typed IR (Fase 1b, funn 1) — and return its path.
|
||||
|
||||
**This is the only outbox artefact written from a ``finally``**, because it is the only one whose
|
||||
subject is a run that may never finish: the measured 1b failure exhausted the round ledger inside
|
||||
the generation loop and left ``run_project`` as a ``BudgetExceeded``, so the proposal/outcome
|
||||
writers below were never reached. An artefact that recorded parse failures only for runs that
|
||||
survived them would be silent for exactly the runs that need it.
|
||||
|
||||
Takes plain mappings (the caller flattens ``generate.ParseFailure``), so this module stays
|
||||
MAF-free — ``generate`` imports ``agent_framework``, and importing it here would drag MAF into
|
||||
the RAW output layer.
|
||||
|
||||
Byte-determinism is NOT claimed for this file, unlike its two neighbours: its content is a live
|
||||
model's prose, which is not reproducible by construction. It uses the same ``_dump`` form for
|
||||
consistency of reading, not to pin bytes. The caller writes it only when there is at least one
|
||||
failure, so the file's PRESENCE is itself the signal that something did not parse."""
|
||||
directory = Path(outbox_dir)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{run_id}-parse-failures.json"
|
||||
path.write_text(
|
||||
_dump({"run_id": run_id, "parse_failures": [dict(f) for f in failures]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def write_run_config(
|
||||
config_dir: str,
|
||||
run_id: str,
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ from portfolio_optimiser.datasource import (
|
|||
retrieve_chunks,
|
||||
)
|
||||
from portfolio_optimiser.dimension import Dimension, admits, load_dimension
|
||||
from portfolio_optimiser.generate import generate_via_llm
|
||||
from portfolio_optimiser.generate import ParseFailure, generate_via_llm
|
||||
from portfolio_optimiser.ir import SavingsProposal
|
||||
from portfolio_optimiser.mandate import (
|
||||
OWN_PROPOSAL_ID,
|
||||
|
|
@ -649,20 +649,46 @@ async def run_project(
|
|||
# is untouched, and the history is accumulated here in call order — one entry per approach that
|
||||
# needed correcting, concatenated (see ``RunResult.refinements`` for that honesty limit).
|
||||
refinements: list[Rejection] = []
|
||||
# Fase 1b, funn 1: the raw replies that did not parse. Owned HERE, beside ``meter``, and handed
|
||||
# down — not read back off a return value. ``generate_via_llm`` raises ``BudgetExceeded`` from
|
||||
# inside its own fetch loop when the round ledger runs out on unparseable replies (the measured
|
||||
# live failure), and on that path it returns nothing at all; a caller-owned accumulator is the
|
||||
# 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] = []
|
||||
|
||||
async def _evaluate(approach: Approach | None) -> ValidatedProposal | Rejection:
|
||||
generated = await generate_via_llm(
|
||||
proposer_client, project, gen_context, meter, baseline=baseline, approach=approach
|
||||
proposer_client,
|
||||
project,
|
||||
gen_context,
|
||||
meter,
|
||||
baseline=baseline,
|
||||
approach=approach,
|
||||
parse_failures=parse_failures,
|
||||
)
|
||||
refinements.extend(generated.refinements)
|
||||
return generated.outcome
|
||||
|
||||
coverage: tuple[ApproachOutcome, ...] = ()
|
||||
evaluated: tuple[tuple[str, ValidatedProposal | Rejection], ...] = ()
|
||||
if mandate is None:
|
||||
validator_outcome = await _evaluate(None)
|
||||
else:
|
||||
validator_outcome, coverage, evaluated = await _evaluate_mandate(mandate, _evaluate)
|
||||
try:
|
||||
if mandate is None:
|
||||
validator_outcome = await _evaluate(None)
|
||||
else:
|
||||
validator_outcome, coverage, evaluated = await _evaluate_mandate(mandate, _evaluate)
|
||||
finally:
|
||||
# ``finally``, not ``except BudgetExceeded``: the round ledger is today's known way out, but
|
||||
# any exception leaving generation destroys the same evidence, and a per-exception-type list
|
||||
# is a list that goes stale. Written only when something actually failed to parse, so the
|
||||
# file's presence is the signal (a run whose replies all parse leaves the outbox unchanged).
|
||||
if outbox_dir is not None and parse_failures:
|
||||
assert run_id is not None # narrowed by the step-0 guard (no wall-clock default)
|
||||
outbox.write_parse_failures(
|
||||
outbox_dir,
|
||||
run_id,
|
||||
failures=[{"text": f.text, "error": f.error} for f in parse_failures],
|
||||
)
|
||||
proposal = validator_outcome.proposal
|
||||
|
||||
# 6. First-class provenance stamp (authoritative; independent of MAF Annotation).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue