mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-12 01:14: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
|
|
@ -91,12 +91,26 @@ switch global type `hermes-switch` (pas de restart, chaque client choisit le sie
|
||||||
par requête à `/v1/ask` (`GET /v1/contexts` liste les profils). Chaque profil (`contexts.py`)
|
par requête à `/v1/ask` (`GET /v1/contexts` liste les profils). Chaque profil (`contexts.py`)
|
||||||
a son **system prompt** et ses **sources live** injectées dans le contexte (`sources.py`,
|
a son **system prompt** et ses **sources live** injectées dans le contexte (`sources.py`,
|
||||||
best-effort, env-config) :
|
best-effort, env-config) :
|
||||||
- `funk` — grounding strict + RAG `funk-docs` (défaut, comportement historique)
|
- `asa` — **boucle d'outils (function calling)**, **défaut prod** (`STT_DEFAULT_CONTEXT=asa`). Voir ci-dessous.
|
||||||
|
- `funk` — grounding strict + RAG `funk-docs` injecté d'office (comportement historique, sélectionnable)
|
||||||
- `ghostfolio` — valeur/positions du portefeuille (API Ghostfolio, `STT_GHOSTFOLIO_TOKEN`)
|
- `ghostfolio` — valeur/positions du portefeuille (API Ghostfolio, `STT_GHOSTFOLIO_TOKEN`)
|
||||||
- `grafana` — métriques clés (Prometheus)
|
- `grafana` — métriques clés (Prometheus)
|
||||||
- `alerting` — alertes actives (Alertmanager, hors Watchdog)
|
- `alerting` — alertes actives (Alertmanager, hors Watchdog)
|
||||||
- `cluster` — état pods/nœuds (Prometheus/kube-state-metrics) + RAG doc
|
- `cluster` — état pods/nœuds (Prometheus/kube-state-metrics) + RAG doc
|
||||||
|
|
||||||
|
**Contexte « asa » — boucle d'outils (function calling, local)** : au lieu d'un appel LLM unique
|
||||||
|
sur un contexte figé, Asa **décide elle-même** quels outils appeler (`tools.py`) puis répond à
|
||||||
|
partir de leurs résultats (`brain.ask_with_tools`, boucle bornée par `STT_TOOL_MAX_ITERS=4` ;
|
||||||
|
au-delà, réponse forcée sans outils). C'est ce qui débloque les questions à **état live** sans
|
||||||
|
présélection — ex. « gpu-01 tourne bien ? » → l'outil `host_health` est appelé → réponse réelle
|
||||||
|
(avant : le RAG seul disait « la doc ne le précise pas »). Outils Phase 1, **LECTURE SEULE** :
|
||||||
|
`search_docs` (RAG `funk-docs`), `host_health(gpu-01|storage-01)` (up/charge/RAM + llama-server,
|
||||||
|
Prometheus), `cluster_status` (nœuds/pods), `prometheus_query(expr)` (PromQL arbitraire). Modèle
|
||||||
|
**local** Qwen3-8B (le tool-calling natif de llama.cpp fonctionne, **même avec `/no_think`** —
|
||||||
|
vérifié). Le `trace` des outils appelés est renvoyé dans `context` → visualiseur HUD (un bloc par
|
||||||
|
appel). L'écriture (agir) reste au contexte `agent` ci-dessous (Phase 3 : la brancher comme outil).
|
||||||
|
> Web search (SearXNG in-cluster) = Phase 2 ; actions admin pilotées par le LLM = Phase 3.
|
||||||
|
|
||||||
La réponse `/v1/ask` renvoie le **contexte assemblé** (`context`: system prompt + blocs live +
|
La réponse `/v1/ask` renvoie le **contexte assemblé** (`context`: system prompt + blocs live +
|
||||||
extraits RAG + mémoire) → alimente le **visualiseur** du HUD (« voir ce qu'on envoie à Asa »).
|
extraits RAG + mémoire) → alimente le **visualiseur** du HUD (« voir ce qu'on envoie à Asa »).
|
||||||
URLs in-cluster dans `k8s/apps/stt/deployment.yaml` (Prometheus/Alertmanager `monitoring`,
|
URLs in-cluster dans `k8s/apps/stt/deployment.yaml` (Prometheus/Alertmanager `monitoring`,
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,11 @@ spec:
|
||||||
value: "http://192.168.10.20:1238/v1/embeddings"
|
value: "http://192.168.10.20:1238/v1/embeddings"
|
||||||
- name: STT_EMBED_MODEL
|
- name: STT_EMBED_MODEL
|
||||||
value: "nomic-embed-text"
|
value: "nomic-embed-text"
|
||||||
# Contextes présélectionnables (sources live) — services in-cluster
|
# Contextes présélectionnables (sources live) — services in-cluster.
|
||||||
|
# Défaut « asa » : boucle d'outils (function calling local) → Asa sonde l'état
|
||||||
|
# live du homelab et la doc d'elle-même. « funk » (RAG strict) reste sélectionnable.
|
||||||
- name: STT_DEFAULT_CONTEXT
|
- name: STT_DEFAULT_CONTEXT
|
||||||
value: "funk"
|
value: "asa"
|
||||||
- name: STT_PROMETHEUS_URL
|
- name: STT_PROMETHEUS_URL
|
||||||
value: "http://kube-prometheus-stack-prometheus.monitoring:9090"
|
value: "http://kube-prometheus-stack-prometheus.monitoring:9090"
|
||||||
- name: STT_ALERTMANAGER_URL
|
- name: STT_ALERTMANAGER_URL
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,30 @@ et le **fix du batch embed** (`llama-embed` ubatch 512 → 2048). Détail : `pro
|
||||||
IaC** : `rocm_rhel_version` 9.4 → 9.7, `llama_server_commit` pinné, pièges documentés dans les
|
IaC** : `rocm_rhel_version` 9.4 → 9.7, `llama_server_commit` pinné, pièges documentés dans les
|
||||||
README de rôle. (PR `fix/gpu-apply-amdgpu-repo-and-commit-pin`.)
|
README de rôle. (PR `fix/gpu-apply-amdgpu-repo-and-commit-pin`.)
|
||||||
|
|
||||||
|
## 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) → « gpu-01 tourne bien ? » tombait sur « la doc ne le précise pas » (état live
|
||||||
|
absent de la doc). Bascule vers une **boucle de function-calling** : le modèle décide quels outils
|
||||||
|
appeler, puis répond à partir des résultats.
|
||||||
|
|
||||||
|
- **Validé empiriquement avant de coder** : Qwen3-8B via llama.cpp fait du tool-calling natif
|
||||||
|
(pas besoin de `--jinja`), LiteLLM transmet bien les `tools`, et le tool-calling **survit au
|
||||||
|
`/no_think`**. → 100 % local, pas d'escalade Claude (choix utilisateur).
|
||||||
|
- **Nouveau contexte `asa`** (`STT_DEFAULT_CONTEXT=asa`, défaut prod) + `tools.py` (registre
|
||||||
|
schémas + exécuteurs) + `brain.ask_with_tools` (boucle bornée `STT_TOOL_MAX_ITERS=4`, réponse
|
||||||
|
forcée au-delà). Outils **Phase 1, lecture seule** : `search_docs`, `host_health`,
|
||||||
|
`cluster_status`, `prometheus_query`. Trace des outils → visualiseur HUD.
|
||||||
|
- **PromQL validés** contre le vrai Prometheus in-cluster (labels `gpu-01-node`/`storage-01`/
|
||||||
|
`llama-server-gpu` confirmés ; up/load/mem renvoient des données réelles). 3 tests unitaires de
|
||||||
|
la boucle (hors-ligne). (PR `feat/asa-tool-loop`.)
|
||||||
|
- **Choix** : web search = **SearXNG in-cluster** (Phase 2) ; modèle = **local uniquement**.
|
||||||
|
|
||||||
## Prochaines étapes
|
## Prochaines étapes
|
||||||
|
|
||||||
|
- **Phase 2** — outil `web_search` (SearXNG self-host in-cluster, namespace `ai`, ArgoCD).
|
||||||
|
- **Phase 3** — outil `admin_action` (écriture) : brancher `hermes-exec` comme outil piloté par le
|
||||||
|
LLM, avec le handshake de confirmation vocale existant.
|
||||||
|
- Enrichir `host_health`/`cluster_status` (GPU via `rocm_scraper`, fs, températures) si utile.
|
||||||
- Si le wake word « asa » génère des faux positifs/négatifs, ajuster `wake_aliases`.
|
- Si le wake word « asa » génère des faux positifs/négatifs, ajuster `wake_aliases`.
|
||||||
- **1er test d'action vocale** (#43, contexte 🤖 Agent → `hermes-exec`).
|
- **1er test d'action vocale** (#43, contexte 🤖 Agent → `hermes-exec`).
|
||||||
- Vérifier qu'un `make apply-gpu` **complet** repasse vert (repo amdgpu sain) à la prochaine occasion.
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "stt-server"
|
name = "stt-server"
|
||||||
version = "0.3.1"
|
version = "0.7.0"
|
||||||
description = "STT-server — orchestrateur AI du homelab Funk (API pour les clients STT)"
|
description = "STT-server — orchestrateur AI du homelab Funk (API pour les clients STT)"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
"""STT-server — orchestrateur AI in-cluster pour les clients STT."""
|
"""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.brain import ask as brain_ask
|
||||||
from stt_server.config import settings
|
from stt_server.config import settings
|
||||||
from stt_server import agent
|
from stt_server import agent
|
||||||
|
from stt_server import tools
|
||||||
from stt_server.contexts import CONTEXTS, assemble, get_context
|
from stt_server.contexts import CONTEXTS, assemble, get_context
|
||||||
from stt_server.knowledge import Knowledge
|
from stt_server.knowledge import Knowledge
|
||||||
from stt_server.longterm import LongTermMemory
|
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}))
|
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)
|
@app.post("/v1/ask", response_model=AskReply)
|
||||||
async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply:
|
async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply:
|
||||||
text = req.text.strip()
|
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.
|
# Contexte « agent » : flux dédié (confirmation 2 temps → hermes-exec), sans LLM.
|
||||||
if ctx.id == "agent":
|
if ctx.id == "agent":
|
||||||
return await _handle_agent(req, text)
|
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()
|
t0 = time.perf_counter()
|
||||||
history = sessions.history(req.session_id) if req.session_id else None
|
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)
|
# 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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from stt_server.config import settings
|
from stt_server.config import settings
|
||||||
|
|
@ -29,6 +32,47 @@ async def aclose() -> None:
|
||||||
_client = 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(
|
async def ask(
|
||||||
text: str,
|
text: str,
|
||||||
system: 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
|
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.
|
(app.v1_ask + contexts) ; ici on ne fait que le réglage thinking + l'appel LLM.
|
||||||
"""
|
"""
|
||||||
if settings.disable_thinking:
|
messages = [{"role": "system", "content": _with_no_think(system)}]
|
||||||
# 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:
|
if history:
|
||||||
messages += history
|
messages += history
|
||||||
messages.append({"role": "user", "content": text})
|
messages.append({"role": "user", "content": text})
|
||||||
payload = {
|
msg = await _post({
|
||||||
"model": model or settings.model,
|
"model": model or settings.model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"max_tokens": settings.max_tokens,
|
"max_tokens": settings.max_tokens,
|
||||||
"temperature": settings.temperature,
|
"temperature": settings.temperature,
|
||||||
}
|
})
|
||||||
headers = {"Authorization": f"Bearer {settings.litellm_key}"}
|
return _content_of(msg)
|
||||||
# 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.
|
# dispatch(name, args) -> texte du résultat d'outil. Fourni par app.py (closure sur les deps).
|
||||||
last_exc: Exception | None = None
|
Dispatch = Callable[[str, dict], Awaitable[str]]
|
||||||
for attempt in range(2):
|
|
||||||
try:
|
|
||||||
r = await _get_client().post(
|
async def ask_with_tools(
|
||||||
settings.litellm_url,
|
text: str,
|
||||||
json=payload,
|
system: str,
|
||||||
headers=headers,
|
*,
|
||||||
timeout=settings.request_timeout,
|
schemas: list[dict],
|
||||||
)
|
dispatch: Dispatch,
|
||||||
r.raise_for_status()
|
model: str | None = None,
|
||||||
break
|
history: list[dict] | None = None,
|
||||||
except httpx.HTTPError as e:
|
) -> tuple[str, list[dict]]:
|
||||||
last_exc = e
|
"""Boucle de function-calling : le modèle appelle des outils jusqu'à pouvoir répondre.
|
||||||
await aclose() # repart sur une connexion neuve (pool potentiellement vicié)
|
|
||||||
else:
|
Renvoie (réponse, trace) où `trace` = [{name, args, result}] des outils exécutés
|
||||||
raise last_exc # type: ignore[misc]
|
(alimente le visualiseur du HUD). Borné par `tool_max_iters` ; au-delà, un dernier
|
||||||
msg = r.json()["choices"][0]["message"]
|
appel SANS outils force une réponse texte. Si aucun schéma n'est fourni, repli sur `ask`.
|
||||||
# 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 "".
|
if not schemas:
|
||||||
content = (msg.get("content") or "").strip()
|
return await ask(text, system, model, history), []
|
||||||
if not content:
|
|
||||||
content = (msg.get("reasoning_content") or "").strip()
|
messages: list[dict] = [{"role": "system", "content": _with_no_think(system)}]
|
||||||
return content
|
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")
|
prometheus_url: str = os.getenv("STT_PROMETHEUS_URL", "http://prometheus.lab.local")
|
||||||
alertmanager_url: str = os.getenv("STT_ALERTMANAGER_URL", "http://alertmanager.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.
|
# 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.
|
# 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"
|
actions_enabled: bool = os.getenv("STT_ACTIONS_ENABLED", "false").lower() == "true"
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,26 @@ class Context:
|
||||||
description: str
|
description: str
|
||||||
system_prompt: str
|
system_prompt: str
|
||||||
sources: tuple[str, ...] = () # "docs","ghostfolio","alerts","cluster","metrics"
|
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).
|
# Le contexte « funk » garde le prompt grounding-strict historique (env override possible).
|
||||||
CONTEXTS: dict[str, Context] = {
|
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(
|
"funk": Context(
|
||||||
id="funk", label="Funk · cluster", icon="🛠️",
|
id="funk", label="Funk · cluster", icon="🛠️",
|
||||||
description="Questions sur le homelab depuis la documentation.",
|
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}"
|
||||||
102
stt/server/tests/test_tool_loop.py
Normal file
102
stt/server/tests/test_tool_loop.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""Tests de la boucle de function-calling (`brain.ask_with_tools`).
|
||||||
|
|
||||||
|
Hors-ligne : on monkeypatche `brain._post` (l'appel LiteLLM) pour simuler les réponses
|
||||||
|
du modèle, et on fournit un `dispatch` factice. Lançable avec `pytest` (pas de plugin
|
||||||
|
async requis : chaque test pilote sa propre boucle via `asyncio.run`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from stt_server import brain
|
||||||
|
|
||||||
|
|
||||||
|
def test_loop_calls_tool_then_answers(monkeypatch):
|
||||||
|
calls: list[dict] = []
|
||||||
|
|
||||||
|
async def fake_post(payload):
|
||||||
|
calls.append(payload)
|
||||||
|
if len(calls) == 1: # 1er tour : le modèle demande un outil
|
||||||
|
return {
|
||||||
|
"role": "assistant", "content": "",
|
||||||
|
"tool_calls": [{
|
||||||
|
"id": "t1", "type": "function",
|
||||||
|
"function": {"name": "host_health", "arguments": '{"host": "gpu-01"}'},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
return {"role": "assistant", "content": "gpu-01 est en ligne et se porte bien."}
|
||||||
|
|
||||||
|
monkeypatch.setattr(brain, "_post", fake_post)
|
||||||
|
executed: list[tuple[str, dict]] = []
|
||||||
|
|
||||||
|
async def dispatch(name, args):
|
||||||
|
executed.append((name, args))
|
||||||
|
return "gpu-01 : en ligne, charge 0.16, RAM libre 48%."
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
schemas = [{"type": "function", "function": {"name": "host_health", "parameters": {}}}]
|
||||||
|
return await brain.ask_with_tools(
|
||||||
|
"gpu-01 tourne bien ?", "Tu es Asa.",
|
||||||
|
schemas=schemas, dispatch=dispatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
reply, trace = asyncio.run(go())
|
||||||
|
assert executed == [("host_health", {"host": "gpu-01"})]
|
||||||
|
assert "en ligne" in reply
|
||||||
|
assert len(trace) == 1 and trace[0]["name"] == "host_health"
|
||||||
|
# Le 2e appel doit réinjecter l'assistant (avec tool_calls) puis le résultat d'outil.
|
||||||
|
assert [m["role"] for m in calls[1]["messages"]] == ["system", "user", "assistant", "tool"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_schemas_falls_back_to_plain_ask(monkeypatch):
|
||||||
|
async def fake_post(payload):
|
||||||
|
assert "tools" not in payload # repli sur ask() : pas d'outils dans le payload
|
||||||
|
return {"role": "assistant", "content": "réponse simple"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(brain, "_post", fake_post)
|
||||||
|
|
||||||
|
async def dispatch(name, args): # ne doit jamais être appelé
|
||||||
|
raise AssertionError("dispatch ne doit pas être invoqué sans schémas")
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
return await brain.ask_with_tools(
|
||||||
|
"salut", "sys", schemas=[], dispatch=dispatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
reply, trace = asyncio.run(go())
|
||||||
|
assert reply == "réponse simple" and trace == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_loop_forces_answer_after_max_iters(monkeypatch):
|
||||||
|
"""Si le modèle boucle indéfiniment sur des outils, un dernier appel force une réponse."""
|
||||||
|
calls: list[dict] = []
|
||||||
|
|
||||||
|
async def fake_post(payload):
|
||||||
|
calls.append(payload)
|
||||||
|
if payload.get("tool_choice") == "none": # appel final forcé
|
||||||
|
return {"role": "assistant", "content": "Réponse finale forcée."}
|
||||||
|
return { # tour normal : redemande toujours un outil
|
||||||
|
"role": "assistant", "content": "",
|
||||||
|
"tool_calls": [{
|
||||||
|
"id": f"t{len(calls)}", "type": "function",
|
||||||
|
"function": {"name": "cluster_status", "arguments": "{}"},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(brain, "_post", fake_post)
|
||||||
|
|
||||||
|
async def dispatch(name, args):
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
schemas = [{"type": "function", "function": {"name": "cluster_status", "parameters": {}}}]
|
||||||
|
return await brain.ask_with_tools(
|
||||||
|
"état ?", "sys", schemas=schemas, dispatch=dispatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
reply, trace = asyncio.run(go())
|
||||||
|
assert reply == "Réponse finale forcée."
|
||||||
|
# tool_max_iters tours d'outils + 1 appel final forcé.
|
||||||
|
assert calls[-1]["tool_choice"] == "none"
|
||||||
|
assert len(trace) == brain.settings.tool_max_iters
|
||||||
Loading…
Add table
Add a link
Reference in a new issue