mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 18:44:43 +02:00
feat(stt): Phase 3 — actions admin pilotées par le LLM (admin_action)
Asa peut désormais AGIR sur le homelab quand on le demande explicitement, via un outil de la boucle agentique — mais jamais sans confirmation. - Outil admin_action(description) (contexte asa) : le LLM PROPOSE une action, n'exécute rien. brain.ask_with_tools gagne `confirm_tools` : un tel outil arrête la boucle et surface sa réponse (la question de confirmation). - _handle_agentic : stocke la proposition en pending par session ; au tour suivant « confirme » → agent.run_action → hermes-exec (hermes -z --yolo), « annule » → oubli. Réutilise le handshake + jeton du contexte agent. - admin_action n'est exposé que si _actions_available() (STT_ACTIONS_ENABLED + jeton) ; sinon retiré des schémas envoyés au modèle. - Factorisation du ctx_debug du visualiseur. 1 test unitaire (confirm_tools arrête la boucle). Serveur 0.9.0 ; doc stt.md + journal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
55344b55ed
commit
5543edce46
9 changed files with 163 additions and 28 deletions
|
|
@ -108,8 +108,16 @@ présélection — ex. « gpu-01 tourne bien ? » → l'outil `host_health` est
|
|||
Prometheus), `cluster_status` (nœuds/pods), `prometheus_query(expr)` (PromQL arbitraire), et
|
||||
**`web_search(query)`** (recherche INTERNET via SearXNG, **Phase 2**). 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).
|
||||
des outils appelés est renvoyé dans `context` → visualiseur HUD (un bloc par appel).
|
||||
|
||||
**Outil d'écriture `admin_action` (Phase 3)** : pour une demande explicite d'AGIR, le LLM appelle
|
||||
`admin_action(description)` qui **n'exécute rien** — la boucle s'arrête (`confirm_tools` dans
|
||||
`ask_with_tools`), Asa **propose** l'action et stocke un `pending` par session. Le tour suivant,
|
||||
si l'utilisateur dit « confirme » (mots de `agent.is_confirmation`), `_handle_agentic` exécute via
|
||||
`agent.run_action` → **hermes-exec** (`hermes -z --yolo`) ; « annule » l'oublie. Réutilise le
|
||||
handshake + le jeton du contexte `agent`. **Exposé seulement si `_actions_available()`** (opt-in
|
||||
`STT_ACTIONS_ENABLED` + jeton) : sinon `admin_action` est retiré des schémas. La confirmation
|
||||
protège des erreurs de transcription ; le jeton, des déclenchements non autorisés.
|
||||
|
||||
> **`cluster_status`** filtre les pods **terminés** (CronJob Succeeded/Failed) via un join
|
||||
> `kube_pod_status_phase` → ne signale comme « non prêts » que les pods réellement actifs (sinon
|
||||
|
|
@ -121,7 +129,7 @@ des outils appelés est renvoyé dans `context` → visualiseur HUD (un bloc par
|
|||
`http://searxng:8080`). Points clés : `use_default_settings: true` (hérite des moteurs),
|
||||
**`search.formats` inclut `json`** (sinon l'API JSON renvoie 403), `limiter:false`/`image_proxy:false`
|
||||
(usage interne), image **pinnée**, conf copiée dans un emptyDir via initContainer (évite le mount RO).
|
||||
Outil = `STT_SEARXNG_URL` (`tools._web_search`). Actions admin pilotées par le LLM = **Phase 3**.
|
||||
Outil = `STT_SEARXNG_URL` (`tools._web_search`).
|
||||
|
||||
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 »).
|
||||
|
|
|
|||
|
|
@ -97,12 +97,24 @@ appeler, puis répond à partir des résultats.
|
|||
inclure `json`. Image pinnée, conf via initContainer→emptyDir (mount RO contourné), PodSecurity
|
||||
restricted. (PR `feat/asa-web-search`, serveur 0.8.0.)
|
||||
|
||||
### Vérif prod #49 + fix SearXNG + Phase 3
|
||||
|
||||
- ✅ **`cluster_status` corrigé vérifié en prod** : « le cluster va bien ? » → *« 38 pods prêts, un
|
||||
seul non prêt : searxng-… »* → ignore bien les pods cron terminés ET pointe le vrai souci.
|
||||
- 🐛 **SearXNG crashait** (`'tcp://…:8080' is not a valid integer`) : le Service `searxng` faisait
|
||||
injecter l'env service-link `SEARXNG_PORT=tcp://…`, lu comme port de bind. Fix `enableServiceLinks:
|
||||
false` + `SEARXNG_PORT`/`SEARXNG_BIND_ADDRESS` explicites (**PR `fix/searxng-port-collision`**).
|
||||
Vérif end-to-end `web_search` **après merge** de ce fix.
|
||||
- ✅ **Phase 3 — actions admin pilotées par le LLM** : outil `admin_action(description)` (contexte
|
||||
`asa`) qui **ne fait que proposer** — la boucle s'arrête (`confirm_tools`), Asa demande confirmation,
|
||||
le tour suivant « confirme » → `hermes-exec`. Réutilise le handshake + jeton du contexte `agent` ;
|
||||
exposé seulement si `STT_ACTIONS_ENABLED` + jeton. 1 test unitaire de plus. (PR `feat/asa-admin-actions`,
|
||||
serveur 0.9.0.)
|
||||
|
||||
## Prochaines étapes
|
||||
|
||||
- **Vérifier SearXNG au déploiement** : pod Ready + `web_search` renvoie des résultats réels
|
||||
(test end-to-end comme pour #48). Ajuster les moteurs SearXNG si bruyant.
|
||||
- **Phase 3** — outil `admin_action` (écriture) : brancher `hermes-exec` comme outil piloté par le
|
||||
LLM, avec le handshake de confirmation vocale existant.
|
||||
- **Vérifier au déploiement** : SearXNG Ready + `web_search` réel ; puis un essai `admin_action`
|
||||
(« redémarre … » → confirme → exécution Hermes).
|
||||
- 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`.
|
||||
- **1er test d'action vocale** (#43, contexte 🤖 Agent → `hermes-exec`).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "stt-server"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
description = "STT-server — orchestrateur AI du homelab Funk (API pour les clients STT)"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
"""STT-server — orchestrateur AI in-cluster pour les clients STT."""
|
||||
|
||||
__version__ = "0.8.0"
|
||||
__version__ = "0.9.0"
|
||||
|
|
|
|||
|
|
@ -171,6 +171,26 @@ async def _handle_agent(req: AskRequest, text: str) -> AskReply:
|
|||
context=_agent_context_debug({"pending": text}))
|
||||
|
||||
|
||||
def _tool_blocks(trace: list[dict]) -> list[dict]:
|
||||
"""Trace d'outils → blocs du visualiseur HUD (un bloc par appel)."""
|
||||
return [
|
||||
{
|
||||
"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 trace
|
||||
]
|
||||
|
||||
|
||||
def _agentic_ctx_debug(ctx, memories: list[str], trace: list[dict]) -> dict:
|
||||
return {
|
||||
"id": ctx.id, "label": ctx.label, "icon": ctx.icon,
|
||||
"system_prompt": ctx.system_prompt,
|
||||
"blocks": _tool_blocks(trace), "docs": [], "memories": memories,
|
||||
}
|
||||
|
||||
|
||||
async def _handle_agentic(
|
||||
req: "AskRequest", text: str, ctx, model: str, background: BackgroundTasks
|
||||
) -> "AskReply":
|
||||
|
|
@ -178,8 +198,26 @@ async def _handle_agentic(
|
|||
|
||||
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.
|
||||
Phase 3 : si une action admin a été proposée au tour précédent, ce tour la confirme/annule.
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
sid = req.session_id or "anon"
|
||||
# Confirmation d'une action admin proposée au tour précédent (handshake, hors LLM).
|
||||
pending = pending_actions.get(sid)
|
||||
if pending and _actions_available():
|
||||
if agent.is_confirmation(text):
|
||||
pending_actions.pop(sid, None)
|
||||
out = await agent.run_action(pending)
|
||||
log.info("asa EXEC sid=%s prompt=%r", sid, pending[:120])
|
||||
trace = [{"name": "admin_action", "args": {"description": pending}, "result": out}]
|
||||
return AskReply(reply=out, model=model, context_id=ctx.id,
|
||||
context=_agentic_ctx_debug(ctx, [], trace))
|
||||
if agent.is_cancellation(text):
|
||||
pending_actions.pop(sid, None)
|
||||
return AskReply(reply="C'est annulé.", model=model, context_id=ctx.id,
|
||||
context=_agentic_ctx_debug(ctx, [], []))
|
||||
# Ni confirmation ni annulation → l'utilisateur est passé à autre chose : on oublie.
|
||||
pending_actions.pop(sid, None)
|
||||
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
|
||||
|
|
@ -189,18 +227,31 @@ async def _handle_agentic(
|
|||
"\n\nÉléments de mémoire long-terme (peuvent aider, ignore si hors-sujet) :\n"
|
||||
+ souvenirs
|
||||
)
|
||||
schemas = tools.schemas_for(ctx.tools)
|
||||
# admin_action n'est exposé que si les actions sont activées (opt-in + jeton).
|
||||
tool_names = tuple(
|
||||
t for t in ctx.tools if t != "admin_action" or _actions_available()
|
||||
)
|
||||
schemas = tools.schemas_for(tool_names)
|
||||
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:
|
||||
# admin_action : on n'exécute PAS — on mémorise la proposition et on demande
|
||||
# confirmation (la boucle s'arrête via confirm_tools). Exécution = tour suivant.
|
||||
if name == "admin_action":
|
||||
desc = (args.get("description") or "").strip()
|
||||
if not desc:
|
||||
return "Aucune action décrite."
|
||||
pending_actions[sid] = desc
|
||||
return (f"Tu veux que j'exécute : « {desc} » ? "
|
||||
"Dis « confirme » pour lancer, ou « annule ».")
|
||||
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,
|
||||
model=model, history=history, confirm_tools=("admin_action",),
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(status_code=502, detail=f"upstream LiteLLM : {e}") from e
|
||||
|
|
@ -215,21 +266,9 @@ async def _handle_agentic(
|
|||
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)
|
||||
# Visualiseur HUD : chaque appel d'outil = un bloc (réutilise l'UI existante).
|
||||
return AskReply(reply=reply, model=model, context_id=ctx.id,
|
||||
context=_agentic_ctx_debug(ctx, memories, tool_trace))
|
||||
|
||||
|
||||
@app.post("/v1/ask", response_model=AskReply)
|
||||
|
|
|
|||
|
|
@ -109,12 +109,17 @@ async def ask_with_tools(
|
|||
dispatch: Dispatch,
|
||||
model: str | None = None,
|
||||
history: list[dict] | None = None,
|
||||
confirm_tools: tuple[str, ...] = (),
|
||||
) -> 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`.
|
||||
|
||||
`confirm_tools` : outils « à confirmation » (ex. admin_action). Quand le modèle en appelle
|
||||
un, la boucle s'ARRÊTE et renvoie directement le texte de son dispatch (la question de
|
||||
confirmation) comme réponse — l'exécution réelle est différée au tour suivant (app.py).
|
||||
"""
|
||||
if not schemas:
|
||||
return await ask(text, system, model, history), []
|
||||
|
|
@ -153,6 +158,9 @@ async def ask_with_tools(
|
|||
args = {}
|
||||
result = await dispatch(name, args)
|
||||
trace.append({"name": name, "args": args, "result": result})
|
||||
if name in confirm_tools:
|
||||
# Outil à confirmation : on stoppe la boucle et on surface la question.
|
||||
return result, trace
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.get("id", ""),
|
||||
|
|
|
|||
|
|
@ -47,8 +47,12 @@ CONTEXTS: dict[str, Context] = {
|
|||
"question porte sur l'état courant, un fait précis du homelab, ou une info externe / "
|
||||
"récente, 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", "web_search"),
|
||||
"bavardage général, réponds directement sans outil. Si l'utilisateur demande "
|
||||
"explicitement d'AGIR (redémarrer, relancer, modifier…), utilise l'outil admin_action "
|
||||
"(s'il est disponible) — il proposera l'action et demandera confirmation, sans rien "
|
||||
"exécuter de toi-même.",
|
||||
tools=("search_docs", "host_health", "cluster_status", "prometheus_query", "web_search",
|
||||
"admin_action"),
|
||||
),
|
||||
"funk": Context(
|
||||
id="funk", label="Funk · cluster", icon="🛠️",
|
||||
|
|
|
|||
|
|
@ -146,6 +146,12 @@ async def _web_search(args: dict, deps: ToolDeps) -> str:
|
|||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _admin_action_placeholder(args: dict, deps: ToolDeps) -> str:
|
||||
# Jamais atteint : app.py intercepte admin_action dans son dispatch (handshake de
|
||||
# confirmation). Présent uniquement pour que le schéma soit exposé au LLM via le registre.
|
||||
return "Action en attente de confirmation."
|
||||
|
||||
|
||||
# --- Registre : nom → (schéma OpenAI, exécuteur) --------------------------------
|
||||
|
||||
_TOOLS: dict[str, tuple[dict, object]] = {
|
||||
|
|
@ -243,6 +249,30 @@ _TOOLS: dict[str, tuple[dict, object]] = {
|
|||
},
|
||||
_web_search,
|
||||
),
|
||||
"admin_action": (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "admin_action",
|
||||
"description": "PROPOSE une action d'administration qui MODIFIE le homelab "
|
||||
"(redémarrer un service/déploiement, relancer un pod, appliquer un changement…). "
|
||||
"N'exécute RIEN directement : l'utilisateur devra confirmer vocalement. À utiliser "
|
||||
"uniquement pour une demande explicite d'AGIR, jamais pour une simple lecture.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Action à effectuer, en une phrase claire et impérative "
|
||||
"(ex. « redémarrer le déploiement open-webui »).",
|
||||
}
|
||||
},
|
||||
"required": ["description"],
|
||||
},
|
||||
},
|
||||
},
|
||||
_admin_action_placeholder,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,40 @@ def test_no_schemas_falls_back_to_plain_ask(monkeypatch):
|
|||
assert reply == "réponse simple" and trace == []
|
||||
|
||||
|
||||
def test_confirm_tool_stops_loop(monkeypatch):
|
||||
"""Un outil de `confirm_tools` (ex. admin_action) arrête la boucle et surface sa réponse."""
|
||||
calls: list[dict] = []
|
||||
|
||||
async def fake_post(payload):
|
||||
calls.append(payload)
|
||||
return { # le modèle veut agir → appelle admin_action
|
||||
"role": "assistant", "content": "",
|
||||
"tool_calls": [{
|
||||
"id": "a1", "type": "function",
|
||||
"function": {"name": "admin_action",
|
||||
"arguments": '{"description": "redémarrer open-webui"}'},
|
||||
}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(brain, "_post", fake_post)
|
||||
|
||||
async def dispatch(name, args):
|
||||
# simule le handshake : renvoie la question de confirmation (pas d'exécution)
|
||||
return f"Tu veux que j'exécute : « {args['description']} » ? Dis « confirme »."
|
||||
|
||||
async def go():
|
||||
schemas = [{"type": "function", "function": {"name": "admin_action", "parameters": {}}}]
|
||||
return await brain.ask_with_tools(
|
||||
"redémarre open-webui", "sys",
|
||||
schemas=schemas, dispatch=dispatch, confirm_tools=("admin_action",),
|
||||
)
|
||||
|
||||
reply, trace = asyncio.run(go())
|
||||
assert "confirme" in reply
|
||||
assert len(calls) == 1 # boucle stoppée après 1 appel LLM (pas de 2e tour)
|
||||
assert trace[0]["name"] == "admin_action"
|
||||
|
||||
|
||||
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] = []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue