mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-10 22:24:43 +02:00
refactor(stt): unifie le jeton Ghostfolio — source client unique, appel côté serveur (#41)
Avant : jeton Ghostfolio en double (config client pour l'intent vocal + secret k8s pour
le contexte serveur). Désormais une SEULE source — la config client — transmise au
serveur, qui fait tous les appels Ghostfolio (un seul code, lecture seule).
Serveur :
- sources.py : cœur _ghostfolio_fetch(token) partagé ; ghostfolio_block (contexte) et
ghostfolio_phrase (intent) acceptent un jeton par requête (priorité client > env).
- app.py : /v1/ask accepte `secrets` (jetons client) passés aux sources ; nouvel
endpoint POST /v1/portfolio {token} → valeur du portefeuille.
- bump 0.4.0 → 0.5.0.
Client :
- api.py : ServerClient.secrets transmis dans /v1/ask ; méthode portfolio() → /v1/portfolio.
- cli.py : _make_client renseigne secrets["ghostfolio_token"] depuis [ghostfolio].
- ui/app.py : l'intent « combien ghostfolio » appelle client.portfolio (serveur).
- supprime portal/ghostfolio.py (client) — l'appel API vit côté serveur.
- config : [ghostfolio] = juste access_token (transmis au serveur).
- bump 0.12.0 → 0.13.0.
Le secret k8s STT_GHOSTFOLIO_TOKEN devient un simple fallback. Validé bout-en-bout
contre une instance serveur locale + Ghostfolio réel : « …vaut environ 16 520 EUR. »
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ae4d5bd20e
commit
e49b4fc3ba
11 changed files with 121 additions and 113 deletions
|
|
@ -69,7 +69,6 @@ local_db = "~/.local/share/stt/memory.sqlite"
|
||||||
# « état du cluster / des services » → résumé de santé (parlé)
|
# « état du cluster / des services » → résumé de santé (parlé)
|
||||||
# « combien sur mon ghostfolio » → valeur du portefeuille (ci-dessous)
|
# « combien sur mon ghostfolio » → valeur du portefeuille (ci-dessous)
|
||||||
# Ghostfolio : access_token = « Security token » du compte (Réglages Ghostfolio).
|
# Ghostfolio : access_token = « Security token » du compte (Réglages Ghostfolio).
|
||||||
|
# Source UNIQUE du jeton — le client le transmet au serveur, qui appelle l'API.
|
||||||
# [ghostfolio]
|
# [ghostfolio]
|
||||||
# url = "http://ghostfolio.lab.local"
|
|
||||||
# access_token = "TON_SECURITY_TOKEN"
|
# access_token = "TON_SECURITY_TOKEN"
|
||||||
# timeout_sec = 6
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "stt"
|
name = "stt"
|
||||||
version = "0.12.0"
|
version = "0.13.0"
|
||||||
description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)"
|
description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ class ServerClient:
|
||||||
# dernier contexte assemblé renvoyé par le serveur (pour le visualiseur)
|
# dernier contexte assemblé renvoyé par le serveur (pour le visualiseur)
|
||||||
self.last_context: dict | None = None
|
self.last_context: dict | None = None
|
||||||
self.last_context_id: str | None = None
|
self.last_context_id: str | None = None
|
||||||
|
# jetons à transmettre au serveur (ex. {"ghostfolio_token": …}) — source UNIQUE
|
||||||
|
# du jeton, côté client ; le serveur les consomme (contexte + /v1/portfolio).
|
||||||
|
self.secrets: dict = {}
|
||||||
# une session par run → mémoire court-terme côté serveur
|
# une session par run → mémoire court-terme côté serveur
|
||||||
self.session_id = uuid.uuid4().hex
|
self.session_id = uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
@ -34,6 +37,8 @@ class ServerClient:
|
||||||
}
|
}
|
||||||
if self.context:
|
if self.context:
|
||||||
payload["context"] = self.context
|
payload["context"] = self.context
|
||||||
|
if self.secrets:
|
||||||
|
payload["secrets"] = self.secrets
|
||||||
r = requests.post(f"{self.url}/v1/ask", json=payload, timeout=self.timeout)
|
r = requests.post(f"{self.url}/v1/ask", json=payload, timeout=self.timeout)
|
||||||
if r.status_code == 400:
|
if r.status_code == 400:
|
||||||
raise RuntimeError(r.json().get("detail", "requête invalide"))
|
raise RuntimeError(r.json().get("detail", "requête invalide"))
|
||||||
|
|
@ -44,6 +49,25 @@ class ServerClient:
|
||||||
self.last_context_id = data.get("context_id")
|
self.last_context_id = data.get("context_id")
|
||||||
return data["reply"].strip()
|
return data["reply"].strip()
|
||||||
|
|
||||||
|
def portfolio(self) -> str:
|
||||||
|
"""Valeur du portefeuille Ghostfolio via le serveur (jeton client transmis).
|
||||||
|
|
||||||
|
Unifie le jeton : c'est le serveur qui parle à Ghostfolio. Dégrade en message
|
||||||
|
clair si le serveur est antérieur (pas d'endpoint) ou injoignable.
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
f"{self.url}/v1/portfolio",
|
||||||
|
json={"token": self.secrets.get("ghostfolio_token")},
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return (r.json().get("summary") or "").strip() or "Pas de réponse de Ghostfolio."
|
||||||
|
except requests.RequestException:
|
||||||
|
return "Je n'arrive pas à joindre le serveur pour Ghostfolio."
|
||||||
|
|
||||||
def contexts(self) -> dict | None:
|
def contexts(self) -> dict | None:
|
||||||
"""{'default':…, 'contexts':[{id,label,icon,description}]} ; None si indisponible.
|
"""{'default':…, 'contexts':[{id,label,icon,description}]} ; None si indisponible.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,10 @@ def _make_client(args):
|
||||||
client = ServerClient(cfg["server"])
|
client = ServerClient(cfg["server"])
|
||||||
if args.model:
|
if args.model:
|
||||||
client.model = resolve_model(args.model)
|
client.model = resolve_model(args.model)
|
||||||
|
# Jeton Ghostfolio (source unique côté client) → transmis au serveur par requête
|
||||||
|
gf_token = (cfg.get("ghostfolio") or {}).get("access_token")
|
||||||
|
if gf_token:
|
||||||
|
client.secrets["ghostfolio_token"] = gf_token
|
||||||
return cfg, client
|
return cfg, client
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -174,13 +174,11 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
"prometheus_url": "http://prometheus.lab.local",
|
"prometheus_url": "http://prometheus.lab.local",
|
||||||
"alertmanager_url": "http://alertmanager.lab.local",
|
"alertmanager_url": "http://alertmanager.lab.local",
|
||||||
},
|
},
|
||||||
# Ghostfolio — pour l'intention vocale « combien sur mon portefeuille ».
|
# Ghostfolio — jeton (« Security token » du compte). SOURCE UNIQUE du jeton :
|
||||||
# access_token = « Security token » du compte (vide → l'assistant répond de le
|
# le client le transmet au serveur, qui fait l'appel API (intent « combien sur mon
|
||||||
# configurer). Lecture seule.
|
# portefeuille » via /v1/portfolio + contexte Ghostfolio). Vide → indisponible.
|
||||||
"ghostfolio": {
|
"ghostfolio": {
|
||||||
"url": "http://ghostfolio.lab.local",
|
|
||||||
"access_token": "",
|
"access_token": "",
|
||||||
"timeout_sec": 6,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,11 @@ La même source alimente le panneau « Services » du HUD et la commande vocale
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .ghostfolio import GhostfolioClient
|
|
||||||
from .health import StatusPoller
|
from .health import StatusPoller
|
||||||
from .intents import route_intent, summarize_status
|
from .intents import route_intent, summarize_status
|
||||||
from .registry import Component, Service, load_services, match_service
|
from .registry import Component, Service, load_services, match_service
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Component", "Service", "load_services", "match_service", "StatusPoller",
|
"Component", "Service", "load_services", "match_service", "StatusPoller",
|
||||||
"GhostfolioClient", "route_intent", "summarize_status",
|
"route_intent", "summarize_status",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
"""Client minimal de l'API Ghostfolio — valeur du portefeuille (lecture seule).
|
|
||||||
|
|
||||||
Flux Ghostfolio : POST /api/v1/auth/anonymous {accessToken} → JWT, puis
|
|
||||||
GET /api/v1/portfolio/details (Bearer) → summary.currentValueInBaseCurrency.
|
|
||||||
|
|
||||||
Le token (« Security token » du compte Ghostfolio) est config-driven, jamais en
|
|
||||||
dur. Sans token → message d'aide ; toute erreur → message parlable, jamais d'exception.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt_money(value: float) -> str:
|
|
||||||
"""12345.6 → « 12 345 » (séparateur de milliers fin, arrondi à l'euro)."""
|
|
||||||
try:
|
|
||||||
return f"{round(float(value)):,}".replace(",", " ")
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
class GhostfolioClient:
|
|
||||||
def __init__(self, cfg: dict[str, Any]):
|
|
||||||
self.url = (cfg.get("url") or "http://ghostfolio.lab.local").rstrip("/")
|
|
||||||
self.token = cfg.get("access_token") or ""
|
|
||||||
self.timeout = float(cfg.get("timeout_sec", 6) or 6)
|
|
||||||
|
|
||||||
def _auth_token(self) -> str | None:
|
|
||||||
import requests
|
|
||||||
|
|
||||||
r = requests.post(
|
|
||||||
f"{self.url}/api/v1/auth/anonymous",
|
|
||||||
json={"accessToken": self.token}, timeout=self.timeout,
|
|
||||||
)
|
|
||||||
if r.status_code >= 400:
|
|
||||||
return None
|
|
||||||
return (r.json() or {}).get("authToken")
|
|
||||||
|
|
||||||
def _base_currency(self) -> str:
|
|
||||||
import requests
|
|
||||||
|
|
||||||
try:
|
|
||||||
r = requests.get(f"{self.url}/api/v1/info", timeout=self.timeout)
|
|
||||||
return (r.json() or {}).get("baseCurrency") or "EUR"
|
|
||||||
except (requests.RequestException, ValueError):
|
|
||||||
return "EUR"
|
|
||||||
|
|
||||||
def value_summary(self) -> str:
|
|
||||||
"""Phrase parlable donnant la valeur du portefeuille (ou un message d'erreur clair)."""
|
|
||||||
import requests
|
|
||||||
|
|
||||||
if not self.token:
|
|
||||||
return ("Je n'ai pas de jeton Ghostfolio configuré. "
|
|
||||||
"Ajoute ton « Security token » dans la section ghostfolio de la config.")
|
|
||||||
try:
|
|
||||||
jwt = self._auth_token()
|
|
||||||
if not jwt:
|
|
||||||
return "Je n'arrive pas à m'authentifier auprès de Ghostfolio — le jeton est peut-être invalide."
|
|
||||||
r = requests.get(
|
|
||||||
f"{self.url}/api/v1/portfolio/details",
|
|
||||||
headers={"Authorization": f"Bearer {jwt}"}, timeout=self.timeout,
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
summary = (r.json() or {}).get("summary", {}) or {}
|
|
||||||
val = (summary.get("currentValueInBaseCurrency")
|
|
||||||
or summary.get("currentValue")
|
|
||||||
or summary.get("netWorth"))
|
|
||||||
if val is None:
|
|
||||||
return "Je n'ai pas trouvé la valeur du portefeuille dans la réponse de Ghostfolio."
|
|
||||||
cur = self._base_currency()
|
|
||||||
return f"Ton portefeuille Ghostfolio vaut environ {_fmt_money(val)} {cur}."
|
|
||||||
except requests.RequestException:
|
|
||||||
return "Ghostfolio est injoignable pour le moment."
|
|
||||||
except (ValueError, KeyError):
|
|
||||||
return "Réponse inattendue de Ghostfolio — je n'ai pas pu lire la valeur."
|
|
||||||
|
|
@ -212,16 +212,16 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None:
|
||||||
engine.context_provider = lambda: client.last_context
|
engine.context_provider = lambda: client.last_context
|
||||||
|
|
||||||
# Intentions vocales locales (lecture seule) : « ouvre X », « état du cluster »,
|
# Intentions vocales locales (lecture seule) : « ouvre X », « état du cluster »,
|
||||||
# « combien sur ghostfolio ». Court-circuitent le LLM.
|
# « combien sur ghostfolio ». Court-circuitent le LLM. Ghostfolio passe par le
|
||||||
from stt.portal import GhostfolioClient, route_intent, summarize_status
|
# serveur (jeton client transmis) → une seule source de jeton, un seul code API.
|
||||||
|
from stt.portal import route_intent, summarize_status
|
||||||
|
|
||||||
_gf = GhostfolioClient(cfg.get("ghostfolio") or {})
|
|
||||||
engine.intent_router = lambda text: route_intent(
|
engine.intent_router = lambda text: route_intent(
|
||||||
text,
|
text,
|
||||||
services=services,
|
services=services,
|
||||||
open_fn=open_service,
|
open_fn=open_service,
|
||||||
status_fn=lambda: summarize_status(last_status, services),
|
status_fn=lambda: summarize_status(last_status, services),
|
||||||
ghostfolio_fn=_gf.value_summary,
|
ghostfolio_fn=client.portfolio,
|
||||||
)
|
)
|
||||||
|
|
||||||
# chargement Whisper + boucle audio dans un thread dédié
|
# chargement Whisper + boucle audio dans un thread dédié
|
||||||
|
|
|
||||||
|
|
@ -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.4.0"
|
__version__ = "0.5.0"
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ 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
|
from stt_server.sources import fetch_blocks, ghostfolio_phrase
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -59,6 +59,11 @@ class AskRequest(BaseModel):
|
||||||
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/…)
|
context: str | None = None # contexte présélectionné (funk/ghostfolio/…)
|
||||||
|
secrets: dict | None = None # jetons client (ex. {"ghostfolio_token": …})
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioRequest(BaseModel):
|
||||||
|
token: str | None = None # jeton Ghostfolio fourni par le client (sinon env)
|
||||||
|
|
||||||
|
|
||||||
class AskReply(BaseModel):
|
class AskReply(BaseModel):
|
||||||
|
|
@ -98,6 +103,18 @@ async def v1_memory_health() -> dict:
|
||||||
return await longterm.health()
|
return await longterm.health()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/portfolio")
|
||||||
|
async def v1_portfolio(req: PortfolioRequest) -> dict:
|
||||||
|
"""Valeur du portefeuille Ghostfolio (intent vocal client → serveur).
|
||||||
|
|
||||||
|
Le serveur fait l'appel Ghostfolio avec le jeton fourni par le client (sinon le
|
||||||
|
jeton serveur `STT_GHOSTFOLIO_TOKEN`). Un seul code Ghostfolio, lecture seule.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient() as c:
|
||||||
|
summary = await ghostfolio_phrase(c, req.token)
|
||||||
|
return {"summary": summary}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/reset")
|
@app.post("/v1/reset")
|
||||||
async def v1_reset(req: AskRequest) -> dict:
|
async def v1_reset(req: AskRequest) -> dict:
|
||||||
if req.session_id:
|
if req.session_id:
|
||||||
|
|
@ -130,7 +147,7 @@ async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply:
|
||||||
live = tuple(s for s in ctx.sources if s != "docs")
|
live = tuple(s for s in ctx.sources if s != "docs")
|
||||||
if live:
|
if live:
|
||||||
async with httpx.AsyncClient() as c:
|
async with httpx.AsyncClient() as c:
|
||||||
blocks = await fetch_blocks(c, live)
|
blocks = await fetch_blocks(c, live, secrets=req.secrets)
|
||||||
t_recall = time.perf_counter()
|
t_recall = time.perf_counter()
|
||||||
system, ctx_debug = assemble(ctx, blocks=blocks, docs=docs, memories=memories)
|
system, ctx_debug = assemble(ctx, blocks=blocks, docs=docs, memories=memories)
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,19 @@ async def _prom_query(client: httpx.AsyncClient, expr: str) -> list[tuple[dict,
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def ghostfolio_block(client: httpx.AsyncClient) -> str:
|
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("/")
|
base = settings.ghostfolio_url.rstrip("/")
|
||||||
token = settings.ghostfolio_token
|
token = token or settings.ghostfolio_token
|
||||||
if not token:
|
if not token:
|
||||||
return "Données indisponibles : jeton Ghostfolio non configuré côté serveur."
|
return "Données indisponibles : aucun jeton Ghostfolio (ni client, ni serveur)."
|
||||||
try:
|
try:
|
||||||
r = await client.post(
|
r = await client.post(
|
||||||
f"{base}/api/v1/auth/anonymous",
|
f"{base}/api/v1/auth/anonymous",
|
||||||
|
|
@ -58,27 +66,47 @@ async def ghostfolio_block(client: httpx.AsyncClient) -> str:
|
||||||
summary = data.get("summary", {}) or {}
|
summary = data.get("summary", {}) or {}
|
||||||
val = (summary.get("currentValueInBaseCurrency")
|
val = (summary.get("currentValueInBaseCurrency")
|
||||||
or summary.get("currentValue") or summary.get("netWorth"))
|
or summary.get("currentValue") or summary.get("netWorth"))
|
||||||
# quelques positions principales (par valeur), si présentes
|
cur = summary.get("baseCurrency") or "EUR"
|
||||||
holdings = data.get("holdings", {}) or {}
|
holdings = data.get("holdings", {}) or {}
|
||||||
tops = sorted(
|
return (val, cur, holdings)
|
||||||
(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:
|
except httpx.HTTPError:
|
||||||
return "Données indisponibles : Ghostfolio injoignable."
|
return "Données indisponibles : Ghostfolio injoignable."
|
||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
return "Données indisponibles : réponse Ghostfolio inattendue."
|
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:
|
async def alerts_block(client: httpx.AsyncClient) -> str:
|
||||||
base = settings.alertmanager_url.rstrip("/")
|
base = settings.alertmanager_url.rstrip("/")
|
||||||
try:
|
try:
|
||||||
|
|
@ -153,15 +181,30 @@ _FETCHERS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def fetch_blocks(client: httpx.AsyncClient, sources: tuple[str, ...]) -> list[tuple[str, str]]:
|
async def fetch_blocks(
|
||||||
"""Récupère les blocs live demandés (hors « docs », géré par le RAG). Best-effort."""
|
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
|
import asyncio
|
||||||
|
|
||||||
|
secrets = secrets or {}
|
||||||
wanted = [s for s in sources if s in _FETCHERS]
|
wanted = [s for s in sources if s in _FETCHERS]
|
||||||
if not wanted:
|
if not wanted:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def _call(s):
|
||||||
|
if s == "ghostfolio":
|
||||||
|
return ghostfolio_block(client, secrets.get("ghostfolio_token"))
|
||||||
|
return _FETCHERS[s](client)
|
||||||
|
|
||||||
results = await asyncio.gather(
|
results = await asyncio.gather(
|
||||||
*(_FETCHERS[s](client) for s in wanted), return_exceptions=True
|
*(_call(s) for s in wanted), return_exceptions=True
|
||||||
)
|
)
|
||||||
out: list[tuple[str, str]] = []
|
out: list[tuple[str, str]] = []
|
||||||
for src, res in zip(wanted, results):
|
for src, res in zip(wanted, results):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue