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:
@@ -1,5 +1,38 @@
|
|||||||
# Changelog - FlowDeck
|
# 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/<date> - <titre>.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
|
## 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
|
- **Panneau Spark** (`static/js/draco-panneau.js`) : le pied du panneau
|
||||||
|
|||||||
+1
-1
@@ -216,7 +216,7 @@ def _lire_version() -> str:
|
|||||||
|
|
||||||
|
|
||||||
# Littéral de version synchronisé avec le fichier VERSION :
|
# 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
|
# 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
|
# ce littéral) et contrôle de tests/test_pwa_offline.py. La version
|
||||||
# runtime reste lue dynamiquement depuis VERSION par _lire_version()
|
# runtime reste lue dynamiquement depuis VERSION par _lire_version()
|
||||||
|
|||||||
@@ -87,4 +87,13 @@ def decide_approval(approval_id: int, request: Request, body: dict = Body(defaul
|
|||||||
raise HTTPException(404, "Pending approval not found")
|
raise HTTPException(404, "Pending approval not found")
|
||||||
audit_log(user, "agent.approval.decide", "agent_approval", approval_id,
|
audit_log(user, "agent.approval.decide", "agent_approval", approval_id,
|
||||||
out["status"], request)
|
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
|
return out
|
||||||
|
|||||||
@@ -396,6 +396,28 @@ def get_transcript_row(transcript_id: int, request: Request):
|
|||||||
raise HTTPException(404, str(exc)) from None
|
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")
|
@router.post("/api/v2/meetings/transcripts/{transcript_id}/consents")
|
||||||
def post_consent(transcript_id: int, request: Request, body: dict = Body(default={})):
|
def post_consent(transcript_id: int, request: Request, body: dict = Body(default={})):
|
||||||
user = _auth_user(request, require_write=True)
|
user = _auth_user(request, require_write=True)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -717,8 +717,8 @@ async def summarize_transcript(transcript_id: int, user_id: int | None = None,
|
|||||||
def record_distribution(transcript_id: int, *, channel: str,
|
def record_distribution(transcript_id: int, *, channel: str,
|
||||||
actor_id: int | None = None,
|
actor_id: int | None = None,
|
||||||
recipients: str = "", status: str = "created") -> dict:
|
recipients: str = "", status: str = "created") -> dict:
|
||||||
if channel not in ("copy_link", "email", "slack"):
|
if channel not in ("copy_link", "email", "slack", "obsigate"):
|
||||||
raise ValueError("channel must be copy_link|email|slack")
|
raise ValueError("channel must be copy_link|email|slack|obsigate")
|
||||||
get_transcript(transcript_id)
|
get_transcript(transcript_id)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -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 — <titre> » (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.
|
||||||
@@ -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 — <date> » (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`).
|
||||||
@@ -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é — <titre> » (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`).
|
||||||
@@ -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/<nom>/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"]
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user