Funk-lab/stt/server/stt_server/tools.py
alkatrazz 19773a837a 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>
2026-06-22 21:45:44 +02:00

210 lines
8 KiB
Python

"""Outils appelables par Asa (function calling) — Phase 1 : LECTURE SEULE.
Chaque outil = un schéma OpenAI (exposé au LLM) + un exécuteur async qui renvoie un
**texte court** réinjecté dans la boucle (`brain.ask_with_tools`). Tout est best-effort :
un exécuteur n'élève jamais d'exception fatale — il renvoie une note d'erreur lisible que
le modèle peut relayer à l'utilisateur.
Les outils réutilisent l'infra existante (Prometheus via `sources`, RAG via `knowledge`).
Aucune action d'écriture ici : le volet « agir sur le homelab » passe par `agent.py`
(hermes-exec, avec confirmation) et sera branché comme outil en Phase 3.
"""
from __future__ import annotations
from dataclasses import dataclass
import httpx
from stt_server import sources
from stt_server.config import settings
# job Prometheus du node_exporter par hôte hors-cluster (cf. sources.cluster_block).
_HOST_JOBS = {"gpu-01": "gpu-01-node", "storage-01": "storage-01"}
@dataclass
class ToolDeps:
"""Dépendances injectées aux exécuteurs (client HTTP partagé, RAG, vecteur de requête)."""
client: httpx.AsyncClient
knowledge: object | None = None # stt_server.knowledge.Knowledge | None
qvec: list[float] | None = None # embedding de la requête (réutilisé par search_docs)
# --- Exécuteurs -----------------------------------------------------------------
async def _search_docs(args: dict, deps: ToolDeps) -> str:
query = (args.get("query") or "").strip()
if not query:
return "Aucune requête fournie."
if deps.knowledge is None:
return "La recherche documentaire est désactivée."
# Réutilise le vecteur déjà calculé si la requête de l'outil == requête utilisateur.
docs = await deps.knowledge.search(query, deps.qvec)
if not docs:
return "Aucun passage pertinent dans la documentation Funk."
return "\n\n".join(docs[:4])
def _fmt_series(rows: list[tuple[dict, float]], label_key: str | None = None, limit: int = 10) -> str:
out = []
for metric, val in rows[:limit]:
v = int(val) if float(val).is_integer() else round(val, 3)
if label_key and metric.get(label_key):
out.append(f"{metric[label_key]}={v}")
elif metric:
ident = metric.get("__name__") or next(iter(metric.values()), "")
out.append(f"{ident}={v}")
else:
out.append(str(v))
return ", ".join(out)
async def _host_health(args: dict, deps: ToolDeps) -> str:
host = (args.get("host") or "").strip()
job = _HOST_JOBS.get(host)
if not job:
return f"Hôte inconnu : {host!r} (attendu : {', '.join(_HOST_JOBS)})."
try:
up = await sources._prom_query(deps.client, f'up{{job="{job}"}}')
load = await sources._prom_query(deps.client, f'node_load1{{job="{job}"}}')
mem = await sources._prom_query(
deps.client,
f'100*node_memory_MemAvailable_bytes{{job="{job}"}}'
f'/node_memory_MemTotal_bytes{{job="{job}"}}',
)
except httpx.HTTPError:
return "Données indisponibles : Prometheus injoignable."
if not up:
return f"{host} : aucune métrique (cible Prometheus absente ou hôte éteint)."
online = up[0][1] == 1
parts = [f"{host} : {'en ligne' if online else 'HORS LIGNE'}"]
if load:
parts.append(f"charge 1 min {round(load[0][1], 2)}")
if mem:
parts.append(f"RAM libre {round(mem[0][1])}%")
if host == "gpu-01":
try:
llama = await sources._prom_query(deps.client, 'up{job="llama-server-gpu"}')
if llama:
parts.append("llama-server " + ("up" if llama[0][1] == 1 else "DOWN"))
except httpx.HTTPError:
pass
return ", ".join(parts) + "."
async def _cluster_status(args: dict, deps: ToolDeps) -> str:
# Réutilise le résumé déjà éprouvé (nœuds joignables, pods prêts / non prêts).
return await sources.cluster_block(deps.client)
async def _prometheus_query(args: dict, deps: ToolDeps) -> str:
expr = (args.get("expr") or "").strip()
if not expr:
return "Aucune expression PromQL fournie."
try:
rows = await sources._prom_query(deps.client, expr)
except httpx.HTTPError:
return "Données indisponibles : Prometheus injoignable."
except Exception as e: # noqa: BLE001 — PromQL invalide, etc.
return f"Requête refusée par Prometheus : {e}"
if not rows:
return "La requête ne renvoie aucune série (résultat vide)."
return _fmt_series(rows)
# --- Registre : nom → (schéma OpenAI, exécuteur) --------------------------------
_TOOLS: dict[str, tuple[dict, object]] = {
"search_docs": (
{
"type": "function",
"function": {
"name": "search_docs",
"description": "Recherche dans la documentation du homelab Funk "
"(machines, services, configuration, procédures). À utiliser pour toute "
"question sur la conception ou la config du homelab.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Question ou mots-clés"}
},
"required": ["query"],
},
},
},
_search_docs,
),
"host_health": (
{
"type": "function",
"function": {
"name": "host_health",
"description": "État de santé LIVE d'une machine hors-cluster "
"(en ligne ?, charge CPU, RAM libre ; pour gpu-01 aussi l'état de llama-server). "
"À utiliser pour « est-ce que gpu-01 / storage-01 tourne bien ? ».",
"parameters": {
"type": "object",
"properties": {
"host": {
"type": "string",
"enum": list(_HOST_JOBS),
"description": "Nom de la machine",
}
},
"required": ["host"],
},
},
},
_host_health,
),
"cluster_status": (
{
"type": "function",
"function": {
"name": "cluster_status",
"description": "État LIVE du cluster Kubernetes Funk : hôtes joignables, "
"nombre de pods prêts et liste des pods non prêts. À utiliser pour "
"« est-ce que le cluster va bien ? ».",
"parameters": {"type": "object", "properties": {}},
},
},
_cluster_status,
),
"prometheus_query": (
{
"type": "function",
"function": {
"name": "prometheus_query",
"description": "Exécute une requête PromQL arbitraire (lecture seule) contre "
"Prometheus et renvoie les séries. À utiliser pour une métrique précise non "
"couverte par les autres outils.",
"parameters": {
"type": "object",
"properties": {
"expr": {"type": "string", "description": "Expression PromQL"}
},
"required": ["expr"],
},
},
},
_prometheus_query,
),
}
def schemas_for(names: tuple[str, ...]) -> list[dict]:
"""Schémas OpenAI des outils demandés (ignore les inconnus)."""
return [_TOOLS[n][0] for n in names if n in _TOOLS]
async def run(name: str, args: dict, deps: ToolDeps) -> str:
"""Exécute un outil ; ne lève jamais — renvoie une note d'erreur lisible."""
entry = _TOOLS.get(name)
if entry is None:
return f"Outil inconnu : {name}."
try:
return await entry[1](args or {}, deps)
except Exception as e: # noqa: BLE001 — robustesse : la boucle ne doit jamais casser
return f"Erreur de l'outil {name} : {e}"