wilenPyxi
QCM : questions qualitatives + reponse=objet complet + notations par langue (intervalles/decimales via variables FR/EN, pas de py) ; garde-fou : skip du RAG si quota OpenAI epuise (429)
7e0a262
Raw
History Blame Contribute Delete
5.75 kB
"""
analyze.py
──────────
Étape 0 du pipeline : analyse LLM (variables/règles/invariants), retriever de
notions, RAG fonctions — les trois sont INDÉPENDANTS et lancés en parallèle
(gain de latence sans aucun impact sur la correction).
Corrige au passage (vs v1) :
• model_idx=2 codé en dur → ANALYSIS_MODEL_IDX (None = modèle utilisateur) ;
• typo needs_matplolib → needs_matplotlib (les deux lues en transition,
une seule orthographe en sortie) ;
• top_k=3 → RAG_TOP_K (10).
"""
from __future__ import annotations
import json
import logging
from concurrent.futures import ThreadPoolExecutor
from app.config import ANALYSIS_MODEL_IDX, NOTIONS_XLSX, RAG_EMBEDDING_MODEL, RAG_TOP_K
from app.knowledge.rules_digest import ALL_RULE_IDS, RULES_BY_ID
from app.llm.client import process_with_openrouter
from app.pipeline.postprocess import strip_fences
from app.pipeline.prompts import STEP1_PROMPT
from app.rag.functions import retrieve_functions_context
from app.rag.notions import enrich_exercise_with_notions
logger = logging.getLogger(__name__)
# Garde-fou : dès que les embeddings OpenAI renvoient un quota épuisé (429), on
# arrête d'appeler le RAG fonctions pour le reste de la session (sinon chaque job
# repaie 3 retries + ~10 s pour un catalogue de toute façon vide). Réactivé au
# prochain démarrage (une fois le compte OpenAI rechargé).
_RAG_STATE = {"off": False}
def _is_quota_error(exc: Exception) -> bool:
s = str(exc).lower()
return "insufficient_quota" in s or "quota" in s or "429" in s
def _rules_menu() -> str:
return "\n".join(f" - {rid}{RULES_BY_ID[rid]['title']}" for rid in ALL_RULE_IDS)
_ANALYSIS_FALLBACK = {
"exercise_type": "Général",
"exercise_title": "Exercice",
"suggested_concepts": [],
"nb_questions": 2,
"variables": [],
"needs_fraction": False,
"needs_sympy": False,
"needs_numpy": False,
"needs_matplotlib": False,
"mathematical_structure": "Non déterminé",
"target_rules": [],
"property_constraints": [],
"has_validated_solution_in_input": False,
}
def _parse_analysis(raw: str, content: str) -> dict:
try:
analysis = json.loads(strip_fences(raw))
if not isinstance(analysis, dict):
raise json.JSONDecodeError("not a dict", raw, 0)
except json.JSONDecodeError:
logger.warning("Analyse LLM : JSON invalide — fallback générique utilisé.")
analysis = dict(_ANALYSIS_FALLBACK)
analysis["exercise_summary"] = content[:300]
# Transition typo v1 : accepter needs_matplolib, ne sortir QUE needs_matplotlib.
if "needs_matplolib" in analysis:
analysis["needs_matplotlib"] = bool(
analysis.get("needs_matplotlib") or analysis.pop("needs_matplolib")
)
analysis.setdefault("needs_matplotlib", False)
return analysis
def run_analysis_phase(content: str, model_idx: int,
model: str | None = None) -> tuple[dict, str, str, str]:
"""
Lance EN PARALLÈLE : analyse LLM, notions, RAG fonctions.
Retourne (analysis, notions_ctx, lists_of_notions, functions_ctx).
Une erreur sur notions/RAG est dégradée en contexte vide (warning loggé) ;
une erreur sur l'analyse LLM est propagée (le pipeline n'a pas de sens sans).
`model` (ID chaîne) prime sur model_idx — sous policy, l'analyse relève du
rôle `mecanique` (classification, §2 du prompt banc).
"""
analysis_model = ANALYSIS_MODEL_IDX if ANALYSIS_MODEL_IDX is not None else model_idx
with ThreadPoolExecutor(max_workers=3, thread_name_prefix="analyse") as pool:
f_analysis = pool.submit(
process_with_openrouter,
prompt=STEP1_PROMPT.format(content=content, available_rules_menu=_rules_menu()),
model_idx=analysis_model,
model=model,
max_tokens=6096,
)
f_notions = pool.submit(enrich_exercise_with_notions, content, xlsx_path=NOTIONS_XLSX)
# RAG fonctions : sauté si les embeddings OpenAI sont déjà « à sec ».
f_functions = None if _RAG_STATE["off"] else pool.submit(
retrieve_functions_context,
exercise=content,
embedding_model=RAG_EMBEDDING_MODEL,
top_k=RAG_TOP_K,
force_rebuild=False,
)
raw_analysis = f_analysis.result()
try:
notions_ctx, lists_of_notions = f_notions.result()
except Exception as e:
logger.warning("Retriever de notions en échec (%s) — contexte vide.", e)
notions_ctx, lists_of_notions = "", ""
if f_functions is None:
functions_ctx = "" # RAG désactivé (quota OpenAI épuisé plus tôt)
else:
try:
functions_ctx = f_functions.result()["catalogue"]
except Exception as e:
functions_ctx = ""
if _is_quota_error(e):
_RAG_STATE["off"] = True
logger.warning(
"RAG fonctions DÉSACTIVÉ pour la session : quota "
"d'embeddings OpenAI épuisé (429). Recharge le compte "
"OpenAI (platform.openai.com/billing) pour le réactiver.")
else:
logger.warning("RAG fonctions en échec (%s) — catalogue vide.", e)
analysis = _parse_analysis(raw_analysis, content)
logger.info("Analyse : type=%s, %d variables, %d règles ciblées",
analysis.get("exercise_type"), len(analysis.get("variables") or []),
len(analysis.get("target_rules") or []))
return analysis, notions_ctx, lists_of_notions, functions_ctx