Files
flowdeck/app/services/skill_gallery.py
T

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