Files
Loki/backend/app/memory.py
T
MichaelandClaude Opus 4.8 a1380d0bf8 fix(chat): heartbeat SSE pendant la préparation + think=False sur les appels auxiliaires
Le déplacement de la préparation (plan, recall RAG) dans le flux SSE
laissait un silence total pendant le chargement d'un gros modèle : le
reverse proxy coupait la connexion (« Connexion interrompue pendant le
chargement du modèle »). Avec la réflexion active, l'appel de plan
laissait en plus le modèle penser — silence encore plus long.

- ping SSE toutes les 10 s pendant la préparation du contexte
- plan / auto-critique / résumé : think=False (repli automatique si le
  modèle refuse le paramètre) — un appel utilitaire ne réfléchit jamais

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 01:01:49 +02:00

112 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Mémoire de conversation compressée — le vrai levier des petits modèles.
Un petit modèle se noie dans un long historique : il oublie la consigne, part
en boucle, et un grand num_ctx le fait déborder du GPU. On garde donc :
[invite système] + [résumé compact des anciens tours] + [N derniers messages]
Le résumé est régénéré en arrière-plan (après la réponse, sans latence pour
l'utilisateur) dès que l'historique dépasse le seuil.
"""
from __future__ import annotations
import logging
import httpx
from . import db
from .ollama_client import OllamaError, ollama
logger = logging.getLogger(__name__)
# Nombre de messages récents passés tels quels au modèle.
KEEP_RECENT = 10
# Au-delà de ce total, les anciens tours sont compressés dans le résumé.
SUMMARIZE_AFTER = KEEP_RECENT + 6
_SUMMARY_PROMPT = (
"Résume la conversation ci-dessous en français, en 10 lignes maximum. "
"Conserve impérativement : l'objectif de l'utilisateur, les décisions "
"prises, les fichiers créés/modifiés et leur rôle, et les points encore "
"ouverts. Réponds UNIQUEMENT par le résumé."
)
def build_convo(sid: str, system_prompt: str) -> list[dict]:
"""Construit le contexte : système + résumé éventuel + messages récents."""
convo: list[dict] = [{"role": "system", "content": system_prompt}]
session = db.get_session(sid) or {}
summary = (session.get("summary") or "").strip()
messages = db.list_messages_for_model(sid)
if summary and len(messages) > KEEP_RECENT:
convo.append(
{
"role": "system",
"content": f"Résumé des échanges précédents :\n{summary}",
}
)
convo += messages[-KEEP_RECENT:]
else:
convo += messages
return convo
async def maybe_summarize(
sid: str,
model: str,
*,
options: dict | None = None,
keep_alive: str | None = None,
) -> None:
"""Compresse les anciens tours dans le résumé (tâche d'arrière-plan).
``options`` doit reprendre les options runner du chat (num_ctx…) pour ne
pas déclencher un rechargement du modèle après chaque réponse.
"""
try:
messages = db.list_messages_for_model(sid)
if len(messages) <= SUMMARIZE_AFTER:
return
session = db.get_session(sid) or {}
previous = (session.get("summary") or "").strip()
old = messages[:-KEEP_RECENT]
transcript = "\n".join(
f"[{m['role']}] {m['content'][:600]}" for m in old
)[-8000:]
if previous:
transcript = f"[résumé existant] {previous}\n{transcript}"
messages = [
{"role": "system", "content": _SUMMARY_PROMPT},
{"role": "user", "content": transcript},
]
opts = {**(options or {}), "temperature": 0.2, "num_predict": 350}
# think=False : le résumé d'arrière-plan ne doit pas « réfléchir »
# (latence ×3 sur un modèle thinking). Repli si paramètre refusé.
think: bool | None = False
while True:
text = ""
try:
async for chunk in ollama.chat(
model, messages, options=opts, think=think,
keep_alive=keep_alive, stream=True,
):
text += chunk.get("message", {}).get("content", "")
if chunk.get("done"):
break
break
except OllamaError as exc:
if think is False and "think" in str(exc).lower():
think = None
continue
raise
if text.strip():
db.set_session_summary(sid, text.strip())
except (httpx.HTTPError, OSError) as exc:
# Best-effort : un échec de résumé ne doit jamais gêner le chat.
logger.warning("Résumé de session %s impossible : %s", sid, exc)