"""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)