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,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)
|
||||
Reference in New Issue
Block a user