"""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"], }, } # ── 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