v0.4.0 MIAM choisit le modèle : banc et choix.json retirés, type + seuil

Applique docs/protocole-agents.md de MIAM (PH2-E3) :
- banc/ et /miam:banc supprimés : MIAM teste les presets lui-même
- miam:delegue passe type + seuil, ou preset ; MIAM peut donc remplacer
  le modèle chargé s'il est sous le seuil
- modeles en repli si le contexte est dépassé ; preset_defaut seulement
  sans test ni modèle chargé
- MIAM 2.5.1 minimum

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
angeandClaude Opus 5.5 committed 2026-10-31 09:55:26 +01:00
1 parent 1f59453b84
commit 5363b86a16
15 files changed
+50 -1515

No files matched your search

+1 -13
View File
@@ -12,24 +12,12 @@
# bundles MCP décompressés
.mcpb-cache/
# --- Python (banc d'essai) --------------------------------------------------------------------------------------
__pycache__/
*.py[cod]
.venv/
.ruff_cache/
.pytest_cache/
# résultats d'un banc lancé à la main dans le dépôt (le skill écrit dans le dossier de données du plugin)
plugins/miam/banc/resultats/
plugins/miam/banc/rapport.md
plugins/miam/banc/choix.json
# --- Node / TypeScript (tests du mod) ---------------------------------------------------------------------------
node_modules/
*.tsbuildinfo
# --- Journaux, sauf les données fixes du banc -------------------------------------------------------------------
# --- Journaux ---------------------------------------------------------------------------------------------------
*.log
!plugins/miam/banc/fichiers/*.log
# --- Système et éditeurs ----------------------------------------------------------------------------------------
.DS_Store
+3 -11
View File
@@ -23,9 +23,9 @@ Ce dépôt est le marketplace A2M Systèmes. Actuellement, il contient le plugin
| `miam` | Délègue des tâches à un modèle local géré par MIAM (Mon IA Manager, orchestrateur llama.cpp), via son serveur MCP. | [plugins/miam/README.md](plugins/miam/README.md) |
Le plugin `miam` fournit :
- le serveur MCP `miam` (outils `etat`, `presets`, `demarrer`, `deleguer`, `suivre`…) ;
- le subagent `miam:delegue`, qui démarre le bon preset, délègue, vérifie le résultat et rend un compte rendu court ;
- la skill `deleguer` (quand et comment déléguer) et la skill `/miam:banc` (banc d'essai des presets) ;
- le serveur MCP `miam` (outils `etat`, `modeles`, `presets`, `demarrer`, `deleguer`, `suivre`…) ;
- le subagent `miam:delegue`, qui délègue en laissant MIAM choisir le modèle, vérifie le résultat et rend un compte rendu court ;
- la skill `deleguer` (quand et comment déléguer) ;
- un compteur de tokens économisés (notification, ligne d'état, `/miam-economies`), net du pilotage et des
corrections, sans coût en tokens.
@@ -119,14 +119,6 @@ plugins/
skills/
deleguer/ # Skill pour déclencher la délégation
SKILL.md
banc/ # Skill pour le banc d'essai
SKILL.md
banc/ # Composants du banc d'essai
banc_miam.py
mcp_http.py
taches.py
verifier_controles.py
fichiers/
LICENSE # Licence du plugin (MIT)
README.md # Documentation du plugin
README.md # Ce fichier
+2 -2
View File
@@ -120,7 +120,7 @@ rien (coût 0) n'est pas envoyé.
**Gain net** = somme des `estimation.evites` − somme des `cout` de `pilotage` − somme des `cout` de `correction`.
Le taux de reprise d'une délégation (`cout` de ses corrections / ses `evites`) mesure la qualité réelle du modèle
local sur la tâche : c'est la donnée la plus utile pour classer les presets avec le banc.
local sur la tâche : c'est la donnée la plus utile pour classer les presets avec les tests de MIAM.
**Calcul du coût (fonction `cout`) :**
`input_tokens + cache_creation_input_tokens + output_tokens + Math.round(cache_read_input_tokens / 10)`.
@@ -190,7 +190,7 @@ tous les ordres.
`evites` (somme de `estimation.evites`), `pilotage` (somme des `cout` de `pilotage`), `corrections` (somme des
`cout` de `correction`), `net = evites - pilotage - corrections`, et `taux_reprise = corrections / evites`
(`null` si `evites` ≤ 0). Fichier absent : section à zéro. La jointure avec `delegations.jsonl` par `demande_id` permet ensuite le détail par preset ou par
format (utile au choix des presets par le banc).
format (utile au choix du modèle par MIAM).
5. Tests : unitaires sur la validation (chaque champ requis, types, lot vide, 501 événements, `evites` négatif accepté)
et sur le calcul du bilan (doublons, pilotage reçu avant sa délégation) ; intégration HTTP réelle comme les tests
MCP existants (401 sans jeton, 200 avec `recus`/`doublons`, renvoi du même lot → tout en doublons, 422, 413).
+3 -3
View File
@@ -1,8 +1,8 @@
{
"name": "miam",
"displayName": "MIAM",
"version": "0.3.0",
"description": "Délégation de tâches à un modèle local géré par MIAM (Mon IA Manager) : subagent, règles de délégation, banc d'essai des presets",
"version": "0.4.0",
"description": "Délégation de tâches à un modèle local géré par MIAM (Mon IA Manager) : subagent, règles de délégation, compteur de tokens économisés",
"author": {
"name": "Ange-Marie MAURIN"
},
@@ -34,7 +34,7 @@
"preset_defaut": {
"type": "string",
"title": "Preset MIAM par défaut",
"description": "Preset démarré quand aucun modèle n'est chargé et que le banc n'a pas encore de résultats (id ou nom, voir l'outil presets)",
"description": "Preset démarré quand aucun modèle n'est chargé et que MIAM n'a aucun test pour le type de la tâche (id ou nom, voir l'outil modeles)",
"default": "pre-16312f8b"
}
}
+8 -14
View File
@@ -8,10 +8,9 @@ Plugin Claude Code pour déléguer des tâches à un modèle local géré par MI
- Serveur MCP `miam` (http://127.0.0.1:{port}/mcp, jeton Bearer) : point d'entrée pour la communication avec le serveur MCP de MIAM.
- Subagent `miam:delegue` : agent dédié qui pilote la délégation des tâches vers MIAM.
- Skill `deleguer` : règles, pour l'agent principal, sur quand et comment déléguer une tâche à MIAM.
- Skill `/miam:banc` : banc d'essai des presets de MIAM pour évaluer les performances.
## Prérequis
MIAM 2.5.0 ou plus lancé avec son serveur MCP démarré (interrupteur du dashboard, menu « Serveur MCP » du tray, ou `--auto-start-mcp`) ; Python 3.12 pour le banc.
MIAM 2.5.1 ou plus (`type`, `seuil` et l'outil `modeles`, champs `offload` à partir de 2.5.2) lancé avec son serveur MCP démarré (interrupteur du dashboard, menu « Serveur MCP » du tray, ou `--auto-start-mcp`).
## Installation
Le plugin fait partie de la marketplace `a2m-plugins` (voir le [README du dépôt](../../README.md#installation) pour
@@ -38,10 +37,12 @@ Puis : à l'activation, Claude Code demande les réglages (voir section suivante
| jeton | (vide) | Jeton Bearer du serveur MCP de MIAM, rangé dans le coffre d'identifiants. |
| port | 8090 | Port du serveur MCP de MIAM sur 127.0.0.1. |
| modele_subagent | haiku | Modèle Claude qui pilote MIAM pour l'agent principal (rédige la consigne, attend, vérifie). |
| preset_defaut | pre-16312f8b | Preset MIAM par défaut, démarré quand aucun modèle n'est chargé et que le banc n'a pas encore de résultats. |
| preset_defaut | pre-16312f8b | Preset MIAM par défaut, démarré quand aucun modèle n'est chargé et que MIAM n'a aucun test pour le type de la tâche. |
## Utilisation
L'agent principal lance le subagent miam:delegue ; celui-ci vérifie que MIAM répond (etat), démarre si aucun modèle n'est chargé le meilleur preset de la catégorie de la tâche d'après choix.json du banc (sinon le preset par défaut), ne remplace jamais un modèle déjà chargé sans demande, délègue, vérifie, et rend un compte rendu court.
L'agent principal lance le subagent miam:delegue ; celui-ci vérifie que MIAM répond (etat), classe la tâche, délègue avec `type` (`code`, `texte`, `rapide`) et `seuil` (0,8 par défaut), vérifie, et rend un compte rendu court avec le choix de MIAM.
MIAM choisit le modèle d'après ses tests de qualité : il garde le modèle chargé s'il atteint le seuil, sinon il charge le meilleur preset, quitte à remplacer le modèle chargé (20 à 40 s) ; si aucun n'atteint le seuil, il prend le meilleur et le signale. L'agent principal peut imposer un preset ou demander de garder le modèle chargé. Contrat : `docs/protocole-agents.md` du dépôt MIAM.
## Tokens économisés
@@ -63,8 +64,8 @@ MIAM ne répond pas) : contrat et modèle de données dans [docs/token-summary-m
moins qu'un token d'Opus, ce que le net ne pondère pas ; une reprise hors du fichier `sortie` n'est pas vue (d'où
l'intérêt de faire écrire les résultats par MIAM avec `sortie`).
## Banc d'essai
Tâches fixes reprises de llm-banc (catégories code, texte, rapide ; pas de vision), lancé par /miam:banc dans une fenêtre PowerShell externe, résultats rapport.md et choix.json dans le dossier de données du plugin, critère de choix (meilleure note par catégorie ; à moins de 0,02 d'écart, la vitesse en tok/s départage). Le script lit le jeton dans %LOCALAPPDATA%\MIAM\config.json ou la variable MIAM_JETON.
## Tests des modèles
MIAM teste lui-même les presets, en travaux de fond (page Benchmarks) : le plugin n'a plus de banc. L'ancien dossier de données du plugin (`banc/`, `rapport.md`, `choix.json`) n'est plus lu.
## Structure du plugin
```text
@@ -75,18 +76,11 @@ agents/
delegue.md # subagent miam:delegue
skills/
deleguer/SKILL.md # quand et comment déléguer
banc/SKILL.md # /miam:banc
hooks/
hooks.json
register.ts # compteur de tokens économisés (ligne d'état, notifications, /miam-economies)
economies.ts # calcul pur, testé
*.test.ts # 35 tests : claude plugin test plugins/miam
banc/
banc_miam.py # banc d'essai des presets via MIAM
mcp_http.py # client MCP minimal
taches.py # tâches et contrôles (repris de llm-banc)
verifier_controles.py
fichiers/ # données fixes du banc
LICENSE
README.md
```
@@ -100,4 +94,4 @@ README.md
## Licence
MIT, voir [LICENSE](LICENSE). Les fichiers du banc (`banc/`) reprennent ceux de llm-banc, du même auteur.
MIT, voir [LICENSE](LICENSE).
+26 -16
View File
@@ -19,21 +19,20 @@ Appelle `etat`. Si l'outil est absent ou échoue (connexion refusée, 401, serve
Reconnect sur `plugin:miam:miam`. Sur 401 : jeton à ressaisir dans `/plugin` → miam → configurer. » Ne tente pas de
faire la tâche à la place du modèle local.
## 2. Un modèle est-il chargé ?
## 2. Quel modèle ?
- **Un modèle est chargé** : utilise-le. Ne le remplace **jamais** (`remplacer=true`) sans que l'agent principal l'ait
demandé explicitement.
- **Aucun modèle** : choisis le preset dans `${CLAUDE_PLUGIN_DATA}/banc/choix.json` (lis-le avec Read) : entrée
`categories.<catégorie>.preset`, catégorie selon la tâche :
- `code` : fonction, tests, script, SVG ;
- `texte` : traduction, résumé court, notes de version, docstrings, commit, JSON à rédiger ;
- `rapide` : lire beaucoup pour rendre peu (log, sortie de tests, questions sur un long document).
Sans ce fichier (banc pas encore lancé), prends le preset par défaut `${user_config.preset_defaut}`.
Appelle `demarrer(preset=…)`. Le chargement prend 20 à 40 s : si la réponse est « en file » (`ok` absent ou nul,
avec `demande_id`), appelle `suivre(demande_id, attente_s=45)` jusqu'à la fin. Un refus (Unreal ouvert, preset
inconnu) : rends-le tel quel.
- Si `choix.json` montre que le modèle chargé est faible pour la catégorie (note < `seuil`), délègue quand même et
signale-le dans le compte rendu, avec le preset conseillé.
MIAM choisit le modèle d'après ses propres tests de qualité. Ne choisis pas à sa place, et n'appelle pas `demarrer` avant
de déléguer. Classe la tâche :
- `code` : fonction, tests, script, SVG ;
- `texte` : traduction, résumé court, notes de version, docstrings, commit, JSON à rédiger ;
- `rapide` : lire beaucoup pour rendre peu (log, sortie de tests, questions sur un long document).
Puis, selon le brief :
- **cas général** : `type` + `seuil` (celui du brief, sinon `0.8`). MIAM garde le modèle chargé s'il atteint le seuil,
sinon il charge le meilleur preset, y compris s'il faut remplacer le modèle chargé ;
- **preset imposé** : `preset` (id ou nom), prioritaire sur `type` ;
- **« garder le modèle chargé »** : ni `type` ni `preset`. Si la catégorie figure dans `sous_seuil` de l'instance
(`etat`), délègue quand même et signale-le dans le compte rendu.
## 3. Déléguer
@@ -41,6 +40,7 @@ faire la tâche à la place du modèle local.
ce qu'il ne faut pas faire), les cas limites. Une consigne vague donne un résultat brouillon.
Appelle `deleguer` :
- `type`, `seuil` ou `preset` : voir la section 2 ;
- `consigne` ; `fichiers` : chemins **absolus** (ou relatifs avec `racine` = dossier du projet), `chemin:debut-fin`
pour une partie ; MIAM les lit, toi non ;
- `format` : `json` pour des faits extraits (validité vérifiée par MIAM, `json_valide`), `code` pour du code (balises
@@ -49,7 +49,16 @@ Appelle `deleguer` :
si l'agent principal l'a dit ;
- `reflexion` : `off` par défaut ; `bas`/`moyen`/`haut` seulement si la tâche demande du raisonnement.
Réponse « en file » : `suivre(demande_id, attente_s=45)` jusqu'au résultat. Pour abandonner : `annuler(demande_id)`.
Réponse « en file » (`ok` absent ou nul, avec `demande_id`) : `suivre(demande_id, attente_s=45)` jusqu'au résultat. Pour
abandonner : `annuler(demande_id)`. Un changement de modèle prend 20 à 40 s de plus : `choix.demarrage` est le
`demande_id` du chargement, la délégation passe juste après ; si elle échoue, `suivre(choix.demarrage)` en donne la cause.
Refus de `deleguer` :
- `aucun preset testé pour « … »` (aucun modèle chargé, MIAM n'a pas de note pour ce type) : `demarrer(preset=
"${user_config.preset_defaut}")`, `suivre` jusqu'à la fin, puis refais le même `deleguer` ;
- contexte dépassé : `modeles`, puis refais le `deleguer` avec `preset` = le mieux noté dont le `contexte` suffit, sans
`offload` si possible (une partie du modèle tourne alors sur le CPU, donc lentement) ;
- autre refus (Unreal ouvert, `seuil` hors limites, preset inconnu) : rends-le tel quel.
## 4. Vérifier
@@ -67,6 +76,7 @@ rends l'échec, n'essaie pas de corriger toi-même un gros résultat.
Court, pour l'agent principal :
- statut (réussi / raté / partiel) et vérifications faites (avec leur résultat) ;
- modèle utilisé, durée, tokens lus/écrits (champs de la réponse de `deleguer`) ;
- modèle utilisé, durée, tokens lus/écrits (champs de la réponse de `deleguer`) ; `choix.raison`, et
`choix.avertissement` s'il existe, tel quel ;
- le résultat : en entier s'il est court, sinon le chemin de `sortie` et ce qu'il contient ;
- limites ou points à relire.
-184
View File
@@ -1,184 +0,0 @@
"""Banc d'essai des presets de MIAM : les tâches fixes de llm-banc, passées par la vraie chaîne de délégation.
Usage (dans une fenêtre PowerShell à part : plusieurs minutes par preset) :
python banc_miam.py --sortie <dossier> tous les presets validés
python banc_miam.py --sortie <dossier> pre-16312f8b ... seulement ceux-là (id ou nom)
python banc_miam.py --sortie <dossier> --categorie code une catégorie (code, texte, rapide)
Pour chaque preset : `demarrer(remplacer=True)`, puis chaque tâche par `deleguer` (fichiers passés par chemin, lus par
MIAM), notée par les contrôles de taches.py. Pas de vision : `deleguer` ne transmet que du texte. MIAM applique ses
règles du poste à chaque démarrage (ComfyUI vidé, refus si Unreal est ouvert). Le modèle chargé au départ est remis à
la fin (sauf --sans-restaurer).
Écrit : <sortie>/<date-heure>/<preset>.json (+ reponses/), puis, à partir du dernier essai de chaque preset,
<sortie>/rapport.md et <sortie>/choix.json (meilleur preset par catégorie, lu par le subagent miam:delegue).
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
from pathlib import Path
from mcp_http import ClientMcp, ErreurMcp, jeton_miam
from taches import FICHIERS, TACHES, Tache
CATEGORIES = ("code", "texte", "rapide")
MAX_TOKENS = 3000
SEUIL = 0.8 # note à partir de laquelle une tâche est réussie
EGALITE = 0.02 # écart de note sous lequel la vitesse départage
LU, ECRIT = 1, 5 # unités de coût de Claude (cf. llm-banc/rapport.py)
def attendre(client: ClientMcp, reponse: dict) -> dict:
"""Suit une demande « en file » jusqu'à sa fin (ok vrai ou faux)."""
while reponse.get("ok") is None and reponse.get("demande_id"):
reponse = client.outil("suivre", demande_id=reponse["demande_id"], attente_s=45)
return reponse
def essayer(client: ClientMcp, tache: Tache) -> tuple[dict, str]:
fichiers = [str(FICHIERS / tache.fichier)] if tache.fichier else None
r = attendre(client, client.outil("deleguer", consigne=tache.consigne, fichiers=fichiers, format="texte",
max_tokens=MAX_TOKENS))
base = {"tache": tache.id, "categorie": tache.categorie, "nature": tache.nature, "a_juger": tache.a_juger}
if not r.get("ok"):
return base | {"note": 0.0, "detail": f"erreur : {r.get('erreur') or r.get('raison') or r}"}, ""
texte = r.get("reponse") or ""
note, detail = tache.controle(texte) if texte.strip() else (0.0, "aucune réponse")
duree, sortie = r.get("duree_s") or 0, r.get("tokens_sortie") or 0
return base | {"note": round(note, 3), "detail": detail, "duree_s": duree,
"tokens_entree": r.get("tokens_entree"), "tokens_sortie": sortie,
"tok_s": round(sortie / duree, 1) if duree else None, # prompt compris : sous-estime l'écriture
"limite_atteinte": bool(r.get("tronque"))}, texte
def gain(t: dict) -> tuple[float, float]:
"""(coût sans déléguer, coût en déléguant) ; une tâche ratée est refaite par Claude (cf. llm-banc)."""
entree, sortie = t.get("tokens_entree") or 0, t.get("tokens_sortie") or 0
if t["nature"] == "lecture":
sans, avec = entree * LU + sortie * ECRIT, 50 * ECRIT + sortie * LU
else:
sans, avec = sortie * ECRIT, entree * ECRIT + sortie * LU
return sans, avec + (sans if t["note"] < SEUIL else 0)
def resume(r: dict) -> dict:
notes = {c: [t["note"] for t in r["taches"] if t["categorie"] == c] for c in CATEGORIES}
vitesses = [t["tok_s"] for t in r["taches"] if t.get("tok_s")]
couts = [gain(t) for t in r["taches"]]
sans, avec = sum(c[0] for c in couts), sum(c[1] for c in couts)
toutes = [n for ns in notes.values() for n in ns]
return {"nom": r["preset"], "modele": r.get("modele"), "date": r["date"],
"notes": {c: round(sum(ns) / len(ns), 3) for c, ns in notes.items() if ns},
"moyenne": round(sum(toutes) / max(len(toutes), 1), 3),
"reussies": f"{sum(n >= SEUIL for n in toutes)}/{len(toutes)}",
"tok_s": round(sum(vitesses) / len(vitesses), 1) if vitesses else None,
"vram_mo": r.get("vram_mo"), "chargement_s": r.get("chargement_s"),
"gain": round((sans - avec) / sans, 2) if sans else None}
def derniers(sortie: Path) -> dict[str, dict]:
"""Dernier essai de chaque preset, tous dossiers d'essai confondus."""
retenus: dict[str, dict] = {}
for f in sorted(sortie.glob("*/*.json")):
r = json.loads(f.read_text(encoding="utf-8"))
if "erreur" not in r and r.get("taches") and r["date"] >= retenus.get(r["id"], {}).get("date", ""):
retenus[r["id"]] = r
return retenus
def choisir(resumes: dict[str, dict]) -> dict:
categories = {}
for c in CATEGORIES:
candidats = [(i, s) for i, s in resumes.items() if c in s["notes"]]
if not candidats:
continue
meilleure = max(s["notes"][c] for _, s in candidats)
proches = [(i, s) for i, s in candidats if s["notes"][c] >= meilleure - EGALITE]
i, s = max(proches, key=lambda p: p[1]["tok_s"] or 0)
categories[c] = {"preset": i, "nom": s["nom"], "note": s["notes"][c], "tok_s": s["tok_s"]}
return {"date": datetime.datetime.now().isoformat(timespec="seconds"), "seuil": SEUIL, "categories": categories,
"presets": resumes}
def rapport(choix: dict) -> str:
lignes = ["# Banc d'essai des presets de MIAM\n",
("Tâches fixes de llm-banc passées par `deleguer` (sans vision). Note de 0 à 1 ; dernier essai de chaque "
"preset. tok/s : tokens écrits / durée de l'appel (lecture du prompt comprise).\n"),
"## Choix par catégorie\n", "| Catégorie | Preset | Note | tok/s |", "|---|---|---|---|"]
lignes += [f"| {c} | {v['nom']} (`{v['preset']}`) | {v['note']:.2f} | {v['tok_s']} |"
for c, v in choix["categories"].items()]
lignes += ["\n## Presets\n", "| Preset | " + " | ".join(CATEGORIES) + " | Moyenne | Réussies | tok/s | VRAM | "
"Chargement | Gain | Essai |", "|---|" + "---|" * (len(CATEGORIES) + 7)]
for i, s in choix["presets"].items():
notes = [f"{s['notes'][c]:.2f}" if c in s["notes"] else "—" for c in CATEGORIES]
vram = f"{s['vram_mo'] / 1024:.1f} Go" if s.get("vram_mo") else "?"
gain_ = f"{s['gain']:+.0%}" if s.get("gain") is not None else "?"
lignes.append(f"| {s['nom']} (`{i}`) | " + " | ".join(notes) + f" | **{s['moyenne']:.2f}** | {s['reussies']} "
f"| {s['tok_s']} | {vram} | {s.get('chargement_s') or '—'} s | {gain_} | {s['date'][:16]} |")
return "\n".join(lignes) + "\n"
def main() -> None:
parseur = argparse.ArgumentParser(description="Banc d'essai des presets de MIAM")
parseur.add_argument("presets", nargs="*", help="id ou nom (défaut : tous les presets validés)")
parseur.add_argument("--sortie", required=True, type=Path, help="dossier des résultats")
parseur.add_argument("--categorie", choices=CATEGORIES)
parseur.add_argument("--port", type=int, default=int(os.environ.get("MIAM_PORT", "8090")))
parseur.add_argument("--sans-restaurer", action="store_true", help="laisser chargé le dernier preset essayé")
options = parseur.parse_args()
client = ClientMcp(f"http://127.0.0.1:{options.port}/mcp", jeton_miam())
client.ouvrir()
valides = client.outil("presets")["presets"]
initial = client.outil("etat").get("instances") or []
initial_id = next((p["id"] for p in valides if initial and p["preset"] == initial[0]["preset"]), None)
demandes = [d.lower() for d in options.presets]
choisis = [p for p in valides if not demandes or p["id"].lower() in demandes or p["preset"].lower() in demandes]
if demandes and len(choisis) < len(demandes):
raise SystemExit(f"presets validés inconnus parmi : {', '.join(options.presets)}")
categories = {options.categorie} if options.categorie else set(CATEGORIES)
taches = [t for t in TACHES if t.categorie in categories]
essai = options.sortie / datetime.datetime.now().strftime("%Y-%m-%d_%H%M")
(essai / "reponses").mkdir(parents=True, exist_ok=True)
try:
for p in choisis:
print(f"\n=== {p['preset']} ({p['id']}) : {len(taches)} tâche(s)", flush=True)
resultat = {"id": p["id"], "preset": p["preset"], "modele": p["modele"],
"date": datetime.datetime.now().isoformat(timespec="seconds"), "taches": []}
fichier = essai / f"{p['id']}.json"
demarrage = attendre(client, client.outil("demarrer", preset=p["id"], remplacer=True))
if not demarrage.get("ok"):
print(f"✘ {demarrage.get('erreur') or demarrage.get('raison')}", flush=True)
resultat["erreur"] = str(demarrage.get("erreur") or demarrage.get("raison"))
fichier.write_text(json.dumps(resultat, ensure_ascii=False, indent=1), encoding="utf-8")
continue
vram = (client.outil("etat").get("memoire_graphique") or {}).get("utilisee_mo")
resultat |= {"chargement_s": demarrage.get("chargement_s"), "vram_mo": vram}
print(f"prêt ({demarrage.get('chargement_s', 0)} s), mémoire graphique : {vram} Mo", flush=True)
for tache in taches:
try:
note, texte = essayer(client, tache)
except ErreurMcp as e:
note, texte = {"tache": tache.id, "categorie": tache.categorie, "nature": tache.nature,
"note": 0.0, "detail": f"erreur : {e}"}, ""
print(f" {tache.id:<16} note {note['note']:.2f} {note['detail']}", flush=True)
(essai / "reponses" / f"{p['id']}__{tache.id}.txt").write_text(texte, encoding="utf-8")
resultat["taches"].append(note)
fichier.write_text(json.dumps(resultat, ensure_ascii=False, indent=1), encoding="utf-8")
finally:
if initial_id and not options.sans_restaurer and choisis and choisis[-1]["id"] != initial_id:
print(f"\nremise du preset de départ : {initial_id}", flush=True)
attendre(client, client.outil("demarrer", preset=initial_id, remplacer=True))
choix = choisir({i: resume(r) for i, r in derniers(options.sortie).items()})
(options.sortie / "choix.json").write_text(json.dumps(choix, ensure_ascii=False, indent=1), encoding="utf-8")
(options.sortie / "rapport.md").write_text(rapport(choix), encoding="utf-8")
print(f"\nfini : {essai}\nrapport : {options.sortie / 'rapport.md'}", flush=True)
if __name__ == "__main__":
main()
@@ -1,165 +0,0 @@
## 9. Médias autonomes (proposé le 2026-10-21)
Les médias des contenus **sans repo** : articles du blog et fiches « page » (4.4). Les projets avec repo gardent les
leurs dans leur repo (images du README récupérées au tag, fichiers joints aux releases) ; rien ne change pour eux.
### 9.1 Où vivent les médias
| Lieu | Rôle |
|---|---|
| **NAS** : repo Gitea privé `ange/apc-mediastorage`, fichiers en **Git LFS** | **Source de vérité** : chaque fichier une seule fois, historique complet (qui, quand, versions précédentes). Jamais nettoyé. Accès par l'API Gitea, **sans clone** |
| **IONOS** : `~/www/media/{prod,preprod}/` | **Diffusion** de l'état courant ; reconstructible depuis le NAS. À côté de `~/www/prod` et `~/www/preprod`, jamais dedans : jamais touché par le déploiement (qui n'écrit que dans `www/<env>`) ni par la bascule |
| **Repo du site** | **Uniquement les `.md`**, avec des adresses `media:` ; jamais de média ni de fiche |
Le repo `apc-mediastorage` est privé, mais ses médias peuvent être publics : le plafond « repo privé ⇒ au plus
`members` » (3.2.1) ne s'applique qu'aux repos de projets.
Création (`a2m media init`, une seule fois) : la chaîne d'outils crée le repo **par l'API Gitea**, **privé**, vérifie
que LFS y est actif (sinon : arrêt, rien n'est envoyé), et y commite un `README.md` comme **seul fichier**. Aucun
`.gitattributes` (voir ci-dessous) ; le repo n'est jamais cloné, ni à la création ni ensuite. Un repo déjà existant
n'est pas recréé : la commande vérifie seulement qu'il est privé et que LFS y est actif.
Écriture dans `apc-mediastorage` (vérifiée avec Gitea 28.2 le 2026-10-21) : le fichier est envoyé par l'**API LFS**
(« batch »), puis la chaîne d'outils commite elle-même son **pointeur** par l'API « contents ». Le repo n'a **pas de
`.gitattributes` LFS** : avec une règle LFS, Gitea emballerait le pointeur une seconde fois. Gitea reconnaît les
pointeurs et sert les fichiers (`/media/<chemin>`) ; un fichier déjà présent (même `sha256`) n'est pas renvoyé. Le
repo ne se clone pas : un clone montrerait les pointeurs ; toute exportation passe par la chaîne d'outils.
Sur IONOS, dans `~/www/media/<env>/` :
| Dossier | Servi par | Adresse (production / préproduction) |
|---|---|---|
| `public/` | Apache, en statique : racine web du sous-domaine | `https://media.a2msystemes.org/<chemin>` / `https://media-preprod.a2msystemes.org/<chemin>` |
| `private/` | Laravel, après contrôle des droits (« Require all denied » pour Apache) | `https://a2msystemes.org/private/media/<chemin>` / `https://preprod.a2msystemes.org/private/media/<chemin>` |
| `.incoming/` | Personne : envois en cours | — |
Comme pour le site, chaque domaine pointe sur un dossier `…/public`, jamais plus haut.
### 9.2 Rangement : un contenu, ses propres médias
- **Dossier d'un contenu** : `blog/<translationKey>/` pour un article (ses versions FR et EN partagent les mêmes
médias), `pages/<id>/` pour une fiche « page ».
- Un contenu ne cite **que les médias de son dossier** : aucun partage entre contenus. Un fichier utile à deux
contenus est **dupliqué** dans chaque dossier.
- **Sur IONOS**, le chemin d'un média est toujours `<dossier du contenu>/<chemin relatif>` : une copie réelle par
contenu.
- **Sur le NAS**, chaque fichier n'existe **qu'une fois**, dans le dossier du premier contenu qui l'a ajouté. Un autre
contenu le **référence** dans sa fiche (`from` + `sha256` figé) ; aucun fichier n'est créé.
- **Fiche `media.toml`** dans chaque dossier de contenu ([schemas/media-v2.schema.json](schemas/media-v2.schema.json)) :
une entrée par média (fichier propre ou référence), avec sa visibilité, son `sha256`, `keep` (jamais nettoyé) et une
description FR/EN (texte alternatif).
- **Valeurs à l'ajout d'un média** (décision `upload` ou `reference`, §9.4) :
- visibilité : celle du **contenu qui l'ajoute** (identique dans ses versions FR et EN, règle 3.2.4) ; modifiable
ensuite (§9.7) ;
- description : `fr` et `en` reprennent le **texte alternatif** de l'image dans la version FR et dans la version EN
du contenu, quand il existe et n'est pas vide ; une langue sans texte alternatif est omise, et la description
entière si aucune n'en a. Rien n'est demandé à l'utilisateur.
- **Noms lisibles et normalisés** : minuscules, accents retirés, tout autre caractère que `[a-z0-9_.-]` remplacé par
`-` ; sous-dossiers conservés. Jamais de sha256 ni d'identifiant dans un nom. Deux fichiers différents qui donnent
le même nom : refus.
### 9.3 Détection dans le Markdown
Implémentation de référence : [tests/medias.py](tests/medias.py) ; exemples : [exemples/markdown/](exemples/markdown/).
- Analyse par un **analyseur CommonMark et un analyseur HTML**, jamais par recherche dans le texte brut : un lien
n'existe que là où l'analyseur en voit un. Le code (en ligne, bloc délimité, bloc indenté) ne contient jamais de lien.
- Liens examinés : images et liens Markdown (y compris les liens de référence), attributs HTML `img[src]`,
`video[src|poster]`, `source[src]`, `audio[src]`, `a[href]`, `iframe[src]`, champ d'en-tête `cover` (seul nom d'une
couverture, §4.5 ; ni `image` ni `coverImage`).
- La réécriture ne remplace **que le texte de l'adresse**, sans reformater le reste ; la relancer ne change rien.
- Adresse d'un média : **`media:<dossier du contenu>/<chemin>`**, sans version ni environnement. Le site la traduit au
build : `https://media.a2msystemes.org/<chemin>` (public) ou `/private/media/<chemin>` (privé), selon l'environnement.
### 9.4 Décisions
Chaque lien reçoit une décision (codes repris par les exemples) :
| Décision | Cas | Effet |
|---|---|---|
| `upload` | Chemin **relatif**, fichier existant, **dans** le dossier du contenu, contenu inconnu du NAS | Envoi au NAS, lien réécrit en `media:` |
| `reference` | Idem, mais un fichier de même `sha256` existe déjà sur le NAS | Référence dans la fiche, aucun envoi ; lien réécrit |
| `unchanged` | Même nom et même `sha256` qu'un média du contenu ; ou notre propre adresse d'un média du contenu | Rien à envoyer ; lien réécrit |
| `replace` | Même nom qu'un média du contenu, `sha256` différent | **Remplacement**, après confirmation ; lien réécrit |
| `duplicate` | **Notre propre adresse** (9.1) d'un média d'un autre contenu | Proposition de dupliquer (référence) ; avec `confirm = make-public` si le média est moins visible que le contenu (confirmation explicite, **non** par défaut) ; refus = erreur |
| `keep` | Adresse `media:` de son propre dossier, connue, assez visible | Inchangée (`available` : arrivée sur IONOS ou non) |
| `ignore` | `external` (tout protocole : `https:`, `mailto:`, `tel:`, `data:`… et `//…`, toute `iframe` externe), `site-route` (`/…`), `anchor` (`#…`), `page-link` (`.md`), `not-media` (chemin local absent sans extension de média) | Inchangé |
| `error` | `missing-file`, `absolute-path` (`C:\`, `C:/`), `network-path` (`\…`), `outside-folder` (`../`), `foreign-media` (`media:` d'un autre contenu), `local-iframe`, `visibility`, `unknown-media`, `name-collision`, `local-link` (build) | Rien n'est envoyé ni publié ; le message dit **quoi faire** (ex. « copiez le fichier dans le dossier du contenu : il ne sera pas dupliqué sur le NAS ») |
### 9.5 Versions et remplacement
- Les **versions** vivent sur le **NAS** (historique git et LFS) ; une ancienne version peut être renvoyée
(`a2m media restore`). **IONOS ne garde que le fichier courant**.
- **Remplacer** un média = même nom, `sha256` différent : nouveau commit sur le NAS (l'ancien fichier reste dans
l'historique), puis remplacement **atomique** sur IONOS (réception dans `.incoming/`, vérification, renommage).
- Une **référence** est figée par son `sha256` : si le contenu d'origine remplace son fichier, la référence garde
l'ancien contenu (retrouvé sur le NAS par son `sha256`). Remplacer une référence en fait un fichier propre.
### 9.6 Transfert, intégrité, contenus en attente
- Le transfert vers IONOS est **asynchrone** : file persistante sur le poste, worker en arrière-plan (démarrage
immédiat par défaut, ou à une heure donnée), envoi **par morceaux avec reprise**, débit limitable. Protocole :
[INTERFACE-MEDIAS.md](INTERFACE-MEDIAS.md).
- **Intégrité** : le `sha256` (= identifiant LFS) est vérifié par IONOS **avant** que le fichier soit servi ; un fichier
non conforme est rejeté. Revérification périodique. Un fichier dont IONOS a déjà le `sha256` y est **copié sur
place**, sans transfert.
- **La publication n'attend jamais les médias** : l'adresse finale d'un média est connue d'avance. État d'un contenu
au build :
| État | Condition | Rendu |
|---|---|---|
| `complete` | Tous ses médias sont disponibles | Normal |
| `pending` | Au moins un média pas encore arrivé | Publié à son adresse, **accessible par le lien** ; bandeau « Article incomplet : médias en cours de mise en ligne » (jusqu'au déploiement suivant) et emplacements réservés (chacun remplacé par son média dès l'arrivée, sans redéploiement) ; **absent** des listes, pages de tags, sitemap, recherche et flux ; `noindex` ; **pas annoncé** |
| `ignored` | Média inconnu, d'un autre contenu ou trop peu visible | Contenu non publié, avertissement |
| `refused` | Un lien local subsiste | **Build en échec** |
Un contenu `pending` devenu complet entre dans les listes et les flux, et il est annoncé, au **déploiement suivant**.
- **Par version** : `complete` et `pending` se calculent pour chaque version d'un article, qui cite ses propres
médias ; la version FR peut être publiée normalement et la version EN avec le bandeau.
- **Pour le contenu entier** : `ignored` écarte toutes ses versions (jamais un article publié dans une seule langue par
accident) ; `refused` fait échouer le build.
- Un média en cours de **remplacement** reste disponible tant que l'ancien fichier est servi (`served_sha256`,
[INTERFACE-MEDIAS.md](INTERFACE-MEDIAS.md) §4).
### 9.7 Visibilité, cache, référencement
- Visibilité **par média** : `public`, `members` ou `groups` (`unlisted` n'a pas de sens : un média n'est jamais
listé). Un média doit être **au moins aussi visible** que le contenu qui le cite. Changer la visibilité déplace le
fichier entre `public/` et `private/`.
- **Changer la visibilité d'un média** (à la demande de l'utilisateur, par la chaîne d'outils) :
1. **Contrôle** : refus (erreur `visibility`, rien n'est modifié) si le média deviendrait moins visible qu'une
version du contenu qui le cite ; le message dit quoi faire (rendre le contenu moins visible d'abord, ou retirer le
lien). Même règle qu'à l'ajout.
2. **NAS d'abord** (source de vérité) : la chaîne d'outils modifie l'entrée dans `media.toml` et la commite par l'API
« contents ». Le fichier ne bouge pas sur le NAS.
3. **Puis le site**, par la **file du worker** (§9.6), dans chaque environnement où le média existe :
`POST /api/media/visibility` ([INTERFACE-MEDIAS.md](INTERFACE-MEDIAS.md) §5). Site injoignable : la demande reste
dans la file (après un envoi du même média encore en attente) et repart plus tard ; en attendant, le site
sert le média avec son ancienne visibilité.
- Une **référence** (`from`) a sa propre visibilité, dans la fiche de son dossier : changer celle du fichier
d'origine ne la modifie pas, et inversement.
- **Adresse fixe**, sans paramètre de version : `Cache-Control: no-cache` et `ETag` (un remplacement est visible
immédiatement, chaque média garde une adresse unique). Médias privés : `X-Robots-Tag: noindex`. Le sous-domaine des
médias n'est pas bloqué par `robots.txt`.
### 9.8 Usage et nettoyage
- À chaque déploiement, le script relève toutes les adresses `media:` des contenus (y compris ceux en attente) et les
transmet au site : c'est l'**usage** de chaque média. Un contenu **retiré** (`status = unpublished`, §4.5 et §8.2)
ne cite plus rien : sa page de retrait n'affiche aucun média, qui deviennent orphelins. S'il est republié, la chaîne
d'outils renvoie depuis le NAS ceux qui ont été nettoyés d'IONOS (le contenu est `pending` en attendant).
- **Nettoyage** des fichiers **d'IONOS** qui ne sont plus cités (le NAS n'est jamais nettoyé) : un fichier non cité
devient **orphelin** (avec sa date), puis il est supprimé après un **délai de grâce** réglable dans `/admin` (30 jours
par défaut, par environnement). `keep` protège un média : transmis au site à l'envoi et à la copie, et modifié par le
même circuit qu'un changement de visibilité (§9.7 : NAS d'abord, puis le site par la file du worker). Exécuté par le
site, déclenché à la demande
(`a2m media cleanup`) ou au déploiement (`a2m deploy --cleanup`) ; simulation par défaut.
### 9.9 Circuit de rédaction
- Les brouillons vivent **hors des repos** (dossier de brouillons de la chaîne d'outils). La chaîne d'outils détecte
les médias, les envoie au NAS, réécrit les liens, puis copie **les seuls `.md`** dans le repo du site.
- Garde-fous côté site : les dossiers de contenus ne versionnent que les `.md` (`.gitignore` en liste blanche), et le
build refuse un lien local (`local-link`). L'en-tête d'un article n'a pas de champ de brouillon (§4.5).
-449
View File
@@ -1,449 +0,0 @@
(.venv) PS E:\0000WorkTechnique\0002dev\light_preset_manager> a2m release publish 0.6.0 --dev --asset "file=.\dist\light_preset_manager-0.6.0.zip;role=download" --deploy preprod
Contrat : apc-spec v3.0.1 (dernier tag publié)
light_preset_manager 0.6.0-dev.1 (dev) → ange/light_preset_manager, visibilité members, branche main
Publier sur Gitea ? [o/n] (o):
✔ tag dev placé sur 68e235d7de et poussé
✔ light_preset_manager-0.6.0.zip envoyé
✔ publication.json envoyé
✔ Release publiée : https://a2msystemes-git.duckdns.org/ange/light_preset_manager/releases/tag/dev
Déploiement preprod — la phrase de passe de la clé SSH peut être demandée.
Container a2msystemes-deploy-run-636568cea4c5 Creating
Container a2msystemes-deploy-run-636568cea4c5 Created
▶ Configuration (preprod) 16:02:58
Serveur : u57217383@home315313559.1and1-data.host:~/www/preprod
Adresse : https://preprod.a2msystemes.org
▶ Dépôt git 16:02:58
Commit : ec2db97 — Contrat apc-spec : passage à v3.0.1
Branche : main
✔ Arbre propre
▶ Connexion SSH 16:03:05
Phrase de passe de la clé a2m_ionos :
Enter passphrase for /root/.ssh/deploy_key:
Identity added: /root/.ssh/deploy_key (a2m-ionos depuis WARMACHINE-A2M)
✔ Serveur joignable, dossiers et .env présents
▶ Export du commit ec2db97 16:03:17
✔ Fichiers exportés dans /build
▶ Projets publiés (Gitea) 16:03:20
4 projet(s) sur Gitea → 2 sur le site public, 0 retiré(s), 2 addon(s) dans le catalogue Blender
a2m_probe_public (public, active) : version 0.1.0
slice_wrap (public, active) : version 1.4.2
0 fichier(s) téléchargé(s) depuis Gitea (les autres venaient du cache)
Changements depuis le déploiement précédent : 2
• nouveau projet : light_preset_manager — public [annoncé]
• nouvelle version : light_preset_manager 0.6.0-dev.1 — members
✔ Projets synchronisés
▶ Build du site (Analog) 16:03:29
added 668 packages in 10s
> a2m-site@0.0.0 build
> vite build && node scripts/clean-sitemap.mjs && node scripts/rss.mjs && pagefind --site dist/analog/public
vite v8.3.4 building client environment for production...
✓ 385 modules transformed.
computing gzip size...
dist/client/index.html 2.34 kB │ gzip: 1.15 kB
dist/client/assets/index-tfsG3tOf.css 2.92 kB │ gzip: 1.18 kB
dist/client/assets/_id_.page-BiFD4vmf.js 0.04 kB │ gzip: 0.06 kB
dist/client/assets/_tag_.page-C78GKh1A.js 0.04 kB │ gzip: 0.06 kB
dist/client/assets/_tag_.page-ul3g5iGa.js 0.04 kB │ gzip: 0.06 kB
dist/client/assets/_slug_.page-CwCGAHT2.js 0.04 kB │ gzip: 0.06 kB
dist/client/assets/index.page-CS5uSO-_.js 0.10 kB │ gzip: 0.12 kB
dist/client/assets/index.page-DtDOTxQs.js 0.11 kB │ gzip: 0.13 kB
dist/client/assets/about.page-DfMG4ZaU.js 0.11 kB │ gzip: 0.13 kB
dist/client/assets/index.page-E1EPiEXA.js 0.11 kB │ gzip: 0.13 kB
dist/client/assets/contact.page-x2KeEPA0.js 0.12 kB │ gzip: 0.13 kB
dist/client/assets/search.page-Cf4mS9zG.js 0.12 kB │ gzip: 0.13 kB
dist/client/assets/login.page-vwQEAs8x.js 0.14 kB │ gzip: 0.15 kB
dist/client/assets/invitation.page-GM_hviQD.js 0.14 kB │ gzip: 0.15 kB
dist/client/assets/blender-addons.page-BX7p4LXE.js 0.14 kB │ gzip: 0.15 kB
dist/client/assets/_...not-found_.page-T4aDkZfz.js 0.14 kB │ gzip: 0.16 kB
dist/client/assets/request-access.page-OVqCRDnG.js 0.15 kB │ gzip: 0.15 kB
dist/client/assets/reset-password.page-Dtr4wPxF.js 0.15 kB │ gzip: 0.16 kB
dist/client/assets/forgot-password.page-NHopf1t6.js 0.16 kB │ gzip: 0.17 kB
dist/client/assets/2026-10-17-opening-COXeaVIs.js 0.51 kB │ gzip: 0.37 kB
dist/client/assets/private-page-g33is5sA.js 0.51 kB │ gzip: 0.36 kB
dist/client/assets/2026-10-17-ouverture-Ccgu8oq2.js 0.55 kB │ gzip: 0.39 kB
dist/client/assets/content-types-CKWdUnQo.js 0.60 kB │ gzip: 0.37 kB
dist/client/assets/a2m-logo.component-B7g-8HJ-.js 0.84 kB │ gzip: 0.52 kB
dist/client/assets/_tag_.page-Cu3xgJqj.js 1.27 kB │ gzip: 0.72 kB
dist/client/assets/tag-links.component-Dqf80SLl.js 1.32 kB │ gzip: 0.73 kB
dist/client/assets/_tag_.page-C6Rw5J1O.js 1.44 kB │ gzip: 0.78 kB
dist/client/assets/index.page-DLuhHome.js 1.49 kB │ gzip: 0.84 kB
dist/client/assets/index.page-DSPqEf6p.js 1.61 kB │ gzip: 0.79 kB
dist/client/assets/turnstile.component-DmoFGgsh.js 1.73 kB │ gzip: 0.91 kB
dist/client/assets/index.page-BMlGtaCa.js 2.16 kB │ gzip: 1.00 kB
dist/client/assets/_slug_.page-DIMMZGOu.js 2.23 kB │ gzip: 1.13 kB
dist/client/assets/post-list.component-Dx5-l1_N.js 2.24 kB │ gzip: 1.01 kB
dist/client/assets/index.page-BymlGbnh.js 2.29 kB │ gzip: 1.12 kB
dist/client/assets/projet-card.component-CElweaIh.js 2.75 kB │ gzip: 1.21 kB
dist/client/assets/_...not-found_.page-Dqakym9Z.js 2.89 kB │ gzip: 1.38 kB
dist/client/assets/_common_module-chunk-h6ypKhQY.js 2.92 kB │ gzip: 1.06 kB
dist/client/assets/mentions-legales.page-wyCgNBH5.js 3.31 kB │ gzip: 1.62 kB
dist/client/assets/legal-notice.page-DqNC6Dcv.js 3.33 kB │ gzip: 1.62 kB
dist/client/assets/a-propos.page-CASZ6S2i.js 3.87 kB │ gzip: 1.81 kB
dist/client/assets/recherche.page-eXrBMQri.js 3.94 kB │ gzip: 1.82 kB
dist/client/assets/mot-de-passe-oublie.page-CV_9XzoQ.js 4.65 kB │ gzip: 1.91 kB
dist/client/assets/blender-addons.page-Cr_MhXe_.js 6.49 kB │ gzip: 2.53 kB
dist/client/assets/reinitialisation.page-CgELz1N6.js 6.54 kB │ gzip: 2.39 kB
dist/client/assets/demande-acces.page-C12RNPUd.js 7.11 kB │ gzip: 2.64 kB
dist/client/assets/contact.page-BjBS0zgJ.js 7.32 kB │ gzip: 2.68 kB
dist/client/assets/invitation.page-Jyg2M_5I.js 8.82 kB │ gzip: 3.08 kB
dist/client/assets/i18n-Bxb5dOM4.js 9.04 kB │ gzip: 3.59 kB
dist/client/assets/privacy.page-CleTh8fE.js 9.69 kB │ gzip: 3.95 kB
dist/client/assets/confidentialite.page-Dy-XOmo4.js 10.43 kB │ gzip: 4.21 kB
dist/client/assets/connexion.page-CqJuvRvc.js 10.79 kB │ gzip: 3.64 kB
dist/client/assets/two-factor-setup.component-OW1SHATp.js 12.69 kB │ gzip: 3.61 kB
dist/client/assets/_id_.page-Bk_3d75e.js 12.96 kB │ gzip: 4.13 kB
dist/client/assets/compte.page-DoqddlkC.js 15.29 kB │ gzip: 4.64 kB
dist/client/assets/projets-DRoUJUHQ.js 31.87 kB │ gzip: 11.17 kB
dist/client/assets/_router-chunk-jpVp88a1.js 293.95 kB │ gzip: 90.58 kB
dist/client/assets/index-DmSuz30L.js 362.89 kB │ gzip: 104.72 kB
✓ built in 3.73s
vite v8.3.4 building ssr environment for production...
✓ 117 modules transformed.
computing gzip size...
dist/ssr/assets/_id_.page-suOqbZ-i.js 0.05 kB │ gzip: 0.07 kB
dist/ssr/assets/_tag_.page-BswG67Wm.js 0.05 kB │ gzip: 0.07 kB
dist/ssr/assets/_tag_.page-Cy8X2rEW.js 0.05 kB │ gzip: 0.07 kB
dist/ssr/assets/_slug_.page-D4AUm7l0.js 0.05 kB │ gzip: 0.07 kB
dist/ssr/assets/index.page-BGinqpfB.js 0.19 kB │ gzip: 0.16 kB
dist/ssr/assets/index.page-BFrjNVmW.js 0.20 kB │ gzip: 0.17 kB
dist/ssr/assets/about.page-CLZCafNJ.js 0.20 kB │ gzip: 0.18 kB
dist/ssr/assets/search.page-DiRdMLCy.js 0.21 kB │ gzip: 0.18 kB
dist/ssr/assets/contact.page-C3U_GqKN.js 0.21 kB │ gzip: 0.17 kB
dist/ssr/assets/index.page-B_wk4wT1.js 0.21 kB │ gzip: 0.18 kB
dist/ssr/assets/login.page-BPWSzZb1.js 0.24 kB │ gzip: 0.20 kB
dist/ssr/assets/invitation.page-DHcT2hsz.js 0.25 kB │ gzip: 0.20 kB
dist/ssr/assets/blender-addons.page-DsBOI6pQ.js 0.25 kB │ gzip: 0.19 kB
dist/ssr/assets/_...not-found_.page-T4k2y208.js 0.25 kB │ gzip: 0.21 kB
dist/ssr/assets/request-access.page-D0mYV7Cz.js 0.26 kB │ gzip: 0.21 kB
dist/ssr/assets/reset-password.page-CDz54Z8T.js 0.27 kB │ gzip: 0.21 kB
dist/ssr/assets/forgot-password.page-CNEJdVPn.js 0.29 kB │ gzip: 0.23 kB
dist/ssr/assets/2026-10-17-opening-BZa7Hkso.js 0.67 kB │ gzip: 0.45 kB
dist/ssr/assets/2026-10-17-ouverture-DjDzIEYl.js 0.72 kB │ gzip: 0.46 kB
dist/ssr/assets/private-page-BscbDqj9.js 1.22 kB │ gzip: 0.70 kB
dist/ssr/assets/content-types-BiGRZjTL.js 1.54 kB │ gzip: 0.85 kB
dist/ssr/assets/a2m-logo.component-nzqLmVmk.js 2.16 kB │ gzip: 1.00 kB
dist/ssr/assets/tag-links.component-OXscgeUN.js 2.70 kB │ gzip: 1.14 kB
dist/ssr/assets/_tag_.page-Bn8XN-cA.js 2.75 kB │ gzip: 1.09 kB
dist/ssr/assets/index.page-DSAWJlxw.js 2.84 kB │ gzip: 1.06 kB
dist/ssr/assets/index.page-hU2clTxq.js 3.23 kB │ gzip: 1.20 kB
dist/ssr/assets/_tag_.page-BaPJqA_c.js 3.30 kB │ gzip: 1.19 kB
dist/ssr/assets/turnstile.component-C9cEwfb9.js 3.78 kB │ gzip: 1.68 kB
dist/ssr/assets/post-list.component-BHhoD_Dr.js 4.27 kB │ gzip: 1.48 kB
dist/ssr/assets/_slug_.page-BV_DBwXS.js 4.55 kB │ gzip: 1.58 kB
dist/ssr/assets/index.page-BmwnnbTw.js 4.72 kB │ gzip: 1.54 kB
dist/ssr/assets/mentions-legales.page-BnJUKYcZ.js 5.08 kB │ gzip: 1.85 kB
dist/ssr/assets/legal-notice.page-Bwta7eN9.js 5.14 kB │ gzip: 1.85 kB
dist/ssr/assets/index.page-BxALBSO8.js 5.31 kB │ gzip: 1.48 kB
dist/ssr/assets/projet-card.component-5vJnk0hk.js 5.42 kB │ gzip: 1.68 kB
dist/ssr/assets/_common_module-chunk-BSBE_0o6.js 5.76 kB │ gzip: 1.71 kB
dist/ssr/assets/_...not-found_.page-CQ7H5Qb5.js 6.32 kB │ gzip: 2.26 kB
dist/ssr/assets/a-propos.page-DRTW7xgX.js 6.91 kB │ gzip: 2.52 kB
dist/ssr/assets/recherche.page-DOV1cBdN.js 7.77 kB │ gzip: 2.77 kB
dist/ssr/assets/mot-de-passe-oublie.page-9lBN9y7G.js 7.98 kB │ gzip: 2.54 kB
dist/ssr/assets/blender-addons.page-CohBx8y_.js 11.79 kB │ gzip: 3.32 kB
dist/ssr/assets/i18n-BXYrWBKp.js 12.38 kB │ gzip: 4.49 kB
dist/ssr/assets/reinitialisation.page-gqn--W9W.js 12.56 kB │ gzip: 3.24 kB
dist/ssr/assets/privacy.page-DSf6t9B2.js 13.55 kB │ gzip: 4.26 kB
dist/ssr/assets/demande-acces.page-uvzUvGbD.js 14.01 kB │ gzip: 3.52 kB
dist/ssr/assets/contact.page-D2o9HWcm.js 14.08 kB │ gzip: 3.48 kB
dist/ssr/assets/confidentialite.page-Bb8poMBs.js 14.26 kB │ gzip: 4.52 kB
dist/ssr/assets/invitation.page-BXBQsf6_.js 16.64 kB │ gzip: 4.03 kB
dist/ssr/assets/xhr2-D9DVWp6g.js 20.67 kB │ gzip: 4.75 kB
dist/ssr/assets/connexion.page-CNF113yJ.js 22.16 kB │ gzip: 4.94 kB
dist/ssr/assets/two-factor-setup.component-ikkTKlnR.js 22.57 kB │ gzip: 5.06 kB
dist/ssr/assets/_id_.page-D-LDsjo0.js 27.40 kB │ gzip: 5.53 kB
dist/ssr/assets/compte.page-aXeGoqTM.js 31.66 kB │ gzip: 6.09 kB
dist/ssr/assets/projets-DGDnIuvC.js 34.43 kB │ gzip: 11.81 kB
dist/ssr/main.server.js 586.23 kB │ gzip: 129.77 kB
dist/ssr/assets/_router-chunk-BLHm19oG.js 597.09 kB │ gzip: 131.83 kB
✓ built in 2.48s
Prerendering static pages...
Building Sitemap...
Writing sitemap at /build/site/dist/analog/public/sitemap.xml
The '@analogjs/platform' server has been successfully built.
sitemap : 14 page(s) retirée(s) (erreurs, comptes, projets non listés ou retirés)
rss fr : 1 article(s), 3 entrée(s) de projets
rss en : 1 article(s), 3 entrée(s) de projets
Running Pagefind v1.5.2 (Extended)
Running from: "/build/site"
Source: "dist/analog/public"
Output: "dist/analog/public/pagefind"
[Walking source directory]
Found 49 files matching **/*.{html}
[Parsing files]
Found a data-pagefind-body element on the site.
↳ Ignoring pages without this tag.
[Reading languages]
Discovered 2 languages: en, fr
[Building search indexes]
Total:
Indexed 2 languages
Indexed 16 pages
Indexed 1984 words
Indexed 0 filters
Indexed 0 sorts
Finished in 0.054 seconds
✔ Site construit (163 fichiers)
▶ Laravel : composer install --no-dev 16:03:49
Installing dependencies from lock file
Verifying lock file contents can be installed on current platform.
Package operations: 111 installs, 0 updates, 0 removals
- Installing voku/portable-ascii (2.1.1): Extracting archive
- Installing symfony/polyfill-php80 (v1.43.0): Extracting archive
- Installing symfony/polyfill-mbstring (v1.43.0): Extracting archive
- Installing symfony/polyfill-ctype (v1.37.0): Extracting archive
- Installing phpoption/phpoption (1.10.0): Extracting archive
- Installing graham-campbell/result-type (v1.2.0): Extracting archive
- Installing vlucas/phpdotenv (v5.7.0): Extracting archive
- Installing symfony/css-selector (v8.1.6): Extracting archive
- Installing tijsverkoyen/css-to-inline-styles (v2.4.0): Extracting archive
- Installing symfony/var-dumper (v8.1.9): Extracting archive
- Installing symfony/polyfill-uuid (v1.43.0): Extracting archive
- Installing symfony/uid (v8.1.9): Extracting archive
- Installing symfony/deprecation-contracts (v3.7.1): Extracting archive
- Installing symfony/routing (v8.1.9): Extracting archive
- Installing symfony/process (v8.1.9): Extracting archive
- Installing symfony/polyfill-php86 (v1.43.0): Extracting archive
- Installing symfony/polyfill-php85 (v1.43.0): Extracting archive
- Installing symfony/polyfill-php84 (v1.43.0): Extracting archive
- Installing symfony/polyfill-intl-normalizer (v1.43.0): Extracting archive
- Installing symfony/polyfill-intl-idn (v1.43.0): Extracting archive
- Installing symfony/mime (v8.1.7): Extracting archive
- Installing psr/container (2.0.2): Extracting archive
- Installing symfony/service-contracts (v3.7.3): Extracting archive
- Installing psr/event-dispatcher (1.0.0): Extracting archive
- Installing symfony/event-dispatcher-contracts (v3.7.1): Extracting archive
- Installing symfony/event-dispatcher (v8.1.5): Extracting archive
- Installing psr/log (3.0.2): Extracting archive
- Installing doctrine/lexer (3.0.3): Extracting archive
- Installing egulias/email-validator (4.0.4): Extracting archive
- Installing symfony/mailer (v8.1.7): Extracting archive
- Installing symfony/http-foundation (v8.1.9): Extracting archive
- Installing symfony/error-handler (v8.1.8): Extracting archive
- Installing symfony/http-kernel (v8.1.9): Extracting archive
- Installing symfony/finder (v8.1.8): Extracting archive
- Installing symfony/polyfill-intl-grapheme (v1.43.0): Extracting archive
- Installing symfony/string (v8.1.9): Extracting archive
- Installing symfony/console (v8.1.9): Extracting archive
- Installing ramsey/collection (2.1.1): Extracting archive
- Installing brick/math (1.0.0): Extracting archive
- Installing ramsey/uuid (4.9.4): Extracting archive
- Installing psr/simple-cache (3.0.0): Extracting archive
- Installing psr/http-message (2.0): Extracting archive
- Installing nunomaduro/termwind (v2.4.0): Extracting archive
- Installing symfony/translation-contracts (v3.7.1): Extracting archive
- Installing symfony/translation (v8.1.5): Extracting archive
- Installing psr/clock (1.0.0): Extracting archive
- Installing symfony/clock (v8.1.9): Extracting archive
- Installing carbonphp/carbon-doctrine-types (3.2.1): Extracting archive
- Installing nesbot/carbon (3.14.2): Extracting archive
- Installing monolog/monolog (3.12.1): Extracting archive
- Installing psr/http-factory (1.1.0): Extracting archive
- Installing league/uri-interfaces (7.9.0): Extracting archive
- Installing league/uri (7.9.0): Extracting archive
- Installing league/mime-type-detection (1.17.0): Extracting archive
- Installing league/flysystem-local (3.35.3): Extracting archive
- Installing league/flysystem (3.36.0): Extracting archive
- Installing nette/utils (v4.1.5): Extracting archive
- Installing nette/schema (v1.3.6): Extracting archive
- Installing dflydev/dot-access-data (v3.0.3): Extracting archive
- Installing league/config (v1.2.0): Extracting archive
- Installing league/commonmark (2.10.3): Extracting archive
- Installing laravel/serializable-closure (v2.1.0): Extracting archive
- Installing laravel/prompts (v0.3.26): Extracting archive
- Installing guzzlehttp/uri-template (v2.0.1): Extracting archive
- Installing symfony/polyfill-php82 (v1.43.0): Extracting archive
- Installing guzzlehttp/psr7 (3.1.0): Extracting archive
- Installing guzzlehttp/promises (3.0.2): Extracting archive
- Installing psr/http-client (1.0.3): Extracting archive
- Installing guzzlehttp/guzzle (8.2.0): Extracting archive
- Installing fruitcake/php-cors (v1.4.0): Extracting archive
- Installing dragonmantank/cron-expression (v3.6.0): Extracting archive
- Installing doctrine/inflector (2.1.0): Extracting archive
- Installing laravel/framework (v13.37.0): Extracting archive
- Installing anourvalar/eloquent-serialize (1.3.12): Extracting archive
- Installing blade-ui-kit/blade-icons (1.10.1): Extracting archive
- Installing blade-ui-kit/blade-heroicons (2.7.0): Extracting archive
- Installing chillerlan/php-settings-container (3.3.0): Extracting archive
- Installing composer/pcre (3.4.0): Extracting archive
- Installing composer/class-map-generator (1.8.0): Extracting archive
- Installing danharrin/date-format-converter (v0.3.1): Extracting archive
- Installing danharrin/livewire-rate-limiting (v2.3.0): Extracting archive
- Installing paragonie/constant_time_encoding (v3.1.3): Extracting archive
- Installing pragmarx/google2fa (v9.1.0): Extracting archive
- Installing pragmarx/google2fa-qrcode (v4.0.0): Extracting archive
- Installing symfony/html-sanitizer (v8.1.8): Extracting archive
- Installing spatie/laravel-package-tools (1.93.3): Extracting archive
- Installing spatie/invade (2.2.0): Extracting archive
- Installing ryangjchandler/blade-capture-directive (v1.1.1): Extracting archive
- Installing nette/php-generator (v4.2.2): Extracting archive
- Installing livewire/livewire (v4.4.7): Extracting archive
- Installing league/uri-components (7.9.0): Extracting archive
- Installing kirschbaum-development/eloquent-power-joins (4.3.5): Extracting archive
- Installing filament/support (v5.10.3): Extracting archive
- Installing openspout/openspout (v4.32.0): Extracting archive
- Installing league/csv (9.28.0): Extracting archive
- Installing filament/actions (v5.10.3): Extracting archive
- Installing filament/notifications (v5.10.3): Extracting archive
- Installing filament/schemas (v5.10.3): Extracting archive
- Installing filament/infolists (v5.10.3): Extracting archive
- Installing spatie/shiki-php (2.4.0): Extracting archive
- Installing scrivo/highlight.php (v9.18.1.10): Extracting archive
- Installing ueberdosis/tiptap-php (2.2.1): Extracting archive
- Installing filament/forms (v5.10.3): Extracting archive
- Installing filament/widgets (v5.10.3): Extracting archive
- Installing filament/query-builder (v5.10.3): Extracting archive
- Installing filament/tables (v5.10.3): Extracting archive
- Installing chillerlan/php-qrcode (5.0.5): Extracting archive
- Installing filament/filament (v5.10.3): Extracting archive
- Installing nikic/php-parser (v5.9.0): Extracting archive
- Installing psy/psysh (v0.12.24): Extracting archive
- Installing laravel/tinker (v3.0.2): Extracting archive
Generating optimized autoload files
> Illuminate\Foundation\ComposerScripts::postAutoloadDump
> @php artisan package:discover --ansi
INFO Discovering packages.
anourvalar/eloquent-serialize ......................................... DONE
blade-ui-kit/blade-heroicons .......................................... DONE
blade-ui-kit/blade-icons .............................................. DONE
filament/actions ...................................................... DONE
filament/filament ..................................................... DONE
filament/forms ........................................................ DONE
filament/infolists .................................................... DONE
filament/notifications ................................................ DONE
filament/query-builder ................................................ DONE
filament/schemas ...................................................... DONE
filament/support ...................................................... DONE
filament/tables ....................................................... DONE
filament/widgets ...................................................... DONE
kirschbaum-development/eloquent-power-joins ........................... DONE
laravel/tinker ........................................................ DONE
livewire/livewire ..................................................... DONE
nesbot/carbon ......................................................... DONE
nunomaduro/termwind ................................................... DONE
ryangjchandler/blade-capture-directive ................................ DONE
> @php artisan filament:upgrade
⇂ /build/public/js/filament/forms/components/builder.js
⇂ /build/public/js/filament/forms/components/checkbox-list.js
⇂ /build/public/js/filament/forms/components/code-editor.js
⇂ /build/public/js/filament/forms/components/color-picker.js
⇂ /build/public/js/filament/forms/components/date-time-picker.js
⇂ /build/public/js/filament/forms/components/file-upload.js
⇂ /build/public/js/filament/forms/components/key-value.js
⇂ /build/public/js/filament/forms/components/markdown-editor.js
⇂ /build/public/js/filament/forms/components/repeater.js
⇂ /build/public/js/filament/forms/components/rich-editor.js
⇂ /build/public/js/filament/forms/components/select.js
⇂ /build/public/js/filament/forms/components/slider.js
⇂ /build/public/js/filament/forms/components/tags-input.js
⇂ /build/public/js/filament/forms/components/textarea.js
⇂ /build/public/js/filament/forms/components/text-input/password-reveal.js
⇂ /build/public/js/filament/forms/components/toggle-buttons.js
⇂ /build/public/js/filament/schemas/components/actions.js
⇂ /build/public/js/filament/schemas/components/tabs.js
⇂ /build/public/js/filament/schemas/components/wizard.js
⇂ /build/public/js/filament/tables/components/columns/checkbox.js
⇂ /build/public/js/filament/tables/components/columns/select.js
⇂ /build/public/js/filament/tables/components/columns/text-input.js
⇂ /build/public/js/filament/tables/components/columns/toggle.js
⇂ /build/public/js/filament/widgets/components/chart.js
⇂ /build/public/js/filament/widgets/components/stats-overview/stat/chart.js
⇂ /build/public/fonts/filament/filament/inter/index.css
⇂ /build/public/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-SP7Z6XGK.woff2
⇂ /build/public/fonts/filament/filament/inter/inter-cyrillic-wght-normal-DR6K5BQD.woff2
⇂ /build/public/fonts/filament/filament/inter/inter-greek-ext-wght-normal-A2J6H3EX.woff2
⇂ /build/public/fonts/filament/filament/inter/inter-greek-wght-normal-ZABHMKQG.woff2
⇂ /build/public/fonts/filament/filament/inter/inter-latin-ext-wght-normal-ZIT2UBIY.woff2
⇂ /build/public/fonts/filament/filament/inter/inter-latin-wght-normal-DIHYUR35.woff2
⇂ /build/public/fonts/filament/filament/inter/inter-vietnamese-wght-normal-VWEHJHBA.woff2
⇂ /build/public/js/filament/actions/actions.js
⇂ /build/public/js/filament/filament/app.js
⇂ /build/public/js/filament/filament/echo.js
⇂ /build/public/js/filament/notifications/notifications.js
⇂ /build/public/js/filament/schemas/schemas.js
⇂ /build/public/js/filament/support/support.js
⇂ /build/public/js/filament/tables/tables.js
⇂ /build/public/css/filament/filament/app.css
INFO Successfully published assets!
INFO Configuration cache cleared successfully.
INFO Route cache cleared successfully.
INFO Compiled views cleared successfully.
INFO Successfully upgraded!
73 packages you are using are looking for funding.
Use the `composer fund` command to find out more!
✔ Dépendances installées
✔ Fichiers de l'administration ajoutés au site
▶ Envoi vers ~/www/preprod 16:03:51
api/ → ~/www/preprod/api
Number of created files: 0
Number of deleted files: 0
Number of regular files transferred: 0
Total transferred file size: 0 bytes
sent 678,674 bytes received 3,604 bytes 3,272.32 bytes/sec
Durée : 3 min 28 s
site → ~/www/preprod/public
Number of created files: 0
Number of deleted files: 0
Number of regular files transferred: 0
Total transferred file size: 0 bytes
sent 12,481 bytes received 104 bytes 2,288.18 bytes/sec
Durée : 5 s
✔ Envoi terminé
▶ Laravel sur le serveur (migrations, caches) 16:07:24
INFO Nothing to migrate.
INFO Caching framework bootstrap, configuration, and metadata.
config ........................................................ 48.31ms DONE
events ......................................................... 6.76ms DONE
routes ........................................................ 52.46ms DONE
views ........................................................ 604.58ms DONE
blade-icons .................................................... 7.27ms DONE
filament ...................................................... 10.03ms DONE
✔ Migrations et caches à jour
▶ Enregistrement des projets publiés 16:07:26
Déploiement n° 20 enregistré, 2 événement(s)
▶ Contrôle de santé : https://preprod.a2msystemes.org/api/health 16:07:27
HTTP 200 : {"status":"ok","database":"ok"}
✔ Le site répond
▶ Déployé : ec2db97 en preprod (https://preprod.a2msystemes.org), en 4 min 29 s 16:07:27
✔ preprod : déployé
</pasted_content id="e9e5">
-45
View File
@@ -1,45 +0,0 @@
============================= test session starts =============================
platform win32 -- Python 3.12.3, pytest-8.4.2, pluggy-1.6.0
rootdir: E:\0000WorkTechnique\0002dev\apc-toolchain
configfile: pyproject.toml
plugins: xdist-3.8.0
8 workers [214 items]
........................................................................ [ 33%]
...................F..........................s......................... [ 67%]
.................................F..................F.................s. [100%]
=================================== FAILURES ===================================
__________________________ test_upload_in_chunks_then_available __________________________
[gw2] win32 -- Python 3.12.3 E:\0000WorkTechnique\0002dev\apc-toolchain\.venv\Scripts\python.exe
site = <test_media_worker.FakeSite object at 0x000001B2F6A83050>
def test_upload_in_chunks_then_available(site, nas, tmp_path):
_send()
worker, events = _worker(site, nas, tmp_path)
report = worker.run()
> assert site.patches == 5 and report.transient == {}
E assert 6 == 5
tests\test_media_worker.py:178: AssertionError
______________________________ test_slug_changed_removes_the_old_version ______________________________
[gw5] win32 -- Python 3.12.3 E:\0000WorkTechnique\0002dev\apc-toolchain\.venv\Scripts\python.exe
def test_slug_changed_removes_the_old_version(drafts, nas, repos):
> assert sorted(p.name for p in blog_fr.iterdir()) == ["ouverture.md"]
E AssertionError: assert ['2026-10-17-ouverture-du-site.md', 'ouverture.md'] == ['ouverture.md']
tests\test_blog_publish.py:142: AssertionError
_______________________________ test_cleanup_simulates_then_deletes_on_request _______________________________
[gw1] win32 -- Python 3.12.3 E:\0000WorkTechnique\0002dev\apc-toolchain\.venv\Scripts\python.exe
def test_cleanup_simulates_then_deletes_on_request(site, monkeypatch):
> assert "1 fichier(s) seraient supprimés" in output and site.cleanups == [True]
E AssertionError: assert '1 fichier(s) seraient supprimés' in 'IONOS preprod : 2 média(s) orphelin(s)'
tests\test_media_worker.py:301: AssertionError
=========================== short test summary info ===========================
FAILED tests/test_media_worker.py::test_upload_in_chunks_then_available - assert 6 == 5
FAILED tests/test_blog_publish.py::test_slug_changed_removes_the_old_version - AssertionError: assert ['2026-10-17-ouv...
FAILED tests/test_media_worker.py::test_cleanup_simulates_then_deletes_on_request - AssertionError: assert '1 fichi...
============= 3 failed, 209 passed, 2 skipped, 1 warning in 131.42s (0:02:11) =============
-92
View File
@@ -1,92 +0,0 @@
"""Client MCP minimal (HTTP « streamable », bibliothèque standard seulement) pour le serveur de MIAM.
Juste ce qu'il faut au banc : initialize, puis tools/call. Le serveur répond en JSON ou en flux SSE ; l'en-tête
`Mcp-Session-Id` éventuel est renvoyé à chaque requête.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from pathlib import Path
class ErreurMcp(RuntimeError):
pass
def jeton_miam() -> str:
"""Jeton : variable MIAM_JETON, sinon la section `mcp` de la config de MIAM (même poste, même compte)."""
if os.environ.get("MIAM_JETON"):
return os.environ["MIAM_JETON"]
config = Path(os.environ.get("LOCALAPPDATA", "")) / "MIAM" / "config.json"
try:
jeton = json.loads(config.read_text(encoding="utf-8"))["mcp"]["token"]
except (OSError, ValueError, KeyError) as e:
raise ErreurMcp(f"jeton introuvable (MIAM_JETON ou {config}) : {e}") from e
if not jeton:
raise ErreurMcp(f"jeton vide dans {config} : démarrer une fois le serveur MCP de MIAM")
return jeton
class ClientMcp:
def __init__(self, url: str, jeton: str, timeout: float = 120) -> None:
self.url, self.jeton, self.timeout = url, jeton, timeout
self.session: str | None = None
self._id = 0
def _envoyer(self, message: dict) -> dict | None:
entetes = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream",
"Authorization": f"Bearer {self.jeton}"}
if self.session:
entetes["Mcp-Session-Id"] = self.session
requete = urllib.request.Request(self.url, data=json.dumps(message).encode(), headers=entetes, method="POST")
try:
with urllib.request.urlopen(requete, timeout=self.timeout) as reponse:
self.session = reponse.headers.get("Mcp-Session-Id") or self.session
type_ = reponse.headers.get("Content-Type", "")
corps = reponse.read().decode("utf-8")
except urllib.error.HTTPError as e:
if e.code == 401:
raise ErreurMcp("401 : jeton refusé par MIAM") from e
raise ErreurMcp(f"HTTP {e.code} : {e.read()[:300]!r}") from e
except urllib.error.URLError as e:
raise ErreurMcp(f"MIAM injoignable sur {self.url} (lancé, serveur MCP démarré ?) : {e.reason}") from e
if "id" not in message or not corps.strip():
return None # notification : 202 sans corps
if "text/event-stream" in type_:
for ligne in corps.splitlines():
if ligne.startswith("data:"):
donnees = json.loads(ligne[5:].strip())
if donnees.get("id") == message["id"]:
return donnees
raise ErreurMcp("flux SSE sans réponse à la requête")
return json.loads(corps)
def _appel(self, methode: str, params: dict) -> dict:
self._id += 1
reponse = self._envoyer({"jsonrpc": "2.0", "id": self._id, "method": methode, "params": params})
if reponse is None or "error" in reponse:
raise ErreurMcp(f"{methode} : {(reponse or {}).get('error')}")
return reponse["result"]
def ouvrir(self) -> None:
self._appel("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "miam-banc", "version": "0.1.0"}})
self._envoyer({"jsonrpc": "2.0", "method": "notifications/initialized"})
def outil(self, nom: str, **arguments) -> dict:
"""Appelle un outil de MIAM ; renvoie son résultat JSON (dict)."""
resultat = self._appel("tools/call", {"name": nom, "arguments": arguments})
if resultat.get("structuredContent"):
contenu = resultat["structuredContent"]
return contenu.get("result", contenu) if set(contenu) == {"result"} else contenu
texte = "".join(c.get("text", "") for c in resultat.get("content", []) if c.get("type") == "text")
if resultat.get("isError"):
raise ErreurMcp(f"{nom} : {texte[:300]}")
try:
return json.loads(texte)
except ValueError:
return {"ok": False, "erreur": f"réponse non JSON : {texte[:300]}"}
-372
View File
@@ -1,372 +0,0 @@
"""Tâches fixes du banc d'essai et leurs contrôles (note de 0 à 1).
Catégories : code, texte, rapide (lire beaucoup pour rendre peu), vision. Chaque tâche a :
- `consigne` (et éventuellement `fichier` : document ou image joint) ;
- `nature` : `lecture` (le modèle lit un long document à ma place) ou `ecriture` (il rédige) — sert au calcul du gain ;
- `controle(reponse) -> (note, détail)` ; `a_juger` : une partie se juge à la lecture (SVG, notes de version).
Les fichiers de `fichiers/` et les références ci-dessous ne changent jamais : c'est ce qui rend les essais comparables.
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
FICHIERS = Path(__file__).parent / "fichiers"
@dataclass
class Tache:
id: str
categorie: str
nature: str # lecture | ecriture
consigne: str
controle: Callable[[str], tuple[float, str]]
fichier: str | None = None # document (texte ajouté à la consigne) ou image (.png)
a_juger: str = "" # ce qu'il faut regarder à la main
@property
def image(self) -> Path | None:
return FICHIERS / self.fichier if self.fichier and self.fichier.endswith(".png") else None
def message(self) -> str:
if self.fichier and not self.fichier.endswith(".png"):
return self.consigne + "\n\n" + (FICHIERS / self.fichier).read_text(encoding="utf-8")
return self.consigne
# --- outils de contrôle ----------------------------------------------------------------------------------------------
def bloc(texte: str, langue: str = r"\w*") -> str:
"""Contenu du premier bloc de code (de cette langue s'il y en a un), sinon le texte entier."""
m = re.search(rf"```(?:{langue})\s*\n(.*?)```", texte, re.S) or re.search(r"```\w*\s*\n(.*?)```", texte, re.S)
return (m.group(1) if m else texte).strip()
def lire_json(texte: str) -> dict | list | None:
brut = bloc(texte, "json")
m = re.search(r"[\[{].*[\]}]", brut, re.S)
try:
return json.loads(m.group(0) if m else brut)
except (ValueError, TypeError):
return None
def executer(langage: str, code: str, essais: str) -> tuple[float, str]:
"""Lance le code du modèle et des assertions dans un processus à part (10 s au plus) ; note = assertions justes."""
lignes = [l for l in essais.strip().splitlines() if l.strip()]
if langage == "python":
aide = ("\ndef _leve(f, erreur):\n try:\n f()\n except erreur:\n return True\n"
" except Exception:\n return False\n return False\n")
corps = code + aide + "\n_ok = 0\n" + "".join(
f"try:\n assert {l}\n _ok += 1\nexcept Exception:\n pass\n" for l in lignes) + "print(_ok)\n"
commande, suffixe = [sys.executable, "-I"], ".py"
else:
corps = code + "\nlet _ok = 0;\n" + "".join(
f"try {{ if ({l}) _ok++; }} catch (e) {{}}\n" for l in lignes) + "console.log(_ok);\n"
commande, suffixe = ["node"], ".js"
with tempfile.TemporaryDirectory() as dossier:
script = Path(dossier) / f"essai{suffixe}"
script.write_text(corps, encoding="utf-8")
try:
sortie = subprocess.run(commande + [str(script)], capture_output=True, text=True, timeout=10, cwd=dossier)
except subprocess.TimeoutExpired:
return 0.0, "trop long (boucle ?)"
try:
justes = int(sortie.stdout.strip().splitlines()[-1])
except (ValueError, IndexError):
return 0.0, "code inutilisable : " + (sortie.stderr.strip().splitlines() or ["?"])[-1][:120]
return justes / len(lignes), f"{justes}/{len(lignes)} cas justes"
def chrf(hypothese: str, reference: str, n: int = 6, beta: float = 2.0) -> float:
"""chrF (n-grammes de caractères, 1 à 6) : mesure classique de la proximité d'une traduction avec la référence."""
def grammes(texte, k):
t = re.sub(r"\s+", " ", texte.strip())
return Counter(t[i:i + k] for i in range(len(t) - k + 1))
precisions, rappels = [], []
for k in range(1, n + 1):
h, r = grammes(hypothese, k), grammes(reference, k)
commun = sum((h & r).values())
precisions.append(commun / max(sum(h.values()), 1))
rappels.append(commun / max(sum(r.values()), 1))
p, r = sum(precisions) / n, sum(rappels) / n
return 0.0 if p + r == 0 else (1 + beta ** 2) * p * r / (beta ** 2 * p + r)
MOTS_FR = {"le", "la", "les", "des", "du", "un", "une", "et", "est", "pour", "dans", "sur", "pas", "que", "qui", "au"}
MOTS_EN = {"the", "and", "is", "for", "in", "on", "not", "that", "which", "to", "of", "with", "are"}
def en_francais(texte: str) -> bool:
mots = re.findall(r"[a-zàâçéèêëîïôûùüÿœ']+", texte.lower())
return sum(m in MOTS_FR for m in mots) > sum(m in MOTS_EN for m in mots)
def mots_cles(texte: str, groupes: list[list[str]]) -> tuple[float, list[str]]:
"""Part des groupes dont au moins un mot apparaît (sans tenir compte de la casse)."""
bas = texte.lower()
manquants = [g[0] for g in groupes if not any(m in bas for m in g)]
return 1 - len(manquants) / len(groupes), manquants
# --- code -----------------------------------------------------------------------------------------------------------
def tache_python(id_, consigne, essais):
return Tache(id_, "code", "ecriture", consigne + "\nRéponds seulement avec le code, dans un bloc ```python.",
lambda r: executer("python", bloc(r, "python"), essais))
CODE = [
tache_python("code-slug", """Écris une fonction Python `slug(date: str, title: str) -> str`, sans dépendance externe,
qui renvoie `<date>-<titre normalisé>` : accents retirés (unicodedata NFKD, puis ASCII), minuscules, toute suite de
caractères hors [a-z0-9] remplacée par un seul « - », tirets retirés au début et à la fin ; si le titre normalisé est
vide, renvoyer seulement la date.""", """
slug("2026-10-17", "Ouverture du site") == "2026-10-17-ouverture-du-site"
slug("2026-10-17", "Éditeur 3D : l'essai_final !") == "2026-10-17-editeur-3d-l-essai-final"
slug("2026-10-17", "!!!") == "2026-10-17"
slug("2026-01-02", " Ça marche -- vraiment ") == "2026-01-02-ca-marche-vraiment"
"""),
tache_python("code-version", """Écris une fonction Python `bump(version: str, part: str) -> str` pour des numéros
X.Y.Z : part vaut "major", "minor" ou "patch" ; major remet minor et patch à 0, minor remet patch à 0. Une version mal
formée (autre chose que trois entiers séparés par des points) lève ValueError.""", """
bump("1.4.2", "patch") == "1.4.3"
bump("1.4.2", "minor") == "1.5.0"
bump("1.4.2", "major") == "2.0.0"
bump("0.9.9", "patch") == "0.9.10"
_leve(lambda: bump("1.2", "patch"), ValueError)
_leve(lambda: bump("1.2.x", "minor"), ValueError)
"""),
tache_python("code-visibilite", """Écris une fonction Python `more_visible(a: dict, b: dict) -> bool` : vrai si la
visibilité `a` est plus ouverte que `b`. Une visibilité est {"level": niveau} avec niveau parmi, du plus restreint au
plus ouvert : "groups", "members", "unlisted", "public" ; pour "groups", elle a aussi "groups" (liste de noms). À
niveau égal "groups", `a` est plus ouverte si l'un de ses groupes n'est pas dans ceux de `b`. Sinon, à niveau égal :
faux.""", """
more_visible({"level": "public"}, {"level": "members"}) is True
more_visible({"level": "members"}, {"level": "public"}) is False
more_visible({"level": "unlisted"}, {"level": "unlisted"}) is False
more_visible({"level": "groups", "groups": ["a"]}, {"level": "groups", "groups": ["a", "b"]}) is False
more_visible({"level": "groups", "groups": ["a", "c"]}, {"level": "groups", "groups": ["a", "b"]}) is True
"""),
tache_python("code-tus", """Écris une fonction Python `parse_metadata(header: str) -> dict` qui lit un en-tête tus
`Upload-Metadata` : des paires « clé valeur » séparées par des virgules, la valeur encodée en base64 (une clé peut
n'avoir aucune valeur : elle vaut alors ""). Les espaces autour des paires sont ignorés.""", """
parse_metadata("path YmxvZy9hLnBuZw==,mode Y3JlYXRl") == {"path": "blog/a.png", "mode": "create"}
parse_metadata("keep dHJ1ZQ==, vide") == {"keep": "true", "vide": ""}
parse_metadata("") == {}
"""),
Tache("code-js", "code", "ecriture", """Écris une fonction JavaScript `formatBytes(n)` qui rend une taille lisible en
français avec une décimale au plus, sans zéro inutile : moins de 1024 → « 512 o » ; puis Kio, Mio, Gio (base 1024) ;
la virgule comme séparateur décimal (« 1,5 Kio »). Réponds seulement avec le code, dans un bloc ```javascript.""",
lambda r: executer("javascript", bloc(r, "javascript|js"), """
formatBytes(512) === "512 o"
formatBytes(1536) === "1,5 Kio"
formatBytes(1048576) === "1 Mio"
formatBytes(3221225472) === "3 Gio"
""")),
]
def controle_svg(r: str, formes: int) -> tuple[float, str]:
try:
racine = ET.fromstring(bloc(r, "svg|xml"))
except ET.ParseError as e:
return 0.0, f"SVG invalide ({e})"
attendus = {"viewBox": "0 0 24 24", "fill": "none", "stroke": "currentColor", "stroke-width": "2"}
justes = sum(racine.get(k) == v for k, v in attendus.items())
elements = [e for e in racine.iter() if e.tag.split("}")[-1] in ("path", "line", "polyline", "circle", "rect")]
note = (justes / len(attendus)) * 0.7 + (0.3 if len(elements) >= formes else 0)
return note, f"valide, {justes}/{len(attendus)} attributs, {len(elements)} tracés (dessin à regarder)"
SVG_CONSIGNE = """Écris une icône SVG 24×24, en traits seulement : viewBox="0 0 24 24", fill="none",
stroke="currentColor", stroke-width="2", stroke-linecap="round", stroke-linejoin="round". Réponds seulement avec le SVG,
dans un bloc ```svg. Icône : """
CODE += [
Tache("svg-nuage", "code", "ecriture", SVG_CONSIGNE + "un nuage avec une flèche vers le haut (envoi).",
lambda r: controle_svg(r, 2), a_juger="le dessin est-il un nuage avec une flèche vers le haut ?"),
Tache("svg-dossier", "code", "ecriture", SVG_CONSIGNE + "un dossier ouvert avec un signe « + ».",
lambda r: controle_svg(r, 2), a_juger="le dessin est-il un dossier avec un « + » ?"),
]
# --- texte ----------------------------------------------------------------------------------------------------------
FR_SOURCE = """Les médias des contenus **sans repo** (articles du blog, fiches) vivent sur le NAS, dans le repo Gitea privé
`apc-mediastorage`, en Git LFS : c'est la source de vérité, jamais nettoyée. IONOS ne fait que la diffusion de l'état
courant, reconstructible depuis le NAS. La chaîne d'outils envoie les fichiers par l'API LFS, puis commite elle-même
leurs pointeurs et la fiche `media.toml` par l'API « contents » : le repo n'est jamais cloné."""
EN_REFERENCE = """The media of content **without a repo** (blog posts, pages) live on the NAS, in the private Gitea repo
`apc-mediastorage`, in Git LFS: it is the source of truth, never cleaned up. IONOS only serves the current state, which
can be rebuilt from the NAS. The toolchain sends the files through the LFS API, then commits their pointers and the
`media.toml` record itself through the "contents" API: the repo is never cloned."""
EN_SOURCE = """When a release is published, the toolchain first checks everything locally: clean working tree, default
branch, changelog and contract. Only then does it commit the version, create the tag and push both at once. The release
is created as a **draft**, so the website never sees it half-uploaded; it is published once every file is attached."""
FR_REFERENCE = """Quand une version est publiée, la chaîne d'outils vérifie d'abord tout en local : arbre propre, branche
par défaut, journal des modifications et contrat. Ensuite seulement, elle commite la version, crée le tag et pousse les
deux en même temps. La release est créée en **brouillon**, pour que le site ne la voie jamais à moitié envoyée ; elle
est publiée une fois tous les fichiers joints."""
def controle_traduction(r: str, reference: str, termes: list[str], vers_fr: bool) -> tuple[float, str]:
score = chrf(r, reference)
gardes = [t for t in termes if t in r]
langue_ok = en_francais(r) == vers_fr
note = min(1.0, score / 0.65) * 0.6 + 0.3 * len(gardes) / len(termes) + (0.1 if langue_ok else 0)
return min(note, 1.0), f"chrF {score:.2f}, termes {len(gardes)}/{len(termes)}, langue {'ok' if langue_ok else 'FAUSSE'}"
def controle_notes(r: str) -> tuple[float, str]:
titres = re.findall(r"^###\s+(.+)$", r, re.M)
puces = re.findall(r"^\s*[-*]\s+(.+)$", r, re.M)
if not titres or not puces:
return 0.0, "ni titres ni puces"
copiees = sum(1 for p in puces if "SPEC" in p or "lot 1" in p.lower() or "Lot 2" in p)
points = [all(t.strip() in ("Ajouté", "Modifié", "Corrigé") for t in titres) and bool(titres),
3 <= len(puces) <= 10, en_francais(r), copiees == 0]
return sum(points) / len(points), (f"{len(titres)} titres, {len(puces)} puces, {copiees} détail(s) interne(s) "
"recopié(s) (exactitude à juger)")
TEXTE = [
Tache("trad-fr-en", "texte", "ecriture", "Traduis en anglais, sans rien ajouter ni commenter, en gardant le "
"Markdown et les termes entre ` ` :\n\n" + FR_SOURCE,
lambda r: controle_traduction(r, EN_REFERENCE, ["`apc-mediastorage`", "`media.toml`", "**", "LFS"], False)),
Tache("trad-en-fr", "texte", "ecriture", "Traduis en français, sans rien ajouter ni commenter, en gardant le "
"Markdown :\n\n" + EN_SOURCE,
lambda r: controle_traduction(r, FR_REFERENCE, ["**", "tag", "commit"], True)),
Tache("notes-version", "texte", "ecriture", """Voici les sujets des derniers commits d'un outil en ligne de commande
(a2m). Rédige la section « Non publié » d'un CHANGELOG en français : titres ### Ajouté, ### Modifié, ### Corrigé (seulement
ceux qui servent), une puce courte par changement utile à un utilisateur, sans détail interne ni invention. Réponds
seulement avec la section Markdown, sans titre de niveau 2.
- Médias : worker lancé de lui-même (verrou, --no-send), a2m media cleanup, a2m deploy --cleanup, détail des erreurs du site
- apc-spec v3.3.0 : keep (envoi, copie, a2m media keep), served_sha256, médias renvoyés à la republication d'un article retiré
- Lot 2 des médias : file SQLite, a2m media worker (tus, copie sur place, reprise), jetons d'envoi, état IONOS
- a2m media status et media visibility ; SPEC : lot 1 codé
- a2m blog new et drafts_path dans a2m config (brouillons d'articles hors des repos)
- Option --deploy validée dès la lecture""", controle_notes,
a_juger="chaque puce correspond-elle à un vrai changement, sans invention ?"),
Tache("resume-court", "texte", "lecture", """Résume ce texte en trois phrases au plus, en français, pour quelqu'un
qui ne connaît pas le projet. Réponds seulement avec le résumé.""", lambda r: _controle_resume_court(r),
fichier="contrat-medias.md"),
Tache("consigne-json", "texte", "ecriture", """Réponds UNIQUEMENT par un objet JSON (sans texte autour) décrivant
trois tâches pour publier un article de blog, avec les clés : "taches" (liste d'objets ayant "titre" (texte en
français), "duree_minutes" (entier) et "ordre" (entier de 1 à 3)).""", lambda r: _controle_json(r)),
]
def _controle_resume_court(r: str) -> tuple[float, str]:
phrases = [p for p in re.split(r"(?<=[.!?])\s+", r.strip()) if p]
couverture, manquants = mots_cles(r, [["nas"], ["ionos"], ["lfs", "gitea"], ["nettoy", "orphelin"]])
points = [len(phrases) <= 3, en_francais(r)]
note = couverture * (0.5 + 0.25 * sum(points)) # le contenu d'abord ; la forme module
return note, f"{len(phrases)} phrase(s), notions manquantes : {', '.join(manquants) or 'aucune'}"
def _controle_json(r: str) -> tuple[float, str]:
d = lire_json(r)
if not isinstance(d, dict) or not isinstance(d.get("taches"), list):
return 0.0, "JSON absent ou mal formé"
taches = d["taches"]
bonnes = [t for t in taches if isinstance(t, dict) and isinstance(t.get("titre"), str) and en_francais(t["titre"] + " le")
and isinstance(t.get("duree_minutes"), int) and t.get("ordre") in (1, 2, 3)]
texte_autour = not r.strip().startswith(("{", "```"))
note = (len(bonnes) / 3 if len(taches) == 3 else 0.3) - (0.2 if texte_autour else 0)
return max(note, 0.0), f"{len(taches)} tâche(s), {len(bonnes)} conforme(s)" + (", texte autour" if texte_autour else "")
# --- rapide : lire beaucoup pour rendre peu --------------------------------------------------------------------------
def controle_faits(r: str, attendus: dict) -> tuple[float, str]:
d = lire_json(r)
if not isinstance(d, dict):
return 0.0, "JSON absent ou mal formé"
faux = [k for k, v in attendus.items() if str(d.get(k)).strip().lower() != str(v).lower()]
return 1 - len(faux) / len(attendus), f"{len(attendus) - len(faux)}/{len(attendus)} faits exacts" + (
f" (faux : {', '.join(faux)})" if faux else "")
def controle_pytest(r: str) -> tuple[float, str]:
d = lire_json(r)
if not isinstance(d, dict):
return 0.0, "JSON absent ou mal formé"
attendus = {"test_upload_in_chunks_then_available", "test_slug_changed_removes_the_old_version",
"test_cleanup_simulates_then_deletes_on_request"}
trouves = {str(t).split("::")[-1].split(" ")[0] for t in d.get("echecs") or []}
points = [trouves == attendus, d.get("reussis") == 209, d.get("ignores") == 2, d.get("fichiers") ==
sorted(["tests/test_blog_publish.py", "tests/test_media_worker.py"])
or sorted(d.get("fichiers") or []) == ["tests/test_blog_publish.py", "tests/test_media_worker.py"]]
return sum(points) / len(points), f"{sum(points)}/4 points (échecs trouvés : {len(trouves & attendus)}/3)"
def controle_questions(r: str) -> tuple[float, str]:
d = lire_json(r)
if not isinstance(d, dict):
return 0.0, "JSON absent ou mal formé"
attendus = {"q1": ["30"], "q2": ["apc-mediastorage"], "q3": ["media-preprod.a2msystemes.org"],
"q4": ["nas"], "q5": [".incoming"]}
justes = [k for k, mots in attendus.items() if any(m in str(d.get(k, "")).lower() for m in mots)]
return len(justes) / len(attendus), f"{len(justes)}/{len(attendus)} réponses justes"
RAPIDE = [
Tache("log-deploiement", "rapide", "lecture", """Voici le log d'un déploiement de site. Réponds UNIQUEMENT par un objet
JSON avec ces clés : cible (preprod ou prod), commit (7 caractères), duree_totale (texte exact du log),
numero_deploiement (entier), sante_ok (booléen), fichiers_api_transferes (entier).""",
lambda r: controle_faits(r, {"cible": "preprod", "commit": "ec2db97", "duree_totale": "4 min 29 s",
"numero_deploiement": 20, "sante_ok": True, "fichiers_api_transferes": 0}),
fichier="deploiement.log"),
Tache("log-tests", "rapide", "lecture", """Voici la sortie d'une suite de tests. Réponds UNIQUEMENT par un objet JSON
avec : echecs (liste des noms des tests en échec, sans le chemin), reussis (entier), ignores (entier), fichiers (liste
triée des fichiers de test qui ont au moins un échec, chemins avec des « / »).""", controle_pytest,
fichier="pytest.log"),
Tache("questions-doc", "rapide", "lecture", """Lis ce document et réponds UNIQUEMENT par un objet JSON avec les clés
q1 à q5 (réponses courtes) : q1 = délai de grâce par défaut du nettoyage, en jours ; q2 = nom du repo Gitea des médias ;
q3 = adresse (domaine) des médias publics de préproduction ; q4 = lieu où se trouve la source de vérité des médias
(un mot) ; q5 = dossier d'IONOS où arrivent les envois en cours.""", controle_questions, fichier="contrat-medias.md"),
]
# --- vision -----------------------------------------------------------------------------------------------------------
def controle_alt(r: str) -> tuple[float, str]:
couverture, manquants = mots_cles(r, [["tête", "tete", "head"], ["mannequin", "buste", "sculpt", "modèle", "3d"],
["dos", "arrière", "derrière", "back"], ["gris", "grey", "gray"]])
court = len(r.strip()) <= 125
return couverture * 0.8 + (0.2 if court else 0), f"{len(r.strip())} caractères, manque : {', '.join(manquants) or 'rien'}"
def controle_lecture_image(r: str) -> tuple[float, str]:
attendus = ["a2m media worker --once", "erreur 409", "le nas et le site divergent", "3 livraison"]
bas = r.lower()
justes = [a for a in attendus if a in bas]
return len(justes) / len(attendus), f"{len(justes)}/{len(attendus)} passages lus"
VISION = [
Tache("vision-alt", "vision", "lecture", "Écris le texte alternatif (attribut alt) de cette image pour un site web, "
"en français, en une phrase de 125 caractères au plus. Réponds seulement avec la phrase.", controle_alt,
fichier="vision-mannequin.png"),
Tache("vision-texte", "vision", "lecture", "Recopie exactement le texte de cette capture d'écran, ligne par ligne, "
"sans rien ajouter.", controle_lecture_image, fichier="vision-texte.png"),
Tache("vision-formes", "vision", "lecture", "Compte les formes de cette image. Réponds UNIQUEMENT par un objet JSON : "
'{"cercles_rouges": n, "carres_bleus": n, "triangles_verts": n}.',
lambda r: controle_faits(r, {"cercles_rouges": 3, "carres_bleus": 2, "triangles_verts": 1}),
fichier="vision-formes.png"),
]
TACHES = CODE + TEXTE + RAPIDE + VISION
CATEGORIES = ("code", "texte", "rapide", "vision")
-109
View File
@@ -1,109 +0,0 @@
"""Contrôle du banc lui-même : une réponse parfaite doit avoir 1, une réponse fausse une note basse.
Usage : python verifier_controles.py (à relancer après toute modification de taches.py).
"""
from taches import EN_REFERENCE, FR_REFERENCE, TACHES
PARFAITES = {
"code-slug": '''```python
import re, unicodedata
def slug(date, title):
t = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode().lower()
t = re.sub(r"[^a-z0-9]+", "-", t).strip("-")
return f"{date}-{t}" if t else date
```''',
"code-version": '''```python
def bump(version, part):
morceaux = version.split(".")
if len(morceaux) != 3 or not all(m.isdigit() for m in morceaux):
raise ValueError(version)
a, b, c = map(int, morceaux)
return {"major": f"{a + 1}.0.0", "minor": f"{a}.{b + 1}.0", "patch": f"{a}.{b}.{c + 1}"}[part]
```''',
"code-visibilite": '''```python
RANG = {"groups": 1, "members": 2, "unlisted": 3, "public": 4}
def more_visible(a, b):
if RANG[a["level"]] != RANG[b["level"]]:
return RANG[a["level"]] > RANG[b["level"]]
if a["level"] == "groups":
return not set(a.get("groups", [])) <= set(b.get("groups", []))
return False
```''',
"code-tus": '''```python
import base64
def parse_metadata(header):
resultat = {}
for paire in filter(None, (p.strip() for p in header.split(","))):
cle, _, valeur = paire.partition(" ")
resultat[cle] = base64.b64decode(valeur).decode() if valeur else ""
return resultat
```''',
"code-js": '''```javascript
function formatBytes(n) {
const unites = ["o", "Kio", "Mio", "Gio"];
let i = 0;
while (n >= 1024 && i < unites.length - 1) { n /= 1024; i++; }
const v = Math.round(n * 10) / 10;
return String(v).replace(".", ",") + " " + unites[i];
}
```''',
"svg-nuage": '''```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>
<path d="M12 18v-7"/><path d="M9 14l3-3 3 3"/></svg>
```''',
"svg-dossier": '''```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<line x1="12" y1="10" x2="12" y2="16"/><line x1="9" y1="13" x2="15" y2="13"/></svg>
```''',
"trad-fr-en": EN_REFERENCE,
"trad-en-fr": FR_REFERENCE,
"notes-version": """### Ajouté
- `a2m media cleanup` et `a2m deploy --cleanup` : nettoyage des médias qui ne sont plus utilisés.
- `a2m media keep` : protéger un média du nettoyage.
- `a2m media worker` : envoi des médias vers le site, avec reprise après une coupure.
- `a2m media status` et `a2m media visibility`.
- `a2m blog new` et le dossier des brouillons dans `a2m config`.
### Modifié
- L'envoi des médias se lance de lui-même après une publication (`--no-send` pour l'éviter).
- Les erreurs du site s'affichent en détail.
- L'option `--deploy` est vérifiée dès la lecture de la commande.""",
"resume-court": "Les médias des articles sont gardés sur le NAS dans un repo Gitea en LFS, qui fait foi et n'est "
"jamais nettoyé. IONOS les diffuse et supprime ceux qui ne sont plus cités après un délai. La "
"publication n'attend pas les médias, envoyés ensuite par morceaux.",
"consigne-json": '{"taches": [{"titre": "Écrire le brouillon", "duree_minutes": 60, "ordre": 1}, {"titre": "Ajouter '
'les images", "duree_minutes": 15, "ordre": 2}, {"titre": "Publier et vérifier le site", '
'"duree_minutes": 10, "ordre": 3}]}',
"log-deploiement": '{"cible": "preprod", "commit": "ec2db97", "duree_totale": "4 min 29 s", "numero_deploiement": '
'20, "sante_ok": true, "fichiers_api_transferes": 0}',
"log-tests": '{"echecs": ["test_upload_in_chunks_then_available", "test_slug_changed_removes_the_old_version", '
'"test_cleanup_simulates_then_deletes_on_request"], "reussis": 209, "ignores": 2, "fichiers": '
'["tests/test_blog_publish.py", "tests/test_media_worker.py"]}',
"questions-doc": '{"q1": "30 jours", "q2": "apc-mediastorage", "q3": "media-preprod.a2msystemes.org", "q4": "le NAS", '
'"q5": ".incoming/"}',
"vision-alt": "Tête de mannequin lisse vue de dos, sur fond gris.",
"vision-texte": "PS E:\\projets> a2m media worker --once\nErreur 409 : le NAS et le site divergent\n"
"Fin : 3 livraison(s) encore en attente",
"vision-formes": '{"cercles_rouges": 3, "carres_bleus": 2, "triangles_verts": 1}',
}
FAUSSE = "Je ne sais pas."
def main() -> None:
erreurs = 0
for tache in TACHES:
bonne, detail = tache.controle(PARFAITES[tache.id])
mauvaise, _ = tache.controle(FAUSSE)
ok = bonne >= 0.99 and mauvaise <= 0.3
erreurs += not ok
print(f"{'ok ' if ok else 'KO '} {tache.id:<16} parfaite {bonne:.2f} ({detail}) ; fausse {mauvaise:.2f}")
print(f"\n{len(TACHES)} tâches, {erreurs} contrôle(s) à revoir")
raise SystemExit(1 if erreurs else 0)
if __name__ == "__main__":
main()
-36
View File
@@ -1,36 +0,0 @@
---
name: banc
description: Lance le banc d'essai des presets de MIAM (tâches fixes code/texte/rapide passées par deleguer) ou affiche son dernier rapport. Le résultat sert au subagent miam:delegue pour choisir le preset à démarrer.
disable-model-invocation: true
argument-hint: "[rapport | preset… | --categorie code|texte|rapide]"
---
# Banc d'essai des presets de MIAM
Pour chaque preset validé de MIAM : `demarrer(remplacer=True)`, puis 15 tâches fixes (code, texte, rapide) envoyées par
`deleguer` et notées automatiquement. Résultats dans `${CLAUDE_PLUGIN_DATA}/banc/` : `rapport.md`, `choix.json` (lu par
`miam:delegue`), un dossier par essai. Plusieurs minutes par preset ; aucun token de Claude pendant l'essai.
Arguments reçus : `$ARGUMENTS`
## « rapport » (ou aucun argument et un rapport existe déjà)
Lis `${CLAUDE_PLUGIN_DATA}/banc/rapport.md` et montre le tableau « Choix par catégorie », la date du dernier essai, et
propose de relancer le banc.
## Lancer le banc
1. Appelle `etat` (outil MIAM) : MIAM doit répondre. Une délégation en cours ? Préviens que le banc attendra son tour.
2. Préviens l'utilisateur : le modèle chargé sera remplacé pendant l'essai, puis remis à la fin ; Unreal doit être fermé.
3. Lance le banc dans une **fenêtre PowerShell externe** (jamais dans un onglet de l'application) :
```powershell
Start-Process pwsh -ArgumentList '-NoExit', '-Command', "python '${CLAUDE_PLUGIN_ROOT}/banc/banc_miam.py' --sortie '${CLAUDE_PLUGIN_DATA}/banc' $ARGUMENTS"
```
(les arguments sont des id ou noms de presets, ou `--categorie …` ; sans argument : tous les presets validés).
Le script lit le jeton dans `%LOCALAPPDATA%\MIAM\config.json` (ou la variable `MIAM_JETON`) et le port dans
`MIAM_PORT` (défaut 8090 ; le port réglé dans le plugin est `${user_config.port}`, à passer avec `--port` s'il
diffère).
4. Dis à l'utilisateur où lire le résultat (`${CLAUDE_PLUGIN_DATA}/banc/rapport.md`) et de relancer `/miam:banc rapport`
une fois la fenêtre terminée. N'attends pas la fin en interrogeant l'état.
+7 -4
View File
@@ -21,16 +21,19 @@ délégation.
## Comment
Lance le subagent `miam:delegue` (outil Agent, `subagent_type: "miam:delegue"`, `model: "${user_config.modele_subagent}"`).
Il vérifie que MIAM répond, démarre au besoin le bon preset, délègue, attend, vérifie et rend un compte rendu court.
Il vérifie que MIAM répond, indique le type de la tâche pour que MIAM choisisse le modèle, délègue, attend, vérifie et
rend un compte rendu court.
Son brief doit se suffire à lui-même :
- la tâche, précise (signature, langue, format exact, cas limites) ;
- les fichiers en **chemins absolus** (MIAM les lit : ne les lis pas toi-même avant) ;
- le format (`texte`, `code`, `json`) et le fichier de `sortie` éventuel (dire si on peut l'écraser) ;
- comment vérifier (tests à lancer, clés JSON attendues) ;
- s'il peut remplacer le modèle déjà chargé (par défaut : non).
- si besoin : un seuil de qualité (0 à 1, défaut 0,8), un preset imposé, ou « garder le modèle chargé ». Par défaut,
MIAM remplace le modèle chargé s'il est sous le seuil pour cette tâche (20 à 40 s).
Plusieurs tâches indépendantes : plusieurs subagents, MIAM les met en file (une délégation à la fois sur le GPU).
Plusieurs tâches indépendantes : plusieurs subagents, MIAM les met en file (une délégation à la fois sur le GPU) ; des
types différents peuvent recharger un modèle entre deux tâches.
## Ensuite
@@ -40,4 +43,4 @@ Relis le compte rendu : un résultat « raté » ou « partiel », tu le reprend
MIAM injoignable (le compte rendu le dit) : le signaler à l'utilisateur (lancer MIAM et son serveur MCP, puis `/mcp` →
Reconnect sur `plugin:miam:miam`) et faire la tâche toi-même si elle est urgente.
Bilan des délégations : outil `bilan` de MIAM. Meilleur preset par catégorie : `/miam:banc`.
Bilan des délégations : outil `bilan` de MIAM. Notes et caractéristiques des presets : outil `modeles`.