feat(skills): 5 méthodes expertes auto-injectées (routeur lexical)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-07-18 17:02:40 +02:00
1 parent afe0a837d4
commit 31dce6036b
9 files changed
+159 -1

No files matched your search

+3
View File
@@ -64,6 +64,7 @@ PROFILE_FIELDS = {
"self_review",
"rag_enabled",
"embed_model",
"skills_enabled",
"keep_alive",
*GENERATION_FIELDS,
}
@@ -107,6 +108,8 @@ DEFAULT_CONFIG: dict = {
# Mémoire long-terme (RAG) entre sessions, via un modèle d'embedding.
"rag_enabled": True,
"embed_model": "auto",
# Skills : méthodes expertes injectées automatiquement selon la tâche.
"skills_enabled": True,
# Durée de maintien du modèle en VRAM (préchargement). "0" = décharge
# aussitôt, "-1" = jamais, "30m" = 30 minutes.
"keep_alive": "30m",
+14 -1
View File
@@ -18,7 +18,8 @@ from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .. import agent_config, coder, db, enhance, memory, rag, router as msg_router
from .. import agent_config, coder, db, enhance, memory, rag, skills
from .. import router as msg_router
from ..tools import check_html, _safe_path
from ..agent import run_agent
from ..config import settings
@@ -237,6 +238,18 @@ async def chat(req: ChatRequest) -> StreamingResponse:
+ "\n---\n".join(memories),
})
# Skill : méthode experte injectée pour ce tour (jamais persistée).
if cfg.get("skills_enabled", True):
skill = skills.pick_skill(req.content)
if skill:
convo.insert(1, {
"role": "system",
"content": (
"Méthode à suivre pour cette tâche :\n" + skill["body"]
),
})
yield _sse("notice", {"message": f"📘 Méthode : {skill['title']}"})
if plan:
yield _sse("plan", {"steps": plan})
+61
View File
@@ -0,0 +1,61 @@
"""Skills : méthodes expertes injectées automatiquement selon la tâche.
Sélection lexicale instantanée (aucun appel LLM) : au plus UNE skill par
message, injectée en message système pour ce tour uniquement.
"""
from __future__ import annotations
import os
import re
_SKILLS_DIR = os.path.join(os.path.dirname(__file__), "..", "skills")
# Seuil : nombre minimal de mots-clés distincts trouvés dans le message.
_MIN_HITS = 2
def _load() -> dict[str, dict]:
out: dict[str, dict] = {}
if not os.path.isdir(_SKILLS_DIR):
return out
for fname in sorted(os.listdir(_SKILLS_DIR)):
if not fname.endswith(".md"):
continue
with open(os.path.join(_SKILLS_DIR, fname), encoding="utf-8") as f:
raw = f.read()
m = re.match(r"^---\n(.*?)\n---\n(.*)$", raw, re.S)
if not m:
continue
meta: dict[str, str] = {}
for line in m.group(1).splitlines():
if ":" in line:
key, _, value = line.partition(":")
meta[key.strip()] = value.strip()
keywords = [
k.strip().lower()
for k in meta.get("keywords", "").split(",")
if k.strip()
]
out[meta.get("name", fname[:-3])] = {
"name": meta.get("name", fname[:-3]),
"title": meta.get("title", fname[:-3]),
"keywords": keywords,
"body": m.group(2).strip(),
}
return out
ALL_SKILLS: dict[str, dict] = _load()
def pick_skill(message: str) -> dict | None:
"""Meilleure skill pour ce message, ou None si rien d'assez net."""
low = message.lower()
best, best_hits = None, 0
for skill in ALL_SKILLS.values():
hits = sum(1 for kw in skill["keywords"] if kw in low)
if hits > best_hits:
best, best_hits = skill, hits
if best is None or best_hits < _MIN_HITS:
return None
return {"name": best["name"], "title": best["title"], "body": best["body"]}