Spaces:
Sleeping
Sleeping
| """ | |
| 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_MODE, | |
| DEFAULT_MODEL_IDX, | |
| DEFAULT_POLICY, | |
| JOB_TTL, | |
| ) | |
| from app.pipeline.orchestrator import run_declinaisons, run_with_policy | |
| logger = logging.getLogger(__name__) | |
| _JOBS: dict = {} | |
| _JOBS_LOCK = threading.Lock() | |
| VALID_LANGS = ("fr", "en", "both") | |
| VALID_LEVELS = ("", "Elementary", "Intermediate", "Advanced") | |
| VALID_MODES = ("pythonise", "declinaisons") | |
| class JobCancelled(Exception): | |
| """Arrêt demandé par l'utilisateur (bouton Stop). L'annulation est | |
| COOPÉRATIVE : elle prend effet au prochain point d'étape du pipeline | |
| (un appel LLM en vol n'est pas interrompu, mais rien ne s'enchaîne).""" | |
| 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 le ZIP. Les déclinaisons portent | |
| déjà leur suffixe (_QCM/_QAT) ; la pythonisation reçoit _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" | |
| suffix = "" if re.search(r"_(QCM|QAT)$", base) else "_pythonise" | |
| name = f"{base}{suffix}.md" | |
| i = 2 | |
| while name in used: | |
| name = f"{base}{suffix}_{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", "cancelled") | |
| and (j.get("finished_at") or 0) < cutoff]: | |
| del _JOBS[jid] | |
| def _cancel_requested(job_id: str) -> bool: | |
| with _JOBS_LOCK: | |
| j = _JOBS.get(job_id) | |
| return bool(j and j.get("cancel_requested")) | |
| def _summarize(results: list[dict], total: int) -> dict: | |
| 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") | |
| cost = round(sum(r["result"]["cost"]["usd"] for r in results | |
| if r["status"] == "done"), 4) | |
| return {"total": total, "verts": ok, "rouges": warn, | |
| "erreurs": err, "cost_usd": cost} | |
| def _decl_output_name(source_name: str, decl_type: str) -> str: | |
| """foo.md + qcm → foo_QCM.md (nommage de sortie des déclinaisons).""" | |
| base = re.sub(r"\.(md|txt)$", "", source_name or "exercice", flags=re.IGNORECASE) | |
| return f"{base}_{'QCM' if decl_type == 'qcm' else 'QAT'}.md" | |
| def _run_job(job_id: str, files: list[dict], level: str, model_idx: int, | |
| lang: str, mode: str = "pythonise", decl_types: list | None = None, | |
| policy: str = "auto", manual_models: dict | None = None): | |
| """Worker de job : boucle séquentielle sur les fichiers, robuste. | |
| En mode `declinaisons`, chaque source produit 1 résultat PAR type coché | |
| (analyse partagée entre types et échelons — aucun appel LLM redondant).""" | |
| results: list[dict] = [] | |
| try: | |
| _run_job_files(job_id, files, level, model_idx, lang, mode, | |
| decl_types, policy, manual_models, results) | |
| except JobCancelled: | |
| _set_job( | |
| job_id, | |
| status="cancelled", | |
| step_label="Arrêté par l'utilisateur", | |
| finished_at=time.time(), | |
| results=list(results), | |
| summary=_summarize(results, len(files)), | |
| ) | |
| logger.info("Job %s annulé par l'utilisateur (%d/%d fichiers traités).", | |
| job_id, len(results), len(files)) | |
| _purge_old_jobs() | |
| return | |
| _set_job( | |
| job_id, | |
| status="done", | |
| step_label="Terminé", | |
| finished_at=time.time(), | |
| summary=_summarize(results, len(files)), | |
| ) | |
| _purge_old_jobs() | |
| def _run_job_files(job_id, files, level, model_idx, lang, mode, decl_types, | |
| policy, manual_models, results): | |
| for i, f in enumerate(files): | |
| if _cancel_requested(job_id): | |
| raise JobCancelled() | |
| 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): | |
| # Point de contrôle d'annulation : chaque étape du pipeline passe | |
| # ici (analyse, génération, audit, harnais, réparation, échelon). | |
| if _cancel_requested(job_id): | |
| raise JobCancelled() | |
| _set_job(job_id, step_label=f"[{_i + 1}/{len(files)}] {_name} — {label}") | |
| try: | |
| if mode == "declinaisons": | |
| for decl_type, result in run_declinaisons( | |
| content=f["content"], | |
| filename=name, | |
| level=level, | |
| model_idx=model_idx, | |
| lang=lang, | |
| types=decl_types, | |
| set_step=set_step, | |
| policy=policy, | |
| manual_models=manual_models, | |
| ): | |
| out_name = _decl_output_name(name, decl_type) | |
| results.append({"filename": out_name, "status": "done", "result": result}) | |
| logger.info("Déclinaison %s : harnais %s, modèle %s, %d warnings, %.1fs, %.4f$", | |
| out_name, "VERT" if result["harness"]["ok"] else "ROUGE", | |
| result.get("model_used"), | |
| len(result["warnings"]), result["duration_s"], | |
| result["cost"]["usd"]) | |
| else: | |
| result = run_with_policy( | |
| content=f["content"], | |
| filename=name, | |
| level=level, | |
| lang=lang, | |
| policy=policy, | |
| manual_models=manual_models, | |
| set_step=set_step, | |
| ) | |
| results.append({"filename": name, "status": "done", "result": result}) | |
| logger.info("Fichier %s : harnais %s, modèle %s, %d warnings, %.1fs, %.4f$", | |
| name, "VERT" if result["harness"]["ok"] else "ROUGE", | |
| result.get("model_used"), | |
| len(result["warnings"]), result["duration_s"], | |
| result["cost"]["usd"]) | |
| except JobCancelled: | |
| raise # remonte au gestionnaire de _run_job | |
| 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) | |
| 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): | |
| 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"'}, | |
| ) | |
| def index(): | |
| return render_template("index.html") | |
| def health(): | |
| from app.config import APP_VERSION | |
| return jsonify({ | |
| "status": "ok", | |
| "version": APP_VERSION, | |
| "openrouter_api_key": bool(os.getenv("OPENROUTER_API_KEY")), | |
| "openai_api_key": bool(os.getenv("OPENAI_API_KEY")), | |
| }) | |
| def models(): | |
| from app.models.catalog import CANDIDATES | |
| from app.models.policy import POLICIES, load_recommended | |
| return jsonify({ | |
| "models": {str(k): v for k, v in AVAILABLE_MODELS.items()}, | |
| "default_idx": DEFAULT_MODEL_IDX, | |
| "default_lang": DEFAULT_LANG, | |
| "policies": list(POLICIES), | |
| "default_policy": DEFAULT_POLICY, | |
| "catalog": CANDIDATES, # candidats par rôle (sans Fable) | |
| "recommended": load_recommended(), | |
| }) | |
| 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 | |
| mode = (data.get("mode") or DEFAULT_MODE).strip() | |
| if mode not in VALID_MODES: | |
| return jsonify({"error": f"'mode' invalide : {mode!r} (pythonise|declinaisons)."}), 400 | |
| decl_types: list = [] | |
| if mode == "declinaisons": | |
| types_obj = data.get("types") or {} | |
| if not isinstance(types_obj, dict): | |
| return jsonify({"error": "'types' doit être un objet {qcm: bool, qat: bool}."}), 400 | |
| decl_types = [t for t in ("qcm", "qat") if types_obj.get(t)] | |
| if not decl_types: | |
| return jsonify({"error": "En mode 'declinaisons', cocher au moins un type (qcm/qat)."}), 400 | |
| # Politique de sélection de modèle (§5) — Fable absent du catalogue. | |
| from app.models.catalog import CATALOG, ROLES | |
| from app.models.policy import POLICIES | |
| policy = (data.get("policy") or DEFAULT_POLICY).strip() | |
| if policy not in POLICIES: | |
| return jsonify({"error": f"'policy' invalide : {policy!r} (auto|best|cheap|manual)."}), 400 | |
| manual_models: dict = {} | |
| if policy == "manual": | |
| models_obj = data.get("models") or {} | |
| if not isinstance(models_obj, dict): | |
| return jsonify({"error": "'models' doit être un objet {generate, audit, mecanique}."}), 400 | |
| for role in ROLES: | |
| key = models_obj.get(role) | |
| if not key: # absent OU "" (select vide) → défaut config | |
| continue | |
| if key not in CATALOG or role not in CATALOG[key]["roles"]: | |
| return jsonify({"error": f"Modèle {key!r} invalide pour le rôle {role} " | |
| f"(catalogue : {[k for k, v in CATALOG.items() if role in v['roles']]})."}), 400 | |
| manual_models[role] = key | |
| 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, mode, decl_types, | |
| policy, manual_models), | |
| daemon=True, | |
| ).start() | |
| return jsonify({"job_id": job_id}), 202 | |
| 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"), | |
| }) | |
| def job_cancel(job_id: str): | |
| """Arrêt coopératif : prend effet au prochain point d'étape (un appel | |
| LLM en vol se termine, rien ne s'enchaîne). Résultats partiels gardés.""" | |
| with _JOBS_LOCK: | |
| job = _JOBS.get(job_id) | |
| if not job: | |
| return jsonify({"error": "Job inconnu."}), 404 | |
| if job["status"] != "running": | |
| return jsonify({"status": job["status"], | |
| "error": "Job déjà terminé."}), 409 | |
| job["cancel_requested"] = True | |
| return jsonify({"status": "cancelling"}), 202 | |
| 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", | |
| ) | |