""" 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