pythonise-exercice / app /pipeline /solutions.py
wilenPxs
Politique de modeles multi-fournisseurs + retrait de Fable + durcissements
81836d8
Raw
History Blame Contribute Delete
3.73 kB
"""
solutions.py
────────────
Substitution déterministe des solutions validées (règle 8.1 — directive
Chabane) : chaque ::::{detailedSolution} générée est remplacée par la version
SOURCE dont seules les valeurs littérales deviennent des {{var}}.
Déplacé depuis routes/pythonise_routes_v2.py (logique inchangée ; gestion
d'erreurs ciblée).
"""
from __future__ import annotations
import logging
import re
from typing import Optional
from app.llm.client import process_with_openrouter
from app.pipeline.postprocess import extract_detailed_solutions, strip_fences
from app.pipeline.prompts import SOLUTION_SUBSTITUTION_PROMPT, SYSTEM_PROMPT
logger = logging.getLogger(__name__)
_DETAILED_SOLUTION_RE = re.compile(
r"::::\{detailedSolution\}\s*\n(.*?)\n::::",
re.DOTALL,
)
def _substitute_solution_via_llm(
source_text: str,
analysis: dict,
model_idx: int,
model: Optional[str] = None,
) -> Optional[str]:
"""Appel LLM ciblé : valeurs littérales de la solution source → {{var}}.
None en cas d'échec (l'appelant garde alors la solution générée)."""
variables = analysis.get("variables") or []
if not variables or not source_text.strip():
return None
variables_table = "\n".join(
f" • `{v['nom']}` (valeur exemple: `{v.get('valeur_exemple', '?')}`)"
f" — {v.get('description', '')[:60]}"
for v in variables
if isinstance(v, dict) and v.get("nom")
)
if not variables_table:
return None
try:
raw = process_with_openrouter(
prompt=SOLUTION_SUBSTITUTION_PROMPT.format(
original_solution=source_text,
variables_table=variables_table,
),
model_idx=model_idx,
model=model,
temperature=0.0,
max_tokens=4096,
system_prompt=SYSTEM_PROMPT,
)
except (RuntimeError, ValueError, OSError) as e:
logger.warning("Substitution de solution en échec : %s", e)
return None
text = strip_fences(raw).strip()
return text or None
def replace_gen_solutions_with_source(
myst_exercise: str,
source_content: str,
analysis: dict,
model_idx: int,
model: Optional[str] = None,
) -> tuple[str, list[dict]]:
"""Remplace chaque detailedSolution générée par la version source
substituée (correspondance positionnelle). Retourne (exercice, patches)."""
source_solutions = extract_detailed_solutions(source_content)
if not source_solutions:
return myst_exercise, []
patches: list[dict] = []
idx = {"i": 0}
def _sub(match):
i = idx["i"]
idx["i"] += 1
if i >= len(source_solutions):
return match.group(0)
src = source_solutions[i]
if not src.strip():
return match.group(0)
substituted = _substitute_solution_via_llm(src, analysis, model_idx, model=model)
if not substituted:
return match.group(0)
new_block = f"::::{{detailedSolution}}\n{substituted}\n::::"
if substituted.strip() != match.group(1).strip():
patches.append({
"rule": "8.1",
"location": f"detailedSolution Q{i + 1}",
"fix": "substitution déterministe (source préservée)",
"message": (f"Question {i + 1}: detailedSolution remplacée par la version "
"source (valeurs littérales → {{var}}), pédagogie préservée "
"(règle 8.1)."),
"iteration": 0,
})
return new_block
return _DETAILED_SOLUTION_RE.sub(_sub, myst_exercise), patches