Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
913d6137b1 |
@@ -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/<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
|
||||
|
||||
- **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 :
|
||||
# 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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
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(
|
||||
|
||||
@@ -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