chore: versioning initial du systeme Context Continuity
Code du systeme de memoire multi-LLM sur Trilium : - trilium_api.py : wrapper trilium-py (notes, labels, relations) - mcp_server.py : serveur MCP Starlette (19 tools, OAuth + Bearer) - api_context.py : API REST FastAPI - trilium_context.py : workflow CLI - watchdog.sh, start_*.sh : supervision et demarrage - skills, docs et ontologie associes Secrets (.env, oauth_state.json) exclus via .gitignore.
This commit is contained in:
+26
@@ -0,0 +1,26 @@
|
||||
# Secrets — JAMAIS versionner
|
||||
.env
|
||||
oauth_state.json
|
||||
ssh-keys/
|
||||
|
||||
# Environnement Python
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
|
||||
# Backups et patches jetables
|
||||
*.bak
|
||||
*.bak_*
|
||||
patch_*.py
|
||||
|
||||
# Logs et PID
|
||||
*.log
|
||||
*.pid
|
||||
|
||||
# Dépôt git imbriqué (test)
|
||||
mon-depot/
|
||||
|
||||
# Fichiers d'IDs runtime (générés, non sensibles mais spécifiques à l'instance)
|
||||
entites_ids.json
|
||||
entites_sliding.json
|
||||
@@ -0,0 +1,60 @@
|
||||
# KIT — Travailler avec Bastien
|
||||
|
||||
Point d'entrée du kit de skills réutilisables. À lire en premier au début
|
||||
d'une session. Indique quel skill activer selon ce que tu fais.
|
||||
|
||||
Bastien est Head of Data Governance chez Pernod Ricard. Il développe des
|
||||
outils sur son infrastructure auto-hébergée (Synology « GrosseBertha »), avec
|
||||
une mémoire de projet centralisée dans Trilium, partagée entre plusieurs LLMs
|
||||
(Claude et Le Chat). Ces skills encodent sa façon de travailler pour qu'elle
|
||||
soit portable d'une conversation et d'un projet à l'autre.
|
||||
|
||||
---
|
||||
|
||||
## Les skills du kit
|
||||
|
||||
| Skill | Quand l'activer |
|
||||
|---|---|
|
||||
| **skill_trilium_continuity.md** | **Toujours, sur tout projet suivi.** Le réflexe de mémoire : lire le contexte en début de session, capitaliser décisions/tests/contraintes au fil de l'eau, clôturer en fin. C'est un comportement attendu, pas optionnel. |
|
||||
| **skill_synology_dev.md** | Dès qu'on touche au code sur GrosseBertha : patch Python, conventions SSH, sécurité des YAML, commits Forgejo. |
|
||||
| **skill_trilium_api_reference.md** | Référence technique — à sortir pour débugger ou étendre le système Trilium (endpoints, wrapper, MCP, structure des notes). Pas nécessaire pour l'usage quotidien. |
|
||||
| **skill_sliding_design_system.md** | Uniquement pour produire une présentation PowerPoint au format Pernod Ricard (YAML pour le moteur de rendu). Indépendant du reste. |
|
||||
|
||||
---
|
||||
|
||||
## Trois couches, du plus large au plus spécifique
|
||||
|
||||
**Couche universelle — toute conversation, tout projet.**
|
||||
`skill_trilium_continuity` + `skill_synology_dev`. Comment travailler avec
|
||||
Bastien et son infrastructure : tenir la mémoire à jour, et développer sur le
|
||||
Synology dans les règles.
|
||||
|
||||
**Couche semi-spécifique — toute production de présentation.**
|
||||
`skill_sliding_design_system`. Utile dès qu'il s'agit de slides au format PR,
|
||||
quel que soit le contexte.
|
||||
|
||||
**Couche spécifique à un projet — vit dans Trilium, pas dans un fichier.**
|
||||
L'état réel d'un projet (décisions actives, historique, backlog) est dans
|
||||
Trilium sous le label `projet=<NomDuProjet>`. On ne le duplique pas dans un
|
||||
document : on le **lit** en début de session via le réflexe de continuité.
|
||||
|
||||
---
|
||||
|
||||
## Ordre de lecture en début de session
|
||||
|
||||
1. Lire **skill_trilium_continuity** et récupérer le briefing de reprise du
|
||||
projet concerné (décisions actives, historique, backlog).
|
||||
2. Selon la tâche, activer **skill_synology_dev** (code) et/ou
|
||||
**skill_sliding_design_system** (présentation).
|
||||
3. Garder **skill_trilium_api_reference** sous la main pour le débogage.
|
||||
4. Tout au long : capitaliser proactivement dans Trilium aux moments clés.
|
||||
|
||||
---
|
||||
|
||||
## Note sur l'accès Trilium côté Claude
|
||||
|
||||
Le serveur MCP Trilium fonctionne pour Le Chat (Mistral) mais pas pour Claude :
|
||||
il utilise un Bearer token statique, alors que les connecteurs Claude exigent
|
||||
OAuth. En attendant l'ajout d'une couche OAuth, l'accès Trilium côté Claude se
|
||||
fait **par relais** — Claude propose la commande ou le curl, Bastien l'exécute
|
||||
et colle le retour. Voir skill_trilium_continuity pour le détail.
|
||||
+677
@@ -0,0 +1,677 @@
|
||||
"""
|
||||
api_context.py — API FastAPI pour Context Continuity
|
||||
Expose les objets Trilium (Projet, Backlog, Decision, Historique,
|
||||
Conversation, Glossaire, ContexteReprise) via REST.
|
||||
|
||||
Usage :
|
||||
uvicorn api_context:app --host 127.0.0.1 --port 5000
|
||||
Doc : http://localhost:5000/api/docs
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, Depends, HTTPException, Header
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
load_dotenv()
|
||||
|
||||
API_KEY_CLAUDE = os.getenv("API_KEY_CLAUDE", "")
|
||||
API_KEY_LECHAT = os.getenv("API_KEY_LECHAT", "")
|
||||
VALID_KEYS = {API_KEY_CLAUDE: "Claude", API_KEY_LECHAT: "LeChat"}
|
||||
|
||||
app = FastAPI(
|
||||
title="Context Continuity API",
|
||||
description="API REST pour la gestion du contexte multi-LLM via Trilium",
|
||||
version="1.0.0",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_llm(authorization: str = Header(...)) -> str:
|
||||
"""Valide la clé API et retourne le nom du LLM appelant."""
|
||||
key = authorization.replace("Bearer ", "").strip()
|
||||
if key not in VALID_KEYS:
|
||||
raise HTTPException(status_code=401, detail="Clé API invalide")
|
||||
return VALID_KEYS[key]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def strip_html(text: str) -> str:
|
||||
text = re.sub(r"<br\s*/?>", "\n", text)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">").strip()
|
||||
|
||||
def note_to_dict(note: dict, with_content: bool = False) -> dict:
|
||||
from trilium_api import get_note, get_note_content, get_label_value
|
||||
nid = note.get("noteId", note.get("id", ""))
|
||||
detail = get_note(nid)
|
||||
attrs = detail.get("attributes", [])
|
||||
labels = {a["name"]: a["value"] for a in attrs if a.get("type") == "label"}
|
||||
result = {
|
||||
"id": nid,
|
||||
"title": detail.get("title", ""),
|
||||
"labels": labels,
|
||||
"created": detail.get("utcDateCreated", ""),
|
||||
"modified": detail.get("utcDateModified", ""),
|
||||
}
|
||||
if with_content:
|
||||
result["content"] = strip_html(get_note_content(nid))
|
||||
return result
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modèles Pydantic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ProjetCreate(BaseModel):
|
||||
nom: str = Field(..., description="Nom du projet (sans espaces)")
|
||||
objectif: str = Field(..., description="Objectif en 1-2 phrases")
|
||||
stack: Optional[str] = Field(None, description="Stack technique")
|
||||
llm_repartition: Optional[str] = Field(None, description="Répartition LLMs")
|
||||
|
||||
class ProjetPatch(BaseModel):
|
||||
statut: Optional[str] = Field(None, pattern="^(actif|en-pause|archive)$")
|
||||
objectif: Optional[str] = None
|
||||
stack: Optional[str] = None
|
||||
|
||||
class BacklogCreate(BaseModel):
|
||||
projet: str
|
||||
titre: str
|
||||
priorite: str = Field("moyenne", pattern="^(haute|moyenne|basse)$")
|
||||
|
||||
class BacklogPatch(BaseModel):
|
||||
statut: Optional[str] = Field(None, pattern="^(a faire|en cours|bloque|fait|abandonne)$")
|
||||
priorite: Optional[str] = Field(None, pattern="^(haute|moyenne|basse)$")
|
||||
|
||||
class DecisionCreate(BaseModel):
|
||||
projet: str
|
||||
enonce: str
|
||||
justification: Optional[str] = None
|
||||
llm: Optional[str] = None
|
||||
|
||||
class DecisionPatch(BaseModel):
|
||||
statut: Optional[str] = Field(None, pattern="^(active|revisee|annulee)$")
|
||||
justification: Optional[str] = None
|
||||
|
||||
class HistoriqueCreate(BaseModel):
|
||||
projet: str
|
||||
enonce: str
|
||||
type_historique: str = Field(..., alias="type",
|
||||
pattern="^(Fait etabli|Test effectue|Hypothese invalidee|Contrainte decouverte)$")
|
||||
detail: Optional[str] = None
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
class HistoriquePatch(BaseModel):
|
||||
encore_valide: Optional[bool] = None
|
||||
detail: Optional[str] = None
|
||||
|
||||
class ConversationCreate(BaseModel):
|
||||
projet: str
|
||||
titre: str
|
||||
llm: str = Field(..., pattern="^(Claude Sonnet|Claude Opus|Le Chat Large|Le Chat Medium)$")
|
||||
|
||||
class ConversationPatch(BaseModel):
|
||||
synthese_cloture: Optional[str] = Field(None, max_length=500)
|
||||
statut: Optional[str] = Field(None, pattern="^(en-cours|clos)$")
|
||||
|
||||
class GlossaireCreate(BaseModel):
|
||||
projet: str
|
||||
terme: str
|
||||
definition: str
|
||||
synonymes_eviter: Optional[str] = None
|
||||
|
||||
class GlossairePatch(BaseModel):
|
||||
definition: Optional[str] = None
|
||||
synonymes_eviter: Optional[str] = None
|
||||
|
||||
class SkillCreate(BaseModel):
|
||||
titre: str
|
||||
contenu: str
|
||||
portee: str = "projet" # "universel" | "reference-technique" | "projet" | autre libellé libre
|
||||
projet: Optional[str] = None # optionnel si portee="universel"
|
||||
|
||||
class SkillPatch(BaseModel):
|
||||
contenu: Optional[str] = None
|
||||
portee: Optional[str] = None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes — Health
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/health", tags=["Système"])
|
||||
def health():
|
||||
return {"status": "ok", "version": "1.0.0"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes — Projets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/projets", tags=["Projets"])
|
||||
def list_projets(llm: str = Depends(get_llm)):
|
||||
from trilium_api import search_by_label
|
||||
notes = search_by_label("type", "projet")
|
||||
# Exclure la note racine Context Continuity
|
||||
notes = [n for n in notes if n.get("title") != "Context Continuity"]
|
||||
return [note_to_dict(n) for n in notes]
|
||||
|
||||
@app.get("/api/projets/{nom}", tags=["Projets"])
|
||||
def get_projet(nom: str, llm: str = Depends(get_llm)):
|
||||
from trilium_api import search_by_label
|
||||
notes = search_by_label("projet", nom)
|
||||
projets = [n for n in notes if n.get("title", "").replace(" ", "") == nom or
|
||||
any(a.get("name") == "projet" and a.get("value") == nom
|
||||
for a in n.get("attributes", []))]
|
||||
# Chercher la note projet spécifique
|
||||
from trilium_api import search_notes, get_note, get_label_value
|
||||
all_projets = search_by_label("type", "projet")
|
||||
for p in all_projets:
|
||||
if get_label_value(p["noteId"], "projet") == nom:
|
||||
return note_to_dict(p, with_content=True)
|
||||
raise HTTPException(status_code=404, detail=f"Projet '{nom}' introuvable")
|
||||
|
||||
@app.post("/api/projets", tags=["Projets"], status_code=201)
|
||||
def create_projet(body: ProjetCreate, llm: str = Depends(get_llm)):
|
||||
import json
|
||||
from trilium_api import create_note, get_note_id, set_label, update_note_content
|
||||
with open(os.path.expanduser("~/App/Context_continuity/trilium_ids.json")) as f:
|
||||
ids = json.load(f)
|
||||
res = create_note(ids["Projets"], body.nom)
|
||||
nid = get_note_id(res)
|
||||
set_label(nid, "type", "projet")
|
||||
set_label(nid, "projet", body.nom)
|
||||
set_label(nid, "statut", "actif")
|
||||
content = f"<h2>{body.nom}</h2><p><b>Objectif</b> : {body.objectif}</p>"
|
||||
if body.stack:
|
||||
content += f"<p><b>Stack</b> : {body.stack}</p>"
|
||||
if body.llm_repartition:
|
||||
content += f"<p><b>LLM</b> : {body.llm_repartition}</p>"
|
||||
update_note_content(nid, content)
|
||||
return {"id": nid, "nom": body.nom, "created_by": llm}
|
||||
|
||||
@app.patch("/api/projets/{note_id}", tags=["Projets"])
|
||||
def patch_projet(note_id: str, body: ProjetPatch, llm: str = Depends(get_llm)):
|
||||
from trilium_api import get_note, set_label, update_note_content, get_note_content
|
||||
try:
|
||||
get_note(note_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail="Projet introuvable")
|
||||
if body.statut:
|
||||
set_label(note_id, "statut", body.statut)
|
||||
if body.objectif or body.stack:
|
||||
content = get_note_content(note_id)
|
||||
if body.objectif:
|
||||
content += f"<p><b>Objectif mis à jour</b> : {body.objectif}</p>"
|
||||
if body.stack:
|
||||
content += f"<p><b>Stack</b> : {body.stack}</p>"
|
||||
update_note_content(note_id, content)
|
||||
return {"id": note_id, "updated_by": llm}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes — Backlog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/backlog/{projet}", tags=["Backlog"])
|
||||
def list_backlog(projet: str, llm: str = Depends(get_llm)):
|
||||
from trilium_api import search_by_label, get_label_value
|
||||
notes = search_by_label("type", "backlogItem")
|
||||
actifs = [n for n in notes
|
||||
if get_label_value(n["noteId"], "projet") == projet
|
||||
and get_label_value(n["noteId"], "statut") not in ("fait", "abandonne")]
|
||||
prio_ordre = {"haute": 0, "moyenne": 1, "basse": 2}
|
||||
actifs.sort(key=lambda n: prio_ordre.get(
|
||||
get_label_value(n["noteId"], "priorite") or "basse", 99))
|
||||
return [note_to_dict(n) for n in actifs]
|
||||
|
||||
@app.post("/api/backlog", tags=["Backlog"], status_code=201)
|
||||
def create_backlog(body: BacklogCreate, llm: str = Depends(get_llm)):
|
||||
import json
|
||||
from trilium_api import create_note, get_note_id, set_label
|
||||
with open(os.path.expanduser("~/App/Context_continuity/trilium_ids.json")) as f:
|
||||
ids = json.load(f)
|
||||
res = create_note(ids["Backlog"], body.titre)
|
||||
nid = get_note_id(res)
|
||||
set_label(nid, "type", "backlogItem")
|
||||
set_label(nid, "projet", body.projet)
|
||||
set_label(nid, "priorite", body.priorite)
|
||||
set_label(nid, "statut", "a faire")
|
||||
return {"id": nid, "titre": body.titre, "created_by": llm}
|
||||
|
||||
@app.patch("/api/backlog/{note_id}", tags=["Backlog"])
|
||||
def patch_backlog(note_id: str, body: BacklogPatch, llm: str = Depends(get_llm)):
|
||||
from trilium_api import get_note, set_label
|
||||
try:
|
||||
get_note(note_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail="Backlog item introuvable")
|
||||
if body.statut:
|
||||
set_label(note_id, "statut", body.statut)
|
||||
if body.priorite:
|
||||
set_label(note_id, "priorite", body.priorite)
|
||||
return {"id": note_id, "updated_by": llm}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes — Décisions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/decisions/{projet}", tags=["Décisions"])
|
||||
def list_decisions(projet: str, llm: str = Depends(get_llm)):
|
||||
from trilium_api import search_by_label, get_label_value
|
||||
notes = search_by_label("type", "decision")
|
||||
actives = [n for n in notes
|
||||
if get_label_value(n["noteId"], "projet") == projet
|
||||
and get_label_value(n["noteId"], "statut") == "active"]
|
||||
return [note_to_dict(n) for n in actives]
|
||||
|
||||
@app.post("/api/decisions", tags=["Décisions"], status_code=201)
|
||||
def create_decision(body: DecisionCreate, llm: str = Depends(get_llm)):
|
||||
import json
|
||||
from trilium_api import create_note, get_note_id, set_label
|
||||
with open(os.path.expanduser("~/App/Context_continuity/trilium_ids.json")) as f:
|
||||
ids = json.load(f)
|
||||
res = create_note(ids["Decisions"], body.enonce,
|
||||
content=f"<p><b>Justification</b> : {body.justification or '-'}</p>")
|
||||
nid = get_note_id(res)
|
||||
set_label(nid, "type", "decision")
|
||||
set_label(nid, "projet", body.projet)
|
||||
set_label(nid, "statut", "active")
|
||||
set_label(nid, "llm", body.llm or llm)
|
||||
return {"id": nid, "enonce": body.enonce, "created_by": llm}
|
||||
|
||||
@app.patch("/api/decisions/{note_id}", tags=["Décisions"])
|
||||
def patch_decision(note_id: str, body: DecisionPatch, llm: str = Depends(get_llm)):
|
||||
from trilium_api import get_note, set_label, get_note_content, update_note_content
|
||||
try:
|
||||
get_note(note_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail="Décision introuvable")
|
||||
if body.statut:
|
||||
set_label(note_id, "statut", body.statut)
|
||||
if body.justification:
|
||||
content = get_note_content(note_id)
|
||||
content += f"<p><b>Révision ({llm})</b> : {body.justification}</p>"
|
||||
update_note_content(note_id, content)
|
||||
return {"id": note_id, "updated_by": llm}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes — Historique
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/historique/{projet}", tags=["Historique"])
|
||||
def list_historique(projet: str, llm: str = Depends(get_llm)):
|
||||
from trilium_api import search_by_label, get_label_value
|
||||
notes = search_by_label("type", "historiqueItem")
|
||||
valides = [n for n in notes
|
||||
if get_label_value(n["noteId"], "projet") == projet
|
||||
and get_label_value(n["noteId"], "encoreValide") == "true"]
|
||||
return [note_to_dict(n) for n in valides]
|
||||
|
||||
@app.post("/api/historique", tags=["Historique"], status_code=201)
|
||||
def create_historique(body: HistoriqueCreate, llm: str = Depends(get_llm)):
|
||||
import json
|
||||
from trilium_api import create_note, get_note_id, set_label
|
||||
with open(os.path.expanduser("~/App/Context_continuity/trilium_ids.json")) as f:
|
||||
ids = json.load(f)
|
||||
type_h = body.type_historique
|
||||
res = create_note(ids["Historique"], body.enonce,
|
||||
content=f"<p><b>Type</b> : {type_h}</p>"
|
||||
f"<p><b>Détail</b> : {body.detail or '-'}</p>")
|
||||
nid = get_note_id(res)
|
||||
set_label(nid, "type", "historiqueItem")
|
||||
set_label(nid, "projet", body.projet)
|
||||
set_label(nid, "typeHistorique", type_h)
|
||||
set_label(nid, "encoreValide", "true")
|
||||
return {"id": nid, "enonce": body.enonce, "created_by": llm}
|
||||
|
||||
@app.patch("/api/historique/{note_id}", tags=["Historique"])
|
||||
def patch_historique(note_id: str, body: HistoriquePatch, llm: str = Depends(get_llm)):
|
||||
from trilium_api import get_note, set_label, get_note_content, update_note_content
|
||||
try:
|
||||
get_note(note_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail="Entrée historique introuvable")
|
||||
if body.encore_valide is not None:
|
||||
set_label(note_id, "encoreValide", "true" if body.encore_valide else "false")
|
||||
if body.detail:
|
||||
content = get_note_content(note_id)
|
||||
content += f"<p><b>Mise à jour ({llm})</b> : {body.detail}</p>"
|
||||
update_note_content(note_id, content)
|
||||
return {"id": note_id, "updated_by": llm}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes — Conversations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/conversations/{projet}", tags=["Conversations"])
|
||||
def list_conversations(projet: str, llm: str = Depends(get_llm)):
|
||||
from trilium_api import search_by_label, get_label_value
|
||||
notes = search_by_label("type", "conversation")
|
||||
conv = [n for n in notes
|
||||
if get_label_value(n["noteId"], "projet") == projet]
|
||||
return [note_to_dict(n) for n in conv]
|
||||
|
||||
@app.get("/api/conversations/{projet}/{note_id}", tags=["Conversations"])
|
||||
def get_conversation(projet: str, note_id: str, llm: str = Depends(get_llm)):
|
||||
from trilium_api import get_note, get_label_value
|
||||
try:
|
||||
note = get_note(note_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail="Conversation introuvable")
|
||||
return note_to_dict({"noteId": note_id}, with_content=True)
|
||||
|
||||
@app.post("/api/conversations", tags=["Conversations"], status_code=201)
|
||||
def create_conversation(body: ConversationCreate, llm: str = Depends(get_llm)):
|
||||
import json
|
||||
from trilium_api import create_note, get_note_id, set_label
|
||||
with open(os.path.expanduser("~/App/Context_continuity/trilium_ids.json")) as f:
|
||||
ids = json.load(f)
|
||||
date = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
titre = f"[{body.llm}] {date} — {body.titre}"
|
||||
content = (f"<h2>{titre}</h2>"
|
||||
f"<table>"
|
||||
f"<tr><td><b>Projet</b></td><td>{body.projet}</td></tr>"
|
||||
f"<tr><td><b>LLM</b></td><td>{body.llm}</td></tr>"
|
||||
f"<tr><td><b>Date</b></td><td>{date}</td></tr>"
|
||||
f"<tr><td><b>Synthèse de clôture</b></td>"
|
||||
f"<td><i>À remplir en fin de session</i></td></tr>"
|
||||
f"</table>")
|
||||
res = create_note(ids["Conversations"], titre, content=content)
|
||||
nid = get_note_id(res)
|
||||
set_label(nid, "type", "conversation")
|
||||
set_label(nid, "projet", body.projet)
|
||||
set_label(nid, "llm", body.llm)
|
||||
set_label(nid, "statut", "en-cours")
|
||||
set_label(nid, "date", date)
|
||||
return {"id": nid, "titre": titre, "created_by": llm}
|
||||
|
||||
@app.patch("/api/conversations/{note_id}", tags=["Conversations"])
|
||||
def patch_conversation(note_id: str, body: ConversationPatch, llm: str = Depends(get_llm)):
|
||||
from trilium_api import get_note, set_label, get_note_content, update_note_content
|
||||
try:
|
||||
get_note(note_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail="Conversation introuvable")
|
||||
if body.synthese_cloture:
|
||||
content = get_note_content(note_id)
|
||||
content = content.replace(
|
||||
"<i>À remplir en fin de session</i>",
|
||||
body.synthese_cloture.replace("<", "<").replace(">", ">")
|
||||
)
|
||||
if "<i>À remplir en fin de session</i>" not in get_note_content(note_id):
|
||||
content += f"<h3>Synthèse</h3><p>{body.synthese_cloture}</p>"
|
||||
update_note_content(note_id, content)
|
||||
set_label(note_id, "syntheseCloture", body.synthese_cloture[:200])
|
||||
set_label(note_id, "statut", "clos")
|
||||
if body.statut:
|
||||
set_label(note_id, "statut", body.statut)
|
||||
return {"id": note_id, "updated_by": llm}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes — Glossaire
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/glossaire/{projet}", tags=["Glossaire"])
|
||||
def list_glossaire(projet: str, llm: str = Depends(get_llm)):
|
||||
from trilium_api import search_by_label, get_label_value
|
||||
notes = search_by_label("type", "termeGlossaire")
|
||||
termes = [n for n in notes
|
||||
if get_label_value(n["noteId"], "projet") == projet]
|
||||
return [note_to_dict(n, with_content=True) for n in termes]
|
||||
|
||||
@app.post("/api/glossaire", tags=["Glossaire"], status_code=201)
|
||||
def create_glossaire(body: GlossaireCreate, llm: str = Depends(get_llm)):
|
||||
import json
|
||||
from trilium_api import create_note, get_note_id, set_label
|
||||
with open(os.path.expanduser("~/App/Context_continuity/trilium_ids.json")) as f:
|
||||
ids = json.load(f)
|
||||
content = f"<p><b>Définition</b> : {body.definition}</p>"
|
||||
if body.synonymes_eviter:
|
||||
content += f"<p><b>Synonymes à éviter</b> : {body.synonymes_eviter}</p>"
|
||||
res = create_note(ids["Glossaire"], body.terme, content=content)
|
||||
nid = get_note_id(res)
|
||||
set_label(nid, "type", "termeGlossaire")
|
||||
set_label(nid, "projet", body.projet)
|
||||
set_label(nid, "definition", body.definition)
|
||||
return {"id": nid, "terme": body.terme, "created_by": llm}
|
||||
|
||||
@app.patch("/api/glossaire/{note_id}", tags=["Glossaire"])
|
||||
def patch_glossaire(note_id: str, body: GlossairePatch, llm: str = Depends(get_llm)):
|
||||
from trilium_api import get_note, set_label, get_note_content, update_note_content
|
||||
try:
|
||||
get_note(note_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail="Terme introuvable")
|
||||
if body.definition:
|
||||
set_label(note_id, "definition", body.definition)
|
||||
content = get_note_content(note_id)
|
||||
content += f"<p><b>Définition mise à jour ({llm})</b> : {body.definition}</p>"
|
||||
update_note_content(note_id, content)
|
||||
if body.synonymes_eviter:
|
||||
content = get_note_content(note_id)
|
||||
content += f"<p><b>Synonymes à éviter</b> : {body.synonymes_eviter}</p>"
|
||||
update_note_content(note_id, content)
|
||||
return {"id": note_id, "updated_by": llm}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes — Contexte Reprise
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/contexte/{projet}", tags=["Contexte Reprise"])
|
||||
def get_contexte(projet: str, llm_cible: str = "LLM",
|
||||
llm: str = Depends(get_llm)):
|
||||
"""Génère et retourne le briefing de reprise en texte."""
|
||||
from trilium_api import (search_by_label, get_label_value,
|
||||
get_note_content, create_note, get_note_id,
|
||||
set_label, get_note)
|
||||
import json, re as re2
|
||||
|
||||
decisions = search_by_label("type", "decision")
|
||||
decisions = [d for d in decisions
|
||||
if get_label_value(d["noteId"], "projet") == projet
|
||||
and get_label_value(d["noteId"], "statut") == "active"]
|
||||
|
||||
historique = search_by_label("type", "historiqueItem")
|
||||
historique = [h for h in historique
|
||||
if get_label_value(h["noteId"], "projet") == projet
|
||||
and get_label_value(h["noteId"], "encoreValide") == "true"]
|
||||
|
||||
backlog = search_by_label("type", "backlogItem")
|
||||
backlog = [b for b in backlog
|
||||
if get_label_value(b["noteId"], "projet") == projet
|
||||
and get_label_value(b["noteId"], "statut") not in ("fait", "abandonne")]
|
||||
|
||||
glossaire = search_by_label("type", "termeGlossaire")
|
||||
glossaire = [g for g in glossaire
|
||||
if get_label_value(g["noteId"], "projet") == projet]
|
||||
|
||||
date = datetime.now().strftime("%d/%m/%Y")
|
||||
lines = [
|
||||
f"# REPRISE DE CONTEXTE — {projet} — {date}",
|
||||
f"**LLM cible : {llm_cible}**", "",
|
||||
]
|
||||
|
||||
if decisions:
|
||||
lines += ["## Décisions actives (ne pas remettre en question)"]
|
||||
for d in decisions:
|
||||
lines.append(f"- {d.get('title', '?')}")
|
||||
lines.append("")
|
||||
|
||||
if historique:
|
||||
lines += ["## Historique — déjà testé / établi"]
|
||||
for h in historique:
|
||||
type_h = get_label_value(h["noteId"], "typeHistorique") or ""
|
||||
lines.append(f"- [{type_h}] {h.get('title', '?')}")
|
||||
lines.append("")
|
||||
|
||||
if glossaire:
|
||||
lines += ["## Glossaire"]
|
||||
for g in glossaire:
|
||||
defn = get_label_value(g["noteId"], "definition") or "(voir note)"
|
||||
lines.append(f"- **{g.get('title','?')}** : {defn}")
|
||||
lines.append("")
|
||||
|
||||
if backlog:
|
||||
lines += ["## Backlog actif"]
|
||||
prio_ordre = {"haute": 0, "moyenne": 1, "basse": 2}
|
||||
backlog.sort(key=lambda b: prio_ordre.get(
|
||||
get_label_value(b["noteId"], "priorite") or "basse", 99))
|
||||
for b in backlog:
|
||||
prio = get_label_value(b["noteId"], "priorite") or "?"
|
||||
statut = get_label_value(b["noteId"], "statut") or "?"
|
||||
lines.append(f"- [{statut}] [{prio}] {b.get('title','?')}")
|
||||
lines.append("")
|
||||
|
||||
lines += [
|
||||
"---",
|
||||
"Confirme en 3 lignes : (a) objectif (b) prochaine action (c) incertitude",
|
||||
]
|
||||
|
||||
briefing = "\n".join(lines)
|
||||
tokens = max(1, int(len(briefing) / 4))
|
||||
return {"projet": projet, "tokens": tokens, "briefing": briefing}
|
||||
|
||||
@app.get("/api/contexte/{projet}/last", tags=["Contexte Reprise"])
|
||||
def get_last_contexte(projet: str, llm: str = Depends(get_llm)):
|
||||
"""Retourne le dernier contexte de reprise sauvegardé."""
|
||||
from trilium_api import search_by_label, get_label_value, get_note_content
|
||||
notes = search_by_label("type", "contexteReprise")
|
||||
notes = [n for n in notes
|
||||
if get_label_value(n["noteId"], "projet") == projet]
|
||||
if not notes:
|
||||
raise HTTPException(status_code=404,
|
||||
detail=f"Aucun contexte de reprise pour '{projet}'")
|
||||
notes.sort(key=lambda n: int(get_label_value(n["noteId"], "version") or 0),
|
||||
reverse=True)
|
||||
derniere = notes[0]
|
||||
nid = derniere["noteId"]
|
||||
content = get_note_content(nid)
|
||||
texte = re.sub(r"<br\s*/?>", "\n", content)
|
||||
texte = re.sub(r"<[^>]+>", "", texte)
|
||||
texte = texte.replace("&", "&").strip()
|
||||
return {
|
||||
"id": nid,
|
||||
"titre": derniere.get("title", ""),
|
||||
"version": get_label_value(nid, "version"),
|
||||
"briefing": texte,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes — Skills
|
||||
# ---------------------------------------------------------------------------
|
||||
# Les skills documentent un savoir-faire réutilisable (dev Synology, charte
|
||||
# design, réflexe Trilium...). Contrairement aux autres objets, le label
|
||||
# `projet` est optionnel : un skill peut être "universel" (portee=universel)
|
||||
# et ne dépendre d'aucun projet précis.
|
||||
|
||||
@app.get("/api/skills", tags=["Skills"])
|
||||
def list_skills(projet: Optional[str] = None, portee: Optional[str] = None,
|
||||
llm: str = Depends(get_llm)):
|
||||
"""Liste les skills. Filtre optionnel par projet et/ou portee.
|
||||
Sans filtre projet, retourne aussi les skills universels."""
|
||||
from trilium_api import search_by_label, get_label_value
|
||||
notes = search_by_label("type", "skill")
|
||||
result = []
|
||||
for n in notes:
|
||||
nid = n["noteId"]
|
||||
note_projet = get_label_value(nid, "projet")
|
||||
note_portee = get_label_value(nid, "portee")
|
||||
if projet and note_projet != projet and note_portee != "universel":
|
||||
continue
|
||||
if portee and note_portee != portee:
|
||||
continue
|
||||
result.append(note_to_dict(n, with_content=True))
|
||||
return result
|
||||
|
||||
@app.get("/api/skills/{note_id}", tags=["Skills"])
|
||||
def get_skill(note_id: str, llm: str = Depends(get_llm)):
|
||||
"""Récupère un skill par son id, contenu inclus."""
|
||||
from trilium_api import get_note
|
||||
try:
|
||||
note = get_note(note_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail="Skill introuvable")
|
||||
return note_to_dict({"noteId": note_id, **note}, with_content=True)
|
||||
|
||||
@app.post("/api/skills", tags=["Skills"], status_code=201)
|
||||
def create_skill(body: SkillCreate, llm: str = Depends(get_llm)):
|
||||
"""Crée un skill. portee='universel' pour un skill sans projet rattaché."""
|
||||
import json
|
||||
from trilium_api import create_note, get_note_id, set_label, find_note_by_title
|
||||
with open(os.path.expanduser("~/App/Context_continuity/trilium_ids.json")) as f:
|
||||
ids = json.load(f)
|
||||
if body.portee != "universel" and not body.projet:
|
||||
raise HTTPException(status_code=400,
|
||||
detail="projet requis sauf si portee='universel'")
|
||||
existant = find_note_by_title(body.titre, ids["Skills"])
|
||||
if existant:
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"Un skill '{body.titre}' existe déjà ({existant})")
|
||||
content = f"<pre>{body.contenu}</pre>"
|
||||
res = create_note(ids["Skills"], body.titre, content=content)
|
||||
nid = get_note_id(res)
|
||||
set_label(nid, "type", "skill")
|
||||
set_label(nid, "portee", body.portee)
|
||||
if body.projet:
|
||||
set_label(nid, "projet", body.projet)
|
||||
return {"id": nid, "titre": body.titre, "created_by": llm}
|
||||
|
||||
@app.patch("/api/skills/{note_id}", tags=["Skills"])
|
||||
def patch_skill(note_id: str, body: SkillPatch, llm: str = Depends(get_llm)):
|
||||
"""Met à jour le contenu et/ou la portee d'un skill existant."""
|
||||
from trilium_api import get_note, set_label, update_note_content
|
||||
try:
|
||||
get_note(note_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail="Skill introuvable")
|
||||
if body.contenu is not None:
|
||||
update_note_content(note_id, f"<pre>{body.contenu}</pre>")
|
||||
if body.portee is not None:
|
||||
set_label(note_id, "portee", body.portee)
|
||||
return {"id": note_id, "updated_by": llm}
|
||||
|
||||
|
||||
@app.delete("/api/notes/{note_id}", tags=["Système"])
|
||||
def delete_note_endpoint(note_id: str, llm: str = Depends(get_llm)):
|
||||
"""Supprime une note du systeme. Garde-fou : refuse les notes hors systeme."""
|
||||
from trilium_api import delete_note_safe
|
||||
ok, message = delete_note_safe(note_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
return {"deleted": True, "message": message, "by": llm}
|
||||
|
||||
|
||||
class MoveNoteBody(BaseModel):
|
||||
ancien_parent_id: str
|
||||
nouveau_parent_id: str
|
||||
|
||||
@app.post("/api/notes/{note_id}/move", tags=["Système"])
|
||||
def move_note_endpoint(note_id: str, body: MoveNoteBody, llm: str = Depends(get_llm)):
|
||||
"""Deplace une note d un parent vers un autre. Gere les clones."""
|
||||
from trilium_api import move_note_safe
|
||||
ok, message = move_note_safe(note_id, body.ancien_parent_id, body.nouveau_parent_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
return {"moved": True, "message": message, "by": llm}
|
||||
|
||||
|
||||
class AddRelationBody(BaseModel):
|
||||
nom: str
|
||||
cible_id: str
|
||||
|
||||
@app.post("/api/notes/{source_id}/relation", tags=["Système"])
|
||||
def add_relation_endpoint(source_id: str, body: AddRelationBody, llm: str = Depends(get_llm)):
|
||||
"""Cree une relation typee entre deux notes. Garde-fou : nom dans l ontologie."""
|
||||
from trilium_api import add_relation_safe
|
||||
ok, message = add_relation_safe(source_id, body.nom, body.cible_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
return {"created": True, "message": message, "by": llm}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Audit lecture seule : detecte TOUS les labels en doublon, quel que soit le nom."""
|
||||
from trilium_api import search_by_label, get_note
|
||||
|
||||
types_notes = ["backlogItem", "decision", "historiqueItem", "conversation",
|
||||
"termeGlossaire", "projet", "contexteReprise", "skill", "documentation"]
|
||||
|
||||
notes_traitees = set()
|
||||
total_doublons = 0
|
||||
notes_affectees = 0
|
||||
|
||||
for tn in types_notes:
|
||||
for n in search_by_label("type", tn):
|
||||
nid = n["noteId"]
|
||||
if nid in notes_traitees:
|
||||
continue
|
||||
notes_traitees.add(nid)
|
||||
note = get_note(nid)
|
||||
attrs = [a for a in note.get("attributes", []) if a.get("type") == "label"]
|
||||
par_nom = {}
|
||||
for a in attrs:
|
||||
par_nom.setdefault(a.get("name"), []).append(a)
|
||||
doublons_note = {nom: len(lst) for nom, lst in par_nom.items() if len(lst) > 1}
|
||||
if doublons_note:
|
||||
notes_affectees += 1
|
||||
titre = note.get("title", "?")[:40]
|
||||
print(f"{nid} ({titre}) : {doublons_note}")
|
||||
total_doublons += sum(v - 1 for v in doublons_note.values())
|
||||
|
||||
print(f"\n{notes_affectees} notes affectees, {total_doublons} labels en double au total.")
|
||||
print("Noms de labels concernes a verifier si absents de la liste de nettoyage :")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Nettoyage one-shot des labels en doublon sur les notes du systeme."""
|
||||
from trilium_api import _ea, search_by_label, get_note
|
||||
|
||||
ea = _ea()
|
||||
LABELS_A_DEDOUBLONNER = ["statut", "priorite", "encoreValide", "type", "projet"]
|
||||
|
||||
# On parcourt tous les types de notes du systeme
|
||||
types_notes = ["backlogItem", "decision", "historiqueItem", "conversation",
|
||||
"termeGlossaire", "projet", "contexteReprise", "skill"]
|
||||
|
||||
notes_traitees = set()
|
||||
total_supprimes = 0
|
||||
|
||||
for tn in types_notes:
|
||||
for n in search_by_label("type", tn):
|
||||
nid = n["noteId"]
|
||||
if nid in notes_traitees:
|
||||
continue
|
||||
notes_traitees.add(nid)
|
||||
note = get_note(nid)
|
||||
attrs = [a for a in note.get("attributes", []) if a.get("type") == "label"]
|
||||
# Grouper par nom de label
|
||||
par_nom = {}
|
||||
for a in attrs:
|
||||
par_nom.setdefault(a.get("name"), []).append(a)
|
||||
for name, lst in par_nom.items():
|
||||
if len(lst) <= 1:
|
||||
continue
|
||||
if name not in LABELS_A_DEDOUBLONNER:
|
||||
continue
|
||||
# Valeur de reference = celle du dernier label (plus recent)
|
||||
valeur_finale = lst[-1].get("value", "")
|
||||
garde = lst[0]
|
||||
# Patcher le premier avec la valeur finale
|
||||
try:
|
||||
ea.patch_attribute(attributeId=garde.get("attributeId"), value=valeur_finale)
|
||||
except Exception as e:
|
||||
print(" patch echoue sur", nid, name, ":", e)
|
||||
# Supprimer tous les autres
|
||||
for doublon in lst[1:]:
|
||||
try:
|
||||
ea.delete_attribute(attributeId=doublon.get("attributeId"))
|
||||
total_supprimes += 1
|
||||
except Exception as e:
|
||||
print(" delete echoue :", e)
|
||||
print(f"{nid} : {name} dedoublonne ({len(lst)} -> 1, valeur={valeur_finale})")
|
||||
|
||||
print(f"\nTermine. {total_supprimes} labels en double supprimes.")
|
||||
@@ -0,0 +1,245 @@
|
||||
import json, os
|
||||
from trilium_api import create_note, get_note_id, set_label, find_note_by_title, update_note_content
|
||||
|
||||
with open(os.path.expanduser('~/App/Context_continuity/trilium_ids.json')) as f:
|
||||
ids = json.load(f)
|
||||
|
||||
root_id = ids['root']
|
||||
|
||||
# ── Dossier Documentation ──────────────────────────────────────────────────
|
||||
doc_id = find_note_by_title('Documentation', root_id)
|
||||
if not doc_id:
|
||||
res = create_note(root_id, 'Documentation', note_type='book')
|
||||
doc_id = get_note_id(res)
|
||||
set_label(doc_id, 'type', 'container')
|
||||
print('Dossier Documentation cree:', doc_id)
|
||||
else:
|
||||
print('Dossier Documentation existant:', doc_id)
|
||||
|
||||
# ── Note index Documentation ───────────────────────────────────────────────
|
||||
res = create_note(doc_id, 'API Context Continuity - Vue densemble')
|
||||
nid = get_note_id(res)
|
||||
set_label(nid, 'type', 'documentation')
|
||||
set_label(nid, 'projet', 'ContextContinuity')
|
||||
update_note_content(nid,
|
||||
'<h1>API Context Continuity</h1>'
|
||||
'<p><b>Base URL externe</b> : <code>https://api-trilium.bertha-cloud.fr</code><br>'
|
||||
'<b>Base URL locale</b> : <code>http://localhost:8765</code><br>'
|
||||
'<b>Doc Swagger</b> : <a href="https://api-trilium.bertha-cloud.fr/api/docs">https://api-trilium.bertha-cloud.fr/api/docs</a><br>'
|
||||
'<b>Auth</b> : Header <code>Authorization: <API_KEY></code></p>'
|
||||
'<h2>Ressources disponibles</h2>'
|
||||
'<table>'
|
||||
'<tr><th>Ressource</th><th>Endpoints</th><th>Note Trilium</th></tr>'
|
||||
'<tr><td>Projets</td><td>GET /api/projets, GET /api/projets/{nom}, POST, PATCH</td><td>Documentation > Projets</td></tr>'
|
||||
'<tr><td>Backlog</td><td>GET /api/backlog/{projet}, POST /api/backlog, PATCH</td><td>Documentation > Backlog</td></tr>'
|
||||
'<tr><td>Decisions</td><td>GET /api/decisions/{projet}, POST, PATCH</td><td>Documentation > Decisions</td></tr>'
|
||||
'<tr><td>Historique</td><td>GET /api/historique/{projet}, POST, PATCH</td><td>Documentation > Historique</td></tr>'
|
||||
'<tr><td>Conversations</td><td>GET /api/conversations/{projet}, POST, PATCH</td><td>Documentation > Conversations</td></tr>'
|
||||
'<tr><td>Glossaire</td><td>GET /api/glossaire/{projet}, POST, PATCH</td><td>Documentation > Glossaire</td></tr>'
|
||||
'<tr><td>Contexte Reprise</td><td>GET /api/contexte/{projet}, GET /api/contexte/{projet}/last</td><td>Documentation > Contexte Reprise</td></tr>'
|
||||
'</table>'
|
||||
'<h2>Codes de retour</h2>'
|
||||
'<table>'
|
||||
'<tr><th>Code</th><th>Signification</th></tr>'
|
||||
'<tr><td>200</td><td>OK</td></tr>'
|
||||
'<tr><td>201</td><td>Cree avec succes</td></tr>'
|
||||
'<tr><td>401</td><td>Cle API invalide</td></tr>'
|
||||
'<tr><td>404</td><td>Ressource introuvable</td></tr>'
|
||||
'<tr><td>422</td><td>Validation Pydantic echouee (parametre invalide)</td></tr>'
|
||||
'</table>'
|
||||
)
|
||||
print('Index documentation cree:', nid)
|
||||
|
||||
# ── Notes par ressource ────────────────────────────────────────────────────
|
||||
ressources = [
|
||||
(
|
||||
'API - Projets',
|
||||
'<h1>API - Projets</h1>'
|
||||
'<h2>GET /api/projets</h2>'
|
||||
'<p>Liste tous les projets.</p>'
|
||||
'<pre>curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/projets</pre>'
|
||||
'<h2>GET /api/projets/{nom}</h2>'
|
||||
'<p>Detail d un projet par son nom (valeur du label #projet).</p>'
|
||||
'<pre>curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/projets/SlidingAutomation</pre>'
|
||||
'<h2>POST /api/projets</h2>'
|
||||
'<p>Cree un nouveau projet.</p>'
|
||||
'<pre>curl -s -X POST -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
|
||||
'-d \'{"nom":"MonProjet","objectif":"Description","stack":"Python","llm_repartition":"Claude → archi"}\' \\\n'
|
||||
'https://api-trilium.bertha-cloud.fr/api/projets</pre>'
|
||||
'<h2>PATCH /api/projets/{note_id}</h2>'
|
||||
'<p>Modifie le statut ou l objectif. Champs optionnels : statut (actif|en-pause|archive), objectif, stack.</p>'
|
||||
'<pre>curl -s -X PATCH -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
|
||||
'-d \'{"statut":"en-pause"}\' \\\n'
|
||||
'https://api-trilium.bertha-cloud.fr/api/projets/NOTE_ID</pre>'
|
||||
),
|
||||
(
|
||||
'API - Backlog',
|
||||
'<h1>API - Backlog</h1>'
|
||||
'<h2>GET /api/backlog/{projet}</h2>'
|
||||
'<p>Liste les items actifs (hors fait et abandonne), tries par priorite.</p>'
|
||||
'<pre>curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/backlog/SlidingAutomation</pre>'
|
||||
'<h2>POST /api/backlog</h2>'
|
||||
'<p>Cree un item. Champs : projet (requis), titre (requis), priorite (haute|moyenne|basse, defaut: moyenne).</p>'
|
||||
'<pre>curl -s -X POST -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
|
||||
'-d \'{"projet":"SlidingAutomation","titre":"Ma tache","priorite":"haute"}\' \\\n'
|
||||
'https://api-trilium.bertha-cloud.fr/api/backlog</pre>'
|
||||
'<h2>PATCH /api/backlog/{note_id}</h2>'
|
||||
'<p>Modifie statut ou priorite. statut valide : a faire|en cours|bloque|fait|abandonne.</p>'
|
||||
'<pre>curl -s -X PATCH -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
|
||||
'-d \'{"statut":"fait"}\' \\\n'
|
||||
'https://api-trilium.bertha-cloud.fr/api/backlog/NOTE_ID</pre>'
|
||||
),
|
||||
(
|
||||
'API - Decisions',
|
||||
'<h1>API - Decisions</h1>'
|
||||
'<h2>GET /api/decisions/{projet}</h2>'
|
||||
'<p>Liste les decisions actives (statut=active).</p>'
|
||||
'<pre>curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/decisions/SlidingAutomation</pre>'
|
||||
'<h2>POST /api/decisions</h2>'
|
||||
'<p>Cree une decision. Champs : projet, enonce (requis), justification, llm.</p>'
|
||||
'<pre>curl -s -X POST -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
|
||||
'-d \'{"projet":"SlidingAutomation","enonce":"On utilise X","justification":"Parce que Y"}\' \\\n'
|
||||
'https://api-trilium.bertha-cloud.fr/api/decisions</pre>'
|
||||
'<h2>PATCH /api/decisions/{note_id}</h2>'
|
||||
'<p>Modifie statut (active|revisee|annulee) ou justification.</p>'
|
||||
'<pre>curl -s -X PATCH -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
|
||||
'-d \'{"statut":"annulee"}\' \\\n'
|
||||
'https://api-trilium.bertha-cloud.fr/api/decisions/NOTE_ID</pre>'
|
||||
),
|
||||
(
|
||||
'API - Historique',
|
||||
'<h1>API - Historique</h1>'
|
||||
'<h2>GET /api/historique/{projet}</h2>'
|
||||
'<p>Liste les entrees encore valides (encoreValide=true).</p>'
|
||||
'<pre>curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/historique/SlidingAutomation</pre>'
|
||||
'<h2>POST /api/historique</h2>'
|
||||
'<p>Cree une entree. type valide : Fait etabli|Test effectue|Hypothese invalidee|Contrainte decouverte.</p>'
|
||||
'<pre>curl -s -X POST -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
|
||||
'-d \'{"projet":"SlidingAutomation","enonce":"Test X","type":"Test effectue","detail":"Resultat Y"}\' \\\n'
|
||||
'https://api-trilium.bertha-cloud.fr/api/historique</pre>'
|
||||
'<h2>PATCH /api/historique/{note_id}</h2>'
|
||||
'<p>Invalider une entree obsolete : encore_valide: false.</p>'
|
||||
'<pre>curl -s -X PATCH -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
|
||||
'-d \'{"encore_valide":false}\' \\\n'
|
||||
'https://api-trilium.bertha-cloud.fr/api/historique/NOTE_ID</pre>'
|
||||
),
|
||||
(
|
||||
'API - Conversations',
|
||||
'<h1>API - Conversations</h1>'
|
||||
'<h2>GET /api/conversations/{projet}</h2>'
|
||||
'<p>Liste toutes les conversations du projet.</p>'
|
||||
'<pre>curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/conversations/SlidingAutomation</pre>'
|
||||
'<h2>GET /api/conversations/{projet}/{note_id}</h2>'
|
||||
'<p>Detail avec contenu HTML d une conversation specifique.</p>'
|
||||
'<h2>POST /api/conversations</h2>'
|
||||
'<p>Cree une conversation. llm valide : Claude Sonnet|Claude Opus|Le Chat Large|Le Chat Medium.</p>'
|
||||
'<pre>curl -s -X POST -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
|
||||
'-d \'{"projet":"SlidingAutomation","titre":"Ma session","llm":"Claude Sonnet"}\' \\\n'
|
||||
'https://api-trilium.bertha-cloud.fr/api/conversations</pre>'
|
||||
'<h2>PATCH /api/conversations/{note_id}</h2>'
|
||||
'<p>Cloture une session : synthese_cloture (max 500 chars) et/ou statut (en-cours|clos).</p>'
|
||||
'<pre>curl -s -X PATCH -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
|
||||
'-d \'{"synthese_cloture":"Session terminee. Reste X."}\' \\\n'
|
||||
'https://api-trilium.bertha-cloud.fr/api/conversations/NOTE_ID</pre>'
|
||||
),
|
||||
(
|
||||
'API - Glossaire',
|
||||
'<h1>API - Glossaire</h1>'
|
||||
'<h2>GET /api/glossaire/{projet}</h2>'
|
||||
'<p>Liste les termes avec contenu (definition).</p>'
|
||||
'<pre>curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/glossaire/SlidingAutomation</pre>'
|
||||
'<h2>POST /api/glossaire</h2>'
|
||||
'<p>Cree un terme. Champs : projet, terme, definition (requis), synonymes_eviter.</p>'
|
||||
'<pre>curl -s -X POST -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
|
||||
'-d \'{"projet":"SlidingAutomation","terme":"Pipeline","definition":"Chaine complete de traitement"}\' \\\n'
|
||||
'https://api-trilium.bertha-cloud.fr/api/glossaire</pre>'
|
||||
'<h2>PATCH /api/glossaire/{note_id}</h2>'
|
||||
'<p>Modifie definition et/ou synonymes_eviter.</p>'
|
||||
),
|
||||
(
|
||||
'API - Contexte Reprise',
|
||||
'<h1>API - Contexte Reprise</h1>'
|
||||
'<h2>GET /api/contexte/{projet}?llm_cible=NomLLM</h2>'
|
||||
'<p>Genere et retourne le briefing de reprise en temps reel depuis Trilium.</p>'
|
||||
'<pre>curl -s -H "Authorization: CLE" \\\n'
|
||||
'"https://api-trilium.bertha-cloud.fr/api/contexte/SlidingAutomation?llm_cible=Le+Chat+Large"</pre>'
|
||||
'<p>Retourne : {"projet":"...","tokens":N,"briefing":"# REPRISE DE CONTEXTE..."}</p>'
|
||||
'<h2>GET /api/contexte/{projet}/last</h2>'
|
||||
'<p>Retourne le dernier contexte de reprise sauvegarde dans Trilium.</p>'
|
||||
'<pre>curl -s -H "Authorization: CLE" \\\n'
|
||||
'https://api-trilium.bertha-cloud.fr/api/contexte/SlidingAutomation/last</pre>'
|
||||
),
|
||||
]
|
||||
|
||||
for titre, contenu in ressources:
|
||||
res = create_note(doc_id, titre)
|
||||
nid = get_note_id(res)
|
||||
set_label(nid, 'type', 'documentation')
|
||||
set_label(nid, 'projet', 'ContextContinuity')
|
||||
update_note_content(nid, contenu)
|
||||
print('Doc cree:', titre)
|
||||
|
||||
# ── Skill API FastAPI pour LLMs ────────────────────────────────────────────
|
||||
skills_id = find_note_by_title('Skills', root_id)
|
||||
res = create_note(skills_id, 'Skill - API FastAPI Context Continuity')
|
||||
nid = get_note_id(res)
|
||||
set_label(nid, 'type', 'skill')
|
||||
set_label(nid, 'domaine', 'api')
|
||||
set_label(nid, 'version', '1.0')
|
||||
|
||||
update_note_content(nid,
|
||||
'<h1>Skill - API FastAPI Context Continuity</h1>'
|
||||
'<p>Ce skill permet a un LLM d appeler directement l API pour lire et ecrire dans Trilium.</p>'
|
||||
'<h2>Configuration</h2>'
|
||||
'<table>'
|
||||
'<tr><th>Parametre</th><th>Valeur</th></tr>'
|
||||
'<tr><td>Base URL</td><td>https://api-trilium.bertha-cloud.fr</td></tr>'
|
||||
'<tr><td>Auth header</td><td>Authorization: <API_KEY></td></tr>'
|
||||
'<tr><td>Cle Claude</td><td>Demander a Bastien ou lire dans .env</td></tr>'
|
||||
'<tr><td>Cle Le Chat</td><td>Demander a Bastien ou lire dans .env</td></tr>'
|
||||
'<tr><td>Doc Swagger</td><td>https://api-trilium.bertha-cloud.fr/api/docs</td></tr>'
|
||||
'</table>'
|
||||
'<h2>Endpoints essentiels pour un LLM</h2>'
|
||||
'<table>'
|
||||
'<tr><th>Action</th><th>Appel</th></tr>'
|
||||
'<tr><td>Lire le contexte projet</td><td>GET /api/contexte/{projet}?llm_cible=NomLLM</td></tr>'
|
||||
'<tr><td>Voir le backlog</td><td>GET /api/backlog/{projet}</td></tr>'
|
||||
'<tr><td>Enregistrer une decision</td><td>POST /api/decisions</td></tr>'
|
||||
'<tr><td>Enregistrer un historique</td><td>POST /api/historique</td></tr>'
|
||||
'<tr><td>Creer une conversation</td><td>POST /api/conversations</td></tr>'
|
||||
'<tr><td>Cloturer une session</td><td>PATCH /api/conversations/{id}</td></tr>'
|
||||
'<tr><td>Marquer backlog item fait</td><td>PATCH /api/backlog/{id}</td></tr>'
|
||||
'</table>'
|
||||
'<h2>Valeurs valides par champ</h2>'
|
||||
'<table>'
|
||||
'<tr><th>Champ</th><th>Valeurs acceptees</th></tr>'
|
||||
'<tr><td>priorite</td><td>haute | moyenne | basse</td></tr>'
|
||||
'<tr><td>statut backlog</td><td>a faire | en cours | bloque | fait | abandonne</td></tr>'
|
||||
'<tr><td>statut decision</td><td>active | revisee | annulee</td></tr>'
|
||||
'<tr><td>type historique</td><td>Fait etabli | Test effectue | Hypothese invalidee | Contrainte decouverte</td></tr>'
|
||||
'<tr><td>llm conversation</td><td>Claude Sonnet | Claude Opus | Le Chat Large | Le Chat Medium</td></tr>'
|
||||
'<tr><td>statut projet</td><td>actif | en-pause | archive</td></tr>'
|
||||
'</table>'
|
||||
'<h2>Exemples curl complets</h2>'
|
||||
'<h3>Lire le contexte de reprise</h3>'
|
||||
'<pre>curl -s -H "Authorization: CLE" \\\n'
|
||||
'"https://api-trilium.bertha-cloud.fr/api/contexte/SlidingAutomation?llm_cible=Le+Chat+Large" \\\n'
|
||||
'| python3 -c "import sys,json; print(json.load(sys.stdin)[\'briefing\'])"</pre>'
|
||||
'<h3>Enregistrer une decision</h3>'
|
||||
'<pre>curl -s -X POST \\\n'
|
||||
'-H "Authorization: CLE" \\\n'
|
||||
'-H "Content-Type: application/json" \\\n'
|
||||
'-d \'{"projet":"SlidingAutomation","enonce":"Decision prise","justification":"Raison"}\' \\\n'
|
||||
'https://api-trilium.bertha-cloud.fr/api/decisions</pre>'
|
||||
'<h3>Marquer un item backlog comme fait</h3>'
|
||||
'<pre>curl -s -X PATCH \\\n'
|
||||
'-H "Authorization: CLE" \\\n'
|
||||
'-H "Content-Type: application/json" \\\n'
|
||||
'-d \'{"statut":"fait"}\' \\\n'
|
||||
'https://api-trilium.bertha-cloud.fr/api/backlog/NOTE_ID</pre>'
|
||||
'<h2>Recuperer ce skill</h2>'
|
||||
'<pre>curl -s -H "Authorization: CLE" \\\n'
|
||||
'"https://api-trilium.bertha-cloud.fr/api/contexte/ContextContinuity/last"</pre>'
|
||||
)
|
||||
print('Skill API FastAPI cree:', nid)
|
||||
print('DONE')
|
||||
@@ -0,0 +1,153 @@
|
||||
import json, os
|
||||
from trilium_api import create_note, get_note_id, set_label, find_note_by_title, update_note_content
|
||||
|
||||
with open(os.path.expanduser('~/App/Context_continuity/trilium_ids.json')) as f:
|
||||
ids = json.load(f)
|
||||
|
||||
res = create_note(ids['root'], 'README - Workflow Context Continuity')
|
||||
nid = get_note_id(res)
|
||||
set_label(nid, 'type', 'documentation')
|
||||
|
||||
content = (
|
||||
'<h1>README - Workflow Context Continuity</h1>'
|
||||
'<p>Systeme de gestion du contexte multi-LLM (Claude + Le Chat) via Trilium comme base pivot.<br>'
|
||||
'Permet de basculer entre les deux LLMs sans perdre le fil du projet.</p>'
|
||||
|
||||
'<h2>Prerequis</h2>'
|
||||
'<pre>'
|
||||
'cd ~/App/Context_continuity\n'
|
||||
'source venv/bin/activate'
|
||||
'</pre>'
|
||||
|
||||
'<h2>ETAPE 1 - Debut de session : creer la conversation</h2>'
|
||||
'<p>Au debut de chaque session de travail avec un LLM, enregistre-la dans Trilium :</p>'
|
||||
'<pre>python trilium_context.py new-conversation \\\n'
|
||||
' --projet SlidingAutomation \\\n'
|
||||
' --llm "Claude Sonnet" \\\n'
|
||||
' --titre "Description courte de ce que tu vas faire"</pre>'
|
||||
'<p><strong>Note l ID retourne</strong> — tu en auras besoin a la fin de la session.<br>'
|
||||
'Exemple de retour : <code>Note l ID pour close-session : Igaa5BBIfNMw</code></p>'
|
||||
|
||||
'<h2>ETAPE 2 - Recuperer le contexte existant (optionnel)</h2>'
|
||||
'<p>Si tu reprends un projet en cours, genere le briefing depuis la derniere session :</p>'
|
||||
'<pre>python trilium_context.py generate-context \\\n'
|
||||
' --projet SlidingAutomation \\\n'
|
||||
' --llm-cible "Claude Sonnet" \\\n'
|
||||
' --note-id ID_DERNIERE_CONV \\\n'
|
||||
' --version N</pre>'
|
||||
'<p>Le briefing est imprime dans le terminal.<br>'
|
||||
'<strong>Copie-colle le texte imprime en debut de nouvelle session LLM.</strong><br>'
|
||||
'Le LLM confirmera sa comprehension en 3 lignes (objectif / prochaine action / incertitude).</p>'
|
||||
'<p>Pour trouver l ID de la derniere conversation :<br>'
|
||||
'Dans Trilium : Context Continuity > Conversations > chercher la plus recente.<br>'
|
||||
'Ou via terminal :</p>'
|
||||
'<pre>python trilium_context.py list-projects</pre>'
|
||||
|
||||
'<h2>ETAPE 3 - Pendant la session : capitaliser au fil de l eau</h2>'
|
||||
'<p>Ne pas attendre la fin — enregistre les informations importantes au fur et a mesure :</p>'
|
||||
'<pre># Une decision est prise\n'
|
||||
'python trilium_context.py add-decision \\\n'
|
||||
' --projet SlidingAutomation \\\n'
|
||||
' --enonce "On fait X plutot que Y" \\\n'
|
||||
' --justification "Parce que Z"\n\n'
|
||||
'# Un test a ete effectue\n'
|
||||
'python trilium_context.py add-history \\\n'
|
||||
' --projet SlidingAutomation \\\n'
|
||||
' --type "Test effectue" \\\n'
|
||||
' --enonce "Test de X" \\\n'
|
||||
' --detail "Resultat : Y"\n\n'
|
||||
'# Une contrainte est decouverte\n'
|
||||
'python trilium_context.py add-history \\\n'
|
||||
' --projet SlidingAutomation \\\n'
|
||||
' --type "Contrainte decouverte" \\\n'
|
||||
' --enonce "X ne fonctionne pas sur Python 3.9" \\\n'
|
||||
' --detail "Utiliser Y a la place"</pre>'
|
||||
|
||||
'<h2>ETAPE 4 - Limite de tokens approche : cloturer la session</h2>'
|
||||
'<p>Signes que la limite approche : le LLM repete des choses deja dites, oublie une contrainte, '
|
||||
'ses reponses deviennent moins precises.<br>'
|
||||
'<strong>Action :</strong> demande au LLM actuel :</p>'
|
||||
'<pre>Fais-moi une synthese de cloture en 200 mots :\n'
|
||||
'ou on en est, ce qui reste a faire, les blocages eventuels.</pre>'
|
||||
'<p>Puis enregistre cette synthese :</p>'
|
||||
'<pre>python trilium_context.py close-session \\\n'
|
||||
' --note-id ID_CONV_ACTUELLE \\\n'
|
||||
' --summary "COLLE ICI LA SYNTHESE DU LLM"</pre>'
|
||||
|
||||
'<h2>ETAPE 5 - Generer le briefing de reprise</h2>'
|
||||
'<pre>python trilium_context.py generate-context \\\n'
|
||||
' --projet SlidingAutomation \\\n'
|
||||
' --llm-cible "Le Chat Large" \\\n'
|
||||
' --note-id ID_CONV_ACTUELLE \\\n'
|
||||
' --version N</pre>'
|
||||
'<p>Le terminal imprime le briefing complet.<br>'
|
||||
'Une note "Contexte Reprise" est aussi creee automatiquement dans Trilium.</p>'
|
||||
|
||||
'<h2>ETAPE 6 - Basculer vers le second LLM</h2>'
|
||||
'<ol>'
|
||||
'<li>Ouvre une nouvelle session sur l autre LLM (Claude ou Le Chat)</li>'
|
||||
'<li>Cree la nouvelle conversation dans Trilium :<br>'
|
||||
'<pre>python trilium_context.py new-conversation \\\n'
|
||||
' --projet SlidingAutomation \\\n'
|
||||
' --llm "Le Chat Large" \\\n'
|
||||
' --titre "Suite - description"</pre></li>'
|
||||
'<li>Copie-colle le briefing imprime a l etape 5 en premier message de la nouvelle session</li>'
|
||||
'<li>Le LLM confirme en 3 lignes — verifie qu il a bien compris avant de continuer</li>'
|
||||
'<li>Reprends le travail depuis le backlog actif</li>'
|
||||
'</ol>'
|
||||
|
||||
'<h2>ETAPE 7 - Voir l etat du projet a tout moment</h2>'
|
||||
'<pre># Backlog actif\n'
|
||||
'python trilium_context.py list-backlog --projet SlidingAutomation\n\n'
|
||||
'# Liste des projets\n'
|
||||
'python trilium_context.py list-projects</pre>'
|
||||
|
||||
'<h2>Resume visuel du cycle</h2>'
|
||||
'<pre>'
|
||||
'DEBUT SESSION\n'
|
||||
' |\n'
|
||||
' v\n'
|
||||
'new-conversation (note l ID)\n'
|
||||
' |\n'
|
||||
' v\n'
|
||||
'generate-context (si reprise) --> coller dans LLM\n'
|
||||
' |\n'
|
||||
' v\n'
|
||||
'TRAVAILLER\n'
|
||||
'add-decision / add-history au fil de l eau\n'
|
||||
' |\n'
|
||||
' v (limite approche)\n'
|
||||
'Demander synthese au LLM\n'
|
||||
' |\n'
|
||||
' v\n'
|
||||
'close-session (coller la synthese)\n'
|
||||
' |\n'
|
||||
' v\n'
|
||||
'generate-context --> briefing imprime\n'
|
||||
' |\n'
|
||||
' v\n'
|
||||
'new-conversation (sur l autre LLM)\n'
|
||||
'Coller le briefing --> LLM confirme en 3 lignes\n'
|
||||
' |\n'
|
||||
' v\n'
|
||||
'REPRENDRE LE TRAVAIL'
|
||||
'</pre>'
|
||||
|
||||
'<h2>Fichiers du systeme</h2>'
|
||||
'<table>'
|
||||
'<tr><th>Fichier</th><th>Role</th></tr>'
|
||||
'<tr><td><code>trilium_api.py</code></td><td>Wrapper API Trilium (ne pas modifier)</td></tr>'
|
||||
'<tr><td><code>trilium_init.py</code></td><td>Initialisation arborescence (une seule fois)</td></tr>'
|
||||
'<tr><td><code>trilium_context.py</code></td><td>Script principal du workflow quotidien</td></tr>'
|
||||
'<tr><td><code>trilium_logger.py</code></td><td>Integration pipeline Sliding Automation</td></tr>'
|
||||
'<tr><td><code>trilium_ids.json</code></td><td>IDs des dossiers Trilium (genere par init)</td></tr>'
|
||||
'<tr><td><code>.env</code></td><td>Token ETAPI et URL Trilium (ne pas commiter)</td></tr>'
|
||||
'</table>'
|
||||
|
||||
'<h2>Skills disponibles dans Trilium</h2>'
|
||||
'<p>Context Continuity > Skills > Skill - API Trilium ETAPI<br>'
|
||||
'Contient les patterns API, erreurs connues et fixes pour TriliumNext 0.95+</p>'
|
||||
)
|
||||
|
||||
update_note_content(nid, content)
|
||||
print('README cree:', nid)
|
||||
@@ -0,0 +1,85 @@
|
||||
import json, os
|
||||
from trilium_api import create_note, get_note_id, set_label, find_note_by_title, update_note_content
|
||||
|
||||
with open(os.path.expanduser('~/App/Context_continuity/trilium_ids.json')) as f:
|
||||
ids = json.load(f)
|
||||
|
||||
# Créer le dossier Skills
|
||||
skills_id = find_note_by_title('Skills', ids['root'])
|
||||
if not skills_id:
|
||||
res = create_note(ids['root'], 'Skills', note_type='book')
|
||||
skills_id = get_note_id(res)
|
||||
set_label(skills_id, 'type', 'container')
|
||||
print('Dossier Skills cree:', skills_id)
|
||||
else:
|
||||
print('Dossier Skills existant:', skills_id)
|
||||
|
||||
# Créer la note skill
|
||||
res = create_note(skills_id, 'Skill - API Trilium ETAPI')
|
||||
nid = get_note_id(res)
|
||||
set_label(nid, 'type', 'skill')
|
||||
set_label(nid, 'domaine', 'trilium')
|
||||
set_label(nid, 'version', '0.95')
|
||||
|
||||
content = (
|
||||
'<h1>Skill - API Trilium ETAPI (TriliumNext 0.95+)</h1>'
|
||||
'<h2>Contexte</h2>'
|
||||
'<p>TriliumNext 0.95 a restructure ses routes internes.<br>'
|
||||
'<code>POST /etapi/notes</code> retourne <em>Router not found</em>.<br>'
|
||||
'Solution validee : utiliser <strong>trilium-py 1.3.9</strong>.</p>'
|
||||
'<h2>Installation</h2>'
|
||||
'<pre>pip install trilium-py python-dotenv</pre>'
|
||||
'<h2>Authentification</h2>'
|
||||
'<p>Token dans .env :</p>'
|
||||
'<pre>TRILIUM_URL=http://localhost:4292\nTRILIUM_TOKEN=token_genere_dans_Options_ETAPI</pre>'
|
||||
'<p>Header : <code>Authorization: token</code> (sans Bearer)<br>'
|
||||
'Bearer accepte depuis 0.93 mais non requis.</p>'
|
||||
'<h2>Wrapper valide : trilium_api.py</h2>'
|
||||
'<p>Chemin : <code>~/App/Context_continuity/trilium_api.py</code></p>'
|
||||
'<table>'
|
||||
'<tr><th>Fonction</th><th>Description</th></tr>'
|
||||
'<tr><td><code>check_api()</code></td><td>Leve SystemExit si Trilium inaccessible</td></tr>'
|
||||
'<tr><td><code>create_note(parent_id, title, content=" ", note_type="text")</code></td><td>content=" " obligatoire — pas vide</td></tr>'
|
||||
'<tr><td><code>get_note_id(result)</code></td><td>Extrait noteId du resultat create_note</td></tr>'
|
||||
'<tr><td><code>get_note(note_id)</code></td><td>Recupere une note par ID</td></tr>'
|
||||
'<tr><td><code>get_note_content(note_id)</code></td><td>Recupere le contenu HTML</td></tr>'
|
||||
'<tr><td><code>update_note_content(note_id, content)</code></td><td>Met a jour le contenu</td></tr>'
|
||||
'<tr><td><code>search_notes(query, limit=50)</code></td><td>Recherche SANS guillemets autour du terme</td></tr>'
|
||||
'<tr><td><code>search_by_label(label, value="")</code></td><td>Syntaxe : #label=value</td></tr>'
|
||||
'<tr><td><code>find_note_by_title(title, parent_id="")</code></td><td>Retourne noteId ou None</td></tr>'
|
||||
'<tr><td><code>set_label(note_id, name, value="")</code></td><td>isInheritable=False requis en interne</td></tr>'
|
||||
'<tr><td><code>get_label_value(note_id, name)</code></td><td>Retourne valeur label ou None</td></tr>'
|
||||
'</table>'
|
||||
'<h2>Erreurs connues et fixes</h2>'
|
||||
'<table>'
|
||||
'<tr><th>Erreur</th><th>Cause</th><th>Fix</th></tr>'
|
||||
'<tr><td>Router not found POST /etapi/notes</td><td>TriliumNext 0.95 routing change</td><td>Utiliser trilium-py</td></tr>'
|
||||
'<tr><td>missing argument isInheritable</td><td>create_attribute() trilium-py</td><td>Passer isInheritable=False</td></tr>'
|
||||
'<tr><td>Note content must be set</td><td>content vide refuse</td><td>Passer content=" "</td></tr>'
|
||||
'<tr><td>Recherche avec guillemets retourne []</td><td>Parser 0.95</td><td>Chercher sans guillemets</td></tr>'
|
||||
'<tr><td>{status,code,message} sur create</td><td>Note existe deja ou contenu vide</td><td>find_note_by_title avant + content=" "</td></tr>'
|
||||
'</table>'
|
||||
'<h2>Patterns valides</h2>'
|
||||
'<h3>Creer une note</h3>'
|
||||
'<pre>result = create_note(parent_id, "Titre", note_type="text")\nnote_id = get_note_id(result)</pre>'
|
||||
'<h3>Rechercher par label</h3>'
|
||||
'<pre>notes = search_by_label("type", "backlogItem")\nnotes = search_by_label("projet", "SlidingAutomation")</pre>'
|
||||
'<h3>Ajouter un attribut</h3>'
|
||||
'<pre>set_label(note_id, "statut", "actif")</pre>'
|
||||
'<h3>Workflow bascule LLM</h3>'
|
||||
'<pre>'
|
||||
'python trilium_context.py new-conversation --projet SlidingAutomation --llm "Claude Sonnet" --titre "Ma session"\n'
|
||||
'# Travailler... puis quand limite approche :\n'
|
||||
'python trilium_context.py close-session --note-id ID --summary "..."\n'
|
||||
'python trilium_context.py generate-context --projet SlidingAutomation --llm-cible "Le Chat Large" --note-id ID --version 2\n'
|
||||
'# Copier le briefing imprime dans la nouvelle session LLM'
|
||||
'</pre>'
|
||||
'<h2>IDs importants</h2>'
|
||||
'<p>Stockes dans <code>trilium_ids.json</code> apres <code>python trilium_init.py</code><br>'
|
||||
'Cles : root, Projets, Conversations, Backlog, Decisions, Historique, Glossaire, ContextesReprise</p>'
|
||||
'<h2>Recuperer ce skill en contexte LLM</h2>'
|
||||
'<pre>python3 -c "\nfrom trilium_api import find_note_by_title, get_note_content\nnid = find_note_by_title(\'Skill - API Trilium ETAPI\')\nprint(get_note_content(nid))\n"</pre>'
|
||||
)
|
||||
|
||||
update_note_content(nid, content)
|
||||
print('Skill cree et rempli:', nid)
|
||||
@@ -0,0 +1,170 @@
|
||||
import json, os
|
||||
from trilium_api import create_note, get_note_id, set_label, find_note_by_title, update_note_content
|
||||
|
||||
with open(os.path.expanduser('~/App/Context_continuity/trilium_ids.json')) as f:
|
||||
ids = json.load(f)
|
||||
|
||||
skills_id = find_note_by_title('Skills', ids['root'])
|
||||
if not skills_id:
|
||||
res = create_note(ids['root'], 'Skills', note_type='book')
|
||||
skills_id = get_note_id(res)
|
||||
set_label(skills_id, 'type', 'container')
|
||||
|
||||
res = create_note(skills_id, 'Skill - Structure Context Continuity')
|
||||
nid = get_note_id(res)
|
||||
set_label(nid, 'type', 'skill')
|
||||
set_label(nid, 'domaine', 'context-continuity')
|
||||
set_label(nid, 'version', '1.0')
|
||||
|
||||
content = (
|
||||
'<h1>Skill - Structure Context Continuity dans Trilium</h1>'
|
||||
'<p>Ce skill decrit comment un LLM doit interagir avec la base Trilium '
|
||||
'du systeme Context Continuity. A lire avant toute session de travail '
|
||||
'sur un projet suivi.</p>'
|
||||
|
||||
'<h2>Architecture generale</h2>'
|
||||
'<p>Trilium est organise sous une note racine <strong>Context Continuity</strong> '
|
||||
'avec 8 sous-dossiers :</p>'
|
||||
'<table>'
|
||||
'<tr><th>Dossier</th><th>Contenu</th><th>Cle trilium_ids.json</th></tr>'
|
||||
'<tr><td>Projets</td><td>Une note par projet suivi</td><td>Projets</td></tr>'
|
||||
'<tr><td>Conversations</td><td>Une note par session LLM</td><td>Conversations</td></tr>'
|
||||
'<tr><td>Backlog</td><td>Taches a developper</td><td>Backlog</td></tr>'
|
||||
'<tr><td>Decisions</td><td>Decisions actives du projet</td><td>Decisions</td></tr>'
|
||||
'<tr><td>Historique</td><td>Faits etablis, tests effectues, contraintes</td><td>Historique</td></tr>'
|
||||
'<tr><td>Glossaire</td><td>Vocabulaire specifique au projet</td><td>Glossaire</td></tr>'
|
||||
'<tr><td>Contextes Reprise</td><td>Briefings generes pour bascule LLM</td><td>ContextesReprise</td></tr>'
|
||||
'<tr><td>Skills</td><td>Documentation technique pour LLMs</td><td>Skills</td></tr>'
|
||||
'</table>'
|
||||
|
||||
'<h2>Types de notes et leurs labels obligatoires</h2>'
|
||||
|
||||
'<h3>Note Projet</h3>'
|
||||
'<table>'
|
||||
'<tr><th>Label</th><th>Valeurs possibles</th><th>Obligatoire</th></tr>'
|
||||
'<tr><td>type</td><td>projet</td><td>oui</td></tr>'
|
||||
'<tr><td>projet</td><td>NomSansEspaces ex: SlidingAutomation</td><td>oui</td></tr>'
|
||||
'<tr><td>statut</td><td>actif | en-pause | archive</td><td>oui</td></tr>'
|
||||
'</table>'
|
||||
'<p>Contenu HTML : objectif, stack technique, repartition LLMs.</p>'
|
||||
|
||||
'<h3>Note Conversation</h3>'
|
||||
'<table>'
|
||||
'<tr><th>Label</th><th>Valeurs possibles</th><th>Obligatoire</th></tr>'
|
||||
'<tr><td>type</td><td>conversation</td><td>oui</td></tr>'
|
||||
'<tr><td>projet</td><td>NomSansEspaces</td><td>oui</td></tr>'
|
||||
'<tr><td>llm</td><td>Claude Sonnet | Claude Opus | Le Chat Large | Le Chat Medium</td><td>oui</td></tr>'
|
||||
'<tr><td>statut</td><td>en-cours | clos</td><td>oui</td></tr>'
|
||||
'<tr><td>date</td><td>YYYY-MM-DD HH:MM</td><td>oui</td></tr>'
|
||||
'<tr><td>syntheseCloture</td><td>texte libre 200 mots max</td><td>non - rempli a la cloture</td></tr>'
|
||||
'</table>'
|
||||
'<p>Creee via : <code>python trilium_context.py new-conversation</code></p>'
|
||||
|
||||
'<h3>Note Backlog Item</h3>'
|
||||
'<table>'
|
||||
'<tr><th>Label</th><th>Valeurs possibles</th><th>Obligatoire</th></tr>'
|
||||
'<tr><td>type</td><td>backlogItem</td><td>oui</td></tr>'
|
||||
'<tr><td>projet</td><td>NomSansEspaces</td><td>oui</td></tr>'
|
||||
'<tr><td>statut</td><td>a faire | en cours | bloque | fait | abandonne</td><td>oui</td></tr>'
|
||||
'<tr><td>priorite</td><td>haute | moyenne | basse</td><td>oui</td></tr>'
|
||||
'</table>'
|
||||
'<p>Creee via : <code>python trilium_context.py add-backlog</code> (a implementer) '
|
||||
'ou manuellement dans Trilium.<br>'
|
||||
'<strong>ATTENTION</strong> : list-backlog filtre sur statut != "fait" et != "abandonne". '
|
||||
'Utiliser exactement ces valeurs.</p>'
|
||||
|
||||
'<h3>Note Decision</h3>'
|
||||
'<table>'
|
||||
'<tr><th>Label</th><th>Valeurs possibles</th><th>Obligatoire</th></tr>'
|
||||
'<tr><td>type</td><td>decision</td><td>oui</td></tr>'
|
||||
'<tr><td>projet</td><td>NomSansEspaces</td><td>oui</td></tr>'
|
||||
'<tr><td>statut</td><td>active | revisee | annulee</td><td>oui</td></tr>'
|
||||
'<tr><td>llm</td><td>nom du LLM ayant aide</td><td>non</td></tr>'
|
||||
'</table>'
|
||||
'<p>Creee via : <code>python trilium_context.py add-decision --projet X --enonce "..." --justification "..."</code><br>'
|
||||
'<strong>generate-context inclut uniquement les decisions avec statut=active.</strong><br>'
|
||||
'Pour annuler une decision, changer son label statut en "annulee" dans Trilium.</p>'
|
||||
|
||||
'<h3>Note Historique</h3>'
|
||||
'<table>'
|
||||
'<tr><th>Label</th><th>Valeurs possibles</th><th>Obligatoire</th></tr>'
|
||||
'<tr><td>type</td><td>historiqueItem</td><td>oui</td></tr>'
|
||||
'<tr><td>projet</td><td>NomSansEspaces</td><td>oui</td></tr>'
|
||||
'<tr><td>typeHistorique</td><td>Fait etabli | Test effectue | Hypothese invalidee | Contrainte decouverte</td><td>oui</td></tr>'
|
||||
'<tr><td>encoreValide</td><td>true | false</td><td>oui</td></tr>'
|
||||
'</table>'
|
||||
'<p>Creee via : <code>python trilium_context.py add-history --projet X --type "..." --enonce "..." --detail "..."</code><br>'
|
||||
'<strong>generate-context inclut uniquement les entrees avec encoreValide=true.</strong><br>'
|
||||
'Pour invalider une entree obsolete, changer encoreValide en "false" dans Trilium.</p>'
|
||||
|
||||
'<h3>Note Terme Glossaire</h3>'
|
||||
'<table>'
|
||||
'<tr><th>Label</th><th>Valeurs possibles</th><th>Obligatoire</th></tr>'
|
||||
'<tr><td>type</td><td>termeGlossaire</td><td>oui</td></tr>'
|
||||
'<tr><td>projet</td><td>NomSansEspaces</td><td>oui</td></tr>'
|
||||
'<tr><td>definition</td><td>texte court - definition operationnelle</td><td>oui</td></tr>'
|
||||
'</table>'
|
||||
'<p><strong>ATTENTION</strong> : le label <code>definition</code> est lu par generate-context '
|
||||
'pour afficher le glossaire dans le briefing. Sans ce label, la note affiche "(voir note)".<br>'
|
||||
'Creer manuellement dans Trilium ou via script avec set_label(nid, "definition", "...").</p>'
|
||||
|
||||
'<h3>Note Contexte Reprise</h3>'
|
||||
'<table>'
|
||||
'<tr><th>Label</th><th>Valeurs possibles</th><th>Obligatoire</th></tr>'
|
||||
'<tr><td>type</td><td>contexteReprise</td><td>oui</td></tr>'
|
||||
'<tr><td>projet</td><td>NomSansEspaces</td><td>oui</td></tr>'
|
||||
'<tr><td>llmCible</td><td>nom du LLM destinataire</td><td>oui</td></tr>'
|
||||
'<tr><td>version</td><td>entier incremente a chaque reprise</td><td>oui</td></tr>'
|
||||
'<tr><td>tokens</td><td>estimation tokens du briefing</td><td>non</td></tr>'
|
||||
'<tr><td>conversationSource</td><td>noteId de la conversation clôturee</td><td>non</td></tr>'
|
||||
'</table>'
|
||||
'<p>Creee automatiquement par generate-context. Ne pas creer manuellement.</p>'
|
||||
|
||||
'<h2>Ce qu un LLM doit faire pendant une session</h2>'
|
||||
'<h3>Capitaliser une decision</h3>'
|
||||
'<p>Des qu une decision technique ou architecturale est prise, demander a l utilisateur :</p>'
|
||||
'<pre>Lance cette commande pour enregistrer la decision :\n'
|
||||
'python trilium_context.py add-decision \\\n'
|
||||
' --projet NomProjet \\\n'
|
||||
' --enonce "Enonce court et actionnable" \\\n'
|
||||
' --justification "Pourquoi cette decision"</pre>'
|
||||
|
||||
'<h3>Capitaliser un test ou une contrainte</h3>'
|
||||
'<p>Des qu un test est effectue ou une contrainte decouverte :</p>'
|
||||
'<pre>python trilium_context.py add-history \\\n'
|
||||
' --projet NomProjet \\\n'
|
||||
' --type "Test effectue" \\\n'
|
||||
' --enonce "Ce qui a ete teste en une ligne" \\\n'
|
||||
' --detail "Resultat observe"</pre>'
|
||||
|
||||
'<h3>Generer la synthese de cloture</h3>'
|
||||
'<p>Quand l utilisateur signale que la limite approche, produire :</p>'
|
||||
'<pre>Synthese de cloture :\n'
|
||||
'- Objectif de la session : [ce qu on voulait faire]\n'
|
||||
'- Accompli : [ce qui a ete fait]\n'
|
||||
'- En cours : [ce qui est a moitie fait]\n'
|
||||
'- Bloque : [blocages eventuels]\n'
|
||||
'- Prochaine action : [premiere chose a faire a la reprise]</pre>'
|
||||
'<p>Format concis, 150-200 mots max, sans markdown complexe '
|
||||
'(sera colle en argument --summary).</p>'
|
||||
|
||||
'<h2>Erreurs frequentes a eviter</h2>'
|
||||
'<table>'
|
||||
'<tr><th>Erreur</th><th>Consequence</th><th>Fix</th></tr>'
|
||||
'<tr><td>Valeur statut avec majuscule ex: "Actif"</td><td>Filtre ne trouve pas la note</td><td>Toujours minuscules : "actif"</td></tr>'
|
||||
'<tr><td>Nom projet avec espaces ex: "Sliding Automation"</td><td>search_by_label echoue</td><td>Sans espaces : "SlidingAutomation"</td></tr>'
|
||||
'<tr><td>Label definition absent sur termeGlossaire</td><td>Briefing affiche "(voir note)"</td><td>Ajouter set_label(nid, "definition", "...")</td></tr>'
|
||||
'<tr><td>encoreValide="True" avec majuscule</td><td>Filtre cherche "true" et ne trouve pas</td><td>Toujours "true" ou "false" minuscules</td></tr>'
|
||||
'<tr><td>typeHistorique hors liste</td><td>Affichage incoherent dans briefing</td><td>Utiliser exactement : Fait etabli | Test effectue | Hypothese invalidee | Contrainte decouverte</td></tr>'
|
||||
'</table>'
|
||||
|
||||
'<h2>Commande pour recuperer ce skill</h2>'
|
||||
'<pre>python3 -c "\n'
|
||||
'from trilium_api import find_note_by_title, get_note_content\n'
|
||||
'nid = find_note_by_title(\'Skill - Structure Context Continuity\')\n'
|
||||
'print(get_note_content(nid))\n'
|
||||
'"</pre>'
|
||||
)
|
||||
|
||||
update_note_content(nid, content)
|
||||
print('Skill structure cree:', nid)
|
||||
+819
@@ -0,0 +1,819 @@
|
||||
"""
|
||||
mcp_server.py — Serveur MCP pur Starlette (compatible Python 3.9)
|
||||
Implémente le protocole MCP (JSON-RPC 2.0) sans FastMCP.
|
||||
|
||||
Transport : Streamable HTTP sur /mcp
|
||||
Auth : Bearer token (clés API depuis .env)
|
||||
|
||||
Usage : python mcp_server.py → écoute sur 127.0.0.1:8766
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import secrets
|
||||
import hashlib
|
||||
import base64
|
||||
import time
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response, HTMLResponse, RedirectResponse
|
||||
|
||||
from trilium_api import (
|
||||
create_note, get_note_id, get_note, get_note_content, get_children,
|
||||
update_note_content, search_by_label, set_label, get_label_value,
|
||||
find_note_by_title, delete_note_safe, move_note_safe, add_relation_safe,
|
||||
)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
API_KEY_CLAUDE = os.getenv("API_KEY_CLAUDE", "")
|
||||
API_KEY_LECHAT = os.getenv("API_KEY_LECHAT", "")
|
||||
VALID_TOKENS = {API_KEY_CLAUDE: "Claude", API_KEY_LECHAT: "LeChat"}
|
||||
|
||||
IDS_FILE = os.path.expanduser("~/App/Context_continuity/trilium_ids.json")
|
||||
PROTOCOL_VERSION = "2025-06-18"
|
||||
SERVER_INFO = {"name": "Context Continuity Trilium", "version": "1.0.0"}
|
||||
|
||||
# --- Config OAuth 2.1 (Couche 1) ---
|
||||
OAUTH_BASE_URL = os.getenv("OAUTH_BASE_URL", "https://mcp-trilium.bertha-cloud.fr")
|
||||
SECURITY_WORD = os.getenv("SECURITY_WORD", "")
|
||||
OAUTH_STATE_FILE = os.path.expanduser("~/App/Context_continuity/oauth_state.json")
|
||||
|
||||
def load_oauth_state():
|
||||
with open(OAUTH_STATE_FILE) as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_oauth_state(state):
|
||||
with open(OAUTH_STATE_FILE, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
def load_ids():
|
||||
with open(IDS_FILE) as f:
|
||||
return json.load(f)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Définition des tools (schema JSON pour Le Chat)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "get_backlog",
|
||||
"description": "Liste les items de backlog actifs d'un projet (hors fait/abandonne), tries par priorite.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"projet": {"type": "string", "description": "Nom du projet"}},
|
||||
"required": ["projet"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_decisions",
|
||||
"description": "Liste les decisions actives d'un projet.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"projet": {"type": "string"}},
|
||||
"required": ["projet"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_historique",
|
||||
"description": "Liste les entrees d'historique encore valides (faits, tests, contraintes).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"projet": {"type": "string"}},
|
||||
"required": ["projet"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_glossaire",
|
||||
"description": "Liste les termes du glossaire d'un projet avec leur definition.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"projet": {"type": "string"}},
|
||||
"required": ["projet"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_contexte",
|
||||
"description": "Genere le briefing de reprise complet d'un projet, pret a reprendre le travail.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"projet": {"type": "string"},
|
||||
"llm_cible": {"type": "string", "description": "Nom du LLM cible"},
|
||||
},
|
||||
"required": ["projet"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "add_decision",
|
||||
"description": "Enregistre une nouvelle decision active dans le projet.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"projet": {"type": "string"},
|
||||
"enonce": {"type": "string"},
|
||||
"justification": {"type": "string"},
|
||||
},
|
||||
"required": ["projet", "enonce"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "add_history",
|
||||
"description": "Enregistre une entree d'historique. type_historique : Fait etabli, Test effectue, Hypothese invalidee, Contrainte decouverte.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"projet": {"type": "string"},
|
||||
"enonce": {"type": "string"},
|
||||
"type_historique": {"type": "string"},
|
||||
"detail": {"type": "string"},
|
||||
},
|
||||
"required": ["projet", "enonce", "type_historique"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "delete_note",
|
||||
"description": "Supprime une note du systeme (backlog, decision, etc.) par son id. Garde-fou : refuse les notes hors systeme.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"note_id": {"type": "string"}},
|
||||
"required": ["note_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "add_backlog",
|
||||
"description": "Ajoute un item au backlog. priorite : haute, moyenne ou basse.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"projet": {"type": "string"},
|
||||
"titre": {"type": "string"},
|
||||
"priorite": {"type": "string"},
|
||||
},
|
||||
"required": ["projet", "titre"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "update_backlog",
|
||||
"description": "Met a jour le statut d'un item backlog. statut : a faire, en cours, bloque, fait, abandonne.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"note_id": {"type": "string"},
|
||||
"statut": {"type": "string"},
|
||||
},
|
||||
"required": ["note_id", "statut"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "new_conversation",
|
||||
"description": "Cree une conversation. llm : Claude Sonnet, Claude Opus, Le Chat Large, Le Chat Medium.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"projet": {"type": "string"},
|
||||
"titre": {"type": "string"},
|
||||
"llm": {"type": "string"},
|
||||
},
|
||||
"required": ["projet", "titre", "llm"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "close_session",
|
||||
"description": "Cloture une conversation en enregistrant sa synthese.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"note_id": {"type": "string"},
|
||||
"synthese": {"type": "string"},
|
||||
},
|
||||
"required": ["note_id", "synthese"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_note",
|
||||
"description": "Lit le contenu d une note du systeme par son id (titre, type, contenu HTML). Garde-fou : refuse les notes hors types systeme.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"note_id": {"type": "string"}},
|
||||
"required": ["note_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_children",
|
||||
"description": "Liste les notes filles d un dossier (id, titre, type). Ne retourne que les notes de type systeme.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"parent_id": {"type": "string"}},
|
||||
"required": ["parent_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_skills",
|
||||
"description": "Liste les skills disponibles. Sans projet, retourne aussi les skills universels.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"projet": {"type": "string", "description": "Optionnel — filtre par projet"},
|
||||
"portee": {"type": "string", "description": "Optionnel — universel | reference-technique | projet | ..."},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "update_note",
|
||||
"description": "Met a jour le contenu d une note existante (skill, decision, historique, doc...). format=markdown enveloppe en <pre> (texte redige) ; format=html ecrit le HTML tel quel (notes structurees). Garde-fou : refuse les notes hors types systeme.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"note_id": {"type": "string"},
|
||||
"contenu": {"type": "string"},
|
||||
"format": {"type": "string", "description": "markdown | html (defaut: markdown)"},
|
||||
},
|
||||
"required": ["note_id", "contenu"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "add_relation",
|
||||
"description": "Cree une relation typee (object property) entre deux notes : source ~nom cible. nom doit etre dans l ontologie (impacte, revise, documentePar, contraintPar, concerneProjet, provient, illustre, reference...). Affichee dans la Note Map.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source_id": {"type": "string"},
|
||||
"nom": {"type": "string"},
|
||||
"cible_id": {"type": "string"},
|
||||
},
|
||||
"required": ["source_id", "nom", "cible_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "move_note",
|
||||
"description": "Deplace une note d un parent vers un autre (3 args : note_id, ancien_parent_id, nouveau_parent_id). Gere les clones.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"note_id": {"type": "string"},
|
||||
"ancien_parent_id": {"type": "string"},
|
||||
"nouveau_parent_id": {"type": "string"},
|
||||
},
|
||||
"required": ["note_id", "ancien_parent_id", "nouveau_parent_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "add_skill",
|
||||
"description": "Enregistre un nouveau skill (savoir-faire reutilisable). portee='universel' si non lie a un projet precis.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"titre": {"type": "string"},
|
||||
"contenu": {"type": "string"},
|
||||
"portee": {"type": "string", "description": "universel | reference-technique | projet | ... (defaut: projet)"},
|
||||
"projet": {"type": "string", "description": "Requis sauf si portee='universel'"},
|
||||
},
|
||||
"required": ["titre", "contenu"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Implémentation des tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def tool_get_backlog(projet):
|
||||
notes = search_by_label("type", "backlogItem")
|
||||
actifs = [n for n in notes
|
||||
if get_label_value(n["noteId"], "projet") == projet
|
||||
and get_label_value(n["noteId"], "statut") not in ("fait", "abandonne")]
|
||||
prio = {"haute": 0, "moyenne": 1, "basse": 2}
|
||||
actifs.sort(key=lambda n: prio.get(get_label_value(n["noteId"], "priorite") or "basse", 99))
|
||||
return [{"id": n["noteId"], "titre": n.get("title", ""),
|
||||
"priorite": get_label_value(n["noteId"], "priorite"),
|
||||
"statut": get_label_value(n["noteId"], "statut")} for n in actifs]
|
||||
|
||||
def tool_get_decisions(projet):
|
||||
notes = search_by_label("type", "decision")
|
||||
return [{"id": n["noteId"], "enonce": n.get("title", "")} for n in notes
|
||||
if get_label_value(n["noteId"], "projet") == projet
|
||||
and get_label_value(n["noteId"], "statut") == "active"]
|
||||
|
||||
def tool_get_historique(projet):
|
||||
notes = search_by_label("type", "historiqueItem")
|
||||
return [{"id": n["noteId"], "enonce": n.get("title", ""),
|
||||
"type": get_label_value(n["noteId"], "typeHistorique")} for n in notes
|
||||
if get_label_value(n["noteId"], "projet") == projet
|
||||
and get_label_value(n["noteId"], "encoreValide") == "true"]
|
||||
|
||||
def tool_get_glossaire(projet):
|
||||
notes = search_by_label("type", "termeGlossaire")
|
||||
return [{"id": n["noteId"], "terme": n.get("title", ""),
|
||||
"definition": get_label_value(n["noteId"], "definition") or ""} for n in notes
|
||||
if get_label_value(n["noteId"], "projet") == projet]
|
||||
|
||||
def tool_get_contexte(projet, llm_cible="LLM"):
|
||||
decisions = [n for n in search_by_label("type", "decision")
|
||||
if get_label_value(n["noteId"], "projet") == projet
|
||||
and get_label_value(n["noteId"], "statut") == "active"]
|
||||
historique = [n for n in search_by_label("type", "historiqueItem")
|
||||
if get_label_value(n["noteId"], "projet") == projet
|
||||
and get_label_value(n["noteId"], "encoreValide") == "true"]
|
||||
backlog = [n for n in search_by_label("type", "backlogItem")
|
||||
if get_label_value(n["noteId"], "projet") == projet
|
||||
and get_label_value(n["noteId"], "statut") not in ("fait", "abandonne")]
|
||||
glossaire = [n for n in search_by_label("type", "termeGlossaire")
|
||||
if get_label_value(n["noteId"], "projet") == projet]
|
||||
date = datetime.now().strftime("%d/%m/%Y")
|
||||
lines = [f"# REPRISE DE CONTEXTE — {projet} — {date}",
|
||||
f"**LLM cible : {llm_cible}**", ""]
|
||||
if decisions:
|
||||
lines += ["## Décisions actives (ne pas remettre en question)"]
|
||||
lines += [f"- {d.get('title','?')}" for d in decisions] + [""]
|
||||
if historique:
|
||||
lines += ["## Historique — déjà testé / établi"]
|
||||
for h in historique:
|
||||
t = get_label_value(h["noteId"], "typeHistorique") or ""
|
||||
lines.append(f"- [{t}] {h.get('title','?')}")
|
||||
lines.append("")
|
||||
if glossaire:
|
||||
lines += ["## Glossaire"]
|
||||
for g in glossaire:
|
||||
d = get_label_value(g["noteId"], "definition") or "(voir note)"
|
||||
lines.append(f"- **{g.get('title','?')}** : {d}")
|
||||
lines.append("")
|
||||
if backlog:
|
||||
lines += ["## Backlog actif"]
|
||||
prio = {"haute": 0, "moyenne": 1, "basse": 2}
|
||||
backlog.sort(key=lambda b: prio.get(get_label_value(b["noteId"], "priorite") or "basse", 99))
|
||||
for b in backlog:
|
||||
p = get_label_value(b["noteId"], "priorite") or "?"
|
||||
s = get_label_value(b["noteId"], "statut") or "?"
|
||||
lines.append(f"- [{s}] [{p}] {b.get('title','?')}")
|
||||
lines.append("")
|
||||
lines += ["---", "Confirme en 3 lignes : (a) objectif (b) prochaine action (c) incertitude"]
|
||||
return "\n".join(lines)
|
||||
|
||||
def tool_add_decision(projet, enonce, justification=""):
|
||||
ids = load_ids()
|
||||
res = create_note(ids["Decisions"], enonce,
|
||||
content=f"<p><b>Justification</b> : {justification or '-'}</p>")
|
||||
nid = get_note_id(res)
|
||||
set_label(nid, "type", "decision"); set_label(nid, "projet", projet)
|
||||
set_label(nid, "statut", "active")
|
||||
return {"id": nid, "enonce": enonce, "status": "cree"}
|
||||
|
||||
def tool_add_history(projet, enonce, type_historique, detail=""):
|
||||
valides = ["Fait etabli", "Test effectue", "Hypothese invalidee", "Contrainte decouverte"]
|
||||
if type_historique not in valides:
|
||||
return {"error": f"type_historique invalide. Valeurs : {valides}"}
|
||||
ids = load_ids()
|
||||
res = create_note(ids["Historique"], enonce,
|
||||
content=f"<p><b>Type</b> : {type_historique}</p><p><b>Détail</b> : {detail or '-'}</p>")
|
||||
nid = get_note_id(res)
|
||||
set_label(nid, "type", "historiqueItem"); set_label(nid, "projet", projet)
|
||||
set_label(nid, "typeHistorique", type_historique); set_label(nid, "encoreValide", "true")
|
||||
return {"id": nid, "enonce": enonce, "status": "cree"}
|
||||
|
||||
def tool_add_backlog(projet, titre, priorite="moyenne"):
|
||||
if priorite not in ("haute", "moyenne", "basse"):
|
||||
return {"error": "priorite doit etre haute, moyenne ou basse"}
|
||||
ids = load_ids()
|
||||
res = create_note(ids["Backlog"], titre)
|
||||
nid = get_note_id(res)
|
||||
set_label(nid, "type", "backlogItem"); set_label(nid, "projet", projet)
|
||||
set_label(nid, "priorite", priorite); set_label(nid, "statut", "a faire")
|
||||
return {"id": nid, "titre": titre, "status": "cree"}
|
||||
|
||||
def tool_update_backlog(note_id, statut):
|
||||
valides = ["a faire", "en cours", "bloque", "fait", "abandonne"]
|
||||
if statut not in valides:
|
||||
return {"error": f"statut invalide. Valeurs : {valides}"}
|
||||
try:
|
||||
get_note(note_id)
|
||||
except Exception:
|
||||
return {"error": "Backlog item introuvable"}
|
||||
set_label(note_id, "statut", statut)
|
||||
return {"id": note_id, "statut": statut, "status": "mis a jour"}
|
||||
|
||||
def tool_new_conversation(projet, titre, llm):
|
||||
ids = load_ids()
|
||||
date = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
full = f"[{llm}] {date} — {titre}"
|
||||
content = (f"<h2>{full}</h2><table>"
|
||||
f"<tr><td><b>Projet</b></td><td>{projet}</td></tr>"
|
||||
f"<tr><td><b>LLM</b></td><td>{llm}</td></tr>"
|
||||
f"<tr><td><b>Date</b></td><td>{date}</td></tr>"
|
||||
f"<tr><td><b>Synthèse de clôture</b></td><td><i>À remplir en fin de session</i></td></tr></table>")
|
||||
res = create_note(ids["Conversations"], full, content=content)
|
||||
nid = get_note_id(res)
|
||||
set_label(nid, "type", "conversation"); set_label(nid, "projet", projet)
|
||||
set_label(nid, "llm", llm); set_label(nid, "statut", "en-cours"); set_label(nid, "date", date)
|
||||
return {"id": nid, "titre": full, "status": "cree"}
|
||||
|
||||
def tool_close_session(note_id, synthese):
|
||||
try:
|
||||
content = get_note_content(note_id)
|
||||
except Exception:
|
||||
return {"error": "Conversation introuvable"}
|
||||
safe = synthese.replace("<", "<").replace(">", ">")
|
||||
if "À remplir en fin de session" in content:
|
||||
content = content.replace("<i>À remplir en fin de session</i>", safe)
|
||||
else:
|
||||
content += f"<h3>Synthèse</h3><p>{safe}</p>"
|
||||
update_note_content(note_id, content)
|
||||
set_label(note_id, "syntheseCloture", synthese[:200]); set_label(note_id, "statut", "clos")
|
||||
return {"id": note_id, "status": "cloture"}
|
||||
|
||||
def tool_get_skills(projet=None, portee=None):
|
||||
notes = search_by_label("type", "skill")
|
||||
result = []
|
||||
for n in notes:
|
||||
nid = n["noteId"]
|
||||
note_projet = get_label_value(nid, "projet")
|
||||
note_portee = get_label_value(nid, "portee")
|
||||
if projet and note_projet != projet and note_portee != "universel":
|
||||
continue
|
||||
if portee and note_portee != portee:
|
||||
continue
|
||||
result.append({
|
||||
"id": nid, "titre": n.get("title", ""),
|
||||
"portee": note_portee or "", "projet": note_projet or "",
|
||||
"contenu": get_note_content(nid),
|
||||
})
|
||||
return result
|
||||
|
||||
def _type_systeme(note):
|
||||
for a in note.get("attributes", []):
|
||||
if a.get("type") == "label" and a.get("name") == "type":
|
||||
return a.get("value")
|
||||
return None
|
||||
|
||||
def tool_get_note(note_id):
|
||||
from trilium_api import TYPES_SYSTEME, get_note_content
|
||||
note = get_note(note_id)
|
||||
if not note:
|
||||
return {"error": "Note introuvable"}
|
||||
tv = _type_systeme(note)
|
||||
if tv not in TYPES_SYSTEME:
|
||||
return {"error": "Refus : note hors type systeme (type=%s)" % tv}
|
||||
return {"id": note_id, "titre": note.get("title", ""), "type": tv,
|
||||
"contenu": get_note_content(note_id)}
|
||||
|
||||
def tool_get_children(parent_id):
|
||||
from trilium_api import TYPES_SYSTEME
|
||||
out = []
|
||||
for child in get_children(parent_id):
|
||||
cid = child.get("noteId")
|
||||
n = get_note(cid)
|
||||
if not n:
|
||||
continue
|
||||
tv = _type_systeme(n)
|
||||
if tv in TYPES_SYSTEME:
|
||||
out.append({"id": cid, "titre": n.get("title", ""), "type": tv})
|
||||
return {"parent": parent_id, "count": len(out), "notes": out}
|
||||
|
||||
def tool_update_note(note_id, contenu, format="markdown"):
|
||||
from trilium_api import TYPES_SYSTEME
|
||||
note = get_note(note_id)
|
||||
if not note:
|
||||
return {"error": "Note introuvable"}
|
||||
type_val = None
|
||||
for a in note.get("attributes", []):
|
||||
if a.get("type") == "label" and a.get("name") == "type":
|
||||
type_val = a.get("value")
|
||||
break
|
||||
if type_val not in TYPES_SYSTEME:
|
||||
return {"error": "Refus : note sans type systeme connu (type=%s)" % type_val}
|
||||
if format == "html":
|
||||
body = contenu
|
||||
else:
|
||||
safe = contenu.replace("<", "<").replace(">", ">")
|
||||
body = "<pre>" + safe + "</pre>"
|
||||
update_note_content(note_id, body)
|
||||
return {"id": note_id, "titre": note.get("title", ""), "format": format, "status": "mis a jour"}
|
||||
|
||||
def tool_add_relation(source_id, nom, cible_id):
|
||||
ok, message = add_relation_safe(source_id, nom, cible_id)
|
||||
return {"success": ok, "message": message}
|
||||
|
||||
def tool_move_note(note_id, ancien_parent_id, nouveau_parent_id):
|
||||
ok, message = move_note_safe(note_id, ancien_parent_id, nouveau_parent_id)
|
||||
return {"success": ok, "message": message}
|
||||
|
||||
def tool_add_skill(titre, contenu, portee="projet", projet=None):
|
||||
if portee != "universel" and not projet:
|
||||
return {"error": "projet requis sauf si portee='universel'"}
|
||||
ids = load_ids()
|
||||
existant = find_note_by_title(titre, ids["Skills"])
|
||||
if existant:
|
||||
return {"error": f"Un skill '{titre}' existe déjà", "id": existant}
|
||||
safe = contenu.replace("<", "<").replace(">", ">")
|
||||
res = create_note(ids["Skills"], titre, content=f"<pre>{safe}</pre>")
|
||||
nid = get_note_id(res)
|
||||
set_label(nid, "type", "skill")
|
||||
set_label(nid, "portee", portee)
|
||||
if projet:
|
||||
set_label(nid, "projet", projet)
|
||||
return {"id": nid, "titre": titre, "status": "cree"}
|
||||
|
||||
def tool_delete_note(note_id):
|
||||
ok, message = delete_note_safe(note_id)
|
||||
return {"success": ok, "message": message}
|
||||
|
||||
DISPATCH = {
|
||||
"get_backlog": tool_get_backlog, "get_decisions": tool_get_decisions,
|
||||
"get_historique": tool_get_historique, "get_glossaire": tool_get_glossaire,
|
||||
"get_contexte": tool_get_contexte, "add_decision": tool_add_decision,
|
||||
"add_history": tool_add_history, "add_backlog": tool_add_backlog,
|
||||
"delete_note": tool_delete_note,
|
||||
"update_backlog": tool_update_backlog, "new_conversation": tool_new_conversation,
|
||||
"close_session": tool_close_session, "get_skills": tool_get_skills,
|
||||
"add_skill": tool_add_skill,
|
||||
"move_note": tool_move_note,
|
||||
"add_relation": tool_add_relation,
|
||||
"update_note": tool_update_note,
|
||||
"get_note": tool_get_note, "get_children": tool_get_children,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handler JSON-RPC MCP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_auth(request: Request):
|
||||
auth = request.headers.get("authorization", "")
|
||||
token = auth.replace("Bearer ", "").strip()
|
||||
# 1) Tokens statiques (Le Chat, cle Claude historique)
|
||||
if token in VALID_TOKENS:
|
||||
return VALID_TOKENS[token]
|
||||
# 2) Tokens OAuth emis (Claude Desktop)
|
||||
if token.startswith("at_"):
|
||||
oauth = load_oauth_state()
|
||||
meta = oauth["tokens"].get(token)
|
||||
if meta:
|
||||
return "Claude-OAuth"
|
||||
return None
|
||||
|
||||
async def mcp_endpoint(request: Request):
|
||||
# Auth
|
||||
llm = check_auth(request)
|
||||
if not llm:
|
||||
return JSONResponse(
|
||||
{"error": "invalid_token", "error_description": "Authentication required"},
|
||||
status_code=401,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
body = await request.json()
|
||||
method = body.get("method")
|
||||
req_id = body.get("id")
|
||||
params = body.get("params", {})
|
||||
|
||||
# initialize
|
||||
if method == "initialize":
|
||||
return JSONResponse({
|
||||
"jsonrpc": "2.0", "id": req_id,
|
||||
"result": {
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": SERVER_INFO,
|
||||
},
|
||||
})
|
||||
|
||||
# notifications/initialized (pas de réponse attendue)
|
||||
if method == "notifications/initialized":
|
||||
return Response(status_code=202)
|
||||
|
||||
# tools/list
|
||||
if method == "tools/list":
|
||||
return JSONResponse({
|
||||
"jsonrpc": "2.0", "id": req_id,
|
||||
"result": {"tools": TOOLS},
|
||||
})
|
||||
|
||||
# tools/call
|
||||
if method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
args = params.get("arguments", {})
|
||||
fn = DISPATCH.get(tool_name)
|
||||
if not fn:
|
||||
return JSONResponse({
|
||||
"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32601, "message": f"Tool inconnu : {tool_name}"},
|
||||
})
|
||||
try:
|
||||
result = fn(**args)
|
||||
return JSONResponse({
|
||||
"jsonrpc": "2.0", "id": req_id,
|
||||
"result": {
|
||||
"content": [{"type": "text",
|
||||
"text": json.dumps(result, ensure_ascii=False, indent=2)}],
|
||||
},
|
||||
})
|
||||
except Exception as e:
|
||||
return JSONResponse({
|
||||
"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32603, "message": str(e)},
|
||||
})
|
||||
|
||||
# méthode inconnue
|
||||
return JSONResponse({
|
||||
"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32601, "message": f"Méthode inconnue : {method}"},
|
||||
})
|
||||
|
||||
async def health(request: Request):
|
||||
return JSONResponse({"status": "ok", "server": "mcp", "version": "1.0.0"})
|
||||
|
||||
async def well_known_protected_resource(request: Request):
|
||||
return JSONResponse({
|
||||
"resource": OAUTH_BASE_URL + "/mcp",
|
||||
"authorization_servers": [OAUTH_BASE_URL],
|
||||
})
|
||||
|
||||
async def well_known_authorization_server(request: Request):
|
||||
return JSONResponse({
|
||||
"issuer": OAUTH_BASE_URL,
|
||||
"authorization_endpoint": OAUTH_BASE_URL + "/authorize",
|
||||
"token_endpoint": OAUTH_BASE_URL + "/token",
|
||||
"registration_endpoint": OAUTH_BASE_URL + "/register",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": ["none"],
|
||||
})
|
||||
|
||||
async def register(request: Request):
|
||||
"""Dynamic Client Registration (RFC 7591)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
client_id = "client_" + secrets.token_urlsafe(16)
|
||||
state = load_oauth_state()
|
||||
state["clients"][client_id] = {
|
||||
"client_id": client_id,
|
||||
"redirect_uris": body.get("redirect_uris", []),
|
||||
"client_name": body.get("client_name", "unknown"),
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
save_oauth_state(state)
|
||||
# Reponse RFC 7591 : client public (pas de secret), auth method none
|
||||
return JSONResponse({
|
||||
"client_id": client_id,
|
||||
"redirect_uris": body.get("redirect_uris", []),
|
||||
"client_name": body.get("client_name", "unknown"),
|
||||
"token_endpoint_auth_method": "none",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
}, status_code=201)
|
||||
|
||||
AUTHORIZE_PAGE = """<!DOCTYPE html>
|
||||
<html lang="fr"><head><meta charset="utf-8"><title>Autorisation Trilium MCP</title>
|
||||
<style>body{{font-family:sans-serif;max-width:400px;margin:80px auto;padding:20px}}
|
||||
input{{width:100%;padding:10px;margin:10px 0;box-sizing:border-box}}
|
||||
button{{padding:10px 20px;background:#000;color:#fff;border:none;cursor:pointer}}
|
||||
.err{{color:#c00}}</style></head>
|
||||
<body><h2>Autorisation Trilium MCP</h2>
|
||||
<p>Saisissez le mot de securite pour autoriser l acces.</p>
|
||||
{error}
|
||||
<form method="POST" action="/authorize">
|
||||
<input type="hidden" name="client_id" value="{client_id}">
|
||||
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
|
||||
<input type="hidden" name="state" value="{state}">
|
||||
<input type="hidden" name="code_challenge" value="{code_challenge}">
|
||||
<input type="hidden" name="code_challenge_method" value="{code_challenge_method}">
|
||||
<input type="password" name="security_word" placeholder="Mot de securite" autofocus>
|
||||
<button type="submit">Autoriser</button>
|
||||
</form></body></html>"""
|
||||
|
||||
async def authorize_get(request: Request):
|
||||
q = request.query_params
|
||||
page = AUTHORIZE_PAGE.format(
|
||||
error="",
|
||||
client_id=q.get("client_id", ""),
|
||||
redirect_uri=q.get("redirect_uri", ""),
|
||||
state=q.get("state", ""),
|
||||
code_challenge=q.get("code_challenge", ""),
|
||||
code_challenge_method=q.get("code_challenge_method", "S256"),
|
||||
)
|
||||
return HTMLResponse(page)
|
||||
|
||||
async def authorize_post(request: Request):
|
||||
form = await request.form()
|
||||
word = form.get("security_word", "")
|
||||
client_id = form.get("client_id", "")
|
||||
redirect_uri = form.get("redirect_uri", "")
|
||||
state_param = form.get("state", "")
|
||||
code_challenge = form.get("code_challenge", "")
|
||||
code_challenge_method = form.get("code_challenge_method", "S256")
|
||||
|
||||
if word != SECURITY_WORD:
|
||||
page = AUTHORIZE_PAGE.format(
|
||||
error='<p class="err">Mot de securite incorrect.</p>',
|
||||
client_id=client_id, redirect_uri=redirect_uri,
|
||||
state=state_param, code_challenge=code_challenge,
|
||||
code_challenge_method=code_challenge_method,
|
||||
)
|
||||
return HTMLResponse(page, status_code=401)
|
||||
|
||||
oauth = load_oauth_state()
|
||||
if client_id not in oauth["clients"]:
|
||||
return JSONResponse({"error": "client inconnu"}, status_code=400)
|
||||
|
||||
code = "code_" + secrets.token_urlsafe(24)
|
||||
oauth["auth_codes"][code] = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": code_challenge_method,
|
||||
"created_at": int(time.time()),
|
||||
"used": False,
|
||||
}
|
||||
save_oauth_state(oauth)
|
||||
|
||||
sep = "&" if "?" in redirect_uri else "?"
|
||||
location = redirect_uri + sep + "code=" + code
|
||||
if state_param:
|
||||
location += "&state=" + state_param
|
||||
return RedirectResponse(location, status_code=302)
|
||||
|
||||
def verify_pkce(code_verifier, code_challenge):
|
||||
"""Verifie PKCE S256 : base64url(sha256(verifier)) == challenge."""
|
||||
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
||||
computed = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||
return computed == code_challenge
|
||||
|
||||
async def token(request: Request):
|
||||
form = await request.form()
|
||||
grant_type = form.get("grant_type", "")
|
||||
oauth = load_oauth_state()
|
||||
|
||||
if grant_type == "authorization_code":
|
||||
code = form.get("code", "")
|
||||
code_verifier = form.get("code_verifier", "")
|
||||
entry = oauth["auth_codes"].get(code)
|
||||
if not entry:
|
||||
return JSONResponse({"error": "invalid_grant"}, status_code=400)
|
||||
if entry.get("used"):
|
||||
return JSONResponse({"error": "invalid_grant", "error_description": "code deja utilise"}, status_code=400)
|
||||
# Validation PKCE
|
||||
challenge = entry.get("code_challenge", "")
|
||||
if challenge:
|
||||
if not code_verifier or not verify_pkce(code_verifier, challenge):
|
||||
return JSONResponse({"error": "invalid_grant", "error_description": "PKCE echec"}, status_code=400)
|
||||
# Emettre les tokens
|
||||
access_token = "at_" + secrets.token_urlsafe(32)
|
||||
refresh_token = "rt_" + secrets.token_urlsafe(32)
|
||||
oauth["tokens"][access_token] = {
|
||||
"client_id": entry["client_id"],
|
||||
"refresh_token": refresh_token,
|
||||
"created_at": int(time.time()),
|
||||
"type": "access",
|
||||
}
|
||||
oauth["auth_codes"][code]["used"] = True
|
||||
save_oauth_state(oauth)
|
||||
return JSONResponse({
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 31536000,
|
||||
"refresh_token": refresh_token,
|
||||
})
|
||||
|
||||
elif grant_type == "refresh_token":
|
||||
rt = form.get("refresh_token", "")
|
||||
# Retrouver le token associe a ce refresh
|
||||
for at, meta in list(oauth["tokens"].items()):
|
||||
if meta.get("refresh_token") == rt:
|
||||
new_at = "at_" + secrets.token_urlsafe(32)
|
||||
oauth["tokens"][new_at] = {
|
||||
"client_id": meta["client_id"],
|
||||
"refresh_token": rt,
|
||||
"created_at": int(time.time()),
|
||||
"type": "access",
|
||||
}
|
||||
save_oauth_state(oauth)
|
||||
return JSONResponse({
|
||||
"access_token": new_at,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 31536000,
|
||||
"refresh_token": rt,
|
||||
})
|
||||
return JSONResponse({"error": "invalid_grant"}, status_code=400)
|
||||
|
||||
return JSONResponse({"error": "unsupported_grant_type"}, status_code=400)
|
||||
|
||||
app = Starlette(routes=[
|
||||
Route("/mcp", mcp_endpoint, methods=["POST"]),
|
||||
Route("/", mcp_endpoint, methods=["POST"]),
|
||||
Route("/health", health, methods=["GET"]),
|
||||
Route("/.well-known/oauth-protected-resource", well_known_protected_resource, methods=["GET"]),
|
||||
Route("/.well-known/oauth-authorization-server", well_known_authorization_server, methods=["GET"]),
|
||||
Route("/register", register, methods=["POST"]),
|
||||
Route("/authorize", authorize_get, methods=["GET"]),
|
||||
Route("/authorize", authorize_post, methods=["POST"]),
|
||||
Route("/token", token, methods=["POST"]),
|
||||
])
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="127.0.0.1", port=8766)
|
||||
@@ -0,0 +1,469 @@
|
||||
# SKILL — Sliding Design System (Pernod Ricard)
|
||||
|
||||
Ce skill décrit le **design system « PR Editorial »** et le format de données
|
||||
que consomme le moteur de rendu `render_engine_v2.py` pour produire des
|
||||
présentations PowerPoint au format Pernod Ricard.
|
||||
|
||||
**Périmètre de ce skill : design et production uniquement.** Il ne traite pas
|
||||
de la structuration narrative, de la rédaction ou de la rhétorique. Il répond
|
||||
à une seule question : *comment produire un fichier YAML valide qui, passé au
|
||||
moteur, génère un PPTX conforme à la charte Pernod Ricard.*
|
||||
|
||||
---
|
||||
|
||||
## 1. Principe de fonctionnement
|
||||
|
||||
Le moteur prend en entrée un fichier **YAML** décrivant une liste de slides.
|
||||
Chaque slide déclare un `layout` (parmi 21) et les champs de contenu de ce
|
||||
layout. Le moteur applique automatiquement la charte (couleurs, polices,
|
||||
grille, centrage vertical). Tu n'as jamais à spécifier de couleur, de police
|
||||
ou de position en mode standard : tu fournis le contenu, le moteur compose.
|
||||
|
||||
Commande de rendu :
|
||||
```bash
|
||||
python3 render_engine_v2.py presentation.yaml sortie.pptx \
|
||||
--theme theme_v2.yaml --components components_v2.yaml --layouts layouts_v2.yaml
|
||||
```
|
||||
|
||||
Structure générale du YAML :
|
||||
```yaml
|
||||
slides:
|
||||
- layout: cover_split
|
||||
titre: "..."
|
||||
- layout: kpi_grid
|
||||
titre: "..."
|
||||
items: [...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. La charte (appliquée automatiquement)
|
||||
|
||||
Tu n'écris jamais ces valeurs en mode standard ; elles sont là pour information.
|
||||
|
||||
**Couleurs**
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| navy | `#061033` | dominant — fonds sombres, titres |
|
||||
| navy_light | `#10204D` | cercles décoratifs sur fond sombre |
|
||||
| coral | `#F4795B` | accent UNIQUE — chiffres, badges, lignes |
|
||||
| glacier | `#8FA9D0` | sous-titres sur fond sombre, 3e couleur |
|
||||
| slate | `#46505A` | 2e couleur de cycle |
|
||||
| card | `#F4F1EC` | fond des cartes (gris chaud) |
|
||||
| card_alt | `#EAF0F8` | variante froide |
|
||||
| body | `#2B3440` | texte courant sur fond clair |
|
||||
| muted | `#8A93A0` | légendes, sources |
|
||||
|
||||
**Polices** : Cambria (titres, chiffres) / Calibri (corps).
|
||||
**Format slide** : 33,87 × 19,05 cm (16:9).
|
||||
**Règle d'accent** : le corail est rare. Un seul élément corail dominant par slide ; le navy domine.
|
||||
**Rythme sandwich** : ouverture, transitions et clôture sur fond sombre (navy) ; contenu sur fond clair.
|
||||
|
||||
---
|
||||
|
||||
## 3. Les 21 layouts standards
|
||||
|
||||
Chaque layout liste ses champs **requis** (obligatoires) et **optionnels**.
|
||||
`titre` doit être une formulation affirmative (le message du slide), pas un label.
|
||||
|
||||
### Ouverture / Clôture / Structure
|
||||
|
||||
**cover_split** — couverture (fond sombre)
|
||||
Requis : `titre` · Optionnel : `sous_titre`
|
||||
```yaml
|
||||
- layout: cover_split
|
||||
titre: "Titre de la présentation"
|
||||
sous_titre: "Tagline en une phrase"
|
||||
```
|
||||
|
||||
**section_divider** — transition de section (fond sombre, numéro auto)
|
||||
Requis : `titre`
|
||||
```yaml
|
||||
- layout: section_divider
|
||||
titre: "Nom de la section"
|
||||
```
|
||||
|
||||
**end_slide** — clôture (fond sombre)
|
||||
Requis : `message`
|
||||
```yaml
|
||||
- layout: end_slide
|
||||
message: "Phrase de conclusion"
|
||||
```
|
||||
|
||||
### Synthèse / Message
|
||||
|
||||
**executive_summary** — synthèse situation/complication/résolution
|
||||
Requis : `titre, situation, complication, resolution`
|
||||
```yaml
|
||||
- layout: executive_summary
|
||||
titre: "Le So What en une phrase affirmative"
|
||||
situation: "État des lieux factuel"
|
||||
complication: "Le problème ou la tension"
|
||||
resolution: "La réponse proposée"
|
||||
```
|
||||
|
||||
**key_message** — citation/message fort (fond sombre)
|
||||
Requis : `message` · Optionnel : `detail`
|
||||
```yaml
|
||||
- layout: key_message
|
||||
message: "Le message clé en une phrase forte."
|
||||
detail: "Sous-texte optionnel."
|
||||
```
|
||||
|
||||
### Données
|
||||
|
||||
**big_stat** — un chiffre héro plein écran
|
||||
Requis : `titre, valeur` · Optionnel : `description, source`
|
||||
```yaml
|
||||
- layout: big_stat
|
||||
titre: "Titre affirmatif"
|
||||
valeur: "78%"
|
||||
description: "ce que le chiffre signifie"
|
||||
source: "Référence — 2026"
|
||||
```
|
||||
|
||||
**kpi_grid** — 2 à 6 cartes KPI
|
||||
Requis : `titre, items`
|
||||
```yaml
|
||||
- layout: kpi_grid
|
||||
titre: "Titre affirmatif"
|
||||
items:
|
||||
- label: "Indicateur 1"
|
||||
valeur: "+13%"
|
||||
description: "Source ou contexte"
|
||||
- label: "Indicateur 2"
|
||||
valeur: "85%"
|
||||
description: "Source ou contexte"
|
||||
```
|
||||
Note : `valeur` est une chaîne. Les valeurs courtes (≤6 car.) s'affichent en
|
||||
très grand, les valeurs longues sont réduites automatiquement.
|
||||
|
||||
### Comparaison
|
||||
|
||||
**two_cols_text** — deux colonnes en cartes (gauche navy, droite corail)
|
||||
Requis : `titre, left, right`
|
||||
```yaml
|
||||
- layout: two_cols_text
|
||||
titre: "Titre affirmatif"
|
||||
left:
|
||||
titre: "Titre colonne gauche"
|
||||
bullets:
|
||||
- texte: "Premier point"
|
||||
- texte: "Deuxième point"
|
||||
right:
|
||||
titre: "Titre colonne droite"
|
||||
bullets:
|
||||
- texte: "Premier point"
|
||||
```
|
||||
|
||||
**comparison_table** — tableau comparatif multi-critères
|
||||
Requis : `titre, headers, rows` (max ~5 colonnes, 8 lignes)
|
||||
```yaml
|
||||
- layout: comparison_table
|
||||
titre: "Titre affirmatif"
|
||||
headers: ["Critère", "Option A", "Option B"]
|
||||
rows:
|
||||
- label: "Premier critère"
|
||||
values: ["Valeur A", "Valeur B"]
|
||||
- label: "Deuxième critère"
|
||||
values: ["Valeur A", "Valeur B"]
|
||||
```
|
||||
|
||||
**from_to_pairs** — transformation avant/après
|
||||
Requis : `titre, pairs` · Optionnel : `label_from, label_to` (max 5 paires)
|
||||
```yaml
|
||||
- layout: from_to_pairs
|
||||
titre: "Titre affirmatif"
|
||||
label_from: "SITUATION ACTUELLE"
|
||||
label_to: "SITUATION CIBLE"
|
||||
pairs:
|
||||
- from: "État de départ"
|
||||
to: "État cible"
|
||||
- from: "Autre point"
|
||||
to: "Autre cible"
|
||||
```
|
||||
|
||||
### Concept / Texte
|
||||
|
||||
**circular_diagram** — 3 à 6 cercles + légende
|
||||
Requis : `titre, segments`
|
||||
```yaml
|
||||
- layout: circular_diagram
|
||||
titre: "Titre affirmatif"
|
||||
segments:
|
||||
- label: "Concept 1"
|
||||
description: "Description courte"
|
||||
- label: "Concept 2"
|
||||
description: "Description courte"
|
||||
- label: "Concept 3"
|
||||
description: "Description courte"
|
||||
```
|
||||
|
||||
**default_bullets** — liste de points (max 5)
|
||||
Requis : `titre, bullets`
|
||||
Le format « Mot-clé : explication » met le mot-clé en gras navy.
|
||||
```yaml
|
||||
- layout: default_bullets
|
||||
titre: "Titre affirmatif"
|
||||
bullets:
|
||||
- texte: "Mot-clé : explication du point"
|
||||
niveau: 1
|
||||
- texte: "Autre mot-clé : explication"
|
||||
niveau: 1
|
||||
```
|
||||
|
||||
### Process / Planning
|
||||
|
||||
**numbered_steps** — étapes en cartes avec badge numéroté (2 à 5)
|
||||
Requis : `titre, steps`
|
||||
```yaml
|
||||
- layout: numbered_steps
|
||||
titre: "Titre affirmatif"
|
||||
steps:
|
||||
- numero: 1
|
||||
titre: "Première étape"
|
||||
description: "Ce que ça implique"
|
||||
- numero: 2
|
||||
titre: "Deuxième étape"
|
||||
description: "Ce que ça implique"
|
||||
```
|
||||
|
||||
**process_arrow** — flux horizontal de 3 à 6 étapes (sans dates)
|
||||
Requis : `titre, steps`
|
||||
```yaml
|
||||
- layout: process_arrow
|
||||
titre: "Titre affirmatif"
|
||||
steps:
|
||||
- titre: "Étape 1"
|
||||
description: "Ce qui se passe"
|
||||
- titre: "Étape 2"
|
||||
description: "Ce qui se passe"
|
||||
- titre: "Étape 3"
|
||||
description: "Ce qui se passe"
|
||||
```
|
||||
|
||||
**phases_timeline** — phases avec périodes
|
||||
Requis : `titre, phases`
|
||||
```yaml
|
||||
- layout: phases_timeline
|
||||
titre: "Titre affirmatif"
|
||||
phases:
|
||||
- label: "Mois 1-2"
|
||||
periode: "Ce qui se passe"
|
||||
- label: "Mois 3-8"
|
||||
periode: "Ce qui se passe"
|
||||
```
|
||||
|
||||
**gantt_timeline** — Gantt par workstreams
|
||||
Requis : `titre, periods, workstreams` (max 3 workstreams, 4 tâches chacun)
|
||||
`start`/`end` = index dans `periods` (0-based).
|
||||
```yaml
|
||||
- layout: gantt_timeline
|
||||
titre: "Titre affirmatif"
|
||||
periods: ["Juin", "Juil", "Août", "Sept"]
|
||||
workstreams:
|
||||
- label: "Workstream 1"
|
||||
tasks:
|
||||
- label: "Tâche A"
|
||||
start: 0
|
||||
end: 2
|
||||
- label: "Workstream 2"
|
||||
tasks:
|
||||
- label: "Tâche B"
|
||||
start: 1
|
||||
end: 3
|
||||
```
|
||||
|
||||
**yearly_timeline** — frise chronologique
|
||||
Requis : `titre, milestones` (max 6, `actif: true` met le jalon en corail)
|
||||
```yaml
|
||||
- layout: yearly_timeline
|
||||
titre: "Titre affirmatif"
|
||||
milestones:
|
||||
- annee: "2024"
|
||||
label: "Premier jalon"
|
||||
- annee: "2025"
|
||||
label: "Deuxième jalon"
|
||||
- annee: "2026"
|
||||
label: "Jalon courant"
|
||||
actif: true
|
||||
```
|
||||
|
||||
### Gouvernance / Organisation / Décision
|
||||
|
||||
**raci_table** — matrice RACI
|
||||
Requis : `titre, roles, tasks` (max 4 rôles, 8 tâches)
|
||||
Lettres : R (Responsable, corail), A (Autorité, navy), C (Consulté, slate), I (Informé, muted).
|
||||
```yaml
|
||||
- layout: raci_table
|
||||
titre: "Titre affirmatif"
|
||||
roles: ["Rôle 1", "Rôle 2", "Rôle 3"]
|
||||
tasks:
|
||||
- label: "Première activité"
|
||||
raci: ["A", "R", "C"]
|
||||
- label: "Deuxième activité"
|
||||
raci: ["A", "I", "R"]
|
||||
```
|
||||
|
||||
**org_chart** — organigramme (max 3 niveaux)
|
||||
Requis : `titre, root`
|
||||
Les `children` peuvent être des objets `{label, children}` OU de simples
|
||||
chaînes. Max 4 enfants directs, 3 petits-enfants par enfant (au-delà, ça serre).
|
||||
```yaml
|
||||
- layout: org_chart
|
||||
titre: "Titre affirmatif"
|
||||
root:
|
||||
label: "Responsable racine"
|
||||
children:
|
||||
- label: "Manager 1"
|
||||
children:
|
||||
- "Équipe A"
|
||||
- "Équipe B"
|
||||
- label: "Manager 2"
|
||||
children:
|
||||
- "Équipe C"
|
||||
```
|
||||
|
||||
**matrix_2x2** — matrice effort/impact
|
||||
Requis : `titre, items` · Optionnel : `axis_x, axis_y, quadrants` (max 8 items)
|
||||
`x` et `y` vont de 0 à 100.
|
||||
```yaml
|
||||
- layout: matrix_2x2
|
||||
titre: "Titre affirmatif"
|
||||
axis_x:
|
||||
label: "Effort"
|
||||
low: "Faible"
|
||||
high: "Élevé"
|
||||
axis_y:
|
||||
label: "Impact"
|
||||
low: "Faible"
|
||||
high: "Élevé"
|
||||
quadrants:
|
||||
top_left: "Gains rapides"
|
||||
top_right: "Projets stratégiques"
|
||||
bottom_left: "Déprioritiser"
|
||||
bottom_right: "À planifier"
|
||||
items:
|
||||
- label: "Initiative A"
|
||||
x: 30
|
||||
y: 80
|
||||
- label: "Initiative B"
|
||||
x: 70
|
||||
y: 75
|
||||
```
|
||||
|
||||
**recommendation_card** — carte de recommandation (sidebar navy)
|
||||
Requis : `titre, headline, bullets` · Optionnel : `numero, cta`
|
||||
```yaml
|
||||
- layout: recommendation_card
|
||||
numero: 1
|
||||
titre: "Nom court de la recommandation"
|
||||
headline: "TROIS DÉCISIONS À PRENDRE MAINTENANT"
|
||||
cta: "Décider en réunion du 30 juin"
|
||||
bullets:
|
||||
- texte: "Première décision précise"
|
||||
- texte: "Deuxième décision précise"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Le flux libre (freeform) — composition sur mesure
|
||||
|
||||
Pour les slides qui ne rentrent dans aucun layout standard, utilise
|
||||
`layout: freeform`. La slide est composée de **blocs positionnés sur une
|
||||
grille 12 × 12** (colonnes 0-12, lignes 0-12). La charte reste **imposée** :
|
||||
les couleurs ne peuvent être que des tokens nommés (jamais de hex).
|
||||
|
||||
Règles de la grille :
|
||||
- `col` (0-12) = colonne de départ, `row` (0-12) = ligne de départ
|
||||
- `w` = largeur en colonnes, `h` = hauteur en lignes
|
||||
- Contraintes : `col + w ≤ 12` et `row + h ≤ 12`
|
||||
- Maximum 8 blocs par slide
|
||||
- `mode: light` (fond blanc) ou `mode: dark` (fond navy)
|
||||
- `footer: false` pour masquer le pied de page (affiché par défaut, même sur fond sombre)
|
||||
|
||||
Note : `freeform` est géré directement par le moteur de rendu et ne figure pas
|
||||
dans `layouts_v2.yaml`. Un validateur basé sur ce fichier peut donc le signaler
|
||||
comme « layout inconnu » — c'est sans effet, le rendu fonctionne normalement.
|
||||
|
||||
Types de blocs disponibles :
|
||||
| type | description | champs spécifiques |
|
||||
|---|---|---|
|
||||
| `title` / `heading` | titre serif | `size` (déf. 28), `align`, `color` |
|
||||
| `text` | texte courant (Calibri) | `size`, `bold`, `italic`, `serif`, `align`, `color` |
|
||||
| `stat` | grand chiffre serif corail | `size` (déf. 72), `align`, `color` |
|
||||
| `circle` | cercle plein (motif charte) | `color` (diamètre = min(w,h)) |
|
||||
| `badge` | cercle numéroté, texte blanc centré | `text`, `color` |
|
||||
| `card` | carte arrondie avec ombre | `color` (déf. card) |
|
||||
| `rect` | rectangle plein | `color`, `rounded` |
|
||||
| `line` | ligne / séparateur | `color`, `weight` (h:0 = horizontale) |
|
||||
|
||||
Tokens couleur autorisés : `navy, navy_light, coral, glacier, slate, card,
|
||||
white, body, muted`. Aucun hex.
|
||||
|
||||
Exemple — slide manifeste (fond sombre, grand chiffre + titre + accent) :
|
||||
```yaml
|
||||
- layout: freeform
|
||||
mode: dark
|
||||
blocks:
|
||||
- type: stat
|
||||
text: "3"
|
||||
col: 0.5
|
||||
row: 1
|
||||
w: 3
|
||||
h: 4
|
||||
size: 150
|
||||
color: coral
|
||||
align: center
|
||||
- type: title
|
||||
text: "trois convictions structurantes"
|
||||
col: 4
|
||||
row: 1.5
|
||||
w: 7.5
|
||||
h: 3
|
||||
size: 30
|
||||
color: white
|
||||
- type: line
|
||||
col: 4
|
||||
row: 4.5
|
||||
w: 7
|
||||
h: 0
|
||||
color: coral
|
||||
weight: 2
|
||||
- type: text
|
||||
text: "Le sous-texte en glacier, italique."
|
||||
col: 4
|
||||
row: 5
|
||||
w: 7.5
|
||||
h: 2
|
||||
size: 18
|
||||
italic: true
|
||||
color: glacier
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Règles de production (à respecter pour un YAML valide)
|
||||
|
||||
1. **Layouts** : utiliser uniquement les 21 noms listés (+ `freeform`).
|
||||
2. **Champs requis** : chaque layout doit avoir tous ses champs requis, non vides.
|
||||
3. **Chaînes** : toujours entre guillemets. Échapper les guillemets internes.
|
||||
4. **Chiffres de contenu** (`valeur`, `stat`) : toujours des chaînes (`"78%"`).
|
||||
5. **Entiers de structure** (`numero`, `niveau`, `start`, `end`, `x`, `y`) : sans guillemets.
|
||||
6. **`titre`** : formulation affirmative (le message), jamais un label thématique.
|
||||
7. **Pas de syntaxe Markdown** dans les valeurs : pas de `**gras**`, pas de `#`.
|
||||
Le formatage est géré par le moteur, pas par le texte.
|
||||
8. **Champ optionnel absent** : l'omettre (ne pas mettre `null`).
|
||||
9. **Freeform** : respecter `col+w ≤ 12`, `row+h ≤ 12`, max 8 blocs, tokens charte uniquement.
|
||||
10. **Densité** : respecter les maxima par layout (ex. kpi_grid ≤ 6, bullets ≤ 5,
|
||||
org_chart ≤ 4 branches). Au-delà, le rendu se dégrade.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sortie attendue
|
||||
|
||||
Quand on te demande de produire une présentation au format Pernod Ricard,
|
||||
tu produis **uniquement le YAML** décrit ci-dessus — rien d'autre. Ce YAML
|
||||
est destiné à être passé tel quel à `render_engine_v2.py`. Pas de texte
|
||||
d'introduction, pas de commentaire, pas de Markdown autour : le YAML brut,
|
||||
directement exploitable.
|
||||
@@ -0,0 +1,238 @@
|
||||
# Skill — Modification de code sur GrosseBertha (Synology DS218)
|
||||
|
||||
## Contexte
|
||||
|
||||
GrosseBertha est un Synology DS218 (ARM64, DSM 7.3.2).
|
||||
L'utilisateur est connecté en SSH avec l'utilisateur `Master`.
|
||||
Le shell est `/bin/sh` (pas bash).
|
||||
|
||||
---
|
||||
|
||||
## Règles de procédure — OBLIGATOIRES
|
||||
|
||||
Ces règles s'appliquent à toutes les sessions de travail sur le pipeline Sliding.
|
||||
Elles existent pour limiter la consommation de tokens et les itérations inutiles.
|
||||
|
||||
### 1. Demander accord avant tout code lourd
|
||||
Ne jamais générer un script de patch, une fonction ou un fichier sans avoir
|
||||
demandé et obtenu l'accord explicite de l'utilisateur.
|
||||
"Je génère ?" → attendre "oui" avant de coder.
|
||||
|
||||
### 2. Lire les lignes exactes avant de patcher
|
||||
Avant tout patch sur render_engine.py, demander le `sed` sur les lignes concernées :
|
||||
```bash
|
||||
sed -n 'X,Yp' ~/App/Sliding/python-pptx/render_engine.py
|
||||
```
|
||||
Ne jamais supposer que le fichier sur GrosseBertha correspond au fichier projet.
|
||||
Le fichier sur GrosseBertha diverge souvent des fichiers de référence.
|
||||
|
||||
### 3. Feedback visuel — PNG uniquement
|
||||
Demander un PNG de la slide concernée uniquement, pas le PDF complet.
|
||||
Depuis PowerPoint : clic droit sur la slide → Enregistrer en image.
|
||||
Évite la rasterisation de PDF multi-pages inutile.
|
||||
|
||||
### 4. Résultat terminal minimal
|
||||
L'utilisateur ne colle que le résultat essentiel :
|
||||
- Si OK : juste "OK" ou les lignes de confirmation
|
||||
- Si erreur : uniquement le traceback (pas tout le terminal)
|
||||
|
||||
### 5. Nettoyage unicode avant patch
|
||||
Le fichier render_engine.py contient des tirets unicode `─` dans les commentaires.
|
||||
Toujours nettoyer en début de script de patch :
|
||||
```python
|
||||
import re
|
||||
c = re.sub(r'[─]+', '-', c)
|
||||
```
|
||||
Sans ce nettoyage, les patterns de remplacement échouent.
|
||||
|
||||
### 6. Valider le fichier final, pas la variable intermédiaire
|
||||
```python
|
||||
# CORRECT
|
||||
with open(SRC) as f:
|
||||
final = f.read()
|
||||
ast.parse(final)
|
||||
|
||||
# INCORRECT
|
||||
ast.parse(content) # content peut etre une variable intermediaire corrompue
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contrainte absolue — 1 seule ligne terminal
|
||||
|
||||
**Toutes les commandes terminal doivent être sur UNE SEULE LIGNE.**
|
||||
|
||||
```bash
|
||||
# CORRECT
|
||||
python3 -c "import os; print(os.getcwd())"
|
||||
|
||||
# INCORRECT
|
||||
python3 -c "
|
||||
import os
|
||||
print(os.getcwd())
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stratégie de modification de fichiers Python
|
||||
|
||||
### Méthode recommandée — Script Python de patch
|
||||
|
||||
Créer un script `.py` dédié téléchargé via l'app mobile Claude.ai.
|
||||
|
||||
```python
|
||||
import ast, shutil, re
|
||||
|
||||
SRC = "render_engine.py"
|
||||
BAK = SRC + ".bak"
|
||||
shutil.copy2(SRC, BAK)
|
||||
|
||||
with open(SRC) as f:
|
||||
c = f.read()
|
||||
|
||||
# Nettoyage tirets unicode (obligatoire)
|
||||
c = re.sub(r'[─]+', '-', c)
|
||||
|
||||
OLD = """...texte exact apres nettoyage..."""
|
||||
NEW = """...nouveau texte..."""
|
||||
|
||||
if OLD in c:
|
||||
c = c.replace(OLD, NEW)
|
||||
print("OK")
|
||||
else:
|
||||
print("ECHEC -- pattern non trouve")
|
||||
|
||||
with open(SRC, "w") as f:
|
||||
f.write(c)
|
||||
|
||||
# Valider le fichier final (pas la variable)
|
||||
with open(SRC) as f:
|
||||
final = f.read()
|
||||
try:
|
||||
ast.parse(final)
|
||||
print(f"SYNTAXE OK -- {len(final.splitlines())} lignes")
|
||||
except SyntaxError as e:
|
||||
print(f"SYNTAXE ERREUR : {e}")
|
||||
shutil.copy2(BAK, SRC)
|
||||
print("Backup restaure.")
|
||||
```
|
||||
|
||||
### Méthode par numéros de ligne (si pattern texte fragile)
|
||||
|
||||
```python
|
||||
import re
|
||||
|
||||
with open("render_engine.py") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Trouver par marqueur fiable
|
||||
start = next(i for i, l in enumerate(lines) if "def ma_fonction(" in l)
|
||||
end = next(i for i in range(start+1, len(lines)) if re.match(r'^ def ', lines[i]))
|
||||
|
||||
new_func = """ def ma_fonction(...):\n ...\n\n"""
|
||||
lines = lines[:start] + [new_func] + lines[end:]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vérifications utiles
|
||||
|
||||
```bash
|
||||
# Verifier qu'un patch est applique
|
||||
grep -n "mot_cle_unique" render_engine.py
|
||||
|
||||
# Lire les lignes exactes avant de patcher
|
||||
sed -n '360,380p' render_engine.py
|
||||
|
||||
# Compter les lignes
|
||||
wc -l render_engine.py
|
||||
|
||||
# Lancer un rendu de test
|
||||
source venv/bin/activate && python3 render_engine.py output/sliding_20260518_220833_input.yaml output/test.pptx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Règles de sécurité — fichiers YAML
|
||||
|
||||
**Ne jamais écraser** `theme.yaml`, `layouts.yaml`, `components.yaml`.
|
||||
Ces fichiers font 15 Ko, 45 Ko et plusieurs Ko.
|
||||
Un LLM qui génère une version minimaliste casse tout le pipeline.
|
||||
|
||||
Si modification nécessaire :
|
||||
1. `cp theme.yaml theme.yaml.bak`
|
||||
2. Script Python ciblé sur la clé concernée uniquement
|
||||
3. `ls -lh *.yaml` pour vérifier la taille après
|
||||
|
||||
En cas d'écrasement : originaux disponibles dans les outputs Claude.ai du projet.
|
||||
|
||||
---
|
||||
|
||||
## Téléchargement depuis Claude.ai
|
||||
|
||||
La webapp desktop échoue sur les fichiers > quelques Ko.
|
||||
**Toujours utiliser l'app mobile Claude.ai** pour télécharger les scripts générés.
|
||||
|
||||
---
|
||||
|
||||
## Structure des dossiers
|
||||
|
||||
```
|
||||
~/App/Sliding/python-pptx/
|
||||
render_engine.py <- moteur rendu PPTX (2300+ lignes, diverge du fichier projet)
|
||||
facilitator.py
|
||||
prompt_injection.py
|
||||
theme.yaml <- NE PAS ECRASER (15 Ko)
|
||||
components.yaml <- NE PAS ECRASER
|
||||
layouts.yaml <- NE PAS ECRASER (45 Ko)
|
||||
assets/fonts/ <- Inter + Cormorant Garamond TTF
|
||||
venv/
|
||||
output/
|
||||
|
||||
~/App/Context_continuity/
|
||||
trilium_context.py
|
||||
|
||||
/volume1/docker/trilium/ <- donnees Trilium (chown 1000:1000)
|
||||
/volume1/web/sliding/ <- galerie layouts HTML
|
||||
/volume1/scripts/ <- scripts utilitaires
|
||||
```
|
||||
|
||||
## Activation du venv
|
||||
|
||||
```bash
|
||||
source ~/App/Sliding/python-pptx/venv/bin/activate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Outils disponibles sur GrosseBertha
|
||||
|
||||
| Outil | Dispo | Note |
|
||||
|---|---|---|
|
||||
| python3 | oui | v3.9 |
|
||||
| pip | oui | avec --break-system-packages |
|
||||
| docker | oui | Container Manager 24.0.2 |
|
||||
| wget | oui | avertissement HSTS ignorable |
|
||||
| unzip | non | utiliser python3 zipfile |
|
||||
| fc-list | non | detecter polices via assets/fonts/ |
|
||||
| setfacl | non | pas disponible |
|
||||
| fc-cache | non | pas disponible |
|
||||
| pdftoppm | oui | poppler-utils installe |
|
||||
| pdftotext | oui | poppler-utils installe |
|
||||
|
||||
---
|
||||
|
||||
## Gestion Container Manager
|
||||
|
||||
```bash
|
||||
# Si Docker en erreur apres update DSM
|
||||
sudo synosetkeyvalue /etc/synoinfo.conf unique synology_rtd1296_ds220j
|
||||
sudo synosetkeyvalue /etc.defaults/synoinfo.conf unique synology_rtd1296_ds220j
|
||||
sudo synopkg start ContainerManager
|
||||
|
||||
# Trilium
|
||||
sudo docker ps
|
||||
sudo docker logs trilium
|
||||
sudo docker restart trilium
|
||||
```
|
||||
@@ -0,0 +1,217 @@
|
||||
# SKILL — Développement sur GrosseBertha (Synology) + versioning Forgejo
|
||||
|
||||
Skill universel pour toute session de développement sur l'infrastructure de
|
||||
Bastien. Couvre la connexion, les conventions de patch de code, et le
|
||||
versioning git via Forgejo. Réutilisable pour n'importe quel projet hébergé
|
||||
sur GrosseBertha.
|
||||
|
||||
---
|
||||
|
||||
## 1. L'environnement
|
||||
|
||||
**GrosseBertha** — Synology DS218, ARM64, DSM 7.3.2.
|
||||
- Connexion : SSH, utilisateur `Master`.
|
||||
- Shell : `/bin/sh` (PAS bash — pas de `[[ ]]`, pas de tableaux bash).
|
||||
- Python : `python3` v3.9 (dans un venv par projet).
|
||||
|
||||
Activation du venv (projet Sliding) :
|
||||
```
|
||||
source ~/App/Sliding/python-pptx/venv/bin/activate
|
||||
```
|
||||
|
||||
Outils disponibles : `python3` (3.9), `pip` (avec `--break-system-packages`),
|
||||
`docker` (Container Manager), `wget`, `pdftoppm`, `pdftotext` (poppler-utils),
|
||||
`git`. **Indisponibles** : `unzip` (utiliser `python3 zipfile`), `fc-list`,
|
||||
`fc-cache`, `setfacl`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Règles de procédure — OBLIGATOIRES
|
||||
|
||||
Ces règles limitent la consommation de tokens et les itérations inutiles.
|
||||
|
||||
**2.1 — Accord avant tout code lourd.** Ne jamais générer un script de patch,
|
||||
une fonction ou un fichier sans accord explicite. « Je génère ? » → attendre
|
||||
« oui ».
|
||||
|
||||
**2.2 — Commandes terminal sur UNE SEULE LIGNE.** Le shell SSH n'accepte pas
|
||||
le multiligne fiablement.
|
||||
```
|
||||
# CORRECT
|
||||
python3 -c "import os; print(os.getcwd())"
|
||||
# INCORRECT (multiligne)
|
||||
```
|
||||
|
||||
**2.3 — Lire les lignes exactes avant de patcher.** Le fichier sur GrosseBertha
|
||||
diverge souvent des fichiers de référence. Toujours :
|
||||
```
|
||||
sed -n 'X,Yp' ~/App/Sliding/python-pptx/render_engine.py
|
||||
```
|
||||
avant tout patch. Ne jamais supposer que le fichier distant correspond au
|
||||
fichier projet.
|
||||
|
||||
**2.4 — Nettoyage unicode avant patch.** Les fichiers contiennent des tirets
|
||||
unicode `─` dans les commentaires. Toujours nettoyer en début de script de
|
||||
patch, sinon les patterns de remplacement échouent :
|
||||
```python
|
||||
import re
|
||||
c = re.sub(r'[─]+', '-', c)
|
||||
```
|
||||
|
||||
**2.5 — Valider le FICHIER FINAL, pas la variable intermédiaire.**
|
||||
```python
|
||||
# CORRECT
|
||||
with open(SRC) as f: final = f.read()
|
||||
ast.parse(final)
|
||||
# INCORRECT — content peut être une variable corrompue
|
||||
ast.parse(content)
|
||||
```
|
||||
|
||||
**2.6 — Feedback visuel : PNG d'UNE slide, pas le PDF complet.** Évite la
|
||||
rasterisation multi-pages inutile.
|
||||
|
||||
**2.7 — Résultat terminal minimal.** Bastien ne colle que l'essentiel : « OK »
|
||||
ou les lignes de confirmation si succès ; uniquement le traceback si erreur.
|
||||
|
||||
**2.8 — Téléchargement des scripts via l'app mobile Claude.ai.** La webapp
|
||||
desktop échoue sur les fichiers de plus de quelques Ko.
|
||||
|
||||
---
|
||||
|
||||
## 3. Patch de code Python — méthode de référence
|
||||
|
||||
Script `.py` dédié, appliqué sur GrosseBertha :
|
||||
|
||||
```python
|
||||
import ast, shutil, re
|
||||
|
||||
SRC = "render_engine.py"
|
||||
BAK = SRC + ".bak"
|
||||
shutil.copy2(SRC, BAK)
|
||||
|
||||
with open(SRC, encoding="utf-8") as f:
|
||||
c = f.read()
|
||||
|
||||
c = re.sub(r'[─]+', '-', c) # nettoyage unicode obligatoire
|
||||
|
||||
OLD = """...texte exact après nettoyage..."""
|
||||
NEW = """...nouveau texte..."""
|
||||
|
||||
if OLD in c:
|
||||
c = c.replace(OLD, NEW)
|
||||
print("OK")
|
||||
else:
|
||||
print("ECHEC -- pattern non trouve")
|
||||
|
||||
with open(SRC, "w", encoding="utf-8") as f:
|
||||
f.write(c)
|
||||
|
||||
with open(SRC, encoding="utf-8") as f: # valider le fichier final
|
||||
final = f.read()
|
||||
try:
|
||||
ast.parse(final)
|
||||
print(f"SYNTAXE OK -- {len(final.splitlines())} lignes")
|
||||
except SyntaxError as e:
|
||||
shutil.copy2(BAK, SRC)
|
||||
print(f"SYNTAXE ERREUR : {e} -- backup restaure")
|
||||
```
|
||||
|
||||
**Méthode par numéros de ligne** (si le pattern texte est fragile) : repérer
|
||||
par marqueur fiable (`def ma_fonction(`) et remplacer la tranche `lines[start:end]`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Sécurité — fichiers YAML de config
|
||||
|
||||
**Ne jamais écraser** `theme.yaml` (15 Ko), `layouts.yaml` (45 Ko),
|
||||
`components.yaml`. Un LLM qui génère une version minimaliste casse tout le
|
||||
pipeline. Si modification nécessaire :
|
||||
1. `cp theme.yaml theme.yaml.bak`
|
||||
2. Script Python ciblé sur la clé concernée uniquement
|
||||
3. `ls -lh *.yaml` pour vérifier la taille après
|
||||
|
||||
Originaux de secours : dans les outputs Claude.ai du projet.
|
||||
|
||||
---
|
||||
|
||||
## 5. Versioning — Forgejo
|
||||
|
||||
Forgejo auto-hébergé en Docker. Accès : SSH port **2222** ou HTTPS port **3000**
|
||||
via `forgejo.bertha-cloud.fr`. Dépôt principal : `Master/sliding-automation`.
|
||||
|
||||
**Workflow de commit après chaque modification validée :**
|
||||
```
|
||||
git add <fichiers modifiés>
|
||||
git commit -m "type: description claire du changement"
|
||||
git push
|
||||
```
|
||||
|
||||
**Conventions de messages de commit :**
|
||||
- `fix:` — correction de bug
|
||||
- `feat:` — nouvelle fonctionnalité
|
||||
- `refactor:` — nettoyage / restructuration sans changement de comportement
|
||||
- `chore:` — maintenance (suppression de fichiers obsolètes, etc.)
|
||||
- `docs:` — documentation
|
||||
|
||||
**Archiver une version (tag) :**
|
||||
```
|
||||
git tag -a v2.0 -m "Description de la version"
|
||||
git push origin v2.0
|
||||
```
|
||||
|
||||
**`.gitignore` type** (ne jamais versionner secrets, venv, sorties) :
|
||||
```
|
||||
.env
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
projets/*/outputs/
|
||||
projets/*/inputs/
|
||||
projets/*/project_state.json
|
||||
projets/*/journal.md
|
||||
*.pptx
|
||||
*.pdf
|
||||
*.bak
|
||||
*.bak_*
|
||||
```
|
||||
|
||||
Principe : git remplace les dossiers `archive/`. Les anciennes versions vivent
|
||||
dans l'historique et les tags, pas dans des copies de fichiers.
|
||||
|
||||
---
|
||||
|
||||
## 6. Container Manager / Docker
|
||||
|
||||
```
|
||||
# Si Docker en erreur après update DSM (workaround communauté DS218)
|
||||
sudo synosetkeyvalue /etc/synoinfo.conf unique synology_rtd1296_ds220j
|
||||
sudo synosetkeyvalue /etc.defaults/synoinfo.conf unique synology_rtd1296_ds220j
|
||||
sudo synopkg start ContainerManager
|
||||
# Conteneurs
|
||||
sudo docker ps
|
||||
sudo docker logs <conteneur>
|
||||
sudo docker restart <conteneur>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Structure des dossiers
|
||||
|
||||
```
|
||||
~/App/Sliding/python-pptx/ ← pipeline Sliding
|
||||
render_engine_v2.py ← moteur de rendu (diverge du fichier projet)
|
||||
facilitator_v9.py ← orchestrateur (version active)
|
||||
prompt_injection_v2.py
|
||||
theme_v2.yaml ← NE PAS ÉCRASER
|
||||
layouts_v2.yaml ← NE PAS ÉCRASER
|
||||
components_v2.yaml ← NE PAS ÉCRASER
|
||||
projets/<slug>/inputs|outputs/
|
||||
venv/
|
||||
|
||||
~/App/Context_continuity/ ← système de mémoire Trilium
|
||||
trilium_context.py
|
||||
trilium_api.py
|
||||
|
||||
/volume1/docker/trilium/ ← données Trilium (chown 1000:1000)
|
||||
/volume1/web/sliding/ ← galerie layouts HTML
|
||||
```
|
||||
@@ -0,0 +1,180 @@
|
||||
# SKILL — Référence technique : APIs Trilium & serveur MCP
|
||||
|
||||
Référence d'implémentation du système Context Continuity. À sortir pour
|
||||
débugger, étendre ou appeler directement les APIs. Le « quand/pourquoi »
|
||||
comportemental est dans le skill de réflexe de continuité ; ici c'est le
|
||||
« comment » technique.
|
||||
|
||||
Trois voies d'accès à Trilium coexistent : **API REST FastAPI** (distante,
|
||||
HTTPS), **ETAPI via wrapper Python** (locale, sur GrosseBertha), **MCP**
|
||||
(pour agents Mistral).
|
||||
|
||||
---
|
||||
|
||||
## 1. API REST FastAPI — accès distant (recommandé pour un LLM)
|
||||
|
||||
Couche créée pour exposer Trilium en HTTPS (pallie l'absence d'accès direct).
|
||||
|
||||
| Paramètre | Valeur |
|
||||
|---|---|
|
||||
| Base URL | `https://api-trilium.bertha-cloud.fr` |
|
||||
| Auth header | `Authorization: <API_KEY>` (clé brute, **sans** `Bearer`) |
|
||||
| Clés | Clé Claude / clé Le Chat — demander à Bastien ou lire dans `.env` |
|
||||
| Doc Swagger | `https://api-trilium.bertha-cloud.fr/api/docs` |
|
||||
|
||||
**Endpoints essentiels :**
|
||||
| Action | Appel |
|
||||
|---|---|
|
||||
| Lire le contexte projet | `GET /api/contexte/{projet}?llm_cible=NomLLM` |
|
||||
| Voir le backlog | `GET /api/backlog/{projet}` |
|
||||
| Enregistrer une décision | `POST /api/decisions` |
|
||||
| Enregistrer un historique | `POST /api/historique` |
|
||||
| Créer une conversation | `POST /api/conversations` |
|
||||
| Clôturer une session | `PATCH /api/conversations/{id}` |
|
||||
| Marquer un backlog item | `PATCH /api/backlog/{id}` |
|
||||
|
||||
**Exemples curl :**
|
||||
```bash
|
||||
# Lire le briefing de reprise
|
||||
curl -s -H "Authorization: CLE" \
|
||||
"https://api-trilium.bertha-cloud.fr/api/contexte/SlidingAutomation?llm_cible=Le+Chat+Large" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['briefing'])"
|
||||
|
||||
# Enregistrer une décision
|
||||
curl -s -X POST -H "Authorization: CLE" -H "Content-Type: application/json" \
|
||||
-d '{"projet":"SlidingAutomation","enonce":"Décision prise","justification":"Raison"}' \
|
||||
https://api-trilium.bertha-cloud.fr/api/decisions
|
||||
|
||||
# Marquer un backlog item comme fait
|
||||
curl -s -X PATCH -H "Authorization: CLE" -H "Content-Type: application/json" \
|
||||
-d '{"statut":"fait"}' \
|
||||
https://api-trilium.bertha-cloud.fr/api/backlog/NOTE_ID
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. ETAPI via wrapper Python — accès local (sur GrosseBertha)
|
||||
|
||||
TriliumNext 0.95+ a restructuré ses routes : `POST /etapi/notes` retourne
|
||||
*Router not found*. **Solution validée : `trilium-py 1.3.9`**, encapsulé dans
|
||||
`~/App/Context_continuity/trilium_api.py`.
|
||||
|
||||
```
|
||||
pip install trilium-py python-dotenv
|
||||
```
|
||||
`.env` :
|
||||
```
|
||||
TRILIUM_URL=http://localhost:4292
|
||||
TRILIUM_TOKEN=<token généré dans Options > ETAPI>
|
||||
```
|
||||
Header d'auth : `Authorization: token` (**sans** `Bearer` ; Bearer accepté
|
||||
depuis 0.93 mais non requis).
|
||||
|
||||
**Fonctions du wrapper `trilium_api.py` :**
|
||||
| Fonction | Note |
|
||||
|---|---|
|
||||
| `check_api()` | Lève SystemExit si Trilium inaccessible |
|
||||
| `create_note(parent_id, title, content=" ", note_type="text")` | `content=" "` obligatoire — jamais vide |
|
||||
| `get_note_id(result)` | Extrait `noteId` du résultat de `create_note` |
|
||||
| `get_note(note_id)` / `get_note_content(note_id)` | Récupère note / contenu HTML |
|
||||
| `update_note_content(note_id, content)` | Met à jour le contenu |
|
||||
| `search_notes(query, limit=50)` | Recherche **sans guillemets** autour du terme |
|
||||
| `search_by_label(label, value="")` | Syntaxe interne `#label=value` |
|
||||
| `find_note_by_title(title, parent_id="")` | Retourne `noteId` ou `None` |
|
||||
| `set_label(note_id, name, value="")` | `isInheritable=False` requis en interne |
|
||||
| `get_label_value(note_id, name)` | Retourne la valeur ou `None` |
|
||||
|
||||
**Erreurs connues et fixes :**
|
||||
| Erreur | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `Router not found POST /etapi/notes` | Routing TriliumNext 0.95 | Utiliser `trilium-py` |
|
||||
| `missing argument isInheritable` | `create_attribute()` trilium-py | Passer `isInheritable=False` |
|
||||
| `Note content must be set` | Contenu vide refusé | Passer `content=" "` |
|
||||
| Recherche avec guillemets → `[]` | Parser 0.95 | Chercher sans guillemets |
|
||||
| `{status,code,message}` sur create | Note existe déjà / contenu vide | `find_note_by_title` avant + `content=" "` |
|
||||
|
||||
IDs des dossiers : `trilium_ids.json` (généré par `trilium_init.py`). Clés :
|
||||
`root, Projets, Conversations, Backlog, Decisions, Historique, Glossaire,
|
||||
ContextesReprise`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Serveur MCP — pour agents Mistral
|
||||
|
||||
`mcp_server.py` — serveur MCP pur Starlette (compatible Python 3.9), transport
|
||||
Streamable HTTP sur `/mcp`, écoute `127.0.0.1:8766`, exposé via
|
||||
`https://mcp-trilium.bertha-cloud.fr`. Protocole `2025-06-18`.
|
||||
|
||||
**Authentification : Bearer token statique** (`API_KEY_CLAUDE` /
|
||||
`API_KEY_LECHAT` depuis `.env`). C'est ce qui le rend **utilisable par Le Chat
|
||||
mais PAS par Claude** : les connecteurs distants de Claude.ai exigent OAuth
|
||||
(endpoints de découverte `/.well-known/oauth-authorization-server`, flux
|
||||
d'autorisation), absents de ce serveur. Pour activer Claude, il faudrait
|
||||
ajouter une couche OAuth devant le serveur.
|
||||
|
||||
**11 tools exposés :** `get_backlog`, `get_decisions`, `get_historique`,
|
||||
`get_glossaire`, `get_contexte`, `add_decision`, `add_history`, `add_backlog`,
|
||||
`update_backlog`, `new_conversation`, `close_session`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Structure des données Trilium
|
||||
|
||||
Note racine **Context Continuity** → 8 sous-dossiers : `Projets`,
|
||||
`Conversations`, `Backlog`, `Decisions`, `Historique`, `Glossaire`,
|
||||
`Contextes Reprise`, `Skills`.
|
||||
|
||||
**Types de notes et labels obligatoires :**
|
||||
|
||||
| Type (`type=`) | Labels obligatoires |
|
||||
|---|---|
|
||||
| `projet` | `projet` (sans espaces), `statut` (actif\|en-pause\|archive) |
|
||||
| `conversation` | `projet`, `llm`, `statut` (en-cours\|clos), `date` (YYYY-MM-DD HH:MM) |
|
||||
| `backlogItem` | `projet`, `statut` (a faire\|en cours\|bloque\|fait\|abandonne), `priorite` (haute\|moyenne\|basse) |
|
||||
| `decision` | `projet`, `statut` (active\|revisee\|annulee) |
|
||||
| `historiqueItem` | `projet`, `typeHistorique`, `encoreValide` (true\|false) |
|
||||
| `termeGlossaire` | `projet`, `definition` |
|
||||
| `contexteReprise` | `projet`, `llmCible`, `version` |
|
||||
|
||||
`typeHistorique` ∈ { `Fait etabli`, `Test effectue`, `Hypothese invalidee`,
|
||||
`Contrainte decouverte` }.
|
||||
|
||||
**Filtres des briefings** (pièges) :
|
||||
- `generate-context` n'inclut que les décisions `statut=active`.
|
||||
- N'inclut que l'historique `encoreValide=true`.
|
||||
- `list-backlog` exclut `statut` ∈ { `fait`, `abandonne` }.
|
||||
- Pour retirer un élément du briefing : passer `statut=annulee` (décision) ou
|
||||
`encoreValide=false` (historique) — ne pas supprimer la note.
|
||||
|
||||
**Pièges d'écriture récurrents :**
|
||||
| Erreur | Conséquence | Fix |
|
||||
|---|---|---|
|
||||
| Statut avec majuscule (`Actif`) | Le filtre rate la note | Toujours minuscules |
|
||||
| Nom projet avec espaces | `search_by_label` échoue | `SlidingAutomation` |
|
||||
| `encoreValide="True"` | Filtre cherche `true` | Minuscules |
|
||||
| `definition` absent sur glossaire | Briefing affiche « (voir note) » | Ajouter le label |
|
||||
|
||||
---
|
||||
|
||||
## 5. Workflow CLI (sur GrosseBertha)
|
||||
|
||||
```bash
|
||||
cd ~/App/Context_continuity && source venv/bin/activate
|
||||
# Début
|
||||
python trilium_context.py new-conversation --projet SlidingAutomation --llm "Claude Sonnet" --titre "..."
|
||||
# Reprise
|
||||
python trilium_context.py generate-context --projet SlidingAutomation --llm-cible "Claude Sonnet" --note-id ID --version N
|
||||
# Au fil de l'eau
|
||||
python trilium_context.py add-decision --projet SlidingAutomation --enonce "..." --justification "..."
|
||||
python trilium_context.py add-history --projet SlidingAutomation --type "Test effectue" --enonce "..." --detail "..."
|
||||
# Clôture
|
||||
python trilium_context.py close-session --note-id ID --summary "..."
|
||||
# État
|
||||
python trilium_context.py list-backlog --projet SlidingAutomation
|
||||
python trilium_context.py list-projects
|
||||
```
|
||||
|
||||
Fichiers du système : `trilium_api.py` (wrapper, ne pas modifier),
|
||||
`trilium_init.py` (init unique), `trilium_context.py` (workflow quotidien),
|
||||
`trilium_logger.py` (intégration pipeline Sliding), `trilium_ids.json`,
|
||||
`mcp_server.py`, `api_context.py` (API FastAPI).
|
||||
@@ -0,0 +1,142 @@
|
||||
# SKILL — Réflexe de continuité Trilium
|
||||
|
||||
Skill universel de mémoire de projet. Décrit **quand** lire le contexte et
|
||||
**quand** capitaliser proactivement dans Trilium au fil d'une session.
|
||||
Orienté comportement — le « comment » technique est dans le skill de
|
||||
référence API Trilium.
|
||||
|
||||
Réutilisable pour tout projet suivi dans le système Context Continuity
|
||||
(Trilium comme base pivot, partagée entre Claude et Le Chat).
|
||||
|
||||
---
|
||||
|
||||
## Le principe
|
||||
|
||||
Trilium est la mémoire longue du projet, partagée entre LLMs. Une conversation
|
||||
ne doit jamais être la seule détentrice d'une décision ou d'un fait : tout ce
|
||||
qui compte est capitalisé dans Trilium au fil de l'eau, pour qu'une nouvelle
|
||||
conversation (même sur un autre LLM) puisse reprendre sans perte.
|
||||
|
||||
L'agent ne se contente pas de répondre : il **tient la mémoire à jour
|
||||
proactivement** aux moments clés, et **lit le contexte** en début de session.
|
||||
|
||||
---
|
||||
|
||||
## Deux chemins d'accès selon le LLM
|
||||
|
||||
**Côté Le Chat (Mistral)** : accès **natif via MCP**. Les tools
|
||||
(`get_contexte`, `add_decision`, `add_history`, etc.) sont appelés directement
|
||||
par l'agent.
|
||||
|
||||
**Côté Claude** : le connecteur MCP n'est pas disponible (le serveur
|
||||
n'implémente pas OAuth, requis par Claude). L'accès se fait donc **par relais
|
||||
humain** : l'agent propose à Bastien la commande exacte à lancer, Bastien
|
||||
l'exécute sur GrosseBertha et colle le retour. C'est transparent côté
|
||||
comportement — seul le mécanisme diffère.
|
||||
|
||||
> Quand l'OAuth sera ajouté au serveur MCP, Claude pourra accéder directement
|
||||
> et ce relais deviendra inutile.
|
||||
|
||||
---
|
||||
|
||||
## EN DÉBUT DE SESSION — lire le contexte
|
||||
|
||||
Au démarrage d'une session sur un projet existant, récupérer le briefing de
|
||||
reprise avant de travailler :
|
||||
|
||||
- **Le Chat** : appeler le tool `get_contexte(projet="SlidingAutomation", llm_cible="Le Chat Large")`.
|
||||
- **Claude** : proposer à Bastien :
|
||||
```
|
||||
python trilium_context.py generate-context --projet SlidingAutomation --llm-cible "Claude Sonnet" --note-id <ID_DERNIERE_CONV> --version <N>
|
||||
```
|
||||
puis lire le briefing qu'il colle.
|
||||
|
||||
Le briefing contient : décisions actives, historique valide, glossaire,
|
||||
backlog actif. **Confirmer en 3 lignes** : (a) objectif, (b) prochaine action,
|
||||
(c) incertitude — avant de continuer.
|
||||
|
||||
Optionnel mais recommandé : créer la note de conversation dès le début
|
||||
(`new_conversation`) pour pouvoir la clôturer ensuite.
|
||||
|
||||
---
|
||||
|
||||
## PENDANT LA SESSION — capitaliser aux moments clés
|
||||
|
||||
L'agent surveille activement ces déclencheurs et propose la capitalisation
|
||||
**sans attendre la fin** :
|
||||
|
||||
### Déclencheur : une décision est actée
|
||||
Dès qu'un choix technique ou architectural est tranché (« on fait X plutôt
|
||||
que Y ») :
|
||||
- **Le Chat** : `add_decision(projet, enonce, justification)`
|
||||
- **Claude** : proposer
|
||||
```
|
||||
python trilium_context.py add-decision --projet SlidingAutomation --enonce "Énoncé court et actionnable" --justification "Pourquoi ce choix"
|
||||
```
|
||||
|
||||
### Déclencheur : un test est effectué ou une contrainte découverte
|
||||
Dès qu'un test valide/invalide quelque chose, ou qu'une limite est rencontrée :
|
||||
- **Le Chat** : `add_history(projet, enonce, type_historique, detail)`
|
||||
- **Claude** : proposer
|
||||
```
|
||||
python trilium_context.py add-history --projet SlidingAutomation --type "Test effectue" --enonce "Ce qui a été testé" --detail "Résultat observé"
|
||||
```
|
||||
`type_historique` ∈ { `Fait etabli`, `Test effectue`, `Hypothese invalidee`,
|
||||
`Contrainte decouverte` } (sans accent, exactement ces libellés).
|
||||
|
||||
### Déclencheur : une tâche future émerge
|
||||
Dès qu'un « il faudra faire X » apparaît :
|
||||
- **Le Chat** : `add_backlog(projet, titre, priorite)`
|
||||
- **Claude** : proposer la commande équivalente.
|
||||
`priorite` ∈ { `haute`, `moyenne`, `basse` }.
|
||||
|
||||
### Déclencheur : une tâche change d'état
|
||||
Quand un item passe en cours / fait / bloqué : `update_backlog(note_id, statut)`.
|
||||
`statut` ∈ { `a faire`, `en cours`, `bloque`, `fait`, `abandonne` }.
|
||||
|
||||
---
|
||||
|
||||
## EN FIN DE SESSION — clôturer
|
||||
|
||||
Quand Bastien signale que la limite de tokens approche (l'agent se répète,
|
||||
oublie une contrainte, perd en précision), produire une **synthèse de clôture**
|
||||
de 150-200 mots, format concis sans markdown complexe :
|
||||
```
|
||||
Synthèse de clôture :
|
||||
- Objectif de la session : ...
|
||||
- Accompli : ...
|
||||
- En cours : ...
|
||||
- Bloqué : ...
|
||||
- Prochaine action : ...
|
||||
```
|
||||
Puis l'enregistrer :
|
||||
- **Le Chat** : `close_session(note_id, synthese)`
|
||||
- **Claude** : proposer
|
||||
```
|
||||
python trilium_context.py close-session --note-id <ID_CONV> --summary "COLLE ICI LA SYNTHÈSE"
|
||||
```
|
||||
|
||||
Enfin, générer le briefing de reprise (`generate-context`) pour la prochaine
|
||||
session ou la bascule vers l'autre LLM.
|
||||
|
||||
---
|
||||
|
||||
## Règles de capitalisation — à respecter strictement
|
||||
|
||||
Ces règles évitent que les filtres de Trilium ratent les notes :
|
||||
|
||||
- **Nom de projet sans espaces** : `SlidingAutomation`, jamais `Sliding Automation`.
|
||||
- **Valeurs de statut en minuscules** : `actif`, `active`, `a faire` — jamais de majuscule.
|
||||
- **`encoreValide`** : `true` / `false` en minuscules.
|
||||
- **`typeHistorique`** : exactement un des 4 libellés sans accent.
|
||||
- Énoncés **courts et actionnables** — une ligne, pas un paragraphe.
|
||||
|
||||
---
|
||||
|
||||
## Posture de l'agent
|
||||
|
||||
Capitaliser n'est pas optionnel ni réservé à la fin. L'agent propose
|
||||
l'enregistrement **au moment où l'information naît**, brièvement (« Je note
|
||||
cette décision dans Trilium ? » + la commande prête). Il n'attend pas que
|
||||
Bastien le demande. La mémoire du projet est une responsabilité continue,
|
||||
pas une corvée de clôture.
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
cd /volume1/homes/Master/App/Context_continuity
|
||||
pkill -f "uvicorn api_context:app" 2>/dev/null
|
||||
sleep 2
|
||||
source venv/bin/activate
|
||||
uvicorn api_context:app --host 127.0.0.1 --port 8765 >> /volume1/homes/Master/App/Context_continuity/api.log 2>&1 &
|
||||
echo $! > /volume1/homes/Master/App/Context_continuity/api.pid
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
cd /volume1/homes/Master/App/Context_continuity
|
||||
# Tuer tout uvicorn mcp_server existant (evite les zombies sur le port 8766)
|
||||
pkill -f "uvicorn mcp_server:app" 2>/dev/null
|
||||
sleep 2
|
||||
source venv/bin/activate
|
||||
uvicorn mcp_server:app --host 127.0.0.1 --port 8766 >> /volume1/homes/Master/App/Context_continuity/mcp.log 2>&1 &
|
||||
echo $! > /volume1/homes/Master/App/Context_continuity/mcp.pid
|
||||
@@ -0,0 +1,187 @@
|
||||
# Synthèse - Projet Context Continuity
|
||||
# Date : 27 mai 2026
|
||||
# Participants : Bastien Gourdon, Claude, Le Chat (Mistral)
|
||||
|
||||
---
|
||||
|
||||
## Contexte du Projet
|
||||
**Objectif principal** : Créer un système de **gestion de contexte partagé** entre plusieurs LLM (Claude, Le Chat) pour dépasser les limites de tokens (≤ 10K) et centraliser les informations liées aux projets (ex: Sliding Automation, Data Governance).
|
||||
|
||||
**Outils utilisés** :
|
||||
- **Trilium** : Base de connaissances self-hosted (Docker sur Synology DS218).
|
||||
- **Synology DS218 (GrosseBertha)** : Hébergement de Trilium et des scripts.
|
||||
- **Reverse Proxy** : Accès sécurisé à Trilium via `https://trilium.bertha-cloud.fr`.
|
||||
- **API ETAPI** : Interaction programmatique avec Trilium.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Décisions Prises
|
||||
|
||||
### 1. **Abandon d’Anytype au profit de Trilium**
|
||||
- **Raison** :
|
||||
- Anytype est une application desktop (Electron) **non utilisable en headless** sur NAS.
|
||||
- Trilium est **100% web**, self-hosted, avec une **API REST (ETAPI)** documentée.
|
||||
- Données **100% self-hosted** sur GrosseBertha (SQLite dans `/volume1/docker/trilium/`).
|
||||
- **Licence AGPL-3.0** et communauté active.
|
||||
|
||||
### 2. **Architecture de Trilium**
|
||||
- **Arborescence validée** :
|
||||
```
|
||||
Context Continuity/
|
||||
├── Conversations/ # Notes de type "Conversation LLM"
|
||||
├── Backlog/ # Tâches à faire
|
||||
├── Décisions/ # Décisions prises
|
||||
├── Glossaire/ # Termes techniques
|
||||
└── Historique/ # Historique des actions
|
||||
```
|
||||
- **Types de notes** :
|
||||
- **Book** : Pour les dossiers (ex: `Conversations/`).
|
||||
- **Text** : Pour les notes de contenu (ex: une conversation spécifique).
|
||||
|
||||
### 3. **Automatisation via Scripts Python**
|
||||
- **Objectif** : Synchroniser automatiquement les contextes entre LLM et Trilium.
|
||||
- **Scripts créés** :
|
||||
- `init_trilium_architecture.py` : Initialise l’arborescence.
|
||||
- `trilium_api.py` : Bibliothèque pour interagir avec l’API ETAPI.
|
||||
- `create_conversation.py` : Crée des notes de conversation.
|
||||
- `generate_context.py` : Génère un contexte pour les LLM.
|
||||
|
||||
### 4. **Configuration Technique**
|
||||
- **Reverse Proxy** :
|
||||
- **Source** : `https://trilium.bertha-cloud.fr:443` (HTTPS).
|
||||
- **Destination** : `http://192.168.1.6:4292` (HTTP vers le conteneur Docker).
|
||||
- **Certificat SSL** : Let’s Encrypt (géré via DSM).
|
||||
- **Docker** :
|
||||
- Image : `triliumnext/notes:latest`.
|
||||
- Port : `4292:8080`.
|
||||
- Volume : `/volume1/docker/trilium:/home/node/trilium-data`.
|
||||
|
||||
---
|
||||
|
||||
## Statut des Tâches
|
||||
|
||||
### ✅ Tâches Effectuées
|
||||
| Tâche | Statut | Détails |
|
||||
|-------|--------|---------|
|
||||
| **Installation de Trilium** | ✅ Terminé | Conteneur Docker lancé et accessible en local (`http://192.168.1.6:4292`). |
|
||||
| **Reverse Proxy** | ✅ Terminé | Configuré dans DSM pour `trilium.bertha-cloud.fr`. |
|
||||
| **Certificat SSL** | ✅ Terminé | Let’s Encrypt configuré via DSM. |
|
||||
| **Accès externe** | ✅ Terminé | `https://trilium.bertha-cloud.fr` fonctionne. |
|
||||
| **Token ETAPI** | ⏳ En attente | À générer dans Trilium (Options → ETAPI → Create new token). |
|
||||
| **Scripts Python** | ✅ Prêts | `init_trilium_architecture.py`, `trilium_api.py`, etc. |
|
||||
|
||||
### Tâches en Cours
|
||||
| Tâche | Statut | Prochaine Étape |
|
||||
|-------|--------|-----------------|
|
||||
| **Initialisation de l’arborescence** | ⏳ En attente | Exécuter `init_trilium_architecture.py`. |
|
||||
| **Test des scripts** | ⏳ En attente | Vérifier que les notes sont créées dans Trilium. |
|
||||
| **Intégration avec les LLM** | ⏳ En attente | Utiliser `generate_context.py` avant chaque session LLM. |
|
||||
|
||||
### ❌ Tâches Restantes
|
||||
| Tâche | Priorité | Dépendances |
|
||||
|-------|----------|-------------|
|
||||
| **Générer le token ETAPI** | ⭐⭐⭐ | Nécessaire pour les scripts Python. |
|
||||
| **Exécuter `init_trilium_architecture.py`** | ⭐⭐⭐ | Nécessite le token ETAPI. |
|
||||
| **Créer la première conversation** | ⭐⭐ | Nécessite l’arborescence Trilium. |
|
||||
| **Tester le workflow complet** | ⭐⭐ | Nécessite les scripts et l’arborescence. |
|
||||
| **Automatiser la synchronisation** | ⭐ | Nécessite un cron job ou un déclencheur manuel. |
|
||||
|
||||
---
|
||||
|
||||
## Tests à Effectuer
|
||||
|
||||
### 1. **Test de l’API ETAPI**
|
||||
```bash
|
||||
# Remplace TON_TOKEN par ton token ETAPI
|
||||
curl -H "Authorization: TON_TOKEN" http://localhost:4292/etapi/notes
|
||||
```
|
||||
**Résultat attendu** : Une liste de notes au format JSON.
|
||||
|
||||
### 2. **Test du Reverse Proxy**
|
||||
- Ouvre `https://trilium.bertha-cloud.fr` dans un navigateur.
|
||||
**Résultat attendu** : Accès à Trilium sans erreur SSL.
|
||||
|
||||
### 3. **Test des Scripts Python**
|
||||
```bash
|
||||
# Initialiser l'arborescence
|
||||
python3 init_trilium_architecture.py
|
||||
|
||||
# Créer une conversation
|
||||
python3 create_conversation.py
|
||||
|
||||
# Générer un contexte
|
||||
python3 generate_context.py > contexte.md
|
||||
```
|
||||
**Résultat attendu** :
|
||||
- Dossiers créés dans Trilium.
|
||||
- Note de conversation ajoutée.
|
||||
- Fichier `contexte.md` généré.
|
||||
|
||||
---
|
||||
|
||||
## Prochaines Étapes avec Claude
|
||||
|
||||
### 1. **Finaliser la Configuration de Trilium**
|
||||
- [ ] **Générer le token ETAPI** dans Trilium (Options → ETAPI → Create new token).
|
||||
- [ ] **Exécuter `init_trilium_architecture.py`** pour créer l’arborescence.
|
||||
- [ ] **Vérifier les IDs des dossiers** dans `trilium_folder_ids.json`.
|
||||
|
||||
### 2. **Tester les Scripts**
|
||||
- [ ] **Tester `create_conversation.py`** avec une conversation factice.
|
||||
- [ ] **Tester `generate_context.py`** pour générer un contexte.
|
||||
- [ ] **Corriger les erreurs** si nécessaire (ex: permissions, token invalide).
|
||||
|
||||
### 3. **Intégrer avec les LLM**
|
||||
- [ ] **Avant une session LLM** : Exécuter `generate_context.py` pour récupérer le contexte.
|
||||
- [ ] **Après une session LLM** : Exécuter `create_conversation.py` pour ajouter la conversation à Trilium.
|
||||
- [ ] **Automatiser** avec un cron job ou un script wrapper.
|
||||
|
||||
### 4. **Améliorations Futures**
|
||||
- [ ] **Ajouter un système de versioning** pour les notes (ex: champ `version`).
|
||||
- [ ] **Intégrer un LLM local** (ex: Mistral 7B) pour résumer les contextes.
|
||||
- [ ] **Créer un dashboard** pour visualiser les projets et conversations.
|
||||
|
||||
---
|
||||
|
||||
## Notes Techniques
|
||||
|
||||
### 1. **Contraintes Synology**
|
||||
- **Commandes terminal** : Toujours sur **1 seule ligne** (contrainte DS218).
|
||||
- **Docker** : Utilise `Container Manager 24.0.2-1606` (script communautaire 007revad).
|
||||
- **synoinfo.conf** : `unique=synology_rtd1296_ds220j` (à vérifier après les mises à jour DSM).
|
||||
|
||||
### 2. **Sécurité**
|
||||
- **Token ETAPI** : À conserver **secrétisé** (ne jamais le commiter dans Git).
|
||||
- **Certificat SSL** : Let’s Encrypt géré via DSM (renouvellement automatique).
|
||||
- **Accès externe** : Vérifier que le **port 443** est ouvert sur la Livebox.
|
||||
|
||||
### 3. **Performances**
|
||||
- **Limite de tokens** : Briefing ≤ 10K tokens pour éviter les coûts excessifs.
|
||||
- **API Trilium** : Éviter les requêtes trop fréquentes (limite à 10 notes par appel dans `generate_context.py`).
|
||||
|
||||
---
|
||||
|
||||
## Liens Utiles
|
||||
- **Trilium** : [https://trilium.bertha-cloud.fr](https://trilium.bertha-cloud.fr)
|
||||
- **Documentation ETAPI** : [http://localhost:4292/etapi/](http://localhost:4292/etapi/)
|
||||
- **Docker Trilium** : [https://hub.docker.com/r/triliumnext/notes](https://hub.docker.com/r/triliumnext/notes)
|
||||
|
||||
---
|
||||
|
||||
## Calendrier Estimé
|
||||
| Étape | Durée | Priorité |
|
||||
|-------|-------|----------|
|
||||
| Générer le token ETAPI | 5 min | ⭐⭐⭐ |
|
||||
| Exécuter `init_trilium_architecture.py` | 10 min | ⭐⭐⭐ |
|
||||
| Tester les scripts | 30 min | ⭐⭐⭐ |
|
||||
| Intégrer avec les LLM | 1h | ⭐⭐ |
|
||||
| Automatiser la synchronisation | 1h | ⭐ |
|
||||
|
||||
---
|
||||
|
||||
## Points à Discuter avec Claude
|
||||
1. **Validation du token ETAPI** : Confirmer que le token est bien généré et fonctionnel.
|
||||
2. **Tests des scripts** : Vérifier que `init_trilium_architecture.py` crée bien l’arborescence.
|
||||
3. **Intégration LLM** : Comment partager le contexte généré avec Claude/Le Chat ?
|
||||
4. **Améliorations** : Faut-il ajouter des champs spécifiques (ex: `priorité`, `tags`) aux notes ?
|
||||
5. **Automatisation** : Comment déclencher les scripts (cron, manuel, autre) ?
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
test_api.py — Tests unitaires FastAPI Context Continuity
|
||||
Usage : pytest test_api.py -v
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from fastapi.testclient import TestClient
|
||||
from api_context import app
|
||||
|
||||
CLIENT = TestClient(app)
|
||||
HEADERS_CLAUDE = {"Authorization": "VyFeOxT1nfyPULlx4z5uts7p5R_RrJIOZ3y_f1aoMk8"}
|
||||
HEADERS_LECHAT = {"Authorization": "WE2gw12Eerz0H-kV6SHNDAq-NN9Z6I81KAq3T2giNME"}
|
||||
HEADERS_INVALID = {"Authorization": "mauvaise_cle"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NOTE_MOCK = {
|
||||
"noteId": "test123",
|
||||
"title": "Note Test",
|
||||
"utcDateCreated": "2026-05-30T10:00:00Z",
|
||||
"utcDateModified": "2026-05-30T10:00:00Z",
|
||||
"attributes": [
|
||||
{"type": "label", "name": "type", "value": "backlogItem"},
|
||||
{"type": "label", "name": "projet", "value": "TestProjet"},
|
||||
{"type": "label", "name": "statut", "value": "a faire"},
|
||||
{"type": "label", "name": "priorite","value": "haute"},
|
||||
],
|
||||
"childNoteIds": [],
|
||||
"parentNoteIds": ["root"],
|
||||
}
|
||||
|
||||
CREATE_RESULT = {
|
||||
"note": {"noteId": "new456", "title": "Nouvelle Note",
|
||||
"utcDateCreated": "2026-05-30T10:00:00Z",
|
||||
"utcDateModified": "2026-05-30T10:00:00Z",
|
||||
"attributes": [], "childNoteIds": [], "parentNoteIds": []},
|
||||
"branch": {}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests Auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAuth:
|
||||
def test_health_no_auth(self):
|
||||
"""Health ne nécessite pas d'auth."""
|
||||
r = CLIENT.get("/api/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
|
||||
def test_protected_no_auth(self):
|
||||
r = CLIENT.get("/api/backlog/TestProjet")
|
||||
assert r.status_code == 422 # Header manquant
|
||||
|
||||
def test_protected_invalid_key(self):
|
||||
r = CLIENT.get("/api/backlog/TestProjet", headers=HEADERS_INVALID)
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_auth_claude(self):
|
||||
with patch("trilium_api.search_by_label", return_value=[]), \
|
||||
patch("trilium_api.get_label_value", return_value="a faire"):
|
||||
r = CLIENT.get("/api/backlog/TestProjet", headers=HEADERS_CLAUDE)
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_auth_lechat(self):
|
||||
with patch("trilium_api.search_by_label", return_value=[]), \
|
||||
patch("trilium_api.get_label_value", return_value="a faire"):
|
||||
r = CLIENT.get("/api/backlog/TestProjet", headers=HEADERS_LECHAT)
|
||||
assert r.status_code == 200
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests Health
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHealth:
|
||||
def test_health(self):
|
||||
r = CLIENT.get("/api/health")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["status"] == "ok"
|
||||
assert "version" in data
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests Backlog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBacklog:
|
||||
def test_list_backlog_vide(self):
|
||||
with patch("trilium_api.search_by_label", return_value=[]), \
|
||||
patch("trilium_api.get_label_value", return_value="a faire"):
|
||||
r = CLIENT.get("/api/backlog/ProjetInexistant", headers=HEADERS_CLAUDE)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
|
||||
def test_list_backlog(self):
|
||||
with patch("trilium_api.search_by_label", return_value=[NOTE_MOCK]), \
|
||||
patch("trilium_api.get_label_value", side_effect=lambda nid, key: {
|
||||
"projet": "TestProjet", "statut": "a faire", "priorite": "haute"
|
||||
}.get(key, "")), \
|
||||
patch("trilium_api.get_note", return_value=NOTE_MOCK):
|
||||
r = CLIENT.get("/api/backlog/TestProjet", headers=HEADERS_CLAUDE)
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_create_backlog(self):
|
||||
with patch("builtins.open", MagicMock(
|
||||
return_value=MagicMock(
|
||||
__enter__=MagicMock(return_value=MagicMock(
|
||||
read=MagicMock(return_value='{"Backlog":"root123"}'))),
|
||||
__exit__=MagicMock(return_value=False)))), \
|
||||
patch("json.load", return_value={"Backlog": "root123"}), \
|
||||
patch("trilium_api.create_note", return_value=CREATE_RESULT), \
|
||||
patch("trilium_api.get_note_id", return_value="new456"), \
|
||||
patch("trilium_api.set_label", return_value=None):
|
||||
r = CLIENT.post("/api/backlog", headers=HEADERS_CLAUDE,
|
||||
json={"projet": "TestProjet", "titre": "Ma tâche",
|
||||
"priorite": "haute"})
|
||||
assert r.status_code == 201
|
||||
assert r.json()["titre"] == "Ma tâche"
|
||||
assert r.json()["created_by"] == "Claude"
|
||||
|
||||
def test_create_backlog_priorite_invalide(self):
|
||||
r = CLIENT.post("/api/backlog", headers=HEADERS_CLAUDE,
|
||||
json={"projet": "TestProjet", "titre": "Tâche",
|
||||
"priorite": "ULTRA"})
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_patch_backlog(self):
|
||||
with patch("trilium_api.get_note", return_value=NOTE_MOCK), \
|
||||
patch("trilium_api.set_label", return_value=None):
|
||||
r = CLIENT.patch("/api/backlog/test123", headers=HEADERS_CLAUDE,
|
||||
json={"statut": "en cours"})
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_patch_backlog_inexistant(self):
|
||||
with patch("trilium_api.get_note", side_effect=Exception("404")):
|
||||
r = CLIENT.patch("/api/backlog/inexistant", headers=HEADERS_CLAUDE,
|
||||
json={"statut": "fait"})
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_patch_backlog_statut_invalide(self):
|
||||
r = CLIENT.patch("/api/backlog/test123", headers=HEADERS_CLAUDE,
|
||||
json={"statut": "statut_invalide"})
|
||||
assert r.status_code == 422
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests Décisions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDecisions:
|
||||
def test_list_decisions_vide(self):
|
||||
with patch("trilium_api.search_by_label", return_value=[]), \
|
||||
patch("trilium_api.get_label_value", return_value="active"):
|
||||
r = CLIENT.get("/api/decisions/TestProjet", headers=HEADERS_CLAUDE)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
|
||||
def test_create_decision(self):
|
||||
with patch("json.load", return_value={"Decisions": "dec123"}), \
|
||||
patch("builtins.open", MagicMock(
|
||||
return_value=MagicMock(
|
||||
__enter__=MagicMock(return_value=MagicMock()),
|
||||
__exit__=MagicMock(return_value=False)))), \
|
||||
patch("trilium_api.create_note", return_value=CREATE_RESULT), \
|
||||
patch("trilium_api.get_note_id", return_value="new456"), \
|
||||
patch("trilium_api.set_label", return_value=None):
|
||||
r = CLIENT.post("/api/decisions", headers=HEADERS_LECHAT,
|
||||
json={"projet": "TestProjet",
|
||||
"enonce": "On utilise FastAPI",
|
||||
"justification": "Validation auto Pydantic"})
|
||||
assert r.status_code == 201
|
||||
assert r.json()["created_by"] == "LeChat"
|
||||
|
||||
def test_patch_decision_annuler(self):
|
||||
with patch("trilium_api.get_note", return_value=NOTE_MOCK), \
|
||||
patch("trilium_api.set_label", return_value=None), \
|
||||
patch("trilium_api.get_note_content", return_value="<p>contenu</p>"), \
|
||||
patch("trilium_api.update_note_content", return_value=None):
|
||||
r = CLIENT.patch("/api/decisions/test123", headers=HEADERS_CLAUDE,
|
||||
json={"statut": "annulee"})
|
||||
assert r.status_code == 200
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests Historique
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHistorique:
|
||||
def test_create_historique_valide(self):
|
||||
with patch("json.load", return_value={"Historique": "hist123"}), \
|
||||
patch("builtins.open", MagicMock(
|
||||
return_value=MagicMock(
|
||||
__enter__=MagicMock(return_value=MagicMock()),
|
||||
__exit__=MagicMock(return_value=False)))), \
|
||||
patch("trilium_api.create_note", return_value=CREATE_RESULT), \
|
||||
patch("trilium_api.get_note_id", return_value="new456"), \
|
||||
patch("trilium_api.set_label", return_value=None):
|
||||
r = CLIENT.post("/api/historique", headers=HEADERS_CLAUDE,
|
||||
json={"projet": "TestProjet",
|
||||
"enonce": "Test X effectue",
|
||||
"type": "Test effectue",
|
||||
"detail": "Resultat OK"})
|
||||
assert r.status_code == 201
|
||||
|
||||
def test_create_historique_type_invalide(self):
|
||||
r = CLIENT.post("/api/historique", headers=HEADERS_CLAUDE,
|
||||
json={"projet": "TestProjet",
|
||||
"enonce": "Test",
|
||||
"type": "Type invalide"})
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_patch_invalider(self):
|
||||
with patch("trilium_api.get_note", return_value=NOTE_MOCK), \
|
||||
patch("trilium_api.set_label", return_value=None), \
|
||||
patch("trilium_api.get_note_content", return_value="<p>ok</p>"), \
|
||||
patch("trilium_api.update_note_content", return_value=None):
|
||||
r = CLIENT.patch("/api/historique/test123", headers=HEADERS_CLAUDE,
|
||||
json={"encore_valide": False})
|
||||
assert r.status_code == 200
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests Conversations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConversations:
|
||||
def test_create_conversation(self):
|
||||
with patch("json.load", return_value={"Conversations": "conv123"}), \
|
||||
patch("builtins.open", MagicMock(
|
||||
return_value=MagicMock(
|
||||
__enter__=MagicMock(return_value=MagicMock()),
|
||||
__exit__=MagicMock(return_value=False)))), \
|
||||
patch("trilium_api.create_note", return_value=CREATE_RESULT), \
|
||||
patch("trilium_api.get_note_id", return_value="new456"), \
|
||||
patch("trilium_api.set_label", return_value=None):
|
||||
r = CLIENT.post("/api/conversations", headers=HEADERS_CLAUDE,
|
||||
json={"projet": "TestProjet",
|
||||
"titre": "Session debug",
|
||||
"llm": "Claude Sonnet"})
|
||||
assert r.status_code == 201
|
||||
|
||||
def test_create_conversation_llm_invalide(self):
|
||||
r = CLIENT.post("/api/conversations", headers=HEADERS_CLAUDE,
|
||||
json={"projet": "TestProjet",
|
||||
"titre": "Session",
|
||||
"llm": "GPT-4"})
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_patch_cloture(self):
|
||||
with patch("trilium_api.get_note", return_value=NOTE_MOCK), \
|
||||
patch("trilium_api.set_label", return_value=None), \
|
||||
patch("trilium_api.get_note_content",
|
||||
return_value="<i>À remplir en fin de session</i>"), \
|
||||
patch("trilium_api.update_note_content", return_value=None):
|
||||
r = CLIENT.patch("/api/conversations/test123", headers=HEADERS_CLAUDE,
|
||||
json={"synthese_cloture": "Session terminee. Reste X."})
|
||||
assert r.status_code == 200
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests Glossaire
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGlossaire:
|
||||
def test_create_terme(self):
|
||||
with patch("json.load", return_value={"Glossaire": "glos123"}), \
|
||||
patch("builtins.open", MagicMock(
|
||||
return_value=MagicMock(
|
||||
__enter__=MagicMock(return_value=MagicMock()),
|
||||
__exit__=MagicMock(return_value=False)))), \
|
||||
patch("trilium_api.create_note", return_value=CREATE_RESULT), \
|
||||
patch("trilium_api.get_note_id", return_value="new456"), \
|
||||
patch("trilium_api.set_label", return_value=None):
|
||||
r = CLIENT.post("/api/glossaire", headers=HEADERS_CLAUDE,
|
||||
json={"projet": "TestProjet",
|
||||
"terme": "Pipeline",
|
||||
"definition": "Chaine de traitement complète"})
|
||||
assert r.status_code == 201
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests Contexte Reprise
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestContexteReprise:
|
||||
def test_get_contexte_vide(self):
|
||||
with patch("trilium_api.search_by_label", return_value=[]), \
|
||||
patch("trilium_api.get_label_value", return_value="active"):
|
||||
r = CLIENT.get("/api/contexte/ProjetVide",
|
||||
headers=HEADERS_CLAUDE)
|
||||
assert r.status_code == 200
|
||||
assert "briefing" in r.json()
|
||||
assert "tokens" in r.json()
|
||||
|
||||
def test_get_last_contexte_inexistant(self):
|
||||
with patch("trilium_api.search_by_label", return_value=[]), \
|
||||
patch("trilium_api.get_label_value", return_value=None):
|
||||
r = CLIENT.get("/api/contexte/ProjetVide/last",
|
||||
headers=HEADERS_CLAUDE)
|
||||
assert r.status_code == 404
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests Docs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDocs:
|
||||
def test_swagger_accessible(self):
|
||||
r = CLIENT.get("/api/docs")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_openapi_accessible(self):
|
||||
r = CLIENT.get("/api/openapi.json")
|
||||
assert r.status_code == 200
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
trilium_api.py — Wrapper trilium-py pour TriliumNext 0.95+
|
||||
Tous les autres scripts importent depuis ce module uniquement.
|
||||
"""
|
||||
import os
|
||||
from typing import Optional
|
||||
from dotenv import load_dotenv
|
||||
from trilium_py.client import ETAPI
|
||||
|
||||
load_dotenv()
|
||||
|
||||
_TRILIUM_URL = os.getenv("TRILIUM_URL", "http://localhost:4292")
|
||||
_TRILIUM_TOKEN = os.getenv("TRILIUM_TOKEN", "")
|
||||
|
||||
ea: Optional[ETAPI] = None
|
||||
|
||||
def check_api():
|
||||
global ea
|
||||
try:
|
||||
ea = ETAPI(_TRILIUM_URL, _TRILIUM_TOKEN)
|
||||
result = ea.get_note("root")
|
||||
if not result or "noteId" not in result:
|
||||
raise ConnectionError("Réponse inattendue")
|
||||
return True
|
||||
except Exception as e:
|
||||
raise SystemExit(
|
||||
f"\n❌ Trilium inaccessible sur {_TRILIUM_URL}"
|
||||
f"\n Détail : {e}"
|
||||
"\n sudo docker ps | grep trilium"
|
||||
"\n sudo docker start trilium\n"
|
||||
)
|
||||
|
||||
def _ea() -> ETAPI:
|
||||
global ea
|
||||
if ea is None:
|
||||
ea = ETAPI(_TRILIUM_URL, _TRILIUM_TOKEN)
|
||||
return ea
|
||||
|
||||
def _note_id(result: dict) -> str:
|
||||
"""Extrait le noteId quel que soit le format retourné par trilium-py."""
|
||||
if "note" in result:
|
||||
return result["note"]["noteId"]
|
||||
if "noteId" in result:
|
||||
return result["noteId"]
|
||||
raise ValueError(f"Format inattendu : {list(result.keys())}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_note(parent_id: str, title: str, content: str = " ",
|
||||
note_type: str = "text") -> dict:
|
||||
return _ea().create_note(
|
||||
parentNoteId=parent_id, title=title,
|
||||
type=note_type, content=content
|
||||
)
|
||||
|
||||
def get_note_id(result: dict) -> str:
|
||||
"""Helper public pour extraire le noteId d'un résultat create_note."""
|
||||
return _note_id(result)
|
||||
|
||||
def get_note(note_id: str) -> dict:
|
||||
return _ea().get_note(note_id)
|
||||
|
||||
def get_note_content(note_id: str) -> str:
|
||||
result = _ea().get_note_content(note_id)
|
||||
if isinstance(result, bytes):
|
||||
return result.decode("utf-8")
|
||||
return result or ""
|
||||
|
||||
def update_note_content(note_id: str, content: str):
|
||||
_ea().update_note_content(note_id, content)
|
||||
|
||||
def get_children(note_id: str) -> list:
|
||||
note = _ea().get_note(note_id)
|
||||
return [{"noteId": cid} for cid in note.get("childNoteIds", [])]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recherche
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def search_notes(query: str, limit: int = 50) -> list:
|
||||
result = _ea().search_note(search=query, limit=limit)
|
||||
return result.get("results", []) if isinstance(result, dict) else []
|
||||
|
||||
def search_by_label(label: str, value: str = "", limit: int = 50) -> list:
|
||||
query = f"#{label}" if not value else f"#{label}={value}"
|
||||
return search_notes(query, limit)
|
||||
|
||||
def find_note_by_title(title: str, parent_id: str = "") -> Optional[str]:
|
||||
for note in search_notes(title):
|
||||
if note.get("title", "").strip().lower() == title.strip().lower():
|
||||
if not parent_id:
|
||||
return note["noteId"]
|
||||
detail = get_note(note["noteId"])
|
||||
if parent_id in detail.get("parentNoteIds", []):
|
||||
return note["noteId"]
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attributs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def set_label(note_id: str, name: str, value: str = ""):
|
||||
"""Cree ou met a jour un label via patch_attribute natif (pas d empilement)."""
|
||||
ea = _ea()
|
||||
note = get_note(note_id)
|
||||
existants = [a for a in note.get("attributes", [])
|
||||
if a.get("type") == "label" and a.get("name") == name]
|
||||
if existants:
|
||||
ea.patch_attribute(attributeId=existants[0].get("attributeId"), value=value)
|
||||
else:
|
||||
ea.create_attribute(noteId=note_id, type="label",
|
||||
name=name, value=value, isInheritable=False)
|
||||
|
||||
def get_label_value(note_id: str, name: str) -> Optional[str]:
|
||||
note = get_note(note_id)
|
||||
for a in note.get("attributes", []):
|
||||
if a.get("type") == "label" and a.get("name") == name:
|
||||
return a.get("value", "")
|
||||
try:
|
||||
attrs = _ea().get_note_attributes(note_id)
|
||||
if isinstance(attrs, list):
|
||||
for a in attrs:
|
||||
if a.get("type") == "label" and a.get("name") == name:
|
||||
return a.get("value", "")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
TYPES_SYSTEME = ["backlogItem","decision","historiqueItem","conversation","termeGlossaire","projet","contexteReprise","skill","documentation"]
|
||||
|
||||
def delete_note_safe(note_id: str):
|
||||
"""Supprime une note SEULEMENT si elle porte un label type connu du systeme.
|
||||
Garde-fou contre la suppression accidentelle de dossiers/notes hors systeme.
|
||||
Retourne (True, message) ou (False, message)."""
|
||||
note = get_note(note_id)
|
||||
if not note:
|
||||
return (False, "Note introuvable")
|
||||
type_val = None
|
||||
for a in note.get("attributes", []):
|
||||
if a.get("type") == "label" and a.get("name") == "type":
|
||||
type_val = a.get("value")
|
||||
break
|
||||
if type_val not in TYPES_SYSTEME:
|
||||
return (False, "Refus : note sans type systeme connu (type=%s)" % type_val)
|
||||
titre = note.get("title", "?")
|
||||
ok = _ea().delete_note(note_id)
|
||||
if ok:
|
||||
return (True, "Note supprimee : %s (%s)" % (titre, note_id))
|
||||
return (False, "Echec suppression cote Trilium")
|
||||
|
||||
|
||||
def move_note_safe(note_id, ancien_parent_id, nouveau_parent_id):
|
||||
"""Deplace une note d un parent vers un autre (gere les clones : ne touche
|
||||
que la branche depuis ancien_parent_id). Garde-fou leger : verifie l existence
|
||||
de la note et du nouveau parent. Retourne (True, message) ou (False, message)."""
|
||||
ea = _ea()
|
||||
note = get_note(note_id)
|
||||
if not note:
|
||||
return (False, "Note introuvable: %s" % note_id)
|
||||
if not get_note(nouveau_parent_id):
|
||||
return (False, "Nouveau parent introuvable: %s" % nouveau_parent_id)
|
||||
# Retrouver la branche reelle depuis l ancien parent
|
||||
cible_branche = None
|
||||
for bid in note.get("parentBranchIds", []):
|
||||
if bid.startswith(ancien_parent_id + "_"):
|
||||
cible_branche = bid
|
||||
break
|
||||
if not cible_branche:
|
||||
return (False, "Aucune branche depuis le parent %s" % ancien_parent_id)
|
||||
# Creer la nouvelle branche puis supprimer l ancienne
|
||||
try:
|
||||
ea.create_branch(noteId=note_id, parentNoteId=nouveau_parent_id,
|
||||
prefix="", isExpanded=False, notePosition=10)
|
||||
except Exception as e:
|
||||
return (False, "Echec create_branch: %s" % e)
|
||||
try:
|
||||
ea.delete_branch(cible_branche)
|
||||
except Exception as e:
|
||||
return (False, "Branche creee mais echec suppression ancienne (%s): %s" % (cible_branche, e))
|
||||
return (True, "Note %s deplacee de %s vers %s" % (note_id, ancien_parent_id, nouveau_parent_id))
|
||||
|
||||
|
||||
# Relations autorisees par l ontologie (anti-drift). Le controle domaine/portee
|
||||
# fin est delegue au Lint ; ici on bloque seulement les noms hors ontologie.
|
||||
RELATIONS_ONTOLOGIE = [
|
||||
"concerneProjet", "meneePar", "priseDans", "produitDans", "synthetise",
|
||||
"destineA", "hebergeSur", "implementePar", "dependDe", "aVersion",
|
||||
"documentePar", "decrit", "impacte", "contraintPar", "revise", "invalide",
|
||||
"reference", "illustre", "provient",
|
||||
]
|
||||
|
||||
def add_relation_safe(source_id, nom, cible_id):
|
||||
"""Cree une relation (object property) entre deux notes.
|
||||
Garde-fou Option B : nom dans l ontologie + source/cible existent.
|
||||
Idempotent : ne recree pas une relation identique deja presente.
|
||||
Retourne (True, message) ou (False, message)."""
|
||||
if nom not in RELATIONS_ONTOLOGIE:
|
||||
return (False, "Relation hors ontologie: %s (voir RELATIONS_ONTOLOGIE)" % nom)
|
||||
src = get_note(source_id)
|
||||
if not src:
|
||||
return (False, "Note source introuvable: %s" % source_id)
|
||||
if not get_note(cible_id):
|
||||
return (False, "Note cible introuvable: %s" % cible_id)
|
||||
# Idempotence : relation identique deja la ?
|
||||
for a in src.get("attributes", []):
|
||||
if a.get("type") == "relation" and a.get("name") == nom and a.get("value") == cible_id:
|
||||
return (True, "Relation deja presente: %s ~%s %s" % (source_id, nom, cible_id))
|
||||
try:
|
||||
_ea().create_attribute(noteId=source_id, type="relation",
|
||||
name=nom, value=cible_id, isInheritable=False)
|
||||
except Exception as e:
|
||||
return (False, "Echec creation relation: %s" % e)
|
||||
return (True, "Relation creee: %s ~%s %s" % (source_id, nom, cible_id))
|
||||
@@ -0,0 +1,316 @@
|
||||
# Trilium Automation Setup - Fichier Unique avec Tous les Scripts
|
||||
# Projet : Context Continuity
|
||||
# Date : 27 mai 2026
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
Ce fichier contient tous les scripts Python et configurations nécessaires pour automatiser la gestion de contexte multi-LLM avec Trilium.
|
||||
|
||||
---
|
||||
|
||||
## Structure des Fichiers
|
||||
```
|
||||
trilium_automation/
|
||||
├── init_trilium_architecture.py # Initialise l'arborescence Trilium
|
||||
├── trilium_api.py # Bibliothèque pour interagir avec l'API ETAPI
|
||||
├── create_conversation.py # Crée des notes de conversation LLM
|
||||
├── generate_context.py # Génère un contexte pour les LLM
|
||||
└── .env.example # Exemple de fichier d'environnement
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Fichier .env.example
|
||||
```ini
|
||||
# Fichier : .env
|
||||
# À placer dans /volume1/homes/Master/App/Automation/
|
||||
# Ne jamais commiter ce fichier dans Git !
|
||||
|
||||
TRILIUM_TOKEN=ton_token_etapi_ici
|
||||
TRILIUM_API_URL=http://localhost:4292/etapi
|
||||
ROOT_NOTE_ID=root
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Script : init_trilium_architecture.py
|
||||
```python
|
||||
import requests
|
||||
import json
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Charger les variables d'environnement depuis .env
|
||||
load_dotenv()
|
||||
|
||||
# ===== CONFIGURATION =====
|
||||
TRILIUM_API_URL = os.getenv("TRILIUM_API_URL", "http://localhost:4292/etapi")
|
||||
TRILIUM_TOKEN = os.getenv("TRILIUM_TOKEN")
|
||||
HEADERS = {
|
||||
"Authorization": TRILIUM_TOKEN,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# ID de la note racine (root) - à remplacer par le tien
|
||||
ROOT_NOTE_ID = os.getenv("ROOT_NOTE_ID", "root")
|
||||
|
||||
# ===== FONCTIONS =====
|
||||
def create_note(parent_id, title, note_type="book"):
|
||||
"""Crée une note de type 'book' (dossier) ou 'text' dans Trilium."""
|
||||
data = {
|
||||
"parentNoteId": parent_id,
|
||||
"title": title,
|
||||
"type": note_type,
|
||||
"content": f"# {title}\n\n*Dossier créé automatiquement via API ETAPI.*"
|
||||
}
|
||||
response = requests.post(
|
||||
f"{TRILIUM_API_URL}/notes",
|
||||
headers=HEADERS,
|
||||
json=data
|
||||
)
|
||||
if response.status_code == 200:
|
||||
note_id = response.json().get("noteId")
|
||||
print(f"✅ Dossier créé : {title} (ID: {note_id})")
|
||||
return note_id
|
||||
else:
|
||||
raise Exception(f"❌ Erreur {response.status_code} : {response.text}")
|
||||
|
||||
def get_note_id_by_title(title, parent_id):
|
||||
"""Récupère l'ID d'une note par son titre (pour éviter les doublons)."""
|
||||
search_url = f"{TRILIUM_API_URL}/search?q=title:{title}"
|
||||
response = requests.get(search_url, headers=HEADERS)
|
||||
if response.status_code == 200:
|
||||
notes = response.json()
|
||||
for note in notes:
|
||||
if note.get("title") == title and note.get("parentNoteId") == parent_id:
|
||||
return note.get("noteId")
|
||||
return None
|
||||
|
||||
# ===== EXÉCUTION =====
|
||||
def main():
|
||||
print(" Initialisation de l'architecture Trilium pour 'Context Continuity'...")
|
||||
|
||||
# 1. Créer le dossier principal "Context Continuity" (s'il n'existe pas)
|
||||
context_continuity_id = get_note_id_by_title("Context Continuity", ROOT_NOTE_ID)
|
||||
if not context_continuity_id:
|
||||
context_continuity_id = create_note(ROOT_NOTE_ID, "Context Continuity", "book")
|
||||
else:
|
||||
print(f"✅ Dossier existant : Context Continuity (ID: {context_continuity_id})")
|
||||
|
||||
# 2. Créer les sous-dossiers
|
||||
subfolders = ["Conversations", "Backlog", "Décisions", "Glossaire", "Historique"]
|
||||
folder_ids = {}
|
||||
|
||||
for folder in subfolders:
|
||||
folder_id = get_note_id_by_title(folder, context_continuity_id)
|
||||
if not folder_id:
|
||||
folder_id = create_note(context_continuity_id, folder, "book")
|
||||
else:
|
||||
print(f"✅ Dossier existant : {folder} (ID: {folder_id})")
|
||||
folder_ids[folder] = folder_id
|
||||
|
||||
# 3. Afficher les IDs pour référence
|
||||
print("\n IDs des dossiers créés (à utiliser dans tes scripts) :")
|
||||
for folder, folder_id in folder_ids.items():
|
||||
print(f"{folder}: {folder_id}")
|
||||
|
||||
# 4. Sauvegarder les IDs dans un fichier JSON
|
||||
with open("trilium_folder_ids.json", "w") as f:
|
||||
json.dump({
|
||||
"root_id": ROOT_NOTE_ID,
|
||||
"context_continuity_id": context_continuity_id,
|
||||
**folder_ids
|
||||
}, f, indent=4)
|
||||
print("\n IDs sauvegardés dans trilium_folder_ids.json")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Script : trilium_api.py
|
||||
```python
|
||||
import requests
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Charger les variables d'environnement
|
||||
load_dotenv()
|
||||
|
||||
TRILIUM_API_URL = os.getenv("TRILIUM_API_URL", "http://localhost:4292/etapi")
|
||||
TRILIUM_TOKEN = os.getenv("TRILIUM_TOKEN")
|
||||
HEADERS = {
|
||||
"Authorization": TRILIUM_TOKEN,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def create_note(parent_id, title, content, note_type="text"):
|
||||
"""Crée une note dans Trilium."""
|
||||
data = {
|
||||
"parentNoteId": parent_id,
|
||||
"title": title,
|
||||
"type": note_type,
|
||||
"content": content
|
||||
}
|
||||
response = requests.post(
|
||||
f"{TRILIUM_API_URL}/notes",
|
||||
headers=HEADERS,
|
||||
json=data
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json().get("noteId")
|
||||
else:
|
||||
raise Exception(f"Erreur {response.status_code} : {response.text}")
|
||||
|
||||
def get_note(note_id):
|
||||
"""Récupère une note par son ID."""
|
||||
response = requests.get(
|
||||
f"{TRILIUM_API_URL}/notes/{note_id}",
|
||||
headers=HEADERS
|
||||
)
|
||||
return response.json()
|
||||
|
||||
def update_note(note_id, content):
|
||||
"""Met à jour une note existante."""
|
||||
data = {"content": content}
|
||||
response = requests.put(
|
||||
f"{TRILIUM_API_URL}/notes/{note_id}",
|
||||
headers=HEADERS,
|
||||
json=data
|
||||
)
|
||||
return response.status_code == 200
|
||||
|
||||
def search_notes(query):
|
||||
"""Recherche des notes par titre ou contenu."""
|
||||
response = requests.get(
|
||||
f"{TRILIUM_API_URL}/search?q={query}",
|
||||
headers=HEADERS
|
||||
)
|
||||
return response.json()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Script : create_conversation.py
|
||||
```python
|
||||
from trilium_api import create_note
|
||||
from datetime import datetime
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# IDs des dossiers (à remplacer par les tiens)
|
||||
CONVERSATIONS_ID = os.getenv("CONVERSATIONS_ID", "ID_DU_DOSSIER_CONVERSATIONS")
|
||||
|
||||
def create_conversation(llm, title, content):
|
||||
"""Crée une note de conversation dans Trilium."""
|
||||
date = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
note_title = f"[{llm}] - {date} - {title}"
|
||||
note_content = f"""# {note_title}
|
||||
|
||||
**Projet** : [[Context Continuity]]
|
||||
**LLM** : {llm}
|
||||
**Date** : {date}
|
||||
**Statut** : En cours
|
||||
|
||||
---
|
||||
## Contexte
|
||||
{content}
|
||||
|
||||
---
|
||||
**Liens** :
|
||||
- [[Backlog/]]
|
||||
- [[Décisions/]]
|
||||
"""
|
||||
note_id = create_note(
|
||||
parent_id=CONVERSATIONS_ID,
|
||||
title=note_title,
|
||||
content=note_content
|
||||
)
|
||||
print(f"Note créée : {note_id} ({note_title})")
|
||||
return note_id
|
||||
|
||||
# Exemple d'utilisation
|
||||
if __name__ == "__main__":
|
||||
create_conversation(
|
||||
llm="Le Chat",
|
||||
title="Configuration Trilium pour Context Continuity",
|
||||
content="Discussion sur la configuration de Trilium pour synchroniser les contextes entre LLM."
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Script : generate_context.py
|
||||
```python
|
||||
from trilium_api import search_notes, get_note
|
||||
from datetime import datetime
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
CONTEXT_CONTINUITY_ID = os.getenv("CONTEXT_CONTINUITY_ID", "ID_DU_DOSSIER_CONTEXT_CONTINUITY")
|
||||
|
||||
def generate_context(project_name, limit=10):
|
||||
"""Génère un contexte pour un projet donné en agrégeant les notes Trilium."""
|
||||
query = f"projet:{project_name}"
|
||||
notes = search_notes(query)
|
||||
|
||||
context = f"# Contexte du projet : {project_name}\n\n"
|
||||
context += "## Conversations récentes\n"
|
||||
for note in notes[:limit]:
|
||||
note_data = get_note(note["noteId"])
|
||||
context += f"- **{note_data['title']}** (LLM: {note_data.get('llm', 'N/A')}, Date: {note_data.get('date', 'N/A')})\n"
|
||||
context += f" {note_data['content'][:200]}...\n\n"
|
||||
|
||||
context += f"\n*Généré le {datetime.now().strftime('%Y-%m-%d %H:%M')}*"
|
||||
return context
|
||||
|
||||
# Exemple d'utilisation
|
||||
if __name__ == "__main__":
|
||||
context = generate_context("Context Continuity")
|
||||
print(context)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Instructions d'Utilisation
|
||||
|
||||
### 1. Préparer l'environnement
|
||||
```bash
|
||||
# Installer les dépendances
|
||||
pip install requests python-dotenv
|
||||
|
||||
# Créer le fichier .env
|
||||
cd /volume1/homes/Master/App/Automation/
|
||||
echo "TRILIUM_TOKEN=ton_token_etapi" > .env
|
||||
echo "TRILIUM_API_URL=http://localhost:4292/etapi" >> .env
|
||||
echo "ROOT_NOTE_ID=root" >> .env
|
||||
```
|
||||
|
||||
### 2. Exécuter les scripts
|
||||
```bash
|
||||
# Initialiser l'arborescence Trilium
|
||||
python3 init_trilium_architecture.py
|
||||
|
||||
# Créer une conversation
|
||||
python3 create_conversation.py
|
||||
|
||||
# Générer un contexte
|
||||
python3 generate_context.py > contexte.md
|
||||
```
|
||||
|
||||
### 3. Vérifier les résultats
|
||||
- Ouvre Trilium et vérifie que les dossiers et notes sont créés.
|
||||
- Le fichier `trilium_folder_ids.json` contient les IDs des dossiers.
|
||||
|
||||
---
|
||||
|
||||
## Notes Importantes
|
||||
- **Ne jamais partager le fichier .env** (il contient ton token ETAPI).
|
||||
- Les scripts supposent que Trilium est accessible via `http://localhost:4292/etapi`.
|
||||
- Si tu utilises le Reverse Proxy, remplace `TRILIUM_API_URL` par `https://trilium.bertha-cloud.fr/etapi`.
|
||||
@@ -0,0 +1,365 @@
|
||||
"""
|
||||
trilium_context.py — Gestion opérationnelle du contexte LLM via Trilium
|
||||
Usage :
|
||||
python trilium_context.py new-conversation --projet SlidingAutomation --llm "Claude Sonnet" --titre "Session render engine"
|
||||
python trilium_context.py close-session --note-id <ID> --summary "..."
|
||||
python trilium_context.py generate-context --projet SlidingAutomation --llm-cible "Le Chat Large" [--note-id <ID>]
|
||||
python trilium_context.py list-backlog --projet SlidingAutomation
|
||||
python trilium_context.py add-decision --projet SlidingAutomation --enonce "..." --justification "..."
|
||||
python trilium_context.py add-history --projet SlidingAutomation --type "Test effectué" --enonce "..." --detail "..."
|
||||
python trilium_context.py list-projects
|
||||
python trilium_context.py add-skill --titre "Mon skill" --fichier skill.md --portee universel
|
||||
python trilium_context.py add-skill --titre "Mon skill" --fichier skill.md --portee projet --projet SlidingAutomation
|
||||
python trilium_context.py list-skills [--projet SlidingAutomation] [--portee universel]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from trilium_api import (check_api, create_note, get_note_id, get_note, get_note_content,
|
||||
update_note_content, search_by_label, set_label,
|
||||
get_label_value, find_note_by_title)
|
||||
|
||||
IDS_FILE = os.path.join(os.path.dirname(__file__), "trilium_ids.json")
|
||||
|
||||
GREEN, YELLOW, RED, BOLD, RESET = "\033[32m", "\033[33m", "\033[31m", "\033[1m", "\033[0m"
|
||||
def ok(m): print(f" {GREEN}✓{RESET} {m}")
|
||||
def warn(m): print(f" {YELLOW}⚠{RESET} {m}")
|
||||
def err(m): print(f" {RED}✗{RESET} {m}")
|
||||
def head(m): print(f"\n{BOLD}{m}{RESET}")
|
||||
|
||||
def load_ids() -> dict:
|
||||
if not os.path.exists(IDS_FILE):
|
||||
raise SystemExit("❌ trilium_ids.json introuvable. Lance d'abord : python trilium_init.py")
|
||||
with open(IDS_FILE) as f:
|
||||
return json.load(f)
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
return max(1, int(len(text) / 4))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commandes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_new_conversation(args):
|
||||
ids = load_ids()
|
||||
date = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
titre = f"[{args.llm}] {date} — {args.titre}"
|
||||
content = f"""<h2>{titre}</h2>
|
||||
<table>
|
||||
<tr><td><b>Projet</b></td><td>{args.projet}</td></tr>
|
||||
<tr><td><b>LLM</b></td><td>{args.llm}</td></tr>
|
||||
<tr><td><b>Date</b></td><td>{date}</td></tr>
|
||||
<tr><td><b>Synthèse de clôture</b></td><td><i>À remplir en fin de session</i></td></tr>
|
||||
</table>"""
|
||||
result = create_note(ids["Conversations"], titre, content)
|
||||
note_id = result["note"]["noteId"]
|
||||
set_label(note_id, "type", "conversation")
|
||||
set_label(note_id, "projet", args.projet)
|
||||
set_label(note_id, "llm", args.llm)
|
||||
set_label(note_id, "statut", "en-cours")
|
||||
set_label(note_id, "date", date)
|
||||
ok(f"Conversation créée : {titre}")
|
||||
print(f"\n Note l'ID pour close-session : {BOLD}{note_id}{RESET}\n")
|
||||
return note_id
|
||||
|
||||
def cmd_close_session(args):
|
||||
date = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
content = get_note_content(args.note_id)
|
||||
# Remplace le placeholder de synthèse
|
||||
if "À remplir en fin de session" in content:
|
||||
content = content.replace(
|
||||
"<i>À remplir en fin de session</i>",
|
||||
args.summary.replace("<", "<").replace(">", ">")
|
||||
)
|
||||
else:
|
||||
content += f"\n<h3>Synthèse de clôture ({date})</h3><p>{args.summary}</p>"
|
||||
update_note_content(args.note_id, content)
|
||||
set_label(args.note_id, "statut", "clos")
|
||||
set_label(args.note_id, "syntheseCloture", args.summary[:200])
|
||||
ok(f"Session clôturée (ID: {args.note_id})")
|
||||
|
||||
def cmd_generate_context(args):
|
||||
ids = load_ids()
|
||||
date = datetime.now().strftime("%d/%m/%Y")
|
||||
|
||||
# Récupérer les données du projet
|
||||
projets = search_by_label("projet", args.projet)
|
||||
decisions = search_by_label("type", "decision")
|
||||
decisions = [d for d in decisions if get_label_value(d["noteId"], "projet") == args.projet
|
||||
and get_label_value(d["noteId"], "statut") == "active"]
|
||||
historique = search_by_label("type", "historiqueItem")
|
||||
historique = [h for h in historique if get_label_value(h["noteId"], "projet") == args.projet
|
||||
and get_label_value(h["noteId"], "encoreValide") == "true"]
|
||||
backlog = search_by_label("type", "backlogItem")
|
||||
backlog = [b for b in backlog if get_label_value(b["noteId"], "projet") == args.projet
|
||||
and get_label_value(b["noteId"], "statut") not in ("fait", "abandonné")]
|
||||
glossaire = search_by_label("type", "termeGlossaire")
|
||||
glossaire = [g for g in glossaire if get_label_value(g["noteId"], "projet") == args.projet]
|
||||
|
||||
# Synthèse de la session précédente
|
||||
last_summary = ""
|
||||
if args.note_id:
|
||||
last_summary = get_label_value(args.note_id, "syntheseCloture") or ""
|
||||
|
||||
# Construire le briefing
|
||||
lines = [
|
||||
f"# REPRISE DE CONTEXTE — {args.projet} — v{args.version} — {date}",
|
||||
f"**LLM cible : {args.llm_cible}**", "",
|
||||
]
|
||||
|
||||
if projets:
|
||||
p = projets[0]
|
||||
content = get_note_content(p["noteId"])
|
||||
# Extraction texte brut simplifié
|
||||
import re
|
||||
texte = re.sub(r"<[^>]+>", " ", content).strip()
|
||||
lines += ["## 1. Projet", texte[:400], ""]
|
||||
|
||||
if last_summary:
|
||||
lines += ["## 2. Où on en était", last_summary, ""]
|
||||
|
||||
if decisions:
|
||||
lines += ["## 3. Décisions actives (ne pas remettre en question)"]
|
||||
for d in decisions:
|
||||
lines.append(f"- {d.get('title', '?')}")
|
||||
lines.append("")
|
||||
|
||||
if historique:
|
||||
lines += ["## 4. Historique — déjà testé / établi (ne pas refaire)"]
|
||||
for h in historique:
|
||||
type_h = get_label_value(h["noteId"], "typeHistorique") or ""
|
||||
lines.append(f"- [{type_h}] {h.get('title', '?')}")
|
||||
lines.append("")
|
||||
|
||||
if glossaire:
|
||||
lines += ["## 5. Glossaire projet"]
|
||||
for g in glossaire:
|
||||
lines.append(f"- **{g.get('title','?')}** : {get_label_value(g['noteId'], 'definition') or '(voir note)'}")
|
||||
lines.append("")
|
||||
|
||||
if backlog:
|
||||
lines += ["## 6. Backlog actif"]
|
||||
prio_ordre = {"haute": 0, "moyenne": 1, "basse": 2}
|
||||
backlog_sorted = sorted(backlog,
|
||||
key=lambda b: prio_ordre.get(get_label_value(b["noteId"], "priorite") or "basse", 99))
|
||||
for b in backlog_sorted:
|
||||
prio = get_label_value(b["noteId"], "priorite") or "?"
|
||||
statut = get_label_value(b["noteId"], "statut") or "?"
|
||||
lines.append(f"- [{statut}] [{prio}] {b.get('title','?')}")
|
||||
lines.append("")
|
||||
|
||||
lines += [
|
||||
"---",
|
||||
"**Confirme la prise en compte du contexte en 3 lignes :**",
|
||||
"(a) L'objectif du projet selon ta compréhension",
|
||||
"(b) La prochaine action concrète",
|
||||
"(c) Une incertitude ou question que tu identifies",
|
||||
]
|
||||
|
||||
briefing = "\n".join(lines)
|
||||
tokens = estimate_tokens(briefing)
|
||||
titre = f"Reprise {args.projet} v{args.version} — {date}"
|
||||
|
||||
# Créer la note Contexte Reprise dans Trilium
|
||||
ids = load_ids()
|
||||
result = create_note(ids["ContextesReprise"], titre, briefing.replace("\n", "<br>"))
|
||||
ctx_id = result["note"]["noteId"]
|
||||
set_label(ctx_id, "type", "contexteReprise")
|
||||
set_label(ctx_id, "projet", args.projet)
|
||||
set_label(ctx_id, "llmCible", args.llm_cible)
|
||||
set_label(ctx_id, "version", str(args.version))
|
||||
set_label(ctx_id, "tokens", str(tokens))
|
||||
if args.note_id:
|
||||
set_label(ctx_id, "conversationSource", args.note_id)
|
||||
|
||||
head("CONTEXTE DE REPRISE GÉNÉRÉ")
|
||||
ok(f"Note Trilium : {titre} (ID: {ctx_id})")
|
||||
ok(f"Tokens estimés : ~{tokens:,}")
|
||||
print("\n" + "═"*60)
|
||||
print("\n BRIEFING À COPIER-COLLER :\n")
|
||||
print(briefing)
|
||||
print("\n" + "═"*60)
|
||||
|
||||
def cmd_list_backlog(args):
|
||||
backlog = search_by_label("type", "backlogItem")
|
||||
items = [b for b in backlog
|
||||
if get_label_value(b["noteId"], "projet") == args.projet
|
||||
and get_label_value(b["noteId"], "statut") not in ("fait", "abandonné")]
|
||||
if not items:
|
||||
ok("Backlog vide (ou tout est fait).")
|
||||
return
|
||||
head(f"BACKLOG — {args.projet} ({len(items)} items actifs)")
|
||||
emoji = {"haute": "", "moyenne": "", "basse": ""}
|
||||
prio_ordre = {"haute": 0, "moyenne": 1, "basse": 2}
|
||||
for b in sorted(items, key=lambda x: prio_ordre.get(
|
||||
get_label_value(x["noteId"], "priorite") or "basse", 99)):
|
||||
prio = get_label_value(b["noteId"], "priorite") or "?"
|
||||
statut = get_label_value(b["noteId"], "statut") or "?"
|
||||
e = emoji.get(prio, "⚪")
|
||||
print(f" {e} [{statut}] {b.get('title','?')} [id: {b['noteId']}]")
|
||||
|
||||
def cmd_add_decision(args):
|
||||
ids = load_ids()
|
||||
date = datetime.now().strftime("%Y-%m-%d")
|
||||
result = create_note(
|
||||
ids["Decisions"], args.enonce,
|
||||
f"<p><b>Justification</b> : {args.justification}</p><p><b>Date</b> : {date}</p>"
|
||||
)
|
||||
nid = result["note"]["noteId"]
|
||||
set_label(nid, "type", "decision")
|
||||
set_label(nid, "projet", args.projet)
|
||||
set_label(nid, "statut", "active")
|
||||
set_label(nid, "llm", args.llm or "")
|
||||
ok(f"Décision créée : {args.enonce[:60]} (ID: {nid})")
|
||||
|
||||
def cmd_add_history(args):
|
||||
ids = load_ids()
|
||||
date = datetime.now().strftime("%Y-%m-%d")
|
||||
result = create_note(
|
||||
ids["Historique"], args.enonce,
|
||||
f"<p><b>Type</b> : {args.type_h}</p>"
|
||||
f"<p><b>Détail</b> : {args.detail or '—'}</p>"
|
||||
f"<p><b>Date</b> : {date}</p>"
|
||||
)
|
||||
nid = result["note"]["noteId"]
|
||||
set_label(nid, "type", "historiqueItem")
|
||||
set_label(nid, "projet", args.projet)
|
||||
set_label(nid, "typeHistorique", args.type_h)
|
||||
set_label(nid, "encoreValide", "true")
|
||||
ok(f"Historique créé : {args.enonce[:60]} (ID: {nid})")
|
||||
|
||||
|
||||
def cmd_add_backlog(args):
|
||||
ids = load_ids()
|
||||
result = create_note(ids["Backlog"], args.titre)
|
||||
nid = get_note_id(result)
|
||||
set_label(nid, "type", "backlogItem")
|
||||
set_label(nid, "projet", args.projet)
|
||||
set_label(nid, "priorite", args.priorite)
|
||||
set_label(nid, "statut", "a faire")
|
||||
ok(f"Backlog item cree : {args.titre[:60]} (ID: {nid})")
|
||||
|
||||
def cmd_list_projects(args):
|
||||
projets = search_by_label("type", "projet")
|
||||
head(f"PROJETS ({len(projets)} trouvés)")
|
||||
for p in projets:
|
||||
statut = get_label_value(p["noteId"], "statut") or "?"
|
||||
print(f" [{statut}] {p.get('title','?')} [id: {p['noteId']}]")
|
||||
|
||||
def cmd_add_skill(args):
|
||||
if args.portee != "universel" and not args.projet:
|
||||
err("--projet requis sauf si --portee universel")
|
||||
sys.exit(1)
|
||||
ids = load_ids()
|
||||
existant = find_note_by_title(args.titre, ids["Skills"])
|
||||
if existant:
|
||||
err(f"Un skill '{args.titre}' existe déjà (ID: {existant})")
|
||||
sys.exit(1)
|
||||
with open(args.fichier, encoding="utf-8") as f:
|
||||
contenu = f.read()
|
||||
safe = contenu.replace("<", "<").replace(">", ">")
|
||||
result = create_note(ids["Skills"], args.titre, f"<pre>{safe}</pre>")
|
||||
nid = get_note_id(result)
|
||||
set_label(nid, "type", "skill")
|
||||
set_label(nid, "portee", args.portee)
|
||||
if args.projet:
|
||||
set_label(nid, "projet", args.projet)
|
||||
ok(f"Skill créé : {args.titre} (ID: {nid})")
|
||||
|
||||
def cmd_list_skills(args):
|
||||
skills = search_by_label("type", "skill")
|
||||
if args.projet:
|
||||
skills = [s for s in skills
|
||||
if get_label_value(s["noteId"], "projet") == args.projet
|
||||
or get_label_value(s["noteId"], "portee") == "universel"]
|
||||
if args.portee:
|
||||
skills = [s for s in skills if get_label_value(s["noteId"], "portee") == args.portee]
|
||||
if not skills:
|
||||
ok("Aucun skill trouvé pour ces filtres.")
|
||||
return
|
||||
head(f"SKILLS ({len(skills)} trouvés)")
|
||||
for s in skills:
|
||||
portee = get_label_value(s["noteId"], "portee") or "?"
|
||||
projet = get_label_value(s["noteId"], "projet") or "—"
|
||||
print(f" [{portee}] [{projet}] {s.get('title','?')} [id: {s['noteId']}]")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Gestion du contexte LLM multi-modèles via Trilium",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p1 = sub.add_parser("new-conversation")
|
||||
p1.add_argument("--projet", required=True)
|
||||
p1.add_argument("--llm", required=True)
|
||||
p1.add_argument("--titre", required=True)
|
||||
|
||||
p2 = sub.add_parser("close-session")
|
||||
p2.add_argument("--note-id", required=True)
|
||||
p2.add_argument("--summary", required=True)
|
||||
|
||||
p3 = sub.add_parser("generate-context")
|
||||
p3.add_argument("--projet", required=True)
|
||||
p3.add_argument("--llm-cible", required=True)
|
||||
p3.add_argument("--note-id", default="")
|
||||
p3.add_argument("--version", type=int, default=1)
|
||||
|
||||
p4 = sub.add_parser("list-backlog")
|
||||
p4.add_argument("--projet", required=True)
|
||||
|
||||
p5 = sub.add_parser("add-decision")
|
||||
p5.add_argument("--projet", required=True)
|
||||
p5.add_argument("--enonce", required=True)
|
||||
p5.add_argument("--justification", default="")
|
||||
p5.add_argument("--llm", default="")
|
||||
|
||||
p6 = sub.add_parser("add-history")
|
||||
p6.add_argument("--projet", required=True)
|
||||
p6.add_argument("--type", required=True, dest="type_h",
|
||||
choices=["Fait etabli", "Test effectue", "Hypothese invalidee", "Contrainte decouverte"])
|
||||
p6.add_argument("--enonce", required=True)
|
||||
p6.add_argument("--detail", default="")
|
||||
|
||||
|
||||
p_ab = sub.add_parser("add-backlog", help="Ajoute un item au backlog")
|
||||
p_ab.add_argument("--projet", required=True)
|
||||
p_ab.add_argument("--titre", required=True)
|
||||
p_ab.add_argument("--priorite", default="moyenne",
|
||||
choices=["haute", "moyenne", "basse"])
|
||||
sub.add_parser("list-projects")
|
||||
|
||||
p_as = sub.add_parser("add-skill", help="Enregistre un skill depuis un fichier")
|
||||
p_as.add_argument("--titre", required=True)
|
||||
p_as.add_argument("--fichier", required=True, help="Chemin du fichier .md contenant le skill")
|
||||
p_as.add_argument("--portee", default="projet",
|
||||
help="universel | reference-technique | projet | ... (defaut: projet)")
|
||||
p_as.add_argument("--projet", default="", help="Requis sauf si --portee universel")
|
||||
|
||||
p_ls = sub.add_parser("list-skills", help="Liste les skills enregistres")
|
||||
p_ls.add_argument("--projet", default="")
|
||||
p_ls.add_argument("--portee", default="")
|
||||
|
||||
args = parser.parse_args()
|
||||
check_api()
|
||||
|
||||
{
|
||||
"new-conversation": cmd_new_conversation,
|
||||
"close-session": cmd_close_session,
|
||||
"generate-context": cmd_generate_context,
|
||||
"list-backlog": cmd_list_backlog,
|
||||
"add-decision": cmd_add_decision,
|
||||
"add-history": cmd_add_history,
|
||||
"add-backlog": cmd_add_backlog,
|
||||
"list-projects": cmd_list_projects,
|
||||
"add-skill": cmd_add_skill,
|
||||
"list-skills": cmd_list_skills,
|
||||
}[args.command](args)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"root": "Vci57mSRj4tN",
|
||||
"Projets": "i5GuPlZmDDj7",
|
||||
"Conversations": "rO1VOLxlZStD",
|
||||
"Backlog": "tSE6NSAxThpE",
|
||||
"Decisions": "JcraxD7XwhWa",
|
||||
"Historique": "S4NBIOBcmnY8",
|
||||
"Glossaire": "nDj8Z4zEkZXZ",
|
||||
"ContextesReprise": "XN2uR5Y7ZEYF",
|
||||
"Skills": "ol1h09MhksPw",
|
||||
"ModelesOntologie": "z5a9JmEyiAlF",
|
||||
"ProcessWorkflows": "Cw3CkUKi2W8N",
|
||||
"EnvironnementTechnique": "aUZ4qIUVEbcz",
|
||||
"Methodes": "ZRVaYgdw19t2"
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
trilium_init.py — Initialisation idempotente de l'arborescence Trilium
|
||||
Usage :
|
||||
python trilium_init.py
|
||||
python trilium_init.py --with-example
|
||||
python trilium_init.py --show-ids
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from trilium_api import (check_api, create_note, get_note_id,
|
||||
find_note_by_title, set_label, update_note_content)
|
||||
|
||||
GREEN, YELLOW, RESET, BOLD = "\033[32m", "\033[33m", "\033[0m", "\033[1m"
|
||||
def ok(m): print(f" {GREEN}✓{RESET} {m}")
|
||||
def warn(m): print(f" {YELLOW}⚠{RESET} {m}")
|
||||
def head(m): print(f"\n{BOLD}{m}{RESET}")
|
||||
|
||||
IDS_FILE = os.path.join(os.path.dirname(__file__), "trilium_ids.json")
|
||||
|
||||
def get_or_create(parent_id, title, note_type="text", labels=None):
|
||||
existing = find_note_by_title(title, parent_id)
|
||||
if existing:
|
||||
warn(f"Existant : {title} (ID: {existing})")
|
||||
return existing
|
||||
result = create_note(parent_id, title, note_type=note_type)
|
||||
note_id = get_note_id(result)
|
||||
if labels:
|
||||
for k, v in labels.items():
|
||||
set_label(note_id, k, v)
|
||||
ok(f"Créé : {title} (ID: {note_id})")
|
||||
return note_id
|
||||
|
||||
def init_structure():
|
||||
head("Initialisation de l'arborescence Trilium")
|
||||
ids = {}
|
||||
|
||||
root_id = get_or_create("root", "Context Continuity", "book",
|
||||
{"type": "projet"})
|
||||
ids["root"] = root_id
|
||||
|
||||
children_map = {
|
||||
"Projets": "Projets",
|
||||
"Conversations": "Conversations",
|
||||
"Backlog": "Backlog",
|
||||
"Décisions": "Decisions",
|
||||
"Historique": "Historique",
|
||||
"Glossaire": "Glossaire",
|
||||
"Contextes Reprise": "ContextesReprise",
|
||||
}
|
||||
for title, key in children_map.items():
|
||||
ids[key] = get_or_create(root_id, title, "book",
|
||||
{"type": "container"})
|
||||
|
||||
with open(IDS_FILE, "w") as f:
|
||||
json.dump(ids, f, indent=2, ensure_ascii=False)
|
||||
ok(f"IDs sauvegardés dans {IDS_FILE}")
|
||||
return ids
|
||||
|
||||
def create_example_project(ids):
|
||||
head("Création du projet exemple : Sliding Automation")
|
||||
projet_id = get_or_create(
|
||||
ids["Projets"], "Sliding Automation", "text",
|
||||
{"type": "projet", "projet": "SlidingAutomation", "statut": "actif"}
|
||||
)
|
||||
update_note_content(projet_id,
|
||||
"<h2>Sliding Automation</h2>"
|
||||
"<p><b>Objectif</b> : Pipeline de génération automatique de "
|
||||
"présentations PPTX via agents Mistral.</p>"
|
||||
"<p><b>Stack</b> : Python, python-pptx, Mistral Large/Small, YAML</p>"
|
||||
"<p><b>LLM</b> : Claude → architecture & rédaction longue | "
|
||||
"Le Chat → génération code & itérations</p>"
|
||||
)
|
||||
|
||||
head("Backlog Items")
|
||||
backlog_items = [
|
||||
("Déployer patch_render_engine.py (chantiers A/B/C/D)", "haute", "à faire"),
|
||||
("Analyser feedbacks slides 5-17", "haute", "à faire"),
|
||||
("Fix two_cols_text : tirets Markdown visibles", "moyenne", "à faire"),
|
||||
("Fix executive_summary : blocs SCR mal positionnés", "moyenne", "à faire"),
|
||||
("Initialiser versioning Git (Forgejo ou Codeberg)", "basse", "à faire"),
|
||||
]
|
||||
for titre, prio, statut in backlog_items:
|
||||
get_or_create(ids["Backlog"], titre, "text",
|
||||
{"type": "backlogItem", "projet": "SlidingAutomation",
|
||||
"priorite": prio, "statut": statut})
|
||||
|
||||
head("Historique")
|
||||
historique = [
|
||||
("Fix semantic key : self.theme['semantic']",
|
||||
"Fait établi",
|
||||
"Clé corrigée dans render_engine.py — ne pas revenir en arrière."),
|
||||
("Fix polices : détection via assets/fonts/ (pas fc-list)",
|
||||
"Fait établi",
|
||||
"fc-list indisponible sur DS218 ARM64. Détection locale obligatoire."),
|
||||
("Fix Python 3.9 : Union[X|Y] non supporté",
|
||||
"Contrainte découverte",
|
||||
"DS218 tourne Python 3.9. Utiliser Union[X, Y] / Optional[X]."),
|
||||
]
|
||||
for titre, type_h, detail in historique:
|
||||
nid = get_or_create(ids["Historique"], titre, "text",
|
||||
{"type": "historiqueItem",
|
||||
"projet": "SlidingAutomation",
|
||||
"typeHistorique": type_h,
|
||||
"encoreValide": "true"})
|
||||
update_note_content(nid, f"<p>{detail}</p>")
|
||||
|
||||
head("Glossaire")
|
||||
glossaire = [
|
||||
("render_engine.py",
|
||||
"Script principal de rendu PPTX. "
|
||||
"NE PAS confondre avec facilitator.py."),
|
||||
("Chantier A/B/C/D",
|
||||
"4 ensembles de corrections visuelles regroupées "
|
||||
"dans patch_render_engine.py."),
|
||||
("Layout",
|
||||
"Template de slide défini dans layouts.yaml. "
|
||||
"28 layouts disponibles (L01-L28)."),
|
||||
]
|
||||
for terme, definition in glossaire:
|
||||
nid = get_or_create(ids["Glossaire"], terme, "text",
|
||||
{"type": "termeGlossaire",
|
||||
"projet": "SlidingAutomation"})
|
||||
update_note_content(nid, f"<p><b>Définition</b> : {definition}</p>")
|
||||
|
||||
ok("Projet Sliding Automation initialisé.")
|
||||
|
||||
def show_ids():
|
||||
if not os.path.exists(IDS_FILE):
|
||||
print("❌ trilium_ids.json introuvable. "
|
||||
"Lance d'abord : python trilium_init.py")
|
||||
return
|
||||
with open(IDS_FILE) as f:
|
||||
ids = json.load(f)
|
||||
head("IDs Trilium")
|
||||
for k, v in ids.items():
|
||||
print(f" {k:<20} : {v}")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Initialise l'arborescence Trilium")
|
||||
parser.add_argument("--with-example", action="store_true",
|
||||
help="Crée aussi les données Sliding Automation")
|
||||
parser.add_argument("--show-ids", action="store_true",
|
||||
help="Affiche les IDs existants")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.show_ids:
|
||||
show_ids()
|
||||
return
|
||||
|
||||
check_api()
|
||||
ids = init_structure()
|
||||
|
||||
if args.with_example:
|
||||
create_example_project(ids)
|
||||
|
||||
head("Done ✓")
|
||||
print(" Lance : python trilium_context.py list-backlog "
|
||||
"--projet SlidingAutomation")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
trilium_logger.py — Intégration pipeline Sliding Automation
|
||||
À appeler depuis facilitator.py pour logger chaque génération automatiquement.
|
||||
|
||||
Usage depuis facilitator.py :
|
||||
from trilium_logger import log_session
|
||||
log_session("Mon titre", nb_slides=12, yaml_path="output/x.yaml",
|
||||
pptx_path="output/x.pptx", layouts=["cover_split", "big_stat"])
|
||||
"""
|
||||
import os
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Import relatif — suppose que trilium_logger.py est dans le même dossier
|
||||
# que trilium_api.py, ou que le chemin est dans PYTHONPATH
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from trilium_api import create_note, set_label, find_note_by_title
|
||||
|
||||
TRILIUM_URL = os.getenv("TRILIUM_URL", "http://localhost:4292")
|
||||
DEFAULT_PROJ = os.getenv("DEFAULT_PROJECT", "SlidingAutomation")
|
||||
|
||||
# ID du dossier Sessions dans Sliding Pipeline (à renseigner après init)
|
||||
# Récupérable via : python trilium_init.py --show-ids
|
||||
SESSIONS_PARENT_ID = os.getenv("TRILIUM_SESSIONS_ID", "")
|
||||
|
||||
def log_session(titre: str, nb_slides: int, yaml_path: str, pptx_path: str,
|
||||
layouts: list = None, projet: str = None):
|
||||
"""
|
||||
Crée une note de session dans Trilium.
|
||||
Appelé automatiquement par facilitator.py en fin de génération.
|
||||
"""
|
||||
if not SESSIONS_PARENT_ID:
|
||||
print(" ⚠ TRILIUM_SESSIONS_ID non défini dans .env — log ignoré")
|
||||
return
|
||||
|
||||
projet = projet or DEFAULT_PROJ
|
||||
date = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
layouts_str = ", ".join(layouts) if layouts else "non renseignés"
|
||||
|
||||
content = f"""<h2>Session {date}</h2>
|
||||
<table>
|
||||
<tr><td><b>Titre</b></td><td>{titre}</td></tr>
|
||||
<tr><td><b>Slides</b></td><td>{nb_slides}</td></tr>
|
||||
<tr><td><b>YAML</b></td><td>{yaml_path}</td></tr>
|
||||
<tr><td><b>PPTX</b></td><td>{pptx_path}</td></tr>
|
||||
<tr><td><b>Layouts</b></td><td>{layouts_str}</td></tr>
|
||||
<tr><td><b>Date</b></td><td>{date}</td></tr>
|
||||
</table>"""
|
||||
|
||||
try:
|
||||
result = create_note(SESSIONS_PARENT_ID, f"Session {date} — {titre}", content)
|
||||
note_id = result["note"]["noteId"]
|
||||
set_label(note_id, "type", "sessionSliding")
|
||||
set_label(note_id, "projet", projet)
|
||||
set_label(note_id, "nbSlides", str(nb_slides))
|
||||
set_label(note_id, "yamlPath", yaml_path)
|
||||
set_label(note_id, "pptxPath", pptx_path)
|
||||
set_label(note_id, "layouts", layouts_str)
|
||||
print(f" ✓ Session loggée dans Trilium : {titre} (ID: {note_id})")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Trilium log failed : {e}")
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
#!/bin/sh
|
||||
# Watchdog Context Continuity — verifie API (8765) et MCP (8766),
|
||||
# relance le service via son start_*.sh s'il ne repond pas sur /health.
|
||||
# Idempotent : couvre le boot (rien ne tourne) ET le crash.
|
||||
# Lance par tache planifiee DSM toutes les 5 min.
|
||||
|
||||
BASE=/volume1/homes/Master/App/Context_continuity
|
||||
LOG="$BASE/watchdog.log"
|
||||
TS=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# Verifie /health avec plusieurs essais. Retourne 0 si OK, 1 sinon.
|
||||
wait_health() {
|
||||
URL="$1"
|
||||
TRIES="$2"
|
||||
i=0
|
||||
while [ "$i" -lt "$TRIES" ]; do
|
||||
if curl -s -f -m 5 "$URL" > /dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 3
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
check_and_restart() {
|
||||
NAME="$1" # libelle (api / mcp)
|
||||
URL="$2" # url health
|
||||
STARTER="$3" # script de demarrage
|
||||
|
||||
# Premier check rapide (1 essai) : service deja vivant ?
|
||||
if curl -s -f -m 5 "$URL" > /dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Service muet : on relance
|
||||
echo "$TS [$NAME] KO sur $URL — relance via $STARTER" >> "$LOG"
|
||||
sh "$BASE/$STARTER"
|
||||
|
||||
# Verification avec patience (jusqu'a 5 essais = ~15s, le demarrage uvicorn peut etre lent)
|
||||
if wait_health "$URL" 5; then
|
||||
echo "$TS [$NAME] relance OK" >> "$LOG"
|
||||
else
|
||||
echo "$TS [$NAME] ECHEC relance — toujours muet apres ~15s" >> "$LOG"
|
||||
fi
|
||||
}
|
||||
|
||||
check_and_restart "api" "http://127.0.0.1:8765/api/health" "start_api.sh"
|
||||
check_and_restart "mcp" "http://127.0.0.1:8766/health" "start_mcp.sh"
|
||||
Reference in New Issue
Block a user