Funk-lab/stt/server/stt_server/brain.py
ALI YESILKAYA 1faf50a9be
feat(stt-server): contextes présélectionnables + sources live + contexte assemblé (visualiseur) (#38)
Asa n'est plus bloqué sur le seul contexte « doc cluster grounding-strict ». Le client
choisit un contexte par requête ; le serveur change le system prompt ET injecte les
données live du domaine, puis renvoie le contexte assemblé pour le visualiseur du HUD.

- contexts.py : profils funk / ghostfolio / grafana / alerting / cluster (system prompt
  + sources) + assemble() (prompt final + structure de visualisation).
- sources.py : fetchers live best-effort (Ghostfolio auth+details, Alertmanager alerts
  hors Watchdog, Prometheus cluster/metrics), env-config, dégradation propre.
- brain.py : ask() reçoit le system prompt déjà assemblé (assemblage remonté).
- app.py : /v1/ask accepte `context`, renvoie context_id + le contexte assemblé ;
  nouveau GET /v1/contexts ; RAG doc conditionné au profil.
- config.py : URLs sources + STT_GHOSTFOLIO_TOKEN + STT_DEFAULT_CONTEXT.
- deployment : env in-cluster (Prometheus/Alertmanager monitoring, Ghostfolio ai),
  jeton via secret optionnel stt-server-secrets/ghostfolio-token.
- bump 0.3.1 → 0.4.0.

Validé en local : assemblage (blocs+RAG+mémoire), parsing des sources (mock),
endpoints /v1/contexts et /v1/ask (LLM mocké) — context_id, visualiseur, fallback
contexte inconnu → funk.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:49:29 +02:00

85 lines
3.3 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.

"""Orchestration AI : route les requêtes des clients vers LiteLLM (s01).
LiteLLM (:4000) est OpenAI-compatible et route lui-même vers Qwen3 (g01) ou Claude
selon l'alias `hermes-default` / `hermes-switch`. L'intégration des outils Hermes
(« agir sur Funk » via le gateway :8080) est une étape ultérieure.
"""
from __future__ import annotations
import httpx
from stt_server.config import settings
# Client persistant (pooling + keep-alive) : évite un handshake TCP vers LiteLLM à chaque tour.
_client: httpx.AsyncClient | None = None
def _get_client() -> httpx.AsyncClient:
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(timeout=settings.request_timeout)
return _client
async def aclose() -> None:
global _client
if _client is not None and not _client.is_closed:
await _client.aclose()
_client = None
async def ask(
text: str,
system: str,
model: str | None = None,
history: list[dict] | None = None,
) -> str:
"""Interroge le LLM avec un system prompt **déjà assemblé** (voir contexts.assemble).
Le choix du contexte, l'injection des sources live et du RAG sont faits en amont
(app.v1_ask + contexts) ; ici on ne fait que le réglage thinking + l'appel LLM.
"""
if settings.disable_thinking:
# Qwen3 est un modèle « thinking » : sans ça il dépense tout le budget max_tokens
# en raisonnement (`reasoning_content`) → `content` vide, ou part en réflexion longue
# → timeout (502). Le token de contrôle `/no_think` désactive le mode raisonnement.
# Inoffensif pour les modèles non-Qwen (simple texte ignoré).
system += "\n/no_think"
messages = [{"role": "system", "content": system}]
if history:
messages += history
messages.append({"role": "user", "content": text})
payload = {
"model": model or settings.model,
"messages": messages,
"max_tokens": settings.max_tokens,
"temperature": settings.temperature,
}
headers = {"Authorization": f"Bearer {settings.litellm_key}"}
# Résilience : la chaîne LiteLLM→llama-server (ROCm) se fige par à-coups. Une 2ᵉ tentative
# récupère un blip transitoire (ex. connexion keep-alive périmée) au lieu de renvoyer 502.
# Timeout PAR tentative (`request_timeout`) → on ne dépasse pas ~2× ce budget au pire.
last_exc: Exception | None = None
for attempt in range(2):
try:
r = await _get_client().post(
settings.litellm_url,
json=payload,
headers=headers,
timeout=settings.request_timeout,
)
r.raise_for_status()
break
except httpx.HTTPError as e:
last_exc = e
await aclose() # repart sur une connexion neuve (pool potentiellement vicié)
else:
raise last_exc # type: ignore[misc]
msg = r.json()["choices"][0]["message"]
# Filet de sécurité : si un modèle « thinking » renvoie un content vide (tout parti
# en reasoning_content), on récupère le raisonnement plutôt que de renvoyer "".
content = (msg.get("content") or "").strip()
if not content:
content = (msg.get("reasoning_content") or "").strip()
return content