diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..677d0089b59f692ce0647bde2fbcddc88a6fbb40 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +# Ne JAMAIS embarquer les secrets ni le venv dans l'image. +.env +.venv/ +venv/ +__pycache__/ +*.pyc +*.log +tests/ + +# Caches FAISS inutiles en déploiement (embeddings locaux non utilisés). +# On GARDE uniquement openai-3-small (défaut, ~1,2 Mo). +data/faiss_cache/sources/codesearch-distilroberta/ +data/faiss_cache/sources/mpnet-multilingual/ +data/faiss_cache/sources/openai-3-large/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..0cb501b11290136971c4f847fbc8aca35615139c --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# OpenRouter — used for all LLM calls (https://openrouter.ai/keys) +OPENROUTER_API_KEY= + +# OpenAI — used by the RAG embeddings (openai-3-small) (https://platform.openai.com/api-keys) +OPENAI_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..4379f501c93b7761b98f1bf04ea7deaa313cda27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Secrets +.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +env/ +*.egg-info/ +.pytest_cache/ + +# OS / editor +.DS_Store +.vscode/ +.idea/ +*.swp + +# Runtime +*.log +static/uploads/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..7783a481f5ac625ff3baa1719c13266135645044 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# Image de déploiement pour Hugging Face Spaces (SDK Docker). +# Slim : pas de torch/sentence-transformers (le défaut openai-3-small passe par +# l'API ; l'import HuggingFace est devenu paresseux). Cache FAISS openai-3-small +# (~1,2 Mo) embarqué → aucune reconstruction au démarrage. + +FROM python:3.11-slim + +# libgomp1 : requis par faiss-cpu. +RUN apt-get update && apt-get install -y --no-install-recommends libgomp1 \ + && rm -rf /var/lib/apt/lists/* + +# HF Spaces exécute le conteneur en utilisateur 1000 (bonne pratique). +RUN useradd -m -u 1000 user +USER user +ENV HOME=/home/user \ + PATH=/home/user/.local/bin:$PATH \ + HOST=0.0.0.0 \ + PORT=7860 \ + MPLCONFIGDIR=/tmp/mpl \ + PYTHONUNBUFFERED=1 + +WORKDIR /home/user/app + +COPY --chown=user requirements-deploy.txt . +RUN pip install --no-cache-dir --user -r requirements-deploy.txt + +COPY --chown=user . . + +EXPOSE 7860 + +# UN SEUL worker (job store en mémoire + threads d'arrière-plan partagés), +# plusieurs threads pour que le polling et un job tournent en parallèle. +CMD ["gunicorn", "-w", "1", "--threads", "8", "--timeout", "120", \ + "-b", "0.0.0.0:7860", "run:app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..7ba69c522b0db772de0873442852e2cc6c4a7c4f --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +--- +title: Pythonise Exercice +emoji: 🐍 +colorFrom: gray +colorTo: indigo +sdk: docker +app_port: 7860 +pinned: false +--- + +# Pythonise Exercice — PyxiScience + +App interne de pythonisation d'exercices MyST. **Protégée par mot de passe** +(variable `APP_PASSWORD`). Configurer les Secrets du Space : +`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `APP_PASSWORD` (et `APP_USER`, optionnel). diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..602e8127486a787f85a83cac240b00a11e90d0c0 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,45 @@ +""" +App « Pythonise Exercice v2 » — package applicatif. + + from app import create_app + app = create_app() + +Lancement : `python run.py` (ou `python -m app`). +""" + +import logging +from pathlib import Path + + +def _load_env() -> None: + """Charge .env AVANT tout import qui lit les clés.""" + try: + from dotenv import load_dotenv + load_dotenv(Path(__file__).resolve().parent.parent / ".env") + except ImportError: + logging.getLogger(__name__).warning( + "python-dotenv absent ; variables d'environnement du shell utilisées.") + + +def _setup_logging() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-7s %(name)s — %(message)s", + datefmt="%H:%M:%S", + ) + logging.getLogger("urllib3").setLevel(logging.WARNING) + logging.getLogger("werkzeug").setLevel(logging.WARNING) + + +def create_app(): + _setup_logging() + _load_env() + + from flask import Flask + from app.config import TEMPLATES_DIR + from app.server import register_routes + + flask_app = Flask(__name__, template_folder=str(TEMPLATES_DIR)) + flask_app.config["JSON_SORT_KEYS"] = False + register_routes(flask_app) + return flask_app diff --git a/app/__main__.py b/app/__main__.py new file mode 100644 index 0000000000000000000000000000000000000000..422b5836ea0bd8885296d0b3e10c45944c750143 --- /dev/null +++ b/app/__main__.py @@ -0,0 +1,6 @@ +"""`python -m app` → serveur de dev (reloader OFF : FAISS/HF lourds à charger).""" + +from app import create_app +from app.config import HOST, PORT + +create_app().run(host=HOST, port=PORT, debug=True, use_reloader=False) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000000000000000000000000000000000000..e7570d18ea02e214360718f756a47b6219084660 --- /dev/null +++ b/app/config.py @@ -0,0 +1,78 @@ +""" +config.py +───────── +Configuration centrale de l'app « Pythonise Exercice v2 ». +Toutes les constantes réglables vivent ici — une seule source de vérité. +""" + +import os +from pathlib import Path + +# ── Chemins ────────────────────────────────────────────────────────────────── +PACKAGE_DIR = Path(__file__).resolve().parent # …/pythonisation_app/app +BASE_DIR = PACKAGE_DIR.parent # …/pythonisation_app +DATA_DIR = BASE_DIR / "data" +NOTIONS_XLSX = DATA_DIR / "notions.xlsx" +FAISS_CACHE = DATA_DIR / "faiss_cache" / "sources" +CORPUS_DIR = PACKAGE_DIR / "corpus" # 5 fichiers de fonctions PyxiScience +KNOWLEDGE_DIR = PACKAGE_DIR / "knowledge" +RULES_MD = KNOWLEDGE_DIR / "pythonisation_rules.md" +FEWSHOTS_DIR = KNOWLEDGE_DIR / "fewshots" +TEMPLATES_DIR = PACKAGE_DIR / "web" / "templates" + +# ── Convention MyST (vérifiée empiriquement : 222/222 exemples plateforme) ─── +# Bloc {python} = 4 backticks ; enveloppe {exercise} = 5 backticks. +PYTHON_FENCE_BACKTICKS = 4 +EXERCISE_FENCE_BACKTICKS = 5 + +# ── Modèles LLM (IDs vérifiés sur l'API OpenRouter le 2026-06-12) ──────────── +AVAILABLE_MODELS = { + 0: "anthropic/claude-opus-4.8", + 1: "anthropic/claude-sonnet-4.6", + 2: "anthropic/claude-fable-5", + 3: "anthropic/claude-haiku-4.5", + 4: "google/gemini-2.5-pro", + 5: "openai/gpt-5.2", +} +DEFAULT_MODEL_IDX = 1 # claude-sonnet-4.6 + +# Modèle de l'étape d'analyse : None = suivre le modèle choisi par l'utilisateur +# (corrige le model_idx=2 codé en dur de l'ancienne version) ; un int force un +# modèle dédié pour l'analyse. +ANALYSIS_MODEL_IDX: int | None = None + +# Modèle du juge de notions (appel léger, JSON court). DOIT supporter +# response_format=json_object côté OpenRouter (modèles OpenAI — les Claude +# le rejettent et le retriever dégrade en contexte vide). +NOTIONS_MODEL = "openai/gpt-5-mini" + +# Prix $/M tokens (fallback si l'API generation ne renvoie pas le coût réel). +# Relevés sur openrouter.ai le 2026-06-12. +MODEL_PRICING = { + "anthropic/claude-opus-4.8": {"input": 5.0, "output": 25.0}, + "anthropic/claude-sonnet-4.6": {"input": 3.0, "output": 15.0}, + "anthropic/claude-fable-5": {"input": 10.0, "output": 50.0}, + "anthropic/claude-haiku-4.5": {"input": 1.0, "output": 5.0}, + "google/gemini-2.5-pro": {"input": 1.25, "output": 10.0}, + "openai/gpt-5.2": {"input": 1.75, "output": 14.0}, +} + +# ── Pipeline ───────────────────────────────────────────────────────────────── +RAG_TOP_K = 10 # catalogue RAG (était 3 — trop étroit) +RAG_EMBEDDING_MODEL = "openai-3-small" +MAX_AUDIT_ITERATIONS = 2 +USE_REASONING = False # extended thinking sur les appels de génération +REASONING_CONFIG = {"max_tokens": 4000} # utilisé seulement si USE_REASONING +MULTI_SEED_NUM = 100 # graines de la validation d'invariants (règle 4.3) +HARNESS_GATE_SEEDS = 100 # graines de la porte harnais en fin de pipeline +HARNESS_REPAIR_MAX = 2 # boucles de réparation LLM si la porte est rouge + +# ── Langue cible ───────────────────────────────────────────────────────────── +DEFAULT_LANG = "fr" # "fr" | "en" | "both" + +# ── Serveur / jobs ─────────────────────────────────────────────────────────── +JOB_TTL = 1800 # s avant purge d'un job terminé +# HOST/PORT pilotables par l'environnement (déploiement). En local : 127.0.0.1. +# En conteneur (Hugging Face Spaces) : HOST=0.0.0.0, PORT=7860 (imposé par HF). +HOST = os.getenv("HOST", "127.0.0.1") +PORT = int(os.getenv("PORT", "5000")) diff --git a/app/corpus/Classes_Extensions.py b/app/corpus/Classes_Extensions.py new file mode 100644 index 0000000000000000000000000000000000000000..cc9d2a9fb1d23335966dc7fd2c7994a4f3b4b6ad --- /dev/null +++ b/app/corpus/Classes_Extensions.py @@ -0,0 +1,1953 @@ +# Importation des modules nécessaires + +import re # Module pour les expressions régulières +from sympy import symbols, oo, latex, Interval # Importation des classes SymPy +from sympy import * +import random as rd +import math as m +from sympy.series.sequences import RecursiveSeq +from src.scripts.Mes_fctions.Mes_fctions_d_alg_lineaire_bis import pxsl_pow +import re +from src.scripts.pxs_runtime import get_pxs_lang, myst + +class pxs_Interval(Interval): + """ + Classe personnalisée héritant de Interval de SymPy + Permet un affichage formaté des intervalles avec : + - Séparateurs de milliers pour les grands nombres + - Notation française pour l'infini et les intervalles + + :pxs_trigger: | + intervalle mathématique, domaine de définition, domaine d'étude, + ensemble solution, ensemble image, image d'un intervalle par une fonction, + tableau de variations intervalle, tableau de signes, borne, + ]a;b[, [a;b], ]-\infty, +\infty[, intervalle ouvert fermé semi-ouvert, + notation française intervalle, TVI intervalle de continuité, + f\left(\left]...\\right]\\right), domaine de continuité, domaine de dérivabilité, + intervalle de convexité, intervalle de monotonie, + croissante sur I, décroissante sur I, continue sur I + :pxs_returns: | + Instance de classe héritant de sympy.Interval. À utiliser comme + un Interval SymPy classique mais avec la méthode .print() pour + obtenir l'affichage LaTeX francisé. + :pxs_example: | + I = pxs_Interval(-oo, 5, True, False) # ]-∞ ; 5] + # Dans MyST : $D = \\py{I.print()}$ + :pxs_antipattern: Utiliser sympy.Interval(...) puis latex() qui produit la notation anglo-saxonne (a, b]. + """ + @classmethod + def from_Interval(cls, interval): + """ + Convertit un Interval standard en pxs_Interval. + + :pxs_trigger: conversion Interval SymPy vers pxs_Interval, upcast intervalle, résultat solveset/domain + :pxs_returns: | + Une instance pxs_Interval reprenant les mêmes bornes et + ouvertures que l'Interval SymPy fourni. + :pxs_example: | + I_sympy = Interval(0, 1, True, False) + I_pxs = pxs_Interval.from_Interval(I_sympy) + # Dans MyST : $\\py{I_pxs.print()}$ + :pxs_antipattern: Recréer manuellement pxs_Interval(I.start, I.end, I.left_open, I.right_open). + """ + if not isinstance(interval, Interval): + raise TypeError("L'objet fourni n'est pas un Interval") + return cls(interval.start, interval.end, + left_open=interval.left_open, + right_open=interval.right_open) + + + def print(self): + """ + Méthode pour générer une représentation LaTeX formatée de l'intervalle + + Returns: + str: Chaîne LaTeX formatée selon les conventions françaises + + :pxs_trigger: intervalle français, notation ]a,b[, borne infinie, affichage LaTeX intervalle, séparateur milliers intervalle + :pxs_returns: | + Chaîne LaTeX (objet myst) représentant l'intervalle avec délimiteurs + français (]a,b[) ou anglais ((a,b)) selon pxs_lang, séparateurs de + milliers pour les grands entiers, et \\infty pour les bornes infinies. + À injecter dans MyST via \\py{...}. + :pxs_example: | + I = pxs_Interval(-oo, 1000, True, False) + intervalle_latex = I.print() + # Dans MyST : $x \\in \\py{intervalle_latex}$ + # Rendu FR : $x \\in \\left]-\\infty;1\\ 000\\right]$ + :pxs_antipattern: Concaténer manuellement f"]{a};{b}[" ou utiliser latex(Interval(...)) qui donne la notation anglo-saxonne (a, b). + """ + + # Formatage de la borne gauche si elle n'est pas -∞ + if self.left != -oo: + # Exemple: 1000 devient "1\ 000" + if isinstance(self.left, (int, Integer)): + left_formate = f"{int(self.left):,}".replace(",", r"\ ") + else: + left_formate = latex(self.left) + else: + left_formate = myst(r""" -\infty """) + + + # Formatage de la borne gauche si elle n'est pas -∞ + if self.right != oo: + # Exemple: 1000 devient "1\ 000" + if isinstance(self.right, (int, Integer)): + right_formate = f"{int(self.right):,}".replace(",", r"\ ") + else: + right_formate = latex(self.right) + else: + right_formate = myst(r""" \inftys """) + # Application des conventions françaises si la langue est française + # Note: pxs_lang doit être définie ailleurs dans le programme + pxs_lang = get_pxs_lang() + if pxs_lang == "fr": + l_delim = r'\left[' if not self.left_open else r'\left]' + r_delim = r'\right]' if not self.right_open else r'\right[' + if pxs_lang == "en": + l_delim = r'\left[' if not self.left_open else r'\left(' + r_delim = r'\right]' if not self.right_open else r'\right)' + + # Conversion des crochets selon la notation française + # En français: ]a,b[ au lieu de (a,b) pour les intervalles ouverts + if pxs_lang == "fr": + Inter_latex = myst(r""" \py{l_delim}\py{left_formate};\py{right_formate}\py{r_delim} """, globals(), locals()) + else: + Inter_latex = myst(r""" \py{l_delim}\py{left_formate},\py{right_formate}\py{r_delim} """, globals(), locals()) + + # Retour de la chaîne LaTeX formatée + return Inter_latex + + +# Tests +# \begin{python} +# # Code Python : Ecrivez ci-dessous votre code Python +# import sympy +# import random as rd +# from sympy import * + +# from pyxiscience.Mes_fctions_d_alg_generale import * +# from pyxiscience.Classes_Extensions import * + +# \end{python} + + +# ## Test de la classe interval de Sympy versus l'extension de PyxiScience + + + +# \begin{python} +# I1 = Interval(1, oo, True, True) +# I2 = Interval(1, oo, False, True) +# I3 = Interval(1, oo, False, False) +# I4 = Interval(1, oo, False, False) + +# Id1 = Interval(1, 2, True, True) +# Id2 = Interval(1, 2, False, True) +# Id3 = Interval(1, 2, True, False) +# Id4 = Interval(1, 2, False, False) + + + +# I1_latex = latex(I1) +# I2_latex = latex(I2) +# I3_latex = latex(I3) +# I4_latex = latex(I4) + + + + + +# Inter_1 = pxs_Interval(1, oo, True, True) +# Inter_2 = pxs_Interval(1, oo, False, True) +# Inter_3 = pxs_Interval(1, oo, False, False) +# Inter_4 = pxs_Interval(1, oo, False, False) + +# Interd_1 = pxs_Interval(1, 2, True, True) +# Interd_2 = pxs_Interval(1, 2, False, True) +# Interd_3 = pxs_Interval(1, 2, True, False) +# Interd_4 = pxs_Interval(1, 2, False, False) + + +# \end{python} + +# $I = \py{I1}$ + +# C'est normal car on appelle l'objet python directement... + + + + +# ## Avec interval de Sympy + + +# \begin{equation*} +# &I_1 = \py{I1_latex}& +# &I_2 = \py{I2_latex}& +# &I_3 = \py{I3_latex}& +# &I_4 = \py{I4_latex}&\\ + +# &Id_1 = \py{Interd_1.print()}& +# &Id_2 = \py{Interd_2.print()}& +# &Id_3 = \py{Interd_3.print()}& +# &Id_4 = \py{Interd_4.print()}& +# \end{equation*} + + + +# ## Avec pxs_interval +# \begin{equation*} +# &I_1 = \py{Inter_1.print()}& +# &I_2 = \py{Inter_2.print()}& +# &I_3 = \py{Inter_3.print()}& +# &I_4 = \py{Inter_4.print()}&\\ + +# &Id_1 = \py{Interd_1.print()}& +# &Id_2 = \py{Interd_2.print()}& +# &Id_3 = \py{Interd_3.print()}& +# &Id_4 = \py{Interd_4.print()}& +# \end{equation*} + + + + + + +#from sympy import * + +class ReverseString: + """ + Classe utilitaire pour inverser l'ordre de tri des chaînes de caractères. + Utilisée pour trier les variables dans l'ordre inverse alphabétique. + + :pxs_trigger: tri inverse alphabétique, ordre descendant variables, clé de tri personnalisée polynôme + :pxs_returns: | + Wrapper autour d'une chaîne dont __lt__ est inversé, à utiliser comme + key dans sorted() pour trier de z vers a au lieu de a vers z. + :pxs_example: | + vars_sorted = sorted(["y", "x", "z"], key=ReverseString) + # → ["z", "y", "x"] + :pxs_antipattern: Utiliser sorted(..., reverse=True) qui inverse TOUT le tri y compris la clé principale (puissance). + """ + def __init__(self, string): + """ + Initialise le wrapper autour de la chaîne à inverser. + + :pxs_trigger: instanciation interne classe ReverseString + :pxs_returns: | + Instance ReverseString encapsulant la chaîne fournie. + :pxs_example: | + wrap = ReverseString("x") + # Utilisé typiquement via sorted(..., key=ReverseString) + :pxs_antipattern: Appeler ReverseString directement dans du code pédagogique — c'est un utilitaire interne de tri. + """ + self.string = string + + def __lt__(self, other): + """ + Comparaison inversée pour le tri descendant. + + :pxs_trigger: comparaison tri inverse interne + :pxs_returns: | + bool : True si self.string > other.string (comparaison inversée). + :pxs_example: | + ReverseString("a") < ReverseString("b") # False (car 'a' < 'b' devient inversé) + :pxs_antipattern: Appeler __lt__ directement au lieu de laisser Python l'utiliser via sorted(). + """ + return self.string > other.string # Inversion de la comparaison + + def __eq__(self, other): + """ + Égalité standard entre chaînes. + + :pxs_trigger: égalité interne ReverseString + :pxs_returns: | + bool : True si les deux chaînes encapsulées sont identiques. + :pxs_example: | + ReverseString("x") == ReverseString("x") # True + :pxs_antipattern: Appeler __eq__ directement au lieu d'utiliser ==. + """ + return self.string == other.string + +class pxs_Poly(Poly): + """ + Extension de la classe Poly de SymPy avec une méthode d'affichage personnalisée. + Permet d'afficher les polynômes avec un formatage LaTeX personnalisé et une factorisation optionnelle. + + :pxs_trigger: polynôme pédagogique, trinôme second degré, affichage LaTeX polynôme, discriminant racines factorisation + :pxs_returns: | + Classe héritant de sympy.Poly, avec méthodes pxsl_print, pxsl_discriminant, + pxsl_solution, pxs_factor et pxs_generate pour usage pédagogique en MyST. + :pxs_example: | + p = pxs_Poly(x**2 + 3*x - 4, x) + # Dans MyST : $P(x) = \\py{p.pxsl_print()}$ + :pxs_antipattern: Utiliser sympy.Poly puis latex() et écrire à la main le calcul du discriminant ou des racines. + """ + + @classmethod + def pxs_generate(cls, x = Symbol("x"), lim_coeff = 9, nb_root = None): + """ + Génère un polynôme du second degré avec des coefficients aléatoires non nuls, + en contrôlant optionnellement le nombre de racines réelles. + + Args: + x: Variable symbolique du polynôme (par défaut Symbol("x")) + lim_coeff: Valeur maximale absolue des coefficients (par défaut 9) + nb_root: Nombre de racines réelles souhaité (0, 1, 2 ou None pour aléatoire) + + Returns: + Un objet pxs_Poly représentant le polynôme ax² + bx + c généré + + :pxs_trigger: générer trinôme aléatoire, exercice second degré avec n racines, discriminant positif/négatif/nul contrôlé + :pxs_returns: | + Un objet pxs_Poly de la forme ax² + bx + c avec a, b, c entiers + non nuls, et discriminant contrôlé selon nb_root (0, 1, 2 ou aléatoire). + :pxs_example: | + p = pxs_Poly.pxs_generate(nb_root=2) + # Trinôme à 2 racines réelles distinctes, coeffs dans [-9,9]\\{0} + # Dans MyST : $P(x) = \\py{p.pxsl_print()}$ + :pxs_antipattern: Tirer a, b, c au hasard avec random.randint puis vérifier le signe du discriminant dans une boucle while. + """ + # Génération aléatoire des signes pour chaque coefficient + sign_coeff = [rd.choice([-1,1]), rd.choice([-1,1]), rd.choice([-1,1])] + + # Génération des coefficients a, b, c (tous non nuls) + # Valeurs entre 1 et lim_coeff, multipliées par leur signe + a = sign_coeff[0] * rd.randint(1, lim_coeff) + b = sign_coeff[1] * rd.randint(1, lim_coeff) + c = sign_coeff[2] * rd.randint(1, lim_coeff) + + # Création du polynôme initial + p = Poly(a * x**2 + b * x + c, x) + + # Si aucune contrainte sur le nombre de racines, retourner le polynôme tel quel + if nb_root is None: + return p + + # CAS 1 : On veut 0 racine réelle (Δ < 0) + # Condition : b² < 4ac, donc a et c doivent être de même signe + if nb_root == 0 and p.discriminant() >= 0: + # Forcer a et c à avoir le même signe (on utilise sign[0] pour les deux) + c = sign_coeff[0] * rd.randint(1,9) + # Choisir b tel que b < 2√(ac) pour garantir Δ < 0 + b = sign_coeff[1] * rd.randint(1, int(2 * m.sqrt(a * c)-1)) + + # CAS 2 : On veut 1 racine double (Δ = 0) + # Condition : b² = 4ac, donc b = ±2√(ac) + if nb_root == 1 and p.discriminant() != 0: + # Pour faciliter, on choisit a et c comme des carrés parfaits + a = sign_coeff[0] * rd.randint(1, int(m.sqrt(lim_coeff)))**2 + c = sign_coeff[0] * rd.randint(1, int(m.sqrt(lim_coeff)))**2 + + # Vérifier qu'on ne dépasse pas la limite des coefficients + if a >= lim_coeff and c >= lim_coeff: + # Si les deux sont trop grands, prendre des valeurs égales plus petites + a = sign_coeff[0] * rd.randint(1, lim_coeff) + c = a + + # Calculer b pour avoir exactement Δ = 0 + b = rd.choice([-1,1]) * int(2 * m.sqrt(a * c)) + + # CAS 3 : On veut 2 racines distinctes (Δ > 0) + # Condition : b² > 4ac, donc c < b²/(4a) + if nb_root == 2 and p.discriminant() <= 0 : + # Choisir c dans l'intervalle qui garantit Δ > 0 + # c doit être inférieur à b²/(4a) - 1 pour avoir une marge + c = sign_coeff[2] * rd.randint(-lim_coeff, min(int(b**2 / (4 * a)) - 1, lim_coeff)) + + # S'assurer que c n'est pas nul (tous les coefficients doivent être non nuls) + while c == 0: + c = sign_coeff[0] * rd.randint(-lim_coeff, int(b**2 / (4 * a)) - 1) + + # Retourner le polynôme avec les coefficients ajustés + return cls(a * x**2 + b * x + c, x) + + def pxsl_print(self, variable=None, ascending=False, displaystyle=True, factor=False): + """ + Affiche le polynôme avec un formatage LaTeX personnalisé. + + Paramètres: + - variable: Variable principale pour l'organisation/factorisation (None = auto-détection) + - ascending: Si True, trie par puissances croissantes, sinon décroissantes + - displaystyle: Si True, utilise le style d'affichage LaTeX étendu + - factor: Si True, factorise le polynôme par rapport à la variable spécifiée + + Retourne: + - String: Expression LaTeX formatée + + :pxs_trigger: afficher polynôme pédagogique, ordre puissances croissantes/décroissantes, factoriser par variable, polynôme deux variables + :pxs_returns: | + Chaîne LaTeX (str) représentant le polynôme avec termes triés selon + 'variable' et 'ascending', coefficients 1/-1 simplifiés, et + optionnellement factorisé par 'variable' si factor=True. + :pxs_example: | + p = pxs_Poly(3*x*y - x**2 + y**2 + x + 2) + # Dans MyST : $P = \\py{p.pxsl_print(variable=x)}$ + # Avec factor=True : $P = \\py{p.pxsl_print(variable=x, factor=True)}$ + :pxs_antipattern: Utiliser latex(poly.as_expr()) qui trie mal les termes multi-variables et n'offre pas la factorisation par variable. + """ + + def is_numeric_key(key): + """ + Vérifie si une clé est numérique (pour filtrer les constantes). + Utilisée pour séparer les variables des constantes numériques. + """ + try: + float(key) + return True + except (ValueError, TypeError): + return False + + # Convertir le polynôme en expression SymPy + expr = self.as_expr() + + # Auto-détection de la variable si non spécifiée + if variable is None: + variables = sorted(expr.free_symbols, key=str) + if not variables: + return latex(expr) # Retourne directement si pas de variables + else: + variables = variable + + # MODE FACTORISATION : traitement spécial quand factor=True + if factor and variable is not None: + # Utilise la fonction collect() de SymPy pour factoriser par la variable + factored_expr = collect(expr, variable) + + # Dictionnaire pour organiser les termes par puissance de la variable + terms_by_power = {} + constant_terms = [] # Termes qui ne contiennent pas la variable + + # Analyser chaque terme de l'expression factorisée + for term in Add.make_args(factored_expr): + if term.has(variable): + # Trouver la puissance de la variable dans ce terme + power = 0 + coeff = term + + # Recherche de la puissance (de 10 à 1 pour prendre la plus haute) + for p in range(10, 0, -1): + c = term.coeff(variable, p) # Coefficient de variable^p + if c != 0: + power = p + coeff = c + break + + # Stocker le coefficient pour cette puissance + if power > 0: + terms_by_power[power] = coeff + else: + # Terme constant (ne contient pas la variable) + constant_terms.append(term) + + # Construction de l'affichage avec la variable APRÈS le coefficient + result_terms = [] + + # Traiter les termes par puissance décroissante + for power in sorted(terms_by_power.keys(), reverse=True): + coeff = terms_by_power[power] + + if power == 1: + # Cas spécial : puissance 1 (pas d'exposant affiché) + if coeff == 1: + result_terms.append(f"{latex(variable)}") + elif coeff == -1: + result_terms.append(f"-{latex(variable)}") + else: + coeff_str = latex(coeff) + # Ajouter des parenthèses si le coefficient est complexe + if len(str(coeff).split()) > 1 or '+' in coeff_str or '-' in coeff_str[1:]: + result_terms.append(f"({coeff_str}){latex(variable)}") + else: + result_terms.append(f"{coeff_str}{latex(variable)}") + else: + # Puissances supérieures à 1 + if coeff == 1: + result_terms.append(f"{latex(variable)}^{{{power}}}") + elif coeff == -1: + result_terms.append(f"-{latex(variable)}^{{{power}}}") + else: + coeff_str = latex(coeff) + # Ajouter des parenthèses si le coefficient est complexe + if len(str(coeff).split()) > 1 or '+' in coeff_str or '-' in coeff_str[1:]: + result_terms.append(f"({coeff_str}){latex(variable)}^{{{power}}}") + else: + result_terms.append(f"{coeff_str}{latex(variable)}^{{{power}}}") + + # Ajouter les termes constants à la fin + for term in constant_terms: + result_terms.append(latex(term)) + + # Assemblage final avec gestion des signes + if not result_terms: + return "0" + + result = result_terms[0] + for term in result_terms[1:]: + if term.startswith('-'): + result += term # Pas de '+' avant un terme négatif + else: + result += f"+{term}" + + return result + + # MODE NORMAL : affichage standard sans factorisation + terms = [] + + # Analyser chaque terme de l'expression pour extraire puissances et coefficients + for term in Add.make_args(expr): + power = 0 # Puissance de la variable principale (0 pour les constantes) + rest = 1 # Coefficient/reste du terme + + # Traitement quand aucune variable n'est spécifiée (auto-détection) + if variable is None: + powers = 0 # Somme de toutes les puissances du terme (pour le tri) + entire_power_dict = term.as_powers_dict() + + # Filtrer les clés numériques (constantes) + keys_to_remove = [] + for key in entire_power_dict.keys(): + try: + float(key) + keys_to_remove.append(key) + except (ValueError, TypeError): + pass # La clé n'est pas numérique, on la garde + + # Créer un dictionnaire sans les constantes numériques + power_dict = {k: v for k, v in entire_power_dict.items() if not is_numeric_key(k)} + + try: + # Choisir la variable "principale" (la plus grande alphabétiquement) + variable = max(power_dict.keys()) + power = power_dict[variable] + rest = term / (variable ** power) if variable else term + except (ValueError, TypeError): + # Pas de variable trouvée, c'est un terme constant + variable = None + rest = term + + # Calculer la somme totale des puissances pour le tri + powers = sum(power_dict.values()) + terms.append((powers, power, variable, False, rest)) + variable = None # Reset pour le prochain terme + + else: + # Variable spécifiée : analyser chaque facteur du terme + for factor in Mul.make_args(term): + if factor.has(variable): + # Ce facteur contient la variable + power_dict = factor.as_powers_dict() + power = power_dict[variable] + # Diviser par la variable à la puissance pour isoler le coefficient + rest *= factor / (variable ** power) + else: + # Ce facteur ne contient pas la variable, c'est part du coefficient + rest *= factor + + # Déterminer si le terme contient d'autres variables (pour le tri) + if variable is not None: + has_other_var = False + if len(expr.free_symbols) > 1: + # Vérifie si le coefficient contient d'autres variables + has_other_var = len(rest.free_symbols) > 0 + + terms.append((power, power, variable, has_other_var, rest)) + + # TRI DES TERMES selon les paramètres spécifiés + if variable is None: + # Tri global par somme des puissances, puis par puissance individuelle, puis par nom de variable + if ascending: + terms.sort(key=lambda x: (x[0], x[1], ReverseString(str(x[2])))) + else: + terms.sort(key=lambda x: (x[0], x[1], ReverseString(str(x[2]))), reverse=True) + else: + # Tri par puissance de la variable spécifiée + if ascending: + # Ordre croissant des puissances, termes avec autres variables après + terms.sort(key=lambda x: (x[1], x[3])) + else: + # Ordre décroissant des puissances, termes avec autres variables après + terms.sort(key=lambda x: (x[1], x[3]), reverse=True) + + # CONSTRUCTION DE L'EXPRESSION LATEX FINALE + result = "" + + for i, (powers, power, variable, has_other_var, coeff) in enumerate(terms): + # Analyse du coefficient pour la gestion des signes + is_negative = latex(coeff).startswith('-') + + # Vérification si le coefficient est +1 ou -1 (pour simplifier l'affichage) + try: + is_One = coeff == 1 + except TypeError: + is_One = False + try: + is_minus_One = coeff == -1 + except TypeError: + is_minus_One = False + + # CONSTRUCTION DU TERME selon sa puissance + if power == 0: + # Terme constant (pas de variable) + term = f"{latex(coeff)}" + elif power == 1: + # Puissance 1 (variable sans exposant) + if is_One: + term = f"{latex(variable)}" + elif is_minus_One: + term = f"-{latex(variable)}" + else: + if displaystyle: + term = f"\\displaystyle {latex(coeff)}{latex(variable)}" + else: + term = f"{latex(coeff)}{latex(variable)}" + else: + # Puissances supérieures à 1 + if is_One: + term = f"{latex(variable)}^{power}" + elif is_minus_One: + term = f"-{latex(variable)}^{power}" + else: + if displaystyle: + term = f"\\displaystyle {latex(coeff)}{latex(variable)}^{power}" + else: + term = f"{latex(coeff)}{latex(variable)}^{power}" + + # GESTION DES SIGNES pour l'assemblage final + if i == 0 or is_negative: + # Premier terme ou terme négatif : pas de '+' devant + result += term + else: + # Terme positif : ajouter un '+' + result += f" +{term}" + + # Retourner l'expression LaTeX complète + return result + + def pxsl_discriminant(self, mult = "\\times", formula = True): + """ + Calcule et affiche le discriminant d'une équation du second degré au format LaTeX. + + Args: + mult: Symbole de multiplication à utiliser (par défaut \times en LaTeX) + + Returns: + Un objet myst contenant l'affichage LaTeX du calcul du discriminant + + :pxs_trigger: calcul discriminant, Delta = b²-4ac, étapes substitution trinôme, exercice second degré + :pxs_returns: | + Objet myst contenant le calcul multi-lignes du discriminant au + format LaTeX aligné (utilisable dans un environnement equation*+split), + avec ou sans la formule littérale Δ = b²-4ac en première ligne. + :pxs_example: | + p = pxs_Poly(x**2 - 5*x + 6, x) + calc = p.pxsl_discriminant() + # Dans MyST : + # \\begin{equation*}\\begin{split} + # \\py{calc} + # \\end{split}\\end{equation*} + :pxs_antipattern: Écrire à la main "\\Delta = b^2-4ac = {b**2} - 4*{a}*{c} = ..." avec f-strings et risquer les signes négatifs mal parenthésés. + """ + # Récupération de tous les coefficients de l'équation (a, b, c) + coeffs = self.all_coeffs() + + # Génération de l'affichage LaTeX du calcul du discriminant + # avec les étapes de substitution et de calcul + if formula: + return myst(r""" + \Delta &= b^2-4ac\\ + &= \py{pxsl_pow(coeffs[1],2)} - 4 \py{mult}\py{pxsl_pow(coeffs[0],1)} \py{mult}\py{pxsl_pow(coeffs[2],1)}\\ + &=\py{latex(self.discriminant())}""" + ,globals(), locals()) + else: + return myst(r""" + \Delta &= \py{pxsl_pow(coeffs[1],2)} - 4 \py{mult}\py{pxsl_pow(coeffs[0],1)} \py{mult}\py{pxsl_pow(coeffs[2],1)}\\ + &=\py{latex(self.discriminant())}""" + ,globals(), locals()) + + def pxsl_solution(self, mult = "\\times", formula = True): + """ + Affiche la ou les solutions d'une équation du second degré au format LaTeX. + + Args: + mult: Symbole de multiplication à utiliser (par défaut \times en LaTeX) + + Returns: + Un tuple de 4 éléments (x1_complet, x2_complet, x1_final, x2_final) où : + - x1_complet, x2_complet : calculs détaillés étape par étape + - x1_final, x2_final : formes finales simplifiées + Retourne (None, None, None, None) si pas de solution réelle + + :pxs_trigger: résoudre équation second degré, racines trinôme, x1 x2, formule (-b±√Δ)/2a, solution double + :pxs_returns: | + Tuple (x_1_complet, x_2_complet, x_1_final, x_2_final) d'objets myst : + les deux premiers contiennent le calcul détaillé étape par étape, + les deux derniers la forme simplifiée finale. Si Δ=0, les deux + solutions sont égales. Si Δ<0, retourne (None, None, None, None). + :pxs_example: | + p = pxs_Poly.pxs_generate(nb_root=2) + expr_x1, expr_x2, x1, x2 = p.pxsl_solution() + # Dans MyST : + # $x_1 = \\py{expr_x1}$, $x_2 = \\py{expr_x2}$ + # Finalement : $x_1 = \\py{x1}$ et $x_2 = \\py{x2}$ + :pxs_antipattern: Calculer sqrt(disc) puis composer une f-string "-b - sqrt(D) / 2a" à la main, en oubliant la simplification des fractions ou la réduction de √Δ. + """ + # Récupération de tous les coefficients de l'équation (a, b, c) + coeffs = self.all_coeffs() + + # Vérifier si le discriminant peut être simplifié (contient des facteurs carrés) + factors = factorint(self.discriminant()) + is_reducible = False + for prime, power in factors.items(): + if power >= 2: + is_reducible = True + + # Préparer l'affichage des coefficients avec parenthèses si négatifs + if self.discriminant() >= 0: + coeff_fin = [pxsl_pow(coeff, 1) for coeff in coeffs] + + # CAS 1 : Discriminant strictement positif (2 solutions distinctes) + if self.discriminant() > 0: + # Calcul détaillé de x1 = (-b - √Δ) / 2a + if formula: + x_1 = myst(r""" \displaystyle{\frac{-b-\sqrt{\Delta}}{2a}}=\displaystyle{\frac{-\py{coeff_fin[1]}-\sqrt{\py{self.discriminant()}}}{2\py{mult}\py{coeff_fin[0]}}}=\displaystyle{\frac{\py{-coeffs[1]}-\py{latex(sqrt(self.discriminant()))}}{\py{2*coeffs[0]}}}""",globals(), locals()) + else: + x_1 = myst(r""" \displaystyle{\frac{-\py{coeff_fin[1]}-\sqrt{\py{self.discriminant()}}}{2\py{mult}\py{coeff_fin[0]}}}=\displaystyle{\frac{\py{-coeffs[1]}-\py{latex(sqrt(self.discriminant()))}}{\py{2*coeffs[0]}}}""",globals(), locals()) + + # Calcul détaillé de x2 = (-b + √Δ) / 2a + if formula: + x_2 = myst(r""" \displaystyle{\frac{-b+\sqrt{\Delta}}{2a}}=\displaystyle{\frac{-\py{coeff_fin[1]}+\sqrt{\py{self.discriminant()}}}{2\py{mult}\py{coeff_fin[0]}}}=\displaystyle{\frac{\py{-coeffs[1]}+\py{latex(sqrt(self.discriminant()))}}{\py{2*coeffs[0]}}}""",globals(), locals()) + else: + x_2 = myst(r""" \displaystyle{\frac{-\py{coeff_fin[1]}+\sqrt{\py{self.discriminant()}}}{2\py{mult}\py{coeff_fin[0]}}}=\displaystyle{\frac{\py{-coeffs[1]}+\py{latex(sqrt(self.discriminant()))}}{\py{2*coeffs[0]}}}""",globals(), locals()) + + # Si √Δ est un entier (carré parfait), calculer directement les fractions + if isinstance(sqrt(self.discriminant()),Integer) or isinstance(sqrt(self.discriminant()),Rational): + x_1 += myst(r""" =\displaystyle{\frac{\py{latex(-coeffs[1]-sqrt(self.discriminant()))}}{\py{2*coeffs[0]}}}""",globals(), locals()) + x_1final = myst(r"""\displaystyle{\py{latex(Rational(-coeffs[1]-sqrt(self.discriminant()),2*coeffs[0]))}} """,globals(), locals()) + x_2 += myst(r""" =\displaystyle{\frac{\py{latex(-coeffs[1]+sqrt(self.discriminant()))}}{\py{2*coeffs[0]}}}""",globals(), locals()) + x_2final = myst(r"""\displaystyle{\py{latex(Rational(-coeffs[1]+sqrt(self.discriminant()),2*coeffs[0]))}} """,globals(), locals()) + return x_1 + myst(r"""= """) + x_1final, x_2 + myst(r""" = """) + x_2final, x_1final, x_2final + + # Si √Δ n'est pas un entier, essayer de simplifier l'expression + else: + # Si a > 0 et qu'on peut simplifier (PGCD ou racine réductible) + if coeffs[0] > 0 and (gcd(-coeffs[1],2*coeffs[0]) != 1 or is_reducible): + # Séparer la partie rationnelle et la partie irrationnelle + x_1final = myst(r"""\displaystyle{\py{latex(Rational(-coeffs[1],2*coeffs[0]))}-\py{latex(sqrt(self.discriminant())/(2*coeffs[0]))}}""", globals(), locals()) + x_2final = myst(r"""\displaystyle{\py{latex(Rational(-coeffs[1],2*coeffs[0]))}+\py{latex(sqrt(self.discriminant())/(2*coeffs[0]))}}""", globals(), locals()) + return x_1 + myst(r"""=""") + x_1final, x_2 + myst(r"""=""") + x_2final, x_1final, x_2final + + # Si a < 0, ajuster les signes pour un affichage plus propre + elif coeffs[0] < 0 : + x_1final = myst(r"""\displaystyle{\py{latex(Rational(-coeffs[1],2*coeffs[0]))}+\py{latex(sqrt(self.discriminant())/(-2*coeffs[0]))}}""", globals(), locals()) + x_2final = myst(r"""\displaystyle{\py{latex(Rational(-coeffs[1],2*coeffs[0]))}-\py{latex(sqrt(self.discriminant())/(-2*coeffs[0]))}}""", globals(), locals()) + return x_1 + myst(r"""= """) + x_1final, x_2 + myst(r"""= """) + x_2final, x_1final, x_2final + + # Cas par défaut : garder la forme fractionnaire complète + else: + x_1final = myst(r"""\displaystyle{\frac{\py{-coeffs[1]}-\py{latex(sqrt(self.discriminant()))}}{\py{2*coeffs[0]}}}""",globals(), locals()) + x_2final = myst(r"""\displaystyle{\frac{\py{-coeffs[1]}+\py{latex(sqrt(self.discriminant()))}}{\py{2*coeffs[0]}}}""",globals(), locals()) + return x_1, x_2, x_1final, x_2final + + # CAS 2 : Discriminant nul (1 solution double) + if self.discriminant() == 0: + # Calcul détaillé de x0 = -b / 2a + x_0 = myst(r""" \displaystyle{\frac{-b}{2a}}=\displaystyle{\frac{-\py{coeff_fin[1]}}{2\py{mult}\py{coeff_fin[0]}}}=\displaystyle{\frac{\py{-coeffs[1]}}{\py{2*coeffs[0]}}}""",globals(), locals()) + + # Si on peut simplifier la fraction (PGCD > 1 ou a < 0) + if coeffs[0] > 0 and gcd(-coeffs[1],2*coeffs[0]) != 1: + x_0final = myst(r"""\displaystyle{\py{latex(Rational(-coeffs[1],2*coeffs[0]))}}""", globals(), locals()) + return x_0 + myst(r"""= """) + x_0final, x_0 + myst(r"""= """) + x_0final, x_0final, x_0final + elif coeffs[0] < 0 : + x_0final = myst(r"""\displaystyle{\py{latex(Rational(-coeffs[1],2*coeffs[0]))}}""", globals(), locals()) + return x_0 + myst(r"""= """) + x_0final, x_0 + myst(r"""= """) + x_0final, x_0final, x_0final + + # Cas par défaut : garder la forme fractionnaire + else: + x_0final = myst(r"""\displaystyle{\frac{\py{-coeffs[1]}}{\py{2*coeffs[0]}}}""",globals(), locals()) + return x_0, x_0, x_0final, x_0final + + # CAS 3 : Discriminant négatif (pas de solution réelle) + if self.discriminant() < 0: + return None, None, None, None + + def pxs_factor(self, variable = None, display = "\displaystyle"): + """ + Factorise un polynôme en utilisant sympy.Poly. + Prend un objet Poly en argument. + + :pxs_trigger: factoriser polynôme par variable dominante, mise en facteur x^n, x²(1 + 1/x + 1/x²), factorisation forcée par plus haut degré + :pxs_returns: | + Tuple (myst_expr, factor, term) : + - myst_expr : chaîne LaTeX "x^n (1 + a/x + ... )" prête à injecter en MyST + - factor : expression sympy du facteur sorti (variable ** degré) + - term : expression sympy du contenu entre parenthèses + :pxs_example: | + p = pxs_Poly(3*x**2 + 2*x + 1, x) + expr, fact, term = p.pxs_factor(variable=x) + # Dans MyST : $P = \\py{expr}$ → $x^2(3 + 2/x + 1/x^2)$ + :pxs_antipattern: Utiliser sympy.factor() qui cherche une factorisation complète sur Q, ou construire la mise en facteur de x^n à la main avec des f-strings. + """ + + # Récupérer la variable (premier générateur) + if variable is None and len(self.gens) != 1: + raise ValueError("Il faut spécifier une variable pour factoriser un polynôme à plusieurs variables") + if variable is None: + variable = self.gens[0] # si variable n'est précisé c'est qu'il n'y en a qu'une, on la récupère + + p = pxs_Poly(self, variable) + + # Récupérer tous les coefficients (SymPy gère automatiquement l'ordre) + coeffs = p.all_coeffs() # Coefficients dans l'ordre décroissant des puissances + degree_poly = p.degree() + + if degree_poly == 0: + return str(coeffs[0]) + + # renvoyer le même terme si y en a qu'un + + # Construire la factorisation + factorized_form = myst(r"""""") + term = 0 + + # Parcourir tous les coefficients depuis la plus haute puissance + for i, coeff in enumerate(coeffs): + if coeff == 0: + continue + current_power = degree_poly - i + + # Coefficient relatif au terme principal + power_diff = degree_poly - current_power + + # Gestion du signe + try: + if coeff > 0 and i != 0: + sign_coeff = "+" + else: + sign_coeff = "" + except: + coeff_str = str(coeff) + if i != 0 and not coeff_str.startswith('-'): + sign_coeff = "+" + else: + sign_coeff = "" + + # Formatage selon la différence de puissance + if i == 0: + term +=coeff + factorized_form += myst(r"""\py{latex(coeff)}""", globals(), locals()) + continue + if power_diff == 1: + term += coeff/variable + # Division par variable + if coeff == 1: + factorized_form += myst(r"""\py{sign_coeff}\py{display}\py{latex(1/variable)}""", globals(), locals()) + elif coeff == -1: + factorized_form += myst(r"""-\py{display}\py{latex(1/variable)}""", globals(), locals()) + else: + factorized_form += myst(r"""\py{sign_coeff} \py{display}\py{latex(coeff/variable)}""", globals(), locals()) + else: + term += coeff/(variable**power_diff) + # Division par variable^power_diff + if coeff == 1: + factorized_form += myst(r"""\py{sign_coeff}\py{display}\py{latex(1/variable**power_diff)}""", globals(), locals()) + elif coeff == -1: + factorized_form += myst(r"""-\py{display}\py{latex(1/variable**power_diff)}""", globals(), locals()) + else: + factorized_form += myst(r"""\py{sign_coeff}\py{display}\py{latex(coeff/variable**power_diff)}""", globals(), locals()) + + # Construire le facteur principal + factor = variable**degree_poly + factor_latex = myst(r"""\py{pxsl_pow(variable, degree_poly)}""", globals(), locals()) + + return myst(r"""\py{factor_latex}\left(\py{factorized_form}\right) """, globals(), locals()), factor, term + +""" X = Symbol('X') +y = Symbol('y') +x = Symbol('x') +poly = pxs_Poly(3*x**4*y**2+Rational(2,3)*x**2*y+3, x) +poly2 = pxs_Poly(3*x**4*y**2+Rational(2,3)*x**2*y+3) + +p1 = pxs_Poly.pxs_generate(nb_root = 2) +p2 = pxs_Poly.pxs_generate(nb_root = 1) +p3 = pxs_Poly.pxs_generate(nb_root = 0) + +expr1 = pxs_Poly(1 + x + x**2) +expr2 = pxs_Poly(3*x**2 - 5*x + 7 - 2*x**3) +expr3 = pxs_Poly(3*x*y - x**2 + y**2 + x + 2) +expr4 = pxs_Poly(x/2 - x**2/3 + 5) +expr5 = pxs_Poly(3*X*y + 2*X**2 + y**2 + X + 2) +expr6 = pxs_Poly(3*x**4*y**2+Rational(2,3)*x**2 +x**2*y+3,x) + +poly = pxs_Poly.pxs_generate(nb_root = 2) +[expr_x1, expr_x2, x1, x2] = poly.pxsl_solution() +poly = pxs_Poly.pxs_generate(nb_root = 1) +[expr_x0, expr_x0, x0, x0] = poly.pxsl_solution() + +\end{python} + +Méthode pxs_generate(cls, x = Symbol("x"), lim_coeff = 9, nb_root = None) + +$\py{latex(pxs_Poly.pxs_generate().as_expr())}$ + +$\py{latex(pxs_Poly.pxs_generate(y).as_expr())}$ + +Modifier la limite des coefficients : $\py{latex(pxs_Poly.pxs_generate(lim_coeff = 2).as_expr())}$ + +2 racines : $\py{latex(p1.as_expr())}$ $\longrightarrow$ $\Delta = \py{p1.discriminant()}$ + +1 racine : $\py{latex(p2.as_expr())}$ $\longrightarrow$ $\Delta = \py{p2.discriminant()}$ + +0 racine : $\py{latex(p3.as_expr())}$ $\longrightarrow$ $\Delta = \py{p3.discriminant()}$ + +________________________________________________ + +Méthode pxsl_print(self, variable=None, ascending=False, displaystyle=True, factor = False) + +expr1: $\py{expr1.pxsl_print()}$ + +expr1 en ordre croissant: $\py{expr1.pxsl_print(ascending = True)}$ + +expr2: $\py{expr2.pxsl_print()}$ + +expr3: $\py{expr3.pxsl_print()}$ + +Par rapport à x : $\py{expr3.pxsl_print(variable = x)}$ + +Par rapport à y : $\py{expr3.pxsl_print(variable = y)}$ + +expr4: $\py{expr4.pxsl_print()}$ + +expr4, sans displaystyle: $\py{expr4.pxsl_print(displaystyle = False)}$ + +expr5: $\py{expr5.pxsl_print()}$ + +Par rapport à X : $\py{expr5.pxsl_print(variable = X)}$ + +expr6, Forme développée : $\py{expr6.pxsl_print(factor=False, variable=x)}$ + +expr6, Forme factorisée en x : $\py{expr6.pxsl_print(factor=True, variable=x)}$ +_________________________________________________________ + +Méthode pxsl_discriminant(self, mult = "\\times") + +expr1: +\begin{equation*} +\begin{split} +\py{expr1.pxsl_discriminant()} +\end{split} +\end{equation*} + +expr4: +\begin{equation*} +\begin{split} +\py{expr4.pxsl_discriminant()} +\end{split} +\end{equation*} + +en modifiant le signe multiplié : +\begin{equation*} +\begin{split} +\py{expr4.pxsl_discriminant("\cdot")} +\end{split} +\end{equation*} +_____________________________________________________________ + +Méthode pxsl_solution(self, mult = "\\times") + +2 racines : + +\begin{equation*} +x_1 = \py{expr_x1} +\end{equation*} +et +\begin{equation*} +x_2 = \py{expr_x2}. +\end{equation*} + +\begin{equation*} +x_1 =\py{x1}\textrm{ et }x_2 = \py{x2}. +\end{equation*} + +1 racine : + +\begin{equation*} +x_0 = \py{expr_x0} +\end{equation*} + +\begin{equation*} +x_0 =\py{x0}. +\end{equation*} +____________________________________________________________ + +Méthode pxs_factor(self, variable = None, display = "\displaystyle") + +expr1 : $\py{expr1.pxs_factor()[0]}$ + +factor : $\py{latex(expr1.pxs_factor(variable = x)[1])}$ + +term : $\py{latex(expr1.pxs_factor(variable = x)[2])}$ + +expr3: $\py{expr3.pxsl_print()}$ + +expr3 par rapport à $x$: $\py{expr3.pxs_factor(variable = x)[0]}$ + +factor : $\py{latex(expr3.pxs_factor(variable = x)[1])}$ + +term : $\py{latex(expr3.pxs_factor(variable = x)[2])}$ + +expr3 par rapport à $y$: $\py{expr3.pxs_factor(variable = y)[0]}$ + +factor : $\py{latex(expr3.pxs_factor(variable = y)[1])}$ + +term : $\py{latex(expr3.pxs_factor(variable = y)[2])}$ + """ + + + +class pxs_SeqFormula(SeqFormula): + """ + Sous-classe de SeqFormula offrant une méthode de sommation des coefficients. + La méthode pxsl_summation produit une expression (myst) représentant + la somme des coefficients de la suite entre deux indices, soit en valeur + numérique, soit en notation symbolique. + + :pxs_trigger: suite numérique, suite avec somme, SeqFormula pédagogique, somme développée termes successifs + :pxs_returns: | + Classe héritant de sympy.SeqFormula, offrant en plus la méthode + pxsl_summation pour afficher la somme u_min + u_{min+1} + ... + u_{max-1} + en LaTeX (valeurs numériques ou notation symbolique u_i). + :pxs_example: | + u = pxs_SeqFormula(n**2, (n, 0, oo)) + # Dans MyST : $S = \\py{u.pxsl_summation(0, 5)}$ + :pxs_antipattern: Utiliser sympy.SeqFormula puis écrire la somme à la main sans simplification des signes. + """ + def pxsl_summation(self, min=0, max=9, symbolic=None): + """ + Génère l'expression de la somme des coefficients u_min + u_{min+1} + ... + u_{max-1}. + + Paramètres: + - min (int) : indice de départ de la sommation (inclu). + - max (int) : indice de fin de la sommation (exclu). + - symbolic (str ou None) : nom du symbole à utiliser pour la notation symbolique; + si None, on utilise self.coeff(i) pour extraire les valeurs numériques. + + Retourne: + - expr : un objet myst contenant la somme formatée. + + :pxs_trigger: somme développée suite, u_0 + u_1 + ... + u_n, écriture symbolique ou numérique d'une somme partielle + :pxs_returns: | + Objet myst (LaTeX) de la somme développée : soit les valeurs + numériques des termes avec gestion correcte des signes (+/-), + soit la notation symbolique "symbolic_0 + symbolic_1 + ...". + :pxs_example: | + u = pxs_SeqFormula(2*n + 1, (n, 0, oo)) + somme = u.pxsl_summation(0, 4) # "1 +3 +5 +7" + somme_sym = u.pxsl_summation(0, 4, symbolic="u") # "u_0 + u_1 + u_2 + u_3" + # Dans MyST : $\\py{somme}$ + :pxs_antipattern: Boucler manuellement en f-string sans gérer le signe (produit "1 + -3" au lieu de "1 - 3"). + """ + if symbolic is None: + # Cas numérique : on ajoute la valeur de chaque coefficient + expr = myst(r"""\py{self.coeff(min)} """, globals(), locals()) + for i in range(min + 1, max): + coeff_i = self.coeff(i) # on récupère le coefficient numérique + if coeff_i >= 0: + # Ajoute '+ coeff_i' si coefficient positif + expr += myst(r""" +\py{coeff_i} """, globals(), locals()) + else: + # Ajoute '- |coeff_i|' si coefficient négatif + expr += myst(r""" -\py{abs(coeff_i)} """, globals(), locals()) + return expr # retourne l'expression complète + else: + # Cas symbolique : on génère u_min = symbolic_0 puis + symbolic_i + expr = myst(r"""\py{symbolic}_0 """, globals(), locals()) + for i in range(min + 1, max): + # On concatène '+ symbolic_i' pour chaque terme suivant + expr += myst(r"""+\py{symbolic}_\py{i} """, globals(), locals()) + return expr # retourne la somme symbolique + +class pxs_Set: + """ + Wrapper pédagogique pour l'affichage LaTeX d'ensembles SymPy (Interval, Union, + Intersection, Complement, Range, ensembles usuels N, N*, Z, R) avec notation + française et délimiteurs \\llbracket \\rrbracket pour les intervalles entiers. + + :pxs_trigger: ensemble mathématique LaTeX, union intersection complément, N*, Reals, Integers, intervalle d'entiers ⟦a,b⟧, notation française ensembles + :pxs_returns: | + Wrapper autour d'un ensemble SymPy (Set) avec une méthode .print() + produisant une chaîne LaTeX respectant les conventions pédagogiques + françaises (N*, ]a;b[, ⟦a;b⟧, A ∪ B, A ∩ B, A \\ B). + :pxs_example: | + S = pxs_Set(Union(Interval(-oo, 0), Interval(1, 5, False, True))) + # Dans MyST : $D = \\py{S.print()}$ + :pxs_antipattern: Utiliser directement latex(ensemble) qui donne la notation anglo-saxonne et ne gère pas N*, ⟦a,b⟧, ni les conventions françaises. + """ + def __init__(self, st): + """ + Initialise le wrapper autour d'un ensemble SymPy. + + :pxs_trigger: instanciation wrapper pxs_Set autour d'un Set sympy + :pxs_returns: | + Instance pxs_Set encapsulant l'ensemble SymPy fourni, prête à + être affichée via .print(). + :pxs_example: | + S = pxs_Set(Interval(0, 1)) + latex_str = S.print() + :pxs_antipattern: Passer une expression qui n'est pas un Set SymPy — la méthode print() suppose la présence d'attributs args cohérents. + """ + self.s = st + + def print(self): + """ + Produit la représentation LaTeX de l'ensemble selon les conventions + pédagogiques françaises PyxiScience. + + :pxs_trigger: afficher ensemble en LaTeX, notation française ensembles, union intersection, ⟦a,b⟧, N*, Reals, domaine de définition composé + :pxs_returns: | + Chaîne LaTeX (str) représentant l'ensemble : Reals pour ]-∞,+∞[, + N, N*, Z pour les ensembles usuels, ⟦a,b⟧ pour les intervalles + entiers (Range), et A ∪ B / A ∩ B / A \\ B avec parenthésage + automatique des sous-ensembles composés. + :pxs_example: | + D = pxs_Set(Complement(Reals, FiniteSet(0))).print() + # Dans MyST : $D_f = \\py{D}$ + :pxs_antipattern: Composer manuellement la représentation d'une union/intersection sans gérer le parenthésage des sous-ensembles composés (A ∪ (B ∩ C)). + """ + + def __pxs_parentheses(st): + #Déterminer si st est composé de plusieurs ensembles + pxs_st = pxs_Set(st) + compose = st.args # tuple des composantes de st, et False si aucune + for x in st.args: + if not isinstance(x, Set): + compose = False + + if compose: + return fr"\big({pxs_st.print()}\big)" + return pxs_st.print() + + if isinstance(self.s, Interval) and self.s.left == -oo and self.s.right == oo: + return latex(Reals) + elif isinstance(self.s, Interval): + pxs_st = pxs_Interval.from_Interval(self.s) + return pxs_st.print() + if isinstance(self.s, Complement): + return r" \setminus ".join(map(__pxs_parentheses, self.s.args)) + if isinstance(self.s, (Union, Intersection)): + op = r" \cap " if isinstance(self.s, Intersection) else r" \cup " + return fr"{op}".join(map(__pxs_parentheses, self.s.args)) + if isinstance(self.s, Range) and self.s.args[2] == 1: + if self.s.args[0] == -oo and self.s.args[1] == oo: + return latex(Integers) + elif self.s.args[0] == 0 and self.s.args[1] == oo: + return latex(Naturals) + elif self.s.args[0] == 1 and self.s.args[1] == oo: + return r"\N^*" + else: + return pxs_Interval(self.s.args[0], self.s.args[1] - 1).print().replace(r"\left[", r"\llbracket").replace(r"\left]", r"\rrbracket").replace(r"\right[", r"\llbracket").replace(r"\right]", r"\rrbracket") + if self.s == Naturals: + return r"\N^*" + if self.s == Naturals0: + return latex(Naturals) + else: + return latex(self.s) + + + # ========================================================================= + # CLASSE pxs_Plotable POUR LA REPRÉSENTATION GRAPHIQUE D'EXPRESSIONS SYMPY + # ========================================================================= + +""" PISTES D'AMÉLIORATION : +- Gestion des singularités : retirer les singularités de la liste des abscisses, voire éventuellement plafonner les valeurs extrêmes de la fonction. P. ex : +if max(ordo) > seuil: # (seuil = 100 par défaut ?) + # ...limiter l'axe des ordonnées à +seuil +if min(ordo) < - seuil: + # ... limiter l'axe des ordonnées à - seuil. + +- vérifier mais je crois que plot_vars n'est pas utile (sauf peut-être pour les vérifs) +dans plot_surface_partial et contour_partial. Peut sans doute être enlevée des variables de sortie de la fonction auxiliaire associée. + +- ajouter une colorbar (peut-être en option) sur les contour. +""" + +import matplotlib.pyplot as plt +import sympy as sp +import numpy as np + + +# # finalement inutile si l'on est obligés d'utiliser numpy pour les tracés 3d... +# def pxs_meshgrid(x, y): +# """Pour remplacer le meshgrid de Numpy""" +# n, p = len(x), len(y) +# X = [x for _ in range(p)] +# Y = [[yi] * n for yi in y] +# return X, Y + + +class pxs_Plotable: + """ + Cette classe permet de représenter graphiquement des fonctions ou suites mathématiques d'une ou plusieurs variables définies par des expressions SymPy (fraphes 2D, 3D, fonctions partielles, suites). + + Voir la documentation de chaque fonction, et la fonction tests_visuels_pxs_Plotable() du fichier de test correspondant + pour un aperçu graphique général. + + :pxs_trigger: représentation graphique expression SymPy, tracé fonction matplotlib pédagogique, courbe surface contour nuage suite + :pxs_returns: | + Classe wrappant une expression SymPy et offrant les méthodes plot, + plot_partial, plot_surface, plot_surface_partial, contour, + contour_partial, plot_corde, scatter pour tous les besoins graphiques + pédagogiques (2D, 3D, suites, fonctions partielles). + :pxs_example: | + pex = pxs_Plotable("cos(x)") + pex.plot(interv=pxs_Interval(0, 2*pi)) + # La figure matplotlib courante est modifiée par effet de bord. + :pxs_antipattern: Combiner sympy.lambdify + np.linspace + plt.plot à la main, avec titre LaTeX et gestion des bornes infinies réécrits à chaque exercice. + """ + + def __init__(self, expr): + """ + Initialise un objet pxs_Plotable pour le tracé de fonctions SymPy. + + Cette classe permet de représenter graphiquement des fonctions ou suites mathématiques d'une ou plusieurs variables définies par des expressions SymPy (fraphes 2D, 3D, fonctions partielles, suites). + + Paramètres + ---------- + expr : str, sympy.Expr + L'expression mathématique à tracer. Peut être une chaîne de caractères + (qui sera convertie automatiquement) ou une expression SymPy. + + Exemples + -------- + >>> from sympy.abc import x, y + >>> pex1 = pxs_Plotable("cos(x)") + >>> pex2 = pxs_Plotable(x**2 + y**2) + >>> pex3 = pxs_Plotable(5) # fonction constante + + :pxs_trigger: créer objet traçable depuis expression sympy ou chaîne, conversion automatique sympify + :pxs_returns: | + Instance pxs_Plotable avec attributs .expr (expression SymPy) et + .vars (set des variables libres), prête pour l'appel à plot, + plot_surface, contour, scatter, etc. + :pxs_example: | + pex = pxs_Plotable("x**2 + y**2") + pex.plot_surface() + :pxs_antipattern: Stocker l'expression sous forme de chaîne et la reconvertir à chaque tracé. + """ + + expr = sp.sympify(expr) # si p. ex expr est une chaîne en entrée + self.expr = expr + self.vars = expr.free_symbols + + def plot(self, interv = pxs_Interval(-5, 5), pas = None, ymin = None, ymax = None, title = True, fast = False, **kwargs): + + """ + Trace la courbe représentative d'une fonction d'une variable. + + Cette méthode trace le graphe d'une fonction à une variable ou d'une constante. + Elle gère automatiquement les bornes infinies et les intervalles ouverts. + + Paramètres + ---------- + interv : pxs_Interval, optionnel + L'intervalle de tracé. Par défaut [-5, 5]. + pas : float, optionnel + Le pas d'échantillonnage. Par défaut (b-a)/1000. + title : bool, optionnel + Afficher ou non le titre avec l'expression LaTeX. Par défaut True. + fast : bool, optionnel + Mode de tracé rapide utilisant plt directement. Par défaut False. + **kwargs + Arguments supplémentaires passés à matplotlib.pyplot.plot. + + Lève + ---- + ValueError + Si l'expression contient plus d'une variable libre. + + Exemples + -------- + >>> from sympy.abc import x + >>> pex = pxs_Plotable("cos(x)") + >>> pex.plot() # Trace cos(x) sur [-5, 5] + >>> pex.plot(interv=pxs_Interval(0, 2*pi), color="red") + >>> + >>> # Intervalle ouvert + >>> pex_log = pxs_Plotable("log(x)") + >>> pex_log.plot(interv=pxs_Interval.open(0, 5)) + + :pxs_trigger: tracer courbe fonction une variable, graphe f(x) sur intervalle, représentation graphique exercice + :pxs_returns: | + None (effet de bord). Dessine la courbe sur l'axe matplotlib + courant (plt.gca() ou plt.plot si fast=True), avec titre LaTeX + automatique "Courbe représentative de la fonction x ↦ f(x)". + :pxs_example: | + pex = pxs_Plotable("x**2 - 1") + pex.plot(interv=pxs_Interval(-3, 3), color="blue") + # Sauvegarde via plt.savefig(...) + :pxs_antipattern: Utiliser np.linspace + sp.lambdify + plt.plot et composer manuellement le titre LaTeX à chaque exercice. + """ + + a, b = interv.left, interv.right + + # /!\ GADGET DE CLAUDE : Gestion des bornes infinies, est-il bien utile de le garder ? + if a == -sp.oo: + a = -100 # Grande valeur négative par défaut + elif a == sp.oo: + a = 100 # Grande valeur positive par défaut + + if b == sp.oo: + b = 100 # Grande valeur positive par défaut + elif b == -sp.oo: + b = -100 # Grande valeur négative par défaut + + + # pas par défaut : 1000 subdivisions + if pas is None: + pas = (b - a) / 1000 + + nb_vars = len(self.vars) + # Fonction constante + if nb_vars == 0: + x = sp.Symbol("x") + var = x # pour le titre + absc = [a, b] + c = self.expr.evalf() + ordo = [c, c] + # Fonction d'exactement deux variables + elif nb_vars == 1: + var = list(self.vars)[0] + fonction = sp.lambdify(var, self.expr, "math") + absc = [a + k * pas for k in range(int( (b-a) / pas) + 1)] + + # Cas d'un intervalle ouvert : retrait des bornes + if interv.left_open: + absc.pop(0) + if interv.right_open: + absc.pop() + + ordo = [fonction(xk) for xk in absc] + + # Cas d'une fonction de plus de deux variables : erreur + elif nb_vars == 2: + raise ValueError("Trop de variables dans l'expression. Utiliser plot_partial ou plot_surface") + else: + raise ValueError("Trop de variables dans l'expression. Utiliser plot_partial ou plot_surface_partial") + + if fast: # Pour une seule figure, sans avoir à définir des subplots et des axes + # plt.clf() + plt.plot(absc, ordo, **kwargs) + if title: + plt.title(fr"Courbe représentative de la fonction ${var} \mapsto {sp.latex(self.expr)}$ sur l'intervalle ${sp.latex(interv)}$") + # plt.show() + else: + ax = plt.gca() + ax.plot(absc, ordo, **kwargs) + if title: + ax.set_title(fr"Courbe représentative de la fonction ${var} \mapsto {sp.latex(self.expr)}$ sur l'intervalle ${sp.latex(interv)}$") + + # Si l'utilisateur spécifie des valeurs limites en ordonnées + if ymin is not None: + ax.set_ylim(bottom = ymin) + if ymax is not None: + ax.set_ylim(top = ymax) + + def plot_partial(self, plot_var = None, fixes = None, **kwargs): + + """ + Trace la courbe d'une fonction partielle en fixant certaines variables. + + Cette méthode permet de tracer une fonction de plusieurs variables en fixant + toutes les variables sauf une. La figure obtenue est une courbe dans le plan. + + Paramètres + ---------- + plot_var : sympy.Symbol, optionnel + La variable selon laquelle tracer. Par défaut, la première variable + de l'expression. + fixes : dict, optionnel + Dictionnaire {variable: valeur} fixant les autres variables. + Par défaut, toutes les autres variables sont fixées à 0. + **kwargs + Arguments supplémentaires passés à la méthode plot(). + + Lève + ---- + ValueError + Si l'expression est constante, si plot_var n'appartient pas aux variables + de l'expression, si certaines variables ne sont pas fixées, ou si une + valeur fixée correspond à une singularité. + + Exemples + -------- + >>> from sympy.abc import x, y, t + >>> pex = pxs_Plotable(2*x**2 + 3*y/t) + >>> # Trace selon x avec y=1, t=1 + >>> pex.plot_partial(x, {y: 1, t: 1}) + >>> # Trace selon t avec x=1, y=1, évite t=0 (singularité) + >>> pex.plot_partial(t, {x: 1, y: 1}, interv=pxs_Interval.open(0, 5)) + + :pxs_trigger: fonction partielle, fixer variables, tracer f(x,y,t) selon x avec y et t fixés, coupe 1D d'une fonction multivariable + :pxs_returns: | + None (effet de bord matplotlib). Substitue les variables de fixes + par leurs valeurs, puis trace la courbe de la fonction partielle + résultante selon plot_var via pxs_Plotable.plot. + :pxs_example: | + pex = pxs_Plotable(2*x**2 + 3*y/t) + pex.plot_partial(x, {y: 1, t: 1}) + # Trace x ↦ 2x² + 3 + :pxs_antipattern: Faire self.expr.subs({y:1, t:1}) à la main puis reconstruire un pxs_Plotable et appeler plot sans vérifier les singularités ni l'appartenance des variables. + """ + + if fixes is None: + const_vars = self.vars.copy() + # Vérification avant suppression + try: + const_vars.remove(plot_var) + except: + raise ValueError(f"La variable {plot_var} ne fait pas partie des variables de l'expression") + fixes = {v : 0 for v in const_vars} # par défaut toutes les var fixées valent 0 + + if len(self.vars) == 0: + raise ValueError("L'expression est constante") + elif plot_var is not None and plot_var not in self.vars: + raise ValueError(f"La variable {plot_var} ne fait pas partie des variables de l'expression") + + if not self.vars.issubset( set(fixes.keys()).union({plot_var}) ): + raise ValueError("Certaines variables de l'expression n'ont pas été fixées") + + for k, v in fixes.items(): + if v in sp.singularities(self.expr, k): + raise ValueError(f"La valeur {v} est une singularité de l'expression par rapport à la variable {k}") + + + # Définition des valeurs par défaut de plot_var et fixes (impossible d'appeler self + # dans les arguments par défaut) + if plot_var is None: + plot_var = list(self.vars)[0] + + if fixes is None: + const_vars = self.vars.copy() + # Vérification avant suppression + try: + const_vars.remove(plot_var) + except: + raise ValueError(f"La variable {plot_var} ne fait pas partie des variables de l'expression") + fixes = {v : 0 for v in const_vars} # par défaut toutes les var fixées valent 0 + + fonction_partielle = pxs_Plotable(self.expr.subs(fixes)) + fonction_partielle.plot(**kwargs) + + def __aux_2var(self, interv1, interv2, nb_points): + """ + Fonction auxiliaire privée utilisée dans les fonctions plot_surface et contour pour discrétiser une fonction de deux variables. + + :pxs_trigger: interne — discrétisation fonction 2 variables pour plot_surface et contour + :pxs_returns: | + Tuple (X, Y, Z) de np.ndarray : grille meshgrid des abscisses et + ordonnées, plus l'évaluation vectorisée de l'expression sur cette grille. + :pxs_example: | + # Appelé uniquement en interne par plot_surface et contour + # X, Y, Z = self.__aux_2var(interv1, interv2, 30) + :pxs_antipattern: Appeler cette méthode privée directement depuis un exercice au lieu d'utiliser plot_surface ou contour. + """ + + if len(self.vars) != 2: + raise ValueError("La fonction à tracer doit être une fonction d'exactement deux variables") + # Convertir les bornes en float "normaux" + a1, b1 = float(interv1.left), float(interv1.right) + a2, b2 = float(interv2.left), float(interv2.right) + + x = np.linspace(a1, b1, nb_points) + y = np.linspace(a2, b2, nb_points) + # Cas d'un intervalle ouvert + if interv1.left_open: + x.pop(0) + if interv1.right_open: + x.pop() + if interv2.left_open: + y.pop(0) + if interv2.right_open: + y.pop() + + X, Y = np.meshgrid(x, y) + liste_vars = list(self.vars) + f = sp.lambdify(liste_vars, self.expr, "numpy") + Z = f(X, Y) + + return X, Y, Z + + def __aux_verif_partial_2var(self, plot_vars, fixes): + """ + Vérifications à effectuer avant l'utilisation des méthodes de tracé de fonctions partielles à plus de deux variables. + + :pxs_trigger: interne — validation arguments plot_surface_partial et contour_partial + :pxs_returns: | + Le dictionnaire fixes complété avec les valeurs par défaut (0) + pour toutes les variables non explicitement fixées et non présentes + dans plot_vars. Lève ValueError en cas d'incohérence. + :pxs_example: | + # Appelé uniquement en interne par plot_surface_partial et contour_partial + # fixes = self.__aux_verif_partial_2var([x, y], {z: 1}) + :pxs_antipattern: Dupliquer cette logique de validation dans plot_surface_partial et contour_partial au lieu de centraliser. + """ + + if plot_vars is None: + lis_vars = list(self.vars) + plot_vars = lis_vars[:2] + + if len(plot_vars) != 2: + raise ValueError("plot_vars doit contenir exactement 2 variables") + + if fixes is None: + const_vars = self.vars.copy() + # Vérifications avant suppression + for var in plot_vars: + try: + const_vars.remove(var) + except: + raise ValueError(f"La variable {var} ne fait pas partie des variables de l'expression") + + fixes = {v : 0 for v in const_vars} # par défaut toutes les var fixées valent 0 + + for var in plot_vars: + if var not in self.vars: + raise ValueError(f"La variable {var} ne fait pas partie des variables de l'expression") + + if fixes is not None: + for k, v in fixes.items(): + if v in sp.singularities(self.expr, k): + raise ValueError(f"La valeur {v} est une singularité de l'expression par rapport à la variable {k}") + + if not self.vars.issubset( set(fixes.keys()).union(set(plot_vars)) ): + raise ValueError("Certaines variables de l'expression n'ont pas été fixées") + + return fixes + + + def plot_surface(self, interv1 = pxs_Interval(-5, 5), interv2 = pxs_Interval(-5, 5), nb_points = 30, cmap = "viridis", title = True, **kwargs): + """ + Trace la surface représentative d'une fonction de deux variables. + + Cette méthode génère un tracé 3D de la surface définie par une fonction + à exactement deux variables. + + Paramètres + ---------- + interv1 : pxs_Interval, optionnel + Intervalle pour la première variable. Par défaut [-5, 5]. + interv2 : pxs_Interval, optionnel + Intervalle pour la deuxième variable. Par défaut [-5, 5]. + nb_points : int, optionnel + Nombre de points par axe pour la grille. Par défaut 30. + cmap : str, optionnel + Colormap matplotlib pour la surface. Par défaut "viridis". + title : bool, optionnel + Afficher ou non le titre avec l'expression LaTeX. Par défaut True. + **kwargs + Arguments supplémentaires passés à matplotlib.Axes3D.plot_surface. + + Lève + ---- + ValueError + Si l'expression ne contient pas exactement deux variables libres. + + Exemples + -------- + >>> from sympy.abc import x, y + >>> surf1 = pxs_Plotable(x**2 + y**2) # Paraboloïde + >>> fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + >>> surf1.plot_surface(cmap="plasma") + >>> + >>> surf2 = pxs_Plotable("sin(x)*cos(y)") # Surface trigonométrique + >>> surf2.plot_surface(nb_points=50, cmap="coolwarm") + + :pxs_trigger: surface 3D fonction deux variables, paraboloïde selle de cheval, tracé tridimensionnel matplotlib, z=f(x,y) + :pxs_returns: | + None (effet de bord). Dessine la surface 3D sur l'axe 3D courant + (nécessite subplot_kw={"projection": "3d"}), avec titre LaTeX + automatique et colormap configurable. + :pxs_example: | + surf = pxs_Plotable(x**2 + y**2) + fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + surf.plot_surface(cmap="plasma") + :pxs_antipattern: Construire meshgrid + lambdify + Axes3D.plot_surface à la main pour chaque exercice, sans titre LaTeX normalisé. + """ + + # l'utilisation de numpy semble obligatoire ici : + # (et si on s'en sert autant le faire partout où ça simplifie les choses non ?) + + # --- ci-dessous : tentative infructueuse de ne pas utiliser numpy... --- + + # a1, b1 = interv1.left.evalf(), interv1.right.evalf() # evalf() pour convetir en float "normaux" + # h1 = (b1 - a1) / (nb_points - 1) + # a2, b2 = interv2.left.evalf(), interv2.right.evalf() # idem + # h2 = (b2 - a2) / (nb_points - 1) + # x = [a1 + k * h1 for k in range(nb_points)] + # y = [a2 + k * h2 for k in range(nb_points)] + # X, Y = pxs_meshgrid(x, y) + # liste_vars = list(self.vars) + # f = sp.lambdify(liste_vars, self.expr, "math") + # Z = [[f(xi, yj).evalf() for xi in x] for yj in y] + + X, Y, Z = self.__aux_2var(interv1, interv2, nb_points) + liste_vars = list(self.vars) + ax = plt.gca() + ax.plot_surface(X, Y, Z, cmap = cmap, **kwargs) + + if title: + var1, var2 = liste_vars[0], liste_vars[1] + ax.set_title(fr"Surface représentative de la fonction $({var1}, {var2}) \mapsto {sp.latex(self.expr)}$") + + + + def plot_surface_partial(self, plot_vars = None, fixes = None, **kwargs): + """ + Trace la surface d'une fonction partielle en fixant certaines variables. + + Cette méthode permet de tracer une fonction de plus de deux variables en fixant + toutes les variables sauf deux, générant ainsi une surface 3D. Elle effectue + automatiquement les vérifications de cohérence. + + Paramètres + ---------- + plot_vars : list of sympy.Symbol, optionnel + Liste de deux variables pour les axes de la surface. Par défaut, + les deux premières variables de l'expression. + fixes : dict, optionnel + Dictionnaire {variable: valeur} fixant les autres variables. + Par défaut, toutes les autres variables sont fixées à 0. + **kwargs + Arguments supplémentaires passés à la méthode plot_surface(). + + Lève + ---- + ValueError + Si plot_vars ne contient pas exactement 2 variables, si les variables + de plot_vars n'appartiennent pas à l'expression, si certaines variables + ne sont pas fixées, ou si une valeur fixée correspond à une singularité. + + Exemples + -------- + >>> from sympy.abc import x, y, z, t + >>> pex = pxs_Plotable(x**2 + y**2 + z + t) + >>> fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + >>> # Surface x-y avec z=1, t=0 + >>> pex.plot_surface_partial([x, y], {z: 1, t: 0}) + >>> # Surface x-z avec y=0, t=1 + >>> pex.plot_surface_partial([x, z], {y: 0, t: 1}, cmap="plasma") + + :pxs_trigger: surface 3D fonction partielle plus de 2 variables, coupe 2D en 3D, fixer certaines variables pour tracer surface, section surface + :pxs_returns: | + None (effet de bord). Fixe les variables de fixes, puis trace la + surface 3D de la fonction partielle sur les deux variables restantes + (plot_vars) via pxs_Plotable.plot_surface. + :pxs_example: | + pex = pxs_Plotable(x**2 + y**2 + z + t) + fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + pex.plot_surface_partial([x, y], {z: 1, t: 0}) + :pxs_antipattern: Faire self.expr.subs puis reconstruire un pxs_Plotable et appeler plot_surface sans valider que plot_vars contient exactement 2 variables ni repérer les singularités. + """ + + fixes = self.__aux_verif_partial_2var(plot_vars, fixes) + + fonction_partielle = pxs_Plotable(self.expr.subs(fixes)) + fonction_partielle.plot_surface(**kwargs) + + def contour(self, interv1 = pxs_Interval(-5, 5), interv2 = pxs_Interval(-5, 5), nb_points = 30, levels = 10, title = True, **kwargs): + """ + Trace les lignes de niveau d'une fonction de deux variables. + + Cette méthode génère un tracé 2D des courbes de niveau d'une fonction + à exactement deux variables. + + Paramètres + ---------- + interv1 : pxs_Interval, optionnel + Intervalle pour la première variable. Par défaut [-5, 5]. + interv2 : pxs_Interval, optionnel + Intervalle pour la deuxième variable. Par défaut [-5, 5]. + nb_points : int, optionnel + Nombre de points par axe pour la grille. Par défaut 30. + levels : int ou array-like, optionnel + Nombre de niveaux ou valeurs spécifiques pour les lignes de niveau. + Par défaut 10. + cmap : str, optionnel + Colormap matplotlib pour les lignes de niveau. Par défaut "viridis". + title : bool, optionnel + Afficher ou non le titre avec l'expression LaTeX. Par défaut True. + **kwargs + Arguments supplémentaires passés à matplotlib.pyplot.contour ou contourf. + + Lève + ---- + ValueError + Si l'expression ne contient pas exactement deux variables libres. + + Exemples + -------- + >>> from sympy.abc import x, y + >>> import matplotlib.pyplot as plt + >>> + >>> # Lignes de niveau d'un paraboloïde + >>> parab = pxs_Plotable(x**2 + y**2) + >>> parab.contour(levels=15) + >>> plt.show() + >>> + >>> # Lignes de niveau avec couleurs personnalisées + >>> gaussian = pxs_Plotable(sp.exp(-(x**2 + y**2)/2)) + >>> gaussian.contour(cmap="hot", levels=20) + >>> + >>> # Niveaux spécifiques pour une fonction trigonométrique + >>> trig = pxs_Plotable(sp.sin(x)*sp.cos(y)) + >>> levels_custom = [-0.8, -0.4, 0, 0.4, 0.8] + >>> trig.contour(levels=levels_custom, colors="black", linewidths=2) + >>> plt.clabel(cs, inline=True, fontsize=10) # Ajouter des labels + >>> + >>> # Selle de cheval avec intervalle personnalisé + >>> selle = pxs_Plotable(x**2 - y**2) + >>> selle.contour(interv1=pxs_Interval(-3, 3), + ... interv2=pxs_Interval(-3, 3), + ... levels=25, cmap="RdBu_r") + + :pxs_trigger: lignes de niveau, courbes de niveau, isolignes, carte topographique, contour 2D fonction deux variables, f(x,y)=c + :pxs_returns: | + None (effet de bord). Dessine les courbes de niveau sur l'axe + matplotlib courant, avec titre LaTeX, labels des axes, et ratio + d'aspect "equal" pour une représentation fidèle. + :pxs_example: | + parab = pxs_Plotable(x**2 + y**2) + parab.contour(levels=15) + plt.show() + :pxs_antipattern: Construire meshgrid + lambdify + plt.contour à la main sans normaliser le titre ni verrouiller l'aspect ratio. + """ + + X, Y, Z = self.__aux_2var(interv1, interv2, nb_points) + liste_vars = list(self.vars) + # Tracé des lignes de niveau + ax = plt.gca() + cs = ax.contour(X, Y, Z, levels=levels, **kwargs) + + # Ajout du titre + if title: + var1, var2 = liste_vars[0], liste_vars[1] + ax.set_title(fr"Lignes de niveau de $({var1}, {var2}) \mapsto {sp.latex(self.expr)}$") + + + # Labels des axes + ax.set_xlabel(f"${liste_vars[0]}$") + ax.set_ylabel(f"${liste_vars[1]}$") + + # Assurer un ratio d'aspect égal pour une représentation fidèle + ax.set_aspect('equal', adjustable='box') + + + def contour_partial(self, plot_vars = None, fixes = None, **kwargs): + """ + Trace les lignes de niveau d'une fonction partielle en fixant certaines variables. + + Cette méthode permet de tracer les lignes de niveau d'une fonction de plus de deux + variables en fixant toutes les variables sauf deux, générant ainsi un tracé 2D de + lignes de niveau. Elle effectue automatiquement les vérifications de cohérence. + + Paramètres + ---------- + plot_vars : lise de sympy.Symbol, optionnel + Liste de deux variables pour les axes du tracé de contour. Par défaut, + les deux premières variables de l'expression. + fixes : dict, optionnel + Dictionnaire {variable: valeur} fixant les autres variables. + Par défaut, toutes les autres variables sont fixées à 0. + **kwargs + Arguments supplémentaires passés à la méthode contour(). + + Retourne + -------- + matplotlib.contour.QuadContourSet + L'objet contour retourné par matplotlib, utile pour ajouter des labels + ou personnaliser l'affichage. + + Lève + ---- + ValueError + Si plot_vars ne contient pas exactement 2 variables, si les variables + de plot_vars n'appartiennent pas à l'expression, si certaines variables + ne sont pas fixées, ou si une valeur fixée correspond à une singularité. + + Exemples + -------- + >>> from sympy.abc import x, y, z, t + >>> import matplotlib.pyplot as plt + >>> + >>> # Fonction de 4 variables + >>> pex = pxs_Plotable(x**2 + y**2 + z*t) + >>> + >>> # Lignes de niveau dans le plan x-y avec z=1, t=2 + >>> pex.contour_partial([x, y], {z: 1, t: 2}, levels=15) + >>> + >>> # Lignes de niveau dans le plan x-z avec y=0, t=1 + >>> pex.contour_partial([x, z], {y: 0, t: 1}, cmap="plasma") + >>> + >>> # Niveaux personnalisés pour une fonction trigonométrique + >>> trig_3d = pxs_Plotable(sp.sin(x)*sp.cos(y) + z) + >>> levels_custom = [0, 0.5, 1.0, 1.5, 2.0] + >>> trig_3d.contour_partial([x, y], {z: 1}, levels=levels_custom, + ... colors="black", linewidths=2) + + :pxs_trigger: lignes de niveau fonction partielle plus de 2 variables, contour 2D avec variables fixées, section isolignes + :pxs_returns: | + None (effet de bord). Fixe les variables de fixes, puis trace les + courbes de niveau de la fonction partielle sur les deux variables + restantes (plot_vars) via pxs_Plotable.contour. + :pxs_example: | + pex = pxs_Plotable(x**2 + y**2 + z*t) + pex.contour_partial([x, y], {z: 1, t: 2}, levels=15) + :pxs_antipattern: Faire expr.subs puis contour à la main sans vérifier la cohérence de plot_vars ni détecter les singularités. + """ + + fixes = self.__aux_verif_partial_2var(plot_vars, fixes) + + fonction_partielle = pxs_Plotable(self.expr.subs(fixes)) + fonction_partielle.contour(**kwargs) + + + + def plot_corde(self, absc_corde, fast = False, color = "black", linestyle = "dashed", marker = ".", **kwargs): + """ + Trace la corde reliant deux points d'une courbe. + + Cette méthode trace un segment de droite reliant deux points de la courbe + représentative de la fonction. + + Paramètres + ---------- + absc_corde : tuple ou list + Tuple ou liste de deux abscisses (a, b) définissant les extrémités + de la corde. + fast : bool, optionnel + Mode de tracé rapide utilisant plt directement. Par défaut False. + color : str, optionnel + Couleur de la corde. Par défaut "black". + linestyle : str, optionnel + Style de ligne. Par défaut "dashed". + marker : str, optionnel + Marqueur aux extrémités. Par défaut ".". + **kwargs + Arguments supplémentaires passés à matplotlib.pyplot.plot. + + Lève + ---- + ValueError + Si l'expression ne contient pas exactement une variable libre. + + Exemples + -------- + >>> from sympy.abc import x + >>> pex = pxs_Plotable("x**3 - 2*x") + >>> pex.plot(color="blue") + >>> # Corde entre x=-1 et x=2 + >>> pex.plot_corde((-1, 2), color="red", linewidth=2) + >>> # Plusieurs cordes + >>> pex.plot_corde((0, 1), color="green", marker="o") + + :pxs_trigger: corde courbe, taux d'accroissement visuel, sécante, segment reliant (a,f(a)) et (b,f(b)), illustration dérivée + :pxs_returns: | + None (effet de bord). Trace un segment en pointillés noirs reliant + les points (a, f(a)) et (b, f(b)) sur l'axe matplotlib courant. + Typiquement superposé à un appel pxs_Plotable.plot préalable. + :pxs_example: | + pex = pxs_Plotable("x**3 - 2*x") + pex.plot(color="blue") + pex.plot_corde((-1, 2), color="red", linewidth=2) + :pxs_antipattern: Calculer f(a) et f(b) via sympy.subs puis plt.plot([a,b],[fa,fb]) à la main à chaque exercice. + """ + + # Vérification du nombre de variables + if len(self.vars) != 1: + raise ValueError("La fonction plot_corde ne peut être utilisée que pour des fonctions d'une seule variable") + + # ci-dessous on suppose que c'est OK + a, b = absc_corde + var = list(self.vars)[0] + fa, fb = self.expr.subs(var, a), self.expr.subs(var, b) + if fast: + plt.plot([a, b], [fa, fb], color = color, linestyle = linestyle, marker = marker, **kwargs) + else: + ax = plt.gca() + ax.plot([a, b], [fa, fb], color = color, linestyle = linestyle, marker = marker, **kwargs) + + def scatter(self, n_terms=20, start_index=0, fast=False, **kwargs): + """ + Trace les termes d'une suite numérique sous forme de nuage de points. + + Cette méthode évalue l'expression pour des valeurs entières consécutives + de la variable et affiche les résultats sous forme de nuage de points. + + Paramètres + ---------- + n_terms : int, optionnel + Nombre de termes de la suite à tracer. Doit être un entier positif. + Par défaut 20. + start_index : int, optionnel + Indice de départ de la suite. Peut être négatif, nul ou positif + selon la définition de la suite. Par défaut 0. + fast : bool, optionnel + Mode de tracé rapide utilisant plt directement sans axes personnalisés. + Si False, ajoute automatiquement les labels et le titre. Par défaut False. + **kwargs + Arguments supplémentaires passés à matplotlib.pyplot.scatter. + Exemples : color, s (taille), alpha, marker, etc. + + Lève + ---- + ValueError + Si l'expression ne contient pas exactement une variable libre. + Message : "La fonction scatter ne peut être utilisée que + pour des suites (fonction d'une seule variable)". + + Exemples + -------- + >>> from sympy.abc import n + >>> import matplotlib.pyplot as plt + >>> + >>> # Suite arithmétique : u_n = 2n + 1 + >>> suite1 = pxs_Plotable(2*n + 1) + >>> suite1.scatter(n_terms=15, color="blue", s=50) + >>> plt.show() + >>> + >>> # Suite géométrique : u_n = (1/2)^n + >>> suite2 = pxs_Plotable("(1/2)**n") + >>> suite2.scatter(n_terms=20, start_index=1, color="red", s=40) + >>> + >>> # Suite alternée : u_n = (-1)^n / n + >>> suite4 = pxs_Plotable((-1)**n / n) + >>> suite4.scatter(n_terms=20, start_index=1, color="purple", alpha=0.7) + >>> + >>> # Mode fast pour tracé rapide sans labels + >>> suite1.scatter(fast=True, n_terms=10, color="cyan", s=80) + + :pxs_trigger: suite numérique, nuage de points, termes u_n graphique, représentation points isolés indice/valeur, suite arithmétique géométrique alternée + :pxs_returns: | + None (effet de bord). Évalue l'expression pour les indices entiers + de start_index à start_index+n_terms-1, puis trace un scatter avec + labels et titre LaTeX de la forme "(u_n)_{n ≥ start_index}". + :pxs_example: | + suite = pxs_Plotable(2*n + 1) + suite.scatter(n_terms=15, color="blue", s=50) + :pxs_antipattern: Faire une boucle for + sympy.subs + plt.scatter à la main sans labels normalisés ni titre LaTeX pédagogique. + """ + if len(self.vars) != 1: + raise ValueError("La fonction plot_scatter_sequence ne peut être utilisée que pour des suites (fonction d'une seule variable)") + + var = list(self.vars)[0] + indices = list(range(start_index, start_index + n_terms)) + values = [float(self.expr.subs(var, n)) for n in indices] + + if fast: + plt.scatter(indices, values, **kwargs) + else: + ax = plt.gca() + ax.scatter(indices, values, **kwargs) + ax.set_xlabel(f"${var}$ (indice)") + ax.set_ylabel(f"${sp.latex(self.expr)}$") + ax.set_title(fr"Termes de la suite de $\left({sp.latex(self.expr)}\right)_{{ {var} \geq {start_index} }}$") \ No newline at end of file diff --git a/app/corpus/Mes_fctions_d_alg_lineaire_bis.py b/app/corpus/Mes_fctions_d_alg_lineaire_bis.py new file mode 100644 index 0000000000000000000000000000000000000000..054c0cf64c2463b511eb4b89883760227661886d --- /dev/null +++ b/app/corpus/Mes_fctions_d_alg_lineaire_bis.py @@ -0,0 +1,3252 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Thu Mar 17 19:11:52 2022 + +@author: jlebovits +""" +from __future__ import division +import sys +from copy import deepcopy +from src.scripts.pxs_runtime import myst, get_pxs_lang +from sympy.printing.latex import LatexPrinter + + +# import src.scripts.Mes_fctions.Mes_fctions_deterministes +# from src.scripts.Mes_fctions.Mes_fctions_deterministes import * + +# import src.scripts.Mes_fctions.Mes_fctions_generalistes +# from src.scripts.Mes_fctions.Mes_fctions_generalistes import * + +# import src.scripts.Mes_fctions.Mes_fctions_probabilistes +# from src.scripts.Mes_fctions.Mes_fctions_probabilistes import * + +# import src.scripts.Mes_fctions.Mes_fctions_d_ecriture_Latex +# from src.scripts.Mes_fctions.Mes_fctions_d_ecriture_Latex import * + +# import src.scripts.Mes_fctions.Mes_fctions_d_alg_generale +# from src.scripts.Mes_fctions.Mes_fctions_d_alg_generale import * + + +from sympy import * +import functools as fct +import math as m +import random as rd +import numpy as np +from src.scripts.Mes_fctions.Mes_fctions_d_alg_lineaire import randmatrixdiagonale, zeros +from src.scripts.Mes_fctions.Mes_fctions_probabilistes_bis import pxsl_res_num +from sympy.stats import P, E, variance, Die, Normal, DiscreteUniform, Bernoulli, sample, Binomial, density, Normal, sample_iter, given + + + +def pxsl_pow(x, n=1, opt=0, displaystyle=True): + """ + Fr : Fonction permettant d'écrire le nombre x entouré de parenthèses + lorsqu'il est négatif ou irrationnel avec deux termes (par ex : 1+sqrt(2) ou 3sqrt(2)) + Ne fonctionne pas pour des valeurs numériques non simplifiées (par ex : 1+3 ou 3*3/2) + En : Function that writes the number x surrounded by parentheses + when it is negative or irrational with two terms (e.g.: 1+sqrt(2) or 3sqrt(2)) + Does not work for unsimplified numerical values (e.g.: 1+3 ou 3*3/2) + + Version 5 + --------- + 13/02/25 + + Vérification + ------ + Auteur : Delphine + Vérificateurs : ?? + + Paramètres + ---------- + x : nombre ou expression + La base à élever à la puissance n + n : int, optional + L'exposant (défaut: 1) + opt : int, optional + Option de formatage (défaut: 0) + 0: formatage standard + 1: simplifie l'affichage pour x=1, x=0 ou n=1 + 2: simplifie davantage et renvoie une chaîne vide pour x=0 + displaystyle : bool, optional + Si True, utilise \displaystyle pour les fractions (défaut: False) + + Retour + ------ + str + retourne l'expression en latex + + Fonction utilisée par + --------------------- + pxsl_sum_matrix, pxsl_prod_scalar_matrix, pxsl_prod_matrix, pxsl_pow_matrix +:pxs_trigger: | + afficher coefficient avec parenthèses, parenthéser terme négatif, + puissance sécurisée coeff^n, affichage pédagogique produit, + substitution valeur numérique dans formule, dérivée formelle affichage, + premier calcul d'une dérivée ligne par ligne, IBP ligne par ligne, + pxsl_pow(coeff, n), a^2 avec a négatif, (-3)^2 éviter -3^2, + coefficient dans équation différentielle y' + ay = ..., affichage trinôme, + étapes de développement polynôme, substitution x=a dans f(x) +:pxs_returns: | + Chaîne LaTeX (str) affichant `coeff^puissance` avec parenthèses + automatiques autour de `coeff` si celui-ci est négatif, rationnel, + ou une expression composée (Add). Si puissance=1, retourne juste le + coefficient (avec parenthèses si nécessaire). Indispensable pour + tout affichage pédagogique où on substitue des valeurs numériques + dans une formule littérale. +:pxs_example: | + # Question : b^2 - 4ac avec a=-3, b=5 + # À la main : f"{b}^2 - 4 \\times {a}" donne "5^2 - 4 \\times -3" ← FAUX + latex_b2 = pxsl_pow(b, 2) # "5^2" + latex_a = pxsl_pow(a, 1) # "(-3)" + # myst(r"\Delta = \py{latex_b2} - 4 \times \py{latex_a} \times ...") +:pxs_antipattern: | + ✗ f"{coeff}^{n}" en f-string — casse les signes négatifs ((-3)^2 devient "-3^2"), + oublie les parenthèses autour des fractions (1/2)^2, et ne gère pas le cas n=1. + ✓ Utiliser pxsl_pow(coeff, n) + :pxs_antipattern: Écrire f"({x})^{{{n}}}" à la main qui ne gère pas les cas x=1, x=0, n=1, ni les sous-cas Rational/Add/Mul/Symbol. + """ + + # Préparation de l'expression LaTeX selon le mode displaystyle + if displaystyle: + latex_x = r"\displaystyle " + latex(x) + else: + latex_x = latex(x) + + # Cas où x est une expression (Add ou Mul) ou nombre négatif: + if isinstance(x, Add) or isinstance(x, Mul) : + if n == 1 : + return myst(r"""\left(\py{latex_x}\right)""", globals(), locals()) + else: + return myst(r"""\left(\py{latex_x}\right)^{\py{n}}""", globals(), locals()) + # Cas où x est un Rational + elif isinstance(x,Rational) and x.q!=1: + if n == 1 : # Pas de parenthèses quand n=1 + if x < 0: # si la fraction est négative il faut des parenthèses + return myst(r"""\left(\py{latex_x}\right)""", globals(), locals()) + else: + return myst(r"""\py{latex_x}""", globals(), locals()) + else: # Parenthèses quand n différent de 1 + return myst(r"""\left(\py{latex_x}\right)^{\py{n}}""", globals(), locals()) + # Cas où x est un Symbol: + elif isinstance(x,Symbol): + if n == 1: + return myst(r"""\py{latex_x}""", globals(), locals()) + else: + return myst(r"""\py{latex_x}^{\py{n}}""", globals(), locals()) + # Cas où x est strictement négatif + elif x<0: + if n == 1: + return myst(r"""\left(\py{latex_x}\right)""", globals(), locals()) + else: + return myst(r"""\left(\py{latex_x}\right)^{\py{n}}""", globals(), locals()) + # Cas où x est un nombre positif ou nul + else: + # Option 0: formatage standard + if opt == 0: + if n == 1: + return myst(r"""\py{latex_x}""", globals(), locals()) + else: + return myst(r"""\py{latex_x}^{\py{n}}""", globals(), locals()) + + # Option 1: simplifie pour x=0, x=1 ou n=1 + elif opt == 1: + if x == 1 or x == 0 or n == 1: + return myst(r"""\py{latex_x}""", globals(), locals()) + else: + return myst(r"""\py{latex_x}^{\py{n}}""", globals(), locals()) + + # Option 2: simplifie davantage, chaîne vide pour x=0 + else: # opt == 2 ou autres valeurs + if x == 0: + return myst(r""" """, globals(), locals()) + elif x == 1 or n == 1: + return myst(r"""\py{latex_x}""", globals(), locals()) + else: + return myst(r"""\py{latex_x}^{\py{n}}""", globals(), locals()) + + +################ EXEMPLES ################## +# pxsl_pow(3,2) retourne l'écriture latex de 3^{2} +# pxsl_pow(-3,2) retourne l'écriture latex de \left(-3\right)^{2} +# pxsl_pow(-3+sqrt(2),3) retourne l'écriture latex de \left(-3+\sqrt{2}\right)^{3} +# pxsl_pow(-3*sqrt(2),3) retourne l'écriture latex de \left(-3\sqrt{2}\right)^{3} +# pxsl_pow(3,Symbol('n')) retourne l'écriture latex de 3^{n} +# pxsl_pow(1,'n') retourne l'écriture latex de 1^n +# pxsl_pow(0,Symbol('n')) retourne l'écriture latex de 0^n +# +# x=Symbol('x') +# y=Symbol('y') +# pxsl_pow(x+y,Symbol('n')) retourne l'écriture latex de \left(x+y\right)^{n} +# +# x=Symbol('x') +# y=Symbol('y') +# pxsl_pow(x*y,Symbol('n')) retourne l'écriture latex de \left(x y\right)^{n} + + +def pxsl_matrix(A, sepG="(", sepD=")", display=False, res_num=False): + """ + Return a LaTeX representation of a matrix with right-aligned entries. + + This function converts a matrix into a nicely formatted LaTeX matrix. + By default, entries are displayed as raw values. Optional display modes + allow symbolic LaTeX rendering or numerical result formatting. + + Parameters + ---------- + A : Matrix + The matrix to be displayed. + sepG : str, optional + Left delimiter of the matrix (default "("). + In English mode, it is automatically replaced by "[". + sepD : str, optional + Right delimiter of the matrix (default ")"). + In English mode, it is automatically replaced by "]". + display : bool, optional + If True, matrix entries are rendered in LaTeX format. + res_num : bool, optional + If True (and `display=True`), entries are displayed as numerical results. + + Returns + ------- + str + A LaTeX string representing the formatted matrix. + + Examples + -------- + Basic usage with raw values: + + >>> from sympy import Matrix + >>> A = Matrix([[1, 2], [3, 4]]) + >>> pxsl_matrix(A) + '\\\\left(\\begin{array}{rr}1 & 2\\\\[0.3em]3 & 4\\end{array}\\\\right)' + + Using custom delimiters: + + >>> pxsl_matrix(A, sepG='[', sepD=']') + '\\\\left[\\begin{array}{rr}1 & 2\\\\[0.3em]3 & 4\\end{array}\\\\right]' + + Displaying symbolic expressions in LaTeX: + + >>> from sympy import symbols + >>> x = symbols('x') + >>> B = Matrix([[x, x**2], [1/x, 2]]) + >>> pxsl_matrix(B, display=True) + '\\\\left(\\begin{array}{rr}x & x^{2}\\\\[0.3em]\\frac{1}{x} & 2\\end{array}\\\\right)' + + Displaying numerical results (after evaluation): + + >>> from sympy import Rational + >>> C = Matrix([[Rational(1, 2), Rational(3, 4)], [1, 2]]) + >>> pxsl_matrix(C, display=True, res_num=True) + '\\\\left(\\begin{array}{rr}0.5 & 0.75\\\\[0.3em]1 & 2\\end{array}\\\\right)' + + :pxs_trigger: afficher matrice LaTeX, délimiteurs personnalisés, déterminant |…|, matrice entre parenthèses ou crochets, alignement colonnes + :pxs_returns: | + Objet myst contenant la représentation LaTeX d'une matrice en + environnement array avec entrées alignées à droite et délimiteurs + configurables (par défaut ( ) en français, [ ] en anglais). + Mode display=True : entrées rendues via latex(). Mode res_num=True : + entrées formatées comme résultats numériques via pxsl_res_num. + :pxs_example: | + A = Matrix([[1, 2, 3], [4, 5, 6]]) + # Dans MyST : $A = \\py{pxsl_matrix(A)}$ + # Pour un déterminant : $\\py{pxsl_matrix(A, sepG='|', sepD='|')}$ + :pxs_antipattern: Écrire f"\\\\begin{{pmatrix}}{a}&{b}\\\\\\\\{c}&{d}\\\\end{{pmatrix}}" à la main sans gérer la dimension variable ni la langue. + """ + [n,p]=A.shape + pxs_lang = get_pxs_lang() + if pxs_lang == "en" and sepG=='(': + sepG='[' + if pxs_lang == "en" and sepD==')': + sepD=']' + expr=myst(r"""\left\py{sepG}\begin{array}{\py{'r'*p}}""",globals(),locals()) + for i in range(n): + if display: + if res_num: + expr+=myst(r"""\py{pxsl_res_num(A[i,0], egal = False)}""",globals(),locals()) + else: + expr+=myst(r"""\py{latex(A[i,0])}""",globals(),locals()) + else: + expr+=myst(r""" \py{A[i,0]}""",globals(),locals()) + for j in range(p-1): + if display: + if res_num: + expr+=myst(r""" &\py{pxsl_res_num(A[i,1+j], egal = False)}""",globals(),locals()) + else: + expr+=myst(r""" &\py{latex(A[i,1+j])}""",globals(),locals()) + else: + expr+=myst(r""" &\py{A[i,1+j]}""",globals(),locals()) + expr+=myst(r"""\\[0.3em]""") + expr+=myst(r"""\end{array}\right\py{sepD}""",globals(),locals()) + return expr + + +################ EXEMPLES ################## +# pxsl_matrix(Matrix([[1,2,3],[2,-3,4]])) retourne +# l'expression latex : \left(\begin{array}{rrr}1&2&3\2&-3&4\\end{array}\right) +# soit la matrice (entourée de parenthèses) : +# 1 2 3 +# 2 -3 4 +# pxsl_matrix(Matrix([[1,2,3],[2,-3,4]]),'|','|') retourne +# l'expression latex : \left|\begin{array}{rrr}1&2&3\2&-3&4\\end{array}\right| +# soit le déterminant (matrice entourée de |) : +# 1 2 3 +# 2 -3 4 +# on peut aussi utiliser une forme de séparateur d'un côté et un autre de l'autre côté + + +def pxsl_sum_matrix(A,B,s="+",sepG='(',sepD=')'): + """ + Fonction permettant d'afficher le détail de la somme (ou la différence) de deux matrices + + Version + ------- + 13/02/25 + + Vérification + ------------ + Auteur : Ronan - Delphine + Vérificateurs : + + Paramètres + ---------- + A : Matrix + Première matrice de la somme + B : Matrix + Deuxième matrice de la somme + s : str + "+" par défaut pour réaliser une somme + "-" pour réaliser une différence + sepG : str + délimiteur gauche de la matrice + sepD : str + délimiteur droit de la matrice + + Retour + ------ + str + retourne l'expression en latex + + Fonction utilisée par + --------------------- + aucune fonction pyxiscience + + :pxs_trigger: détailler somme de matrices, a_ij + b_ij terme à terme, différence matricielle A - B, affichage pédagogique A+B non calculé + :pxs_returns: | + Objet myst affichant la matrice dont chaque coefficient est + "a_ij + b_ij" (ou "a_ij - b_ij" si s="-") sans calcul effectué, + avec parenthèses automatiques autour des b_ij négatifs via pxsl_pow. + :pxs_example: | + A = Matrix([[-1, 0], [2, 3]]) + B = Matrix([[2, -3], [1, -3]]) + # Dans MyST : $A + B = \\py{pxsl_sum_matrix(A, B)}$ + # Résultat : matrice des expressions "-1+2", "0+(-3)", "2+1", "3+(-3)" + :pxs_antipattern: Calculer A + B avec sympy puis afficher le résultat final — perd le détail pédagogique "a_ij + b_ij" montrant l'opération. + """ + [n,p]=A.shape + pxs_lang = get_pxs_lang() + if pxs_lang == "en" and sepG=='(': + sepG='[' + if pxs_lang == "en" and sepD==')': + sepD=']' + expr=myst(r"""\left\py{sepG}\begin{array}{\py{'c'*p}}""",globals(),locals()) + for i in range(n): + expr+=myst(r""" \py{A[i,0]}\py{s}""",globals(),locals())+pxsl_pow(B[i,0]) + for j in range(p-1): + expr+=myst(r""" &\py{A[i,1+j]}\py{s}""",globals(),locals())+pxsl_pow(B[i,1+j]) + expr+=myst(r"""\\""") + expr+=myst(r"""\end{array}\right\py{sepD}""",globals(),locals()) + return expr + +################ EXEMPLES ################## +# pxsl_sum_matrix(Matrix([[-1,0,3],[2,3,4]]),Matrix([[2,3,5],[2,-3,4]])) retourne +# l'expression latex : \left(\begin{array}{ccc} -1+2&0+3&3+5\2+2&3+\left(-3\right)&4+4\\end{array}\right) +# soit la matrice : +# -1+2 0+3 3+5 +# 2+2 3+(-3) 4+4 +# la commande pxsl_sum_matrix(Matrix([[-1,0,3],[2,3,4]]),Matrix([[2,3,5],[2,-3,4]]),"+") renvoie la même chose +# pxsl_sum_matrix(Matrix([[-1,0,3],[2,3,4]]),Matrix([[2,3,5],[2,-3,4]]),"-") retourne +# l'expression latex : \left(\begin{array}{-1-2&0-3&3-5\2-2&3-\left(-3\right)&4-4\\end{array}\right) +# soit la matrice : +# -1-2 0-3 3-5 +# 2-2 3-(-3) 4-4 + +def pxsl_prod_scalar_matrix(lamb,A,mult="times",sepG='(',sepD=')'): + """ + Fonction permettant d'afficher le détail du produit entre un scalaire et une matrice + + Version + ------- + 13/02/25 + + Vérification + ------------ + Auteur : Ronan - Delphine + Vérificateurs : + + Paramètres + ---------- + lamb : float + coefficient multiplicateur + A : Matrix + Matrice + mult : str + "times" par défaut, peut-être remplacé par "cdot" pour modifier le symbole multiplicatif + sepG : str + délimiteur gauche de la matrice + sepD : str + délimiteur droit de la matrice + + Retour + ------ + str + retourne l'expression en latex + + Fonction utilisée par + --------------------- + aucune fonction pyxiscience + + :pxs_trigger: détailler produit scalaire par matrice, λ·A terme à terme, afficher 2×A sans calcul, lambda x a_ij pédagogique + :pxs_returns: | + Objet myst affichant la matrice dont chaque coefficient est + "λ × a_ij" (ou "λ · a_ij" si mult="cdot") sans calcul effectué, + avec parenthèses automatiques autour des a_ij négatifs via pxsl_pow. + :pxs_example: | + A = Matrix([[1, 2, -3], [2, 3, 4]]) + # Dans MyST : $2A = \\py{pxsl_prod_scalar_matrix(2, A)}$ + # Avec symbole · : $\\py{pxsl_prod_scalar_matrix(2, A, "cdot")}$ + :pxs_antipattern: Calculer 2*A avec sympy puis afficher — perd le détail "2×1, 2×(-3)" montrant le produit avant simplification. + """ + [n,p]=A.shape + pxs_lang = get_pxs_lang() + if pxs_lang == "en" and sepG=='(': + sepG='[' + if pxs_lang == "en" and sepD==')': + sepD=']' + expr=myst(r"""\left\py{sepG}\begin{array}{\py{'c'*p}}""",globals(),locals()) + for i in range(n): + expr+=myst(r"""\py{lamb}\\py{mult}""",globals(),locals())+pxsl_pow(A[i,0]) + for j in range(p-1): + expr+=myst(r"""&""")+myst(r"""\py{lamb}\\py{mult}""",globals(),locals())+pxsl_pow(A[i,1+j]) + expr+=myst(r"""\\""") + expr+=myst(r"""\end{array}\right\py{sepD}""",globals(),locals()) + return expr + +################ EXEMPLES ################## +# pxsl_prod_scalar_matrix(2,Matrix([[1,2,-3],[2,3,4]])) retourne +# l'expression latex : \left(\begin{array}{ccc} 2\times1&2\times2&2\times\left(-3\right)\2\times2&2\times3&2\times4\\end{array}\right) +# donc la matrice : +# 2 x 1 2 x 2 2 x (-3) +# 2 x 2 2 x 3 2 x 4 +# la commande pxsl_prod_scalar_matrix(2,Matrix([[1,2,-3],[2,3,4]]),"times") renvoie la même chose +# pxsl_prod_scalar_matrix(-2,Matrix([[1,2,-3],[2,3,4]])) retourne +# l'expression latex : \left(\begin{array}{ccc} -2\times1&-2\times2&-2\times\left(-3\right)\-2\times2&-2\times3&-2\times4\\end{array}\right) +# soit la matrice : +# -2 x 1 -2 x 2 -2 x (-3) +# -2 x 2 -2 x 3 -2 x 4 +# pxsl_prod_scalar_matrix(2,Matrix([[1,2,-3],[2,3,4]]),"cdot") retourne +# l'expression latex : \left(\begin{array}{ccc} 2\cdot1&2\cdot2&2\cdot\left(-3\right)\2\cdot2&2\cdot3&2\cdot4\\end{array}\right) +# soit la matrice : +# -2.1 -2.2 -2.(-3) +# -2.2 -2.3 -2.4 avec le . correspondant à la commande latex cdot +# pxsl_prod_scalar_matrix('a',Matrix([[1,2,-3],[2,3,4]])) retourne +# l'expression latex : \left(\begin{array}{ccc} a\times1&a\times2&a\times\left(-3\right)\a\times2&a\times3&a\times4\\end{array}\right) +# soit la matrice : +# a x 1 a x 2 a x (-3) +# a x 2 a x 3 a x 4 +# pxsl_prod_scalar_matrix(Symbol('a'),Matrix([[1,2,-3],[2,3,4]])) renvoie la même chose + +def pxsl_prod_matrix(A,B,mult="times",sepG='(',sepD=')'): + """ + Fonction permettant d'afficher le détail du produit entre deux matrices + + Version + ------- + 13/02/25 + + Vérification + ------------ + Auteur : Ronan - Delphine + Vérificateurs : + + Paramètres + ---------- + A : Matrix + Première matrice du produit + B : Matrix + Deuxième matrice du produit + mult : str + "times" par défaut, peut-être remplacé par "cdot" pour modifier le symbole multiplicatif + sepG : str + délimiteur gauche de la matrice + sepD : str + délimiteur droit de la matrice + + Retour + ------ + str + retourne l'expression en latex + + Fonction utilisée par + --------------------- + aucune fonction pyxiscience + + :pxs_trigger: détailler produit matriciel A×B, somme a_ik × b_kj, formule produit matrices non calculée, pédagogique composante (AB)_ij + :pxs_returns: | + Objet myst affichant la matrice produit A·B où chaque coefficient + (AB)_ij est présenté sous forme développée "a_i0·b_0j + a_i1·b_1j + …" + sans calcul effectué, avec parenthèses automatiques via pxsl_pow. + :pxs_example: | + A = Matrix([[-1, -2, 3], [2, 0, 4]]) + B = Matrix([[2, 3], [2, -3], [2, 3]]) + # Dans MyST : $AB = \\py{pxsl_prod_matrix(A, B)}$ + :pxs_antipattern: Calculer A*B avec sympy puis afficher — perd l'explicitation pédagogique des sommes de produits de la formule matricielle. + """ + [nA,pA]=A.shape + [nB,pB]=B.shape + pxs_lang = get_pxs_lang() + if pxs_lang == "en" and sepG=='(': + sepG='[' + if pxs_lang == "en" and sepD==')': + sepD=']' + expr=myst(r"""\left\py{sepG}\begin{array}{\py{'c'*pB}}""",globals(),locals()) + for i in range(nA): + expr+=myst(r"""\py{A[i,0]}\\py{mult}""",globals(),locals())+pxsl_pow(B[0,0]) + for k in range(pA-1): + expr+=myst(r"""+""")+pxsl_pow(A[i,1+k])+myst(r"""\\py{mult}""",globals(),locals())+pxsl_pow(B[1+k,0]) + for j in range(pB-1): + expr+=myst(r"""&""")+myst(r"""\py{A[i,0]}\\py{mult}""",globals(),locals())+pxsl_pow(B[0,1+j]) + for k in range(pA-1): + expr+=myst(r"""+""")+pxsl_pow(A[i,1+k])+myst(r"""\\py{mult}""",globals(),locals())+pxsl_pow(B[1+k,1+j]) + expr+=myst(r"""\\""") + expr+=myst(r"""\end{array}\right\py{sepD}""",globals(),locals()) + return expr + +################ EXEMPLES ################## +# pxsl_prod_matrix(Matrix([[-1,-2,3],[2,0,4]]),Matrix([[2,3,5],[2,-3,4],[2,3,4]])) retourne +# l'expression latex : \left(\begin{array}{ccc} -1\times2+\left(-2\right)\times2+3\times2&-1\times3+\left(-2\right)\times\left(-3\right)+3\times3&-1\times5+\left(-2\right)\times4+3\times4\2\times2+0\times2+4\times2&2\times3+0\times\left(-3\right)+4\times3&2\times5+0\times4+4\times4\\end{array}\right) +# donc la matrice : +# -1x2+(-2)x2+3x2 -1x3+(-2)x(-3)+3x3 -1x5+(-2)x4+3x4 +# 2x2+0x2+4x2 2x3+0x(-3)+4x3 2x5+0x4+4x4 +# la commande pxsl_prod_matrix(Matrix([[-1,-2,3],[2,0,4]]),Matrix([[2,3,5],[2,-3,4],[2,3,4]]),"times") renvoie la même chose +# pxsl_prod_matrix(Matrix([[-1,-2,3],[2,0,4]]),Matrix([[2,3,5],[2,-3,4],[2,3,4]]),"cdot") retourne la matrice : +# -1.2+(-2).2+3.2 -1.3+(-2).(-3)+3.3 -1.5+(-2).4+3.4 +# 2.2+0.2+4.2 2.3+0.(-3)+4.3 2.5+0.4+4.4 avec le . correspondant à la commande latex cdot + + +def pxs_system_simpl(n=3,N="",opt="sys",max_coef=2,limit_sum=15): + """ + Fonction permettant de créer les matrices A et B d'un système linéaire en s'assurant de la simplicité de la solution + Par défaut, la matrice est de taille 3x3 et B un vecteur aléatoire, de composant entre 1 et 3, de dimension 3 + La fonction est également utilisable pour générer A dans le cadre de l'inversion de matrice + + Version + ------- + 25/03/25 + + Vérification + ------------ + Auteur : Delphine + Vérificateurs : + + Paramètres + ---------- + n : int + Dimension de la matrice A + N : Matrix + Deuxième matrice du produit, solution du système + opt : char + "sys" : c'est un système, on renvoie A et B pour Ax=B + sinon : on renvoie seulement A + max_coef : int + on tire les opérations à réaliser sur les coefficients entre 1 et max_coef + limit_sum : int + si les coefficients de A et B dépassent la valeur de limit_sum la simulation est relancée + + Retour + ------ + A,B + retourne les deux matrices du système AX=B + + Fonction utilisée par + --------------------- + pxs_commute_matrix + + :pxs_trigger: générer système linéaire à solution simple, exercice AX=B aléatoire avec solution entière, matrice inversible à coefficients bornés + :pxs_returns: | + Si opt="sys" : tuple (A, B) de Matrix sympy tels que AX=B admet la + solution N (vecteur fourni ou aléatoire à composantes dans [-3,3]). + Sinon : Matrix A seule (typiquement pour une inversion). + Les coefficients de A et B sont bornés en valeur absolue par limit_sum. + :pxs_example: | + A, B = pxs_system_simpl(n=3) + # Dans MyST : $\\py{pxsl_system_lin(A, B)}$ + # de solution entière simple + :pxs_antipattern: Tirer les coefficients de A et B au hasard sans garantir une solution simple — produit des solutions irrationnelles ou énormes. + """ + # La matrice est N est copiée pour ne pas modifier la matrice originale + if N=="": + N=Matrix([rd.randint(-3,3) for i in range(n)]) + A,B=eye(n),N.copy() + # La variable compte permet de compter le nombre d'éléments >15 en valeur absolue dans la matrice A + # Si un élément est supérieur à 15 en valeur absolue, on recommence. La variable compte est initialisée à 1 par défaut + compte=1 + while compte!=0: + A,B=eye(n),N.copy() + # Tant que le nombre de 0 dans la matrice A est supérieur à la dimension -1, on continue + # on autorise donc pas de 0 pour une matrice 2x2, on autorise un 0 pour une matrice 3x3 etc... + while sum(1 for element in A if element == 0)>=n-1: + # on tire aléatoirement les deux lignes impliquées dans la relation + index=rd.sample([i for i in range(n)],2) + # on tire les coefficients multiplicateurs + lamb=[rd.choice([-1,1])*rd.randint(1,max_coef),rd.choice([-1,1])*rd.randint(1,max_coef)] + # On aura par exemple L1=2*L1+3*L2 + A[index[0],:]=lamb[0]*A[index[0],:]+lamb[1]*A[index[1],:] + B[index[0],:]=lamb[0]*B[index[0],:]+lamb[1]*B[index[1],:] + compte=sum(1 for element in A if abs(element) >= limit_sum)+sum(1 for element in B if abs(element) >= limit_sum) + if opt=="sys": + return A,B + else: + return A + + + +################ EXEMPLES ################## +# pxs_system_simpl() retournera par exemple les matrices : +# 2 -4 -2 -4 +# A= 2 -5 -2 B= -6 +# 2 -4 -4 0 +# pour la solution +# 0 +# x= 2 +# -2 + + + +def pxsl_ax(a,x=Symbol('x'),sign=" ",frac=True): + """ + Fonction permettant d'afficher l'expression ax en fonction des valeurs de a + + Version + ------- + 23/09/25 + + Vérification + ------------ + Auteur : Delphine + Vérificateurs : + + Paramètres + ---------- + a : numerique + x : Symbol ('x' par défaut) + si x=Symbol("val"), la valeur a est affichée dans tous les cas + sign : str + "" ou "+", "" par défaut, le symbole "+" indique qu'il faut écrire le signe '+' + devant l'expression. + frac : boolean + True : fraction écrite en mode math + False : fraction écrite a/b en ligne + + Retour + ------ + str + retourne l'expression en latex + + Fonction utilisée par + --------------------- + pxsl_system_lin, pxsl_lines_op + + :pxs_trigger: afficher terme ax avec gestion signes automatique, coefficient fraction dans équation, a=1 ou a=-1 simplifié, a=0 chaîne vide, terme d'un système linéaire + :pxs_returns: | + Objet myst représentant "ax" en LaTeX avec : + - chaîne vide si a=0 + - "x" ou "-x" si a=±1 (coefficient implicite) + - "a/b x" en ligne si frac=False et a rationnel + - "\\frac{p}{q}x" si frac=True + - préfixe '+' configurable via sign pour l'enchaînement des termes. + :pxs_example: | + pxsl_ax(2, Symbol('x')) # → "+2x" + pxsl_ax(Rational(1,3), Symbol('x_1')) # → "+\\frac{1}{3}x_1" + pxsl_ax(0, Symbol('x')) # → "" (chaîne vide) + pxsl_ax(-1, Symbol('L_1')) # → "-L_1" + :pxs_antipattern: Utiliser f"{a}{x}" qui produit "1x", "-1x", "0x" au lieu de "x", "-x", "" et ne gère pas le signe + devant un terme positif. + """ + + # on règle le cas a nul en premier + if a==0: + return myst(r""" """) + # on considère ensuite le cas a entier (qui est aussi considéré comme un Rational donc + # il ne faut pas inverser les if) + if isinstance(a,Integer): + if a==1: + return myst(r""" \py{sign} 1""",globals(),locals()) if x is None else myst(r""" \py{sign} \py{x}""",globals(),locals()) + elif a==-1: + return myst(r""" - 1""",globals(),locals()) if x is None else myst(r""" - \py{x}""",globals(),locals()) + elif a<0: + return myst(r""" \py{a}""",globals(),locals()) if x is None else myst(r""" \py{a}\py{x}""",globals(),locals()) + else : + return myst(r"""\py{sign} \py{a}""",globals(),locals()) if x is None else myst(r"""\py{sign} \py{a}\py{x}""",globals(),locals()) + # on considère ensuite le cas a Rational + if isinstance(a,Rational) and frac == True: + if a<0: + return myst(r""" -\frac{\py{abs(a.p)}}{\py{a.q}}""",globals(),locals()) if x is None else myst(r""" -\frac{\py{abs(a.p)}}{\py{a.q}}\py{x}""",globals(),locals()) + else: + return myst(r""" \py{sign}\frac{\py{a.p}}{\py{a.q}}""",globals(),locals()) if x is None else myst(r""" \py{sign}\frac{\py{a.p}}{\py{a.q}}\py{x}""",globals(),locals()) + if isinstance(a,Rational) and frac == False: + if a.p==-1: + return myst(r""" - 1/\py{a.q}""",globals(),locals()) if x is None else myst(r""" - \py{x}/\py{a.q}""",globals(),locals()) + elif a.p==1: + return myst(r""" \py{sign}1/\py{a.q}""",globals(),locals()) if x is None else myst(r""" \py{sign}\py{x}/\py{a.q}""",globals(),locals()) + elif a<0: + return myst(r""" -\py{abs(a.p)}/\py{a.q}""",globals(),locals()) if x is None else myst(r""" -\py{abs(a.p)}\py{x}/\py{a.q}""",globals(),locals()) + else: + return myst(r""" \py{sign}\py{a.p}/\py{a.q}""",globals(),locals()) if x is None else myst(r""" \py{sign}\py{a.p}\py{x}/\py{a.q}""",globals(),locals()) + # on ferme avec le traitement pour tout nombre car certains calculs envoient un int + # non reconnu dans les conditionnels précédants + + if a==1: + return myst(r""" \py{sign} 1""",globals(),locals()) if x is None else myst(r""" \py{sign} \py{x}""",globals(),locals()) + elif a==-1: + return myst(r""" - 1""",globals(),locals()) if x is None else myst(r""" - \py{x}""",globals(),locals()) + try: + if a<0: + return myst(r""" \py{a}""",globals(),locals()) if x is None else myst(r""" \py{a}\py{x}""",globals(),locals()) + else: + return myst(r"""\py{sign} \py{a}""",globals(),locals()) if x is None else myst(r"""\py{sign} \py{a}\py{x}""",globals(),locals()) + except : + return myst(r"""\py{sign} \py{latex(a)}""",globals(),locals()) if x is None else myst(r"""\py{sign} \py{latex(a)}\py{x}""",globals(),locals()) + + +################ EXEMPLES ################## +# pxsl_ax(2) retourne +# l'expression latex : 2x +# +# pxsl_ax(2,Symbol('y')) retourne +# l'expression latex : 2y +# +# pxsl_ax(2,Symbol('L_{'+str(1)+'}')) retourne +# l'expression latex : 2L_{1} +# +# pxsl_ax(1,Symbol('L_{'+str(1)+'}')) retourne +# l'expression latex : L_{1} +# +# pxsl_ax(0,Symbol('L_{'+str(1)+'}')) retourne "" + +def pxsl_double_matrix(A,B,listeMat=[],opt='sep'): + """ + Fonction permettant d'afficher un système linéaire Ax=B + + Version + ------- + 13/02/25 + + Vérification + ------------ + Auteur : Delphine + Vérificateurs : + + Paramètres + ---------- + A : Matrix + B : Matrix + listeMat : liste + permet d'envisager l'ajout de matrices supplémentaires + opt : str ('sep' par défaut) + permet de préciser la présentation des matrices + 'sep' : les deux matrices sont présentées côte à côte entourées de parenthèses + 'ext' : les deux matrices sont présentées en matrice étendue séparée par + + Retour + ------ + str + retourne l'expression en latex + + Fonction utilisée par + --------------------- + pxsl_resol_system + + :pxs_trigger: afficher deux matrices côte à côte, matrice étendue A|B, matrice augmentée pour inversion ou système linéaire + :pxs_returns: | + Objet myst affichant deux matrices en environnement array : + - opt="sep" : (A) (B) côte à côte, chacune entre parenthèses + - opt="ext" : matrice augmentée (A|B) avec séparation pointillée + (utilisée pour les étapes de Gauss). + :pxs_example: | + A = Matrix([[1, 2], [3, 4]]) + B = Matrix([[1], [0]]) + # Matrices séparées : $\\py{pxsl_double_matrix(A, B)}$ + # Matrice augmentée : $\\py{pxsl_double_matrix(A, B, opt="ext")}$ + :pxs_antipattern: Concaténer deux appels pxsl_matrix sans environnement array — l'alignement vertical et la matrice étendue (A|B) ne marcheront pas. + """ + if opt=="sep": + expr=myst(r"""\begin{array}{cc}""") + expr+=pxsl_matrix(A)+pxsl_matrix(B)+myst(r"""\end{array}""") + return expr + else: + expr=myst(r"""\begin{array}{c:c}""") + expr+=pxsl_matrix(A,'(','.')+myst(r"""&""",globals(),locals())+pxsl_matrix(B,".",')')+myst(r"""\end{array}""") + return expr + +################ EXEMPLES ################## +# pxsl_double_matrix(Matrix([[1,2,3],[2,-3,4]]),Matrix([[1,2,3],[2,-3,4]])) retourne +# l'expression latex : \begin{array}{ccc}\left(\begin{array}{rrr}1&2&3\2&-3&4\\end{array}\right)&&\left(\begin{array}{rrr}1&2&3\2&-3&4\\end{array}\right)\end{array} +# c'est-à-dire deux matrices entourées de parenthèses et placées l'une à côté de l'autre +# pxsl_double_matrix(Matrix([[1,2,3],[2,-3,4]]),Matrix([[1,2,3],[2,-3,4]]),opt="sep") retourne la même chose +# ATTENTION : ne pas oublier opt= car il y a un autre paramêtre au milieu (la liste) +# +# pxsl_double_matrix(Matrix([[1,2,3],[2,-3,4]]),Matrix([[1,2,3],[2,-3,4]]),opt="ext") retourne +# l'expression latex : \begin{array}{c:c}\left(\begin{array}{rrr}1&2&3\2&-3&4\\end{array}\right.&\left.\begin{array}{rrr}1&2&3\2&-3&4\\end{array}\right)\end{array} +# c'est-à-dire deux matrices réécrite en matrice étendue séparées par des pointillés +# +# EXTENSION A VENIR : écrire une opération entre les deux matrices, possibilités d'écrire plus de 2 matrices + +def pxsl_system_lin(A, B, x = 'x', frac = True): + """ + Construct a LaTeX representation of a linear system. + + The function formats a linear system of equations defined by a coefficient + matrix `A` and a right-hand side vector `B` into a LaTeX `array` environment. + Each equation is written as a linear combination of symbolic variables + followed by its corresponding constant term. + + Parameters + ---------- + A : Matrix + Coefficient matrix of the linear system. + B : Matrix + Right-hand side vector of the system. + x : str, optional + Base name of the unknown variables (default is `"x"`), producing + variables of the form `x_1, x_2, ..., x_n`. + frac : bool, optional + If True, coefficients are displayed as fractions when appropriate. + If False, coefficients are displayed in a simplified inline form. + + Returns + ------- + Any + A symbolic object representing the LaTeX code of the linear system + formatted as an `array`. + + Examples + -------- + >>> pxsl_system_lin(A, B) + '\\\\left\\{ \\\\begin{array}{rcl} ... \\\\end{array}\\\\right.' + + :pxs_trigger: afficher système linéaire accolade, a1x1 + a2x2 = b, forme équationnelle de AX=B, variables x_1 x_2 x_3 personnalisables + :pxs_returns: | + Objet myst représentant le système linéaire AX=B avec accolade gauche, + en environnement array{rcl}, une équation par ligne de la forme + "a1 x_1 + a2 x_2 = b". Gère les coefficients nuls (terme omis), + ±1 (signe simple), et l'affichage en fraction ou en ligne. + :pxs_example: | + A = Matrix([[2, 3], [1, 4]]) + B = Matrix([1, 1]) + # Dans MyST : $\\py{pxsl_system_lin(A, B)}$ + # → { 2x_1 + 3x_2 = 1 ; x_1 + 4x_2 = 1 } + :pxs_antipattern: Composer manuellement des f-strings "2x_1+3x_2=1" sans gérer les coefficients nuls, les ±1 ni les variables personnalisées. + """ + [n,p]=A.shape + + # Permet de créer le vecteur des x_i en fonction de la dimension de A + vect_x=Matrix([Symbol(x+'_1')]) + for i in range(p-1): + vect_x=vect_x.row_join(Matrix([Symbol(x+'_'+str(i+2))])) + expr=myst(r"""\left\lbrace \begin{array}{rcl} """) + for i in range(n): + if A[i, :].is_zero_matrix: + expr += "0" + # Gère l'affichage du premier terme non nul sans le '+' devant + if A[i, 0] != 0: + expr+=pxsl_ax(A[i,0],vect_x[0], frac = frac) + sign="+" + else: + sign="" + for j in range(1,p): + if A[i,j]!=0: + expr+=pxsl_ax(A[i,j],vect_x[j],sign, frac = frac) + sign="+" + + rhs = myst(r"""\py{B[i].p}/\py{B[i].q}""", globals(), locals()) if (isinstance(B[i], Rational) and B[i].q != 1 and not frac) else latex(B[i]) + + expr+=myst(r""" &=&\py{rhs}""",globals(),locals())+myst(r"""\\[0.3em]""") + expr+=myst(r"""\end{array}\right.""") + return expr + +################ EXEMPLES ################## +# pxsl_system_lin(Matrix([[2,3],[1,4]]),Matrix([1,1])) renvoie +# l'expression latex \left{ \begin{array}{rcr} 2x_1+ 3x_2&=&1\\ x_1+ 4x_2&=&1\\end{array}\right. +# +# pxsl_system_lin(Matrix([[2,3,0],[1,4,-1],[-2,3,5]]),Matrix([1,1,0])) renvoie +# l'expression latex \left{ \begin{array}{rcr} 2x_1+ 3x_2&=&1\\ x_1+ 4x_2-x_3&=&1\\-2x_1+ 3x_2+ 5x_3&=&0\\end{array}\right. + + + +def pxsl_lines_op(n, listOp, opt="sys", frac = True): + """ + Construct a LaTeX array describing elementary row (line) operations. + + The function generates a symbolic LaTeX representation of a sequence of + elementary row operations applied to a system or a matrix. Each operation + is displayed line by line using an `array` environment, with arrows and + linear combinations formatted according to the current language settings + (French or English). + + Parameters + ---------- + n : int + Number of rows of the system or matrix. + listOp : list + List of elementary row operations. Each element of the list is expected + to be a tuple of the form `(a, i, b, j)` representing an operation applied + to row `i` using row `j`: + - if `a == 0`, rows `i` and `j` are swapped; + - otherwise, the operation corresponds to + `row_i ← a * row_i + b * row_j`. + Row indices are assumed to be 1-based. + opt : str, optional + Output option (currently kept for interface consistency; default is + `"sys"`). + frac : bool, optional + If True, coefficients are displayed as fractions when appropriate. + If False, coefficients are displayed in a simplified inline form. + + Returns + ------- + Any + A symbolic object representing the LaTeX code of an `array` environment + describing the row operations. + + Examples + -------- + >>> pxsl_lines_op( + ... n=3, + ... listOp=[(1, 1, -2, 2), (0, 2, 1, 3)] + ... ) + '\\\\begin{array}{ccc} ... \\\\end{array}' + + :pxs_trigger: afficher opérations élémentaires sur les lignes, L1 ← 2L1 + 3L2, permutation L_i ↔ L_j, étapes pivot de Gauss, pédagogique + :pxs_returns: | + Objet myst affichant les opérations élémentaires en LaTeX, une par + ligne en environnement array{ccc} : + - "L_i ← a·L_i + b·L_j" pour les combinaisons linéaires + - "L_i ↔ b·L_j" pour les permutations (a=0) + Utilise L_i (français) ou R_i (anglais) selon pxs_lang. + :pxs_example: | + # Affiche L_1 ← L_1 - 2·L_2 puis L_2 ↔ L_3 + ops = [(1, 1, -2, 2), (0, 2, 1, 3)] + # Dans MyST : $\\py{pxsl_lines_op(3, ops)}$ + :pxs_antipattern: Écrire f"L_{i} \\\\leftarrow {a}L_{i} + {b}L_{j}" sans gérer a=0 (swap), a=±1, b=±1, ni la langue (L/R). + """ + + espace = "" + expr = myst(r""" \begin{array}{ccc}""") + pxs_lang = get_pxs_lang() + for j in range(n): + for i in range(len(listOp)): + if j==listOp[i][1]-1: + a,b=listOp[i][0],listOp[i][2] + ind1,ind2=listOp[i][1],listOp[i][3] + var1 = Symbol('L_{'+str(ind1)+'}') if pxs_lang == "fr" else Symbol('R_{'+str(ind1)+'}') + var2 = Symbol('L_{'+str(ind2)+'}') if pxs_lang == "fr" else Symbol('R_{'+str(ind2)+'}') + if a==0: + expr+=myst(r""" \fr{L}\en{R}_{\py{ind1}}& \leftrightarrow &""",globals(),locals())+pxsl_ax(a,var1,"",frac = frac)+pxsl_ax(b,var2,"")+myst(r"""\py{espace}""",globals(),locals()) + else: + expr+=myst(r""" \fr{L}\en{R}_{\py{ind1}}& \leftarrow &""",globals(),locals())+pxsl_ax(a,var1,"",frac = frac)+pxsl_ax(b,var2,"+",frac = frac)+myst(r"""\py{espace}""",globals(),locals()) + if i==len(listOp)-1: + expr+=myst(r""" \\[0.3em]""") + expr+=myst(r"""\end{array}""") + return expr + +################ EXEMPLES ################## +# pxsl_lines_op(2,[2,1,3,2]) retourne +# l'expression latex : \begin{array}{c}L_{1} \leftarrow 2L_{1}+ 3L_{2}\\\end{array}\ +# +# pxsl_lines_op(2,[0,1,3,2]) retourne +# l'expression latex : \begin{array}{c}L_{1} \leftarrow 3L_{2}\\\end{array}\ +# +# pxsl_lines_op(2,[0,1,-1,2]) retourne +# l'expression latex : \begin{array}{c}L_{1} \leftarrow -3L_{2}\\\end{array}\ +# + +def pxsl_resol_system(listA,listB=[],listOp=[],x='x',method="sys",view="sep", detail = "on"): + """ + Fonction qui permet d'écrire chaque étape de la résolution d'un problème impliquant des manipulations de lignes + + Version + ------- + 23/09/25 + + Vérification + ------------ + Auteur : Ronan - Delphine + Vérificateurs : + + Paramètres + ---------- + listA : list + contient la liste des matrices A successives utilisées lors de la résolution + listB : list + contient la liste des vecteurs (système) ou matrice (inversion) B successives utilisées lors de la résolution + listOp : liste de liste + chaque liste de la liste contient 4 éléments [a, ind1,b,ind2] permettant de réaliser le calcul sur la ligne d'indice ind1 a*L_ind1+b*L_ind2 + x : s.Symbol ('x' par défaut) + utilisé comme variable dans le cas de la résolution d'un système + method : str ('sys' par défaut) + "sys" : résolution d'un système + "mat" : inversion d'une matrice + "ech" : échelonnage d'une matrice + view : str ("sep" par défaut) + "sep" : les deux matrices sont représentées côte à côte + "ext" : représente la matrice étendue A|B + + Retour + ------ + str + retourne l'expression en latex + + Fonction utilisée par + --------------------- + Aucune fonction pyxiscience + + :pxs_trigger: afficher étapes résolution système linéaire, pivot de Gauss pas à pas, inversion matrice avec opérations détaillées, échelonnage matrice historique complet + :pxs_returns: | + Objet myst affichant toute la résolution pas à pas en environnement + array : chaque étape comprend les opérations sur les lignes + (pxsl_lines_op) puis la matrice/système résultant, séparés par "\\\\". + Modes method : "sys" (système), "mat" (inversion), "ech" (échelonnage). + :pxs_example: | + listA = [A0, A1, A2, A_final] + listB = [B0, B1, B2, B_final] + listOp = [[0,0,0,0], [1,2,-2,1], [1,3,1,2], [Rational(1,2),1,0,0]] + # Dans MyST : $\\py{pxsl_resol_system(listA, listB, listOp)}$ + :pxs_antipattern: Construire la résolution à la main avec une boucle for et des concat LaTeX — perd l'alignement et la mise en page pédagogique automatiques. + """ + listA, listB, listOp = pxs_regroupe_ligne(listA, listB, listOp) + expr="" + for i in range(len(listA)): + if i==0: + if method=="sys": + expr=myst(r"""\begin{array}{cl} """)+myst(r"""&""")+ pxsl_system_lin(listA[i],listB[i],x)+myst(r"""\\ \\""") + elif method=="ech": + expr=myst(r"""\begin{array}{cc} """)+myst(r"""&""")+ pxsl_matrix(listA[i])+myst(r"""\\ \\""") + else: + expr=myst(r"""\begin{array}{cc} """)+myst(r"""&""")+ pxsl_double_matrix(listA[i],listB[i],opt=view)+myst(r""" \\ \\""") + if i!=0: + if detail == "on": + if method=="sys": + expr+=pxsl_lines_op(listA[0].shape[0],listOp[i])+myst(r""" & """)+pxsl_system_lin(listA[i],listB[i],x)+myst(r"""\\ \\""") + elif method=="ech": + expr+=pxsl_lines_op(listA[0].shape[0],listOp[i],opt="ech")+myst(r""" & """)+pxsl_matrix(listA[i])+myst(r"""\\ \\""") + else: + expr+=pxsl_lines_op(listA[0].shape[0],listOp[i])+myst(r""" & """)+pxsl_double_matrix(listA[i],listB[i],opt=view)+myst(r"""\\ \\""") + else: + if method=="sys": + expr+=myst(r""" & """)+pxsl_system_lin(listA[i],listB[i],x)+myst(r"""\\ \\""") + elif method=="ech": + expr+=myst(r""" & """)+pxsl_matrix(listA[i])+myst(r"""\\ \\""") + else: + expr+=myst(r""" & """)+pxsl_double_matrix(listA[i],listB[i],opt=view)+myst(r"""\\ \\""") + expr+=myst(r"""\end{array}""") + return expr + + + +################ EXEMPLES ################## +# Soit les variables de départ : +# listA=[Matrix([ [-4, 2], [ 1, -1]]), Matrix([ [-2, 1], [ 1, -1]]), Matrix([ [-2, 1], [ 0, -1]]), Matrix([ [-2, 0], [ 0, -1]]), Matrix([ [-1, 0], [ 0, -1]]), Matrix([ [1, 0], [0, -1]]), Matrix([ [1, 0], [0, 1]])] +# listB=[Matrix([ [-6], [ 0]]), Matrix([ [-3], [ 0]]), Matrix([ [-3], [-3]]), Matrix([ [-6], [-3]]), Matrix([ [-3], [-3]]), Matrix([ [ 3], [-3]]), Matrix([ [3], [3]])] +# listOp=[[0, 0, 0, 0], [Rational(1,2), 1, 0, 0], [2, 2, 1, 1], [1, 1, 1, 2], [Rational(1,2), 1, 0, 0], [-1, 1, 0, 0], [-1, 2, 0, 0]] +# +# pxsl_resol_system(listA,listB,listOp,method='sys') retourne l'expression latex pour représenter +# la résolution du système. +# + +def pxs_reduce_pgcd(A, B, listA, listB, listOp): + """ + Fonction permettant de diviser lignes des matrices A et B lorsque leur pgcd est différent de 1 + + Version + ------- + 23/09/25 (modifié 14/10/25) + + Vérification + ------------ + Auteur : Delphine + Vérificateurs : + Paramètres + ---------- + A : Matrix + B : Matrix + listA : list + liste de matrices, permet de retrouver les différentes transformations de la matrice A + listB : list + liste de matrices, permet de retrouver les différentes transformations de la matrice B + listOp : list + Chaque élément de la liste est une liste de 4 éléments : + [facteur multiplicatif de la ligne i, indice de la ligne i, facteur multiplicatif de la ligne j, indice de la ligne j] + Retour + ------ + liste, liste, liste + retourne les listes actualisées de l'opération de permutation + Fonction utilisée par + --------------------- + pxs_steps_invert_matrix + + :pxs_trigger: simplifier ligne par PGCD, diviser ligne matrice pour réduire coefficients, intermédiaire Gauss simplification, interne pivot + :pxs_returns: | + Tuple (listA, listB, listOp) mis à jour : pour chaque ligne où le PGCD + des coefficients (numérateurs A[i,:] et B[i,:]) est ≠ 1, une nouvelle + étape est ajoutée aux trois listes (matrice divisée + opération [1/pgcd, i+1, 0, 0]). + Modifie A et B en place. Appelée par pxs_steps_invert_matrix. + :pxs_example: | + # Appelée typiquement en interne après chaque élimination dans pxs_steps_invert_matrix + # listA, listB, listOp = pxs_reduce_pgcd(A, B, listA, listB, listOp) + :pxs_antipattern: Ne pas simplifier après chaque étape — produit des coefficients qui explosent (ex. 1024/512) au lieu de 2. + """ + + [n, p] = A.shape + for i in range(n): + # Extraction des numérateurs pour la ligne i de A + A_row_nums = [elem.numerator if isinstance(elem, Rational) else elem for elem in A[i, :]] + + # Extraction des numérateurs pour la ligne i de B + B_row_nums = [elem.numerator if isinstance(elem, Rational) else elem for elem in B[i, :]] + + # Calcul du PGCD pour les numérateurs + if len(A_row_nums) > 0 and len(B_row_nums) > 0: + # Utiliser sympy_gcd pour gérer les objets sympy + pg_A = A_row_nums[0] + for val in A_row_nums[1:]: + pg_A = gcd(pg_A, val) + + pg_B = B_row_nums[0] + for val in B_row_nums[1:]: + pg_B = gcd(pg_B, val) + + pg = gcd(pg_A, pg_B) + + # Vérifier si pg est différent de 1 + if pg != 1 and pg != 0: + # Division de la ligne par le PGCD + A[i, :] = A[i, :] / pg + B[i, :] = B[i, :] / pg + + # Mise à jour des listes + listA.append(A.copy()) + listB.append(B.copy()) + + for j in range(n): + if j == i: + # L'opération est L_{i+1} -> 1/pg * L_{i+1} d'où la liste [1/pg, i+1, 0, 0] + try: + listOp.append([Rational(1, pg), i+1, 0, 0]) + except: + listOp.append([1/ pg, i+1, 0, 0]) + + return listA, listB, listOp + +################ EXEMPLES ################## +# Exemple pour une matrice avec PGCD = 2 +# pxs_reduce_pgcd(Matrix([[2,4,6],[1,2,3]]),Matrix([2,3,4]),[Matrix([[2,4,6],[1,2,3]])],[Matrix([2,3,4])],[[0,0,0,0]]) retourne +# listA = [Matrix([ [2, 4, 6], [1, 2, 3]]), Matrix([ [1, 2, 3], [1, 2, 3]])] +# listB = [Matrix([ [2], [3], [4]]), Matrix([ [1], [3], [4]])] +# listOp = [[0, 0, 0, 0], [1/2, 1, 0, 0]] +# + +def pxs_steps_invert_matrix(A,B,x='x',method="sys",view="sep", detail = "on"): + """ + Fonction permettant de stocker toutes les étapes de la résolution d'un système ou l'inversion d'une matrice + + Version + ------- + 23/09/25 + + Vérification + ------------ + Auteur : Ronan - Delphine + Vérificateurs : + + Paramètres + ---------- + A : Matrix + B : Matrix + x : Symbol ('x' par défaut) + liste de matrices, permet de retrouver les différentes transformations de la matrice A + method : str + "sys" : pour afficher la résolution d'un système + listOp : list + Chaque élément de la liste est une liste de 4 éléments : + [facteur multiplicatif de la ligne i, indice de la ligne i, facteur multiplicatif de la ligne j, indice de la ligne j] + + Retour + ------ + liste, liste, liste + retourne les listes actualisées de l'opération de permutation + + Fonction utilisée par + --------------------- + Aucune fonction pyxiscience + + :pxs_trigger: résoudre système linéaire pas à pas, inverser matrice par Gauss-Jordan, échelonner matrice avec historique, AX=B résolution détaillée + :pxs_returns: | + Objet myst contenant la résolution complète pas à pas de AX=B par + pivot de Gauss-Jordan, en mettant A et B sous forme échelonnée + réduite. Modifie A et B en place. Appelle pxsl_print_operations + pour le rendu LaTeX final. + :pxs_example: | + A = Matrix([[2,3,0],[1,4,-1],[-2,3,5]]) + B = Matrix([1, 1, 0]) + # Résolution système : $\\py{pxs_steps_invert_matrix(A, B)}$ + # Inversion : pxs_steps_invert_matrix(A, eye(3), method="mat") + :pxs_antipattern: Utiliser A.inv() ou A.solve(B) qui donnent le résultat sans les étapes intermédiaires pédagogiques. + """ + [n,p]=A.shape + nmin=min(n,p) + listA,listB=[A.copy()],[B.copy()] + listOp=[[0,0,0,0]] + # On vérifie le pgcd de chaque ligne pour simplifier + listA,listB,listOp=pxs_reduce_pgcd(A,B,listA,listB,listOp) + for k in range(nmin): + # On cherche la ligne pivot + r=-1 + for i in range(k,n): + if r<0 and A[i,k]!=0: + r=i + if r>k: + # On met la ligne pivot en haut si elle ne l'est pas + A.row_swap(k, r) + B.row_swap(k,r) + listA.append(A.copy()) + listB.append(B.copy()) + for j in range(n): + if j==r: + listOp.append([0,r+1,1,1+k]) + if r>=k: + # On élimine les lignes differentes de k + for j in range(n): + if A[j,k]!=0 and j!=k: + pg=gcd(A[k,k],A[j,k]) + cpAjk=A[j,k] + cpAkk=A[k,k] + try: + A[j,:]=abs(cpAkk)*A[j,:]/pg- abs(cpAjk)*A[k,:]/pg*np.sign( cpAjk* cpAkk) + except: + A[j,:]=cpAkk*A[j,:]/pg- cpAjk*A[k,:]/pg + try: + B[j,:]=abs(cpAkk)*B[j,:]/pg- abs(cpAjk)*B[k,:]/pg*np.sign( cpAjk* cpAkk) + except: + B[j,:]=cpAkk*B[j,:]/pg- cpAjk*B[k,:]/pg + listA.append(A.copy()) + listB.append(B.copy()) + for l in range(n): + if l==j: + try: + listOp.append([abs(cpAkk)/pg,j+1,-abs(cpAjk)/pg*np.sign( cpAjk* cpAkk),k+1]) + except: + listOp.append([cpAkk/pg,j+1,-cpAjk/pg,k+1]) + # On vérifie le pgcd de chaque ligne pour simplifier + listA,listB,listOp=pxs_reduce_pgcd(A,B,listA,listB,listOp) + # On exprime les solutions sous la forme finale + for i in range(nmin): + if A[i,i]!=0 and A[i,i]!=1: + for j in range(B.shape[1]): + try: + B[i,j]=Rational(B[i,j],A[i,i]) + except: + B[i,j]=B[i,j]/A[i,i] + cpAi=A[i,i] + A[i,:]=A[i,:]/cpAi + listA.append(A.copy()) + listB.append(B.copy()) + # Stockage de l'opération + for j in range(nmin): + if j==i and cpAi!=0: + try: + listOp.append([Rational(1,cpAi),i+1,0,0]) + except: + listOp.append([1/cpAi,i+1,0,0]) + elif j==i and cpAi == -1: + listOp.append([-1,i+1,0,0]) + expr = pxsl_print_operations([listA, listB], listOp, method, x, view, detail) + return expr + +################ EXEMPLES ################## +# pxs_steps_invert_matrix(Matrix([[2,3,0],[1,4,-1],[-2,3,5]]),Matrix([1,1,0])) renvoie +# l'expression latex qui permet de décrire toute la résolution du système. +# +# pxs_steps_invert_matrix(Matrix([[2,3,0],[1,4,-1],[-2,3,5]]),Matrix([[1,0,0],[0,1,0],[0,0,1]]),method="mat") renvoie +# l'expression latex qui permet de décrire l'inversion de la matrice avec les matrices mises côte à côte. +# +# pxs_steps_invert_matrix(Matrix([[2,3,0],[1,4,-1],[-2,3,5]]),Matrix([[1,0,0],[0,1,0],[0,0,1]]),method="mat",view="ext") renvoie +# l'expression latex qui permet de décrire l'inversion de la matrice avec la matrice étendue. + +def pxs_LU_decomposition(A, view = "sep", detail = "on", name_matrix = " ", PLU = False): + """ + Details the steps of LU factorization for a square matrix A. + + Version + ------- + 26/12/25 + + Authors + ------------ + Author: Raphaël + Checked by: + + Arguments + ---------- + A: Matrix, the matrix to factorize + method: str, display option + view: str, display option + detail: str, "on" to get additional details + name_matrix: str, name the matrix is referred as + + Returns + ------ + text (str), L (Matrix), U (Matrix) (if PLU = False) + text (str), P (Matrix), L (Matrix), U (Matrix) (if PLU = True) + text: steps of the computation + (P,) L, U: Matrixes such that (P)A = LU if they exist. None, None otherwise + + Function used by + --------------------- + No pyxiscience function + + Examples + -------- + >>> A = Matrix([[1, 2, 1], [3, 10, 3], [-2, -8, 5]]) # LU factorization exists + >>> resol, L, U = pxs_LU_decomposition(A.copy()) + + >>> B = Matrix([[1, 2, 1], [3, 6, -1], [1, 1, 1]]) # LU factorization does not exist + >>> resol, P, L, U = pxs_LU_decomposition(A4.copy(), name_matrix = "B", PLU = True) # L, U = None, None + + :pxs_trigger: décomposition LU matrice carrée, factorisation PLU avec permutation, triangulaire inférieure supérieure, pivots pédagogique + :pxs_returns: | + Si PLU=False : tuple (text, L, U) où text est l'objet myst des étapes + LaTeX, L matrice triangulaire inf. à diagonale unité, U triangulaire + sup. telle que A=LU (ou L,U=None si impossible sans permutation). + Si PLU=True : tuple (text, P, L, U) avec P matrice de permutation + telle que PA=LU (toujours possible pour A carrée). + :pxs_example: | + A = Matrix([[1, 2, 1], [3, 10, 3], [-2, -8, 5]]) + resol, L, U = pxs_LU_decomposition(A.copy()) + # Dans MyST : \\py{resol} puis $L = \\py{pxsl_matrix(L)}$, $U = \\py{pxsl_matrix(U)}$ + :pxs_antipattern: Utiliser A.LUdecomposition() qui donne uniquement le résultat final sans les étapes détaillées de réduction. + """ + + pxs_lang = get_pxs_lang() + + [n,p] = A.shape + # Check if square + if n != p: + err = "La matrice fournie doit être carrée" if pxs_lang == "fr" else "Input matrix must be square" + raise ValueError(err) + + B = Matrix(np.eye(n).astype(np.int64)) + listA, listB=[A.copy()], [B.copy()] + if PLU: + P = Matrix(np.eye(n).astype(np.int64)) + listP = [P.copy()] + listOp = [[0,0,0,0]] + k = 0 + ok = True + while k < n - 1 and ok: # for each column except the last one and while it is possible + if A[k, k] != 0: + # Handle subdiagonal coefficients on the (k+1)-th column + for j in range(k + 1, n): + if A[j,k]!=0: + coeff = A[j, k] / A[k, k] + A[j, :] -= coeff * A[k, :] + B[j, k] = coeff + listA.append(A.copy()) + listB.append(B.copy()) + if PLU: + listP.append(P.copy()) + listOp.append([1, j + 1, -coeff, k + 1]) + k += 1 + # below : cases where the k-th pivot is 0 + elif np.any(A[k+1:, k]): # else A[k:, k] == 0, all coeff under the pivot are 0, nothing to do + if not PLU: + # looking above for another line to use as a "pivot" + r = k - 1 + while r >= 0 and not (A[r, k] != 0 and not np.any(A[r, :k])): + r -= 1 + if r == -1: # nothing found + ok = False + else: # using r-th row to eliminate the subdiagonal coefficients on the (k+1)-th column + for j in range(k + 1, n): + if A[j,k]!=0: + coeff = A[j, k] / A[r, k] + A[j, :] -= coeff * A[r, :] + B[j, r] = coeff + listA.append(A.copy()) + listB.append(B.copy()) + listOp.append([1, j + 1, -coeff, r + 1]) + k += 1 + else: # PLU case, looking for a non-zero coeff below in order to swap lines + r = np.where(np.ravel(A[k + 1:, k]) != 0)[0][0] + k + 1 # row of first non-zero coeff. on column k + A.row_swap(k, r) + B[k, :k], B[r, :k] = B[r, :k], B[k, :k] + P.row_swap(k, r) + listA.append(A.copy()) + listB.append(B.copy()) + listP.append(P.copy()) + listOp.append([0, k + 1, 1, r + 1]) + else: + k += 1 + + list_mat = [listA, listB] + if PLU: list_mat.append(listP) + + text = myst(r"""\begin{equation*}""", locals(), globals()) + text += pxsl_print_operations(list_mat, listOp, method = "mat", view = view, detail = detail) + text += myst(r"""\end{equation*}""", locals(), globals()) + + if PLU: + return text, listP[-1], listB[-1], listA[-1] # text, P, L, U + elif ok: # no permutation, and LU factorization exists + return text, listB[-1], listA[-1] + else: # no permutation, and LU factorization does not exist + if pxs_lang == "fr": + negative_conclusion = myst(r""" +On ne peut pas poursuivre la réduction sans permutation de lignes, la matrice $\py{name_matrix}$ ne possède donc pas de décomposition $LU$.""", locals(), globals()) + if pxs_lang == "en": + negative_conclusion = myst(r""" +A line permutation would be required at this stage, hence the matrix $\py{name_matrix}$ does not admit an $LU$ factorization.""", locals(), globals()) + text += negative_conclusion + return text, None, None + + +def pxsl_print_operations(list_mat, listOp=[], method = "sys", x = "x", view="sep", detail = "on", frac = True): + """ + Displays each step of the resolution for a problem involving line operations. + This function is meant to replace pxsl_resol_system + + Version + ------- + 06/01/26 + + Authors + ------------ + Auteur : Ronan - Delphine - Raphaël + Vérificateurs : + + Arguments + ---------- + list_mat : list of lists + each element is a list of successive matrices appearing in the resolution + listOp : liste of lists + each sublist contains 4 elements [a, ind1, b, ind2] describing the following operation: + L(ind1) <- a * L_ind1 + b * L_ind2 + x : s.Symbol ('x' par défaut) + determines the name of the variables in the system + method : str ('sys' by default) + "sys" : system form + "mat" : matrix form + view : str ("sep" by default) + "sep" : matrices are displayed side-by-side + "ext" : extended matrix A1|A2|...|An + frac : bool, optional + If True, coefficients are displayed as fractions when appropriate. + If False, coefficients are displayed in a simplified inline form. + + Returns + ------ + str + Latex expression + + Function used by + --------------------- + pxs_steps_invert_matrix, pxs_LU_decomposition, pxs_compute_ech, pxs_compute_ech_reduite + + :pxs_trigger: afficher étapes résolution multi-matrices, remplace pxsl_resol_system, historique Gauss avec plusieurs matrices simultanées, LU échelonnage déterminant + :pxs_returns: | + Objet myst affichant toutes les étapes de résolution en LaTeX, avec + une ligne par étape : opérations sur lignes + matrice(s) + correspondante(s). Généralise pxsl_resol_system à un nombre + arbitraire de matrices suivies en parallèle (ex. [A, B, P] pour + LU+permutation). Modes method : "sys" ou "mat". + :pxs_example: | + # Appelée en interne par pxs_steps_invert_matrix, pxs_LU_decomposition, + # pxs_compute_ech, pxs_compute_ech_reduite : + # expr = pxsl_print_operations([listA, listB], listOp, method="mat") + :pxs_antipattern: Utiliser l'ancien pxsl_resol_system qui ne gère que [listA, listB] au lieu d'un nombre arbitraire de listes de matrices. + """ + + def __pxsl_multiple_matrix(list_mat, view, display = frac): + n = len(list_mat) + cols = "c" * n if view == "sep" else ":".join("c" * n) + seps = [["(", ")"] if view == "sep" else [".", "."] for _ in range(n)] + seps[0][0], seps[-1][1] = "(", ")" + expr = myst(r"""\begin{array}{\py{cols}}""", locals(), globals()) + expr += "&".join([pxsl_matrix(mat, sep[0], sep[1], display = display) for mat, sep in zip(list_mat, seps)]) + expr += myst(r"""\end{array}""", locals(), globals()) + return expr + + n = list_mat[0][0].shape[0] + list_mat, listOp = pxs_regroupe_ligne(list_mat, listOp) + + if method == "sys": + try: + listA, listB = list_mat + except: + raise ValueError("list_mat must be of length 2 exactly when method is 'sys'") + + # First line + if method=="sys": + expr = myst(r"""\begin{array}{cl} """)+myst(r"""&""")+ pxsl_system_lin(listA[0],listB[0],x, frac = frac)+myst(r"""\\ \\""") + else: + expr = myst(r"""\begin{array}{cc} """)+myst(r"""&""")+ __pxsl_multiple_matrix([listX[0] for listX in list_mat], view = view) + myst(r""" \\ \\""") + + # other lines + for i in range(1, len(list_mat[0])): + printed_ops = pxsl_lines_op(n, listOp[i], frac = frac) if detail == "on" else " " + if method=="sys": + expr += myst(r"""\py{printed_ops} & """, locals(), globals()) + pxsl_system_lin(listA[i],listB[i],x, frac = frac)+myst(r"""\\ \\""") + else: + expr += myst(r"""\py{printed_ops} & """, locals(), globals()) + __pxsl_multiple_matrix([listX[i] for listX in list_mat], view = view) + myst(r"""\\ \\""") + + expr+=myst(r"""\end{array}""") + return expr + +## + +def pxs_commute_matrix(n,opt=""): + """ + Fonction permettant de créer les matrices A, B et C de dimension n avec A et B commutantes et A et C non commutantes + + Version + ------- + 13/02/25 + + Vérification + ------------ + Auteur : Ronan - Delphine + Vérificateurs : + + Paramètres + ---------- + n : int + Dimension de la matrice A + opt: str + "commut" : Envoie deux matrices qui commutent + "noncommut" : Envoie deux matrices qui ne commutent pas + autre : renvoie les trois matrices + + Retour + ------ + A,B + retourne les deux matrices du système AX=B + + Fonction utilisée par + --------------------- + aucune fonction pyxiscience + + :pxs_trigger: générer matrices commutantes ou non, exercice AB=BA vs AB≠BA, matrices simultanément diagonalisables, P D P^-1, commutateur matriciel + :pxs_returns: | + Selon opt : + - "commute" : tuple (A, B) de Matrix de dimension n×n qui commutent (AB=BA) + - "noncommute" : tuple (A, C) qui ne commutent pas (AC≠CA) + - autre : triplet (A, B, C) avec A·B=B·A et A·C≠C·A + Coefficients entiers bornés en valeur absolue par 10n via construction + P·D·P^-1 avec D diagonale aléatoire et P aléatoire inversible. + :pxs_example: | + A, B, C = pxs_commute_matrix(3) + # Dans MyST : $A = \\py{pxsl_matrix(A)}$, $B = \\py{pxsl_matrix(B)}$ + # avec AB=BA mais AC≠CA pour un exercice de commutation. + :pxs_antipattern: Tirer des coefficients aléatoirement et tester AB=BA — probabilité quasi-nulle d'obtenir la commutation sans construction P·D·P^-1. + """ + Nmax=3 + A=ones(n)*10*n + C=A.copy() + while A*C==C*A or sum(1 for element in A if abs(element) >= 10*n)>=1 or sum(1 for element in C if abs(element) >= 10*n)>=1: + # On construit les matrices diagonales de la décomposition P*A*Pinv + P=pxs_system_simpl(n,eye(n),"mat") + Pinv=P.inv() + DiagA=randmatrixdiagonale(n,-Nmax,Nmax) + DiagC=DiagA.copy() + DiagC[0,n-1]=1 + C=P*DiagC*Pinv*P.det() + C=C/fct.reduce(m.gcd,C[:,:]) + # on s'assure que A ne puisse pas commuter avec n'importe quelle matrice (ce qui arrive si les éléments de la diagonale sont tous égaux) + # on s'assure également qu'on n'obtient pas la matrice nulle + while DiagA==zeros(n) or DiagA[0,0]==DiagA[n-1,n-1]: + DiagA=randmatrixdiagonale(n,-Nmax,Nmax) + + # on génère les matrices A et B commutantes + A=P*DiagA*Pinv*P.det() + A=A/fct.reduce(m.gcd,A[:,:]) + + B=C.copy()*10*n + while A*B!=B*A or sum(1 for element in B if abs(element) >= 10*n)>=1: + DiagB=randmatrixdiagonale(n,-Nmax,Nmax) + while DiagB==zeros(n): + DiagB=randmatrixdiagonale(n,-Nmax,Nmax) + if DiagA[0,0]==DiagB[0,0]: + DiagB[0,0]=DiagB[0,0]+1 + B=P*DiagB*Pinv*P.det() + B=B/fct.reduce(m.gcd,B[:,:]) + + if opt=="commute": + return A,B + + if opt=="noncommute": + return A,C + else: + return A,B,C + +################ EXEMPLES ################## +# pxs_commute_matrix(2) renvoie A, B et C de dimension 2x2 telles que A et B commutent et +# A et C ne commutent pas. +# Par exemple : +# 3 4 5 8 5 2 +# A = -2 -3 B = -4 -7 C = -2 1 +# +# pxs_commute_matrix(3) renvoie A, B et C de dimension 3x3 telles que A et B commutent et +# A et C ne commutent pas. +# Par exemple : +# 0 1 1 4 -5 -5 0 3 3 +# A = 4 3 -1 B = -8 1 5 C = 4 1 -3 +# -4 -1 3 8 -7 -11 -4 1 5 +# +# pxs_commute_matrix(2,"commute") renvoie A, B dimension 2x2 telles que A et B commutent +# Par exemple : +# 3 4 5 8 +# A = -2 -3 B = -4 -7 +# +# pxs_commute_matrix(3,"noncommute") renvoie A et C de dimension 3x3 telles que A et C ne commutent pas. +# Par exemple : +# 0 1 1 0 3 3 +# A = 4 3 -1 C = 4 1 -3 +# -4 -1 3 -4 1 5 + +def pxsl_pow_matrix(A,k,opt=0,sepG='(',sepD=')'): + """ + Fonction permettant d'écrire en latex une matrice dont tous les coefficients sont élevés à la même puissance + Les puissances de 0 et 1 sont simplifiées, les valeurs sont centrées par défaut + + Version + ------- + 13/02/25 + + Vérification + ------------ + Auteur : Ronan - Delphine + Vérificateurs : + + Paramètres + ---------- + A : Matrix + k : float ou Symbol + valeur de la puissance + sepG : str + délimiteur gauche de la matrice + sepD : str + délimiteur droit de la matrice + + Retour + ------ + str + retourne l'expression en latex + + Fonction utilisée par + --------------------- + aucune fonction pyxiscience + + :pxs_trigger: matrice dont chaque coefficient est élevé à la même puissance, a_ij^k terme à terme, puissance symbolique matrice non matricielle + :pxs_returns: | + Objet myst affichant la matrice dont chaque coefficient a_ij est + élevé à la puissance k (terme à terme, PAS la puissance matricielle + A^k). Les cas a_ij=0,1 et k=1 sont simplifiés selon opt via pxsl_pow. + :pxs_example: | + A = Matrix([[2, 0, 1], [4, 8, -4]]) + # Dans MyST : $\\py{pxsl_pow_matrix(A, 2)}$ + # → matrice des 2², 0², 1², 4², 8², (-4)² + :pxs_antipattern: Utiliser A**k qui calcule la puissance matricielle A·A·…·A au lieu du calcul terme à terme. + """ + + [n,q]=A.shape + pxs_lang = get_pxs_lang() + if pxs_lang == "en" and sepG=='(': + sepG='[' + if pxs_lang == "en" and sepD==')': + sepD=']' + expr=myst(r"""\left\py{sepG}\begin{array}{\py{'c'*q}}""",globals(),locals()) + for i in range(n): + expr=expr+pxsl_pow(A[i,0],k,opt) + for j in range(q-1): + expr=expr+myst(r""" &""")+pxsl_pow(A[i,1+j],k,opt) + expr=expr+myst(r"""\\""") + expr=expr+myst(r"""\end{array}\right\py{sepD}""",globals(),locals()) + return expr + +################ EXEMPLES ################## +# pxsl_pow_matrix(Matrix([[2,0,1],[4,8,-4],[2,3,0]]),2) +# renvoie l'expression latex \left(\begin{array}{ccc}2^{2}&0^2&1^2\4^{2}&8^{2}&\left(-4\right)^{2}\2^{2}&3^{2}&0^2\\end{array}\right) +# c'est-à-dire +# +# 2^2 0^2 1^2 +# 4^2 8^2 (-4)^2 +# 2^2 3^2 0^2 +# pxsl_pow_matrix(Matrix([[2,0,1],[4,8,-4],[2,3,0]]),2,1) +# renvoie l'expression latex \left(\begin{array}{ccc}2^{2}&0&1\4^{2}&8^{2}&\left(-4\right)^{2}\2^{2}&3^{2}&0\\end{array}\right) +# c'est-à-dire +# +# 2^2 0 1 +# 4^2 8^2 (-4)^2 +# 2^2 3^2 0 + + +def pxs_regroupe_ligne(list_mat, listOp=[]): + """ + Fonction permettant de regrouper les lignes qui peuvent être écrites en une seule étape + + Version + ------- + 21/03/25 -> 06/01/26 (Raphaël) + + Vérification + ------------ + Auteur : Delphine + Vérificateurs : + + Paramètres + ---------- + listA : list + liste des étapes pour la matrice/système de départ + listB : list + liste des étapes pour la matrice miroir (inversion) ou membre droit (système) + listOp : liste + liste des opérations sur lignes + + Retour + ------ + listA, listB, listOp + retourne les listes actualisées + + Fonction utilisée par + --------------------- + pxsl_resol_system, pxsl_print_operations + + :pxs_trigger: regrouper opérations indépendantes Gauss, compacter étapes résolution, fusionner opérations sur lignes disjointes, interne + :pxs_returns: | + Tuple (list_mat_bis, listOp_bis) où les opérations successives portant + sur des lignes disjointes sont regroupées en une seule étape (liste + d'opérations). Divise le nombre d'étapes affichées dans la résolution + finale, rendant le LaTeX plus compact. + :pxs_example: | + # Appelée en interne par pxsl_resol_system et pxsl_print_operations : + # list_mat, listOp = pxs_regroupe_ligne([listA, listB], listOp) + :pxs_antipattern: Afficher chaque opération sur une ligne séparée, ce qui explose la longueur du LaTeX final pour les grandes matrices. + """ + + if len(listOp) <= 1: + return list_mat, listOp + + n, nb_etape = len(list_mat[0]), 1 + + listOp_bis = [listOp[0]] + listOp_bis.append([listOp[1]]) + list_mat_bis = [[listX[0]] for listX in list_mat] + + for i in range(1, n-1): + if listOp[i][0] != 0 and all(sous_liste[1] !=listOp[i+1][1] for sous_liste in listOp_bis[nb_etape]) and all(sous_liste[1] !=listOp[i+1][3] for sous_liste in listOp_bis[nb_etape]): + listOp_bis[nb_etape].append(listOp[i+1]) + else: + for j in range(len(list_mat)): + list_mat_bis[j].append(list_mat[j][i]) + nb_etape += 1 + listOp_bis.append([listOp[i+1]]) + if i == n-2: + for j in range(len(list_mat)): + list_mat_bis[j].append(list_mat[j][i+1]) + + if listOp_bis: + return list_mat_bis, listOp_bis + else: + return list_mat, listOp + + +def pxs_compute_ech(A): + """ + Fonction permettant de stocker toutes les étapes de la construction d'une matrice échelonnée + + Paramètres + ---------- + A : Matrix + + Retour + ------ + liste, liste, liste + retourne les listes actualisées de l'opération de permutation + + Fonction utilisée par + --------------------- + Aucune fonction pyxiscience + + :pxs_trigger: échelonner matrice pas à pas, forme échelonnée par Gauss, REF row echelon form avec historique, zéros sous-diagonale + :pxs_returns: | + Objet myst affichant toutes les étapes de l'échelonnage de A en forme + échelonnée (REF, PAS réduite) : recherche pivots, permutations des + lignes si nécessaire, élimination sous la diagonale. Modifie A + en place. Appelle pxsl_print_operations pour le rendu LaTeX. + :pxs_example: | + A = Matrix([[0, 1, 2], [2, 4, 6], [1, 1, 1]]) + # Dans MyST : \\py{pxs_compute_ech(A)} + # Rend matrice triangulaire supérieure avec étapes intermédiaires. + :pxs_antipattern: Utiliser A.echelon_form() qui donne directement la REF sans les étapes d'opérations pédagogiques. + """ + [n,p]=A.shape + nmin=min(n,p) + listA=[A.copy()] + listOp=[[0,0,0,0]] + + for k in range(nmin): + # On cherche la ligne pivot + r=-1 + for i in range(k,n): + if r<0 and A[i,k]!=0: + r=i + if r>k: + # On met la ligne pivot en haut si elle ne l'est pas + A.row_swap(k, r) + listA.append(A.copy()) + for j in range(n): + if j==r: + listOp.append([0,r+1,1,1+k]) + if r>=k: + # On élimine les lignes après k + for j in range(k,n): + if A[j,k]!=0 and j!=k: + pg=m.gcd(A[k,k],A[j,k]) + cpAjk=A[j,k] + cpAkk=A[k,k] + A[j,:]=abs(cpAkk)*A[j,:]/pg- abs(cpAjk)*A[k,:]/pg*np.sign( cpAjk* cpAkk) + listA.append(A.copy()) + for l in range(n): + if l==j: + listOp.append([abs(cpAkk)/pg,j+1,-abs(cpAjk)/pg*np.sign( cpAjk* cpAkk),k+1]) + expr = pxsl_print_operations([listA], listOp = listOp, method = "mat") + return expr + + + + +def pxs_compute_ech_reduite(A): + """ + Fonction transformant une matrice en forme échelonnée réduite en stockant chaque étape. + + Paramètres + ---------- + A : numpy.ndarray + Matrice d'entrée + + Retour + ------ + listA : liste des matrices à chaque étape + listOp : liste des opérations sous forme [a, i, b, j] avec : + - a : coefficient multiplicatif pour L_i + - i : numéro de la ligne affectée (1-based index) + - b : coefficient multiplicatif pour L_j + - j : numéro de la ligne utilisée (1-based index) + + :pxs_trigger: forme échelonnée réduite RREF pas à pas, Gauss-Jordan matrice, pivots unités zéros au-dessus et en-dessous, échelon réduit + :pxs_returns: | + Objet myst affichant toutes les étapes pour amener A à sa forme + échelonnée réduite (RREF) : permutations, normalisation des pivots + à 1, élimination au-dessus ET au-dessous de chaque pivot. Modifie + A en place. Appelle pxsl_print_operations pour le rendu LaTeX. + :pxs_example: | + A = Matrix([[1, 2, 3], [2, 5, 7], [0, 1, 1]]) + # Dans MyST : \\py{pxs_compute_ech_reduite(A)} + # Rend matrice en RREF avec toutes les étapes. + :pxs_antipattern: Utiliser A.rref() qui donne directement la RREF sans les étapes pédagogiques détaillées. + """ + [n, p] = A.shape + listA = [A.copy()] + listOp = [[0,0,0,0]] + + for k in range(min(n, p)): + # Trouver la ligne pivot + r = -1 + for i in range(k, n): + if A[i, k] != 0: + r = i + break + + if r == -1: + continue # Si toute la colonne est nulle, on passe à la suivante + + # Échanger L_k et L_r si nécessaire + if r != k: + A.row_swap(k, r) # Échange des lignes + listA.append(A.copy()) + listOp.append([0, k + 1, 1, r + 1]) # Format imposé + + # Normaliser le pivot (L_k = L_k / pivot pour avoir 1) + pivot = A[k, k] + if pivot != 1: + A[k, :] /= pivot + listA.append(A.copy()) + listOp.append([1 / pivot, k + 1, 0, 0]) # Division de la ligne par le pivot + + # Élimination en dessous et au-dessus + for j in range(n): + if j != k and A[j, k] != 0: + facteur = A[j, k] + A[j, :] -= facteur * A[k, :] + listA.append(A.copy()) + listOp.append([1, j + 1, -facteur, k + 1]) # Format imposé + expr = pxsl_print_operations([listA], listOp = listOp, method = "mat") + return expr + + +def randmatrixrect(p,q,a,b): + """Returns a rectangular matrix with p rows and q columns such that every coefficient is a realization of +of a discrete random variable on range(a, b) + :returns: LaTeX bmatrix as a string + + :pxs_trigger: générer matrice rectangulaire aléatoire, coefficients entiers uniformes, exercice matrice p x q quelconque + :pxs_returns: | + Matrix sympy de dimensions p×q dont chaque coefficient est tiré + uniformément et indépendamment dans range(a, b) (intervalle semi-ouvert). + :pxs_example: | + M = randmatrixrect(3, 4, -5, 5) + # Dans MyST : $M = \\py{pxsl_matrix(M)}$ + :pxs_antipattern: Utiliser np.random.randint puis convertir en Matrix sympy — perd le comportement reproductible avec sympy.stats.DiscreteUniform. + """ + + M= eye(p,q) + for i in range(p): + for j in range(q): + #print('(i,j) =', i,j,'\n') + #M[i,j] = next(sample(DiscreteUniform('h', range(a, b)))) + M[i,j] = sample(DiscreteUniform('h', range(a, b))) + # M[i,j] = next(sample(DiscreteUniform('h', range(a, b)))) for i in range(p) for j in range(p)] + + return M + +def pxs_invertible_matrix(n): + """ + Génère une matrice carrée inversible de dimension n à coefficients entiers. + + :pxs_trigger: générer matrice carrée inversible aléatoire, matrice n×n avec déterminant non nul, exercice avec matrice inversible + :pxs_returns: | + Matrix sympy de dimension n×n à coefficients entiers dans [-2, 2], + garantie inversible (det≠0). Si 100 tirages consécutifs échouent, + retourne la matrice identité eye(n). + :pxs_example: | + M = pxs_invertible_matrix(3) + # Dans MyST : $M = \\py{pxsl_matrix(M)}$ + # Garantie : M.det() != 0 + :pxs_antipattern: Tirer coefficients au hasard sans vérifier det≠0 — risque d'obtenir une matrice non inversible dans un exercice qui exige l'inversibilité. + """ + for _ in range(100): + entries = [[random.randint(-2, 2) for _ in range(n)] for _ in range(n)] + M = Matrix(entries) + if M.det() != 0: + return M + return Matrix.eye(n) + +def pxs_diag_matrix(p,a,b): + """ + Returns a square diagonal matrix of size p such that every coefficient is a + realization of a uniform discrete random variable, the range of which is range(a, b) + + :pxs_trigger: générer matrice diagonale aléatoire, diagonale de valeurs entières, exercice diagonalisation ou valeurs propres contrôlées + :pxs_returns: | + Matrix sympy diagonale de dimension p×p dont les coefficients + diagonaux sont tirés uniformément dans range(a, b) (intervalle + semi-ouvert), et zéros hors diagonale. + :pxs_example: | + D = pxs_diag_matrix(3, -5, 5) + # Dans MyST : $D = \\py{pxsl_matrix(D)}$ + # Utile pour construire P·D·P^-1 avec valeurs propres contrôlées. + :pxs_antipattern: Utiliser sympy.diag(randint, randint, randint) qui ne contrôle pas la bornitude exacte via DiscreteUniform. + """ + D= eye(p) + for i in range(p): + for j in range(p): + if j==i: + D[i,j] = sample(DiscreteUniform('h', range(a, b))) + else: + D[i,j] = 0 + return D + +def pxs_construct_RREF(n = 3, p = 3, M = (1, 2, 3), min = -9, max = 9): + """ + Construct a matrix with a partial Row Reduced Echelon Form (RREF) structure + based on a given pivot pattern. + + The function creates a matrix of shape `(n, p)` whose pivot positions are + specified by the tuple `M`. Each element `m` of `M` indicates that the + corresponding row has a pivot equal to 1 in column `m-1`. + The remaining coefficients located to the right of the pivot and outside + the pivot columns are filled with random integers between `min` and `max`. + + Parameters + ---------- + n : int, optional + Number of rows of the matrix (default is 3). + p : int, optional + Number of columns of the matrix (default is 3). + M : tuple or Matrix, optional + - If `M` is a tuple, it represents the pivot positions (1-based indexing). + - If `M` is a SymPy Matrix, it is copied and used as the initial matrix. + min : int, optional + Minimum value for the random coefficients (default is -9). + max : int, optional + Maximum value for the random coefficients (default is 9). + + Returns + ------- + Matrix + A SymPy matrix of shape `(n, p)` that follows the structure imposed + by `M`. + + Examples + -------- + >>> pxs_construct_RREF(n=3, p=4, M=(1, 3)) + Matrix([ + [1, 0, 0, a], + [0, 0, 1, b], + [0, 0, 0, 0] + ]) + + >>> pxs_construct_RREF(n=2, p=3, M=(2,)) + Matrix([ + [0, 1, c], + [0, 0, 0] + ]) + + :pxs_trigger: construire matrice RREF avec pivots aux positions choisies, exercice forme échelonnée réduite avec variables libres, pivots 1 sur colonnes imposées + :pxs_returns: | + Matrix sympy de forme (n, p) avec pivots égaux à 1 aux colonnes + spécifiées dans M (1-based), zéros dans les colonnes de pivot hors + ligne de pivot, et entiers aléatoires entre min et max dans les + autres positions à droite des pivots. + :pxs_example: | + A = pxs_construct_RREF(n=3, p=4, M=(1, 3)) + # Matrice en RREF avec pivots en colonnes 1 et 3, x_2 et x_4 libres. + # Dans MyST : $A = \\py{pxsl_matrix(A)}$ + :pxs_antipattern: Construire manuellement à la main — trop long et sujet à erreurs sur le placement des zéros dans les colonnes pivot. + """ + + if isinstance(M, Matrix): + A = M. copy() + elif isinstance(M, tuple): + A = zeros(n, p) + for i, m in enumerate(M): + A[i, m-1] = 1 + for i in range(len(M)): + for k in range(p): + if k+1 > M[i] and k+1 not in M: + A[i, k] = rd.randint(min, max) + return A + +def pxs_generate_sys(M = (1, 2, 3), n = 3, p = 3, N = "", opt = "sys", min = -9, max = 9): + """ + Generate a linear system associated with a matrix in (partial) RREF form. + + The function first constructs a matrix with a Row Reduced Echelon Form–like + structure using `pxs_construct_RREF`, based on the pivot pattern `M`. + A right-hand side vector is generated (or copied) and a simplifying + transformation matrix is then applied to produce either the full linear + system or only the transformed coefficient matrix. + + Parameters + ---------- + M : tuple or Matrix, optional + - If `M` is a tuple, it specifies the pivot positions (1-based indexing) + used to construct the RREF-like matrix. + - If `M` is a SymPy Matrix, its shape defines the values of `n` and `p`. + n : int, optional + Number of rows of the system (default is 3). + p : int, optional + Number of columns of the coefficient matrix (default is 3). + N : Matrix or str, optional + Right-hand side vector of the system. + If an empty string is provided, a random vector of length `n` with + integer entries between `-3` and `3` is generated. + opt : str, optional + Output option: + - `"sys"` returns both the transformed coefficient matrix and the + transformed right-hand side vector. + - Any other value returns only the transformed coefficient matrix. + min : int, optional + Minimum value for the random coefficients in the generated matrix + (default is 9). + max : int, optional + Maximum value for the random coefficients in the generated matrix + (default is 9). + + Returns + ------- + Matrix or tuple of Matrix + - If `opt == "sys"`, returns a tuple `(A, B)` where `A` is the transformed + coefficient matrix and `B` is the transformed right-hand side vector. + - Otherwise, returns only the transformed coefficient matrix. + + Examples + -------- + >>> A, B = pxs_generate_sys(M=(1, 3), n=3, p=4) + >>> A.shape + (3, 4) + + >>> A = pxs_generate_sys(M=(2,), n=2, p=3, opt="mat") + >>> A.shape + (2, 3) + + :pxs_trigger: générer système linéaire avec pivots imposés, exercice Gauss-Jordan avec variables libres contrôlées, système à partir RREF brouillé + :pxs_returns: | + Si opt="sys" : tuple (A, B) où A est une matrice (n,p) obtenue en + multipliant une matrice RREF par une transformation inversible + (pxs_system_simpl) et B le second membre correspondant. + Sinon : A seule. La solution du système est prédictible depuis M. + :pxs_example: | + A, B = pxs_generate_sys(M=(1, 3), n=3, p=4) + # Système de 3 équations à 4 inconnues, x_2 et x_4 libres. + # Dans MyST : $\\py{pxsl_system_lin(A, B)}$ + :pxs_antipattern: Tirer A et B au hasard puis rétro-ingénierer la solution — impossible de contrôler la structure des variables libres. + """ + + # La matrice est N est copiée pour ne pas modifier la matrice originale + + if isinstance(M, Matrix): + n, p = M.shape + if N=="": + N = Matrix([rd.randint(-3,3) for i in range(n)]) + A, B = pxs_construct_RREF(n, p, M, min, max), N.copy() + + A1 = pxs_system_simpl(n = n, opt = "") + + if opt=="sys": + return A1 * A, A1 * B + else: + return A1 * A + +def pxs_repeat_generate_sys(M = (1, 2, 3), n = 3, p = 3, N = "", opt = "sys", min = -9, max = 9, backup = Matrix([[1, 1, 1], [1, 2, 3], [2, 3, 4]]), nb_iter = 10): + """ + Essaie pxs_generate_sys jusqu'à obtenir un système sans colonne nulle. + + :pxs_trigger: générer système linéaire robuste sans colonne nulle, retry avec backup, exercice garantie coefficient de chaque variable présent + :pxs_returns: | + Même type de retour que pxs_generate_sys (tuple (A, B) ou A seule + selon opt), mais en réessayant jusqu'à nb_iter fois pour éviter les + matrices avec une colonne nulle (variable absente du système). + Si échec après nb_iter essais, retourne le backup (matrice fixe) + et un vecteur nul compatible. + :pxs_example: | + A, B = pxs_repeat_generate_sys(M=(1, 3), n=3, p=4, nb_iter=20) + # Garantie : pas de colonne nulle dans A (toutes les variables apparaissent). + :pxs_antipattern: Appeler pxs_generate_sys sans contrôle — risque d'obtenir un système où une variable n'apparaît nulle part (exercice cassé). + """ + for _ in range(nb_iter): + res = pxs_generate_sys(M, n, p, N, opt, min, max) + A = res[0] if opt == "sys" else res + if not pxs_zero_column(A): + return res + return (backup, zeros(backup.rows, 1)) if opt == "sys" else backup + + +def pxs_gauss_jordan( + A, + B=None, + x: str = "x", + method: str = "sys", + view: str = "sep", + detail: str = "on", + strict: bool = False, + frac: bool = True, + vectors: str = "col", + short: bool = True +): + """ + Perform a Gauss–Jordan elimination and generate a formatted (LaTeX) output + of all intermediate steps. + + The function applies the Gauss–Jordan algorithm to the linear system + ``A * X = B`` (or to the reduction of ``A`` alone if ``B is None``). + Each elementary row operation is recorded so that a detailed, step-by-step + symbolic representation of the reduction process can be produced, typically + for inclusion in a LaTeX document via the ``myst`` / ``pxsl_*`` utilities. + + Parameters + ---------- + A : Matrix + Coefficient matrix of the linear system (SymPy ``Matrix``). + B : Matrix or None, optional + Right-hand side vector or matrix. If ``None``, the function only reduces + ``A`` (the right-hand side is taken as a zero matrix of compatible size). + x : str, optional + Base name of the unknown variables used in the symbolic display + (e.g. ``"x"`` produces ``x_1, x_2, ...``). Default is ``"x"``. + method : str, optional + Display method passed to the printing routine (typically ``"sys"`` to + format the output as a linear system). Default is ``"sys"``. + view : str, optional + Visualization mode for intermediate steps (for example, separate or + combined views of matrices and operations). Default is ``"sep"``. + detail : str, optional + Level of detail in the output: + - ``"on"`` displays all elementary operations, + - other values may reduce verbosity (depending on + ``pxsl_print_operations``). + Default is ``"on"``. + strict : bool, optional + Pivot selection strategy: + - if ``True``, applies a strict Gauss–Jordan strategy by choosing, below + the current row, the pivot with the largest absolute value in the + column (partial pivoting); + - if ``False``, chooses the first non-zero coefficient below the current + row (simplified strategy). + Default is ``False``. + frac : bool, optional + Controls the rendering of rational coefficients: + - if ``True``, coefficients are displayed as fractions when appropriate, + - if ``False``, coefficients may be displayed in a simplified inline form. + Default is ``True``. + vectors : str, optional + Orientation of solution vectors in the display: + - ``"col"`` for column vectors, + - any other value for row vectors. + Default is ``"col"``. + + Returns + ------- + Any + A symbolic object representing the formatted output (typically a LaTeX + string or structure produced via ``myst`` and ``pxsl_*`` utilities). + + Examples + -------- + Basic example (solving a square linear system): + + >>> from sympy import Matrix + >>> A = Matrix([[1, 2], [3, 4]]) + >>> B = Matrix([[5], [6]]) + >>> out = pxs_gauss_jordan(A, B) + >>> isinstance(out, str) or out is not None + True + + Changing the variable base name (``x="u"`` produces ``u_1, u_2, ...``): + + >>> A = Matrix([[1, 1], [0, 1]]) + >>> B = Matrix([[2], [3]]) + >>> out = pxs_gauss_jordan(A, B, x="u") + >>> isinstance(out, str) or out is not None + True + + Pivot strategy: simplified vs strict (partial pivoting): + + >>> A = Matrix([[0, 1], [2, 3]]) + >>> B = Matrix([[1], [1]]) + >>> out1 = pxs_gauss_jordan(A, B, strict=False) # first non-zero pivot + >>> out2 = pxs_gauss_jordan(A, B, strict=True) # largest |value| pivot + >>> (out1 is not None) and (out2 is not None) + True + + Reducing verbosity (if supported by the display routine): + + >>> A = Matrix([[1, 2], [3, 4]]) + >>> B = Matrix([[5], [6]]) + >>> out = pxs_gauss_jordan(A, B, detail="off") + >>> out is not None + True + + Skipping the final solution display (only reduction steps): + + >>> A = Matrix([[1, 2], [3, 4]]) + >>> B = Matrix([[5], [6]]) + >>> out = pxs_gauss_jordan(A, B, solve=False) + >>> out is not None + True + + Reducing ``A`` alone (``B=None``): + + >>> A = Matrix([[1, 2, 3], [2, 4, 6]]) + >>> out = pxs_gauss_jordan(A) + >>> out is not None + True + + Solution set representation (vector orientation and span form): + + >>> A = Matrix([[1, 1, 0], [0, 0, 1]]) + >>> B = Matrix([[2], [3]]) + >>> out_col = pxs_gauss_jordan(A, B, vectors="col", span=True) + >>> out_row = pxs_gauss_jordan(A, B, vectors="row", span=False) + >>> (out_col is not None) and (out_row is not None) + True + + :pxs_trigger: Gauss-Jordan complet avec solution paramétrique, résolution système sous-déterminé, ensemble solution vectoriel, Vect base noyau, AX=B paramétrée + :pxs_returns: | + Dictionnaire sol avec les clés : + - "resol" (myst) : étapes détaillées de la réduction en LaTeX + - "sys" (myst) : système final résolu ou incompatible + - "param" (tuple myst) : écriture paramétrique (x_1,...) = (...) + - "free_var" (myst) : liste des variables libres + - "set" (myst) : ensemble solution sous forme {x0 + x_i·v_i + ... : x_i ∈ R} + - "span" (myst) : représentation x0 + Vect(v_1, ..., v_k) + - "A", "B" (Matrix) : matrices finales après réduction. + :pxs_example: | + A = Matrix([[1, 2, 3], [2, 4, 7]]) + B = Matrix([1, 3]) + sol = pxs_gauss_jordan(A, B) + # Dans MyST : \\py{sol["resol"]} puis $\\mathcal{S} = \\py{sol["set"]}$ + :pxs_antipattern: Utiliser sympy.linsolve() qui donne la solution paramétrique en une seule expression sans détailler les étapes de Gauss-Jordan ni la décomposition en base + particulière. + """ + + def __check_incompatible(A, B): + npA = np.array(A) + npB = np.ravel(B) + ind_zero_lines = np.where([not npA[i].any() for i in range(len(npA))])[0] + return npB[ind_zero_lines].any() + + #def __print_solved(A, B, col_pivots, free_indices, x = "x", frac = True): + #n,p = A.shape + #r = len(col_pivots) + + #vect_x = Matrix([Symbol(x + "_" + str(j + 1)) for j in range(p)]) + #expr = myst(r"""\left\{ \begin{array}{rcl} """) if r > 1 else myst(r"""\left. \begin{array}{rcl} """) + #for i in range(r): + #j = col_pivots[i] - 1 + #expr += myst(r"""\py{vect_x[j]} & =& """, globals(), locals()) + #sign = " " + #if B[i] == 0 and A[i, j+1:].is_zero_matrix: + #expr += "0" + #if B[i]: + #rhs = myst(r"""\py{B[i].p}/\py{B[i].q}""", globals(), locals()) if (isinstance(B[i], Rational) and B[i].q != 1 and not frac) else latex(B[i]) + #expr += myst(r"""\py{rhs}""",globals(),locals()) + #sign = "+" + + #for k in range(j+1, p): + #if A[i, k] != 0: + #expr += pxsl_ax(-A[i, k], vect_x[k], sign, frac = frac) + #sign = "+" + #expr += myst(r"""\\[0.3em]""") + + + + # displaying the list of free variables + #if free_indices: + #expr += latex(tuple(vect_x[j-1] for j in free_indices)) if len(free_indices) > 1 else myst(r"""\py{vect_x[list(free_indices)[0] - 1]}""", globals(), locals()) + #set_r = myst(r"""\R^{\py{p-r}}""", globals(), locals()) if p - r > 1 else myst(r"""\R""") + #expr += myst(r""" &\in &\py{set_r}\\""", globals(), locals()) + #expr += myst(r"""\end{array}\right.""") + + #return expr + + def __print_solved(A, B, col_pivots, free_indices, x = "x", frac = True): + n,p = A.shape + r = len(col_pivots) + + vect_x = Matrix([Symbol(x + "_" + str(j + 1)) for j in range(p)]) + expr = myst(r"""\left\{ \begin{array}{rcl} """) + k = -1 + for i in range(p): + if i+1 in free_indices: + expr += myst(r"""\py{latex(vect_x[i])} &=&\py{latex(vect_x[i])}\\""", globals(), locals()) + continue + k += 1 + j = i + sign = " " + + expr += myst(r"""\py{latex(vect_x[i])} &=& """, globals(), locals()) + if B[k] == 0 and A[k, j+1:].is_zero_matrix: + expr += myst(r"""0""", globals(), locals()) + if B[k]: + rhs = myst(r"""\py{B[k].p}/\py{B[k].q},""", globals(), locals()) if (isinstance(B[k], Rational) and B[k].q != 1 and not frac) else latex(B[k]) + expr += myst(r"""\py{rhs}""",globals(),locals()) + sign = "+" + + for l in range(j+1, p): + if A[k, l] != 0: + expr += myst(r"""\py{pxsl_ax(-A[k, l], vect_x[l], sign, frac = frac)}""", globals(), locals()) + sign = "+" + if i != p-1: + expr += myst(r""" \\ """) + + # displaying the list of free variables + #if free_indices: + #for j in free_indices: + #expr += myst(r"""\py{latex(vect_x[j-1])}""", globals(), locals()) + expr += myst(r"""\end{array}\right.""") + + + return expr + + def __print_param(A, B, col_pivots, free_indices, x = "x", frac = True): + n,p = A.shape + r = len(col_pivots) + + vect_x = Matrix([Symbol(x + "_" + str(j + 1)) for j in range(p)]) + expr1 = myst(r"""\left( """) + for i, v in enumerate(vect_x): + if i != len(vect_x) -1: + expr1 += myst(r"""\py{latex(v)},""", globals(), locals()) + else: + expr1 += myst(r"""\py{latex(v)}""", globals(), locals()) + expr1 += myst(r""" \right) = ( """) + k = -1 + for i in range(p): + if i+1 in free_indices: + if i != p-1: + expr1 += myst(r"""\py{latex(vect_x[i])},""", globals(), locals()) + else: + expr1 += myst(r"""\py{latex(vect_x[i])}""", globals(), locals()) + continue + k += 1 + j = i + sign = " " + if B[k] == 0 and A[k, j+1:].is_zero_matrix: + expr1 += myst(r"""0""", globals(), locals()) + if B[k]: + rhs = myst(r"""\py{B[k].p}/\py{B[k].q},""", globals(), locals()) if (isinstance(B[k], Rational) and B[k].q != 1 and not frac) else latex(B[k]) + if k != p-1: + expr1 += myst(r"""\py{rhs}""",globals(),locals()) + else: + expr1 += myst(r"""\py{rhs}""",globals(),locals()) + sign = "+" + + for l in range(j+1, p): + if A[k, l] != 0: + expr1 += myst(r"""\py{pxsl_ax(-A[k, l], vect_x[l], sign, frac = frac)}""", globals(), locals()) + sign = "+" + if i != p-1: + expr1 += myst(r""" , """) + + # displaying the list of free variables + #if free_indices: + #for j in free_indices: + #expr += myst(r"""\py{latex(vect_x[j-1])}""", globals(), locals()) --> + expr1 += myst(r""") """) + expr2 = myst(r""" """) + if free_indices: + expr2 = latex(tuple(vect_x[j-1] for j in free_indices)) if len(free_indices) > 1 else myst(r"""\py{vect_x[list(free_indices)[0] - 1]}""", globals(), locals()) + set_r = myst(r"""\R^{\py{p-r}}""", globals(), locals()) if p - r > 1 else myst(r"""\R""") + expr2 += myst(r""" \in \py{set_r}""", globals(), locals()) + + return expr1, expr2 + + def __get_basis(A, B, col_pivots, free_indices, frac = True): + n, p = A.shape + canonical = eye(p) + basis = [] + for j in free_indices: + lesser_pivots = [p-1 for p in col_pivots if p < j] + nb = len(lesser_pivots) + u = canonical[j-1, :] - sum([A[i, j-1] * canonical[pivot, :] for i, pivot in enumerate(lesser_pivots)], start = zeros(1, p)) + basis.append(u) + # particular solution: + x0 = sum([B[i] * canonical[p - 1, :] for i, p in enumerate(col_pivots)], start = zeros(1, p)) + return basis, x0 + + pxs_lang = get_pxs_lang() + sol = {"sys": myst(r""" """), "param": myst(r""" """), "free_var": myst(r""" """)} + + + [n, p] = A.shape + + no_rhs = B is None + if no_rhs: + B = zeros(n, 1) + + listA = [A.copy()] + listB = [B.copy()] + listOp = [[0,0,0,0]] + r = -1 + + col_pivots = [] + sol["resol"] = myst(r"""\begin{equation*}""", locals(), globals()) if solve else " " + + j = 0 + go_on = True + while j < p and r < n - 1 and go_on: + # looking for the line to swap with: + if strict: + k = max(range(r+1, A.rows), key = lambda i: Abs(A[i, j])) + # if strict = False, take the row of the first non zero coefficient if any + elif np.any(A[r+1:, j]): + k = np.where(np.ravel(A[r + 1:, j]) != 0)[0][0] + r + 1 + else: # all coeffs under row r+1 are zero, nothing will be done anyway + k = r + 1 + + if A[k, j] != 0: + r += 1 + if A[k, j] != 1: + listOp.append([1 / A[k, j], k + 1, 0, 0]) + B[k, :] = B[k, :] / A[k, j] + A[k, :] = A[k, :] / A[k, j] + listA.append(A.copy()) + listB.append(B.copy()) + + col_pivots.append(j + 1) + + if k != r: + A.row_swap(k, r) + B.row_swap(k, r) + listA.append(A.copy()) + listB.append(B.copy()) + listOp.append([0, r + 1, 1, k + 1]) + for i in range(n): + if i != r and A[i, j]: + listOp.append([1, i + 1, -A[i, j], r + 1]) + B[i, :] = B[i, :] - A[i, j] * B[r, :] + A[i, :] = A[i, :] - A[i, j] * A[r, :] + listA.append(A.copy()) + listB.append(B.copy()) + + go_on = not (short and __check_incompatible(A, B)) + j += 1 + + free_indices = set(range(1, p + 1)) - set(col_pivots) # will be useful for basis of solutions + + list_mat = [listA] if no_rhs else [listA, listB] + sol["resol"] += pxsl_print_operations(list_mat, listOp = listOp, method = method, x = x, view = view, detail = detail, frac = frac) + sol["resol"] += myst(r"""\end{equation*} +""") + #if len(col_pivots) > 1: + #sol["resol"] += myst(r""" + #On obtient donc le système équivalent suivant :""") if pxs_lang == "fr" else myst(r"""Hence we get the following equivalent system:""") + #else: + #sol["resol"] += myst(r""" + #Le système est donc équivalent à :""") if pxs_lang == "fr" else myst(r"""Hence the system is equivalent to:""") --> +# sol["sys"] = myst(r""" +# \begin{equation*}""") + + consistent = not __check_incompatible(A, B) + + sol["sys"] = __print_solved(A, B, col_pivots, free_indices, x = x, frac = frac) if consistent else pxsl_system_lin(A, B, x = x, frac = frac) + sol["param"], sol["free_var"] = __print_param(A, B, col_pivots, free_indices, x = x, frac = frac) +# sol["sys"] += myst(r""" +# \end{equation*}""") + sol["A"] = A + sol["B"] = B + #expr += myst(r"""\\ \\""") + #expr += __print_solved(A, B, col_pivots, free_indices, x = x, frac = frac) + #expr += myst(r"""\end{equation*}""", locals(), globals()) + + # displaying the list of free variables + vect_x_free = Matrix([Symbol(x + "_" + str(j)) for j in free_indices]) + basis, x0 = __get_basis(A, B, col_pivots, free_indices, frac = frac) + if vectors == "col": basis, x0 = [v.T for v in basis], x0.T + + + # displaying the solutions as linear combinations of the basis vectors: + mat_delim = "[" if pxs_lang == "en" else "(" + if consistent: + # sol["set"] = myst(r"""\begin{equation*} + # \begin{align*} + # \mathcal{S} &= \left\{""") + sol["set"] = myst(r"""\left\{""") + if not x0.is_zero_matrix or not free_indices: + sol["set"] += latex(x0, mat_delim = mat_delim, fold_short_frac = not frac) + if free_indices: sol["set"]+= " + " + for l in range(len(basis)): + vector_tex = latex(basis[l], mat_delim = mat_delim, fold_short_frac = not frac) + sol["set"] += myst(r""" \py{vect_x_free[l]} . \py{vector_tex} \py{" + " if (l < len(basis) - 1) else " "}""", globals(), locals()) + if free_indices: sol["set"] += myst(r""" ~ : ~ """) + # displaying the list of free variables + if free_indices: + sol["set"] += latex(tuple(vect_x_free)) if len(vect_x_free) > 1 else myst(r"""\py{vect_x_free[0]}""", globals(), locals()) + set_r = myst(r"""\R^{\py{p-r - 1}}""", globals(), locals()) if p - r -1 > 1 else myst(r"""\R""") + sol["set"] += myst(r""" \in \py{set_r}""", globals(), locals()) + + # sol["set"] += myst(r"""\right\} + # \end{align*} + # \end{equation*}""") + sol["set"] += myst(r"""\right\}""") + + sol["span"] = myst(r""" """) + if free_indices: + vepan = "Vect" if pxs_lang == "fr" else "Span" + # sol["span"] = myst(r""" + # \begin{equation*} + # \begin{align*}""") + if not x0.is_zero_matrix: + sol["span"] += latex(x0, mat_delim = mat_delim, fold_short_frac = not frac) + " + " + sol["span"] += myst(r"""\text{\py{vepan}}\left( """, globals(), locals()) + # for l in range(len(basis)): + # vector_tex = latex(basis[l], mat_delim = mat_delim, fold_short_frac = not frac) + # expr += myst(r"""\py{vector_tex} \py{" , " if (l < len(basis) - 1) else " "}""", globals(), locals()) + sol["span"] += " , ".join([latex(vector, mat_delim = mat_delim, fold_short_frac = not frac) for vector in basis]) + sol["span"] += myst(r"""\right)""") + # sol["span"] += myst(r""" + # \end{align*} + # \end{equation*}""") + + else: + sol["set"], sol["span"] = myst(r"""\emptyset"""), myst(r"""\emptyset""") + + return sol + +def pxs_colinear_rows(M, i, j): + """ + Test whether two rows of a matrix are colinear. + + Two rows are said to be colinear if one is a scalar multiple of the other. + The test is performed by computing the rank of the matrix formed by the + two rows. + + By convention, if at least one of the two rows is a zero row, the function + returns ``False`` (zero rows are ignored). + + Parameters + ---------- + M : Matrix + A SymPy matrix. + i : int + Index of the first row to test (0-based). + j : int + Index of the second row to test (0-based). + + Returns + ------- + bool + ``True`` if rows ``i`` and ``j`` are colinear, ``False`` otherwise. + + Examples + -------- + Two proportional rows: + + >>> from sympy import Matrix + >>> M = Matrix([[1, 2, 3], + ... [2, 4, 6], + ... [1, 0, 1]]) + >>> pxs_colinear_rows(M, 0, 1) + True + + Rows that are not colinear: + + >>> pxs_colinear_rows(M, 0, 2) + False + + A zero row is ignored: + + >>> M = Matrix([[1, 2, 3], + ... [0, 0, 0], + ... [2, 4, 6]]) + >>> pxs_colinear_rows(M, 0, 1) + False + + Colinearity still detected with non-adjacent rows: + + >>> pxs_colinear_rows(M, 0, 2) + True + + :pxs_trigger: tester colinéarité deux lignes matrice, lignes proportionnelles, rang 1 paire de lignes, vérifier redondance équations + :pxs_returns: | + bool : True si les lignes i et j sont non nulles et proportionnelles + (rang de la matrice formée des deux lignes égal à 1), False sinon. + Par convention, les lignes nulles sont ignorées (retourne False). + :pxs_example: | + M = Matrix([[1, 2, 3], [2, 4, 6], [1, 0, 1]]) + pxs_colinear_rows(M, 0, 1) # True, car L_2 = 2·L_1 + pxs_colinear_rows(M, 0, 2) # False + :pxs_antipattern: Vérifier M.row(i) / M.row(j) == constant — échoue si une entrée est zéro ou si on teste avec des symboles. + """ + if M.row(i).is_zero or M.row(j).is_zero: + return False # on ignore les lignes nulles ici + return Matrix([M.row(i), M.row(j)]).rank() == 1 + +def pxs_break_colinearity(M, N, i, j, *, coef_range=(-3, 3)): + """ + Break the colinearity between two rows of a linear system using + an elementary row operation. + + If rows ``i`` and ``j`` of the matrix ``M`` are colinear, the function + replaces row ``i`` by a linear combination + + row_i ← a * row_i + b * row_k + + where ``k`` is a row index different from ``i`` and ``j``, and + ``a`` and ``b`` are nonzero integers chosen randomly in ``coef_range``. + + The same operation is applied consistently to the right-hand side + vector ``N`` so that the linear system remains equivalent. + + If rows ``i`` and ``j`` are not colinear, or if no suitable third row + is available, the matrices are returned unchanged. + + Parameters + ---------- + M : Matrix + Coefficient matrix of the linear system. + N : Matrix + Right-hand side column vector of the system. + i : int + Index of the first row (0-based). + j : int + Index of the second row (0-based). + coef_range : tuple of int, optional + Range ``(min, max)`` from which the integer coefficients ``a`` and + ``b`` are drawn (default is ``(-3, 3)``). Zero is excluded. + + Returns + ------- + Matrix + The modified coefficient matrix. + Matrix + The modified right-hand side vector. + + Examples + -------- + Breaking colinearity between two proportional rows: + + >>> from sympy import Matrix + >>> M = Matrix([[1, 2, 3], + ... [2, 4, 6], + ... [1, 0, 1]]) + >>> N = Matrix([1, 2, 0]) + + >>> M2, N2 = pxs_break_colinearity(M, N, 0, 1) + + The resulting system is equivalent, but rows 0 and 1 are no longer colinear: + + >>> from sympy import Matrix + >>> Matrix([M2.row(0), M2.row(1)]).rank() == 1 + False + + If the rows are not colinear, nothing is changed: + + >>> M = Matrix([[1, 2], + ... [3, 4]]) + >>> N = Matrix([1, 1]) + >>> M2, N2 = pxs_break_colinearity(M, N, 0, 1) + >>> M2 == M and N2 == N + True + + If no suitable third row exists, the matrices are returned unchanged: + + >>> M = Matrix([[1, 2], + ... [2, 4]]) + >>> N = Matrix([1, 2]) + >>> M2, N2 = pxs_break_colinearity(M, N, 0, 1) + >>> M2 == M and N2 == N + True + + :pxs_trigger: casser colinéarité deux lignes système linéaire, rendre lignes indépendantes par opération élémentaire, préserver solution du système + :pxs_returns: | + Tuple (M_modifiée, N_modifiée) où la ligne i de M est remplacée par + a·L_i + b·L_k avec k ≠ i,j et a,b entiers non nuls aléatoires + dans coef_range. N est modifiée cohéremment pour préserver la + solution. Si pas de colinéarité ou pas de ligne k valide, retourne + (M, N) inchangées. + :pxs_example: | + M = Matrix([[1, 2, 3], [2, 4, 6], [1, 0, 1]]) + N = Matrix([1, 2, 0]) + M2, N2 = pxs_break_colinearity(M, N, 0, 1) + # L_1 devient une combinaison avec L_3 ; la solution reste la même. + :pxs_antipattern: Supprimer la ligne colinéaire — change le système et élimine une équation au lieu de la rendre indépendante. + """ + M = M.copy() + N = N.copy() + n = M.rows + + if not pxs_colinear_rows(M, i, j): + return M, N # rien à faire + + # choisir une ligne k différente de i et j + candidates = [k for k in range(n) if k not in (i, j) and not M.row(k).is_zero] + if not candidates: + return M, N # pas de ligne exploitable + + k = rd.choice(candidates) + + # coefficients non nuls + a = rd.choice([c for c in range(*coef_range) if c != 0]) + b = rd.choice([c for c in range(*coef_range) if c != 0]) + + # opération élémentaire + M.row_op(i, lambda v, col: a * v + b * M[k, col]) + N.row_op(i, lambda v, col: a * v + b * N[k]) + + return M, N + +def pxs_break_all_colinear_rows(A, B, max_iter=5): + """ + Remove colinearity between all pairs of rows of a linear system. + + The function repeatedly scans the coefficient matrix ``A`` for pairs of + colinear rows. Whenever such a pair is found, an elementary row operation + is applied (via :func:`pxs_break_colinearity`) to break the colinearity + while preserving the solution set of the system. + + The process is repeated until no colinear row pairs remain, or until the + maximum number of iterations is reached. + + Parameters + ---------- + A : Matrix + Coefficient matrix of the linear system. + B : Matrix + Right-hand side column vector. + max_iter : int, optional + Maximum number of iterations allowed to remove colinearities + (default is ``10``). + + Returns + ------- + Matrix + The modified coefficient matrix with reduced row colinearity. + Matrix + The modified right-hand side vector. + + Examples + -------- + Removing colinearity between multiple rows: + + >>> from sympy import Matrix + >>> A = Matrix([[1, 2, 3], + ... [2, 4, 6], + ... [3, 6, 9]]) + >>> B = Matrix([1, 2, 3]) + + >>> A2, B2 = pxs_break_all_colinear_rows(A, B) + + After processing, no two nonzero rows are colinear: + + >>> any( + ... Matrix([A2.row(i), A2.row(j)]).rank() == 1 + ... for i in range(A2.rows) + ... for j in range(i + 1, A2.rows) + ... if not A2.row(i).is_zero and not A2.row(j).is_zero + ... ) + False + + If the matrix contains no colinear rows, it is returned unchanged: + + >>> A = Matrix([[1, 0], + ... [0, 1]]) + >>> B = Matrix([1, 1]) + >>> A2, B2 = pxs_break_all_colinear_rows(A, B) + >>> A2 == A and B2 == B + True + + :pxs_trigger: rendre toutes lignes indépendantes, supprimer toute colinéarité système linéaire, éviter système redondant, exercice avec lignes non proportionnelles + :pxs_returns: | + Tuple (A_modifiée, B_modifiée) où itérativement, toute paire de lignes + colinéaires est brisée via pxs_break_colinearity jusqu'à ce qu'il + n'y en ait plus ou que max_iter itérations aient été effectuées. + Le système solution est préservé. + :pxs_example: | + A = Matrix([[1, 2, 3], [2, 4, 6], [3, 6, 9]]) + B = Matrix([1, 2, 3]) + A2, B2 = pxs_break_all_colinear_rows(A, B) + # Plus aucune paire de lignes colinéaires, mais le système reste équivalent. + :pxs_antipattern: Calculer A.rref() et s'arrêter — donne une forme réduite canonique, pas un système équivalent pédagogique avec lignes "dissemblables". + """ + M = A.copy() + N = B.copy() + + for _ in range(max_iter): + changed = False + for i in range(M.rows): + for j in range(i + 1, M.rows): + if pxs_colinear_rows(M, i, j): + M, N = pxs_break_colinearity(M, N, i, j) + changed = True + break + if changed: + break + if not changed: + return M, N + + return M, N + +def pxs_zero_column(A): + """ + Checks whether Matrix A has at least one zero column. + + Parameters + ---------- + A : Matrix + + Returns + ------- + bool : True if A has at least one zero column, False otherwise + + Examples + -------- + >>> A = Matrix( + ... [[1, 0, 2], + ... [2, 0, -3], + ... [1, 0, 1]]) + >>> pxs_zero_column(A) + True + + >>> M = Matrix( + ... [[1, 0, 2], + ... [2, 1, -3], + ... [1, 0, 1]]) + >>> pxs_zero_column(M) + False + + :pxs_trigger: tester colonne nulle matrice, détecter variable absente système linéaire, colonne de zéros dans matrice + :pxs_returns: | + bool : True si la matrice A possède au moins une colonne entièrement + nulle, False sinon. Utilisé pour filtrer les systèmes où une variable + n'apparaît pas dans les équations (dégénérescence). + :pxs_example: | + A = Matrix([[1, 0, 2], [2, 0, -3], [1, 0, 1]]) + pxs_zero_column(A) # True (colonne 2 entière nulle) + :pxs_antipattern: Parcourir toutes les colonnes à la main avec une boucle for — utiliser .is_zero_matrix sur la tranche colonne est plus sûr. + """ + return np.any([A[:, j].is_zero_matrix for j in range(A.cols)]) + +## CALCULS DE DÉTERMINANTS + +def pxs_determinant(A, detail = "on", **kwargs): + """ + Calcule pas à pas le déterminant d'une matrice par réduction à une forme triangulaire. + + :pxs_trigger: calcul déterminant par pivot de Gauss, étapes triangulaire supérieure, échanges de lignes factor signe, facteurs diagonaux déterminant + :pxs_returns: | + Dictionnaire avec les clés : + - "oper" (myst) : affichage des opérations et matrices successives + - "swaps" (int) : nombre d'échanges de lignes effectués + - "exp" (str) : expression factorisée finale du déterminant (ex. "(-1)^2 · 2 · 3 · 5") + - "val" (str) : valeur numérique simplifiée du déterminant + - "all" (myst) : texte LaTeX complet avec explication des échanges. + :pxs_example: | + A = Matrix([[1, 2, 3], [4, 5, 6], [7, 8, 10]]) + det_info = pxs_determinant(A) + # Dans MyST : \\py{det_info["all"]} + # ou séparément : étapes = \\py{det_info["oper"]}, valeur = $\\py{det_info["val"]}$ + :pxs_antipattern: Utiliser A.det() qui donne la valeur sans détailler la méthode par pivot (échanges, facteurs, diagonale finale). + """ + + def __print_ops(listA, listOp, factors, **kwargs): + n = listA[0].shape[0] + [listA], listOp = pxs_regroupe_ligne([listA], listOp) + swaps_txt = myst(r"""\text{swaps:}\quad """) if pxs_lang == "en" else myst(r"""échanges : """) + factors_txt = myst(r"""\text{factors:}\quad """) if pxs_lang == "en" else myst(r"""facteurs : """) + r = 0 # nb of swaps + f = 0 # index of factor + # First line + expr = myst(r"""\begin{array}{ccc} """)+myst(r"""&""")+ pxsl_matrix(listA[0]) + myst(r""" \\ \\""") + + # other lines + for i in range(1, len(listA)): + printed_ops = pxsl_lines_op(n, listOp[i], frac = frac) if detail == "on" else " " + expr += myst(r"""\py{printed_ops} & """, locals(), globals()) + pxsl_matrix(listA[i]) + myst(r"""&""") + + if not all([op[0] for op in listOp[i]]): # swap + r += 1 + expr += myst(r"""\py{swaps_txt} \py{r}""", globals(), locals()) + if not all([op[2] for op in listOp[i]]): # scaling + f += 1 + current_factors = latex(Mul(*factors[:f], evaluate = False), **kwargs) + expr += myst(r"""\py{factors_txt} \py{current_factors}""", globals(), locals()) + expr += myst(r"""\\ \\""") + expr+=myst(r"""\end{array}""") + return expr + + def __writing(operations, expression, value, nb_swaps): + text = myst(r"""\begin{equation*} +\py{operations} +\end{equation*} +""", globals(), locals()) + if expression: + if nb_swaps: + neg = " " + nb_swaps_tex = myst(r"""$\py{latex(nb_swaps)}$""", globals(), locals()) + else: + neg = "not" if pxs_lang == "en" else "n'" + nb_swaps_tex = "aucun" if pxs_lang == "fr" else "any" + if pxs_lang == "en": + text += myst(r"""We have \py{neg} performed \py{nb_swaps_tex} row swap, hence: +\begin{equation*} +\begin{align*} +\det \py{pxsl_matrix(A)} &= \py{expression} \\ +&= \py{value} \,. +\end{align*} +\end{equation*} +""", globals(), locals()) + else: + text += myst(r"""Nous \py{neg}avons effectué \py{nb_swaps_tex} échange de lignes, donc : +\begin{equation*} +\begin{align*} +\det \py{pxsl_matrix(A)} &= \py{expression} \\ +&= \py{value} \,. +\end{align*} +\end{equation*} +""", globals(), locals()) + else: + if pxs_lang == "en": + text += myst(r"""The last matrix above has a zero diagonal coefficient, hence: +\begin{equation*} +\det \py{pxsl_matrix(A)} = 0\,. +\end{equation*}""") + else: + text +=myst(r"""La dernière matrice obtenue ci-dessus possède un coefficient diagonal nul, hence: +\begin{equation*} +\det \py{pxsl_matrix(A)} = 0\,. +\end{equation*}""") + + return text + + + pxs_lang = get_pxs_lang() + + n = A.rows + listA=[A.copy()] + listOp=[[0,0,0,0]] + nb_swaps = 0 + factors = [] + for k in range(n - 1): + if np.any(A[k:, k]): + # swapping + r = np.where(np.ravel(A[k:, k]) != 0)[0][0] + k + if r != k: + nb_swaps += 1 + A.row_swap(k, r) + listA.append(A.copy()) + listOp.append([0, r + 1, 1, k + 1]) + # setting 1 as pivot + if A[k, k] != 1: + factors.append(A[k, k]) + listOp.append([1 / A[k, k], k + 1, 0, 0]) + A[k, :] /= A[k, k] + listA.append(A.copy()) + # getting zeros under the pivot + for j in range(k+1, n): + if A[j, k] != 0: + listOp.append([1, j+1, -A[j, k], k + 1]) + A[j, :] = A[j, :] - A[j, k] * A[k, :] + listA.append(A.copy()) + else: # zero pivot, computation is over + operations = pxsl_print_operations([listA], listOp = listOp, method = "mat") + all_details = __writing(operations, None, None, nb_swaps) + return {"oper" : operations, "swaps" : nb_swaps, "exp" : "0", + "val" : "0", "all" : all_details} + np_diag = np.array(A)[range(n), range(n)] + diag_coeffs = list(np_diag[np_diag != 1]) # non-ones diagonal coefficients of the last matrix + if not diag_coeffs: diag_coeffs = [1] + sign = [Pow(-1, nb_swaps, evaluate = False)] if nb_swaps else [] + all_det_factors = sign + factors + diag_coeffs + + # operations = pxsl_print_operations([listA], listOp = listOp, method = "mat") + operations = __print_ops(listA, listOp, factors, **kwargs) + expression = latex(Mul(*all_det_factors, evaluate = False), **kwargs) + value = latex(Mul(*[(-1) ** nb_swaps] + factors + diag_coeffs), **kwargs) + all_details = __writing(operations, expression, value, nb_swaps) + + result = { + "oper" : operations, + "swaps" : nb_swaps, + "exp" : expression, + "val" : value, + "all" : all_details, + } + return result + + +def pxs_compute_determinant(A, smart = True, **kwargs): + """ + Calcule le déterminant d'une matrice par développement récursif selon les cofacteurs. + + :pxs_trigger: calcul déterminant par cofacteurs développement Laplace, récurrence ligne ou colonne contenant des zéros, stratégie smart meilleure ligne/colonne + :pxs_returns: | + Objet myst contenant le calcul complet du déterminant par développement + récursif selon lignes/colonnes en LaTeX (environnement align*), avec + factorisation intermédiaire des coefficients communs. Si smart=True, + choisit à chaque étape la ligne ou colonne contenant le plus de zéros. + :pxs_example: | + A = Matrix([[1, 0, 0, 2], [3, 4, 0, 5], [6, 7, 8, 0], [9, 1, 2, 3]]) + # Dans MyST : \\py{pxs_compute_determinant(A)} + # Développement optimal par la 1ère ligne (3 zéros). + :pxs_antipattern: Utiliser A.det() ou développer systématiquement selon la 1ère ligne sans exploiter les lignes/colonnes avec zéros — calcul inutilement lourd. + """ + + def __rec_compute_determinant(A, smart, expr, values, **kwargs): + + def __latex_without_ones(list_terms, **kwargs): + list_no_ones = [x for x in list_terms if x != 1] + if list_no_ones: + return latex(Mul(*list_no_ones, evaluate = False), **kwargs) if list_no_ones != [-1] else myst(r""" - """) + else: + return " " + + def __get_coeffs(vector): + flat = np.array(vector).ravel() # flatten to get a 1d-array + ones_ind = np.where(flat == 1)[0] # indices of the ones, if any + indices = np.where(flat)[0] + if ones_ind: # if any + return ones_ind[0], indices + else: + return indices[0], indices + + def __det2x2(A, **kwargs): + a, b, c, d = A + ad = Mul(a, d, evaluate = not (a * d)) + bc = Mul(b, c, evaluate = not (b * c)) + return myst(r"""\py{latex(ad, **kwargs)} - \py{latex(bc, **kwargs)}""", globals(), locals()).replace("- -", "+") + + pxs_lang = get_pxs_lang() + n = A.rows + + if n == 2: + try: + mult = mul_symbol + except: + mult = myst(r""" """) + lpar = myst(r"""\left(""") if len(values) != values.count(1) else myst(r""" """) + rpar = myst(r"""\right)""") if len(values) != values.count(1) else myst(r""" """) + expr += myst(r""" + &= \py{__latex_without_ones(values, **kwargs)} \py{mult} \py{lpar} \py{__det2x2(A, **kwargs)} \py{rpar} & \\ + """, globals(), locals()) + values.append(A.det()) + final_value = Mul(*values) + expr += myst(r""" + &= \py{latex(final_value, **kwargs)} \,. + """, globals(), locals()) + return expr + + if n <= 1: + values.append(A[0, 0]) + final_value = Mul(*values) + expr += myst(r"""\\ + &= \py{__latex_without_ones(values, **kwargs)} \\ + &= \py{latex(final_value, **kwargs)} \,. + """, globals(), locals()) + return expr + + if A[:, 0].is_zero_matrix: + expr += myst(r"""\\ + &= 0 \, . + """) + return expr + + operations = [] + sign = 1 + column_operations = False # replace row operations by column operations if needed + if smart: + nonzero_row = np.count_nonzero(A, axis = 1) + nonzero_col = np.count_nonzero(A, axis = 0) + row_min, col_min = min(nonzero_row), min(nonzero_col) + if row_min < col_min: # The best is a row + column_operations = True # in this case we perform column operations + k = np.argmin(nonzero_row) + j0, indices = __get_coeffs(A[k, :]) + + coeff = A[k, j0] + # scaling if need be + if coeff != 1: + operations.append([1 / coeff, j0 + 1, 0, 0]) + A[:, j0] /= coeff + # getting zeros on the row + for j in indices: + if j != j0: + operations.append([1, j + 1, -A[k, j], j0 + 1]) + A[:, j] = A[:, j] - A[k, j] * A[:, j0] + sign = (-1) ** (k + j0) + + else: # The best is a column + k = np.argmin(nonzero_col) + i0, indices = __get_coeffs(A[:, k]) + + coeff = A[i0, k] + # scaling if need be + if coeff != 1: + operations.append([1 / coeff, i0 + 1, 0, 0]) + A[i0, :] /= coeff + # getting zeros on the row + for i in indices: + if i != i0: + operations.append([1, i + 1, -A[i, k], i0 + 1]) + A[i, :] = A[i, :] - A[i, k] * A[i0, :] + sign = (-1) ** (i0 + k) + else: + # swapping + if A[0, 0].is_zero: + r = np.where(np.ravel(A[:, 0]) != 0)[0][0] + sign = -1 + A.row_swap(0, r) + operations.append([0, r + 1, 1, 1]) + coeff = A[0, 0] + # scaling if need be + if coeff != 1: + operations.append([1 / coeff, 1, 0, 0]) + A[0, :] /= coeff + # getting zeros under the pivot + for j in range(1, n): + if A[j, 0] != 0: + operations.append([1, j+1, -A[j, 0], 1]) + A[j, :] = A[j, :] - A[j, 0] * A[0, :] + + + minor = A.copy() + if column_operations: # smart and column operations + i, j = k, j0 + elif smart: # smart and row operations + i, j = i0, k + else: # unsmart + i, j = 0, 0 + minor.row_del(i) + minor.col_del(j) + + row_symb = "L" if pxs_lang == "fr" else "R" + print_ops = pxsl_lines_op(n, operations).replace(row_symb, "C") if column_operations else pxsl_lines_op(n, operations) + expr += myst(r""" + &= \py{__latex_without_ones(values + [1 if smart else sign, coeff], **kwargs)} \det \py{pxsl_matrix(A)} & \py{print_ops} \\ + """, globals(), locals()) + values.append(sign * coeff) + expr += myst(r""" + &= \py{__latex_without_ones(values, **kwargs)} \det \py{pxsl_matrix(minor)} & \\ + """, globals(), locals()) + return __rec_compute_determinant(minor, smart, expr, values, **kwargs) + + begin = myst(r""" +\begin{equation*} +\begin{align*} +\det \py{pxsl_matrix(A)} +""") + end = myst(r""" +\end{align*} +\end{equation*} +""") + return begin + __rec_compute_determinant(A, smart, " ", [], **kwargs) + end + + +def pxs_expand_determinant(A, rc, k): + """ + Développe un déterminant selon une ligne ou une colonne, formule de Laplace. + + :pxs_trigger: développer déterminant selon ligne ou colonne, formule de Laplace, cofacteurs signes (-1)^(i+j), écriture pédagogique somme des mineurs + :pxs_returns: | + Chaîne LaTeX de l'expression du déterminant développé selon la + ligne/colonne k au format "Σ (-1)^(i+k)·a_ik·det(M_ik)". + - rc="r" : développement selon la ligne k (1-based) + - rc="c" : développement selon la colonne k (1-based) + Les termes avec a_ik=0 sont omis. + :pxs_example: | + A = Matrix([[1, 0, 2], [3, 4, 5], [0, 6, 7]]) + # Développement selon la ligne 1 (contient un 0) : + # Dans MyST : $\\det A = \\py{pxs_expand_determinant(A, "r", 1)}$ + # → "1·det(...) + 2·det(...)" + :pxs_antipattern: Écrire la formule de Laplace à la main avec des f-strings — cauchemar pour gérer les signes (-1)^(i+j) et les coefficients ±1 implicites. + """ + n = A.rows + A = A.copy() if rc == "r" else A.T + couples = [] + M = A.copy() + M.row_del(k - 1) + for j in range(n): + if A[k - 1, j]: + factor = (-1) ** (j + k - 1) * A[k - 1, j] + minor = M.copy() + minor.col_del(j) + if rc == "c": + minor = minor.T + symb = Symbol(myst(r"""\det \py{pxsl_matrix(minor)}""", globals(), locals())) + if factor != 1: + couples.append((factor, symb)) + else: + couples.append((symb,)) + addition = Add(*[Mul(*prod, evaluate = False) for prod in couples], evaluate = False) + + return LatexPrinter(dict(order = "none"))._print_Add(addition) \ No newline at end of file diff --git a/app/corpus/Mes_fctions_d_analyse_bis.py b/app/corpus/Mes_fctions_d_analyse_bis.py new file mode 100644 index 0000000000000000000000000000000000000000..d43ff29a6725d2d4131962b7ce785c2723cbf63e --- /dev/null +++ b/app/corpus/Mes_fctions_d_analyse_bis.py @@ -0,0 +1,1193 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Thu Mar 17 19:11:52 2022 + +@author: jlebovits +""" + +from __future__ import division +import sys +import random +from copy import deepcopy + +import src.scripts.Mes_fctions.Mes_fctions_deterministes +from src.scripts.Mes_fctions.Mes_fctions_deterministes import * + +import src.scripts.Mes_fctions.Mes_fctions_generalistes +from src.scripts.Mes_fctions.Mes_fctions_generalistes import * + +import src.scripts.Mes_fctions.Mes_fctions_probabilistes +from src.scripts.Mes_fctions.Mes_fctions_probabilistes import * + +import src.scripts.Mes_fctions.Mes_fctions_d_ecriture_Latex +from src.scripts.Mes_fctions.Mes_fctions_d_ecriture_Latex import * + +import src.scripts.Mes_fctions.Mes_fctions_d_alg_lineaire_bis +from src.scripts.Mes_fctions.Mes_fctions_d_alg_lineaire_bis import * + +import src.scripts.Mes_fctions.Mes_fctions_generalistes_bis +from src.scripts.Mes_fctions.Mes_fctions_generalistes_bis import * + +import src.scripts.Mes_fctions.Mes_fctions_utilitaires +from src.scripts.Mes_fctions.Mes_fctions_utilitaires import _pxs_add_letter + +from sympy import * +from src.scripts.pxs_runtime import get_pxs_lang, myst + + + +def pxsl_par(expr, minus = False, add = False): + """ + Wraps a LaTeX expression in parentheses if it starts with a minus sign or if it is an Add. + + Parameters + ---------- + expr : sympy expression or numeric + The expression to be displayed. + minus : bool + If True, wrap when the expression extracts a minus sign. + add : bool + If True, wrap when the expression is a sympy ``Add``. + + Returns + ------- + myst : LaTeX-formatted object (via the myst function) + The LaTeX string, wrapped in parentheses if negative or Add. + + :pxs_trigger: affichage d'une expression dans une formule, parenthèses conditionnelles autour d'un terme, éviter ambiguïté de signe, protéger un coefficient négatif ou une somme dans un produit LaTeX + :pxs_returns: | + chaîne MyST/LaTeX prête à être injectée dans une équation : soit + ``\\left(\\right)`` si l'expression est négative ou un ``Add``, + soit simplement ```` sinon. + :pxs_example: | + from sympy import Symbol + x = Symbol('x') + expr = -x + 1 + txt = pxsl_par(expr, add=True, minus=True) + # injection : a \\cdot {txt} → a \\cdot \\left(-x + 1\\right) + :pxs_antipattern: Concaténer manuellement r"\\left(" + latex(expr) + r"\\right)" sans vérifier si les parenthèses sont vraiment nécessaires. + """ + config_standard = pxs_config() + # Vérifie si l'expression, convertie en chaîne, commence par un signe '-' + # Cela permet d'identifier les expressions négatives (par exemple -x, -3, -sin(x)...) + if minus and add: + if str(expr).startswith('-') or expr.could_extract_minus_sign() or isinstance(expr, Add): + # Si l'expression est négative, on ajoute des parenthèses autour de son affichage LaTeX + # Exemple : -x → \left(-x\right) + # myst() est ici utilisée pour insérer la version LaTeX dynamique de l'expression + return myst( + r"""\left(\py{latex(expr, **config_standard)}\right) """, + globals(), locals() + ) + elif minus: + if str(expr).startswith('-') or expr.could_extract_minus_sign(): + # Si l'expression est négative, on ajoute des parenthèses autour de son affichage LaTeX + # Exemple : -x → \left(-x\right) + # myst() est ici utilisée pour insérer la version LaTeX dynamique de l'expression + return myst( + r"""\left(\py{latex(expr, **config_standard)}\right) """, + globals(), locals() + ) + elif add: + if isinstance(expr, Add): + # Si l'expression est négative, on ajoute des parenthèses autour de son affichage LaTeX + # Exemple : -x → \left(-x\right) + # myst() est ici utilisée pour insérer la version LaTeX dynamique de l'expression + return myst( + r"""\left(\py{latex(expr, **config_standard)}\right) """, + globals(), locals() + ) + # Si l'expression n’est pas négative, on la renvoie simplement en LaTeX sans parenthèses + return myst( + r"""\py{latex(expr, **config_standard)} """, + globals(), locals() + ) + + + + +def _pxsl_choose_udv(sol, u, du, v, dv, var = Symbol('x')): + """ + Generates bilingual LaTeX text (English/French) explaining the choice of + functions u and v' in an integration by parts setup. + + Parameters + ---------- + u : sympy expression + Function chosen as u(x). + du : sympy expression + Derivative of u(x). + v : sympy expression + Function chosen as v(x). + dv : sympy expression + Derivative of v(x), i.e., v'(x). + + Returns + ------- + str + A LaTeX-formatted bilingual text describing u, v', and their derivatives. + + :pxs_trigger: intégration par parties, annonce du choix de u et v', phrase "on choisit u et v' tels que", mise en place initiale d'une IBP quand l'utilisateur donne (u, dv) + :pxs_returns: | + chaîne MyST bilingue (FR/EN) contenant deux blocs ``\\begin{equation*}`` : + l'un pour u(x) et v'(x), l'autre pour u'(x) et v(x). Également stockée + dans ``sol["choice"]`` pour réutilisation ultérieure. + :pxs_example: | + from sympy import Symbol, exp, diff, integrate + x = Symbol('x') + u, dv = x, exp(x) + du, v = diff(u, x), integrate(dv, x) + txt = _pxsl_choose_udv(sol, u, du, v, dv, x) + # injection dans le corrigé : {txt} + :pxs_antipattern: Rédiger à la main les deux blocs ``\\begin{equation*}`` en FR+EN pour annoncer u, u', v, v' à chaque nouvel exercice. + """ + config_standard = pxs_config() + text = "" + + # Introduction : phrase bilingue annonçant le choix de u et v' + text = myst(r""" + \en{with}\fr{On choisit $u$ et $v'$ tels que :} + """, globals(), locals()) + + # Bloc d'équations affichant les expressions de u(x) et v'(x) + text += myst(r""" + \en{\begin{equation*} + &u = \py{latex(u, **config_standard)}& + &\textrm{and}& + &v' = \py{latex(dv, **config_standard)}& + \end{equation*}} + \fr{\begin{equation*} + &u(\py{var}) = \py{latex(u, **config_standard)}& + &\textrm{et}& + &v'(\py{var}) = \py{latex(dv, **config_standard)}.& + \end{equation*}} + """, globals(), locals()) + + # Transition bilingue vers les dérivées et primitives correspondantes + text += myst(r""" + \en{gives}\fr{On en déduit donc} + """) + + # Bloc d'équations affichant u'(x) et v(x) + text += myst(r""" + \en{\begin{equation*} + &u' = \py{latex(du, **config_standard)}& + &\textrm{and}& + &v = \py{latex(v, **config_standard)}.& + \end{equation*}} + \fr{\begin{equation*} + &u'(\py{var}) = \py{latex(du, **config_standard)}& + &\textrm{et}& + &v(\py{var}) = \py{latex(v, **config_standard)}.& + \end{equation*}} + """, globals(), locals()) + + # Retourne le texte LaTeX complet (mélange de phrases et équations) + sol["choice"] = text + return text + +def _pxsl_choose_vdu(sol, u, du, v, dv, var = Symbol('x')): + """ + Generates bilingual LaTeX text (English/French) explaining the choice of + functions u' and v in an integration by parts setup. + + Parameters + ---------- + u : sympy expression + Function corresponding to u(x). + du : sympy expression + Derivative of u(x), i.e., u'(x). + v : sympy expression + Function chosen as v(x). + dv : sympy expression + Derivative of v(x), i.e., v'(x). + + Returns + ------- + str + A LaTeX-formatted bilingual text describing u', v, and their corresponding + functions and derivatives. + + :pxs_trigger: intégration par parties avec choix inversé, annonce "on choisit u' et v tels que", IBP partant d'une dérivée et d'une primitive déjà connues + :pxs_returns: | + chaîne MyST bilingue (FR/EN) avec deux blocs ``\\begin{equation*}`` : + u'(x) et v(x), puis u(x) et v'(x). Également stockée dans ``sol["choice"]``. + :pxs_example: | + from sympy import Symbol, sin, cos, diff, integrate + x = Symbol('x') + du, v = cos(x), x + u, dv = integrate(du, x), diff(v, x) + txt = _pxsl_choose_vdu(sol, u, du, v, dv, x) + :pxs_antipattern: Copier-coller _pxsl_choose_udv et inverser à la main l'ordre des lignes pour obtenir l'annonce "u' et v". + """ + config_standard = pxs_config() + text = "" + + # Introduction : phrase bilingue annonçant le choix de u' et v + text = myst(r""" + \en{with}\fr{On choisit $u'$ et $v$ tels que :} + """, globals(), locals()) + + # Bloc d'équations affichant u'(x) et v(x) + text += myst(r""" + \en{\begin{equation*} + &u' = \py{latex(du, **config_standard)}& + &\textrm{and}& + &v = \py{latex(v, **config_standard)}& + \end{equation*}} + \fr{\begin{equation*} + &u'(\py{var}) = \py{latex(du, **config_standard)}& + &\textrm{et}& + &v(\py{var}) = \py{latex(v, **config_standard)}.& + \end{equation*}} + """, globals(), locals()) + + # Transition bilingue vers les primitives et dérivées correspondantes + text += myst(r""" + \en{gives}\fr{On en déduit donc} + """) + + # Bloc d’équations affichant u(x) et v'(x) + text += myst(r""" + \en{\begin{equation*} + &u = \py{latex(u, **config_standard)}& + &\textrm{and}& + &v' = \py{latex(dv, **config_standard)}.& + \end{equation*} } + \fr{\begin{equation*} + &u(\py{var}) = \py{latex(u, **config_standard)}& + &\textrm{et}& + &v'(\py{var}) = \py{latex(dv, **config_standard)}.& + \end{equation*} } + """, globals(), locals()) + + # Retourne le texte LaTeX complet (phrases bilingues et équations) + sol["choice"] = text + return text + + +def _pxsl_sentence1(sol, u, du, v, dv, type_int, var = Symbol('x'), bl = None, br = None, intf = None): + """ + Generates the bilingual LaTeX formula showing the integration by parts + resolution in the case of an *indefinite integral* (primitive). + + Depending on the type of pair chosen ("udv" or "vdu"), + the function builds the appropriate LaTeX equation for: + ∫ u·v' dx = u·v − ∫ u'·v dx + or + ∫ u'·v dx = u·v − ∫ u·v' dx + + Parameters + ---------- + u : sympy expression + Function u(x). + du : sympy expression + Derivative of u(x). + v : sympy expression + Function v(x). + dv : sympy expression + Derivative of v(x), i.e., v'(x). + type_int : str + Type of integration by parts ("udv" or "vdu"). + + Returns + ------- + str + A LaTeX-formatted bilingual text representing the integration by parts formula. + + :pxs_trigger: première ligne de l'IBP, écriture de l'égalité ∫u·v' = [uv] − ∫u'·v, ouverture du begin{equation*} du calcul + :pxs_returns: | + chaîne LaTeX commençant un ``\\begin{equation*}`` avec le label ``eq`` + et la première égalité de l'IBP ; parenthèses sur u, v, u', v' déjà + gérées via ``pxsl_par``. Également stockée dans ``sol["sentence1"]``. + :pxs_example: | + bl, br, intf = _pxs_bounds(a=None, b=None) + txt = _pxsl_sentence1(sol, u, du, v, dv, "udv", x, bl, br, intf) + :pxs_antipattern: Écrire à la main "\\int u \\cdot v' dx &= uv - \\int u' \\cdot v dx" sans passer par pxsl_par pour les signes et les Add. + """ + + # Cas où l'utilisateur a fourni (u, dv) + # Construction de la formule : ∫u·v' dx = u·v − ∫u'·v dx + if type_int == "udv": + text = myst(r""" + \begin{equation*} + \label{eq} + \py{intf} \py{pxsl_par(u, add = True)}\cdot \py{pxsl_par(dv, add = True, minus = True)} \ d\py{var} + &= \py{bl}\py{pxsl_par(u, add = True)}\cdot \py{pxsl_par(v, add = True, minus = True)}\py{br} - \py{intf} \py{pxsl_par(du, add = True)} \cdot \py{pxsl_par(v, add = True, minus = True)} \ d\py{var}\\ + """, globals(), locals()) + + # Cas où l'utilisateur a fourni (du, v) + # Construction de la formule : ∫u'·v dx = u·v − ∫u·v' dx + if type_int == "vdu": + text = myst(r""" + \begin{equation*} + \label{eq} + \py{intf} \py{pxsl_par(du, add = True)}\cdot \py{pxsl_par(v, add = True, minus = True)} \ d\py{var} + &=\py{bl} \py{pxsl_par(u, add = True)}\cdot \py{pxsl_par(v, add = True, minus = True)} \py{br}- \py{intf} \py{pxsl_par(u, add = true)} \cdot \py{pxsl_par(dv, add = True, minus = True)} \ d\py{var}\\ + """, globals(), locals()) + + # Retourne le texte LaTeX de la résolution par parties + sol["sentence1"] = text + return text + + + + +def _pxsl_sentence2(sol, uv, expr, nb_IBP, a = None, b = None, var = Symbol('x')): + """ + Builds LaTeX fragments for the integration by parts process when handling + sign changes and coefficients, with or without integration bounds. + + Parameters + ---------- + uv : sympy expression + The product term u·v from integration by parts. + expr : sympy expression + The remaining integral expression after applying integration by parts. + nb_IBP : int + The number of successive integrations by parts already applied (used to adjust display). + a, b : numeric or symbolic, optional + Lower and upper bounds of integration. If None, the integral is indefinite. + + Returns + ------- + tuple + text : LaTeX string representing intermediate and simplified steps + of the integration by parts process, with correct sign and coefficient handling. + + :pxs_trigger: deuxième ligne de l'IBP, simplification du terme [uv] et factorisation de l'intégrale résiduelle, gestion du coefficient et du signe devant la seconde intégrale + :pxs_returns: | + tuple ``(text, expr, sol)`` : + - ``text`` : chaîne LaTeX de la ligne "= [uv] ± coef·∫..." + - ``expr`` : expression résiduelle factorisée (coef déjà extrait) + - ``sol`` : dictionnaire enrichi avec ``sol["uv"]``, ``sol["coeff"]``, ``sol["sentence2"]``. + :pxs_example: | + txt, expr_res, sol = _pxsl_sentence2(sol, u*v, v*du, nb_IBP=1, + a=None, b=None, var=x) + :pxs_antipattern: Appeler latex(uv) puis latex(expr) séparément et recoller manuellement le signe sans pxs_separate_factors/pxsl_par. + """ + config_standard = pxs_config() + # Si les bornes a et b ne sont pas données → intégrale indéfinie + bl, br, intf = _pxs_bounds(a, b) + + # Extraction du coefficient numérique de expr (partie indépendante de x) + # puis gestion du signe et de la valeur absolue + expr = factor(-simplify(-expr)) + if a is None and b is None: + text = myst(r""" + &= \py{bl}\py{latex(uv, **config_standard)}\py{br} + """, globals(), locals()) + sol["uv"] = uv + else: + text = myst(r""" + &= \py{latex(uv.subs(var,b), **config_standard)} - \py{pxsl_par(uv.subs(var,a), minus = True, add = True)} + """, globals(), locals()) + sol["uv"] = uv.subs(var,b) - uv.subs(var,a) + text += myst(r""" \py{pxsl_latex_coefficient(-pxs_separate_factors(expr, var)[0], sign = True)}\py{intf} \py{pxsl_par(pxs_separate_factors(expr, var)[1], add = True, minus = True)} \ d\py{var} + """, globals(), locals()) + + sol["coeff"] = pxs_separate_factors(expr, var)[0] + if nb_IBP == 2 and a is not None and b is not None: + if uv.subs(var,a).could_extract_minus_sign(): + text += myst(r""" + \\&=\py{latex(uv.subs(var,b) - uv.subs(var,a), **config_standard)} \py{pxsl_latex_coefficient(pxs_separate_factors(expr, var)[0], sign = True)}\py{intf} \py{pxsl_par(pxs_separate_factors(expr, var)[1], add = True, minus = True)} \ d\py{var} + """, globals(), locals()) + sol["sentence2"] = text + return text, pxs_separate_factors(expr, var)[1], sol + sol["sentence2"] = text + return text, expr, sol + + +def _pxsl_explain(sol, u, du, v, dv, type_int, nb_IBP, a = None, b = None, var = Symbol('x'), bl = None, br = None, intf = None): + """ + Builds a bilingual LaTeX explanation for the integration by parts process, + using either the primitive form or the definite integral form. + + The function combines: + - the core integration by parts equation (via _pxsl_resolution_prim or _pxsl_resolution_int) + - the corresponding minus/plus term adjustments (via _pxsl_minus) + depending on the integration type ("udv" or "vdu") and the presence of bounds. + + Parameters + ---------- + u : sympy expression + Function u(x). + du : sympy expression + Derivative of u(x). + v : sympy expression + Function v(x). + dv : sympy expression + Derivative of v(x), i.e., v'(x). + type_int : str + Integration by parts type: "udv" for (u, dv) or "vdu" for (du, v). + nb_IBP : int + Number of integration by parts steps performed (for formatting purposes). + a, b : numeric or symbolic, optional + Integration bounds. If None, the integral is indefinite. + + Returns + ------- + tuple + text : LaTeX string representing the detailed explanation + and simplified version of the integration by parts process. + + :pxs_trigger: rédaction des deux premières lignes d'une IBP (formule + simplification), orchestration interne de sentence1 + sentence2, prise en compte du type (udv/vdu) et des bornes + :pxs_returns: | + tuple ``(text, expr, sol)`` : + - ``text`` : chaîne LaTeX concaténée (sentence1 suivi de sentence2) + - ``expr`` : intégrale résiduelle simplifiée + - ``sol`` : dictionnaire enrichi par les deux sous-fonctions. + :pxs_example: | + bl, br, intf = _pxs_bounds(a, b) + txt, expr_res, sol = _pxsl_explain(sol, u, du, v, dv, + "udv", nb_IBP=1, + a=a, b=b, var=x, + bl=bl, br=br, intf=intf) + :pxs_antipattern: Appeler _pxsl_sentence1 puis _pxsl_sentence2 à la main dans chaque exercice en gérant soi-même le swap udv/vdu. + """ + + # Étape 1 : construire la résolution pour une primitive + text = _pxsl_sentence1(sol, u, du, v, dv, type_int, var, bl, br, intf) + + # Étape 2 : ajouter les termes avec le bon signe selon le type d’intégration + if type_int == "udv": + txt, expr, sol = _pxsl_sentence2(sol, u*v, v*du, nb_IBP, a = a, b = b, var = var) + text += txt + else: + txt, expr, sol = _pxsl_sentence2(sol, u*v, u*dv, nb_IBP, a = a, b = b, var = var) + text += txt + + # Retourne le texte complet et sa version simplifiée + return text, expr, sol + + + +def _pxsl_conclude(sol, uv, expr, origin_int, var = Symbol('x')): + """ + Generates the final bilingual LaTeX step that concludes the integration by parts + process, displaying the complete evaluated primitive with constant C. + + Parameters + ---------- + uv : sympy expression + The product term u·v obtained during integration by parts. + expr : sympy expression + The remaining part of the expression (integral to be evaluated or simplified). + origin_int : sympy expression + The original integral expression (used for final verification and display). + + Returns + ------- + str + A LaTeX-formatted bilingual text showing the final result of the integration, + including the constant of integration. + + :pxs_trigger: conclusion d'une IBP sur une primitive (pas de bornes), ligne finale "= primitive + C", fermeture du begin{equation*}, ajout de la mention "C constante réelle" + :pxs_returns: | + chaîne LaTeX terminant le calcul : ligne "= [uv] ± coef·primitive + C", + puis ligne "= résultat sympy + C" et fermeture ``\\end{equation*}``. + Également stockée dans ``sol["conclude"]``. + :pxs_example: | + txt = _pxsl_conclude(sol, u*v, v*du, u*dv, var=x) + # Fermeture propre du corrigé IBP indéfini. + :pxs_antipattern: Terminer à la main avec "+ C" et ``integrate(origin_int, var)`` sans gérer le signe du coefficient ni la phrase bilingue "where C is a real constant". + """ + config_standard = pxs_config() + expr = factor(-simplify(-expr)) + # Extraction du coefficient numérique de l’expression (partie indépendante de x) + # Gestion du signe et des cas particuliers (valeurs 1 ou -1 ignorées) + + if pxs_separate_factors(expr, var)[0] < 0 and pxs_separate_factors(expr, var)[0] != -1: + coeff = myst(r"""\py{latex(-pxs_separate_factors(expr, var)[0], **config_standard)} """, globals(), locals()) + dot = myst(r"""\cdot """) + elif pxs_separate_factors(expr, var)[0] > 0 and pxs_separate_factors(expr, var)[0] != 1: + coeff = myst(r"""\py{latex(pxs_separate_factors(expr, var)[0], **config_standard)} """, globals(), locals()) + dot = myst(r"""\cdot """) + else: + coeff = myst(r""" """, globals(), locals()) + + # Si l’expression commence par un signe négatif + if str(expr).startswith('-'): + # Cas où le signe devient "plus" après simplification + + + text = myst(r""" \\&= +\py{latex(uv, **config_standard)} + \py{coeff}\py{dot} \py{pxsl_par(integrate(pxs_separate_factors(expr, var)[1], x), add = True, minus = True)} + C + + """, globals(), locals()) + else: + # Cas standard : on garde le signe négatif devant le terme intégré + text = myst(r""" \\&= +\py{latex(uv, **config_standard)} - \py{coeff}\py{dot}\py{pxsl_par(integrate(pxs_separate_factors(expr, var)[1], var), add = True, minus = True)} + C + + """, globals(), locals()) + + # Ajout de la ligne finale donnant la primitive complète de l’intégrale d’origine + text += myst(r"""\\ + & = \py{latex(integrate(origin_int, var), **config_standard)} + C, \textrm{ \en{with}\fr{où} }C\textrm{ \en{a real constant}\fr{est une constante réelle}}. + \end{equation*} + """, globals(), locals()) + + # Retourne le texte LaTeX complet de conclusion + sol["conclude"] = text + return text + + +def _pxsl_conclude_int(sol, uv, expr, origin_int, a, b, var = Symbol('x')): + """ + Generates the final bilingual LaTeX step concluding the integration by parts + process for a *definite integral* between bounds a and b. + + This function evaluates and formats the expression: + [u·v]_a^b ± coefficient × [∫f(x)dx]_a^b + and shows the fully evaluated result of the original definite integral. + + Parameters + ---------- + uv : sympy expression + The product term u·v obtained during integration by parts. + expr : sympy expression + The remaining part of the expression (integral to be evaluated or simplified). + origin_int : sympy expression + The original integral expression (used for final comparison and display). + a, b : numeric or symbolic + Lower and upper bounds of the definite integral. + + Returns + ------- + str + A LaTeX-formatted bilingual text showing the final step of the definite + integration by parts, with all evaluations and signs displayed correctly. + + :pxs_trigger: conclusion d'une IBP sur une intégrale définie, évaluation ``[uv]_a^b`` et ``[primitive]_a^b``, ligne finale donnant la valeur numérique/symbolique de ∫_a^b + :pxs_returns: | + chaîne LaTeX terminant le ``\\begin{equation*}`` avec les évaluations + aux bornes, le développement du terme ``u(a)v(a)`` (Add géré terme à + terme), puis l'égalité avec ``integrate(origin_int, (var, a, b))``. + Également stockée dans ``sol["conclude"]``. + :pxs_example: | + txt = _pxsl_conclude_int(sol, u*v, v*du, u*dv, a=0, b=1, var=x) + :pxs_antipattern: Écrire à la main ``f(b) - f(a)`` en oubliant de gérer séparément les Add négatifs de ``u(a)v(a)`` et les signes de coef. + """ + config_standard = pxs_config() + bl, br, intf = _pxs_bounds(a, b) + expr = factor(-simplify(-expr)) + # Définit les crochets et notations bilingues pour les bornes d’intégration + + # Extraction du coefficient numérique et gestion du signe + # On ajoute aussi les symboles nécessaires selon la présence d’un coefficient + if pxs_separate_factors(expr, var)[0] < 0 and pxs_separate_factors(expr, var)[0] != -1: + coeff = myst(r"""\py{latex(-pxs_separate_factors(expr, var)[0], **config_standard)} """, globals(), locals()) + dot = myst(r"""\cdot """) + l_par = myst(r"""\left( """) + r_par = myst(r"""\right) """) + elif pxs_separate_factors(expr, var)[0] > 0 and pxs_separate_factors(expr, var)[0] != 1: + coeff = myst(r"""\py{latex(pxs_separate_factors(expr, var)[0], **config_standard)} """, globals(), locals()) + dot = myst(r"""\cdot """) + l_par = myst(r"""\left( """) + r_par = myst(r"""\right) """) + else: + coeff = myst(r""" """, globals(), locals()) + dot = myst(r""" """) + l_par = myst(r""" """) + r_par = myst(r""" """) + text = "" + # Si l’expression commence par un signe négatif + if expr.could_extract_minus_sign(): + # Cas où le signe devient "plus" après simplification + + if isinstance(uv.subs(var,a), Add): + text += myst(r""" + \\&=\py{latex(uv.subs(var,b), **config_standard)}""", globals(), locals()) + for term in uv.subs(var,a).as_ordered_terms(): + if term.could_extract_minus_sign(): + text += myst(r""" + \py{latex(-term, **config_standard)}""", globals(), locals()) + else: + text += myst(r""" - \py{latex(abs(term), **config_standard)}""", globals(), locals()) + text += myst(r""" + \py{coeff} \py{dot}\py{bl}\py{pxsl_par(integrate(pxs_separate_factors(expr, var)[1], var), minus = True)}\py{br} + """, globals(), locals()) + text += myst(r""" + \py{coeff} \py{dot}\py{l_par}\py{latex(integrate(pxs_separate_factors(expr, var)[1], var).subs(var,b), **config_standard)} - \py{pxsl_par(integrate(pxs_separate_factors(expr, var)[1], var).subs(var,a), add = True, minus = True)}\py{r_par} + """, globals(), locals()) + elif uv.subs(var,a).could_extract_minus_sign(): + text += myst(r""" + \\&=\py{latex(uv.subs(var,b), **config_standard)} + \py{latex(-uv.subs(var,a), **config_standard)} + \py{coeff} \py{dot}\py{bl}\py{pxsl_par(integrate(pxs_separate_factors(expr, var)[1], var), minus = True)}\py{br} + """, globals(), locals()) + text += myst(r""" + \\&=\py{latex(uv.subs(var,b), **config_standard)} + \py{latex(-uv.subs(var,a), **config_standard)} + \py{coeff} \py{dot}\py{l_par}\py{pxsl_par(integrate(pxs_separate_factors(expr, var)[1], var).subs(var,b), minus = True, add = True)} - \py{pxsl_par(integrate(pxs_separate_factors(expr, var)[1], var).subs(var,a), minus = True, add = True)}\py{r_par} + """, globals(), locals()) + else: + text += myst(r""" + \\&=\py{latex(uv.subs(var,b) - uv.subs(var,a), **config_standard)} + \py{coeff} \py{dot}\py{bl}\py{pxsl_par(integrate(pxs_separate_factors(expr, var)[1], var), minus = True)}\py{br} + """, globals(), locals()) + text += myst(r""" + \\&=\py{latex(uv.subs(var,b) - uv.subs(var,a), **config_standard)} + \py{coeff} \py{dot}\py{l_par}\py{pxsl_par(integrate(pxs_separate_factors(expr, var)[1], var).subs(var,b))} - \py{pxsl_par(integrate(pxs_separate_factors(expr, var)[1], var).subs(var,a), add = True, minus = True)}\py{r_par} + """, globals(), locals()) + else: + # Cas standard : le signe reste négatif devant la deuxième intégrale + text = myst(r""" \\&= +\py{latex(uv.subs(var,b), **config_standard)} - \py{pxsl_par(uv.subs(var,a), add = True, minus = True)} - \py{coeff}\py{dot}\py{bl}\py{pxsl_par(integrate(pxs_separate_factors(expr, var)[1], var), minus = True)}\py{br}""", globals(), locals()) + if isinstance(uv.subs(var,a), Add): + text += myst(r""" + \\&=\py{latex(uv.subs(var,b), **config_standard)}""", globals(), locals()) + for term in uv.subs(var,a).as_ordered_terms(): + if term.could_extract_minus_sign(): + text += myst(r""" + \py{latex(-term, **config_standard)}""", globals(), locals()) + else: + text += myst(r""" - \py{latex(term, **config_standard)}""", globals(), locals()) + text += myst(r""" - \py{coeff} \py{dot}\left(\py{latex(integrate(pxs_separate_factors(expr, var)[1], var).subs(var,b), **config_standard)} - \py{pxsl_par(integrate(pxs_separate_factors(expr, var)[1], var).subs(var,a), minus = True, add = True)}\right) + """, globals(), locals()) + elif uv.subs(var,a).could_extract_minus_sign(): + text += myst(r""" + \\&=\py{latex(uv.subs(var,b), **config_standard)} + \py{latex(-uv.subs(var,a), **config_standard)} - \py{coeff} \py{dot}\left(\py{latex(integrate(pxs_separate_factors(expr, var)[1], var).subs(var,b), **config_standard)} - \py{pxsl_par(integrate(pxs_separate_factors(expr, var)[1], var).subs(var,a), add = True, minus = True)}\right) + """, globals(), locals()) + else: + text += myst(r""" + \\&=\py{latex(uv.subs(var,b), **config_standard)} - \py{latex(uv.subs(var,a), **config_standard)} - \py{coeff} \py{dot}\left(\py{latex(integrate(pxs_separate_factors(expr, var)[1], var).subs(var,b), **config_standard)} - \py{pxsl_par(integrate(pxs_separate_factors(expr, var)[1], var).subs(var,a), add = True, minus = True)}\right) + """, globals(), locals()) + + # Ligne finale : égalité avec l’intégrale définie complète d’origine + text += myst(r"""\\ + & =\py{latex(integrate(origin_int, (var, a, b)), **config_standard)}. + \end{equation*} + """, globals(), locals()) + + # Retourne le texte LaTeX complet de conclusion (forme avec bornes) + sol["conclude"] = text + return text + + + + +def _pxs_bounds(a, b): + """ + Builds the LaTeX fragments needed to render either an indefinite integral + or a definite integral with bounds ``a`` and ``b``. + + Parameters + ---------- + a, b : numeric or symbolic, optional + Lower and upper bounds of integration. If both are ``None`` the + integral is treated as indefinite. + + Returns + ------- + tuple + ``(bl, br, intf)`` where ``bl`` is the left bracket (or empty), + ``br`` is the right bracket / evaluation bar, and ``intf`` is the + integral symbol with or without bounds. + + :pxs_trigger: génération conjointe des crochets d'évaluation et du symbole intégrale, bascule FR (\\left[...\\right]_a^b) / EN (\\bigg|_a^b), distinction primitive vs intégrale définie + :pxs_returns: | + tuple ``(bl, br, intf)`` : + - ``bl`` : crochet gauche ``\\left[`` en FR, vide en EN, vide si primitive + - ``br`` : crochet droit ``\\right]_a^b`` en FR, ``\\bigg|_a^b`` en EN, vide si primitive + - ``intf`` : ``\\int`` ou ``\\int_a^b`` selon la présence des bornes. + :pxs_example: | + bl, br, intf = _pxs_bounds(0, 1) + # utilisables directement dans \\py{intf} ... \\py{bl} uv \\py{br} + :pxs_antipattern: Tester à la main ``if a is None`` dans chaque fonction et recréer les chaînes ``\\left[`` / ``\\bigg|`` à chaque endroit. + """ + config_standard = pxs_config() + if a is None and b is None: + bl = myst(r""" """) + br = myst(r""" """) + intf = myst(r""" \int """) + else: + bl = myst(r"""\en{}\fr{\left[} """, globals(), locals()) + br = myst(r"""\en{\bigg|_{\py{latex(a, **config_standard)}}^{\py{latex(b, **config_standard)}}}\fr{\right]_{\py{latex(a, **config_standard)}}^{\py{latex(b, **config_standard)}}} """, globals(), locals()) + intf = myst(r""" \int_{\py{latex(a, **config_standard)}}^{\py{latex(b, **config_standard)}} """, globals(), locals()) + return bl, br, intf + + + +def _pxs_explain_IBP(sol, var = Symbol('x'), f1 = None, f2 = None, type_int = None, nb_IBP = 1, a = None, b = None): + """ + Builds a complete bilingual LaTeX correction text for the Integration by Parts (IBP) process, + handling both single and double applications, and supporting definite or indefinite integrals. + + Depending on the number of integrations by parts (`nb_IBP`) and the type (`type_int`), + this function calls all the appropriate sub-functions: + - _pxs_resolve_pairs → identifies (u, du, v, dv) + - _pxsl_choose_udv/vdu → generates the introduction LaTeX text + - _pxsl_explain → generates the main formula + - _pxsl_conclude/_int → final step (primitive or definite) + - recursively calls itself if nb_IBP = 2 + + Parameters + ---------- + x : sympy Symbol + The integration variable. + f1, f2 : sympy expressions + The two parts of the integrand provided by the user. + type_int : str + Integration by parts type: "udv" for (u, dv) or "vdu" for (du, v). + nb_IBP : int, optional + Number of integration by parts steps (1 or 2). Default is 1. + a, b : numeric or symbolic, optional + Lower and upper bounds of integration (if given, produces a definite integral). + + Returns + ------- + str + A LaTeX-formatted bilingual text that fully explains the integration by parts + procedure, with all symbolic steps, coefficients, and final results. + + :pxs_trigger: orchestration complète d'un corrigé IBP interne, IBP simple ou double, intégrale définie ou indéfinie, enchaînement choose → explain → conclude + :pxs_returns: | + tuple ``(text, u, du, v, dv, expr, sol)`` : + - ``text`` : corrigé LaTeX complet (intro + formule + simplification + conclusion éventuelle) + - ``u, du, v, dv`` : les quatre fonctions de l'IBP, prêtes pour une 2e IBP + - ``expr`` : intégrale résiduelle à traiter ensuite + - ``sol`` : dictionnaire enrichi à chaque étape. + :pxs_example: | + text, u, du, v, dv, expr, sol = _pxs_explain_IBP( + sol, var=x, f1=x, f2=exp(x), type_int="udv", nb_IBP=1) + :pxs_antipattern: Réécrire dans chaque exercice l'enchaînement choose_udv → sentence1 → sentence2 → conclude au lieu d'appeler la fonction passerelle. + """ + config_standard = pxs_config() + text = "" + + # --- Définition des éléments de mise en forme selon la présence de bornes --- + bl, br, intf = _pxs_bounds(a, b) + + # === Première intégration par parties === + + # Retrouver u, du, v et dv en fonction des informations de départ + if type_int == "udv": + u, du, v, dv = f1, diff(f1, var), integrate(f2, var), f2 + if type_int == "vdu": + u, du, v, dv = integrate(f2, var), f2, f1, diff(f1, var) + + # Introduction selon le type choisi (u,dv) ou (du,v) + if type_int == "udv": + text += _pxsl_choose_udv(sol, u, du, v, dv, var) + else: + text += _pxsl_choose_vdu(sol, u, du, v, dv, var) + + # Phrase bilingue avant l’application de la formule + text += myst(r""" + \en{So integration by parts gives us:}\fr{En appliquant la formule, on obtient : }""") + + # Construction de la formule selon la présence ou non de bornes + txt, expr, sol = _pxsl_explain(sol, u, du, v, dv, type_int, nb_IBP, var = var, a = a, b = b, bl = bl, br = br, intf = intf) + text += txt + + + # === Conclusion si une seule intégration par parties === + if nb_IBP == 1 and a is None and b is None: + if type_int == "udv": + text += _pxsl_conclude(sol, u*v, v*du, u*dv, var = var) + if type_int == "vdu": + text += _pxsl_conclude(sol, u*v, u*dv, v*du, var = var) + elif nb_IBP == 1: + if type_int == "udv": + text += _pxsl_conclude_int(sol, u*v, v*du, u*dv, a, b, var = var) + if type_int == "vdu": + text += _pxsl_conclude_int(sol, u*v, u*dv, v*du, a, b, var = var) + + # === Cas où une deuxième intégration par parties est demandée (primitive) === + if nb_IBP == 2 : + text += myst(r""" + \end{equation*} + """) + + # Retourne le texte LaTeX complet expliquant les étapes de l’IBP + return text, u, du, v, dv, expr, sol + + +def pxs_explain_IBP(var = Symbol('x'), f1 = None, f2 = None, type_int = "udv", a = None, b = None, nb_IBP = 1, intro = True, conclude = True, link = "https://app.pyxiscience.com/teacher/dashboard/module/7e0b271d-92f9-11f0-a777-0e37881c19a9/chapter/258e5825-9de5-11f0-a5a8-0e37881c19a9#quotient-fini#IBP2"): + """ + Creates a complete bilingual LaTeX explanation for computing an integral + using the Integration by Parts (IBP) method. + + This is the main public-facing function that introduces the IBP concept, + calls the internal recursive explanation generator `_pxs_explain_IBP`, and + concludes by showing the final boxed result of the integral (definite or + indefinite). + + Parameters + ---------- + var : sympy Symbol + The integration variable. + f1, f2 : sympy expressions + The two parts of the integrand (used as u and dv or du and v). + type_int : str, optional + Type of integration by parts ("udv" or "vdu"). Default is "udv". + a, b : numeric or symbolic, optional + Lower and upper bounds of the integral. If None, it’s treated as an + indefinite integral. + nb_IBP : int, optional + Number of integrations by parts to perform (1 or 2). Default is 1. + + Returns + ------- + str + A bilingual LaTeX-formatted text containing the full reasoning, + step-by-step explanation, and final boxed conclusion of the IBP process. + + :pxs_trigger: calcul d'une intégrale par intégration par parties, corrigé automatique d'un ∫ f(x)g(x)dx ou ∫_a^b f(x)g(x)dx, énoncé "calculer cette intégrale en utilisant une IBP", primitive de x·e^x, ln(x), x·cos(x), etc. + :pxs_returns: | + dictionnaire ``sol`` prêt à être injecté dans un corrigé MyST : + - ``sol["text"]`` : corrigé LaTeX bilingue complet (intro + déroulé + conclusion encadrée) + - ``sol["int"]`` : valeur sympy de l'intégrale (définie ou indéfinie) + - ``sol["u"]``, ``sol["du"]``, ``sol["v"]``, ``sol["dv"]`` : fonctions choisies + - ``sol["choice"]``, ``sol["sentence1"]``, ``sol["sentence2"]``, ``sol["conclude"]`` : fragments réutilisables + - ``sol["a"]``, ``sol["b"]``, ``sol["var"]``, ``sol["expr"]`` : contexte de l'exercice. + :pxs_example: | + from sympy import Symbol, exp, ln + x = Symbol('x') + sol = pxs_explain_IBP(var=x, f1=ln(x), f2=1, type_int="udv") + # dans le .md : {sol["text"]} + # ou juste la valeur : \\(\\py{{latex(sol["int"])}}\\) + :pxs_antipattern: Rédiger à la main un corrigé IBP avec ``\\begin{equation*}``, ``\\int``, ``[uv]_a^b``, la conclusion encadrée et la phrase bilingue "C constante réelle". + """ + config_standard = pxs_config() + sol = {} + # Introduction bilingue avec lien interactif vers le chapitre concerné + if intro: + text = myst(r""" +```{fr} +On calcule cette intégrale en réalisant une [{color:blue}`intégration par parties`]({{link}}) : +``` +```{en} +Using [{color:blue}`integration by parts`]({link}) : +``` +""", globals(), locals()) + else: + text = myst(r""" """) + + sol["intro"] = text + # Appel de la fonction principale interne qui rédige les étapes détaillées + txt, u, du, v, dv, expr, sol = _pxs_explain_IBP(sol, var, f1, f2, type_int, nb_IBP, a, b) + text += txt + sol["u"], sol["du"], sol["v"], sol["dv"] = u, du, v, dv + sol["a"], sol["b"], sol["var"] = a, b, var + + + # === Cas (u, dv) sans bornes === + if conclude and type_int == "udv" and a is None and b is None and nb_IBP == 1: + text += myst(r""" + \en{Thus,}\fr{On a donc montré :} + \begin{equation*} + \fbox{$\displaystyle{\int \py{latex(u*dv, **config_standard)} \;d\py{var} = \py{latex(integrate(u*dv, var), **config_standard)}} + C$, \en{with}\fr{où} $C$ \en{a real constant}\fr{est une constante réelle}.} + \end{equation*}""", globals(), locals()) + elif type_int == "udv" and a is None and b is None and nb_IBP == 1: + sol["int"] = integrate(u*dv, var) + + # === Cas (u, dv) avec bornes === + elif conclude and type_int == "udv" and nb_IBP == 1: + text += myst(r""" + \en{Thus,}\fr{On a donc montré :} + \begin{equation*} + \fbox{$\displaystyle{\int_{\py{latex(a, **config_standard)}}^{\py{latex(b, **config_standard)}} \py{latex(u*dv, **config_standard)} \;d\py{var} = \py{latex(integrate(u*dv, (var, a, b)), **config_standard)}}$} + \end{equation*}""", globals(), locals()) + sol["int"] = integrate(u*dv, (var, a, b)) + elif type_int == "udv" and nb_IBP == 1: + sol["int"] = integrate(u*dv, (var, a, b)) + + # === Cas (du, v) sans bornes === + if conclude and type_int == "vdu" and a is None and b is None and nb_IBP == 1: + text += myst(r""" + \en{Thus,}\fr{On a donc montré :} + \begin{equation*} + \fbox{$\displaystyle{\int \py{latex(v*du, **config_standard)} \;d\py{var} = \py{latex(integrate(v*du, var), **config_standard)}} + C$, \en{with}\fr{où} $C$ \en{a real constant}\fr{est une constante réelle}.} + \end{equation*}""", globals(), locals()) + sol["int"] = integrate(v*du, var) + elif type_int == "vdu" and a is None and b is None and nb_IBP == 1: + sol["int"] = integrate(v*du, var) + + # === Cas (du, v) avec bornes === + elif conclude and type_int == "vdu" and nb_IBP == 1: + text += myst(r""" + \en{Thus,}\fr{On a donc montré :} + \begin{equation*} + \fbox{$\displaystyle{\int_{\py{latex(a, **config_standard)}}^{\py{latex(b, **config_standard)}} \py{latex(v*du, **config_standard)}\;d\py{var} = \py{latex(integrate(v*du, (var, a, b)), **config_standard)}}$} + \end{equation*}""", globals(), locals()) + sol["int"] = integrate(v*du, (var, a, b)) + elif type_int == "vdu" and nb_IBP == 1: + sol["int"] = integrate(v*du, (var, a, b)) + + sol["text"] = text + sol["expr"] = expr + # Retourne le texte complet, incluant l’introduction, le déroulé et la conclusion + return sol + +def pxsl_final_sentence(sol, a, b, var, mult, *args): + """ + Recombine the intermediate ``sol`` dictionaries from successive integrations + by parts into a single bilingual LaTeX conclusion, showing how the two + partial results collapse into the final value of the original integral. + + Parameters + ---------- + sol : dict + Master solution dictionary (the final LaTeX is stored in + ``sol["final_sentence"]``). + a, b : numeric or symbolic, optional + Integration bounds. If both are ``None`` a ``+C`` is appended. + var : sympy Symbol + Integration variable. + mult : str + Multiplication symbol used between coefficient and remaining integral. + *args : dict + Successive per-step dictionaries produced by ``pxs_explain_IBP`` + (each contains ``"uv"``, ``"coeff"``, ``"int"``, ``"u"``, ``"dv"``). + + Returns + ------- + str + A LaTeX string (also stored in ``sol["final_sentence"]``) wrapping up + the full multi-IBP computation in a single ``\\begin{equation*}``. + + :pxs_trigger: conclusion d'une double (ou multiple) intégration par parties, recollage des résultats partiels, égalité finale "∫ f(x)g(x)dx = ... + C", synthèse de deux IBP successives + :pxs_returns: | + chaîne LaTeX contenue dans un ``\\begin{equation*}`` qui part de + l'intégrale originale ``∫ u·dv`` et enchaîne les égalités jusqu'à la + valeur finale (avec ``+ C`` si intégrale indéfinie). Également stockée + dans ``sol["final_sentence"]``. + :pxs_example: | + sol1 = pxs_explain_IBP(var=x, f1=x**2, f2=exp(x), + type_int="udv", conclude=False, nb_IBP=2) + sol2 = pxs_explain_IBP(var=x, f1=sol1["expr"], f2=1, + type_int="udv", intro=False, conclude=False) + pxsl_final_sentence(sol, a=None, b=None, var=x, + mult=r"\\cdot", sol1, sol2) + # injection : {sol["final_sentence"]} + :pxs_antipattern: Concaténer à la main ``sol1["uv"] + sol2["int"]`` dans une f-string LaTeX sans gérer les signes, le ``+C`` et la réduction finale via ``integrate``. + """ + config_standard = pxs_config() + bl, br, intf = _pxs_bounds(a, b) + + final_sol = myst(r""" + \begin{equation*} +\py{intf}\py{latex(args[0]["u"]*args[0]["dv"],**config_standard)}\,d\py{var} + """, globals(), locals()) + for i in range(len(args) - 1): + if args[i]["coeff"] == -1 and (args[i+1]["int"].could_extract_minus_sign() or latex(args[i+1]["int"], **config_standard).startswith('-')): + final_sol += myst(r""" + &= \py{latex(args[i]["uv"], **config_standard)}\py{latex(args[i+1]["int"], **config_standard)} + """, globals(), locals()) + if a is None and b is None: + final_sol += myst(r""" +C """, globals(), locals()) + if Add(args[i]["uv"], args[i+1]["int"], evaluate = False) != args[i]["uv"] + args[i+1]["int"]: + final_sol += myst(r""" + \\ &= \py{latex(integrate(args[0]["u"]*args[0]["dv"], (var, a, b)), **config_standard)} + """, globals(), locals()) + if a is None and b is None: + final_sol += myst(r""" +C """, globals(), locals()) + elif args[i]["coeff"] == -1: + final_sol += myst(r""" + &=\py{latex(args[i]["uv"], **config_standard)}+\py{latex(args[i+1]["int"], **config_standard)} + """, globals(), locals()) + if a is None and b is None: + final_sol += myst(r""" + C """, globals(), locals()) + if Add(args[i]["uv"], args[i+1]["int"], evaluate = False) != args[i]["uv"] + args[i+1]["int"]: + final_sol += myst(r""" + \\&= \py{latex(integrate(args[0]["u"]*args[0]["dv"], (var, a, b)), **config_standard)} + """, globals(), locals()) + if a is None and b is None: + final_sol += myst(r""" + C """, globals(), locals()) + else : + mult2 = mult if args[i]["coeff"] != 1 else myst(r""" """) + final_sol += myst(r""" + &= \py{latex(args[i]["uv"], **config_standard)}\py{pxsl_latex_coefficient(-args[i]["coeff"], sign = True)}\py{mult2}\py{pxsl_par(args[i+1]["int"], add = True, minus = True)} + """, globals(), locals()) + if a is None and b is None: + final_sol += myst(r""" + C """, globals(), locals()) + final_sol += myst(r""" +\\&= \py{latex(integrate(args[0]["u"]*args[0]["dv"], (var, a, b)), **config_standard)} + """, globals(), locals()) + if a is None and b is None: + final_sol += myst(r""" + C, \textrm{ \en{with}\fr{où} }C\textrm{ \en{a real constant}\fr{est une constante réelle}}. """, globals(), locals()) + final_sol += myst(r""" \end{equation*}""") + sol["final_sentence"] = final_sol + return final_sol + +def pxsl_partial_decomp( + num: list = [], + den: list = [], + var=Symbol('x'), + mul_symbol=myst(r""" \cdot"""), + method = "simple" +) -> dict: + """ + Construct the symbolic structure of a partial fraction decomposition. + + The function analyzes the denominator factors, generates the appropriate + elementary fractions (including powers for repeated factors), assigns + symbolic coefficients (A, B, C, ...), and builds the associated identity + equation. + + Parameters + ---------- + num : list, optional + List representing the numerator (kept for consistency, not expanded here). + den : list, optional + List of denominator factors (SymPy expressions). + var : Symbol, optional + Main symbolic variable (default is ``Symbol('x')``). + mul_symbol : Any, optional + Multiplication symbol used in LaTeX rendering. + method : str, optional + Decomposition method: "simple" or "advanced" (default is "simple"). + If "simple", one letter by term, if "advanced", linear terms for irreducible quadratics. + + Returns + ------- + dict + A dictionary containing: + - num : list + - den : list + - letters : list of str + - elem_list : list of denominator elements + - var : Symbol + - expr : symbolic sum of elementary fractions + - identity : simplified identity after clearing denominators + + Examples + -------- + Basic usage (distinct linear factors) + >>> from sympy import Symbol + >>> x = Symbol('x') + >>> sol = pxsl_partial_decomp(den=[x, x - 1]) + >>> sol["letters"] + ['A', 'B'] + >>> sol["expr"] + A/x + B/(x - 1) + + Repeated factor (powers are generated automatically) + >>> sol = pxsl_partial_decomp(den=[x, (x - 1)**2]) + >>> sol["letters"] + ['A', 'B', 'C'] + >>> sol["expr"] + A/x + B/(x - 1) + C/(x - 1)**2 + + Mix of repeated and distinct factors + >>> sol = pxsl_partial_decomp(den=[(x + 2)**3, (2*x - 1)]) + >>> sol["letters"] + ['A', 'B', 'C', 'D'] + >>> sol["expr"] + A/(x + 2) + B/(x + 2)**2 + C/(x + 2)**3 + D/(2*x - 1) + + Advanced mode (irreducible quadratic gets a linear numerator) + >>> sol = pxsl_partial_decomp(den=[x**2 + 1], method="advanced") + >>> sol["letters"] + ['A', 'B'] + >>> sol["expr"] + (A*x + B)/(x**2 + 1) + + Advanced mode with a mix (quadratic + repeated linear factor) + >>> sol = pxsl_partial_decomp(den=[x**2 + 1, (x - 3)**2], method="advanced") + >>> sol["letters"] + ['A', 'B', 'C', 'D'] + >>> sol["expr"] + (A*x + B)/(x**2 + 1) + C/(x - 3) + D/(x - 3)**2 + + Identity after clearing denominators (useful to solve for coefficients) + >>> sol = pxsl_partial_decomp(den=[x, x - 1]) + >>> sol["identity"] + A*(x - 1) + B*x + + :pxs_trigger: décomposition en éléments simples, fraction rationnelle P(x)/Q(x), pôles simples et multiples, poser les coefficients A, B, C, trinôme irréductible au dénominateur (méthode "advanced"), identité polynomiale après multiplication + :pxs_returns: | + dictionnaire ``sol`` contenant : + - ``letters`` : liste des coefficients symboliques générés ["A","B",...] + - ``expr`` : somme symbolique ``A/den1 + B/den2 + ...`` (avec + numérateur linéaire ``A*x+B`` pour les quadratiques irréductibles + si ``method="advanced"``) + - ``identity`` : polynôme obtenu en multipliant ``expr`` par le + produit des dénominateurs (utile pour résoudre A, B, C) + - ``num``, ``den``, ``var`` : ré-exposés pour les étapes suivantes. + :pxs_example: | + from sympy import Symbol + x = Symbol('x') + sol = pxsl_partial_decomp(den=[x, (x - 1)**2], method="simple") + # sol["expr"] → A/x + B/(x-1) + C/(x-1)**2 + # sol["identity"] → A*(x-1)**2 + B*x*(x-1) + C*x + :pxs_antipattern: Créer à la main ``Symbol('A')/d1 + Symbol('B')/d2 + ...`` et boucler soi-même sur les puissances et les trinômes irréductibles à chaque exercice. + """ + + sol = { + "num": num, + "den": den, + "letters": [], + "expr": 0, + "var": var, + } + for elem in sol["den"]: + try: + poly = Poly(elem, sol["var"]) + except: + pass + if isinstance(poly, Poly) and poly.degree() >= 2 and not pxs_is_factorable(poly) and method != "simple": + _pxs_add_letter(sol) + _pxs_add_letter(sol) + sol["expr"] += (Symbol(sol["letters"][-2])*sol["var"] + Symbol(sol["letters"][-1])) / poly + elif isinstance(elem, Pow) and elem.args[1] >= 2: + for n in range(1, elem.exp + 1): + _pxs_add_letter(sol) + sol["expr"] += Symbol(sol["letters"][-1]) / elem.base**n + else: + _pxs_add_letter(sol) + sol["expr"] += Symbol(sol["letters"][-1]) / elem + + sol["identity"] = simplify(sol["expr"] * Mul(*sol["den"])) + + return sol + + +def pxsl_decomp_sol(sol: dict, x_0, mul_symbol: str = "", par: bool = False) -> None: + """ + Generate and store the LaTeX representation of the decomposition identity + evaluated at a specific value of the variable. + + The result is stored directly in the solution dictionary under a dynamically + generated key. + + Parameters + ---------- + sol : dict + Dictionary produced by ``pxsl_partial_decomp``. + x_0 : int or float + Value substituted for the main variable. + mul_symbol : str, optional + Multiplication symbol used in LaTeX output. + par : bool, optional + If True, negative values of `x_0` are enclosed in parentheses. + + Returns + ------- + None + The dictionary is modified in place by adding a LaTeX string. + + Examples + -------- + >>> pxsl_decomp_sol(sol, 2) + >>> "expr_2" in sol + True + + :pxs_trigger: évaluation de l'identité de décomposition en éléments simples en un point x_0, méthode des valeurs particulières pour trouver A, B, C, substitution x = pôle ou x = 0/1 dans l'identité polynomiale + :pxs_returns: | + ``None``. Effet de bord : ajoute à ``sol`` une clé ``"expr" + str(x_0)`` + contenant la chaîne LaTeX de ``sol["identity"]`` avec la variable + remplacée par ``x_0`` (entourée de parenthèses si ``x_0 < 0`` et + ``par=True``), prête pour un ``\\py{...}`` dans le corrigé MyST. + :pxs_example: | + sol = pxsl_partial_decomp(den=[x, x - 1]) + pxsl_decomp_sol(sol, 0) + pxsl_decomp_sol(sol, 1) + # dans le .md : {sol["expr0"]} et {sol["expr1"]} + :pxs_antipattern: Faire ``latex(sol["identity"].subs(var, x_0))`` à la main en oubliant de gérer les parenthèses pour les valeurs négatives et de stocker sous la bonne clé "expr". + """ + var_str = ( + myst(r"""\py{str(x_0)}""", globals(), locals()) + if x_0 >= 0 or par is False + else myst(r"""\left(\py{str(x_0)}\right)""", globals(), locals()) + ) + + sol["expr" + str(x_0)] = latex( + sol["identity"], + symbol_names={sol["var"]: var_str}, + mul_symbol=mul_symbol, + ) \ No newline at end of file diff --git a/app/corpus/Mes_fctions_generalistes_bis.py b/app/corpus/Mes_fctions_generalistes_bis.py new file mode 100644 index 0000000000000000000000000000000000000000..d647a28bf25954c400b335e3292829c5bc6fc26b --- /dev/null +++ b/app/corpus/Mes_fctions_generalistes_bis.py @@ -0,0 +1,3020 @@ +"""Mes_fctions_generalistes_bis - Gestion de certaines fonctions de formatage Latex. + Pour voir les tests unitaires s'afficher dans l'éditeur + ---------------------------------------------------------------------- + >>> \\begin{python} + >>> # Code Python : Ecrivez ci-dessous votre code Python + >>> from src.scripts.tests.test_Mes_fctions_generalistes_bis import print_tests_Mes_fctions_generalistes_bis + >>> print_tests_Mes_fctions_generalistes_bis() + >>> \\end{python} + +""" + + + +from sympy import * +from fractions import Fraction +from sympy import latex as sympy_latex +from src.scripts.pxs_runtime import get_pxs_lang, myst +import random as rd + +# Les fonctions à tester + +def pxs_config(mul_symbol: str = "") -> dict: + """ + Build a configuration dictionary for LaTeX rendering, depending on the + current pyxisciences language settings. + + The language is retrieved using `get_pxs_lang()` and affects some formatting + options, such as the decimal separator. + + Parameters + ---------- + mul_symbol : str, optional + Multiplication symbol to be used in LaTeX output (default is ""). + + Returns + ------- + dict + A dictionary containing LaTeX configuration options, including: + - ln_notation : bool + - mul_symbol : str + - order : str + - decimal_separator : str + - inv_trig_style : str + + Examples + -------- + >>> pxs_config() + {'ln_notation': True, 'mul_symbol': '', 'order': 'lex', ...} + + :pxs_trigger: configuration LaTeX standard sympy, virgule/point décimal selon FR/EN via get_pxs_lang(), kwargs à passer à latex(**config), ln_notation, inv_trig_style, choix du mul_symbol, mise en forme homogène dans un exercice PyxiScience + :pxs_returns: | + dictionnaire de kwargs ``{ln_notation, mul_symbol, order, decimal_separator, inv_trig_style}`` + à déballer avec ``**`` dans ``sympy.latex(expr, **config)`` pour obtenir un rendu LaTeX + conforme à la langue courante de l'exercice. + :pxs_example: | + config_standard = pxs_config() + txt = myst(r"\py{latex(expr, **config_standard)}") + :pxs_antipattern: Fixer ``decimal_separator="comma"`` en dur sans tenir compte de la langue, ou appeler ``sympy.latex(expr)`` sans kwargs. + """ + pxs_lang = get_pxs_lang() + if pxs_lang == 'fr': + return { + "ln_notation": True, + "mul_symbol": mul_symbol, + "order": "lex", + "decimal_separator": "comma", + "inv_trig_style": "full", + } + else: + return { + "ln_notation": True, + "mul_symbol": mul_symbol, + "order": "lex", + "decimal_separator": "dot", + "inv_trig_style": "full", + } + + +def pxsl_mult(val, mult=myst(r"""\cdot""")): + """ + Return a multiplication symbol or a blank space depending on the coefficient + value. + + This function is used to avoid displaying an explicit multiplication symbol + when the coefficient is 0, 1, or -1, following standard mathematical + conventions. + + Parameters + ---------- + val : Any + Coefficient value to be tested (typically a numeric or SymPy object). + mult : Any, optional + LaTeX representation (or compatible object) of the multiplication symbol. + Default is ``myst(r"\\cdot")``. + + Returns + ------- + Any + The multiplication symbol if `val` is not equal to 0, 1, or -1; + otherwise, a blank space. + + Examples + -------- + >>> pxsl_mult(3) + \\cdot + + >>> pxsl_mult(1) + + + :pxs_trigger: décider d'afficher ou non un symbole de multiplication devant un coefficient, éviter "1·x" ou "-1·x", omettre · quand coef ∈ {-1, 0, 1}, chaînage propre de coefficients dans un polynôme, affichage mathématique standard + :pxs_returns: | + chaîne MyST : soit le symbole ``\cdot`` (ou le ``mult`` passé en paramètre) + si ``val`` n'est pas dans ``{-1, 0, 1}``, soit un espace vide sinon. + :pxs_example: | + myst(r"\py{pxsl_mult(a)}\py{latex(x)}") # n'affiche pas · si a == 1 + :pxs_antipattern: Insérer un ``\cdot`` en dur devant la variable sans tester les cas ±1 et 0. + """ + if val != 1 and val != -1 and val != 0: + return mult + else: + return myst(r""" """) + + +def pxsl_sign(expr: str): + ''' {py:function} Returns the sign of an expression in LaTeX format. + + This function takes an expression and returns its sign in LaTeX format: + '+' if the expression is positive, '-' if it is negative, + and '' (empty string) if it is zero. + + :param sympy.Expr: The expression whose sign we want to determine. + :return: The sign of the expression in LaTeX format. + :rtype: str + + Examples + -------- + >>> pxsl_sign(5) + '+' + + >>> pxsl_sign(-3) + '-' + + >>> pxsl_sign(0) + '' + + :pxs_trigger: récupérer uniquement le signe "+"/"-"/"" d'une valeur pour l'afficher en LaTeX, préfixer un terme dans un polynôme, chaînage de termes, extraction de signe avant affichage de |valeur| + :pxs_returns: | + chaîne MyST "+" si ``expr > 0``, "-" si ``expr < 0``, "" si ``expr == 0``. + :pxs_example: | + myst(r"\py{pxsl_sign(coef)}\py{latex(abs(coef))}x") + :pxs_antipattern: Tester ``if expr > 0: "+"`` à la main dans chaque exercice. + ''' + if expr > 0: + return myst(r"""+""", globals(), locals()) + elif expr < 0: + return myst(r"""-""", globals(), locals()) + else: + return myst(r"""""" , globals(), locals()) + + +def formater_nombre(nombre): + """ + Formate un nombre en ajoutant des espaces pour les milliers et gère l'infini. + + Cette fonction prend un nombre et le formate pour l'affichage LaTeX en ajoutant + des espaces pour séparer les milliers. Elle gère également les cas spéciaux + de l'infini positif et négatif. + + Parameters + ---------- + nombre : int, float, or sympy.core.numbers.Infinity + Le nombre à formater. Peut être un entier, un flottant, ou l'infini. + + Returns + ------- + str + Le nombre formaté avec des espaces pour les milliers ou la représentation + LaTeX de l'infini. + + Examples + -------- + >>> formater_nombre(1234) + '1\\ 234' + + >>> formater_nombre(1000000) + '1\\ 000\\ 000' + + >>> formater_nombre(oo) + '\\infty' + + >>> formater_nombre(-oo) + '-\\infty' + + :pxs_trigger: afficher un entier en LaTeX avec séparateur de milliers "1\ 000\ 000", gérer oo / -oo, version historique FR uniquement, éviter "1000000" brut ; préférer ``pxsl_format_number`` pour un exercice bilingue + :pxs_returns: | + chaîne MyST : nombre avec séparateurs ``\ `` entre milliers, ou ``\infty`` / ``-\infty`` + pour l'infini. Attention : bug historique qui renvoie ``\inftys`` au lieu de ``\infty`` + pour +oo, corrigé dans ``pxsl_format_number``. + :pxs_example: | + txt = myst(r"\py{formater_nombre(1500000)}") # → "1\ 500\ 000" + :pxs_antipattern: Écrire ``str(n).replace(',', ' ')`` à la main dans chaque exercice, ou oublier le cas ``oo``. + """ + if isinstance(nombre, Integer): + nombre = int(nombre) + if nombre != oo and nombre != -oo: + #return f"{nombre:,}".replace(",", r"\ ") + return myst(f"{nombre:,}".replace(",", r"\ "), globals(), locals()) + elif nombre == oo: + go = myst(r"""\inftys""") + return f"{go}" + else: + go = myst(r"""-\infty""") + return f"{go}" + +def pxsl_formater_nombre(nombre): + """ + Formate un nombre en ajoutant des espaces pour les milliers et gère l'infini. + + Cette fonction prend un nombre et le formate pour l'affichage LaTeX en ajoutant + des espaces pour séparer les milliers. Elle gère également les cas spéciaux + de l'infini positif et négatif. + + Parameters + ---------- + nombre : int, float, or sympy.core.numbers.Infinity + Le nombre à formater. Peut être un entier, un flottant, ou l'infini. + + Returns + ------- + str + Le nombre formaté avec des espaces pour les milliers ou la représentation + LaTeX de l'infini. + + Examples + -------- + >>> pxsl_formater_nombre(1234) + '1\\ 234' + + >>> pxsl_formater_nombre(1000000) + '1\\ 000\\ 000' + + >>> pxsl_formater_nombre(oo) + '\\infty' + + >>> pxsl_formater_nombre(-oo) + '-\\infty' + + :pxs_trigger: idem formater_nombre, variante préfixée pxsl_, séparateur de milliers en espace LaTeX \ et gestion de oo/-oo ; version FR historique à éviter dans les exercices bilingues (préférer pxsl_format_number) + :pxs_returns: | + chaîne MyST, séparateur ``\ `` entre milliers, ou ``\infty`` / ``-\infty``. + Hérite du bug ``\inftys`` pour +oo. + :pxs_example: | + myst(r"\py{pxsl_formater_nombre(1234)}") + :pxs_antipattern: Réinventer le formatage des milliers avec f-string au lieu d'appeler cette fonction. + """ + if isinstance(nombre, Integer): + nombre = int(nombre) + if nombre != oo and nombre != -oo: + #return f"{nombre:,}".replace(",", r"\ ") + return myst(f"{nombre:,}".replace(",", r"\ "), globals(), locals()) + elif nombre == oo: + go = myst(r"""\inftys""") + return f"{go}" + else: + go = myst(r"""-\infty""") + return f"{go}" + +def pxsl_format_number(number): + """ + \en{Formats a number by adding spaces for thousands and handles infinity.} + \fr{Formate un nombre en ajoutant des espaces pour les milliers et gère l'infini.} + + \en{This function takes a number and formats it for LaTeX display by adding + spaces to separate thousands. It also handles the special cases + of positive and negative infinity.} + \fr{Cette fonction prend un nombre et le formate pour l'affichage LaTeX en ajoutant + des espaces pour séparer les milliers. Elle gère également les cas spéciaux + de l'infini positif et négatif.} + + Parameters + ---------- + number : int, float, or sympy.core.numbers.Infinity + \en{The number to format. Can be an integer, a float, or infinity.} + \fr{Le nombre à formater. Peut être un entier, un flottant, ou l'infini.} + + Returns + ------- + str + \en{The number formatted with spaces for thousands or its LaTeX representation of infinity.} + \fr{Le nombre formaté avec des espaces pour les milliers ou la représentation LaTeX de l'infini.} + + Examples + -------- + >>> pxsl_format_number(1234) + '1\\ 234' + + >>> pxsl_format_number(1000000) + '1\\ 000\\ 000' + + >>> pxsl_format_number(oo) + '\\infty' + + >>> pxsl_format_number(-oo) + '-\\infty' + + :pxs_trigger: formatage bilingue FR/EN d'un nombre avec séparateur de milliers, version à préférer dans tout nouvel exercice PyxiScience (QCM/QCL/QAT), renvoie correctement \infty (pas \inftys), gestion de oo et -oo + :pxs_returns: | + chaîne MyST bilingue : séparateurs ``\ `` entre milliers, ``\infty`` ou ``-\infty`` + pour l'infini. Version corrigée et bilingue de ``pxsl_formater_nombre``. + :pxs_example: | + myst(r"\py{pxsl_format_number(10000)}") # → "10\ 000" + :pxs_antipattern: Choisir ``formater_nombre`` ou ``pxsl_formater_nombre`` (FR-only, bug \inftys) dans un exercice bilingue. + """ + if isinstance(number, Integer): + number = int(number) + if number != oo and number != -oo: + #return f"{number:,}".replace(",", r"\ ") + return myst(f"{number:,}".replace(",", r"\ "), globals(), locals()) + elif number == oo: + go = myst(r"""\infty""") + return f"{go}" + else: + go = myst(r"""-\infty""") + return f"{go}" + +def latex_avec_formatage(expr, sign = False, display = True): + """ + Wrapper LaTeX qui applique le formatage des nombres. + + Cette fonction prend une expression et la convertit en format LaTeX en appliquant + un formatage spécial pour les grands nombres (>= 1000). Elle gère les entiers, + flottants, fractions rationnelles et expressions SymPy. + + Parameters + ---------- + expr : int, float, sympy.Rational, or sympy.Expr + L'expression à convertir en LaTeX avec formatage. + + Returns + ------- + str + La représentation LaTeX de l'expression avec formatage appliqué. + + Examples + -------- + >>> latex_avec_formatage(1500) + '1\\ 500' + + >>> latex_avec_formatage(Rational(2000, 3)) + '\\frac{2\\ 000}{3}' + + >>> latex_avec_formatage(Rational(1, 2)) + '\\frac{1}{2}' + + :pxs_trigger: affichage LaTeX d'un grand nombre ou d'une fraction avec séparateurs de milliers + signe préfixé optionnel + \displaystyle, auto-simplification des fractions via gcd, fallback sympy_latex ; version historique FR — préférer ``pxsl_latex_with_formatting`` en bilingue + :pxs_returns: | + chaîne LaTeX : ``\displaystyle\frac{num}{den}`` pour les Rational non entiers, + nombre formaté avec ``\ `` pour les entiers ≥ 1000, préfixe "+" ou "-" si ``sign=True``. + :pxs_example: | + txt = myst(r"\py{latex_avec_formatage(Rational(2000,3), sign=True)}") + :pxs_antipattern: Appeler ``latex(expr)`` directement et laisser "2000/3" sans espaces de milliers, ou dupliquer la logique de simplification gcd. + """ + if display: + disp = '\displaystyle ' + else: + disp = ' ' + if expr == oo or expr == -oo: + return formater_nombre(expr) + if isinstance(expr, (int, Integer, float, Float)) and abs(expr) >= 1000: + if sign is False: + return formater_nombre(expr) + else: + return myst(r""" - \;\py{formater_nombre(abs(expr))} """, globals(), locals()) if expr < 0 else myst(r""" + \;\py{formater_nombre(abs(expr))} """, globals(), locals()) + elif isinstance(expr, Rational): + if expr.q == 1: + num = expr.p + if abs(num) >= 1000: + if sign is False: + return formater_nombre(num) + else: + return myst(r""" - \py{formater_nombre(abs(num))} """, globals(), locals()) if num < 0 else myst(r""" + \py{formater_nombre(abs(num))} """, globals(), locals()) + else: + if sign is False: + return str(num) + else: + return myst(r""" - \py{str(num)} """, globals(), locals()) if num < 0 else myst(r""" + \py{str(num)} """, globals(), locals()) + else: + num = expr.p + den = expr.q + # Pour les fractions, simplifier d'abord puis formater + if abs(num) >= 1000 and abs(den) >= 1000: + from math import gcd + pgcd = gcd(abs(num), abs(den)) + if pgcd > 1: + num_simp = num // pgcd + den_simp = den // pgcd + if abs(num_simp) < 1000 and abs(den_simp) < 1000: + if sign is False: + return myst(r"""\py{disp}\frac{\py{num_simp}}{\py{den_simp}}""", globals(), locals()) if num_simp > 0 else myst(r"""-\py{disp}\frac{\py{abs(num_simp)}}{\py{den_simp}}""", globals(), locals()) + else: + return myst(r"""+ \py{disp}\frac{\py{num_simp}}{\py{den_simp}}""", globals(), locals()) if num_simp > 0 else myst(r"""- \py{disp}\frac{\py{abs(num_simp)}}{\py{den_simp}}""", globals(), locals()) + + # Sinon, formater avec espaces pour les grands nombres + if abs(num) >= 1000: + num_formatted = formater_nombre(abs(num)) + else: + num_formatted = str(abs(num)) + + if abs(den) >= 1000: + den_formatted = formater_nombre(den) + else: + den_formatted = str(den) + + if sign is False: + return myst(r"""\py{disp}\frac{\py{num_formatted}}{\py{den_formatted}}""", globals(), locals()) if num > 0 else myst(r"""-\py{disp}\frac{\py{num_formatted}}{\py{den_formatted}}""", globals(), locals()) + else: + return myst(r"""+\; \py{disp}\frac{\py{num_formatted}}{\py{den_formatted}}""", globals(), locals()) if num > 0 else myst(r"""- \;\py{disp}\frac{\py{num_formatted}}{\py{den_formatted}}""", globals(), locals()) + else: + try: + from sympy import latex as sympy_latex + if sign is False: + return sympy_latex(expr) + else: + if str(expr.startswith('-')): + return myst(r""" \py{disp}sympy_latex(expr)""", globals(), locals()) + else: + return myst(r"""+ \py{disp}\py{sympy_latex(expr)}""", globals(), locals()) + except: + try: + if expr < 0: + return myst(r""" -\; \py{disp} \py{sympy_latex(abs(expr))}""", globals(), locals()) + else: + return myst(r"""+ \;\py{disp} \py{sympy_latex(expr)}""", globals(), locals()) + except: + return str(expr) + +def pxsl_latex_avec_formatage(expr, sign = False, display = True): + """ + Wrapper LaTeX qui applique le formatage des nombres. + + Cette fonction prend une expression et la convertit en format LaTeX en appliquant + un formatage spécial pour les grands nombres (>= 1000). Elle gère les entiers, + flottants, fractions rationnelles et expressions SymPy. + + Parameters + ---------- + expr : int, float, sympy.Rational, or sympy.Expr + L'expression à convertir en LaTeX avec formatage. + + Returns + ------- + str + La représentation LaTeX de l'expression avec formatage appliqué. + + Examples + -------- + >>> pxsl_latex_avec_formatage(1500) + '1\\ 500' + + >>> pxsl_latex_avec_formatage(Rational(2000, 3)) + '\\frac{2\\ 000}{3}' + + >>> pxsl_latex_avec_formatage(Rational(1, 2)) + '\\frac{1}{2}' + + :pxs_trigger: idem latex_avec_formatage, variante préfixée pxsl_, formatage LaTeX d'un nombre ou d'une fraction avec milliers ; version FR historique, ne pas confondre avec ``pxsl_latex_with_formatting`` qui est bilingue + :pxs_returns: | + chaîne LaTeX identique à ``latex_avec_formatage`` : fraction en \displaystyle, + nombre avec séparateurs, signe optionnel préfixé. + :pxs_example: | + pxsl_latex_avec_formatage(Rational(1, 2)) + :pxs_antipattern: Dupliquer cette logique dans chaque exercice au lieu d'importer la fonction. + """ + if display: + disp = '\displaystyle ' + else: + disp = ' ' + if expr == oo or expr == -oo: + return pxsl_formater_nombre(expr) + if isinstance(expr, (int, Integer, float, Float)) and abs(expr) >= 1000: + if sign is False: + return pxsl_formater_nombre(expr) + else: + return myst(r""" - \;\py{pxsl_formater_nombre(abs(expr))} """, globals(), locals()) if expr < 0 else myst(r""" + \;\py{pxsl_formater_nombre(abs(expr))} """, globals(), locals()) + elif isinstance(expr, Rational): + if expr.q == 1: + num = expr.p + if abs(num) >= 1000: + if sign is False: + return pxsl_formater_nombre(num) + else: + return myst(r""" - \py{pxsl_formater_nombre(abs(num))} """, globals(), locals()) if num < 0 else myst(r""" + \py{pxsl_formater_nombre(abs(num))} """, globals(), locals()) + else: + if sign is False: + return str(num) + else: + return myst(r""" - \py{str(num)} """, globals(), locals()) if num < 0 else myst(r""" + \py{str(num)} """, globals(), locals()) + else: + num = expr.p + den = expr.q + # Pour les fractions, simplifier d'abord puis formater + if abs(num) >= 1000 and abs(den) >= 1000: + from math import gcd + pgcd = gcd(abs(num), abs(den)) + if pgcd > 1: + num_simp = num // pgcd + den_simp = den // pgcd + if abs(num_simp) < 1000 and abs(den_simp) < 1000: + if sign is False: + return myst(r"""\py{disp}\frac{\py{num_simp}}{\py{den_simp}}""", globals(), locals()) if num_simp > 0 else myst(r"""-\py{disp}\frac{\py{abs(num_simp)}}{\py{den_simp}}""", globals(), locals()) + else: + return myst(r"""+ \py{disp}\frac{\py{num_simp}}{\py{den_simp}}""", globals(), locals()) if num_simp > 0 else myst(r"""- \py{disp}\frac{\py{abs(num_simp)}}{\py{den_simp}}""", globals(), locals()) + + # Sinon, formater avec espaces pour les grands nombres + if abs(num) >= 1000: + num_formatted = pxsl_formater_nombre(abs(num)) + else: + num_formatted = str(abs(num)) + + if abs(den) >= 1000: + den_formatted = pxsl_formater_nombre(den) + else: + den_formatted = str(den) + + if sign is False: + return myst(r"""\py{disp}\frac{\py{num_formatted}}{\py{den_formatted}}""", globals(), locals()) if num > 0 else myst(r"""-\py{disp}\frac{\py{num_formatted}}{\py{den_formatted}}""", globals(), locals()) + else: + return myst(r"""+\; \py{disp}\frac{\py{num_formatted}}{\py{den_formatted}}""", globals(), locals()) if num > 0 else myst(r"""- \;\py{disp}\frac{\py{num_formatted}}{\py{den_formatted}}""", globals(), locals()) + else: + try: + from sympy import latex as sympy_latex + if sign is False: + return sympy_latex(expr) + else: + if str(expr.startswith('-')): + return myst(r""" \py{disp}sympy_latex(expr)""", globals(), locals()) + else: + return myst(r"""+ \py{disp}\py{sympy_latex(expr)}""", globals(), locals()) + except: + try: + if expr < 0: + return myst(r""" -\; \py{disp} \py{sympy_latex(abs(expr))}""", globals(), locals()) + else: + return myst(r"""+ \;\py{disp} \py{sympy_latex(expr)}""", globals(), locals()) + except: + return str(expr) + +def pxsl_latex_with_formatting(expr, sign=False, display=True): + """ + \en{LaTeX wrapper that applies number formatting.} + \fr{Wrapper LaTeX qui applique le formatage des nombres.} + + \en{This function converts an expression to LaTeX and applies special formatting + for large numbers (>= 1000). It handles integers, floats, rational fractions, + and general SymPy expressions.} + \fr{Cette fonction convertit une expression en LaTeX et applique un formatage + spécial pour les grands nombres (>= 1000). Elle gère les entiers, flottants, + fractions rationnelles et expressions SymPy.} + + Parameters + ---------- + expr : int, float, sympy.Rational, or sympy.Expr + \en{Expression to convert to LaTeX with formatting.} + \fr{Expression à convertir en LaTeX avec formatage.} + sign : bool, optional + \en{If True, explicitly prefixes a sign ('+' or '-'). Default: False.} + \fr{Si True, préfixe explicitement un signe ('+' ou '-'). Par défaut : False.} + display : bool, optional + \en{If True, uses `\displaystyle` for the LaTeX output. Default: True.} + \fr{Si True, utilise `\displaystyle` pour la sortie LaTeX. Par défaut : True.} + + Returns + ------- + str + \en{The LaTeX string with number formatting applied.} + \fr{La chaîne LaTeX avec formatage appliqué.} + + Examples + -------- + >>> pxsl_latex_with_formatting(1500) + '1\\ 500' + >>> pxsl_latex_with_formatting(Rational(2000, 3)) + '\\frac{2\\ 000}{3}' + >>> pxsl_latex_with_formatting(Rational(1, 2)) + '\\frac{1}{2}' + + :pxs_trigger: affichage LaTeX bilingue (FR/EN) d'un nombre, d'une fraction ou d'une expression symbolique avec formatage des milliers, intégration automatique de pxs_config() (virgule/point selon langue) ; version à préférer dans tous les nouveaux exercices PyxiScience + :pxs_returns: | + chaîne LaTeX bilingue : grands entiers avec ``\ ``, Rational en ``\displaystyle\frac``, + expressions sympy rendues via ``sympy_latex(expr, **pxs_config())``, signe ``+``/``-`` + préfixé si ``sign=True``. + :pxs_example: | + myst(r"x = \py{pxsl_latex_with_formatting(Rational(-5,2), sign=True)}") + :pxs_antipattern: Utiliser ``latex_avec_formatage`` (FR-only) dans un exercice exporté en EN, ou oublier d'intégrer la virgule décimale selon la langue. + """ + config_standard = pxs_config() + + if display: + disp = '\displaystyle ' + else: + disp = ' ' + + if expr == oo or expr == -oo: + return pxsl_format_number(expr) + + if isinstance(expr, (int, Integer, float, Float)) and abs(expr) >= 1000: + if sign == False: + return pxsl_format_number(expr) + else: + return myst(r""" - \;\py{pxsl_format_number(abs(expr))} """, globals(), locals()) if expr < 0 else myst(r""" + \;\py{pxsl_format_number(abs(expr))} """, globals(), locals()) + + elif isinstance(expr, Rational): + if expr.q == 1: + num = expr.p + if abs(num) >= 1000: + if sign == False: + return pxsl_format_number(num) + else: + return myst(r""" - \py{pxsl_format_number(abs(num))} """, globals(), locals()) if num < 0 else myst(r""" + \py{pxsl_format_number(abs(num))} """, globals(), locals()) + else: + if sign == False: + return str(num) + else: + return myst(r""" - \py{str(abs(num))} """, globals(), locals()) if num < 0 else myst(r""" + \py{str(num)} """, globals(), locals()) + else: + num = expr.p + den = expr.q + # For fractions, simplify first and then format + if abs(num) >= 1000 and abs(den) >= 1000: + from math import gcd + gcd_val = gcd(abs(num), abs(den)) + if gcd_val > 1: + num_simplified = num // gcd_val + den_simplified = den // gcd_val + if abs(num_simplified) < 1000 and abs(den_simplified) < 1000: + if sign == False: + return myst(r"""\py{disp}\frac{\py{num_simplified}}{\py{den_simplified}}""", globals(), locals()) if num_simplified > 0 else myst(r"""-\py{disp}\frac{\py{abs(num_simplified)}}{\py{den_simplified}}""", globals(), locals()) + else: + return myst(r"""+ \py{disp}\frac{\py{num_simplified}}{\py{den_simplified}}""", globals(), locals()) if num_simplified > 0 else myst(r"""- \py{disp}\frac{\py{abs(num_simplified)}}{\py{den_simplified}}""", globals(), locals()) + + # Otherwise, format with spaces for large numbers + if abs(num) >= 1000: + num_formatted = pxsl_format_number(abs(num)) + else: + num_formatted = str(abs(num)) + + if abs(den) >= 1000: + den_formatted = pxsl_format_number(den) + else: + den_formatted = str(den) + + if sign == False: + return myst(r"""\py{disp}\frac{\py{num_formatted}}{\py{den_formatted}}""", globals(), locals()) if num > 0 else myst(r"""-\py{disp}\frac{\py{num_formatted}}{\py{den_formatted}}""", globals(), locals()) + else: + return myst(r"""+\; \py{disp}\frac{\py{num_formatted}}{\py{den_formatted}}""", globals(), locals()) if num > 0 else myst(r"""- \;\py{disp}\frac{\py{num_formatted}}{\py{den_formatted}}""", globals(), locals()) + + else: + try: + from sympy import latex as sympy_latex + if sign == False: + return sympy_latex(expr) + else: + if str(expr.startswith('-')) or expr.could_extract_minus_sign(): + return myst(r""" \py{disp}sympy_latex(expr, **config_standard)""", globals(), locals()) + else: + return myst(r"""+ \py{disp}\py{sympy_latex(expr, **config_standard)}""", globals(), locals()) + except: + try: + if expr < 0: + return myst(r""" -\; \py{disp} \py{sympy_latex(abs(expr), **config_standard)}""", globals(), locals()) + else: + return myst(r"""+ \;\py{disp} \py{sympy_latex(expr, **config_standard)}""", globals(), locals()) + except: + return myst(r"""\py{sympy_latex(expr, **config_standard)}""", globals(), locals()) + + +def latex_coefficient(coeff, variable = None, sign = False, zeros = True, ones = False, display = True): + """ + Formats a coefficient for LaTeX display. + + This function formats a coefficient for display in a LaTeX polynomial expression. + It handles special cases where the coefficient is 1 or -1 and provides options for + displaying signs, omitting zeros, or showing numerical ones. + + Parameters + ---------- + coeff : int, float, or sympy.Expr + The coefficient to format. + sign : None or '+' + If '+', a '+' sign is displayed before the expression when it is positive. + variable : str or Symbol, optional + Expression or variable attached to the coefficient (can be omitted if zeros=False). + zeros : bool, default True + If False, the coefficient and its variable are not written when the coefficient is zero. + ones : bool, default False + If False, -1 is written as '-' and 1 as an empty string. + If True, both -1 and 1 are kept as numeric values. + display : bool, optional + Whether to produce display-mode LaTeX (used in the examples below). + + Returns + ------- + str + The coefficient formatted for LaTeX. Returns an empty string for coeff=1, + '-' for coeff=-1, and the formatted representation otherwise. + + Examples + -------- + >>> pxsl_latex_coefficient(1) + '' + >>> pxsl_latex_coefficient(-1, ones = True) + '-1' + >>> pxsl_latex_coefficient(-1) + '-' + >>> pxsl_latex_coefficient(5) + '5' + >>> pxsl_latex_coefficient(5, sign = True) + '+5' + >>> pxsl_latex_coefficient(1500) + '1\\ 500' + >>> pxsl_latex_coefficient(0, variable = Symbol('L_1'), zeros = True) + '0L_1' + >>> pxsl_latex_coefficient(0, variable = Symbol('L_1'), zeros = False) + '' + >>> pxsl_latex_coefficient(Rational(-5, 2), sign = '+', display = False) + '-\\frac{5}{2}' + >>> pxsl_latex_coefficient(Rational(-5, 2), sign='+') + '-\\displaystyle \\frac{5}{2}' + + :pxs_trigger: affichage d'un coefficient dans un polynôme avec cas spéciaux 1→"", -1→"-", 0→"" si zeros=False, variable attachée après le coefficient (5x, -y, ...), préfixe "+" pour chaînage, délègue à pxsl_latex_with_formatting pour les autres cas + :pxs_returns: | + chaîne LaTeX du coefficient : vide pour ``1``, ``"-"`` pour ``-1``, nombre formaté sinon ; + avec ``"\;"`` entre signe et valeur si ``sign=True`` ; concatène la variable si fournie. + :pxs_example: | + myst(r"\py{latex_coefficient(5, Symbol('x'))} \py{latex_coefficient(-1, Symbol('y'), sign=True)}") + :pxs_antipattern: Écrire ``f"{coef}x"`` à la main sans gérer les cas ±1 et 0, ou afficher "1x" au lieu de "x". + """ + config_standard = pxs_config() + if zeros == False and coeff == 0: + return myst(r""" """) + elif variable is not None: + return myst( + r"""\py{pxsl_latex_coefficient(coeff, sign=sign, zeros=zeros, ones=ones, display=display)}\py{latex(variable, **config_standard)}""", + globals(), locals() + ) + if coeff == 1: + if sign: + return myst(r""" +\; """) if ones == False else myst(r"""+ \;\py{latex(coeff, **config_standard)} """, globals(), locals()) + else: + return myst(r""" """) if ones == False else myst(r"""\py{latex(coeff, **config_standard)} """, globals(), locals()) + elif coeff == -1: + if sign: + return myst(r""" -\; """) if ones == False else myst(r"""- \;\py{latex(abs(coeff), **config_standard)} """, globals(), locals()) + else: + return myst(r""" - """) if ones == False else myst(r"""\py{latex(coeff, **config_standard)} """, globals(), locals()) + else: + return pxsl_latex_with_formatting(coeff, sign=sign, display=display) + + +def pxsl_latex_coefficient(coeff, variable=None, sign=False, zeros=True, ones=False, display=True): + """ + Formats a coefficient for LaTeX display. + + This function formats a coefficient for display in a LaTeX polynomial expression. + It handles special cases where the coefficient is 1 or -1 and provides options for + displaying signs, omitting zeros, or showing numerical ones. + + Parameters + ---------- + coeff : int, float, or sympy.Expr + The coefficient to format. + sign : None or '+' + If '+', a '+' sign is displayed before the expression when it is positive. + variable : str or Symbol, optional + Expression or variable attached to the coefficient (can be omitted if zeros=False). + zeros : bool, default True + If False, the coefficient and its variable are not written when the coefficient is zero. + ones : bool, default False + If False, -1 is written as '-' and 1 as an empty string. + If True, both -1 and 1 are kept as numeric values. + display : bool, optional + Whether to produce display-mode LaTeX (used in the examples below). + + Returns + ------- + str + The coefficient formatted for LaTeX. Returns an empty string for coeff=1, + '-' for coeff=-1, and the formatted representation otherwise. + + Examples + -------- + >>> pxsl_latex_coefficient(1) + '' + >>> pxsl_latex_coefficient(-1, ones = True) + '-1' + >>> pxsl_latex_coefficient(-1) + '-' + >>> pxsl_latex_coefficient(5) + '5' + >>> pxsl_latex_coefficient(5, sign = True) + '+5' + >>> pxsl_latex_coefficient(1500) + '1\\ 500' + >>> pxsl_latex_coefficient(0, variable = Symbol('L_1'), zeros = True) + '0L_1' + >>> pxsl_latex_coefficient(0, variable = Symbol('L_1'), zeros = False) + '' + >>> pxsl_latex_coefficient(Rational(-5, 2), sign = '+', display = False) + '-\\frac{5}{2}' + >>> pxsl_latex_coefficient(Rational(-5, 2), sign='+') + '-\\displaystyle \\frac{5}{2}' + + :pxs_trigger: version préfixée pxsl_ de latex_coefficient, gestion coefficient + variable dans un polynôme/somme avec cas ±1 et 0, affichage d'un terme a_i·x^i ; version à préférer dans les nouveaux scripts PyxiScience + :pxs_returns: | + identique à ``latex_coefficient`` : chaîne LaTeX du coefficient (vide pour 1, "-" pour -1) + avec variable optionnelle attachée et préfixe de signe optionnel. + :pxs_example: | + pxsl_latex_coefficient(-5, Symbol('x'), sign=True) # "- \;5 x" + :pxs_antipattern: Gérer manuellement les cas particuliers ±1 et 0 à chaque ligne de polynôme. + """ + config_standard = pxs_config() + if zeros == False and coeff == 0: + return myst(r""" """) + elif variable is not None: + return myst( + r"""\py{pxsl_latex_coefficient(coeff, sign=sign, zeros=zeros, ones=ones, display=display)}\py{latex(variable, **config_standard)}""", + globals(), locals() + ) + if coeff == 1: + if sign: + return myst(r""" +\; """) if ones == False else myst(r"""+ \;\py{latex(coeff, **config_standard)} """, globals(), locals()) + else: + return myst(r""" """) if ones == False else myst(r"""\py{latex(coeff, **config_standard)} """, globals(), locals()) + elif coeff == -1: + if sign: + return myst(r""" -\; """) if ones == False else myst(r"""- \;\py{latex(abs(coeff), **config_standard)} """, globals(), locals()) + else: + return myst(r""" - """) if ones == False else myst(r"""\py{latex(coeff, **config_standard)} """, globals(), locals()) + else: + return pxsl_latex_with_formatting(coeff, sign=sign, display=display) + + +def to_rational_or_symbol(value): + """ + Convertit un nombre en Rational SymPy ou garde un symbole SymPy. + + Cette fonction prend une valeur et la convertit en objet Rational de SymPy + si c'est un nombre, ou la garde telle quelle si c'est déjà un symbole SymPy. + Pour les flottants, elle utilise Fraction pour une conversion précise. + + Parameters + ---------- + value : int, float, sympy.Symbol, or sympy.Rational + La valeur à convertir. + + Returns + ------- + sympy.Rational or sympy.Symbol or any + La valeur convertie en Rational si c'est un nombre, ou la valeur + originale si c'est un symbole ou autre type. + + Examples + -------- + >>> from sympy import Symbol + >>> x = Symbol('x') + >>> to_rational_or_symbol(5) + Rational(5, 1) + + >>> to_rational_or_symbol(0.5) + Rational(1, 2) + + >>> to_rational_or_symbol(0.5) + Rational(1, 2) + + >>> to_rational_or_symbol(x) + Symbol('x') + + :pxs_trigger: normaliser une entrée utilisateur (int, float, Rational, Symbol) en Rational sympy exact pour manipulation algébrique, conversion précise des floats via Fraction.from_float(...).limit_denominator(), conservation des symboles ; version historique FR — préférer ``pxsl_to_rational_or_symbol`` + :pxs_returns: | + objet sympy : ``Rational`` si ``value`` est numérique, la ``value`` originale si c'est un + ``Symbol``, sinon identité. Les floats passent par ``Fraction`` pour éviter l'imprécision binaire. + :pxs_example: | + a = to_rational_or_symbol(0.5) # → Rational(1, 2), pas 0.5 binaire + :pxs_antipattern: Appeler ``Rational(0.5)`` directement, ce qui produit une approximation binaire grossière du float. + """ + if isinstance(value, Symbol): + return value + elif isinstance(value, (int, Rational)): + return Rational(value) + elif isinstance(value, float): + frac = Fraction.from_float(value).limit_denominator() + return Rational(frac.numerator, frac.denominator) + else: + return value + +def pxsl_to_rational_or_symbol(value): + r""" + \en{Converts a number to a SymPy Rational or keeps a SymPy symbol as is.} + \fr{Convertit un nombre en Rational SymPy ou garde un symbole SymPy tel quel.} + + \en{This function takes a value and converts it into a SymPy `Rational` + object if it is numeric, or keeps it unchanged if it is already a SymPy `Symbol`. + For floats, it uses Python's `Fraction` class to ensure a precise rational conversion.} + \fr{Cette fonction prend une valeur et la convertit en objet `Rational` de SymPy + si c’est un nombre, ou la garde telle quelle si c’est déjà un symbole SymPy. + Pour les flottants, elle utilise la classe `Fraction` de Python pour une conversion rationnelle précise.} + + Parameters + ---------- + value : int, float, sympy.Symbol, or sympy.Rational + \en{The value to convert.} + \fr{La valeur à convertir.} + + Returns + ------- + sympy.Rational or sympy.Symbol or any + \en{The value converted to `Rational` if numeric, or the original value + if it is a symbol or another type.} + \fr{La valeur convertie en `Rational` si c’est un nombre, ou la valeur + originale si c’est un symbole ou un autre type.} + + Examples + -------- + >>> from sympy import Symbol + >>> x = Symbol('x') + >>> pxsl_to_rational_or_symbol(5) + Rational(5, 1) + + >>> pxsl_to_rational_or_symbol(0.5) + Rational(1, 2) + + >>> pxsl_to_rational_or_symbol(x) + Symbol('x') + + :pxs_trigger: idem to_rational_or_symbol, variante préfixée pxsl_ à utiliser dans les nouveaux scripts, conversion exacte des floats en Rational via Fraction, garder les Symbol intacts, bilingue par convention de nommage + :pxs_returns: | + ``sympy.Rational`` exact si ``value`` est numérique, ou la ``value`` inchangée si elle + est déjà un Symbol ou un autre type non numérique. + :pxs_example: | + pxsl_to_rational_or_symbol(x) # → x si x est un Symbol + pxsl_to_rational_or_symbol(0.25) # → Rational(1, 4) + :pxs_antipattern: Utiliser ``sympify(0.1)`` pour obtenir une fraction exacte (ne le fait pas), ou ``Rational(0.1)`` directement. + """ + if isinstance(value, Symbol): + return value + elif isinstance(value, (int, Rational)): + return Rational(value) + elif isinstance(value, float): + frac = Fraction.from_float(value).limit_denominator() + return Rational(frac.numerator, frac.denominator) + else: + return value + +def resoudre_inequation_generale(a = 1, b = 0, c = 0, variable = "x", inegalite = ">=", domaine = "R", puissance = 1, signe_a = None, detail_signe_a = False): + """ + Solves an inequality of the form ax^p + b ≥ c with detailed step-by-step reasoning in LaTeX. + + This function solves linear (p=1) or quadratic (p=2) inequalities and generates + a complete LaTeX-formatted solution showing all intermediate steps. It handles + symbolic coefficients, different domains (real, integer, natural numbers), and + cases where the sign of coefficient 'a' needs to be analyzed separately. + + Parameters + ---------- + a : int, float, or sympy expression, optional + Coefficient of the variable term. Default is 1. + b : int, float, or sympy expression, optional + Constant term on the left side. Default is 0. + c : int, float, or sympy expression, optional + Constant term on the right side. Default is 0. + variable : str, optional + Name of the variable. Default is "x". + inegalite : str, optional + Inequality symbol: ">=", ">", "<=", or "<". Default is ">=". + domaine : str, optional + Solution domain: "R" (reals), "Z" (integers), or "N" (natural numbers). + Default is "R". + puissance : int, optional + Exponent of the variable: 1 (linear) or 2 (quadratic). Default is 1. + signe_a : str or None, optional + Sign of coefficient 'a' when symbolic: ">" for positive, "<" for negative, + or None to determine automatically. Default is None. + detail_signe_a : bool, optional + Whether to explicitly mention the sign of 'a' when dividing. Default is False. + + Returns + ------- + tuple + A tuple (solution_set, latex_reasoning) where: + - solution_set : sympy set or dict + The solution set. Returns a dict with cases if the sign of 'a' + or the right-hand side is undetermined. + - latex_reasoning : str + Complete LaTeX-formatted step-by-step solution. + + Raises + ------ + ValueError + If inegalite is not in [">=", ">", "<=", "<"]. + If domaine is not in ["R", "Z", "N"]. + If puissance is not 1 or 2. + If signe_a is not None, ">", or "<". + + Examples + -------- + >>> # Simple linear inequality + >>> sol, latex = resoudre_inequation_generale(2, 3, 7, variable="x", inegalite=">=") + >>> print(sol) + [2, oo) + + >>> # Quadratic inequality + >>> sol, latex = resoudre_inequation_generale(1, 0, 4, puissance=2, inegalite="<=") + >>> print(sol) + [-2, 2] + + >>> # Integer domain + >>> sol, latex = resoudre_inequation_generale(3, -1, 5, domaine="Z", inegalite=">") + >>> print(sol) + {3, 4, 5, ...} + + >>> # Symbolic coefficient with sign analysis + >>> from sympy import Symbol + >>> a = Symbol('a') + >>> sol, latex = resoudre_inequation_generale(a, 0, 5, inegalite=">=") + >>> # Returns dict with cases for a>0 and a<0 + + :pxs_trigger: résoudre ET corriger pas-à-pas une inéquation ax^p + b ⋛ c (p ∈ {1, 2}), corrigé LaTeX complet en \begin{align*}, domaine R/Z/N, coefficients symboliques ou numériques, discussion automatique du signe de a quand indéterminé ; version historique FR — préférer ``pxsl_solve_general_inequality`` en bilingue + :pxs_returns: | + tuple ``(ensemble_solution, raisonnement_latex)`` : + - ``ensemble_solution`` : ``sympy.Interval`` / ``Union`` / ``EmptySet`` / ``Intersection``, + ou ``dict`` avec cas ``{"a>0": ..., "a<0": ...}`` / ``{"rhs>0": ..., "rhs<0": ...}`` + si le signe de ``a`` ou de ``c-b`` est indéterminé ; + - ``raisonnement_latex`` : chaîne MyST avec un ``\begin{equation*}\begin{align*}`` + détaillant toutes les étapes jusqu'à l'ensemble solution. + :pxs_example: | + sol, corrige = resoudre_inequation_generale(2, 3, 7, variable="x", inegalite=">=") + # dans le .md : Solutions : \py{latex(sol)}. Raisonnement : \py{corrige} + :pxs_antipattern: Résoudre à la main avec ``sympy.solve`` puis rédiger séparément le raisonnement LaTeX étape par étape (duplication + risques d'incohérence). + """ + + + # ======= FONCTIONS AUXILIAIRES ================= + + def __ceil(x): + return (x if x.is_integer else floor(x) + 1) + + def __simplifier_signes(chaine): + return chaine.replace("+ + ", "+ ").replace("+ -", "-").replace("- + ", "- ").replace("- -", "+") + + def __test_a_frac(a): + if isinstance(a, Rational) and a.q != 1: + return True + if isinstance(a, Mul): + for fac in a.args: + if isinstance(fac, Rational) or (isinstance(fac, Pow) and fac.args[1].is_integer and fac.args[1].is_negative): + return True + return False + + + def __solutions_inequation_generale(a=1, b=0, c=0, inegalite=">=", domaine="R", puissance=1, signe_a=None, signe_quotient = None): + from src.scripts.Mes_fctions.Classes_Extensions import pxs_Interval + + a = sympify(a) + b = sympify(b) + c = sympify(c) + dico_ensembles = {"R" : Reals, "N" : Naturals, "Z" : Integers} + ensemble = dico_ensembles[domaine] + quotient = (c - b) / a + if a == 0: + valeur = b - c + dico_cas_existence = {"<=" : valeur.is_nonpositive, "<" : valeur.is_negative, + ">=" : valeur.is_nonnegative, ">" : valeur.is_positive} + cas_existence = dico_cas_existence[inegalite] + sol = ensemble if cas_existence else EmptySet + elif a.is_negative or signe_a == "<": + dico_inverse = {"<=" : ">=", "<" : ">", ">=" : "<=", ">" : "<"} + symb_inverse = dico_inverse[inegalite] + return __solutions_inequation_generale(-a, -b, -c, inegalite = symb_inverse, domaine = domaine, puissance = puissance, signe_a = ">", signe_quotient = signe_quotient) + elif a.is_nonnegative or signe_a == ">": + + if puissance == 1: + if inegalite == ">=": + sol = pxs_Interval(quotient, oo) + elif inegalite == ">": + sol = pxs_Interval.open(quotient, oo) + elif inegalite == "<=": + sol = pxs_Interval(-oo, quotient) + else: + sol = pxs_Interval.open(-oo, quotient) + + elif puissance == 2: + if quotient.is_negative or signe_quotient == "<": + if inegalite in [">=", ">"]: + sol = ensemble + else: + sol = EmptySet + elif quotient.is_nonnegative or signe_quotient == ">": + racine = simplify(sqrt(quotient)) + if inegalite == ">=": + sol = pxs_Interval(-oo, -racine).union(pxs_Interval(racine, oo)) + elif inegalite == ">": + sol = pxs_Interval.open(-oo, -racine).union(pxs_Interval.open(racine, oo)) + elif inegalite == "<=": + sol = pxs_Interval(-racine, racine) + else: + sol = pxs_Interval.open(-racine, racine) + else: # cas où le signe de quotient est indéterminé + sol_rhs_pos = __solutions_inequation_generale(a, b, c, inegalite = inegalite, domaine = domaine, puissance = puissance, signe_a = signe_a, signe_quotient = ">") + sol_rhs_neg = __solutions_inequation_generale(a, b, c, inegalite = inegalite, domaine = domaine, puissance = puissance, signe_a = signe_a, signe_quotient = "<") + return {"rhs>0" : sol_rhs_pos, "rhs<0" : sol_rhs_neg} + + + else: # cas où le signe de a est indéterminé + sol_pos = __solutions_inequation_generale(a, b, c, inegalite = inegalite, domaine = domaine, puissance = puissance, signe_a = ">", signe_quotient = signe_quotient) + sol_neg = __solutions_inequation_generale(a, b, c, inegalite = inegalite, domaine = domaine, puissance = puissance, signe_a = "<", signe_quotient = signe_quotient) + return {"a>0" : sol_pos, "a<0" : sol_neg} + + if domaine in ["Z", "N"]: + try: + sol = sol.intersect(Integers) if domaine == "Z" else sol.intersect(Naturals0) + except: + sol = Intersection(sol, Integers, evaluate = False) if domaine == "Z" else Intersection(sol, Naturals0, evaluate = False) + + return sol + + # =================================================================== + + if inegalite not in [">=", ">", "<=", "<"]: + raise ValueError("Inégalité non valide") + if domaine not in ["R", "Z", "N"]: + raise ValueError("Domaine non valide") + if puissance not in [1, 2]: + raise ValueError("Puissance non valide (1 ou 2 seulement)") + if signe_a not in [None, ">", "<"]: + raise ValueError("Signe de a non valide. Utilisez None, '>' pour positif, ou '<' pour négatif.") + + # Convertir les paramètres + a = to_rational_or_symbol(a) + b = to_rational_or_symbol(b) + c = to_rational_or_symbol(c) + + # Symbole associé à la variable + var_symb = Symbol(variable) + + ensemble_solution = __solutions_inequation_generale(a, b, c, inegalite, domaine, puissance, signe_a) + + # Si le signe de a n'est pas déterminé, on distingue deux cas : + if not (signe_a in [">", "<"] or sympify(a).is_nonpositive or sympify(a).is_nonnegative): + latex_a = latex_avec_formatage(a) + + raisonnement_latex = myst(r"""On distingue deux cas : +1. Si \py{latex_a} > 0 : +""", locals(), globals()) + raisonnement_latex += resoudre_inequation_generale(a, b, c, variable = variable, inegalite = inegalite, domaine = domaine, puissance = puissance, signe_a = ">")[-1] + raisonnement_latex += myst(r""" +2. Si \py{latex_a} < 0 : +""", locals(), globals()) + raisonnement_latex += resoudre_inequation_generale(a, b, c, variable = variable, inegalite = inegalite, domaine = domaine, puissance = puissance, signe_a = "<")[-1] + + raisonnement_latex = __simplifier_signes(raisonnement_latex) + return ensemble_solution, raisonnement_latex # L'ensemble des solutions est indéterminé + + raisonnement_latex = myst(r"""\begin{equation*}\begin{align*}""", locals(), globals()) + + # symbole d'inégalité initial (et son affichage latex) + symb = inegalite + dico_symboles_latex = {">=": myst(r"""\geq""", locals(), globals()), ">": myst(r""">""", locals(), globals()), "<=": myst(r"""\leq""", locals(), globals()), "<": myst(r"""<""", locals(), globals())} + symb_inegalite = dico_symboles_latex[inegalite] + + # Construire l'inéquation d'origine + # si a est une somme, on met des parenthèses autour + coeff_str = latex_coefficient(a) + coeff_str = myst(r"""\left(\py{coeff_str}\right)""", locals(), globals()) if a.is_Add else coeff_str + latex_c = latex_avec_formatage(c) + + # Expression du côté gauche + if sympify(b).is_zero: + expression = sympy_latex(a * var_symb ** puissance) + expr_gauche = myst(r"""\py{expression}""", locals(), globals()) + elif isinstance(b, (int, Rational)) and b >= 0: + latex_b = latex_avec_formatage(b) + if puissance == 1: + expr_gauche = myst(r"""\py{coeff_str}\py{variable} + \py{latex_b}""", locals(), globals()) if coeff_str else myst(r"""\py{variable} + \py{latex_b}""", locals(), globals()) + else: + expr_gauche = myst(r"""\py{coeff_str}\py{variable}^2 + \py{latex_b}""", locals(), globals()) if coeff_str else myst(r"""\py{variable}^2 + \py{latex_b}""", locals(), globals()) + elif isinstance(b, (int, Rational)) and b < 0: + latex_abs_b = latex_avec_formatage(abs(b)) + if puissance == 1: + expr_gauche = myst(r"""\py{coeff_str}\py{variable} - \py{latex_abs_b}""", locals(), globals()) if coeff_str else myst(r"""\py{variable} - \py{latex_abs_b}""", locals(), globals()) + else: + expr_gauche = myst(r"""\py{coeff_str}\py{variable}^2 - \py{latex_abs_b}""", locals(), globals()) if coeff_str else myst(r"""\py{variable}^2 - \py{latex_abs_b}""", locals(), globals()) + else: + latex_b = latex_avec_formatage(b) + if puissance == 1: + expr_gauche = myst(r"""\py{coeff_str}\py{variable} + \py{latex_b}""", locals(), globals()) if coeff_str else myst(r"""\py{variable} + \py{latex_b}""", locals(), globals()) + else: + expr_gauche = myst(r"""\py{coeff_str}\py{variable}^2 + \py{latex_b}""", locals(), globals()) if coeff_str else myst(r"""\py{variable}^2 + \py{latex_b}""", locals(), globals()) + + + # CAS DÉGÉNÉRÉ: a = 0 + if a == 0: + latex_b_simple = latex_avec_formatage(b) + raisonnement_latex += myst(r"""\py{latex_b_simple} \py{symb_inegalite} \py{latex_c}""", locals(), globals()) + + if ensemble_solution == EmptySet: + raisonnement_latex += myst(r"""\text{ impossible.}""", locals(), globals()) + else: + raisonnement_latex += myst(r"""&\text{ toujours vrai.}""", locals(), globals()) + + raisonnement_latex += myst(r"""\\ \end{align*}\end{equation*}""", locals(), globals()) + + raisonnement_latex = __simplifier_signes(raisonnement_latex) + + return ensemble_solution, raisonnement_latex + + + # Première ligne: l'inéquation d'origine + raisonnement_latex += myst(r""" +\py{expr_gauche} \py{symb_inegalite} \py{latex_c}""", locals(), globals()) + + + valeur_droite = c - b + + # Étape 1: Isoler le terme en x - construire c - b avec gestion des signes + if not sympify(b).is_zero: # si b est nul il n'y a rien à faire à cette étape + latex_b = latex_avec_formatage(b) + latex_c = latex_avec_formatage(c) + if c == 0: + latex_droite_etape1 = latex_avec_formatage(-b) + elif b.is_Add: + latex_droite_etape1 = latex_c + " - " + myst(r"""\left(\py{latex_b}\right)""", locals(), globals()) + else: + latex_droite_etape1 = __simplifier_signes(latex_c + " - " + latex_b) + + if puissance == 1: + terme_variable = myst(r"""\py{coeff_str}\py{variable}""", locals(), globals()) if coeff_str else variable + else: + terme_variable = myst(r"""\py{coeff_str}\py{variable}^2""", locals(), globals()) if coeff_str else myst(r"""\py{variable}^2""", locals(), globals()) + + raisonnement_latex += myst(r""" &\iff \py{terme_variable} \py{symb_inegalite} +\py{latex_droite_etape1} \\""", locals(), globals()) + + # Etape de simplification de c-b + latex_mb = latex_avec_formatage(-b) + expr_equiv = __simplifier_signes(myst(r"""\py{latex_mb} + \py{latex_c}""", locals(), globals())) # si la réecriture est équivalente, on ne la détaille pas + if latex_droite_etape1 != latex_avec_formatage(c - b) and latex_avec_formatage(c - b) != expr_equiv: + latex_droite_etape1 = latex_avec_formatage(c - b) + raisonnement_latex += myst(r""" &\iff \py{terme_variable} \py{symb_inegalite} +\py{latex_droite_etape1}\\""", locals(), globals()) + + + + else: # si b=0 + latex_droite_etape1 = latex_avec_formatage(c) + + # Pour la fraction, utiliser la même logique de gestion des signes + latex_fraction_num = latex_droite_etape1 + latex_a = latex_avec_formatage(a) + + # A partir de là on va diviser par a : le sens des inégalités peut changer + if sympify(a).is_negative or signe_a == "<": + symb = {"<=" : ">=", "<" : ">", ">=" : "<=", ">" : "<"}[inegalite] + symb_inegalite = dico_symboles_latex[symb] + + + # Étape 2: Solution selon le signe de a (avec option signe_a) + + # Désormais le membre de gauche vaudra juste variable ou variable^2 : + if puissance == 1: + terme_variable = myst(r"""\py{variable}""", locals(), globals()) + else: + terme_variable = myst(r"""\py{variable}^2""", locals(), globals()) + + if signe_a is None: + signe_a = ">" if sympify(a).is_positive else "<" + + if detail_signe_a or a.free_symbols: + car_a = myst(r""", \quad \text{car } \py{latex_a} \py{signe_a} 0""", locals(), globals()) + else: + car_a = " " + + if a == 1: + pass + elif a == -1: + symb_inverse = {">=": myst(r"""\leq""", locals(), globals()), ">": myst(r"""<""", locals(), globals()), "<=": myst(r"""\geq""", locals(), globals()), "<": myst(r""">""", locals(), globals())}[inegalite] + latex_membre_droite = latex_avec_formatage(b - c) + raisonnement_latex += myst(r"""&\iff \py{terme_variable} \py{symb_inegalite} \py{latex_membre_droite}\\""", locals(), globals()) + elif c - b == 0: + raisonnement_latex += myst(r"""&\iff \py{terme_variable} \py{symb_inegalite} 0 \py{car_a}\\""", locals(), globals()) + + elif __test_a_frac(a): # S'il est plus pertinent de multiplier par 1/a que de diviser par a + fraction = Mul(c - b, 1 / a, evaluate = False) + latex_fraction = latex_avec_formatage(fraction) + raisonnement_latex += myst(r"""&\iff \py{terme_variable} \py{symb_inegalite} \py{latex_fraction} \py{car_a}\\""", locals(), globals()) + fraction1 = Mul(c - b, 1 / a) + if fraction1 != fraction: + latex_fraction = latex_avec_formatage(fraction1) + raisonnement_latex += myst(r"""&\iff \py{terme_variable} \py{symb_inegalite} \py{latex_fraction} \\""", locals(), globals()) + else: + latex_fraction = myst(r"""\frac{\py{latex_fraction_num}}{\py{latex_a}}""", locals(), globals()) + + latex_rhs = latex_avec_formatage((c - b) / a) + if gcd(c - b, a) != 1 or a.is_noninteger: + raisonnement_latex += myst(r"""&\iff \py{terme_variable} \py{symb_inegalite} \py{latex_fraction} \py{car_a}\\""", locals(), globals()) + raisonnement_latex += myst(r"""&\iff \py{terme_variable} \py{symb_inegalite} \py{latex_rhs} \\""", locals(), globals()) + else: + raisonnement_latex += myst(r"""&\iff \py{terme_variable} \py{symb_inegalite} \py{latex_fraction} \py{car_a} \\""", locals(), globals()) + + + # Gestion domaine entier pour puissance = 1 + valeur_critique = (c - b) / a + if puissance == 1 and domaine in ["Z", "N"]: + if symb in [">=", ">"]: + if symb == ">": + valeurs_entieres_limites = valeur_critique + 1 if valeur_critique.is_integer else __ceil(valeur_critique) + else: + valeurs_entieres_limites = valeur_critique if valeur_critique.is_integer else __ceil(valeur_critique) + else: + if symb == "<": + valeurs_entieres_limites = valeur_critique - 1 if valeur_critique.is_integer else floor(valeur_critique) + else: + valeurs_entieres_limites = floor(valeur_critique) + + if domaine == "Z" or (symb in [">=", ">"] and (not valeurs_entieres_limites.is_negative)): # on inclut le cas n >= C Naturel + + # N'ajouter la phrase explicative que si on change vraiment l'inégalité pour les entiers + if valeur_critique.is_noninteger or symb in ['<', '>'] or not (valeur_critique.is_nonpositive or valeur_critique.is_nonnegative): + + # Ne pas répéter la ligne qui est déjà dans le raisonnement principal + latex_limite = latex_avec_formatage(valeurs_entieres_limites) + if symb == ">": + resultat_final = myst(r"""\py{variable} \geq \py{latex_limite}""", locals(), globals()) + elif symb == "<": + resultat_final = myst(r"""\py{variable} \leq \py{latex_limite}""", locals(), globals()) + else: + symb_latex_final = dico_symboles_latex[symb] + resultat_final = myst(r"""\py{variable} \py{symb_latex_final} \py{latex_limite}""", locals(), globals()) + + raisonnement_latex += myst(r"""&\iff \py{resultat_final} \quad \quad \text{car } \py{variable} \text{ doit appartenir à } \mathbb{\py{domaine}} \\""", locals(), globals()) + # autres cas où domaine = "N" et valeurs_entieres_limites a un signe déterminé : + else: + latex_limite = latex_avec_formatage(valeurs_entieres_limites) + if valeurs_entieres_limites.is_positive and symb in ["<=", "<"]: # cas n <= C (C positif) + resultat_final = myst(r"""0 \leq \py{variable} \leq \py{latex_limite}""", locals(), globals()) + raisonnement_latex += myst(r"""&\iff \py{resultat_final} \quad \quad \text{car } \py{variable} \text{ doit appartenir à } \mathbb{N} \\""", locals(), globals()) + + elif valeurs_entieres_limites == 0 and symb in ["<=", "<"]: + raisonnement_latex += myst(r"""&\iff \py{variable} = 0 \quad \quad \text{car } \py{variable} \text{ doit appartenir à } \mathbb{N} \\""", locals(), globals()) + elif valeurs_entieres_limites.is_negative and symb in [">=", ">"]: # cas n >= -C (C positif) + + raisonnement_latex += myst(r"""&\iff \py{variable} \text{ est un entier naturel quelconque} \\""", locals(), globals()) + + elif valeurs_entieres_limites.is_negative and symb in ["<=", "<"]: # cas n <= -C (C positif) + raisonnement_latex += myst(r"""\quad \quad \text{ impossible car } \py{variable}\in\N \\""", locals(), globals()) + + else: + raisonnement_latex += myst(r"""& \text{(le résultat dépend du signe de } \py{latex_limite}) \\""", locals(), globals()) + + + + if puissance == 2: + quotient = sympify(valeur_droite / a) + latex_quotient = latex_avec_formatage(quotient) + + racine = simplify(sqrt(quotient)) + latex_racine = latex_avec_formatage(racine) + # Remplacer brutalement et impoliment ces maudits "-b+c" par "c-b" : + latex_mb = latex_avec_formatage(-b) + expr_a_remplacer = __simplifier_signes(myst(r"""\py{latex_mb} + \py{latex_c}""", locals(), globals())) + expr_nouvelle = __simplifier_signes(myst(r"""\py{latex_c} - \py{latex_b}""", locals(), globals())) + latex_racine = latex_racine.replace(expr_a_remplacer, expr_nouvelle) + + # Étape 3: Résoudre x² symb quotient + + phrase_cas_entier_a_ajouter = False + + bool_q_neg = quotient.is_nonpositive or (signe_a == "<" and (c-b).is_positive) or (signe_a == ">" and (c-b).is_negative) # quotient négatif + bool_q_pos = quotient.is_nonnegative or (signe_a == ">" and (c-b).is_positive) or (signe_a == "<" and (c-b).is_negative) + + if quotient.is_zero: + if symb == ">=": + raisonnement_latex += myst(r"""&\iff \py{variable} \text{ quelconque} """, locals(), globals()) + raisonnement_latex += myst(r"""\text{ (car on a toujours } \py{variable}^2 \geq 0) \\""", locals(), globals()) + elif symb == ">": + if domaine == "N": + raisonnement_latex += myst(r"""&\iff \py{variable} \in \mathbb{N} \setminus \{0\} \\""", locals(), globals()) + else: + raisonnement_latex += myst(r"""&\iff \py{variable} \neq 0 \\""", locals(), globals()) + + elif symb == "<=": + if domaine == "N": + raisonnement_latex += myst(r"""&\iff \py{variable} = 0 \\""", locals(), globals()) + else: + raisonnement_latex += myst(r"""&\iff \py{variable} = 0 \\""", locals(), globals()) + else: # symb == "<" + raisonnement_latex += myst(r"""& \text{ impossible} \\""", locals(), globals()) + # raisonnement_latex += myst(r"""\text{ (car } \py{variable}^2 \geq 0 {et non} < 0)""", locals(), globals()) + + elif bool_q_neg: + if symb in [">=", ">"]: + raisonnement_latex += myst(r"""&\iff \py{variable} \text{ quelconque } """, locals(), globals()) + raisonnement_latex += myst(r"""\text{ (car on a toujours } \py{variable}^2 \geq 0) \\""", locals(), globals()) + else: + raisonnement_latex += myst(r"""& \text{ impossible car } \py{variable}^2 \geq 0 > \py{latex_quotient} \\""", locals(), globals()) + + + elif bool_q_pos: # quotient > 0 + racine0 = sqrt(quotient, evaluate = False) + latex_racine0 = latex_avec_formatage(racine0) + racine1 = sqrt(quotient) + latex_racine1 = latex_avec_formatage(racine1) + valeurs_critiques = [-racine, racine] + + if symb in [">=", ">"]: + pg, pp = (r"\geq", r"\leq") if symb == ">=" else (">", "<") + + # Si nécessaire, on détaille le calcul de sqrt(quotient) : + if racine1 != racine0: + raisonnement_latex += myst(r"""&\iff \py{variable} \py{pp} -\py{latex_racine0} \text{ ou } \py{variable} \py{pg} \py{latex_racine0} \\""", locals(), globals()) + if racine != racine1: + raisonnement_latex += myst(r"""&\iff \py{variable} \py{pp} -\py{latex_racine1} \text{ ou } \py{variable} \py{pg} \py{latex_racine1} \\""", locals(), globals()) + + raisonnement_latex += myst(r"""&\iff \py{variable} \py{pp} -\py{latex_racine} \text{ ou } \py{variable} \py{pg} \py{latex_racine} \\""", locals(), globals()) + + + + else: # symb in ["<=", "<"] + pp = r"\leq" if symb == "<=" else "<" + + # si nécessaire, on détaille le calcul de sqrt(racine1) + if racine1 != racine0: + raisonnement_latex += myst(r"""&\iff -\py{latex_racine0} \py{pp} \py{variable} \py{pp} \py{latex_racine0} \\""", locals(), globals()) + if racine != racine1: + raisonnement_latex += myst(r"""&\iff -\py{latex_racine1} \py{pp} \py{variable} \py{pp} \py{latex_racine1} \\""", locals(), globals()) + + + raisonnement_latex += myst(r"""&\iff -\py{latex_racine} \py{pp} \py{variable} \py{pp} \py{latex_racine} \\""", locals(), globals()) + + else: # le signe de quotient est indéterminé + if (c-b).is_positive or (c-b).is_negative: + sens_pos, sens_neg = (r"\geq", "<") if (c-b).is_positive else (r"\leq", ">") + cas_pos = myst(r""" \text{ si } \py{latex_a} \py{sens_pos} 0""", locals(), globals()) + cas_neg = myst(r""" \text{ si } \py{latex_a} \py{sens_neg} 0""", locals(), globals()) + elif a.is_positive or a.is_negative or signe_a: + sens_pos, sens_neg = (r"\geq", "<") if (a.is_positive or signe_a == ">") else (r"\leq", ">") + cas_pos = myst(r""" \text{ si } \py{latex_fraction_num} \py{sens_pos} 0""", locals(), globals()) + cas_neg = myst(r""" \text{ si } \py{latex_fraction_num} \py{sens_neg} 0""", locals(), globals()) + else: + cas_pos = myst(r""" \text{ si } \py{latex_quotient} > 0""", locals(), globals()) + cas_neg = myst(r""" \text{ si } \py{latex_quotient} < 0""", locals(), globals()) + + if symb in [">=", ">"]: + pg, pp = (r"\geq", r"\leq") if symb == ">=" else (">", "<") + raisonnement_latex += myst(r"""&\iff \begin{cases} \py{variable} \py{pp} -\py{latex_racine} \text{ ou } \py{variable} \py{pg} \py{latex_racine}, & \py{cas_pos} \\ +\py{variable} \text{ quelconque}, & \py{cas_neg} +\end{cases} \\""", locals(), globals()) + else: # symb in ["<=", "<"] + pp = r"\leq" if symb == "<=" else "<" + raisonnement_latex += myst(r"""&\iff \begin{cases} -\py{latex_racine} \py{pp} \py{variable} \py{pp} \py{latex_racine}, & \py{cas_pos} \\ +\text{ impossible}, & \py{cas_neg} +\end{cases} \\""", locals(), globals()) + + + # Si des détails doivent être donnés dans le cas entier : + # (quel que soit le sens de l'inégalité) + if not bool_q_neg: # à moins qu'on ne soit sûr que q <= 0 + if domaine in ["Z", "N"] and (not racine.is_integer or symb in ["<", ">"]): + phrase_cas_entier_a_ajouter = True + # cas particulier où 0 est la seule solution + if (symb == "<=" and (racine - 1).is_negative) or (symb == "<" and (racine - 1).is_nonpositive): + raisonnement_latex += myst(r"""&\iff \py{variable} = 0""", locals(), globals()) + # tous les autres cas : + else: + # calcul des valeurs extrêmes : + if symb == "<=": + limite_pos = floor(racine) + limite_neg = __ceil(-racine) if domaine == "Z" else 0 + elif symb == ">=": + limite_pos = __ceil(racine) + limite_neg = floor(-racine) + elif symb == "<": + if racine.is_integer: + limite_pos = racine - 1 + limite_neg = - racine + 1 if domaine == "Z" else 0 + else: + limite_pos = floor(racine) + limite_neg = __ceil(-racine) if domaine == "Z" else 0 + else: #symb = ">" + limite_pos = racine + 1 if racine.is_integer else __ceil(racine) + limite_neg = - racine - 1 if racine.is_integer else floor(- racine) + + # affichage de la dernière étape : + latex_limite_pos = latex_avec_formatage(limite_pos) + latex_limite_neg = latex_avec_formatage(limite_neg) + if symb in ["<=", "<"]: + raisonnement_latex += myst(r"""&\iff \py{latex_limite_neg} \leq \py{variable} \leq \py{latex_limite_pos}""", locals(), globals()) + else: # >= ou > + if domaine == "Z": + raisonnement_latex += myst(r"""&\iff \py{variable} \leq \py{latex_limite_neg} \text{ ou } \py{variable} \geq \py{latex_limite_pos}""", locals(), globals()) + else: + raisonnement_latex += myst(r"""&\iff \py{variable} \geq \py{latex_limite_pos}""", locals(), globals()) + naturel_ou_pas = "naturel" if domaine == "N" else " " + raisonnement_latex += myst(r"""& \text{car } \py{variable} \text{ est entier \py{naturel_ou_pas}}\\""", locals(), globals()) +# ============================================================================================================== + + + # Terminer le raisonnement LaTeX + raisonnement_latex = raisonnement_latex[:-2] # on enlève le saut de ligne final + raisonnement_latex += myst(r"""\,. +\end{align*}\end{equation*}""", locals(), globals()) + + raisonnement_latex = __simplifier_signes(raisonnement_latex) + + return ensemble_solution, raisonnement_latex + +def pxsl_solve_general_inequality(a=1, b=0, c=0, variable="x", inequality=">=", domain="R", power=1, sign_a=None, detail_sign_a=False): + r""" + \en{Solves an inequality of the form \(a x^p + b \,\square\, c\) with detailed step-by-step reasoning in LaTeX.} + \fr{Résout une inéquation de la forme \(a x^p + b \,\square\, c\) avec un raisonnement détaillé pas à pas en LaTeX.} + + \en{This function solves linear (\(p=1\)) or quadratic (\(p=2\)) inequalities and generates + a complete LaTeX-formatted solution showing all intermediate steps. It handles + symbolic coefficients, different domains (reals, integers, natural numbers), and + cases where the sign of the coefficient \(a\) must be analyzed.} + \fr{Cette fonction résout des inéquations linéaires (\(p=1\)) ou quadratiques (\(p=2\)) et génère + une solution complète au format LaTeX en détaillant toutes les étapes. Elle gère + des coefficients symboliques, différents domaines (réels, entiers, naturels) et + les cas où le signe du coefficient \(a\) doit être analysé.} + + Parameters + ---------- + a : int, float, or sympy expression, optional + \en{Coefficient of the variable term. Default: 1.} + \fr{Coefficient du terme en variable. Par défaut : 1.} + b : int, float, or sympy expression, optional + \en{Constant term on the left-hand side. Default: 0.} + \fr{Terme constant au membre de gauche. Par défaut : 0.} + c : int, float, or sympy expression, optional + \en{Constant term on the right-hand side. Default: 0.} + \fr{Terme constant au membre de droite. Par défaut : 0.} + variable : str, optional + \en{Name of the variable. Default: "x".} + \fr{Nom de la variable. Par défaut : "x".} + inequality : str, optional + \en{Inequality symbol: ">=", ">", "<=", "<". Default: ">=".} + \fr{Symbole d'inégalité : ">=", ">", "<=", "<". Par défaut : ">=".} + domain : str, optional + \en{Solution domain: "R" (reals), "Z" (integers), "N" (natural numbers). Default: "R".} + \fr{Domaine des solutions : "R" (réels), "Z" (entiers), "N" (naturels). Par défaut : "R".} + power : int, optional + \en{Exponent of the variable: 1 (linear) or 2 (quadratic). Default: 1.} + \fr{Exposant de la variable : 1 (linéaire) ou 2 (quadratique). Par défaut : 1.} + sign_a : str or None, optional + \en{Sign of the coefficient \(a\) when symbolic: ">" for positive, "<" for negative, + or None to determine automatically.} + \fr{Signe du coefficient \(a\) quand il est symbolique : ">" pour positif, "<" pour négatif, + ou None pour déterminer automatiquement.} + detail_sign_a : bool, optional + \en{Whether to explicitly mention the sign of \(a\) when dividing. Default: False.} + \fr{Indique s'il faut expliciter le signe de \(a\) lors d'une division. Par défaut : False.} + + Returns + ------- + tuple + \en{A tuple \((\text{solution\_set}, \text{latex\_reasoning})\) where:} + \fr{Un tuple \((\text{solution\_set}, \text{latex\_reasoning})\) où :} + - solution_set : sympy set or dict + \en{The solution set. Returns a dict with cases if the sign of \(a\) or the RHS is undetermined.} + \fr{L'ensemble des solutions. Retourne un dictionnaire par cas si le signe de \(a\) ou le membre droit est indéterminé.} + - latex_reasoning : str + \en{Complete LaTeX-formatted step-by-step solution.} + \fr{Solution détaillée pas à pas au format LaTeX.} + + Raises + ------ + ValueError + \en{If `inequality` not in [">=", ">", "<=", "<"].} + \fr{Si `inequality` n'est pas dans [">=", ">", "<=", "<"].} + \en{If `domain` not in ["R", "Z", "N"].} + \fr{Si `domain` n'est pas dans ["R", "Z", "N"].} + \en{If `power` not in [1, 2].} + \fr{Si `power` n'est pas dans [1, 2].} + \en{If `sign_a` not in {None, ">", "<"}.} + \fr{Si `sign_a` n'est pas dans {None, ">", "<"}.} + + Examples + -------- + >>> # Simple linear inequality + >>> sol, latex = solve_general_inequality(2, 3, 7, variable="x", inequality=">=") + >>> print(sol) + [2, oo) + + >>> # Quadratic inequality + >>> sol, latex = solve_general_inequality(1, 0, 4, power=2, inequality="<=") + >>> print(sol) + [-2, 2] + + >>> # Integer domain + >>> sol, latex = solve_general_inequality(3, -1, 5, domain="Z", inequality=">") + >>> print(sol) + {3, 4, 5, ...} + + >>> # Symbolic coefficient with sign analysis + >>> from sympy import Symbol + >>> a = Symbol('a') + >>> sol, latex = solve_general_inequality(a, 0, 5, inequality=">=") + >>> # Returns dict with cases for a>0 and a<0 + + :pxs_trigger: idem resoudre_inequation_generale, version bilingue FR/EN avec balises \en{}\fr{} dans le corrigé, gère aussi le cas égalité ``inequality="="`` (équations), paramètres en anglais (variable, inequality, domain, power, sign_a) ; version à préférer dans tous les nouveaux exercices PyxiScience + :pxs_returns: | + tuple ``(solution_set, latex_reasoning)`` bilingue : ``solution_set`` de type sympy + (Interval, EmptySet, Intersection, ou ``dict`` par cas), ``latex_reasoning`` MyST bilingue + avec ``\en{}`` / ``\fr{}`` et ``\begin{align*}`` détaillant chaque étape. + :pxs_example: | + from sympy import Symbol + a = Symbol('a') + sol, corrige = pxsl_solve_general_inequality(a, 0, 5, inequality=">=") + # → sol = {"a>0": ..., "a<0": ...}, corrige = raisonnement bilingue cassé par cas + :pxs_antipattern: Utiliser ``resoudre_inequation_generale`` (FR-only) dans un exercice destiné à NYU Paris, ou dupliquer la logique pour l'équation ``=`` au lieu de passer ``inequality="="``. + """ + + # ======= AUXILIARY FUNCTIONS ================= + + def __ceil(x): + return (x if x.is_integer else floor(x) + 1) + + def __simplify_signs(s): + return s.replace("+ + ", "+ ").replace("+ -", "-").replace("- + ", "- ").replace("- -", "+") + + def __is_a_fraction_like(a_): + if isinstance(a_, Rational) and a_.q != 1: + return True + if isinstance(a_, Mul): + for fac in a_.args: + if isinstance(fac, Rational) or (isinstance(fac, Pow) and fac.args[1].is_integer and fac.args[1].is_negative): + return True + return False + + def __solutions_general_inequality(a=1, b=0, c=0, inequality=">=", domain="R", power=1, sign_a=None, sign_rhs=None): + a = sympify(a) + b = sympify(b) + c = sympify(c) + set_map = {"R": Reals, "N": Naturals, "Z": Integers} + the_set = set_map[domain] + quotient = (c - b) / a + + if a == 0: + value = b - c + existence_cases = {"<=": value.is_nonpositive, "<": value.is_negative, + ">=": value.is_nonnegative, ">": value.is_positive, + "=": value.is_nonzero} + ok = existence_cases[inequality] + sol = the_set if ok else EmptySet + + elif a.is_negative or sign_a == "<": + inv = {"<=": ">=", "<": ">", ">=": "<=", ">": "<", "=" : "="}[inequality] + return __solutions_general_inequality(-a, -b, -c, inequality=inv, domain=domain, power=power, sign_a=">", sign_rhs=sign_rhs) + + elif a.is_nonnegative or sign_a == ">": + + if power == 1: + if inequality == ">=": + sol = pxs_Interval(quotient, oo) + elif inequality == ">": + sol = pxs_Interval.open(quotient, oo) + elif inequality == "<=": + sol = pxs_Interval(-oo, quotient) + elif inequality == "<": + sol = pxs_Interval.open(-oo, quotient) + else: + sol = sympify({quotient}) + + elif power == 2: + if quotient.is_negative or sign_rhs == "<": + if inequality in [">=", ">"]: + sol = the_set + else: + sol = EmptySet + elif quotient.is_nonnegative or sign_rhs == ">": + root = simplify(sqrt(quotient)) + if inequality == ">=": + sol = pxs_Interval(-oo, -root).union(pxs_Interval(root, oo)) + elif inequality == ">": + sol = pxs_Interval.open(-oo, -root).union(pxs_Interval.open(root, oo)) + elif inequality == "<=": + sol = pxs_Interval(-root, root) + elif inequality == "<": + sol = pxs_Interval.open(-root, root) + else: + sol = sympify({-root, root}) + else: # sign of RHS undetermined + sol_rhs_pos = __solutions_general_inequality(a, b, c, inequality=inequality, domain=domain, power=power, sign_a=sign_a, sign_rhs=">") + sol_rhs_neg = __solutions_general_inequality(a, b, c, inequality=inequality, domain=domain, power=power, sign_a=sign_a, sign_rhs="<") + return {"rhs>0": sol_rhs_pos, "rhs<0": sol_rhs_neg} + + else: # sign of a undetermined + sol_pos = __solutions_general_inequality(a, b, c, inequality=inequality, domain=domain, power=power, sign_a=">", sign_rhs=sign_rhs) + sol_neg = __solutions_general_inequality(a, b, c, inequality=inequality, domain=domain, power=power, sign_a="<", sign_rhs=sign_rhs) + return {"a>0": sol_pos, "a<0": sol_neg} + + if domain in ["Z", "N"]: + try: + sol = sol.intersect(Integers) if domain == "Z" else sol.intersect(Naturals0) + except: + sol = Intersection(sol, Integers, evaluate=False) if domain == "Z" else Intersection(sol, Naturals0, evaluate=False) + + return sol + + # =================================================================== + + if inequality not in [">=", ">", "<=", "<", "="]: + raise ValueError("Invalid inequality symbol") + if domain not in ["R", "Z", "N"]: + raise ValueError("Invalid domain") + if power not in [1, 2]: + raise ValueError("Invalid power (must be 1 or 2)") + if sign_a not in [None, ">", "<"]: + raise ValueError("Invalid sign_a. Use None, '>' for positive, or '<' for negative.") + + # Convert parameters + a = pxsl_to_rational_or_symbol(a) + b = pxsl_to_rational_or_symbol(b) + c = pxsl_to_rational_or_symbol(c) + + # Variable symbol + var_symb = Symbol(variable) + + solution_set = __solutions_general_inequality(a, b, c, inequality, domain, power, sign_a) + + # If the sign of a is not determined, split into cases + if inequality != "=" and not (sign_a in [">", "<"] or sympify(a).is_nonpositive or sympify(a).is_nonnegative): + latex_a = pxsl_latex_with_formatting(a) + + latex_reasoning = myst(r"""\en{We distinguish two cases:}\fr{On distingue deux cas :} +1. \en{If}\fr{Si} \py{latex_a} > 0: +""", locals(), globals()) + latex_reasoning += pxsl_solve_general_inequality(a, b, c, variable=variable, inequality=inequality, domain=domain, power=power, sign_a=">")[-1] + latex_reasoning += myst(r""" +2. \en{If}\fr{Si} \py{latex_a} < 0: +""", locals(), globals()) + latex_reasoning += pxsl_solve_general_inequality(a, b, c, variable=variable, inequality=inequality, domain=domain, power=power, sign_a="<")[-1] + + latex_reasoning = __simplify_signs(latex_reasoning) + return solution_set, latex_reasoning # the solution set is case-dependent + + latex_reasoning = myst(r"""\begin{equation*}\begin{align*}""", locals(), globals()) + + # Initial inequality symbol (and LaTeX display) + symb = inequality + symb_latex_map = {">=": myst(r"""\geq""", locals(), globals()), ">": myst(r""">""", locals(), globals()), + "<=": myst(r"""\leq""", locals(), globals()), "<": myst(r"""<""", locals(), globals()), + "=" : myst(r"""=""", locals(), globals())} + latex_ineq_symbol = symb_latex_map[inequality] + + # Build the original inequality + coeff_str = pxsl_latex_coefficient(a) + coeff_str = myst(r"""\left(\py{coeff_str}\right)""", locals(), globals()) if a.is_Add else coeff_str + latex_c = pxsl_latex_with_formatting(c) + + # Left-hand expression + if sympify(b).is_zero: + expression = sympy_latex(a * var_symb ** power) + lhs_expr = myst(r"""\py{expression}""", locals(), globals()) + elif isinstance(b, (int, Rational)) and b >= 0: + latex_b = pxsl_latex_with_formatting(b) + if power == 1: + lhs_expr = myst(r"""\py{coeff_str}\py{variable} + \py{latex_b}""", locals(), globals()) if coeff_str else myst(r"""\py{variable} + \py{latex_b}""", locals(), globals()) + else: + lhs_expr = myst(r"""\py{coeff_str}\py{variable}^2 + \py{latex_b}""", locals(), globals()) if coeff_str else myst(r"""\py{variable}^2 + \py{latex_b}""", locals(), globals()) + elif isinstance(b, (int, Rational)) and b < 0: + latex_abs_b = pxsl_latex_with_formatting(abs(b)) + if power == 1: + lhs_expr = myst(r"""\py{coeff_str}\py{variable} - \py{latex_abs_b}""", locals(), globals()) if coeff_str else myst(r"""\py{variable} - \py{latex_abs_b}""", locals(), globals()) + else: + lhs_expr = myst(r"""\py{coeff_str}\py{variable}^2 - \py{latex_abs_b}""", locals(), globals()) if coeff_str else myst(r"""\py{variable}^2 - \py{latex_abs_b}""", locals(), globals()) + else: + latex_b = pxsl_latex_with_formatting(b) + if power == 1: + lhs_expr = myst(r"""\py{coeff_str}\py{variable} + \py{latex_b}""", locals(), globals()) if coeff_str else myst(r"""\py{variable} + \py{latex_b}""", locals(), globals()) + else: + lhs_expr = myst(r"""\py{coeff_str}\py{variable}^2 + \py{latex_b}""", locals(), globals()) if coeff_str else myst(r"""\py{variable}^2 + \py{latex_b}""", locals(), globals()) + + # Degenerate case: a = 0 + if a == 0: + latex_b_simple = pxsl_latex_with_formatting(b) + latex_reasoning += myst(r"""\py{latex_b_simple} \py{latex_ineq_symbol} \py{latex_c}""", locals(), globals()) + + if solution_set == EmptySet: + latex_reasoning += myst(r"""\text{ impossible.}""", locals(), globals()) + else: + latex_reasoning += myst(r"""&\text{ \en{always true}\fr{toujours vrai}.}""", locals(), globals()) + + latex_reasoning += myst(r"""\\ \end{align*}\end{equation*}""", locals(), globals()) + latex_reasoning = __simplify_signs(latex_reasoning) + return solution_set, latex_reasoning + + # First line: original inequality + latex_reasoning += myst(r""" +\py{lhs_expr} \py{latex_ineq_symbol} \py{latex_c}""", locals(), globals()) + + rhs_value = c - b + + # Step 1: isolate the x-term — construct c - b with sign handling + if not sympify(b).is_zero: + latex_b = pxsl_latex_with_formatting(b) + latex_c = pxsl_latex_with_formatting(c) + if c == 0: + latex_rhs_step1 = pxsl_latex_with_formatting(-b) + elif b.is_Add: + latex_rhs_step1 = latex_c + " - " + myst(r"""\left(\py{latex_b}\right)""", locals(), globals()) + else: + latex_rhs_step1 = __simplify_signs(latex_c + " - " + latex_b) + + if power == 1: + var_term = myst(r"""\py{coeff_str}\py{variable}""", locals(), globals()) if coeff_str else variable + else: + var_term = myst(r"""\py{coeff_str}\py{variable}^2""", locals(), globals()) if coeff_str else myst(r"""\py{variable}^2""", locals(), globals()) + + latex_reasoning += myst(r""" &\iff \py{var_term} \py{latex_ineq_symbol} +\py{latex_rhs_step1} \\""", locals(), globals()) + + # Simplify c-b + latex_mb = pxsl_latex_with_formatting(-b) + expr_equiv = __simplify_signs(myst(r"""\py{latex_mb} + \py{latex_c}""", locals(), globals())) + if latex_rhs_step1 != pxsl_latex_with_formatting(c - b) and pxsl_latex_with_formatting(c - b) != expr_equiv: + latex_rhs_step1 = pxsl_latex_with_formatting(c - b) + latex_reasoning += myst(r""" &\iff \py{var_term} \py{latex_ineq_symbol} +\py{latex_rhs_step1}\\""", locals(), globals()) + else: + latex_rhs_step1 = pxsl_latex_with_formatting(c) + + # Fraction handling and sign of a + latex_fraction_num = latex_rhs_step1 + latex_a = pxsl_latex_with_formatting(a) + + # From here we divide by a: inequality direction may change + if sympify(a).is_negative or sign_a == "<": + symb = {"<=": ">=", "<": ">", ">=": "<=", ">": "<", "=" : "="}[inequality] + latex_ineq_symbol = symb_latex_map[symb] + + # Step 2: solution depending on the sign of a (with option sign_a) + + # Left side becomes variable or variable^2: + var_term_simple = myst(r"""\py{variable}""", locals(), globals()) if power == 1 else myst(r"""\py{variable}^2""", locals(), globals()) + + if inequality != "=" and sign_a is None: + sign_a = ">" if sympify(a).is_positive else "<" + + if inequality != "=" and (detail_sign_a or a.free_symbols): + because_a = myst(r""", \quad \text{since } \py{latex_a} \py{sign_a} 0""", locals(), globals()) + else: + because_a = " " + + if a == 1: + pass + elif a == -1: + inv = {">=": myst(r"""\leq""", locals(), globals()), ">": myst(r"""<""", locals(), globals()), + "<=": myst(r"""\geq""", locals(), globals()), "<": myst(r""">""", locals(), globals()), + "=" : myst(r"""=""", locals(), globals())}[inequality] + latex_rhs_only = pxsl_latex_with_formatting(b - c) + latex_reasoning += myst(r"""&\iff \py{var_term_simple} \py{latex_ineq_symbol} \py{latex_rhs_only}\\""", locals(), globals()) + elif c - b == 0: + latex_reasoning += myst(r"""&\iff \py{var_term_simple} \py{latex_ineq_symbol} 0 \py{because_a}\\""", locals(), globals()) + + elif __is_a_fraction_like(a): # multiplying by 1/a may be clearer than dividing by a + fraction = Mul(c - b, 1 / a, evaluate=False) + latex_fraction = pxsl_latex_with_formatting(fraction) + latex_reasoning += myst(r"""&\iff \py{var_term_simple} \py{latex_ineq_symbol} \py{latex_fraction} \py{because_a}\\""", locals(), globals()) + fraction1 = Mul(c - b, 1 / a) + if fraction1 != fraction: + latex_fraction = pxsl_latex_with_formatting(fraction1) + latex_reasoning += myst(r"""&\iff \py{var_term_simple} \py{latex_ineq_symbol} \py{latex_fraction} \\""", locals(), globals()) + else: + latex_fraction = myst(r"""\frac{\py{latex_fraction_num}}{\py{latex_a}}""", locals(), globals()) + if gcd(c - b, a) != 1 or a.is_noninteger: + latex_reasoning += myst(r"""&\iff \py{var_term_simple} \py{latex_ineq_symbol} \py{latex_fraction} \py{because_a}\\""", locals(), globals()) + latex_fraction = pxsl_latex_with_formatting((c - b) / a) + latex_reasoning += myst(r"""&\iff \py{var_term_simple} \py{latex_ineq_symbol} \py{latex_fraction} \\""", locals(), globals()) + else: + latex_reasoning += myst(r"""&\iff \py{var_term_simple} \py{latex_ineq_symbol} \py{latex_fraction} \py{because_a} \\""", locals(), globals()) + + # Integer/Natural domain when power = 1 + critical_value = (c - b) / a + if power == 1 and domain in ["Z", "N"]: + if inequality == "=": # equation case + if critical_value.is_noninteger or (domain == "N" and critical_value.is_negative): # on sait que la solution n'est pas dans le domaine + latex_reasoning = latex_reasoning[:-2] # on enlève le saut de ligne final + latex_reasoning += myst(r""" + \end{align*}\end{equation*}""", locals(), globals()) + latex_reasoning += myst(r""" +Or $\py{variable}$ doit appartenir à l'ensemble $\mathbb{\py{domain}}\,$, donc l'équation ne possède aucune solution.""", locals(), globals()) + latex_reasoning = __simplify_signs(latex_reasoning) + return solution_set, latex_reasoning + + elif (not critical_value.is_integer) or (domain == "N" and not critical_value.is_nonnegative):# solution might not be in domain + positive_or_not = " positif" if domain == "N" else "" + latex_reasoning = latex_reasoning[:-2] + latex_reasoning += myst(r""" + \end{align*}\end{equation*}""", locals(), globals()) + latex_reasoning += myst(r""" +Ainsi l'équation admet pour unique solution $\ds \py{latex_fraction}$ si $\ds \py{latex_fraction}$ est un entier\py{positive_or_not}, et n'admet aucune solution sinon.""", locals(), globals()) + latex_reasoning = __simplify_signs(latex_reasoning) + return solution_set, latex_reasoning + + else: # inequation case + if symb in [">=", ">"]: + if symb == ">": + int_bounds = critical_value + 1 if critical_value.is_integer else __ceil(critical_value) + else: + int_bounds = critical_value if critical_value.is_integer else __ceil(critical_value) + else: + if symb == "<": + int_bounds = critical_value - 1 if critical_value.is_integer else floor(critical_value) + else: + int_bounds = floor(critical_value) + + if domain == "Z" or (symb in [">=", ">"] and (not int_bounds.is_negative)): + if critical_value.is_noninteger or symb in ['<', '>'] or not (critical_value.is_nonpositive or critical_value.is_nonnegative): + latex_bound = pxsl_latex_with_formatting(int_bounds) + if symb == ">": + final_result = myst(r"""\py{variable} \geq \py{latex_bound}""", locals(), globals()) + elif symb == "<": + final_result = myst(r"""\py{variable} \leq \py{latex_bound}""", locals(), globals()) + else: + latex_final_symbol = symb_latex_map[symb] + final_result = myst(r"""\py{variable} \py{latex_final_symbol} \py{latex_bound}""", locals(), globals()) + + latex_reasoning += myst(r"""&\iff \py{final_result} \quad \quad \text{\en{since}\fr{car} } \py{variable} \text{ \en{must lie in}\fr{doit appartenir à} } \mathbb{\py{domain}} \\""", locals(), globals()) + else: + latex_bound = pxsl_latex_with_formatting(int_bounds) + if int_bounds.is_positive and symb in ["<=", "<"]: + final_result = myst(r"""0 \leq \py{variable} \leq \py{latex_bound}""", locals(), globals()) + latex_reasoning += myst(r"""&\iff \py{final_result} \quad \quad \text{\en{since}\fr{car} } \py{variable} \text{ \en{must lie in}\fr{doit appartenir à} } \mathbb{N} \\""", locals(), globals()) + elif int_bounds == 0 and symb in ["<=", "<"]: + latex_reasoning += myst(r"""&\iff \py{variable} = 0 \quad \quad \text{\en{since}\fr{car} } \py{variable} \text{ \en{must lie in}\fr{doit appartenir à} } \mathbb{N} \\""", locals(), globals()) + elif int_bounds.is_negative and symb in [">=", ">"]: + latex_reasoning += myst(r"""&\iff \py{variable} \text{ \en{is any natural integer}\fr{est un entier naturel quelconque}} \\""", locals(), globals()) + elif int_bounds.is_negative and symb in ["<=", "<"]: + latex_reasoning += myst(r"""\quad \quad \text{ impossible \en{since}\fr{car} } \py{variable}\in\mathbb{N} \\""", locals(), globals()) + else: + latex_reasoning += myst(r"""& \text{(\en{the result depends on the sign of}\fr{le résultat dépend du signe de} } \py{latex_bound}) \\""", locals(), globals()) + + # Quadratic case + if power == 2: + quotient = sympify(rhs_value / a) + latex_quotient = pxsl_latex_with_formatting(quotient) + + root = simplify(sqrt(quotient)) + latex_root = pxsl_latex_with_formatting(root) + + # Replace "-b + c" by "c - b" inside latex_root when needed: + latex_mb = pxsl_latex_with_formatting(-b) + expr_to_replace = __simplify_signs(myst(r"""\py{latex_mb} + \py{latex_c}""", locals(), globals())) + new_expr = __simplify_signs(myst(r"""\py{latex_c} - \py{latex_b}""", locals(), globals())) + latex_root = latex_root.replace(expr_to_replace, new_expr) + + # Step 3: solve x^2 (symb) quotient + phrase_integer_case = False + + q_neg = quotient.is_nonpositive or (sign_a == "<" and (c - b).is_positive) or (sign_a == ">" and (c - b).is_negative) + q_pos = quotient.is_nonnegative or (sign_a == ">" and (c - b).is_positive) or (sign_a == "<" and (c - b).is_negative) + + if quotient.is_zero: + if symb == ">=": + latex_reasoning += myst(r"""&\iff \py{variable} \text{ \en{arbitraryø\fr{arbitraire}} """, locals(), globals()) + latex_reasoning += myst(r"""\text{ (\en{since we always have}\fr{car on a toujours} } \py{variable}^2 \ge 0) \\""", locals(), globals()) + elif symb == ">": + if domain == "N": + latex_reasoning += myst(r"""&\iff \py{variable} \in \mathbb{N} \setminus \{0\} \\""", locals(), globals()) + else: + latex_reasoning += myst(r"""&\iff \py{variable} \neq 0 \\""", locals(), globals()) + elif symb in ["<=", "="]: + latex_reasoning += myst(r"""&\iff \py{variable} = 0 \\""", locals(), globals()) + else: # symb == "<" + latex_reasoning += myst(r"""& \text{ impossible} \\""", locals(), globals()) + + elif q_neg: + if symb in [">=", ">"]: + latex_reasoning += myst(r"""&\iff \py{variable} \text{ \en{arbitrary}\fr{arbitraire} } """, locals(), globals()) + latex_reasoning += myst(r"""\text{ (\en{since we always have}\fr{car on a toujours} } \py{variable}^2 \ge 0) \\""", locals(), globals()) + else: + latex_reasoning += myst(r"""& \text{ impossible \en{since}\fr{car} } \py{variable}^2 \ge 0 > \py{latex_quotient} \\""", locals(), globals()) + + elif q_pos: + root0 = sqrt(quotient, evaluate=False) + latex_root0 = pxsl_latex_with_formatting(root0) + root1 = sqrt(quotient) + latex_root1 = pxsl_latex_with_formatting(root1) + + if symb in [">=", ">", "="]: + if symb == ">=": + ge, le = r"\geq", r"\leq" + elif symb == ">": + ge, le = ">", "<" + else: # symb = "=" + ge, le = "=", "=" + + if root1 != root0: + latex_reasoning += myst(r"""&\iff \py{variable} \py{le} -\py{latex_root0} \text{ or } \py{variable} \py{ge} \py{latex_root0} \\""", locals(), globals()) + if root != root1: + latex_reasoning += myst(r"""&\iff \py{variable} \py{le} -\py{latex_root1} \text{ or } \py{variable} \py{ge} \py{latex_root1} \\""", locals(), globals()) + latex_reasoning += myst(r"""&\iff \py{variable} \py{le} -\py{latex_root} \text{ or } \py{variable} \py{ge} \py{latex_root} \\""", locals(), globals()) + + else: # symb in ["<=", "<"] + le = r"\leq" if symb == "<=" else "<" + if root1 != root0: + latex_reasoning += myst(r"""&\iff -\py{latex_root0} \py{le} \py{variable} \py{le} \py{latex_root0} \\""", locals(), globals()) + if root != root1: + latex_reasoning += myst(r"""&\iff -\py{latex_root1} \py{le} \py{variable} \py{le} \py{latex_root1} \\""", locals(), globals()) + latex_reasoning += myst(r"""&\iff -\py{latex_root} \py{le} \py{variable} \py{le} \py{latex_root} \\""", locals(), globals()) + + else: # sign of quotient undetermined + if (c - b).is_positive or (c - b).is_negative: + ge0, lt0 = (r"\geq", "<") if (c - b).is_positive else (r"\leq", ">") + case_pos = myst(r""" \text{ \en{if}\fr{si} } \py{latex_a} \py{ge0} 0""", locals(), globals()) + case_neg = myst(r""" \text{ \en{if}\fr{si} } \py{latex_a} \py{lt0} 0""", locals(), globals()) + elif a.is_positive or a.is_negative or sign_a: + ge0, lt0 = (r"\geq", "<") if (a.is_positive or sign_a == ">") else (r"\leq", ">") + case_pos = myst(r""" \text{ \en{if}\fr{si} } \py{latex_fraction_num} \py{ge0} 0""", locals(), globals()) + case_neg = myst(r""" \text{ \en{if}\fr{si} } \py{latex_fraction_num} \py{lt0} 0""", locals(), globals()) + else: + case_pos = myst(r""" \text{ \en{if}\fr{si} } \py{latex_quotient} > 0""", locals(), globals()) + case_neg = myst(r""" \text{ \en{if}\fr{si} } \py{latex_quotient} < 0""", locals(), globals()) + + if symb == "=": + latex_reasoning += myst(r"""&\iff \begin{cases} \py{variable} = -\py{latex_root} \text{ \en{or}\fr{ou} } \py{variable} = \py{latex_root}, & \py{case_pos} \\ +\text{ impossible}, & \py{case_neg} +\end{cases} \\""", locals(), globals()) + + elif symb in [">=", ">"]: + if symb == ">=": + ge, le = r"\geq", r"\leq" + elif symb == ">": + ge, le = ">", "<" + latex_reasoning += myst(r"""&\iff \begin{cases} \py{variable} \py{le} -\py{latex_root} \text{ \en{or}\fr{ou} } \py{variable} \py{ge} \py{latex_root}, & \py{case_pos} \\ +\py{variable} \text{ \en{arbitrary}\fr{arbitraire}}, & \py{case_neg} +\end{cases} \\""", locals(), globals()) + + else: # "<=", "<" + le = r"\leq" if symb == "<=" else "<" + latex_reasoning += myst(r"""&\iff \begin{cases} -\py{latex_root} \py{le} \py{variable} \py{le} \py{latex_root}, & \py{case_pos} \\ +\text{ impossible}, & \py{case_neg} +\end{cases} \\""", locals(), globals()) + + # Integer-domain details (any inequality direction) + if not q_neg: + if domain in ["Z", "N"] and (not root.is_integer or symb in ["<", ">"] or domain == "N"): + phrase_integer_case = True + if (symb == "<=" and (root - 1).is_negative) or (symb == "<" and (root - 1).is_nonpositive): + latex_reasoning += myst(r"""&\iff \py{variable} = 0""", locals(), globals()) + + elif symb == "=": + if root.is_noninteger or (not root.free_symbols and not root.is_integer): # root is not an integer + final_sentence = myst(r""" +Or $\py{variable}$ doit être un entier, donc l'équation n'admet aucune solution.""", locals(), globals()) + elif not root.is_integer: # don't know whether root is integer or not + if domain == "N": + final_sentence = myst(r""" +Or $\py{variable}$ doit être un entier positif, donc l'équation admet pour unique solution $\py{latex_root}$ si $\py{latex_root}$ est un entier, et n'admet aucune solution sinon.""", locals(), globals()) + if domain == "Z": + final_sentence = myst(r""" +Or \py{variable} doit être un entier, donc l'équation admet deux solutions -\py{latex_root} et \py{latex_root} si \py{latex_root} est un entier, et n'admet aucune solution sinon.""", locals(), globals()) + elif domain == "N": # on sait que racine est entier et domaine = N + latex_reasoning += myst(r"""&\iff \py{variable} = \py{latex_root} \\""", locals(), globals()) + final_sentence = myst(r""" +En effet, $\py{variable}$ doit être un entier positif.""", locals(), globals()) + + latex_reasoning = latex_reasoning[:-2] # on enlève le saut de ligne final + latex_reasoning += myst(r"""\,. + \end{align*}\end{equation*}""", locals(), globals()) + latex_reasoning += final_sentence + + latex_reasoning = __simplify_signs(latex_reasoning) + + return solution_set, latex_reasoning + + else: + if symb == "<=": + lim_pos = floor(root) + lim_neg = __ceil(-root) if domain == "Z" else 0 + elif symb == ">=": + lim_pos = __ceil(root) + lim_neg = floor(-root) + elif symb == "<": + if root.is_integer: + lim_pos = root - 1 + lim_neg = -root + 1 if domain == "Z" else 0 + else: + lim_pos = floor(root) + lim_neg = __ceil(-root) if domain == "Z" else 0 + else: # ">" + lim_pos = root + 1 if root.is_integer else __ceil(root) + lim_neg = -root - 1 if root.is_integer else floor(-root) + + latex_lim_pos = pxsl_latex_with_formatting(lim_pos) + latex_lim_neg = pxsl_latex_with_formatting(lim_neg) + if symb in ["<=", "<"]: + latex_reasoning += myst(r"""&\iff \py{latex_lim_neg} \leq \py{variable} \leq \py{latex_lim_pos}""", locals(), globals()) + else: + if domain == "Z": + latex_reasoning += myst(r"""&\iff \py{variable} \leq \py{latex_lim_neg} \text{ \en{or}\fr{ou} } \py{variable} \geq \py{latex_lim_pos}""", locals(), globals()) + else: + latex_reasoning += myst(r"""&\iff \py{variable} \geq \py{latex_lim_pos}""", locals(), globals()) + nat_flag = myst(r"""\en{natural}\fr{naturel}""", locals(), globals()) if domain == "N" else " " + latex_reasoning += myst(r"""& \text{\fr{since}\en{car} } \py{variable} \text{ \en{is an integer}\fr{est un entier} \py{nat_flag}}\\""", locals(), globals()) + + # Finish LaTeX reasoning + latex_reasoning = latex_reasoning[:-2] # remove last line break + latex_reasoning += myst(r"""\,. +\end{align*}\end{equation*}""", locals(), globals()) + + latex_reasoning = __simplify_signs(latex_reasoning) + + return solution_set, latex_reasoning + + + +## ==================== Tests resoudre_inequation_generale() ================= + +def run_tests_resoudre_inequation_generale(): + """ + Lance la batterie de 35 tests de régression de ``resoudre_inequation_generale``. + + Affiche pour chaque cas le raisonnement LaTeX et l'ensemble solution, à des fins + de debug uniquement. Ne retourne rien et n'a pas vocation à être appelée depuis + un exercice PyxiScience. + + :pxs_trigger: lancer manuellement la batterie de tests de régression (35 cas : linéaire/quadratique, R/Z/N, coefficients numériques/symboliques, dégénéré, totalement indéterminé) après une modification de ``resoudre_inequation_generale``, debug d'un corrigé cassé, vérification avant commit — PAS un helper d'affichage + :pxs_returns: | + ``None``. Effet de bord : ``print()`` de chaque cas avec son raisonnement LaTeX + et son ensemble solution séparés par des barres de 80 ``=``. + :pxs_example: | + from src.scripts.Mes_fctions.Mes_fctions_generalistes_bis import run_tests_resoudre_inequation_generale + run_tests_resoudre_inequation_generale() + :pxs_antipattern: Appeler cette fonction dans un bloc ``myst(...)`` d'exercice — c'est un outil de debug, pas un helper d'affichage. Utiliser ``resoudre_inequation_generale`` directement dans un exercice. + """ + from sympy import Symbol, sqrt, Rational, oo, latex + + # Définition des variables symboliques + x = Symbol('x') + a_sym = Symbol('a', positive=True) + b_sym = Symbol('b', real=True) + k = Symbol('k', real=True) + + print("="*80) + print("TESTS DE LA FONCTION resoudre_inequation_generale") + print("="*80 + "\n") + + # ============================================================================ + # TESTS LINÉAIRES (puissance=1) - CAS NUMÉRIQUES + # ============================================================================ + + print("Test 1a: 2x + 3 ≥ 10 (linéaire, a>0, >=, domaine R)") + result = resoudre_inequation_generale(2, 3, 10, variable="x", inegalite=">=", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 1b: 2x + 3 ≤ 10 (linéaire, a>0, <=, domaine R)") + result = resoudre_inequation_generale(2, 3, 10, variable="x", inegalite="<=", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 1c: 2x + 3 > 10 (linéaire, a>0, >, domaine R)") + result = resoudre_inequation_generale(2, 3, 10, variable="x", inegalite=">", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 1d: 2x + 3 < 10 (linéaire, a>0, <, domaine R)") + result = resoudre_inequation_generale(2, 3, 10, variable="x", inegalite="<", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 2a: -3x + 5 ≥ 2 (linéaire, a<0, >=, domaine R)") + result = resoudre_inequation_generale(-3, 5, 2, variable="x", inegalite=">=", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 2b: -3x + 5 ≤ 2 (linéaire, a<0, <=, domaine R)") + result = resoudre_inequation_generale(-3, 5, 2, variable="x", inegalite="<=", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 3a: 4x - 7 ≥ 5 (linéaire, b<0, domaine Z)") + result = resoudre_inequation_generale(4, -7, 5, variable="x", inegalite=">=", domaine="Z", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 3b: 4x - 7 ≥ 5 (linéaire, b<0, domaine N)") + result = resoudre_inequation_generale(4, -7, 5, variable="x", inegalite=">=", domaine="N", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 4: 5x + 12 < -3 (linéaire, c<0, domaine Z)") + result = resoudre_inequation_generale(5, 12, -3, variable="x", inegalite="<", domaine="Z", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 5: -2x - 8 > -4 (linéaire, a<0, b<0, c<0, domaine R)") + result = resoudre_inequation_generale(-2, -8, -4, variable="x", inegalite=">", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + # ============================================================================ + # TESTS LINÉAIRES - CAS AVEC FRACTIONS + # ============================================================================ + + print("Test 6: (1/2)x + 3 ≥ 7 (linéaire, coefficients rationnels)") + result = resoudre_inequation_generale(Rational(1, 2), 3, 7, variable="x", inegalite=">=", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 7: (-3/4)x + 2 < 5 (linéaire, a rationnel négatif)") + result = resoudre_inequation_generale(Rational(-3, 4), 2, 5, variable="x", inegalite="<", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 7b: autre cas a fractionnaire : (1/sqrt(2))x + 2 < 5 (linéaire, a rationnel négatif)") + result = resoudre_inequation_generale(1 / sqrt(2), 2, 5, variable="x", inegalite="<", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + # ============================================================================ + # TESTS LINÉAIRES - CAS AVEC RACINES ET EXPRESSIONS + # ============================================================================ + + print("Test 8: x + √2 ≥ 5 (linéaire, b irrationnel)") + result = resoudre_inequation_generale(1, sqrt(2), 5, variable="x", inegalite=">=", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 9: 2x + (1 - √3) ≤ 4 (linéaire, b expression avec racine)") + result = resoudre_inequation_generale(2, 1 - sqrt(3), 4, variable="x", inegalite="<=", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 10: √5·x + 2 > √7 + 1 (linéaire, a et c irrationnels)") + result = resoudre_inequation_generale(sqrt(5), 2, sqrt(7) + 1, variable="x", inegalite=">", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 10bis: 2x - 3 + 2√3 > 1 (linéaire, a et c irrationnels, doit se simplifier)") + result = resoudre_inequation_generale(2, -3 + 2 * sqrt(3), 1, variable="x", inegalite=">", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + # ============================================================================ + # TESTS LINÉAIRES - CAS SYMBOLIQUES + # ============================================================================ + + print("Test 11: ax + 3 ≥ 10 (linéaire symbolique, a>0 par hypothèse)") + result = resoudre_inequation_generale(a_sym, 3, 10, variable="x", inegalite=">=", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 12: 2x + b ≤ 5 (linéaire symbolique, b réel)") + result = resoudre_inequation_generale(2, b_sym, 5, variable="x", inegalite="<=", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 13: ax + b > k (linéaire entièrement symbolique)") + result = resoudre_inequation_generale(a_sym, b_sym, k, variable="x", inegalite=">", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + # ============================================================================ + # TESTS QUADRATIQUES (puissance=2) - CAS NUMÉRIQUES + # ============================================================================ + + print("Test 14a: x² + 0 ≥ 9 (quadratique, a>0, b=0, >=)") + result = resoudre_inequation_generale(1, 0, 9, variable="x", inegalite=">=", domaine="R", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 14b: x² + 0 ≤ 9 (quadratique, a>0, b=0, <=)") + result = resoudre_inequation_generale(1, 0, 9, variable="x", inegalite="<=", domaine="R", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 15: 2x² + 5 > 15 (quadratique, a>0, b>0, >)") + result = resoudre_inequation_generale(2, 5, 15, variable="x", inegalite=">", domaine="R", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 16a: 3x² - 7 < 20 (quadratique, a>0, b<0, <)") + result = resoudre_inequation_generale(3, -7, 20, variable="x", inegalite="<", domaine="R", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 16b: 3x² - 7 < 20 (quadratique, a>0, b<0, < et détail signe a)") + result = resoudre_inequation_generale(3, -7, 20, variable="x", inegalite="<", domaine="R", puissance=2, detail_signe_a = True) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 17: -x² + 10 ≥ 6 (quadratique, a<0, >=)") + result = resoudre_inequation_generale(-1, 10, 6, variable="x", inegalite=">=", domaine="R", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 18: -2x² + 8 ≤ 0 (quadratique, a<0, c=0, <=)") + result = resoudre_inequation_generale(-2, 8, 0, variable="x", inegalite="<=", domaine="R", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 19: x² + 3 ≥ 7 (quadratique, domaine Z)") + result = resoudre_inequation_generale(1, 3, 7, variable="x", inegalite=">=", domaine="Z", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 20: x² - 5 < 4 (quadratique, domaine N)") + result = resoudre_inequation_generale(1, -5, 4, variable="x", inegalite="<", domaine="N", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + # ============================================================================ + # TESTS QUADRATIQUES - CAS SANS SOLUTION OU SOLUTION VIDE + # ============================================================================ + + print("Test 21: x² + 5 < 2 (quadratique, pas de solution réelle)") + result = resoudre_inequation_generale(1, 5, 2, variable="x", inegalite="<", domaine="R", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 22: -x² - 3 > 0 (quadratique, a<0, pas de solution)") + result = resoudre_inequation_generale(-1, -3, 0, variable="x", inegalite=">", domaine="R", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + # ============================================================================ + # TESTS QUADRATIQUES - CAS AVEC FRACTIONS ET RACINES + # ============================================================================ + + print("Test 23: (1/2)x² + 1 ≥ 3 (quadratique, a rationnel)") + result = resoudre_inequation_generale(Rational(1, 2), 1, 3, variable="x", inegalite=">=", domaine="R", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 24: x² + √2 ≤ 5 (quadratique, b irrationnel)") + result = resoudre_inequation_generale(1, sqrt(2), 5, variable="x", inegalite="<=", domaine="R", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 25: 2x² + (√3 - 1) > √5 (quadratique, expressions irrationnelles)") + result = resoudre_inequation_generale(2, sqrt(3) - 1, sqrt(5), variable="x", inegalite=">", domaine="R", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + # ============================================================================ + # TESTS QUADRATIQUES - CAS SYMBOLIQUES + # ============================================================================ + + print("Test 26: ax² + 2 ≥ 10 (quadratique symbolique, a>0)") + result = resoudre_inequation_generale(a_sym, 2, 10, variable="x", inegalite=">=", domaine="R", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 27: x² + b ≤ k (quadratique symbolique)") + result = resoudre_inequation_generale(1, b_sym, k, variable="x", inegalite="<=", domaine="R", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + # ============================================================================ + # TESTS DE CAS LIMITES + # ============================================================================ + + print("Test 28: 0x + 5 ≥ 3 (linéaire dégénéré, a=0, toujours vrai)") + result = resoudre_inequation_generale(0, 5, 3, variable="x", inegalite=">=", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 29: 0x + 2 > 5 (linéaire dégénéré, a=0, jamais vrai)") + result = resoudre_inequation_generale(0, 2, 5, variable="x", inegalite=">", domaine="R", puissance=1) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 30: x² + 0 ≥ 0 (quadratique, toujours vrai)") + result = resoudre_inequation_generale(1, 0, 0, variable="x", inegalite=">=", domaine="R", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 31a: -3x + 7 ≤ 1 (linéaire, a<0, domaine N avec solution)") + result = resoudre_inequation_generale(-3, 7, 1, variable="x", inegalite="<=", domaine="N", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 31b: -3x + 7 ≤ 1 (linéaire, a<0, domaine N avec solution + détail signe a)") + result = resoudre_inequation_generale(-3, 7, 1, variable="x", inegalite="<=", domaine="N", puissance=2, detail_signe_a = True) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 32: (1/3)x² - 2 > 1 (quadratique rationnel, domaine Z)") + result = resoudre_inequation_generale(Rational(1, 3), -2, 1, variable="x", inegalite=">", domaine="Z", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + + print("Test 33: Cx² + sqrt(2) + 1 > sqrt(3)") + C = Symbol("C") + result = resoudre_inequation_generale(C, sqrt(2) + 1, sqrt(3), variable="n", inegalite=">", domaine="R", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 34: Cx² + sqrt(2) + 1 > sqrt(3) dans Z") + C = Symbol("C") + result = resoudre_inequation_generale(C, sqrt(2) + 1, sqrt(3), variable="n", inegalite=">", domaine="Z", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("Test 35: totalement indéterminé:") + a, b, c = symbols("a,b,c") + result = resoudre_inequation_generale(a, b, c, variable="n", inegalite=">", domaine="R", puissance=2) + print("RAISONNEMENT DÉTAILLÉ:") + print(result[1]) + print(f"\nSolutions : ${latex(result[0])}$") + print("\n" + "="*80 + "\n") + + print("="*80) + print("FIN DES TESTS") + print("="*80) + + + + + + +def pxsl_latex(expr, reverse = False): + """ + Convertit une expression symbolique en représentation LaTeX. + + Args: + expr: Expression symbolique (probablement SymPy) à convertir + reverse (bool): Si True, inverse l'ordre des termes dans l'expression + + Returns: + str: Représentation LaTeX de l'expression + + Examples: + >>> from sympy import symbols + >>> x, y = symbols('x y') + + # Conversion standard + >>> expr = x**2 + 3*x - 5 + >>> pxsl_latex(expr) + 'x^{2} + 3 x - 5' + + # Conversion avec ordre inversé + >>> pxsl_latex(expr, reverse=True) + '- 5 + 3 x + x^{2}' + + # Expression avec termes négatifs + >>> expr2 = -2*x**2 + x - 7 + >>> pxsl_latex(expr2, reverse=True) + '- 7 + x - 2 x^{2}' + + # Expression plus complexe + >>> expr3 = x**3 - 2*x**2 + 3*x - 4 + >>> pxsl_latex(expr3, reverse=True) + '- 4 + 3 x - 2 x^{2} + x^{3}' + + Note: + La fonction utilise myst() pour l'interpolation des variables, + ce qui suggère une intégration avec MyST (Markedly Structured Text). + + :pxs_trigger: afficher une expression sympy en LaTeX avec option d'ordre inversé (degrés croissants au lieu de décroissants), polynôme "-5 + 3x + x^2" au lieu de "x^2 + 3x - 5", énoncé qui lit les termes du plus bas au plus haut degré, chaînage terme à terme avec + / - + :pxs_returns: | + chaîne LaTeX. Si ``reverse=False``, équivalent à ``sympy.latex(expr)``. Si ``reverse=True``, + itère sur ``expr.as_ordered_terms()[::-1]`` et insère manuellement ``+`` entre termes + positifs consécutifs. + :pxs_example: | + pxsl_latex(x**2 + 3*x - 5, reverse=True) # → "- 5 + 3 x + x^{2}" + :pxs_antipattern: Reconstruire la chaîne LaTeX à la main en concaténant les termes d'un polynôme dans l'ordre inverse avec des str.split. + """ + # Si l'option reverse est activée, traiter les termes dans l'ordre inverse + if reverse == True: + # Initialiser la chaîne LaTeX vide + expr_latex = "" + + # Obtenir les termes de l'expression dans l'ordre inverse + # as_ordered_terms() retourne une liste des termes, [::-1] inverse la liste + terms = expr.as_ordered_terms()[::-1] + + # Parcourir chaque terme avec son index + for i, term in enumerate(terms): + # Convertir le terme en chaîne de caractères + term_str = str(term) + + # Pour tous les termes sauf le premier, ajouter un signe + + # seulement si le terme ne commence pas déjà par un signe négatif + if i != 0 and not term_str.startswith('-'): + sign = myst(r"""+""") + else: + # Pas de signe pour le premier terme ou les termes négatifs + sign = "" + + # Construire l'expression LaTeX en ajoutant le signe et le terme converti + # myst() semble être une fonction de template qui interpole les variables + expr_latex += myst(r"""\py{sign}\py{latex(term)} """, globals(), locals()) + + # Retourner l'expression LaTeX complète + return expr_latex + + # Cas par défaut : retourner simplement la conversion LaTeX standard + return latex(expr) + +def pxs_is_reductible_sqrt(x): + """ + fr : détermine si un nombre ou une expression est reductible en racine carrée + en : determines if a number or an expression is reducible for square root + + Args: + x : nombre ou expression symbolique + + Returns: + bool: True si x est simplifiable dans une racine carrée + + Examples: + >>> pxs_is_reductible_sqrt(16) + 'True' + >>> pxs_is_reductible_sqrt(24) + 'True' + >>> pxs_is_reductible_sqrt(13) + 'False' + >>> pxs_is_reductible_sqrt(13/24) + 'True' + + >>> x = Symbol('x') + >>> pxs_is_reductible_sqrt(4*x) + 'True' + >>> pxs_is_reductible_sqrt(x/4) + 'True' + >>> y = Symbol('y') + >>> pxs_is_reductible_sqrt(x/(4*y)) + 'True' + >>> pxs_is_reductible_sqrt(3*x/(2*y)) + 'False' + + :pxs_trigger: tester si sqrt(x) peut être simplifié, détection d'un facteur premier de multiplicité ≥ 2 dans la factorisation du numérateur ou dénominateur de x, préparer une étape "√24 = 2√6" dans un corrigé, décider d'ajouter l'étape de simplification des racines + :pxs_returns: | + ``bool`` : ``True`` si ``x`` (int, Integer, Rational ou Mul d'entiers/symboles) possède un + facteur premier d'exposant ≥ 2 dans ``p`` ou ``q``, ``False`` sinon. + :pxs_example: | + if pxs_is_reductible_sqrt(24): + # ajouter l'étape √24 = 2√6 dans le corrigé + ... + :pxs_antipattern: Appeler ``simplify(sqrt(x)) != sqrt(x)`` qui peut échouer ou boucler sur des Mul symboliques, ou tester ``str(sqrt(x))``. + """ + + def _is_factor(x, is_reducible): + factors = factorint(x) + for prime, power in factors.items(): + if power >= 2: + is_reducible = True + return is_reducible + + def _is_reducible_int(x, is_reducible): + if isinstance(x, (int, Integer)): + is_reducible = _is_factor(x, is_reducible) + return is_reducible + + def _is_reducible_rational(x, is_reducible): + try: + x_ratio = Rational(x) + is_reducible = _is_factor(x_ratio.p, is_reducible) + is_reducible = _is_factor(x_ratio.q, is_reducible) + except: + pass + return is_reducible + + is_reducible = False + is_reducible = _is_reducible_int(x, is_reducible) + is_reducible = _is_reducible_rational(x, is_reducible) + + if isinstance(x, Mul): + for arg in x.args: + is_reducible = _is_reducible_int(arg, is_reducible) + is_reducible = _is_reducible_rational(arg, is_reducible) + return is_reducible + +def pxsl_Rational(num, den, orientation="v", display=True): + """ + Builds a SymPy expression representing the rational fraction `num/den`, + simplifying only the numeric parts while keeping the symbolic or irrational + factors in place. + + Parameters + ---------- + num : sympy.Expr, int, float + The numerator of the fraction. Can be numeric or symbolic + (e.g., `3*pi`, `2*x`, etc.). + den : sympy.Expr, int, float + The denominator of the fraction. Must not be zero. + orientation : str, optional + Display orientation: `'v'` for vertical (LaTeX-style fraction), + or any other value for horizontal rendering. Default is `'v'`. + display : bool, optional + If `True`, returns a formatted LaTeX string via `myst()` for + visual display. If `False`, returns a raw LaTeX string. + Default is `True`. + + Returns + ------- + sympy.Expr or str + A SymPy expression representing the simplified fraction, or a LaTeX + string depending on the `orientation` and `display` parameters. + + Raises + ------ + ZeroDivisionError + If `den` equals zero. + + Examples + -------- + >>> pxsl_Rational(3*pi, 6) + \displaystyle{\frac{\pi}{2}} + + >>> x = Symbol('x') + >>> pxsl_Rational(4*x, 8) + \displaystyle{\frac{x}{2}} + + >>> pxsl_Rational(3*pi, 6, orientation='h') + \pi / 2 + + :pxs_trigger: construire une fraction LaTeX ``num/den`` en simplifiant uniquement les parties numériques et en gardant les symboles/irrationnels (π, x, √3) intacts, affichage vertical \frac par défaut ou horizontal "a / b", gestion du cas dénominateur ±1, simplification par gcd en interne + :pxs_returns: | + chaîne MyST/LaTeX : ``\displaystyle{\frac{...}{...}}`` par défaut (``orientation="v"``, + ``display=True``), variante horizontale ``a / b`` si ``orientation!="v"``, ou juste le + numérateur si le dénominateur se simplifie à ±1. Lève ``ZeroDivisionError`` si ``den == 0``. + :pxs_example: | + myst(r"x = \py{pxsl_Rational(3*pi, 6)}") # → \displaystyle{\frac{\pi}{2}} + :pxs_antipattern: Passer ``Rational(3*pi, 6)`` ou ``3*pi / 6`` à ``latex()`` directement — sympy peut écraser le π ou produire une forme non simplifiée. + """ + + # Convert inputs into SymPy expressions + num = sympify(num) + den = sympify(den) + + # Denominator must not be zero + if den == 0: + raise ZeroDivisionError("Denominator is zero.") + + # Separate the numeric coefficient from the symbolic/irrational remainder + # Example: 3*pi → (3, pi), 6 → (6, 1) + ncoef, nrest = num.as_coeff_Mul() + dcoef, drest = den.as_coeff_Mul() + + # Extract internal numerator/denominator for rational arithmetic + # Example: Rational(3,2) → (3, 2) + a, b = ncoef.as_numer_denom() + c, d = dcoef.as_numer_denom() + + # Preliminary GCD reduction between a and c to avoid large integers + g1 = gcd(a, c) + if g1 != 0: + a //= g1 + c //= g1 + + # Compute the numeric coefficient (a*d)/(b*c) + num_int = a * d + den_int = b * c + + # Normalize sign: denominator must always be positive + if den_int < 0: + den_int = -den_int + num_int = -num_int + + # Final GCD reduction for the numeric ratio + g2 = gcd(num_int, den_int) + if g2 != 0: + num_int //= g2 + den_int //= g2 + + # Build the final SymPy expression, keeping symbolic structure intact + expr = (Integer(num_int) * nrest) / (Integer(den_int) * drest) + + # Display handling according to user parameters + if orientation == 'v' and display: + # Special case: denominator equals ±1 + if Integer(den_int) * drest == 1: + return myst(r""" \displaystyle{\py{latex(Integer(num_int) * nrest)}} """, locals(), globals()) + elif Integer(den_int) * drest == -1: + return myst(r""" \displaystyle{\py{latex(-Integer(num_int) * nrest)}} """, locals(), globals()) + # General case + return myst(r"""\displaystyle{\py{latex(expr)}}""", locals(), globals()) + + elif orientation == 'v': + # Non-displayed version (raw LaTeX) + return myst(r"""\py{latex(expr)}""", locals(), globals()) + + else: + # Horizontal display mode + if Integer(den_int) * drest == 1: + return myst(r"""\py{latex(Integer(num_int) * nrest)}""", locals(), globals()) + elif Integer(den_int) * drest == -1: + return myst(r"""\py{latex(-Integer(num_int) * nrest)}""", locals(), globals()) + else: + return myst(r"""\py{latex(Integer(num_int) * nrest)} / \py{latex(Integer(den_int) * drest)}""", locals(), globals()) + +def pxs_separate_factors(expr, var): + """ + Split an expression into (factor independent of ``var``, factor containing ``var``). + + For a ``Mul``, delegates to ``expr.as_independent(var)``. For an ``Add``, attempts + factorization first and retries ; returns ``(1, expr)`` if no clean split is found. + + :pxs_trigger: séparer un produit en (coefficient indépendant de var, partie contenant var), isoler le coefficient numérique/symbolique extérieur à la variable d'intégration, utilisé en IBP pour extraire le facteur devant u(x) ou v'(x), décomposition Mul vs Add + :pxs_returns: | + tuple ``(coeff, reste)`` : ``coeff`` indépendant de ``var``, ``reste`` contenant ``var``. + Retourne ``(1, expr)`` si ``expr`` est un ``Add`` non factorisable ou d'un autre type. + :pxs_example: | + from sympy import Symbol, exp + x = Symbol('x') + coef, rest = pxs_separate_factors(3*x*exp(x), x) # → (3, x*exp(x)) + :pxs_antipattern: Deviner manuellement le coefficient extérieur avec des regex sur la chaîne LaTeX, ou appeler ``expr.args[0]`` en supposant que c'est toujours le coefficient. + """ + if isinstance(expr, Mul): + return expr.as_independent(var)[0], expr.as_independent(var)[1] + if isinstance(expr, Add): + expr = factor(expr) + if isinstance(expr, Add): + return 1, expr + else: + return pxs_separate_factors(expr, var) + return 1, expr + +def pxs_ln(arg): + """ + Reduce the natural logarithm ln(arg) when the argument is a perfect power. + + The function rewrites ln(m**k) as k*ln(m) whenever possible. + If the argument cannot be reduced, the expression ln(arg) is returned unchanged. + + Parameters + ---------- + arg : sympy expression or int + Argument of the natural logarithm. + + Returns + ------- + sympy expression + A reduced logarithmic expression of the form k*ln(m) if applicable, + otherwise ln(arg). + + Examples + -------- + >>> pxs_ln(9) + 2*ln(3) + + >>> pxs_ln(12) + ln(12) + + >>> pxs_ln(1) + ln(1) + + >>> pxs_ln(72) + 2*ln(6) + + :pxs_trigger: simplifier ln(m**k) en k·ln(m) pour un entier positif dont la factorisation admet un exposant commun ≥ 2, factorisation de l'argument d'un logarithme, exercice "écrire ln(9) sous la forme k·ln(m)", propriétés des log + :pxs_returns: | + expression sympy : ``k*ln(m)`` si ``arg = m**k`` avec ``k = min(exponents) ≥ 2`` dans sa + factorisation première, sinon ``ln(arg)`` inchangé. Retourne ``ln(arg)`` pour les + non-entiers, les négatifs, et le cas ``arg == 1``. + :pxs_example: | + pxs_ln(9) # → 2*ln(3) + pxs_ln(12) # → ln(12) (pas de facteur commun ≥ 2) + :pxs_antipattern: Utiliser ``logcombine`` ou ``expand_log`` qui ne cherchent pas à factoriser l'argument entier du log. + """ + + # Convert input to a SymPy object + arg = sympify(arg) + + # Domain and type checks + if not arg.is_Integer or arg <= 0: + return ln(arg) + + # Special case: ln(1) + if arg == 1: + return ln(1) + + # Prime factorization of the argument + factors = factorint(arg) + + # Safety check: empty factorization (e.g. arg == 1) + if not factors: + return ln(arg) + + # Extract the maximal common exponent + k = min(factors.values()) + if k <= 1: + return ln(arg) + + # Reconstruct the base m such that arg = m**k + m = Integer(1) + for p, e in factors.items(): + m *= p**(e // k) + + return k * ln(m) + +def pxs_is_factorable(expr) -> bool: + """ + Determine whether a SymPy expression is factorable. + + An expression is considered factorable if its factorized form + is not equivalent to its expanded form. + + Parameters + ---------- + expr : sympy expression or str + The expression to test. + + Returns + ------- + bool + True if the expression is factorable, False otherwise. + + Examples + -------- + >>> pxsl_is_factorable("x^2 - 1") + True + + >>> pxsl_is_factorable("x^2 + 1") + False + + >>> pxsl_is_factorable("2*x*(x+1)") + False + + :pxs_trigger: tester si un polynôme est factorisable (x²-1 factorisable, x²+1 non), choisir entre méthode "simple" et "advanced" pour la décomposition en éléments simples, décider d'afficher une étape de factorisation dans un corrigé, branchement logique avant appel à factor() + :pxs_returns: | + ``bool`` : ``True`` si ``factor(expr)`` diffère structurellement de ``expand(expr)`` + (test via ``simplify(expanded - factored) != 0``), ``False`` sinon. Accepte ``Poly``, + expression sympy ou ``str``. + :pxs_example: | + if pxs_is_factorable(x**2 - 1): + # afficher (x-1)(x+1) dans le corrigé + ... + :pxs_antipattern: Comparer ``str(factor(e)) == str(expand(e))`` qui dépend de l'ordre d'affichage des termes. + """ + if isinstance(expr, Poly): + expr = expr.as_expr() + expr = sympify(expr) + + expanded = expand(expr) + factored = factor(expr) + + # If factor() does not change the structure, it's not factorable + return not simplify(expanded - factored) == 0 + +def pxsl_quotient(num: "sympy.Expr", den: "sympy.Expr", sign: bool = True) -> str: + """ + Formats a quotient in LaTeX using myst, with special handling depending + on whether the numerator is equal to 1. + + Parameters + ---------- + num : sympy.Expr + The numerator of the quotient. + den : sympy.Expr + The denominator of the quotient. + sign : bool, optional + If True, the sign of the expression is explicitly handled. + Default is True. + + Returns + ------- + str + A LaTeX-formatted string generated via `myst`, representing + either the simplified quotient `num/den` or the product + `num * 1/den` depending on the value of `num.q`. + + Examples + -------- + Case 1 — numerator behaves like 1 (num.q == 1) + The function returns a single `lc(num/den, sign=sign)` block. + + >>> # Example setup (illustrative) + >>> # num = 1, den = x + 1 + >>> pxsl_quotient(num, den) + '\\n \\\\py{lc(num/den, sign = sign)}\\n ' + + >>> # Same case, but without forcing sign handling + >>> pxsl_quotient(num, den, sign=False) + '\\n \\\\py{lc(num/den, sign = sign)}\\n ' + + Case 2 — general numerator (num.q != 1) + The function returns `lc(num, sign=sign)` multiplied by `1/den`. + + >>> # Example setup (illustrative) + >>> # num = 3*x, den = x + 1 + >>> pxsl_quotient(num, den) + '\\n \\\\py{lc(num, sign = sign)}\\\\py{mult_A}\\\\frac{1}{\\\\py{latex(den)}}\\n ' + + >>> # Same case, but without forcing sign handling + >>> pxsl_quotient(num, den, sign=False) + '\\n \\\\py{lc(num, sign = sign)}\\\\py{mult_A}\\\\frac{1}{\\\\py{latex(den)}}\\n ' + + :pxs_trigger: afficher un quotient num/den en LaTeX avec choix automatique entre ``\frac{num}{den}`` (si num.q == 1, i.e. num entier) et ``num · \frac{1}{den}`` (si num est une Rational non entière), formatage de coefficients complexes dans un corrigé de dérivation / intégration ; attention : dépend de ``lc`` et ``mult_A`` disponibles dans le scope appelant + :pxs_returns: | + chaîne MyST/LaTeX : soit ``\py{lc(num/den, sign=sign)}``, soit + ``\py{lc(num, sign=sign)}\py{mult_A}\frac{1}{\py{latex(den)}}`` selon la nature de + ``num.q``. Nécessite ``lc`` et ``mult_A`` définis en globals/locals à l'appel. + :pxs_example: | + # dans un exercice où `lc = pxsl_latex_coefficient` et `mult_A = myst(r"\cdot")` + myst(r"f'(x) = \py{pxsl_quotient(Rational(3), x + 1)}") + :pxs_antipattern: Écrire ``\frac{num}{den}`` en dur sans différencier le cas où num contient déjà une fraction (produit une fraction complexe illisible). + """ + + if num.q == 1: + return myst(r""" + \py{lc(num/den, sign = sign)} + """, globals(), locals()) + else: + return myst(r""" + \py{lc(num, sign = sign)}\py{mult_A}\frac{1}{\py{latex(den)}} + """, globals(), locals()) + +def pxs_randint(mini, maxi, exclude = []): + """ + Returns a random integer between mini and maxi avoiding the element(s) in exclude. + Exclude can be an integer or a collection of integers. + + :pxs_trigger: tirer un entier aléatoire dans [mini, maxi] en excluant une ou plusieurs valeurs, génération de paramètres d'exercice (coefficients, exposants, indices) sans collision, éviter 0 / 1 / une valeur déjà utilisée ailleurs, randomisation reproductible d'un énoncé + :pxs_returns: | + ``int`` : entier tiré uniformément dans ``{mini, ..., maxi} \ exclude``. + ``exclude`` accepte un ``int`` seul ou n'importe quel itérable d'entiers. + :pxs_example: | + a = pxs_randint(-5, 5, exclude=0) # coefficient non nul + b = pxs_randint(1, 10, exclude=[a, a+1]) # évite a et a+1 + :pxs_antipattern: Boucler ``while x in exclude: x = randint(...)`` à la main dans chaque exercice, ou tirer dans la liste complète et re-tirer en cas de collision. + """ + + if isinstance(exclude, int): + exclude = exclude, + st = set(range(mini, maxi + 1)) - set(exclude) + return rd.choice(list(st)) \ No newline at end of file diff --git a/app/corpus/Mes_fctions_probabilistes_bis.py b/app/corpus/Mes_fctions_probabilistes_bis.py new file mode 100644 index 0000000000000000000000000000000000000000..ff186ceaa79983a2cdbefda5ae619065ee7ec8b2 --- /dev/null +++ b/app/corpus/Mes_fctions_probabilistes_bis.py @@ -0,0 +1,1162 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Thu Apr 04 2025 + +@author: Delphine +""" + + +from __future__ import division +import sys +from copy import deepcopy + +import src.scripts.Mes_fctions.Mes_fctions_deterministes +from src.scripts.Mes_fctions.Mes_fctions_deterministes import * + +import src.scripts.Mes_fctions.Mes_fctions_generalistes +from src.scripts.Mes_fctions.Mes_fctions_generalistes import * + +import src.scripts.Mes_fctions.Mes_fctions_probabilistes +from src.scripts.Mes_fctions.Mes_fctions_probabilistes import * + +import src.scripts.Mes_fctions.Mes_fctions_d_ecriture_Latex +from src.scripts.Mes_fctions.Mes_fctions_d_ecriture_Latex import * + +import src.scripts.Mes_fctions.Mes_fctions_d_alg_lineaire_bis +from src.scripts.Mes_fctions.Mes_fctions_d_alg_lineaire_bis import * + +from src.scripts.pxs_runtime import myst +from sympy import * +import sympy.stats as stats +import functools as fct +import math as m +import random as rd +import numpy as np + +####################### FONCTIONS COPIES A RETIRER DES QUE LE PROBLEME DE DOUBLE APPEL EST REGLE + +def pxsl_pow(x, n=1, opt=0, displaystyle=True): + """ + Fonction permettant d'écrire le nombre x entouré de parenthèses + lorsqu'il est négatif ou irrationnel avec deux termes (par ex : 1+sqrt(2) ou 3sqrt(2)) + Ne fonctionne pas pour des valeurs numériques non simplifiées (par ex : 1+3 ou 3*3/2) + + Version + ------- + 13/02/25 + + Paramètres + ---------- + x : nombre ou expression + La base à élever à la puissance n + n : int, optional + L'exposant (défaut: 1) + opt : int, optional + Option de formatage (défaut: 0) + 0: formatage standard + 1: simplifie l'affichage pour x=1, x=0 ou n=1 + 2: simplifie davantage et renvoie une chaîne vide pour x=0 + displaystyle : bool, optional + Si True, utilise \displaystyle pour les fractions (défaut: False) + + Retour + ------ + str + retourne l'expression en latex + + :pxs_trigger: écriture LaTeX d'une puissance, base potentiellement négative, base fractionnaire Rational, expression composée (Add/Mul), parenthèses conditionnelles autour d'une base, affichage x^n dans un calcul, exposant entier appliqué à un coefficient + :pxs_returns: | + str LaTeX rendu via myst() (pour injection \\py{...}). + Ajoute automatiquement \\left(...\\right) autour de x si x est négatif, Rational non entier, Add ou Mul. + Sans parenthèses si x est Symbol ou positif. Si n==1, l'exposant n'est pas affiché. + :pxs_example: | + a = -3 + expr = pxsl_pow(a, 2) # -> \\left(-3\\right)^{2} + expr2 = pxsl_pow(Rational(1,2), 3) # -> \\left(\\frac{1}{2}\\right)^{3} + # Injection : myst(r\"\"\"On a \\py{expr}\"\"\", globals(), locals()) + :pxs_antipattern: écrire à la main f"({latex(x)})^{n}" if x < 0 else f"{latex(x)}^{n}" — manque les cas Rational, Add, Mul, Symbol et n==1. + """ + + # Préparation de l'expression LaTeX selon le mode displaystyle + if displaystyle: + latex_x = r"\displaystyle " + latex(x) + else: + latex_x = latex(x) + + # Cas où x est une expression (Add ou Mul) ou nombre négatif: + if isinstance(x, Add) or isinstance(x, Mul) : + if n == 1 : + return myst(r"""\left(\py{latex_x}\right)""", globals(), locals()) + else: + return myst(r"""\left(\py{latex_x}\right)^{\py{n}}""", globals(), locals()) + # Cas où x est un Rational + elif isinstance(x,Rational) and x.q!=1: + if n == 1 : # Pas de parenthèses quand n=1 + return myst(r"""\py{latex_x}""", globals(), locals()) + else: # Parenthèses quand n différent de 1 + return myst(r"""\left(\py{latex_x}\right)^{\py{n}}""", globals(), locals()) + # Cas où x est un Symbol: + elif isinstance(x,Symbol): + if n == 1: + return myst(r"""\py{latex_x}""", globals(), locals()) + else: + return myst(r"""\py{latex_x}^{\py{n}}""", globals(), locals()) + # Cas où x est strictement négatif + elif x<0: + if n == 1: + return myst(r"""\left(\py{latex_x}\right)""", globals(), locals()) + else: + return myst(r"""\left(\py{latex_x}\right)^{\py{n}}""", globals(), locals()) + # Cas où x est un nombre positif ou nul + else: + # Option 0: formatage standard + if opt == 0: + if n == 1: + return myst(r"""\py{latex_x}""", globals(), locals()) + else: + return myst(r"""\py{latex_x}^{\py{n}}""", globals(), locals()) + + # Option 1: simplifie pour x=0, x=1 ou n=1 + elif opt == 1: + if x == 1 or x == 0 or n == 1: + return myst(r"""\py{latex_x}""", globals(), locals()) + else: + return myst(r"""\py{latex_x}^{\py{n}}""", globals(), locals()) + + # Option 2: simplifie davantage, chaîne vide pour x=0 + else: # opt == 2 ou autres valeurs + if x == 0: + return myst(r""" """, globals(), locals()) + elif x == 1 or n == 1: + return myst(r"""\py{latex_x}""", globals(), locals()) + else: + return myst(r"""\py{latex_x}^{\py{n}}""", globals(), locals()) + +################################################################################# + +def pxs_nvirgzero(x): + """ + Fr : Fonction qui supprime .0 si le nombre a une valeur entière en le convertissant en int. + En : Function that removes .0 if the number has an integer value by converting it to int. + + Version 2 + --------- + 13/03/25 + + Vérification + ------------ + Auteur : Ronan + Vérificateurs : Delphine + + Paramètres + ---------- + x : nombre + + Retour + ------ + int ou float : + si le nombre a une valeur entière avec une précision de E-10, il est transformé en int, sinon il n'est pas modifié. + + Fonction utilisée par + --------------------- + pxsl_res_num, pxs_simul_law, pxsl_sum_vector + + :pxs_trigger: suppression du .0 terminal, conversion float→int si valeur entière, nettoyage d'affichage numérique avant LaTeX, éviter "3.0" au lieu de "3", tolérance numérique aux erreurs de flottant + :pxs_returns: | + int si abs(x - int(x)) < 1e-10 (tolérance), sinon float inchangé. + Utilisé principalement juste avant latex(...) pour éviter ".0" parasites. + :pxs_example: | + a = pxs_nvirgzero(3.0) # -> 3 (int) + b = pxs_nvirgzero(2.00000000000004) # -> 2 (int, tolérance) + c = pxs_nvirgzero(0.235) # -> 0.235 (float, inchangé) + # Usage typique : myst(r"\\py{latex(pxs_nvirgzero(round(v, 4)))}", ...) + :pxs_antipattern: `int(x) if x == int(x) else x` — échoue silencieusement sur 2.00000000000004 qui devrait être traité comme 2. + """ + + if m.isclose(x, int(x), abs_tol=1e-10)==True: + x=int(x) + return x + +# # ############################################################################################################# +# # #### Début des essais ####### +# # ############################################################################################################# + +# a=0.23546 +# pxs_nvirgzero(a) +# retourne 0.23546 +# b=3.0 +# pxs_nvirgzero(b) +# retourne 3 +# c=2.00000000000004 +# pxs_nvirgzero(c) +# retourne 2 + +# # ############################################################################################################# +# # #### Fin des essais ######## +# # ############################################################################################################# + +def pxsl_res_num(x, dec=4, pourc=False, text=False, egal=True, dot = True): + """ + Fr : Formate un nombre pour l'affichage avec LaTeX, avec gestion d'approximation. + En : Formats a number for display with LaTeX, with approximation handling. + + Version 2 + --------- + 13/03/25 + + Vérification + ------------ + Auteur : Ronan + Vérificateurs : Delphine + + Arguments: + x (float/str): Nombre à formater + dec (int): Nombre de décimales pour l'arrondi (défaut: 4) + pourc (bool): Si True, affiche également le résultat en pourcentage (défaut: False) + text (bool): Si True, utilise un format texte plus descriptif (défaut: False) + egal (bool): Si False, affichera simplement le nombre sans = ou approx devant + + Returns: + str: Formule LaTeX formatée + + Fonction utilisée par + --------------------- + Aucune fonction pyxiscience + + :pxs_trigger: affichage résultat final d'un calcul probabiliste ou numérique, choix automatique entre "=" et "\\approx" selon exactitude de l'arrondi, conversion en pourcentage avec texte bilingue FR/EN, "soit environ X %" + :pxs_returns: | + str LaTeX. Si egal=True : préfixé par " = " (exact) ou " \\approx " (arrondi). + Si pourc=True : ajoute la version en pourcentage. Si text=True : texte descriptif bilingue "\\fr{environ}\\en{approximately}". + :pxs_example: | + p = 7/30 + resultat = pxsl_res_num(p, dec=4, pourc=True) + # -> " \\approx 0.2333 \\%" en fait " \\approx 23.33 \\%" + # myst(r"La probabilité vaut \\py{resultat}", globals(), locals()) + :pxs_antipattern: f" = {round(x, 4)}" ou f" \\approx {round(x*100, 2)}\\%" — ne distingue pas exact/approché et ne gère ni le bilinguisme ni le point final. + """ + # Conversion et arrondi du nombre + valeur_precise = round(float(x), 10) # Conversion en float et arrondi à 10 décimales pour précision interne + valeur_arrondie = round(valeur_precise, dec) # Arrondi au nombre de décimales demandé + + # Vérification si l'arrondi modifie la valeur (pour décider d'utiliser ≈ ou =) + valeur_precise_int = int(valeur_precise * (10**10)) # Conversion en entier pour comparaison précise + valeur_arrondie_int = int(valeur_arrondie * (10**10)) # Conversion de la valeur arrondie + + # Définition du symbole et format selon que la valeur est exacte ou approximative + est_exact = (valeur_precise_int == valeur_arrondie_int) + symbole = "" if egal == False else (" = " if est_exact else " \\approx ") + + # Construction de la formule LaTeX selon les paramètres + if text: + # Version texte descriptive + prefixe = "" if est_exact else " \\fr{ environ }\\en{ approximately } " + + if pourc: + # Format pourcentage avec texte explicatif + texte_pourcentage = ", \\fr{ soit " + ("" if est_exact else "environ ") + "}\\en{that is " + ("" if est_exact else "approximately ") + "} " + if dot: + resultat = myst(r"""{0}$\py{{latex(pxs_nvirgzero(round(valeur_precise,dec)))}}${1}$\py{{latex(pxs_nvirgzero(round(100*valeur_precise,dec-2)))}}$ $\%$.""".format( + prefixe, texte_pourcentage), globals(), locals()) + else: + resultat = myst(r"""{0}$\py{{latex(pxs_nvirgzero(round(valeur_precise,dec)))}}${1}$\py{{latex(pxs_nvirgzero(round(100*valeur_precise,dec-2)))}}$ $\%$""".format( + prefixe, texte_pourcentage), globals(), locals()) + else: + # Format décimal simple + if dot: + resultat = myst(r"""{0}$\py{{latex(pxs_nvirgzero(round(valeur_precise,dec)))}}$.""".format(prefixe), globals(), locals()) + else: + resultat = myst(r"""{0}$\py{{latex(pxs_nvirgzero(round(valeur_precise,dec)))}}$""".format(prefixe), globals(), locals()) + else: + # Version concise avec symbole mathématique + if pourc: + # Format pourcentage + resultat = myst(r"""{0}\py{{latex(pxs_nvirgzero(round(100*valeur_precise,dec-2)))}} \%""".format(symbole), globals(), locals()) + else: + # Format décimal simple + resultat = myst(r"""{0}\py{{latex(pxs_nvirgzero(round(valeur_precise,dec)))}}""".format(symbole), globals(), locals()) + + return resultat + +# # ############################################################################################################# +# # #### Début des essais ####### +# # ############################################################################################################# + +# a=0.2354 +# b=pxsl_res_num(a,dec=4,pourc=False,text=False) +# retourne "=0.2354" +# c=pxsl_res_num(a,dec=4,pourc=True,text=False) +# retourne "=23.45%" +# d=pxsl_res_num(a,dec=4,pourc=False,text=True) +# retourne "0.2354." centré +# e=pxsl_res_num(a,dec=4,pourc=True,text=True) +# retourne "est 0.2354, soit 23.54%" +# f=0.2354278 +# g=pxsl_res_num(f,dec=4,pourc=False,text=False) +# retourne "\approx 0.2354" +# h=pxsl_res_num(f,dec=4,pourc=True,text=False) +# retourne "\approx 23.54%" +# i=pxsl_res_num(f,dec=4,pourc=False,text=True) +# retourne "est environ 0.2354" +# j=pxsl_res_num(f,dec=4,pourc=True,text=True) +# retourne "est environ 0.2354, soit environ 23.54%" + +# # ############################################################################################################# +# # #### Fin des essais ######## +# # ############################################################################################################# + +def pxsl_scalar_product(a, b, prod="times",displaystyle=True): + """ + Fr : Fonction permettant d'écrire le calcul du produit scalaire entre les deux vecteurs a et b. + En : Function to calculate the dot product between two vectors a and b. + + Version 1 + --------- + 02/03/25 + + Vérification + ------------ + Auteur : Ronan + Vérificateurs : Delphine + + Paramètres + ---------- + a : liste + Premier vecteur du produit + b : liste + Deuxième vecteur du produit + prod : str + times : le symbole produit est \times + dot : le symbole produit est \cdot + + Retour + ------ + str + retourne l'expression en latex + + Fonction utilisée par + --------------------- + Aucune fonction pyxiscience + + :pxs_trigger: produit scalaire de deux vecteurs, somme de produits terme à terme a1×b1+a2×b2+..., calcul détaillé en LaTeX avec \\times ou \\cdot, développement de pour exercice de géométrie ou probabilités + :pxs_returns: | + str LaTeX de la forme "a1 \\times b1 + a2 \\times b2 + ... + an \\times bn". + Chaque terme passe par pxsl_pow donc parenthèses auto sur nombres négatifs/Rational. + Retourne un message d'erreur LaTeX si listes vides ou tailles différentes. + :pxs_example: | + u = [Rational(1,2), 2] + v = [3, 4] + calcul = pxsl_scalar_product(u, v, prod="times") + # -> "\\frac{1}{2}\\times 3 + 2\\times 4" + # myst(r"\\langle u,v\\rangle = \\py{calcul}", globals(), locals()) + :pxs_antipattern: " + ".join(f"{ai}\\times {bi}" for ai, bi in zip(a, b)) — casse sur négatifs (signe perdu) et fractions (pas de displaystyle). + """ + # Vérification si une des listes est vide + if len(a) == 0 or len(b) == 0: + return myst(r""" \textrm{Attention liste vide}""") + + # Vérification si les listes sont de tailles différentes + if len(a) != len(b): + return myst(r"""\textrm{Attention les deux listes ne sont pas de la même taille}""") + + # Détermination de la taille des vecteurs + taille_vecteur = len(a) + dernier_index = taille_vecteur - 1 + + # Initialisation de la chaîne résultat + resultat = myst(r""" """) + + # Détermination du symbole de produit à utiliser + symbole_produit = r"""\times""" if prod == "times" else r"""\cdot""" + + # Construction de l'expression du produit scalaire terme à terme + for i in range(dernier_index): + # Ajout de chaque terme sauf le dernier, suivi du symbole + + resultat = resultat + pxsl_pow(a[i],displaystyle=displaystyle) + myst(symbole_produit) + pxsl_pow(b[i],displaystyle=displaystyle) + myst(r"""+ """) + + # Ajout du dernier terme (sans symbole + à la fin) + resultat = resultat + pxsl_pow(a[dernier_index],displaystyle=displaystyle) + myst(f" {symbole_produit}") + pxsl_pow(b[dernier_index],displaystyle=displaystyle) + + return resultat + +# # ############################################################################################################# +# # #### Début des essais ####### +# # ############################################################################################################# + +# pxsl_scalar_product([1, 2], [3, 4], prod="times") +# renvoie la forme latex de 1x3+2x4 +# pxsl_scalar_product([5, 6, 7], [8, 9, 10], prod="dot") +# renvoie la forme latex de 5.8+6.9+7.10 +# pxsl_scalar_product([Rational(1,2), 2], [3, 4]) +# renvoie la forme latex de 1/2x3+2x4 avec displaystyle actif +# pxsl_scalar_product([Rational(1,2), 2], [3, 4],displaystyle=False) +# renvoie la forme latex de 1/2x3+2x4 avec displaystyle inactif +# pxsl_scalar_product([], []) +# renvoie un message indiquant qu'une des listes est vide +# pxsl_scalar_product([5], [10]) +# renvoie la forme latex de 5x10 +# pxsl_scalar_product([5,2], [10]) +# renvoie un message indiquant que les listes ne sont pas de la même taille + +# # ############################################################################################################# +# # #### Fin des essais ######## +# # ############################################################################################################# + +def pxsl_moment(X, n=1, prod="times",displaystyle=True): + """ + Fr : Fonction permettant d'écrire le calcul du moment d'ordre n de la variable aléatoire finie X. + En : Function to calculate the nth order moment of the finite random variable X. + + Version 1 + --------- + 02/03/25 + + Vérification + ------------ + Auteur : Ronan + Vérificateurs : Delphine + + Paramètres + ---------- + X : variable aléatoire finie + + n : int + Ordre du moment + + prod : str + times : le symbole produit est \times + dot : le symbole produit est \cdot + + Retour + ------ + str + retourne l'expression en latex + + Fonction utilisée par + --------------------- + Aucune fonction pyxiscience + + :pxs_trigger: calcul détaillé espérance E(X) ou E(X^n) d'une variable aléatoire finie sympy, moment d'ordre n, écriture développée Σ x_i^n × P(X=x_i), exercice "calculer E(X)" ou "calculer la variance" avec étape détaillée + :pxs_returns: | + str LaTeX de la forme "x1^n \\times p1 + x2^n \\times p2 + ... + xk^n \\times pk". + Valeurs triées par clé croissante. pxsl_pow gère les parenthèses autour de valeurs négatives et des Rational. + :pxs_example: | + X = stats.FiniteRV('X', {-1: Rational(1,4), 0: Rational(1,2), 1: Rational(1,4)}) + calcul_esp = pxsl_moment(X, n=1) # pour E(X) + calcul_var = pxsl_moment(X, n=2) # pour E(X^2) + # myst(r"E(X) = \\py{calcul_esp}", globals(), locals()) + :pxs_antipattern: boucler manuellement `density(X).dict.items()` et concaténer `f"{k}^{n} * {v} + "` — rate le tri, les parenthèses sur négatifs et le displaystyle des fractions. + """ + # Récupération et tri des éléments de la variable aléatoire + loi_proba = stats.density(X).dict + loi_proba_triee = dict(sorted(loi_proba.items())) + valeurs = list(loi_proba_triee.keys()) + probabilites = list(loi_proba_triee.values()) + + # Vérification si la distribution est vide + if len(valeurs) == 0: + return myst(r""" Attention distribution vide""") + + # Détermination du symbole de produit à utiliser + symbole_produit = r"""\times""" if prod == "times" else r"""\cdot""" + + # Initialisation du résultat + resultat = myst(r""" """) + + # Construction de l'expression du moment terme à terme + dernier_index = len(valeurs) - 1 + + # Ajout de tous les termes sauf le dernier + for i in range(dernier_index): + resultat = resultat + pxsl_pow(valeurs[i], n,displaystyle=displaystyle) + myst(symbole_produit) + pxsl_pow(probabilites[i],displaystyle=displaystyle) + myst(r"""+ """) + + # Ajout du dernier terme (sans symbole + à la fin) + resultat = resultat + pxsl_pow(valeurs[dernier_index], n,displaystyle=displaystyle) + myst(f" {symbole_produit}") + pxsl_pow(probabilites[dernier_index],displaystyle=displaystyle) + + return resultat + +# # ############################################################################################################# +# # #### Début des essais ####### +# # ############################################################################################################# + +# Calcul du moment d'ordre 1 d'une loi de Bernouilli +# X = stats.FiniteRV('X', {0: 0.3, 1: 0.7}) +# pxsl_moment(X, n=1, prod="times") +# renvoie l'expression latex de 0 x 0.3 + 1 x 0.7 + +# Calcul du moment d'ordre 2 pour un dé à 6 faces displaystyle actif +# Y = stats.FiniteRV('Y', {1: Rational(1,6), 2: Rational(1,6), 3: Rational(1,6), 4: Rational(1,6), 5: Rational(1,6), 6: Rational(1,6)}) +# pxsl_moment(Y, n=2, prod="dot") +# renvoie l'expression latex de 1^2.1/6+2^2.1/6+3^2.1/6+4^2.1/6+5^2.1/6+6^2.1/6 les fractions étant écrite en displaystyle + +# Calcul du moment d'ordre 2 pour un dé à 6 faces, displaystyle inactif +# Z = stats.FiniteRV('Z', {1: Rational(1,6), 2: Rational(1,6), 3: Rational(1,6), 4: Rational(1,6), 5: Rational(1,6), 6: Rational(1,6)}) +# pxsl_moment(Z, n=2, prod="dot",displaystyle=False) +# même résultat que pour Y mais sans le format displaystyle + +# Loi simple - moment d'ordre 3 +# W = stats.FiniteRV('W', {-1: 0.25, 0: 0.5, 1: 0.25}) +# pxsl_moment(W, n=3, prod="times") +# renvoie l'expression latex de (-1)^3 x 0.25+0^3 x 0.5 + 1^3 x 0.25 + +# # ############################################################################################################# +# # #### Fin des essais ######## +# # ############################################################################################################# + +def pxsl_law(textx, textprob, X, frac="", nzero=True): + """ + Fr : Fonction permettant d'écrire le tableau de loi d'une variable aléatoire X finie. + En : Function to write the probability distribution table of a finite random variable X. + + Version 1 + --------- + 02/03/25 + + Vérification + ------------ + Auteur : Ronan + Vérificateurs : Delphine + + Paramètres + ---------- + textx : str + Entrée de la première ligne du tableau de loi (valeurs possibles) + Peut contenir du LaTeX directement (sans échappement) + + textprob : str + Entrée de la deuxième ligne du tableau de loi (probabilités) + Peut contenir du LaTeX directement (sans échappement) + + X : variable aléatoire finie + Variable aléatoire dont on veut afficher la loi + + frac : str, optional + "/" : les fractions sont représentées avec / (notation simple) + "" : les fractions sont représentées avec la commande \frac{}{} (défaut) + + nzero : boolean, optional + True : les probabilités nulles ne sont pas représentées (défaut) + False : les probabilités nulles sont représentées + + Retour + ------ + str + Retourne un tableau LaTeX contenant la loi de probabilité + + Fonction utilisée par + --------------------- + Aucune fonction pyxiscience + + :pxs_trigger: tableau de loi d'une variable aléatoire discrète finie, ligne x_i + ligne P(X=x_i), représenter la distribution d'une VA dans un énoncé ou une correction, table à 2 lignes valeurs/probabilités avec entêtes LaTeX personnalisées + :pxs_returns: | + str LaTeX d'un \\begin{array}{...} à 2 lignes (valeurs, probabilités) avec séparateurs verticaux. + Les fractions sont rendues en \\displaystyle par défaut. Les probabilités nulles sont masquées si nzero=True. + :pxs_example: | + X = stats.FiniteRV('X', {1: Rational(1,6), 2: Rational(1,6), 3: Rational(1,6), + 4: Rational(1,6), 5: Rational(1,6), 6: Rational(1,6)}) + tableau = pxsl_law("x_i", "P(X=x_i)", X) + # myst(r"La loi de X est donnée par : \\py{tableau}", globals(), locals()) + :pxs_antipattern: construire à la main `\\begin{array}{|c|c|c|}` avec une boucle sur `density(X).dict.items()` et `latex(v)` — duplique la logique de tri, de gestion des zéros et du displaystyle. + """ + # Récupération et tri des éléments de la variable aléatoire + loi_proba = stats.density(X).dict + loi_proba_triee = dict(sorted(loi_proba.items())) + + # Filtrage des probabilités nulles si demandé + if nzero: + loi_proba_triee = {k: v for k, v in loi_proba_triee.items() if v != 0} + + # Extraction des valeurs et probabilités + valeurs = list(loi_proba_triee.keys()) + probabilites = list(loi_proba_triee.values()) + nb_valeurs = len(valeurs) + + # Configuration du tableau LaTeX + largeur_colonnes = 'ccc|' * (nb_valeurs + 1) + ligne_vide = '&' * (nb_valeurs * 3 + 2) + + # Début du tableau + resultat = myst(r""" \begin{array}{|\py{largeur_colonnes}} \hline """, globals(), locals()) + + # Construction de la première ligne (valeurs possibles) - Les en-têtes sont passés tels quels + resultat = resultat + myst(r""" \py{ligne_vide} \\""", globals(), locals()) + resultat = resultat + myst(r""" & \py{textx} & """, globals(), locals()) + + for i in range(nb_valeurs): + if frac == "/": + # Affichage simple sans utiliser \frac + resultat = resultat + myst(r"""& & \py{valeurs[i]} & """, globals(), locals()) + else: + # Affichage avec \displaystyle pour les fractions + resultat = resultat + myst(r"""& & \displaystyle \py{latex(valeurs[i])} & """, globals(), locals()) + + resultat = resultat + myst(r"""\\ """) + resultat = resultat + myst(r""" \py{ligne_vide} \\""", globals(), locals()) + resultat = resultat + myst(r"""\hline """) + + # Construction de la deuxième ligne (probabilités) + resultat = resultat + myst(r""" \py{ligne_vide} \\""", globals(), locals()) + resultat = resultat + myst(r""" & \py{textprob} & """, globals(), locals()) + + for i in range(nb_valeurs): + if frac == "/": + # Affichage simple des probabilités + resultat = resultat + myst(r"""& & \py{probabilites[i]} &""", globals(), locals()) + else: + # Affichage des probabilités avec \displaystyle pour les fractions + resultat = resultat + myst(r"""& & \displaystyle \py{latex(probabilites[i])} &""", globals(), locals()) + + resultat = resultat + myst(r"""\\ """) + resultat = resultat + myst(r""" \py{ligne_vide} \\""", globals(), locals()) + + # Fin du tableau + resultat = resultat + myst(r"""\hline """) + resultat = resultat + myst(r"""\end{array}""") + + return resultat + +# # ############################################################################################################# +# # #### Début des essais ####### +# # ############################################################################################################# + +# Affichage entête simple +# X = stats.FiniteRV('X', {0: 0.3, 1: 0.7}) +# pxsl_law("x", "P(X=x)", X) +# renvoie un tableau 2 lignes, 3 colonnes +# | x | 0 | 1 | +# |P(X=x)|0.3|0.7| + +# Affichage entête mathématique +# X2 = stats.FiniteRV('X2', {1: Rational(1,6), 2: Rational(1,6), 3: Rational(1,6), 4: Rational(1,6), 5: Rational(1,6), 6: Rational(1,6)}) +# pxsl_law("y", "P(Y=y)", X2) +# renvoie un tableau 2 lignes, 7 colonnes (fractions écrites en displaystyle) +# | y | 1 | 2 | 3 | 4 | 5 | 6 | +# |P(Y=y)|1/6|1/6|1/6|1/6|1/6|1/6| + +# Choix d'affichage des zéros +# Z = stats.FiniteRV('Z', {1: 0.2, 2: 0, 3: 0.5, 4: 0, 5: 0.3}) +# Sans les zéros +# pxsl_law("z", "P(Z=z)", Z) +# renvoie un tableau 2 lignes, 4 colonnes +# | z | 1 | 3 | 5 | +# |P(Z=z)|0.2|0.5|0.3| +# Avec les zéros +# pxsl_law("z", "P(Z=z)", Z, nzero=False) +# renvoie un tableau 2 lignes, 6 colonnes +# | z | 1 | 2 | 3 | 4 | 5 | +# |P(Z=z)|0.2| 0 |0.5| 0 |0.3| + +# Variable avec valeur fractionnaire +# W = stats.FiniteRV('W', {Rational(1, 3): 0.25, Rational(2, 3): 0.25, 1: 0.5}) +# pxsl_law("z", "P(Z=z)", W) +# renvoie un tableau 2 lignes, 4 colonnes, les fractions sont en displaystyle +# | z |1/3 |2/3 | 1 | +# |P(Z=z)|0.25|0.25|0.5| + +# Affichage entête personnalisée +# stats.FiniteRV('Z', {1: 0.4, 2: 0.6}) +# pxsl_law(r"\text{Valeur} ~ z", r"\text{Probabilité} ~ P(Z=z)", Z) +# renvoie un tableau 2 lignes, 3 colonnes +# | Valeur z | 1 | 2 | +# |Probabilité P(Z=z)|0.4|0.6| +# stats.FiniteRV('W', {10: 0.25, 20: 0.25, 30: 0.5}) +# pxsl_law("w", "P_W(w)", W) +# renvoie un tableau 2 lignes, 4 colonnes (le P_W(w) est interprêté en latex) +# | w | 10 | 20 | 30 | +# |P_W(w)|0.25|0.25|0.5 | + +# # ############################################################################################################# +# # #### Fin des essais ######## +# # ############################################################################################################# + +def pxs_simul_law(n, type_proba="dec", prec=0.01, nzero=True): + """ + Fr : Fonction permettant de simuler une loi de probabilité discrète de taille n. + En : Function to simulate a discrete probability distribution of size n. + + Version 1 + --------- + 02/03/25 + + Vérification + ------------ + Auteur : Ronan + Vérificateurs : Delphine + + Paramètres: + ----------- + n : int + Nombre de valeurs possibles de la loi + + type_proba : str + Format des probabilités générées + - "dec" : nombres décimaux (entre 0 et 1) + - "perc" : pourcentages (entre 0 et 100) + - "frac" : fractions + + prec : float ou int + - Si type=="dec" ou "perc" : les probabilités seront des multiples de prec + - Si type=="frac" : prec est un entier, les probabilités seront des multiples de 1/prec + + nzero : bool + - True : les probabilités seront non nulles si possible + - False : les probabilités peuvent être nulles + + Retour: + ------- + list + Liste des probabilités (la somme vaut 1 ou 100 selon le type) + + Fonction utilisée par + --------------------- + Aucune fonction pyxiscience + + :pxs_trigger: génération aléatoire des probabilités d'un énoncé probabiliste, simulation d'une loi discrète dont la somme vaut exactement 1 (ou 100%), création de données pour exercices variabilisés, choix décimal/pourcentage/fraction avec précision contrôlée + :pxs_returns: | + list de longueur n. Somme exacte = 1 (dec), 100 (perc) ou 1 (frac). + En mode "frac" : liste de sympy.Rational avec dénominateur prec. Ordre aléatoire (shuffle final). + Avec nzero=True : toutes les entrées sont > 0 si faisable. + :pxs_example: | + probas = pxs_simul_law(4, type_proba="frac", prec=12) + # ex: [Rational(1,2), Rational(1,3), Rational(1,12), Rational(1,12)] + X = pxs_finiterv('X', [5, 10, 15, 20], probas) + tableau = pxsl_law("x_i", "P(X=x_i)", X) + :pxs_antipattern: `probas = [random.random() for _ in range(n)]; probas = [p/sum(probas) for p in probas]` — pas de précision contrôlée, ne garantit pas la somme exacte après arrondi, pas de support Rational. + """ + + resultat = [] + + # Cas des pourcentages (entre 0 et 100) + if type_proba == "perc": + total_restant = 100 + compteur = 1 + probas_temp = [] + precision_log = m.floor(m.log10(prec)) + valeur = rd.randint(0, int(round(total_restant/(2*prec)))) * prec + if precision_log < 0: + valeur = round(valeur, -precision_log) + probas_temp.append(pxs_nvirgzero(valeur)) + + while compteur < (n-1): + total_restant = 100 - sum(probas_temp) + valeur = rd.randint(0, int(round(total_restant/(2*prec)))) * prec + if precision_log < 0: + valeur = round(valeur, -precision_log) + probas_temp.append(pxs_nvirgzero(valeur)) + compteur += 1 + + valeur = 100 - sum(probas_temp) + if precision_log < 0: + valeur = round(valeur, -precision_log) + probas_temp.append(pxs_nvirgzero(valeur)) + + # Si on veut des valeurs non nulles et que c'est possible + if nzero == True and prec*(n-1) < 100: + produit = 1 + taille = len(probas_temp) + for i in range(taille): + produit *= probas_temp[i] + + while produit == 0: # S'il y a au moins un zéro + total_restant = 100 + compteur = 1 + probas_temp = [] + precision_log = m.floor(m.log10(prec)) + valeur = rd.randint(0, int(round(total_restant/(2*prec)))) * prec + if precision_log < 0: + valeur = round(valeur, -precision_log) + probas_temp.append(pxs_nvirgzero(valeur)) + + while compteur < (n-1): + total_restant = 100 - sum(probas_temp) + valeur = rd.randint(0, int(round(total_restant/(2*prec)))) * prec + if precision_log < 0: + valeur = round(valeur, -precision_log) + probas_temp.append(pxs_nvirgzero(valeur)) + compteur += 1 + + valeur = 100 - sum(probas_temp) + if precision_log < 0: + valeur = round(valeur, -precision_log) + probas_temp.append(pxs_nvirgzero(valeur)) + + # Recalculer le produit + produit = 1 + for i in range(taille): + produit *= probas_temp[i] + + resultat = probas_temp + + # Cas des décimaux (entre 0 et 1) + elif type_proba == "dec": + total_restant = 1 + compteur = 1 + probas_temp = [] + precision_log = m.floor(m.log10(prec)) + valeur = rd.randint(0, int(round(total_restant/(2*prec)))) * prec + if precision_log < 0: + valeur = round(valeur, -precision_log) + probas_temp.append(pxs_nvirgzero(valeur)) + + while compteur < (n-1): + total_restant = 1 - sum(probas_temp) + valeur = rd.randint(0, int(round(total_restant/(2*prec)))) * prec + if precision_log < 0: + valeur = round(valeur, -precision_log) + probas_temp.append(pxs_nvirgzero(valeur)) + compteur += 1 + + valeur = 1 - sum(probas_temp) + if precision_log < 0: + valeur = round(valeur, -precision_log) + probas_temp.append(pxs_nvirgzero(valeur)) + + # Si on veut des valeurs non nulles et que c'est possible + if nzero == True and prec*(n-1) < 1: + produit = 1 + taille = len(probas_temp) + for i in range(taille): + produit *= probas_temp[i] + + while produit == 0: # S'il y a au moins un zéro + total_restant = 1 + compteur = 1 + probas_temp = [] + precision_log = m.floor(m.log10(prec)) + valeur = rd.randint(0, int(round(total_restant/(2*prec)))) * prec + if precision_log < 0: + valeur = round(valeur, -precision_log) + probas_temp.append(pxs_nvirgzero(valeur)) + + while compteur < (n-1): + total_restant = 1 - sum(probas_temp) + valeur = rd.randint(0, int(round(total_restant/(2*prec)))) * prec + if precision_log < 0: + valeur = round(valeur, -precision_log) + probas_temp.append(pxs_nvirgzero(valeur)) + compteur += 1 + + valeur = 1 - sum(probas_temp) + if precision_log < 0: + valeur = round(valeur, -precision_log) + probas_temp.append(pxs_nvirgzero(valeur)) + + # Recalculer le produit + produit = 1 + for i in range(len(probas_temp)): + produit *= probas_temp[i] + + resultat = probas_temp + + # Cas des fractions + elif type_proba == "frac": + total_restant = prec + compteur = 1 + probas_temp = [] + probas_temp.append(Rational(rd.randint(0, int(round(total_restant/2))), prec)) + + while compteur < (n-1): + total_restant = int(prec - sum(probas_temp)*prec) + probas_temp.append(Rational(rd.randint(0, int(round(total_restant/2))), prec)) + compteur += 1 + + probas_temp.append(Rational(prec - sum(probas_temp*prec), prec)) + + # Si on veut des valeurs non nulles et que c'est possible + if nzero == True and n <= prec: + produit = 1 + taille = len(probas_temp) + for i in range(taille): + produit *= probas_temp[i] + + while produit == 0: # S'il y a au moins un zéro + total_restant = prec + compteur = 1 + probas_temp = [] + probas_temp.append(Rational(rd.randint(0, int(round(total_restant/2))), prec)) + + while compteur < (n-1): + total_restant = int(prec - sum(probas_temp)*prec) + probas_temp.append(Rational(rd.randint(0, int(round(total_restant/2))), prec)) + compteur += 1 + + probas_temp.append(Rational(prec - sum(probas_temp*prec), prec)) + + # Recalculer le produit + produit = 1 + for i in range(len(probas_temp)): + produit *= probas_temp[i] + + resultat = probas_temp + + # Mélange des probabilités pour avoir un ordre aléatoire + rd.shuffle(resultat) + return resultat + +# # ############################################################################################################# +# # #### Début des essais ####### +# # ############################################################################################################# + +# 1. Test avec type "dec" (décimal, valeur par défaut) +# Test avec paramètres par défaut +# pxs_simul_law(n=5) +# renvoie par exemple la liste [0.2,0.04,0.47,0.23,0.06] + +# Test avec une précision différente +# pxs_simul_law(n=4, prec=0.05) +# renvoie par exemple la liste [0.4,0.3,0.25,0.05] + +# Test avec sans_zero=False +# pxs_simul_law(n=6, nzero=False) +# renvoie par exemple la liste [0.45,0.19,0.09,0.17,0.1,0] + +# Test avec un grand nombre de valeurs +# pxs_simul_law(n=10, prec=0.001) +# renvoie par exemple la liste [0.012,0.191,0.134,0.187,0.05,0.046,0.078,0.043,0.024,0.235] + +# 2. Test avec type "perc" (pourcentage) +# pxs_simul_law(n=4, type_proba="perc", prec=0.5) +# renvoie par exemple la liste [1.5,43.5,47,8] + +# 3. Test avec type "frac" (fraction) +# pxs_simul_law(n=4, type_proba="frac", prec=12) +# renvoie par exemple la liste [1/2,1/3,1/12,1/12] + +# # ############################################################################################################# +# # #### Fin des essais ######## +# # ############################################################################################################# + +def pxs_fct_finiterv(f, X): + """ + Fr : Transforme une variable aléatoire finie X via une fonction f. + Cette fonction crée une nouvelle variable aléatoire Y = f(X) en appliquant + la fonction f à chaque valeur possible de X et en adaptant les probabilités. + En : Transforms a finite random variable X using a function f. + This function creates a new random variable Y = f(X) by applying + the function f to each possible value of X and adapting the probabilities accordingly. + + Version 1 + --------- + 02/03/25 + + Vérification + ------------ + Auteur : Ronan + Vérificateurs : Delphine + + Paramètres + ---------- + f : function + Fonction à appliquer à la variable aléatoire X + X : RandomSymbol + Variable aléatoire finie source + + Retour + ------ + RandomSymbol + Variable aléatoire finie Y = f(X) + + Fonction utilisée par + --------------------- + Aucune fonction pyxiscience + + :pxs_trigger: création de Y=f(X) à partir d'une VA finie X (Y=X², Y=|X|, Y=floor(X), Y=1_{X>a}), calcul de la loi de image d'une VA, agrégation automatique des probabilités quand f n'est pas injective + :pxs_returns: | + stats.FiniteRV nommé "Y" : nouvelle variable aléatoire finie sympy avec pour support {f(x): sum_{k:f(k)=f(x)} P(X=k)}. + Prêt à être passé à pxsl_law, pxsl_moment, stats.E(), stats.variance()... + :pxs_example: | + X = stats.FiniteRV('X', {-2: 0.25, -1: 0.25, 1: 0.25, 2: 0.25}) + Y = pxs_fct_finiterv(lambda x: x**2, X) # Y=X², support {1: 0.5, 4: 0.5} + tableau_Y = pxsl_law("y_i", "P(Y=y_i)", Y) + # myst(r"Loi de Y=X^2 : \\py{tableau_Y}", globals(), locals()) + :pxs_antipattern: `{f(k): v for k, v in density(X).dict.items()}` — écrase les probabilités quand f non injective (ex : f(-1)=f(1)=1) au lieu de les sommer. + """ + + # Initialisation du nouveau dictionnaire de probabilités + dico = {} + + # Pour chaque valeur possible de X et sa probabilité associée + for key, value in density(X).dict.items(): + # Appliquer f à la valeur + newkey = f(key) + + # Si la valeur transformée existe déjà, on ajoute la probabilité + # (cas où plusieurs valeurs de X donnent la même valeur après transformation) + if newkey in dico: + dico[newkey] = dico[newkey] + value + else: + dico[newkey] = value + + # Création du nom de la nouvelle variable aléatoire + Name = "Y" + + # Création de la nouvelle variable aléatoire + Y = stats.FiniteRV(Name, dico) + + return Y + +# # ############################################################################################################# +# # #### Début des essais ####### +# # ############################################################################################################# + +# Définir une pièce biaisée (70% face, 30% pile) +# piece_biaisee = stats.FiniteRV('piece_biaisee', {0: 0.3, 1: 0.7}) + +# Définir une variable discrète représentant le nombre d'enfants dans une famille +# nb_enfants = stats.FiniteRV('nb_enfants', {0: 0.1, 1: 0.3, 2: 0.4, 3: 0.15, 4: 0.05}) + +# Cas 1: Fonction définie directement (lambda x: x**2) +# pxs_fct_finiterv(lambda x: x**2, X2) +# renvoie stats.FiniteRV('Y',{1: Rational(1,6), 4: Rational(1,6), 9: Rational(1,6), 16: Rational(1,6), 25: Rational(1,6), 36: Rational(1,6)}) + +# Cas 2: Fonctions mathématiques standard +# pxs_fct_finiterv(exp, X2) +# renvoie stats.FiniteRV('Y',{e: Rational(1,6), e^2: Rational(1,6), e^3: Rational(1,6), e^4: Rational(1,6), e^5: Rational(1,6), e^6: Rational(1,6)}) +# pxs_fct_finiterv(sqrt, X2) +# renvoie stats.FiniteRV('Y',{1: Rational(1,6), sqrt(2): Rational(1,6), sqrt(3): Rational(1,6), 2: Rational(1,6), sqrt(5): Rational(1,6), sqrt(6): Rational(1,6)}) + +# Cas 3: Fonctions de partie entière +# notes = stats.FiniteRV('notes', {8.5: 0.05, 8.7: 0.1, 11.3: 0.15, 11.6: 0.2,14.9: 0.15, 15.5: 0.2, 17.8: 0.15}) +# pxs_fct_finiterv(floor, notes) +# renvoie stats.FiniteRV('Y',{8: 0.15, 11: 0.35, 14: 0.15, 15: 0.2, 17: 0.15}) +# pxs_fct_finiterv(ceiling, notes) +# renvoie stats.FiniteRV('Y',{9: 0.15, 12: 0.35, 15: 0.15, 16: 0.2, 18: 0.15}) + +# Cas 4: Fonctions indicatrices +# nb_enfants = stats.FiniteRV('nb_enfants', {0: 0.1, 1: 0.3, 2: 0.4, 3: 0.15, 4: 0.05}) +# Indicatrice (x > 2) +# pxs_fct_finiterv(lambda x: 1 if x > 2 else 0, nb_enfants) +# renvoie stats.FiniteRV('Y',{0: 0.8, 1: 0.2}) +# Indicatrice (x est pair) +# pxs_fct_finiterv(lambda x: 1 if x % 2 == 0 else 0, nb_enfants) +# renvoie stats.FiniteRV('Y',{0: 0.45, 1: 0.55}) + +# # ############################################################################################################# +# # #### Fin des essais ######## +# # ############################################################################################################# + +def pxsl_sum_vector(x): + """ + Fr : Fonction permettant d'écrire la somme des éléments du vecteur x + En: Function to calculate the sum of elements in vector x + + Version + ------- + 02/03/25 + + Vérification + ------ + Auteur : Ronan + Vérificateurs : Delphine + + Paramètres + ---------- + x : liste + + Retour + ------ + str + retourne la somme de la liste de x + + Fonction utilisée par + --------------------- + Aucune fonction pyxiscience + + :pxs_trigger: affichage LaTeX d'une somme d'entiers/décimaux signés, écriture "a - b + c" avec gestion automatique des signes + et -, détail du calcul d'une somme algébrique d'une liste, linéarisation d'un résultat (ex : total d'une série) + :pxs_returns: | + str LaTeX. Premier terme : "-v" si négatif, "v" sinon (pas de "+" initial). + Termes suivants : " -|v|" si négatif, " +v" si positif. Passe par pxs_nvirgzero pour nettoyer les .0. + :pxs_example: | + valeurs = [2, -1, 3, -4] + somme_latex = pxsl_sum_vector(valeurs) # -> "2 -1 +3 -4" + # myst(r"S = \\py{somme_latex} = \\py{sum(valeurs)}", globals(), locals()) + :pxs_antipattern: `" + ".join(str(v) for v in liste)` qui produit "2 + -1 + 3 + -4" (double opérateur laid) au lieu de "2 - 1 + 3 - 4". + """ + s=len(x) + if x[0]<0: + d=myst(r""" -\py{latex(pxs_nvirgzero(abs(x[0])))}""",globals(),locals()) + else: + d=myst(r"""\py{latex(pxs_nvirgzero(x[0]))}""",globals(),locals()) + for i in range(1,s): + if x[i]<0: + d=d+myst(r""" -\py{latex(pxs_nvirgzero(abs(x[i])))}""",globals(),locals()) + else: + d=d+myst(r""" +\py{latex(pxs_nvirgzero(x[i]))}""",globals(),locals()) + return d + +# # ############################################################################################################# +# # #### Début des essais ####### +# # ############################################################################################################# + +# a=[2,-1] +# pxsl_sum_vector(a) +# retourne l'écriture latex de la somme des éléments de a : 2 - 1 +# b=[-2,-1,3] +# pxsl_sum_vector(b) +# retourne l'écriture latex de la somme des éléments de b : - 2 - 1 + 3 + +# # ############################################################################################################# +# # #### Fin des essais ######## +# # ############################################################################################################# + +def pxs_finiterv(x,val,prob): + """ + Fr : Fonction permettant de créer la variables aléatoire x dont les valeurs sont val et les probabilités associées sont prob. + En: Function to create the random variable x whose values are val and the associated probabilities are prob. + + Version + ------- + 02/03/25 + + Vérification + ------------ + Auteur : Ronan + Vérificateurs : Delphine + + Paramètres + ---------- + x : liste + + Retour + ------ + variable aléatoire + + Fonction utilisée par + --------------------- + Aucune fonction pyxiscience + + :pxs_trigger: création d'une VA finie sympy à partir de deux listes parallèles valeurs/probabilités, raccourci de stats.FiniteRV quand on a déjà val et prob séparés (souvent issus de pxs_simul_law), instanciation rapide d'une loi discrète + :pxs_returns: | + stats.FiniteRV (RandomSymbol sympy) nommé x, de support dict(zip(val, prob)). + Prêt à passer à pxsl_law, pxsl_moment, pxs_fct_finiterv, stats.E(), stats.variance()... + :pxs_example: | + val = [5, 10, 15] + prob = [Rational(1,4), Rational(1,2), Rational(1,4)] + X = pxs_finiterv('X', val, prob) + tableau = pxsl_law("x_i", "P(X=x_i)", X) + # myst(r"La loi de X : \\py{tableau}", globals(), locals()) + :pxs_antipattern: `stats.FiniteRV('X', {val[i]: prob[i] for i in range(len(val))})` — verbose, duplique la logique à chaque exercice. + """ + + siz=len(val) + dic={} + for j in range(siz): + dic[val[j]]=prob[j] + return stats.FiniteRV(x,dic) + +# # ############################################################################################################# +# # #### Début des essais ####### +# # ############################################################################################################# + +# x='X' +# val = [5,10,15] +# prob= [1/4,1/2,1/4] +# pxsl_law("x", "P(X=x)",pxs_finiterv(x,val,prob) ) +# retourne la variable aléatoire X dont la loi est dict={5 : 0.25 , 10 : 0.5, 15 : 0.25} +# prob= [Rational(1,4),Rational(1,2),Rational(1,4)] +# pxsl_law("x", "P(X=x)",pxs_finiterv(x,val,prob) ) +# retourne la variable aléatoire X dont la loi est dict={5 : 1/4 , 10 : 1/2, 15 : 1/4} + +# # ############################################################################################################# +# # #### Fin des essais ######## +# # ############################################################################################################# \ No newline at end of file diff --git a/app/keys.py b/app/keys.py new file mode 100644 index 0000000000000000000000000000000000000000..f13de9b6af7d01b83ccdb675f195f18b7244b491 --- /dev/null +++ b/app/keys.py @@ -0,0 +1,22 @@ +""" +Centralised loader for API keys. + +Loads variables from a `.env` file at the project root if present, then exposes +them as module-level constants. Never hardcode secrets here — put them in `.env`. +""" + +import os +from pathlib import Path + +try: + from dotenv import load_dotenv + load_dotenv(Path(__file__).parent.parent / ".env") # pythonisation_app/.env +except ImportError: + # python-dotenv is optional; env vars can also be set directly in the shell + pass + +OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "") +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "") + +if not OPENROUTER_API_KEY: + print("[ld.py] WARNING: OPENROUTER_API_KEY is empty. Set it in .env or the shell environment.") diff --git a/app/knowledge/__init__.py b/app/knowledge/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/knowledge/fewshots/equations.md b/app/knowledge/fewshots/equations.md new file mode 100644 index 0000000000000000000000000000000000000000..2328727f777d8c28ba95df458bc9fdf2304751ce --- /dev/null +++ b/app/knowledge/fewshots/equations.md @@ -0,0 +1,128 @@ +`````{exercise} +:title: Tester si une valeur est solution +:modules: +:recommendedExecutionTime: 6 +:level: Elementary +:chap: chap_equations_Inequalities_linearEquations_ESCP +:involvedConcepts: +:originalSource: +:visibility: All +:variations: +:comment: Échauffement. Tester une valeur par substitution dans les deux membres, puis résoudre l'équation linéaire et donner l'ensemble solution. + +````{python} +import random as rd +from pyxiscience.Mes_fctions_generalistes_bis import pxs_config, pxsl_latex_coefficient as lc + +config_standard = pxs_config() + +# Contraintes : a != 0, b != 0, solution entière non nulle (a | (c - b), c != b). +for _ in range(300): + a = rd.choice([i for i in range(-10, 11) if i not in (0, 1, -1)]) + b = rd.choice([i for i in range(-15, 16) if i != 0]) + c = rd.randint(-15, 15) + if c == b or (c - b) % a != 0: + continue + xSol = (c - b) // a + if xSol == 0: + continue + delta = rd.choice([i for i in range(-3, 4) if i != 0]) + xTest1 = xSol + xTest2 = xSol + delta + break + +bPrime = b - c +verifTest1 = a * xTest1 + b # == c (xTest1 est solution) +verifTest2 = a * xTest2 + b # != c (xTest2 n'est pas solution) +prodTest1 = a * xTest1 +prodTest2 = a * xTest2 + +coefAAff = lc(a) +coefANumAff = lc(a, ones=True) +cstBAff = lc(b, ones=True, sign=True) +cstBPrimeAff = lc(bPrime, ones=True, sign=True) +prodTest1Aff = lc(prodTest1, ones=True) +prodTest2Aff = lc(prodTest2, ones=True) +xTest1ParenAff = str(xTest1) if xTest1 >= 0 else "(%d)" % xTest1 +xTest2ParenAff = str(xTest2) if xTest2 >= 0 else "(%d)" % xTest2 +repSolAff = str(xSol) +repSolParenAff = str(xSol) if xSol >= 0 else "(%d)" % xSol + +globals() +```` + +:::::{question} +:questionType: STQ +:questionId: 0 +:questionIndex: 0 + +::::{questionStatement} +On considère l'équation ${}{{coefAAff}}x {{cstBAff}} = {{c}}$. La valeur $x = {{xTest1}}$ est-elle solution ? Et la valeur $x = {{xTest2}}$ ? Justifier dans chaque cas en substituant la valeur dans les deux membres. +:::: + +::::{questionHint} +Pour tester une valeur, substitue-la dans les deux membres et vérifie si l'égalité est vraie. +:::: + +::::{detailedSolution} +Test de $x = {{xTest1}}$ : + +\begin{equation*} +{{coefANumAff}} \times {{xTest1ParenAff}} {{cstBAff}} &= {{prodTest1Aff}} {{cstBAff}} \\ +&= {{verifTest1}}. +\end{equation*} + +Le membre de droite vaut ${}{{c}}$. Comme ${}{{verifTest1}} = {{c}}$, l'égalité est vraie : $x = {{xTest1}}$ est solution. + +Test de $x = {{xTest2}}$ : + +\begin{equation*} +{{coefANumAff}} \times {{xTest2ParenAff}} {{cstBAff}} &= {{prodTest2Aff}} {{cstBAff}} \\ +&= {{verifTest2}}. +\end{equation*} + +Le membre de droite vaut ${}{{c}}$. Comme ${}{{verifTest2}} \neq {{c}}$, l'égalité est fausse : $x = {{xTest2}}$ n'est pas solution. +:::: + +::::{weightDistribution} +:logic: 15 +:abstraction: 15 +:reasoning: 25 +:calculation: 45 +:::: +::::: + +:::::{question} +:questionType: STQ +:questionId: 1 +:questionIndex: 1 + +::::{questionStatement} +Donner l'ensemble solution de l'équation ${}{{coefAAff}}x {{cstBAff}} = {{c}}$. +:::: + +::::{questionHint} +Mets l'équation sous la forme $ax + b = 0$, puis applique $x = -b/a$. +:::: + +::::{detailedSolution} +En soustrayant ${}{{c}}$ des deux membres, l'équation s'écrit ${}{{coefAAff}}x {{cstBPrimeAff}} = 0$, avec $a = {{a}} \neq 0$ et $b = {{bPrime}}$. Par le théorème d'unicité : + +\begin{equation*} +x &= -\dfrac{b}{a} \\ +&= -\dfrac{ {{bPrime}} }{ {{a}} } \\ +&= {{repSolAff}}. +\end{equation*} + +Vérification : ${}{{coefANumAff}} \times {{repSolParenAff}} {{cstBAff}} = {{c}}$. L'ensemble solution est $\{ {{repSolAff}} \}$. +:::: + +::::{weightDistribution} +:logic: 12 +:abstraction: 18 +:reasoning: 25 +:calculation: 45 +:::: +::::: + +````` diff --git a/app/knowledge/fewshots/figure.md b/app/knowledge/fewshots/figure.md new file mode 100644 index 0000000000000000000000000000000000000000..c458e37f1636ea076b3b86ab5ce58d90340d9249 --- /dev/null +++ b/app/knowledge/fewshots/figure.md @@ -0,0 +1,226 @@ +`````{exercise} +:title: Seuil de rentabilité d'une entreprise +:modules: +:recommendedExecutionTime: 18 +:level: Advanced +:chap: +:involvedConcepts: +:originalSource: +:visibility: All +:variations: +:comment: Appliqué (économie & gestion) : profit comme différence recette − coût, seuil de rentabilité, interprétation des signes et lecture graphique. + +````{python} +import random as rd +import math +import matplotlib.pyplot as plt +from sympy import symbols, latex, Rational +from pyxiscience.Mes_fctions_generalistes_bis import pxs_config, pxsl_latex_coefficient as lc +from pyxiscience.Mes_fctions_probabilistes_bis import pxsl_res_num + +config_standard = pxs_config() +q = symbols('q') + +# Tirage avec contraintes +for _ in range(2000): + prix_unitaire = rd.choice(range(40, 105, 5)) + cout_variable = rd.choice(range(10, 55, 5)) + if prix_unitaire <= cout_variable: + continue + cout_fixe = rd.choice(range(500, 3100, 100)) + coef_profit = prix_unitaire - cout_variable + q_seuil = Rational(cout_fixe, coef_profit) + q_test_1 = rd.choice([10, 15, 20, 25, 30, 35, 40]) + q_test_2 = rd.choice([50, 55, 60, 65, 70, 75, 80]) + if q_test_1 < q_seuil < q_test_2: + break + +profit_expr = coef_profit * q - cout_fixe +q_seuil_entier = math.ceil(cout_fixe / coef_profit) +qSeuilExactAff = latex(q_seuil) +qSeuilDecimalAff = pxsl_res_num(q_seuil, dec=2, egal=False) + +# Marges et profits aux points de test +marge_un = coef_profit * q_test_1 +marge_deux = coef_profit * q_test_2 +profit_un = marge_un - cout_fixe +profit_deux = marge_deux - cout_fixe + +# Chaînes d'affichage +piMidUn = f"{prix_unitaire}q - ({cout_fixe} + {cout_variable}q)" +piMidDeux = f"{prix_unitaire}q - {cout_fixe} - {cout_variable}q" +piFinalAff = f"{coef_profit}q {lc(-cout_fixe, ones=True, sign=True)}" +line1Un = f"{coef_profit}\\times {q_test_1} - {cout_fixe}" +line2Un = f"{marge_un} - {cout_fixe}" +line1Deux = f"{coef_profit}\\times {q_test_2} - {cout_fixe}" +line2Deux = f"{marge_deux} - {cout_fixe}" + +# Variables injectées (sans underscore) +prixUnit = prix_unitaire +coutVar = cout_variable +coutFixe = cout_fixe +coefProfit = coef_profit +qTestUn = q_test_1 +qTestDeux = q_test_2 +qSeuilEntier = q_seuil_entier +profitUn = profit_un +profitDeux = profit_deux + +# Figure (construite une seule fois dans le bloc unique) +q_seuil_f = float(q_seuil) +fig, ax = plt.subplots(figsize=(6, 5)) +q_vals = [0, 100] +R_vals = [prix_unitaire * v for v in q_vals] +C_vals = [cout_fixe + cout_variable * v for v in q_vals] +ax.plot(q_vals, R_vals, 'b-', linewidth=2, label=f'$R(q)={prix_unitaire}q$') +ax.plot(q_vals, C_vals, 'r-', linewidth=2, label=f'$C(q)={cout_fixe}+{cout_variable}q$') +y_seuil = prix_unitaire * q_seuil_f +ax.plot([q_seuil_f, q_seuil_f], [0, y_seuil], 'k--', linewidth=1) +ax.plot([0, q_seuil_f], [y_seuil, y_seuil], 'k--', linewidth=1) +ax.plot(q_seuil_f, y_seuil, 'ko', markersize=6) +ax.text(q_seuil_f, y_seuil + 200, '$q_0$', ha='center') +ax.set_xlabel('$q$ (unités)', fontsize=11) +ax.set_ylabel('Euros', fontsize=11) +ax.set_xlim(-5, 105) +ax.set_ylim(-200, max(R_vals[-1], C_vals[-1]) + 500) +ax.grid(True, alpha=0.3) +ax.legend(fontsize=10) +ax.axhline(y=0, color='k', linewidth=0.8) +ax.axvline(x=0, color='k', linewidth=0.8) +plt.tight_layout() +plt.show() + +globals() +```` + +:::::{question} +:questionType: STQ +:questionId: 0 +:questionIndex: 0 + +::::{questionStatement} +Une entreprise produit et vend $q\geq 0$ unités d'un bien. La recette est $R(q)={{ prixUnit }}q$ euros et le coût total est $C(q)={{ coutFixe }} + {{ coutVar }}q$ euros. Écrire la fonction profit $\Pi(q)=R(q)-C(q)$ et simplifier. +:::: + +::::{questionHint} +$\Pi(q)=R(q)-C(q)$ : penser à distribuer le signe moins sur les deux termes de $C(q)$. +:::: + +::::{detailedSolution} +Règle utilisée : différence de deux fonctions, puis regroupement des termes. + +\begin{equation*} +\Pi(q) &= {{ piMidUn }} \\ +&= {{ piMidDeux }} \\ +&= {{ piFinalAff }}. +\end{equation*} +:::: + +::::{weightDistribution} +:logic: 15 +:abstraction: 20 +:reasoning: 30 +:calculation: 35 +:::: +::::: + +:::::{question} +:questionType: STQ +:questionId: 1 +:questionIndex: 1 + +::::{questionStatement} +Déterminer le seuil de rentabilité $q_0$, c'est-à-dire la valeur de $q$ pour laquelle $\Pi(q)=0$. +:::: + +::::{questionHint} +Résoudre l'équation du premier degré ${}{{ piFinalAff }}=0$. +:::: + +::::{detailedSolution} +On résout $\Pi(q_0)=0$. + +\begin{equation*} +{{ coefProfit }}q_0 - {{ coutFixe }} &= 0 \\ +q_0 &= \frac{ {{ coutFixe }} }{ {{ coefProfit }} } \\ +&= {{ qSeuilExactAff }} \approx {{ qSeuilDecimalAff }}. +\end{equation*} + +L'entreprise atteint le seuil de rentabilité à partir de ${}{{ qSeuilEntier }}$ unités vendues (arrondi au nombre entier supérieur). +:::: + +::::{weightDistribution} +:logic: 20 +:abstraction: 25 +:reasoning: 30 +:calculation: 25 +:::: +::::: + +:::::{question} +:questionType: STQ +:questionId: 2 +:questionIndex: 2 + +::::{questionStatement} +Calculer $\Pi({{ qTestUn }})$ et $\Pi({{ qTestDeux }})$, puis interpréter. +:::: + +::::{questionHint} +Substituer chaque valeur dans $\Pi(q)={{ piFinalAff }}$ et comparer avec le seuil $q_0$. +:::: + +::::{detailedSolution} +On évalue la fonction profit. + +\begin{equation*} +\Pi({{ qTestUn }}) &= {{ line1Un }} \\ +&= {{ line2Un }} \\ +&= {{ profitUn }}. +\end{equation*} + +\begin{equation*} +\Pi({{ qTestDeux }}) &= {{ line1Deux }} \\ +&= {{ line2Deux }} \\ +&= {{ profitDeux }}. +\end{equation*} + +Pour $q={{ qTestUn }}q_0$, elle réalise un bénéfice de ${}{{ profitDeux }}$ euros. +:::: + +::::{weightDistribution} +:logic: 15 +:abstraction: 20 +:reasoning: 30 +:calculation: 35 +:::: +::::: + +:::::{question} +:questionType: STQ +:questionId: 3 +:questionIndex: 3 + +::::{questionStatement} +Sur la figure ci-dessus, identifier graphiquement le seuil de rentabilité comme l'intersection des courbes de $R$ et $C$. +:::: + +::::{questionHint} +Le seuil de rentabilité est le point où recette = coût, c'est-à-dire l'intersection des deux droites. +:::: + +::::{detailedSolution} +Au seuil de rentabilité, les courbes de la recette $R$ et du coût total $C$ se croisent : à gauche de $q_0$ le coût dépasse la recette (perte), à droite la recette dépasse le coût (bénéfice). + +Sur le graphique, on observe que les deux droites se coupent en $q_0 \approx {{ qSeuilDecimalAff }}$, ce qui correspond au calcul algébrique effectué à la question précédente. Pour $qq_0$, la situation s'inverse et l'entreprise réalise un profit. +:::: + +::::{weightDistribution} +:logic: 25 +:abstraction: 30 +:reasoning: 35 +:calculation: 10 +:::: +::::: + +````` diff --git a/app/knowledge/fewshots/integration.md b/app/knowledge/fewshots/integration.md new file mode 100644 index 0000000000000000000000000000000000000000..af771c454eb729d62c185d04a01a8f9203b80b90 --- /dev/null +++ b/app/knowledge/fewshots/integration.md @@ -0,0 +1,465 @@ +`````{exercise} +:title: {fr}`Étude d'une fonction avec logarithme : limites, variations et intégration par parties`{en}`Study of a function involving a logarithm: limits, variations and integration by parts` +:modules: annale_bac +:recommendedExecutionTime: 30 +:level: Intermediate +:chap: +:involvedConcepts: TYPE_BAC,Integration_by_Parts, Limits, Comparative_Growth +:originalSource: Exercice 4 du baccalauréat Amérique du Nord 21 mai 2026 (sujet 2) +:visibility: All +:variations: +:comment: Exercice d'analyse (fonction x(ln x)^2, deux IPP, théorème de la bijection) - non randomisé. Énoncé et solution officiels ; solution validée par Chabane. +:id: 4e6e2f22-5f5a-11f1-a8a1-0ed8d3b012a9 + +````{python} +import math +import random as rd +from sympy import symbols, exp, ln, sqrt, latex, Rational, nsolve +from pyxiscience.Mes_fctions_generalistes_bis import pxs_config, pxsl_res_num + +config_standard = pxs_config() +x = symbols('x', positive=True, real=True) + +# Fonction et identités officielles : f(x) = x (ln x)^2. +# L'exposant 2 est VERROUILLÉ (l'identité f = 4 (g(sqrt x))^2 de la Q2a, l'expression de f', +# le tableau de variation et les deux IPP avec la limite 1/4 ne valent que pour le carré). +# Seul paramètre randomisable proprement : le second membre de l'équation f(x) = seuil. +f = x * (ln(x)) ** 2 +xCrit = exp(-2) +fMax = 4 * exp(-2) # = e^{-2} * (-2)^2, maximum local sur ]0 ; 1] (~0,541) + +# Second membre entier de l'équation f(x) = seuil : seuil > 4/e^2 (~0,541), +# donc aucune solution sur ]0 ; 1] et une unique solution sur [1 ; +inf[. (Officiel : seuil = 2.) +seuil = rd.randint(1, 6) +alphaApprox = float(nsolve(f - seuil, 3)) +alphaInf = math.floor(alphaApprox * 10) / 10 +alphaSup = round(alphaInf + 0.1, 1) +limInt = Rational(1, 4) + +fMaxAff = latex(fMax, **config_standard) +fMaxDecAff = pxsl_res_num(fMax, dec=2, egal=False) +seuilAff = str(seuil) +alphaApproxAff = pxsl_res_num(alphaApprox, dec=2, egal=False) +alphaInfAff = pxsl_res_num(alphaInf, dec=1, egal=False) +alphaSupAff = pxsl_res_num(alphaSup, dec=1, egal=False) +limIntAff = latex(limInt, **config_standard) + +globals() +```` + +On considère la fonction $f$ définie sur l'intervalle $]0\,;\,+\infty[$ par +\begin{equation*} +f(x) = x(\ln x)^2. +\end{equation*} +On admet que la fonction $f$ est dérivable sur l'intervalle $]0\,;\,+\infty[$. On note $f'$ sa fonction dérivée. + +:::::{question} +:questionType: STQ +:questionId: 0 +:questionIndex: 0 + +::::{questionStatement} +Déterminer la limite de la fonction $f$ en $+\infty$. +:::: + +::::{questionHint} +Écrire $f(x) = x \times (\ln x)^2$ et utiliser la limite de $\ln x$ en $+\infty$, puis un produit de limites. +:::: + +::::{detailedSolution} +Pour tout réel $x > 0$, on a $f(x) = x \times (\ln x)^2$. + +Comme $\ds \lim_{x \to +\infty} \ln x = +\infty$ et $\ds \lim_{x \to +\infty} x = +\infty$, on obtient par produit : +\begin{equation*} +\boxed{\lim_{x \to +\infty} f(x) = +\infty.} +\end{equation*} +:::: + +::::{weightDistribution} +:reasoning: 30 +:logic: 20 +:abstraction: 15 +:calculation: 35 +:::: +::::: + +Pour tout réel $x > 0$, on pose $g(x) = x \ln x$. + +:::::{question} +:questionType: STQ +:questionId: 1 +:questionIndex: 1 + +::::{questionStatement} +Démontrer que, pour tout réel $x > 0$, on a $f(x) = 4\bigl(g(\sqrt{x})\bigr)^2$. +:::: + +::::{questionHint} +Utiliser que $\ln\sqrt{x} = \dfrac{1}{2}\ln x$, puis développer ${}4\bigl(g(\sqrt{x})\bigr)^2$. +:::: + +::::{detailedSolution} +Soit $x \in\, ]0\,;\,+\infty[$. En utilisant $\ln\sqrt{x} = \dfrac{1}{2}\ln x$ : +\begin{equation*} +4\bigl(g(\sqrt{x})\bigr)^2 &= 4\bigl(\sqrt{x}\,\ln(\sqrt{x})\bigr)^2 \\ +&= 4\left(\sqrt{x} \times \tfrac{1}{2}\ln x\right)^2 \\ +&= 4 \times x \times \tfrac{1}{4}(\ln x)^2 \\ +&= x(\ln x)^2 \\ +&= f(x). +\end{equation*} +On a donc bien, pour tout réel $x > 0$ : +\begin{equation*} +\boxed{f(x) = 4\bigl(g(\sqrt{x})\bigr)^2.} +\end{equation*} +:::: + +::::{weightDistribution} +:reasoning: 20 +:logic: 20 +:abstraction: 15 +:calculation: 45 +:::: +::::: + +:::::{question} +:questionType: STQ +:questionId: 2 +:questionIndex: 2 + +::::{questionStatement} +En déduire $\ds \lim_{x\to 0}f(x)$. +:::: + +::::{questionHint} +Par croissances comparées, $\ds\lim_{X\to 0}X\ln X=0$. Composer avec $X=\sqrt{x}$. +:::: + +::::{detailedSolution} +On sait que $\ds\lim_{x\to 0}\sqrt{x}=0$ et, par croissances comparées, $\ds\lim_{X\to 0}X\ln X=0$. + +Par composition, $\ds\lim_{x\to 0}g(\sqrt{x})=\lim_{x\to 0}\sqrt{x}\,\ln(\sqrt{x})=0$. + +D'après la question précédente, $f(x)=4\bigl(g(\sqrt{x})\bigr)^2$, donc par produit : +\begin{equation*} +\boxed{\lim_{x\to 0}f(x)=0.} +\end{equation*} +:::: + +::::{weightDistribution} +:reasoning: 35 +:logic: 25 +:abstraction: 25 +:calculation: 15 +:::: +::::: + +Dans cette question, on étudie les variations de la fonction $f$ sur l'intervalle $]0\,;\,+\infty[$. + +:::::{question} +:questionType: STQ +:questionId: 3 +:questionIndex: 3 + +::::{questionStatement} +Démontrer que, sur l'intervalle $]0\,;\,+\infty[$, $f'(x)=(\ln x)(2+\ln x)$. +:::: + +::::{questionHint} +$f$ est un produit : poser $u(x)=x$ et $v(x)=(\ln x)^2$, puis appliquer $f'=u'v+uv'$. +:::: + +::::{detailedSolution} +La fonction $f$ est de la forme $uv$ avec, pour tout $x\in\,]0\,;\,+\infty[$, $u(x)=x$ et $v(x)=(\ln x)^2$, d'où $u'(x)=1$ et $v'(x)=2\times\dfrac{1}{x}\times\ln x=\dfrac{2\ln x}{x}$. + +Comme $f'=u'v+uv'$, on obtient pour tout $x\in\,]0\,;\,+\infty[$ : +\begin{equation*} +f'(x)&=1\times(\ln x)^2+x\times\dfrac{2\ln x}{x}\\ +&=(\ln x)^2+2\ln x\\ +&=(\ln x)(\ln x+2). +\end{equation*} +Donc, sur l'intervalle $]0\,;\,+\infty[$ : +\begin{equation*} +\boxed{f'(x)=(\ln x)(2+\ln x).} +\end{equation*} +:::: + +::::{weightDistribution} +:reasoning: 20 +:logic: 15 +:abstraction: 10 +:calculation: 55 +:::: +::::: + +:::::{question} +:questionType: STQ +:questionId: 4 +:questionIndex: 4 + +::::{questionStatement} +En déduire les variations de la fonction $f$ sur l'intervalle $]0\,;\,+\infty[$. +:::: + +::::{questionHint} +Étudier séparément le signe de $\ln x$ et celui de ${}2+\ln x$, puis en déduire le signe du produit $f'(x)$. +:::: + +::::{detailedSolution} +On étudie le signe de $f'(x)=(\ln x)(2+\ln x)$. + +$\bullet$ ${}2+\ln x>0\iff \ln x>-2\iff x>\mathrm{e}^{-2}$ ; + +$\bullet$ $\ln x>0\iff x>1$. + +On en déduit le signe de $f'$ sur $]0\,;\,+\infty[$ : + +$\bullet$ sur $]0\,;\,\mathrm{e}^{-2}]$ : $\ln x<0$ et ${}2+\ln x<0$, donc $f'(x)>0$ ; + +$\bullet$ sur $[\mathrm{e}^{-2}\,;\,1]$ : $\ln x<0$ et ${}2+\ln x>0$, donc $f'(x)<0$ ; + +$\bullet$ sur $[1\,;\,+\infty[$ : $\ln x>0$ et ${}2+\ln x>0$, donc $f'(x)>0$. + +Par conséquent, $f$ est strictement croissante sur $]0\,;\,\mathrm{e}^{-2}]$, strictement décroissante sur $[\mathrm{e}^{-2}\,;\,1]$, puis strictement croissante sur $[1\,;\,+\infty[$. +:::: + +::::{weightDistribution} +:reasoning: 25 +:logic: 35 +:abstraction: 15 +:calculation: 25 +:::: +::::: + +:::::{question} +:questionType: STQ +:questionId: 5 +:questionIndex: 5 + +::::{questionStatement} +Donner la valeur exacte du maximum de la fonction $f$ sur l'intervalle $]0\,;\,1]$. +:::: + +::::{questionHint} +Sur $]0\,;\,1]$, $f$ croît jusqu'en $\mathrm{e}^{-2}$ puis décroît : le maximum est $f(\mathrm{e}^{-2})$. Calculer cette valeur. +:::: + +::::{detailedSolution} +Sur l'intervalle $]0\,;\,1]$, la fonction $f$ est croissante sur $]0\,;\,\mathrm{e}^{-2}]$ puis décroissante sur $[\mathrm{e}^{-2}\,;\,1]$ : son maximum est donc atteint en $x=\mathrm{e}^{-2}$. +\begin{equation*} +f(\mathrm{e}^{-2})&=\mathrm{e}^{-2}\bigl(\ln(\mathrm{e}^{-2})\bigr)^2\\ +&=\mathrm{e}^{-2}\times(-2)^2\\ +&={{fMaxAff}}. +\end{equation*} +Le maximum de $f$ sur $]0\,;\,1]$ vaut donc : +\begin{equation*} +\boxed{ {{fMaxAff}} \approx {{fMaxDecAff}}. } +\end{equation*} +:::: + +::::{weightDistribution} +:reasoning: 25 +:logic: 20 +:abstraction: 15 +:calculation: 40 +:::: +::::: + +On considère l'équation $f(x)={{seuilAff}}$. + +:::::{question} +:questionType: STQ +:questionId: 6 +:questionIndex: 6 + +::::{questionStatement} +Justifier que, sur l'intervalle $]0\,;\,+\infty[$, cette équation admet une unique solution. On note $\alpha$ cette solution. +:::: + +::::{questionHint} +Distinguer $]0\,;\,1]$ (comparer le maximum à ${}{{seuilAff}}$) et $[1\,;\,+\infty[$ (appliquer le théorème de la bijection). +:::: + +::::{detailedSolution} +$\bullet$ Sur l'intervalle $]0\,;\,1]$, le maximum de $f$ vaut ${}{{fMaxAff}} \approx {{fMaxDecAff}} < {{seuilAff}}$ : l'équation $f(x)={{seuilAff}}$ n'y admet donc aucune solution. + +$\bullet$ Sur l'intervalle $[1\,;\,+\infty[$, la fonction $f$ est continue (car dérivable) et strictement croissante. De plus $f(1)=0<{{seuilAff}}$ et $\ds\lim_{x\to+\infty}f(x)=+\infty>{{seuilAff}}$. + +D'après le théorème de la bijection (corollaire du théorème des valeurs intermédiaires), l'équation $f(x)={{seuilAff}}$ admet une unique solution sur $[1\,;\,+\infty[$. + +En réunissant les deux intervalles, l'équation $f(x)={{seuilAff}}$ admet une unique solution $\alpha$ sur $]0\,;\,+\infty[$. +:::: + +::::{weightDistribution} +:reasoning: 35 +:logic: 30 +:abstraction: 20 +:calculation: 15 +:::: +::::: + +:::::{question} +:questionType: STQ +:questionId: 7 +:questionIndex: 7 + +::::{questionStatement} +Donner un encadrement de $\alpha$ d'amplitude ${}0{,}1$. +:::: + +::::{questionHint} +Utiliser la calculatrice (ou un tableau de valeurs) pour localiser $\alpha$ entre deux décimaux distants de ${}0{,}1$. +:::: + +::::{detailedSolution} +À l'aide de la calculatrice, $\alpha\approx{{alphaApproxAff}}$. On en déduit l'encadrement d'amplitude ${}0{,}1$ : +\begin{equation*} +\boxed{ {{alphaInfAff}} \leqslant \alpha \leqslant {{alphaSupAff}}. } +\end{equation*} +:::: + +::::{weightDistribution} +:reasoning: 15 +:logic: 15 +:abstraction: 10 +:calculation: 60 +:::: +::::: + +Soit $a$ un nombre réel appartenant à l'intervalle $]0\,;\,1]$. + +:::::{question} +:questionType: STQ +:questionId: 8 +:questionIndex: 8 + +::::{questionStatement} +Donner une interprétation géométrique de $\ds\int_a^1 f(x)\,\mathrm{d}x$. +:::: + +::::{questionHint} +Déterminer le signe de $f$ sur $[a\,;\,1]$, puis relier l'intégrale à une aire. +:::: + +::::{detailedSolution} +Sur l'intervalle $[a\,;\,1]$, la fonction $f$ est positive. L'intégrale $\ds\int_a^1 f(x)\,\mathrm{d}x$ représente donc l'aire, exprimée en unités d'aire, du domaine délimité par la courbe représentative de $f$, l'axe des abscisses et les droites d'équations $x=a$ et $x=1$. +:::: + +::::{weightDistribution} +:reasoning: 30 +:logic: 20 +:abstraction: 40 +:calculation: 10 +:::: +::::: + +:::::{question} +:questionType: STQ +:questionId: 9 +:questionIndex: 9 + +::::{questionStatement} +À l'aide d'une intégration par parties, justifier que : +\begin{equation*} +\int_a^1 f(x)\,\mathrm{d}x=-\dfrac{a^2}{2}(\ln a)^2-\int_a^1 x\ln x\,\mathrm{d}x. +\end{equation*} +:::: + +::::{questionHint} +Poser $u'(x)=x$ et $v(x)=(\ln x)^2$, d'où $u(x)=\dfrac{x^2}{2}$ et $v'(x)=\dfrac{2\ln x}{x}$. +:::: + +::::{detailedSolution} +On pose $u'(x)=x$ et $v(x)=(\ln x)^2$, d'où $u(x)=\dfrac{x^2}{2}$ et $v'(x)=\dfrac{2\ln x}{x}$. Ces fonctions sont continûment dérivables sur $[a\,;\,1]$, donc par intégration par parties : +\begin{equation*} +\int_a^1 f(x)\,\mathrm{d}x&=\left[\dfrac{x^2}{2}(\ln x)^2\right]_a^1-\int_a^1 \dfrac{x^2}{2}\times\dfrac{2\ln x}{x}\,\mathrm{d}x\\ +&=\left(\dfrac{1}{2}(\ln 1)^2-\dfrac{a^2}{2}(\ln a)^2\right)-\int_a^1 x\ln x\,\mathrm{d}x\\ +&=-\dfrac{a^2}{2}(\ln a)^2-\int_a^1 x\ln x\,\mathrm{d}x, +\end{equation*} +ce qui est le résultat demandé. +:::: + +::::{weightDistribution} +:reasoning: 25 +:logic: 20 +:abstraction: 15 +:calculation: 40 +:::: +::::: + +:::::{question} +:questionType: STQ +:questionId: 10 +:questionIndex: 10 + +::::{questionStatement} +En utilisant à nouveau une intégration par parties, démontrer que : +\begin{equation*} +\int_a^1 f(x)\,\mathrm{d}x=-\dfrac{a^2}{2}(\ln a)^2+\dfrac{a^2}{2}\ln a+\dfrac{1}{4}-\dfrac{a^2}{4}. +\end{equation*} +:::: + +::::{questionHint} +Calculer $\ds\int_a^1 x\ln x\,\mathrm{d}x$ par IPP avec $u'(x)=x$ et $v(x)=\ln x$, puis reporter dans la question précédente. +:::: + +::::{detailedSolution} +On calcule d'abord $\ds\int_a^1 x\ln x\,\mathrm{d}x$ par intégration par parties, avec $u'(x)=x$ et $v(x)=\ln x$, d'où $u(x)=\dfrac{x^2}{2}$ et $v'(x)=\dfrac{1}{x}$ : +\begin{equation*} +\int_a^1 x\ln x\,\mathrm{d}x&=\left[\dfrac{x^2}{2}\ln x\right]_a^1-\int_a^1 \dfrac{x^2}{2}\times\dfrac{1}{x}\,\mathrm{d}x\\ +&=-\dfrac{a^2}{2}\ln a-\int_a^1 \dfrac{x}{2}\,\mathrm{d}x\\ +&=-\dfrac{a^2}{2}\ln a-\left[\dfrac{x^2}{4}\right]_a^1\\ +&=-\dfrac{a^2}{2}\ln a-\dfrac{1}{4}+\dfrac{a^2}{4}. +\end{equation*} +En reportant dans le résultat de la question précédente : +\begin{equation*} +\int_a^1 f(x)\,\mathrm{d}x&=-\dfrac{a^2}{2}(\ln a)^2-\left(-\dfrac{a^2}{2}\ln a-\dfrac{1}{4}+\dfrac{a^2}{4}\right)\\ +&=-\dfrac{a^2}{2}(\ln a)^2+\dfrac{a^2}{2}\ln a+\dfrac{1}{4}-\dfrac{a^2}{4}, +\end{equation*} +ce qui est le résultat demandé. +:::: + +::::{weightDistribution} +:reasoning: 20 +:logic: 20 +:abstraction: 10 +:calculation: 50 +:::: +::::: + +:::::{question} +:questionType: STQ +:questionId: 11 +:questionIndex: 11 + +::::{questionStatement} +Déterminer la limite de $\ds\int_a^1 f(x)\,\mathrm{d}x$ quand $a$ tend vers ${}0$. +:::: + +::::{questionHint} +Faire apparaître $a\ln a$ : $a^2(\ln a)^2=(a\ln a)^2$. Par croissances comparées, $\ds\lim_{a\to 0}a\ln a=0$. +:::: + +::::{detailedSolution} +On part de l'expression obtenue à la question précédente. Par croissances comparées, $\ds\lim_{a\to 0}a\ln a=0$, donc : + +$\bullet$ $\ds\lim_{a\to 0}\dfrac{a^2}{2}(\ln a)^2=\lim_{a\to 0}\dfrac{1}{2}(a\ln a)^2=0$ ; + +$\bullet$ $\ds\lim_{a\to 0}\dfrac{a^2}{2}\ln a=\lim_{a\to 0}\dfrac{a}{2}\times a\ln a=0$ ; + +$\bullet$ $\ds\lim_{a\to 0}\left(\dfrac{1}{4}-\dfrac{a^2}{4}\right)=\dfrac{1}{4}$. + +Par somme : +\begin{equation*} +\boxed{\lim_{a\to 0}\int_a^1 f(x)\,\mathrm{d}x={{limIntAff}}.} +\end{equation*} +:::: + +::::{weightDistribution} +:reasoning: 30 +:logic: 25 +:abstraction: 20 +:calculation: 25 +:::: +::::: + +````` diff --git a/app/knowledge/fewshots/logexp.md b/app/knowledge/fewshots/logexp.md new file mode 100644 index 0000000000000000000000000000000000000000..e32e11cd0f433012a4695989a90ec1830bea7fe5 --- /dev/null +++ b/app/knowledge/fewshots/logexp.md @@ -0,0 +1,86 @@ +`````{exercise} +:title: Évaluation d'une fonction exponentielle et monotonie +:modules: +:recommendedExecutionTime: 7 +:level: Elementary +:chap: +:involvedConcepts: +:originalSource: +:visibility: All +:variations: +:comment: Échauffement (thème pur) : évaluation de x↦3^x en des entiers (dont les exposants négatifs), passage par (0,1), et monotonie déduite de la base. + +````{python} +import random as rd + +# Construction déterministe : base entière > 1 (donc f croissante), valeurs exactes. +b = rd.choice([2, 3, 4, 5]) + +f1 = b +f2 = b**2 +f3 = b**3 + +fm1Aff = r'\dfrac{1}{' + str(b) + '}' +fm2Aff = r'\dfrac{1}{' + str(b**2) + '}' +monotonie = 'croissante' if b > 1 else 'décroissante' + +globals() +```` + +Soit $f(x)={{ b }}^{x}$. Calculer $f(0)$, $f(1)$, $f(2)$, $f(3)$, puis $f(-1)$ et $f(-2)$. + +:::::{question} +:questionType: STQ +:questionId: 0 +:questionIndex: 0 + +::::{questionStatement} +Soit $f(x)={{ b }}^{x}$. Calculer $f(0)$, $f(1)$, $f(2)$, $f(3)$, puis $f(-1)$ et $f(-2)$. +:::: + +::::{questionHint} +Rappel : $b^{x}=1$ lorsque $x=0$ pour toute base $b>0$, et $b^{-n}=\dfrac{1}{b^{n}}$. +:::: + +::::{detailedSolution} +Pour les exposants positifs ou nuls : $f(0)=1$, $f(1)={{ f1 }}$, $f(2)={{ f2 }}$, $f(3)={{ f3 }}$. + +En particulier $f(0)=1$ : le graphe d'une fonction exponentielle passe toujours par le point $(0,1)$. + +Pour les exposants négatifs : $f(-1)={{ fm1Aff }}$ et $f(-2)={{ fm2Aff }}$. +:::: + +::::{weightDistribution} +:logic: 10 +:abstraction: 15 +:reasoning: 25 +:calculation: 50 +:::: +::::: + +:::::{question} +:questionType: STQ +:questionId: 1 +:questionIndex: 1 + +::::{questionStatement} +En déduire si $f$ est croissante ou décroissante, et justifier à partir de la base. +:::: + +::::{questionHint} +Si $b>1$, la fonction $x\mapsto b^{x}$ est strictement croissante. +:::: + +::::{detailedSolution} +La base est $b={{ b }}>1$, donc $f$ est strictement {{ monotonie }} sur $\mathbb{R}$. On le vérifie sur les valeurs : ${{ fm2Aff }}<{{ fm1Aff }}<1<{{ f1 }}<{{ f2 }}<{{ f3 }}$, soit $f(-2) quantite_totale: + break + +# Rendus +prixA = prix_A +prixR = prix_R +quantiteTotale = quantite_totale +coutTotal = cout_total +prixRSignAff = lc(prix_R, sign=True) +prixRQtSignAff = lc(prix_R * quantite_totale, sign=True) +negPrixRSignAff = lc(-prix_R, sign=True) +prixMoins = prix_A - prix_R +coutMoins = cout_total - prix_R * quantite_totale +coutMoyenAff = latex(Rational(cout_total, quantite_totale)) +rapportUnAff = latex(Rational(1, prix_A)) +rapportDeuxAff = latex(Rational(1, prix_R)) + +# Figure (une seule fois) : droites D1 et D2 dans le plan (a, r) +amax = solA + 4 +xs = np.linspace(-1, amax, 200) +r_d1 = quantite_totale - xs +r_d2 = (cout_total - prix_A * xs) / prix_R +fig, ax = plt.subplots(figsize=(6, 5.5)) +ax.plot(xs, r_d1, color="tab:blue", linewidth=2, label="$D_1:a+r=%d$" % quantite_totale) +ax.plot(xs, r_d2, color="tab:red", linewidth=2, label="$D_2$ (coût total)") +ax.fill([0, quantite_totale, 0], [0, 0, quantite_totale], color="gray", alpha=0.15) +ax.text(quantite_totale * 0.18, quantite_totale * 0.18, "zone réalisable", color="gray", fontsize=9) +ax.plot([solA], [solR], "o", color="black", markersize=7) +ax.text(solA, solR, " $(%d;%d)$" % (solA, solR), fontsize=10, va="top") +ax.axhline(y=0, color="k", linewidth=0.8) +ax.axvline(x=0, color="k", linewidth=0.8) +ax.set_xlabel("$a$ (kg Arabica)", fontsize=11) +ax.set_ylabel("$r$ (kg Robusta)", fontsize=11) +ax.set_title("Intersection hors du domaine réalisable", fontsize=11) +ax.grid(True, alpha=0.3) +ax.legend(fontsize=9, loc="upper right") +plt.tight_layout() +plt.show() + +globals() +```` + +:::::{question} +:questionType: STQ +:questionId: 0 +:questionIndex: 0 + +::::{questionStatement} +Un responsable logistique veut constituer un assortiment de deux cafés en grains : Arabica à ${}{{ prixA }}$ €/kg et Robusta à ${}{{ prixR }}$ €/kg. Il vise exactement ${}{{ quantiteTotale }}$ kg d'assortiment pour un coût total de ${}{{ coutTotal }}$ €. On note $a$ la quantité d'Arabica (kg) et $r$ celle de Robusta (kg). Modéliser la situation par un système de deux équations à deux inconnues. +:::: + +::::{questionHint} +Une équation pour la quantité totale, une équation pour le coût total. +:::: + +::::{detailedSolution} +Les contraintes de quantité totale et de coût total donnent : + +\begin{equation*} +\begin{cases} +a + r &= {{ quantiteTotale }} & (1) \\ +{{ prixA }}a {{ prixRSignAff }}r &= {{ coutTotal }} & (2) +\end{cases} +\end{equation*} +:::: + +::::{weightDistribution} +:logic: 20 +:abstraction: 35 +:reasoning: 30 +:calculation: 15 +:::: +::::: + +:::::{question} +:questionType: STQ +:questionId: 1 +:questionIndex: 1 + +::::{questionStatement} +Résoudre le système, puis examiner le signe des quantités obtenues afin de déterminer si le plan d'assortiment est réalisable. +:::: + +::::{questionHint} +Les coefficients ne sont pas proportionnels (${}{{ rapportUnAff }}\neq {{ rapportDeuxAff }}$) : le système a une solution unique. Vérifier ensuite que $a\geq 0$ et $r\geq 0$. +:::: + +::::{detailedSolution} +Les rapports des coefficients diffèrent (${}{{ rapportUnAff }}\neq {{ rapportDeuxAff }}$) : droites sécantes, solution unique. De $(1)$ : $r={{ quantiteTotale }}-a$. On substitue dans $(2)$ : + +\begin{equation*} +{{ prixA }}a + {{ prixR }}({{ quantiteTotale }} - a) &= {{ coutTotal }} \\ +{{ prixA }}a {{ prixRQtSignAff }} {{ negPrixRSignAff }}a &= {{ coutTotal }} \\ +{{ prixMoins }}a &= {{ coutMoins }} \\ +a &= {{ solA }}. +\end{equation*} + +\begin{equation*} +r &= {{ quantiteTotale }} - {{ solA }} \\ +&= {{ solR }}. +\end{equation*} + +La solution mathématique est $(a,r)=({{ solA }},{{ solR }})$. Comme $r={{ solR }}<0$, une quantité négative de Robusta n'a pas de sens : le système a bien une solution unique, mais le plan d'assortiment n'est pas réalisable. +:::: + +::::{weightDistribution} +:logic: 25 +:abstraction: 30 +:reasoning: 30 +:calculation: 15 +:::: +::::: + +:::::{question} +:questionType: STQ +:questionId: 2 +:questionIndex: 2 + +::::{questionStatement} +Interpréter économiquement ce résultat : pourquoi ce plan d'assortiment est-il irréalisable ? La situation est représentée graphiquement en tête d'exercice. +:::: + +::::{questionHint} +Comparer le coût moyen visé au prix du café le plus cher. +:::: + +::::{detailedSolution} +Le coût moyen visé serait ${}{{ coutTotal }}/{{ quantiteTotale }}={{ coutMoyenAff }}$ €/kg. Or le café le plus cher (Arabica) ne coûte que ${}{{ prixA }}$ €/kg : aucun mélange de deux cafés à ${}{{ prixA }}$ et ${}{{ prixR }}$ €/kg ne peut atteindre une moyenne de ${}{{ coutMoyenAff }}$ €/kg. Le budget de ${}{{ coutTotal }}$ € est trop élevé pour ${}{{ quantiteTotale }}$ kg. Aucun plan réalisable ($a\geq 0$, $r\geq 0$) ne satisfait les deux contraintes : les droites se coupent en $({{ solA }},{{ solR }})$, hors du domaine de faisabilité (voir la figure en tête d'exercice). +:::: + +::::{weightDistribution} +:logic: 25 +:abstraction: 35 +:reasoning: 30 +:calculation: 10 +:::: +::::: + +````` \ No newline at end of file diff --git a/app/knowledge/functions_catalogue.md b/app/knowledge/functions_catalogue.md new file mode 100644 index 0000000000000000000000000000000000000000..df0eb0e75ed785d4ff96e1812d7822da98df17f1 --- /dev/null +++ b/app/knowledge/functions_catalogue.md @@ -0,0 +1,124 @@ + + +# PyxiScience — Catalogue des fonctions (helpers à utiliser DANS le bloc Python) + +> Workflow : scanner l'exercice → identifier le domaine (analyse / algèbre +> linéaire / proba / formatage) → récupérer ici les fonctions utiles et leur +> signature exacte AVANT d'écrire du code. **Tout affichage passe par un helper +> du catalogue appelé dans le bloc Python, résultat rangé dans une variable +> `…Aff` injectée nue.** Ne jamais réimplémenter une fonction qui existe ici. + +## 0. Conventions critiques + +- **Expression/scalaire → `latex(expr, **config_standard)`** (voie par défaut, + prouvée sur le corpus livré). `pxsl_latex(expr)` existe (convention maison, + réordonne si `reverse=True`) mais n'est pas le défaut. **Matrice → + `pxsl_matrix(...)`. Résultat numérique → `pxsl_res_num(...)`.** +- **`config_standard = pxs_config()`** en tête de bloc, puis `**config_standard` + passé à `latex(...)` (notation `ln`, séparateur FR `,`, ordre des termes). + `**config_standard` est réservé à `latex()` — **jamais** sur un helper `pxsl_*`. +- **Nom Python réservé** : `total` interdit → `nb_total`. +- **Ne jamais concaténer un signe** : construire la chaîne complète + (`pxsl_latex_coefficient(c, sign=True)`) puis injecter la variable seule. + +## 2. Briques quasi toujours utiles + +1. `pxs_config()` → `config_standard`. +2. `latex(expr, **config_standard)` → toute sortie scalaire/expression. +3. `pxsl_latex_coefficient(c, sign=, ones=)` → coefficients signés sans `1·x`/`+0`. +4. `pxsl_res_num(x, dec=, egal=)` → résultat numérique formaté (`= 0,2354`, `23,54 %`). +5. `pxsl_pow(x, n)` → puissance avec parenthèses si base négative/irrationnelle. + +## 4. Généralistes — formatage & LaTeX (`Mes_fctions_generalistes_bis.py`) + +- **`pxs_config()`** → dict d'options LaTeX (ln, virgule FR, ordre). `**config_standard`. +- **`latex(expr, **config_standard)`** — voie par défaut. `pxsl_latex(expr, reverse=False)` + = variante maison (réordonne du degré faible au fort si `reverse=True`). +- **`pxsl_sign(expr)`** → `'+'` / `'-'` / `''`. Construire un signe à part AVANT injection. +- **`pxsl_format_number(n)`** → milliers en espaces fines, gère `±∞`. (Aucun kwarg — pas d'arrondi, arrondir avant.) `pxsl_format_number(1234)`→`'1\ 234'`. +- **`pxsl_latex_with_formatting(expr, sign=False, display=True)`** → `latex()` + milliers (≥1000), y compris dans les fractions. +- **`pxsl_latex_coefficient(coeff, variable=None, sign=False, zeros=True, ones=False, display=True)`** → gère `1`→`''`, `-1`→`'-'` (ou `'-1'` si `ones=True`), signe explicite. Alias `lc`. +- **`pxsl_to_rational_or_symbol(value)`** → `Rational` SymPy précis (via Fraction) ; laisse un Symbol. +- **`pxsl_solve_general_inequality(a=1, b=0, c=0, variable='x', inequality='>=', domain='R', power=1, sign_a=None)`** → résout `a·x^p+b ⋛ c` (p=1 ou 2) + rédaction LaTeX complète. Retour `(solution_set, latex_reasoning)`. +- **`pxsl_Rational(num, den, orientation='v', display=True)`** → fraction « propre » `π/2`, `x/2` (simplifie le numérique, garde le symbolique). ⚠️ ZeroDivisionError si den=0. +- **`pxs_is_reductible_sqrt(x)`** → bool (carré parfait sous la racine). +- **`pxs_separate_factors(expr, var)`** → `(indépendants de var, dépendants de var)`. + +## 5. Analyse — intégration par parties (`Mes_fctions_d_analyse_bis.py`) + +- **`pxs_explain_IBP(var=Symbol('x'), f1=None, f2=None, type_int='udv', a=None, b=None, nb_IBP=1, intro=True, conclude=True)`** → **rédaction LaTeX complète** d'un calcul par IPP (intégrale définie `a,b` ou primitive ; une ou deux IPP). Pour « calculer ∫ u·v′ par IPP ». `type_int='udv'`/`'vdu'`. Retour `str` LaTeX prêt à injecter. + Ex. `ipp = pxs_explain_IBP(f1=x, f2=exp(x), type_int='udv', a=0, b=1)` → `{{ipp}}`. +- **`pxsl_par(expr, minus=False, add=False)`** → parenthèses si l'expression commence par `-` ou est une somme (`Add`). Sécurise un facteur. +- **`pxsl_final_sentence(sol, a, b, var, mult, *args)`** → phrase finale d'une IPP multi-étapes. + +## 6. Algèbre linéaire — matrices & systèmes (`Mes_fctions_d_alg_lineaire_bis.py`) + +> `pxsl_*` AFFICHENT (LaTeX) ; `pxs_*` GÉNÈRENT/CALCULENT. `sepG`/`sepD` = +> délimiteurs (`'('`/`')'` défaut ; `'|'`/`'|'` pour un déterminant). + +- **`pxsl_pow(x, n=1, opt=0, displaystyle=True)`** → `x^n` avec parenthèses si base négative/irrationnelle. `pxsl_pow(-3,2)`→`'\left(-3\right)^{2}'`. +- **`pxsl_matrix(A, sepG='(', sepD=')', display=False)`** → matrice LaTeX (nombres alignés à droite) ; `'|','|'` → déterminant. +- **`pxsl_sum_matrix(A, B, s='+')`** → détail case par case de `A±B`. +- **`pxsl_prod_scalar_matrix(lamb, A, mult='times')`** → détail de `λ·A`. +- **`pxsl_prod_matrix(A, B, mult='times')`** → détail du produit `A·B`. +- **`pxsl_ax(a, x=Symbol('x'), sign=' ', frac=True)`** → `ax` en gérant `a=0/1/−1` ; `x` symbole quelconque (`L_{1}`). +- **`pxsl_system_lin(A, B, x='x')`** → système `Ax=B` en forme accolade. +- **`pxsl_double_matrix(A, B, opt='sep')`** → `A` et `B` côte à côte (`'sep'`) ou matrice augmentée (`'ext'`). +- **`pxsl_lines_op(n, listOp, opt='sys')`** → opération `L_{i} ← a·L_{i}+b·L_{j}` depuis `listOp=[a,i,b,j]`. +- **`pxsl_resol_system(listA, listB=[], listOp=[], x='x', method='sys', view='sep')`** → chaque étape d'une élimination (`method` : `'sys'`/`'mat'`/`'ech'`). +- **`pxs_steps_invert_matrix(A, B, method='sys')`** → résolution de système (`B`) ou **inversion** (`B=eye(n)`, `method='mat'`), rédaction LaTeX complète. +- **`pxs_compute_ech(A)` / `pxs_compute_ech_reduite(A)`** → forme échelonnée / **RREF** avec étapes en LaTeX. (Équivalents de `pxs_construct_RREF`.) +- **`pxs_system_simpl(n=3, opt='sys', max_coef=2, limit_sum=15)`** → génère `(A, B)` d'un système `Ax=B` à solution entière simple. +- **`pxs_commute_matrix(n, opt='')`** → `(A,B)` qui commutent et `(A,C)` qui non. +- **`pxsl_pow_matrix(A, k)`** → chaque coefficient élevé à la puissance `k`. +- **`pxs_invertible_matrix(n)`** / **`pxs_diag_matrix(p, a, b)`** / **`randmatrixrect(p, q, a, b)`** → matrice inversible / diagonale / rectangulaire aléatoire (coeffs entiers). + +## 7. Probabilités — variables aléatoires finies (`Mes_fctions_probabilistes_bis.py`) + +> v.a. finies SymPy (`stats.FiniteRV`). `pxsl_*` affichent ; `pxs_*` construisent. + +- **`pxs_finiterv(x, val, prob)`** → v.a. `x` de valeurs `val` et probas `prob`. Ex. `X = pxs_finiterv('X', [0,1,2], [Rational(1,4),Rational(1,2),Rational(1,4)])`. +- **`pxsl_law(textx, textprob, X, frac='', nzero=True)`** → tableau de loi (valeurs / probas) en LaTeX. `textx`/`textprob` = en-têtes. +- **`pxsl_moment(X, n=1, prod='times')`** → calcul détaillé de `E[Xⁿ]` (espérance/variance). +- **`pxsl_scalar_product(a, b, prod='times')`** → calcul de `a·b = Σ aᵢbᵢ`. +- **`pxs_simul_law(n, type_proba='dec', prec=0.01)`** → simule une loi discrète de taille n. +- **`pxs_fct_finiterv(f, X)`** → v.a. `Y = f(X)`. Ex. `Y = pxs_fct_finiterv(lambda t: t**2, X)`. +- **`pxsl_res_num(x, dec=4, pourc=False, text=False, egal=True, dot=True)`** → résultat numérique (`= 0,2354`, `23,54 %`). `egal=False` retire le préfixe. +- **`pxsl_sum_vector(x)`** → `Σ xᵢ` en LaTeX. +- **`pxs_nvirgzero(x)`** → supprime `.0` d'un float entier (tolérance 1e-10). +- **`pxsl_pow(...)`** aussi présent ici (identique à §6). + +## 8. Utilitaires complémentaires (présence runtime à vérifier) + +⚠️ NON présents dans le corpus embarqué — ne les utiliser que si la plateforme +les charge au runtime (sinon coder l'équivalent à la main) : +- **`indi_l_r_symb(l, alpha, beta, r)`** → fonction indicatrice symbolique `1_{[α,β]}`. `f = indi_l_r_symb('[',0,1,']'); f(x)`. +- **`pxs_round(x, ndigits=0)`** → arrondi « calculatrice » (half-up, pas banker's). `pxs_round(0.125,2)`→`0.13`. +- **`Poly_with_random_coef(symbol, deg, constant_coef)`** → polynôme aléatoire (coeffs `[-9,9]`) + LaTeX croissant/décroissant. + +## 3. Primitives plateforme (référencées, non dans ces modules) + +- **`{{ var }}`** — interpolation MyST (un nom de variable nu, voir règles). +- **`myst(...)`** — rendu plateforme (stubbé en local). Les `\fr{}\en{}` legacy ne + rendent pas → utiliser `` {fr}`…`{en}`…` ``. +- **`pxsl_mat(...)`** — alias plateforme ; équivalent présent = **`pxsl_matrix`**. +- **`pxs_Interval(...)`** — affichage d'intervalle (présent dans Classes_Extensions.py). diff --git a/app/knowledge/prompts_v1_archive.md b/app/knowledge/prompts_v1_archive.md new file mode 100644 index 0000000000000000000000000000000000000000..09d702aa23721c552349e2e926a1bfeb2b1c4ca3 --- /dev/null +++ b/app/knowledge/prompts_v1_archive.md @@ -0,0 +1,1047 @@ +# Archive v1 des prompts LLM (extraits verbatim de routes/pythonise_routes_v2.py avant refonte 2026-06-12) + +## STEP1_PROMPT (v1) +# ───────────────────────────────────────────────────────────────────────────── +# v1 → version antérieure conservée dans l'historique git (commit avant 2026-05-13). +# Cette version étend la sortie JSON avec target_rules / property_constraints / +# has_validated_solution_in_input afin que STEP_PAIR_PROMPT puisse injecter +# un digest ciblé des règles de pythonisation_rules.md à risque pour cet exo. +STEP1_PROMPT = """\ +Tu es un expert en analyse d'exercices mathématiques & detection des variables pythons pour PyxiScience. +Permettant de passer d'un exercice avec des valeurs statiques à un exercice avec des valeurs aléatoires **correctes**. + +EXERCICE : +{content} + +───────────────────────────────────────────────────── +MISSION : Analyse cet exercice et identifie **TOUTES** les entités mathématiques +qui devront être générées aléatoirement en Python pour produire des variables aléatoires python valides. + +Couvre TOUS les types possibles : + • Scalaires entiers (ex: coefficients, dimensions, scores) + • Scalaires réels (ex: probabilités, longueurs) + • Fractions (ex: 3/4, -2/5) + • Listes / tableaux (ex: liste de notes, de mesures) + • Vecteurs (ex: (3, -2, 1)) + • Matrices (ex: matrice 2×2, 3×3) + • Ensembles (ex: Ω = {{1,2,3,4,5,6}}) + • Polynômes (ex: ax² + bx + c → coefficients a,b,c) + • Fonctions (ex: f(x) = x·e^(ax)) + • Pourcentages (ex: 25%, 50%) + • Autres entités (ex: angles, intervalles) + +Pour chaque variable : + • nom : nom Python valide court (ex: a, b, n, mat_A, liste_notes), + cette variable peut être partagée entre plusieurs questions + • type_python : "int"|"float"|"Fraction"|"list"|"matrix"|"set"|"vector"|"other" + • description : rôle dans l'énoncé (1 phrase) + • contraintes : contraintes mathématiques (ex: a ≠ 0, n ∈ [2,10]) + • plage_python : expression Python exacte de génération aléatoire + • location : "énoncé"|"inter-question"|"question"|"solution 1"|"solution 2"|"solution 3"|"solution 4"|"solution 5" + les emplacements de l'apparition de la variable dans les differentes parties de l'exercice. + "énoncé" = texte d'introduction avant la 1ère question. + "inter-question" = texte entre deux blocs question (transitions, hypothèses partagées). + • valeur_exemple: valeur typique + +───────────────────────────────────────────────────── +RÈGLES DE PYTHONISATION (catalogue) — choisis dans "target_rules" celles qui +sont LE PLUS À RISQUE pour CET exercice spécifique (5 à 12 IDs maximum). +Une règle est "à risque" si l'énoncé contient un piège typique (tirages flottants, +matplotlib, coefficients signés, paragraphes contextuels, bilingue long, etc.). + +{available_rules_menu} + +───────────────────────────────────────────────────── +Réponds UNIQUEMENT en JSON valide : +{{ + "exercise_type": "...", + "exercise_title": "...", + "exercise_summary": "...", + "suggested_concepts": ["..."], + "nb_questions": 1, + "variables": [ + {{ + "nom": "...", + "type_python": "...", + "description": "...", + "contraintes": "...", + "location": "énoncé|question|solution 1 ", + "plage_python": "...", + "valeur_exemple": "..." + }} + ], + "needs_fraction": false, + "needs_sympy": false, + "needs_numpy": false, + "needs_matplolib": false, + "mathematical_structure": "...", + "target_rules": ["3.1", "4.1", "6.1"], + "property_constraints": [ + "", + "" + ], + "has_validated_solution_in_input": false +}} + +Notes : + • "target_rules" : uniquement des IDs présents dans le catalogue ci-dessus. + Sélectionne celles qui CET exercice risque de violer (top 5-12). + • "property_constraints" : invariants à préserver lors du tirage aléatoire + (cf. règle 4.3). Liste vide si aucun. + • "has_validated_solution_in_input" : true SI l'énoncé contient déjà des + blocs `::::{{detailedSolution}}` rédigés (cf. règles 8.1–8.3). +""" + +## STEP_PAIR_PROMPT (v1) +# STEP 2+ – Per-pair generation (python block + énoncé/inter-text + questions) +# ───────────────────────────────────────────────────────────────────────────── + +""" +STEP_PAIR_PROMPT — Restructuré pour forcer l'usage du catalogue PyxiScience +ET pour pythoniser l'énoncé + les textes inter-question. + +Changements majeurs vs version précédente : + • La SECTION À PYTHONISER peut désormais contenir, en plus des blocs + :::::{question}, du texte d'énoncé (1ère paire) et du texte + inter-question (avant chaque question d'une paire), à réécrire avec + injections de variables. + • Catalogue et règles de compliance déplacés au TOUT DÉBUT du prompt + (exploit du biais primacy : les LLMs retiennent mieux ce qui arrive + en tête). + • Imports PyxiScience NE SONT PLUS hardcodés — le modèle doit les + recopier depuis le catalogue pour les fonctions qu'il utilise. + • Exemples concrets ❌/✅ de violation au lieu de règles abstraites. + • Suppression des phrases "ne pas sur-optimiser / ne pas complexifier" + qui poussaient le modèle à faire l'inverse du souhaité. + • Règles MyST et structure de question conservées intactes. +""" + +STEP_PAIR_PROMPT = """\ +Tu es un expert en pythonisation d'exercices PyxiScience MyST, niveau {niveau}. +Mission : transformer un exercice statique (paramètres fixes) en version pythonisée +(paramètres randomisés + injectés dans le MyST). + +═══════════════════════════════════════════════════════════════════════════ + PRINCIPE FONDAMENTAL — PYTHON = VALEURS, MyST = TEXTE +═══════════════════════════════════════════════════════════════════════════ + +Bloc Python = imports + tirages aléatoires + calculs sympy + `globals()` final. +Variables = VALEURS brutes (nombres, expressions sympy, intervalles). +**Aucun texte pédagogique en chaîne Python.** + +Texte (énoncés, hypothèses, démonstrations) = directement dans le MyST des +`questionStatement` / `detailedSolution`. Valeurs injectées via : + • `{{{{var}}}}` — nom de variable simple + • `{{{{latex(expr)}}}}` — expression sympy (config_standard est injecté automatiquement par post-process) + • `{{{{pxsl_res_num(val, egal=False)}}}}` — résultat numérique + • `{{{{pxsl_format_number(n)}}}}` — nombre formaté + • `{{{{lc(c, sign=True)}}}}` — coefficient (alias `pxsl_latex_coefficient`) + • `{{{{obj.print()}}}}` — intervalle, polynôme + +⚠️ **JAMAIS de `**kwargs` dans `{{{{...}}}}`** (cf. règle 6.1). MyST parse `**` comme bold +markdown avant l'interpolation Python — le rendu casse. Si tu as besoin d'arguments, +pré-calcule la string dans le bloc python : `coef_disp = pxsl_format_number(float(coef), **config_standard)` +puis injecte `{{{{coef_disp}}}}`. + +❌ ANTI-PATTERN — pré-construire la solution pédagogique en variable Python : +```{{python}} +calcul_q2 = myst(r'''\begin{{equation*}} P(A_n) &= ... \end{{equation*}}''', globals(), locals()) +``` +Puis `{{{{calcul_q2}}}}` dans le MyST → toute la pédagogie quitte le fichier. +**REJETÉ.** Idem pour `*_latex`, `*_display` qui pré-stockent un rendu. + +✅ EXCEPTION — `myst(r'''...''', globals(), locals())` est légitime UNIQUEMENT +quand le TEXTE varie selon une condition Python (ex: signe d'un coefficient, +`pxs_variation_number == N`). Pattern : variables `xxx_fr` / `xxx_en` SANS +directives bilingues internes, injectées dans des parents `:::{{fr}}/:::{{en}}`. + +═══════════════════════════════════════════════════════════════════════════ + EXEMPLE MINIMAL — Signe d'un trinôme (branchement conditionnel + intervalles) +═══════════════════════════════════════════════════════════════════════════ + +```{{python}} +import random as rd +from sympy import symbols, sqrt, latex, simplify, oo +from pyxiscience.Mes_fctions_generalistes_bis import pxs_config, pxsl_latex_coefficient as lc +from pyxiscience.Classes_Extensions import pxs_Interval + +config_standard = pxs_config() +x = symbols('x') + +# Contrainte : a > 0 et c < 0 garantit delta > 0 (deux racines réelles) +a = rd.randint(1, 3) +b = rd.randint(2, 6) +c = rd.randint(-4, -1) + +T = a*x**2 + b*x + c +delta = b**2 - 4*a*c +x1 = (-b - sqrt(delta)) / (2*a) +x2 = (-b + sqrt(delta)) / (2*a) + +intervalle_entre = pxs_Interval(x1, x2, True, True) +intervalle_avant = pxs_Interval(-oo, x1, True, True) +intervalle_apres = pxs_Interval(x2, oo, True, True) + +globals() +``` + +:::{{fr}} +On considère le trinôme défini pour tout réel $x$ par +\begin{{equation*}} +T(x) = {{{{latex(T)}}}}. +\end{{equation*}} +::: +:::{{en}} +We consider the quadratic polynomial defined for every real $x$ by +\begin{{equation*}} +T(x) = {{{{latex(T)}}}}. +\end{{equation*}} +::: + +:::::{{question}} +:questionType: STQ +:questionId: 0 +:questionIndex: 0 + +::::{{questionStatement}} +{{fr}}`Calculer le discriminant $\Delta$ et en déduire le nombre de racines.`{{en}}`Compute $\Delta$ and deduce the number of real roots.` +:::: + +::::{{questionHint}} +{{fr}}`$\Delta = b^2 - 4ac$.`{{en}}`$\Delta = b^2 - 4ac$.` +:::: + +::::{{detailedSolution}} +{{fr}}`Application numérique :`{{en}}`Numerical application:` +\begin{{equation*}} +\Delta &= {{{{b}}}}^2 - 4 \times {{{{a}}}} \times ({{{{c}}}}) \\ + &= {{{{latex(delta)}}}}. +\end{{equation*}} +{{fr}}`Comme $\Delta = {{{{latex(delta)}}}} > 0$, deux racines réelles distinctes.`{{en}}`Since $\Delta > 0$, two distinct real roots.` +:::: + +::::{{weightDistribution}} +:reasoning: 25 +:logic: 20 +:abstraction: 15 +:calculation: 40 +:::: +::::: + +```{{python}} +# Bloc intermédiaire : phrase qui dépend du signe de a +if a > 0: + text = myst(r""{{fr}}`Comme $a = {{{{a}}}} > 0$, $T$ est négatif entre les racines, positif à l'extérieur :`{{en}}`Since $a = {{{{a}}}} > 0$, $T$ is negative between the roots and positive outside:`"", globals(), locals()) +else: + text = myst(r""{{fr}}`Comme $a = {{{{a}}}} < 0$, $T$ est positif entre les racines, négatif à l'extérieur :`{{en}}`Since $a = {{{{a}}}} < 0$, $T$ is positive between the roots and negative outside:`"", globals(), locals()) +``` + +:::::{{question}} +:questionType: STQ +:questionId: 1 +:questionIndex: 1 + +::::{{questionStatement}} +{{fr}}`Étudier le signe de $T(x)$.`{{en}}`Study the sign of $T(x)$.` +:::: + +::::{{questionHint}} +:::: + +::::{{detailedSolution}} +{{{{text}}}} + +{{fr}}` - $T(x) < 0$ sur $\ds {{{{intervalle_entre.print()}}}}$ ;` +{{en}}` - $T(x) < 0$ on $\ds {{{{intervalle_entre.print()}}}}$ ;` + +{{fr}}` - $T(x) > 0$ sur $\ds {{{{intervalle_avant.print()}}}} \cup {{{{intervalle_apres.print()}}}}$.` +{{en}}` - $T(x) > 0$ on $\ds {{{{intervalle_avant.print()}}}} \cup {{{{intervalle_apres.print()}}}}$.` +:::: + +::::{{weightDistribution}} +:reasoning: 30 +:logic: 30 +:abstraction: 20 +:calculation: 20 +:::: +::::: +``` + +═══════════════════════════════════════════════════════════════════════════ + EXEMPLE CORPUS — extrait Bac Amérique du Nord (style authentique) +═══════════════════════════════════════════════════════════════════════════ + +```{{python}} +import random as rd +from sympy import * +from pyxiscience.Mes_fctions_generalistes_bis import pxs_config, pxsl_latex_coefficient as lc +from pyxiscience.Classes_Extensions import pxs_Interval + +config_standard = pxs_config() +x = symbols('x') + +# 4 entiers DISTINCTS dans [1,5] +a, b, c, d = rd.sample(range(1, 6), 4) + +f1 = exp(b*x); f2 = sin(b*x) +f = f1 * f2 +df1 = diff(f1, x); df2 = diff(f2, x) + +dom = pxs_Interval(0, pi/b, False, False) + +globals() +``` + +{{fr}}`Soit $f$ définie sur `{{en}}`Let $f$ be defined on `$\ds {{{{dom.print()}}}}$ {{fr}}`par`{{en}}`by` +\begin{{equation*}} +f(x) = {{{{latex(f)}}}}. +\end{{equation*}} + +:::::{{question}} +:questionType: STQ +:questionId: 0 +:questionIndex: 0 + +::::{{questionStatement}} +{{fr}}`Démontrer que pour tout $x$ de `{{en}}`Prove that for all $x$ in `$\ds {{{{dom.print()}}}}$, +\begin{{equation*}} +f'(x) = {{{{b}}}}e^{{{{{{lc(b)}}}}x}}[\sin({{{{lc(b)}}}}x) + \cos({{{{lc(b)}}}}x)]. +\end{{equation*}} +:::: + +::::{{questionHint}} +{{fr}}`Règle du produit avec $u = {{{{latex(f1)}}}}$, $v = {{{{latex(f2)}}}}$.`{{en}}`Product rule with $u = {{{{latex(f1)}}}}$, $v = {{{{latex(f2)}}}}$.` +:::: + +::::{{detailedSolution}} +{{fr}}`$f = u \times v$ avec $u = {{{{latex(f1)}}}}$, $v = {{{{latex(f2)}}}}$. Alors $u' = {{{{latex(df1)}}}}$, $v' = {{{{latex(df2)}}}}$. Donc :`{{en}}`$f = u \times v$ with $u = {{{{latex(f1)}}}}$, $v = {{{{latex(f2)}}}}$. Then $u' = {{{{latex(df1)}}}}$, $v' = {{{{latex(df2)}}}}$. Therefore:` +\begin{{equation*}} +f'(x) &= {{{{latex(df1)}}}} \times {{{{latex(f2)}}}} + {{{{latex(f1)}}}} \times {{{{latex(df2)}}}} \\[6pt] + &= {{{{b}}}}e^{{{{{{lc(b)}}}}x}}[\sin({{{{lc(b)}}}}x) + \cos({{{{lc(b)}}}}x)]. +\end{{equation*}} +:::: + +::::{{weightDistribution}} +:reasoning: 20 +:logic: 20 +:abstraction: 20 +:calculation: 40 +:::: +::::: +``` + +═══════════════════════════════════════════════════════════════════════════ + EXEMPLE CATALOGUE — usage dense des helpers pxsl_* + (Add evaluate=False, lc avec ones=/sign=, pxsl_par avec minus=) +═══════════════════════════════════════════════════════════════════════════ + +```{{python}} +import random as rd +from sympy import * +from pyxiscience.Mes_fctions_generalistes_bis import pxs_config, pxsl_latex_coefficient as lc +from pyxiscience.Mes_fctions_d_analyse_bis import pxs_explain_IBP, pxsl_par, pxsl_mult + +config_standard = pxs_config() +x = symbols('x') +n = Symbol('n') + +a = rd.randint(1, 5) +b = rd.randint(2, 5) +c = rd.randint(a + 1, 9) +d = rd.choice([-1, 1]) * rd.randint(1, 10) + +# Add(..., evaluate=False) préserve la forme non simplifiée pour l'affichage +fx = Add(a*x * exp(-b*x), c*x + d, evaluate=False) + +# Intégration par parties auto-explicative : retourne le LaTeX complet du calcul +# type_int='vdu' = ∫u dv ; bornes [a, b] = [1, n] ; nb_IBP=1 = une IBP suffit +ipp = pxs_explain_IBP(var=x, f1=a*x, f2=exp(-b*x), type_int='vdu', + a=1, b=n, nb_IBP=1, intro=True, conclude=True) +In = -Rational(a, b) * (n + Rational(1, b)) * exp(-b*n) \ + + Rational(a, b) * (1 + Rational(1, b)) * exp(-b) + +globals() +``` + +{{fr}}`Soit $f$ définie sur $\mathbb{{R}}$ par`{{en}}`Let $f$ be defined on $\mathbb{{R}}$ by` +\begin{{equation*}} +f(x) = {{{{lc(a)}}}}xe^{{{{{{lc(-b)}}}}x}} + {{{{lc(c)}}}}x {{{{lc(d, ones=True, sign=True)}}}}. +\end{{equation*}} + +:::::{{question}} +:questionType: STQ +:questionId: 0 +:questionIndex: 0 + +::::{{questionStatement}} +{{fr}}`Calculer $f'(x)$ pour tout $x \in \mathbb{{R}}$.`{{en}}`Compute $f'(x)$ for every $x \in \mathbb{{R}}$.` +:::: + +::::{{questionHint}} +{{fr}}`Règle du produit avec $u = {{{{latex(a*x)}}}}$, $v = \mathrm{{e}}^{{{{{{latex(-b*x)}}}}}}$.`{{en}}`Product rule with $u = {{{{latex(a*x)}}}}$, $v = \mathrm{{e}}^{{{{{{latex(-b*x)}}}}}}$.` +:::: + +::::{{detailedSolution}} +{{fr}}`Avec $u = {{{{lc(a)}}}}x$, $v = e^{{{{{{lc(-b)}}}}x}}$, $w = {{{{lc(c)}}}}x {{{{lc(d, ones=True, sign=True)}}}}$ :`{{en}}`With $u = {{{{lc(a)}}}}x$, $v = e^{{{{{{lc(-b)}}}}x}}$, $w = {{{{lc(c)}}}}x {{{{lc(d, ones=True, sign=True)}}}}$:` + +\begin{{equation*}} +f'(x) &= {{{{latex(a)}}}} \times e^{{{{{{lc(-b)}}}}x}} + {{{{lc(a)}}}}x \times {{{{pxsl_par(-b*exp(-b*x), minus=True)}}}} + {{{{latex(c)}}}} \\[6pt] + &= {{{{lc(a)}}}}\left(1 {{{{lc(-b, sign=True)}}}}x\right)e^{{{{{{lc(-b)}}}}x}} + {{{{lc(c)}}}}. +\end{{equation*}} +:::: + +::::{{weightDistribution}} +:reasoning: 20 +:logic: 15 +:abstraction: 15 +:calculation: 50 +:::: +::::: + +:::::{{question}} +:questionType: STQ +:questionId: 1 +:questionIndex: 1 + +::::{{questionStatement}} +{{fr}}`Pour tout entier $n \geq 1$, on pose $I_n = \int_1^n {{{{lc(a)}}}}xe^{{{{{{lc(-b)}}}}x}}\,\mathrm{{d}}x$. Calculer $I_n$ par intégration par parties.`{{en}}`For every integer $n \geq 1$, let $I_n = \int_1^n {{{{lc(a)}}}}xe^{{{{{{lc(-b)}}}}x}}\,\mathrm{{d}}x$. Compute $I_n$ using integration by parts.` +:::: + +::::{{questionHint}} +{{fr}}`Poser $u = {{{{lc(a)}}}}x$ et $\mathrm{{d}}v = e^{{{{{{lc(-b)}}}}x}}\,\mathrm{{d}}x$.`{{en}}`Set $u = {{{{lc(a)}}}}x$ and $\mathrm{{d}}v = e^{{{{{{lc(-b)}}}}x}}\,\mathrm{{d}}x$.` +:::: + +::::{{detailedSolution}} +{{{{ipp}}}} + +\begin{{equation*}} +\boxed{{I_n = {{{{latex(In)}}}}.}} +\end{{equation*}} +:::: + +::::{{weightDistribution}} +:reasoning: 25 +:logic: 20 +:abstraction: 15 +:calculation: 40 +:::: +::::: +``` + +→ Points clés : `lc(d, ones=True, sign=True)` affiche `+ d` même si `d = ±1` ; + `lc(-b, sign=True)` affiche le signe ; `pxsl_par(expr, minus=True)` parenthèse + une expression à signe négatif ; `pxs_explain_IBP(...)` retourne directement + le LaTeX complet de l'IBP (intro + calcul + conclusion) → injecté via + `{{{{ipp}}}}` dans `detailedSolution`. Pour les puissances, utiliser + `pxsl_pow(base, exp)` plutôt qu'écrire `base^{{exp}}` à la main. + +────────────────────────────────────────────────────────────────────────── + SNIPPET — affichage matriciel via `pxsl_matrix` +────────────────────────────────────────────────────────────────────────── + +```{{python}} +from pyxiscience.Mes_fctions_d_alg_lineaire_bis import pxsl_matrix +A = Matrix([[a, b], [c, d]]) +``` + +\begin{{equation*}} +A = {{{{pxsl_matrix(A, **config_standard)}}}}, \quad \det(A) = {{{{latex(A.det())}}}}. +\end{{equation*}} + +→ `pxsl_matrix` rend le délimiteur (parenthèses/crochets) selon `config_standard`. + Toujours préférer `pxsl_matrix` à `\begin{{pmatrix}}...\end{{pmatrix}}` à la main. + +────────────────────────────────────────────────────────────────────────── + SNIPPET — Loi binomiale + Bienaymé-Tchebychev (pattern Bac, règles 16.2 / 13.1) +────────────────────────────────────────────────────────────────────────── + +Pour tout calcul `P(X ≤ k)`, `P(X ≥ k)`, `P(X = k)` d'une binomiale : +- TOUJOURS via `scipy.stats.binom` (cdf / sf / pmf), JAMAIS sommer à la main. +- Tirage `p` : si la précision n'est pas critique (probabilité affichée à 0.001 près), + `round(rd.uniform(...), 3)` est toléré ; sinon `Fraction(rd.randint(a,b), 20)`. +- Pour `P(X ≥ a | X ≥ b)` avec `a ≥ b`, garantir `b < a` au tirage : + `nb = rd.randint(6, 9); ne = rd.randint(2, nb-3)` puis `binom.sf(nb-1, n, p) / binom.sf(ne-1, n, p)`. +- Seuil Bienaymé-Tchebychev : `math.floor(seuil * 100) / 100` pour le centième inférieur. + +```{{python}} +import random as rd +import math +from scipy.stats import binom +from pyxiscience.Mes_fctions_probabilistes_bis import pxsl_res_num + +n = rd.randint(20, 30) +p = round(rd.uniform(0.20, 0.45), 2) +k = rd.randint(3, 8) +nb = rd.randint(6, 9) +ne = rd.randint(2, nb - 3) # garantit ne < nb + +prob_inf = binom.cdf(k, n, p) # P(X ≤ k) +prob_sup = binom.sf(k - 1, n, p) # P(X ≥ k) +prob_eq = binom.pmf(k, n, p) # P(X = k) +prob_cond = binom.sf(nb - 1, n, p) / binom.sf(ne - 1, n, p) # P(X≥nb | X≥ne) + +esperance = n * p +variance = n * p * (1 - p) +seuil_tcheb = math.floor((1 - variance / (n * p) ** 2) * 100) / 100 + +prob_inf_disp = pxsl_res_num(prob_inf, dec=4, egal=False) +prob_eq_disp = pxsl_res_num(prob_eq, dec=4, egal=False) +prob_cond_disp = pxsl_res_num(prob_cond, dec=4, egal=False) +globals() +``` + +\begin{{equation*}} +P(X \leq {{{{k}}}}) {{{{prob_inf_disp}}}}, \quad P(X = {{{{k}}}}) {{{{prob_eq_disp}}}}. +\end{{equation*}} + +→ `pxsl_res_num(val, dec=N, egal=False)` retourne la chaîne LaTeX `\approx 0.1234` + (ou `= 0.1234` si exact) — pas besoin d'ajouter `=` ou `\approx` à la main. + `binom.sf(k-1, ...)` est strictement `P(X > k-1) = P(X ≥ k)` — surveille les indices. + +══════════════════════════════════════════════════════════════════════════ + SNIPPET — Géométrie 3D (pattern Bac, règle 12.3 — pythonisable) +══════════════════════════════════════════════════════════════════════════ + +Pythoniser un exo de géométrie sans changer le NIVEAU de la question. Tirer les +coordonnées des sommets, mais laisser l'élève DÉMONTRER les propriétés +(orthogonalité, projection, distance) — ne PAS hardcoder la réponse. + +```{{python}} +import random as rd +from sympy import Matrix, Symbol, sqrt, solve, simplify +from pyxiscience.Mes_fctions_d_alg_lineaire_bis import pxsl_matrix + +# Tirage des sommets — 3 entiers distincts ≥ 1 +alpha, beta, gamma = rd.sample(range(1, 5), 3) +A = Matrix([alpha, 0, 0]) +B = Matrix([0, beta, 0]) +C = Matrix([0, 0, gamma]) + +# Vecteurs directeurs +AB = B - A +AC = C - A + +# Projection orthogonale de C sur la droite (AB) +lam = Symbol('lam', real=True) +K = A + lam * (B - A) +CK = K - C +lam_sol = solve(CK.dot(AB), lam)[0] +K_proj = A + lam_sol * (B - A) + +# Distance de C à la droite (AB) +distance_C_AB = simplify(sqrt((K_proj - C).dot(K_proj - C))) +globals() +``` + +→ L'énoncé MyST utilise `{{{{pxsl_matrix(A)}}}}`, `{{{{pxsl_matrix(B)}}}}`, etc. pour les + sommets, et `{{{{latex(lam_sol)}}}}`, `{{{{latex(distance_C_AB)}}}}` pour les valeurs. + Les coordonnées du projeté `K_proj` NE doivent PAS apparaître dans l'énoncé — + c'est ce que l'élève doit calculer. + +══════════════════════════════════════════════════════════════════════════ + SNIPPET — Validation de paramètres avec contraintes (règle 16.1) +══════════════════════════════════════════════════════════════════════════ + +Quand les paramètres ont des contraintes croisées, énumérer toutes les +combinaisons valides en list comprehension puis `rd.choice()` — JAMAIS +`for _ in range(100): ... if cond: break` (peut sortir avec params invalides). + +```{{python}} +import random as rd +from sympy import Rational + +valeurs = [Rational(1,4), Rational(7,25), Rational(3,10), Rational(2,5)] +couples_valides = [ + (p_vv, p_nv) for p_vv in valeurs for p_nv in valeurs + if p_vv > p_nv and Rational(1, 1 - (p_vv - p_nv)).q <= 6 +] +p_vv, p_nv = rd.choice(couples_valides) # garanti valide +``` + +══════════════════════════════════════════════════════════════════════════ + SNIPPET — Helpers de formatage à recopier au besoin (règles 4.6, 7.4, 10.4, 10.5) +══════════════════════════════════════════════════════════════════════════ + +Inclure ces helpers dans le bloc `{{python}}` quand l'exo affiche des +fractions / décimales / valeurs très petites. Ils sont auto-documentés. + +```{{python}} +import math +from fractions import Fraction + +# Règle 10.4 — décimal fini si possible, sinon \\frac{{a}}{{b}} +def _frac_to_latex(f): + if f.denominator == 1: + return str(f.numerator) + d = f.denominator + while d % 2 == 0: d //= 2 + while d % 5 == 0: d //= 5 + if d == 1: + return str(float(f)).replace('.', '{{,}}') + return f"\\\\frac{{{{{{f.numerator}}}}}}{{{{{{f.denominator}}}}}}" + +# Règle 10.5 — notation scientifique pour valeurs < 10⁻⁴ +def _frac_to_str_smart(f, sci_threshold=1e-4): + fl = float(f) + if 0 < abs(fl) < sci_threshold: + exp = int(math.floor(math.log10(abs(fl)))) + mant = fl / (10 ** exp) + mant_str = f"{{mant:.2f}}".rstrip('0').rstrip('.').replace('.', '{{,}}') or "1" + return f"{{mant_str}} \\\\times 10^{{{{{{exp}}}}}}" + return _frac_to_latex(f) + +# Règle 7.4 — format 3 décimales fixes (jamais '0.6', toujours '0,600') +def _fmt_dec3(x): + return f"{{x:.3f}}".replace('.', '{{,}}') + +# Règle 4.7 — développer une chaîne multiplicative dynamique +# (renvoie "start*(start-1)*...*(start-length+1)" en LaTeX, length facteurs) +def _develop_chain(start, length): + return " \\\\times ".join(str(start - i) for i in range(length)) + +# Règle 4.6 — arrondi nul à supprimer (cas notation scientifique) +def _proba_disp(proba_float, exact_str): + if round(proba_float, 3) == 0: + return exact_str # "8,1 × 10⁻⁵" seul + return f"{{exact_str}} \\\\approx {{_fmt_dec3(proba_float)}}" +``` + +→ Tous ces helpers sont OPTIONNELS — ne les inclure que si l'exo les utilise. + Ne PAS les inclure pour un exo qui n'a ni fractions exotiques, ni valeurs + très petites, ni développement de produits. + +══════════════════════════════════════════════════════════════════════════ + SNIPPET — Axes matplotlib "zero spines" (style scolaire FR, règle 16.3) +══════════════════════════════════════════════════════════════════════════ + +Pour les graphes d'étude de fonction, axes positionnés à l'origine et spines +droite/haute masquées. Ne PAS utiliser pour histogrammes/distributions. + +```{{python}} +import matplotlib.pyplot as plt +import numpy as np + +fig, ax = plt.subplots(figsize=(6, 5)) +x = np.linspace(-3, 3, 200) +ax.plot(x, x**2, 'b-', linewidth=1.5) +ax.spines['left'].set_position('zero') +ax.spines['bottom'].set_position('zero') +ax.spines['right'].set_color('none') +ax.spines['top'].set_color('none') +ax.xaxis.set_label_coords(1.02, -0.05) +ax.yaxis.set_label_coords(-0.05, 1.02) +ax.set_xlabel('$x$') +ax.set_ylabel('$y$') +plt.show() +``` + +═══════════════════════════════════════════════════════════════════════════ + RÈGLES TECHNIQUES (compactes) +═══════════════════════════════════════════════════════════════════════════ + +**Catalogue PyxiScience** — tout affichage (nombre, coefficient, intervalle, +puissance, matrice) DOIT passer par un helper appelé inline. Coder à la main += rejet. Liste des helpers : +{functions} + +**Bilingue** — directives NE S'IMBRIQUENT PAS : +| Contexte | Syntaxe | +|---------------------------------------|--------------------------------------| +| Phrase courte fixe | `{{fr}}`...`{{en}}`...` | +| Avec variable injectée | splitter : `{{fr}}`Il y a `{{en}}`There are `{{{{n}}}}` | +| Texte fixe long ou math display | bloc `:::{{fr}}` ... `:::` puis `:::{{en}}` ... `:::` | +| Texte qui VARIE selon Python | `xxx_fr`/`xxx_en` SANS directives internes, parents bilingues | + +`{{{{var}}}}` ne parse PAS dans `{{fr}}`...`{{en}}`...` inline. `{{fr}}/{{en}}` +cassent dans `$...$` math inline → sortir avant le `$`. + +**Math** : inline `$...$`, display `\begin{{equation*}} ... \end{{equation*}}`, +align via `&=` directement (PAS `\begin{{align}}`). Interdits : `$$...$$`, `\(\)`, `\[\]`. + +**LaTeX** : toujours `latex(expr, **config_standard)` — sans cela, ordre +alphabétique non-canonique, séparateur incorrect, `log` au lieu de `ln`. + +**Imports** : minimaux, ciblés, tous APPELÉS au moins une fois (Python ou inline MyST). +Toujours : `import random as rd`, `from sympy import *`, `config_standard = pxs_config()`. +Symbols : déclarer UNIQUEMENT ceux utilisés. Pas de `t,k,s,x,y,z = symbols(...)`. + +**Contraintes Python** : `for _ in range(100)` + `break` (jamais `while`), +pas de `print()`, pas de double underscore. Auto-distribution sympy : pour +distinguer factorisée/développée, construire les deux formes en strings. + +**Figures matplotlib** : `figsize ≤ (6, 6)`, `plt.show()` SEUL. Interdit : +`savefig`, `close` après show, `matplotlib.use('Agg')`. Région polaire : +fermer via polygone incluant l'origine, pas `ax.fill` sur l'arc seul. + +**STQ + dessin attendu** : non-validable → MCQ avec figures en options, ou +commentaire `# EXERCICE À CORRIGER MANUELLEMENT` en tête. + +**Texte hors-question** (énoncé, inter-question text) : à recopier au même +emplacement (avant les questions pour l'énoncé, juste avant la question +concernée pour l'inter-text). Valeurs statiques → injections `{{{{var}}}}`. +Le bloc `{{python}}` vient TOUJOURS en premier dans la sortie, car ce texte +utilise les variables qu'il définit. + +═══════════════════════════════════════════════════════════════════════════ + CONTEXTE +═══════════════════════════════════════════════════════════════════════════ + +EN-TÊTE déjà finalisé (NE PAS reproduire) : +{content} + +VARIABLES DÉTECTÉES : +{analysis} + +BLOCS PRÉCÉDENTS (ne pas redéfinir leurs variables) : +{previous_blocks} + +SECTION À PYTHONISER ({range_label} / {nb_total}) : +{current_segment} + +═══════════════════════════════════════════════════════════════════════════ + SORTIE ATTENDUE — RÈGLES CRITIQUES D'ASSEMBLAGE +═══════════════════════════════════════════════════════════════════════════ + +⚠️ TU PRODUIS UNIQUEMENT LE CONTENU DE CETTE PAIRE — JAMAIS le contenu +des paires précédentes (qui sont dans `BLOCS PRÉCÉDENTS` et seront concaténés +mécaniquement avant ta sortie). + +⚠️ TU PRODUIS EXACTEMENT {nb_current} bloc(s) `:::::{{question}}` — pas plus, +pas moins. Les `questionId` / `questionIndex` sont CONTINUS depuis la paire +précédente (si pair 1 a fini à questionId 1, tu commences à questionId 2). + +⚠️ POUR LA PAIRE 1 UNIQUEMENT (et seulement elle) : + • Tu produis le bloc `{{python}}` initial avec TOUS les imports + tirages + calculs + • Tu produis l'énoncé général (`:::{{fr}}/:::{{en}}`) AVANT la première question + +⚠️ POUR LES PAIRES SUIVANTES (paire 2, 3, …) : + • Tu NE RÉPÈTES PAS l'énoncé général (il est déjà dans previous_blocks) + • Tu NE RÉPÈTES PAS les questions précédentes (elles sont dans previous_blocks) + • Tu NE RÉ-IMPORTES PAS et NE RE-TIRES PAS les variables existantes + • SI tu as besoin d'ajouter des variables (ex: `f_expr`, `t_graph` pour un tracé + Partie C), tu peux ajouter un PETIT bloc `{{python}}` AVANT tes nouvelles + questions, contenant UNIQUEMENT les lignes nouvelles, sans imports ni + redéfinition. Exemple : `f_expr = p_sympy * x * (1 - x/n); globals()` + (les variables `p_sympy`, `x`, `n` viennent du bloc principal). + +Format de sortie attendu — paire 1 : + +```{{python}} + +globals() +``` + +<énoncé général, dans `:::{{fr}}/:::{{en}}`> + +:::::{{question}} +::::{{questionStatement}} ... :::: +::::{{questionHint}} ... :::: +::::{{detailedSolution}} ... :::: +::::{{weightDistribution}} :logic: ... :abstraction: ... :reasoning: ... :calculation: ... :::: +::::: + +(répété pour la 2e question de la paire si nb_current = 2) + +Format de sortie attendu — paire 2+ : + +[optionnel] + +[optionnel] ```{{python}} + +globals() +``` + +:::::{{question}} +... (questionId continue) +::::: + +→ Somme `weightDistribution = 100` exactement par question. +→ Pas de `:id:` dans la métadonnée (la plateforme l'assigne). + +═══════════════════════════════════════════════════════════════════════════ + RÈGLES CIBLÉES POUR CET EXERCICE (depuis pythonisation_rules.md) +═══════════════════════════════════════════════════════════════════════════ +Step 1 a identifié ces règles comme étant À RISQUE pour cet exercice spécifique. +Chacune liste un cas FAUTIF (à ne pas reproduire) et un cas CORRECT (à imiter). + +{targeted_rules} + +INVARIANTS MATHÉMATIQUES à préserver lors des tirages aléatoires : +{property_constraints} + +═══════════════════════════════════════════════════════════════════════════ + CHECKLIST +═══════════════════════════════════════════════════════════════════════════ + + □ Bloc Python = valeurs/calculs uniquement, se termine par `globals()` + □ Aucun `myst()` qui pré-construit la pédagogie ; aucun `*_latex`/`*_display` + □ Solutions écrites DIRECTEMENT dans `:::{{fr}}/:::{{en}}`, valeurs via `{{{{...}}}}` inline + □ JAMAIS de `**kwargs` dans `{{{{...}}}}` — pré-calculer en `_disp`/`_tex` (règle 6.1) + □ Tous les imports APPELÉS, helpers du catalogue utilisés pour tout affichage + □ Display math en `\begin{{equation*}}` (pas `$$`, pas `\begin{{align}}`) + □ `weightDistribution` somme = 100, `questionId` continu, niveau {niveau} respecté + □ Énoncé / inter-text recopiés au bon emplacement avec valeurs injectées + □ Si l'input contient déjà des `detailedSolution` : substitue UNIQUEMENT + les valeurs littérales (règle 8.1, pas de reformulation) +""" + +## SYSTEM_PROMPT (v1) +SYSTEM_PROMPT =""" +Tu es professeur de mathématiques avec une forte maîtrise de Python scientifique (sympy, random, math, matplotlib, numpy). Tu produis des exercices PyxiScience randomisés en MyST + KaTeX, dont les valeurs aléatoires sont générées par du code Python custom. + +Le code Python vit dans des blocs ```{python} ... ```. Les variables qui y sont définies sont injectées dans le MyST via `{{ variable }}`. + +⚠️ La syntaxe `{{ var }}` est exécutée par notre runtime Python custom — ce n'est pas du Jinja. Pas de `{% ... %}`, pas de filtres `{{ var | upper }}`, et **aucune logique dans `{{ ... }}`** (pas de `if`, pas de boucle, pas de calcul). Toute logique vit dans le bloc `{python}`. + +──────── RÈGLES ──────── + +1. **Niveau et méthodologie respectés.** Un même exercice se résout différemment selon le niveau (lycée ≠ L3). Garde la méthode de l'original — pas de récurrence forte en terminale, pas d'epsilon-delta en seconde. + +2. **Randomisation contrainte, pas hasardeuse.** Chaque tirage doit produire un exercice mathématiquement valide. Filtre les cas dégénérés (Δ < 0 quand on attend 2 racines, dénominateur nul, dérivée constante, intervalle vide, etc.) via `for _ in range(100): ... break`. + +3. **Priorité absolue aux helpers `pxs*`.** Toute valeur affichée dans le MyST passe par un helper du catalogue (`pxsl_latex_coefficient`, `pxsl_par`, `pxsl_matrix`, `pxs_explain_IBP`, `pxs_Interval`, `pxsl_res_num`, etc.), appelé inline : `{{ pxsl_latex_coefficient(a, sign=True) }}`. Quand un helper existe, l'utiliser n'est pas optionnel. + +4. **Priorité absolue aux deux fonctions d'affichages pxs_latex_coefficient() , pxsl_res_num(variable, egal=False) OBLIGATOIRE POUR TOUT LES FLOATANT **. Ces fonctions sont les seules à utiliser pour afficher des coefficients et des résultats numériques. + +def pxsl_res_num(x, dec=4, pourc=False, text=False, egal=True, dot = True): + ''' + Fr : Formate un nombre pour l'affichage avec LaTeX, avec gestion d'approximation. + En : Formats a number for display with LaTeX, with approximation handling. + + Version 2 + --------- + 13/03/25 + + Vérification + ------------ + Auteur : Ronan + Vérificateurs : Delphine + + Arguments: + x (float/str): Nombre à formater + dec (int): Nombre de décimales pour l'arrondi (défaut: 4) + pourc (bool): Si True, affiche également le résultat en pourcentage (défaut: False) + text (bool): Si True, utilise un format texte plus descriptif (défaut: False) + egal (bool): Si False, affichera simplement le nombre sans = ou approx devant + + Returns: + str: Formule LaTeX formatée + + Fonction utilisée par + --------------------- + Aucune fonction pyxiscience + + :pxs_trigger: affichage résultat final d'un calcul probabiliste ou numérique, choix automatique entre "=" et "\\approx" selon exactitude de l'arrondi, conversion en pourcentage avec texte bilingue FR/EN, "soit environ X %" + :pxs_returns: | + str LaTeX. Si egal=True : préfixé par " = " (exact) ou " \\approx " (arrondi). + Si pourc=True : ajoute la version en pourcentage. Si text=True : texte descriptif bilingue "\\fr{environ}\\en{approximately}". + :pxs_example: | + p = 7/30 + resultat = pxsl_res_num(p, dec=4, pourc=True) + # -> " \\approx 0.2333 \\%" en fait " \\approx 23.33 \\%" + # myst(r"La probabilité vaut \\py{resultat}", globals(), locals()) + :pxs_antipattern: f" = {round(x, 4)}" ou f" \\approx {round(x*100, 2)}\\%" — ne distingue pas exact/approché et ne gère ni le bilinguisme ni le point final. + ''' + # Conversion et arrondi du nombre + valeur_precise = round(float(x), 10) # Conversion en float et arrondi à 10 décimales pour précision interne + valeur_arrondie = round(valeur_precise, dec) # Arrondi au nombre de décimales demandé + + # Vérification si l'arrondi modifie la valeur (pour décider d'utiliser ≈ ou =) + valeur_precise_int = int(valeur_precise * (10**10)) # Conversion en entier pour comparaison précise + valeur_arrondie_int = int(valeur_arrondie * (10**10)) # Conversion de la valeur arrondie + + # Définition du symbole et format selon que la valeur est exacte ou approximative + est_exact = (valeur_precise_int == valeur_arrondie_int) + symbole = "" if egal == False else (" = " if est_exact else " \\approx ") + + # Construction de la formule LaTeX selon les paramètres + if text: + # Version texte descriptive + prefixe = "" if est_exact else " \\fr{ environ }\\en{ approximately } " + + if pourc: + # Format pourcentage avec texte explicatif + texte_pourcentage = ", \\fr{ soit " + ("" if est_exact else "environ ") + "}\\en{that is " + ("" if est_exact else "approximately ") + "} " + if dot: + resultat = myst(r""{0}$\py{{latex(pxs_nvirgzero(round(valeur_precise,dec)))}}${1}$\py{{latex(pxs_nvirgzero(round(100*valeur_precise,dec-2)))}}$ $\%$."".format( + prefixe, texte_pourcentage), globals(), locals()) + else: + resultat = myst(r""{0}$\py{{latex(pxs_nvirgzero(round(valeur_precise,dec)))}}${1}$\py{{latex(pxs_nvirgzero(round(100*valeur_precise,dec-2)))}}$ $\%$"".format( + prefixe, texte_pourcentage), globals(), locals()) + else: + # Format décimal simple + if dot: + resultat = myst(r""{0}$\py{{latex(pxs_nvirgzero(round(valeur_precise,dec)))}}$."".format(prefixe), globals(), locals()) + else: + resultat = myst(r""{0}$\py{{latex(pxs_nvirgzero(round(valeur_precise,dec)))}}$"".format(prefixe), globals(), locals()) + else: + # Version concise avec symbole mathématique + if pourc: + # Format pourcentage + resultat = myst(r""{0}\py{{latex(pxs_nvirgzero(round(100*valeur_precise,dec-2)))}} \%"".format(symbole), globals(), locals()) + else: + # Format décimal simple + resultat = myst(r""{0}\py{{latex(pxs_nvirgzero(round(valeur_precise,dec)))}}"".format(symbole), globals(), locals()) + + return resultat + +# # ############################################################################################################# +# # #### Début des essais ####### +# # ############################################################################################################# + +# a=0.2354 +# b=pxsl_res_num(a,dec=4,pourc=False,text=False) +# retourne "=0.2354" +# c=pxsl_res_num(a,dec=4,pourc=True,text=False) +# retourne "=23.45%" +# d=pxsl_res_num(a,dec=4,pourc=False,text=True) +# retourne "0.2354." centré +# e=pxsl_res_num(a,dec=4,pourc=True,text=True) +# retourne "est 0.2354, soit 23.54%" +# f=0.2354278 +# g=pxsl_res_num(f,dec=4,pourc=False,text=False) +# retourne "\approx 0.2354" +# h=pxsl_res_num(f,dec=4,pourc=True,text=False) +# retourne "\approx 23.54%" +# i=pxsl_res_num(f,dec=4,pourc=False,text=True) +# retourne "est environ 0.2354" +# j=pxsl_res_num(f,dec=4,pourc=True,text=True) +# retourne "est environ 0.2354, soit environ 23.54%" + +-----------------COMMENT UTILISER pxsl_latex_coefficient----------------- + +pxsl_latex_coefficient(coeff, variable=None, sign=False, zeros=True, ones=False, display=True): + Formats a coefficient for LaTeX display. + + This function formats a coefficient for display in a LaTeX polynomial expression. + It handles special cases where the coefficient is 1 or -1 and provides options for + displaying signs, omitting zeros, or showing numerical ones. + + Parameters + ---------- + coeff : int, float, or sympy.Expr + The coefficient to format. + sign : None or '+' + If '+', a '+' sign is displayed before the expression when it is positive. + variable : str or Symbol, optional + Expression or variable attached to the coefficient (can be omitted if zeros=False). + zeros : bool, default True + If False, the coefficient and its variable are not written when the coefficient is zero. + ones : bool, default False + If False, -1 is written as '-' and 1 as an empty string. + If True, both -1 and 1 are kept as numeric values. + display : bool, optional + Whether to produce display-mode LaTeX (used in the examples below). + + Returns + ------- + str + The coefficient formatted for LaTeX. Returns an empty string for coeff=1, + '-' for coeff=-1, and the formatted representation otherwise. + + Examples + -------- + >>> pxsl_latex_coefficient(1) + '' + >>> pxsl_latex_coefficient(-1, ones = True) + '-1' + >>> pxsl_latex_coefficient(-1) + '-' + >>> pxsl_latex_coefficient(5) + '5' + >>> pxsl_latex_coefficient(5, sign = True) + '+5' + >>> pxsl_latex_coefficient(1500) + '1\\ 500' + >>> pxsl_latex_coefficient(0, variable = Symbol('L_1'), zeros = True) + '0L_1' + >>> pxsl_latex_coefficient(0, variable = Symbol('L_1'), zeros = False) + '' + >>> pxsl_latex_coefficient(Rational(-5, 2), sign = '+', display = False) + '-\\frac{5}{2}' + >>> pxsl_latex_coefficient(Rational(-5, 2), sign='+') + '-\\displaystyle \\frac{5}{2}' + +LES AUTRES FONCTIONS LATEX SONT PRIMORDIALES ET PRIORITAIRES POUR LA CREATION D'EXERCICES. + + +""" + +## STEP_AUDIT_PROMPT (v1) +# ───────────────────────────────────────────────────────────────────────────── +# STEP AUDIT — re-pass the assembled exercise through an LLM that checks each +# targeted rule and proposes string-level patches that the pipeline applies +# verbatim. Up to 2 iterations. +# ───────────────────────────────────────────────────────────────────────────── +STEP_AUDIT_PROMPT = """\ +Tu es l'auditeur PyxiScience. Tu reçois l'exercice pythonisé final et tu vérifies +UNIQUEMENT les règles listées ci-dessous (chacune avec son cas FAUTIF/CORRECT). + +RÈGLES À VÉRIFIER : + +{audit_rules} + +EXERCICE À AUDITER : +{exercise} + +═══════════════════════════════════════════════════════════════════════════ + MISSION +═══════════════════════════════════════════════════════════════════════════ + +Pour chaque règle ci-dessus, vérifie si l'exercice la respecte. +Si une règle est VIOLÉE, renvoie une "issue" avec : + + • rule : l'ID de la règle (ex: "6.1") + • location : le snippet EXACT (1 ligne, ≤ 200 caractères) où la violation + apparaît, copié VERBATIM (sans reformulation, sans guillemets + ajoutés). Utilisé tel quel par str.replace() côté Python. + • fix : le remplacement EXACT à appliquer en str.replace(location, fix). + • python_insert : (OPTIONNEL) si la correction nécessite AUSSI d'ajouter une + ligne dans le bloc python (ex: pré-calculer une variable + d'affichage), la mettre ici. Elle sera insérée juste avant + le dernier `globals()` du dernier bloc {{python}} de l'exercice. + Exemple : `"f_dev_tex = latex(f_dev, **config_standard)"`. + null ou absent si pas besoin. + • can_patch : true si la correction est sûre (location dans le texte, + fix non ambigu), false sinon (seul un warning sera levé). + • message : phrase explicative en français. + +Règles à NE PAS lister : + • celles qui sont respectées (verdict OK) + • celles qui ne s'appliquent pas à ce type d'exercice + • toute règle hors de la liste ci-dessus + +Réponds UNIQUEMENT en JSON valide, sans markdown : + +{{ + "verdict": "OK" ou "PATCH_REQUIRED", + "issues": [ + {{ + "rule": "6.1", + "location": "{{{{latex(f_dev, **config_standard)}}}}", + "fix": "{{{{f_dev_tex}}}}", + "python_insert": "f_dev_tex = latex(f_dev, **config_standard)", + "can_patch": true, + "message": "Appel de fonction avec **kwargs dans {{{{...}}}} — variable pré-calculée." + }}, + {{ + "rule": "2.1", + "location": ":id: abc-123-stale", + "fix": ":id:", + "can_patch": true, + "message": "ID hardcodé — vidé pour que la plateforme en attribue un." + }} + ] +}} + +⚠️ Règles d'application : + • `location` DOIT être présent textuellement dans l'exercice — sinon str.replace + échoue silencieusement et la violation persiste. + • Si une même violation apparaît à N endroits identiques, crée UNE seule issue + (str.replace côté Python remplacera la 1re occurrence ; appelle plusieurs + issues distinctes avec des locations DIFFÉRENTES si elles diffèrent). + • Si `python_insert` est utilisé, NE METS PAS le `globals()` dedans — il sera + conservé. Et n'inclus PAS de fence ```{{python}}``` — juste les lignes Python. + • Idempotence : si `python_insert` génère une ligne qui existe déjà dans le + bloc python, le post-process la déduplique. +""" diff --git a/app/knowledge/pythonisation_rules.md b/app/knowledge/pythonisation_rules.md new file mode 100644 index 0000000000000000000000000000000000000000..5dc77c75a3c7ac77772b14fbb0b2da6788519c9e --- /dev/null +++ b/app/knowledge/pythonisation_rules.md @@ -0,0 +1,1062 @@ +# Règles de pythonisation PyxiScience — Base de connaissances + +> Document destiné à être injecté dans un prompt système Claude Code modifiant le générateur d'exercices pythonisés. Chaque règle est dérivée de bugs réels rencontrés en production sur les annales Bac (Métropole J2 18/06/2025, exos 2 à 4) et de leur correction validée par l'équipe contenu. + +--- + +## 0. Objectif + +Le générateur prend en entrée : +- un énoncé APMEP (PDF/LaTeX/image) +- une version PyxiScience non pythonisée déjà validée par Chabane + +Et produit en sortie une version pythonisée MyST (`.md`) prête pour soumission à la plateforme. + +Les règles ci-dessous codifient les invariants à respecter. Tout livrable qui les enfreint sera rejeté par l'audit. + +--- + +## 1. Conventions PyxiScience (rappel) + +Ces conventions sont préalables et non négociables. Le générateur les respecte par défaut. + +- **Variables Python** : camelCase uniquement. Les underscores (`p_a`, `coef_t`) provoquent des erreurs de subscript LaTeX dans certains contextes. +- **Bloc Python** : ouvert avec **4 backticks** (vérifié sur 222/222 exemples livrés — constante `PYTHON_FENCE_BACKTICKS` dans `app/config.py`), terminé par `globals()` pour exposer les variables au moteur de rendu MyST (accès via `{{var}}`). Enveloppe `{exercise}` = 5 backticks. +- **Bilingue** : rôles inline `` {fr}`...`{en}`...` `` UNIQUEMENT (aucun bloc `:::{fr}`/`:::{en}` dans les 222 exemples livrés — ne pas en produire). +- **Math display** : `\begin{equation*}...\end{equation*}` avec `&` pour alignement. Ne JAMAIS utiliser `\[ \]` ni `$$ $$`. +- **Espacement** : `\phantom{-}\\` pour saut vertical en math, `%` avant la première équation d'un bloc. +- **Interdits** : `\textbf{}` (utiliser `**...**`), `\begin{itemize}` (utiliser `$\bullet$` ou tirets MyST), `\bbRac` (n'existe pas), variable nommée `total` (réservée), `if pxs_variation_number` (vaut toujours 1). +- **Niveaux de difficulté** : `Advanced` = avancé, `Intermediate` = intermédiaire. +- **`pxsl_format_number` ne fait PAS d'arrondi** — c'est purement un formattage (virgule française). Toujours arrondir AVANT. + +### RÈGLE 1.1 — `$` collé à un chiffre : préfixer par `${}` (casse silencieuse) + +Ajoutée 2026-06-12 (cas réel : 1413 occurrences dans les lots livrés ; motif interdit du harnais). + +**❌ FAUTIF** : +```latex +Calculer $4\bigl(g(\sqrt{x})\bigr)^2$. Le prix est ${{prixAff}}$ €. +``` + +**✅ CORRIGÉ** : +```latex +Calculer ${}4\bigl(g(\sqrt{x})\bigr)^2$. Le prix est ${}{{prixAff}}$ €. +``` + +**POURQUOI** : un `$` immédiatement suivi d'un chiffre est lu par le moteur comme un MONTANT en devise, pas comme une ouverture de math inline ; le `$…$` se désynchronise et toute la directive part en texte brut. Le groupe vide `{}` est invisible au rendu. Préfixer par `${}` TOUTE injection inline `${{…}}` susceptible de rendre un nombre. (L'app applique un auto-correctif déterministe dans `app/pipeline/postprocess.py::fix_dollar_digit`.) + +--- + +## 2. Métadonnées du fichier + +### RÈGLE 2.1 — `:id:` en première ligne, vide + +``` +`````{exercise} +:id: +:title:... +:modules: ... +... +``` + +**❌ FAUTIF** +``` +`````{exercise} +:title:... +:id: 7dd83aee-26e1-11f1-828c-0ed8d3b012a9 +``` + +**✅ CORRIGÉ** +``` +`````{exercise} +:id: +:title:... +``` + +**POURQUOI** : la plateforme génère un nouvel ID à la soumission. Tout ID hardcodé sera réutilisé et provoquera un conflit. + +### RÈGLE 2.2 — Toutes les autres métadonnées sont conservées strictement à l'identique + +Aucun ajout, aucune modification de `:title:`, `:modules:`, `:level:`, `:involvedConcepts:`, `:originalSource:`, etc. + +--- + +## 3. Architecture du bloc Python — UN SEUL BLOC PRINCIPAL + +### RÈGLE 3.1 — Tous les paramètres aléatoires et calculs sont dans UN bloc Python en début d'exercice + +**❌ FAUTIF** (cas vu dans la version pythonisée fournie de l'exo 4) +```python +# Bloc 1 (début exo) +v0 = rd.randint(10, 15) +k = round(rd.uniform(0.4, 0.8), 1) +... + +# Bloc 2 (avant Q3) +k_val = round(rd.uniform(0.4, 0.8), 1) # REDÉFINIT k ! +t_A = round(rd.uniform(4.0, 6.0), 1) +... + +# Bloc 3 (avant Q5) +v0 = rd.randint(10, 15) # REDÉFINIT v0 ! +k_val = round(rd.uniform(0.4, 0.8), 1) +... +``` + +**✅ CORRIGÉ** +```python +# UN SEUL bloc en début d'exercice +import random as rd +import math +import numpy as np +import matplotlib.pyplot as plt +from sympy import Rational, latex +from pyxiscience.Mes_fctions_generalistes_bis import pxs_config, pxsl_format_number + +config_std = pxs_config() + +# Tirage initial +v0 = rd.choice([11, 12, 13]) +k_num = rd.choice([5, 6]) +k_coef = Rational(k_num, 10) + +# Toutes les variables dérivées calculées ici +inv_k = Rational(10, k_num) +asymptote = v0 * inv_k + inv_k**2 +alpha = ... +# etc. + +globals() +``` + +**POURQUOI** : `random.seed` n'est pas réinitialisé entre blocs Python. Redéfinir `v0`, `k_coef`, etc. dans des blocs ultérieurs fait que les valeurs **diffèrent entre questions** : Q4 dit "y' + 0,6y = e^(-0,6t)" puis Q7 dit "v(t) = (12+t)e^(-0,4t)" → exercice incohérent. + +### RÈGLE 3.2 — Imports uniquement dans le premier bloc + +Tous les `import numpy as np`, `import matplotlib.pyplot as plt`, etc. sont factorisés. Les blocs ultérieurs (typiquement le bloc graphique) réutilisent les imports via `globals()`. + +### RÈGLE 3.3 — Le bloc graphique réutilise les variables du bloc principal + +Si un bloc Python séparé est nécessaire pour le graphique (placement après l'intro de la partie A par exemple), il doit utiliser les variables déjà calculées, pas les re-tirer. + +```python +# Bloc graphique (après l'énoncé de la partie A) +fig, ax = plt.subplots(figsize=(7, 5)) +t_graph = np.linspace(0, 16.5, 2000) +y_graph = np.array([d_num(x) for x in t_graph]) # d_num défini dans bloc principal +ax.plot(t_graph, y_graph, color="blue") +# ... +plt.show() +``` + +--- + +## 4. Tirage des paramètres — Cohérence mathématique + +### RÈGLE 4.1 — Tirages en Rational exact, pas en flottant arrondi + +**❌ FAUTIF** +```python +k = round(rd.uniform(0.4, 0.8), 1) +k_coef = Rational(int(k * 10), 10) # int(7.0) peut donner 6 à cause des flottants +``` + +**✅ CORRIGÉ** +```python +k_num = rd.choice([5, 6, 7, 8]) +k_coef = Rational(k_num, 10) +``` + +**POURQUOI** : `round(rd.uniform(0.4, 0.8), 1)` peut produire `0.6999999999999...`, et `int(0.6999... * 10) = 6` au lieu de 7. Bug d'arrondi flottant classique. Tirer directement les entiers numérateurs/dénominateurs. + +**TOLÉRANCE** : `round(rd.uniform(...), N)` est ACCEPTABLE pour une **probabilité d'affichage** dont la précision n'est pas critique (par ex. `p = round(rd.uniform(0.05, 0.25), 3)` pour un coefficient binomial affiché à 0.001 près). À ne PAS utiliser pour des valeurs entrant dans des calculs sympy exacts, des bornes d'intervalles, ou des paramètres de récurrence. + +### RÈGLE 4.2 — Les paramètres dérivés sont COHÉRENTS avec les paramètres tirés + +Tout paramètre qui apparaît dans l'énoncé ET qui dépend mathématiquement d'autres paramètres ne doit pas être tiré indépendamment. + +**❌ FAUTIF** (vu dans exo 4) +```python +v0 = rd.randint(10, 15) +k = round(rd.uniform(0.4, 0.8), 1) +L_min = rd.randint(20, 25) # tirage indépendant +d_15 = rd.randint(12, 18) # tirage indépendant +``` + +Conséquences : +- `L_min` peut être inférieur à l'asymptote `v0/k + 1/k²` → la zone de freinage est trop petite, l'énoncé est faux. +- `d_15` n'a aucun lien avec la valeur de `d(2)` à lire sur le graphique → la "lecture graphique" donne un résultat incohérent. + +**✅ CORRIGÉ** +```python +v0 = rd.choice([11, 12, 13]) +k_num = rd.choice([5, 6]) +k_coef = Rational(k_num, 10) + +# Paramètres DÉRIVÉS +asymptote = v0 * Rational(10, k_num) + Rational(10, k_num)**2 +L_min = math.ceil(float(asymptote)) # garanti > asymptote +d_lecture = round(d_num(2)) # cohérent avec graphique +``` + +**POURQUOI** : un paramètre qui doit satisfaire une contrainte mathématique se calcule, il ne se tire pas. + +### RÈGLE 4.3 — Les paramètres aléatoires PRÉSERVENT la propriété démontrée + +Si l'exercice démontre une propriété (ex: `w_n ≥ n`), les paramètres tirés doivent la rendre vraie pour TOUS les seeds. + +**❌ FAUTIF** (vu dans exo 3) +```python +w0 = rd.randint(-2, 3) +alpha = rd.randint(2, 4) +beta = rd.randint(-3, -1) +gamma = rd.randint(1, 5) +``` + +Sur 1000 seeds testés : +- 36% ont `w0 < 0` → l'initialisation `w_0 ≥ 0` est fausse. +- 34% ont `α + β ≤ 0` → la récurrence `w_n ≥ n` est mathématiquement fausse (testé : `α=2, β=-3, γ=1, w_0=1` donne `w_4 = -2 < 4`). + +**✅ CORRIGÉ** +```python +# Contrainte : w_0 >= 0, alpha + beta = 1, gamma >= 1 +w0 = rd.randint(0, 3) +ab_pairs = [(2, -1), (3, -2), (4, -3)] # alpha + beta = 1 garanti +alpha, beta = rd.choice(ab_pairs) +gamma = rd.randint(1, 5) +``` + +**POURQUOI** : pythoniser ne signifie pas randomiser sans contrainte. La propriété démontrée doit rester vraie sur 100% des variantes. Si nécessaire, restreindre la plage ou utiliser une boucle `while` pour rejeter les tirages invalides. + +**EXTENSION — calculs intermédiaires dans `detailedSolution`** : si une variable aléatoire change (ex. `p ∈ {0.7, 0.8, 0.9}`), TOUS les calculs intermédiaires affichés dans les `detailedSolution` doivent être recalculés à partir de cette variable. **Aucune valeur dérivée ne peut être hardcodée en littéral.** + +``` +❌ FAUTIF : `= 0{,}81 + 0{,}01 = 0{,}82` (vrai uniquement pour p=0.9) +✅ CORRIGÉ : `= {{p_fidele_carre_str}} + {{p_contraire_carre_str}} = {{p_V3_str}}` +``` + +L'audit doit lister TOUS les nombres décimaux apparaissant dans les `detailedSolution` et vérifier qu'ils proviennent d'une variable Python, pas d'une chaîne littérale. + +### RÈGLE 4.5 — Détection des invariants Vrai/Faux dépendants des paramètres + +Pour les exercices "Vrai ou Faux", la réponse V/F dépend souvent de valeurs numériques précises. **Avant de tirer aléatoirement un paramètre**, vérifier algébriquement pour quelles valeurs la réponse V/F annoncée par la solution reste correcte. + +Exemples typiques : +- `∫₀^a x² dx = a²` (aire d'un carré) ⟺ `a = 3` +- `f(x) = e^x + e^(cx)` solution de `y' = cy - e^x` ⟺ `c = 2` +- `A(n, k) = C(m, p)` ⟺ couples (n, k, m, p) précis + +**Deux stratégies acceptables** : +1. **Fixer les paramètres** sur la valeur source (pas de variation aléatoire) +2. **Adapter dynamiquement la conclusion V/F** selon le tirage (cf. règle 8.4) + +### RÈGLE 4.6 — Arrondi nul à supprimer + +Quand une probabilité ou un résultat est exprimé en notation scientifique (valeur `< 10⁻³`), ne pas afficher l'arrondi `≈ 0,000` qui n'apporte aucune information. + +**✅ PATTERN** +```python +if round(float(proba), 3) == 0: + proba_final = proba_exact_str # ex: "8,1 × 10⁻⁵" +else: + proba_final = f"{proba_exact_str} \\approx {proba_str}" +``` + +### RÈGLE 4.7 — Cohérence des formules développées avec les paramètres + +Si une solution développe une formule pas à pas (ex. `n × (n-1) × (n-2)` pour un arrangement à `k=3`), le paramètre `k` DOIT être fixé OU le développement DOIT être généré dynamiquement. + +**✅ PATTERN — helpers à inclure dans le bloc `{python}`** +```python +def develop_chain(start, length): + """start × (start-1) × ... (length facteurs)""" + return " \\times ".join(str(start - i) for i in range(length)) + +def develop_chain_to(start, end_inclusive): + """start × (start-1) × ... × end_inclusive""" + return " \\times ".join(str(i) for i in range(start, end_inclusive - 1, -1)) +``` + +### RÈGLE 4.8 — Pas d'assertion sur les distances numériques + +Quand on tire une variable "presque égale" à une valeur cible (typiquement pour créer un piège dans un Vrai/Faux), NE PAS imposer un seuil de distance strict — l'inégalité mathématique structurelle (ex: rationnel vs irrationnel) suffit. + +``` +❌ FAUTIF : assert abs(float(frac) - J_approx) > 1e-4 + # plante pour 7/11 (différence ≈ 7×10⁻⁵) qui est pourtant + # le piège le plus crédible + +✅ CORRIGÉ : pas d'assertion sur la distance — la distinction structurelle + (rationnel vs irrationnel) garantit l'inégalité. +``` + +### RÈGLE 4.4 — Identifier les contraintes mathématiques avant de pythoniser + +Avant de transformer une constante en variable aléatoire, vérifier qu'elle peut varier sans casser : +- l'initialisation d'une récurrence +- les conditions d'une inégalité +- les hypothèses d'un théorème invoqué dans la solution +- le sens d'une asymptote / limite + +Si une contrainte existe, soit borner le tirage, soit ne pas pythoniser ce paramètre. + +--- + +## 5. Calculs — Rational exact, pas de double approximation + +### RÈGLE 5.1 — Tous les calculs intermédiaires en Rational/Fraction exacte + +**❌ FAUTIF** +```python +p_A = round(rd.uniform(0.4, 0.8) / 0.05) * 0.05 # produit 0.35000000000000003 +``` + +**✅ CORRIGÉ** +```python +num_pA = rd.randint(8, 16) # 0,40 à 0,80 par pas de 0,05 +p_A = Fraction(num_pA, 20) +``` + +### RÈGLE 5.2 — Conversion `float()` UNIQUEMENT au moment de l'affichage + +```python +# Calculs : tout en Rational +asymptote = v0 * inv_k + inv_k**2 + +# Affichage : convertir avec arrondi explicite +asymptote_disp = pxsl_format_number(round(float(asymptote), 2), **config_std) +``` + +### RÈGLE 5.3 — Pas de fractions reconstruites par double `limit_denominator` + +**❌ FAUTIF** +```python +frac = Fraction(p_float).limit_denominator(1000) / Fraction(q_float).limit_denominator(1000) +frac = frac.limit_denominator(100) +``` + +**✅ CORRIGÉ** +```python +# Calculer la fraction exacte directement à partir des Rational initiaux +frac_exact = p_inter_q / p_total +``` + +### RÈGLE 5.4 — Centième inférieur via `math.floor`, pas via formule arithmétique + +**❌ FAUTIF** +```python +seuil_arrondi = round(seuil * 100 - 5) / 100 # donne 0,73 au lieu de 0,77 +``` + +**✅ CORRIGÉ** +```python +seuil_arrondi = math.floor(seuil * 100) / 100 +``` + +### RÈGLE 5.5 — Vérifier les opérations sur les coefficients + +Si la solution simplifie `αn + βn`, le coefficient résultant est `α + β`, pas `α - β`. + +**❌ FAUTIF** (vu dans exo 3) +```python +# Dans la solution : +{{alpha}} w_n {{beta}} n + {{gamma}} &\geqslant {{alpha - beta}} n + {{gamma}} +``` + +Avec `α=3, β=-2`, le code calcule `α - β = 5` au lieu de `α + β = 1`. Bug critique de simplification. + +**✅ CORRIGÉ** +```python +{{alpha}} w_n {{beta_n_str}} + {{gamma}} &\geqslant n + {{gamma}} # car alpha + beta = 1 +``` + +--- + +## 6. Interpolation MyST `{{...}}` — Pièges critiques + +### RÈGLE 6.1 — INTERDIT : appels de fonction avec `**kwargs` dans `{{...}}` + +**❌ FAUTIF** +``` +La suite converge vers ${{latex(limite_proposee, **config_standard)}}$. +``` + +**Le rendu produit `latex(limite_proposee, **config_standard)` en clair**, pas la valeur. Cause : MyST parse `**config_standard` comme du gras markdown, ce qui casse l'expression. + +**✅ CORRIGÉ** +```python +# Dans le bloc Python +limite_proposee_tex = latex(limite_proposee, **config_standard) +``` +``` +La suite converge vers ${{limite_proposee_tex}}$. +``` + +**POURQUOI** : tout appel `f(x, **kwargs)` dans `{{...}}` est interprété par MyST comme markdown avant interpolation Python. Toujours stocker le résultat dans une variable string en amont. + +### RÈGLE 6.2 — `pxsl_format_number(...)` dans `{{...}}` : créer la string en amont + +**❌ FAUTIF** +``` +Le coefficient vaut ${{pxsl_format_number(round(float(coef), 2), **config_standard)}}$. +``` + +**✅ CORRIGÉ** +```python +coef_disp = pxsl_format_number(round(float(coef), 2), **config_std) +``` +``` +Le coefficient vaut ${{coef_disp}}$. +``` + +### RÈGLE 6.3 — Pré-calculer toutes les variables d'affichage dans le bloc Python + +À la fin du bloc Python principal, créer un bloc dédié au formattage. Seul `sympy.latex(...)` accepte `**config_standard` — les helpers PyxiScience (`pxsl_format_number`, `pxsl_res_num`, etc.) ont leurs propres kwargs spécifiques (cf. règle 6.4). + +```python +# === Variables d'affichage prêtes === +k_disp = pxsl_format_number(float(k_coef)) # PAS de **config_std +coef_const_disp = pxsl_format_number(float(coef_const)) +inv_k_tex = latex(inv_k, **config_standard) # OK ici +asymptote_tex = latex(asymptote, **config_standard) # OK ici +alpha_disp = pxsl_format_number(alpha) +# etc. + +globals() +``` + +Le markdown ne contient alors plus que `{{var}}` simples, sans appel de fonction. + +### RÈGLE 6.4 — `**config_standard` réservé à `sympy.latex` + +Le résultat de `pxs_config()` contient des clés (`ln_notation`, `mat_str`, etc.) destinées **uniquement à `sympy.latex()`**. Les helpers PyxiScience (`pxsl_format_number`, `pxsl_res_num`, `pxsl_matrix`, etc.) ont leurs propres kwargs et **plantent** si on leur passe `**config_standard`. + +``` +❌ FAUTIF : pxsl_format_number(aire_carre, **config_standard) + → TypeError: got an unexpected keyword argument 'ln_notation' + +✅ CORRECT : pxsl_format_number(aire_carre) ← pas de splat +✅ CORRECT : pxsl_res_num(proba, dec=4, egal=False) ← kwargs explicites +✅ CORRECT : latex(p_fidele, **config_standard) ← seul sympy.latex +``` + +Liste explicite : **NE PAS** splat `**config_standard` sur `pxsl_format_number`, `pxsl_res_num`, `pxsl_matrix`, `pxsl_pow`, `pxsl_latex_coefficient` (alias `lc`), `pxsl_par`, `pxsl_mult`, `pxsl_choose_udv`, `pxs_Interval(...).print()`. **TOUJOURS** splat sur `sympy.latex(...)`. + +### RÈGLE 6.5 — `{{ }}` ne contient QU'UN nom de variable nu (camelCase, suffixe `Aff`) + +Ajoutée 2026-06-12 (convention vérifiée sur 222/222 exemples livrés : 0 appel de fonction, 0 underscore, 2563 injections `…Aff` ; contrôle STATIQUE du harnais). + +**❌ FAUTIF** : +```latex +$T(x) = {{latex(T)}}$, coefficient {{lc(a, sign=True)}}, sur {{dom.print()}}, p = {{p_str}} +``` + +**✅ CORRIGÉ** : +```python +# Dans le bloc Python : +tTex = latex(T, **config_standard) +coefAAff = lc(a, sign=True) +domAff = dom.print() +pStr = str(p) +``` +```latex +$T(x) = {{tTex}}$, coefficient {{coefAAff}}, sur {{domAff}}, p = {{pStr}} +``` + +**POURQUOI** : le runtime de la plateforme ne substitue de façon fiable que des identifiants nus ; un appel/calcul dans `{{ }}` peut fuir tel quel dans le rendu, et un underscore crée des indices LaTeX parasites. Tout s'évalue dans le bloc Python, dans des variables d'affichage camelCase suffixées `Aff`. (Auto-lift déterministe : `app/pipeline/postprocess.py::auto_lift_injections` + `rename_underscore_injections`.) + +--- + +## 7. Affichage des coefficients signés — Anti-concaténation + +### RÈGLE 7.1 — NE JAMAIS concaténer un signe à une valeur dans le markdown + +**❌ FAUTIF** +``` +w_{n+1} = {{alpha}} w_n {{beta}} n + {{gamma}} +``` + +Avec `β = -1`, le rendu donne `2 w_n -1 n + 3` (moche : `-1n` au lieu de `-n`). + +**✅ CORRIGÉ** +```python +# Dans le bloc Python +if beta == -1: + beta_n_str = "- n" +elif beta < 0: + beta_n_str = f"- {-beta}n" +else: + beta_n_str = f"+ {beta}n" +``` +``` +w_{n+1} = {{alpha}} w_n {{beta_n_str}} + {{gamma}} +``` + +### RÈGLE 7.2 — Pour tout coefficient pouvant être ±, générer une string complète avec signe intégré + +```python +# Pour v'(t) = (coef_const + coef_t * t) e^(-kt) +# coef_const < 0 toujours → latex(coef_const) commence par "-" +# coef_t < 0 toujours → on construit explicitement +coef_t_signed = f"- {pxsl_format_number(float(-coef_t), **config_std)}" +# Affichage : ({{coef_const_disp}} {{coef_t_signed}} t) +# → "(-6,2 - 0,6 t)" +``` + +### RÈGLE 7.3 — Cas typiques de coefficients à traiter explicitement + +- Coefficient devant `t`, `n`, `x` (terme variable d'un polynôme) +- Constante d'intégration ajoutée +- Reste d'une factorisation +- Termes dans une somme avec signes alternés + +### RÈGLE 7.4 — Format des décimales à 3 chiffres fixes + +Pour les arrondis à 10⁻³ près (consigne fréquente en Bac), utiliser un format à **3 décimales fixes** plutôt que `str(round(...))` qui tronque les zéros. + +``` +❌ FAUTIF : str(round(0.6561, 3)) → '0.656' (OK) + str(round(0.6, 3)) → '0.6' (manque "00" final attendu) + +✅ CORRECT : f"{x:.3f}".replace('.', '{,}') + # 0.6561 → '0{,}656' + # 0.6 → '0{,}600' +``` + +--- + +## 8. Solutions validées — Directive Chabane + +### RÈGLE 8.1 — INTERDICTION ABSOLUE de modifier les solutions validées + +Le générateur ne doit pas : +- ajouter des listes à puces qui n'existaient pas +- ajouter des préambules redondants en début de solution +- ajouter des étapes intermédiaires de calcul non validées +- reformuler des phrases (changer "factoriser" en "diviser", etc.) +- ajouter de la pédagogie ("Les quatre conditions de la loi binomiale sont vérifiées...") + +**❌ FAUTIF** (vu dans exo 3) +``` +{fr}`On divise le numérateur et le dénominateur par {{b1}}^n :` +``` + +Alors que la version validée disait : +``` +{fr}`On factorise le numérateur par 5^n et le dénominateur par 3^n :` +``` + +(La manipulation est en réalité une **factorisation séparée** au numérateur et au dénominateur, pas une division commune.) + +**✅ CORRIGÉ** +``` +{fr}`On factorise le numérateur par {{b1}}^n et le dénominateur par {{d1}}^n :` +``` + +### RÈGLE 8.2 — En cas de doute, copier-coller la solution validée + +Le générateur ne doit jamais "améliorer" ou "compléter" une solution validée. Si la pythonisation impose une adaptation (variable au lieu de constante), substituer mécaniquement et rien d'autre. + +### RÈGLE 8.3 — Si la pythonisation a touché à une solution validée, signaler avant correction + +L'audit doit demander confirmation explicite avant de restaurer. Le générateur ne doit pas le faire silencieusement. + +### RÈGLE 8.4 — Conclusions Vrai/Faux adaptatives + +Pour les exos "Vrai ou Faux" où on veut faire varier des paramètres qui peuvent changer la réponse, préparer la conclusion en Python comme variable conditionnelle : + +```python +if condition_vraie: + conclusion_fr = "... L'affirmation est vraie." + conclusion_en = "... Statement is true." +else: + conclusion_fr = f"... (détail spécifique). L'affirmation est fausse." + conclusion_en = f"... (specific detail). Statement is false." +``` + +Puis injecter via `{{conclusion_fr}}` / `{{conclusion_en}}` dans la `detailedSolution`. La structure du raisonnement reste identique, seul le verdict final s'adapte au tirage. + +--- + +## 9. Structure MyST — Paragraphes contextuels + +### RÈGLE 9.1 — Les paragraphes contextuels (intro de partie, intro de question groupée) sont HORS `questionStatement` + +**❌ FAUTIF** +``` +:::::{question} +:questionType: STQ + +::::{questionStatement} +$\underline{\textbf{Partie A}}$ + +{fr}`Le centre propose aux personnes...`{en}`...` + +{fr}`Question 1...`{en}`...` +:::: +``` + +**✅ CORRIGÉ** +``` +$\underline{\textbf{Partie A}}$ + +{fr}`Le centre propose aux personnes...`{en}`...` + +(exo1-q1)= + +:::::{question} +:questionType: STQ + +::::{questionStatement} +{fr}`Question 1...`{en}`...` +:::: +``` + +**POURQUOI** : `questionStatement` doit contenir UNIQUEMENT la consigne de la question. Tout contexte commun à plusieurs questions est un paragraphe libre entre les blocs `:::::{question}`. + +### RÈGLE 9.2 — Les paragraphes contextuels qui utilisent des variables `{{var}}` viennent APRÈS le bloc Python qui les définit + +``` +```{python} +v0 = ... +k_coef = ... +globals() +``` + +{fr}`On considère la fonction $v$ avec $v(0) = {{v0}}$.`{en}`...` +``` + +### RÈGLE 9.3 — Phrase "Dans cette question, on étudie..." (regroupement APMEP) + +L'APMEP utilise parfois "Dans cette question, on étudie X" pour introduire un groupe de sous-questions (2.a, 2.b, 2.c, 2.d). En PyxiScience, les sous-questions sont éclatées en questions distinctes. + +**RÈGLE** : reformuler en précisant le périmètre exact pour ne pas inclure les questions hors groupe. + +``` +{fr}`Dans les quatre questions qui suivent, on étudie la fonction $v$ sur $[0~;~+\infty[$.` +``` + +Le **nombre est explicite** (`les quatre questions`) pour éviter d'englober la question suivante (numérotée séparément en APMEP). + +### RÈGLE 9.4 — Pas de doublons d'énoncés + +L'énoncé de chaque question apparaît UNE SEULE FOIS, dans son `questionStatement`. Ne pas le répéter en paragraphe libre avant le bloc question. + +**❌ FAUTIF** (vu dans exo 4) +``` +{fr}`Que vaut $d'(t_A)$ ? Interpréter ce résultat.` + +:::::{question} +::::{questionStatement} +{fr}`Que vaut $d'(t_A)$ ? Interpréter ce résultat.` # DOUBLON +:::: +``` + +### RÈGLE 9.5 — Variables Python dans les blocs matplotlib + +Les substitutions `{{...}}` ne fonctionnent que dans le **texte MyST**, **PAS** à l'intérieur des blocs ```` ```{python} ```` . Dans un bloc Python, utiliser les vraies variables Python (f-string ou concaténation). + +``` +❌ FAUTIF : probs = r"${{p_fidele_str}}$" + # matplotlib reçoit littéralement "${{p_fidele_str}}$" + # mathtext rend "p_fidele_str" en italique math (incompréhensible) + +✅ CORRECT : probs = f"${p_fidele_str}$" + # Python interpole d'abord → matplotlib reçoit "$0{,}9$" +``` + +Pareil pour les `ax.set_title(...)`, `ax.text(...)`, `ax.legend(...)` : utiliser des f-strings Python, jamais `{{...}}`. + +--- + +## 10. Bilinguisme FR/EN — Symétrie + +### RÈGLE 10.1 — FR et EN doivent être équivalents en niveau de détail + +**❌ FAUTIF** (vu dans exo 2 Q6, exo 4 Q9) +- FR détaille le changement de variable `X = 0,6t` puis applique les croissances comparées (5 lignes) +- EN saute directement à "by comparison of growth rates" puis le boxed (2 lignes) + +**✅ CORRIGÉ** : les deux versions ont le même squelette de raisonnement, mêmes étapes, mêmes formules. + +### RÈGLE 10.2 — Notation math identique en FR et EN + +**❌ FAUTIF** +``` +{fr}`...l'intervalle $[0\,;\,1]$.`{en}`...the interval [0\,;\,1].` + ^^^^^^^^^^^ pas en mode math +``` + +**✅ CORRIGÉ** +``` +{fr}`...l'intervalle $[0\,;\,1]$.`{en}`...the interval $[0\,;\,1]$.` +``` + +### RÈGLE 10.3 — Si l'utilisateur a modifié l'énoncé, effacer la version EN + +Si la version pythonisée modifie le contenu de l'énoncé (pas seulement réagencement structurel), le générateur efface tous les blocs `{en}` pour permettre passage en retraduction par l'app dédiée. Le réagencement (séparation a/b/c en questions distinctes) n'est PAS une modification. + +### RÈGLE 10.4 — Format auto fraction / décimal selon la nature + +Pour les valeurs qui peuvent être rationnelles non-décimales (ex. `0,5 / 0,6 = 5/6`), créer un helper qui choisit automatiquement entre forme décimale finie et fraction LaTeX : + +```python +def _frac_to_latex(f): + """Fraction Python → décimal LaTeX si fini, sinon \\frac{a}{b}.""" + if f.denominator == 1: + return str(f.numerator) + d = f.denominator + while d % 2 == 0: d //= 2 + while d % 5 == 0: d //= 5 + if d == 1: + return str(float(f)).replace('.', '{,}') + return f"\\frac{{{f.numerator}}}{{{f.denominator}}}" +``` + +Exemples : +- `_frac_to_latex(Fraction(5, 8))` → `'0{,}625'` (décimal fini) +- `_frac_to_latex(Fraction(5, 6))` → `'\\frac{5}{6}'` (décimal infini) +- `_frac_to_latex(Fraction(5, 4))` → `'1{,}25'` (décimal fini) + +### RÈGLE 10.5 — Notation scientifique française pour valeurs très petites + +Pour les valeurs `< 10⁻⁴`, basculer en notation scientifique LaTeX plutôt qu'afficher `0{,}000081`. + +```python +import math + +def _frac_to_str_smart(f, sci_threshold=1e-4): + """Décimal pour valeurs raisonnables, sinon notation scientifique.""" + fl = float(f) + if 0 < abs(fl) < sci_threshold: + exp = int(math.floor(math.log10(abs(fl)))) + mant = fl / (10 ** exp) + mant_str = f"{mant:.2f}".rstrip('0').rstrip('.').replace('.', '{,}') or "1" + return f"{mant_str} \\times 10^{{{exp}}}" + return _frac_to_latex(f) +``` + +--- + +## 11. Rendu graphique matplotlib + +### RÈGLE 11.1 — Toute variable utilisée dans l'énoncé DOIT être utilisée dans le tracé + +**❌ FAUTIF** (vu dans exo 3) +```python +x_tangente_horiz = rd.randint(1, 3) # tirée + +# Énoncé : "tangente horizontale en x = {{x_tangente_horiz}}" + +# Mais le code matplotlib trace une fonction f(x) = K * (x^c - 1) * ln(x) +# qui a SA tangente horizontale FIGÉE en x=1 (par construction f(1)=0, f'(1)=0). +``` + +Le graphique montre la tangente horizontale en x=1 alors que l'énoncé annonce x=2 ou x=3. Incohérent dans 66% des seeds. + +**✅ CORRIGÉ** +- Soit fixer `x_tangente_horiz = 1` (constante), soit modifier la fonction tracée pour que sa tangente horizontale soit effectivement en `x_tangente_horiz`. + +### RÈGLE 11.2 — La tangente tracée DOIT être la vraie tangente analytique + +**❌ FAUTIF** +```python +slope = f(x_A) / (x_A + 0.3) # heuristique arbitraire +``` + +**✅ CORRIGÉ** +```python +def f_prime(x): + c = coeff_puissance + return facteur * (c * x**(c-1) * np.log(x) + (x**c - 1)/x) + +slope = f_prime(x_A) +b_intercept = f(x_A) - slope * x_A +``` + +### RÈGLE 11.3 — Les labels (Cf, T, Δ, A) doivent rester DANS la fenêtre du graphique + +**Symptôme** : si un label est positionné à `(x, y)` avec `y > ylim_max`, matplotlib étend automatiquement la zone d'affichage pour l'inclure, ce qui **compresse** le graphique. + +**❌ FAUTIF** +```python +ax.set_ylim(-1.2, 8) +ax.text(11, f(11) - 0.4, r"$\mathcal{C}_f$", ...) # f(11) peut être > 8 +ax.text(11, slope*11 + b_intercept + 0.15, "T", ...) # idem +``` + +**✅ CORRIGÉ** +```python +# Borner les paramètres pour que f(x_max_visible) reste sous le plafond +y_label_target = 6.5 +denom_y_max = (x_max**coeff_puissance - 1) * np.log(x_max) +facteur_max = min(70, int(y_label_target / denom_y_max)) +facteur_echelle = rd.randint(30, max(30, facteur_max)) + +# Placer les labels dans une zone garantie sûre +x_label = max(x_A + 1, x_max - 1.5) +x_label = min(x_label, x_max) +y_lab_cf = f(x_label) - 0.3 +y_lab_t = min(slope * x_label + b_intercept + 0.5, ylim_max - 0.5) +``` + +### RÈGLE 11.4 — Pas de mélange Rational sympy + numpy array + +**❌ FAUTIF** +```python +d_vals = Rational(5, 3) * t_graph + asymptote # Rational * np.array casse +``` + +**✅ CORRIGÉ** +```python +d_vals = float(Rational(5, 3)) * t_graph + float(asymptote) +# ou +inv_k_float = 1.0 / float(k_coef) +d_vals = -(v0 + t_graph) * inv_k_float * np.exp(-float(k_coef) * t_graph) + ... +``` + +### RÈGLE 11.5 — `plt.show()`, jamais `plt.savefig` + +```python +# ❌ Le chemin local n'est pas résolu côté plateforme +plt.savefig('arbre.png') +# puis dans le markdown : ![](arbre.png) + +# ✅ +plt.show() +``` + +### RÈGLE 11.6 — Pas d'éléments parasites dans le graphique + +- Pas de `ax.text(8.5, 22.2, "Fig. 2", ...)` à l'intérieur du graphique. La référence "Fig. 2" est une légende externe APMEP, pas un élément du tracé. +- Pas de doubles labels "1" sur les axes si les ticks par défaut sont déjà annotés. +- Pas de titre auto matplotlib. + +--- + +## 12. Fidélité APMEP + +### RÈGLE 12.1 — L'énoncé pythonisé est strictement conforme à l'APMEP, modulo paramètres + +- Toutes les phrases-clés de l'énoncé original sont présentes textuellement. +- Toutes les valeurs numériques sont identiques (modulo pythonisation). +- L'ordre des questions est respecté. + +### RÈGLE 12.2 — Sous-questions APMEP a/b/c → questions PyxiScience distinctes + +Convention PyxiScience : chaque sous-question APMEP devient une question distincte (`q1`, `q2`, ...) avec son propre `questionStatement` / `questionHint` / `detailedSolution`. Ce réagencement n'est PAS une modification d'énoncé au sens de la directive 2. + +### RÈGLE 12.3 — Géométrie : pythonisation autorisée à condition de préserver le niveau + +La pythonisation des exercices de géométrie dans l'espace (vecteurs, droites, plans, projections, orthocentres) est **autorisée** (cf. exos validés Amérique du Sud 13 novembre 2025, Amérique du Nord 22 mai 2025). Convention observée : + +- **Représenter les points/vecteurs en `sympy.Matrix([x, y, z])`** pour calculs symboliques. +- **Vérifier l'orthogonalité via `.dot()` = 0** au lieu de calculer par projection. +- **Pour une projection orthogonale**, paramétrer la droite (`K = A + lam*(B-A)`), construire `CK.dot(AB) = 0`, résoudre via `solve(...)`. +- **Distances via `sqrt(vec.dot(vec))`** (norme). + +⚠️ **NE PAS changer le niveau pédagogique** : si l'APMEP demande une démonstration géométrique (collinéarité, coplanarité, équation cartésienne d'un plan), la version pythonisée doit conserver cette difficulté — on randomise les coefficients, pas la nature du raisonnement. + +**❌ FAUTIF** (mauvaise pythonisation) +```python +# Donner les coordonnées du point projeté directement +A = Matrix([1, 0, 0]); B = Matrix([0, 1, 0]); C = Matrix([0, 0, 1]) +K = Matrix([0.5, 0.5, 0]) # hardcodé — élève n'a plus à calculer +``` + +**✅ CORRIGÉ** +```python +# Le tirage des sommets randomise les valeurs, mais l'élève DOIT calculer la projection +alpha, beta, gamma = rd.sample(range(1, 4), 3) +A = Matrix([alpha, 0, 0]); B = Matrix([0, beta, 0]); C = Matrix([0, 0, gamma]) +lam = Symbol('lam', real=True) +K = A + lam*(B - A) +sol = solve((K - C).dot(B - A), lam)[0] +# K_proj = A + sol*(B - A) # ← l'élève le démontre dans sa solution +``` + +### RÈGLE 12.4 — Coquilles APMEP corrigées silencieusement + +Si l'APMEP contient une coquille (ex: "au activités" sans "x"), la version PyxiScience peut la corriger silencieusement. Ce n'est pas une modification d'énoncé. + +--- + +## 13. Pièges spécifiques par type d'exercice + +### 13.1 — Probabilités + +- Tirages de probabilités : `rd.randint(a, b) / 20` + `Fraction`, jamais `round(uniform/0.05)*0.05`. +- Variables conditionnelles : ne pas écraser `p_non_B_sachant_non_A` (P_{Ā}(B̄)) par `p_non_A_sachant_non_B` (P_{B̄}(Ā)). Ce sont des conditionnelles inversées (Bayes), pas la même quantité. +- Loi binomiale : `scipy.stats.binom.cdf(k, n, p)` pour `P(X ≤ k)`. +- Bienaymé-Tchebychev : `math.floor(seuil*100)/100` pour le centième inférieur. + +### 13.2 — Suites + +- Variation `\case1`/`\case2` : à mettre INSIDE `\right{}`/`\wrong{}`, jamais autour. +- `pxs_variation_number` toujours = 1 dans le code Python — calculer toutes les variations en parallèle. +- Récurrence : voir RÈGLE 4.3 (préserver la propriété par contraintes sur les paramètres). + +### 13.3 — Analyse / Intégration + +- Constante d'intégration : ajouter `\py{C_new}` ou demander explicitement à l'utilisateur. +- Pas de concaténation `\py{sg(x)}` : créer une variable complète `signe_coeff_x = "+ val"` ou `"- val"` (RÈGLE 7.1). + +### 13.4 — Tracés matplotlib + +- Voir section 11 complète. + +--- + +## 16bis. Patterns récurrents observés en production + +(Section issue de l'analyse des exos validés de mai/novembre 2025. À ne pas confondre avec la section 16 (Anti-patterns), qui liste les BUGS — ici on liste des CONVENTIONS positives à reproduire.) + +### RÈGLE 16.1 — Tirage de paramètres avec contraintes : préférer la list comprehension + +Quand plusieurs paramètres doivent satisfaire une contrainte couplée (ex: `p_vv > p_nv` et `denominator(1/(1-(p_vv-p_nv))) <= 6`), ne PAS faire de `for _ in range(100): ... if cond: break` qui peut échouer silencieusement. Préférer une **list comprehension qui énumère toutes les combinaisons valides**, puis `rd.choice()`. + +**❌ FAUTIF** +```python +for _ in range(100): + p_vv = rd.choice(valeurs) + p_nv = rd.choice(valeurs) + if p_vv > p_nv and Rational(1, 1-(p_vv-p_nv)).denominator <= 6: + break # peut sortir sans break, valeurs invalides en silence +``` + +**✅ CORRIGÉ** +```python +couples_valides = [ + (p_vv, p_nv) for p_vv in valeurs for p_nv in valeurs + if p_vv > p_nv and Rational(1, 1 - (p_vv - p_nv)).denominator <= 6 +] +p_vv, p_nv = rd.choice(couples_valides) # garanti valide +``` + +**POURQUOI** : la list comprehension échoue de manière visible (IndexError si liste vide), au lieu de laisser passer des paramètres invalides. Pattern observé dans les exos validés Amérique du Sud 13 novembre 2025. + +### RÈGLE 16.2 — Loi binomiale : utiliser scipy.stats.binom (sf / cdf / pmf) + +Pour les calculs `P(X ≥ k)`, `P(X ≤ k)`, `P(X = k)` d'une binomiale, **toujours** utiliser `scipy.stats.binom`. + +**❌ FAUTIF** (calcul à la main, lent et fragile) +```python +prob_inf = sum(binomial(n, i) * p**i * (1-p)**(n-i) for i in range(k+1)) +``` + +**✅ CORRIGÉ** +```python +from scipy.stats import binom +prob_inf = binom.cdf(k, n, p) # P(X ≤ k) +prob_sup_strict = binom.sf(k - 1, n, p) # P(X ≥ k) (sf = 1 - cdf(k-1)) +prob_egal = binom.pmf(k, n, p) # P(X = k) +``` + +**Astuce conditionnelle** : si `(X ≥ nb) ⊂ (X ≥ ne)` (donc `nb ≥ ne`), alors `P(X ≥ nb | X ≥ ne) = binom.sf(nb-1, n, p) / binom.sf(ne-1, n, p)`. Garantir `ne < nb` au tirage pour que le quotient soit ≤ 1. + +**POURQUOI** : `binom.sf` (survival function) évite les sommes manuelles de coefficients binomiaux qui sont coûteuses en sympy et peuvent dériver numériquement. Pattern dans Exo 2 Amérique du Sud, exo binomiale randomisée. + +### RÈGLE 16.3 — Matplotlib : axes "zero spines" pour la convention math française + +Pour les graphes de fonction (style scolaire FR), positionner les axes sur l'origine et masquer les spines droite/haute. C'est la convention attendue par les enseignants. + +**✅ PATTERN STANDARD** +```python +fig, ax = plt.subplots(figsize=(6, 5)) +# ... ax.plot(...) ... +ax.spines['left'].set_position('zero') +ax.spines['bottom'].set_position('zero') +ax.spines['right'].set_color('none') +ax.spines['top'].set_color('none') +ax.xaxis.set_label_coords(1.02, -0.05) # label "x" en bout d'axe +ax.yaxis.set_label_coords(-0.05, 1.02) # label "y" en haut d'axe +plt.show() +``` + +À utiliser pour **toute étude de fonction** (Bac, terminale). Ne PAS utiliser pour les graphes de distribution / histogrammes (cadre classique avec spines complètes mieux adapté). Pattern observé dans Exos 3 + 4 Amérique du Sud. + +--- + +## 14. Checklist pre-flight avant livraison + +Le générateur n'envoie pas le fichier tant que les points suivants ne sont pas cochés : + +### Métadonnées +- [ ] `:id:` est en première ligne et vide +- [ ] Toutes les autres métadonnées sont identiques à la version non pythonisée + +### Architecture Python +- [ ] UN SEUL bloc Python principal en début d'exercice +- [ ] Imports uniquement dans le premier bloc +- [ ] Tous les blocs Python se terminent par `globals()` +- [ ] Aucune redéfinition de variable entre blocs +- [ ] Bloc graphique (s'il existe) réutilise les variables du bloc principal + +### Tirages aléatoires +- [ ] Calculs en `Rational` exact, conversion `float()` uniquement à l'affichage +- [ ] Pas de `int(x * 10)` sur des flottants +- [ ] Tous les paramètres dérivés sont **calculés**, pas tirés indépendamment +- [ ] La propriété démontrée par l'exercice est vraie sur 100% des seeds (testée sur ≥ 200 seeds) +- [ ] Pas de valeurs négatives quand l'énoncé exige des entiers naturels (ex: `w_0 ≥ 0`) + +### Affichage / interpolation +- [ ] Aucun `{{f(x, **kwargs)}}` dans le markdown — toutes les strings d'affichage pré-calculées dans le bloc Python +- [ ] Coefficients signés générés explicitement (pas de `{{a}}{{b}}t` avec `b` négatif) +- [ ] `pxsl_format_number` reçoit toujours des valeurs déjà arrondies (`round(float(x), n)` en amont) + +### Solutions +- [ ] Toutes les solutions validées Chabane sont conformes textuellement (modulo paramètres) +- [ ] Aucun ajout pédagogique non sollicité (listes à puces, préambules, étapes intermédiaires) +- [ ] Pas de reformulation ("factoriser" reste "factoriser", pas "diviser") + +### Structure MyST +- [ ] Paragraphes contextuels HORS `questionStatement` +- [ ] Pas de doublons d'énoncés (chaque énoncé apparaît une seule fois) +- [ ] Phrases "Dans cette question, on étudie..." reformulées avec périmètre explicite +- [ ] Ancres `(exoN-qK)=` présentes avant chaque bloc question + +### Bilinguisme +- [ ] FR et EN équivalents en niveau de détail +- [ ] Notation math identique (`$[0\,;\,1]$` dans les deux versions) +- [ ] Si l'énoncé est modifié → blocs `{en}` effacés (sinon conservés intacts) + +### Rendu graphique +- [ ] Toutes les variables aléatoires utilisées dans l'énoncé sont effectivement utilisées dans le tracé +- [ ] Tangentes calculées analytiquement (`f'(x_A)`), pas par heuristique +- [ ] Tous les labels (Cf, T, Δ, A) restent dans la fenêtre `[xlim, ylim]` +- [ ] Paramètres bornés pour que les courbes ne sortent pas du cadre +- [ ] `plt.show()` (pas `plt.savefig`) +- [ ] Pas de Rational sympy mélangé à des array numpy + +### Tests automatiques +- [ ] Simulation sur ≥ 100 seeds : aucune erreur de rendu +- [ ] Simulation sur ≥ 100 seeds : aucun artefact flottant (`0.35000000000000003`, `0.44999999999999996`) +- [ ] Simulation sur ≥ 100 seeds : toutes les contraintes mathématiques de l'exercice sont respectées +- [ ] Aucune valeur affichée hors plage attendue (probabilité > 1, distance négative, etc.) + +--- + +## 15. Convention de nommage des fichiers + +- Version non pythonisée corrigée : `exoN_corrige.md` +- Version pythonisée corrigée : `exoN_pythonise_corrige.md` +- Version FR-only (après modif d'énoncé) : `*_FR_only.md` +- Toujours dans `/mnt/user-data/outputs/` +- Pas de longues chaînes descriptives dans le nom + +--- + +## 16. Anti-patterns récurrents — résumé + +Les 10 erreurs les plus fréquentes vues sur les pythonisations livrées : + +1. **`{{latex(x, **config_standard)}}`** dans le markdown → casse à cause des `**`. Stocker en variable. +2. **Variables redéfinies entre blocs Python** → valeurs incohérentes entre questions. +3. **Tirages indépendants de paramètres mathématiquement liés** → énoncé faux (ex: `L_min < asymptote`). +4. **Paramètres aléatoires qui invalident la propriété démontrée** → solution dit "vrai" alors que c'est faux. +5. **`int(x * 10)` sur flottant** → bugs d'arrondi (ex: `int(4.7 * 10) = 46`). +6. **Heuristique de tangente** au lieu de `f'(x_A)` analytique. +7. **`x_paramètre` aléatoire mais ignoré dans le tracé matplotlib** → graphique incohérent avec l'énoncé. +8. **Labels matplotlib qui sortent de la fenêtre** → graphique compressé. +9. **Solutions validées modifiées** (listes à puces ajoutées, reformulations, étapes ajoutées) → directive Chabane violée. +10. **Doublons d'énoncés** (même phrase en paragraphe libre + dans `questionStatement`). diff --git a/app/knowledge/rules_digest.py b/app/knowledge/rules_digest.py new file mode 100644 index 0000000000000000000000000000000000000000..d7ffab7ccab14bddd065e2b1a055c35b255768f2 --- /dev/null +++ b/app/knowledge/rules_digest.py @@ -0,0 +1,138 @@ +""" +rules_digest.py (ex utils/rules_digest.py) +────────────────────────────────────────── +Parser de `pythonisation_rules.md` + construction de digests ciblés injectés +dans les prompts. Parsé une fois à l'import, caché en module. + +API publique : + RULES_BY_ID dict[str, Rule] + ALL_RULE_IDS list[str] + build_rules_digest(rule_ids) → markdown compact + AUDIT_RULES_ALWAYS list[str] +""" + +from __future__ import annotations + +import re +from typing import Optional, TypedDict + +from app.config import RULES_MD + + +class Rule(TypedDict, total=False): + id: str + title: str + section: str + bad: Optional[str] + good: Optional[str] + why: Optional[str] + + +_RULE_HEADING = re.compile( + r"^### RÈGLE (?P\d+\.\d+(?:\.\d+)?)\s*—\s*(?P.+?)\s*$", + re.MULTILINE, +) +_SECTION_HEADING = re.compile( + r"^## (?P<num>\d+)\.\s*(?P<title>.+?)\s*$", + re.MULTILINE, +) +_BAD_BLOCK = re.compile( + r"\*\*❌\s*FAUTIF\*\*[^\n]*\n```(?:[a-zA-Z]*)\n(.+?)\n```", + re.DOTALL, +) +_GOOD_BLOCK = re.compile( + r"\*\*✅\s*CORRIG(?:É|EE?|É?)\*\*[^\n]*\n```(?:[a-zA-Z]*)\n(.+?)\n```", + re.DOTALL, +) +_WHY = re.compile( + r"\*\*POURQUOI\*\*\s*:\s*(.+?)(?=\n{2,}|\n#{2,3}\s|\Z)", + re.DOTALL, +) + + +def _parse_rules(content: str) -> dict[str, Rule]: + sections: list[tuple[int, str]] = [] + for m in _SECTION_HEADING.finditer(content): + sections.append((m.start(), f"{m.group('num')}. {m.group('title').strip()}")) + + rule_matches = list(_RULE_HEADING.finditer(content)) + rules: dict[str, Rule] = {} + + for i, m in enumerate(rule_matches): + rule_id = m.group("id") + title = m.group("title").strip() + body_start = m.end() + body_end = rule_matches[i + 1].start() if i + 1 < len(rule_matches) else len(content) + body = content[body_start:body_end].strip() + + section_title = "" + for off, label in sections: + if off < m.start(): + section_title = label + + bad_m = _BAD_BLOCK.search(body) + good_m = _GOOD_BLOCK.search(body) + why_m = _WHY.search(body) + + rules[rule_id] = Rule( + id=rule_id, + title=title, + section=section_title, + bad=bad_m.group(1).rstrip() if bad_m else None, + good=good_m.group(1).rstrip() if good_m else None, + why=why_m.group(1).strip() if why_m else None, + ) + return rules + + +_RULES_CACHE: Optional[dict[str, Rule]] = None + + +def _load_rules() -> dict[str, Rule]: + global _RULES_CACHE + if _RULES_CACHE is None: + _RULES_CACHE = _parse_rules(RULES_MD.read_text(encoding="utf-8")) + return _RULES_CACHE + + +RULES_BY_ID: dict[str, Rule] = _load_rules() +ALL_RULE_IDS: list[str] = sorted( + RULES_BY_ID.keys(), + key=lambda s: tuple(int(p) for p in s.split(".")), +) + + +def build_rules_digest(rule_ids: list[str]) -> str: + """Digest markdown compact des règles demandées (IDs inconnus ignorés).""" + if not rule_ids: + return "" + parts: list[str] = [] + for rid in rule_ids: + rule = RULES_BY_ID.get(rid) + if rule is None: + continue + parts.append(f"### Règle {rule['id']} — {rule['title']}") + if rule.get("bad"): + parts.append("❌ FAUTIF :") + parts.append(f"```\n{rule['bad']}\n```") + if rule.get("good"): + parts.append("✅ CORRECT :") + parts.append(f"```\n{rule['good']}\n```") + if rule.get("why"): + parts.append(f"→ {' '.join(rule['why'].split())}") + parts.append("") + return "\n".join(parts).rstrip() + "\n" + + +# Règles vérifiables sur le texte assemblé seul (pas d'exec) — passe d'audit. +AUDIT_RULES_ALWAYS: list[str] = [ + "2.1", # :id: vide en première ligne + "3.1", # un seul bloc Python principal + "6.1", # pas d'appels **kwargs dans {{...}} + "6.2", # pas de pxsl_format_number(**kwargs) dans {{...}} + "6.3", # variables d'affichage pré-calculées + "7.1", # pas de concaténation signe + valeur + "9.1", # paragraphes contextuels hors questionStatement + "9.4", # pas de doublons d'énoncés + "11.5", # plt.show() (pas savefig) +] diff --git a/app/llm/__init__.py b/app/llm/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/llm/client.py b/app/llm/client.py new file mode 100644 index 0000000000000000000000000000000000000000..f36325035ce8cfc1eaa28d87b3d0ec281c2b1b2d --- /dev/null +++ b/app/llm/client.py @@ -0,0 +1,166 @@ +""" +Client LLM OpenRouter — appels texte (+ multimodal) avec retries et tracking +des coûts. Fusion de l'ancien utils/llm_client.py et de la variante +« reasoning » (utils/llm_client_with_reasoning.py, jamais branchée) : le +raisonnement est désormais un simple paramètre `reasoning=` contrôlé par +config.USE_REASONING. +""" + +import logging +import os +import random +import time +from typing import Optional, Tuple + +import requests + +from app.config import AVAILABLE_MODELS, REASONING_CONFIG +from app.llm.cost import get_cost_tracker, CostTracker + +logger = logging.getLogger(__name__) + +SYSTEM_MSG = "Vous êtes un assistant pour la génération d'exercices MystMarkdown dynamiques." + + +class LLMClient: + """Client pour les appels LLM via OpenRouter avec tracking des coûts.""" + + def __init__(self, track_costs: bool = True): + self.api_key = os.getenv("OPENROUTER_API_KEY", "") + if not self.api_key: + raise ValueError("OPENROUTER_API_KEY not found in environment variables") + + self.base_url = "https://openrouter.ai/api/v1/chat/completions" + self.headers = { + "Authorization": f"Bearer {self.api_key}", + "HTTP-Referer": "http://localhost:5000", + "Content-Type": "application/json", + } + self.max_retries = 3 + self.retry_delay = 2 + self.track_costs = track_costs + self._cost_tracker: Optional[CostTracker] = None + + @property + def cost_tracker(self) -> CostTracker: + if self._cost_tracker is None: + self._cost_tracker = get_cost_tracker() + return self._cost_tracker + + def call_llm(self, prompt: str, model_idx: int, temperature: float = 0.0, + max_tokens: int = 4096, system_prompt: str = SYSTEM_MSG, + reasoning: bool = False) -> str: + """Appel LLM standard avec gestion des erreurs et retry.""" + model = AVAILABLE_MODELS.get(model_idx) + if model is None: + raise ValueError(f"model_idx {model_idx} inexistant dans AVAILABLE_MODELS") + + payload = { + "model": model, + "temperature": temperature, + "max_tokens": max_tokens, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt}, + ], + } + if reasoning: + payload["reasoning"] = dict(REASONING_CONFIG) + + content, generation_id = self._make_request(payload) + + if self.track_costs and generation_id: + self.cost_tracker.track_cost( + generation_id=generation_id, + model=model, + is_image=False, + prompt=prompt, + response=content, + ) + return content + + def call_llm_multimodal(self, image_b64: str, prompt: str, model_idx: int, + temperature: float = 0.0, max_tokens: int = 4096, + system_prompt: str = SYSTEM_MSG) -> str: + """Appel LLM multimodal avec image.""" + model = AVAILABLE_MODELS.get(model_idx) + if model is None: + raise ValueError(f"model_idx {model_idx} inexistant dans AVAILABLE_MODELS") + + payload = { + "model": model, + "temperature": temperature, + "max_tokens": max_tokens, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": [ + {"type": "text", "text": prompt}, + {"type": "image_url", "image_url": f"data:image/png;base64,{image_b64}"}, + ]}, + ], + } + content, generation_id = self._make_request(payload, is_multimodal=True) + + if self.track_costs and generation_id: + self.cost_tracker.track_cost( + generation_id=generation_id, + model=model, + is_image=True, + prompt=prompt, + response=content, + ) + return content + + def _make_request(self, payload: dict, is_multimodal: bool = False) -> Tuple[str, Optional[str]]: + """Requête POST avec retries (backoff exponentiel + jitter, gestion 429).""" + for attempt in range(self.max_retries): + try: + resp = requests.post(self.base_url, json=payload, headers=self.headers, timeout=180) + + if resp.status_code == 429: + wait = self.retry_delay * (2 ** attempt) + random.uniform(0, 5) + logger.warning("Rate-limited (%s) ; attente %.1fs (retry %d/%d)", + "multimodal" if is_multimodal else "standard", + wait, attempt + 1, self.max_retries) + time.sleep(wait) + continue + + resp.raise_for_status() + data = resp.json() + + if "error" in data: + raise RuntimeError(f"API error : {data['error']}") + + content = data["choices"][0]["message"]["content"] + generation_id = data.get("id") + return content, generation_id + + except (requests.RequestException, RuntimeError) as e: + if attempt == self.max_retries - 1: + raise + wait = self.retry_delay * (2 ** attempt) + random.uniform(0, 5) + logger.warning("Erreur LLM : %s — retry dans %.1fs", e, wait) + time.sleep(wait) + + raise RuntimeError("Échec après plusieurs tentatives") + + +_llm_client: Optional[LLMClient] = None + + +def get_llm_client() -> LLMClient: + global _llm_client + if _llm_client is None: + _llm_client = LLMClient() + return _llm_client + + +def process_with_openrouter(prompt: str, model_idx: int, temperature: float = 0.0, + max_tokens: int = 4096, image_b64: str = None, + system_prompt: str = SYSTEM_MSG, reasoning: bool = False) -> str: + """Point d'entrée unique du pipeline pour tous les appels LLM.""" + if image_b64: + return get_llm_client().call_llm_multimodal( + image_b64, prompt, model_idx, temperature, max_tokens, system_prompt) + return get_llm_client().call_llm( + prompt, model_idx, temperature, max_tokens, system_prompt, reasoning=reasoning) diff --git a/app/llm/cost.py b/app/llm/cost.py new file mode 100644 index 0000000000000000000000000000000000000000..258368a4ed64d69dfb5dd789b90f0f7dde8495b4 --- /dev/null +++ b/app/llm/cost.py @@ -0,0 +1,263 @@ +""" +Suivi des coûts des appels OpenRouter (ex utils/cost_estimator.py). +Coûts RÉELS récupérés via GET /api/v1/generation?id=… (tokens + coût exacts) ; +estimation len//4 utilisée uniquement en fallback temporaire, remplacée dès +que la réponse de l'API arrive. +""" + +import logging +import os +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, Optional + +import requests + +from app.config import MODEL_PRICING + +logger = logging.getLogger(__name__) + +IMAGE_COST_PER_IMAGE = 0.002 # ~$0.002 par image pour la plupart des modèles + + +@dataclass +class CostRecord: + """Enregistrement d'un coût pour une génération.""" + generation_id: str + model: str + timestamp: datetime + input_tokens: int = 0 + output_tokens: int = 0 + total_cost: float = 0.0 + is_estimated: bool = True + is_image: bool = False + raw_stats: Dict = field(default_factory=dict) + + +class CostEstimator: + """Estime et enregistre les coûts des appels LLM.""" + + def __init__(self): + self.actual_costs: Dict[str, CostRecord] = {} + self.estimated_costs: Dict[str, CostRecord] = {} + self.session_total: float = 0.0 + self.session_start: datetime = datetime.now() + self._lock = threading.Lock() + + def estimate_tokens(self, text: str) -> int: + """Approximation ~4 caractères/token (fallback uniquement).""" + if not text: + return 0 + return max(1, len(text) // 4) + + def get_model_pricing(self, model: str) -> Dict[str, float]: + return MODEL_PRICING.get(model, {"input": 1.0, "output": 3.0}) + + def estimate_cost(self, model: str, prompt: str, response: str, + is_image: bool = False) -> CostRecord: + input_tokens = self.estimate_tokens(prompt) + output_tokens = self.estimate_tokens(response) + pricing = self.get_model_pricing(model) + total_cost = ( + (input_tokens / 1_000_000) * pricing["input"] + + (output_tokens / 1_000_000) * pricing["output"] + ) + if is_image: + total_cost += IMAGE_COST_PER_IMAGE + + record = CostRecord( + generation_id=f"est_{int(time.time() * 1000)}", + model=model, + timestamp=datetime.now(), + input_tokens=input_tokens, + output_tokens=output_tokens, + total_cost=total_cost, + is_estimated=True, + is_image=is_image, + ) + with self._lock: + self.estimated_costs[record.generation_id] = record + self.session_total += total_cost + return record + + def record_actual_cost(self, model: str, generation_id: str, + stats: Dict, is_image: bool = False) -> bool: + try: + usage = stats.get("usage", stats.get("data", {})) + input_tokens = usage.get("prompt_tokens", 0) or usage.get("tokens_prompt", 0) + output_tokens = usage.get("completion_tokens", 0) or usage.get("tokens_completion", 0) + total_cost = ( + stats.get("total_cost") + or stats.get("cost") + or usage.get("total_cost") + or 0.0 + ) + if total_cost == 0 and (input_tokens > 0 or output_tokens > 0): + pricing = self.get_model_pricing(model) + total_cost = ( + (input_tokens / 1_000_000) * pricing["input"] + + (output_tokens / 1_000_000) * pricing["output"] + ) + if is_image: + total_cost += IMAGE_COST_PER_IMAGE + + record = CostRecord( + generation_id=generation_id, + model=model, + timestamp=datetime.now(), + input_tokens=input_tokens, + output_tokens=output_tokens, + total_cost=total_cost, + is_estimated=False, + is_image=is_image, + raw_stats=stats, + ) + with self._lock: + # Remplacer l'estimation provisoire la plus proche dans le temps. + for est_id, est_record in list(self.estimated_costs.items()): + if est_record.model == model and abs( + (est_record.timestamp - record.timestamp).total_seconds() + ) < 60: + del self.estimated_costs[est_id] + self.session_total -= est_record.total_cost + break + self.actual_costs[generation_id] = record + self.session_total += total_cost + return True + + except (KeyError, TypeError, ValueError) as e: + logger.warning("Enregistrement du coût réel impossible : %s", e) + return False + + def get_session_summary(self) -> Dict[str, Any]: + with self._lock: + costs_by_model: Dict[str, Dict] = {} + for record in list(self.actual_costs.values()) + list(self.estimated_costs.values()): + entry = costs_by_model.setdefault(record.model, { + "requests": 0, "input_tokens": 0, "output_tokens": 0, "total_cost": 0.0, + }) + entry["requests"] += 1 + entry["input_tokens"] += record.input_tokens + entry["output_tokens"] += record.output_tokens + entry["total_cost"] += record.total_cost + + return { + "session_start": self.session_start.isoformat(), + "total_requests": len(self.actual_costs) + len(self.estimated_costs), + "actual_costs_count": len(self.actual_costs), + "estimated_costs_count": len(self.estimated_costs), + "total_cost_usd": round(self.session_total, 6), + "total_cost_eur": round(self.session_total * 0.92, 6), + "costs_by_model": costs_by_model, + } + + def reset_session(self): + with self._lock: + self.actual_costs.clear() + self.estimated_costs.clear() + self.session_total = 0.0 + self.session_start = datetime.now() + + +class CostTracker: + """Tracker de coûts avec récupération asynchrone des stats réelles.""" + + def __init__(self, api_key: str = None): + self.api_key = api_key or os.getenv("OPENROUTER_API_KEY") + self.cost_estimator = CostEstimator() + self.base_url = "https://openrouter.ai/api/v1" + self._pending_fetches: Dict[str, threading.Thread] = {} + + def fetch_generation_stats(self, generation_id: str) -> Optional[Dict]: + """Stats réelles (tokens + coût) d'une génération via l'API OpenRouter.""" + if not generation_id: + return None + try: + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + url = f"{self.base_url}/generation?id={generation_id}" + response = requests.get(url, headers=headers, timeout=10) + if response.status_code == 200: + return response.json() + logger.debug("API stats %s : %s", response.status_code, response.text[:200]) + return None + except requests.RequestException as e: + logger.debug("Récupération des stats impossible : %s", e) + return None + + def _schedule_generation_stats_fetch(self, generation_id: str, model: str, is_image: bool): + """Récupération asynchrone différée (les stats arrivent avec ~2 s de retard).""" + def fetch_delayed(): + time.sleep(2) + try: + stats = self.fetch_generation_stats(generation_id) + if stats: + self.cost_estimator.record_actual_cost( + model=model, generation_id=generation_id, + stats=stats, is_image=is_image, + ) + finally: + self._pending_fetches.pop(generation_id, None) + + thread = threading.Thread(target=fetch_delayed, daemon=True) + self._pending_fetches[generation_id] = thread + thread.start() + + def track_cost(self, generation_id: str, model: str, is_image: bool, + prompt: str = "", response: str = "") -> bool: + """Coût réel si dispo immédiatement, sinon estimation + fetch async.""" + try: + stats = self.fetch_generation_stats(generation_id) + if stats and ("data" in stats or "cost" in stats + or "total_cost" in stats or "usage" in stats): + if self.cost_estimator.record_actual_cost( + model=model, generation_id=generation_id, + stats=stats, is_image=is_image, + ): + return True + except Exception as e: + logger.debug("Fetch immédiat du coût raté : %s", e) + + self._schedule_generation_stats_fetch(generation_id, model, is_image) + if generation_id not in self.cost_estimator.actual_costs: + self.cost_estimator.estimate_cost( + model=model, prompt=prompt, response=response, is_image=is_image) + return False + + def get_summary(self) -> Dict[str, Any]: + return self.cost_estimator.get_session_summary() + + def reset(self): + self.cost_estimator.reset_session() + + +_cost_tracker: Optional[CostTracker] = None + + +def get_cost_tracker() -> CostTracker: + global _cost_tracker + if _cost_tracker is None: + _cost_tracker = CostTracker() + return _cost_tracker + + +def cost_snapshot() -> dict: + """Photo des totaux de session — l'orchestrateur fait before/after pour + attribuer un coût par job (mono-poste : pas de jobs LLM concurrents).""" + s = get_cost_tracker().get_summary() + return {"usd": s["total_cost_usd"], "requests": s["total_requests"]} + + +def cost_delta(before: dict) -> dict: + """Coût (USD/EUR) et nb de requêtes depuis `before` (cf. cost_snapshot).""" + after = cost_snapshot() + usd = max(0.0, round(after["usd"] - before["usd"], 6)) + return { + "usd": usd, + "eur": round(usd * 0.92, 6), + "requests": after["requests"] - before["requests"], + } diff --git a/app/pipeline/__init__.py b/app/pipeline/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/pipeline/analyze.py b/app/pipeline/analyze.py new file mode 100644 index 0000000000000000000000000000000000000000..2a1ab93e27d3dc6d85e75c5ce696b3474a534f59 --- /dev/null +++ b/app/pipeline/analyze.py @@ -0,0 +1,115 @@ +""" +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__) + + +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) -> 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). + """ + 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, + max_tokens=6096, + ) + f_notions = pool.submit(enrich_exercise_with_notions, content, xlsx_path=NOTIONS_XLSX) + f_functions = 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 = "", "" + + try: + functions_ctx = f_functions.result()["catalogue"] + except Exception as e: + logger.warning("RAG fonctions en échec (%s) — catalogue vide.", e) + functions_ctx = "" + + 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 diff --git a/app/pipeline/audit.py b/app/pipeline/audit.py new file mode 100644 index 0000000000000000000000000000000000000000..8669d0db394467089547cc1bd997897590f843af --- /dev/null +++ b/app/pipeline/audit.py @@ -0,0 +1,266 @@ +""" +audit.py +──────── +Passe d'audit LLM (≤ MAX_AUDIT_ITERATIONS) + filet de sécurité des patches. +Code déplacé depuis routes/pythonise_routes_v2.py avec deux corrections : + • un patch sûr est appliqué à TOUTES les occurrences identiques de + `location` (la v1 ne corrigeait que la première — un problème répété + subsistait N-1 fois) ; + • gestion d'erreurs ciblée (plus d'`except (json.JSONDecodeError, Exception)`). +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import Callable, Optional + +from app.config import MAX_AUDIT_ITERATIONS +from app.knowledge.rules_digest import AUDIT_RULES_ALWAYS, build_rules_digest +from app.llm.client import process_with_openrouter +from app.pipeline.postprocess import insert_python_lines, strip_fences +from app.pipeline.prompts import STEP_AUDIT_PROMPT, SYSTEM_PROMPT + +logger = logging.getLogger(__name__) + + +# ───────────────────────────────────────────────────────────────────────────── +# Filet de sécurité des patches (déplacé tel quel, noms inchangés) +# ───────────────────────────────────────────────────────────────────────────── + +_KNOWN_FREE_NAMES = frozenset({ + "True", "False", "None", "and", "or", "not", "in", "is", "if", "else", + "elif", "for", "while", "def", "class", "return", "lambda", "yield", + "with", "as", "from", "import", "pass", "break", "continue", "try", + "except", "finally", "raise", "global", "nonlocal", "assert", "del", + "self", "cls", + "pi", "e", "oo", "abs", "min", "max", "sum", "range", "len", "int", + "float", "str", "bool", "list", "tuple", "dict", "set", "round", "pow", + "all", "any", "map", "filter", "zip", "enumerate", "sorted", "reversed", + "print", "isinstance", "type", "repr", "hash", + "x", "y", "z", "t", "n", "config_standard", +}) + + +def _patch_introduces_unbound_name( + myst_exercise: str, + location: str, + fix: str, + python_insert: Optional[str], +) -> Optional[str]: + """Nom INJECTÉ ({{var}}) introduit par `fix` qui ne serait lié nulle part + après patch (NameError au rendu). None = sûr sur ce critère. + Volontairement limité aux placeholders : les autres mots d'un patch + markdown sont de la prose/du LaTeX, pas des variables Python (la v2 du + filet flaguait à tort `id`, `f`, `align`… dans des patches purement texte).""" + fix_names = set(re.findall(r"\{\{\s*([a-zA-Z_]\w*)\s*\}\}", fix)) + loc_names = set(re.findall(r"\{\{\s*([a-zA-Z_]\w*)\s*\}\}", location)) + new_names = (fix_names - loc_names) - _KNOWN_FREE_NAMES + if not new_names: + return None + + after_patch = myst_exercise.replace(location, fix) + if python_insert: + after_patch += "\n" + python_insert + + for name in new_names: + patterns = ( + rf"\b{re.escape(name)}\s*=(?!=)", + rf"\bdef\s+{re.escape(name)}\b", + rf"\bclass\s+{re.escape(name)}\b", + rf"\bfor\s+{re.escape(name)}\b", + rf"\bas\s+{re.escape(name)}\b", + rf"\bimport\s+(?:\w+\s*,\s*)*{re.escape(name)}\b", + rf"\bfrom\s+[\w.]+\s+import\s+(?:[^,\n]*,\s*)*{re.escape(name)}\b", + rf"\bdef\s+\w+\([^)]*\b{re.escape(name)}\b[^)]*\)", + ) + if not any(re.search(p, after_patch) for p in patterns): + return name + return None + + +def _is_patch_safe( + myst_exercise: str, + location: str, + fix: str, + python_insert: Optional[str] = None, +) -> tuple[bool, str]: + """Validation défensive avant application d'un patch d'audit.""" + # 1) Alias d'import supprimé mais encore utilisé. + alias_re = re.compile(r"\bimport\b[^\n]*?\bas\s+(\w+)") + dropped = set(alias_re.findall(location)) - set(alias_re.findall(fix)) + if dropped: + rest = myst_exercise.replace(location, "", 1) + for alias in dropped: + if re.search(rf"\b{re.escape(alias)}\s*\(", rest): + return False, f"Alias `{alias}` est utilisé ailleurs dans le code — patch refusé." + + # 1bis) Import supprimé mais encore référencé. + def _extract_imported_names(text: str) -> set[str]: + names: set[str] = set() + for m in re.finditer(r"^\s*import\s+([\w.]+)(?:\s+as\s+(\w+))?", text, re.MULTILINE): + names.add(m.group(2) or m.group(1).split(".")[0]) + for m in re.finditer(r"^\s*from\s+[\w.]+\s+import\s+(.+?)\s*$", text, re.MULTILINE): + for piece in m.group(1).split(","): + am = re.match(r"^(\w+)(?:\s+as\s+(\w+))?$", piece.strip()) + if am: + names.add(am.group(2) or am.group(1)) + return names + + dropped_imports = _extract_imported_names(location) - _extract_imported_names(fix) + if dropped_imports: + rest = myst_exercise.replace(location, fix, 1) + for name in dropped_imports: + if re.search(rf"\b{re.escape(name)}\b(?:\s*[(.])", rest): + return False, (f"L'import `{name}` est encore utilisé ailleurs après le patch " + "— refus pour éviter une NameError au runtime.") + + # 2) Suppression de globals(). + if "globals()" in location and "globals()" not in fix: + return False, "Le patch supprimerait `globals()` (requis en fin de bloc python)." + + # 2bis) Règle 6.4 — **config_standard sur un helper PyxiScience (réservé à + # sympy.latex). Liste alignée sur le catalogue curé app/knowledge. + _PYXISCIENCE_HELPERS = ( + "pxsl_format_number", "pxsl_res_num", "pxsl_matrix", "pxsl_pow", + "pxsl_latex_coefficient", "pxsl_par", "pxsl_mult", "pxsl_choose_udv", "lc", + "pxsl_latex", "pxsl_sign", "pxsl_latex_with_formatting", "pxsl_Rational", + "pxsl_sum_matrix", "pxsl_prod_matrix", "pxsl_prod_scalar_matrix", + "pxsl_ax", "pxsl_system_lin", "pxsl_double_matrix", "pxsl_lines_op", + "pxsl_resol_system", "pxsl_pow_matrix", "pxsl_law", "pxsl_moment", + "pxsl_scalar_product", "pxsl_sum_vector", "pxs_explain_IBP", + ) + + def _splat_pattern(name: str) -> str: + return (rf"\b{re.escape(name)}\s*\(" + r"(?:[^()]|\([^()]*\))*" + r"\*\*\s*config_standard" + r"(?:[^()]|\([^()]*\))*\)") + + for helper in _PYXISCIENCE_HELPERS: + pat = _splat_pattern(helper) + if re.search(pat, fix) and not re.search(pat, location): + return False, (f"Le patch ajoute `**config_standard` à `{helper}(...)` — " + "ce helper PyxiScience ne l'accepte pas et plante (règle 6.4). " + "Réserve `**config_standard` à `sympy.latex(...)` uniquement.") + + # 3) Variable jamais définie. + unbound = _patch_introduces_unbound_name(myst_exercise, location, fix, python_insert) + if unbound: + return False, (f"Le patch introduit la variable `{unbound}` qui n'est définie nulle part " + "(NameError à l'exécution). Si une définition Python est nécessaire, " + "utilise `python_insert` dans l'audit.") + return True, "" + + +def _replace_everywhere(text: str, location: str, fix: str) -> tuple[str, int]: + """Remplace TOUTES les occurrences de location (1 seule si fix ⊇ location, + pour éviter la ré-expansion infinie).""" + if location in fix: + return text.replace(location, fix, 1), 1 + n = text.count(location) + return text.replace(location, fix), n + + +# ───────────────────────────────────────────────────────────────────────────── +# Boucle d'audit +# ───────────────────────────────────────────────────────────────────────────── + +def run_audit( + myst_exercise: str, + step1_targets: list[str], + model_idx: int, + set_step: Optional[Callable[[str], None]] = None, +) -> tuple[str, list[dict], list[dict]]: + """Audit LLM en boucle (≤ MAX_AUDIT_ITERATIONS). Retourne + (exercice patché, patches appliqués, warnings).""" + audit_rule_ids = list(dict.fromkeys(AUDIT_RULES_ALWAYS + step1_targets)) + audit_digest = build_rules_digest(audit_rule_ids) + + patches: list[dict] = [] + warnings: list[dict] = [] + + for audit_iter in range(MAX_AUDIT_ITERATIONS): + if set_step: + set_step(f"Audit {audit_iter + 1}/{MAX_AUDIT_ITERATIONS}…") + try: + audit_raw = process_with_openrouter( + prompt=STEP_AUDIT_PROMPT.format( + audit_rules=audit_digest, + exercise=myst_exercise, + ), + model_idx=model_idx, + temperature=0.0, + max_tokens=8192, + system_prompt=SYSTEM_PROMPT, + ) + except (RuntimeError, ValueError, OSError) as audit_err: + logger.warning("Appel d'audit en échec : %s", audit_err) + warnings.append({"rule": "?", "message": f"Audit pass failed: {audit_err}"}) + break + + try: + audit_data = json.loads(strip_fences(audit_raw)) + except json.JSONDecodeError: + warnings.append({ + "rule": "?", + "message": "Audit LLM did not return valid JSON; skipping further iterations.", + }) + break + + if audit_data.get("verdict") == "OK": + break + + applied_this_iter = 0 + for issue in audit_data.get("issues") or []: + if not isinstance(issue, dict): + continue + rule = str(issue.get("rule", "?")) + location = issue.get("location") or "" + fix = issue.get("fix") + python_insert = issue.get("python_insert") + can_patch = bool(issue.get("can_patch", True)) + message = issue.get("message") or "" + applied = False + + if (can_patch and isinstance(location, str) and isinstance(fix, str) + and location and location in myst_exercise): + safe, reason = _is_patch_safe(myst_exercise, location, fix, python_insert) + if safe: + myst_exercise, n_occ = _replace_everywhere(myst_exercise, location, fix) + applied = True + if n_occ > 1: + message += f" ({n_occ} occurrences corrigées)" + else: + warnings.append({ + "rule": rule, + "location": location, + "message": f"Patch refusé par filet de sécurité : {reason} (suggestion: {message})", + }) + continue + + if applied and isinstance(python_insert, str) and python_insert.strip(): + myst_exercise = insert_python_lines(myst_exercise, [python_insert.strip()]) + + if applied: + patches.append({ + "rule": rule, + "location": location, + "fix": fix, + "python_insert": python_insert or None, + "message": message, + "iteration": audit_iter + 1, + }) + applied_this_iter += 1 + else: + warnings.append({ + "rule": rule, + "location": location if isinstance(location, str) else "", + "message": message, + }) + + if applied_this_iter == 0: + break + + return myst_exercise, patches, warnings diff --git a/app/pipeline/fewshots.py b/app/pipeline/fewshots.py new file mode 100644 index 0000000000000000000000000000000000000000..a06d964bcbdf400cb75f189f1de27a9891ee02d2 --- /dev/null +++ b/app/pipeline/fewshots.py @@ -0,0 +1,81 @@ +""" +fewshots.py +─────────── +Banque de few-shots par type d'exercice, tirée des exemples pythonisés LIVRÉS +de la plateforme (Exemples d'exercices/master_all → app/knowledge/fewshots/). +Seuls des exemples VERTS au harnais sont admis comme modèles positifs. + +Sélection : mots-clés sur (exercise_type + title + concepts) de l'analyse. +Injection : version ÉLAGUÉE (bloc python + énoncé + 1re question) pour +limiter le coût en contexte. +""" + +from __future__ import annotations + +import logging +import re +from functools import lru_cache + +from app.config import FEWSHOTS_DIR + +logger = logging.getLogger(__name__) + +# Ordre = priorité (premier motif qui matche gagne). +_TYPE_KEYWORDS: list[tuple[str, str]] = [ + ("figure", r"figure|graph|matplotlib|trac[ée]|courbe|repr[ée]sent|parabole.*dessin"), + ("integration", r"int[ée]gra|primitive|\bipp\b|parties"), + ("systemes", r"syst[èe]me"), + ("logexp", r"\blog\b|logarithm|exponentiel|exposant|puissance"), + ("quadratique", r"trin[ôo]me|quadrat|second degr[ée]|discriminant|racine|factoris"), + ("equations", r"[ée]quation|in[ée]quation|lin[ée]aire|affine|int[ée]r[êe]t|seuil|finance|proportion"), +] +_DEFAULT_KEY = "equations" + +_Q_BLOCK_RE = re.compile(r":::::\{question\}.*?:::::", re.DOTALL) +_PY_BLOCK_RE = re.compile(r"(?ms)^(`{3,4})\{python\}[ \t]*\n.*?\n\1[ \t]*$") + + +def pick_fewshot_key(analysis: dict) -> str: + """Choisit la clé de few-shot selon le type détecté par l'analyse.""" + haystack = " ".join([ + str(analysis.get("exercise_type", "")), + str(analysis.get("exercise_title", "")), + " ".join(str(c) for c in (analysis.get("suggested_concepts") or [])), + ]).lower() + if analysis.get("needs_matplotlib"): + return "figure" + for key, pattern in _TYPE_KEYWORDS: + if re.search(pattern, haystack): + return key + return _DEFAULT_KEY + + +@lru_cache(maxsize=None) +def load_fewshot(key: str) -> str: + """Few-shot élagué : bloc python + énoncé + 1re question de l'exemple + canonique. Chaîne vide si le fichier manque (le prompt reste valable).""" + path = FEWSHOTS_DIR / f"{key}.md" + if not path.exists(): + logger.warning("Few-shot absent : %s", path) + return "" + text = path.read_text(encoding="utf-8") + + py = _PY_BLOCK_RE.search(text) + first_q = _Q_BLOCK_RE.search(text) + if not py or not first_q: + return "" + + # Énoncé = texte entre la fin du bloc python et la 1re question. + enonce = text[py.end():first_q.start()].strip() + parts = [py.group(0)] + if enonce: + parts.append(enonce) + parts.append(first_q.group(0)) + return "\n\n".join(parts) + + +def fewshot_for(analysis: dict) -> str: + key = pick_fewshot_key(analysis) + shot = load_fewshot(key) + logger.info("Few-shot sélectionné : %s (%d caractères)", key, len(shot)) + return shot or "(aucun exemple canonique disponible pour ce type)" diff --git a/app/pipeline/generate.py b/app/pipeline/generate.py new file mode 100644 index 0000000000000000000000000000000000000000..e7741dffdb9e067972b512e3f872b61f3baa369d --- /dev/null +++ b/app/pipeline/generate.py @@ -0,0 +1,211 @@ +""" +generate.py +─────────── +Découpage de l'exercice source (métadonnées / énoncé / segments de questions) +et génération par paires de questions (LLM). Code déplacé par blocs depuis +routes/pythonise_routes_v2.py ; les paires restent SÉQUENTIELLES (contexte +partagé : chaque appel reçoit les blocs déjà générés). +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import Callable, Optional + +from app.config import USE_REASONING +from app.llm.client import process_with_openrouter +from app.pipeline.postprocess import ( + PYTHON_FENCE_RE, + normalize_python_fences, + strip_fences, +) +from app.pipeline.prompts import STEP_PAIR_PROMPT, SYSTEM_PROMPT + +logger = logging.getLogger(__name__) + + +# ───────────────────────────────────────────────────────────────────────────── +# Découpage du source MyST +# ───────────────────────────────────────────────────────────────────────────── + +def strip_python_block_from_text(text: str) -> str: + """Retire les blocs {python} d'un texte (le Python est régénéré par paire).""" + cleaned = PYTHON_FENCE_RE.sub("", text) + cleaned = re.sub(r"\n{3,}", "\n\n", cleaned) + return cleaned.strip() + + +def split_metadata_and_enonce(header: str) -> tuple[str, str]: + """Sépare l'en-tête en (métadonnées directives, énoncé libre).""" + lines = header.splitlines() + OPTION_RE = re.compile(r"^\s*:[A-Za-z_][\w-]*:") + DIRECTIVE_OPEN_RE = re.compile(r"^\s*(?:`{3,}|:{3,}|\\begin)") + + metadata_end = 0 + found_enonce = False + for i, line in enumerate(lines): + stripped = line.strip() + if not stripped: + metadata_end = i + 1 + continue + if (OPTION_RE.match(line) or DIRECTIVE_OPEN_RE.match(line) + or stripped in ("}", "{")): + metadata_end = i + 1 + continue + found_enonce = True + break + + if not found_enonce: + return header.rstrip(), "" + + while metadata_end > 0 and not lines[metadata_end - 1].strip(): + metadata_end -= 1 + return "\n".join(lines[:metadata_end]), "\n".join(lines[metadata_end:]).strip() + + +def split_original_questions(content: str) -> tuple[str, str, list[str]]: + """ + Découpe l'exercice en (métadonnées, énoncé, segments de questions). + Chaque segment = [texte inter-question éventuel] + bloc :::::{question}. + Entrée non-MyST (pas de :::::{question}) → ("", content, []). + """ + if ":::::{question}" not in content: + return "", content, [] + + first_q = content.index(":::::{question}") + metadata, enonce = split_metadata_and_enonce(content[:first_q]) + enonce = strip_python_block_from_text(enonce) + body_part = content[first_q:] + + pattern = re.compile(r":::::\{question\}.*?:::::", re.DOTALL) + segments: list[str] = [] + last_end = 0 + for m in pattern.finditer(body_part): + inter_text = strip_python_block_from_text(body_part[last_end:m.start()].strip()) + segments.append(f"{inter_text}\n\n{m.group(0)}" if inter_text else m.group(0)) + last_end = m.end() + return metadata, enonce, segments + + +def build_exercise_metadata(metadata: str, lists_of_notions: str) -> str: + """En-tête déterministe (aucun LLM) — injecte les notions à côté de + :involvedConcepts: TYPE_BAC.""" + cleaned = strip_python_block_from_text(metadata) + if lists_of_notions and lists_of_notions not in cleaned: + cleaned = re.sub( + r":involvedConcepts:\s*TYPE_BAC(?![\w,])", + f":involvedConcepts: TYPE_BAC,{lists_of_notions}", + cleaned, + count=1, + ) + return cleaned.rstrip() + + +def assemble_exercise(metadata_header: str, pair_blocks: list[str]) -> str: + """Header + blocs de paires + fence finale 5 backticks. Les fences {python} + sont normalisées à la convention plateforme (4 backticks).""" + parts = [metadata_header.rstrip()] + parts.extend(block.strip() for block in pair_blocks) + parts.append("`````") + assembled = "\n\n".join(parts) + assembled = assembled.replace("{align*}", "{equation*}") + return normalize_python_fences(assembled) + + +# ───────────────────────────────────────────────────────────────────────────── +# Génération par paires +# ───────────────────────────────────────────────────────────────────────────── + +_LANG_DIRECTIVES = { + "fr": "Langue cible de CETTE sortie : FRANÇAIS uniquement (décimales à virgule).", + "en": "Langue cible de CETTE sortie : ANGLAIS uniquement (décimales à point).", + "both": ("Langue cible de CETTE sortie : BILINGUE — chaque prose en rôles " + "{fr}`…`{en}`…` symétriques (cf. règle bilingue ci-dessus)."), + "auto": "Langue cible : MÊME langue(s) que la source (ne traduis rien).", +} + + +def generate_pair_blocks( + content: str, + exercise_header: str, + enonce: str, + question_segments: list[str], + analysis: dict, + functions_ctx: str, + fewshot: str, + targeted_rules_digest: str, + property_constraints_text: str, + level: str, + model_idx: int, + lang: str = "auto", + set_step: Optional[Callable[[str], None]] = None, +) -> list[str]: + """Boucle de génération par paires (séquentielle). Retourne les blocs.""" + nb_questions = analysis.get("nb_questions", max(1, len(question_segments))) + lang_directive = _LANG_DIRECTIVES.get(lang, _LANG_DIRECTIVES["auto"]) + common = dict( + analysis=json.dumps(analysis, ensure_ascii=False, indent=2), + functions=functions_ctx or "Aucune fonction spécifique détectée.", + niveau=level or "non précisé", + targeted_rules=targeted_rules_digest, + property_constraints=property_constraints_text, + fewshot=fewshot, + lang_directive=lang_directive, + nb_total=nb_questions, + ) + + generated: list[str] = [] + + if not question_segments: + # Entrée texte brut → une seule génération. + if set_step: + set_step("Génération (bloc Python + toutes les questions)…") + raw = process_with_openrouter( + prompt=STEP_PAIR_PROMPT.format( + content=content, + previous_blocks="(aucun — première génération)", + nb_current=nb_questions, + range_label=f"1–{nb_questions}", + current_segment=content, + **common, + ), + model_idx=model_idx, + max_tokens=30000, + system_prompt=SYSTEM_PROMPT, + reasoning=USE_REASONING, + ) + return [strip_fences(raw)] + + pairs = [question_segments[i: i + 2] for i in range(0, len(question_segments), 2)] + for pair_idx, pair in enumerate(pairs): + q_start = pair_idx * 2 + 1 + q_end = min(pair_idx * 2 + len(pair), nb_questions) + range_label = str(q_start) if q_start == q_end else f"{q_start}–{q_end}" + if set_step: + set_step(f"Génération questions {range_label} / {nb_questions}…") + + current_segment = "\n\n".join(pair) + if pair_idx == 0 and enonce: + current_segment = enonce + "\n\n" + current_segment + + raw_pair = process_with_openrouter( + prompt=STEP_PAIR_PROMPT.format( + content=exercise_header, + previous_blocks=("\n\n".join(generated) if generated + else "(aucun — première paire)"), + nb_current=len(pair), + range_label=range_label, + current_segment=current_segment, + **common, + ), + model_idx=model_idx, + temperature=0.4, + max_tokens=16384, + system_prompt=SYSTEM_PROMPT, + reasoning=USE_REASONING, + ) + generated.append(strip_fences(raw_pair)) + + return generated diff --git a/app/pipeline/orchestrator.py b/app/pipeline/orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..0abd472aa236c41276209cbd0f1c3e68e3439809 --- /dev/null +++ b/app/pipeline/orchestrator.py @@ -0,0 +1,406 @@ +""" +orchestrator.py +─────────────── +Chef d'orchestre du pipeline pour UN exercice. (Le job/threading vit dans +server.py ; le mode batch boucle simplement sur run_exercise.) + +Ordre des étapes : + 1. Analyse + notions + RAG fonctions (PARALLÈLE — indépendants) + 2. Génération par paires (séquentielle, contexte partagé) + 3. Post-traitements déterministes (config_standard, assemblage 4-backticks, + dédoublonnage) + 4. Substitution des solutions validées (si présentes dans la source) + 5. Audit LLM (≤ 2 itérations, patches toutes-occurrences sécurisés) + 6. Post-traitements déterministes finaux : auto-lift GÉNÉRALISÉ des + injections non nues, renommage underscores, auto-correctif $+chiffre, + :id: vide, diff solutions, décimales (langue), invariants multi-seed, + contrôles matplotlib + 7. Langue cible (déterministe ou LLM masqué) + 8. PORTE HARNAIS (HARNESS_GATE_SEEDS graines) + 1 boucle de réparation LLM + max ; verdict exposé dans le résultat. +""" + +from __future__ import annotations + +import json +import logging +import time +from typing import Callable, Optional + +from app.config import ( + HARNESS_GATE_SEEDS, + HARNESS_REPAIR_MAX, + MULTI_SEED_NUM, +) +from app.knowledge.rules_digest import build_rules_digest +from app.llm.client import process_with_openrouter +from app.llm.cost import cost_delta, cost_snapshot +from app.rag.catalogue import catalogue_for +from app.pipeline import postprocess as pp +from app.pipeline.analyze import run_analysis_phase +from app.pipeline.audit import run_audit +from app.pipeline.fewshots import fewshot_for +from app.pipeline.generate import ( + assemble_exercise, + build_exercise_metadata, + generate_pair_blocks, + split_original_questions, +) +from app.pipeline.prompts import REPAIR_PROMPT, SYSTEM_PROMPT, TRANSLATE_CONSTRAINTS_PROMPT +from app.pipeline.solutions import replace_gen_solutions_with_source +from app.pipeline.translate import ensure_language +from app.validation import harness +from app.validation.sandbox import ( + dynamic_check_matplotlib, + extract_all_python_blocks, + extract_main_python_block, + multi_seed_validate, + static_check_rational_numpy_mix, + static_check_unused_random_vars, +) + +logger = logging.getLogger(__name__) + +TRUNK_RULES = ["2.1", "3.1", "3.2", "6.1", "6.3", "8.1"] + + +def _translate_constraints_to_assertions(code: str, constraints: list[str], + model_idx: int) -> list[dict]: + """Mini appel LLM : contrainte FR → expression booléenne Python.""" + if not constraints or not code.strip(): + return [] + try: + raw = process_with_openrouter( + prompt=TRANSLATE_CONSTRAINTS_PROMPT.format( + code=code, + constraints="\n".join(f" • {c}" for c in constraints + if isinstance(c, str) and c.strip()), + ), + model_idx=model_idx, + temperature=0.0, + max_tokens=2048, + system_prompt=SYSTEM_PROMPT, + ) + except (RuntimeError, ValueError, OSError) as e: + logger.warning("Traduction des contraintes en échec : %s", e) + return [] + try: + data = json.loads(pp.strip_fences(raw)) + except json.JSONDecodeError: + logger.warning("Traduction des contraintes : JSON invalide.") + return [] + if not isinstance(data, list): + return [] + return [ + {"description": str(d.get("description", "")), "assertion": d.get("assertion")} + for d in data + if isinstance(d, dict) and d.get("assertion") + ] + + +def run_exercise( + content: str, + filename: str = "exercise.md", + level: str = "", + model_idx: int = 1, + lang: str = "fr", + set_step: Optional[Callable[[str], None]] = None, +) -> dict: + """ + Pythonise UN exercice. Retourne le dict résultat (contrat UI) : + exercise, pair_blocks, analysis, functions, notions, audit_patches, + warnings, harness {ok, summary, seeds}, lang {source, target, action}, + cost {usd, eur, requests}, duration_s + """ + t0 = time.time() + cost_before = cost_snapshot() + _step = set_step or (lambda label: None) + + # ── 1. Analyse + notions + RAG (parallèle) ─────────────────────────────── + _step("Analyse + notions + catalogue RAG (en parallèle)…") + analysis, notions_ctx, lists_of_notions, functions_ctx = run_analysis_phase( + content, model_idx) + + step1_targets = [r for r in (analysis.get("target_rules") or []) if isinstance(r, str)] + target_rules = list(dict.fromkeys(TRUNK_RULES + step1_targets)) + targeted_rules_digest = build_rules_digest(target_rules) or "(aucune règle spécifique ciblée)" + + constraints = [c for c in (analysis.get("property_constraints") or []) + if isinstance(c, str) and c.strip()] + property_constraints_text = ("\n".join(f" • {c}" for c in constraints) + if constraints + else " (aucun invariant explicite — tirages libres)") + + # ── 2. Génération par paires ───────────────────────────────────────────── + metadata, enonce, question_segments = split_original_questions(content) + exercise_header = build_exercise_metadata(metadata, lists_of_notions) + + # Contexte fonctions = catalogue CURÉ (domaine détecté) + hits RAG FAISS. + # Le catalogue curé donne « quel helper pour quel besoin » + couvre les + # domaines absents du corpus livré (matrices, proba, IBP). + catalogue_ctx = catalogue_for(analysis) + functions_combined = "\n\n".join(filter(None, [ + catalogue_ctx, + ("CATALOGUE RAG (hits spécifiques sur le code réel) :\n" + functions_ctx) + if functions_ctx else "", + ])) or "Aucune fonction spécifique détectée." + + pair_blocks = generate_pair_blocks( + content=content, + exercise_header=exercise_header, + enonce=enonce, + question_segments=question_segments, + analysis=analysis, + functions_ctx=functions_combined, + fewshot=fewshot_for(analysis), + targeted_rules_digest=targeted_rules_digest, + property_constraints_text=property_constraints_text, + level=level, + model_idx=model_idx, + lang=lang, + set_step=_step, + ) + + # ── 3. Post-traitements déterministes ──────────────────────────────────── + _step("Post-traitements déterministes…") + pair_blocks = [pp.inject_config_standard_in_pair_block(b) for b in pair_blocks] + myst_exercise = assemble_exercise(exercise_header, pair_blocks) + + audit_patches: list[dict] = [] + audit_warnings: list[dict] = [] + + myst_exercise, orphan_py = pp.fix_orphan_python_openers(myst_exercise) + if orphan_py: + audit_patches.append({ + "rule": "3.1", "location": "(orphan python opener)", + "fix": f"{orphan_py} opener(s) orphelin(s) supprimé(s)", + "message": f"{orphan_py} fence(s) {{python}} orpheline(s) supprimée(s) (opener doublé).", + "iteration": 0, + }) + myst_exercise, dup_q = pp.dedupe_question_blocks(myst_exercise) + myst_exercise, dup_py = pp.dedupe_python_blocks(myst_exercise) + myst_exercise, empty_py = pp.drop_empty_python_blocks(myst_exercise) + if empty_py: + audit_patches.append({ + "rule": "3.1", "location": "(empty python blocks)", + "fix": f"{empty_py} bloc(s) vide(s) supprimé(s)", + "message": f"{empty_py} bloc(s) {{python}} vide(s) (globals() seul) supprimé(s).", + "iteration": 0, + }) + if dup_q: + audit_patches.append({ + "rule": "9.4", "location": "(duplicate question blocks)", + "fix": f"{dup_q} bloc(s) dédupliqué(s)", + "message": f"{dup_q} `:::::{{question}}` redondant(s) supprimé(s).", + "iteration": 0, + }) + if dup_py: + audit_patches.append({ + "rule": "3.1", "location": "(duplicate python blocks)", + "fix": f"{dup_py} bloc(s) dédupliqué(s)", + "message": f"{dup_py} bloc(s) {{python}} redondant(s) supprimé(s).", + "iteration": 0, + }) + + # ── 4. Solutions validées (règle 8.1) ──────────────────────────────────── + if analysis.get("has_validated_solution_in_input"): + _step("Substitution déterministe des solutions validées…") + myst_exercise, sol_patches = replace_gen_solutions_with_source( + myst_exercise, content, analysis, model_idx) + audit_patches.extend(sol_patches) + + # ── 5. Audit LLM ───────────────────────────────────────────────────────── + myst_exercise, llm_patches, llm_warnings = run_audit( + myst_exercise, step1_targets, model_idx, set_step=_step) + audit_patches.extend(llm_patches) + audit_warnings.extend(llm_warnings) + + # ── 6. Filets déterministes finaux ─────────────────────────────────────── + _step("Filets déterministes (injections, $, id, décimales)…") + myst_exercise, brace_patches = pp.fix_triple_braces(myst_exercise) + audit_patches.extend(brace_patches) + + myst_exercise, sup_fixed = pp.fix_superscript_double_brace(myst_exercise) + if sup_fixed: + audit_patches.append({ + "rule": "6.1", "location": "^{{\\latex / _{{\\latex", + "fix": "^{ {\\latex / _{ {\\latex", + "message": f"{sup_fixed} double-accolade de superscript/indice désambiguïsée(s) (espace inséré).", + "iteration": 0, + }) + + myst_exercise, unwrapped = pp.unwrap_latex_injections(myst_exercise) + if unwrapped: + audit_patches.append({ + "rule": "6.1", "location": "{{ \\latex … }}", + "fix": f"{unwrapped} enveloppe(s) {{{{ }}}} externe(s) retirée(s)", + "message": f"{unwrapped} injection(s) enveloppant du LaTeX déballée(s) (l'injection interne est la vraie).", + "iteration": 0, + }) + + myst_exercise, lift_patches = pp.auto_lift_injections(myst_exercise) + audit_patches.extend(lift_patches) + + myst_exercise, rename_patches = pp.rename_underscore_injections(myst_exercise) + audit_patches.extend(rename_patches) + + myst_exercise, dollar_patches = pp.fix_dollar_digit(myst_exercise) + audit_patches.extend(dollar_patches) + + # Les warnings 6.1 du LLM deviennent du bruit une fois l'auto-lift passé. + if not pp.INJECTION_RE.search(myst_exercise) or not any( + "(" in tok or "**" in tok for tok in pp.INJECTION_RE.findall(myst_exercise) + ): + audit_warnings = [w for w in audit_warnings + if not (isinstance(w, dict) and w.get("rule") == "6.1")] + + myst_exercise, id_patched = pp.force_empty_id(myst_exercise) + if id_patched and not any(p.get("rule") == "2.1" for p in audit_patches): + audit_patches.append({ + "rule": "2.1", "location": "(metadata header)", "fix": ":id:", + "message": "ID vidé par post-process déterministe.", "iteration": 0, + }) + + audit_warnings.extend(pp.diff_solutions(content, myst_exercise)) + audit_warnings.extend(pp.check_hardcoded_decimals_in_solutions(myst_exercise)) + + # Invariants multi-seed (règle 4.3). + main_code = extract_main_python_block(myst_exercise) + if constraints and main_code: + _step("Validation multi-seed des invariants…") + assertions = _translate_constraints_to_assertions(main_code, constraints, model_idx) + if assertions: + seed_report = multi_seed_validate( + main_code, assertions, num_seeds=MULTI_SEED_NUM, timeout_per_seed=3.0) + if seed_report["num_exec_errors"] > 0: + audit_warnings.append({ + "rule": "4.3", + "message": (f"Bloc Python : {seed_report['num_exec_errors']}/{MULTI_SEED_NUM} " + f"exécutions ont échoué. Première erreur : " + f"{seed_report.get('first_exec_error') or '?'}."), + }) + for a in assertions: + summary = seed_report["summary_per_assertion"].get(a["assertion"], {}) + viol = summary.get("violations", 0) + summary.get("errors", 0) + if viol: + audit_warnings.append({ + "rule": "4.3", + "message": (f"Invariant « {a['description']} » violé sur " + f"{viol}/{MULTI_SEED_NUM} seeds. " + f"Assertion : `{a['assertion']}`."), + }) + + # Contrôles matplotlib (règles 11.x). + if main_code: + all_python_code = "\n".join(extract_all_python_blocks(myst_exercise)) + audit_warnings.extend(static_check_rational_numpy_mix(all_python_code)) + if "matplotlib" in all_python_code or analysis.get("needs_matplotlib"): + random_var_names = [v.get("nom") for v in (analysis.get("variables") or []) + if isinstance(v, dict) and isinstance(v.get("nom"), str)] + unused = static_check_unused_random_vars( + all_python_code, random_var_names, markdown_text=myst_exercise) + if unused: + audit_warnings.append({ + "rule": "11.1", + "message": ("Variables aléatoires non utilisées dans le tracé : " + f"{', '.join(unused)}."), + }) + _step("Validation matplotlib (labels in-bounds)…") + try: + audit_warnings.extend(dynamic_check_matplotlib(all_python_code, timeout=8.0)) + except Exception as e: + audit_warnings.append({ + "rule": "11.3", + "message": f"Audit matplotlib impossible : {type(e).__name__}: {e}.", + }) + + # ── 7. Langue cible ────────────────────────────────────────────────────── + _step("Langue cible…") + myst_exercise, lang_warnings, lang_info = ensure_language(myst_exercise, lang, model_idx) + audit_warnings.extend(lang_warnings) + effective_lang = lang if lang_info["action"] != "aucune" else lang_info["source"] + audit_warnings.extend(pp.check_decimals_for_lang(myst_exercise, effective_lang)) + + # ── 8. Porte harnais + réparation ──────────────────────────────────────── + myst_exercise, renum = pp.renumber_question_ids(myst_exercise) + if renum: + audit_patches.append({ + "rule": "2.x", "location": ":questionId:/:questionIndex:", + "fix": "renumérotation 0..N-1", + "message": f"{renum} questionId/questionIndex renuméroté(s) (contiguïté plateforme).", + "iteration": 0, + }) + _step(f"Porte harnais ({HARNESS_GATE_SEEDS} graines)…") + report = harness.validate_text(myst_exercise, seeds=HARNESS_GATE_SEEDS) + + for attempt in range(HARNESS_REPAIR_MAX): + if report["ok"]: + break + _step(f"Harnais ROUGE — réparation LLM {attempt + 1}/{HARNESS_REPAIR_MAX}…") + try: + repaired = process_with_openrouter( + prompt=REPAIR_PROMPT.format( + failures=harness.format_report(report), + exercise=myst_exercise, + ), + model_idx=model_idx, + temperature=0.0, + max_tokens=30000, + system_prompt=SYSTEM_PROMPT, + ) + except (RuntimeError, ValueError, OSError) as e: + audit_warnings.append({"rule": "harnais", + "message": f"Réparation LLM en échec : {e}."}) + break + candidate = pp.strip_fences(repaired) + # Re-passe des filets déterministes sur le candidat réparé. + candidate, _ = pp.fix_orphan_python_openers(candidate) + candidate = pp.normalize_python_fences(candidate) + candidate, _ = pp.drop_empty_python_blocks(candidate) + candidate, _ = pp.fix_triple_braces(candidate) + candidate, _ = pp.fix_superscript_double_brace(candidate) + candidate, _ = pp.unwrap_latex_injections(candidate) + candidate, _ = pp.auto_lift_injections(candidate) + candidate, _ = pp.rename_underscore_injections(candidate) + candidate, _ = pp.fix_dollar_digit(candidate) + candidate, _ = pp.renumber_question_ids(candidate) + candidate_report = harness.validate_text(candidate, seeds=HARNESS_GATE_SEEDS) + + def _badness(r: dict) -> int: + return (len(r["static_errors"]) + r["n_exec_errors"] + + r["n_unresolved"] + r["n_forbidden"]) + + if candidate_report["ok"] or _badness(candidate_report) < _badness(report): + myst_exercise, report = candidate, candidate_report + audit_patches.append({ + "rule": "harnais", "location": "(exercice complet)", + "fix": "réparation LLM post-harnais", + "message": "Sortie réparée suite au verdict rouge du harnais.", + "iteration": attempt + 1, + }) + + if not report["ok"]: + audit_warnings.append({ + "rule": "harnais", + "message": ("⚠️ SORTIE NON VERTE AU HARNAIS — à corriger avant soumission. " + + harness.format_report(report)[:600]), + }) + + # ── Résultat ───────────────────────────────────────────────────────────── + return { + "exercise": myst_exercise, + "pair_blocks": pair_blocks, + "analysis": analysis, + "functions": functions_ctx, + "notions": (notions_ctx + "\n" + lists_of_notions).strip(), + "audit_patches": audit_patches, + "warnings": audit_warnings, + "harness": { + "ok": report["ok"], + "seeds": report["seeds"], + "summary": harness.format_report(report), + }, + "lang": lang_info, + "cost": cost_delta(cost_before), + "duration_s": round(time.time() - t0, 1), + } diff --git a/app/pipeline/postprocess.py b/app/pipeline/postprocess.py new file mode 100644 index 0000000000000000000000000000000000000000..9c106e9bdef4bcfb09ae9e0532a491a4b09731b6 --- /dev/null +++ b/app/pipeline/postprocess.py @@ -0,0 +1,676 @@ +""" +postprocess.py +────────────── +Tous les traitements DÉTERMINISTES (sans LLM) appliqués à l'exercice assemblé. + +Déplacés depuis routes/pythonise_routes_v2.py : + • inject_config_standard_in_pair_block (AST : **config_standard sur latex()) + • dedupe questions / blocs python, force_empty_id + • diff des solutions validées, décimales hardcodées + +Nouveaux (alignement sur les conventions réelles de la plateforme, +vérifiées sur 222 exemples : fences {python} à 4 backticks, injections += variables nues camelCase suffixe Aff, préfixe ${} anti-devise) : + • normalize_python_fences — fences {python} → PYTHON_FENCE_BACKTICKS + • fix_dollar_digit — `$`+chiffre / `${{` → `${}` (auto-correctif) + • auto_lift_injections — TOUTE injection non nue ({{latex(x)}}, + {{lc(a)}}, {{obj.print()}}, {{a*b}}) remontée + en variable pré-calculée du bloc principal + • rename_underscore_injections — variables injectées avec `_` → camelCase + • detect_languages / strip_language / check_decimals_for_lang +""" + +from __future__ import annotations + +import ast +import difflib +import logging +import re +from typing import Optional + +from app.config import PYTHON_FENCE_BACKTICKS + +logger = logging.getLogger(__name__) + +PY_FENCE = "`" * PYTHON_FENCE_BACKTICKS + +# Fence {python} à 3 OU 4 backticks (lecture tolérante ; l'écriture est +# toujours normalisée à PYTHON_FENCE_BACKTICKS via normalize_python_fences). +PYTHON_FENCE_RE = re.compile( + r"(?ms)^(?P<open>`{3,4})\{python\}[ \t]*\n(?P<code>.*?)\n(?P=open)[ \t]*$" +) + +INJECTION_RE = re.compile(r"\{\{(.*?)\}\}", re.DOTALL) +BARE_IDENT_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*$") + + +# ───────────────────────────────────────────────────────────────────────────── +# Utilitaires génériques +# ───────────────────────────────────────────────────────────────────────────── + +def strip_fences(text: str) -> str: + """Retire un wrapper markdown EXTERNE (``` ou ```lang) d'une sortie LLM, + sans toucher aux fences qui font partie du contenu (```{python}, `````).""" + text = text.strip() + if text.startswith("`````"): + return text + text = re.sub(r"^```\w*\s*\n", "", text) + text = re.sub(r"\n```\s*$", "", text) + return text.strip() + + +def extract_python_blocks(text: str) -> list[str]: + """Tous les corps de blocs {python}, dans l'ordre du document.""" + return [m.group("code") for m in PYTHON_FENCE_RE.finditer(text)] + + +def mask_python_blocks(text: str) -> tuple[str, list[str]]: + """Remplace chaque bloc {python} par un sentinel ; retourne (masqué, blocs).""" + blocks: list[str] = [] + + def _sub(m: re.Match) -> str: + blocks.append(m.group(0)) + return f"\x00PYBLOCK{len(blocks) - 1}\x00" + + return PYTHON_FENCE_RE.sub(_sub, text), blocks + + +def unmask_python_blocks(text: str, blocks: list[str]) -> str: + for i, b in enumerate(blocks): + text = text.replace(f"\x00PYBLOCK{i}\x00", b) + return text + + +def normalize_python_fences(text: str) -> str: + """Réécrit toute fence {python} (3 ou 4 backticks) avec exactement + PYTHON_FENCE_BACKTICKS backticks — convention plateforme (4).""" + def _sub(m: re.Match) -> str: + return f"{PY_FENCE}{{python}}\n{m.group('code')}\n{PY_FENCE}" + return PYTHON_FENCE_RE.sub(_sub, text) + + +def insert_python_lines(exercise: str, lines: list[str]) -> str: + """Insère des lignes juste avant le `globals()` du PREMIER bloc {python} + (bloc principal — les expressions injectées référencent ses variables). + Les lignes déjà présentes dans l'exercice ne sont pas dupliquées.""" + todo = [l for l in lines if l and l not in exercise] + if not todo: + return exercise + m = PYTHON_FENCE_RE.search(exercise) + if not m: + return exercise + code = m.group("code") + idx = code.rfind("globals()") + if idx == -1: + new_code = code + "\n" + "\n".join(todo) + else: + new_code = code[:idx] + "\n".join(todo) + "\n" + code[idx:] + start, end = m.span() + return exercise[:start] + f"{m.group('open')}{{python}}\n{new_code}\n{m.group('open')}" + exercise[end:] + + +# ───────────────────────────────────────────────────────────────────────────── +# **config_standard automatique sur latex(...) — AST, idempotent +# ───────────────────────────────────────────────────────────────────────────── + +class LatexTransformer(ast.NodeTransformer): + def visit_Call(self, node): + self.generic_visit(node) + if isinstance(node.func, ast.Name) and node.func.id == "latex": + has_config = any( + isinstance(kw, ast.keyword) and kw.arg is None + and isinstance(kw.value, ast.Name) and kw.value.id == "config_standard" + for kw in node.keywords + ) + if not has_config: + node.keywords.append( + ast.keyword(arg=None, value=ast.Name(id="config_standard", ctx=ast.Load())) + ) + return node + + +def add_config_standard(code: str) -> str: + tree = ast.parse(code) + tree = LatexTransformer().visit(tree) + ast.fix_missing_locations(tree) + return ast.unparse(tree) + + +def inject_config_standard_in_pair_block(block: str) -> str: + """Applique add_config_standard à chaque bloc {python} d'un bloc de paire. + Un bloc non parsable (rare) est laissé intact plutôt que de planter.""" + def _sub(m: re.Match) -> str: + try: + transformed = add_config_standard(m.group("code")) + except SyntaxError: + return m.group(0) + return f"{m.group('open')}{{python}}\n{transformed}\n{m.group('open')}" + return PYTHON_FENCE_RE.sub(_sub, block) + + +# ───────────────────────────────────────────────────────────────────────────── +# Dédoublonnage questions / blocs python +# ───────────────────────────────────────────────────────────────────────────── + +_QUESTION_BLOCK_RE = re.compile(r"(:::::\{question\}.*?:::::)", re.DOTALL) + + +def dedupe_question_blocks(exercise: str) -> tuple[str, int]: + """Supprime les blocs :::::{question} dont le questionStatement est + identique (la paire N+1 régénère parfois une question de la paire N).""" + blocks = list(_QUESTION_BLOCK_RE.finditer(exercise)) + if len(blocks) < 2: + return exercise, 0 + + qstmt_re = re.compile(r"::::\{questionStatement\}\s*\n?(.*?)\n?::::", re.DOTALL) + seen: set[str] = set() + to_remove: list[tuple[int, int]] = [] + for m in blocks: + qm = qstmt_re.search(m.group(1)) + if not qm: + continue + key = re.sub(r"\s+", " ", qm.group(1)).strip().lower() + if key in seen: + to_remove.append((m.start(), m.end())) + else: + seen.add(key) + + if not to_remove: + return exercise, 0 + new_exercise = exercise + for start, end in reversed(to_remove): + new_exercise = new_exercise[:start] + new_exercise[end:] + return re.sub(r"\n{3,}", "\n\n", new_exercise), len(to_remove) + + +def dedupe_python_blocks(exercise: str) -> tuple[str, int]: + """Supprime les blocs {python} identiques (modulo espaces).""" + blocks = list(PYTHON_FENCE_RE.finditer(exercise)) + if len(blocks) < 2: + return exercise, 0 + seen: set[str] = set() + to_remove: list[tuple[int, int]] = [] + for m in blocks: + key = re.sub(r"\s+", " ", m.group("code")).strip() + if key in seen: + to_remove.append((m.start(), m.end())) + else: + seen.add(key) + if not to_remove: + return exercise, 0 + new_exercise = exercise + for start, end in reversed(to_remove): + new_exercise = new_exercise[:start] + new_exercise[end:] + return re.sub(r"\n{3,}", "\n\n", new_exercise), len(to_remove) + + +def fix_orphan_python_openers(exercise: str) -> tuple[str, int]: + """Supprime un opener {python} immédiatement suivi (à blanc près) d'un + autre opener — malformation LLM observée qui fait avaler un bloc entier + par la regex de fence et casse l'extraction du code.""" + new, n = re.subn( + r"(?m)^`{3,4}\{python\}[ \t]*\n\s*(?=`{3,4}\{python\}[ \t]*$)", + "", + exercise, + ) + return new, n + + +def renumber_question_ids(exercise: str) -> tuple[str, int]: + """Renumérote :questionId: et :questionIndex: en séquence 0..N-1 dans + l'ordre du document (invariant plateforme : uniques et contigus). Le LLM + se trompe parfois sur les exercices longs — la renumérotation d'office + est toujours correcte.""" + changed = {"n": 0} + + def _renum(field: str, text: str) -> str: + counter = {"i": -1} + + def _sub(m: re.Match) -> str: + counter["i"] += 1 + if m.group(1) != str(counter["i"]): + changed["n"] += 1 + return f":{field}: {counter['i']}" + + return re.sub(rf":{field}:\s*(\d+)", _sub, text) + + new = _renum("questionId", exercise) + new = _renum("questionIndex", new) + return new, changed["n"] + + +def fix_superscript_double_brace(exercise: str) -> tuple[str, int]: + """`^{{\\frac…}}` / `_{{\\sqrt…}}` : le `{` LaTeX collé au `{` du + superscript simule une ouverture d'injection `{{`. On insère un espace + (`^{ {\\frac…} }`) UNIQUEMENT quand le contenu commence par une commande + LaTeX — jamais quand c'est une vraie injection `^{{var}}`.""" + masked, blocks = mask_python_blocks(exercise) + new, n = re.subn(r"([\^_])\{\{(?=\s*\\)", r"\1{ {", masked) + if not n: + return exercise, 0 + return unmask_python_blocks(new, blocks), n + + +def unwrap_latex_injections(exercise: str) -> tuple[str, int]: + """`{{ \\frac{\\pi}{ {{xAff}} } }}` → `\\frac{\\pi}{ {{xAff}} }` : + quand le LLM enveloppe du LaTeX (ou une injection imbriquée) dans des + `{{ }}`, on retire la paire EXTERNE (scan à accolades équilibrées) — + l'injection interne reste la vraie.""" + masked, blocks = mask_python_blocks(exercise) + out: list[str] = [] + i, n, removed = 0, len(masked), 0 + while i < n: + if masked.startswith("{{", i): + # contenu commence-t-il par du LaTeX (\) ou une injection imbriquée ? + j = i + 2 + while j < n and masked[j] in " \t": + j += 1 + if j < n and (masked[j] == "\\" or masked.startswith("{{", j)): + # scan équilibré depuis i+2 (profondeur 2) + depth, k = 2, i + 2 + while k < n and depth > 0: + if masked[k] == "{": + depth += 1 + elif masked[k] == "}": + depth -= 1 + k += 1 + if depth == 0 and k - 2 >= i + 2 and masked[k - 2:k] == "}}": + inner = masked[i + 2:k - 2] + out.append(inner) + removed += 1 + i = k + continue + out.append(masked[i]) + i += 1 + if not removed: + return exercise, 0 + return unmask_python_blocks("".join(out), blocks), removed + + +def drop_empty_python_blocks(exercise: str) -> tuple[str, int]: + """Supprime les blocs {python} VIDES (rien, ou seulement `globals()` / + commentaires) — la génération par paires en insère parfois un entre deux + questions ; le skill exige UN SEUL bloc utile (relecture 2026-06-12).""" + removed = {"n": 0} + + def _sub(m: re.Match) -> str: + body = "\n".join( + l for l in m.group("code").splitlines() + if l.strip() and not l.strip().startswith("#") and l.strip() != "globals()" + ) + if body.strip(): + return m.group(0) + removed["n"] += 1 + return "" + + new = PYTHON_FENCE_RE.sub(_sub, exercise) + if removed["n"]: + new = re.sub(r"\n{3,}", "\n\n", new) + return new, removed["n"] + + +def force_empty_id(exercise: str) -> tuple[str, bool]: + """Règle 2.1 — vide la valeur de `:id:` si elle est renseignée.""" + new = re.sub(r"^(\s*:id:)[ \t]+[^\n]+$", r"\1", exercise, flags=re.MULTILINE) + return new, (new != exercise) + + +# ───────────────────────────────────────────────────────────────────────────── +# Auto-correctif `$` collé à un chiffre (règle du skill §1) — casse silencieuse +# ───────────────────────────────────────────────────────────────────────────── + +def fix_dollar_digit(exercise: str) -> tuple[str, list[dict]]: + """Hors blocs {python} : + • `$` non échappé immédiatement suivi d'un chiffre → `${}` + chiffre + • `$` non échappé immédiatement suivi de `{{` → `${}{{` + Le groupe vide {} est invisible au rendu mais empêche la lecture + « montant en devise » qui désynchronise tout le `$…$`.""" + masked, blocks = mask_python_blocks(exercise) + patches: list[dict] = [] + + def _digit(m: re.Match) -> str: + patches.append({ + "rule": "$+chiffre", + "location": m.group(0), + "fix": "${}" + m.group(1), + "message": "Auto-correctif : `$` collé à un chiffre préfixé par un groupe vide {} (anti-devise).", + "iteration": 0, + }) + return "${}" + m.group(1) + + def _inj(m: re.Match) -> str: + patches.append({ + "rule": "$+chiffre", + "location": "${{", + "fix": "${}{{", + "message": "Auto-correctif : injection inline `${{…}}` préfixée par {} (le rendu peut commencer par un chiffre).", + "iteration": 0, + }) + return "${}{{" + + masked = re.sub(r"(?<!\\)\$(?!\{)(\d)", _digit, masked) + masked = re.sub(r"(?<!\\)\$\{\{", _inj, masked) + return unmask_python_blocks(masked, blocks), patches + + +def fix_triple_braces(exercise: str) -> tuple[str, list[dict]]: + """`x^{{{exp}}}` (triple accolade, motif interdit) → `x^{ {{exp}} }`. + Sans cette normalisation, le découpage des injections est ambigu et + l'auto-lift peut produire du Python invalide.""" + masked, blocks = mask_python_blocks(exercise) + patches: list[dict] = [] + n = 0 + while "{{{" in masked or "}}}" in masked: + masked = masked.replace("{{{", "{ {{", 1) if "{{{" in masked else masked + masked = masked.replace("}}}", "}} }", 1) if "}}}" in masked else masked + n += 1 + if n > 500: # garde-fou anti-boucle + break + if n: + patches.append({ + "rule": "6.1", + "location": "{{{ / }}}", + "fix": "{ {{ / }} }", + "message": f"Triple-accolade normalisée avec espaces ({n} remplacement(s)) — motif interdit qui casse la substitution.", + "iteration": 0, + }) + return unmask_python_blocks(masked, blocks), patches + + +# ───────────────────────────────────────────────────────────────────────────── +# Auto-lift GÉNÉRALISÉ : toute injection non nue → variable pré-calculée +# (les 222 exemples plateforme n'ont AUCUN appel/expr dans {{ }} — uniquement +# des noms nus camelCase ; le harnais le vérifie en statique) +# ───────────────────────────────────────────────────────────────────────────── + +_LIFT_SUFFIX = {"latex": "Tex"} + + +def _camel(name: str) -> str: + parts = [p for p in name.split("_") if p] + if not parts: + return name + return parts[0] + "".join(p[:1].upper() + p[1:] for p in parts[1:]) + + +def auto_lift_injections(exercise: str) -> tuple[str, list[dict]]: + """Remplace chaque `{{ <expr non nue> }}` par `{{ <varAff> }}` et insère + `<varAff> = <expr>` avant le globals() du bloc principal. + + Couvre : appels ({{latex(f)}}, {{lc(a, sign=True)}}, y compris avec + **config_standard), attributs ({{dom.print()}}), opérations ({{a*b}}). + Idempotent : expressions identiques → même variable ; lignes déjà + présentes non dupliquées.""" + masked, blocks = mask_python_blocks(exercise) + patches: list[dict] = [] + lifted: dict[str, str] = {} # expr normalisée → nom de variable + used_names: set[str] = set(re.findall(r"\b([A-Za-z_]\w*)\s*=", exercise)) + counter = {"n": 0} + + def _make_name(expr: str) -> str: + m = re.match(r"^([A-Za-z_]\w*)\s*\(\s*([A-Za-z_]\w*)\s*(?:,\s*\*\*\s*config_standard\s*)?\)$", expr) + if m: + func, arg = m.group(1), m.group(2) + base = _camel(arg) + _LIFT_SUFFIX.get(func, "Aff") + else: + counter["n"] += 1 + base = f"injAff{counter['n']}" + name, i = base, 2 + while name in used_names: + name = f"{base}{i}" + i += 1 + used_names.add(name) + return name + + def _sub(m: re.Match) -> str: + expr = m.group(1).strip() + if not expr or BARE_IDENT_RE.match(expr): + return m.group(0) # déjà nu (ou vide → harnais) + if re.match(r"^[A-Za-z][A-Za-z0-9_]*$", expr): + return m.group(0) # identifiant avec _ → renommage dédié + if "{" in expr or "}" in expr or "\n" in expr: + return m.group(0) # découpage ambigu → on ne lifte pas + try: # l'insert DOIT être du Python valide + compile(f"_x = {expr}", "<lift>", "exec") + except SyntaxError: + logger.warning("Auto-lift refusé (expression non compilable) : %r", expr[:80]) + return m.group(0) + key = re.sub(r"\s+", " ", expr) + if key in lifted: + name = lifted[key] + insert = None + else: + name = _make_name(expr) + lifted[key] = name + insert = f"{name} = {expr}" + patches.append({ + "rule": "6.1", + "location": m.group(0), + "fix": "{{" + name + "}}", + "python_insert": insert, + "message": "Auto-lift déterministe : expression dans {{…}} pré-calculée en variable d'affichage.", + "iteration": 0, + }) + return "{{" + name + "}}" + + new_masked = INJECTION_RE.sub(_sub, masked) + if not patches: + return exercise, [] + new_exercise = unmask_python_blocks(new_masked, blocks) + new_exercise = insert_python_lines( + new_exercise, [p["python_insert"] for p in patches if p.get("python_insert")] + ) + return new_exercise, patches + + +def rename_underscore_injections(exercise: str) -> tuple[str, list[dict]]: + """Les `_` dans une variable injectée cassent la substitution plateforme + (indices LaTeX parasites). Renomme p_str → pStr partout (corps + python).""" + masked, blocks = mask_python_blocks(exercise) + names = { + tok.strip() for tok in INJECTION_RE.findall(masked) + if re.match(r"^[A-Za-z][A-Za-z0-9_]*$", tok.strip()) and "_" in tok + } + if not names: + return exercise, [] + + taken = set(re.findall(r"\b([A-Za-z_]\w*)\b", exercise)) + patches: list[dict] = [] + new_exercise = exercise + for old in sorted(names, key=len, reverse=True): + new = _camel(old) + i = 2 + while new in taken: + new = f"{_camel(old)}{i}" + i += 1 + taken.add(new) + new_exercise = re.sub(rf"\b{re.escape(old)}\b", new, new_exercise) + patches.append({ + "rule": "6.3", + "location": f"{{{{{old}}}}}", + "fix": f"{{{{{new}}}}}", + "message": f"Variable injectée renommée {old} → {new} (underscore interdit dans {{{{…}}}}).", + "iteration": 0, + }) + return new_exercise, patches + + +# ───────────────────────────────────────────────────────────────────────────── +# Solutions validées : extraction / diff pédagogique (règle 8.1) +# ───────────────────────────────────────────────────────────────────────────── + +_DETAILED_SOLUTION_RE = re.compile( + r"::::\{detailedSolution\}\s*\n(.*?)\n::::", + re.DOTALL, +) + +_STOPWORDS = frozenset({ + # FR + "des", "les", "une", "est", "que", "qui", "par", "pour", "avec", "sur", "dans", + "ces", "son", "ses", "leur", "leurs", "cette", "donc", "ainsi", "alors", "plus", + "moins", "tout", "tous", "toute", "toutes", "aussi", "mais", "comme", "puis", + "soit", "deux", "trois", "fois", + # EN + "the", "and", "are", "for", "with", "from", "then", "this", "that", "these", + "those", "have", "has", "been", "their", "there", "where", "when", "which", + "also", "but", "more", "less", "than", "into", "only", "thus", "such", "very", +}) + + +def extract_detailed_solutions(myst_text: str) -> list[str]: + return [m.group(1).strip() for m in _DETAILED_SOLUTION_RE.finditer(myst_text)] + + +def _extract_pedagogical_words(text: str) -> list[str]: + """Mots porteurs de sens pédagogique (hors placeholders, nombres, LaTeX).""" + cleaned = re.sub(r"\{\{[^}]*\}\}", " ", text) + cleaned = re.sub(r"\\[a-zA-Z]+\*?\s*\{[^}]*\}", " ", cleaned) + cleaned = re.sub(r"\\[a-zA-Z]+\*?", " ", cleaned) + cleaned = re.sub(r"-?\d+(?:[.,]\d+)?", " ", cleaned) + cleaned = re.sub(r"\{[a-zA-Z*]+\}", " ", cleaned) + cleaned = cleaned.replace("`", " ") + tokens = re.findall(r"[a-zA-ZÀ-ÿ]{3,}", cleaned) + return [t.lower() for t in tokens if t.lower() not in _STOPWORDS] + + +def diff_solutions(original_content: str, generated_exercise: str) -> list[dict]: + """Compare les detailedSolution source ↔ sortie (mots pédagogiques seuls). + Warning si divergence (similarité < 95 %) — règle 8.1.""" + orig = extract_detailed_solutions(original_content) + gen = extract_detailed_solutions(generated_exercise) + warnings: list[dict] = [] + if not orig: + return warnings + if len(orig) != len(gen): + warnings.append({ + "rule": "8.1", + "message": (f"Nombre de detailedSolution différent : {len(orig)} dans la source, " + f"{len(gen)} dans la sortie."), + }) + for i, (o, g) in enumerate(zip(orig, gen)): + o_words = _extract_pedagogical_words(o) + g_words = _extract_pedagogical_words(g) + if o_words == g_words: + continue + ratio = difflib.SequenceMatcher(None, o_words, g_words).ratio() + if ratio >= 0.95: + continue + severity = "FORTE divergence" if ratio < 0.70 else "divergence" + missing = [w for w in o_words if w not in g_words][:5] + added = [w for w in g_words if w not in o_words][:5] + hint = "" + if missing: + hint += f" Mots disparus : {', '.join(missing)}." + if added: + hint += f" Mots ajoutés : {', '.join(added)}." + warnings.append({ + "rule": "8.1", + "message": (f"Question {i + 1} : detailedSolution {severity} vs source " + f"(similarité pédagogique = {ratio:.0%}).{hint} " + "Vérifier que le raisonnement original est préservé (règle 8.1)."), + }) + return warnings + + +_DECIMAL_LITERAL_RE = re.compile(r"\b\d+\{,\}\d+\b") +_DECIMAL_DOT_RE = re.compile(r"(?<![\\\w])\d+\.\d+(?!\w)") +_INTERP_RE = re.compile(r"\{\{[^}]*\}\}") + + +def check_hardcoded_decimals_in_solutions(exercise: str) -> list[dict]: + """Règle 4.3 (ext.) : décimale littérale hors {{…}} dans une solution + → probable valeur codée en dur qui ne suivra pas la randomisation.""" + warnings: list[dict] = [] + for i, m in enumerate(_DETAILED_SOLUTION_RE.finditer(exercise)): + stripped = _INTERP_RE.sub(" ", m.group(1)) + hits = list(_DECIMAL_LITERAL_RE.finditer(stripped)) + list(_DECIMAL_DOT_RE.finditer(stripped)) + seen: set[str] = set() + unique: list[str] = [] + for h in hits: + text = h.group(0) + if text in seen or text.startswith("10.") or text.endswith("e"): + continue + seen.add(text) + unique.append(text) + if len(unique) >= 3: + break + if unique: + warnings.append({ + "rule": "4.3", + "message": (f"Question {i + 1} : detailedSolution contient des décimales littérales " + f"({', '.join(repr(h) for h in unique)}) hors `{{{{var}}}}`. Si l'exo " + "randomise un paramètre dont elles dérivent, elles ne suivront pas la " + "variation. Vérifier qu'elles sont injectées."), + }) + return warnings + + +# ───────────────────────────────────────────────────────────────────────────── +# Langues : détection, réduction bilingue → mono, contrôle des décimales +# ───────────────────────────────────────────────────────────────────────────── + +_ROLE_RE = { + "fr": re.compile(r"\{fr\}`([^`]*)`"), + "en": re.compile(r"\{en\}`([^`]*)`"), +} +_FR_HINTS = (" le ", " la ", " les ", " une ", " des ", " est ", " que ", " pour ", + "Calculer", "Montrer", "Déterminer", "Résoudre", "On considère", "définie") +_EN_HINTS = (" the ", " a ", " an ", " is ", " that ", " for ", " of ", + "Compute", "Show", "Determine", "Solve", "We consider", "defined") + + +def detect_languages(text: str) -> str: + """Retourne 'both' si les rôles {fr}`…`/{en}`…` sont présents, sinon + 'fr' ou 'en' par heuristique de mots-outils sur la prose.""" + body, _ = mask_python_blocks(text) + if _ROLE_RE["fr"].search(body) and _ROLE_RE["en"].search(body): + return "both" + fr_score = sum(body.count(h) for h in _FR_HINTS) + en_score = sum(body.count(h) for h in _EN_HINTS) + return "fr" if fr_score >= en_score else "en" + + +def strip_language(text: str, keep: str) -> str: + """Réduction déterministe d'un exercice bilingue (rôles {fr}`…`{en}`…`) + vers une seule langue : déballe la langue gardée, supprime l'autre.""" + drop = "en" if keep == "fr" else "fr" + masked, blocks = mask_python_blocks(text) + masked = _ROLE_RE[drop].sub("", masked) + masked = _ROLE_RE[keep].sub(lambda m: m.group(1), masked) + masked = re.sub(r"[ \t]+\n", "\n", masked) + masked = re.sub(r" +", " ", masked) + return unmask_python_blocks(masked, blocks) + + +def check_decimals_for_lang(exercise: str, lang: str) -> list[dict]: + """Décimales conformes à la langue : FR = virgule ({,}), EN = point. + En bilingue, chaque rôle est contrôlé séparément.""" + body, _ = mask_python_blocks(exercise) + body = _INTERP_RE.sub(" ", body) # les valeurs injectées sont contrôlées par ailleurs + warnings: list[dict] = [] + + def _scan(segment: str, seg_lang: str, where: str): + if seg_lang == "fr": + hits = [h.group(0) for h in _DECIMAL_DOT_RE.finditer(segment) + if not h.group(0).startswith("10.")] + if hits: + warnings.append({ + "rule": "décimales", + "message": (f"Décimales à POINT dans du texte FR ({where}) : " + f"{', '.join(sorted(set(hits))[:4])} — attendu virgule `{{,}}`."), + }) + else: + hits = [h.group(0) for h in _DECIMAL_LITERAL_RE.finditer(segment)] + if hits: + warnings.append({ + "rule": "décimales", + "message": (f"Décimales à VIRGULE dans du texte EN ({where}) : " + f"{', '.join(sorted(set(hits))[:4])} — attendu point décimal."), + }) + + if lang == "both": + _scan(" ".join(_ROLE_RE["fr"].findall(body)), "fr", "rôles {fr}") + _scan(" ".join(_ROLE_RE["en"].findall(body)), "en", "rôles {en}") + else: + _scan(body, lang, "corps") + return warnings diff --git a/app/pipeline/prompts.py b/app/pipeline/prompts.py new file mode 100644 index 0000000000000000000000000000000000000000..3f322f6f68149ff737d573c807f2dc8c3834f779 --- /dev/null +++ b/app/pipeline/prompts.py @@ -0,0 +1,563 @@ +""" +prompts.py +────────── +Tous les prompts LLM du pipeline. + +# v1 → les versions antérieures (STEP1_PROMPT, STEP_PAIR_PROMPT, SYSTEM_PROMPT, +# STEP_AUDIT_PROMPT) sont archivées VERBATIM dans +# app/knowledge/prompts_v1_archive.md (extraites de routes/pythonise_routes_v2.py +# avant la refonte du 2026-06-12). +# +# Changements majeurs v1 → v2 (alignement sur les conventions RÉELLES de la +# plateforme, vérifiées sur les 222 exemples pythonisés livrés + skill +# pyxiscience-pythonisation) : +# • Injections {{ }} = UNIQUEMENT des noms de variables nus, camelCase, +# suffixe Aff, sans underscore. La v1 enseignait {{latex(expr)}}, +# {{lc(a, sign=True)}}, {{pxsl_res_num(...)}} — tous absents des exemples +# validés et refusés par le harnais. +# • Bloc {python} à 4 backticks (la v1 montrait 3) ; enveloppe exercise à 5. +# • Bilingue = rôles inline {fr}`…`{en}`…` UNIQUEMENT (aucun bloc +# :::{fr}/:::{en} dans les 222 exemples — la v1 les enseignait). +# • Règle du `$` collé à un chiffre (préfixe ${}) — absente de la v1. +# • Fraction (module fractions) interdit en sortie de formateur ; +# pxsl_format_number SANS kwargs ; décimales localisées FR/EN. +# • Few-shot du type détecté injecté ({fewshot}) au lieu de 5 exemples +# génériques pavés dans le prompt. +# • SYSTEM_PROMPT raccourci (le dump de 180 lignes du source de +# pxsl_res_num doublonnait le catalogue RAG). +""" + +# ───────────────────────────────────────────────────────────────────────────── +# STEP 1 — Analyse (variables + règles à risque + invariants) +# v1 → app/knowledge/prompts_v1_archive.md §STEP1_PROMPT +# (v2 : typo needs_matplolib corrigée → needs_matplotlib ; mention du type +# pour la sélection de few-shot ; sinon structure conservée) +# ───────────────────────────────────────────────────────────────────────────── +STEP1_PROMPT = """\ +Tu es un expert en analyse d'exercices mathématiques & détection des variables Python pour PyxiScience, +pour passer d'un exercice à valeurs statiques à un exercice à valeurs aléatoires **correctes**. + +EXERCICE : +{content} + +───────────────────────────────────────────────────── +MISSION : analyse cet exercice et identifie **TOUTES** les entités mathématiques +qui devront être générées aléatoirement en Python. + +Couvre TOUS les types possibles : scalaires entiers/réels, fractions, listes, +vecteurs, matrices, ensembles, polynômes, fonctions, pourcentages, angles, intervalles… + +Pour chaque variable : + • nom : nom Python valide court (ex: a, b, n, matA, listeNotes), + partageable entre questions + • type_python : "int"|"float"|"Fraction"|"list"|"matrix"|"set"|"vector"|"other" + • description : rôle dans l'énoncé (1 phrase) + • contraintes : contraintes mathématiques (ex: a ≠ 0, n ∈ [2,10]) + • plage_python : expression Python exacte de génération aléatoire + • location : "énoncé"|"inter-question"|"question"|"solution 1"|…|"solution 5" + • valeur_exemple: valeur typique + +───────────────────────────────────────────────────── +RÈGLES DE PYTHONISATION (catalogue) — choisis dans "target_rules" celles qui +sont LE PLUS À RISQUE pour CET exercice (5 à 12 IDs maximum). + +{available_rules_menu} + +───────────────────────────────────────────────────── +Réponds UNIQUEMENT en JSON valide : +{{ + "exercise_type": "...", + "exercise_title": "...", + "exercise_summary": "...", + "suggested_concepts": ["..."], + "nb_questions": 1, + "variables": [ + {{ + "nom": "...", + "type_python": "...", + "description": "...", + "contraintes": "...", + "location": "énoncé|question|solution 1", + "plage_python": "...", + "valeur_exemple": "..." + }} + ], + "needs_fraction": false, + "needs_sympy": false, + "needs_numpy": false, + "needs_matplotlib": false, + "mathematical_structure": "...", + "target_rules": ["3.1", "4.1", "6.1"], + "property_constraints": [ + "<invariant mathématique en français — ex: w_n ≥ n pour tout n>" + ], + "has_validated_solution_in_input": false +}} + +Notes : + • "exercise_type" : type court et standard (ex: "équation linéaire", + "trinôme/discriminant", "fonction avec figure", "logarithme/exponentielle", + "système linéaire", "intégration par parties", "probabilités/binomiale", + "suites", "finance/intérêts") — il sert à choisir un exemple canonique. + • "target_rules" : uniquement des IDs du catalogue ci-dessus (top 5-12 à risque). + • "property_constraints" : invariants à préserver au tirage (règle 4.3). Liste vide si aucun. + • "has_validated_solution_in_input" : true SI l'énoncé contient déjà des + blocs `::::{{detailedSolution}}` rédigés (règles 8.1–8.3). +""" + + +# ───────────────────────────────────────────────────────────────────────────── +# SYSTEM — rôle + sémantique du runtime {{ }} +# v1 → app/knowledge/prompts_v1_archive.md §SYSTEM_PROMPT +# ───────────────────────────────────────────────────────────────────────────── +SYSTEM_PROMPT = """\ +Tu es professeur de mathématiques, expert en Python scientifique (sympy, random, +math, matplotlib, numpy) et en exercices PyxiScience randomisés (MyST + KaTeX). + +Le code Python vit dans UN bloc ````{python} … ```` (4 backticks) terminé par +`globals()`. Les variables y définies sont injectées dans le MyST via `{{ var }}`. + +⚠️ La syntaxe `{{ var }}` est exécutée par un runtime Python maison — PAS du +Jinja. RÈGLE ABSOLUE : `{{ … }}` contient UNIQUEMENT un nom de variable nu, +en camelCase, SANS underscore, généralement suffixé `Aff` pour les affichages +(ex: `{{coefAAff}}`). JAMAIS d'appel de fonction, de calcul, de filtre ni de +logique dans `{{ }}` — tout est pré-calculé dans le bloc Python. + +Deux règles d'or : +1. Tout ce qui s'affiche est PRÉ-CALCULÉ dans une variable puis injecté tel quel. +2. On ne code JAMAIS en dur une réponse vraie seulement pour les valeurs de la + source : si un paramètre est randomisé, la réponse affichée est RECALCULÉE. + +Priorité aux helpers du catalogue PyxiScience (pxsl_latex_coefficient/lc, +pxsl_res_num, pxsl_format_number, pxsl_matrix, pxs_Interval, pxs_config…) — +appelés DANS le bloc Python, résultat stocké dans une variable `…Aff`. +Ne jamais réimplémenter un helper existant. + +Exercices applicatifs : contexte ÉCONOMIE/GESTION (finance, comptabilité, +marketing, microéconomie — registre école de commerce), jamais physique/chimie. +""" + + +# ───────────────────────────────────────────────────────────────────────────── +# STEP 2+ — Génération par paires +# v1 → app/knowledge/prompts_v1_archive.md §STEP_PAIR_PROMPT +# ───────────────────────────────────────────────────────────────────────────── +STEP_PAIR_PROMPT = """\ +Tu pythonises un exercice PyxiScience MyST, niveau {niveau}. +Transformer l'exercice statique (valeurs fixes) en version randomisée +(paramètres tirés en Python + injectés dans le MyST), EN respectant à la +lettre les conventions de la plateforme ci-dessous. + +═══════════════════════════════════════════════════════════════════════════ + CONVENTIONS PLATEFORME (vérifiées sur les exercices livrés — NON NÉGOCIABLES) +═══════════════════════════════════════════════════════════════════════════ + +STRUCTURE : + • Enveloppe exercice : 5 backticks `````{{exercise}} … ````` (déjà gérée). + • UN bloc Python principal : 4 backticks ````{{python}} … ```` terminé par + `globals()`. Imports en tête (`import random as rd`, sympy ciblé, + `from pyxiscience.Mes_fctions_generalistes_bis import pxs_config, + pxsl_latex_coefficient as lc` …) puis `config_standard = pxs_config()`. + • Chaque question : `:::::{{question}}` (5 deux-points) avec + `:questionType:`, `:questionId:`, `:questionIndex:` (contigus depuis 0) ; + sous-blocs `::::{{questionStatement}}`, `::::{{questionHint}}`, + `::::{{detailedSolution}}`, `::::{{weightDistribution}}` (4 deux-points). + • `:weightDistribution:` : recopier les poids de la source VERBATIM + (somme = 100 par question). + +INJECTIONS `{{{{ }}}}` — LA règle qui fait tout casser si violée : + • UNIQUEMENT un nom de variable NU : `{{{{coefAAff}}}}`, `{{{{resQ1Aff}}}}`. + camelCase, SANS underscore, suffixe `Aff` pour tout affichage. + • INTERDIT dans `{{{{ }}}}` : appel de fonction (`latex(...)`, `lc(...)`, + `pxsl_…(...)`, `obj.print()`), calcul (`a*b`), `round(...)`, `**kwargs`. + → TOUT se pré-calcule dans le bloc Python : + `eqAff = latex(a*x**2 + b*x + c, **config_standard)` puis `{{{{eqAff}}}}`. + • `**config_standard` est réservé à `sympy.latex(...)` DANS le bloc Python. + `pxsl_format_number()` n'accepte AUCUN kwarg. `pxsl_res_num(x, dec=…, + egal=False)` s'appelle dans le bloc Python, résultat dans une variable Aff. + • Espaces contre la triple-accolade : `x^{{ {{{{expAff}}}} }}` ✅, + jamais `x^{{{{{{expAff}}}}}}` ❌. + +RÈGLE DU `$` COLLÉ À UN CHIFFRE (casse silencieuse) : + Un `$` immédiatement suivi d'un chiffre est lu comme un MONTANT en devise et + désynchronise tout le `$…$`. Préfixer par un groupe vide : `${{}}3 \\times 2$`, + et SURTOUT `${{}}{{{{nAff}}}}$` pour toute injection inline qui rend un nombre. + Pour un vrai pourcentage affiché : `\\%` partout (jamais `%` nu dans le texte) ; + ne JAMAIS échapper les `%` à l'intérieur du bloc Python (chaînes "%.2f"). + +TIRAGES — exclure les cas dégénérés (boucle de rejet ou énumération) : + • `for _ in range(300): … ; break` ou liste de candidats + `rd.choice(...)` + (contraintes croisées → TOUJOURS énumération en compréhension, jamais + une boucle qui peut sortir invalide). + • Exclure : exposant 0 ou 1 affiché (`^{{0}}`, `^{{1}}`), dénominateur 1 + (`\\frac{{…}}{{1}}`), `\\sqrt[1]`, `\\sqrt[2]` (→ `\\sqrt`), double signe + (`+ -`), division par zéro, Δ de signe inattendu, intervalle vide. + • Exactitude : `sympy.Rational(1, 2)`, JAMAIS `1/2` flottant ni `round()` + pour une valeur exacte. Le module `fractions.Fraction` PLANTE les + formateurs plateforme — ne jamais le passer à un helper pxsl_*. + • Coefficients signés : ne JAMAIS concaténer un signe à une valeur ; + utiliser `lc(coef, sign=True, ones=True)` (pxsl_latex_coefficient) dans + le bloc Python → variable Aff. + +DÉCIMALES SELON LA LANGUE : FR = virgule (`0{{,}}18`, `4,12`), milliers `\\,` ; +EN = point (`0.18`), milliers virgule. {lang_directive} + +BILINGUE (si l'exercice l'est) : rôles INLINE uniquement : + {{fr}}`Calculer …`{{en}}`Compute …` + Jamais de bloc :::{{fr}}/:::{{en}}. Les injections `{{{{var}}}}` se placent + HORS des rôles : {{fr}}`Il y a `{{en}}`There are `{{{{nAff}}}}. + Symétrie totale FR/EN (même détail, mêmes placeholders). Si un nombre + décimal s'affiche, prévoir des variables séparées par langue + (`prixAffFr` virgule / `prixAffEn` point) et injecter la bonne dans chaque rôle. + +FIDÉLITÉ À LA SOURCE (directive Chabane — INTOUCHABLE) : + • Même énoncé, même méthode, même structure de solution, mêmes poids. + • Ne JAMAIS AJOUTER de phrase d'énoncé, de transition ou de rappel de règle + absent de la source (même « pour aider ») — on pythonise, on n'enrichit pas. + • Conserver les commentaires utiles du bloc Python source s'il en a. + • Si la source contient des `detailedSolution` validées : INTERDICTION de + reformuler la prose — seules les valeurs littérales deviennent `{{{{var}}}}`. + • Conserver tels quels : `\\ds`, `\\dfrac`, `\\inftys` (macro maison, ne + JAMAIS la « corriger »), `\\begin{{equation*}}` avec `&=` direct, + `\\phantom{{-}}\\\\`. Pas de `$$…$$`, pas de `\\[…\\]`, pas de `\\begin{{align}}`. + • Géométrie pure : ne pas pythoniser (valeurs statiques conservées). + +FIGURES matplotlib (si l'exo en a) : construites dans LE bloc Python unique, +variables du tirage réellement utilisées dans le tracé, labels DANS la fenêtre, +pas de mélange Rational+numpy (passer par float()), UN SEUL `plt.show()` final — +jamais `savefig`, jamais `matplotlib.use(...)`. + +═══════════════════════════════════════════════════════════════════════════ + CATALOGUE PyxiScience (helpers à utiliser DANS le bloc Python) +═══════════════════════════════════════════════════════════════════════════ +RÈGLE D'OR DU CATALOGUE : avant d'écrire le moindre LaTeX à la main, CHERCHE +ici un helper qui fait le travail et UTILISE-LE (résultat dans une variable +`…Aff` injectée nue). Coder à la main ce qu'un helper sait faire = REJET. + • Matrices → `pxsl_matrix` (jamais `\\begin{{pmatrix}}` à la main) ; sommes/ + produits/scalaires détaillés → `pxsl_sum_matrix`/`pxsl_prod_matrix`/ + `pxsl_prod_scalar_matrix` ; système `Ax=B` → `pxsl_system_lin` ; résolution + pas à pas / inversion → `pxsl_resol_system` / `pxs_steps_invert_matrix` ; + échelon/RREF → `pxs_compute_ech`/`pxs_compute_ech_reduite`. + • Proba (v.a. finie) → `pxs_finiterv`, tableau de loi `pxsl_law`, moment + `pxsl_moment`, transformation `pxs_fct_finiterv`. + • Intégration par parties → `pxs_explain_IBP` (rédaction complète, injectée + via `{{{{ipp}}}}`). + • Coefficients signés → `pxsl_latex_coefficient`/`lc` ; puissances → + `pxsl_pow` ; résultat numérique → `pxsl_res_num` ; inéquation rédigée → + `pxsl_solve_general_inequality`. + • Voie par défaut pour une expression : `latex(expr, **config_standard)`. + `**config_standard` est réservé à `latex()` — JAMAIS sur un helper `pxsl_*`. + • N'appelle PAS un helper marqué « runtime à vérifier » (indi_l_r_symb, + pxs_round, Poly_with_random_coef) sans certitude qu'il est chargé. + +{functions} + +═══════════════════════════════════════════════════════════════════════════ + EXEMPLE CANONIQUE DU MÊME TYPE (extrait d'un exercice livré et validé — + imite sa structure, ses conventions d'affichage et son niveau de détail) +═══════════════════════════════════════════════════════════════════════════ +{fewshot} + +═══════════════════════════════════════════════════════════════════════════ + CONTEXTE +═══════════════════════════════════════════════════════════════════════════ + +EN-TÊTE déjà finalisé (NE PAS reproduire) : +{content} + +VARIABLES DÉTECTÉES : +{analysis} + +BLOCS PRÉCÉDENTS (ne pas redéfinir leurs variables, ne pas les répéter) : +{previous_blocks} + +SECTION À PYTHONISER ({range_label} / {nb_total}) : +{current_segment} + +═══════════════════════════════════════════════════════════════════════════ + RÈGLES D'ASSEMBLAGE PAR PAIRE +═══════════════════════════════════════════════════════════════════════════ + +⚠️ TU PRODUIS UNIQUEMENT LE CONTENU DE CETTE PAIRE — jamais celui des paires +précédentes (concaténées mécaniquement avant ta sortie). + +⚠️ EXACTEMENT {nb_current} bloc(s) `:::::{{question}}` — pas plus, pas moins. +`questionId`/`questionIndex` CONTINUS depuis la paire précédente. + +⚠️ PAIRE 1 UNIQUEMENT : tu produis le bloc ````{{python}}```` principal +(imports + tirages + calculs + variables Aff + `globals()`) puis l'énoncé +général réécrit avec injections, AVANT la première question. + +⚠️ PAIRES SUIVANTES : ni énoncé, ni questions précédentes, ni ré-imports. +Si de NOUVELLES variables sont nécessaires (ex. figure d'une partie C), un +PETIT bloc ````{{python}}```` additionnel SANS imports, terminé par `globals()`. + +Format paire 1 : + +````{{python}} +<imports + tirages (cas dégénérés exclus) + calculs sympy exacts + + TOUTES les variables d'affichage …Aff ; AUCUN texte pédagogique> +globals() +```` + +<énoncé général avec valeurs → {{{{varAff}}}}> + +:::::{{question}} +:questionType: STQ +:questionId: N +:questionIndex: N + +::::{{questionStatement}} … :::: +::::{{questionHint}} … :::: +::::{{detailedSolution}} … :::: +::::{{weightDistribution}} +:logic: … +:abstraction: … +:reasoning: … +:calculation: … +:::: +::::: + +═══════════════════════════════════════════════════════════════════════════ + RÈGLES CIBLÉES POUR CET EXERCICE (depuis la base de règles) +═══════════════════════════════════════════════════════════════════════════ +{targeted_rules} + +INVARIANTS MATHÉMATIQUES à préserver lors des tirages : +{property_constraints} + +═══════════════════════════════════════════════════════════════════════════ + CHECKLIST FINALE (vérifie chaque point avant de répondre) +═══════════════════════════════════════════════════════════════════════════ + □ Bloc ````{{python}}```` (4 backticks) terminé par `globals()` + □ CHAQUE `{{{{ }}}}` = nom de variable NU camelCase sans underscore + □ Aucun appel/calcul/`**kwargs` dans `{{{{ }}}}` — tout pré-calculé en `…Aff` + □ Aucun `$` collé à un chiffre — `${{}}` devant toute injection inline numérique + □ Tirages sans cas dégénéré (^{{1}}, ^{{0}}, frac{{}}{{1}}, sqrt[2], double signe) + □ Coefficients signés via lc(...) pré-calculé ; décimales localisées + □ weightDistribution = poids source verbatim (somme 100) ; IDs contigus + □ Solutions validées : prose INTACTE, valeurs → {{{{var}}}} + □ {{fr}}`…`{{en}}`…` symétriques si bilingue ; `\\%` pour les pourcentages +""" + + +# ───────────────────────────────────────────────────────────────────────────── +# AUDIT — vérification ciblée + patches textuels +# v1 → app/knowledge/prompts_v1_archive.md §STEP_AUDIT_PROMPT +# (v2 : ajout des contrôles injections nues / $+chiffre / fences 4 backticks ; +# les patches sont désormais appliqués à TOUTES les occurrences identiques) +# ───────────────────────────────────────────────────────────────────────────── +STEP_AUDIT_PROMPT = """\ +Tu es l'auditeur PyxiScience. Tu reçois l'exercice pythonisé final et tu vérifies +UNIQUEMENT les règles listées ci-dessous (chacune avec son cas FAUTIF/CORRECT), +plus les 4 invariants plateforme : + (a) toute injection `{{{{ }}}}` est un nom de variable NU camelCase sans underscore ; + (b) aucun `$` non échappé collé à un chiffre (préfixe `${{}}` requis) ; + (c) bloc {{python}} à 4 backticks terminé par `globals()` ; + (d) questionId/questionIndex contigus depuis 0, weightDistribution somme 100. + +RÈGLES À VÉRIFIER : + +{audit_rules} + +EXERCICE À AUDITER : +{exercise} + +═══════════════════════════════════════════════════════════════════════════ + MISSION +═══════════════════════════════════════════════════════════════════════════ + +Pour chaque règle violée, renvoie une "issue" : + • rule : ID de la règle (ex: "6.1") ou "(a)"…"(d)" + • location : snippet EXACT (1 ligne, ≤ 200 caractères), copié VERBATIM — + utilisé tel quel par str.replace() côté Python. + Si la même violation apparaît à N endroits IDENTIQUES, + une seule issue suffit (toutes les occurrences seront + remplacées). + • fix : remplacement EXACT. + • python_insert : (OPTIONNEL) ligne(s) Python à insérer avant le `globals()` + du bloc principal (ex: "eqAff = latex(eq, **config_standard)"). + • can_patch : true si la correction est sûre, false sinon (warning seul). + • message : phrase explicative en français. + +Ne liste PAS les règles respectées ni celles hors liste. Ne « corrige » JAMAIS +`\\inftys`, `\\ds`, `\\dfrac`, ni la prose d'une solution validée. +Un exercice MONOLINGUE (tout FR ou tout EN, sans rôles {{fr}}`…`{{en}}`…`) est +LÉGITIME — ne le signale pas ; n'exige le bilingue que s'il est déjà partiel. +Les espaces internes `{{{{ var }}}}` sont tolérés (le moteur trim) — ne les +signale pas. + +Réponds UNIQUEMENT en JSON valide, sans markdown : +{{ + "verdict": "OK" ou "PATCH_REQUIRED", + "issues": [ + {{ + "rule": "6.1", + "location": "{{{{latex(fDev, **config_standard)}}}}", + "fix": "{{{{fDevTex}}}}", + "python_insert": "fDevTex = latex(fDev, **config_standard)", + "can_patch": true, + "message": "Appel avec **kwargs dans {{{{…}}}} — variable pré-calculée." + }} + ] +}} +""" + + +# ───────────────────────────────────────────────────────────────────────────── +# Traduction de contraintes FR → assertions Python (règle 4.3) +# (inchangé v1 — déplacé depuis routes/pythonise_routes_v2.py) +# ───────────────────────────────────────────────────────────────────────────── +TRANSLATE_CONSTRAINTS_PROMPT = """\ +Tu reçois un bloc de code Python qui tire des variables aléatoires et calcule +des grandeurs dérivées, et une liste de contraintes mathématiques en français +à vérifier sur les variables produites. + +CODE PYTHON : +```python +{code} +``` + +CONTRAINTES (français) : +{constraints} + +MISSION : +Pour chaque contrainte, écris une expression Python booléenne qui, évaluée +dans le namespace résultant de l'exécution du code, retourne True si la +contrainte est respectée. + +Règles : + • Contrainte universelle ("pour tout n") → échantillonner n = 0..10 max et + combiner avec `all(...)` (5 à 10 valeurs, pas plus). + • Notations math (≤, ≥, ≠) → `<=`, `>=`, `!=`. + • Contrainte intestable (variable absente du code) → `"assertion": null`. + • AUCUN import supplémentaire — seulement les variables du namespace + builtins. + +Réponds UNIQUEMENT en JSON valide : +[ + {{"description": "<contrainte originale>", "assertion": "<expression Python>" }}, + {{"description": "<autre contrainte>", "assertion": null }} +] +""" + + +# ───────────────────────────────────────────────────────────────────────────── +# Substitution des solutions validées (règle 8.1) +# (inchangé v1 — déplacé depuis routes/pythonise_routes_v2.py) +# ───────────────────────────────────────────────────────────────────────────── +SOLUTION_SUBSTITUTION_PROMPT = """\ +Tu reçois UNE solution mathématique rédigée avec des VALEURS LITTÉRALES (nombres, +fractions, expressions concrètes), et une liste de VARIABLES PYTHON disponibles +dans le bloc `{{python}}` de l'exercice pythonisé. + +SOLUTION ORIGINALE (source MyST, à préserver mot à mot) : +───────────────────────────────── +{original_solution} +───────────────────────────────── + +VARIABLES PYTHON DISPONIBLES (chaque var a une valeur d'exemple ; substitue +chaque occurrence numérique par le placeholder MyST {{{{var}}}}) : +{variables_table} + +═══════════════════════════════════════════════════════════════════════════ + MISSION +═══════════════════════════════════════════════════════════════════════════ + +Remplace CHAQUE valeur littérale (nombre entier, fraction, expression numérique) +qui correspond à une variable Python par son placeholder MyST `{{{{nomVar}}}}`. + +⚠️ STRICTES INTERDICTIONS : + • NE PAS ajouter de mots (Initialisation, Hérédité, Conclusion, Soit, Donc, etc.) + • NE PAS ajouter de **gras** ou *italique* si pas dans la source + • NE PAS reformuler la moindre phrase ; NE PAS modifier la ponctuation + • NE PAS ajouter de paragraphes ou de blocs equation* + • Si la source utilise déjà des `{{{{var}}}}`, les PRÉSERVER tels quels. + +EXEMPLE : + Source : `On a $f(0) = 3$ et $f(2) = -1$.` + Variables : x0=0, x2=2, fx0=3, fx2=-1 + Sortie : `On a $f({{{{x0}}}}) = {{{{fx0}}}}$ et $f({{{{x2}}}}) = {{{{fx2}}}}$.` + +Réponds UNIQUEMENT avec le texte modifié, SANS préambule, SANS wrapper markdown, +SANS guillemets ajoutés. Si tu ne peux pas substituer, recopie la source telle quelle. +""" + + +# ───────────────────────────────────────────────────────────────────────────── +# Traduction / bilinguisation (NOUVEAU — chantier langue cible) +# ───────────────────────────────────────────────────────────────────────────── +TRANSLATE_PROMPT = """\ +Tu traduis la PROSE d'un exercice PyxiScience MyST, de {source_label} vers {target_label}. + +TEXTE (les blocs Python ont été remplacés par des sentinelles <<<PYBLOCK_n>>> — à RECOPIER TELLES QUELLES) : +───────────────────────────────── +{body} +───────────────────────────────── + +RÈGLES ABSOLUES : + • Traduire UNIQUEMENT la prose (énoncés, indications, solutions, titres). + • PRÉSERVER À L'IDENTIQUE : toutes les sentinelles <<<PYBLOCK_n>>>, tous les + placeholders `{{{{var}}}}` (mêmes noms, mêmes positions logiques), tout le + LaTeX/maths ($…$, \\begin{{equation*}}…), la structure des fences MyST + (`````, :::::, ::::), toutes les options `:clé: valeur` (dont + :questionId:, :weightDistribution: et leurs valeurs), `\\ds`, `\\dfrac`, + `\\inftys`, `\\%`. + • {format_directive} + • Décimales : virgule en FR (`0{{,}}5`), point en EN (`0.5`) — adapte les + décimales LITTÉRALES de la prose à la langue de chaque segment ; ne touche + pas aux `{{{{var}}}}`. + • Terminologie mathématique scolaire exacte ; même niveau de détail. + +Réponds UNIQUEMENT avec le texte transformé, sans préambule ni wrapper. +""" + +TRANSLATE_FORMAT_MONO = ( + "Sortie MONOLINGUE en {target_label} : remplace chaque texte source par sa " + "traduction, sans rôles {{fr}}/{{en}}." +) +TRANSLATE_FORMAT_BOTH = ( + "Sortie BILINGUE : chaque segment de prose devient une paire de rôles inline " + "{fr}`texte français`{en}`english text` (JAMAIS de bloc :::{fr}/:::{en}). " + "Les injections {{var}} et le LaTeX restent HORS des rôles, partagés par les " + "deux langues : {fr}`Il y a `{en}`There are `{{nAff}}." +) + + +# ───────────────────────────────────────────────────────────────────────────── +# Réparation post-harnais (NOUVEAU — 1 itération max) +# ───────────────────────────────────────────────────────────────────────────── +REPAIR_PROMPT = """\ +Le harnais de validation PyxiScience a REJETÉ l'exercice pythonisé ci-dessous. +Corrige-le en changeant LE MINIMUM (ne réécris pas l'exercice, ne reformule +aucune prose, ne change pas la structure ni les poids). + +ÉCHECS DU HARNAIS : +{failures} + +EXERCICE ACTUEL : +{exercise} + +RÈGLES DE CORRECTION : + • Exception à l'exécution → corrige le bloc Python (tirage dégénéré, import + manquant, division par zéro…) par la modification la plus locale possible. + • Variable non résolue `{{{{x}}}}` → définis-la dans le bloc Python (avant + `globals()`) ou corrige le nom injecté. + • Motif interdit `$`+chiffre → préfixe `${{}}`. + • **Double signe `+ -`** (cas le plus fréquent) : un `+` littéral du texte + est suivi d'une injection qui rend une valeur NÉGATIVE (ex. + `… + {{{{dfAff}}}}` avec dfAff = "- 4 \\sin(4x)"). Correctif : supprimer le + `+` littéral ET pré-calculer la chaîne SIGNÉE dans le bloc Python + (`dfSignAff = latex(df, **config_standard)` rend déjà le signe ; ou + construire `"+ …"`/`"- …"` selon le signe) puis injecter + `… {{{{dfSignAff}}}}` sans opérateur devant. + • **`\\frac{{…}}{{1}}` / `^{{1}}` / `^{{0}}` / `\\sqrt[2]`** : presque toujours un + TIRAGE DÉGÉNÉRÉ — exclure la valeur fautive à la source + (ex. `b = rd.randint(2, 5)` au lieu de `randint(1, 5)`, ou boucle de rejet + `if b == 1: continue`). Ne PAS rafistoler le texte : corriger le tirage. + • Injection non nue → pré-calculer en variable camelCase `…Aff`. + • Le bloc {{python}} reste à 4 backticks et se termine par `globals()`. + • Ne touche NI à `\\inftys`/`\\ds`/`\\dfrac`, NI à la prose des solutions. + +Réponds UNIQUEMENT avec l'exercice complet corrigé (de `````{{exercise}} à `````), +sans préambule ni wrapper markdown. +""" diff --git a/app/pipeline/solutions.py b/app/pipeline/solutions.py new file mode 100644 index 0000000000000000000000000000000000000000..d3c5d9133e793d77d7a8484949835ccdd1956085 --- /dev/null +++ b/app/pipeline/solutions.py @@ -0,0 +1,108 @@ +""" +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, +) -> 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, + 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, +) -> 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) + 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 diff --git a/app/pipeline/translate.py b/app/pipeline/translate.py new file mode 100644 index 0000000000000000000000000000000000000000..247b41f28de21d13c017df61d038f21759a8bfd4 --- /dev/null +++ b/app/pipeline/translate.py @@ -0,0 +1,123 @@ +""" +translate.py +──────────── +Étape langue cible (NOUVEAU — chantier E) : FR / EN / les deux. + +Stratégie (sécurité d'abord) : + • détection déterministe de la/des langue(s) source (rôles {fr}`…`{en}`…`, + sinon heuristique de mots-outils) ; + • cible == source → aucun appel LLM ; + • bilingue → mono : réduction DÉTERMINISTE (on déballe la langue gardée, + on supprime l'autre — zéro LLM, zéro risque) ; + • mono → autre langue ou bilingue : appel LLM sur le corps avec les blocs + Python MASQUÉS par sentinelles (le Python ne passe jamais par le LLM), + puis garde-fous : sentinelles intactes, MÊME ensemble de {{placeholders}}, + même nombre de blocs :::::{question}. En cas d'écart → on garde la + version non traduite + warning (jamais de sortie corrompue). +""" + +from __future__ import annotations + +import logging +import re + +from app.llm.client import process_with_openrouter +from app.pipeline.postprocess import ( + INJECTION_RE, + detect_languages, + mask_python_blocks, + strip_language, + unmask_python_blocks, +) +from app.pipeline.prompts import ( + TRANSLATE_FORMAT_BOTH, + TRANSLATE_FORMAT_MONO, + TRANSLATE_PROMPT, +) + +logger = logging.getLogger(__name__) + +_LABELS = {"fr": "le FRANÇAIS", "en": "l'ANGLAIS", "both": "le BILINGUE FR+EN"} + + +def _placeholders(text: str) -> set[str]: + return {tok.strip() for tok in INJECTION_RE.findall(text)} + + +def ensure_language( + exercise: str, + target: str, + model_idx: int, +) -> tuple[str, list[dict], dict]: + """ + Amène l'exercice dans la langue cible. Retourne + (exercice, warnings, info={"source": …, "target": …, "action": …}). + """ + warnings: list[dict] = [] + source = detect_languages(exercise) + info = {"source": source, "target": target, "action": "aucune"} + + if target not in ("fr", "en", "both") or target == source: + return exercise, warnings, info + + # Bilingue → mono : déterministe. + if source == "both" and target in ("fr", "en"): + info["action"] = f"réduction déterministe bilingue → {target}" + return strip_language(exercise, keep=target), warnings, info + + # Mono → autre mono, ou mono → bilingue : LLM sur la prose seule. + # Les sentinelles internes \x00PYBLOCKn\x00 (caractère NUL) sont + # intransmissibles à un LLM — on les convertit en sentinelles texte + # copiables, puis on reconvertit au retour. + masked, blocks = mask_python_blocks(exercise) + for i in range(len(blocks)): + masked = masked.replace(f"\x00PYBLOCK{i}\x00", f"<<<PYBLOCK_{i}>>>") + fmt = (TRANSLATE_FORMAT_BOTH if target == "both" + else TRANSLATE_FORMAT_MONO.format(target_label=_LABELS[target])) + try: + raw = process_with_openrouter( + prompt=TRANSLATE_PROMPT.format( + source_label=_LABELS.get(source, source), + target_label=_LABELS[target], + body=masked, + format_directive=fmt, + ), + model_idx=model_idx, + temperature=0.0, + max_tokens=24000, + ) + except (RuntimeError, ValueError, OSError) as e: + warnings.append({ + "rule": "langue", + "message": f"Étape de traduction en échec ({e}) — sortie laissée en {source}.", + }) + return exercise, warnings, info + + translated = raw.strip() + translated = re.sub(r"^```\w*\s*\n", "", translated) + translated = re.sub(r"\n```\s*$", "", translated) + for i in range(len(blocks)): + translated = translated.replace(f"<<<PYBLOCK_{i}>>>", f"\x00PYBLOCK{i}\x00") + + # Garde-fous structurels — si l'un casse, on n'applique PAS la traduction. + checks = [] + for i in range(len(blocks)): + if f"\x00PYBLOCK{i}\x00" not in translated: + checks.append(f"sentinelle PYBLOCK{i} perdue") + if _placeholders(translated) != _placeholders(masked): + missing = _placeholders(masked) - _placeholders(translated) + added = _placeholders(translated) - _placeholders(masked) + checks.append(f"placeholders modifiés (perdus: {sorted(missing)[:4]}, ajoutés: {sorted(added)[:4]})") + if translated.count(":::::{question}") != masked.count(":::::{question}"): + checks.append("nombre de blocs question modifié") + + if checks: + warnings.append({ + "rule": "langue", + "message": ("Traduction rejetée par les garde-fous (" + " ; ".join(checks) + + ") — sortie laissée en " + source + "."), + }) + return exercise, warnings, info + + info["action"] = f"traduction LLM {source} → {target}" + return unmask_python_blocks(translated, blocks), warnings, info diff --git a/app/rag/__init__.py b/app/rag/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/rag/catalogue.py b/app/rag/catalogue.py new file mode 100644 index 0000000000000000000000000000000000000000..dbb436e8adbecd9ace93194e9d8f7f36de21168c --- /dev/null +++ b/app/rag/catalogue.py @@ -0,0 +1,93 @@ +""" +catalogue.py +──────────── +Catalogue CURÉ des helpers PyxiScience (app/knowledge/functions_catalogue.md), +injecté par TRANCHE DE DOMAINE dans le prompt de génération. + +Complète le RAG FAISS (app/rag/functions.py) : FAISS donne des hits sémantiques +sur le code réel (signatures/docstrings) ; ce catalogue donne la couche +« quel helper pour quel besoin » + les domaines que le corpus livré ne couvre +pas (matrices, proba, IBP). Les sections §0 (conventions) et §2 (briques +essentielles) sont toujours incluses ; la/les section(s) de domaine sont +choisies selon le type détecté à l'analyse. +""" + +from __future__ import annotations + +import logging +import re +from functools import lru_cache + +from app.config import KNOWLEDGE_DIR + +logger = logging.getLogger(__name__) + +_CATALOGUE_MD = KNOWLEDGE_DIR / "functions_catalogue.md" + +# Sections toujours injectées (numéros de header `## N.`). +_ALWAYS = ("0", "2") + +# Domaine → (sections, motif de déclenchement sur type/title/concepts). +_DOMAINS: list[tuple[str, tuple[str, ...], str]] = [ + ("algebre_lineaire", ("6",), + r"matric|système|systeme|déterminant|determinant|gauss|invers|" + r"échelon|echelon|rref|vecteur|alg[eè]bre lin|linear algebra|pivot"), + ("probabilites", ("7",), + r"probabilit|variable al[ée]atoire|loi\b|espérance|esperance|variance|" + r"moment|binomial|distribution|tirage al[ée]atoire de v\.?a"), + ("integration", ("5",), + r"int[ée]gra|primitive|\bipp\b|parties|\bibp\b"), +] +# §4 (généralistes) : injecté par défaut quand aucun domaine spécialisé ne sort +# (inéquations, coefficients, fractions, polynômes — le cas courant). +_DEFAULT_SECTIONS = ("4",) + +_SECTION_RE = re.compile(r"(?m)^## (?P<num>\d+)\.\s.*$") + + +@lru_cache(maxsize=1) +def _sections() -> dict[str, str]: + """Découpe le catalogue en {num_section: texte (header inclus)}.""" + if not _CATALOGUE_MD.exists(): + logger.warning("Catalogue de fonctions absent : %s", _CATALOGUE_MD) + return {} + text = _CATALOGUE_MD.read_text(encoding="utf-8") + # Retire le commentaire HTML de vérification en tête. + text = re.sub(r"^<!--.*?-->\s*", "", text, count=1, flags=re.DOTALL) + matches = list(_SECTION_RE.finditer(text)) + out: dict[str, str] = {} + for i, m in enumerate(matches): + end = matches[i + 1].start() if i + 1 < len(matches) else len(text) + out[m.group("num")] = text[m.start():end].strip() + return out + + +def select_sections(analysis: dict) -> list[str]: + """Numéros de sections à injecter pour cet exercice (ordre stable).""" + haystack = " ".join([ + str(analysis.get("exercise_type", "")), + str(analysis.get("exercise_title", "")), + str(analysis.get("mathematical_structure", "")), + " ".join(str(c) for c in (analysis.get("suggested_concepts") or [])), + ]).lower() + + domains = [secs for _name, secs, pat in _DOMAINS if re.search(pat, haystack)] + chosen = list(_ALWAYS) + if domains: + for secs in domains: + chosen.extend(secs) + else: + chosen.extend(_DEFAULT_SECTIONS) + # dédup en préservant l'ordre + return list(dict.fromkeys(chosen)) + + +def catalogue_for(analysis: dict) -> str: + """Tranche curée du catalogue pour le type détecté (chaîne prête à injecter).""" + sections = _sections() + if not sections: + return "" + wanted = select_sections(analysis) + parts = [sections[n] for n in wanted if n in sections] + logger.info("Catalogue curé : sections %s injectées", wanted) + return "\n\n".join(parts) diff --git a/app/rag/formatter.py b/app/rag/formatter.py new file mode 100644 index 0000000000000000000000000000000000000000..9710f5e869ae9a1a8cb97ad56fd6dbddff31aa50 --- /dev/null +++ b/app/rag/formatter.py @@ -0,0 +1,229 @@ +""" +rag_formatter.py +──────────────── +Compact, LLM-optimized formatter for retrieved PyxiScience entities. + +Replaces the legacy 4-section verbose catalogue (imports / signatures / +names / detailed) with a single structured block per entity, driven by +the :pxs_trigger: / :pxs_returns: / :pxs_example: / :pxs_antipattern: +meta-tags embedded in PyxiScience docstrings. + +Entity dict schema expected by `build_catalogue`: + + { + "qualname": "pxs_Poly.pxsl_solution", + "signature": "(mult='\\times', formula=True)", + "import_line": "from pyxiscience.Classes_Extensions import pxs_Poly", + "kind": "function" | "method" | "class", + "docstring": "<raw docstring, :pxs_*: tags intact>", + # for classes, optionally: + "methods": [{"name": str, "signature": str, "summary": str}, ...], + } + +Functions and methods go through `_format_function_entity`. +Classes with a non-empty `methods` list go through `_format_class_entity`. +""" + +from __future__ import annotations + +import re +import textwrap +from typing import Dict, List + + +# Regex patterns for the :pxs_*: meta-tags. +# Each one matches non-greedily up to the next :pxs_*: tag or end-of-string, +# so multi-line values are preserved. +_META_TAGS = { + "trigger": r":pxs_trigger:\s*(.+?)(?=\n\s*:pxs_|\Z)", + "returns": r":pxs_returns:\s*\|?\s*\n?(.+?)(?=\n\s*:pxs_|\Z)", + "example": r":pxs_example:\s*\|?\s*\n?(.+?)(?=\n\s*:pxs_|\Z)", + "antipattern": r":pxs_antipattern:\s*(.+?)(?=\n\s*:pxs_|\Z)", +} + + +def _extract_meta_tags(docstring: str) -> Dict[str, str]: + """Parse :pxs_*: tags from an enriched docstring into a flat dict.""" + if not docstring: + return {} + meta: Dict[str, str] = {} + for key, pattern in _META_TAGS.items(): + m = re.search(pattern, docstring, flags=re.DOTALL) + if m: + # Normalise indentation & trim trailing whitespace. + meta[key] = textwrap.dedent(m.group(1)).strip() + return meta + + +def _first_useful_line(docstring: str) -> str: + """ + Fallback summary when no :pxs_trigger: is present. + + Pulls the first non-trivial line BEFORE any :pxs_*: tag, strips + bilingual prefixes ('Fr :', '\\fr{', 'En :', etc.) and truncates. + """ + if not docstring: + return "" + # Only look at the human-readable head (before the first meta-tag). + cut = re.search(r"^\s*:pxs_\w+:", docstring, flags=re.MULTILINE) + head = docstring[: cut.start()] if cut else docstring + + for raw in head.splitlines(): + line = raw.strip() + if not line or line.startswith((":", "---", "===", "Args:", "Returns:", "Paramètres")): + continue + # Strip bilingual markers. + line = re.sub(r"^(Fr|En|\\fr|\\en)\s*[:{]?\s*", "", line) + line = line.rstrip("}").strip() + if len(line) > 10: + return line[:140] + return "" + + +def _indent(text: str, prefix: str = " ") -> str: + """Indent every line of a block with a constant prefix.""" + return "\n".join(prefix + line if line else line for line in text.splitlines()) + + +# ──────────────────────────────────────────────────────────────────────── +# Per-entity formatters +# ──────────────────────────────────────────────────────────────────────── + +def _format_function_entity(entity: Dict) -> str: + """Compact block for a standalone function or (unattached) method.""" + meta = _extract_meta_tags(entity.get("docstring", "")) + lines: List[str] = [] + + # Header: qualified name + signature, then the import line. + lines.append(f"▸ {entity['qualname']}{entity.get('signature', '(...)')}") + lines.append(f" {entity['import_line']}") + lines.append("") + + trigger = meta.get("trigger") or _first_useful_line(entity.get("docstring", "")) + if trigger: + lines.append("QUAND UTILISER") + lines.append(_indent(trigger)) + lines.append("") + + if meta.get("returns"): + lines.append("CE QUE ÇA RETOURNE") + lines.append(_indent(meta["returns"])) + lines.append("") + + if meta.get("example"): + lines.append("EXEMPLE D'APPEL") + lines.append(_indent(meta["example"])) + lines.append("") + + if meta.get("antipattern"): + lines.append("NE PAS RÉIMPLÉMENTER MANUELLEMENT") + lines.append(f" ✗ {meta['antipattern']}") + lines.append(f" ✓ Utiliser {entity['qualname']}") + + while lines and lines[-1] == "": + lines.pop() + return "\n".join(lines) + + +def _format_class_entity(entity: Dict) -> str: + """ + Compact block for a class + its methods. Expects entity['methods'] to be + a non-empty list of {name, signature, summary} dicts. + """ + meta = _extract_meta_tags(entity.get("docstring", "")) + lines: List[str] = [] + + lines.append(f"▸ {entity['qualname']} (classe)") + lines.append(f" {entity['import_line']}") + lines.append("") + + lines.append("MÉTHODES DISPONIBLES") + for m in entity["methods"]: + sig = m.get("signature", "(...)") + summary = (m.get("summary") or "").strip() + suffix = f" — {summary}" if summary else "" + lines.append(f" .{m['name']}{sig}{suffix}") + lines.append("") + + trigger = meta.get("trigger") or _first_useful_line(entity.get("docstring", "")) + if trigger: + lines.append("QUAND UTILISER (la classe)") + lines.append(_indent(trigger)) + lines.append("") + + if meta.get("example"): + lines.append("EXEMPLE D'USAGE TYPIQUE") + lines.append(_indent(meta["example"])) + lines.append("") + + if meta.get("antipattern"): + lines.append("NE PAS RÉIMPLÉMENTER MANUELLEMENT") + lines.append(f" ✗ {meta['antipattern']}") + lines.append(f" ✓ Utiliser {entity['qualname']}") + + while lines and lines[-1] == "": + lines.pop() + return "\n".join(lines) + + +def _format_entity_for_llm(entity: Dict) -> str: + """Dispatch: class with methods → grouped block; else → function block.""" + if entity.get("kind") == "class" and entity.get("methods"): + return _format_class_entity(entity) + return _format_function_entity(entity) + + +# ──────────────────────────────────────────────────────────────────────── +# Public API +# ──────────────────────────────────────────────────────────────────────── + +_SEP = "━" * 55 + + +def build_catalogue(entities: List[Dict]) -> str: + """ + Assemble the full catalogue string from an ordered list of retrieved + entity dicts. Use this as the `{functions}` injection in a downstream + code-generation prompt. + """ + if not entities: + return "Aucune fonction PyxiScience pertinente détectée pour cet exercice." + + header = [ + "FONCTIONS PYXISCIENCE DISPONIBLES — USAGE OBLIGATOIRE", + "", + "Les entités ci-dessous ont été sélectionnées comme pertinentes", + "pour cet exercice. Utilise-les plutôt que de réimplémenter", + "leur logique à la main — c'est la différence entre du code", + "PyxiScience et du code générique qui sera rejeté.", + "", + ] + + blocks = [_format_entity_for_llm(e) for e in entities] + body = f"\n{_SEP}\n".join([_SEP] + blocks + [_SEP]) + + return "\n".join(header) + body + + +def build_imports_block(entities: List[Dict]) -> str: + """ + Produce a de-duplicated 'from X import a, b, c' block from the entities, + grouped by module. For methods, we import the PARENT CLASS (not the + method itself — that's unimportable). + """ + by_module: Dict[str, set] = {} + for e in entities: + import_line = e.get("import_line", "") + m = re.match(r"from\s+(\S+)\s+import\s+(.+)", import_line) + if not m: + continue + module, names = m.group(1), m.group(2) + by_module.setdefault(module, set()).update( + n.strip() for n in names.split(",") if n.strip() + ) + if not by_module: + return "# (aucun import)" + return "\n".join( + f"from {mod} import {', '.join(sorted(names))}" + for mod, names in sorted(by_module.items()) + ) \ No newline at end of file diff --git a/app/rag/functions.py b/app/rag/functions.py new file mode 100644 index 0000000000000000000000000000000000000000..3b28366a853f63c57e838f2f16c5fbea13e9b28e --- /dev/null +++ b/app/rag/functions.py @@ -0,0 +1,1093 @@ +""" +PyxiScience RAG Pipeline — Source-Based, Corrected Edition +=========================================================== + +What changed vs the legacy `rag.py`: + + 1. FIXED — Method `import_line`. Legacy code emitted + from pyxiscience.Classes_Extensions import pxsl_solution + which is unimportable (pxsl_solution is a *method* of pxs_Poly). + Each method Document now carries: + import_line = "from <module> import <parent_class>" + parent_class = "<ClassName>" + qualname = "<ClassName>.<method_name>" + + 2. FIXED — Class/method grouping. Each class Document now carries a + serialised `methods` list in its metadata. When a class is + retrieved, it renders as ONE grouped block (class header + all + method signatures + one-line summaries) via the compact formatter. + + 3. FIXED — De-duplication at render time. If both a class AND some of + its own methods are retrieved, the methods are absorbed into the + class's rendered block instead of being emitted as duplicate + standalone blocks underneath. + + 4. INTEGRATED — Compact LLM-optimized formatter (rag_formatter.py) + replaces the legacy 4-section verbose catalogue. Leverages the + :pxs_trigger: / :pxs_returns: / :pxs_example: / :pxs_antipattern: + meta-tags embedded in PyxiScience docstrings. + + 5. SCHEMA — CACHE_SCHEMA_VERSION bumped 2 → 3. Old FAISS caches are + auto-invalidated on first load (see `_is_cache_stale`). + + 6. EXTENDED — ALWAYS_INCLUDE now includes `pxs_Interval` (class) and + `pxsl_pow` (function). These are ubiquitous in analysis exercises + (domains, solution sets, variation tables) and polynomial display + (safe coefficient parenthesisation) but were systematically missed + by semantic retrieval because their trigger words don't appear in + exercise statements. `build_always_include_context` now emits + kind-aware diagnostics so missing/malformed baseline entries are + loud at startup. + + ⚠️ After enriching docstrings (`:pxs_trigger:` / etc.), call + `clear_cache(<model_key>)` once — the embedded `page_content` has + changed but `CACHE_SCHEMA_VERSION` did not, so caches won't + auto-invalidate on content-only changes. + +Public entry points (unchanged names): + + retrieve_functions_context(exercise, ...) + Pure retrieval + compact catalogue. NO LLM call. This is what a + downstream code-generation prompt should inject as `{functions}`. + + retrieve_raw(exercise, ...) + Just the ranked top-k hits with scores. + + retrieve_functions(exercise, model=..., ...) + retrieve_functions_context + an LLM hop. Standalone Q&A only. +""" + +from __future__ import annotations + +import logging +import os +import re +import ast +import time +from pathlib import Path +from typing import Optional, List, Dict, Any, Tuple + +from langchain_openai import ChatOpenAI +from langchain_community.vectorstores import FAISS +from langchain_core.documents import Document + +# HuggingFaceEmbeddings tire torch + sentence-transformers (~1 Go). Il n'est +# nécessaire QUE pour les embeddings locaux ; le défaut `openai-3-small` passe +# par l'API. Import PARESSEUX → l'app (et le déploiement slim) tourne sans torch. +def _load_hf_embeddings_cls(): + from langchain_huggingface import HuggingFaceEmbeddings + return HuggingFaceEmbeddings + + +try: + from langchain_openai import OpenAIEmbeddings + _OPENAI_AVAILABLE = True +except ImportError: + _OPENAI_AVAILABLE = False + +from app.rag.formatter import build_catalogue, build_imports_block + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# 1. CONFIGURATION +# ============================================================================= + +from app.config import CORPUS_DIR, FAISS_CACHE + +from app.keys import OPENROUTER_API_KEY, OPENAI_API_KEY + +OPENROUTER_MODELS = { + "claude-haiku": "anthropic/claude-3-haiku", + "gpt-4o-mini": "openai/gpt-4o-mini", + "deepseek": "deepseek/deepseek-chat", + "gemini-flash": "google/gemini-flash-1.5", + "claude-sonnet": "anthropic/claude-4.5-sonnet", +} + +SCRIPTS_DIR = CORPUS_DIR + +PYXISCIENCE_SOURCE_FILES = { + "Classes_Extensions": "pyxiscience.Classes_Extensions", + "Mes_fctions_d_alg_lineaire_bis": "pyxiscience.Mes_fctions_d_alg_lineaire_bis", + "Mes_fctions_d_analyse_bis": "pyxiscience.Mes_fctions_d_analyse_bis", + "Mes_fctions_generalistes_bis": "pyxiscience.Mes_fctions_generalistes_bis", + "Mes_fctions_probabilistes_bis": "pyxiscience.Mes_fctions_probabilistes_bis", +} + +# --------------------------------------------------------------------------- +# ALWAYS_INCLUDE +# --------------------------------------------------------------------------- +# Entities injected into every prompt regardless of retrieval score. +# These are "ubiquitous utilities": functions/classes that the expert +# reaches for in ≥70% of exercises but whose trigger words rarely appear +# in the exercise statement itself, so semantic retrieval misses them. +# +# Ordering is preserved in the final catalogue (ALWAYS first, then +# retrieved entities). +# +# ─ Core formatting (present from v3) ─ +# pxsl_latex_coefficient — signed coefficient "+ 3x", "- x", "" +# pxsl_format_number — bilingual thousands separator + \infty +# pxsl_res_num — " = "/" \approx " result formatting +# pxsl_matrix — LaTeX matrix with auto delimiters +# pxs_config — sympy.latex kwargs (FR/EN, ln_notation, …) +# +# ─ Analysis & polynomial display (added v3.1) ─ +# pxs_Interval — classe ubiquitaire pour domaines, ensembles solution, +# tableaux de variations, image d'intervalle par TVI. +# La classe porte ses méthodes (.print, .from_Interval) +# dans sa metadata → rendues groupées dans le catalogue. +# pxsl_pow — affichage sécurisé coeff^n avec parenthèses auto sur +# coeff négatif/rationnel/composé. Indispensable dans +# tout corrigé pédagogique avec substitution numérique. +# --------------------------------------------------------------------------- +ALWAYS_INCLUDE = [ + "pxsl_latex_coefficient", + "pxsl_format_number", + "pxsl_res_num", + "pxsl_matrix", + "pxs_config", + # Added v3.1 — ubiquitous in analysis & polynomial display + "pxs_Interval", + "pxsl_pow", +] + +SOURCES_CACHE_ROOT = FAISS_CACHE +CACHE_SCHEMA_VERSION = 3 # bumped (method import fix) + +# FAISS L2 on normalised embeddings ∈ [0, 2]; lower = better. +DEFAULT_SCORE_THRESHOLD = 1.6 +DEFAULT_MODEL_KEY = "openai-3-small" + + +# ============================================================================= +# 2. EMBEDDING MODELS REGISTRY +# ============================================================================= + +EMBEDDING_MODELS: Dict[str, Dict] = { + "codesearch-distilroberta": { + "model_name": "flax-sentence-embeddings/st-codesearch-distilroberta-base", + "description": "Code-search DistilRoBERTa — optimized for code retrieval (EN)", + "normalize": True, "prefix": None, "backend": "huggingface", "cost_per_1k": None, + }, + "minilm-multilingual": { + "model_name": "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2", + "description": "Multilingual MiniLM — fast, decent FR/EN", + "normalize": True, "prefix": None, "backend": "huggingface", "cost_per_1k": None, + }, + "mpnet-multilingual": { + "model_name": "sentence-transformers/paraphrase-multilingual-mpnet-base-v2", + "description": "Multilingual MPNet — strong FR/EN, good default", + "normalize": True, "prefix": None, "backend": "huggingface", "cost_per_1k": None, + }, + "distiluse-multilingual": { + "model_name": "sentence-transformers/distiluse-base-multilingual-cased-v2", + "description": "Distilled USE multilingual", + "normalize": True, "prefix": None, "backend": "huggingface", "cost_per_1k": None, + }, + "labse": { + "model_name": "sentence-transformers/LaBSE", + "description": "LaBSE — 109 languages, strong cross-lingual", + "normalize": True, "prefix": None, "backend": "huggingface", "cost_per_1k": None, + }, + "e5-multilingual-small": { + "model_name": "intfloat/multilingual-e5-small", + "description": "Multilingual E5 small", + "normalize": True, "prefix": "query: ", "backend": "huggingface", "cost_per_1k": None, + }, + "e5-multilingual-base": { + "model_name": "intfloat/multilingual-e5-base", + "description": "Multilingual E5 base — top HF retrieval", + "normalize": True, "prefix": "query: ", "backend": "huggingface", "cost_per_1k": None, + }, + "e5-multilingual-large": { + "model_name": "intfloat/multilingual-e5-large", + "description": "Multilingual E5 large — slow", + "normalize": True, "prefix": "query: ", "backend": "huggingface", "cost_per_1k": None, + }, + "all-mpnet-base": { + "model_name": "sentence-transformers/all-mpnet-base-v2", + "description": "English MPNet", + "normalize": True, "prefix": None, "backend": "huggingface", "cost_per_1k": None, + }, + "all-minilm-l6": { + "model_name": "sentence-transformers/all-MiniLM-L6-v2", + "description": "English MiniLM L6", + "normalize": True, "prefix": None, "backend": "huggingface", "cost_per_1k": None, + }, + "openai-3-small": { + "model_name": "text-embedding-3-small", + "description": "OpenAI 3-small — 1536-dim, cheap, multilingual", + "normalize": True, "prefix": None, "backend": "openai", + "cost_per_1k": 0.00002, "dimensions": 1536, + }, + "openai-3-large": { + "model_name": "text-embedding-3-large", + "description": "OpenAI 3-large — 3072-dim", + "normalize": True, "prefix": None, "backend": "openai", + "cost_per_1k": 0.00013, "dimensions": 3072, + }, + "openai-ada-002": { + "model_name": "text-embedding-ada-002", + "description": "OpenAI ada-002 — legacy", + "normalize": True, "prefix": None, "backend": "openai", + "cost_per_1k": 0.00010, "dimensions": 1536, + }, +} + + +# ============================================================================= +# 3. SOURCE FILE PARSER — CORRECTED +# ============================================================================= + +def _extract_signature(node: ast.AST) -> str: + """Signature like 'f(x, y=0, **kwargs)' via ast.unparse (Py ≥ 3.9).""" + try: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + return f"{node.name}({ast.unparse(node.args)})" + if isinstance(node, ast.ClassDef): + # Prefer __init__ signature; fallback to generic "..." + for item in node.body: + if (isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) + and item.name == "__init__"): + args_src = ast.unparse(item.args) + args_src = re.sub(r"^self\s*,?\s*", "", args_src) + return f"{node.name}({args_src})" + return f"{node.name}(...)" + except Exception: + pass + return f"{getattr(node, 'name', '?')}(...)" + + +def _method_signature(node: ast.AST) -> str: + """Method signature with leading `self` stripped.""" + try: + args_src = ast.unparse(node.args) + args_src = re.sub(r"^self\s*,?\s*", "", args_src) + return f"({args_src})" + except Exception: + return "(...)" + + +def _summary_from_docstring(docstring: str) -> str: + """ + One-line purpose extracted from the first non-trivial line of the + docstring, up to the first :pxs_*: tag. Used to annotate methods + in a class-grouped block. + """ + if not docstring: + return "" + cut = re.search(r"^\s*:pxs_\w+:", docstring, flags=re.MULTILINE) + head = docstring[: cut.start()] if cut else docstring + for raw in head.splitlines(): + line = raw.strip() + if not line or line.startswith(("Args:", "Returns:", "Paramètres", "Retourne", + "Lève", "Exemples", "---", "===")): + continue + return line[:90] + return "" + + +def _page_content_for_embedding(doc_meta: Dict[str, Any], docstring: str, + source_snippet: str) -> str: + """ + Text fed to the embedder. Keep it dense in retrieval signal: qualname, + signature, the full docstring (incl. :pxs_*: tags — triggers matter a + lot for semantic match), and a trimmed source snippet. + """ + head = ( + f"NAME : {doc_meta['qualname']}\n" + f"SIGNATURE: {doc_meta['signature']}\n" + f"IMPORT : {doc_meta['import_line']}\n" + f"KIND : {doc_meta['kind']}\n" + ) + body = head + if docstring: + body += f"\nDESCRIPTION:\n{docstring}\n" + body += f"\nSOURCE:\n{source_snippet}" + return body + + +def parse_python_source(filepath: str, module_name: str) -> List[Document]: + """ + AST-walk a Python source file and emit one Document per top-level + function, class, and method. + + Key fix: methods' `import_line` references the PARENT CLASS, not + the method name. Classes carry a serialised `methods` list so the + renderer can group them. + """ + with open(filepath, "r", encoding="utf-8") as fh: + source = fh.read() + try: + tree = ast.parse(source) + except SyntaxError as e: + logger.info(f" ⚠️ SyntaxError in {filepath}: {e}") + return [] + + lines = source.splitlines() + + def get_source_block(node, limit: int = 1200) -> str: + start = node.lineno - 1 + end = getattr(node, "end_lineno", start + 40) + block = "\n".join(lines[start:end]) + return block[:limit] + ("\n..." if len(block) > limit else "") + + docs: List[Document] = [] + + for node in ast.iter_child_nodes(tree): + + # ── Top-level function ──────────────────────────────────────── + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + name = node.name + docstring = ast.get_docstring(node) or "" + signature = _extract_signature(node) + meta = { + "schema_version": CACHE_SCHEMA_VERSION, + "qualname": name, + "base_name": name, + "parent_class": "", + "module_name": module_name, + "import_line": f"from {module_name} import {name}", + "signature": signature.replace(name, "", 1), # "(...)" only + "kind": "function", + "docstring": docstring, + "filepath": filepath, + "methods": [], # unused for functions + } + # Keep full signature string alongside for downstream convenience + meta["signature"] = re.sub(r"^[^(]*", "", signature) or "(...)" + snippet = get_source_block(node) + docs.append(Document( + page_content=_page_content_for_embedding(meta, docstring, snippet), + metadata=meta, + )) + continue + + # ── Class ───────────────────────────────────────────────────── + if isinstance(node, ast.ClassDef): + cls_name = node.name + cls_docstring = ast.get_docstring(node) or "" + cls_signature = _extract_signature(node) # e.g. "pxs_Poly(x, ...)" + cls_signature = re.sub(r"^[^(]*", "", cls_signature) or "(...)" + + # Collect methods first so we can stash them on the class doc. + method_nodes = [ + item for item in node.body + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) + ] + + methods_meta: List[Dict[str, str]] = [] + for m in method_nodes: + msig = _method_signature(m) + mdoc = ast.get_docstring(m) or "" + methods_meta.append({ + "name": m.name, + "signature": msig, + "summary": _summary_from_docstring(mdoc), + }) + + # Class Document (retrievable on its own; renders grouped). + cls_meta = { + "schema_version": CACHE_SCHEMA_VERSION, + "qualname": cls_name, + "base_name": cls_name, + "parent_class": "", + "module_name": module_name, + "import_line": f"from {module_name} import {cls_name}", + "signature": cls_signature, + "kind": "class", + "docstring": cls_docstring, + "filepath": filepath, + "methods": methods_meta, + } + docs.append(Document( + page_content=_page_content_for_embedding( + cls_meta, cls_docstring, get_source_block(node, limit=600) + ), + metadata=cls_meta, + )) + + # Method Documents (retrievable individually; FIXED import line). + for m in method_nodes: + m_name = m.name + m_doc = ast.get_docstring(m) or "" + m_sig = _method_signature(m) + m_qual = f"{cls_name}.{m_name}" + m_meta = { + "schema_version": CACHE_SCHEMA_VERSION, + "qualname": m_qual, + "base_name": m_name, + "parent_class": cls_name, + "module_name": module_name, + # ⚠️ Methods are not importable on their own — import the class. + "import_line": f"from {module_name} import {cls_name}", + "signature": m_sig, + "kind": "method", + "docstring": m_doc, + "filepath": filepath, + "methods": [], + } + docs.append(Document( + page_content=_page_content_for_embedding( + m_meta, m_doc, get_source_block(m) + ), + metadata=m_meta, + )) + + return docs + + +def load_all_sources(scripts_dir: str | Path = SCRIPTS_DIR) -> List[Document]: + all_docs: List[Document] = [] + for filename, module_name in PYXISCIENCE_SOURCE_FILES.items(): + candidates = [ + os.path.join(scripts_dir, filename), + os.path.join(scripts_dir, filename + ".py"), + ] + filepath = next((p for p in candidates if os.path.exists(p)), None) + if filepath is None: + logger.info(f" ⚠️ Not found: {filename} (skipping)") + continue + logger.info(f" 📄 Parsing {filename} → {module_name}") + docs = parse_python_source(filepath, module_name) + all_docs.extend(docs) + logger.info(f" → {len(docs)} functions/classes/methods indexed") + return all_docs + + +def build_always_include_context(all_docs: List[Document]) -> List[Document]: + """ + Resolve ALWAYS_INCLUDE names → Documents. We match on `qualname` (which + equals the bare name for top-level functions and classes). + + Kind-aware diagnostics: + - A missing entry is a hard warning (the entity won't be injected). + - A class with no methods is a soft warning (likely a parser + regression — its methods won't be listed in the catalogue block). + - Duplicate base_names across modules (e.g. `pxsl_pow` defined in + two source files) are flagged so the user can dedupe canonically. + """ + # Detect duplicates across modules BEFORE dedup-by-qualname. + base_name_locations: Dict[str, List[str]] = {} + for d in all_docs: + if d.metadata.get("parent_class"): + continue # skip methods (their name collides with siblings by design) + base = d.metadata["base_name"] + base_name_locations.setdefault(base, []).append(d.metadata["module_name"]) + + for name in ALWAYS_INCLUDE: + locs = base_name_locations.get(name, []) + if len(locs) > 1: + logger.info( + f" ⚠️ ALWAYS_INCLUDE '{name}' defined in MULTIPLE modules: " + f"{locs} — last one wins in the index, consider deduping." + ) + + # The actual resolution index — one doc per top-level qualname + # (duplicates get clobbered; that's the existing behaviour). + index = {d.metadata["qualname"]: d for d in all_docs if not d.metadata["parent_class"]} + + result: List[Document] = [] + for name in ALWAYS_INCLUDE: + doc = index.get(name) + if doc is None: + logger.info( + f" ⚠️ ALWAYS_INCLUDE '{name}' not found in sources — " + f"check PYXISCIENCE_SOURCE_FILES and that the symbol is " + f"defined at module top-level." + ) + continue + + kind = doc.metadata.get("kind", "?") + if kind == "class" and not doc.metadata.get("methods"): + logger.info( + f" ⚠️ ALWAYS_INCLUDE class '{name}' has no methods in its " + f"metadata — likely a parser issue. Catalogue will show the " + f"class header only." + ) + + logger.info(f" ✓ ALWAYS_INCLUDE '{name}' → {kind} " + f"({doc.metadata['module_name']})") + result.append(doc) + + return result + + +# ============================================================================= +# 4. EMBEDDINGS + VECTORSTORE +# ============================================================================= + +_vs_cache: Dict[str, FAISS] = {} +_emb_cache: Dict[str, object] = {} +_always_docs: List[Document] = [] + + +def _resolve_openai_key() -> str: + if not _OPENAI_AVAILABLE: + raise RuntimeError("pip install langchain-openai") + key = OPENAI_API_KEY + if not key or len(key) < 20: + raise RuntimeError("OpenAI embedding models require OPENAI_API_KEY.") + return key + + +def _get_embeddings(model_key: str = DEFAULT_MODEL_KEY) -> object: + if model_key in _emb_cache: + return _emb_cache[model_key] + cfg = EMBEDDING_MODELS.get(model_key) + if cfg is None: + raise ValueError( + f"Unknown model key '{model_key}'. " + f"Available: {list(EMBEDDING_MODELS.keys())}" + ) + backend = cfg.get("backend", "huggingface") + logger.info(f" 🔧 Loading embedding model [{backend}]: {cfg['model_name']} ...") + if backend == "openai": + emb = OpenAIEmbeddings( + model=cfg["model_name"], + openai_api_key=_resolve_openai_key(), + ) + else: + HuggingFaceEmbeddings = _load_hf_embeddings_cls() + emb = HuggingFaceEmbeddings( + model_name=cfg["model_name"], + model_kwargs={"device": "cpu"}, + encode_kwargs={"normalize_embeddings": cfg["normalize"]}, + ) + _emb_cache[model_key] = emb + return emb + + +def _cache_path_for(model_key: str) -> Path: + return SOURCES_CACHE_ROOT / model_key + + +def _is_cache_stale(vs: FAISS) -> Tuple[bool, str]: + """Detect old caches built with a prior metadata schema.""" + try: + if not vs.index_to_docstore_id: + return True, "empty index" + first_id = vs.index_to_docstore_id[0] + first_doc = vs.docstore.search(first_id) + if not isinstance(first_doc, Document): + return True, f"docstore returned {type(first_doc)}" + meta = first_doc.metadata or {} + version = meta.get("schema_version", 0) + if version < CACHE_SCHEMA_VERSION: + return True, f"schema v{version} < current v{CACHE_SCHEMA_VERSION}" + # Sanity: v3 requires 'qualname' and 'parent_class' in every doc. + if "qualname" not in meta or "parent_class" not in meta: + return True, "missing qualname/parent_class metadata" + return False, "" + except Exception as e: + return True, f"inspection failed: {e}" + + +def setup_vectorstore( + scripts_dir: str | Path = SCRIPTS_DIR, + model_key: str = DEFAULT_MODEL_KEY, + use_cache: bool = True, + force_rebuild: bool = False, +) -> FAISS: + global _always_docs + + if model_key in _vs_cache and not force_rebuild: + if not _always_docs: + _always_docs = build_always_include_context(load_all_sources(scripts_dir)) + return _vs_cache[model_key] + + cache_path = _cache_path_for(model_key) + emb = _get_embeddings(model_key) + + # Try cache. + if use_cache and not force_rebuild and cache_path.exists(): + try: + logger.info(f"📦 Loading sources vectorstore from cache ({model_key})...") + vs = FAISS.load_local( + str(cache_path), emb, allow_dangerous_deserialization=True + ) + stale, reason = _is_cache_stale(vs) + if stale: + logger.info(f"♻️ Cache stale ({reason}) — rebuilding") + else: + logger.info(f"✅ Loaded {vs.index.ntotal} vectors (schema v{CACHE_SCHEMA_VERSION})") + _vs_cache[model_key] = vs + _always_docs = build_always_include_context(load_all_sources(scripts_dir)) + return vs + except Exception as e: + logger.info(f"⚠️ Cache load failed, rebuilding: {e}") + + # Rebuild. + logger.info(f"🔨 Building sources vectorstore with '{model_key}'...") + all_docs = load_all_sources(scripts_dir) + _always_docs = build_always_include_context(all_docs) + + if not all_docs: + raise RuntimeError(f"No documents found in {scripts_dir}.") + + # E5-style models want a 'passage: ' prefix on indexed docs, + # 'query: ' on the query. + cfg = EMBEDDING_MODELS[model_key] + if cfg.get("prefix"): + passage_prefix = cfg["prefix"].replace("query: ", "passage: ") + for d in all_docs: + d.page_content = passage_prefix + d.page_content + + logger.info(f"🧮 Embedding {len(all_docs)} entities with '{model_key}'...") + vs = FAISS.from_documents(all_docs, emb) + _vs_cache[model_key] = vs + + if use_cache: + cache_path.mkdir(parents=True, exist_ok=True) + vs.save_local(str(cache_path)) + logger.info(f"💾 Cache saved → {cache_path}") + + return vs + + +# ============================================================================= +# 5. DEDUPLICATION + ENTITY CONVERSION +# ============================================================================= + +def _doc_to_entity(doc: Document) -> Dict[str, Any]: + """Document → entity dict understood by rag_formatter.build_catalogue.""" + m = doc.metadata + return { + "qualname": m["qualname"], + "signature": m.get("signature", "(...)"), + "import_line": m["import_line"], + "kind": m["kind"], + "docstring": m.get("docstring", ""), + "methods": list(m.get("methods", [])) if m["kind"] == "class" else [], + # Extra fields preserved for debugging/telemetry: + "module_name": m["module_name"], + "parent_class": m.get("parent_class", ""), + } + + +def _merge_docs(retrieved_docs: List[Document], + always_docs: List[Document]) -> List[Document]: + """ + Merge ALWAYS_INCLUDE + retrieved, de-duplicated by qualname, + preserving ALWAYS order first. + """ + seen: set = set() + merged: List[Document] = [] + for doc in always_docs + retrieved_docs: + q = doc.metadata["qualname"] + if q in seen: + continue + seen.add(q) + merged.append(doc) + return merged + + +def _absorb_methods_into_classes(docs: List[Document]) -> List[Document]: + """ + If a class Document AND some of its methods are both in `docs`, keep + the class and drop the loose methods (the class block already lists + them). Standalone methods whose class is NOT retrieved stay as-is. + """ + retrieved_classes = { + d.metadata["qualname"] for d in docs if d.metadata["kind"] == "class" + } + if not retrieved_classes: + return docs + return [ + d for d in docs + if not (d.metadata["kind"] == "method" + and d.metadata.get("parent_class") in retrieved_classes) + ] + + +def _flatten_metadata(docs: List[Document]) -> List[Dict[str, Any]]: + return [ + { + "qualname": d.metadata["qualname"], + "import_line": d.metadata["import_line"], + "signature": d.metadata.get("signature", ""), + "docstring": (d.metadata.get("docstring") or "")[:300], + "kind": d.metadata["kind"], + "module_name": d.metadata["module_name"], + "parent_class": d.metadata.get("parent_class", ""), + } + for d in docs + ] + + +# ============================================================================= +# 6. PUBLIC API — retrieval-only (NO LLM) +# ============================================================================= + +def retrieve_functions_context( + exercise: str, + embedding_model: str = DEFAULT_MODEL_KEY, + top_k: int = 12, + score_threshold: float = DEFAULT_SCORE_THRESHOLD, + force_rebuild: bool = False, + verbose: bool = True, +) -> Dict[str, Any]: + """ + Retrieve relevant PyxiScience entities and build a prompt-ready catalogue. + + NO LLM CALL. Drop `result["catalogue"]` into your downstream prompt's + `{functions}` slot. + + Returns: + { + "catalogue": str, # drop-in for {functions} + "imports_block": str, # importable header + "entities": List[dict], # formatted entity dicts (ordered) + "retrieved": List[dict], # flat meta for retrieved hits + "always_included": List[dict], # flat meta for baseline + "embedding_model": str, + "top_k": int, + "kept": int, # survived score_threshold + } + """ + if embedding_model not in EMBEDDING_MODELS: + raise ValueError( + f"Unknown embedding_model '{embedding_model}'. " + f"Available: {list(EMBEDDING_MODELS.keys())}" + ) + + t0 = time.time() + vs = setup_vectorstore(model_key=embedding_model, force_rebuild=force_rebuild) + + cfg = EMBEDDING_MODELS[embedding_model] + query_text = (cfg.get("prefix") or "") + exercise + + hits = vs.similarity_search_with_score(query_text, k=top_k) + retrieved_docs = [doc for doc, score in hits if score < score_threshold] + + if verbose: + logger.info("\n" + "═" * 70) + logger.info(f"🔍 RETRIEVAL [{embedding_model}, top-{top_k}, thr<{score_threshold}]") + logger.info("═" * 70) + for doc, score in hits: + if score < 1.2: label = "✅" + elif score < score_threshold: label = "⚠️ " + else: label = "❌" + qn = doc.metadata.get("qualname", "?") + logger.info(f" {label} score={score:.3f} → {qn}") + logger.info(f"\n → kept {len(retrieved_docs)} / {len(hits)}") + logger.info("═" * 70) + + # Merge with ALWAYS_INCLUDE → absorb methods into retrieved classes. + merged = _merge_docs(retrieved_docs, _always_docs) + merged = _absorb_methods_into_classes(merged) + + entities = [_doc_to_entity(d) for d in merged] + catalogue = build_catalogue(entities) + imports = build_imports_block(entities) + + out = { + "catalogue": catalogue, + "imports_block": imports, + "entities": entities, + "retrieved": _flatten_metadata(retrieved_docs), + "always_included": _flatten_metadata(_always_docs), + "embedding_model": embedding_model, + "top_k": top_k, + "kept": len(retrieved_docs), + } + + if verbose: + logger.info(f"⏱️ retrieve_functions_context in {time.time() - t0:.2f}s") + + return out + + +def retrieve_raw( + exercise: str, + embedding_model: str = DEFAULT_MODEL_KEY, + top_k: int = 12, + force_rebuild: bool = False, +) -> List[Dict[str, Any]]: + """Lightweight retrieval — just the ranked list of hits with scores.""" + if embedding_model not in EMBEDDING_MODELS: + raise ValueError( + f"Unknown embedding_model '{embedding_model}'. " + f"Available: {list(EMBEDDING_MODELS.keys())}" + ) + vs = setup_vectorstore(model_key=embedding_model, force_rebuild=force_rebuild) + cfg = EMBEDDING_MODELS[embedding_model] + query_text = (cfg.get("prefix") or "") + exercise + hits = vs.similarity_search_with_score(query_text, k=top_k) + return [ + { + "rank": i + 1, + "score": float(score), + "qualname": d.metadata["qualname"], + "signature": d.metadata.get("signature", ""), + "import_line": d.metadata["import_line"], + "module_name": d.metadata["module_name"], + "kind": d.metadata["kind"], + "parent_class": d.metadata.get("parent_class", ""), + "docstring": (d.metadata.get("docstring") or "")[:200], + } + for i, (d, score) in enumerate(hits) + ] + + +# ============================================================================= +# 7. LLM WRAPPER — retrieve_functions (standalone Q&A) +# ============================================================================= + +_llm: Optional[ChatOpenAI] = None + + +def get_llm(model_key: str = "gpt-4o-mini") -> ChatOpenAI: + global _llm + if _llm is None: + _llm = ChatOpenAI( + model=OPENROUTER_MODELS.get(model_key, model_key), + openai_api_key=OPENROUTER_API_KEY, + openai_api_base="https://openrouter.ai/api/v1", + temperature=0, + timeout=60, + max_retries=2, + default_headers={ + "HTTP-Referer": "http://localhost:8501", + "X-Title": "PyxiScience RAG", + }, + ) + return _llm + + +def retrieve_functions( + exercise: str, + model: str = "gpt-4o-mini", + embedding_model: str = DEFAULT_MODEL_KEY, + top_k: int = 12, + force_rebuild: bool = False, + verbose: bool = True, +) -> Dict[str, Any]: + """ + Standalone Q&A: retrieve + ask an LLM which functions to use and how. + + If you already have your own downstream code-generation prompt, use + `retrieve_functions_context` instead and skip this LLM hop. + """ + global _llm + + t0 = time.time() + logger.info(f"🤖 LLM: {model}") + logger.info(f"🧠 Embeddings: {embedding_model}") + _llm = get_llm(model) + + ctx = retrieve_functions_context( + exercise=exercise, + embedding_model=embedding_model, + top_k=top_k, + force_rebuild=force_rebuild, + verbose=verbose, + ) + + prompt = f"""Tu es un expert Python / mathématiques pour la bibliothèque **PyxiScience**. + +Ta mission : résoudre l'exercice ci-dessous **en utilisant le catalogue de +fonctions PyxiScience fourni**. Tu n'as pas le droit de réimplémenter ce qui +existe déjà dans le catalogue. + +═══════════════════════════════════════════════════════════════════════════ + RÈGLES IMPÉRATIVES +═══════════════════════════════════════════════════════════════════════════ + +1. **USAGE OBLIGATOIRE** — Dès qu'une fonction du catalogue couvre un + besoin, tu DOIS l'utiliser. +2. **INTERDICTION DE RÉIMPLÉMENTER** — Si tu écris une fonction qui existe + déjà dans le catalogue, STOP, importe celle du catalogue. +3. **IMPORTS EXACTS** — Recopie les lignes d'import EXACTES du catalogue. + Pour une méthode, tu importes la CLASSE et tu l'appelles via une instance. +4. **SIGNATURES EXACTES** — Appelle chaque fonction avec la signature + EXACTE donnée. +5. **pxs_config() EN PREMIER** — `config = pxs_config()` en tête du script, + puis `**config` aux `latex(...)` et aux `pxsl_*` qui l'acceptent. +6. **BIBLIOTHÈQUES EXTERNES EN DERNIER RECOURS** — sympy / numpy / + matplotlib uniquement si rien dans le catalogue ne convient. + +═══════════════════════════════════════════════════════════════════════════ + CATALOGUE +═══════════════════════════════════════════════════════════════════════════ +{ctx['catalogue']} + +═══════════════════════════════════════════════════════════════════════════ + EXERCICE +═══════════════════════════════════════════════════════════════════════════ +{exercise} + +═══════════════════════════════════════════════════════════════════════════ + FORMAT DE RÉPONSE +═══════════════════════════════════════════════════════════════════════════ + +### 1. Plan d'attaque +étape → `nom_fonction(signature)` — justification. +Étape sans fonction adéquate → `(aucune — fallback sympy/numpy)`. + +### 2. Imports +```python +{ctx['imports_block']} +``` + +### 3. Code complet +```python +config = pxs_config() +# ... code appelant EFFECTIVEMENT les fonctions du catalogue ... +``` + +### 4. Vérification +Pour chaque fonction PyxiScience utilisée : +- [ ] importée (classe pour une méthode) ? +- [ ] appelée ? +- [ ] signature respectée ? + +Génère maintenant ta réponse :""" + + response = _llm.invoke(prompt) + answer = response.content + + logger.info(f"⏱️ Total (retrieve + LLM): {time.time() - t0:.1f}s") + + if verbose: + logger.info("\n" + "═" * 70) + logger.info("📝 RÉPONSE FINALE:") + logger.info("═" * 70) + logger.info(answer) + logger.info("═" * 70) + + return {**ctx, "answer": answer, "llm_model": model} + + +# ============================================================================= +# 8. UTILITIES (cache + benchmark) +# ============================================================================= + +def list_embedding_models() -> None: + logger.info(f"\n{'═'*92}") + logger.info(" Available Embedding Models") + logger.info(f"{'═'*92}") + logger.info(f" {'Key':<28} {'Backend':<14} {'Dims':<6} {'Cost/1k':<12} {'Model'}") + logger.info(f" {'─'*28} {'─'*14} {'─'*6} {'─'*12} {'─'*36}") + for k, v in EMBEDDING_MODELS.items(): + star = " ★" if k == DEFAULT_MODEL_KEY else "" + backend = v.get("backend", "huggingface") + dims = str(v.get("dimensions", "—")) + cost = f"${v['cost_per_1k']:.5f}" if v.get("cost_per_1k") else "free" + icon = "💰" if backend == "openai" else "🤗" + logger.info(f" {k:<28} {icon} {backend:<12} {dims:<6} {cost:<12} {v['model_name']}{star}") + logger.info(f"\n ★ = default 💰 = OPENAI_API_KEY required 🤗 = local/free\n") + + +def list_cached_models() -> List[str]: + if not SOURCES_CACHE_ROOT.exists(): + return [] + return [p.name for p in SOURCES_CACHE_ROOT.iterdir() if p.is_dir()] + + +def clear_cache(embedding_model: Optional[str] = None) -> None: + import shutil + if embedding_model: + p = _cache_path_for(embedding_model) + if p.exists(): + shutil.rmtree(p) + logger.info(f"🗑️ Deleted cache for '{embedding_model}'") + _vs_cache.pop(embedding_model, None) + _emb_cache.pop(embedding_model, None) + else: + if SOURCES_CACHE_ROOT.exists(): + shutil.rmtree(SOURCES_CACHE_ROOT) + logger.info("🗑️ Deleted all caches") + _vs_cache.clear() + _emb_cache.clear() + + +def benchmark_embedding_models( + test_queries: List[str], + top_k: int = 5, + model_keys: Optional[List[str]] = None, + force_rebuild: bool = False, +) -> Dict[str, Dict[str, List[Dict]]]: + keys = model_keys or list(EMBEDDING_MODELS.keys()) + results: Dict[str, Dict[str, List[Dict]]] = {} + logger.info(f"\n{'═'*72}") + logger.info(f" 🔬 BENCHMARK — {len(keys)} models × {len(test_queries)} queries") + logger.info(f"{'═'*72}\n") + for mk in keys: + logger.info(f" ▶ {mk} ({EMBEDDING_MODELS[mk]['description']})") + results[mk] = {} + for q in test_queries: + t0 = time.time() + try: + hits = retrieve_raw(q, embedding_model=mk, top_k=top_k, + force_rebuild=force_rebuild) + latency = time.time() - t0 + results[mk][q] = hits + top1 = hits[0]["qualname"] if hits else "—" + logger.info(f" ✅ [{latency:.2f}s] '{q[:50]}...' → top1={top1}") + except Exception as e: + logger.info(f" ❌ '{q[:50]}...' → ERROR: {e}") + results[mk][q] = [] + force_rebuild = False # only rebuild once, on first model + logger.info() + return results + + +# ============================================================================= +# 9. MAIN — smoke test +# ============================================================================= + +if __name__ == "__main__": + exercise_text = r""" +`````{exercise} +:id: f1e09b57-8f62-4926-a484-1893f724a627 +:title: Exercice 1 Asie J1 5 septembre juin 2025 Fonction exponentielle et équation différentielle +:modules: annales_bac, Analysis +:recommendedExecutionTime: 25 +:level: Intermediate +:chap: Fonctions exponentielles et équations différentielles +:involvedConcepts: TYPE_BAC, Dérivation, équation différentielle, convexité, théorème des valeurs intermédiaires, intégration par parties +:originalSource: Baccalauréat - Exercice sur les fonctions exponentielles +:visibility: All +:variations: +:comment: Exercice d'analyse avec affirmations vrai/faux sur une fonction exponentielle + +{fr}`Soit $f$ la fonction définie sur $\mathbb{R}$ par $f(x) = x\,\mathrm{e}^{-2x}$. + +On admet que $f$ est deux fois dérivable sur $\mathbb{R}$.` + +$\textit{Pour chacune des affirmations suivantes, préciser si elle est vraie ou fausse, puis justifier +la réponse donnée. Toute réponse non argumentée ne sera pas prise en compte.}$ + +(contenu tronqué dans le smoke test — l'exercice complet est transmis en contexte) +````` +""" + + list_embedding_models() + + logger.info("\n>>> retrieve_functions_context (no LLM)") + ctx = retrieve_functions_context( + exercise=exercise_text, + embedding_model="openai-3-large", + top_k=12, + force_rebuild=False, + ) + logger.info("\n── CATALOGUE (injectable as {functions}) ──\n") + logger.info(ctx["catalogue"] + "\n...") + logger.info(f"\n── imports_block ──\n{ctx['imports_block']}") + logger.info(f"\n── {len(ctx['entities'])} entities " + f"({len(ctx['retrieved'])} retrieved + {len(ctx['always_included'])} always) ──") + + # Sanity checks on the new ALWAYS_INCLUDE entries + entity_names = [e["qualname"] for e in ctx["entities"]] + logger.info("\n── SANITY ──") + logger.info(f" pxs_Interval present: {'pxs_Interval' in entity_names}") + logger.info(f" pxsl_pow present: {'pxsl_pow' in entity_names}") + for e in ctx["entities"]: + if e["qualname"] == "pxs_Interval": + method_names = [m["name"] for m in e.get("methods", [])] + logger.info(f" pxs_Interval methods: {method_names}") + break \ No newline at end of file diff --git a/app/rag/notions.py b/app/rag/notions.py new file mode 100644 index 0000000000000000000000000000000000000000..5d07760889ff88e90b9b0e636d81340a964390b3 --- /dev/null +++ b/app/rag/notions.py @@ -0,0 +1,526 @@ +""" +PyxiScience — LLM Notion Retriever (OpenRouter backend) +========================================================= +Selects relevant notions from the PyxiScience taxonomy (notions.xlsx) for a +given exercise by asking an LLM judge — replacing the FAISS/cosine retriever. + +All LLM calls go through OpenRouter (https://openrouter.ai), which exposes an +OpenAI-compatible API. Any OpenRouter-hosted model is usable by setting +`DEFAULT_LLM_MODEL` or the `model=` kwarg, e.g.: + "openai/gpt-4o" + "openai/gpt-4o-mini" + "anthropic/claude-sonnet-4.5" + "anthropic/claude-opus-4.1" + "google/gemini-2.5-pro" + +Why LLM over cosine? + • Short bilingual strings ("Mean Value Theorem" / "Théorème des + accroissements finis") don't give embedding models enough lexical + signal to rank precisely. + • The LLM can reason about *which notion is actually exercised* rather + than which string is most surface-similar. + • Bonus: it can suggest GENERAL notions that the exercise covers but + the catalogue is missing — a prerequisite for evolving the taxonomy. + +Public API (drop-in compatible with the previous retriever): + retrieve_notions(text, ...) -> List[Dict[str, str]] + retrieve_notions_for_exercise(myst, ...) -> List[Dict[str, str]] + enrich_exercise_with_notions(myst, ...) -> (block, csv_ids) + +Extra: + retrieve_notions_llm(text, ...) -> rich dict with suggestions + +Author: PyxiScience Team +Version: 1.0 +""" + +from __future__ import annotations + +import json +import logging +import time +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import pandas as pd +from openai import OpenAI + +from app.keys import OPENROUTER_API_KEY + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# 1. CONFIGURATION +# ============================================================================= + +from app.config import NOTIONS_XLSX as DEFAULT_NOTIONS_XLSX, NOTIONS_MODEL + +COL_FR = "FR_Name" +COL_EN = "EN_Name" +COL_ID = "Name_ID" + +# Default LLM. Any model hosted on OpenRouter works — use `provider/model` form. +# See https://openrouter.ai/models for the full list. +DEFAULT_LLM_MODEL = NOTIONS_MODEL +DEFAULT_TOP_K = 5 +DEFAULT_MAX_SUGGESTIONS = 2 +DEFAULT_TEMPERATURE = 0.0 + +OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" + +# Optional OpenRouter attribution headers (shown on their leaderboards + dashboard). +# Safe to leave as-is; override in your own deployment if you want. +OPENROUTER_HEADERS = { + "HTTP-Referer": "https://pyxiscience.com", + "X-Title": "PyxiScience Notion Retriever", +} + + +# ============================================================================= +# 2. CATALOGUE LOADING (cached in memory per xlsx path) +# ============================================================================= + +_catalogue_cache: Dict[Path, Tuple[str, Dict[str, Dict[str, str]]]] = {} + + +def load_notions_catalogue( + xlsx_path: str | Path = DEFAULT_NOTIONS_XLSX, +) -> Tuple[str, Dict[str, Dict[str, str]]]: + """ + Read the notions Excel file. + + Returns + ------- + catalogue_text : str + One line per notion, formatted as `ID | FR | EN`. This is the + exact text shipped to the LLM. + id_map : dict + {name_id: {"fr_name": ..., "en_name": ...}} — used to validate + returned IDs and enrich the LLM response. + """ + xlsx_path = Path(xlsx_path) + if xlsx_path in _catalogue_cache: + return _catalogue_cache[xlsx_path] + + df = pd.read_excel(xlsx_path, dtype=str).fillna("") + + missing = [c for c in (COL_FR, COL_EN, COL_ID) if c not in df.columns] + if missing: + raise ValueError( + f"Missing columns in '{xlsx_path}': {missing}\n" + f"Found: {list(df.columns)}" + ) + + lines: List[str] = [] + id_map: Dict[str, Dict[str, str]] = {} + + for _, row in df.iterrows(): + nid = row[COL_ID].strip() + fr = row[COL_FR].strip() + en = row[COL_EN].strip() + if not nid: + continue + lines.append(f"{nid} | {fr} | {en}") + id_map[nid] = {"fr_name": fr, "en_name": en} + + catalogue_text = "\n".join(lines) + _catalogue_cache[xlsx_path] = (catalogue_text, id_map) + logger.info(f" 📋 Loaded {len(id_map)} notions from '{xlsx_path.name}'") + return catalogue_text, id_map + + +# ============================================================================= +# 3. PROMPT +# ============================================================================= + +SYSTEM_PROMPT = """You are a mathematical content classifier for PyxiScience, a bilingual (FR/EN) math education platform. + +TASK +Given a math exercise and a CATALOGUE of notions (format per line: `ID | FR name | EN name`), +identify which notions are actually *exercised* by the problem — not merely mentioned. + +RULES +1. Return ONLY a valid JSON object matching the schema below. No markdown fences, no commentary. +2. `selected_notion_ids` must contain IDs that EXACTLY appear in the catalogue (copy them verbatim). + Do NOT invent, paraphrase, or alter IDs. If a concept is missing from the catalogue, put it in + `suggested_new_notions` — never in `selected_notion_ids`. +3. Rank `selected_notion_ids` from most to least relevant. Keep only notions whose content is + genuinely needed to solve the exercise. Quality > quantity — it is fine to return fewer than + the requested top_k if the exercise only exercises one or two notions. +4. `relevance` ∈ [0, 1]. Reserve ≥ 0.8 for central notions, 0.4–0.7 for supporting ones. +5. `rationale` must be a short, concrete sentence (FR or EN) explaining the link. +6. `suggested_new_notions` are GENERAL mathematical concepts that: + (a) are clearly exercised by the problem, + (b) are NOT already in the catalogue — check both IDs AND FR/EN names, + (c) are broad enough to be reused across many exercises (avoid hyper-specific tricks, + avoid renaming existing notions). + Be conservative: 0–2 suggestions is ideal, never more than 3. If nothing is missing, return []. +7. `proposed_id` follows PyxiScience convention: `Capitalised_Words_In_Snake_Case` + (e.g. `Polar_Equation_Conversion`, `Partial_Fraction_Decomposition`). + +OUTPUT JSON SCHEMA +{ + "selected_notion_ids": [ + { + "name_id": "<exact ID from catalogue>", + "relevance": <float in [0,1]>, + "rationale": "<one short sentence>" + } + ], + "suggested_new_notions": [ + { + "proposed_id": "<Snake_Case_Id>", + "fr_name": "<FR label>", + "en_name": "<EN label>", + "rationale": "<why it's missing AND why it's relevant>" + } + ] +} +""" + + +def _build_user_prompt( + exercise_text: str, + catalogue_text: str, + top_k: int, + max_suggestions: int, +) -> str: + n_notions = len(catalogue_text.splitlines()) + return ( + f"CATALOGUE ({n_notions} notions)\n" + f"═══════════════════════════════════════════════════════════════\n" + f"{catalogue_text}\n" + f"═══════════════════════════════════════════════════════════════\n\n" + f"EXERCISE\n" + f"═══════════════════════════════════════════════════════════════\n" + f"{exercise_text}\n" + f"═══════════════════════════════════════════════════════════════\n\n" + f"Return up to {top_k} selected notions (ranked best→worst) and up to " + f"{max_suggestions} suggested new notions.\n" + f"Reminder: every `name_id` in `selected_notion_ids` MUST appear verbatim " + f"in the catalogue above." + ) + + +# ============================================================================= +# 4. LLM CLIENT +# ============================================================================= + +_client: Optional[OpenAI] = None + + +def _get_client() -> OpenAI: + """ + Return a cached OpenAI-compatible client pointed at OpenRouter. + """ + global _client + if _client is None: + if not OPENROUTER_API_KEY or len(OPENROUTER_API_KEY) < 20: + raise RuntimeError( + "OPENROUTER_API_KEY is missing or invalid. Set it in core.ld." + ) + _client = OpenAI( + api_key=OPENROUTER_API_KEY, + base_url=OPENROUTER_BASE_URL, + default_headers=OPENROUTER_HEADERS, + ) + return _client + + +def _call_llm( + system: str, + user: str, + model: str, + temperature: float = DEFAULT_TEMPERATURE, +) -> Dict: + """Single LLM call via OpenRouter, forcing JSON-object output.""" + client = _get_client() + response = client.chat.completions.create( + model=model, + temperature=temperature, + response_format={"type": "json_object"}, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + ) + raw = response.choices[0].message.content or "{}" + try: + return json.loads(raw) + except json.JSONDecodeError as e: + raise RuntimeError( + f"LLM returned non-JSON content despite json_object mode.\n" + f"Model: {model}\n" + f"Raw: {raw[:500]}\n" + f"Error: {e}" + ) + + +# ============================================================================= +# 5. PUBLIC API — rich version +# ============================================================================= + +def retrieve_notions_llm( + exercise_text: str, + xlsx_path: str | Path = DEFAULT_NOTIONS_XLSX, + top_k: int = DEFAULT_TOP_K, + max_suggestions: int = DEFAULT_MAX_SUGGESTIONS, + model: str = DEFAULT_LLM_MODEL, + temperature: float = DEFAULT_TEMPERATURE, + verbose: bool = True, +) -> Dict: + """ + Retrieve relevant notions for an exercise using an LLM judge. + + Returns + ------- + { + "selected": [ {rank, name_id, fr_name, en_name, relevance, rationale}, ... ], + "suggested": [ {proposed_id, fr_name, en_name, rationale}, ... ], + "elapsed_s": float, + "model": str, + } + """ + t0 = time.time() + + catalogue_text, id_map = load_notions_catalogue(xlsx_path) + user_prompt = _build_user_prompt( + exercise_text, catalogue_text, top_k, max_suggestions + ) + data = _call_llm(SYSTEM_PROMPT, user_prompt, model=model, temperature=temperature) + + # -------- Validate & enrich `selected` -------- + selected: List[Dict] = [] + for rank, item in enumerate(data.get("selected_notion_ids", [])[:top_k], start=1): + nid = (item.get("name_id") or "").strip() + if nid not in id_map: + if verbose: + logger.info(f" ⚠️ LLM returned unknown notion_id '{nid}' — dropping") + continue + meta = id_map[nid] + selected.append({ + "rank": rank, + "name_id": nid, + "fr_name": meta["fr_name"], + "en_name": meta["en_name"], + "relevance": float(item.get("relevance", 0.0) or 0.0), + "rationale": (item.get("rationale") or "").strip(), + }) + # Re-rank in case of drops so ranks stay contiguous + for i, s in enumerate(selected, start=1): + s["rank"] = i + + # -------- Validate & dedupe `suggested` -------- + existing_ids = set(id_map.keys()) + existing_names_fr = {v["fr_name"].lower() for v in id_map.values()} + existing_names_en = {v["en_name"].lower() for v in id_map.values()} + + suggested: List[Dict] = [] + for item in data.get("suggested_new_notions", [])[:max_suggestions]: + pid = (item.get("proposed_id") or "").strip() + fr = (item.get("fr_name") or "").strip() + en = (item.get("en_name") or "").strip() + if not (pid and fr and en): + continue + if pid in existing_ids: + if verbose: + logger.info(f" ⚠️ Suggested ID '{pid}' already exists — skipping") + continue + if fr.lower() in existing_names_fr or en.lower() in existing_names_en: + if verbose: + logger.info(f" ⚠️ Suggested name '{fr} / {en}' duplicates an existing notion — skipping") + continue + suggested.append({ + "proposed_id": pid, + "fr_name": fr, + "en_name": en, + "rationale": (item.get("rationale") or "").strip(), + }) + + result = { + "selected": selected, + "suggested": suggested, + "elapsed_s": round(time.time() - t0, 2), + "model": model, + } + + if verbose: + _print_result(result) + + return result + + +# ============================================================================= +# 6. DROP-IN REPLACEMENTS for the old API +# ============================================================================= + +def retrieve_notions( + text: str, + xlsx_path: str | Path = DEFAULT_NOTIONS_XLSX, + top_k: int = DEFAULT_TOP_K, + model: str = DEFAULT_LLM_MODEL, + verbose: bool = True, + **_ignored, # swallows old kwargs: model_key, use_cache, force_rebuild +) -> List[Dict[str, str]]: + """ + Old-shape output for backward compatibility with the FAISS retriever: + [{"rank", "name_id", "fr_name", "en_name", "score"}, ...] + where `score` is the LLM relevance (higher = more relevant, unlike + the cosine distance returned by the old retriever). + """ + result = retrieve_notions_llm( + exercise_text=text, + xlsx_path=xlsx_path, + top_k=top_k, + model=model, + verbose=verbose, + ) + return [ + { + "rank": str(s["rank"]), + "name_id": s["name_id"], + "fr_name": s["fr_name"], + "en_name": s["en_name"], + "score": f"{s['relevance']:.4f}", + } + for s in result["selected"] + ] + + +def retrieve_notions_for_exercise( + exercise_myst: str, + xlsx_path: str | Path = DEFAULT_NOTIONS_XLSX, + top_k: int = DEFAULT_TOP_K, + model: str = DEFAULT_LLM_MODEL, + **kwargs, +) -> List[Dict[str, str]]: + """Pass the raw MyST exercise block — the LLM handles markup natively.""" + return retrieve_notions( + exercise_myst, xlsx_path=xlsx_path, top_k=top_k, model=model, **kwargs + ) + + +def enrich_exercise_with_notions( + exercise_myst: str, + xlsx_path: str | Path = DEFAULT_NOTIONS_XLSX, + top_k: int = DEFAULT_TOP_K, + max_suggestions: int = DEFAULT_MAX_SUGGESTIONS, + model: str = DEFAULT_LLM_MODEL, +) -> Tuple[str, str]: + """ + Same signature/return as before: (formatted_block, comma_separated_ids). + The formatted block now also lists any SUGGESTED new notions, so you + can feed them back into your taxonomy pipeline. + """ + result = retrieve_notions_llm( + exercise_text=exercise_myst, + xlsx_path=xlsx_path, + top_k=top_k, + max_suggestions=max_suggestions, + model=model, + verbose=False, + ) + + lines = ["═" * 70] + lines.append( + f" NOTIONS RETRIEVED via LLM " + f"[{result['model']} · {result['elapsed_s']}s]" + ) + lines.append("═" * 70) + for s in result["selected"]: + lines.append( + f" [{s['rank']}] {s['name_id']:<32} relevance={s['relevance']:.2f}\n" + f" → {s['fr_name']} / {s['en_name']}\n" + f" ↳ {s['rationale']}" + ) + + if result["suggested"]: + lines.append("─" * 70) + lines.append(" ★ SUGGESTED NEW NOTIONS (not in catalogue)") + lines.append("─" * 70) + for g in result["suggested"]: + lines.append( + f" + {g['proposed_id']}\n" + f" → {g['fr_name']} / {g['en_name']}\n" + f" ↳ {g['rationale']}" + ) + + lines.append("═" * 70) + lines.append("Utilise ces notions comme :involvedConcepts: si pertinent.") + + ids_csv = ", ".join(s["name_id"] for s in result["selected"]) + return "\n".join(lines), ids_csv + + +# ============================================================================= +# 7. PRETTY PRINTER +# ============================================================================= + +def _print_result(result: Dict) -> None: + w = 72 + logger.info(f"\n{'═'*w}") + logger.info(f" 🏷️ LLM NOTION RETRIEVAL " + f"[model: {result['model']} · {result['elapsed_s']}s]") + logger.info("═" * w) + logger.info(f" {'Rank':<5} {'Rel.':<6} {'Name_ID':<32} FR / EN") + logger.info(f" {'─'*5} {'─'*6} {'─'*32} {'─'*25}") + for s in result["selected"]: + label = f"{s['fr_name']} / {s['en_name']}" + if len(label) > 25: + label = label[:22] + "..." + logger.info(f" {s['rank']:<5} {s['relevance']:<6.2f} {s['name_id']:<32} {label}") + if s["rationale"]: + logger.info(f" ↳ {s['rationale']}") + + if result["suggested"]: + logger.info(f"\n ★ SUGGESTED NEW NOTIONS") + for g in result["suggested"]: + logger.info(f" + {g['proposed_id']} — {g['fr_name']} / {g['en_name']}") + if g["rationale"]: + logger.info(f" ↳ {g['rationale']}") + logger.info(f"{'═'*w}\n") + + +# ============================================================================= +# 8. DEMO +# ============================================================================= + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="LLM-based notion retriever") + parser.add_argument("--xlsx", default=str(DEFAULT_NOTIONS_XLSX)) + parser.add_argument("--model", default=DEFAULT_LLM_MODEL) + parser.add_argument("--topk", type=int, default=DEFAULT_TOP_K) + parser.add_argument("--suggest", type=int, default=DEFAULT_MAX_SUGGESTIONS) + parser.add_argument("--query", default=None, help="Raw exercise text or MyST block") + args = parser.parse_args() + + demo_query = args.query or r""" +`````{exercise} +:id: 9dfb0461-1ece-4f6d-b24d-26564e3f12ba +:title: Polar Coordinates - exo 20 +:chap: Polar_Coordinates + +:::::{question} +:questionType: STQ +::::{questionStatement} +Find a polar equation for the curve represented by the given Cartesian equation +$3 y^2 = x$. +:::: +::::{detailedSolution} +Substitute $x = r \cos(\theta)$ and $y = r \sin(\theta)$, then solve for $r$. +Result: $r = \tfrac{1}{3} \cot(\theta)\csc(\theta)$. +:::: +::::: +````` +""" + + retrieve_notions_llm( + exercise_text=demo_query, + xlsx_path=Path(args.xlsx), + top_k=args.topk, + max_suggestions=args.suggest, + model=args.model, + ) \ No newline at end of file diff --git a/app/server.py b/app/server.py new file mode 100644 index 0000000000000000000000000000000000000000..bea06791a407b262a77b1a072d7ee60a84c1316c --- /dev/null +++ b/app/server.py @@ -0,0 +1,284 @@ +""" +server.py +───────── +Routes Flask — le minimum pour une app mono-fonctionnalité : + + GET / UI + GET /health sanity check + GET /api/models roster des modèles + défauts + POST /api/jobs lance un job (1 fichier OU batch) → {job_id} + GET /api/jobs/<id> suivi + résultats (par fichier) + +Le job store reste en mémoire (mono-poste, mono-process — assumé) ; un job +batch traite ses fichiers SÉQUENTIELLEMENT et continue si l'un échoue. +""" + +from __future__ import annotations + +import io +import logging +import os +import re +import secrets +import threading +import time +import uuid +import zipfile + +from flask import Response, jsonify, render_template, request, send_file + +from app.config import ( + AVAILABLE_MODELS, + DEFAULT_LANG, + DEFAULT_MODEL_IDX, + JOB_TTL, +) +from app.pipeline.orchestrator import run_exercise + +logger = logging.getLogger(__name__) + +_JOBS: dict = {} +_JOBS_LOCK = threading.Lock() + +VALID_LANGS = ("fr", "en", "both") +VALID_LEVELS = ("", "Intermediate", "Advanced") + + +def _set_job(job_id: str, **kwargs): + with _JOBS_LOCK: + if job_id in _JOBS: + _JOBS[job_id].update(kwargs) + + +def _get_job(job_id: str): + with _JOBS_LOCK: + job = _JOBS.get(job_id) + return dict(job) if job else None + + +def _safe_md_name(filename: str, used: set) -> str: + """Nom de fichier .md sûr et unique pour la sortie pythonisée + (suffixe _pythonise pour ne pas écraser la source).""" + base = re.sub(r"\.(md|txt)$", "", filename or "", flags=re.IGNORECASE) + base = re.sub(r"[^\w.\-() ]+", "_", base).strip() or "exercice" + name = f"{base}_pythonise.md" + i = 2 + while name in used: + name = f"{base}_pythonise_{i}.md" + i += 1 + used.add(name) + return name + + +def _purge_old_jobs(): + cutoff = time.time() - JOB_TTL + with _JOBS_LOCK: + for jid in [jid for jid, j in _JOBS.items() + if j.get("status") in ("done", "error") and (j.get("finished_at") or 0) < cutoff]: + del _JOBS[jid] + + +def _run_job(job_id: str, files: list[dict], level: str, model_idx: int, lang: str): + """Worker de job : boucle séquentielle sur les fichiers, robuste.""" + results: list[dict] = [] + for i, f in enumerate(files): + name = f.get("filename") or f"fichier_{i + 1}.md" + _set_job(job_id, current_file=name, files_done=i, + step_label=f"[{i + 1}/{len(files)}] {name} — démarrage…") + + def set_step(label: str, _i=i, _name=name): + _set_job(job_id, step_label=f"[{_i + 1}/{len(files)}] {_name} — {label}") + + try: + result = run_exercise( + content=f["content"], + filename=name, + level=level, + model_idx=model_idx, + lang=lang, + set_step=set_step, + ) + results.append({"filename": name, "status": "done", "result": result}) + logger.info("Fichier %s : harnais %s, %d warnings, %.1fs, %.4f$", + name, "VERT" if result["harness"]["ok"] else "ROUGE", + len(result["warnings"]), result["duration_s"], + result["cost"]["usd"]) + except Exception as exc: + logger.exception("Échec du pipeline sur %s", name) + results.append({"filename": name, "status": "error", "error": str(exc)}) + _set_job(job_id, results=list(results), files_done=i + 1) + + ok = sum(1 for r in results + if r["status"] == "done" and r["result"]["harness"]["ok"]) + warn = sum(1 for r in results + if r["status"] == "done" and not r["result"]["harness"]["ok"]) + err = sum(1 for r in results if r["status"] == "error") + total_cost = round(sum(r["result"]["cost"]["usd"] for r in results + if r["status"] == "done"), 4) + _set_job( + job_id, + status="done", + step_label="Terminé", + finished_at=time.time(), + summary={"total": len(files), "verts": ok, "rouges": warn, + "erreurs": err, "cost_usd": total_cost}, + ) + _purge_old_jobs() + + +def _auth_ok() -> bool: + """Basic Auth si APP_PASSWORD est défini ; ouvert sinon (dev local).""" + password = os.getenv("APP_PASSWORD", "") + if not password: + return True + auth = request.authorization + user = os.getenv("APP_USER", "pyxi") + return bool( + auth + and auth.username == user + and secrets.compare_digest(auth.password or "", password) + ) + + +def register_routes(app): + + @app.before_request + def _gate(): + # /health reste ouvert (sonde de l'hébergeur) ; tout le reste est protégé + # dès qu'un APP_PASSWORD est configuré (déploiement public). + if request.path == "/health" or _auth_ok(): + return None + return Response( + "Authentification requise.", + 401, + {"WWW-Authenticate": 'Basic realm="Pythonise Exercice"'}, + ) + + @app.route("/") + def index(): + return render_template("index.html") + + @app.route("/health") + def health(): + return jsonify({ + "status": "ok", + "openrouter_api_key": bool(os.getenv("OPENROUTER_API_KEY")), + "openai_api_key": bool(os.getenv("OPENAI_API_KEY")), + }) + + @app.route("/api/models", methods=["GET"]) + def models(): + return jsonify({ + "models": {str(k): v for k, v in AVAILABLE_MODELS.items()}, + "default_idx": DEFAULT_MODEL_IDX, + "default_lang": DEFAULT_LANG, + }) + + @app.route("/api/jobs", methods=["POST"]) + def start_job(): + """Body : {files: [{filename, content}, …], level?, model_idx?, lang?} + (rétro-compat : {content, filename} accepté pour un fichier unique).""" + data = request.get_json(force=True, silent=True) or {} + + files = data.get("files") + if not files and data.get("content"): + files = [{"filename": data.get("filename", "exercise.md"), + "content": data["content"]}] + if not isinstance(files, list) or not files: + return jsonify({"error": "Champ 'files' manquant ou vide."}), 400 + clean_files = [] + for f in files: + if not isinstance(f, dict) or not str(f.get("content", "")).strip(): + return jsonify({"error": "Chaque fichier doit avoir un 'content' non vide."}), 400 + clean_files.append({ + "filename": str(f.get("filename") or "exercise.md"), + "content": str(f["content"]).strip(), + }) + + level = (data.get("level") or "").strip() + if level not in VALID_LEVELS: + return jsonify({"error": f"'level' invalide : {level!r}."}), 400 + + lang = (data.get("lang") or DEFAULT_LANG).strip() + if lang not in VALID_LANGS: + return jsonify({"error": f"'lang' invalide : {lang!r} (fr|en|both)."}), 400 + + try: + model_idx = int(data.get("model_idx", DEFAULT_MODEL_IDX)) + except (TypeError, ValueError): + return jsonify({"error": "'model_idx' doit être un entier."}), 400 + if model_idx not in AVAILABLE_MODELS: + return jsonify({ + "error": f"'model_idx' inconnu : {model_idx}. Valides : {sorted(AVAILABLE_MODELS)}." + }), 400 + + job_id = uuid.uuid4().hex + with _JOBS_LOCK: + _JOBS[job_id] = { + "status": "running", + "step_label": "Démarrage…", + "current_file": clean_files[0]["filename"], + "files_total": len(clean_files), + "files_done": 0, + "results": [], + "summary": None, + "error": None, + "started_at": time.time(), + "finished_at": None, + } + + threading.Thread( + target=_run_job, + args=(job_id, clean_files, level, model_idx, lang), + daemon=True, + ).start() + return jsonify({"job_id": job_id}), 202 + + @app.route("/api/jobs/<job_id>", methods=["GET"]) + def job_status(job_id: str): + job = _get_job(job_id) + if not job: + return jsonify({"error": "Job inconnu."}), 404 + return jsonify({ + "status": job["status"], + "step_label": job["step_label"], + "current_file": job.get("current_file"), + "files_total": job["files_total"], + "files_done": job["files_done"], + "results": job["results"], + "summary": job.get("summary"), + "error": job.get("error"), + }) + + @app.route("/api/jobs/<job_id>/download", methods=["GET"]) + def job_download_zip(job_id: str): + """ZIP de toutes les sorties .md prêtes du job + un récapitulatif.""" + job = _get_job(job_id) + if not job: + return jsonify({"error": "Job inconnu (peut-être purgé)."}), 404 + done = [r for r in (job.get("results") or []) if r.get("status") == "done"] + if not done: + return jsonify({"error": "Aucun fichier prêt à télécharger."}), 404 + + buf = io.BytesIO() + used: set = set() + recap = ["# Récapitulatif de pythonisation", ""] + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for r in done: + name = _safe_md_name(r["filename"], used) + zf.writestr(name, r["result"]["exercise"]) + h = r["result"]["harness"] + recap.append( + f"- {name} — harnais {'VERT' if h['ok'] else 'ROUGE'} " + f"({h['seeds']} graines) · {len(r['result']['warnings'])} warnings " + f"· {r['result']['cost']['usd']} $" + ) + for r in (job.get("results") or []): + if r.get("status") == "error": + recap.append(f"- {r['filename']} — ERREUR : {r.get('error')}") + zf.writestr("_recapitulatif.md", "\n".join(recap) + "\n") + buf.seek(0) + return send_file( + buf, mimetype="application/zip", as_attachment=True, + download_name=f"pythonise_{job_id[:8]}.zip", + ) diff --git a/app/validation/__init__.py b/app/validation/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/validation/harness.py b/app/validation/harness.py new file mode 100644 index 0000000000000000000000000000000000000000..5d03dac69de4f074a828d9c83bc27e68ee40f082 --- /dev/null +++ b/app/validation/harness.py @@ -0,0 +1,293 @@ +""" +harness.py — porte de qualité déterministe du pipeline. + +Miroir importable du harnais de référence +(.claude/skills/validation-harness/harness.py) : mêmes contrôles, même +sévérité, mais API Python (validate_text) au lieu d'un CLI. Toute sortie du +pipeline DOIT passer ici avant d'être rendue ; un verdict rouge déclenche +une (1) boucle de réparation LLM puis, à défaut, un warning fort. + +Contrôles : + 1. STATIQUE — {{ }} = identifiant nu camelCase sans _ ; questionId/Index + uniques et contigus dès 0 ; bloc {python} présent. + 2. EXEC — tous les blocs {python} (concaténés) s'exécutent sur N + graines, 0 exception. Stubs pyxiscience légers. + 3. INJECTION — chaque {{ var }} se résout dans le namespace, 0 inconnue. + 4. INTERDITS — motifs interdits sur le corps rendu (skill §6). +""" + +from __future__ import annotations + +import io +import logging +import random +import re +import sys +import traceback +import types +import warnings as _warnings +from contextlib import redirect_stdout + +logger = logging.getLogger(__name__) + +try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as _plt + _HAS_MPL = True +except Exception: + _plt = None + _HAS_MPL = False + + +# ── Stubs pyxiscience (identiques au harnais de référence) ─────────────────── + +def install_pyxiscience_stubs() -> None: + if "pyxiscience" in sys.modules: + return + + class _Universal: + def __init__(self, *a, **k): pass + def __call__(self, *a, **k): return _Universal() + def __getattr__(self, _): return _Universal() + def __getitem__(self, _): return _Universal() + def __iter__(self): return iter(()) + def __str__(self): return "" + def __repr__(self): return "" + def __mul__(self, o): return self + __rmul__ = __add__ = __radd__ = __sub__ = __rsub__ = __mul__ + + def _passthrough(*a, **k): + return str(a[0]) if a else "" + + def _pxs_config(*a, **k): + # dict VIDE : utilisable comme **config_standard sans faire planter + # sympy.latex (qui rejette toute clé inconnue). + return {} + + def _make_module(name: str) -> types.ModuleType: + mod = types.ModuleType(name) + + def __getattr__(attr): # PEP 562 + if attr == "pxs_config": + return _pxs_config + # Classe si CamelCase OU motif pxs_Xxx (pxs_Interval, pxs_Plotable…) + if attr and (attr[0].isupper() or re.match(r"pxs_[A-Z]", attr)): + return _Universal + return _passthrough + + mod.__getattr__ = __getattr__ + return mod + + root = _make_module("pyxiscience") + sys.modules["pyxiscience"] = root + for sub in ( + "Mes_fctions_generalistes_bis", + "Mes_fctions_d_analyse_bis", + "Mes_fctions_d_alg_lineaire_bis", + "Mes_fctions_probabilistes_bis", + "Classes_Extensions", + ): + full = f"pyxiscience.{sub}" + sys.modules[full] = _make_module(full) + setattr(root, sub, sys.modules[full]) + + +# ── Extraction (fences 3 ou 4 backticks, lecture tolérante) ────────────────── + +PY_FENCE_ANY_RE = re.compile(r"(?ms)^(`{3,4})\{python\}[ \t]*\n(.*?)\n\1[ \t]*$") +INJECTION_RE = re.compile(r"\{\{(.*?)\}\}", re.DOTALL) +VALID_TOKEN_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*$") + + +def extract_python_code(text: str) -> str | None: + """Concatène TOUS les blocs {python} (namespace partagé, ordre document).""" + blocks = [m.group(2) for m in PY_FENCE_ANY_RE.finditer(text)] + return "\n\n".join(blocks) if blocks else None + + +def strip_python_blocks(text: str) -> str: + return PY_FENCE_ANY_RE.sub("", text) + + +# ── Contrôles statiques ────────────────────────────────────────────────────── + +def check_injection_tokens(text: str) -> list[str]: + errs: list[str] = [] + if "{{{" in text: + errs.append("triple-accolade `{{{` détectée (casse la substitution)") + for raw in INJECTION_RE.findall(strip_python_blocks(text)): + tok = raw.strip() + if not tok: + errs.append("injection vide `{{ }}`") + continue + if VALID_TOKEN_RE.match(tok): + continue + why = [] + if "_" in tok: + why.append("underscore") + if "(" in tok or ")" in tok: + why.append("appel de fonction") + if any(c in tok for c in "+*/\\,"): + why.append("calcul/opérateur") + if "." in tok: + why.append("attribut") + if " " in tok: + why.append("espace / plusieurs tokens") + if not why: + why.append("pas un identifiant nu") + errs.append(f"injection invalide `{{{{ {tok} }}}}` : {', '.join(why)}") + return errs + + +def check_question_ids(text: str) -> list[str]: + errs: list[str] = [] + for field in ("questionId", "questionIndex"): + ids = [int(x) for x in re.findall(rf":{field}:\s*(\d+)", text)] + if not ids: + continue + if len(ids) != len(set(ids)): + errs.append(f":{field}: contient des doublons : {ids}") + if sorted(ids) != list(range(len(ids))): + errs.append(f":{field}: non contigu à partir de 0 : {ids}") + return errs + + +# ── Motifs interdits (corps rendu — skill §6) ──────────────────────────────── + +FORBIDDEN = [ + (r"(?<!\\)\$[0-9]", r"`$` (non échappé) collé à un chiffre — préfixer par ${} ou passer en display"), + (r"\{\{\{", r"triple-accolade `{{{`"), + (r"\^\{\s*0\s*\}", r"`^{0}`"), + (r"\^\{\s*1\s*\}", r"`^{1}`"), + (r"\\sqrt\[1\]", r"`\sqrt[1]`"), + (r"\\sqrt\[2\]", r"`\sqrt[2]` (utiliser `\sqrt`)"), + (r"\\frac\{\s*\}\{", r"`\frac{}{…}` (numérateur vide)"), + (r"\\frac\{[^{}]*\}\{\s*1\s*\}", r"`\frac{…}{1}`"), + (r"\blatex\(", r"`latex(` ayant fui dans le rendu"), + (r"\bround\(", r"`round(` dans le rendu"), + (r"\bpxsl_matrix\b", r"`pxsl_matrix` dans le rendu"), + (r"-\s*\+", r"double signe `- +`"), + (r"\+\s*-", r"double signe `+ -`"), +] +FORBIDDEN = [(re.compile(p), msg) for p, msg in FORBIDDEN] + + +def scan_forbidden(body: str) -> list[str]: + errs = [] + for rx, msg in FORBIDDEN: + m = rx.search(body) + if m: + start = max(0, m.start() - 30) + ctx = body[start:m.end() + 30].replace("\n", " ") + errs.append(f"{msg} …{ctx}…") + return errs + + +# ── Rendu (substitution des injections) ────────────────────────────────────── + +def render_body(body_tmpl: str, env: dict) -> tuple[str, list[str]]: + unresolved: list[str] = [] + + def repl(m): + name = m.group(1).strip() + if name in env: + try: + return str(env[name]) + except Exception: + return f"<<ERR:{name}>>" + unresolved.append(name) + return f"<<{name}>>" + + return INJECTION_RE.sub(repl, body_tmpl), unresolved + + +# ── Validation principale ──────────────────────────────────────────────────── + +def validate_text(text: str, seeds: int = 100) -> dict: + """ + Valide un exercice pythonisé (texte MyST complet). + + Retourne : + { + "ok": bool, + "seeds": int, + "static_errors": [str], + "n_exec_errors": int, "n_unresolved": int, "n_forbidden": int, + "first_failures": [str], # ≤ 8, lisibles + } + """ + static_errors = check_injection_tokens(text) + check_question_ids(text) + code = extract_python_code(text) + if code is None: + static_errors.append("aucun bloc `{python}` trouvé") + + report = { + "ok": False, + "seeds": seeds, + "static_errors": static_errors, + "n_exec_errors": 0, + "n_unresolved": 0, + "n_forbidden": 0, + "first_failures": [], + } + if code is None: + return report + + body_tmpl = strip_python_blocks(text) + install_pyxiscience_stubs() + failures: list[str] = [] + + for s in range(seeds): + random.seed(s) + env: dict = {"rd": random, "random": random} + try: + with redirect_stdout(io.StringIO()), _warnings.catch_warnings(): + _warnings.simplefilter("ignore") # plt.show() sous Agg, etc. + exec(code, env) # noqa: S102 — sandbox stubs + contenu maison + except Exception: + report["n_exec_errors"] += 1 + if len(failures) < 5: + tb = traceback.format_exc().strip().splitlines() + failures.append(f"seed {s} : exception — {tb[-1]}") + if _HAS_MPL: + _plt.close("all") + continue + if _HAS_MPL: + _plt.close("all") + + rendered, unresolved = render_body(body_tmpl, env) + if unresolved: + report["n_unresolved"] += 1 + if len(failures) < 8: + failures.append(f"seed {s} : variable(s) non résolue(s) : {sorted(set(unresolved))}") + fb = scan_forbidden(rendered) + if fb: + report["n_forbidden"] += 1 + if len(failures) < 8: + failures.append(f"seed {s} : motif interdit : {fb[0]}") + + report["first_failures"] = failures + report["ok"] = ( + not static_errors + and report["n_exec_errors"] == 0 + and report["n_unresolved"] == 0 + and report["n_forbidden"] == 0 + ) + return report + + +def format_report(report: dict) -> str: + """Résumé texte court (pour warnings / prompt de réparation).""" + lines = [] + for e in report["static_errors"]: + lines.append(f"[STATIQUE] {e}") + n = report["seeds"] + if report["n_exec_errors"]: + lines.append(f"[EXEC] {report['n_exec_errors']}/{n} graines en exception") + if report["n_unresolved"]: + lines.append(f"[INJECTION] {report['n_unresolved']}/{n} graines avec variables non résolues") + if report["n_forbidden"]: + lines.append(f"[INTERDITS] {report['n_forbidden']}/{n} graines avec motif interdit") + lines.extend(f" • {f}" for f in report["first_failures"]) + return "\n".join(lines) if lines else "VERT" diff --git a/app/validation/sandbox.py b/app/validation/sandbox.py new file mode 100644 index 0000000000000000000000000000000000000000..c3103dd655975466a1d2616e2b75b0452b7a4873 --- /dev/null +++ b/app/validation/sandbox.py @@ -0,0 +1,587 @@ +""" +exec_validator.py +───────────────── +Sandboxed execution of the Python block produced by the pythonisation pipeline, +used to validate at runtime the rules that cannot be checked statically : + + • règle 4.3 — la propriété démontrée doit être vraie sur 100 % des seeds + (validation multi-seed avec assertions Python). + • règle 11.1 — toute variable de tirage utilisée dans l'énoncé doit être + utilisée dans le tracé matplotlib (analyse AST). + • règle 11.4 — pas de mélange Rational(sympy) * np.array (analyse AST). + • règle 11.3 — labels du graphique dans la fenêtre [xlim, ylim] (matplotlib + backend Agg + extraction des Text/Line objects). + +Toutes les exécutions sont : + • Isolées dans un namespace neuf (pas de leak entre seeds) + • Pré-seedées via `random.seed(N)` (déterministe par seed) + • Time-boxées (signal.alarm, défaut 5 s par exec) + • Avec PyxiScience mocké via `sys.modules` (le vrai package n'est pas + installé localement et n'est de toute façon pas nécessaire pour la + validation des tirages / contraintes math / position des labels). +""" + +from __future__ import annotations + +import ast +import builtins +import re +import signal +import sys +import threading +import types +from typing import Any, Optional + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. PyxiScience stubs (the real package isn't installed locally) +# ───────────────────────────────────────────────────────────────────────────── + +_STUBS_INSTALLED = False + + +def _passthrough(*args, **kwargs): + """Universal callable stub: returns first arg cast to str if any, else ''.""" + if not args: + return "" + # latex(...) and pxsl_* helpers all return strings; mirror that shape. + return str(args[0]) + + +def _config_stub(*args, **kwargs): + """ + Stub for `pxs_config()`. Must return a `dict` so that downstream code + like `latex(expr, **config_standard)` (where `config_standard = pxs_config()`) + doesn't crash with "argument of type 'str' is not iterable". + """ + return {} + + +class _StubObject: + """Universal class stub with arbitrary attribute access.""" + + def __init__(self, *args, **kwargs): + self._args = args + self._kwargs = kwargs + + def print(self): + return "" + + def __call__(self, *args, **kwargs): + return _StubObject(*args, **kwargs) + + def __getattr__(self, name): + # Any method access returns a no-op callable + return _passthrough + + def __repr__(self): + return f"_StubObject({self._args!r})" + + +def install_pyxiscience_stubs() -> None: + """ + Register stub modules for `pyxiscience.*` in sys.modules so that generated + Python blocks can `import` PyxiScience helpers without crashing. Idempotent. + """ + global _STUBS_INSTALLED + if _STUBS_INSTALLED: + return + + if "pyxiscience" in sys.modules: + _STUBS_INSTALLED = True + return + + pyxiscience = types.ModuleType("pyxiscience") + sys.modules["pyxiscience"] = pyxiscience + + submodules = [ + "Mes_fctions_generalistes_bis", + "Classes_Extensions", + "Mes_fctions_d_analyse_bis", + "Mes_fctions_d_analyse", # alias without _bis (cf. Exo 2 Am. Sud) + "Mes_fctions_d_alg_lineaire_bis", + "Mes_fctions_probabilistes_bis", + ] + helper_names = [ + "pxs_config", "pxsl_latex_coefficient", "pxsl_format_number", + "pxsl_res_num", "pxsl_matrix", "pxsl_pow", "pxsl_par", "pxsl_mult", + "pxs_explain_IBP", "pxs_nvirgzero", "_pxsl_choose_udv", + "pxs_variation_number", "myst", + "pxsl_latex_avec_formatage", # cf. Exo 2 Am Sud + "pxsl_choose_udv", + ] + class_names = ["pxs_Interval", "pxs_Plotable"] + + for sub in submodules: + m = types.ModuleType(f"pyxiscience.{sub}") + for name in helper_names: + setattr(m, name, _passthrough) + # Special-case: pxs_config must return a dict (used as **kwargs). + setattr(m, "pxs_config", _config_stub) + for name in class_names: + setattr(m, name, _StubObject) + sys.modules[f"pyxiscience.{sub}"] = m + setattr(pyxiscience, sub, m) + + # Top-level convenience attribute (some code does `import pyxiscience`) + pyxiscience.pxs_variation_number = 1 # règle 13.2 + + # Stub `src.scripts.pxs_runtime` for `myst()` helper used in conditional text. + # Observed in real exos like the binomiale exercise: + # from src.scripts.pxs_runtime import myst + # shot_name = myst(r"{fr}`...`{en}`...`") + src_mod = types.ModuleType("src") + scripts_mod = types.ModuleType("src.scripts") + runtime_mod = types.ModuleType("src.scripts.pxs_runtime") + runtime_mod.myst = _passthrough + sys.modules["src"] = src_mod + sys.modules["src.scripts"] = scripts_mod + sys.modules["src.scripts.pxs_runtime"] = runtime_mod + src_mod.scripts = scripts_mod + scripts_mod.pxs_runtime = runtime_mod + + _STUBS_INSTALLED = True + + +# ───────────────────────────────────────────────────────────────────────────── +# 2. Extract the main python block from an assembled exercise +# ───────────────────────────────────────────────────────────────────────────── + +# Fence {python} à 3 OU 4 backticks (l'app émet 4 — convention plateforme — +# mais la lecture reste tolérante pour les contenus legacy). +_PYTHON_FENCE_RE = re.compile(r"(?ms)^(`{3,4})\{python\}[ \t]*\n(.*?)\n\1[ \t]*$") + + +def extract_main_python_block(exercise: str) -> Optional[str]: + """ + Return the contents of the FIRST {python} block in the assembled + exercise (which by convention holds the imports + random sampling + + main computations — règle 3.1). Returns None if absent. + """ + m = _PYTHON_FENCE_RE.search(exercise) + return m.group(2) if m else None + + +def extract_all_python_blocks(exercise: str) -> list[str]: + """Return all {python} block bodies, in order.""" + return [m.group(2) for m in _PYTHON_FENCE_RE.finditer(exercise)] + + +# ───────────────────────────────────────────────────────────────────────────── +# 3. Sandboxed exec with timeout +# ───────────────────────────────────────────────────────────────────────────── + +class ExecTimeout(Exception): + """Raised when exec exceeds its time budget.""" + + +def _timeout_handler(signum, frame): + raise ExecTimeout("exec exceeded its time budget") + + +def _exec_with_timeout(code: str, namespace: dict, timeout: float) -> None: + """ + Run `exec(code, namespace)` under a timeout. + + Two strategies: + • Main thread → use `signal.SIGALRM` (cheap, interruptible). + • Background thread (Flask worker) → use a daemon thread + `Event.wait`. + The daemon thread can't actually be killed in Python; it survives the + timeout but doesn't block subsequent execs since each call spawns a + fresh daemon. Acceptable for short math-only workloads. + """ + compiled = compile(code, "<sandbox>", "exec") + + if threading.current_thread() is threading.main_thread(): + old_handler = signal.signal(signal.SIGALRM, _timeout_handler) + signal.setitimer(signal.ITIMER_REAL, timeout) + try: + exec(compiled, namespace) + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, old_handler) + return + + # Background-thread variant: run in a daemon child thread. + captured: dict[str, Optional[BaseException]] = {"exc": None} + done = threading.Event() + + def _target() -> None: + try: + exec(compiled, namespace) + except BaseException as e: # noqa: BLE001 — re-raised below + captured["exc"] = e + finally: + done.set() + + worker = threading.Thread(target=_target, daemon=True) + worker.start() + if not done.wait(timeout): + raise ExecTimeout(f"exec exceeded {timeout}s") + if captured["exc"] is not None: + raise captured["exc"] + + +def exec_python_block( + code: str, + seed: int = 0, + extra_globals: Optional[dict] = None, + timeout: float = 5.0, +) -> dict: + """ + Execute `code` once, pre-seeding `random` with `seed`. + Returns: + {"success": bool, "ns": dict | None, "error": str | None} + On success, `ns` contains the namespace after exec (variables available + for assertion evaluation). + + Stubs `pyxiscience.*` and disallows obvious filesystem / network builtins + by stripping them from the namespace. + """ + install_pyxiscience_stubs() + + namespace: dict[str, Any] = { + "__name__": "__sandbox__", + "__builtins__": _safe_builtins(), + } + if extra_globals: + namespace.update(extra_globals) + + # Pre-seed both random and numpy.random (cheap; ignored if numpy not used). + preamble = ( + f"import random as _rnd_internal; _rnd_internal.seed({seed})\n" + f"try:\n" + f" import numpy as _np_internal; _np_internal.random.seed({seed})\n" + f"except Exception:\n" + f" pass\n" + ) + full_code = preamble + code + + try: + _exec_with_timeout(full_code, namespace, timeout) + return {"success": True, "ns": namespace, "error": None} + except ExecTimeout as e: + return {"success": False, "ns": None, "error": f"timeout ({timeout}s)"} + except Exception as e: + return {"success": False, "ns": None, "error": f"{type(e).__name__}: {e}"} + + +def _safe_builtins() -> dict: + """ + Return a copy of builtins with dangerous filesystem/network names removed. + The sandboxed code is generated by an LLM operating on math content; we + don't want it to accidentally `open(...)` or `__import__('subprocess')`. + """ + blocked = { + "open", "input", "exit", "quit", "compile", "eval", "exec", + "__import__", # block dynamic imports — explicit imports in code still work via the import statement + } + safe: dict[str, Any] = {} + for name in dir(builtins): + if name in blocked: + continue + safe[name] = getattr(builtins, name) + # `__import__` we restore but wrap to whitelist + safe["__import__"] = _safe_import + return safe + + +_ALLOWED_TOP_LEVEL_MODULES = { + "random", "math", "sympy", "numpy", "fractions", "pandas", + "matplotlib", "scipy", "itertools", "functools", "collections", + "decimal", "statistics", "operator", "copy", "re", "json", + "pyxiscience", # stubbed + "src", # stubbed (for `from src.scripts.pxs_runtime import myst`) +} + + +def _safe_import(name, *args, **kwargs): + top = name.split(".")[0] + if top not in _ALLOWED_TOP_LEVEL_MODULES: + raise ImportError(f"import of {name!r} blocked in sandbox") + return builtins.__import__(name, *args, **kwargs) + + +# ───────────────────────────────────────────────────────────────────────────── +# 4. Multi-seed validation for règle 4.3 +# ───────────────────────────────────────────────────────────────────────────── + +def multi_seed_validate( + code: str, + assertions: list[dict], + num_seeds: int = 100, + timeout_per_seed: float = 3.0, +) -> dict: + """ + Run `code` num_seeds times with seeds 0..num_seeds-1 and evaluate each + assertion in the resulting namespace. Each assertion is a dict with: + {"description": "...", "assertion": "<python boolean expression>"} + + Returns: + { + "num_seeds": int, + "num_exec_errors": int, + "violations": [ + {"seed": int, "assertion": "...", "description": "...", "value": "False" | "<exception>"}, + ... # capped at 5 per assertion + ], + "summary_per_assertion": {assertion_str: {"violations": int, "errors": int}} + } + """ + summary: dict[str, dict[str, int]] = { + a["assertion"]: {"violations": 0, "errors": 0} + for a in assertions if "assertion" in a + } + violations: list[dict] = [] + num_exec_errors = 0 + first_exec_error: Optional[str] = None + capped_assertions: set[str] = set() + + for seed in range(num_seeds): + res = exec_python_block(code, seed=seed, timeout=timeout_per_seed) + if not res["success"]: + num_exec_errors += 1 + if first_exec_error is None: + first_exec_error = res["error"] + continue + ns = res["ns"] + for a in assertions: + assertion = a.get("assertion") + description = a.get("description", "") + if not isinstance(assertion, str) or not assertion.strip(): + continue + try: + ok = bool(eval(assertion, ns)) + if not ok: + summary[assertion]["violations"] += 1 + if assertion not in capped_assertions and len( + [v for v in violations if v["assertion"] == assertion] + ) < 5: + violations.append({ + "seed": seed, + "assertion": assertion, + "description": description, + "value": "False", + }) + except Exception as e: + summary[assertion]["errors"] += 1 + if assertion not in capped_assertions and len( + [v for v in violations if v["assertion"] == assertion] + ) < 5: + violations.append({ + "seed": seed, + "assertion": assertion, + "description": description, + "value": f"{type(e).__name__}: {e}", + }) + + return { + "num_seeds": num_seeds, + "num_exec_errors": num_exec_errors, + "first_exec_error": first_exec_error, + "violations": violations, + "summary_per_assertion": summary, + } + + +# ───────────────────────────────────────────────────────────────────────────── +# 5. Static AST checks for règles 11.1 (unused random vars in plot) +# and 11.4 (Rational sympy * numpy array) +# ───────────────────────────────────────────────────────────────────────────── + +# Names of plotting functions to look for (matplotlib API used in PyxiScience). +_PLOT_FN_NAMES = { + "plot", "scatter", "fill_between", "fill", "vlines", "hlines", + "axhline", "axvline", "text", "annotate", "errorbar", "stem", + "step", "imshow", "contour", "contourf", "quiver", "stairs", +} + + +def static_check_unused_random_vars( + code: str, + random_var_names: list[str], + markdown_text: str = "", +) -> list[str]: + """ + Règle 11.1 : any variable sampled randomly should be referenced either + in the rest of the Python code (Load context) OR somewhere in the MyST + markdown (`{{var}}` placeholder). Otherwise it's dead code. + + `markdown_text` should be the assembled exercise WITHOUT the Python + blocks (or the whole exercise — both work because the regex looks for + `{{var}}` patterns which only appear in MyST sections). + + Returns the list of names that have ZERO references anywhere. Empty + list = règle respectée. + """ + if not random_var_names: + return [] + + referenced: set[str] = set() + + # 1) References in the Python code (Load context). + try: + tree = ast.parse(code) + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): + if node.id in random_var_names: + referenced.add(node.id) + except SyntaxError: + pass # leniency: don't false-positive on parse failures + + # 2) References in the MyST markdown ({{var}} or {{var.foo}} or {{f(var)}}). + if markdown_text: + for name in random_var_names: + if name in referenced: + continue + if re.search(rf"\{{\{{[^}}]*\b{re.escape(name)}\b[^}}]*\}}\}}", markdown_text): + referenced.add(name) + + return [v for v in random_var_names if v not in referenced] + + +def static_check_rational_numpy_mix(code: str) -> list[dict]: + """ + Règle 11.4 : detect `Rational(...) * <np.array_expr>` or similar + sympy-Rational ↔ numpy mixes that crash at runtime. + + Detection is intentionally narrow to avoid false positives. We flag: + Rational(...) * <expr_referencing_np> + <expr_referencing_np> * Rational(...) + where `<expr_referencing_np>` contains a `np.something` or a name we + recognise as a numpy array (heuristic: contains `_graph` suffix). + """ + issues: list[dict] = [] + try: + tree = ast.parse(code) + except SyntaxError: + return issues + + def _is_rational_call(node: ast.AST) -> bool: + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "Rational" + ) + + def _references_numpy(node: ast.AST) -> bool: + for sub in ast.walk(node): + if isinstance(sub, ast.Attribute) and isinstance(sub.value, ast.Name): + if sub.value.id in {"np", "numpy"}: + return True + if isinstance(sub, ast.Name) and ( + sub.id.endswith("_graph") or sub.id.endswith("_arr") + ): + return True + return False + + for node in ast.walk(tree): + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Mult, ast.Add, ast.Sub)): + left, right = node.left, node.right + if (_is_rational_call(left) and _references_numpy(right)) or ( + _is_rational_call(right) and _references_numpy(left) + ): + issues.append({ + "rule": "11.4", + "message": ( + "Mélange Rational(sympy) ↔ numpy détecté à la ligne " + f"{getattr(node, 'lineno', '?')} — convertir Rational en float() " + "AVANT toute opération numpy." + ), + }) + return issues + + +# ───────────────────────────────────────────────────────────────────────────── +# 6. Dynamic matplotlib check for règle 11.3 (labels in plot window) +# ───────────────────────────────────────────────────────────────────────────── + +def dynamic_check_matplotlib(code: str, timeout: float = 8.0) -> list[dict]: + """ + Execute `code` with a headless matplotlib backend, then inspect every + Text artist on every axis and flag any whose position falls outside + [xlim, ylim] (règle 11.3). + + Notes: + • Only runs if `matplotlib` is imported in the code (avoid pointless exec). + • Uses `matplotlib.use("Agg", force=True)` BEFORE the user code imports + matplotlib — this is achieved by pre-importing pyplot in the namespace + with the Agg backend already set. + • `plt.show()` becomes a no-op under Agg, so the user code runs to + completion without opening a window. + """ + if "matplotlib" not in code: + return [] + + import matplotlib + matplotlib.use("Agg", force=True) + import matplotlib.pyplot as plt + + # Reset figure state to isolate runs (close any leftovers). + plt.close("all") + + # Inject `plt`/`matplotlib` already set up into the namespace so the user + # code's `import matplotlib.pyplot as plt` finds the Agg backend. + extra = {} + + res = exec_python_block(code, seed=0, extra_globals=extra, timeout=timeout) + issues: list[dict] = [] + if not res["success"]: + # Don't fault the user; runtime errors are caught elsewhere. + plt.close("all") + return [] + + for fig_num in plt.get_fignums(): + fig = plt.figure(fig_num) + for ax in fig.get_axes(): + try: + xmin, xmax = ax.get_xlim() + ymin, ymax = ax.get_ylim() + except Exception: + continue + for text_artist in ax.texts: + try: + x, y = text_artist.get_position() + except Exception: + continue + # Numeric only; skip annotations with non-numeric positions. + if not (isinstance(x, (int, float)) and isinstance(y, (int, float))): + continue + out_of_bounds = (x < xmin or x > xmax or y < ymin or y > ymax) + if out_of_bounds: + label = text_artist.get_text() + snippet = label.strip()[:40].replace("\n", " ") + issues.append({ + "rule": "11.3", + "message": ( + f"Label « {snippet} » à ({x:.2f}, {y:.2f}) sort de la fenêtre " + f"[{xmin:.1f}, {xmax:.1f}] × [{ymin:.1f}, {ymax:.1f}]. " + "Matplotlib va étendre l'axe et compresser le graphique." + ), + }) + plt.close("all") + return issues + + +# ───────────────────────────────────────────────────────────────────────────── +# 7. Smoke test (run module directly: `python utils/exec_validator.py`) +# ───────────────────────────────────────────────────────────────────────────── +if __name__ == "__main__": + code = """ +import random as rd +from sympy import Rational +a = rd.randint(1, 10) +b = rd.choice([2, 3, 4]) +result = Rational(a, b) +""" + print("[smoke] running 10 seeds with simple code...") + out = multi_seed_validate( + code, + assertions=[ + {"description": "a est positif", "assertion": "a > 0"}, + {"description": "b est dans {2,3,4}", "assertion": "b in (2, 3, 4)"}, + {"description": "a < b (BUG attendu sur certains seeds)", "assertion": "a < b"}, + ], + num_seeds=10, + ) + print(out) diff --git a/app/web/templates/index.html b/app/web/templates/index.html new file mode 100644 index 0000000000000000000000000000000000000000..5a375039cd6043050d6569e5e32505c7b2206f6b --- /dev/null +++ b/app/web/templates/index.html @@ -0,0 +1,827 @@ +<!DOCTYPE html> +<html lang="fr"> +<head> +<meta charset="UTF-8"> +<title>Pythonise Exercice — v2 + + + + + + + + + + +
+ +
+
+
Py
+
+

Pythonise Exercice

+

PyxiScience MyST → version pythonisée · validée au harnais

+
+
+
service actif
+
+ +
+ + +
+

Source

+ +
+ +
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + +
+ +
+
+ + En attente + +
+
+
+ + +
+ + +
+

Résultat

+ + + + + + +
+
+
L'exercice pythonisé apparaîtra ici.
+
+
+

Aucun audit pour le moment.

+
+
+
{}
+
+
+
+
+
+
+ +
+ +
+ v2 · Pythonisation PyxiScience · porte harnais déterministe · OpenRouter +
+ +
+ + + + + + + + + + + diff --git a/data/notions.xlsx b/data/notions.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ee1c79eebd7ed871705754edd1b69fd02be8a496 Binary files /dev/null and b/data/notions.xlsx differ diff --git a/requirements-deploy.txt b/requirements-deploy.txt new file mode 100644 index 0000000000000000000000000000000000000000..a2b0dba974852a8886653e4374c60399b868b11f --- /dev/null +++ b/requirements-deploy.txt @@ -0,0 +1,18 @@ +# Dépendances de DÉPLOIEMENT (slim) — pour Hugging Face Spaces / conteneur. +# Différence avec requirements.txt : on retire torch / sentence-transformers / +# langchain-huggingface (≈ 1 Go, inutiles avec l'embedding par défaut +# openai-3-small qui passe par l'API). On ajoute gunicorn (serveur WSGI). +flask>=3.0,<4.0 +gunicorn>=21.0 +python-dotenv>=1.0 +requests>=2.31 +openai>=1.40 +langchain-core>=0.3 +langchain-community>=0.3 +langchain-openai>=0.2 +faiss-cpu>=1.8 +pandas>=2.0 +openpyxl>=3.1 +sympy>=1.12 +numpy>=1.26 +matplotlib>=3.8 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..d320780bd54d702b8635c63c6078743d33436082 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,26 @@ +# Flask +flask>=3.0,<4.0 + +# Env loading +python-dotenv>=1.0 + +# HTTP / LLM +requests>=2.31 +openai>=1.40 + +# LangChain stack (RAG) +langchain-core>=0.3 +langchain-community>=0.3 +langchain-openai>=0.2 +langchain-huggingface>=0.1 + +# Embeddings backends +sentence-transformers>=3.0 +faiss-cpu>=1.8 + +# Data / math +pandas>=2.0 +openpyxl>=3.1 +sympy>=1.12 +numpy>=1.26 +matplotlib>=3.8 diff --git a/run.py b/run.py new file mode 100644 index 0000000000000000000000000000000000000000..8c0b4fa57d6845b71e5c24a341548c778be85d0a --- /dev/null +++ b/run.py @@ -0,0 +1,10 @@ +"""Point d'entrée : `python run.py` → http://127.0.0.1:5000 +(équivalent : `python -m app`). Reloader OFF — FAISS/HF lourds à charger.""" + +from app import create_app +from app.config import HOST, PORT + +app = create_app() + +if __name__ == "__main__": + app.run(host=HOST, port=PORT, debug=True, use_reloader=False)