167 lines
6.9 KiB
Python
167 lines
6.9 KiB
Python
"""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)
|