mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 11:44:43 +02:00
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>
This commit is contained in:
parent
de1cd6c407
commit
7cafc06069
11 changed files with 566 additions and 42 deletions
|
|
@ -1,3 +1,3 @@
|
|||
"""STT-server — orchestrateur AI in-cluster pour les clients STT."""
|
||||
|
||||
__version__ = "0.6.0"
|
||||
__version__ = "0.7.0"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from stt_server import brain
|
|||
from stt_server.brain import ask as brain_ask
|
||||
from stt_server.config import settings
|
||||
from stt_server import agent
|
||||
from stt_server import tools
|
||||
from stt_server.contexts import CONTEXTS, assemble, get_context
|
||||
from stt_server.knowledge import Knowledge
|
||||
from stt_server.longterm import LongTermMemory
|
||||
|
|
@ -170,6 +171,67 @@ async def _handle_agent(req: AskRequest, text: str) -> AskReply:
|
|||
context=_agent_context_debug({"pending": text}))
|
||||
|
||||
|
||||
async def _handle_agentic(
|
||||
req: "AskRequest", text: str, ctx, model: str, background: BackgroundTasks
|
||||
) -> "AskReply":
|
||||
"""Contexte à outils : recall mémoire (perso) + boucle de function-calling (état live).
|
||||
|
||||
La doc n'est plus injectée d'office (elle est devenue l'outil `search_docs`) ; on garde
|
||||
le recall long-terme pour la personnalisation. Le trace des outils alimente le visualiseur.
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
history = sessions.history(req.session_id) if req.session_id else None
|
||||
memories, qvec = await longterm.recall(text) if longterm else ([], None)
|
||||
system = ctx.system_prompt
|
||||
if memories:
|
||||
souvenirs = "\n".join(f"- {m}" for m in memories)
|
||||
system += (
|
||||
"\n\nÉléments de mémoire long-terme (peuvent aider, ignore si hors-sujet) :\n"
|
||||
+ souvenirs
|
||||
)
|
||||
schemas = tools.schemas_for(ctx.tools)
|
||||
t_recall = time.perf_counter()
|
||||
async with httpx.AsyncClient() as client:
|
||||
deps = tools.ToolDeps(client=client, knowledge=knowledge, qvec=qvec)
|
||||
|
||||
async def _dispatch(name: str, args: dict) -> str:
|
||||
return await tools.run(name, args, deps)
|
||||
|
||||
try:
|
||||
reply, tool_trace = await brain.ask_with_tools(
|
||||
text, system, schemas=schemas, dispatch=_dispatch,
|
||||
model=model, history=history,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(status_code=502, detail=f"upstream LiteLLM : {e}") from e
|
||||
t_gen = time.perf_counter()
|
||||
if req.session_id:
|
||||
sessions.add(req.session_id, "user", text)
|
||||
sessions.add(req.session_id, "assistant", reply)
|
||||
if longterm:
|
||||
background.add_task(longterm.store, req.session_id or "anon", text, qvec)
|
||||
log.info(
|
||||
"ask ctx=%s model=%s recall=%.0fms loop=%.0fms total=%.0fms mem=%d tools=%d",
|
||||
ctx.id, model, (t_recall - t0) * 1000, (t_gen - t_recall) * 1000,
|
||||
(t_gen - t0) * 1000, len(memories), len(tool_trace),
|
||||
)
|
||||
# Visualiseur HUD : on présente chaque appel d'outil comme un bloc (réutilise l'UI existante).
|
||||
ctx_debug = {
|
||||
"id": ctx.id, "label": ctx.label, "icon": ctx.icon,
|
||||
"system_prompt": ctx.system_prompt,
|
||||
"blocks": [
|
||||
{
|
||||
"source": f"tool:{t['name']}",
|
||||
"title": f"🔧 {t['name']}({', '.join(f'{k}={v}' for k, v in t['args'].items())})",
|
||||
"text": t["result"],
|
||||
}
|
||||
for t in tool_trace
|
||||
],
|
||||
"docs": [], "memories": memories,
|
||||
}
|
||||
return AskReply(reply=reply, model=model, context_id=ctx.id, context=ctx_debug)
|
||||
|
||||
|
||||
@app.post("/v1/ask", response_model=AskReply)
|
||||
async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply:
|
||||
text = req.text.strip()
|
||||
|
|
@ -185,6 +247,9 @@ async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply:
|
|||
# Contexte « agent » : flux dédié (confirmation 2 temps → hermes-exec), sans LLM.
|
||||
if ctx.id == "agent":
|
||||
return await _handle_agent(req, text)
|
||||
# Contexte à outils (« asa ») : boucle de function-calling (le modèle sonde l'état live).
|
||||
if ctx.tools:
|
||||
return await _handle_agentic(req, text, ctx, model, background)
|
||||
t0 = time.perf_counter()
|
||||
history = sessions.history(req.session_id) if req.session_id else None
|
||||
# recall : timeout serré, dégrade vite ; renvoie aussi le vecteur (réutilisé ci-dessous)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -78,6 +78,14 @@ class Settings:
|
|||
prometheus_url: str = os.getenv("STT_PROMETHEUS_URL", "http://prometheus.lab.local")
|
||||
alertmanager_url: str = os.getenv("STT_ALERTMANAGER_URL", "http://alertmanager.lab.local")
|
||||
|
||||
# Boucle d'outils (contexte « asa ») — function calling local (Qwen3-8B). Le modèle
|
||||
# appelle des outils de lecture (doc, santé hôtes, état cluster, PromQL) jusqu'à pouvoir
|
||||
# répondre, borné par tool_max_iters (puis réponse forcée sans outils).
|
||||
tool_max_iters: int = int(os.getenv("STT_TOOL_MAX_ITERS", "4"))
|
||||
tool_max_tokens: int = int(os.getenv("STT_TOOL_MAX_TOKENS", "512"))
|
||||
# Température basse pour le tool-use (sélection d'outil + arguments déterministes).
|
||||
tool_temperature: float = float(os.getenv("STT_TOOL_TEMPERATURE", "0.2"))
|
||||
|
||||
# Actions via Hermes (contexte « agent ») — OPT-IN, désactivé par défaut.
|
||||
# Le contexte « agent » n'est exposé que si actions_enabled ET un jeton est présent.
|
||||
actions_enabled: bool = os.getenv("STT_ACTIONS_ENABLED", "false").lower() == "true"
|
||||
|
|
|
|||
|
|
@ -29,10 +29,26 @@ class Context:
|
|||
description: str
|
||||
system_prompt: str
|
||||
sources: tuple[str, ...] = () # "docs","ghostfolio","alerts","cluster","metrics"
|
||||
tools: tuple[str, ...] = () # outils function-calling (cf. tools.py) → boucle agentique
|
||||
|
||||
|
||||
# Le contexte « funk » garde le prompt grounding-strict historique (env override possible).
|
||||
CONTEXTS: dict[str, Context] = {
|
||||
# Contexte « asa » : boucle d'outils (function calling). Asa décide elle-même quand
|
||||
# sonder l'état live du homelab ou chercher dans la doc, puis répond. C'est le contexte
|
||||
# par défaut côté production (STT_DEFAULT_CONTEXT=asa) — « gpu-01 tourne bien ? » marche
|
||||
# sans présélection. Les outils sont en LECTURE SEULE (volet actions = contexte « agent »).
|
||||
"asa": Context(
|
||||
id="asa", label="Asa · agent", icon="✨",
|
||||
description="Asa répond en s'appuyant sur des outils (doc + état live du homelab).",
|
||||
system_prompt=_BASE + " Tu disposes d'outils pour obtenir l'état RÉEL et LIVE du "
|
||||
"homelab (santé des machines, état du cluster, métriques Prometheus) et pour chercher "
|
||||
"dans la documentation Funk. Dès qu'une question porte sur l'état courant, un fait "
|
||||
"précis ou la config du homelab, APPELLE l'outil adéquat puis réponds à partir de son "
|
||||
"résultat — n'invente jamais. Si les outils ne donnent pas l'information, dis-le "
|
||||
"simplement. Pour le bavardage général, réponds directement sans outil.",
|
||||
tools=("search_docs", "host_health", "cluster_status", "prometheus_query"),
|
||||
),
|
||||
"funk": Context(
|
||||
id="funk", label="Funk · cluster", icon="🛠️",
|
||||
description="Questions sur le homelab depuis la documentation.",
|
||||
|
|
|
|||
210
stt/server/stt_server/tools.py
Normal file
210
stt/server/stt_server/tools.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
"""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}"
|
||||
Loading…
Add table
Add a link
Reference in a new issue