mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 18:14:42 +02:00
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>
102 lines
3.8 KiB
Python
102 lines
3.8 KiB
Python
"""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
|