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:
@@ -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