Funk-lab/stt/server/stt_server/brain.py
ALI YESILKAYA 7cafc06069
feat(stt): Asa agentique — boucle d'outils (function calling, Phase 1) (#48)
Asa ne répondait qu'à partir d'un contexte figé (RAG doc ou blocs live
pré-câblés, à présélectionner) → les questions à état live (« gpu-01 tourne
bien ? ») tombaient sur « la doc ne le précise pas ». Bascule vers une boucle
de function-calling : le modèle décide quels outils appeler puis répond à
partir de leurs résultats.

- Nouveau contexte `asa` (STT_DEFAULT_CONTEXT=asa, défaut prod) + tools.py
  (registre schémas OpenAI + exécuteurs) + brain.ask_with_tools (boucle bornée
  STT_TOOL_MAX_ITERS=4, réponse forcée au-delà ; _post factorisé + retry).
- Outils Phase 1, LECTURE SEULE : search_docs (RAG funk-docs), host_health
  (gpu-01|storage-01 : up/charge/RAM + llama-server), cluster_status,
  prometheus_query (PromQL arbitraire). Réutilisent sources.py / knowledge.py.
- Trace des outils renvoyée dans `context` → visualiseur HUD (un bloc par appel).
- 100 % local (Qwen3-8B) : tool-calling natif llama.cpp validé, survit au
  /no_think. LiteLLM transmet bien les tools. PromQL validés contre le vrai
  Prometheus in-cluster. 3 tests unitaires de la boucle (hors-ligne).

Web search (SearXNG in-cluster) = Phase 2 ; actions admin pilotées par le LLM
(hermes-exec comme outil) = Phase 3.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:52:28 +02:00

170 lines
6 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 json
from collections.abc import Awaitable, Callable
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 _post(payload: dict) -> dict:
"""POST vers LiteLLM avec retry sur blip transitoire ; renvoie le message du 1er choix.
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.
"""
headers = {"Authorization": f"Bearer {settings.litellm_key}"}
last_exc: Exception | None = None
for _ in range(2):
try:
r = await _get_client().post(
settings.litellm_url, json=payload, headers=headers,
timeout=settings.request_timeout,
)
r.raise_for_status()
return r.json()["choices"][0]["message"]
except httpx.HTTPError as e:
last_exc = e
await aclose() # repart sur une connexion neuve (pool potentiellement vicié)
raise last_exc # type: ignore[misc]
def _content_of(msg: dict) -> str:
"""content du message ; repli sur reasoning_content (modèle thinking → content vide)."""
content = (msg.get("content") or "").strip()
if not content:
content = (msg.get("reasoning_content") or "").strip()
return content
def _with_no_think(system: str) -> str:
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 `/no_think` désactive le raisonnement (le tool-calling
# survit — vérifié). Inoffensif pour les modèles non-Qwen (simple texte ignoré).
return system + "\n/no_think"
return system
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.
"""
messages = [{"role": "system", "content": _with_no_think(system)}]
if history:
messages += history
messages.append({"role": "user", "content": text})
msg = await _post({
"model": model or settings.model,
"messages": messages,
"max_tokens": settings.max_tokens,
"temperature": settings.temperature,
})
return _content_of(msg)
# dispatch(name, args) -> texte du résultat d'outil. Fourni par app.py (closure sur les deps).
Dispatch = Callable[[str, dict], Awaitable[str]]
async def ask_with_tools(
text: str,
system: str,
*,
schemas: list[dict],
dispatch: Dispatch,
model: str | None = None,
history: list[dict] | None = None,
) -> tuple[str, list[dict]]:
"""Boucle de function-calling : le modèle appelle des outils jusqu'à pouvoir répondre.
Renvoie (réponse, trace) où `trace` = [{name, args, result}] des outils exécutés
(alimente le visualiseur du HUD). Borné par `tool_max_iters` ; au-delà, un dernier
appel SANS outils force une réponse texte. Si aucun schéma n'est fourni, repli sur `ask`.
"""
if not schemas:
return await ask(text, system, model, history), []
messages: list[dict] = [{"role": "system", "content": _with_no_think(system)}]
if history:
messages += history
messages.append({"role": "user", "content": text})
mdl = model or settings.model
trace: list[dict] = []
for _ in range(settings.tool_max_iters):
msg = await _post({
"model": mdl,
"messages": messages,
"tools": schemas,
"tool_choice": "auto",
"max_tokens": settings.tool_max_tokens,
"temperature": settings.tool_temperature,
})
calls = msg.get("tool_calls") or []
if not calls:
return _content_of(msg), trace
# Réinjecte le message assistant (avec ses tool_calls) avant les résultats d'outils.
messages.append({
"role": "assistant",
"content": msg.get("content") or "",
"tool_calls": calls,
})
for tc in calls:
fn = tc.get("function", {})
name = fn.get("name", "")
try:
args = json.loads(fn.get("arguments") or "{}")
except (ValueError, TypeError):
args = {}
result = await dispatch(name, args)
trace.append({"name": name, "args": args, "result": result})
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", ""),
"content": result,
})
# Budget d'itérations épuisé : on force une réponse finale sans nouveaux appels d'outils.
msg = await _post({
"model": mdl,
"messages": messages,
"tool_choice": "none",
"max_tokens": settings.max_tokens,
"temperature": settings.temperature,
})
return _content_of(msg), trace