feat(fusion): format commun des skills (ACC-4.5), pilote réunion → ObsiGate (ACC-6.2), contrat panneau (ACC-4.4) — 7.84.0
FlowDeck CI / test (push) Failing after 3h11m27s
FlowDeck CI / lint (push) Successful in 1m51s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
Spark
2026-10-26 16:19:16 -04:00
parent eff459ddd7
commit 913d6137b1
14 changed files with 915 additions and 4 deletions
+166
View File
@@ -0,0 +1,166 @@
"""Pilote ACC-6.2 — archiver un résumé de réunion Flowdeck dans
ObsiGate, **via la file d'approbations unifiée**.
Chaîne du pilote (patron d'écriture approuvée §7.5) :
1. ``demander_approbation`` — depuis la note de réunion (transcript
avec résumé généré), on prépare l'archive (titre, chemin cible
dans le vault, contenu Markdown) et on dépose une demande dans
``agent_approvals`` (outil ``archiver_reunion_obsigate``) : rien
n'est écrit tant que Bruno n'a pas approuvé.
2. À l'approbation (route gouvernance ``…/agent-approvals/{id}/decide``
qui appelle ``traiter_decision``), ``executer`` envoie le contenu à
ObsiGate par le pont MCP (ACC-2.2) en ``propose_write_file`` —
jamais en écriture directe : ObsiGate garde son propre brouillon,
l'application propriétaire tranche l'application finale.
3. La distribution est journalisée dans ``meeting_distributions``
(canal ``obsigate``), comme les autres canaux de diffusion.
Prérequis réels (gestes de Bruno, inchangés) : connecteur
« obsigate » configuré et sondé dans Flowdeck (clé ObsiGate dédiée
au pont, limitée à un vault) — sans lui, l'exécution échoue avec un
message explicite, la demande reste tracée. Le pilote est
**conditionnel au chantier Meetings** (décision A/B de Bruno) pour
son usage réel ; le code n'en dépend pas : il archive le résumé
d'un transcript existant.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from app.db import get_conn
from app.services import draco_urls, meetings
logger = logging.getLogger(__name__)
#: Nom d'« outil » porté par les demandes d'approbation du pilote
#: dans la file unifiée (`agent_approvals`).
OUTIL = "archiver_reunion_obsigate"
#: Dossier cible dans le vault ObsiGate.
DOSSIER = "Réunions"
def _assainir(nom: str) -> str:
nom = re.sub(r"[\\/:*?\"<>|]+", " ", nom or "")
nom = re.sub(r"\s+", " ", nom).strip().strip(".")
return nom[:80] or "Réunion sans titre"
def construire_archive(tr: dict) -> dict:
"""Transcript (avec résumé) → {titre, chemin, contenu} de l'archive."""
titre = (tr.get("generated_title") or "").strip() or "Réunion"
date = str(tr.get("created_at") or "")[:10]
chemin = f"{DOSSIER}/{date + ' - ' if date else ''}{_assainir(titre)}.md"
lien = f"{draco_urls.flowdeck_url()}/pages/{tr['page_id']}"
contenu = (
f"# {titre}\n\n"
f"> Résumé de réunion archivé depuis Flowdeck "
f"({date or 'date inconnue'}) — note source : {lien}\n\n"
f"{(tr.get('summary') or '').strip()}\n"
)
return {"titre": titre, "chemin": chemin, "contenu": contenu}
def demander_approbation(transcript_id: int, user_id: int) -> dict:
"""Dépose la demande d'archivage dans la file d'approbations.
Lève ValueError si le transcript n'existe pas, si son résumé est
vide (rien à archiver) ou si une demande identique est déjà en
attente (pas de doublon dans la file).
"""
tr = meetings.get_transcript(transcript_id)
if not (tr.get("summary") or "").strip():
raise ValueError("Résumé vide : générer le résumé avant d'archiver")
archive = construire_archive(tr)
args = {"transcript_id": transcript_id, "chemin": archive["chemin"],
"titre": archive["titre"]}
with get_conn() as conn:
en_attente = conn.execute(
"SELECT id, args_json FROM agent_approvals "
"WHERE tool=? AND status='pending'", (OUTIL,)).fetchall()
for row in en_attente:
try:
if json.loads(row["args_json"] or "{}").get("transcript_id") == transcript_id:
raise ValueError(
"Une demande d'archivage est déjà en attente "
f"(approbation #{row['id']})")
except json.JSONDecodeError:
continue
cur = conn.execute(
"INSERT INTO agent_approvals "
"(conversation_id, tool, args_json, status, requester_id) "
"VALUES (0, ?, ?, 'pending', ?)",
(OUTIL, json.dumps(args, ensure_ascii=False), user_id))
conn.commit()
row = conn.execute("SELECT * FROM agent_approvals WHERE id=?",
(cur.lastrowid,)).fetchone()
return dict(row)
def _connecteur_obsigate() -> dict | None:
"""Premier connecteur ObsiGate activé (preset ACC-2.2), ou None."""
from app.services import connectors
for row in connectors.list_connectors():
if row.get("kind") == "obsigate" and row.get("enabled"):
return row
return None
async def executer(approval: dict) -> dict:
"""Exécute un archivage **approuvé** : propose_write_file via le
pont MCP. Retourne {status: ok|erreur, detail} — ne lève pas."""
from app.services import mcp_client
try:
args = json.loads(approval.get("args_json") or "{}")
except json.JSONDecodeError:
args = {}
transcript_id = args.get("transcript_id")
conn = _connecteur_obsigate()
if conn is None:
return {"status": "erreur",
"detail": "Connecteur ObsiGate non configuré dans Flowdeck "
"(Panneau agent → Connecteurs → ObsiGate) : "
"archivage impossible."}
try:
tr = meetings.get_transcript(int(transcript_id))
archive = construire_archive(tr)
res = await mcp_client.call_tool(
conn["id"], "propose_write_file",
{"path": archive["chemin"], "content": archive["contenu"]})
except Exception as exc: # noqa: BLE001 — l'échec est journalisé, pas levé
logger.warning("Archivage réunion ObsiGate échoué: %s", exc)
return {"status": "erreur", "detail": str(exc)[:300]}
if res.get("status") != "ok":
return {"status": "erreur",
"detail": (res.get("text") or "ObsiGate a refusé la proposition")[:300]}
meetings.record_distribution(
int(transcript_id), channel="obsigate",
actor_id=approval.get("approver_id"),
recipients=archive["chemin"], status="propose")
return {"status": "ok",
"detail": f"Proposition d'écriture envoyée à ObsiGate : "
f"{archive['chemin']} (brouillon à appliquer dans ObsiGate)"}
def executer_sync(approval: dict) -> dict:
"""Variante synchrone pour les routes non async (threadpool)."""
return asyncio.run(executer(approval))
def traiter_decision_sync(approval: dict) -> dict | None:
"""Après décision dans la file unifiée : si la demande est du
pilote et approuvée, exécuter l'archivage. Sinon None (autre
outil) — la route gouvernance est synchrone (threadpool)."""
if not approval or approval.get("tool") != OUTIL:
return None
if approval.get("status") != "approved":
return {"status": "rejete",
"detail": "Demande rejetée : rien n'a été envoyé à ObsiGate."}
return executer_sync(approval)
+2 -2
View File
@@ -717,8 +717,8 @@ async def summarize_transcript(transcript_id: int, user_id: int | None = None,
def record_distribution(transcript_id: int, *, channel: str,
actor_id: int | None = None,
recipients: str = "", status: str = "created") -> dict:
if channel not in ("copy_link", "email", "slack"):
raise ValueError("channel must be copy_link|email|slack")
if channel not in ("copy_link", "email", "slack", "obsigate"):
raise ValueError("channel must be copy_link|email|slack|obsigate")
get_transcript(transcript_id)
with get_conn() as conn:
cur = conn.execute(
+218
View File
@@ -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}"