Les 5 évolutions : plan, auto-critique, RAG, vérif HTML, benchmark

1. Plan-puis-exécute (enhance.make_plan) : les demandes complexes sont
   décomposées en 3-5 étapes (event SSE 'plan', carte PLAN dans le fil,
   meta.plan persisté) ; le plan guide l'agent et le moteur code.
2. Auto-critique « Qualité + » (enhance.self_review, toggle Intelligence) :
   critique éclair puis révision de la réponse (event 'revision').
3. Mémoire long-terme RAG (rag.py) : échanges vectorisés via /api/embed
   (modèle d'embedding auto-détecté), rappel cosinus top-3 inter-sessions
   injecté en contexte, indexation en arrière-plan, élagage à 2000 souvenirs.
4. Vérification HTML (tools.check_html) : références locales cassées et
   balises déséquilibrées ; branchée sur l'auto-vérification des outils ET
   sur le moteur code avec une passe d'auto-correction Aider.
5. Benchmark intégré (bench.py + /api/bench) : 5 épreuves notées /100
   (appel d'outil, code exécuté en sous-processus isolé, consignes, JSON,
   format), streaming SSE, scores stockés ; carte BENCHMARK dans l'UI.

Config : plan_mode / self_review / rag_enabled / embed_model + carte
Intelligence (3 toggles). Client SSE : events plan/revision ; PlanCard.

Tests : heuristique+parsing du plan, révision, index/rappel RAG (exclusion
de la session courante), html_check, bench 100/100 sur modèle simulé,
intégration chat HTTP (event plan + meta persisté), build front.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
This commit is contained in:
Claude committed 2026-07-07 11:47:46 +00:00
1 parent cf1444f7c0
commit d9be1c4dda
15 files changed
+960 -24

No files matched your search

+17
View File
@@ -115,6 +115,23 @@ npm run dev # http://localhost:5173
| `web_search` | Recherche web (DuckDuckGo / SearxNG) | désactivé | | `web_search` | Recherche web (DuckDuckGo / SearxNG) | désactivé |
| `run_shell` | Exécuter une commande **(sensible)** | désactivé | | `run_shell` | Exécuter une commande **(sensible)** | désactivé |
## Intelligence augmentée
- **Plan-puis-exécute** : les demandes complexes sont décomposées en 3-5 étapes
affichées dans le fil ; l'agent (ou le moteur code) suit le plan.
- **Auto-critique « Qualité + »** (Configuration → Intelligence) : la réponse
est relue et révisée avant d'être finalisée.
- **Mémoire long-terme (RAG)** : chaque échange est vectorisé (`/api/embed`)
et les souvenirs pertinents des anciennes sessions sont réinjectés en
contexte. Nécessite un modèle d'embedding installé (ex.
`ollama pull nomic-embed-text`) — sinon désactivé silencieusement.
- **Vérification HTML** : liens locaux cassés et balises déséquilibrées sont
détectés après chaque génération ; le moteur code fait une passe
d'auto-correction.
- **Benchmark intégré** (Configuration → Benchmark) : 5 mini-épreuves notées
/100 (appel d'outil, code exécutable, consignes, extraction JSON, format)
pour comparer objectivement tes modèles installés.
## Tirer le meilleur des petits modèles ## Tirer le meilleur des petits modèles
Loki est conçu pour qu'un modèle local modeste se comporte comme un bon agent : Loki est conçu pour qu'un modèle local modeste se comporte comme un bon agent :
+11
View File
@@ -60,6 +60,10 @@ PROFILE_FIELDS = {
"confirm_shell", "confirm_shell",
"think", "think",
"code_model", "code_model",
"plan_mode",
"self_review",
"rag_enabled",
"embed_model",
*GENERATION_FIELDS, *GENERATION_FIELDS,
} }
@@ -95,6 +99,13 @@ DEFAULT_CONFIG: dict = {
# Modèle utilisé par le moteur code : "auto" = meilleur modèle code installé # Modèle utilisé par le moteur code : "auto" = meilleur modèle code installé
# (qwen-coder, deepseek-coder…), sinon le modèle de chat courant. # (qwen-coder, deepseek-coder…), sinon le modèle de chat courant.
"code_model": "auto", "code_model": "auto",
# Plan-puis-exécute : décompose les demandes complexes en étapes.
"plan_mode": True,
# Auto-critique : une passe de relecture/révision avant la réponse finale.
"self_review": False,
# Mémoire long-terme (RAG) entre sessions, via un modèle d'embedding.
"rag_enabled": True,
"embed_model": "auto",
} }
+206
View File
@@ -0,0 +1,206 @@
"""Benchmark intégré : évalue objectivement chaque modèle installé.
Cinq mini-épreuves (~30-60 s au total) qui mesurent ce qui compte pour Loki :
appel d'outil, code exécutable, respect des consignes, extraction JSON,
respect d'un format. Score /100, stocké en base et affiché dans l'UI.
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
import tempfile
import time
from typing import AsyncIterator
import httpx
from . import db
from .ollama_client import ollama
BENCH_KEY = "bench" # config[bench] = {model: {score, details, at}}
async def _ask(model: str, prompt: str, *, system: str = "",
tools: list | None = None, num_predict: int = 400) -> dict:
"""Un appel modèle ; renvoie {text, tool_calls}."""
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
text, calls = "", []
async for chunk in ollama.chat(
model, messages, tools=tools,
options={"temperature": 0, "num_predict": num_predict}, stream=True,
):
msg = chunk.get("message", {})
text += msg.get("content", "")
if msg.get("tool_calls"):
calls.extend(msg["tool_calls"])
if chunk.get("done"):
break
return {"text": text.strip(), "tool_calls": calls}
def _extract_code(text: str) -> str:
m = re.search(r"```(?:python)?\s*(.*?)```", text, re.S)
return (m.group(1) if m else text).strip()
def _run_python(code: str, test: str) -> bool:
"""Exécute code+test dans un sous-processus isolé (timeout 8 s)."""
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f:
f.write(code + "\n" + test)
path = f.name
try:
proc = subprocess.run(
[sys.executable, "-I", path],
capture_output=True, timeout=8,
)
return proc.returncode == 0
except (subprocess.SubprocessError, OSError):
return False
# ── Les 5 épreuves (score 0-20 chacune) ──────────────────────────────────
async def _task_tool_call(model: str) -> tuple[int, str]:
tools = [{
"type": "function",
"function": {
"name": "write_file",
"description": "Écrire un fichier",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
},
},
}]
try:
r = await _ask(
model,
"Crée le fichier bonjour.txt contenant exactement le texte : salut",
system="Utilise l'outil write_file pour créer le fichier demandé.",
tools=tools, num_predict=200,
)
except httpx.HTTPStatusError as exc:
if "does not support tools" in exc.response.text.lower():
return 0, "outils non supportés par ce modèle"
raise
for tc in r["tool_calls"]:
fn = tc.get("function", {})
if fn.get("name") == "write_file":
args = fn.get("arguments") or {}
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
return 8, "appel d'outil aux arguments illisibles"
ok_path = "bonjour" in str(args.get("path", "")).lower()
ok_content = "salut" in str(args.get("content", "")).lower()
score = 10 + 5 * ok_path + 5 * ok_content
return score, "appel d'outil correct" if score == 20 else "appel partiel"
return 0, "aucun appel d'outil émis"
async def _task_code(model: str) -> tuple[int, str]:
r = await _ask(
model,
"Écris une fonction Python `somme_pairs(nombres)` qui renvoie la somme "
"des nombres pairs de la liste. Réponds UNIQUEMENT avec le code.",
num_predict=300,
)
code = _extract_code(r["text"])
if "def somme_pairs" not in code:
return 0, "fonction absente"
test = (
"assert somme_pairs([1,2,3,4]) == 6\n"
"assert somme_pairs([]) == 0\n"
"assert somme_pairs([7,9]) == 0\n"
)
return (20, "code correct (3/3 tests)") if _run_python(code, test) \
else (6, "code présent mais tests échoués")
async def _task_instruction(model: str) -> tuple[int, str]:
r = await _ask(
model,
"Quelle est la capitale de la France ? Réponds en 3 mots maximum.",
num_predict=30,
)
text = r["text"]
has_answer = "paris" in text.lower()
short = len(text.split()) <= 6
score = 12 * has_answer + 8 * short
return score, f"réponse « {text[:40]} »"
async def _task_json(model: str) -> tuple[int, str]:
r = await _ask(
model,
'Extrait les informations en JSON strict {"nom": ..., "ville": ...} '
"depuis : « Marie habite à Lyon ». Réponds UNIQUEMENT avec le JSON.",
num_predict=80,
)
m = re.search(r"\{.*\}", r["text"], re.S)
if not m:
return 0, "pas de JSON"
try:
data = json.loads(m.group(0))
except json.JSONDecodeError:
return 5, "JSON invalide"
ok_nom = "marie" in str(data.get("nom", "")).lower()
ok_ville = "lyon" in str(data.get("ville", "")).lower()
return 10 + 5 * ok_nom + 5 * ok_ville, "extraction correcte" \
if ok_nom and ok_ville else (10 + 5 * ok_nom + 5 * ok_ville, "extraction partielle")
async def _task_format(model: str) -> tuple[int, str]:
r = await _ask(
model,
"Liste exactement 3 fruits, un par ligne, chaque ligne préfixée par « - ».",
num_predict=60,
)
lines = [l for l in r["text"].splitlines() if l.strip().startswith("-")]
if len(lines) == 3:
return 20, "format exact"
if len(lines) >= 2:
return 10, f"{len(lines)} lignes au lieu de 3"
return 0, "format non respecté"
TASKS = [
("Appel d'outil", _task_tool_call),
("Code exécutable", _task_code),
("Consigne courte", _task_instruction),
("Extraction JSON", _task_json),
("Respect du format", _task_format),
]
async def run_bench(model: str) -> AsyncIterator[dict]:
"""Exécute les 5 épreuves en streamant la progression, stocke le score."""
total = 0
details = []
for name, fn in TASKS:
yield {"type": "task_start", "task": name}
try:
score, detail = await fn(model)
except (httpx.HTTPError, OSError) as exc:
score, detail = 0, f"erreur : {str(exc)[:80]}"
total += score
details.append({"task": name, "score": score, "detail": detail})
yield {"type": "task_done", "task": name, "score": score, "detail": detail}
results = db.get_config_value(BENCH_KEY) or {}
results[model] = {"score": total, "details": details, "at": time.time()}
db.set_config_value(BENCH_KEY, results)
yield {"type": "done", "score": total, "details": details}
def get_scores() -> dict:
return db.get_config_value(BENCH_KEY) or {}
+105
View File
@@ -0,0 +1,105 @@
"""Boosters de qualité pour petits modèles : plan-puis-exécute et auto-critique.
- make_plan : décompose une demande complexe en 3-5 étapes courtes. Un petit
modèle qui suit un plan écrit réussit bien mieux qu'en improvisant.
- self_review : une passe de critique éclair sur la réponse, puis une révision
si des défauts sont trouvés (activable : coûte un peu de latence).
"""
from __future__ import annotations
import logging
import re
import httpx
from .ollama_client import ollama
logger = logging.getLogger(__name__)
_PLAN_PROMPT = (
"Découpe la demande en 3 à 5 étapes courtes et concrètes, une par ligne, "
"numérotées « 1. », « 2. »… Pas d'introduction, pas de conclusion, "
"UNIQUEMENT les étapes, en français."
)
_CRITIQUE_PROMPT = (
"Tu es un relecteur exigeant. Voici une demande et la réponse d'un "
"assistant. Si la réponse est correcte et complète, réponds exactement "
"PARFAIT. Sinon, liste au plus 3 défauts concrets (erreurs, oublis, "
"incohérences), un par ligne."
)
_REVISE_PROMPT = (
"Réécris la réponse en corrigeant les défauts listés. Donne UNIQUEMENT la "
"réponse finale corrigée, sans commentaire sur la révision."
)
def needs_plan(message: str) -> bool:
"""Une demande assez longue/composée mérite un plan explicite."""
if len(message) < 120:
return False
connectors = len(re.findall(
r"\b(puis|ensuite|après|avec|ainsi que|et aussi|également)\b",
message, re.I,
))
return len(message) > 240 or connectors >= 2
async def _ask(model: str, system: str, user: str, *, num_predict: int) -> str:
text = ""
async for chunk in ollama.chat(
model,
[{"role": "system", "content": system},
{"role": "user", "content": user}],
options={"temperature": 0.2, "num_predict": num_predict},
stream=True,
):
text += chunk.get("message", {}).get("content", "")
if chunk.get("done"):
break
return text.strip()
async def make_plan(model: str, message: str) -> list[str]:
"""Renvoie la liste des étapes (vide si échec — jamais bloquant)."""
try:
raw = await _ask(model, _PLAN_PROMPT, message[:1200], num_predict=220)
except (httpx.HTTPError, OSError) as exc:
logger.warning("Plan impossible : %s", exc)
return []
steps = []
for line in raw.splitlines():
line = line.strip()
m = re.match(r"^\d+[.)]\s*(.+)$", line)
if m:
steps.append(m.group(1).strip())
return steps[:5] if len(steps) >= 2 else []
async def self_review(model: str, request: str, answer: str) -> str | None:
"""Critique puis révise la réponse. None si rien à corriger / échec."""
if len(answer) < 80:
return None
try:
critique = await _ask(
model,
_CRITIQUE_PROMPT,
f"Demande :\n{request[:800]}\n\nRéponse :\n{answer[:2500]}",
num_predict=180,
)
if not critique or "PARFAIT" in critique.upper()[:40]:
return None
revised = await _ask(
model,
_REVISE_PROMPT,
f"Demande :\n{request[:800]}\n\nRéponse initiale :\n{answer[:2500]}"
f"\n\nDéfauts :\n{critique[:600]}",
num_predict=1500,
)
# Garde-fou : une révision vide ou minuscule ne remplace rien.
return revised if len(revised) > len(answer) // 3 else None
except (httpx.HTTPError, OSError) as exc:
logger.warning("Auto-critique impossible : %s", exc)
return None
+4 -2
View File
@@ -12,14 +12,15 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from . import coder, db from . import coder, db, rag
from .config import settings from .config import settings
from .routes import chat, config, files, models, sessions, shell, system from .routes import benchmark, chat, config, files, models, sessions, shell, system
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
db.init_db() db.init_db()
rag.init_table()
# Workspace en dépôt git : requis pour les commits du moteur code (Aider). # Workspace en dépôt git : requis pour les commits du moteur code (Aider).
coder.ensure_git(settings.workspace_dir) coder.ensure_git(settings.workspace_dir)
yield yield
@@ -45,6 +46,7 @@ app.include_router(files.router)
app.include_router(config.router) app.include_router(config.router)
app.include_router(shell.router) app.include_router(shell.router)
app.include_router(system.router) app.include_router(system.router)
app.include_router(benchmark.router)
@app.get("/api/health") @app.get("/api/health")
+9
View File
@@ -65,6 +65,15 @@ class OllamaClient:
resp.raise_for_status() resp.raise_for_status()
return resp.json().get("models", []) return resp.json().get("models", [])
async def embed(self, model: str, texts: list[str]) -> list[list[float]]:
"""Vecteurs d'embedding pour une liste de textes (/api/embed)."""
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
resp = await client.post(
f"{self.host}/api/embed", json={"model": model, "input": texts}
)
resp.raise_for_status()
return resp.json().get("embeddings", [])
async def ps(self) -> list[dict]: async def ps(self) -> list[dict]:
"""Modèles actuellement chargés et leur répartition VRAM/CPU (/api/ps).""" """Modèles actuellement chargés et leur répartition VRAM/CPU (/api/ps)."""
async with httpx.AsyncClient(timeout=5.0, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=5.0, follow_redirects=True) as client:
+142
View File
@@ -0,0 +1,142 @@
"""Mémoire long-terme (RAG) : l'agent se souvient des anciennes sessions.
Chaque échange (question + réponse) est vectorisé via /api/embed d'Ollama et
stocké en SQLite. À chaque nouveau message, on recherche les souvenirs les
plus proches (cosinus) dans les AUTRES sessions et on les injecte en contexte.
Tout est best-effort : sans modèle d'embedding installé, le RAG se désactive
silencieusement (aucun impact sur le chat).
"""
from __future__ import annotations
import json
import logging
import math
import time
import uuid
import httpx
from . import db
from .ollama_client import ollama
logger = logging.getLogger(__name__)
# Modèles d'embedding reconnus, par ordre de préférence.
_EMBED_HINTS = ("nomic-embed", "mxbai-embed", "bge-", "snowflake-arctic-embed",
"all-minilm", "embed")
_TOP_K = 3
_MIN_SCORE = 0.45
_MAX_MEMORIES = 2000 # au-delà, on élague les plus anciens
_embed_model_cache: dict = {"value": None, "checked_at": 0.0}
def init_table() -> None:
with db._LOCK, db._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
content TEXT NOT NULL,
embedding TEXT NOT NULL,
created_at REAL NOT NULL
)
"""
)
async def resolve_embed_model(preference: str | None = None) -> str | None:
"""Trouve le modèle d'embedding à utiliser (None = RAG indisponible)."""
if preference and preference != "auto":
return preference
# Cache 60 s pour ne pas marteler /api/tags.
now = time.time()
if now - _embed_model_cache["checked_at"] < 60:
return _embed_model_cache["value"]
value = None
try:
for m in await ollama.list_models():
name = (m.get("name") or "").lower()
if any(h in name for h in _EMBED_HINTS):
value = m["name"]
break
except (httpx.HTTPError, OSError):
value = None
_embed_model_cache.update(value=value, checked_at=now)
return value
def _cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(x * x for x in b))
return dot / (na * nb) if na and nb else 0.0
async def index_exchange(
sid: str, user_text: str, assistant_text: str, *, embed_model: str | None
) -> None:
"""Indexe un échange terminé (tâche d'arrière-plan, best-effort)."""
model = await resolve_embed_model(embed_model)
if not model:
return
content = f"Q: {user_text[:500]}\nR: {assistant_text[:800]}"
try:
vectors = await ollama.embed(model, [content])
if not vectors:
return
with db._LOCK, db._connect() as conn:
conn.execute(
"INSERT INTO memories (id, session_id, content, embedding, created_at)"
" VALUES (?, ?, ?, ?, ?)",
(uuid.uuid4().hex, sid, content,
json.dumps(vectors[0]), time.time()),
)
# Élagage des souvenirs les plus anciens.
conn.execute(
"DELETE FROM memories WHERE id IN ("
" SELECT id FROM memories ORDER BY created_at DESC"
f" LIMIT -1 OFFSET {_MAX_MEMORIES})"
)
except (httpx.HTTPError, OSError) as exc:
logger.warning("Indexation RAG impossible : %s", exc)
async def recall(
sid: str, query: str, *, embed_model: str | None
) -> list[str]:
"""Souvenirs pertinents issus des AUTRES sessions (top-k, score minimal)."""
model = await resolve_embed_model(embed_model)
if not model:
return []
try:
vectors = await ollama.embed(model, [query[:800]])
if not vectors:
return []
qvec = vectors[0]
with db._LOCK, db._connect() as conn:
rows = conn.execute(
"SELECT content, embedding FROM memories WHERE session_id != ?",
(sid,),
).fetchall()
scored = []
for row in rows:
try:
score = _cosine(qvec, json.loads(row["embedding"]))
except (ValueError, TypeError):
continue
if score >= _MIN_SCORE:
scored.append((score, row["content"]))
scored.sort(reverse=True)
return [c for _, c in scored[:_TOP_K]]
except (httpx.HTTPError, OSError) as exc:
logger.warning("Rappel RAG impossible : %s", exc)
return []
+35
View File
@@ -0,0 +1,35 @@
"""Routes du benchmark de modèles."""
from __future__ import annotations
import json
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .. import bench
router = APIRouter(prefix="/api/bench", tags=["bench"])
class BenchRequest(BaseModel):
model: str
@router.get("")
async def scores() -> dict:
return {"scores": bench.get_scores()}
@router.post("")
async def run(req: BenchRequest) -> StreamingResponse:
async def event_stream():
async for ev in bench.run_bench(req.model):
etype = ev.pop("type")
yield f"event: {etype}\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"},
)
+136 -16
View File
@@ -18,7 +18,8 @@ from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from .. import agent_config, coder, db, memory, router as msg_router from .. import agent_config, coder, db, enhance, memory, rag, router as msg_router
from ..tools import check_html, _safe_path
from ..agent import run_agent from ..agent import run_agent
from ..config import settings from ..config import settings
@@ -36,33 +37,92 @@ def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
async def _code_stream(req: ChatRequest, model: str): async def _run_aider_keepalive(instruction: str, model: str):
"""Chemin « moteur code » : Aider travaille, on garde le flux SSE vivant.""" """Lance Aider dans un thread en gardant le flux SSE vivant."""
instruction = req.content
yield _sse("tool_call", {"name": "code_task", "args": {"instruction": instruction}})
task = asyncio.create_task( task = asyncio.create_task(
asyncio.to_thread(coder.run_code_task, instruction, model, None) asyncio.to_thread(coder.run_code_task, instruction, model, None)
) )
# Keepalive SSE pendant le travail (peut durer plusieurs minutes).
while not task.done(): while not task.done():
await asyncio.sleep(10) await asyncio.sleep(10)
if not task.done(): if not task.done():
yield None # signal keepalive
yield await task
async def _code_stream(
req: ChatRequest, model: str, *, extra: str = "", plan: list[str] | None = None
):
"""Chemin « moteur code » : Aider + vérification HTML avec auto-correction."""
instruction = req.content + (extra or "")
yield _sse("tool_call", {"name": "code_task", "args": {"instruction": req.content}})
result = None
async for item in _run_aider_keepalive(instruction, model):
if item is None:
yield ": keepalive\n\n" yield ": keepalive\n\n"
result = await task else:
result = item
status = "ok" if result.get("ok") else "error" status = "ok" if result.get("ok") else "error"
record = { record = {
"name": "code_task", "name": "code_task",
"args": {"instruction": instruction}, "args": {"instruction": req.content},
"summary": result.get("summary", "terminé"), "summary": result.get("summary", "terminé"),
"status": status, "status": status,
} }
tools_meta = [record] tools_meta = [record]
yield _sse("tool_result", record) yield _sse("tool_result", record)
all_files = list(result.get("files") or [])
# Vérification des pages HTML produites + une passe d'auto-correction.
html_issues: list[str] = []
for f in all_files:
if f.lower().endswith((".html", ".htm")):
try:
issues = check_html(_safe_path(f))
except Exception:
issues = []
if issues:
html_issues.append(f"{f} : " + " ; ".join(issues))
if html_issues and result.get("ok"):
yield _sse("tool_call", {"name": "html_check", "args": {"path": "vérification"}})
yield _sse("tool_result", {
"name": "html_check", "args": {"path": "vérification"},
"summary": " | ".join(html_issues)[:200], "status": "error",
})
tools_meta.append({
"name": "html_check", "args": {},
"summary": " | ".join(html_issues)[:200], "status": "error",
})
fix_instruction = (
"Corrige ces problèmes détectés dans les fichiers HTML, sans rien "
"casser d'autre :\n" + "\n".join(html_issues)
)
fix = None
async for item in _run_aider_keepalive(fix_instruction, model):
if item is None:
yield ": keepalive\n\n"
else:
fix = item
fix_rec = {
"name": "code_task",
"args": {"instruction": "auto-correction HTML"},
"summary": fix.get("summary", "terminé"),
"status": "ok" if fix.get("ok") else "error",
}
tools_meta.append(fix_rec)
yield _sse("tool_call", {"name": "code_task", "args": fix_rec["args"]})
yield _sse("tool_result", fix_rec)
for f in fix.get("files") or []:
if f not in all_files:
all_files.append(f)
if fix.get("text"):
result["text"] = (result.get("text") or "") + "\n\n" + fix["text"]
# Cartes par fichier modifié (réutilise le rendu write_file de l'UI). # Cartes par fichier modifié (réutilise le rendu write_file de l'UI).
for f in result.get("files") or []: for f in all_files:
file_rec = { file_rec = {
"name": "write_file", "name": "write_file",
"args": {"path": f}, "args": {"path": f},
@@ -79,10 +139,10 @@ async def _code_stream(req: ChatRequest, model: str):
if text: if text:
yield _sse("token", {"content": text}) yield _sse("token", {"content": text})
db.add_message( meta: dict = {"tools": tools_meta, "engine": "code"}
req.session_id, "assistant", text, model, if plan:
meta={"tools": tools_meta, "engine": "code"}, meta["plan"] = plan
) db.add_message(req.session_id, "assistant", text, model, meta=meta)
yield _sse("done", {"content": text, "tools": tools_meta}) yield _sse("done", {"content": text, "tools": tools_meta})
@@ -113,22 +173,61 @@ async def chat(req: ChatRequest) -> StreamingResponse:
# Mémoire compressée : système + résumé des anciens tours + messages récents. # Mémoire compressée : système + résumé des anciens tours + messages récents.
convo = memory.build_convo(req.session_id, cfg["system_prompt"]) convo = memory.build_convo(req.session_id, cfg["system_prompt"])
# Mémoire long-terme (RAG) : souvenirs pertinents des autres sessions.
if cfg.get("rag_enabled", True):
memories = await rag.recall(
req.session_id, req.content, embed_model=cfg.get("embed_model")
)
if memories:
convo.insert(1, {
"role": "system",
"content": "Souvenirs pertinents d'anciennes sessions :\n"
+ "\n---\n".join(memories),
})
# Moteur code : choisit le meilleur modèle code installé (config "auto"). # Moteur code : choisit le meilleur modèle code installé (config "auto").
code_model = ( code_model = (
await coder.pick_code_model(model, cfg.get("code_model")) await coder.pick_code_model(model, cfg.get("code_model"))
if use_code else model if use_code else model
) )
# Plan-puis-exécute : les demandes complexes sont décomposées d'abord.
plan: list[str] = []
if cfg.get("plan_mode", True) and (use_code or enhance.needs_plan(req.content)):
plan = await enhance.make_plan(model, req.content)
async def event_stream(): async def event_stream():
yield _sse("start", {"model": model, "engine": "code" if use_code else "agent"}) yield _sse("start", {"model": model, "engine": "code" if use_code else "agent"})
if plan:
yield _sse("plan", {"steps": plan})
# Chemin « moteur code » : Aider gère la tâche de bout en bout. # Chemin « moteur code » : Aider gère la tâche de bout en bout.
if use_code: if use_code:
async for chunk in _code_stream(req, code_model): instruction_plan = (
"\n\nPlan à suivre :\n"
+ "\n".join(f"{i+1}. {s}" for i, s in enumerate(plan))
if plan else ""
)
async for chunk in _code_stream(req, code_model, extra=instruction_plan, plan=plan):
yield chunk yield chunk
asyncio.create_task(memory.maybe_summarize(req.session_id, model)) asyncio.create_task(memory.maybe_summarize(req.session_id, model))
if cfg.get("rag_enabled", True):
last = db.list_messages(req.session_id)
answer = last[-1]["content"] if last else ""
asyncio.create_task(rag.index_exchange(
req.session_id, req.content, answer,
embed_model=cfg.get("embed_model"),
))
return return
if plan:
convo.append({
"role": "system",
"content": "Plan à suivre pour cette demande :\n"
+ "\n".join(f"{i+1}. {s}" for i, s in enumerate(plan)),
})
final_content = "" final_content = ""
tools_meta: list[dict] = [] tools_meta: list[dict] = []
stats_meta: dict | None = None stats_meta: dict | None = None
@@ -198,6 +297,20 @@ async def chat(req: ChatRequest) -> StreamingResponse:
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await producer await producer
# Auto-critique : relecture éclair puis révision (option « Qualité + »).
if (
cfg.get("self_review", False)
and final_content
and not error_message
and not tools_meta
):
yield _sse("status", {"message": "Relecture de la réponse…"})
revised = await enhance.self_review(model, req.content, final_content)
if revised:
final_content = revised
yield _sse("revision", {"content": revised})
yield _sse("notice", {"message": "Réponse révisée après auto-critique ✓"})
if final_content or tools_meta: if final_content or tools_meta:
meta: dict = {} meta: dict = {}
if tools_meta: if tools_meta:
@@ -206,6 +319,8 @@ async def chat(req: ChatRequest) -> StreamingResponse:
meta["stats"] = stats_meta meta["stats"] = stats_meta
if thinking_meta: if thinking_meta:
meta["thinking"] = thinking_meta meta["thinking"] = thinking_meta
if plan:
meta["plan"] = plan
db.add_message( db.add_message(
req.session_id, req.session_id,
"assistant", "assistant",
@@ -222,8 +337,13 @@ async def chat(req: ChatRequest) -> StreamingResponse:
"error": error_message or None, "error": error_message or None,
}, },
) )
# Compression de l'historique en arrière-plan (sans bloquer la réponse). # Tâches d'arrière-plan : compression de l'historique + mémoire RAG.
asyncio.create_task(memory.maybe_summarize(req.session_id, model)) asyncio.create_task(memory.maybe_summarize(req.session_id, model))
if cfg.get("rag_enabled", True) and final_content:
asyncio.create_task(rag.index_exchange(
req.session_id, req.content, final_content,
embed_model=cfg.get("embed_model"),
))
return StreamingResponse( return StreamingResponse(
event_stream(), event_stream(),
+4
View File
@@ -22,6 +22,10 @@ class ConfigPatch(BaseModel):
confirm_shell: bool | None = None confirm_shell: bool | None = None
think: bool | None = None think: bool | None = None
code_model: str | None = None code_model: str | None = None
plan_mode: bool | None = None
self_review: bool | None = None
rag_enabled: bool | None = None
embed_model: str | None = None
@router.get("") @router.get("")
+37 -1
View File
@@ -50,8 +50,40 @@ def read_file(path: str) -> dict:
return {"ok": True, "content": content, "summary": summary} return {"ok": True, "content": content, "summary": summary}
def check_html(target: str) -> list[str]:
"""Contrôles rapides d'une page HTML : références locales et balises.
Renvoie une liste de problèmes (vide = OK).
"""
issues: list[str] = []
try:
with open(target, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError:
return issues
base_dir = os.path.dirname(target)
# Références locales cassées (href/src vers un fichier absent).
for _, ref in re.findall(r"""(href|src)=["']([^"'#]+)["']""", content, re.I):
if re.match(r"^(https?:|data:|mailto:|tel:|//|javascript:)", ref, re.I):
continue
ref_path = os.path.normpath(os.path.join(base_dir, ref.split("?")[0]))
if not os.path.exists(ref_path):
issues.append(f"référence cassée : {ref}")
# Équilibre des balises structurantes.
for tag in ("html", "head", "body", "div", "section", "script", "style"):
opened = len(re.findall(rf"<{tag}[\s>]", content, re.I))
closed = len(re.findall(rf"</{tag}>", content, re.I))
if opened != closed:
issues.append(f"balise <{tag}> : {opened} ouverte(s) / {closed} fermée(s)")
return issues[:6]
def _verify_written(target: str) -> str | None: def _verify_written(target: str) -> str | None:
"""Vérification syntaxique immédiate après écriture (py/json). """Vérification immédiate après écriture (py/json/html).
Renvoyer l'erreur au modèle tout de suite lui permet de se corriger dans Renvoyer l'erreur au modèle tout de suite lui permet de se corriger dans
le même tour, au lieu de livrer un fichier cassé. le même tour, au lieu de livrer un fichier cassé.
@@ -65,6 +97,10 @@ def _verify_written(target: str) -> str | None:
_json.loads(content) _json.loads(content)
elif ext == ".py": elif ext == ".py":
compile(content, target, "exec") compile(content, target, "exec")
elif ext in (".html", ".htm"):
problems = check_html(target)
if problems:
return " ; ".join(problems)
except SyntaxError as exc: except SyntaxError as exc:
return f"SyntaxError ligne {exc.lineno}: {exc.msg}" return f"SyntaxError ligne {exc.lineno}: {exc.msg}"
except ValueError as exc: except ValueError as exc:
+80 -1
View File
@@ -89,7 +89,13 @@ export interface Message {
role: "user" | "assistant"; role: "user" | "assistant";
content: string; content: string;
model?: string; model?: string;
meta?: { tools?: ToolCall[]; stats?: MessageStats; thinking?: string } | null; meta?: {
tools?: ToolCall[];
stats?: MessageStats;
thinking?: string;
plan?: string[];
engine?: string;
} | null;
created_at: number; created_at: number;
} }
@@ -105,6 +111,75 @@ export interface AgentConfig {
tools: Record<string, boolean>; tools: Record<string, boolean>;
confirm_shell: boolean; confirm_shell: boolean;
think: boolean; think: boolean;
code_model: string;
plan_mode: boolean;
self_review: boolean;
rag_enabled: boolean;
embed_model: string;
}
// ── Benchmark de modèles ─────────────────────────────────────────────────
export interface BenchDetail {
task: string;
score: number;
detail: string;
}
export interface BenchResult {
score: number;
details: BenchDetail[];
at: number;
}
export async function getBenchScores(): Promise<Record<string, BenchResult>> {
const res = await fetch("/api/bench");
return (await res.json()).scores;
}
/** Lance le benchmark d'un modèle en streamant la progression. */
export async function runBench(
model: string,
onProgress: (task: string, score: number | null, detail?: string) => void
): Promise<BenchResult | null> {
const res = await fetch("/api/bench", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
if (!res.body) return null;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let final: BenchResult | null = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop() ?? "";
for (const block of events) {
let event = "";
let data = "";
for (const line of block.split("\n")) {
if (line.startsWith("event: ")) event = line.slice(7).trim();
else if (line.startsWith("data: ")) data += line.slice(6);
}
if (!data) continue;
try {
const payload = JSON.parse(data);
if (event === "task_start") onProgress(payload.task, null);
else if (event === "task_done")
onProgress(payload.task, payload.score, payload.detail);
else if (event === "done")
final = { score: payload.score, details: payload.details, at: Date.now() / 1000 };
} catch {
/* bloc partiel */
}
}
}
return final;
} }
export async function runShell( export async function runShell(
@@ -204,6 +279,8 @@ export async function streamChat(
onToolConfirm: (command: string) => void; onToolConfirm: (command: string) => void;
onStatus: (msg: string) => void; onStatus: (msg: string) => void;
onNotice: (msg: string) => void; onNotice: (msg: string) => void;
onPlan?: (steps: string[]) => void;
onRevision?: (content: string) => void;
onDone: (full: string) => void; onDone: (full: string) => void;
onError: (msg: string) => void; onError: (msg: string) => void;
onAbort?: () => void; onAbort?: () => void;
@@ -265,6 +342,8 @@ export async function streamChat(
try { try {
const payload = JSON.parse(dataLines.join("\n")); const payload = JSON.parse(dataLines.join("\n"));
if (event === "token") handlers.onToken(payload.content); if (event === "token") handlers.onToken(payload.content);
else if (event === "plan") handlers.onPlan?.(payload.steps);
else if (event === "revision") handlers.onRevision?.(payload.content);
else if (event === "thinking") handlers.onThinking(payload.content); else if (event === "thinking") handlers.onThinking(payload.content);
else if (event === "status") handlers.onStatus(payload.message); else if (event === "status") handlers.onStatus(payload.message);
else if (event === "notice") handlers.onNotice(payload.message); else if (event === "notice") handlers.onNotice(payload.message);
+33 -2
View File
@@ -17,6 +17,7 @@ export function ChatPanel() {
streamStatus, streamStatus,
streamNotice, streamNotice,
streamTools, streamTools,
streamPlan,
sendMessage, sendMessage,
currentSessionId, currentSessionId,
config, config,
@@ -37,7 +38,7 @@ export function ChatPanel() {
// Auto-scroll vers le bas à chaque token / message. // Auto-scroll vers le bas à chaque token / message.
useEffect(() => { useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [messages, streamContent, streamThinking, streamTools]); }, [messages, streamContent, streamThinking, streamTools, streamPlan]);
const submit = () => { const submit = () => {
if (!draft.trim() || streaming) return; if (!draft.trim() || streaming) return;
@@ -101,7 +102,7 @@ export function ChatPanel() {
role: "assistant", role: "assistant",
content: streamContent, content: streamContent,
model: selectedModel, model: selectedModel,
meta: { tools: streamTools }, meta: { tools: streamTools, plan: streamPlan },
created_at: Date.now() / 1000, created_at: Date.now() / 1000,
}} }}
pending pending
@@ -173,6 +174,33 @@ export function ChatPanel() {
); );
} }
/** Plan d'exécution affiché avant le travail de l'agent. */
function PlanCard({ steps }: { steps: string[] }) {
return (
<div
className="mb-[11px] overflow-hidden border-[3px] border-line bg-card shadow-hard-sm"
style={{ borderRadius: 7 }}
>
<div className="flex items-center gap-2 border-b-2 border-line-soft px-3 py-2">
<span className="font-pixel text-[9px] text-accent">PLAN</span>
<span className="text-[12px] text-muted-2">
{steps.length} étape{steps.length > 1 ? "s" : ""}
</span>
</div>
<ol className="m-0 list-none px-3 py-2">
{steps.map((s, i) => (
<li key={i} className="flex gap-2 py-[3px] text-[13px] text-ink-2">
<span className="flex h-[18px] w-[18px] flex-none items-center justify-center border-2 border-line bg-base text-[11px] text-ink">
{i + 1}
</span>
<span className="min-w-0">{s}</span>
</li>
))}
</ol>
</div>
);
}
function Bubble({ function Bubble({
msg, msg,
pending, pending,
@@ -227,6 +255,9 @@ function Bubble({
text={thinking ?? msg.meta?.thinking ?? ""} text={thinking ?? msg.meta?.thinking ?? ""}
live={!!pending} live={!!pending}
/> />
{(msg.meta?.plan?.length ?? 0) > 0 && (
<PlanCard steps={msg.meta!.plan!} />
)}
{(msg.meta?.tools ?? []).map((t: ToolCall, i: number) => ( {(msg.meta?.tools ?? []).map((t: ToolCall, i: number) => (
<ToolCard key={i} call={t} /> <ToolCard key={i} call={t} />
))} ))}
+136 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useStore } from "../store/useStore"; import { useStore } from "../store/useStore";
import { deleteModel, pullModel } from "../api/client"; import { deleteModel, getBenchScores, pullModel, runBench } from "../api/client";
import type { AgentConfig } from "../api/client"; import type { AgentConfig, BenchResult } from "../api/client";
import { DownloadIcon, RefreshIcon } from "../components/Icon"; import { DownloadIcon, RefreshIcon } from "../components/Icon";
const TOOL_DESC: Record<string, string> = { const TOOL_DESC: Record<string, string> = {
@@ -376,6 +376,38 @@ export function SettingsView() {
</div> </div>
)} )}
</Card> </Card>
<Card>
<div className="mb-3.5 font-pixel text-[11px] text-ink">
INTELLIGENCE
</div>
{[
["plan_mode", "Plan-puis-exécute",
"Décompose les demandes complexes en étapes"] as const,
["self_review", "Auto-critique (Qualité +)",
"Relit et révise la réponse avant de la donner"] as const,
["rag_enabled", "Mémoire long-terme (RAG)",
"Se souvient des anciennes sessions (modèle d'embedding requis)"] as const,
].map(([key, label, desc], i) => (
<div
key={key}
className={`flex items-center gap-3 py-2 ${
i > 0 ? "border-t-2 border-line-soft" : ""
}`}
>
<span className="flex-1">
<span className="block text-[14px] text-ink">{label}</span>
<span className="block text-[12px] text-muted-2">{desc}</span>
</span>
<Toggle
on={Boolean(draft[key])}
onClick={() => set(key, !draft[key])}
/>
</div>
))}
</Card>
<BenchCard />
</div> </div>
</div> </div>
@@ -403,6 +435,108 @@ export function SettingsView() {
); );
} }
/** Benchmark : évalue le modèle sélectionné sur 5 mini-épreuves. */
function BenchCard() {
const selectedModel = useStore((s) => s.selectedModel);
const [scores, setScores] = useState<Record<string, BenchResult>>({});
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState<
{ task: string; score: number | null; detail?: string }[]
>([]);
useEffect(() => {
getBenchScores().then(setScores).catch(() => {});
}, []);
const launch = async () => {
if (!selectedModel || running) return;
setRunning(true);
setProgress([]);
try {
const result = await runBench(selectedModel, (task, score, detail) => {
setProgress((p) => {
const rest = p.filter((x) => x.task !== task);
return [...rest, { task, score, detail }];
});
});
if (result) setScores((s) => ({ ...s, [selectedModel]: result }));
} finally {
setRunning(false);
}
};
const current = scores[selectedModel];
return (
<Card>
<div className="mb-3.5 flex items-center justify-between">
<div className="font-pixel text-[11px] text-ink">BENCHMARK</div>
<button
onClick={launch}
disabled={running || !selectedModel}
className="flex h-8 items-center gap-1.5 border-[3px] border-line bg-accent px-3 text-[13px] text-white shadow-accent-soft disabled:opacity-40"
style={{ borderRadius: 7 }}
>
{running ? "ÉVALUATION…" : "TESTER CE MODÈLE"}
</button>
</div>
<div className="mb-2 text-[13px] text-muted-2">
5 mini-épreuves (outils, code, consignes, JSON, format) — score /100
pour <b className="text-ink">{selectedModel || "—"}</b>.
</div>
{running && (
<div className="border-2 border-line bg-base px-3 py-2">
{progress.map((p) => (
<div key={p.task} className="flex items-center gap-2 py-[2px] text-[13px]">
<span className="flex-1 text-ink-2">{p.task}</span>
{p.score === null ? (
<span className="text-muted-3">…</span>
) : (
<span className={p.score >= 14 ? "text-ok" : p.score >= 8 ? "text-ink" : "text-warn"}>
{p.score}/20
</span>
)}
</div>
))}
</div>
)}
{!running && current && (
<div className="border-2 border-line bg-base px-3 py-2">
<div className="mb-1 flex items-center justify-between">
<span className="text-[13px] text-ink">Score</span>
<span
className={`font-pixel text-[13px] ${
current.score >= 70 ? "text-ok" : current.score >= 45 ? "text-ink" : "text-warn"
}`}
>
{current.score}/100
</span>
</div>
{current.details.map((d) => (
<div key={d.task} className="flex items-center gap-2 py-[2px] text-[12px]">
<span className="flex-1 text-muted-2">{d.task}</span>
<span className="text-muted" title={d.detail}>{d.score}/20</span>
</div>
))}
</div>
)}
{Object.keys(scores).length > 1 && (
<div className="mt-2 text-[12px] text-muted-2">
Autres :{" "}
{Object.entries(scores)
.filter(([m]) => m !== selectedModel)
.map(([m, r]) => `${m} (${r.score})`)
.join(" · ")}
</div>
)}
</Card>
);
}
function Card({ function Card({
children, children,
className = "", className = "",
+5
View File
@@ -40,6 +40,7 @@ interface LokiState {
streamStatus: string; streamStatus: string;
streamNotice: string | null; streamNotice: string | null;
streamTools: ToolCall[]; // appels d'outils de la réponse en cours streamTools: ToolCall[]; // appels d'outils de la réponse en cours
streamPlan: string[]; // plan de la réponse en cours
fileTree: FileNode[]; fileTree: FileNode[];
previewPath: string | null; previewPath: string | null;
@@ -88,6 +89,7 @@ export const useStore = create<LokiState>((set, get) => ({
streamStatus: "", streamStatus: "",
streamNotice: null, streamNotice: null,
streamTools: [], streamTools: [],
streamPlan: [],
fileTree: [], fileTree: [],
previewPath: null, previewPath: null,
previewContent: "", previewContent: "",
@@ -234,6 +236,7 @@ export const useStore = create<LokiState>((set, get) => ({
streamStatus: "", streamStatus: "",
streamNotice: null, streamNotice: null,
streamTools: [], streamTools: [],
streamPlan: [],
pendingShell: null, pendingShell: null,
}); });
void get().refreshSessions(); void get().refreshSessions();
@@ -305,6 +308,8 @@ export const useStore = create<LokiState>((set, get) => ({
onToken: (t) => set({ streamContent: get().streamContent + t }), onToken: (t) => set({ streamContent: get().streamContent + t }),
onThinking: (t) => set({ streamThinking: get().streamThinking + t }), onThinking: (t) => set({ streamThinking: get().streamThinking + t }),
onStatus: (message) => set({ streamStatus: message }), onStatus: (message) => set({ streamStatus: message }),
onPlan: (steps) => set({ streamPlan: steps }),
onRevision: (content) => set({ streamContent: content }),
onNotice: (message) => set({ streamNotice: message }), onNotice: (message) => set({ streamNotice: message }),
onToolCall: (call) => onToolCall: (call) =>
set({ streamTools: [...get().streamTools, call] }), set({ streamTools: [...get().streamTools, call] }),