mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-11 08:34:42 +02:00
feat(stt): Asa agentique — boucle d'outils (function calling, Phase 1)
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>
This commit is contained in:
parent
de1cd6c407
commit
19773a837a
11 changed files with 566 additions and 42 deletions
|
|
@ -7,6 +7,9 @@ selon l'alias `hermes-default` / `hermes-switch`. L'intégration des outils Herm
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import httpx
|
||||
|
||||
from stt_server.config import settings
|
||||
|
|
@ -29,6 +32,47 @@ async def aclose() -> None:
|
|||
_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,
|
||||
|
|
@ -40,46 +84,87 @@ async def ask(
|
|||
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}]
|
||||
messages = [{"role": "system", "content": _with_no_think(system)}]
|
||||
if history:
|
||||
messages += history
|
||||
messages.append({"role": "user", "content": text})
|
||||
payload = {
|
||||
msg = await _post({
|
||||
"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
|
||||
})
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue