Closes gap #5 (maalbilde §5/§7): generate_via_llm's outer max_attempts loop built the prompt ONCE and re-sent it identically — a BLIND retry. The validator's per-attempt Rejection.reason was captured in `last` but never reached the next prompt, so the proposer re-answered the same question with no knowledge of WHY the prior candidate failed. Step 5 routes that reason into the next attempt. - generate.py: _build_messages() gains prior_rejection; when set it appends a revision block carrying ONLY the falsification reason verbatim (never the rejected proposal JSON). None -> the byte-identical base prompt, so attempt 1 is unchanged. generate_via_llm() rebuilds messages inside the outer loop with prior_rejection=`last` (None on attempt 1); _fetch_parsed() takes messages as an explicit parameter. `last` is overwritten each round -> only the most-recent falsification ("forrige"), never an accumulated history. Bound unchanged: max_attempts + meter.tick_round (no new loop; §6 — "improve until good enough" without a ceiling stays impossible). - Scope honesty: the only per-attempt falsifier here is the validator. The checker is a run-level, one-shot signal (run.py, before generation); seeding generation with the checker critique is separately scoped and NOT done here. The boundary is written into the generate_via_llm docstring + README + CLAUDE. Load-bearing (maalbilde §7): tests/test_step5_refine_loadbearing.py is a PAIR — the positive test keys the proposer's flip on the validator REASON PAYLOAD (the rejected claim value, derived from validate_proposal(bad).reason so test and SUT share one source of truth), and asserts the reason reached attempt 2's prompt VERBATIM (the green-but-dead guard). It goes RED on detach (build messages once): the flip token never arrives, so the outcome never flips AND the verbatim assertion fails — proven double-red. The bounded control proves a never-fixed proposer exhausts exactly max_attempts and returns a Rejection. Adversarial Plan agent hardened the design pre-implementation (flip on payload not wrapper/call-count; derive flip-key from the validator reason; drive through generate_via_llm directly; docstring honesty). Suite 136->138 passed, 4 skipped; mypy + ruff check clean. New test ruff-formatted; pre-existing ruff-format drift (budget/verdicts/test_contracts) left untouched for a surgical diff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MHR8iKxJRxDiDfNw8HZmWE
154 lines
7 KiB
Python
154 lines
7 KiB
Python
"""LLM->IR generation wired to validator-as-retry (B1 + research 03 Dim 3).
|
|
|
|
A NON-STREAMING chat call asks the model for a structured ``SavingsProposal``; the reply is
|
|
parsed into the typed IR. Small local models leak text or emit wrong-typed tool calls
|
|
(research 03 Dim 3), so a malformed reply is RETRIED — never silently accepted. The
|
|
deterministic validator is the reliability mechanism.
|
|
|
|
The token bound lives HERE, in the generate loop (``meter`` checked between attempts) — so
|
|
``validator.py`` stays the verbatim Step-2 module (it is in this step's ``forbidden_paths``).
|
|
|
|
Two entry points, because the LLM call is async while ``validator.self_repair`` is sync:
|
|
|
|
* ``generate_with_validation`` — the SYNC validator-as-retry primitive: it drives
|
|
``validator.self_repair`` over a sync candidate source and adds the meter bound between
|
|
attempts. Used for deterministic candidate sources.
|
|
* ``generate_via_llm`` — the ASYNC LLM path: an async mirror of the same bounded retry that
|
|
awaits the chat call (parse-retry inside the meter budget, then ``validate_proposal``).
|
|
Returns ``ValidatedProposal | Rejection``; never a malformed proposal; raises
|
|
``BudgetExceeded`` when the meter cap is crossed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Callable
|
|
|
|
from agent_framework import BaseChatClient, Message
|
|
from pydantic import ValidationError
|
|
|
|
from portfolio_optimiser.budget import TokenMeter
|
|
from portfolio_optimiser.ir import SavingsProposal
|
|
from portfolio_optimiser.reference_domain import Project
|
|
from portfolio_optimiser.validator import (
|
|
Rejection,
|
|
ValidatedProposal,
|
|
self_repair,
|
|
validate_proposal,
|
|
)
|
|
|
|
|
|
class GenerationError(RuntimeError):
|
|
"""No parseable proposal could be produced within the attempt budget."""
|
|
|
|
|
|
def _build_messages(
|
|
project: Project, context: str, prior_rejection: Rejection | None = None
|
|
) -> list[Message]:
|
|
"""Build the hypothesis prompt. When ``prior_rejection`` is set (Step 5, målbilde §5/§7),
|
|
append a revision block carrying ONLY the falsification *reason* verbatim — never the prior
|
|
proposal JSON (minimal honest payload: the model must address the falsification, not parrot
|
|
the rejected candidate back). ``None`` -> the byte-identical base prompt, so attempt 1 is
|
|
unchanged. The reason carries only the rejected claim/feasible figures, which deliberately
|
|
do not collide with other load-bearing prompt markers."""
|
|
prompt = (
|
|
"Propose ONE concrete cost-saving measure for this project.\n"
|
|
f"Project: {project.id} - {project.name}\n"
|
|
f"Context (prior verdicts / cited cost docs):\n{context}\n\n"
|
|
"Respond with ONLY a JSON object for a SavingsProposal with keys: project_id, "
|
|
"measure, affected_items (list of {code, quantity, unit_cost}), claimed_saving_nok, "
|
|
"and optional assumptions."
|
|
)
|
|
if prior_rejection is not None:
|
|
prompt += (
|
|
"\n\nYour previous proposal was REJECTED by the deterministic validator.\n"
|
|
f"Reason: {prior_rejection.reason}\n"
|
|
"Produce a REVISED SavingsProposal that resolves this."
|
|
)
|
|
return [Message(role="user", contents=[prompt])]
|
|
|
|
|
|
def _parse_ir(text: str, project: Project) -> SavingsProposal:
|
|
"""Parse the model's structured reply into the typed IR. Raises on malformed/text-leaked
|
|
output (JSON error or Pydantic ``ValidationError``)."""
|
|
data = json.loads(text)
|
|
if not isinstance(data, dict):
|
|
raise ValueError("reply is not a JSON object")
|
|
data.setdefault("project_id", project.id)
|
|
return SavingsProposal(**data)
|
|
|
|
|
|
def _charge_usage(meter: TokenMeter, reply: object) -> None:
|
|
usage = getattr(reply, "usage_details", None)
|
|
total = usage.get("total_token_count") if usage else None
|
|
if total:
|
|
meter.charge(int(total)) # raises BudgetExceeded over cap
|
|
|
|
|
|
def generate_with_validation(
|
|
make_proposal: Callable[[int], SavingsProposal],
|
|
meter: TokenMeter,
|
|
*,
|
|
max_attempts: int = 3,
|
|
) -> ValidatedProposal | Rejection:
|
|
"""Sync validator-as-retry: drive ``validator.self_repair`` over a sync candidate source,
|
|
checking the token meter between attempts (the token bound lives HERE, never in
|
|
``validator.py``). Returns ``ValidatedProposal | Rejection``; raises ``BudgetExceeded`` on
|
|
a meter cap."""
|
|
|
|
def _attempt(attempt: int) -> SavingsProposal:
|
|
meter.tick_round() # between-attempt iteration bound (BudgetExceeded over cap)
|
|
return make_proposal(attempt)
|
|
|
|
return self_repair(_attempt, max_attempts=max_attempts)
|
|
|
|
|
|
async def generate_via_llm(
|
|
chat_client: BaseChatClient,
|
|
project: Project,
|
|
context: str,
|
|
meter: TokenMeter,
|
|
*,
|
|
max_attempts: int = 3,
|
|
) -> ValidatedProposal | Rejection:
|
|
"""Async LLM path: non-streaming chat -> parse -> validate, with TWO bounded retry kinds,
|
|
the meter checked in this loop:
|
|
|
|
* malformed/text-leaked reply -> BLIND parse-retry (inner loop): re-fetch until the reply
|
|
parses; never silently accepted.
|
|
* validator rejection -> INFORMED refinement (outer ``max_attempts`` loop; Step 5,
|
|
målbilde §5/§7): the previous attempt's ``Rejection.reason`` is fed into the next
|
|
attempt's prompt (``_build_messages(prior_rejection=...)``) so the proposer can correct
|
|
rather than re-answer blindly. Bounded by ``max_attempts`` + the meter (no new loop; §6).
|
|
|
|
The only per-attempt falsifier here is the deterministic validator (the numbers). The
|
|
checker is a run-level, one-shot signal (run.py, before generation); seeding generation
|
|
with the checker critique is separately scoped and NOT done here. Returns
|
|
``ValidatedProposal | Rejection``; never a malformed proposal; raises ``BudgetExceeded``
|
|
when the meter cap is crossed."""
|
|
|
|
async def _fetch_parsed(messages: list[Message]) -> SavingsProposal:
|
|
# Parse-robust: a malformed/text-leaked reply is retried; the meter caps total work.
|
|
while True:
|
|
meter.tick_round() # between-attempt bound (BudgetExceeded over cap)
|
|
reply = await chat_client.get_response(messages) # non-streaming
|
|
_charge_usage(meter, reply)
|
|
try:
|
|
return _parse_ir(reply.text, project)
|
|
except (ValidationError, ValueError, TypeError):
|
|
continue
|
|
|
|
last: Rejection | None = None
|
|
for _ in range(max_attempts):
|
|
# Informed refinement: feed the PREVIOUS attempt's validator rejection into this
|
|
# attempt's prompt. ``last`` is None on attempt 1 -> the unchanged base prompt; it is
|
|
# overwritten each round -> only the most-recent falsification ("forrige"), never an
|
|
# accumulated history (bounded prompt growth).
|
|
messages = _build_messages(project, context, prior_rejection=last)
|
|
candidate = await _fetch_parsed(messages)
|
|
result = validate_proposal(candidate)
|
|
if isinstance(result, ValidatedProposal):
|
|
return result
|
|
last = result
|
|
assert last is not None # max_attempts >= 1, so at least one validation ran
|
|
return last # validation never passed within the attempt budget -> typed Rejection
|