Update app.py
Browse files
app.py
CHANGED
|
@@ -1,8 +1,5 @@
|
|
| 1 |
-
|
| 2 |
-
from google import genai
|
| 3 |
import os
|
| 4 |
-
from google.genai import types
|
| 5 |
-
from PIL import Image
|
| 6 |
import io
|
| 7 |
import base64
|
| 8 |
import json
|
|
@@ -14,146 +11,219 @@ import tempfile
|
|
| 14 |
import subprocess
|
| 15 |
import shutil
|
| 16 |
import re
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
|
|
|
| 18 |
app = Flask(__name__)
|
| 19 |
|
|
|
|
| 20 |
GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY")
|
| 21 |
TELEGRAM_BOT_TOKEN = "8004545342:AAGcZaoDjYg8dmbbXRsR1N3TfSSbEiAGz88"
|
| 22 |
TELEGRAM_CHAT_ID = "-1002564204301"
|
| 23 |
GENERATED_PDF_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'generated_pdfs')
|
| 24 |
|
|
|
|
|
|
|
| 25 |
if GOOGLE_API_KEY:
|
| 26 |
try:
|
| 27 |
client = genai.Client(api_key=GOOGLE_API_KEY)
|
|
|
|
| 28 |
except Exception as e:
|
| 29 |
-
|
| 30 |
-
client = None
|
| 31 |
else:
|
| 32 |
-
|
| 33 |
-
client = None
|
| 34 |
|
| 35 |
task_results = {}
|
| 36 |
|
|
|
|
|
|
|
| 37 |
def load_prompt_from_file(filename):
|
|
|
|
| 38 |
try:
|
| 39 |
prompts_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'prompts')
|
| 40 |
filepath = os.path.join(prompts_dir, filename)
|
|
|
|
| 41 |
with open(filepath, 'r', encoding='utf-8') as f:
|
| 42 |
return f.read()
|
| 43 |
except Exception as e:
|
| 44 |
-
|
| 45 |
return ""
|
| 46 |
|
| 47 |
def get_prompt_for_style(style):
|
|
|
|
|
|
|
| 48 |
return load_prompt_from_file('prompt_light.txt') if style == 'light' else load_prompt_from_file('prompt_colorful.txt')
|
| 49 |
|
| 50 |
def check_latex_installation():
|
|
|
|
|
|
|
| 51 |
try:
|
|
|
|
|
|
|
| 52 |
subprocess.run(["pdflatex", "-version"], capture_output=True, check=True, timeout=10)
|
|
|
|
| 53 |
return True
|
| 54 |
-
except
|
|
|
|
| 55 |
return False
|
| 56 |
|
| 57 |
IS_LATEX_INSTALLED = check_latex_installation()
|
| 58 |
|
| 59 |
def clean_latex_code(latex_code):
|
|
|
|
|
|
|
|
|
|
| 60 |
match_latex = re.search(r"```(?:latex|tex)\s*(.*?)\s*```", latex_code, re.DOTALL | re.IGNORECASE)
|
| 61 |
if match_latex:
|
|
|
|
| 62 |
return match_latex.group(1).strip()
|
|
|
|
|
|
|
| 63 |
match_generic = re.search(r"```\s*(\\documentclass.*?)\s*```", latex_code, re.DOTALL | re.IGNORECASE)
|
| 64 |
if match_generic:
|
|
|
|
| 65 |
return match_generic.group(1).strip()
|
|
|
|
|
|
|
| 66 |
return latex_code.strip()
|
| 67 |
|
| 68 |
def latex_to_pdf(latex_code, output_filename_base, output_dir):
|
|
|
|
| 69 |
if not IS_LATEX_INSTALLED:
|
| 70 |
-
|
|
|
|
| 71 |
|
| 72 |
tex_filename = f"{output_filename_base}.tex"
|
| 73 |
tex_path = os.path.join(output_dir, tex_filename)
|
| 74 |
pdf_path = os.path.join(output_dir, f"{output_filename_base}.pdf")
|
| 75 |
|
|
|
|
|
|
|
| 76 |
try:
|
|
|
|
| 77 |
with open(tex_path, "w", encoding="utf-8") as tex_file:
|
| 78 |
tex_file.write(latex_code)
|
|
|
|
| 79 |
|
|
|
|
| 80 |
my_env = os.environ.copy()
|
| 81 |
my_env["LC_ALL"] = "C.UTF-8"
|
| 82 |
my_env["LANG"] = "C.UTF-8"
|
| 83 |
|
| 84 |
last_result = None
|
| 85 |
-
|
|
|
|
|
|
|
| 86 |
process = subprocess.run(
|
| 87 |
["pdflatex", "-interaction=nonstopmode", "-output-directory", output_dir, tex_path],
|
| 88 |
-
capture_output=True, text=True, check=False, encoding="utf-8", errors="replace", env=my_env,
|
| 89 |
)
|
| 90 |
last_result = process
|
|
|
|
| 91 |
if not os.path.exists(pdf_path) and process.returncode != 0:
|
|
|
|
| 92 |
break
|
| 93 |
|
| 94 |
if os.path.exists(pdf_path):
|
|
|
|
| 95 |
return pdf_path, f"PDF généré: {os.path.basename(pdf_path)}"
|
| 96 |
else:
|
| 97 |
-
error_log = last_result.stdout + "\n" + last_result.stderr if last_result else "Aucun résultat de compilation."
|
| 98 |
-
|
|
|
|
|
|
|
| 99 |
except Exception as e:
|
| 100 |
-
|
|
|
|
| 101 |
|
| 102 |
def send_to_telegram(file_data, filename, caption="Nouveau fichier uploadé"):
|
|
|
|
|
|
|
| 103 |
try:
|
| 104 |
if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')):
|
| 105 |
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendPhoto"
|
| 106 |
files = {'photo': (filename, file_data)}
|
|
|
|
| 107 |
else:
|
| 108 |
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendDocument"
|
| 109 |
files = {'document': (filename, file_data)}
|
|
|
|
|
|
|
|
|
|
| 110 |
data = {'chat_id': TELEGRAM_CHAT_ID, 'caption': caption}
|
| 111 |
-
requests.post(url, files=files, data=data, timeout=30)
|
|
|
|
|
|
|
| 112 |
except Exception as e:
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
def process_files_background(task_id, files_data, resolution_style):
|
|
|
|
|
|
|
|
|
|
| 116 |
uploaded_file_refs = []
|
|
|
|
| 117 |
try:
|
| 118 |
-
task_results[task_id]['status'] = 'processing'
|
| 119 |
if not client:
|
| 120 |
-
raise ConnectionError("
|
| 121 |
|
| 122 |
contents = []
|
|
|
|
| 123 |
for file_info in files_data:
|
| 124 |
if file_info['type'].startswith('image/'):
|
|
|
|
| 125 |
img = Image.open(io.BytesIO(file_info['data']))
|
| 126 |
buffered = io.BytesIO()
|
| 127 |
-
img.save(buffered, format="PNG")
|
| 128 |
img_base64_str = base64.b64encode(buffered.getvalue()).decode()
|
| 129 |
contents.append({'inline_data': {'mime_type': 'image/png', 'data': img_base64_str}})
|
|
|
|
| 130 |
elif file_info['type'] == 'application/pdf':
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
| 142 |
if not contents:
|
| 143 |
-
raise ValueError("Aucun contenu valide
|
| 144 |
|
| 145 |
prompt_to_use = get_prompt_for_style(resolution_style)
|
| 146 |
if not prompt_to_use:
|
| 147 |
-
raise ValueError(f"
|
| 148 |
contents.append(prompt_to_use)
|
| 149 |
|
| 150 |
task_results[task_id]['status'] = 'generating_latex'
|
|
|
|
| 151 |
gemini_response = client.models.generate_content(
|
| 152 |
model="gemini-2.5-pro",
|
| 153 |
contents=contents,
|
| 154 |
config=types.GenerateContentConfig(tools=[types.Tool(code_execution=types.ToolCodeExecution)])
|
| 155 |
)
|
| 156 |
|
|
|
|
| 157 |
full_latex_response = ""
|
| 158 |
if gemini_response.candidates and gemini_response.candidates[0].content and gemini_response.candidates[0].content.parts:
|
| 159 |
for part in gemini_response.candidates[0].content.parts:
|
|
@@ -161,10 +231,13 @@ def process_files_background(task_id, files_data, resolution_style):
|
|
| 161 |
full_latex_response += part.text
|
| 162 |
|
| 163 |
if not full_latex_response.strip():
|
| 164 |
-
raise ValueError("
|
|
|
|
| 165 |
|
| 166 |
task_results[task_id]['status'] = 'cleaning_latex'
|
| 167 |
cleaned_latex = clean_latex_code(full_latex_response)
|
|
|
|
|
|
|
| 168 |
|
| 169 |
task_results[task_id]['status'] = 'generating_pdf'
|
| 170 |
pdf_filename_base = f"solution_{task_id}"
|
|
@@ -174,31 +247,44 @@ def process_files_background(task_id, files_data, resolution_style):
|
|
| 174 |
task_results[task_id]['status'] = 'completed'
|
| 175 |
task_results[task_id]['pdf_filename'] = os.path.basename(pdf_file_path)
|
| 176 |
task_results[task_id]['response'] = f"PDF généré avec succès: {os.path.basename(pdf_file_path)}"
|
|
|
|
| 177 |
else:
|
| 178 |
-
raise RuntimeError(f"Échec de la génération PDF: {pdf_message}")
|
| 179 |
|
| 180 |
except Exception as e:
|
| 181 |
-
|
| 182 |
task_results[task_id]['status'] = 'error'
|
| 183 |
task_results[task_id]['error'] = str(e)
|
| 184 |
-
task_results[task_id]['response'] = f"
|
| 185 |
finally:
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
@app.route('/')
|
| 191 |
def index():
|
|
|
|
| 192 |
return render_template('index.html')
|
| 193 |
|
| 194 |
@app.route('/solve', methods=['POST'])
|
| 195 |
def solve():
|
|
|
|
| 196 |
try:
|
| 197 |
if 'user_files' not in request.files:
|
| 198 |
-
|
|
|
|
| 199 |
|
| 200 |
uploaded_files = request.files.getlist('user_files')
|
| 201 |
if not uploaded_files or all(f.filename == '' for f in uploaded_files):
|
|
|
|
| 202 |
return jsonify({'error': 'Aucun fichier sélectionné'}), 400
|
| 203 |
|
| 204 |
resolution_style = request.form.get('style', 'colorful')
|
|
@@ -210,43 +296,46 @@ def solve():
|
|
| 210 |
file_data = file.read()
|
| 211 |
file_type = file.content_type or 'application/octet-stream'
|
| 212 |
|
|
|
|
| 213 |
if file_type.startswith('image/'):
|
| 214 |
file_count['images'] += 1
|
| 215 |
files_data.append({'filename': file.filename, 'data': file_data, 'type': file_type})
|
| 216 |
-
send_to_telegram(file_data, file.filename, f"Image reçue
|
| 217 |
elif file_type == 'application/pdf':
|
| 218 |
if file_count['pdfs'] >= 1:
|
| 219 |
-
|
|
|
|
| 220 |
file_count['pdfs'] += 1
|
| 221 |
files_data.append({'filename': file.filename, 'data': file_data, 'type': file_type})
|
| 222 |
-
send_to_telegram(file_data, file.filename, f"PDF reçu
|
|
|
|
|
|
|
| 223 |
|
| 224 |
if not files_data:
|
| 225 |
-
|
|
|
|
| 226 |
|
| 227 |
task_id = str(uuid.uuid4())
|
| 228 |
task_results[task_id] = {
|
| 229 |
-
'status': 'pending',
|
| 230 |
-
'
|
| 231 |
-
'error': None,
|
| 232 |
-
'time_started': time.time(),
|
| 233 |
-
'style': resolution_style,
|
| 234 |
-
'file_count': file_count,
|
| 235 |
-
'first_filename': files_data[0]['filename']
|
| 236 |
}
|
| 237 |
|
|
|
|
| 238 |
threading.Thread(target=process_files_background, args=(task_id, files_data, resolution_style)).start()
|
| 239 |
|
| 240 |
return jsonify({'task_id': task_id, 'status': 'pending', 'first_filename': files_data[0]['filename']})
|
| 241 |
|
| 242 |
except Exception as e:
|
| 243 |
-
|
| 244 |
-
return jsonify({'error': f'Erreur serveur: {e}'}), 500
|
| 245 |
|
| 246 |
@app.route('/task/<task_id>', methods=['GET'])
|
| 247 |
def get_task_status(task_id):
|
|
|
|
| 248 |
task = task_results.get(task_id)
|
| 249 |
if not task:
|
|
|
|
| 250 |
return jsonify({'error': 'Tâche introuvable'}), 404
|
| 251 |
|
| 252 |
response_data = {'status': task['status'], 'response': task.get('response'), 'error': task.get('error')}
|
|
@@ -257,12 +346,15 @@ def get_task_status(task_id):
|
|
| 257 |
|
| 258 |
@app.route('/stream/<task_id>', methods=['GET'])
|
| 259 |
def stream_task_progress(task_id):
|
|
|
|
| 260 |
def generate():
|
|
|
|
| 261 |
last_status_sent = None
|
| 262 |
while True:
|
| 263 |
task = task_results.get(task_id)
|
| 264 |
if not task:
|
| 265 |
-
|
|
|
|
| 266 |
break
|
| 267 |
|
| 268 |
current_status = task['status']
|
|
@@ -274,32 +366,38 @@ def stream_task_progress(task_id):
|
|
| 274 |
elif current_status == 'error':
|
| 275 |
data_to_send["error"] = task.get("error", "Erreur inconnue")
|
| 276 |
|
|
|
|
| 277 |
yield f'data: {json.dumps(data_to_send)}\n\n'
|
| 278 |
last_status_sent = current_status
|
| 279 |
|
| 280 |
if current_status in ['completed', 'error']:
|
|
|
|
| 281 |
break
|
| 282 |
|
| 283 |
-
time.sleep(1)
|
| 284 |
|
| 285 |
return Response(stream_with_context(generate()), mimetype='text/event-stream', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
|
| 286 |
|
| 287 |
@app.route('/download/<task_id>')
|
| 288 |
def download_pdf(task_id):
|
|
|
|
| 289 |
task = task_results.get(task_id)
|
| 290 |
if not task or task['status'] != 'completed' or 'pdf_filename' not in task:
|
| 291 |
-
|
|
|
|
| 292 |
|
| 293 |
try:
|
|
|
|
| 294 |
return send_from_directory(GENERATED_PDF_DIR, task['pdf_filename'], as_attachment=True)
|
| 295 |
except FileNotFoundError:
|
| 296 |
-
|
|
|
|
| 297 |
|
| 298 |
if __name__ == '__main__':
|
|
|
|
|
|
|
|
|
|
| 299 |
os.makedirs(GENERATED_PDF_DIR, exist_ok=True)
|
| 300 |
-
|
| 301 |
-
print("CRITICAL: GOOGLE_API_KEY non défini.")
|
| 302 |
-
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
|
| 303 |
-
print("CRITICAL: Clés Telegram non définies.")
|
| 304 |
|
| 305 |
-
|
|
|
|
| 1 |
+
import logging
|
|
|
|
| 2 |
import os
|
|
|
|
|
|
|
| 3 |
import io
|
| 4 |
import base64
|
| 5 |
import json
|
|
|
|
| 11 |
import subprocess
|
| 12 |
import shutil
|
| 13 |
import re
|
| 14 |
+
from flask import Flask, render_template, request, jsonify, Response, stream_with_context, send_from_directory
|
| 15 |
+
from google import genai
|
| 16 |
+
from google.genai import types
|
| 17 |
+
from PIL import Image
|
| 18 |
+
|
| 19 |
+
# --- Configuration du Logging ---
|
| 20 |
+
# Configuration d'un logger qui écrit dans la console (stdout).
|
| 21 |
+
# C'est la pratique recommandée pour les applications conteneurisées (Docker) ou déployées sur des services comme Heroku/Render.
|
| 22 |
+
logging.basicConfig(
|
| 23 |
+
level=logging.INFO, # Niveau de log par défaut. Changer à logging.DEBUG pour plus de détails.
|
| 24 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 25 |
+
datefmt='%Y-%m-%d %H:%M:%S'
|
| 26 |
+
)
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
|
| 29 |
+
# --- Configuration de l'Application Flask ---
|
| 30 |
app = Flask(__name__)
|
| 31 |
|
| 32 |
+
# --- Constantes et Variables Globales ---
|
| 33 |
GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY")
|
| 34 |
TELEGRAM_BOT_TOKEN = "8004545342:AAGcZaoDjYg8dmbbXRsR1N3TfSSbEiAGz88"
|
| 35 |
TELEGRAM_CHAT_ID = "-1002564204301"
|
| 36 |
GENERATED_PDF_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'generated_pdfs')
|
| 37 |
|
| 38 |
+
# --- Initialisation des Services Externes ---
|
| 39 |
+
client = None
|
| 40 |
if GOOGLE_API_KEY:
|
| 41 |
try:
|
| 42 |
client = genai.Client(api_key=GOOGLE_API_KEY)
|
| 43 |
+
logger.info("Client Google GenAI initialisé avec succès.")
|
| 44 |
except Exception as e:
|
| 45 |
+
logger.critical(f"Erreur critique lors de l'initialisation du client Gemini: {e}", exc_info=True)
|
|
|
|
| 46 |
else:
|
| 47 |
+
logger.critical("GEMINI_API_KEY non trouvé dans les variables d'environnement. Le service ne fonctionnera pas.")
|
|
|
|
| 48 |
|
| 49 |
task_results = {}
|
| 50 |
|
| 51 |
+
# --- Fonctions Utilitaires ---
|
| 52 |
+
|
| 53 |
def load_prompt_from_file(filename):
|
| 54 |
+
"""Charge le contenu d'un fichier de prompt."""
|
| 55 |
try:
|
| 56 |
prompts_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'prompts')
|
| 57 |
filepath = os.path.join(prompts_dir, filename)
|
| 58 |
+
logger.info(f"Chargement du prompt depuis '{filepath}'")
|
| 59 |
with open(filepath, 'r', encoding='utf-8') as f:
|
| 60 |
return f.read()
|
| 61 |
except Exception as e:
|
| 62 |
+
logger.error(f"Erreur lors du chargement du prompt '{filename}': {e}", exc_info=True)
|
| 63 |
return ""
|
| 64 |
|
| 65 |
def get_prompt_for_style(style):
|
| 66 |
+
"""Retourne le prompt approprié en fonction du style demandé."""
|
| 67 |
+
logger.info(f"Sélection du prompt pour le style: '{style}'")
|
| 68 |
return load_prompt_from_file('prompt_light.txt') if style == 'light' else load_prompt_from_file('prompt_colorful.txt')
|
| 69 |
|
| 70 |
def check_latex_installation():
|
| 71 |
+
"""Vérifie si pdflatex est installé et accessible dans le PATH."""
|
| 72 |
+
logger.info("Vérification de l'installation de LaTeX (pdflatex)...")
|
| 73 |
try:
|
| 74 |
+
# Exécute 'pdflatex -version' pour vérifier son existence.
|
| 75 |
+
# capture_output=True masque la sortie, check=True lève une exception en cas d'échec.
|
| 76 |
subprocess.run(["pdflatex", "-version"], capture_output=True, check=True, timeout=10)
|
| 77 |
+
logger.info("Vérification réussie: pdflatex est installé et fonctionnel.")
|
| 78 |
return True
|
| 79 |
+
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired) as e:
|
| 80 |
+
logger.warning(f"pdflatex n'est pas installé ou n'est pas dans le PATH. La génération de PDF sera désactivée. Erreur: {e}")
|
| 81 |
return False
|
| 82 |
|
| 83 |
IS_LATEX_INSTALLED = check_latex_installation()
|
| 84 |
|
| 85 |
def clean_latex_code(latex_code):
|
| 86 |
+
"""Extrait le code LaTeX brut des blocs de code formatés (```latex ... ```)."""
|
| 87 |
+
logger.info("Nettoyage du code LaTeX reçu de Gemini...")
|
| 88 |
+
# Cherche un bloc de code explicite ```latex ... ```
|
| 89 |
match_latex = re.search(r"```(?:latex|tex)\s*(.*?)\s*```", latex_code, re.DOTALL | re.IGNORECASE)
|
| 90 |
if match_latex:
|
| 91 |
+
logger.info("Bloc de code 'latex' ou 'tex' trouvé et extrait.")
|
| 92 |
return match_latex.group(1).strip()
|
| 93 |
+
|
| 94 |
+
# Plan B : Cherche un bloc de code générique qui commence par \documentclass
|
| 95 |
match_generic = re.search(r"```\s*(\\documentclass.*?)\s*```", latex_code, re.DOTALL | re.IGNORECASE)
|
| 96 |
if match_generic:
|
| 97 |
+
logger.info("Bloc de code générique avec '\\documentclass' trouvé et extrait.")
|
| 98 |
return match_generic.group(1).strip()
|
| 99 |
+
|
| 100 |
+
logger.warning("Aucun bloc de code LaTeX (```...```) n'a été trouvé. Utilisation de la réponse brute.")
|
| 101 |
return latex_code.strip()
|
| 102 |
|
| 103 |
def latex_to_pdf(latex_code, output_filename_base, output_dir):
|
| 104 |
+
"""Compile une chaîne de code LaTeX en fichier PDF."""
|
| 105 |
if not IS_LATEX_INSTALLED:
|
| 106 |
+
logger.error("Tentative de compilation LaTeX alors que pdflatex n'est pas disponible.")
|
| 107 |
+
return None, "Erreur: pdflatex n'est pas installé sur le serveur."
|
| 108 |
|
| 109 |
tex_filename = f"{output_filename_base}.tex"
|
| 110 |
tex_path = os.path.join(output_dir, tex_filename)
|
| 111 |
pdf_path = os.path.join(output_dir, f"{output_filename_base}.pdf")
|
| 112 |
|
| 113 |
+
logger.info(f"Début de la compilation LaTeX vers PDF pour '{output_filename_base}'")
|
| 114 |
+
|
| 115 |
try:
|
| 116 |
+
# Écriture du fichier .tex
|
| 117 |
with open(tex_path, "w", encoding="utf-8") as tex_file:
|
| 118 |
tex_file.write(latex_code)
|
| 119 |
+
logger.info(f"Fichier .tex '{tex_path}' créé avec succès.")
|
| 120 |
|
| 121 |
+
# Copie de l'environnement et configuration pour UTF-8 pour éviter les erreurs d'encodage
|
| 122 |
my_env = os.environ.copy()
|
| 123 |
my_env["LC_ALL"] = "C.UTF-8"
|
| 124 |
my_env["LANG"] = "C.UTF-8"
|
| 125 |
|
| 126 |
last_result = None
|
| 127 |
+
# Exécution de pdflatex deux fois pour résoudre les références (table des matières, etc.)
|
| 128 |
+
for i in range(2):
|
| 129 |
+
logger.info(f"Exécution de pdflatex - Passe {i+1}/2...")
|
| 130 |
process = subprocess.run(
|
| 131 |
["pdflatex", "-interaction=nonstopmode", "-output-directory", output_dir, tex_path],
|
| 132 |
+
capture_output=True, text=True, check=False, encoding="utf-8", errors="replace", env=my_env, timeout=60
|
| 133 |
)
|
| 134 |
last_result = process
|
| 135 |
+
# Si le PDF n'est pas créé et que la première passe a échoué, inutile de continuer
|
| 136 |
if not os.path.exists(pdf_path) and process.returncode != 0:
|
| 137 |
+
logger.warning(f"La passe {i+1} de pdflatex a échoué et aucun PDF n'a été créé. Arrêt de la compilation.")
|
| 138 |
break
|
| 139 |
|
| 140 |
if os.path.exists(pdf_path):
|
| 141 |
+
logger.info(f"PDF généré avec succès : '{pdf_path}'")
|
| 142 |
return pdf_path, f"PDF généré: {os.path.basename(pdf_path)}"
|
| 143 |
else:
|
| 144 |
+
error_log = last_result.stdout + "\n" + last_result.stderr if last_result else "Aucun résultat de compilation disponible."
|
| 145 |
+
logger.error(f"Échec de la compilation PDF pour '{tex_filename}'. Log de pdflatex:\n{error_log}")
|
| 146 |
+
return None, f"Erreur de compilation PDF. Log: ...{error_log[-1000:]}" # Retourne les 1000 derniers caractères du log
|
| 147 |
+
|
| 148 |
except Exception as e:
|
| 149 |
+
logger.error(f"Exception pendant la génération du PDF: {e}", exc_info=True)
|
| 150 |
+
return None, f"Exception durant la génération du PDF: {str(e)}"
|
| 151 |
|
| 152 |
def send_to_telegram(file_data, filename, caption="Nouveau fichier uploadé"):
|
| 153 |
+
"""Envoie un fichier au canal Telegram configuré."""
|
| 154 |
+
logger.info(f"Préparation de l'envoi du fichier '{filename}' à Telegram.")
|
| 155 |
try:
|
| 156 |
if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')):
|
| 157 |
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendPhoto"
|
| 158 |
files = {'photo': (filename, file_data)}
|
| 159 |
+
log_msg = f"Envoi de l'image '{filename}' à Telegram..."
|
| 160 |
else:
|
| 161 |
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendDocument"
|
| 162 |
files = {'document': (filename, file_data)}
|
| 163 |
+
log_msg = f"Envoi du document '{filename}' à Telegram..."
|
| 164 |
+
|
| 165 |
+
logger.info(log_msg)
|
| 166 |
data = {'chat_id': TELEGRAM_CHAT_ID, 'caption': caption}
|
| 167 |
+
response = requests.post(url, files=files, data=data, timeout=30)
|
| 168 |
+
response.raise_for_status() # Lève une exception si le statut HTTP est une erreur (4xx ou 5xx)
|
| 169 |
+
logger.info(f"Fichier '{filename}' envoyé avec succès à Telegram.")
|
| 170 |
except Exception as e:
|
| 171 |
+
logger.error(f"Erreur lors de l'envoi à Telegram: {e}", exc_info=True)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# --- Logique Principale (Worker en arrière-plan) ---
|
| 175 |
|
| 176 |
def process_files_background(task_id, files_data, resolution_style):
|
| 177 |
+
"""Fonction exécutée en thread pour traiter les fichiers, appeler Gemini et générer le PDF."""
|
| 178 |
+
logger.info(f"[Task {task_id}] Démarrage du traitement en arrière-plan.")
|
| 179 |
+
task_results[task_id]['status'] = 'processing'
|
| 180 |
uploaded_file_refs = []
|
| 181 |
+
|
| 182 |
try:
|
|
|
|
| 183 |
if not client:
|
| 184 |
+
raise ConnectionError("Le client Gemini n'est pas initialisé.")
|
| 185 |
|
| 186 |
contents = []
|
| 187 |
+
logger.info(f"[Task {task_id}] Préparation des fichiers pour l'API Gemini.")
|
| 188 |
for file_info in files_data:
|
| 189 |
if file_info['type'].startswith('image/'):
|
| 190 |
+
logger.info(f"[Task {task_id}] Traitement de l'image '{file_info['filename']}'.")
|
| 191 |
img = Image.open(io.BytesIO(file_info['data']))
|
| 192 |
buffered = io.BytesIO()
|
| 193 |
+
img.save(buffered, format="PNG") # Convertit en PNG pour la consistance
|
| 194 |
img_base64_str = base64.b64encode(buffered.getvalue()).decode()
|
| 195 |
contents.append({'inline_data': {'mime_type': 'image/png', 'data': img_base64_str}})
|
| 196 |
+
|
| 197 |
elif file_info['type'] == 'application/pdf':
|
| 198 |
+
logger.info(f"[Task {task_id}] Upload du PDF '{file_info['filename']}' vers Google GenAI File API.")
|
| 199 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_pdf:
|
| 200 |
+
temp_pdf.write(file_info['data'])
|
| 201 |
+
temp_pdf_path = temp_pdf.name
|
| 202 |
+
|
| 203 |
+
# Upload du fichier et ajout de la référence à la liste de nettoyage
|
| 204 |
+
file_ref = client.files.upload(file=temp_pdf_path)
|
| 205 |
+
uploaded_file_refs.append(file_ref)
|
| 206 |
+
contents.append(file_ref)
|
| 207 |
+
os.unlink(temp_pdf_path) # Supprime le fichier temporaire local
|
| 208 |
+
logger.info(f"[Task {task_id}] PDF '{file_info['filename']}' uploadé avec succès. Référence: {file_ref.name}")
|
| 209 |
+
|
| 210 |
if not contents:
|
| 211 |
+
raise ValueError("Aucun contenu valide (image ou PDF) n'a été traité.")
|
| 212 |
|
| 213 |
prompt_to_use = get_prompt_for_style(resolution_style)
|
| 214 |
if not prompt_to_use:
|
| 215 |
+
raise ValueError(f"Le fichier de prompt pour le style '{resolution_style}' est introuvable ou vide.")
|
| 216 |
contents.append(prompt_to_use)
|
| 217 |
|
| 218 |
task_results[task_id]['status'] = 'generating_latex'
|
| 219 |
+
logger.info(f"[Task {task_id}] Envoi de la requête à l'API Gemini (modèle gemini-2.5-pro).")
|
| 220 |
gemini_response = client.models.generate_content(
|
| 221 |
model="gemini-2.5-pro",
|
| 222 |
contents=contents,
|
| 223 |
config=types.GenerateContentConfig(tools=[types.Tool(code_execution=types.ToolCodeExecution)])
|
| 224 |
)
|
| 225 |
|
| 226 |
+
logger.info(f"[Task {task_id}] Réponse reçue de Gemini.")
|
| 227 |
full_latex_response = ""
|
| 228 |
if gemini_response.candidates and gemini_response.candidates[0].content and gemini_response.candidates[0].content.parts:
|
| 229 |
for part in gemini_response.candidates[0].content.parts:
|
|
|
|
| 231 |
full_latex_response += part.text
|
| 232 |
|
| 233 |
if not full_latex_response.strip():
|
| 234 |
+
raise ValueError("La réponse de Gemini était vide.")
|
| 235 |
+
logger.debug(f"[Task {task_id}] Réponse brute de Gemini:\n---\n{full_latex_response[:500]}...\n---")
|
| 236 |
|
| 237 |
task_results[task_id]['status'] = 'cleaning_latex'
|
| 238 |
cleaned_latex = clean_latex_code(full_latex_response)
|
| 239 |
+
logger.debug(f"[Task {task_id}] Code LaTeX nettoyé:\n---\n{cleaned_latex[:500]}...\n---")
|
| 240 |
+
|
| 241 |
|
| 242 |
task_results[task_id]['status'] = 'generating_pdf'
|
| 243 |
pdf_filename_base = f"solution_{task_id}"
|
|
|
|
| 247 |
task_results[task_id]['status'] = 'completed'
|
| 248 |
task_results[task_id]['pdf_filename'] = os.path.basename(pdf_file_path)
|
| 249 |
task_results[task_id]['response'] = f"PDF généré avec succès: {os.path.basename(pdf_file_path)}"
|
| 250 |
+
logger.info(f"[Task {task_id}] Tâche terminée avec succès. PDF: {os.path.basename(pdf_file_path)}")
|
| 251 |
else:
|
| 252 |
+
raise RuntimeError(f"Échec de la génération du PDF: {pdf_message}")
|
| 253 |
|
| 254 |
except Exception as e:
|
| 255 |
+
logger.error(f"[Task {task_id}] Une erreur est survenue dans le thread de traitement.", exc_info=True)
|
| 256 |
task_results[task_id]['status'] = 'error'
|
| 257 |
task_results[task_id]['error'] = str(e)
|
| 258 |
+
task_results[task_id]['response'] = f"Une erreur est survenue: {str(e)}"
|
| 259 |
finally:
|
| 260 |
+
# Nettoyage des fichiers uploadés à l'API Gemini
|
| 261 |
+
if uploaded_file_refs:
|
| 262 |
+
logger.info(f"[Task {task_id}] Nettoyage des {len(uploaded_file_refs)} fichiers temporaires de l'API Gemini.")
|
| 263 |
+
for file_ref in uploaded_file_refs:
|
| 264 |
+
try:
|
| 265 |
+
client.files.delete(file_ref)
|
| 266 |
+
logger.info(f"[Task {task_id}] Fichier temporaire Gemini '{file_ref.name}' supprimé.")
|
| 267 |
+
except Exception as del_e:
|
| 268 |
+
logger.warning(f"[Task {task_id}] Échec de la suppression du fichier temporaire Gemini '{file_ref.name}': {del_e}")
|
| 269 |
+
|
| 270 |
+
# --- Routes Flask (API Endpoints) ---
|
| 271 |
|
| 272 |
@app.route('/')
|
| 273 |
def index():
|
| 274 |
+
logger.info(f"Requête servie pour l'endpoint '/' depuis {request.remote_addr}")
|
| 275 |
return render_template('index.html')
|
| 276 |
|
| 277 |
@app.route('/solve', methods=['POST'])
|
| 278 |
def solve():
|
| 279 |
+
logger.info(f"Nouvelle requête sur /solve depuis {request.remote_addr}")
|
| 280 |
try:
|
| 281 |
if 'user_files' not in request.files:
|
| 282 |
+
logger.warning(f"/solve: Requête de {request.remote_addr} sans 'user_files'.")
|
| 283 |
+
return jsonify({'error': 'Aucun champ de fichier dans la requête'}), 400
|
| 284 |
|
| 285 |
uploaded_files = request.files.getlist('user_files')
|
| 286 |
if not uploaded_files or all(f.filename == '' for f in uploaded_files):
|
| 287 |
+
logger.warning(f"/solve: Requête de {request.remote_addr} avec champ 'user_files' mais sans fichiers.")
|
| 288 |
return jsonify({'error': 'Aucun fichier sélectionné'}), 400
|
| 289 |
|
| 290 |
resolution_style = request.form.get('style', 'colorful')
|
|
|
|
| 296 |
file_data = file.read()
|
| 297 |
file_type = file.content_type or 'application/octet-stream'
|
| 298 |
|
| 299 |
+
# Validation et traitement des fichiers
|
| 300 |
if file_type.startswith('image/'):
|
| 301 |
file_count['images'] += 1
|
| 302 |
files_data.append({'filename': file.filename, 'data': file_data, 'type': file_type})
|
| 303 |
+
send_to_telegram(file_data, file.filename, f"Image reçue: {file.filename} (Style: {resolution_style})")
|
| 304 |
elif file_type == 'application/pdf':
|
| 305 |
if file_count['pdfs'] >= 1:
|
| 306 |
+
logger.warning(f"/solve: Requête de {request.remote_addr} avec plusieurs PDFs. Rejetée.")
|
| 307 |
+
return jsonify({'error': 'Un seul fichier PDF est autorisé par requête'}), 400
|
| 308 |
file_count['pdfs'] += 1
|
| 309 |
files_data.append({'filename': file.filename, 'data': file_data, 'type': file_type})
|
| 310 |
+
send_to_telegram(file_data, file.filename, f"PDF reçu: {file.filename} (Style: {resolution_style})")
|
| 311 |
+
else:
|
| 312 |
+
logger.warning(f"/solve: Fichier non supporté '{file.filename}' de type '{file_type}' uploadé par {request.remote_addr}.")
|
| 313 |
|
| 314 |
if not files_data:
|
| 315 |
+
logger.warning(f"/solve: Aucun fichier valide (image/pdf) trouvé dans la requête de {request.remote_addr}.")
|
| 316 |
+
return jsonify({'error': 'Aucun fichier valide (image ou PDF) n\'a été fourni'}), 400
|
| 317 |
|
| 318 |
task_id = str(uuid.uuid4())
|
| 319 |
task_results[task_id] = {
|
| 320 |
+
'status': 'pending', 'response': '', 'error': None, 'time_started': time.time(),
|
| 321 |
+
'style': resolution_style, 'file_count': file_count, 'first_filename': files_data[0]['filename']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
}
|
| 323 |
|
| 324 |
+
logger.info(f"Création de la tâche {task_id} pour {file_count['images']} image(s) et {file_count['pdfs']} PDF(s). Style: {resolution_style}.")
|
| 325 |
threading.Thread(target=process_files_background, args=(task_id, files_data, resolution_style)).start()
|
| 326 |
|
| 327 |
return jsonify({'task_id': task_id, 'status': 'pending', 'first_filename': files_data[0]['filename']})
|
| 328 |
|
| 329 |
except Exception as e:
|
| 330 |
+
logger.error(f"Erreur inattendue dans l'endpoint /solve: {e}", exc_info=True)
|
| 331 |
+
return jsonify({'error': f'Erreur interne du serveur: {e}'}), 500
|
| 332 |
|
| 333 |
@app.route('/task/<task_id>', methods=['GET'])
|
| 334 |
def get_task_status(task_id):
|
| 335 |
+
logger.debug(f"Requête de statut pour la tâche {task_id}")
|
| 336 |
task = task_results.get(task_id)
|
| 337 |
if not task:
|
| 338 |
+
logger.warning(f"Tentative d'accès à une tâche inexistante: {task_id}")
|
| 339 |
return jsonify({'error': 'Tâche introuvable'}), 404
|
| 340 |
|
| 341 |
response_data = {'status': task['status'], 'response': task.get('response'), 'error': task.get('error')}
|
|
|
|
| 346 |
|
| 347 |
@app.route('/stream/<task_id>', methods=['GET'])
|
| 348 |
def stream_task_progress(task_id):
|
| 349 |
+
"""Endpoint pour Server-Sent Events (SSE) pour streamer la progression."""
|
| 350 |
def generate():
|
| 351 |
+
logger.info(f"Nouvelle connexion de streaming (SSE) pour la tâche {task_id}")
|
| 352 |
last_status_sent = None
|
| 353 |
while True:
|
| 354 |
task = task_results.get(task_id)
|
| 355 |
if not task:
|
| 356 |
+
logger.warning(f"La tâche {task_id} a disparu pendant le streaming.")
|
| 357 |
+
yield f'data: {json.dumps({"error": "La tâche a été perdue", "status": "error"})}\n\n'
|
| 358 |
break
|
| 359 |
|
| 360 |
current_status = task['status']
|
|
|
|
| 366 |
elif current_status == 'error':
|
| 367 |
data_to_send["error"] = task.get("error", "Erreur inconnue")
|
| 368 |
|
| 369 |
+
logger.info(f"[Task {task_id}] Envoi de la mise à jour de statut via SSE: {current_status}")
|
| 370 |
yield f'data: {json.dumps(data_to_send)}\n\n'
|
| 371 |
last_status_sent = current_status
|
| 372 |
|
| 373 |
if current_status in ['completed', 'error']:
|
| 374 |
+
logger.info(f"Fermeture de la connexion SSE pour la tâche terminée/échouée {task_id}")
|
| 375 |
break
|
| 376 |
|
| 377 |
+
time.sleep(1) # Attendre 1 seconde avant de vérifier à nouveau
|
| 378 |
|
| 379 |
return Response(stream_with_context(generate()), mimetype='text/event-stream', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
|
| 380 |
|
| 381 |
@app.route('/download/<task_id>')
|
| 382 |
def download_pdf(task_id):
|
| 383 |
+
logger.info(f"Requête de téléchargement pour la tâche {task_id}")
|
| 384 |
task = task_results.get(task_id)
|
| 385 |
if not task or task['status'] != 'completed' or 'pdf_filename' not in task:
|
| 386 |
+
logger.warning(f"Échec du téléchargement pour la tâche {task_id}: Fichier non trouvé ou tâche non terminée.")
|
| 387 |
+
return "Fichier non trouvé ou la tâche n'est pas encore terminée.", 404
|
| 388 |
|
| 389 |
try:
|
| 390 |
+
logger.info(f"Envoi du fichier '{task['pdf_filename']}' pour la tâche {task_id}")
|
| 391 |
return send_from_directory(GENERATED_PDF_DIR, task['pdf_filename'], as_attachment=True)
|
| 392 |
except FileNotFoundError:
|
| 393 |
+
logger.error(f"Le fichier PDF '{task['pdf_filename']}' pour la tâche {task_id} est introuvable sur le disque.")
|
| 394 |
+
return "Erreur: Fichier introuvable sur le serveur.", 404
|
| 395 |
|
| 396 |
if __name__ == '__main__':
|
| 397 |
+
logger.info("Démarrage de l'application Flask.")
|
| 398 |
+
|
| 399 |
+
# Création du répertoire pour les PDFs générés s'il n'existe pas
|
| 400 |
os.makedirs(GENERATED_PDF_DIR, exist_ok=True)
|
| 401 |
+
logger.info(f"Répertoire pour les PDFs générés assuré d'exister: '{GENERATED_PDF_DIR}'")
|
|
|
|
|
|
|
|
|
|
| 402 |
|
| 403 |
+
# Vérifications cri
|