Files
Loki/backend/app/memory.py
T
Claude 889de8c052 Fix 400 Ollama : un seul message système, en première position
Beaucoup de templates (Gemma, Mistral…) contiennent un garde
« {% if role == 'system' and not loop.first %}{{ raise_exception(...) }} » :
tout message système qui n'est pas le premier fait échouer la requête
ENTIÈRE en 400, y compris la génération du parseur d'outils — d'où
« Unable to generate parser for this template […] System message must be
at the beginning », même sur un simple « bonjour ».

Loki en empilait jusqu'à huit : souvenirs, état du workspace, reprise de
code, skill, Ponytail, contraintes web, notes de mémoire, et surtout le
plan, ajouté APRÈS les messages utilisateur.

- Les consignes du tour sont désormais collectées puis fusionnées par
  _merge_system dans l'UNIQUE message système en tête, qui absorbe aussi
  les systèmes égarés.
- memory.build_convo fusionne le résumé de session dans l'invite système
  au lieu d'ajouter un second message système : correct même seul.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
2026-08-03 09:31:20 +00:00

111 lines
4.0 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.
Le résumé est fusionné DANS l'invite système plutôt qu'ajouté comme second
message système : de nombreux templates (Gemma, Mistral…) rejettent tout
message système qui n'est pas le premier de la liste.
"""
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:
system_prompt = (
f"{system_prompt}\n\nRésumé des échanges précédents :\n{summary}"
)
messages = messages[-KEEP_RECENT:]
return [{"role": "system", "content": system_prompt}, *messages]
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)