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

@ -212,7 +212,8 @@ au prompt système (`STT_DISABLE_THINKING`, défaut `true` ; inoffensif hors Qwe
| — ASR | `stt/client/stt/voice/asr/` | backend enfichable : `whisper` (faster-whisper CPU, défaut) \| `onnx` (Parakeet/Canary/Nemotron via onnx-asr, multilingue, streaming-ready). Choisi par `[voice] asr_engine` | | — ASR | `stt/client/stt/voice/asr/` | backend enfichable : `whisper` (faster-whisper CPU, défaut) \| `onnx` (Parakeet/Canary/Nemotron via onnx-asr, multilingue, streaming-ready). Choisi par `[voice] asr_engine` |
| — api | `stt/client/stt/api.py` | `ServerClient``POST /v1/ask` | | — api | `stt/client/stt/api.py` | `ServerClient``POST /v1/ask` |
| — UI/HUD | `stt/client/stt/ui/` + `hud/` | HTTP statique + websocket bidirectionnel (états → HUD ; réglages **et messages texte** → backend) ; HUD embarqué dans le package | | — UI/HUD | `stt/client/stt/ui/` + `hud/` | HTTP statique + websocket bidirectionnel (états → HUD ; réglages **et messages texte** → backend) ; HUD embarqué dans le package |
| — portail | `stt/client/stt/portal/` | `registry.py` (services config-driven `[[services]]`) + `health.py` (StatusPoller). Panneau « Services » du HUD : tuiles + **pages détail** (description, **santé live**, composants, alertes, bouton Ouvrir). Santé = probe HTTP + **Prometheus** `up{}`/kube-state-metrics + **Alertmanager**. Ouvre l'URL dans le navigateur normal | | — portail | `stt/client/stt/portal/` | `registry.py` (services config-driven `[[services]]`) + `health.py` (StatusPoller). Panneau « Services » du HUD : tuiles + **pages détail** (description, **santé live**, composants, alertes, **aperçu enrichi**, bouton Ouvrir). Santé = probe HTTP + **Prometheus** `up{}`/kube-state-metrics + **Alertmanager**. Ouvre l'URL dans le navigateur normal |
| — aperçu enrichi (≥ 0.18.0) | `hud/index.html` (`renderServiceData`) + `ui/app.py` (`service-detail`) | Aperçu live spécifique au service dans sa page détail : **Ghostfolio** → valeur + performance + positions (alloc % et P/L par ligne, via `POST /v1/portfolio/details` du serveur, jeton client) ; **Alertmanager****toutes** les alertes actives (rendu HUD à partir de `all_alerts` poussé par le poller, auto-rafraîchi). Extensible : `DETAIL_PROVIDERS` (`server` = requête backend \| `alerts` = liste locale) |
| — intentions vocales | `stt/client/stt/portal/intents.py` + `ghostfolio.py` | lecture seule, court-circuitent le LLM (interceptées dans `engine._respond`) : « ouvre &lt;service&gt; », « état du cluster/services » (résumé santé parlé), « combien sur mon ghostfolio » (API Ghostfolio, `[ghostfolio] access_token`) | | — intentions vocales | `stt/client/stt/portal/intents.py` + `ghostfolio.py` | lecture seule, court-circuitent le LLM (interceptées dans `engine._respond`) : « ouvre &lt;service&gt; », « état du cluster/services » (résumé santé parlé), « combien sur mon ghostfolio » (API Ghostfolio, `[ghostfolio] access_token`) |
| — contexte HUD | `stt/client/stt/ui/app.py` + `hud/` | sélecteur de contexte (Réglages) envoyé par requête + **visualiseur** (drawer) affichant le contexte assemblé renvoyé par le serveur ; affichage de la version installée | | — contexte HUD | `stt/client/stt/ui/app.py` + `hud/` | sélecteur de contexte (Réglages) envoyé par requête + **visualiseur** (drawer) affichant le contexte assemblé renvoyé par le serveur ; affichage de la version installée |
| STT-server | `stt/server/` (conteneur) | FastAPI : `/healthz`, `/v1/ask` ; `brain.py` → LiteLLM | | STT-server | `stt/server/` (conteneur) | FastAPI : `/healthz`, `/v1/ask` ; `brain.py` → LiteLLM |

View file

@ -1,6 +1,6 @@
[project] [project]
name = "stt" name = "stt"
version = "0.17.0" version = "0.18.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"

View file

@ -68,6 +68,27 @@ class ServerClient:
except requests.RequestException: except requests.RequestException:
return "Je n'arrive pas à joindre le serveur pour Ghostfolio." return "Je n'arrive pas à joindre le serveur pour Ghostfolio."
def portfolio_details(self) -> dict:
"""Aperçu structuré du portefeuille (page détail du portail HUD).
{value, currency, performance_pct, holdings:[]} ou {error: }. Dégrade en
clair si le serveur est antérieur (pas d'endpoint) ou injoignable.
"""
import requests
try:
r = requests.post(
f"{self.url}/v1/portfolio/details",
json={"token": self.secrets.get("ghostfolio_token")},
timeout=self.timeout,
)
if r.status_code == 404:
return {"error": "Aperçu indisponible (STT-server antérieur)."}
r.raise_for_status()
return r.json()
except requests.RequestException:
return {"error": "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.

View file

@ -751,6 +751,36 @@
.svc-info a { color: var(--accent-active); text-decoration: none; } .svc-info a { color: var(--accent-active); text-decoration: none; }
.svc-open-btn { margin-top: 18px; } .svc-open-btn { margin-top: 18px; }
/* Aperçu enrichi (page détail) : cartes de stats + lignes de données */
.svc-stats { display: flex; flex-wrap: wrap; gap: 10px; }
.svc-stats:empty { display: none; }
.svc-stat {
flex: 1 1 120px; min-width: 0;
padding: 12px 14px; border-radius: 12px;
background: var(--bg-soft); border: 1px solid var(--line);
}
.svc-stat .svc-stat-label {
display: block; font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .04em; color: var(--ink-soft); margin-bottom: 6px;
}
.svc-stat .svc-stat-value { font-size: 20px; font-weight: 650; letter-spacing: -.01em; }
.svc-stat[data-tone="good"] .svc-stat-value { color: #2bb673; }
.svc-stat[data-tone="bad"] .svc-stat-value { color: #d23a52; }
.svc-data-rows { margin-top: 12px; }
.svc-data-rows:empty { display: none; }
.svc-data-row {
display: flex; align-items: baseline; gap: 10px;
padding: 8px 0; font-size: 13.5px; border-top: 1px solid var(--line);
}
.svc-data-row:first-child { border-top: none; }
.svc-data-row .svc-data-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.svc-data-row .svc-data-sub { font-size: 11.5px; color: var(--ink-soft); margin-left: 6px; }
.svc-data-row .svc-data-val { font-weight: 600; flex: none; }
.svc-data-row .svc-data-val[data-tone="good"] { color: #2bb673; }
.svc-data-row .svc-data-val[data-tone="bad"] { color: #d23a52; }
.svc-data-row .svc-data-val[data-tone="warn"] { color: #c5841f; }
.svc-data-empty { font-size: 13px; color: var(--ink-soft); margin: 4px 0 0; }
/* Badge alertes global (en tête de la liste) */ /* Badge alertes global (en tête de la liste) */
.svc-alert-badge { .svc-alert-badge {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
@ -1090,6 +1120,15 @@
</div> </div>
<p class="svc-detail-desc hint" id="svcDetailDesc"></p> <p class="svc-detail-desc hint" id="svcDetailDesc"></p>
<!-- Aperçu enrichi spécifique au service (portefeuille Ghostfolio, toutes les
alertes Alertmanager…). Cartes de stats + lignes de données. -->
<div class="svc-section" id="svcDataWrap" hidden>
<span class="flabel" id="svcDataLabel">Aperçu</span>
<div class="svc-stats" id="svcStats"></div>
<div class="svc-data-rows" id="svcDataRows"></div>
<p class="svc-data-empty hint" id="svcDataEmpty" hidden></p>
</div>
<div class="svc-section" id="svcDetailCompWrap" hidden> <div class="svc-section" id="svcDetailCompWrap" hidden>
<span class="flabel">Composants</span> <span class="flabel">Composants</span>
<div id="svcCompList"></div> <div id="svcCompList"></div>
@ -1288,6 +1327,7 @@ function handleEvent(ev) {
case 'mic': setMicUI(!!ev.muted); break; // état micro renvoyé par le backend case 'mic': setMicUI(!!ev.muted); break; // état micro renvoyé par le backend
case 'services': renderServices(ev.services || []); break; // tuiles du portail case 'services': renderServices(ev.services || []); break; // tuiles du portail
case 'portal-status': applyPortalStatus(ev); break; // santé live des services case 'portal-status': applyPortalStatus(ev); break; // santé live des services
case 'service-detail': applyServiceDetail(ev); break; // aperçu enrichi (portefeuille…)
case 'contexts': applyContexts(ev); break; // contextes présélectionnables case 'contexts': applyContexts(ev); break; // contextes présélectionnables
case 'meta': setVersion(ev.version || '?'); break; // version installée case 'meta': setVersion(ev.version || '?'); break; // version installée
case 'context': renderContextViewer(ev.context); break; // contexte assemblé (visualiseur) case 'context': renderContextViewer(ev.context); break; // contexte assemblé (visualiseur)
@ -1429,7 +1469,14 @@ let toastTimer = null;
let servicesById = {}; // id → {id,name,url,icon,description} let servicesById = {}; // id → {id,name,url,icon,description}
let portalStatus = {}; // id → {state,health,latency_ms,components,alerts} let portalStatus = {}; // id → {state,health,latency_ms,components,alerts}
let alertsTotal = null; let alertsTotal = null;
let allAlerts = []; // liste complète des alertes actives (page Alertmanager)
let detailId = null; // service affiché en détail, ou null (vue liste) let detailId = null; // service affiché en détail, ou null (vue liste)
let detailData = {}; // id → aperçu enrichi reçu du serveur (ex. portefeuille Ghostfolio)
/* Services dont la page détail a un aperçu enrichi.
'server' : données récupérées via le backend (requête WS). 'alerts' : rendu côté
HUD à partir de la liste `allAlerts` déjà poussée (auto-rafraîchie). */
const DETAIL_PROVIDERS = { ghostfolio: 'server', alertmanager: 'alerts' };
const HEALTH_LABELS = { ok:'En ligne', degraded:'Dégradé', down:'Hors ligne', unknown:'Inconnu' }; const HEALTH_LABELS = { ok:'En ligne', degraded:'Dégradé', down:'Hors ligne', unknown:'Inconnu' };
const COMP_LABELS = { up:'en ligne', down:'en panne', unknown:'—', info:'' }; const COMP_LABELS = { up:'en ligne', down:'en panne', unknown:'—', info:'' };
@ -1442,6 +1489,15 @@ const DEMO_SERVICES = [
{ id:'argocd', name:'ArgoCD', icon:'🚢', description:'Déploiement GitOps.' }, { id:'argocd', name:'ArgoCD', icon:'🚢', description:'Déploiement GitOps.' },
{ id:'n8n', name:'n8n', icon:'🔀', description:'Workflows.' }, { id:'n8n', name:'n8n', icon:'🔀', description:'Workflows.' },
]; ];
/* Aperçus enrichis simulés (mode DÉMO seulement) */
const DEMO_DETAIL = {
ghostfolio: { service:'ghostfolio', kind:'portfolio', data:{
value:12480, currency:'EUR', performance_pct:3.2, holdings:[
{ name:'Apple Inc.', symbol:'AAPL', value:3200, allocation_pct:25.6, performance_pct:12.4 },
{ name:'Bitcoin', symbol:'BTC', value:2100, allocation_pct:16.8, performance_pct:-4.1 },
{ name:'MSCI World', symbol:'IWDA', value:1800, allocation_pct:14.4, performance_pct:6.0 },
] } },
};
function renderServices(list) { function renderServices(list) {
const items = Array.isArray(list) ? list : []; const items = Array.isArray(list) ? list : [];
@ -1472,6 +1528,7 @@ function renderServices(list) {
function applyPortalStatus(msg) { function applyPortalStatus(msg) {
portalStatus = (msg && msg.services) || {}; portalStatus = (msg && msg.services) || {};
alertsTotal = msg ? msg.alerts_total : null; alertsTotal = msg ? msg.alerts_total : null;
allAlerts = (msg && Array.isArray(msg.all_alerts)) ? msg.all_alerts : [];
updateTileDots(); updateTileDots();
updateAlertBadge(); updateAlertBadge();
if (detailId && servicesById[detailId]) renderDetail(detailId); if (detailId && servicesById[detailId]) renderDetail(detailId);
@ -1526,6 +1583,9 @@ function renderDetail(id) {
desc.textContent = s.description || ''; desc.textContent = s.description || '';
desc.hidden = !s.description; desc.hidden = !s.description;
// Aperçu enrichi (portefeuille, alertes…)
renderServiceData(id);
// Composants // Composants
const comps = st.components || []; const comps = st.components || [];
document.getElementById('svcDetailCompWrap').hidden = comps.length === 0; document.getElementById('svcDetailCompWrap').hidden = comps.length === 0;
@ -1570,6 +1630,101 @@ function renderDetail(id) {
document.getElementById('svcOpenBtn').onclick = () => openService(s); document.getElementById('svcOpenBtn').onclick = () => openService(s);
} }
/* ----- Aperçu enrichi par service (portefeuille, alertes…) ----- */
const svcDataWrap = document.getElementById('svcDataWrap');
const svcDataLabel = document.getElementById('svcDataLabel');
const svcStats = document.getElementById('svcStats');
const svcDataRows = document.getElementById('svcDataRows');
const svcDataEmpty = document.getElementById('svcDataEmpty');
const fmtMoney = (v, cur) => {
if (v == null || isNaN(v)) return '—';
return Math.round(v).toLocaleString('fr-FR') + (cur ? ' ' + curSymbol(cur) : '');
};
const curSymbol = (c) => ({ EUR:'€', USD:'$', GBP:'£' })[c] || c;
const fmtPct = (p) => (p == null || isNaN(p)) ? null
: (p >= 0 ? '+' : '') + p.toFixed(1).replace('.', ',') + ' %';
const pctTone = (p) => (p == null || isNaN(p)) ? '' : (p >= 0 ? 'good' : 'bad');
function clearData() { svcStats.innerHTML = ''; svcDataRows.innerHTML = ''; svcDataEmpty.hidden = true; }
function addStat(label, value, tone) {
const c = document.createElement('div');
c.className = 'svc-stat';
if (tone) c.dataset.tone = tone;
c.innerHTML = `<span class="svc-stat-label"></span><span class="svc-stat-value"></span>`;
c.querySelector('.svc-stat-label').textContent = label;
c.querySelector('.svc-stat-value').textContent = value;
svcStats.appendChild(c);
}
function addDataRow(name, sub, value, tone) {
const r = document.createElement('div');
r.className = 'svc-data-row';
r.innerHTML = `<span class="svc-data-name"></span>` +
(sub ? `<span class="svc-data-sub"></span>` : '') +
`<span class="svc-data-val"></span>`;
r.querySelector('.svc-data-name').textContent = name;
if (sub) r.querySelector('.svc-data-sub').textContent = sub;
const v = r.querySelector('.svc-data-val');
v.textContent = value; if (tone) v.dataset.tone = tone;
svcDataRows.appendChild(r);
}
function dataEmpty(text) { svcDataEmpty.hidden = false; svcDataEmpty.textContent = text; }
function renderServiceData(id) {
const kind = DETAIL_PROVIDERS[id];
if (!kind) { svcDataWrap.hidden = true; return; }
svcDataWrap.hidden = false;
clearData();
if (kind === 'alerts') { renderAlertsData(); return; }
if (kind === 'server') { // Ghostfolio
svcDataLabel.textContent = 'Portefeuille';
const d = detailData[id];
if (d) renderPortfolio(d);
else { dataEmpty('Chargement…'); requestServiceData(id); }
}
}
function renderAlertsData() {
svcDataLabel.textContent = 'Alertes actives du homelab';
const list = allAlerts || [];
const crit = list.filter(a => (a.severity || '').toLowerCase() === 'critical').length;
addStat('Total', String(list.length), list.length ? 'bad' : 'good');
if (crit) addStat('Critiques', String(crit), 'bad');
if (list.length === 0) { dataEmpty('Aucune alerte active ✓'); return; }
for (const a of list) {
const sev = (a.severity || 'none').toLowerCase();
const tone = sev === 'critical' ? 'bad' : (sev === 'warning' ? 'warn' : '');
addDataRow(a.name || 'alerte', a.summary || '', sev, tone);
}
}
function renderPortfolio(d) {
if (d.error) { dataEmpty(d.error); return; }
addStat('Valeur', fmtMoney(d.value, d.currency), '');
const perf = fmtPct(d.performance_pct);
if (perf) addStat('Performance', perf, pctTone(d.performance_pct));
const holdings = Array.isArray(d.holdings) ? d.holdings : [];
if (holdings.length === 0) { dataEmpty('Aucune position renvoyée.'); return; }
for (const h of holdings) {
const alloc = (h.allocation_pct != null && !isNaN(h.allocation_pct))
? Math.round(h.allocation_pct) + ' %' : '';
addDataRow(h.name || h.symbol || '?', alloc,
fmtMoney(h.value, d.currency), pctTone(h.performance_pct));
}
}
function requestServiceData(id) {
if (DEMO) { applyServiceDetail(DEMO_DETAIL[id]); return; }
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'control', action: 'service-detail', service: id }));
}
function applyServiceDetail(ev) {
if (!ev || !ev.service) return;
detailData[ev.service] = ev.data || {};
if (detailId === ev.service) renderServiceData(ev.service); // page encore ouverte
}
function openService(s) { function openService(s) {
showToast(`${s.icon || '🔗'} Ouverture de ${s.name || s.id}`); showToast(`${s.icon || '🔗'} Ouverture de ${s.name || s.id}`);
if (DEMO || !ws || ws.readyState !== WebSocket.OPEN) { if (DEMO || !ws || ws.readyState !== WebSocket.OPEN) {

View file

@ -182,7 +182,14 @@ class StatusPoller:
} }
total = None if alerts is None else len(alerts) total = None if alerts is None else len(alerts)
return {"type": "portal-status", "services": services_out, "alerts_total": total} # `all_alerts` : liste complète (déjà récupérée) → alimente la page détail
# d'Alertmanager (aperçu de TOUTES les alertes, pas seulement celles d'un service).
return {
"type": "portal-status",
"services": services_out,
"alerts_total": total,
"all_alerts": alerts or [],
}
# ── thread ─────────────────────────────────────────────────────────── # ── thread ───────────────────────────────────────────────────────────
def _run(self) -> None: def _run(self) -> None:

View file

@ -132,6 +132,17 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None:
stt_bin = _shutil.which("stt") or _sys.argv[0] stt_bin = _shutil.which("stt") or _sys.argv[0]
def fetch_service_detail(sid: str) -> None:
"""Aperçu enrichi à la demande pour une page détail (HTTP bloquant → executor).
Seul Ghostfolio passe par le serveur (jeton). Alertmanager se rend côté HUD à
partir de `all_alerts` (déjà poussé par le poller) pas d'appel ici.
"""
if sid == "ghostfolio":
data = client.portfolio_details()
emit({"type": "service-detail", "service": sid,
"kind": "portfolio", "data": data})
def run_service_action(action: str) -> None: def run_service_action(action: str) -> None:
"""Pilote le service depuis le HUD : restart / stop / update. """Pilote le service depuis le HUD : restart / stop / update.
@ -187,6 +198,11 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None:
elif mtype == "control" and msg.get("action") == "open-service": elif mtype == "control" and msg.get("action") == "open-service":
# indépendant du moteur vocal → cliquable même sans micro # indépendant du moteur vocal → cliquable même sans micro
open_service(str(msg.get("service") or "")) open_service(str(msg.get("service") or ""))
elif mtype == "control" and msg.get("action") == "service-detail":
# aperçu enrichi d'une page détail (Ghostfolio → serveur)
loop.run_in_executor(
None, fetch_service_detail, str(msg.get("service") or "")
)
elif mtype == "control" and msg.get("action") in ( elif mtype == "control" and msg.get("action") in (
"service-restart", "service-stop", "service-update" "service-restart", "service-stop", "service-update"
): ):

View file

@ -25,7 +25,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, ghostfolio_phrase from stt_server.sources import fetch_blocks, ghostfolio_details, 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
@ -126,6 +126,16 @@ async def v1_portfolio(req: PortfolioRequest) -> dict:
return {"summary": summary} 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") @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:

View file

@ -38,10 +38,25 @@ def _money(v) -> str:
return f"{round(float(v)):,}".replace(",", " ") return f"{round(float(v)):,}".replace(",", " ")
async def _ghostfolio_fetch(client: httpx.AsyncClient, token: str | None): def _pct(v) -> float | None:
"""Récupère le portefeuille → (value, currency, holdings) ou une str d'erreur. """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). `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("/") base = settings.ghostfolio_url.rstrip("/")
token = token or settings.ghostfolio_token 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, headers={"Authorization": f"Bearer {jwt}"}, timeout=settings.sources_timeout,
) )
r.raise_for_status() r.raise_for_status()
data = r.json() or {} return 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: 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_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: async def ghostfolio_block(client: httpx.AsyncClient, token: str | None = None) -> str:
"""Bloc de contexte (valeur + positions principales).""" """Bloc de contexte (valeur + positions principales)."""
res = await _ghostfolio_fetch(client, token) 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}." 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: async def alerts_block(client: httpx.AsyncClient) -> str:
base = settings.alertmanager_url.rstrip("/") base = settings.alertmanager_url.rstrip("/")
try: try: