From e49b4fc3ba9e76362f2c75ab8b933d46a7aab500 Mon Sep 17 00:00:00 2001 From: ALI YESILKAYA Date: Sun, 21 Jun 2026 03:28:52 +0200 Subject: [PATCH] =?UTF-8?q?refactor(stt):=20unifie=20le=20jeton=20Ghostfol?= =?UTF-8?q?io=20=E2=80=94=20source=20client=20unique,=20appel=20c=C3=B4t?= =?UTF-8?q?=C3=A9=20serveur=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- stt/client/config/stt.example.toml | 3 +- stt/client/pyproject.toml | 2 +- stt/client/stt/api.py | 24 +++++++++ stt/client/stt/cli.py | 4 ++ stt/client/stt/config.py | 8 ++- stt/client/stt/portal/__init__.py | 3 +- stt/client/stt/portal/ghostfolio.py | 76 -------------------------- stt/client/stt/ui/app.py | 8 +-- stt/server/stt_server/__init__.py | 2 +- stt/server/stt_server/app.py | 21 +++++++- stt/server/stt_server/sources.py | 83 ++++++++++++++++++++++------- 11 files changed, 121 insertions(+), 113 deletions(-) delete mode 100644 stt/client/stt/portal/ghostfolio.py diff --git a/stt/client/config/stt.example.toml b/stt/client/config/stt.example.toml index dfc8be1..abd271c 100644 --- a/stt/client/config/stt.example.toml +++ b/stt/client/config/stt.example.toml @@ -69,7 +69,6 @@ local_db = "~/.local/share/stt/memory.sqlite" # « état du cluster / des services » → résumé de santé (parlé) # « combien sur mon ghostfolio » → valeur du portefeuille (ci-dessous) # 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] -# url = "http://ghostfolio.lab.local" # access_token = "TON_SECURITY_TOKEN" -# timeout_sec = 6 diff --git a/stt/client/pyproject.toml b/stt/client/pyproject.toml index 3e29de2..6a6050d 100644 --- a/stt/client/pyproject.toml +++ b/stt/client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stt" -version = "0.12.0" +version = "0.13.0" description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)" requires-python = ">=3.11" readme = "README.md" diff --git a/stt/client/stt/api.py b/stt/client/stt/api.py index 5974a0f..f5e3af5 100644 --- a/stt/client/stt/api.py +++ b/stt/client/stt/api.py @@ -21,6 +21,9 @@ class ServerClient: # dernier contexte assemblé renvoyé par le serveur (pour le visualiseur) self.last_context: dict | 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 self.session_id = uuid.uuid4().hex @@ -34,6 +37,8 @@ class ServerClient: } if 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) if r.status_code == 400: raise RuntimeError(r.json().get("detail", "requête invalide")) @@ -44,6 +49,25 @@ class ServerClient: self.last_context_id = data.get("context_id") 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: """{'default':…, 'contexts':[{id,label,icon,description}]} ; None si indisponible. diff --git a/stt/client/stt/cli.py b/stt/client/stt/cli.py index 77124de..6f3bf1f 100644 --- a/stt/client/stt/cli.py +++ b/stt/client/stt/cli.py @@ -347,6 +347,10 @@ def _make_client(args): client = ServerClient(cfg["server"]) if 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 diff --git a/stt/client/stt/config.py b/stt/client/stt/config.py index cad7551..c14a931 100644 --- a/stt/client/stt/config.py +++ b/stt/client/stt/config.py @@ -174,13 +174,11 @@ DEFAULT_CONFIG: dict[str, Any] = { "prometheus_url": "http://prometheus.lab.local", "alertmanager_url": "http://alertmanager.lab.local", }, - # Ghostfolio — pour l'intention vocale « combien sur mon portefeuille ». - # access_token = « Security token » du compte (vide → l'assistant répond de le - # configurer). Lecture seule. + # Ghostfolio — jeton (« Security token » du compte). SOURCE UNIQUE du jeton : + # le client le transmet au serveur, qui fait l'appel API (intent « combien sur mon + # portefeuille » via /v1/portfolio + contexte Ghostfolio). Vide → indisponible. "ghostfolio": { - "url": "http://ghostfolio.lab.local", "access_token": "", - "timeout_sec": 6, }, } diff --git a/stt/client/stt/portal/__init__.py b/stt/client/stt/portal/__init__.py index 393976b..b91852b 100644 --- a/stt/client/stt/portal/__init__.py +++ b/stt/client/stt/portal/__init__.py @@ -8,12 +8,11 @@ La même source alimente le panneau « Services » du HUD et la commande vocale from __future__ import annotations -from .ghostfolio import GhostfolioClient from .health import StatusPoller from .intents import route_intent, summarize_status from .registry import Component, Service, load_services, match_service __all__ = [ "Component", "Service", "load_services", "match_service", "StatusPoller", - "GhostfolioClient", "route_intent", "summarize_status", + "route_intent", "summarize_status", ] diff --git a/stt/client/stt/portal/ghostfolio.py b/stt/client/stt/portal/ghostfolio.py deleted file mode 100644 index 32ff4e4..0000000 --- a/stt/client/stt/portal/ghostfolio.py +++ /dev/null @@ -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." diff --git a/stt/client/stt/ui/app.py b/stt/client/stt/ui/app.py index fa4668a..6c3d0f6 100644 --- a/stt/client/stt/ui/app.py +++ b/stt/client/stt/ui/app.py @@ -212,16 +212,16 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None: engine.context_provider = lambda: client.last_context # Intentions vocales locales (lecture seule) : « ouvre X », « état du cluster », - # « combien sur ghostfolio ». Court-circuitent le LLM. - from stt.portal import GhostfolioClient, route_intent, summarize_status + # « combien sur ghostfolio ». Court-circuitent le LLM. Ghostfolio passe par le + # 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( text, services=services, open_fn=open_service, 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é diff --git a/stt/server/stt_server/__init__.py b/stt/server/stt_server/__init__.py index 68864ea..cf1cac3 100644 --- a/stt/server/stt_server/__init__.py +++ b/stt/server/stt_server/__init__.py @@ -1,3 +1,3 @@ """STT-server — orchestrateur AI in-cluster pour les clients STT.""" -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/stt/server/stt_server/app.py b/stt/server/stt_server/app.py index 4bd0c36..06ccbdb 100644 --- a/stt/server/stt_server/app.py +++ b/stt/server/stt_server/app.py @@ -23,7 +23,7 @@ from stt_server.contexts import CONTEXTS, assemble, get_context from stt_server.knowledge import Knowledge from stt_server.longterm import LongTermMemory 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") # 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 session_id: str | None = None # mémoire court-terme : fil de conversation 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): @@ -98,6 +103,18 @@ async def v1_memory_health() -> dict: 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") async def v1_reset(req: AskRequest) -> dict: 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") if live: 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() system, ctx_debug = assemble(ctx, blocks=blocks, docs=docs, memories=memories) try: diff --git a/stt/server/stt_server/sources.py b/stt/server/stt_server/sources.py index 6bc0b7a..f6e1ec2 100644 --- a/stt/server/stt_server/sources.py +++ b/stt/server/stt_server/sources.py @@ -34,11 +34,19 @@ async def _prom_query(client: httpx.AsyncClient, expr: str) -> list[tuple[dict, 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("/") - token = settings.ghostfolio_token + token = token or settings.ghostfolio_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: r = await client.post( f"{base}/api/v1/auth/anonymous", @@ -58,27 +66,47 @@ async def ghostfolio_block(client: httpx.AsyncClient) -> str: 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 + cur = summary.get("baseCurrency") or "EUR" 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." + 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: @@ -153,15 +181,30 @@ _FETCHERS = { } -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.""" +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( - *(_FETCHERS[s](client) for s in wanted), return_exceptions=True + *(_call(s) for s in wanted), return_exceptions=True ) out: list[tuple[str, str]] = [] for src, res in zip(wanted, results):