diff --git a/CHANGELOG.md b/CHANGELOG.md index 96b992e..34541cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog - FlowDeck +## v7.84.0 (2026-10-26) — Fusion : format commun des skills (ACC-4.5), pilote réunion → ObsiGate (ACC-6.2), panneau commun (ACC-4.4) + +- **ACC-4.5 — format de skill commun** (`app/services/skill_format.py`, + contrat figé ACC-0.3 de `dracodev-commons`). Lecture et validation + d'un `SKILL.md` au format commun (frontmatter YAML : `name`, + `description`-déclencheur, `version`, `apps`, `domaines`, `outils`, + `ecritures` obligatoire dès qu'un outil modifie ; corps en trois + sections ordonnées), conversion vers les champs Flowdeck + (installable via `skill_gallery.upsert_skill`) et conversion + inverse depuis un preset de la galerie. **Preuve du format** : + trois skills de la galerie convertis dans `skills-communs/` — + `compte-rendu-reunion`, `rapport-hebdo`, `resume-document`. +- **ACC-6.2 — pilote d'écriture approuvée** (`app/services/meeting_archive.py`). + Nouvelle route `POST /api/v2/meetings/transcripts/{id}/archiver-obsigate` : + prépare l'archive du résumé de réunion (chemin `Réunions/ - .md`, + contenu Markdown avec lien profond vers la note) et la dépose dans + la **file d'approbations unifiée** (`agent_approvals`, outil + `archiver_reunion_obsigate`) — rien n'est écrit avant approbation. + À la décision favorable (route gouvernance), `propose_write_file` + part vers ObsiGate par le pont MCP (ACC-2.2) et la distribution est + journalisée (nouveau canal `obsigate` de `meeting_distributions`). + Sans connecteur ObsiGate configuré, l'exécution échoue avec un + message explicite. Pilote : usage réel conditionnel au chantier + Meetings (décision A/B) et à la clé ObsiGate dédiée au pont. +- **ACC-4.4 — panneau de l'agent commun** : intégré depuis la 7.81.0 + (panneau v3, conversation commune du service d'agents), complété + par le mode console (7.82.x) et la version du service en sidebar + (7.83.1). Cette version fige l'intégration par des tests de + contrat (`tests/test_fusion_panneau_commun.py`) : assets chargés, + configuration injectée par l'hôte (aucune URL publique codée en + dur), conversation du service — et automatisations locales + conservées (décision C5). + ## v7.83.1 (2026-10-26) — Panneau Spark : version du service d'agents visible en mode sidebar - **Panneau Spark** (`static/js/draco-panneau.js`) : le pied du panneau diff --git a/VERSION b/VERSION index de3f8e8..1ec007e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.83.1 +7.84.0 diff --git a/app/main.py b/app/main.py index 3ec4d37..b07a25a 100644 --- a/app/main.py +++ b/app/main.py @@ -216,7 +216,7 @@ def _lire_version() -> str: # Littéral de version synchronisé avec le fichier VERSION : -# version="7.83.1" +# version="7.84.0" # Patron historique des bumps (voir scripts/bump_version.py, qui cherche # ce littéral) et contrôle de tests/test_pwa_offline.py. La version # runtime reste lue dynamiquement depuis VERSION par _lire_version() diff --git a/app/routers/governance.py b/app/routers/governance.py index 31bcf0d..778d2f7 100644 --- a/app/routers/governance.py +++ b/app/routers/governance.py @@ -87,4 +87,13 @@ def decide_approval(approval_id: int, request: Request, body: dict = Body(defaul raise HTTPException(404, "Pending approval not found") audit_log(user, "agent.approval.decide", "agent_approval", approval_id, out["status"], request) + # Pilote ACC-6.2 : une demande d'archivage de réunion approuvée + # s'exécute ici (proposition d'écriture vers ObsiGate) ; le + # résultat accompagne la réponse, sans changer le contrat de la + # file pour les autres outils. + from app.services import meeting_archive + + archive = meeting_archive.traiter_decision_sync(out) + if archive is not None: + return {**out, "archive": archive} return out diff --git a/app/routers/meetings.py b/app/routers/meetings.py index 54ecb56..25e7480 100644 --- a/app/routers/meetings.py +++ b/app/routers/meetings.py @@ -396,6 +396,28 @@ def get_transcript_row(transcript_id: int, request: Request): raise HTTPException(404, str(exc)) from None +@router.post("/api/v2/meetings/transcripts/{transcript_id}/archiver-obsigate", + status_code=201) +def archiver_obsigate(transcript_id: int, request: Request): + """Pilote ACC-6.2 : dépose l'archivage du résumé dans ObsiGate + dans la file d'approbations unifiée (rien n'est écrit avant + l'approbation ; l'exécution part de la décision, voir + governance.decide_approval).""" + user = _auth_user(request, require_write=True) + from app.services import meeting_archive + + try: + approval = meeting_archive.demander_approbation(transcript_id, user["id"]) + except ValueError as exc: + msg = str(exc) + raise HTTPException(404 if "not found" in msg else 400, msg) from None + audit_log(user, "meeting.archive.request", "transcript", transcript_id, + f"approval #{approval['id']}", request) + archive = meeting_archive.construire_archive(meet.get_transcript(transcript_id)) + return {"approval": approval, "titre": archive["titre"], + "chemin": archive["chemin"]} + + @router.post("/api/v2/meetings/transcripts/{transcript_id}/consents") def post_consent(transcript_id: int, request: Request, body: dict = Body(default={})): user = _auth_user(request, require_write=True) diff --git a/app/services/meeting_archive.py b/app/services/meeting_archive.py new file mode 100644 index 0000000..ffc67ea --- /dev/null +++ b/app/services/meeting_archive.py @@ -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) diff --git a/app/services/meetings.py b/app/services/meetings.py index a934182..7d31368 100644 --- a/app/services/meetings.py +++ b/app/services/meetings.py @@ -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( diff --git a/app/services/skill_format.py b/app/services/skill_format.py new file mode 100644 index 0000000..f7cffd9 --- /dev/null +++ b/app/services/skill_format.py @@ -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}" diff --git a/skills-communs/compte-rendu-reunion/SKILL.md b/skills-communs/compte-rendu-reunion/SKILL.md new file mode 100644 index 0000000..f89f78e --- /dev/null +++ b/skills-communs/compte-rendu-reunion/SKILL.md @@ -0,0 +1,46 @@ +--- +name: compte-rendu-reunion +description: > + Transforme une note brute de réunion en compte rendu structuré + (décisions et actions) dans Flowdeck. À utiliser quand Bruno demande + un compte rendu, un CR ou une synthèse décisionnelle à partir d'une + note de réunion. +version: 1.0.0 +apps: [flowdeck] +domaines: [pro, famille, perso] +outils: + - flowdeck.search_workspace + - flowdeck.read_document +ecritures: + - action: flowdeck.create_document + approbation: selon-portee + - action: flowdeck.write_blocks + approbation: selon-portee +--- + +## Quand l'utiliser + +Quand Bruno fournit une note de réunion brute — ou demande de la +retrouver dans le workspace — et veut un compte rendu propre : +décisions prises, actions avec responsable et échéance, points +ouverts. Ne pas l'utiliser pour un simple résumé informatif sans +décisions (voir `resume-document`). + +## Procédure + +1. Retrouver la note de réunion si elle n'est pas fournie + (search_workspace), puis la lire intégralement (read_document). +2. Créer un document « CR — » (create_document). +3. Y écrire (write_blocks) la structure : Contexte · Décisions + prises · Actions (avec responsable et échéance) · Points ouverts. +4. Ne garder que ce qui est dans la note ; lister explicitement les + points manquants plutôt que de les combler. + +## Garde-fous + +- Aucune décision ni action inventée : tout vient de la note lue. +- N'agir que dans les domaines déclarés ; un besoin croisé entre + domaines est signalé, jamais contourné. +- L'écriture du document suit la politique d'approbation de l'agent + (section `ecritures`) : le contenu exact est présenté avant + exécution quand la portée l'exige. diff --git a/skills-communs/rapport-hebdo/SKILL.md b/skills-communs/rapport-hebdo/SKILL.md new file mode 100644 index 0000000..a83af8b --- /dev/null +++ b/skills-communs/rapport-hebdo/SKILL.md @@ -0,0 +1,46 @@ +--- +name: rapport-hebdo +description: > + Agrège les pages et documents modifiés de la semaine dans Flowdeck + et rédige un rapport structuré de l'équipe. À utiliser quand Bruno + demande le rapport hebdomadaire, un bilan de semaine ou l'état + d'avancement de l'équipe. +version: 1.0.0 +apps: [flowdeck] +domaines: [pro] +outils: + - flowdeck.search_workspace + - flowdeck.read_document +ecritures: + - action: flowdeck.create_document + approbation: selon-portee + - action: flowdeck.write_blocks + approbation: selon-portee +--- + +## Quand l'utiliser + +Quand Bruno demande le rapport de la semaine : ce qui a avancé, ce +qui bloque, ce qui a été décidé, et le plan de la semaine suivante. +Ne pas l'utiliser pour une seule réunion (voir +`compte-rendu-reunion`) ni pour un document unique (voir +`resume-document`). + +## Procédure + +1. Repérer les pages et documents modifiés cette semaine + (search_workspace). +2. Lire les plus significatifs (read_document) et en extraire + avancées, blocages et décisions. +3. Créer un document « Rapport hebdo — » (create_document) et + y écrire (write_blocks) la structure : Résumé · Faits marquants · + Blocages · Plan de la semaine prochaine. + +## Garde-fous + +- Chaque affirmation s'appuie sur un document lu, jamais sur une + supposition ; une section sans élément le dit explicitement. +- Domaine professionnel uniquement (domaine déclaré) : ne pas + mélanger des contenus famille ou perso au rapport d'équipe. +- L'écriture suit la politique d'approbation de l'agent (section + `ecritures`). diff --git a/skills-communs/resume-document/SKILL.md b/skills-communs/resume-document/SKILL.md new file mode 100644 index 0000000..0f0404f --- /dev/null +++ b/skills-communs/resume-document/SKILL.md @@ -0,0 +1,45 @@ +--- +name: resume-document +description: > + Résume un document long de Flowdeck en une page : points clés, + chiffres marquants, décisions et questions ouvertes. À utiliser + quand Bruno demande de résumer, condenser ou synthétiser un + document précis. +version: 1.0.0 +apps: [flowdeck] +domaines: [pro, famille, perso] +outils: + - flowdeck.search_workspace + - flowdeck.read_document +ecritures: + - action: flowdeck.create_document + approbation: selon-portee + - action: flowdeck.write_blocks + approbation: selon-portee +--- + +## Quand l'utiliser + +Quand Bruno désigne un document — ou demande de le retrouver — et +veut l'essentiel en une page. Ne pas l'utiliser pour agréger +plusieurs documents sur une période (voir `rapport-hebdo`) ni pour +produire un compte rendu décisionnel de réunion (voir +`compte-rendu-reunion`). + +## Procédure + +1. Retrouver le document s'il n'est pas fourni (search_workspace), + puis le lire intégralement (read_document). +2. Créer un document « Résumé — » (create_document). +3. Y écrire (write_blocks) : 5 points clés, chiffres marquants, + décisions/engagements, questions restées ouvertes. Maximum une + page, phrases courtes. + +## Garde-fous + +- Aucune reformulation qui change le sens ; en cas de doute, + citer le passage plutôt que l'interpréter. +- N'agir que dans les domaines déclarés ; un besoin croisé entre + domaines est signalé, jamais contourné. +- L'écriture suit la politique d'approbation de l'agent (section + `ecritures`). diff --git a/tests/test_fusion_format_commun.py b/tests/test_fusion_format_commun.py new file mode 100644 index 0000000..3322927 --- /dev/null +++ b/tests/test_fusion_format_commun.py @@ -0,0 +1,78 @@ +"""Fusion ACC-4.5 — conversion d'Agents & Skills Flowdeck au format +commun (contrat ACC-0.3, `dracodev-commons/contrats/skill-format.md`). + +Preuve du format : trois presets de la galerie Flowdeck convertis en +`skills-communs//SKILL.md` (compte-rendu-reunion, rapport-hebdo, +resume-document). Ils doivent être conformes au contrat, se convertir +en champs Flowdeck et s'installer dans un workspace via le chemin +existant (`skill_gallery.upsert_skill`). +""" + +from pathlib import Path + +import pytest + +from app.services import skill_format, skill_gallery + +RACINE = Path(__file__).resolve().parent.parent +CONVERTIS = ["compte-rendu-reunion", "rapport-hebdo", "resume-document"] + + +def _lire(nom: str) -> str: + return (RACINE / "skills-communs" / nom / "SKILL.md").read_text(encoding="utf-8") + + +def test_trois_skills_convertis_conformes(): + for nom in CONVERTIS: + skill = skill_format.analyser_skill_md(_lire(nom)) + assert skill_format.valider(skill) == [], (nom, skill_format.valider(skill)) + assert skill["meta"]["name"] == nom + assert skill["meta"]["apps"] == ["flowdeck"] + assert skill["meta"]["version"] == "1.0.0" + # Toute écriture est déclarée, avec approbation explicite. + actions = {e["action"] for e in skill["meta"]["ecritures"]} + assert actions == {"flowdeck.create_document", "flowdeck.write_blocks"} + + +def test_conversion_vers_flowdeck_et_installation(client): + for nom in CONVERTIS: + skill = skill_format.analyser_skill_md(_lire(nom)) + champs = skill_format.vers_champs_flowdeck(skill) + assert champs["name"] == nom + assert set(champs["allowed_tools"]) == { + "search_workspace", "read_document", "create_document", "write_blocks"} + assert "Procédure" in champs["prompt_template"] + assert "Garde-fous" in champs["prompt_template"] + row, cree = skill_gallery.upsert_skill( + champs, workspace_id=None, created_by=1) + assert cree is True and row["name"] == nom + + +def test_validation_rejette_les_ecarts(): + incomplet = skill_format.analyser_skill_md( + "---\nname: mauvais nom\napps: [flowdeck]\n---\n\n## Procédure\n\nx\n") + erreurs = skill_format.valider(incomplet) + assert any("kebab" in e for e in erreurs) + assert any("description" in e for e in erreurs) + assert any("Quand l'utiliser" in e for e in erreurs) + with pytest.raises(ValueError): + skill_format.vers_champs_flowdeck(incomplet) + + +def test_ecritures_obligatoires_si_outil_ecrivant(): + texte = ( + "---\nname: supprime-tout\ndescription: x\nversion: 1.0.0\n" + "apps: [flowdeck]\ndomaines: [pro]\noutils: [flowdeck.delete_page]\n---\n\n" + "## Quand l'utiliser\n\nx\n\n## Procédure\n\nx\n\n## Garde-fous\n\nx\n") + erreurs = skill_format.valider(skill_format.analyser_skill_md(texte)) + assert any("ecritures obligatoire" in e for e in erreurs) + + +def test_aller_retour_depuis_un_preset(): + preset = skill_gallery.get_gallery("compte-rendu-reunion") + texte = skill_format.depuis_preset_flowdeck(preset, domaines=["pro"]) + skill = skill_format.analyser_skill_md(texte) + assert skill_format.valider(skill) == [] + champs = skill_format.vers_champs_flowdeck(skill) + assert champs["name"] == "compte-rendu-de-reunion" + assert "create_document" in champs["allowed_tools"] diff --git a/tests/test_fusion_panneau_commun.py b/tests/test_fusion_panneau_commun.py new file mode 100644 index 0000000..7899d4b --- /dev/null +++ b/tests/test_fusion_panneau_commun.py @@ -0,0 +1,57 @@ +"""Fusion ACC-4.4 — panneau de l'agent commun intégré à Flowdeck. + +Le panneau commun (composant partagé des quatre apps, contrat +d'injection `dracodev-commons`) est intégré depuis la 7.81.0 +(panneau v3), complété par le mode console (7.82.x) et la version du +service affichée en mode sidebar (7.83.1, fusion #64). Ces tests +figent le contrat d'intégration côté Flowdeck : + +- le gabarit de base charge les assets du panneau et injecte la + configuration (URL du service d'agents résolue par configuration, + jamais un nom public codé en dur dans le JS) ; +- le panneau parle au service d'agents (conversation **commune** : + même discussion perpétuelle que dans les autres apps) ; +- les automatisations locales de Flowdeck restent locales (C5) : + le panneau commun ne remplace pas le moteur d'agent local. +""" + +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent + + +def test_gabarit_charge_le_panneau_et_injecte_la_config(): + base = (RACINE / "app/templates/base.html").read_text(encoding="utf-8") + assert "/static/css/draco-panneau.css" in base + assert "/static/js/draco-panneau.js" in base + assert "window.DRACO_PANNEAU_CONFIG" in base + assert "draco_agent_url()" in base + + +def test_panneau_parle_au_service_agent_conversation_commune(): + js = (RACINE / "static/js/draco-panneau.js").read_text(encoding="utf-8") + # Même conversation continue que dans les autres apps : le + # panneau s'appuie sur l'API du service (contrat commons). + assert "/api/v1/me" in js + assert "/api/v1/chat" in js + assert "/api/v1/conversations" in js + # La configuration vient de l'hôte ; le seul repli est le nom + # officiel — aucun nom public d'app en dur dans le composant. + assert "DRACO_PANNEAU_CONFIG" in js + for nom in ("flowdeck-dev", "og-dev", "tiktak-dev", "kanbante-dev", + "agent-dev"): + assert f"://{nom}." not in js # aucune URL publique -dev codée en dur + + +def test_assets_panneau_servis(client): + for chemin in ("/static/js/draco-panneau.js", "/static/css/draco-panneau.css"): + r = client.get(chemin) + assert r.status_code == 200, chemin + + +def test_automatisations_locales_conservées(client): + # C5 : l'agent local de Flowdeck (automatisations, gouvernance) + # reste en place à côté du panneau commun. + r = client.get("/api/v2/agent-approvals", params={"status": "pending"}) + assert r.status_code == 200 + assert "approvals" in r.json() diff --git a/tests/test_fusion_pilote_reunion_obsigate.py b/tests/test_fusion_pilote_reunion_obsigate.py new file mode 100644 index 0000000..6a32e07 --- /dev/null +++ b/tests/test_fusion_pilote_reunion_obsigate.py @@ -0,0 +1,191 @@ +"""Fusion ACC-6.2 — pilote d'écriture approuvée : archiver un résumé +de réunion Flowdeck dans ObsiGate via la file d'approbations unifiée. + +Chaîne testée : demande déposée dans `agent_approvals` (outil +`archiver_reunion_obsigate`) → décision dans la file (route +gouvernance) → si approuvée, `propose_write_file` part vers ObsiGate +par le pont MCP (ACC-2.2) et la distribution est journalisée (canal +`obsigate`). Rejet = rien n'est envoyé. Sans connecteur ObsiGate, +l'exécution échoue proprement avec un message explicite. + +La preuve réelle reste conditionnelle (chantier Meetings A/B + +clé ObsiGate dédiée de Bruno) ; ici le serveur MCP est simulé. +""" + +import asyncio +import json + +import pytest + +from app.auth.session import SessionManager +from app.db import get_conn +from app.services import mcp_client, meeting_archive, meetings +from app.services.importers import url_fetch + + +@pytest.fixture(autouse=True) +def _dns_monde_reel(monkeypatch): + monkeypatch.setattr(url_fetch, "_validate_url", lambda url: url.strip()) + monkeypatch.setattr(mcp_client, "_sessions", {}) + + +@pytest.fixture +def admin_cookies(): + with get_conn() as conn: + conn.execute( + "INSERT OR IGNORE INTO users (id, login, full_name, is_admin)" + " VALUES (1, 'tester', 'Tester', 1)") + conn.commit() + session = SessionManager.create_session( + {"id": 1, "login": "tester", "is_admin": 1}) + return {"flowdeck_session": session} + + +def _transcript_avec_resume(): + with get_conn() as conn: + pid = conn.execute( + "INSERT INTO pages (workspace, title, content, content_format)" + " VALUES ('test', 'Réunion hebdo', '', 'blocks')").lastrowid + tid = conn.execute( + "INSERT INTO meeting_transcripts (page_id, summary)" + " VALUES (?, ?)", + (pid, "## Décisions\n\n- Lancer le pilote d'archivage.\n" + "- Revoir le pont MCP la semaine prochaine.")).lastrowid + conn.commit() + meetings._update(tid, generated_title="Hebdo produit", status="done") + return tid + + +def _creer_connecteur_obsigate(client): + r = client.post("/api/agent/connectors", json={ + "name": "ObsiGate", "kind": "obsigate", + "url": "https://og.dracodev.net/mcp", + "secret": "og_cle_pont_test", "auth": "bearer"}) + assert r.status_code == 200, r.text + return r.json()["id"] + + +def _faux_rpc_obsigate(appels): + async def fake_rpc(url, payload, headers=None): + appels.append(payload.get("method")) + method = payload.get("method") + if method == "initialize": + return {"result": {"protocolVersion": "2025-03-26", + "serverInfo": {"name": "obsigate"}}} + if method == "tools/call": + params = payload.get("params") or {} + appels.append((params.get("name"), params.get("arguments"))) + return {"result": {"content": [ + {"type": "text", "text": json.dumps({"draft_id": "b-42"})}]}} + return {} + return fake_rpc + + +def test_demande_approbation_et_gardes(client, admin_cookies): + tid = _transcript_avec_resume() + r = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate", + cookies=admin_cookies) + assert r.status_code == 201, r.text + body = r.json() + assert body["titre"] == "Hebdo produit" + assert body["chemin"].startswith("Réunions/") + assert body["chemin"].endswith("Hebdo produit.md") + approval = body["approval"] + assert approval["tool"] == "archiver_reunion_obsigate" + assert approval["status"] == "pending" + assert json.loads(approval["args_json"])["transcript_id"] == tid + # Doublon refusé tant que la demande est en attente. + r2 = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate", + cookies=admin_cookies) + assert r2.status_code == 400 + + +def test_resume_vide_refuse(client, admin_cookies): + with get_conn() as conn: + pid = conn.execute( + "INSERT INTO pages (workspace, title, content, content_format)" + " VALUES ('test', 'Vide', '', 'blocks')").lastrowid + tid = conn.execute( + "INSERT INTO meeting_transcripts (page_id) VALUES (?)", + (pid,)).lastrowid + conn.commit() + r = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate", + cookies=admin_cookies) + assert r.status_code == 400 + assert "Résumé vide" in r.text + + +def test_approbation_execute_la_proposition(client, admin_cookies, monkeypatch): + _creer_connecteur_obsigate(client) + appels: list = [] + monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_obsigate(appels)) + tid = _transcript_avec_resume() + r = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate", + cookies=admin_cookies) + aid = r.json()["approval"]["id"] + + d = client.post(f"/api/v2/agent-approvals/{aid}/decide", + cookies=admin_cookies, json={"approve": True}) + assert d.status_code == 200, d.text + body = d.json() + assert body["status"] == "approved" + assert body["archive"]["status"] == "ok", body["archive"] + assert "Réunions/" in body["archive"]["detail"] + # propose_write_file a reçu le résumé, jamais un apply direct. + calls = [a for a in appels if isinstance(a, tuple)] + assert len(calls) == 1 and calls[0][0] == "propose_write_file" + assert "Lancer le pilote" in calls[0][1]["content"] + assert calls[0][1]["path"].endswith("Hebdo produit.md") + # Distribution journalisée, canal obsigate. + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM meeting_distributions WHERE transcript_id=?", + (tid,)).fetchall() + assert len(rows) == 1 + assert rows[0]["channel"] == "obsigate" and rows[0]["status"] == "propose" + + +def test_rejet_ne_envoie_rien(client, admin_cookies, monkeypatch): + _creer_connecteur_obsigate(client) + appels: list = [] + monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_obsigate(appels)) + tid = _transcript_avec_resume() + aid = meeting_archive.demander_approbation(tid, 1)["id"] + d = client.post(f"/api/v2/agent-approvals/{aid}/decide", + cookies=admin_cookies, json={"approve": False}) + assert d.json()["archive"]["status"] == "rejete" + assert not [a for a in appels if isinstance(a, tuple)] + with get_conn() as conn: + n = conn.execute( + "SELECT COUNT(*) AS n FROM meeting_distributions" + " WHERE transcript_id=?", (tid,)).fetchone()["n"] + assert n == 0 + + +def test_sans_connecteur_erreur_explicite(client, admin_cookies): + tid = _transcript_avec_resume() + aid = meeting_archive.demander_approbation(tid, 1)["id"] + d = client.post(f"/api/v2/agent-approvals/{aid}/decide", + cookies=admin_cookies, json={"approve": True}) + archive = d.json()["archive"] + assert archive["status"] == "erreur" + assert "Connecteur ObsiGate non configuré" in archive["detail"] + + +def test_construire_archive_contenu(client): + tr = meetings.get_transcript(_transcript_avec_resume()) + archive = meeting_archive.construire_archive(tr) + assert archive["chemin"].startswith("Réunions/") + assert "# Hebdo produit" in archive["contenu"] + assert "Lancer le pilote" in archive["contenu"] + assert "/pages/" in archive["contenu"] # lien profond vers la note + + +def test_executer_async_direct(client, monkeypatch): + appels: list = [] + monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_obsigate(appels)) + tid = _transcript_avec_resume() + approval = meeting_archive.demander_approbation(tid, 1) + # Sans connecteur : erreur propre, pas d'exception. + out = asyncio.run(meeting_archive.executer(approval)) + assert out["status"] == "erreur"