Files
Loki/backend/app/rag.py
T
Claude d9be1c4dda 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
2026-07-07 11:47:46 +00:00

143 lines
4.5 KiB
Python

"""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 []