Spaces:
Running
Running
Download app.py from RDS777/TARGET_SPACE: direct link, hf CLI and curl.
- Browser
- Download file 37.5 kB
-
https://huggingface.co/spaces/RDS777/TARGET_SPACE/resolve/main/app.py
- Command line
-
hf download hf://spaces/RDS777/TARGET_SPACE/app.py
-
curl -L -o app.py https://huggingface.co/spaces/RDS777/TARGET_SPACE/resolve/main/app.py
37.5 kB
| """Point d'entrée unique du Space (port 7860) : FastAPI, interface Gradio UNIQUE montée à la racine, actifs Symbioza. | |
| Interface du Space UNIQUE (v4.0, mode local) : un seul Space, aucune inférence en ligne. | |
| Trois onglets : pilotage de l'entraînement local (courbes, historique, console), dépôt de fichiers JSONL vérifiés | |
| dans le bac à sable, et vérification de code (boucle M.O.U.H. : AST, pytest, Z3). | |
| """ | |
| import hmac | |
| import json | |
| import os | |
| import subprocess | |
| import sys | |
| import threading | |
| import time | |
| from contextlib import asynccontextmanager | |
| from pathlib import Path | |
| from mouh.learning.local_store import hf_home_for, resolve_root | |
| ROOT = resolve_root() | |
| _HF_HOME = hf_home_for(ROOT) # v4.1.2 : None = cache des poids sur le disque local (le montage /data faisait dépasser la RAM) | |
| if _HF_HOME is not None: | |
| os.environ.setdefault("HF_HOME", str(_HF_HOME)) | |
| import gradio as gr # noqa: E402 | |
| from fastapi import APIRouter, FastAPI # noqa: E402 | |
| from fastapi.staticfiles import StaticFiles # noqa: E402 | |
| import mouh.service as service # noqa: E402 | |
| import mouh.lab as lab # noqa: E402 - bancs d'essai du laboratoire (v4.5) | |
| import mouh.world as world # noqa: E402 - ce que l'essaim bâtit dans l'arène (v4.4, secteurs en v4.5) | |
| from agents.core import ( # noqa: E402 - pipeline d'agents, journal de travail, auto-jeu | |
| AGENTS, | |
| EMPTY_STATEMENT, | |
| AgentPipeline, | |
| WorkJournal, | |
| generate_experiences, | |
| precheck, | |
| read_sessions, | |
| ) | |
| from engine import trainer as rl # noqa: E402 - n'importe ni torch ni le modèle : aucune VRAM réservée ici | |
| from engine.trainer import is_out_of_memory # noqa: E402 | |
| from mouh.geometry.api import make_router # noqa: E402 | |
| from mouh.geometry.sync import SyncUnavailable, dataset_repo, push_pending # noqa: E402 | |
| from mouh.learning import dashboard, recorder # noqa: E402 | |
| from mouh.learning.local_ingest import ingest_jsonl # noqa: E402 | |
| from mouh.learning.local_store import LocalStore # noqa: E402 | |
| from mouh.learning.local_trainer import ( # noqa: E402 | |
| LocalTrainer, | |
| gpu_status, | |
| import_from_hub, | |
| suspend_remote_jobs, | |
| ) | |
| from mouh.lab.api import make_lab_router # noqa: E402 - monde et laboratoire en lecture (v4.5) | |
| from mouh.webui import StaticCache, make_page_router, make_ui_router # noqa: E402 - page unique et ses routes JSON | |
| from mouh.service import ( # noqa: E402 | |
| GENERATOR_CHOICES, | |
| load_model_generator, | |
| load_unique_generator, | |
| stream_session, | |
| ) | |
| STORE = LocalStore(ROOT) | |
| TRAINER = LocalTrainer(STORE) | |
| JOURNAL = WorkJournal(STORE.root / "work_journal.jsonl") # journal de travail daté, partagé par tout le Space | |
| SKY_QUEUE_NAME = "sky-trajectories" # file d'attente locale ; hors de data/, donc jamais lue par l'entraînement | |
| def world_queue() -> Path: | |
| return STORE.root / SKY_QUEUE_NAME | |
| def world_dir() -> Path: | |
| """Magasin du monde (v4.5) : à part de la file de géométries Symbioza.""" | |
| return STORE.root / "world" | |
| def build_world(deposit, source: str): | |
| """Fait entrer une expérience (certifiée, ou rompue par son banc d'essai) dans le monde, et le journalise. | |
| ``deposit`` est un appel sans argument vers ``mouh.world``. Une panne est journalisée (« monde_erreur ») et ne | |
| remonte jamais : le verdict de l'Arbitre est déjà rendu, et le monde ne peut que s'y ajouter. | |
| """ | |
| try: | |
| result = deposit() | |
| except Exception as exc: # noqa: BLE001 | |
| JOURNAL.append("monde_erreur", agent="Arbitre", source=source, erreur=f"{type(exc).__name__}: {exc}"[:300]) | |
| return None | |
| if not result.get("ok"): | |
| return result # un échec sans artefact simulé ne bâtit rien : rien à journaliser | |
| JOURNAL.append("monde", agent="Arbitre", source=source, ok=True, etat=result.get("status", ""), | |
| matiere=result.get("kind", ""), domaine=result.get("domain", ""), slot=result.get("slot", ""), | |
| detail=world.describe(result)) | |
| return result | |
| def world_hook(episode) -> None: | |
| """Crochet de fin d'épisode : l'état mesuré (validé, ou rupture chiffrée) entre dans le monde.""" | |
| world.building_end(episode.task_hash) | |
| build_world(lambda: world.deposit_episode(episode, world_dir()), episode.source) | |
| def world_hook_verify(task, outcome) -> None: | |
| """Même crochet pour la boucle de l'onglet « Vérifier » : le code final certifié passe au banc d'essai.""" | |
| from mouh.learning.records import task_hash | |
| files = dict(outcome.final.files) | |
| tests = dict(task.test_files) | |
| build_world(lambda: world.deposit(world_dir(), instruction=task.instruction, files=files, | |
| task_hash=task_hash(task.instruction, tests), certified=True, | |
| report=lab.evaluate(files, tests), episode_id=str(outcome.session_id), | |
| attempts=int(outcome.attempts or 1), source="interface"), "interface") | |
| def announce_building(instruction: str, tests: dict, candidate: str) -> str: | |
| """Marque l'emplacement « chantier » pendant l'expérience (domaine : spécification, sinon code fourni).""" | |
| from mouh.learning.records import task_hash | |
| th = task_hash((instruction or "").strip(), tests) | |
| domain = "algorithmique" | |
| try: | |
| spec = lab.read_spec(tests) or {} | |
| fn = lab.artifact_function({"solution.py": candidate or ""}, spec) | |
| domain = spec.get("domain") or (lab.ARTIFACTS[fn][0] if fn else domain) | |
| world.building_start(world_dir(), th, instruction, domain) | |
| except Exception: # noqa: BLE001, S110 — un chantier mal annoncé ne bloque jamais l'expérience | |
| pass | |
| return th | |
| PIPELINE = AgentPipeline(STORE.root, journal=JOURNAL, on_finished=world_hook) | |
| REMOTE_ADAPTER = os.environ.get("UNIQUE_ADAPTER_REPO", "").strip() # pour l'import unique depuis le Hub | |
| VERIFY_ONLY = GENERATOR_CHOICES[0] | |
| UI_CHOICES = [c for c in GENERATOR_CHOICES if c != GENERATOR_CHOICES[1]] # sans le nano-modèle non entraîné | |
| _GPU = {"value": None} | |
| _STAMP = {"value": None} | |
| EXAMPLES = { | |
| "Bug : add retourne a - b (rejeté)": ( | |
| "Écrire add(a, b) qui renvoie la somme.", | |
| "def add(a, b):\n return a - b\n", | |
| "from solution import add\n\n\ndef test_add():\n assert add(2, 3) == 5\n\n\ndef test_zero():\n assert add(0, 0) == 0\n", | |
| "", | |
| ), | |
| "Correct : add retourne a + b (certifié)": ( | |
| "Écrire add(a, b) qui renvoie la somme.", | |
| "def add(a, b):\n return a + b\n", | |
| "from solution import add\n\n\ndef test_add():\n assert add(2, 3) == 5\n\n\ndef test_zero():\n assert add(0, 0) == 0\n", | |
| '[{"name": "croissance", "declarations": {"x": "Int"}, "assumptions": [[">", "x", 0]], "goal": [">", ["+", "x", 1], 1]}]', | |
| ), | |
| } | |
| INTRO = """# UNIQUE — fabrique locale | |
| Un seul Space, **aucune inférence en ligne** : les données vivent dans le dossier local, l'entraînement tourne ici | |
| (sur le GPU du Space), et rien n'est certifié sans passer par le bac à sable (AST, pytest, Z3).""" | |
| DATA_HELP = """Formats acceptés, une ligne JSON par exemple : | |
| - **tâche simple** : `{"instruction": "...", "tests": "from solution import f\\n\\ndef test_f():\\n assert ...", "solution": "def f(...): ...", "level": "extrême", "rejected": "code faux (facultatif)"}` ; | |
| - **session** `mouh-session/1` certifiée (format du Space et de l'entraînement) ; | |
| - **échec réel** `{task_hash, instruction, tests, files, pytest_output}`. | |
| Chaque solution est exécutée contre ses tests ; un `rejected` qui échoue vraiment devient un échec réel (paire DPO et | |
| exemple de réparation). Les paires `{prompt, chosen, rejected}` sans tests sont refusées : elles sont invérifiables.""" | |
| def authorized(key) -> bool: | |
| expected = os.environ.get("UNIQUE_ADMIN_KEY", "") | |
| return bool(expected) and hmac.compare_digest(str(key or "").encode(), expected.encode()) | |
| REFUSED = "Commande refusée : clé d'administration absente ou incorrecte (secret UNIQUE_ADMIN_KEY)." | |
| def gpu() -> dict: | |
| if _GPU["value"] is None: | |
| _GPU["value"] = gpu_status() | |
| return _GPU["value"] | |
| def sync_adapter() -> None: | |
| """Le Space sert l'adaptateur local promu ; un nouvel adaptateur est rechargé à la génération suivante.""" | |
| if STORE.has_adapter(): | |
| os.environ["UNIQUE_ADAPTER_REPO"] = str(STORE.adapter_dir) | |
| stamp = STORE.adapter_stamp() | |
| if stamp != _STAMP["value"]: | |
| with service._MODEL_LOCK: | |
| service._MODEL_CACHE.pop("unique", None) | |
| _STAMP["value"] = stamp | |
| def refresh(): | |
| sync_adapter() | |
| state, progress = STORE.read_json("state.json"), STORE.read_json("progress.json") | |
| rows = dashboard.cycle_rows(state, progress) | |
| status = dashboard.status_markdown(store=STORE, trainer=TRAINER, gpu=gpu(), state=state, progress=progress, | |
| inventory=STORE.inventory()) | |
| return status, dashboard.pass_frame(rows), dashboard.loss_frame(rows), dashboard.table(rows), TRAINER.log_tail() | |
| def start_cmd(key, dpo): | |
| if not authorized(key): | |
| return REFUSED | |
| TRAINER.dpo = bool(dpo) | |
| msg = TRAINER.start() | |
| TRAINER.note(msg) | |
| return msg | |
| def stop_cmd(key): | |
| if not authorized(key): | |
| return REFUSED | |
| msg = TRAINER.stop() | |
| TRAINER.note(msg) | |
| return msg | |
| def suspend_cmd(key): | |
| if not authorized(key): | |
| return REFUSED | |
| try: | |
| done = suspend_remote_jobs(os.environ.get("HF_TOKEN", "").strip() or None, cancel_running=True) | |
| except Exception as exc: # noqa: BLE001 | |
| return f"Jobs Hugging Face illisibles : {exc}" | |
| msg = "Jobs Hugging Face : " + (", ".join(done) if done else "aucun job actif à arrêter") | |
| TRAINER.note(msg) | |
| return msg | |
| def import_cmd(key): | |
| if not authorized(key): | |
| return REFUSED | |
| token = os.environ.get("HF_TOKEN", "").strip() or None | |
| repo = os.environ.get("MOUH_DATASET_REPO", "").strip() | |
| def work(): | |
| try: | |
| TRAINER.note("import depuis le Hub en cours…") | |
| done = import_from_hub(STORE, repo, REMOTE_ADAPTER or None, token) | |
| TRAINER.note("import terminé : " + (", ".join(done) or "rien à importer")) | |
| except Exception as exc: # noqa: BLE001 | |
| TRAINER.note(f"import impossible : {exc}") | |
| threading.Thread(target=work, daemon=True).start() | |
| return "Import lancé (une seule fois) : suivez la console." | |
| def upload_texts(items, verify) -> str: | |
| """Dépose des fichiers .jsonl déjà lus en mémoire (page unique) ; même ingestion que l'onglet Données.""" | |
| reports = [] | |
| for raw_name, text in items: | |
| name = os.path.basename(str(raw_name)) | |
| if not name.endswith(".jsonl"): | |
| reports.append(f"**{name}** : refusé (seuls les fichiers .jsonl sont acceptés)") | |
| continue | |
| rep = ingest_jsonl(text, name, STORE.data_dir, STORE.failures_dir, verify=bool(verify), log=TRAINER.note) | |
| reports.append(rep.markdown()) | |
| return "\n\n".join(reports) + "\n\nCes exemples seront lus au prochain passage d'entraînement." | |
| def upload_cmd(files, verify, key): | |
| if not authorized(key): | |
| return REFUSED | |
| if not files: | |
| return "Aucun fichier choisi." | |
| items = [] | |
| for f in files if isinstance(files, list) else [files]: | |
| path = f if isinstance(f, str) else getattr(f, "name", None) | |
| if not path: | |
| continue | |
| with open(path, encoding="utf-8", errors="replace") as fh: | |
| items.append((os.path.basename(path), fh.read())) | |
| return upload_texts(items, verify) | |
| def run(instruction, code, tests, proofs, max_attempts, generator, record=True): | |
| if generator != VERIFY_ONLY and TRAINER.running: | |
| yield [], ("GPU occupé par l'entraînement : arrêtez-le, ou choisissez « Vérification seule »."), "", "" | |
| return | |
| sync_adapter() | |
| last = None | |
| for s in stream_session(instruction, code, tests, proofs, int(max_attempts), generator, bool(record), | |
| on_certified=world_hook_verify): | |
| last = s | |
| yield s["rows"], s["status"], s["final_code"], s["report"] | |
| if last is None: | |
| yield [], "aucun résultat", "", "" | |
| def load_example(name): | |
| return EXAMPLES[name] | |
| # -- pipeline d'agents : le monde où UNIQUE fait ses expériences -------------------------------- | |
| AGENT_HELP = """Une **expérience** complète : l'Architecte lit le contrat dans les tests, le Codeur propose, | |
| le Testeur exécute (pytest, puis propriétés Hypothesis, puis preuves Z3), l'Arbitre accepte ou renvoie les | |
| diagnostics au Codeur. Chaque étape est écrite, datée, dans `work_journal.jsonl`. | |
| Une expérience certifiée part dans `data/`, sa première tentative ratée dans `failures/` : l'entraînement les | |
| lit sans rien changer. L'**auto-jeu** fabrique des variantes fautives de solutions déjà certifiées et ne garde | |
| que celles qui échouent réellement aux tests.""" | |
| def agent_generator(choice: str): | |
| """Relie un générateur du Space (UNIQUE, M.O.U.H.) au Codeur du pipeline. ``None`` = vérification seule.""" | |
| if choice == VERIFY_ONLY: | |
| return None, "vérification seule : aucun code n'est proposé" | |
| from mouh.jev.types import JevDiagnostic, JevErrorType, JevMessageType, JevSeverity, TaskPayload | |
| from mouh.verification.generators import GenerationRequest | |
| gen, note = (load_model_generator() if choice == GENERATOR_CHOICES[1] else load_unique_generator()) | |
| if choice in GENERATOR_CHOICES[2:]: | |
| gen = gen.with_options(adaptive=choice == GENERATOR_CHOICES[3]) | |
| def generate(instruction, tests, plan, attempt, previous_files, diagnostics): | |
| task = TaskPayload(instruction=instruction or "(sans énoncé)", candidate_files={}, test_files=dict(tests)) | |
| diags = tuple(JevDiagnostic(error_type=JevErrorType.TEST_FAILURE, severity=JevSeverity.ERROR, | |
| message=str(d)[:4000]) for d in (diagnostics or ())) | |
| kind = JevMessageType.TASK_SUBMISSION if attempt == 1 else JevMessageType.CORRECTION_REQUEST | |
| return gen.generate(GenerationRequest(task=task, attempt=int(attempt), message_type=kind, | |
| experts=(1,), previous_files=previous_files, diagnostics=diags)) | |
| return generate, note | |
| GPU_BUSY = ("GPU occupé par l'entraînement continu : le modèle ne tient pas à côté (un 7B en bf16 demande " | |
| "environ 15 Go). Arrêtez l'entraînement dans Pilotage, ou choisissez « Vérification seule » pour " | |
| "faire tourner l'expérience sur un code que vous fournissez.") | |
| def out_of_memory_message(exc: BaseException) -> str: | |
| """Message court et utile quand la VRAM manque, au lieu de la trace CUDA brute.""" | |
| return ("Mémoire GPU insuffisante pour charger le modèle. Arrêtez l'entraînement continu dans Pilotage " | |
| "(il réserve la carte), puis réessayez ; sinon, utilisez « Vérification seule ». " | |
| f"Détail : {str(exc).strip().splitlines()[0][:200]}") | |
| def refusal_message(why: str) -> str: | |
| """Phrase claire pour un refus décidé avant toute exécution (aucun modèle n'a été chargé).""" | |
| if why == EMPTY_STATEMENT: | |
| return ("**REFUSÉ** · énoncé vide : décrivez la tâche dans « Énoncé de la tâche », puis relancez. " | |
| "Les tests sont lus, mais aucun modèle n'a été chargé.") | |
| return f"**REFUSÉ** · {why}. Aucun modèle n'a été chargé." | |
| def agent_cmd(instruction, tests, properties, proofs_json, max_attempts, choice, candidate, lab_spec=""): | |
| """Lance une expérience et renvoie (résumé, étapes, code final, journal). | |
| ``lab_spec`` (v4.5) : spécification de laboratoire (JSON) ; elle voyage avec les tests sous ``lab_spec.json`` et | |
| fait du banc d'essai de son domaine un oracle de l'Arbitre.""" | |
| tests_text = (tests or "").strip() | |
| if not tests_text: | |
| return "Aucun test : rien ne peut être vérifié, donc rien n'est lancé.", [], "", journal_rows() | |
| task_tests = {"test_solution.py": tests_text} | |
| if (lab_spec or "").strip(): | |
| task_tests[lab.SPEC_FILE] = lab_spec.strip() | |
| # Refus avant tout chargement : un énoncé vide ou des tests inexploitables ne doivent pas coûter ~15 s | |
| # de chargement d'un 7B (ni de VRAM). L'épisode refusé est tout de même journalisé, comme avant. | |
| why = precheck(instruction or "", task_tests) | |
| if why: | |
| PIPELINE.run_episode(instruction or "", task_tests, source="interface") | |
| return refusal_message(why), [], "", journal_rows() | |
| if choice != VERIFY_ONLY and TRAINER.running: # même garde que l'onglet « Vérifier du code » | |
| return GPU_BUSY, [], "", journal_rows() | |
| try: | |
| gen, note = agent_generator(choice) | |
| except Exception as exc: # noqa: BLE001 | |
| if is_out_of_memory(exc): | |
| return out_of_memory_message(exc), [], "", journal_rows() | |
| return f"Générateur indisponible : {exc}", [], "", journal_rows() | |
| proofs = [] | |
| if (proofs_json or "").strip(): | |
| try: | |
| raw = json.loads(proofs_json) | |
| proofs = raw if isinstance(raw, list) else [raw] | |
| except ValueError as exc: | |
| return f"Preuves illisibles : {exc}", [], "", journal_rows() | |
| props = {"test_properties.py": properties} if (properties or "").strip() else None | |
| PIPELINE.arbiter.max_attempts = max(1, int(max_attempts)) | |
| th = announce_building(instruction or "", task_tests, candidate or "") | |
| try: | |
| episode = PIPELINE.run_episode(instruction or "", task_tests, | |
| candidate_files={"solution.py": candidate} if (candidate or "").strip() else None, | |
| generator=gen, properties=props, proofs=proofs, source="interface") | |
| except Exception as exc: # noqa: BLE001 — une panne du générateur ne doit pas casser l'onglet | |
| world.building_end(th) | |
| JOURNAL.append("episode_error", source="interface", erreur=f"{type(exc).__name__}: {exc}"[:400]) | |
| if is_out_of_memory(exc): | |
| return out_of_memory_message(exc), [], "", journal_rows() | |
| return f"Expérience interrompue : {type(exc).__name__} : {str(exc)[:300]}", [], "", journal_rows() | |
| v = episode.verdict | |
| verdict_line = (f"**{episode.status.upper()}** en {episode.attempts} tentative(s) · " | |
| f"tests {'OK' if v.get('tests_ok') else 'échec'}" | |
| + ("" if v.get("properties_ok") is None else f" · propriétés {'OK' if v['properties_ok'] else 'échec'}") | |
| + ("" if v.get("proofs_ok") is None else f" · preuves {'OK' if v['proofs_ok'] else 'échec'}") | |
| + ("" if v.get("simulation_ok") is None else | |
| f" · simulation {'OK' if v['simulation_ok'] else 'RUPTURE'}") | |
| + f" · score {v.get('score', 0):.2f} · {episode.duration_s:.1f} s" | |
| + (f" · {note}" if note else "")) | |
| rows = [[s["attempt"], s["agent"], s["action"], "oui" if s["ok"] else "non", s["detail"]] for s in episode.steps] | |
| code = "\n\n".join(f"# === {n} ===\n{c}" for n, c in sorted(episode.files.items())) or "(aucun code produit)" | |
| return verdict_line, rows, code, journal_rows() | |
| def selfplay_cmd(key, limit, per_task): | |
| if not authorized(key): | |
| return REFUSED, journal_rows() | |
| try: | |
| report = generate_experiences(STORE.root, journal=JOURNAL, limit=int(limit), per_task=int(per_task)) | |
| except Exception as exc: # noqa: BLE001 | |
| return f"Auto-jeu impossible : {exc}", journal_rows() | |
| msg = (f"{report['echecs_produits']} échec(s) vérifié(s) à partir de {report['sessions_examinees']} session(s). " | |
| f"{report['tests_sans_mutation_detectee']} session(s) dont aucune variante fautive n'a été détectée " | |
| f"(couverture de tests faible).") | |
| return msg, journal_rows() | |
| def world_rebuild_cmd(key, limit=60): | |
| """Matérialise dans le monde les sessions déjà certifiées (les plus récentes d'abord retenues, une par tâche).""" | |
| if not authorized(key): | |
| return REFUSED | |
| limit = max(1, min(int(limit), 200)) | |
| try: | |
| report = world.backfill(read_sessions(STORE.data_dir, limit=limit * 3), world_dir(), limit=limit) | |
| except Exception as exc: # noqa: BLE001 | |
| JOURNAL.append("monde_erreur", agent="Arbitre", source="reconstruction", erreur=f"{type(exc).__name__}: {exc}"[:300]) | |
| return f"Reconstruction impossible : {type(exc).__name__} : {str(exc)[:200]}" | |
| JOURNAL.append("monde", agent="Arbitre", source="reconstruction", ok=True, matiere="reconstruction", slot="", | |
| detail=(f"monde : reconstruction — {report['deposees']} objet(s) déposé(s) dont {report['ruptures']} " | |
| f"en rupture, {report['inchangees']} déjà présent(s), {report['refusees']} refusé(s) sur " | |
| f"{report['examinees']} tâche(s) certifiée(s)")) | |
| msg = (f"{report['examinees']} tâche(s) certifiée(s) examinée(s) : {report['deposees']} nouvel(s) objet(s) dans le monde " | |
| f"(dont {report['ruptures']} en rupture au banc d'essai), {report['inchangees']} déjà présent(s), " | |
| f"{report['refusees']} refusé(s).") | |
| if report["raisons"]: | |
| msg += " Raisons : " + " | ".join(report["raisons"]) | |
| return msg | |
| def world_state() -> dict: | |
| """Aperçu du monde pour la console : objets par secteur et par état, chantiers en cours.""" | |
| return world.summary(world_dir()) | |
| def journal_rows(limit: int = 40): | |
| return [[e.get("at", "")[11:19], e.get("kind", ""), e.get("agent") or e.get("phase") or "", | |
| (e.get("detail") or e.get("instruction") or e.get("resultat") or | |
| json.dumps({k: v for k, v in e.items() if k not in ("seq", "at", "kind")}, ensure_ascii=False))[:160]] | |
| for e in JOURNAL.tail(limit)] | |
| def agents_refresh(): | |
| s = JOURNAL.stats() | |
| line = (f"**Épisodes** {s['episodes']} · certifiés {s['certifiees']} · réparés {s['reparees']} · " | |
| f"échecs {s['echecs']} · compétences {s['competences']} · " | |
| f"expériences d'auto-jeu {s['experiences_autojeu']} · cache de verdicts {PIPELINE.cache.info()['entrees']}") | |
| if TRAINER.running: # dit la contrainte avant le clic, pas après l'erreur | |
| line += ("\n\n⚠ Entraînement continu en cours : il réserve le GPU. Dans cet onglet, seul " | |
| "« Vérification seule » fonctionne tant qu'il tourne.") | |
| return line, journal_rows() | |
| # -- moteur GRPO/DPO lancé hors du processus de l'interface ------------------------------------- | |
| RL_STATE: dict = {"proc": None, "log": None} | |
| def rl_plan_text() -> str: | |
| try: | |
| p = rl.plan() | |
| except Exception as exc: # noqa: BLE001 | |
| return f"Profil de VRAM illisible : {exc}" | |
| return f"**Profil** · {p.describe()}" + ("\n\n" + " · ".join(p.notes) if p.notes else "") | |
| def rl_start(key, phases): | |
| if not authorized(key): | |
| return REFUSED | |
| if TRAINER.running: | |
| return "L'entraînement continu occupe le GPU : arrêtez-le dans Pilotage avant de lancer GRPO/DPO." | |
| proc = RL_STATE.get("proc") | |
| if proc is not None and proc.poll() is None: | |
| return "Une passe GRPO/DPO tourne déjà." | |
| log_path = STORE.logs_dir / f"rl-{time.strftime('%Y%m%dT%H%M%S')}.log" | |
| cmd = [sys.executable, "-m", "engine.trainer", "--root", str(STORE.root), "--phases", str(phases).strip()] | |
| env = {**os.environ, "PYTHONUNBUFFERED": "1"} | |
| with open(log_path, "ab") as fh: | |
| RL_STATE["proc"] = subprocess.Popen(cmd, stdout=fh, stderr=subprocess.STDOUT, env=env, | |
| cwd=str(Path(__file__).resolve().parent)) | |
| RL_STATE["log"] = log_path | |
| JOURNAL.append("entrainement", phase="lancement", phases=str(phases), journal=log_path.name) | |
| return f"Passe GRPO/DPO lancée ({phases}) · journal {log_path.name} · adaptateur écrit dans adapter_rl/" | |
| def rl_stop(key): | |
| if not authorized(key): | |
| return REFUSED | |
| proc = RL_STATE.get("proc") | |
| if proc is None or proc.poll() is not None: | |
| return "Aucune passe GRPO/DPO en cours." | |
| proc.terminate() | |
| return "Arrêt demandé." | |
| def rl_log(n: int = 120) -> str: | |
| path = RL_STATE.get("log") | |
| if path is None or not Path(path).exists(): | |
| return "aucune passe GRPO/DPO lancée dans cette session" | |
| with open(path, "rb") as fh: | |
| fh.seek(0, os.SEEK_END) | |
| fh.seek(max(0, fh.tell() - 48_000)) | |
| text = fh.read().decode("utf-8", "replace") | |
| return "\n".join(text.splitlines()[-n:]) | |
| with gr.Blocks(title="UNIQUE") as demo: | |
| gr.Markdown(INTRO) | |
| with gr.Tab("Pilotage"): | |
| status = gr.Markdown("Lecture de l'état…") | |
| with gr.Row(): | |
| admin_key = gr.Textbox(label="Clé d'administration (UNIQUE_ADMIN_KEY)", type="password", scale=2) | |
| dpo = gr.Checkbox(value=TRAINER.dpo, label="Phase DPO (paires data/ + failures/)") | |
| with gr.Row(): | |
| start = gr.Button("Démarrer l'entraînement continu", variant="primary") | |
| stop = gr.Button("Arrêter", variant="stop") | |
| suspend = gr.Button("Arrêter les anciens jobs Hugging Face") | |
| importer = gr.Button("Importer une fois depuis le Hub") | |
| command_msg = gr.Markdown() | |
| with gr.Row(): | |
| pass_plot = gr.LinePlot(x="n", y="taux", color="série", title="Réussite à l'examen (%) par phase évaluée") | |
| loss_plot = gr.LinePlot(x="n", y="loss", color="phase", title="Loss d'entraînement par phase") | |
| history = gr.Dataframe(headers=dashboard.TABLE_HEADERS, interactive=False, wrap=True, | |
| label="Historique des cycles (plus récent en haut)") | |
| console = gr.Textbox(label="Console", lines=16, max_lines=16, interactive=False, autoscroll=True) | |
| start.click(start_cmd, [admin_key, dpo], command_msg) | |
| stop.click(stop_cmd, admin_key, command_msg) | |
| suspend.click(suspend_cmd, admin_key, command_msg) | |
| importer.click(import_cmd, admin_key, command_msg) | |
| with gr.Tab("Données"): | |
| gr.Markdown(DATA_HELP) | |
| uploads = gr.File(label="Fichiers .jsonl", file_count="multiple", file_types=[".jsonl"]) | |
| with gr.Row(): | |
| verify = gr.Checkbox(value=True, label="Réexécuter aussi les sessions déjà certifiées (recommandé)") | |
| data_key = gr.Textbox(label="Clé d'administration", type="password") | |
| add = gr.Button("Ajouter au dataset local", variant="primary") | |
| upload_msg = gr.Markdown() | |
| add.click(upload_cmd, [uploads, verify, data_key], upload_msg) | |
| with gr.Tab("Vérifier du code"): | |
| with gr.Row(): | |
| with gr.Column(): | |
| example = gr.Dropdown(list(EXAMPLES), label="Exemple", value=None) | |
| instruction = gr.Textbox(label="Énoncé de la tâche", lines=2) | |
| code = gr.Code(label="Code candidat (solution.py)", language="python", lines=10) | |
| tests = gr.Code(label="Tests pytest (test_solution.py)", language="python", lines=10) | |
| proofs = gr.Code(label="Obligations de preuve Z3 (JSON, optionnel)", language="json", lines=6) | |
| with gr.Row(): | |
| attempts = gr.Slider(1, 8, value=4, step=1, label="Tentatives max") | |
| generator = gr.Dropdown(UI_CHOICES, value=VERIFY_ONLY, label="Générateur de corrections") | |
| record = gr.Checkbox(value=os.environ.get("MOUH_RECORD_DEFAULT", "1") == "1", | |
| label="Garder cette session pour l'entraînement (dossier local data/)") | |
| btn = gr.Button("Vérifier", variant="primary") | |
| with gr.Column(): | |
| out_status = gr.Textbox(label="Statut", interactive=False) | |
| bus = gr.Dataframe(headers=["#", "type", "émetteur → destinataire", "résumé"], interactive=False, | |
| wrap=True, label="Bus JEV (temps réel)") | |
| final = gr.Code(label="Sortie certifiée", language="python", interactive=False) | |
| report = gr.Code(label="Rapport", language="json", interactive=False) | |
| example.change(load_example, example, [instruction, code, tests, proofs]) | |
| btn.click(run, [instruction, code, tests, proofs, attempts, generator, record], [bus, out_status, final, report]) | |
| with gr.Tab("Agents"): | |
| gr.Markdown(AGENT_HELP) | |
| agents_status = gr.Markdown("Lecture du journal…") | |
| with gr.Row(): | |
| with gr.Column(): | |
| a_instruction = gr.Textbox(label="Énoncé de la tâche", lines=2) | |
| a_tests = gr.Code(label="Tests pytest (test_solution.py) — la vérité terrain", language="python", | |
| lines=9) | |
| a_candidate = gr.Code(label="Code de départ (optionnel)", language="python", lines=6) | |
| with gr.Accordion("Oracles supplémentaires (optionnels)", open=False): | |
| a_properties = gr.Code(label="Tests de propriétés Hypothesis (test_properties.py) — vide : " | |
| "un contrôle de déterminisme est engendré automatiquement", | |
| language="python", lines=6) | |
| a_proofs = gr.Code(label="Obligations de preuve Z3 (JSON)", language="json", lines=4) | |
| with gr.Row(): | |
| a_attempts = gr.Slider(1, 8, value=4, step=1, label="Tentatives max") | |
| a_generator = gr.Dropdown(UI_CHOICES, value=VERIFY_ONLY, label="Codeur") | |
| a_run = gr.Button("Lancer l'expérience", variant="primary") | |
| with gr.Column(): | |
| a_verdict = gr.Markdown() | |
| a_steps = gr.Dataframe(headers=["tentative", "agent", "action", "ok", "détail"], interactive=False, | |
| wrap=True, label=f"Chaîne {' → '.join(AGENTS)}") | |
| a_code = gr.Code(label="Code retenu", language="python", interactive=False) | |
| with gr.Accordion("Auto-jeu : fabriquer des échecs vérifiés", open=False): | |
| gr.Markdown("Mute une solution certifiée, exécute les tests, et ne garde que les variantes qui " | |
| "échouent réellement. Une mutation que les tests ne voient pas signale un trou de " | |
| "couverture, et elle est signalée comme telle.") | |
| with gr.Row(): | |
| sp_limit = gr.Slider(1, 200, value=20, step=1, label="Échecs à produire au plus") | |
| sp_per = gr.Slider(1, 5, value=2, step=1, label="Par tâche") | |
| sp_key = gr.Textbox(label="Clé d'administration", type="password") | |
| sp_btn = gr.Button("Lancer l'auto-jeu") | |
| sp_msg = gr.Markdown() | |
| with gr.Accordion("Moteur GRPO / DPO (récompenses vérifiables)", open=False): | |
| rl_profile = gr.Markdown("Lecture du profil de VRAM…") | |
| gr.Markdown("L'adaptateur entraîné est écrit dans `adapter_rl/` : l'adaptateur promu en service " | |
| "dans `adapter/` n'est jamais modifié.") | |
| with gr.Row(): | |
| rl_phases = gr.Textbox(value=os.environ.get("UNIQUE_RL_PHASES", "grpo:60,dpo:200"), | |
| label="Phases (ex. grpo:60,dpo:200)") | |
| rl_key = gr.Textbox(label="Clé d'administration", type="password") | |
| rl_go = gr.Button("Lancer une passe", variant="primary") | |
| rl_halt = gr.Button("Arrêter", variant="stop") | |
| rl_msg = gr.Markdown() | |
| rl_console = gr.Textbox(label="Journal GRPO/DPO", lines=12, max_lines=12, interactive=False, | |
| autoscroll=True) | |
| a_journal = gr.Dataframe(headers=["heure", "type", "agent/phase", "détail"], interactive=False, wrap=True, | |
| label="Journal de travail (work_journal.jsonl, plus récent en haut)") | |
| a_run.click(agent_cmd, [a_instruction, a_tests, a_properties, a_proofs, a_attempts, a_generator, | |
| a_candidate], [a_verdict, a_steps, a_code, a_journal]) | |
| sp_btn.click(selfplay_cmd, [sp_key, sp_limit, sp_per], [sp_msg, a_journal]) | |
| rl_go.click(rl_start, [rl_key, rl_phases], rl_msg) | |
| rl_halt.click(rl_stop, rl_key, rl_msg) | |
| timer = gr.Timer(10) | |
| timer.tick(refresh, None, [status, pass_plot, loss_plot, history, console]) | |
| timer.tick(agents_refresh, None, [agents_status, a_journal]) | |
| timer.tick(rl_log, None, rl_console) | |
| demo.load(refresh, None, [status, pass_plot, loss_plot, history, console]) | |
| demo.load(agents_refresh, None, [agents_status, a_journal]) | |
| demo.load(rl_plan_text, None, rl_profile) | |
| def startup() -> None: | |
| """Au lancement : mise à jour par zip si demandée, arrêt des anciens jobs planifiés, entraînement automatique.""" | |
| sync_adapter() | |
| token = os.environ.get("HF_TOKEN", "").strip() or None | |
| if os.environ.get("UNIQUE_EXT_INSTALL", "").strip() or os.environ.get("UNIQUE_EXT_ROLLBACK", "0") == "1": | |
| try: | |
| from huggingface_hub import HfApi | |
| from unique_ext.updater import ensure_update | |
| msg, _ = ensure_update(HfApi(token=token), {"token": token, "space": os.environ.get("SPACE_ID", ""), | |
| "dataset": os.environ.get("MOUH_DATASET_REPO", "")}) | |
| TRAINER.note(msg) | |
| except Exception as exc: # noqa: BLE001 | |
| TRAINER.note(f"mise à jour impossible : {exc}") | |
| if token and os.environ.get("UNIQUE_SUSPEND_REMOTE_JOBS", "1") == "1": | |
| try: | |
| done = suspend_remote_jobs(token) | |
| TRAINER.note("anciens jobs planifiés : " + (", ".join(done) if done else "aucun à suspendre")) | |
| except Exception as exc: # noqa: BLE001 | |
| TRAINER.note(f"anciens jobs planifiés illisibles : {exc}") | |
| if os.environ.get("UNIQUE_AUTOTRAIN", "0") == "1": | |
| TRAINER.note(TRAINER.start()) | |
| SYMBIOZA_DIR = Path(os.environ.get("SYMBIOZA_DIR") or Path(__file__).resolve().parent / "web" / "symbioza") | |
| async def lifespan(_app: FastAPI): | |
| """Le thread d'arrière-plan (mise à jour, jobs, entraînement automatique) suit la durée de vie du serveur.""" | |
| threading.Thread(target=startup, name="unique-startup", daemon=True).start() | |
| yield | |
| def sync_sky() -> dict: | |
| """Envoie la file sky-trajectories vers le dépôt Dataset privé (jeton lu ici, jamais renvoyé ni journalisé).""" | |
| from huggingface_hub import HfApi | |
| token = os.environ.get("HF_TOKEN", "").strip() | |
| if not token: | |
| raise SyncUnavailable("HF_TOKEN absent : synchronisation désactivée") | |
| return push_pending(STORE.root / SKY_QUEUE_NAME, HfApi(token=token), dataset_repo()) | |
| MAX_JOURNAL_PAGE = 200 | |
| def agents_router() -> APIRouter: | |
| """Lecture seule du journal de travail : ce que Symbioza affiche comme activité réelle de l'essaim. | |
| Aucune de ces routes n'expose de code ni de secret : seulement des compteurs, des libellés et des états. | |
| """ | |
| router = APIRouter(prefix="/api/agents", tags=["agents"]) | |
| def stats() -> dict: | |
| return {**JOURNAL.stats(), "cache": PIPELINE.cache.info(), "agents": list(AGENTS)} | |
| def journal(limit: int = 50) -> dict: | |
| return {"events": JOURNAL.tail(max(1, min(limit, MAX_JOURNAL_PAGE)))} | |
| def episodes(limit: int = 20, status: str = "") -> dict: | |
| return {"episodes": JOURNAL.episodes(max(1, min(limit, MAX_JOURNAL_PAGE)), status=status.strip() or None)} | |
| return router | |
| def build_app() -> FastAPI: | |
| """Application ASGI unique (v4.4). | |
| La page unique (arène 3D + console UNIQUE) est servie à la racine ; l'ancienne interface Gradio reste intacte sur | |
| ``/gradio/`` et l'ancienne adresse ``/symbioza/`` sert toujours la même page. Les routes d'API passent avant les | |
| montages statiques. Sans le dossier ``web/symbioza`` (installation partielle), Gradio reprend la racine : le | |
| Space ne se retrouve jamais sans interface. | |
| """ | |
| demo.queue() | |
| api = FastAPI(title="Space unifié : UNIQUE + Symbioza", lifespan=lifespan) | |
| api.add_middleware(StaticCache) # v4.5.1 : une mise à jour du Space se voit au prochain chargement de la page | |
| api.include_router(make_router(STORE.root / SKY_QUEUE_NAME, authorized, sync=sync_sky)) | |
| api.include_router(agents_router()) | |
| api.include_router(make_ui_router(sys.modules[__name__])) | |
| api.include_router(make_lab_router(world_dir)) | |
| if not SYMBIOZA_DIR.is_dir(): | |
| return gr.mount_gradio_app(api, demo, path="/") | |
| api.include_router(make_page_router(SYMBIOZA_DIR)) # v4.5.4 : page versionnée, avant les montages statiques | |
| api.mount("/symbioza", StaticFiles(directory=str(SYMBIOZA_DIR), html=True), name="symbioza") | |
| gr.mount_gradio_app(api, demo, path="/gradio") | |
| api.mount("/", StaticFiles(directory=str(SYMBIOZA_DIR), html=True), name="hub") # en dernier : capte le reste | |
| return api | |
| if __name__ == "__main__": | |
| import uvicorn | |
| recorder.use_folder(STORE.root) # sessions de l'interface → data/ local, jamais vers le Hub | |
| uvicorn.run(build_app(), host="0.0.0.0", port=int(os.environ.get("PORT", "7860")), proxy_headers=True) | |