feat(fusion): format commun des skills (ACC-4.5), pilote réunion → ObsiGate (ACC-6.2), contrat panneau (ACC-4.4) — 7.84.0
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
"""Format de skill commun (Fusion ACC-0.3) — lecture, validation,
|
||||
conversion (ACC-4.5).
|
||||
|
||||
Le contrat figé vit dans `dracodev-commons/contrats/skill-format.md` :
|
||||
un dossier par skill, un `SKILL.md` à frontmatter YAML (`name`,
|
||||
`description` = déclencheur, `version`, `apps`, `domaines`, `outils`,
|
||||
section `ecritures` obligatoire dès qu'il y a modification) et un
|
||||
corps en trois sections ordonnées — **Quand l'utiliser**,
|
||||
**Procédure**, **Garde-fous**.
|
||||
|
||||
Ce module est la part Flowdeck de la preuve du format (ACC-4.5) :
|
||||
|
||||
- ``analyser_skill_md`` : SKILL.md → structure (métadonnées + sections) ;
|
||||
- ``valider`` : contrôle du contrat, liste d'erreurs lisibles ;
|
||||
- ``vers_champs_flowdeck`` : conversion vers les champs d'un skill
|
||||
Flowdeck (`agent_skills` / galerie) — les outils `flowdeck.*`
|
||||
deviennent les `allowed_tools`, la Procédure devient le
|
||||
`prompt_template` (précédée du déclencheur et suivie des garde-fous) ;
|
||||
- ``depuis_preset_flowdeck`` : conversion inverse d'un preset de la
|
||||
galerie vers un SKILL.md au format commun (point de départ d'une
|
||||
conversion relue par un humain).
|
||||
|
||||
Les skills convertis de la galerie vivent dans `skills-communs/` à la
|
||||
racine du dépôt ; ils sont installables tels quels via
|
||||
``skill_gallery.upsert_skill(vers_champs_flowdeck(...))``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import yaml
|
||||
|
||||
#: Sections du corps, dans l'ordre imposé par le contrat.
|
||||
SECTIONS = ("Quand l'utiliser", "Procédure", "Garde-fous")
|
||||
|
||||
#: Valeurs admises pour l'approbation d'une écriture.
|
||||
APPROBATIONS = ("toujours", "selon-portee", "jamais")
|
||||
|
||||
#: Champs de frontmatter obligatoires (contrat § « En-tête »).
|
||||
CHAMPS_REQUIS = ("name", "description", "version", "apps", "domaines")
|
||||
|
||||
_KEBAB = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
||||
_TITRE_SECTION = re.compile(r"^#{1,3}\s+(.+?)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
def analyser_skill_md(texte: str) -> dict:
|
||||
"""SKILL.md → {meta, sections, corps}. Lève ValueError si le
|
||||
frontmatter est absent ou illisible."""
|
||||
brut = (texte or "").replace("\r\n", "\n")
|
||||
if not brut.startswith("---\n"):
|
||||
raise ValueError("SKILL.md doit commencer par un frontmatter YAML (---)")
|
||||
fin = brut.find("\n---", 4)
|
||||
if fin < 0:
|
||||
raise ValueError("Frontmatter YAML non fermé (---)")
|
||||
try:
|
||||
meta = yaml.safe_load(brut[4:fin]) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise ValueError(f"Frontmatter YAML illisible : {exc}") from exc
|
||||
if not isinstance(meta, dict):
|
||||
raise ValueError("Frontmatter YAML : un objet est attendu")
|
||||
corps = brut[fin + 4:].lstrip("\n")
|
||||
sections: dict[str, str] = {}
|
||||
marques = [(m.start(), m.group(1).strip()) for m in _TITRE_SECTION.finditer(corps)]
|
||||
for i, (pos, titre) in enumerate(marques):
|
||||
if titre in SECTIONS:
|
||||
debut = corps.find("\n", pos) + 1
|
||||
fin_s = marques[i + 1][0] if i + 1 < len(marques) else len(corps)
|
||||
sections[titre] = corps[debut:fin_s].strip()
|
||||
return {"meta": meta, "sections": sections, "corps": corps}
|
||||
|
||||
|
||||
def _outils_flowdeck(skill: dict) -> list[str]:
|
||||
"""Noms d'outils Flowdeck déclarés (préfixe `flowdeck.` retiré),
|
||||
lectures (`outils`) puis écritures (`ecritures`), sans doublons."""
|
||||
meta = skill["meta"]
|
||||
noms: list[str] = []
|
||||
for entree in meta.get("outils") or []:
|
||||
nom = str(entree).strip()
|
||||
if nom.startswith("flowdeck."):
|
||||
noms.append(nom[len("flowdeck."):])
|
||||
for entree in meta.get("ecritures") or []:
|
||||
if isinstance(entree, dict):
|
||||
nom = str(entree.get("action") or "").strip()
|
||||
if nom.startswith("flowdeck."):
|
||||
noms.append(nom[len("flowdeck."):])
|
||||
return list(dict.fromkeys(noms))
|
||||
|
||||
|
||||
def valider(skill: dict) -> list[str]:
|
||||
"""Contrôle le contrat ACC-0.3 → liste d'erreurs (vide = conforme)."""
|
||||
erreurs: list[str] = []
|
||||
meta = skill.get("meta") or {}
|
||||
for champ in CHAMPS_REQUIS:
|
||||
valeur = meta.get(champ)
|
||||
if valeur is None or (isinstance(valeur, str) and not valeur.strip()) \
|
||||
or (isinstance(valeur, list) and not valeur):
|
||||
erreurs.append(f"Champ obligatoire manquant ou vide : {champ}")
|
||||
nom = str(meta.get("name") or "")
|
||||
if nom and not _KEBAB.match(nom):
|
||||
erreurs.append(f"name doit être en kebab-case : {nom!r}")
|
||||
for champ in ("apps", "domaines"):
|
||||
valeur = meta.get(champ)
|
||||
if valeur is not None and (not isinstance(valeur, list)
|
||||
or not all(isinstance(v, str) for v in valeur)):
|
||||
erreurs.append(f"{champ} doit être une liste de chaînes")
|
||||
outils = meta.get("outils") or []
|
||||
if not isinstance(outils, list) or not all(isinstance(o, str) for o in outils):
|
||||
erreurs.append("outils doit être une liste de noms (app.outil)")
|
||||
ecritures = meta.get("ecritures") or []
|
||||
if not isinstance(ecritures, list):
|
||||
erreurs.append("ecritures doit être une liste d'objets {action, approbation}")
|
||||
ecritures = []
|
||||
for entree in ecritures:
|
||||
if not isinstance(entree, dict) or not str(entree.get("action") or "").strip():
|
||||
erreurs.append("Chaque écriture doit déclarer une action")
|
||||
continue
|
||||
approbation = str(entree.get("approbation") or "selon-portee")
|
||||
if approbation not in APPROBATIONS:
|
||||
erreurs.append(
|
||||
f"approbation inconnue pour {entree['action']} : {approbation!r} "
|
||||
f"(attendu : {', '.join(APPROBATIONS)})")
|
||||
# Règle du contrat : « ecritures » obligatoire dès que le skill
|
||||
# modifie quelque chose — pour la part Flowdeck, un outil déclaré
|
||||
# qui est un outil d'écriture interne impose la section.
|
||||
if not ecritures and "flowdeck" in (meta.get("apps") or []):
|
||||
from app.services.permission_manager import DESTRUCTIVE_TOOLS, WRITE_TOOLS
|
||||
|
||||
modifiants = [o for o in _outils_flowdeck(skill)
|
||||
if o in WRITE_TOOLS or o in DESTRUCTIVE_TOOLS]
|
||||
if modifiants:
|
||||
erreurs.append(
|
||||
"Section ecritures obligatoire : le skill utilise des outils "
|
||||
f"d'écriture Flowdeck ({', '.join(modifiants)})")
|
||||
sections = skill.get("sections") or {}
|
||||
for titre in SECTIONS:
|
||||
if not sections.get(titre):
|
||||
erreurs.append(f"Section de corps manquante ou vide : « {titre} »")
|
||||
return erreurs
|
||||
|
||||
|
||||
def vers_champs_flowdeck(skill: dict) -> dict:
|
||||
"""Skill au format commun → champs Flowdeck (`agent_skills`).
|
||||
|
||||
Le `prompt_template` assemble le déclencheur, la procédure et les
|
||||
garde-fous ; les écritures déclarées restent visibles dans le
|
||||
prompt (le skill ne s'exécute jamais sans la politique d'agent).
|
||||
"""
|
||||
erreurs = valider(skill)
|
||||
if erreurs:
|
||||
raise ValueError("Skill non conforme au format commun : " + " ; ".join(erreurs))
|
||||
meta = skill["meta"]
|
||||
sections = skill["sections"]
|
||||
morceaux = [
|
||||
"Quand l'utiliser\n" + sections["Quand l'utiliser"],
|
||||
"Procédure\n" + sections["Procédure"],
|
||||
"Garde-fous\n" + sections["Garde-fous"],
|
||||
]
|
||||
ecritures = meta.get("ecritures") or []
|
||||
if ecritures:
|
||||
lignes = "\n".join(
|
||||
f"- {e['action']} : approbation "
|
||||
f"{e.get('approbation') or 'selon-portee'}"
|
||||
for e in ecritures)
|
||||
morceaux.append(f"Écritures déclarées (format commun)\n{lignes}")
|
||||
return {
|
||||
"name": str(meta["name"]),
|
||||
"description": str(meta["description"]).strip(),
|
||||
"prompt_template": "\n\n".join(morceaux),
|
||||
"allowed_tools": _outils_flowdeck(skill),
|
||||
}
|
||||
|
||||
|
||||
def _slug(nom: str) -> str:
|
||||
import unicodedata
|
||||
|
||||
plat = unicodedata.normalize("NFD", nom.lower())
|
||||
plat = "".join(c for c in plat if unicodedata.category(c) != "Mn")
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", plat).strip("-")
|
||||
return slug or "skill"
|
||||
|
||||
|
||||
def depuis_preset_flowdeck(preset: dict, *, domaines: list[str] | None = None) -> str:
|
||||
"""Preset de la galerie Flowdeck → SKILL.md au format commun.
|
||||
|
||||
Point de départ d'une conversion (ACC-4.5) : les outils
|
||||
d'écriture du preset deviennent la section `ecritures`
|
||||
(approbation `selon-portee`, le régime par défaut du contrat) ;
|
||||
le texte est à relire par un humain avant diffusion.
|
||||
"""
|
||||
from app.services.permission_manager import WRITE_TOOLS
|
||||
|
||||
outils = [str(o) for o in preset.get("allowed_tools") or []]
|
||||
lectures = [o for o in outils if o not in WRITE_TOOLS]
|
||||
ecritures = [o for o in outils if o in WRITE_TOOLS]
|
||||
meta: dict = {
|
||||
"name": _slug(str(preset.get("name") or "skill")),
|
||||
"description": str(preset.get("description") or "").strip(),
|
||||
"version": "1.0.0",
|
||||
"apps": ["flowdeck"],
|
||||
"domaines": domaines or ["pro"],
|
||||
"outils": [f"flowdeck.{o}" for o in lectures],
|
||||
}
|
||||
if ecritures:
|
||||
meta["ecritures"] = [
|
||||
{"action": f"flowdeck.{o}", "approbation": "selon-portee"}
|
||||
for o in ecritures
|
||||
]
|
||||
front = yaml.safe_dump(meta, allow_unicode=True, sort_keys=False).strip()
|
||||
corps = (
|
||||
f"## Quand l'utiliser\n\n{preset.get('description') or ''}\n\n"
|
||||
f"## Procédure\n\n{preset.get('prompt_template') or ''}\n\n"
|
||||
"## Garde-fous\n\n"
|
||||
"- N'agir que dans les domaines déclarés ; un besoin croisé "
|
||||
"entre domaines est signalé, jamais contourné.\n"
|
||||
"- Ne jamais inventer un contenu absent des sources lues.\n"
|
||||
)
|
||||
return f"---\n{front}\n---\n\n{corps}"
|
||||
Reference in New Issue
Block a user