mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 21:34:43 +02:00
Phase 2 d'Asa agentique : nouvel outil web_search adossé à un SearXNG self-host in-cluster, + correction du faux positif de cluster_status. - SearXNG (k8s/apps/searxng/) : Deployment + Service + ConfigMap settings.yml + IngressRoute searxng.lab.local, Application ArgoCD. Namespace ai, interne (l'outil tape http://searxng:8080). use_default_settings + search.formats inclut json (sinon API JSON 403) ; limiter/image_proxy off ; image pinnée ; conf copiée dans un emptyDir via initContainer (contourne le mount RO) ; PodSecurity restricted. - Outil web_search (tools._web_search) ajouté au contexte asa + STT_SEARXNG_URL. - fix(cluster_status) : les pods de CronJob TERMINÉS (Succeeded/Failed, ex. sacrifice-assign-renfort) comptaient comme « non prêts » → fausse alarme. Join kube_pod_status_phase{phase=~"Running |Pending"} (3 faux positifs → 0, validé in-cluster). - Serveur 0.8.0 ; doc stt.md + journal mis à jour. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
217 lines
8.3 KiB
Python
217 lines
8.3 KiB
Python
"""Sources de données live injectées dans le contexte d'Asa (lecture seule).
|
|
|
|
Chaque fonction renvoie un bloc de texte court (ou une note d'indisponibilité) à
|
|
injecter dans le prompt. Tout est best-effort : une source injoignable renvoie une
|
|
note explicative, jamais une exception (on n'ajoute jamais de latence fatale au tour).
|
|
|
|
URLs et jeton via variables d'environnement (cf. config.Settings) → surchargeables
|
|
selon la résolution réseau in-cluster.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
|
|
from stt_server.config import settings
|
|
|
|
# Alerte toujours active (test du pipeline) — exclue des résumés.
|
|
_ALWAYS_ON = {"Watchdog"}
|
|
|
|
|
|
async def _prom_query(client: httpx.AsyncClient, expr: str) -> list[tuple[dict, float]]:
|
|
r = await client.get(
|
|
f"{settings.prometheus_url.rstrip('/')}/api/v1/query",
|
|
params={"query": expr}, timeout=settings.sources_timeout,
|
|
)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
out: list[tuple[dict, float]] = []
|
|
for s in data.get("data", {}).get("result", []):
|
|
try:
|
|
out.append((s.get("metric", {}), float(s["value"][1])))
|
|
except (KeyError, IndexError, ValueError, TypeError):
|
|
pass
|
|
return out
|
|
|
|
|
|
def _money(v) -> str:
|
|
return f"{round(float(v)):,}".replace(",", " ")
|
|
|
|
|
|
async def _ghostfolio_fetch(client: httpx.AsyncClient, token: str | None):
|
|
"""Récupère le portefeuille → (value, currency, holdings) ou une str d'erreur.
|
|
|
|
`token` : jeton fourni par le client (prioritaire) ; à défaut, jeton serveur (env).
|
|
"""
|
|
base = settings.ghostfolio_url.rstrip("/")
|
|
token = token or settings.ghostfolio_token
|
|
if not token:
|
|
return "Données indisponibles : aucun jeton Ghostfolio (ni client, ni serveur)."
|
|
try:
|
|
r = await client.post(
|
|
f"{base}/api/v1/auth/anonymous",
|
|
json={"accessToken": token}, timeout=settings.sources_timeout,
|
|
)
|
|
if r.status_code >= 400:
|
|
return "Données indisponibles : authentification Ghostfolio refusée (jeton ?)."
|
|
jwt = (r.json() or {}).get("authToken")
|
|
if not jwt:
|
|
return "Données indisponibles : jeton Ghostfolio invalide."
|
|
r = await client.get(
|
|
f"{base}/api/v1/portfolio/details",
|
|
headers={"Authorization": f"Bearer {jwt}"}, timeout=settings.sources_timeout,
|
|
)
|
|
r.raise_for_status()
|
|
data = r.json() or {}
|
|
summary = data.get("summary", {}) or {}
|
|
val = (summary.get("currentValueInBaseCurrency")
|
|
or summary.get("currentValue") or summary.get("netWorth"))
|
|
cur = summary.get("baseCurrency") or "EUR"
|
|
holdings = data.get("holdings", {}) or {}
|
|
return (val, cur, holdings)
|
|
except httpx.HTTPError:
|
|
return "Données indisponibles : Ghostfolio injoignable."
|
|
except (ValueError, KeyError):
|
|
return "Données indisponibles : réponse Ghostfolio inattendue."
|
|
|
|
|
|
async def ghostfolio_block(client: httpx.AsyncClient, token: str | None = None) -> str:
|
|
"""Bloc de contexte (valeur + positions principales)."""
|
|
res = await _ghostfolio_fetch(client, token)
|
|
if isinstance(res, str):
|
|
return res
|
|
val, cur, holdings = res
|
|
tops = sorted(
|
|
(h for h in holdings.values() if isinstance(h, dict)),
|
|
key=lambda h: h.get("valueInBaseCurrency") or 0, reverse=True,
|
|
)[:5]
|
|
lines = []
|
|
if val is not None:
|
|
lines.append(f"Valeur actuelle du portefeuille : {_money(val)} {cur}.")
|
|
for h in tops:
|
|
name = h.get("name") or h.get("symbol") or "?"
|
|
hv = h.get("valueInBaseCurrency")
|
|
if hv:
|
|
lines.append(f"- {name} : {_money(hv)}")
|
|
return "\n".join(lines) if lines else "Aucune donnée de portefeuille renvoyée."
|
|
|
|
|
|
async def ghostfolio_phrase(client: httpx.AsyncClient, token: str | None = None) -> str:
|
|
"""Phrase parlable (intent vocal « combien sur mon ghostfolio »)."""
|
|
res = await _ghostfolio_fetch(client, token)
|
|
if isinstance(res, str):
|
|
return res
|
|
val, cur, _ = res
|
|
if val is None:
|
|
return "Je n'ai pas trouvé la valeur du portefeuille dans la réponse de Ghostfolio."
|
|
return f"Ton portefeuille Ghostfolio vaut environ {_money(val)} {cur}."
|
|
|
|
|
|
async def alerts_block(client: httpx.AsyncClient) -> str:
|
|
base = settings.alertmanager_url.rstrip("/")
|
|
try:
|
|
r = await client.get(
|
|
f"{base}/api/v2/alerts",
|
|
params={"active": "true", "silenced": "false"}, timeout=settings.sources_timeout,
|
|
)
|
|
r.raise_for_status()
|
|
raw = r.json()
|
|
except (httpx.HTTPError, ValueError):
|
|
return "Données indisponibles : Alertmanager injoignable."
|
|
alerts = []
|
|
for a in raw if isinstance(raw, list) else []:
|
|
labels = a.get("labels", {}) or {}
|
|
if labels.get("alertname") in _ALWAYS_ON:
|
|
continue
|
|
ann = a.get("annotations", {}) or {}
|
|
alerts.append(
|
|
f"- {labels.get('alertname', 'alerte')} "
|
|
f"({labels.get('severity', 'none')}) : "
|
|
f"{ann.get('summary') or ann.get('description') or ''}".strip()
|
|
)
|
|
if not alerts:
|
|
return "Aucune alerte active actuellement."
|
|
return f"{len(alerts)} alerte(s) active(s) :\n" + "\n".join(alerts)
|
|
|
|
|
|
async def cluster_block(client: httpx.AsyncClient) -> str:
|
|
try:
|
|
nodes = await _prom_query(client, 'up{job=~"storage-01|gpu-01-node"}')
|
|
# « Non prêt » = ready false ET pod ENCORE actif (Running/Pending). Sans le filtre de
|
|
# phase, les pods de CronJob terminés (Succeeded/Failed — ex. sacrifice-assign-renfort)
|
|
# comptent comme « non prêts » → fausse alarme. Le join exclut les pods terminés.
|
|
not_ready = await _prom_query(
|
|
client,
|
|
'kube_pod_status_ready{condition="false"} == 1'
|
|
' and on(namespace,pod) kube_pod_status_phase{phase=~"Running|Pending"} == 1',
|
|
)
|
|
ready = await _prom_query(client, 'count(kube_pod_status_ready{condition="true"} == 1)')
|
|
except httpx.HTTPError:
|
|
return "Données indisponibles : Prometheus injoignable."
|
|
hosts_up = sum(1 for _, v in nodes if v == 1)
|
|
lines = [f"Hôtes hors-cluster joignables : {hosts_up}/{len(nodes) or 2} (storage-01, gpu-01)."]
|
|
if ready:
|
|
lines.append(f"Pods prêts (cluster) : {int(ready[0][1])}.")
|
|
if not_ready:
|
|
names = ", ".join(
|
|
m.get("pod", "?") for m, _ in not_ready[:6] if m.get("pod")
|
|
)
|
|
lines.append(f"Pods NON prêts : {names or len(not_ready)}.")
|
|
else:
|
|
lines.append("Tous les pods scrutés sont prêts.")
|
|
return "\n".join(lines)
|
|
|
|
|
|
async def metrics_block(client: httpx.AsyncClient) -> str:
|
|
try:
|
|
llama = await _prom_query(client, 'up{job="llama-server-gpu"}')
|
|
targets_up = await _prom_query(client, 'count(up == 1)')
|
|
targets_all = await _prom_query(client, 'count(up)')
|
|
except httpx.HTTPError:
|
|
return "Données indisponibles : Prometheus injoignable."
|
|
lines = []
|
|
if llama:
|
|
lines.append("llama-server (GPU) : " + ("en ligne" if llama[0][1] == 1 else "hors ligne") + ".")
|
|
if targets_up and targets_all:
|
|
lines.append(f"Cibles Prometheus UP : {int(targets_up[0][1])}/{int(targets_all[0][1])}.")
|
|
return "\n".join(lines) if lines else "Aucune métrique disponible."
|
|
|
|
|
|
# Aiguillage source_id → fetcher
|
|
_FETCHERS = {
|
|
"ghostfolio": ghostfolio_block,
|
|
"alerts": alerts_block,
|
|
"cluster": cluster_block,
|
|
"metrics": metrics_block,
|
|
}
|
|
|
|
|
|
async def fetch_blocks(
|
|
client: httpx.AsyncClient,
|
|
sources: tuple[str, ...],
|
|
secrets: dict | None = None,
|
|
) -> list[tuple[str, str]]:
|
|
"""Récupère les blocs live demandés (hors « docs », géré par le RAG). Best-effort.
|
|
|
|
`secrets` : jetons fournis par le client (ex. {"ghostfolio_token": …}) → priorité
|
|
sur les jetons serveur (env). Permet d'unifier la source du jeton côté client.
|
|
"""
|
|
import asyncio
|
|
|
|
secrets = secrets or {}
|
|
wanted = [s for s in sources if s in _FETCHERS]
|
|
if not wanted:
|
|
return []
|
|
|
|
def _call(s):
|
|
if s == "ghostfolio":
|
|
return ghostfolio_block(client, secrets.get("ghostfolio_token"))
|
|
return _FETCHERS[s](client)
|
|
|
|
results = await asyncio.gather(
|
|
*(_call(s) for s in wanted), return_exceptions=True
|
|
)
|
|
out: list[tuple[str, str]] = []
|
|
for src, res in zip(wanted, results):
|
|
out.append((src, res if isinstance(res, str) else ""))
|
|
return out
|