feat(stt-client): pages détail enrichies (aperçu portefeuille + alertes) (#59)

Les pages détail du portail HUD affichent désormais un aperçu live des données
du service, pas seulement santé + lien :

- Ghostfolio → portefeuille complet : valeur, performance globale, positions
  (allocation % + P/L par ligne, coloré). Nouvel endpoint serveur
  POST /v1/portfolio/details (jeton client, source unique réutilisée).
- Alertmanager → TOUTES les alertes actives du homelab (total + critiques +
  liste avec sévérité), rendu côté HUD depuis `all_alerts` poussé par le
  StatusPoller (auto-rafraîchi, aucun appel supplémentaire).

Mécanisme extensible (`DETAIL_PROVIDERS` : `server` = requête backend |
`alerts` = liste locale). Cartes de stats + lignes de données génériques.

Client 0.18.0. Vérifié via Playwright (rendu, tons, normalisation des %).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-23 23:48:20 +02:00 committed by GitHub
parent 7bfedb0901
commit f47bc36f29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 288 additions and 13 deletions

View file

@ -25,7 +25,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, ghostfolio_phrase
from stt_server.sources import fetch_blocks, ghostfolio_details, ghostfolio_phrase
log = logging.getLogger("stt_server")
# uvicorn ne configure que ses propres loggers : on attache notre handler en INFO
@ -126,6 +126,16 @@ async def v1_portfolio(req: PortfolioRequest) -> dict:
return {"summary": summary}
@app.post("/v1/portfolio/details")
async def v1_portfolio_details(req: PortfolioRequest) -> dict:
"""Aperçu structuré du portefeuille (page détail du portail HUD).
Même source de jeton que /v1/portfolio (client prioritaire, sinon env serveur).
"""
async with httpx.AsyncClient() as c:
return await ghostfolio_details(c, req.token)
@app.post("/v1/reset")
async def v1_reset(req: AskRequest) -> dict:
if req.session_id:

View file

@ -38,10 +38,25 @@ 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.
def _pct(v) -> float | None:
"""Normalise un pourcentage Ghostfolio → toujours en POURCENT.
Ghostfolio rend ses ratios tantôt en fraction (0.12 = 12 %), tantôt déjà en
pourcent (12) selon la version/le champ. Heuristique : |v| 1.5 fraction.
"""
try:
v = float(v)
except (TypeError, ValueError):
return None
return v * 100 if -1.5 <= v <= 1.5 else v
async def _ghostfolio_data(client: httpx.AsyncClient, token: str | None):
"""Auth + GET /portfolio/details → dict brut Ghostfolio, ou une str d'erreur.
`token` : jeton fourni par le client (prioritaire) ; à défaut, jeton serveur (env).
Source UNIQUE de l'appel Ghostfolio (réutilisée par le bloc contexte, la phrase
vocale et l'aperçu structuré du portail).
"""
base = settings.ghostfolio_url.rstrip("/")
token = token or settings.ghostfolio_token
@ -62,19 +77,26 @@ async def _ghostfolio_fetch(client: httpx.AsyncClient, token: str | None):
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)
return r.json() or {}
except httpx.HTTPError:
return "Données indisponibles : Ghostfolio injoignable."
except (ValueError, KeyError):
return "Données indisponibles : réponse Ghostfolio inattendue."
async def _ghostfolio_fetch(client: httpx.AsyncClient, token: str | None):
"""Récupère le portefeuille → (value, currency, holdings) ou une str d'erreur."""
data = await _ghostfolio_data(client, token)
if isinstance(data, str):
return data
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)
async def ghostfolio_block(client: httpx.AsyncClient, token: str | None = None) -> str:
"""Bloc de contexte (valeur + positions principales)."""
res = await _ghostfolio_fetch(client, token)
@ -107,6 +129,49 @@ async def ghostfolio_phrase(client: httpx.AsyncClient, token: str | None = None)
return f"Ton portefeuille Ghostfolio vaut environ {_money(val)} {cur}."
async def ghostfolio_details(client: httpx.AsyncClient, token: str | None = None) -> dict:
"""Aperçu STRUCTURÉ du portefeuille (page détail du portail HUD).
Renvoie {value, currency, performance_pct, holdings:[{name, symbol, value,
allocation_pct, performance_pct}]} ou {error: }. Lecture seule, best-effort.
"""
data = await _ghostfolio_data(client, token)
if isinstance(data, str):
return {"error": data}
summary = data.get("summary", {}) or {}
val = (summary.get("currentValueInBaseCurrency")
or summary.get("currentValue") or summary.get("netWorth"))
cur = summary.get("baseCurrency") or "EUR"
perf = _pct(
summary.get("currentNetPerformancePercent")
or summary.get("netPerformancePercent")
or summary.get("currentNetPerformancePercentWithCurrencyEffect")
)
holdings = data.get("holdings", {}) or {}
rows = sorted(
(h for h in holdings.values() if isinstance(h, dict)),
key=lambda h: h.get("valueInBaseCurrency") or 0, reverse=True,
)
out = []
for h in rows[:12]:
hv = h.get("valueInBaseCurrency")
if not hv:
continue
out.append({
"name": h.get("name") or h.get("symbol") or "?",
"symbol": h.get("symbol") or "",
"value": round(float(hv)),
"allocation_pct": _pct(h.get("allocationInPercentage")),
"performance_pct": _pct(h.get("netPerformancePercent")),
})
return {
"value": round(float(val)) if val is not None else None,
"currency": cur,
"performance_pct": perf,
"holdings": out,
}
async def alerts_block(client: httpx.AsyncClient) -> str:
base = settings.alertmanager_url.rstrip("/")
try: