feat(fase2): LLM->IR generation with validator-as-retry

This commit is contained in:
Kjell Tore Guttormsen 2026-06-24 13:48:54 +02:00
commit 0abc7083df
2 changed files with 188 additions and 0 deletions

View file

@ -0,0 +1,125 @@
"""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) -> list[Message]:
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."
)
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 bounded retries and the
meter checked in this loop. A malformed/text-leaked reply is retried (never silently
accepted); a validation rejection is retried; returns ``ValidatedProposal | Rejection``;
raises ``BudgetExceeded`` when the meter cap is crossed."""
messages = _build_messages(project, context)
async def _fetch_parsed() -> 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):
candidate = await _fetch_parsed()
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