mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 11:44:43 +02:00
feat(stt-server): contextes présélectionnables + sources live + contexte assemblé (visualiseur) (#38)
Asa n'est plus bloqué sur le seul contexte « doc cluster grounding-strict ». Le client choisit un contexte par requête ; le serveur change le system prompt ET injecte les données live du domaine, puis renvoie le contexte assemblé pour le visualiseur du HUD. - contexts.py : profils funk / ghostfolio / grafana / alerting / cluster (system prompt + sources) + assemble() (prompt final + structure de visualisation). - sources.py : fetchers live best-effort (Ghostfolio auth+details, Alertmanager alerts hors Watchdog, Prometheus cluster/metrics), env-config, dégradation propre. - brain.py : ask() reçoit le system prompt déjà assemblé (assemblage remonté). - app.py : /v1/ask accepte `context`, renvoie context_id + le contexte assemblé ; nouveau GET /v1/contexts ; RAG doc conditionné au profil. - config.py : URLs sources + STT_GHOSTFOLIO_TOKEN + STT_DEFAULT_CONTEXT. - deployment : env in-cluster (Prometheus/Alertmanager monitoring, Ghostfolio ai), jeton via secret optionnel stt-server-secrets/ghostfolio-token. - bump 0.3.1 → 0.4.0. Validé en local : assemblage (blocs+RAG+mémoire), parsing des sources (mock), endpoints /v1/contexts et /v1/ask (LLM mocké) — context_id, visualiseur, fallback contexte inconnu → funk. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d4b053622c
commit
1faf50a9be
8 changed files with 388 additions and 27 deletions
|
|
@ -87,6 +87,21 @@ switch global type `hermes-switch` (pas de restart, chaque client choisit le sie
|
||||||
`GET /v1/models` liste le défaut + les alias autorisés. Noms courts client : `hermes`
|
`GET /v1/models` liste le défaut + les alias autorisés. Noms courts client : `hermes`
|
||||||
(=hermes-default), `qwen`, `claude` (=claude-sonnet-4-6), `opus`.
|
(=hermes-default), `qwen`, `claude` (=claude-sonnet-4-6), `opus`.
|
||||||
|
|
||||||
|
**Contextes présélectionnables (visualiseur + presets live)** : le client envoie un `context`
|
||||||
|
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`,
|
||||||
|
best-effort, env-config) :
|
||||||
|
- `funk` — grounding strict + RAG `funk-docs` (défaut, comportement historique)
|
||||||
|
- `ghostfolio` — valeur/positions du portefeuille (API Ghostfolio, `STT_GHOSTFOLIO_TOKEN`)
|
||||||
|
- `grafana` — métriques clés (Prometheus)
|
||||||
|
- `alerting` — alertes actives (Alertmanager, hors Watchdog)
|
||||||
|
- `cluster` — état pods/nœuds (Prometheus/kube-state-metrics) + RAG doc
|
||||||
|
|
||||||
|
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 »).
|
||||||
|
URLs in-cluster dans `k8s/apps/stt/deployment.yaml` (Prometheus/Alertmanager `monitoring`,
|
||||||
|
Ghostfolio `ai`). Jeton Ghostfolio = secret optionnel `stt-server-secrets/ghostfolio-token`.
|
||||||
|
|
||||||
**Caveat Qwen3 — mode « thinking » (corrigé)** : Qwen3 est un modèle de raisonnement. Sans
|
**Caveat Qwen3 — mode « thinking » (corrigé)** : Qwen3 est un modèle de raisonnement. Sans
|
||||||
précaution, il dépense tout le budget `max_tokens` (200) dans `reasoning_content` → `content`
|
précaution, il dépense tout le budget `max_tokens` (200) dans `reasoning_content` → `content`
|
||||||
**vide**, ou réfléchit trop longtemps → **timeout 502** (`upstream LiteLLM : ` au message vide —
|
**vide**, ou réfléchit trop longtemps → **timeout 502** (`upstream LiteLLM : ` au message vide —
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,24 @@ 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
|
||||||
|
- name: STT_DEFAULT_CONTEXT
|
||||||
|
value: "funk"
|
||||||
|
- name: STT_PROMETHEUS_URL
|
||||||
|
value: "http://kube-prometheus-stack-prometheus.monitoring:9090"
|
||||||
|
- name: STT_ALERTMANAGER_URL
|
||||||
|
value: "http://kube-prometheus-stack-alertmanager.monitoring:9093"
|
||||||
|
- name: STT_GHOSTFOLIO_URL
|
||||||
|
value: "http://ghostfolio:3333" # même namespace (ai)
|
||||||
|
# Jeton Ghostfolio (« Security token ») — secret optionnel créé à la main :
|
||||||
|
# kubectl -n ai create secret generic stt-server-secrets \
|
||||||
|
# --from-literal=ghostfolio-token=<TOKEN>
|
||||||
|
- name: STT_GHOSTFOLIO_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: stt-server-secrets
|
||||||
|
key: ghostfolio-token
|
||||||
|
optional: true
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /healthz
|
path: /healthz
|
||||||
|
|
|
||||||
|
|
@ -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.3.1"
|
__version__ = "0.4.0"
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,11 @@ from stt_server import __version__
|
||||||
from stt_server import brain
|
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.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
|
||||||
from stt_server.memory import SessionStore
|
from stt_server.memory import SessionStore
|
||||||
|
from stt_server.sources import fetch_blocks
|
||||||
|
|
||||||
log = logging.getLogger("stt_server")
|
log = logging.getLogger("stt_server")
|
||||||
# uvicorn ne configure que ses propres loggers : on attache notre handler en INFO
|
# uvicorn ne configure que ses propres loggers : on attache notre handler en INFO
|
||||||
|
|
@ -54,13 +56,16 @@ app = FastAPI(title="STT-server", version=__version__, lifespan=lifespan)
|
||||||
|
|
||||||
class AskRequest(BaseModel):
|
class AskRequest(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
model: str | None = None # alias LiteLLM ; défaut serveur si absent
|
model: str | None = None # alias LiteLLM ; défaut serveur si absent
|
||||||
session_id: str | None = None # mémoire court-terme : fil de conversation
|
session_id: str | None = None # mémoire court-terme : fil de conversation
|
||||||
|
context: str | None = None # contexte présélectionné (funk/ghostfolio/…)
|
||||||
|
|
||||||
|
|
||||||
class AskReply(BaseModel):
|
class AskReply(BaseModel):
|
||||||
reply: str
|
reply: str
|
||||||
model: str
|
model: str
|
||||||
|
context_id: str # contexte effectivement utilisé
|
||||||
|
context: dict | None = None # contexte assemblé (visualiseur HUD)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
|
|
@ -73,6 +78,18 @@ async def v1_models() -> dict:
|
||||||
return {"default": settings.model, "available": settings.allowed_models}
|
return {"default": settings.model, "available": settings.allowed_models}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/contexts")
|
||||||
|
async def v1_contexts() -> dict:
|
||||||
|
"""Contextes présélectionnables (pour le sélecteur du HUD)."""
|
||||||
|
return {
|
||||||
|
"default": settings.default_context,
|
||||||
|
"contexts": [
|
||||||
|
{"id": c.id, "label": c.label, "icon": c.icon, "description": c.description}
|
||||||
|
for c in CONTEXTS.values()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/v1/memory/health")
|
@app.get("/v1/memory/health")
|
||||||
async def v1_memory_health() -> dict:
|
async def v1_memory_health() -> dict:
|
||||||
"""État de la mémoire long-terme (embeddings + Qdrant + collection), erreurs exposées."""
|
"""État de la mémoire long-terme (embeddings + Qdrant + collection), erreurs exposées."""
|
||||||
|
|
@ -99,15 +116,25 @@ async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply:
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"modèle '{model}' non autorisé ; dispo : {settings.allowed_models}",
|
detail=f"modèle '{model}' non autorisé ; dispo : {settings.allowed_models}",
|
||||||
)
|
)
|
||||||
|
ctx = get_context(req.context)
|
||||||
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)
|
||||||
memories, qvec = await longterm.recall(text) if longterm else ([], None)
|
memories, qvec = await longterm.recall(text) if longterm else ([], None)
|
||||||
# RAG doc : on réutilise qvec (même embedder nomic) → ancre la réponse dans funk-docs
|
# RAG doc : seulement si le contexte le demande (réutilise qvec, même embedder nomic)
|
||||||
docs = await knowledge.search(text, qvec) if knowledge else []
|
docs = []
|
||||||
|
if "docs" in ctx.sources and knowledge:
|
||||||
|
docs = await knowledge.search(text, qvec)
|
||||||
|
# Sources live du contexte (Ghostfolio / Prometheus / Alertmanager) — best-effort
|
||||||
|
blocks: list = []
|
||||||
|
live = tuple(s for s in ctx.sources if s != "docs")
|
||||||
|
if live:
|
||||||
|
async with httpx.AsyncClient() as c:
|
||||||
|
blocks = await fetch_blocks(c, live)
|
||||||
t_recall = time.perf_counter()
|
t_recall = time.perf_counter()
|
||||||
|
system, ctx_debug = assemble(ctx, blocks=blocks, docs=docs, memories=memories)
|
||||||
try:
|
try:
|
||||||
reply = await brain_ask(text, model, history, memories, docs)
|
reply = await brain_ask(text, system, model, history)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
raise HTTPException(status_code=502, detail=f"upstream LiteLLM : {e}") from e
|
raise HTTPException(status_code=502, detail=f"upstream LiteLLM : {e}") from e
|
||||||
t_gen = time.perf_counter()
|
t_gen = time.perf_counter()
|
||||||
|
|
@ -118,15 +145,14 @@ async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply:
|
||||||
if longterm:
|
if longterm:
|
||||||
background.add_task(longterm.store, req.session_id or "anon", text, qvec)
|
background.add_task(longterm.store, req.session_id or "anon", text, qvec)
|
||||||
log.info(
|
log.info(
|
||||||
"ask model=%s recall=%.0fms gen=%.0fms total=%.0fms mem=%d docs=%d",
|
"ask ctx=%s model=%s recall+src=%.0fms gen=%.0fms total=%.0fms mem=%d docs=%d blocks=%d",
|
||||||
model,
|
ctx.id, model,
|
||||||
(t_recall - t0) * 1000,
|
(t_recall - t0) * 1000,
|
||||||
(t_gen - t_recall) * 1000,
|
(t_gen - t_recall) * 1000,
|
||||||
(t_gen - t0) * 1000,
|
(t_gen - t0) * 1000,
|
||||||
len(memories),
|
len(memories), len(docs), len(blocks),
|
||||||
len(docs),
|
|
||||||
)
|
)
|
||||||
return AskReply(reply=reply, model=model)
|
return AskReply(reply=reply, model=model, context_id=ctx.id, context=ctx_debug)
|
||||||
|
|
||||||
|
|
||||||
def run() -> None:
|
def run() -> None:
|
||||||
|
|
|
||||||
|
|
@ -31,32 +31,21 @@ async def aclose() -> None:
|
||||||
|
|
||||||
async def ask(
|
async def ask(
|
||||||
text: str,
|
text: str,
|
||||||
|
system: str,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
history: list[dict] | None = None,
|
history: list[dict] | None = None,
|
||||||
memories: list[str] | None = None,
|
|
||||||
knowledge: list[str] | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
system = settings.system_prompt
|
"""Interroge le LLM avec un system prompt **déjà assemblé** (voir contexts.assemble).
|
||||||
|
|
||||||
|
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:
|
if settings.disable_thinking:
|
||||||
# Qwen3 est un modèle « thinking » : sans ça il dépense tout le budget max_tokens
|
# 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
|
# 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.
|
# → timeout (502). Le token de contrôle `/no_think` désactive le mode raisonnement.
|
||||||
# Inoffensif pour les modèles non-Qwen (simple texte ignoré).
|
# Inoffensif pour les modèles non-Qwen (simple texte ignoré).
|
||||||
system += "\n/no_think"
|
system += "\n/no_think"
|
||||||
if knowledge:
|
|
||||||
# Contexte AUTORITAIRE : extraits de la doc Funk (RAG funk-docs). Le modèle doit
|
|
||||||
# répondre à partir de ça pour les questions sur le homelab (sinon il hallucine).
|
|
||||||
extraits = "\n\n".join(knowledge)
|
|
||||||
system += (
|
|
||||||
"\n\n=== Documentation Funk (réponds À PARTIR de ces extraits ; "
|
|
||||||
"si l'info n'y est pas, dis-le) ===\n" + extraits
|
|
||||||
)
|
|
||||||
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
|
|
||||||
)
|
|
||||||
messages = [{"role": "system", "content": system}]
|
messages = [{"role": "system", "content": system}]
|
||||||
if history:
|
if history:
|
||||||
messages += history
|
messages += history
|
||||||
|
|
|
||||||
|
|
@ -67,5 +67,16 @@ class Settings:
|
||||||
# Sur le chemin de réponse (1 recherche Qdrant, vecteur déjà calculé) → timeout serré.
|
# Sur le chemin de réponse (1 recherche Qdrant, vecteur déjà calculé) → timeout serré.
|
||||||
docs_timeout: float = float(os.getenv("STT_DOCS_TIMEOUT", "4"))
|
docs_timeout: float = float(os.getenv("STT_DOCS_TIMEOUT", "4"))
|
||||||
|
|
||||||
|
# Contextes présélectionnables (visualiseur + presets live). Le contexte « funk »
|
||||||
|
# garde le system_prompt grounding-strict ci-dessus ; les autres injectent des
|
||||||
|
# données live. URLs surchargeables selon la résolution réseau in-cluster.
|
||||||
|
default_context: str = os.getenv("STT_DEFAULT_CONTEXT", "funk")
|
||||||
|
sources_timeout: float = float(os.getenv("STT_SOURCES_TIMEOUT", "4"))
|
||||||
|
ghostfolio_url: str = os.getenv("STT_GHOSTFOLIO_URL", "http://ghostfolio.lab.local")
|
||||||
|
# Jeton « Security token » du compte Ghostfolio (vide → données indisponibles).
|
||||||
|
ghostfolio_token: str = os.getenv("STT_GHOSTFOLIO_TOKEN", "")
|
||||||
|
prometheus_url: str = os.getenv("STT_PROMETHEUS_URL", "http://prometheus.lab.local")
|
||||||
|
alertmanager_url: str = os.getenv("STT_ALERTMANAGER_URL", "http://alertmanager.lab.local")
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
133
stt/server/stt_server/contexts.py
Normal file
133
stt/server/stt_server/contexts.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
"""Contextes présélectionnables d'Asa — profils de domaine + assemblage du prompt.
|
||||||
|
|
||||||
|
Chaque profil définit un system prompt et les **sources live** à injecter dans le
|
||||||
|
contexte (Ghostfolio, Prometheus, Alertmanager, doc Funk). Le client choisit un
|
||||||
|
contexte par requête (`context` dans /v1/ask) ; le serveur assemble le prompt et
|
||||||
|
renvoie le contexte assemblé pour le **visualiseur** du HUD.
|
||||||
|
|
||||||
|
Ajouter un contexte = une entrée dans CONTEXTS (id, label, icône, prompt, sources).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from stt_server.config import settings
|
||||||
|
|
||||||
|
# Préambule commun (ton, langue, concision) — chaque prompt de contexte le complète.
|
||||||
|
_BASE = (
|
||||||
|
"Tu es Hermes, l'assistant vocal du homelab Funk. Réponds toujours en français, "
|
||||||
|
"de façon concise (2-3 phrases maximum), sans markdown ni listes."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Context:
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
icon: str
|
||||||
|
description: str
|
||||||
|
system_prompt: str
|
||||||
|
sources: tuple[str, ...] = () # "docs","ghostfolio","alerts","cluster","metrics"
|
||||||
|
|
||||||
|
|
||||||
|
# Le contexte « funk » garde le prompt grounding-strict historique (env override possible).
|
||||||
|
CONTEXTS: dict[str, Context] = {
|
||||||
|
"funk": Context(
|
||||||
|
id="funk", label="Funk · cluster", icon="🛠️",
|
||||||
|
description="Questions sur le homelab depuis la documentation.",
|
||||||
|
system_prompt=settings.system_prompt,
|
||||||
|
sources=("docs",),
|
||||||
|
),
|
||||||
|
"ghostfolio": Context(
|
||||||
|
id="ghostfolio", label="Ghostfolio", icon="💰",
|
||||||
|
description="Ton portefeuille et tes investissements.",
|
||||||
|
system_prompt=_BASE + " Tu aides sur le portefeuille d'investissement de "
|
||||||
|
"l'utilisateur. Appuie-toi sur les données Ghostfolio fournies ci-dessous ; "
|
||||||
|
"donne des montants précis quand ils y figurent. Si une donnée n'est pas "
|
||||||
|
"fournie, dis-le simplement.",
|
||||||
|
sources=("ghostfolio",),
|
||||||
|
),
|
||||||
|
"grafana": Context(
|
||||||
|
id="grafana", label="Grafana · métriques", icon="📊",
|
||||||
|
description="Métriques et observabilité du homelab.",
|
||||||
|
system_prompt=_BASE + " Tu réponds sur les métriques et l'observabilité du "
|
||||||
|
"homelab à partir des données fournies ci-dessous (Prometheus). Reste factuel.",
|
||||||
|
sources=("metrics",),
|
||||||
|
),
|
||||||
|
"alerting": Context(
|
||||||
|
id="alerting", label="Alerting", icon="🚨",
|
||||||
|
description="Alertes actives et leur signification.",
|
||||||
|
system_prompt=_BASE + " Tu expliques et tries les alertes actives fournies "
|
||||||
|
"ci-dessous : gravité, cause probable, quoi vérifier. S'il n'y a aucune "
|
||||||
|
"alerte, rassure brièvement.",
|
||||||
|
sources=("alerts",),
|
||||||
|
),
|
||||||
|
"cluster": Context(
|
||||||
|
id="cluster", label="Gestion cluster", icon="🚢",
|
||||||
|
description="État et gestion du cluster Kubernetes Funk (lecture seule).",
|
||||||
|
system_prompt=_BASE + " Tu réponds sur l'état et la gestion du cluster "
|
||||||
|
"Kubernetes Funk à partir de l'état fourni ci-dessous et de la documentation. "
|
||||||
|
"Tu fais de la LECTURE SEULE : tu n'exécutes aucune action.",
|
||||||
|
sources=("cluster", "docs"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Titres des blocs injectés (visualiseur + prompt)
|
||||||
|
_SOURCE_TITLES = {
|
||||||
|
"ghostfolio": "Portefeuille Ghostfolio (données live)",
|
||||||
|
"metrics": "Métriques Prometheus (live)",
|
||||||
|
"alerts": "Alertes actives (Alertmanager)",
|
||||||
|
"cluster": "État du cluster (live)",
|
||||||
|
"docs": "Documentation Funk",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_context(context_id: str | None) -> Context:
|
||||||
|
return CONTEXTS.get(context_id or "", CONTEXTS[settings.default_context])
|
||||||
|
|
||||||
|
|
||||||
|
def assemble(
|
||||||
|
ctx: Context,
|
||||||
|
*,
|
||||||
|
blocks: list[tuple[str, str]],
|
||||||
|
docs: list[str],
|
||||||
|
memories: list[str],
|
||||||
|
) -> tuple[str, dict]:
|
||||||
|
"""Construit le system prompt final + la structure de visualisation.
|
||||||
|
|
||||||
|
`blocks` : (source_id, texte) des sources live déjà récupérées.
|
||||||
|
Renvoie (system_prompt, debug) où debug alimente le visualiseur du HUD.
|
||||||
|
"""
|
||||||
|
system = ctx.system_prompt
|
||||||
|
debug_blocks: list[dict] = []
|
||||||
|
for src, text in blocks:
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
title = _SOURCE_TITLES.get(src, src)
|
||||||
|
system += f"\n\n=== {title} ===\n{text}"
|
||||||
|
debug_blocks.append({"source": src, "title": title, "text": text})
|
||||||
|
|
||||||
|
if "docs" in ctx.sources and docs:
|
||||||
|
extraits = "\n\n".join(docs)
|
||||||
|
system += (
|
||||||
|
"\n\n=== Documentation Funk (réponds À PARTIR de ces extraits ; "
|
||||||
|
"si l'info n'y est pas, dis-le) ===\n" + extraits
|
||||||
|
)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
debug = {
|
||||||
|
"id": ctx.id,
|
||||||
|
"label": ctx.label,
|
||||||
|
"icon": ctx.icon,
|
||||||
|
"system_prompt": ctx.system_prompt,
|
||||||
|
"blocks": debug_blocks,
|
||||||
|
"docs": docs,
|
||||||
|
"memories": memories,
|
||||||
|
}
|
||||||
|
return system, debug
|
||||||
169
stt/server/stt_server/sources.py
Normal file
169
stt/server/stt_server/sources.py
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
async def ghostfolio_block(client: httpx.AsyncClient) -> str:
|
||||||
|
base = settings.ghostfolio_url.rstrip("/")
|
||||||
|
token = settings.ghostfolio_token
|
||||||
|
if not token:
|
||||||
|
return "Données indisponibles : jeton Ghostfolio non configuré côté 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"))
|
||||||
|
# quelques positions principales (par valeur), si présentes
|
||||||
|
holdings = data.get("holdings", {}) or {}
|
||||||
|
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 : {round(float(val)):,} (devise de base).".replace(",", " "))
|
||||||
|
for h in tops:
|
||||||
|
name = h.get("name") or h.get("symbol") or "?"
|
||||||
|
hv = h.get("valueInBaseCurrency")
|
||||||
|
if hv:
|
||||||
|
lines.append(f"- {name} : {round(float(hv)):,}".replace(",", " "))
|
||||||
|
return "\n".join(lines) if lines else "Aucune donnée de portefeuille renvoyée."
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return "Données indisponibles : Ghostfolio injoignable."
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
return "Données indisponibles : réponse Ghostfolio inattendue."
|
||||||
|
|
||||||
|
|
||||||
|
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"}')
|
||||||
|
not_ready = await _prom_query(
|
||||||
|
client, 'kube_pod_status_ready{condition="false"} == 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, ...]) -> list[tuple[str, str]]:
|
||||||
|
"""Récupère les blocs live demandés (hors « docs », géré par le RAG). Best-effort."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
wanted = [s for s in sources if s in _FETCHERS]
|
||||||
|
if not wanted:
|
||||||
|
return []
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(_FETCHERS[s](client) 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue