mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-18 20:57:05 +02:00
perf: fix model reload thrash and cut time-to-first-token
Ollama reloaded the chat model mid-message because plan/summary/router calls sent divergent runner options (no num_ctx/num_batch) and omitted keep_alive — the main cause of perceived slowness. - share one keep-alive httpx.AsyncClient for all Ollama calls - unify runner options (runner_options) + keep_alive on every model call, embeddings included - drop the blocking LLM routing fallback (pure lexical heuristic) - run RAG recall + plan + code-model pick in parallel inside the SSE stream, after the start event - RAG cosine scoring off the event loop; cache /api/tags 30s and nvidia-smi 5s; frontend polls 2s->5s, warm poll backoff, dedup config fetch feat: working session menu in TopBar (switch/create/rename/delete) feat: workspace file deletion (DELETE /api/files + UI trash buttons) docs: recommended Ollama env vars (KEEP_ALIVE, MAX_LOADED_MODELS...) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
552548e4b4
commit
cb2872c78a
23 files changed
+672
-224
No files matched your search
@@ -179,15 +179,15 @@ def save_config(patch: dict, model: str | None = None) -> dict:
|
||||
return get_config(model)
|
||||
|
||||
|
||||
def ollama_options(cfg: dict) -> dict:
|
||||
"""Traduit la config en options de génération Ollama."""
|
||||
opts = {
|
||||
"temperature": cfg["temperature"],
|
||||
"top_p": cfg["top_p"],
|
||||
"top_k": cfg["top_k"],
|
||||
"num_predict": cfg["max_tokens"],
|
||||
"num_batch": cfg["num_batch"],
|
||||
}
|
||||
def runner_options(cfg: dict) -> dict:
|
||||
"""Sous-ensemble d'options qui détermine l'identité du runner Ollama.
|
||||
|
||||
Ollama choisit son runner (processus de chargement du modèle) d'après
|
||||
num_ctx / num_batch / num_gpu. TOUT appel au même modèle (plan, résumé,
|
||||
agent…) doit envoyer ces mêmes valeurs, sinon Ollama recharge le modèle en
|
||||
plein milieu d'un message — la cause principale des lenteurs observées.
|
||||
"""
|
||||
opts: dict = {"num_batch": cfg["num_batch"]}
|
||||
# num_gpu n'est transmis que si l'utilisateur force explicitement un nombre
|
||||
# de couches (≥ 0). En -1 (défaut), on laisse Ollama auto-ajuster l'offload
|
||||
# GPU comme `ollama run` ; lui imposer une valeur peut le forcer sur le CPU.
|
||||
@@ -200,5 +200,16 @@ def ollama_options(cfg: dict) -> dict:
|
||||
return opts
|
||||
|
||||
|
||||
def ollama_options(cfg: dict) -> dict:
|
||||
"""Traduit la config en options de génération Ollama."""
|
||||
return {
|
||||
**runner_options(cfg),
|
||||
"temperature": cfg["temperature"],
|
||||
"top_p": cfg["top_p"],
|
||||
"top_k": cfg["top_k"],
|
||||
"num_predict": cfg["max_tokens"],
|
||||
}
|
||||
|
||||
|
||||
def enabled_tool_names(cfg: dict) -> list[str]:
|
||||
return [name for name, on in cfg["tools"].items() if on]
|
||||
@@ -59,7 +59,7 @@ async def pick_code_model(current: str, preference: str | None = None) -> str:
|
||||
|
||||
from .ollama_client import ollama
|
||||
try:
|
||||
installed = await ollama.list_models()
|
||||
installed = await ollama.list_models_cached()
|
||||
except Exception:
|
||||
return current
|
||||
|
||||
|
||||
+38
-5
@@ -46,13 +46,28 @@ def needs_plan(message: str) -> bool:
|
||||
return len(message) > 240 or connectors >= 2
|
||||
|
||||
|
||||
async def _ask(model: str, system: str, user: str, *, num_predict: int) -> str:
|
||||
async def _ask(
|
||||
model: str,
|
||||
system: str,
|
||||
user: str,
|
||||
*,
|
||||
num_predict: int,
|
||||
options: dict | None = None,
|
||||
keep_alive: str | None = None,
|
||||
) -> str:
|
||||
"""Appel court au modèle.
|
||||
|
||||
``options`` doit contenir les options runner (num_ctx, num_batch…) du chat
|
||||
principal : un appel avec des options divergentes force Ollama à recharger
|
||||
le modèle en plein message.
|
||||
"""
|
||||
text = ""
|
||||
async for chunk in ollama.chat(
|
||||
model,
|
||||
[{"role": "system", "content": system},
|
||||
{"role": "user", "content": user}],
|
||||
options={"temperature": 0.2, "num_predict": num_predict},
|
||||
options={**(options or {}), "temperature": 0.2, "num_predict": num_predict},
|
||||
keep_alive=keep_alive,
|
||||
stream=True,
|
||||
):
|
||||
text += chunk.get("message", {}).get("content", "")
|
||||
@@ -61,10 +76,19 @@ async def _ask(model: str, system: str, user: str, *, num_predict: int) -> str:
|
||||
return text.strip()
|
||||
|
||||
|
||||
async def make_plan(model: str, message: str) -> list[str]:
|
||||
async def make_plan(
|
||||
model: str,
|
||||
message: str,
|
||||
*,
|
||||
options: dict | None = None,
|
||||
keep_alive: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Renvoie la liste des étapes (vide si échec — jamais bloquant)."""
|
||||
try:
|
||||
raw = await _ask(model, _PLAN_PROMPT, message[:1200], num_predict=220)
|
||||
raw = await _ask(
|
||||
model, _PLAN_PROMPT, message[:1200], num_predict=220,
|
||||
options=options, keep_alive=keep_alive,
|
||||
)
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
logger.warning("Plan impossible : %s", exc)
|
||||
return []
|
||||
@@ -77,7 +101,14 @@ async def make_plan(model: str, message: str) -> list[str]:
|
||||
return steps[:5] if len(steps) >= 2 else []
|
||||
|
||||
|
||||
async def self_review(model: str, request: str, answer: str) -> str | None:
|
||||
async def self_review(
|
||||
model: str,
|
||||
request: str,
|
||||
answer: str,
|
||||
*,
|
||||
options: dict | None = None,
|
||||
keep_alive: str | None = None,
|
||||
) -> str | None:
|
||||
"""Critique puis révise la réponse. None si rien à corriger / échec."""
|
||||
if len(answer) < 80:
|
||||
return None
|
||||
@@ -87,6 +118,7 @@ async def self_review(model: str, request: str, answer: str) -> str | None:
|
||||
_CRITIQUE_PROMPT,
|
||||
f"Demande :\n{request[:800]}\n\nRéponse :\n{answer[:2500]}",
|
||||
num_predict=180,
|
||||
options=options, keep_alive=keep_alive,
|
||||
)
|
||||
if not critique or "PARFAIT" in critique.upper()[:40]:
|
||||
return None
|
||||
@@ -97,6 +129,7 @@ async def self_review(model: str, request: str, answer: str) -> str | None:
|
||||
f"Demande :\n{request[:800]}\n\nRéponse initiale :\n{answer[:2500]}"
|
||||
f"\n\nDéfauts :\n{critique[:600]}",
|
||||
num_predict=1500,
|
||||
options=options, keep_alive=keep_alive,
|
||||
)
|
||||
# Garde-fou : une révision vide ou minuscule ne remplace rien.
|
||||
return revised if len(revised) > len(answer) // 3 else None
|
||||
|
||||
@@ -14,6 +14,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import agent_config, coder, db, rag
|
||||
from .config import settings
|
||||
from .ollama_client import ollama
|
||||
from .routes import benchmark, chat, config, files, git, models, sessions, shell, system
|
||||
|
||||
|
||||
@@ -48,6 +49,8 @@ async def lifespan(_: FastAPI):
|
||||
# Préchargement du modèle par défaut, sans bloquer le démarrage.
|
||||
asyncio.create_task(_warm_default_model())
|
||||
yield
|
||||
# Ferme le pool HTTP partagé vers Ollama.
|
||||
await ollama.aclose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
|
||||
+14
-3
@@ -52,8 +52,18 @@ def build_convo(sid: str, system_prompt: str) -> list[dict]:
|
||||
return convo
|
||||
|
||||
|
||||
async def maybe_summarize(sid: str, model: str) -> None:
|
||||
"""Compresse les anciens tours dans le résumé (tâche d'arrière-plan)."""
|
||||
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:
|
||||
@@ -76,7 +86,8 @@ async def maybe_summarize(sid: str, model: str) -> None:
|
||||
{"role": "system", "content": _SUMMARY_PROMPT},
|
||||
{"role": "user", "content": transcript},
|
||||
],
|
||||
options={"temperature": 0.2, "num_predict": 350},
|
||||
options={**(options or {}), "temperature": 0.2, "num_predict": 350},
|
||||
keep_alive=keep_alive,
|
||||
stream=True,
|
||||
):
|
||||
text += chunk.get("message", {}).get("content", "")
|
||||
|
||||
+133
-84
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import AsyncIterator
|
||||
|
||||
import httpx
|
||||
@@ -28,6 +29,12 @@ class OllamaError(RuntimeError):
|
||||
# une génération longue (ou un chargement de modèle sur CPU) ne doit pas couper.
|
||||
_STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=10.0)
|
||||
|
||||
# Un chat déclenche plusieurs appels Ollama (routage, embed, plan, agent…) :
|
||||
# un pool keep-alive partagé évite un handshake TCP à chaque appel.
|
||||
_LIMITS = httpx.Limits(
|
||||
max_connections=20, max_keepalive_connections=10, keepalive_expiry=30.0
|
||||
)
|
||||
|
||||
|
||||
async def _raise_for_stream_status(resp: httpx.Response) -> None:
|
||||
"""Lève une ``OllamaError`` détaillée si la réponse streaming est en erreur.
|
||||
@@ -51,72 +58,118 @@ class OllamaClient:
|
||||
|
||||
def __init__(self, host: str | None = None) -> None:
|
||||
self.host = (host or settings.ollama_host).rstrip("/")
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
# Cache court de /api/tags : la liste des modèles installés change
|
||||
# rarement mais est consultée par plusieurs modules à chaque message.
|
||||
self._tags_at = 0.0
|
||||
self._tags: list[dict] = []
|
||||
|
||||
def _http(self) -> httpx.AsyncClient:
|
||||
"""Client partagé (pool keep-alive), créé paresseusement.
|
||||
|
||||
Un seul worker uvicorn / une seule boucle : la création lazy est sûre.
|
||||
Le garde ``is_closed`` recrée le client si un arrêt l'a fermé.
|
||||
"""
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=_STREAM_TIMEOUT, follow_redirects=True, limits=_LIMITS
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Ferme le pool partagé (appelé au shutdown de l'app)."""
|
||||
if self._client is not None and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
|
||||
async def ping(self) -> dict:
|
||||
"""Vérifie la connexion et renvoie la version d'Ollama."""
|
||||
async with httpx.AsyncClient(timeout=5.0, follow_redirects=True) as client:
|
||||
resp = await client.get(f"{self.host}/api/version")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
resp = await self._http().get(f"{self.host}/api/version", timeout=5.0)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def list_models(self) -> list[dict]:
|
||||
"""Liste les modèles installés localement (/api/tags)."""
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||
resp = await client.get(f"{self.host}/api/tags")
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("models", [])
|
||||
"""Liste les modèles installés localement (/api/tags), sans cache."""
|
||||
resp = await self._http().get(f"{self.host}/api/tags", timeout=10.0)
|
||||
resp.raise_for_status()
|
||||
models = resp.json().get("models", [])
|
||||
self._tags, self._tags_at = models, time.monotonic()
|
||||
return 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 list_models_cached(self, ttl: float = 30.0) -> list[dict]:
|
||||
"""Comme ``list_models`` mais avec un cache court partagé.
|
||||
|
||||
Utilisé par les chemins chauds (routage code, résolution embed, route
|
||||
/api/models) pour ne pas marteler /api/tags à chaque message.
|
||||
"""
|
||||
if self._tags and time.monotonic() - self._tags_at < ttl:
|
||||
return self._tags
|
||||
return await self.list_models()
|
||||
|
||||
def invalidate_tags_cache(self) -> None:
|
||||
"""Force un rafraîchissement après un pull ou une suppression de modèle."""
|
||||
self._tags_at = 0.0
|
||||
self._tags = []
|
||||
|
||||
async def embed(
|
||||
self, model: str, texts: list[str], keep_alive: str = "30m"
|
||||
) -> list[list[float]]:
|
||||
"""Vecteurs d'embedding pour une liste de textes (/api/embed).
|
||||
|
||||
``keep_alive`` long : le modèle d'embedding est minuscule (<0,5 Go) et
|
||||
sollicité à chaque message (recall + indexation) — le laisser chargé
|
||||
évite un aller-retour VRAM permanent avec le modèle de chat.
|
||||
"""
|
||||
resp = await self._http().post(
|
||||
f"{self.host}/api/embed",
|
||||
json={"model": model, "input": texts, "keep_alive": keep_alive},
|
||||
timeout=30.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("embeddings", [])
|
||||
|
||||
async def ps(self) -> list[dict]:
|
||||
"""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:
|
||||
resp = await client.get(f"{self.host}/api/ps")
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("models", [])
|
||||
resp = await self._http().get(f"{self.host}/api/ps", timeout=5.0)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("models", [])
|
||||
|
||||
async def show(self, name: str) -> dict:
|
||||
"""Métadonnées détaillées d'un modèle (/api/show)."""
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
||||
resp = await client.post(f"{self.host}/api/show", json={"name": name})
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
resp = await self._http().post(
|
||||
f"{self.host}/api/show", json={"name": name}, timeout=15.0
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def delete_model(self, name: str) -> dict:
|
||||
"""Supprime un modèle installé (/api/delete)."""
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
resp = await client.request(
|
||||
"DELETE", f"{self.host}/api/delete", json={"model": name}
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json() if resp.content else {"status": "success"}
|
||||
resp = await self._http().request(
|
||||
"DELETE", f"{self.host}/api/delete", json={"model": name}, timeout=30.0
|
||||
)
|
||||
resp.raise_for_status()
|
||||
self.invalidate_tags_cache()
|
||||
return resp.json() if resp.content else {"status": "success"}
|
||||
|
||||
async def pull_model(self, name: str) -> AsyncIterator[dict]:
|
||||
"""Télécharge un modèle en streamant la progression (/api/pull)."""
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_STREAM_TIMEOUT, follow_redirects=True
|
||||
) as client:
|
||||
async with client.stream(
|
||||
"POST", f"{self.host}/api/pull", json={"name": name}
|
||||
) as resp:
|
||||
await _raise_for_stream_status(resp)
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(chunk, dict) and chunk.get("error"):
|
||||
raise OllamaError(str(chunk["error"]))
|
||||
yield chunk
|
||||
async with self._http().stream(
|
||||
"POST",
|
||||
f"{self.host}/api/pull",
|
||||
json={"name": name},
|
||||
timeout=_STREAM_TIMEOUT,
|
||||
) as resp:
|
||||
await _raise_for_stream_status(resp)
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(chunk, dict) and chunk.get("error"):
|
||||
raise OllamaError(str(chunk["error"]))
|
||||
yield chunk
|
||||
self.invalidate_tags_cache()
|
||||
|
||||
async def warm(
|
||||
self, model: str, keep_alive: str = "30m", options: dict | None = None
|
||||
@@ -135,21 +188,20 @@ class OllamaClient:
|
||||
# Le chargement se fait désormais en tâche de fond côté API Loki. On lui
|
||||
# laisse jusqu'à dix minutes pour les gros modèles ou un stockage lent.
|
||||
timeout = httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=10.0)
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
for attempt in range(2):
|
||||
resp = await client.post(
|
||||
f"{self.host}/api/generate", json=payload
|
||||
)
|
||||
try:
|
||||
# Inclut le corps JSON d'Ollama dans l'erreur (OOM, runner…),
|
||||
# contrairement à raise_for_status qui ne montrait que « 500 ».
|
||||
await _raise_for_stream_status(resp)
|
||||
except OllamaError:
|
||||
if resp.status_code >= 500 and attempt == 0:
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
raise
|
||||
return resp.json()
|
||||
for attempt in range(2):
|
||||
resp = await self._http().post(
|
||||
f"{self.host}/api/generate", json=payload, timeout=timeout
|
||||
)
|
||||
try:
|
||||
# Inclut le corps JSON d'Ollama dans l'erreur (OOM, runner…),
|
||||
# contrairement à raise_for_status qui ne montrait que « 500 ».
|
||||
await _raise_for_stream_status(resp)
|
||||
except OllamaError:
|
||||
if resp.status_code >= 500 and attempt == 0:
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
raise
|
||||
return resp.json()
|
||||
raise OllamaError("préchargement interrompu sans réponse")
|
||||
|
||||
async def chat(
|
||||
@@ -176,27 +228,24 @@ class OllamaClient:
|
||||
if keep_alive is not None:
|
||||
payload["keep_alive"] = keep_alive
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_STREAM_TIMEOUT, follow_redirects=True
|
||||
) as client:
|
||||
async with client.stream(
|
||||
"POST", f"{self.host}/api/chat", json=payload
|
||||
) as resp:
|
||||
await _raise_for_stream_status(resp)
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
# Ligne partielle / non-JSON : on l'ignore plutôt que de
|
||||
# faire planter tout le flux.
|
||||
continue
|
||||
# Échec en cours de génération (OOM, contexte trop grand…) :
|
||||
# Ollama l'émet dans le flux avec HTTP 200. On le remonte.
|
||||
if isinstance(chunk, dict) and chunk.get("error"):
|
||||
raise OllamaError(str(chunk["error"]))
|
||||
yield chunk
|
||||
async with self._http().stream(
|
||||
"POST", f"{self.host}/api/chat", json=payload, timeout=_STREAM_TIMEOUT
|
||||
) as resp:
|
||||
await _raise_for_stream_status(resp)
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
# Ligne partielle / non-JSON : on l'ignore plutôt que de
|
||||
# faire planter tout le flux.
|
||||
continue
|
||||
# Échec en cours de génération (OOM, contexte trop grand…) :
|
||||
# Ollama l'émet dans le flux avec HTTP 200. On le remonte.
|
||||
if isinstance(chunk, dict) and chunk.get("error"):
|
||||
raise OllamaError(str(chunk["error"]))
|
||||
yield chunk
|
||||
|
||||
|
||||
ollama = OllamaClient()
|
||||
+21
-11
@@ -9,6 +9,7 @@ silencieusement (aucun impact sur le chat).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
@@ -60,7 +61,7 @@ async def resolve_embed_model(preference: str | None = None) -> str | None:
|
||||
|
||||
value = None
|
||||
try:
|
||||
for m in await ollama.list_models():
|
||||
for m in await ollama.list_models_cached():
|
||||
name = (m.get("name") or "").lower()
|
||||
if any(h in name for h in _EMBED_HINTS):
|
||||
value = m["name"]
|
||||
@@ -79,6 +80,24 @@ def _cosine(a: list[float], b: list[float]) -> float:
|
||||
return dot / (na * nb) if na and nb else 0.0
|
||||
|
||||
|
||||
def _score_rows(qvec: list[float], rows: list) -> list[str]:
|
||||
"""Scoring cosinus sur toutes les mémoires — CPU pur, à lancer via to_thread.
|
||||
|
||||
Jusqu'à _MAX_MEMORIES vecteurs : la boucle Python bloquerait l'event loop
|
||||
(et donc tous les SSE en cours) pendant plusieurs dizaines de ms.
|
||||
"""
|
||||
scored: list[tuple[float, str]] = []
|
||||
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]]
|
||||
|
||||
|
||||
async def index_exchange(
|
||||
sid: str, user_text: str, assistant_text: str, *, embed_model: str | None
|
||||
) -> None:
|
||||
@@ -127,16 +146,7 @@ async def recall(
|
||||
(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]]
|
||||
return await asyncio.to_thread(_score_rows, qvec, rows)
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
logger.warning("Rappel RAG impossible : %s", exc)
|
||||
return []
|
||||
+9
-42
@@ -1,17 +1,15 @@
|
||||
"""Routeur automatique : moteur code (Aider) ou boucle agent classique ?
|
||||
|
||||
Invisible pour l'utilisateur : chaque message est classé.
|
||||
1. Heuristique lexicale rapide (gratuite) — tranche les cas évidents.
|
||||
2. Cas ambigus : micro-classification par le modèle lui-même (3 jetons).
|
||||
Invisible pour l'utilisateur : chaque message est classé par une heuristique
|
||||
lexicale instantanée. Les cas ambigus partent vers la boucle agent, qui garde
|
||||
l'outil `code_task` en secours — plus d'appel LLM bloquant avant le premier
|
||||
token (l'ancien micro-classifieur coûtait un aller-retour modèle complet et
|
||||
chargeait un runner divergent).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
from .ollama_client import ollama
|
||||
|
||||
# Vocabulaire fortement lié au code / au développement.
|
||||
_STRONG = re.compile(
|
||||
r"\b(code|coder?|script|fonction|classe|refactor|bug|d[ée]bug|html|css|"
|
||||
@@ -32,7 +30,7 @@ _ACTION = re.compile(
|
||||
|
||||
|
||||
def score_code_task(message: str) -> int:
|
||||
"""Score heuristique : >= 3 -> code, <= 1 -> agent, 2 -> ambigu."""
|
||||
"""Score heuristique : >= 3 -> code, <= 2 -> agent."""
|
||||
score = 0
|
||||
if _STRONG.search(message):
|
||||
score += 2
|
||||
@@ -45,37 +43,6 @@ def score_code_task(message: str) -> int:
|
||||
return score
|
||||
|
||||
|
||||
async def is_code_task(message: str, model: str) -> bool:
|
||||
"""Décision finale : heuristique, puis LLM sur les cas ambigus."""
|
||||
score = score_code_task(message)
|
||||
if score >= 3:
|
||||
return True
|
||||
if score <= 1:
|
||||
return False
|
||||
|
||||
# Ambigu : on demande au modèle (réponse d'un mot, quasi instantané).
|
||||
try:
|
||||
answer = ""
|
||||
async for chunk in ollama.chat(
|
||||
model,
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Tu classes des demandes. Réponds UNIQUEMENT par CODE "
|
||||
"si la demande nécessite d'écrire ou modifier du code "
|
||||
"ou des fichiers de projet, sinon CHAT."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": message[:500]},
|
||||
],
|
||||
options={"num_predict": 4, "temperature": 0},
|
||||
stream=True,
|
||||
):
|
||||
answer += chunk.get("message", {}).get("content", "")
|
||||
if chunk.get("done"):
|
||||
break
|
||||
return "CODE" in answer.upper()
|
||||
except (httpx.HTTPError, OSError):
|
||||
# Ollama indisponible : la boucle agent gérera l'erreur proprement.
|
||||
return False
|
||||
def is_code_task(message: str) -> bool:
|
||||
"""Décision : heuristique pure, aucune requête modèle."""
|
||||
return score_code_task(message) >= 3
|
||||
+38
-23
@@ -186,21 +186,43 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
db.add_message(req.session_id, "user", req.content, None)
|
||||
|
||||
# Routage automatique : moteur code si la demande est une tâche de code,
|
||||
# que l'outil est actif et qu'Aider est disponible.
|
||||
# que l'outil est actif et qu'Aider est disponible. Heuristique instantanée.
|
||||
use_code = (
|
||||
cfg["tools"].get("code_task", True)
|
||||
and coder.available()
|
||||
and await msg_router.is_code_task(req.content, model)
|
||||
and msg_router.is_code_task(req.content)
|
||||
)
|
||||
|
||||
# Mémoire compressée : système + résumé des anciens tours + messages récents.
|
||||
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")
|
||||
# Options runner partagées par TOUS les appels au modèle de chat (plan,
|
||||
# résumé, agent) : indispensables pour qu'Ollama garde le même runner.
|
||||
run_opts = agent_config.runner_options(cfg)
|
||||
keep = cfg.get("keep_alive", "30m")
|
||||
|
||||
async def event_stream():
|
||||
yield _sse("start", {"model": model, "engine": "code" if use_code else "agent"})
|
||||
|
||||
# Préparation du contexte APRÈS le start SSE et en PARALLÈLE : rappel
|
||||
# RAG, plan et choix du modèle code partent ensemble au lieu de
|
||||
# s'enchaîner en bloquant le premier token.
|
||||
want_rag = cfg.get("rag_enabled", True)
|
||||
want_plan = cfg.get("plan_mode", True) and (
|
||||
use_code or enhance.needs_plan(req.content)
|
||||
)
|
||||
if want_rag or want_plan or use_code:
|
||||
yield _sse("status", {"message": "Préparation du contexte…"})
|
||||
|
||||
memories, plan, code_model = await asyncio.gather(
|
||||
rag.recall(req.session_id, req.content, embed_model=cfg.get("embed_model"))
|
||||
if want_rag else asyncio.sleep(0, result=[]),
|
||||
enhance.make_plan(model, req.content, options=run_opts, keep_alive=keep)
|
||||
if want_plan else asyncio.sleep(0, result=[]),
|
||||
coder.pick_code_model(model, cfg.get("code_model"))
|
||||
if use_code else asyncio.sleep(0, result=model),
|
||||
)
|
||||
|
||||
if memories:
|
||||
convo.insert(1, {
|
||||
"role": "system",
|
||||
@@ -208,20 +230,6 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
+ "\n---\n".join(memories),
|
||||
})
|
||||
|
||||
# Moteur code : choisit le meilleur modèle code installé (config "auto").
|
||||
code_model = (
|
||||
await coder.pick_code_model(model, cfg.get("code_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():
|
||||
yield _sse("start", {"model": model, "engine": "code" if use_code else "agent"})
|
||||
|
||||
if plan:
|
||||
yield _sse("plan", {"steps": plan})
|
||||
|
||||
@@ -234,7 +242,9 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
)
|
||||
async for chunk in _code_stream(req, code_model, extra=instruction_plan, plan=plan):
|
||||
yield chunk
|
||||
asyncio.create_task(memory.maybe_summarize(req.session_id, model))
|
||||
asyncio.create_task(memory.maybe_summarize(
|
||||
req.session_id, model, options=run_opts, keep_alive=keep,
|
||||
))
|
||||
if cfg.get("rag_enabled", True):
|
||||
last = db.list_messages(req.session_id)
|
||||
answer = last[-1]["content"] if last else ""
|
||||
@@ -329,7 +339,10 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
and not tools_meta
|
||||
):
|
||||
yield _sse("status", {"message": "Relecture de la réponse…"})
|
||||
revised = await enhance.self_review(model, req.content, final_content)
|
||||
revised = await enhance.self_review(
|
||||
model, req.content, final_content,
|
||||
options=run_opts, keep_alive=keep,
|
||||
)
|
||||
if revised:
|
||||
final_content = revised
|
||||
yield _sse("revision", {"content": revised})
|
||||
@@ -362,7 +375,9 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
},
|
||||
)
|
||||
# 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, options=run_opts, keep_alive=keep,
|
||||
))
|
||||
if cfg.get("rag_enabled", True) and final_content:
|
||||
asyncio.create_task(rag.index_exchange(
|
||||
req.session_id, req.content, final_content,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Routes de lecture du workspace (arborescence + contenu d'un fichier)."""
|
||||
"""Routes du workspace : arborescence, contenu, téléchargement, suppression."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -64,3 +65,27 @@ async def download_file(path: str) -> FileResponse:
|
||||
filename=os.path.basename(target),
|
||||
media_type="application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("")
|
||||
async def delete_file(path: str) -> dict:
|
||||
"""Supprime un fichier ou un dossier (récursif) du workspace.
|
||||
|
||||
Même confinement que le téléchargement (`_safe_path`) ; la racine du
|
||||
workspace est refusée. Les dotfiles (.git…) ne sont jamais listés par
|
||||
l'arborescence, donc inaccessibles depuis l'UI.
|
||||
"""
|
||||
try:
|
||||
target = _safe_path(path)
|
||||
except ToolError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
root = os.path.abspath(settings.workspace_dir)
|
||||
if os.path.abspath(target) == root:
|
||||
raise HTTPException(400, "suppression de la racine du workspace refusée")
|
||||
if os.path.isdir(target):
|
||||
shutil.rmtree(target)
|
||||
elif os.path.isfile(target):
|
||||
os.remove(target)
|
||||
else:
|
||||
raise HTTPException(404, "fichier introuvable")
|
||||
return {"deleted": path}
|
||||
@@ -46,7 +46,7 @@ async def status() -> dict:
|
||||
async def list_models() -> dict:
|
||||
"""Modèles installés localement, formatés pour le sélecteur de l'UI."""
|
||||
try:
|
||||
raw = await ollama.list_models()
|
||||
raw = await ollama.list_models_cached()
|
||||
except (httpx.HTTPError, OSError):
|
||||
return {"models": []}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
@@ -15,11 +16,21 @@ router = APIRouter(prefix="/api/system", tags=["system"])
|
||||
|
||||
_NVIDIA_SMI = shutil.which("nvidia-smi")
|
||||
|
||||
# Cache court : le front interroge /stats en continu ; relancer un sous-processus
|
||||
# nvidia-smi à chaque tick charge la machine qui héberge aussi Ollama.
|
||||
_GPU_CACHE_TTL = 5.0
|
||||
_gpu_cache: dict = {"at": 0.0, "value": None}
|
||||
|
||||
|
||||
async def _gpu_stats() -> dict | None:
|
||||
"""Utilisation GPU/VRAM via nvidia-smi ; None si absent (pas de GPU NVIDIA)."""
|
||||
"""Utilisation GPU/VRAM via nvidia-smi ; None si absent (pas de GPU NVIDIA).
|
||||
|
||||
Résultat mis en cache ~5 s pour limiter les sous-processus.
|
||||
"""
|
||||
if not _NVIDIA_SMI:
|
||||
return None
|
||||
if time.monotonic() - _gpu_cache["at"] < _GPU_CACHE_TTL:
|
||||
return _gpu_cache["value"]
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
_NVIDIA_SMI,
|
||||
@@ -31,14 +42,16 @@ async def _gpu_stats() -> dict | None:
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=3)
|
||||
line = out.decode().strip().splitlines()[0]
|
||||
util, used, total, name = (p.strip() for p in line.split(","))
|
||||
return {
|
||||
value = {
|
||||
"name": name,
|
||||
"util_pct": float(util),
|
||||
"vram_used_mb": float(used),
|
||||
"vram_total_mb": float(total),
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
value = None
|
||||
_gpu_cache.update(at=time.monotonic(), value=value)
|
||||
return value
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
|
||||
Reference in new issue
Block a user