"""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 — » (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 — » (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 — » (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é — » (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 — » (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 — » (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 — » (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 — » (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 « » :\n" "1. Cherche 3 requêtes distinctes (web_search) : le sujet lui-même, « alternatives », " "« prix tarifs 2026 ».\n" "2. Approfondis les 3 pages les plus prometteuses (fetch_url).\n" "3. Crée un document « Marché — » (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 « » :\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 — — » (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 : .\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 — » (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 : .\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 — » (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 « » :\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 — » (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 — » (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