232 lines
11 KiB
Python
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
|