Files
flowdeck/app/services/skill_gallery.py
T
bruno 1d1cdbd618
FlowDeck CI / test (push) Failing after 3h13m58s
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / docker (push) Skipped
fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient
  loovverture et le redimensionnement inoperants -> cblage direct sur le document.
- Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw,
  clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks.
- database-table-container margin:0 (tableau colle a gauche, marge Library).
- .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px.
- ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
2026-10-05 22:47:36 -04:00

427 lines
23 KiB
Python

"""FlowDeck — Skill marketplace (v6.6.0, Agent phase 5 « Plateforme »).
Single source of truth for shareable agent skills:
* **Gallery** — built-in presets shipped with FlowDeck, installable into any
workspace with one call (the self-hosted equivalent of a skill marketplace).
* **Portable payloads** — a skill exports to a versioned JSON document that any
other FlowDeck instance can re-import unchanged.
Both the internal router (``/api/agent/skills/*``, session cookie) and the
public API v2 (``/api/v2/skills/*``, Bearer + scopes) call into this module so
there is exactly one implementation of export/import/install.
"""
from __future__ import annotations
import json
from typing import Any
from app.db import get_conn
EXPORT_FORMAT = "flowdeck-skill"
EXPORT_VERSION = 1
# ── Gallery presets ────────────────────────────────────────────────────────
# `allowed_tools` only references real ToolRegistry names (v4.10 → v5 tool set),
# so an installed preset can never expose a tool that does not exist.
GALLERY: dict[str, dict] = {
"rapport-hebdo": {
"name": "Rapport hebdo",
"icon": "📊",
"description": "Agrège les pages modifiées de la semaine et rédige un rapport structuré.",
"prompt_template": (
"Rédige le rapport hebdomadaire de l'équipe.\n"
"1. Repère les pages et documents modifiés cette semaine (search_workspace).\n"
"2. Lis les plus significatifs (read_document) et en extrais avancées, blocages, décisions.\n"
"3. Crée un document « Rapport hebdo — <date> » (create_document) structuré ainsi : "
"Résumé · Faits marquants · Blocages · Plan de la semaine prochaine.\n"
"Chaque affirmation doit s'appuyer sur un document lu, jamais sur une supposition."
),
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
},
"compte-rendu-reunion": {
"name": "Compte rendu de réunion",
"icon": "📝",
"description": "Transforme une note brute de réunion en compte rendu avec décisions et tâches.",
"prompt_template": (
"À partir de la note de réunion fournie (ou demandée) :\n"
"1. Crée un document « CR — <titre> » (create_document).\n"
"2. Structure : Contexte · Décisions prises · Actions (avec responsable et échéance) · Points ouverts.\n"
"3. Ne garde que ce qui est dans la note ; liste explicitement les points manquants."
),
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
},
"base-crm": {
"name": "Base CRM",
"icon": "🗂️",
"description": "Crée une collection CRM (contacts, statut, dernière interaction) avec une vue board.",
"prompt_template": (
"Crée une base CRM complète :\n"
"1. Collection « CRM » (create_collection) avec propriétés : Société (texte), Contact (texte), "
"Statut (sélection : Prospect/Négociation/Gagné/Perdu), Montant (nombre), Dernière interaction (date).\n"
"2. Vue board groupée sur Statut (create_view).\n"
"3. Trois lignes d'exemple réalistes (create_page).\n"
"Termine par le lien/identifiant de la collection créée."
),
"allowed_tools": ["create_collection", "add_property", "create_view", "create_page"],
},
"okr": {
"name": "Objectifs OKR",
"icon": "🎯",
"description": "Génère une base d'OKR avec objectifs, résultats clés et progression.",
"prompt_template": (
"Crée une base « OKR » :\n"
"1. Collection avec propriétés : Objectif (texte), Responsable (texte), Période (sélection : T1..T4), "
"Progression (nombre 0-100).\n"
"2. Collection « Résultats clés » liée à l'objectif (add_relation), avec Critère de succès et Progression.\n"
"3. Un jeu d'exemple : 3 objectifs, 2 résultats clés chacun (create_page, create_sub_item).\n"
"4. Vue board par période (create_view)."
),
"allowed_tools": [
"create_collection", "add_property", "add_relation",
"create_view", "create_page", "create_sub_item",
],
},
"analyse-repo": {
"name": "Analyse repo Gitea",
"icon": "🛠️",
"description": "Analyse les issues d'un dépôt Gitea et produit un dashboard de suivi.",
"prompt_template": (
"Analyse le dépôt Gitea du workspace :\n"
"1. Lis les issues ouvertes (read_gitea_issues) et synchronise l'état (sync_gitea).\n"
"2. Regroupe par label/priorité : bloquantes, en cours, à trier.\n"
"3. Crée un document « Suivi repo — <dépôt> » (create_document) avec un tableau des issues "
"et 3 recommandations de priorisation."
),
"allowed_tools": ["read_gitea_issues", "sync_gitea", "search_workspace", "create_document", "write_blocks"],
},
"resume-document": {
"name": "Résumé de document",
"icon": "📄",
"description": "Résume un document long en une page : points clés, chiffres, décisions.",
"prompt_template": (
"Résume le document fourni (ou demandé) :\n"
"1. Lis-le intégralement (read_document).\n"
"2. Crée un document « Résumé — <titre> » (create_document) : 5 points clés, chiffres marquants, "
"décisions/engagements, questions restées ouvertes.\n"
"3. Maximum une page, phrases courtes, aucune reformulation qui change le sens."
),
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
},
# ── Vague 1 : skills 100 % locaux (aucun nouveau tool requis) ──────────
"veille-documentaire": {
"name": "Veille documentaire",
"icon": "🔎",
"description": "Repère les pages du workspace sans mise à jour récente et génère un digest d'entrée.",
"prompt_template": (
"Fais une veille documentaire du workspace :\n"
"1. Repère les documents dont le titre évoque une décision, un chantier ou un arbitrage "
"(search_workspace : « décision », « ADR », « roadmap », « spec », « bilan »).\n"
"2. Lis les plus pertinents (read_document) et note pour chacun : date de mise à jour, "
"statut (vivant / obsolète / contradictoire), propriétaire mentionné quand il existe.\n"
"3. Crée un document « Veille — <date> » (create_document) en 3 sections : "
"À valider (décisions non actées), À mettre à jour (docs dépassés par d'autres), "
"Cohérences (points de vue contradictoires entre documents).\n"
"4. Ne modifie aucun document : tu proposes, l'humain décide."
),
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
},
"chasse-decisions": {
"name": "Chasse aux décisions",
"icon": "⚖️",
"description": " Rassemble en une page toutes les décisions prises dans le workspace, avec leur statut.",
"prompt_template": (
"Construis le registre des décisions du workspace :\n"
"1. Cherche les documents de décision (search_workspace : « décision », « arbitrage », "
"« choix de », « remplacement de »).\n"
"2. Lis-les (read_document) et extrais pour chacune : la décision, la date, la motivation, "
"les options écartées.\n"
"3. Crée un document « Registre des décisions » (create_document) en tableau : "
"Décision · Date · Motivation · Statut (actif / révoqué / jamais tranché).\n"
"4. Signale explicitement les sujets récurrents où aucune décision n'a été actée, "
"plutôt que de combler les trous."
),
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
},
"onboarding-nouveau": {
"name": "Onboarding nouveau",
"icon": "🧭",
"description": "Génère un parcours de lecture ordonné pour une personne qui rejoint l'équipe.",
"prompt_template": (
"Prépare l'onboarding d'une nouvelle personne :\n"
"1. Repère les documents d'entrée : onboarding existant, README, architecture, process, "
"glossaire (search_workspace).\n"
"2. Lis-les (read_document) et classe-les par jour d'arrivée : Jour 1, Semaine 1, "
"Puis plus tard.\n"
"3. Crée un document « Onboarding — <prénom> » (create_document) : pour chaque étape, "
"le document à lire, ce qu'on doit en retenir, et 2 questions pour vérifier la compréhension.\n"
"4. Termine par les 5 questions qu'une personne doit savoir répondre à la fin de la semaine 1."
),
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
},
"audit-base": {
"name": "Audit de base",
"icon": "🧹",
"description": "Audite une collection : colonnes vides, doublons, lignes sans propriétaire, propriétés inutilisées.",
"prompt_template": (
"Audite la collection demandée :\n"
"1. Lis son schéma et ses lignes (read_collection).\n"
"2. Détecte : propriétés jamais remplies, lignes sans propriétaire, doublons de titre, "
"valeurs incohérentes dans une même colonne, colonnes ajoutées puis abandonnées.\n"
"3. Crée un document « Audit — <collection> » (create_document) avec, par anomalie : "
"l'ampleur (nombre de lignes), des exemples concrets, et la correction proposée.\n"
"4. N'applique AUCUNE correction : liste d'abord, l'humain valide ensuite."
),
"allowed_tools": ["search_workspace", "read_collection", "read_page", "create_document", "write_blocks"],
},
"sprint-review": {
"name": "Revue de sprint",
"icon": "🏁",
"description": "Construit une revue de sprint depuis les issues Gitea : accompli, en retard, risques.",
"prompt_template": (
"Prépare la revue de sprint à partir des issues Gitea :\n"
"1. Lis les issues du dépôt (read_gitea_issues, state=all) et resynchronise si nécessaire (sync_gitea).\n"
"2. Classe-les : accompli (fermées), en retard (ouvertes et dépassées), à trier (sans label).\n"
"3. Croise avec les documents du workspace (search_workspace) pour voir si des décisions "
"changent la priorisation.\n"
"4. Crée un document « Revue de sprint — <période> » (create_document) : Accompli · En retard · "
"Risques · Prochaines priorités, avec le numéro d'issue pour chaque affirmation.\n"
"5. Ne ferme ni ne modifie aucune issue."
),
"allowed_tools": ["read_gitea_issues", "sync_gitea", "search_workspace",
"create_document", "write_blocks"],
},
# ── Vague 2 : skills web (web_search / fetch_url / search_code) ────────
"recherche-marche": {
"name": "Recherche marché",
"icon": "🌍",
"description": "Étudie un sujet de marché sur le web et produit une note sourcée avec alternatives.",
"prompt_template": (
"Produis une étude de marché sourcée sur « <sujet> » :\n"
"1. Cherche 3 requêtes distinctes (web_search) : le sujet lui-même, « <sujet> alternatives », "
"« <sujet> prix tarifs 2026 ».\n"
"2. Approfondis les 3 pages les plus prometteuses (fetch_url).\n"
"3. Crée un document « Marché — <sujet> » (create_document) : acteurs et positionnement, "
"grille tarifaire, forces/faiblesses, et 3 recommandations.\n"
"4. OBLIGATION : chaque affirmation porte sa source au format [titre — URL]. "
"Si une information n'est pas sourcée, écris « non vérifié » plutôt que d'inventer.\n"
"5. Indique la date de la recherche : ces données périment vite."
),
"allowed_tools": ["web_search", "fetch_url", "create_document", "write_blocks"],
},
"veille-techno": {
"name": "Veille technologique",
"icon": "🛰️",
"description": "Surveille l'actualité d'une techno : versions, ruptures, dépendances et advisories.",
"prompt_template": (
"Fais une veille technologique sur « <techno> » :\n"
"1. Web : dernières annonces et sorties de version (web_search).\n"
"2. GitHub : dépôts de référence et issues récentes résolues (search_code, kind=issues).\n"
"3. Approfondis les annonces importantes (fetch_url).\n"
"4. Crée un document « Veille — <techno> — <date> » (create_document) : "
"Nouveautés depuis le dernier point · Risques de rupture · Actions recommandées (mettre à jour / "
"surveiller / ignorer).\n"
"5. Une ligne par fait, avec [titre — URL]. Ne présente jamais une rumeur comme une release."
),
"allowed_tools": ["web_search", "fetch_url", "search_code",
"create_document", "write_blocks"],
},
"debug-web": {
"name": "Debug web",
"icon": "🐛",
"description": "Diagnostique un bug technique en croisant les docs, le code GitHub et les discussions.",
"prompt_template": (
"Diagnostique le problème suivant : <erreur / symptôme>.\n"
"1. Reformule le symptôme en une recherche web précise (web_search) : message d'erreur exact "
"+ bibliothèque + version.\n"
"2. Cherche si c'est un bug connu (search_code, kind=issues) et lis les discussions pertinentes (fetch_url).\n"
"3. Crée un document « Debug — <symptôme> » (create_document) : Cause probable (classée par "
"probabilité) · Vérifications à faire dans cet ordre · Correctif proposé.\n"
"4. Distingue clairement ce qui est vérifié par une source de ce qui est ton hypothèse."
),
"allowed_tools": ["web_search", "search_code", "fetch_url",
"create_document", "write_blocks"],
},
# ── Vague 3 : skills « pouvoir » ──────────────────────────────────────
"triage-incident": {
"name": "Triage d'incident",
"icon": "🚨",
"description": "Prend un incident décrit, ouvre un runbook pré-rempli et pose les premières questions de qualification.",
"prompt_template": (
"Traite l'incident suivant : <description>.\n"
"1. Qualifie avant de chercher : quel service, depuis quand, quel périmètre d'impact, "
"erreur observable. Les inconnues deviennent des questions, pas des suppositions.\n"
"2. Cherche les incidents similaires déjà résolus dans le workspace (search_workspace) "
"et les runbooks existants.\n"
"3. Crée un document « Incident — <titre court> » (create_document) : Symptôme · Impact · "
"Cause probable · Hypothèses classées · Étapes de résolution · Questions à poser à l'utilisateur.\n"
"4. Ne modifie aucun code ni aucune configuration : tu produis un runbook, pas un correctif.\n"
"5. Termine par les 3 informations qui feraient le plus progresser le diagnostic."
),
"allowed_tools": ["search_workspace", "read_document", "web_search",
"create_document", "write_blocks"],
},
"nettoyage-base": {
"name": "Nettoyage de base",
"icon": "🧽",
"description": "Prépare un plan de nettoyage d'une collection (doublons, valeurs vides) avec aperçu avant écriture.",
"prompt_template": (
"Prépare le nettoyage de la collection « <collection> » :\n"
"1. Lis son schéma et ses lignes (read_collection).\n"
"2. Identifie les corrections : lignes vides à supprimer, doublons à fusionner, "
"valeurs parasites à normaliser, propriétés obsolètes.\n"
"3. PRODUIRE UN APERÇU D'AVANT : pour chaque catégorie de correction, le nombre de lignes "
"concernées et 3 exemples concrets.\n"
"4. Règle absolue : n'applique AUCUNE modification avant validation explicite de l'utilisateur. "
"Le plan est le livrable.\n"
"5. Crée le document « Plan de nettoyage — <collection> » (create_document) avec le plan, "
"puis demande la validation."
),
"allowed_tools": ["search_workspace", "read_collection", "read_page",
"create_document", "write_blocks"],
},
"briefing-quotidien": {
"name": "Briefing quotidien",
"icon": "🌅",
"description": "Prépare le point du matin : activités de la veille, décisions en attente, blocages.",
"prompt_template": (
"Prépare le briefing du jour :\n"
"1. Activités : ce qui a bougé dans le workspace et le dépôt (read_gitea_issues, "
"search_workspace) — uniquement des faits datés.\n"
"2. Décisions en attente : documents de décision sans statut clair.\n"
"3. Blocages : tickets ouverts sans mouvement, sujets qui reviennent dans plusieurs documents.\n"
"4. Crée un document « Briefing — <date> » (create_document) en moins d'une page : "
"Hier en bref · À trancher aujourd'hui · Bloquants · Une chose à surveiller.\n"
"5. Si une section n'a aucun élément, écris « rien à signaler » plutôt que d'inventer."
),
"allowed_tools": ["search_workspace", "read_document", "read_gitea_issues",
"create_document", "write_blocks"],
},
}
# ── Gallery access ─────────────────────────────────────────────────────────
def list_gallery() -> list[dict]:
"""Return every preset with its slug, ready for API responses."""
return [{"slug": slug, **preset} for slug, preset in GALLERY.items()]
def get_gallery(slug: str) -> dict | None:
preset = GALLERY.get(str(slug or "").strip().lower())
return {"slug": slug, **preset} if preset else None
# ── Portable payload (export / import) ─────────────────────────────────────
def export_skill(row: Any) -> dict:
"""Build the portable JSON document for a stored skill row.
Only shareable fields are included: ids, workspace and author stay local so
an import never leaks (nor depends on) the source instance's internals.
"""
skill = row if isinstance(row, dict) else dict(row)
tools = skill.get("allowed_tools_json") or "[]"
if isinstance(tools, str):
try:
tools = json.loads(tools)
except (TypeError, ValueError):
tools = []
return {
"format": EXPORT_FORMAT,
"version": EXPORT_VERSION,
"skill": {
"name": skill.get("name") or "",
"description": skill.get("description") or "",
"prompt_template": skill.get("prompt_template") or "",
"allowed_tools": list(tools) if isinstance(tools, list) else [],
},
}
def parse_payload(payload: Any) -> dict:
"""Validate an import payload → normalized skill fields.
Accepts a full exported document (``{format, version, skill}``) or a bare
skill object (``{name, prompt_template, ...}``) for hand-written imports.
Raises ``ValueError`` with a human-readable message on invalid input.
"""
if not isinstance(payload, dict):
raise ValueError("Payload JSON attendu (objet)")
skill = payload.get("skill") if isinstance(payload.get("skill"), dict) else payload
if payload.get("format") and payload.get("format") != EXPORT_FORMAT:
raise ValueError(f"Format inconnu: {payload.get('format')} (attendu {EXPORT_FORMAT})")
version = payload.get("version")
if version is not None and (not isinstance(version, int) or version > EXPORT_VERSION):
raise ValueError(f"Version non supportée: {version} (max {EXPORT_VERSION})")
name = str(skill.get("name") or "").strip()
prompt = str(skill.get("prompt_template") or "").strip()
if not name:
raise ValueError("name est requis")
if not prompt:
raise ValueError("prompt_template est requis")
tools = skill.get("allowed_tools") or []
if isinstance(tools, str):
try:
tools = json.loads(tools)
except (TypeError, ValueError) as exc:
raise ValueError("allowed_tools doit être une liste de noms d'outils") from exc
if not isinstance(tools, list) or not all(isinstance(t, str) for t in tools):
raise ValueError("allowed_tools doit être une liste de noms d'outils")
return {
"name": name[:120],
"description": str(skill.get("description") or "")[:500],
"prompt_template": prompt,
"allowed_tools": tools,
}
def upsert_skill(
fields: dict,
*,
workspace_id: int | None,
created_by: int | None,
overwrite: bool = False,
) -> tuple[dict, bool]:
"""Insert (or replace) a skill in the current workspace.
Returns ``(row, created)``. Raises ``ValueError`` when the name already
exists and ``overwrite`` is False — callers translate that to HTTP 409.
"""
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM agent_skills WHERE workspace_id IS ? AND name=?",
(workspace_id, fields["name"]),
).fetchone()
if existing and not overwrite:
raise ValueError(f"Skill déjà présente dans ce workspace: {fields['name']}")
tools_json = json.dumps(fields["allowed_tools"])
if existing:
conn.execute(
"UPDATE agent_skills SET description=?, prompt_template=?, allowed_tools_json=? WHERE id=?",
(fields["description"], fields["prompt_template"], tools_json, existing["id"]),
)
skill_id = existing["id"]
else:
cur = conn.execute(
"""INSERT INTO agent_skills
(workspace_id, name, description, prompt_template, allowed_tools_json, created_by)
VALUES (?,?,?,?,?,?)""",
(workspace_id, fields["name"], fields["description"],
fields["prompt_template"], tools_json, created_by),
)
skill_id = cur.lastrowid
conn.commit()
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
data = dict(row) if row else {"id": skill_id}
return data, existing is None