feat(finlab): import de relevé par image (upload dashboard → scan Console IA)

Process dédié pour actualiser le portefeuille à partir d'une capture de relevé :

- Dashboard : bouton « 📥 Importer un relevé » (panneau Mes comptes) → upload de
  l'image vers le workspace (imports/) via POST /api/import ; une modale donne la
  phrase exacte à dire à la console + un lien direct
- Backend : endpoints /api/import (UploadFile, refuse les non-images) et /api/imports
  (listing), dossier imports/ sous FINLAB_HOME (workspace, persistant)
- Persona console (CLAUDE.md) : workflow « Import d'un relevé » — lire l'image,
  extraire compte/positions, mapper vers tickers Yahoo en VÉRIFIANT les cours,
  confirmer, puis mettre à jour portfolio.yaml

Le scan vision se fait dans la Console IA (abonnement, pas de clé API). Le dashboard
n'est que le pont d'upload. Testé : POST image→200 + listing OK, non-image→400 ;
JS node --check OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
alkatrazz 2026-06-30 10:51:22 +02:00
parent 2228613050
commit 72f22a7063
3 changed files with 89 additions and 2 deletions

View file

@ -8,12 +8,13 @@ Lancement : uvicorn dashboard.server:app --host 0.0.0.0 --port 8800
""" """
from __future__ import annotations from __future__ import annotations
import datetime as dt
import math import math
from pathlib import Path from pathlib import Path
import pandas as pd import pandas as pd
import yaml import yaml
from fastapi import FastAPI, Request from fastapi import FastAPI, File, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@ -167,6 +168,34 @@ def api_plan(ticker: str, capital: float = 1427.0):
return _clean({"plan": p, "render": plan_mod.render(p)}) return _clean({"plan": p, "render": plan_mod.render(p)})
# ── Import de relevés (image → analysée par la Console IA) ─────────────────────
IMPORTS_DIR = data.ROOT / "imports"
_IMG_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
@app.post("/api/import")
async def api_import(file: UploadFile = File(...)):
"""Enregistre une capture de relevé dans le workspace (imports/) ; la Console IA la scanne
ensuite pour mettre à jour portfolio.yaml (vision côté abonnement, pas de clé API ici)."""
ext = Path(file.filename or "").suffix.lower()
if ext not in _IMG_EXT:
return JSONResponse(status_code=400, content={"error": f"format image non supporté: {ext or '?'}"})
IMPORTS_DIR.mkdir(parents=True, exist_ok=True)
ts = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
name = f"releve-{ts}{ext}"
(IMPORTS_DIR / name).write_bytes(await file.read())
return {"saved": name, "rel": f"imports/{name}"}
@app.get("/api/imports")
def api_imports():
if not IMPORTS_DIR.exists():
return {"imports": []}
files = sorted((f for f in IMPORTS_DIR.iterdir() if f.suffix.lower() in _IMG_EXT),
key=lambda f: f.stat().st_mtime, reverse=True)
return {"imports": [f.name for f in files]}
# ── Statique ────────────────────────────────────────────────────────────────── # ── Statique ──────────────────────────────────────────────────────────────────
@app.get("/") @app.get("/")
def index(): def index():

View file

@ -101,7 +101,10 @@
<!-- Colonne gauche : portefeuille + watchlists --> <!-- Colonne gauche : portefeuille + watchlists -->
<div> <div>
<div class="panel"> <div class="panel">
<h3>Mes comptes <span class="muted" id="pfCount"></span></h3> <h3>Mes comptes <span class="muted" id="pfCount"></span>
<button class="btn perbtn" style="font-size:11px" title="Importer une capture de relevé (la Console IA la scanne)" onclick="document.getElementById('importFile').click()">📥 Importer un relevé</button>
</h3>
<input type="file" id="importFile" accept="image/*" style="display:none" onchange="uploadImport(this.files[0])">
<div class="acct-tabs" id="acctTabs"></div> <div class="acct-tabs" id="acctTabs"></div>
<div id="pfList"><div class="loading">Chargement…</div></div> <div id="pfList"><div class="loading">Chargement…</div></div>
</div> </div>
@ -190,6 +193,17 @@
</div> </div>
</div> </div>
<!-- Modal import de relevé -->
<div class="modal" id="importModal" onclick="if(event.target===this)this.classList.remove('on')">
<div class="box">
<h3 style="margin-bottom:10px">Importer un relevé (image)</h3>
<div id="importOut" class="muted"></div>
<div class="controls" style="margin-top:12px">
<button class="btn" onclick="document.getElementById('importModal').classList.remove('on')">Fermer</button>
</div>
</div>
</div>
<script> <script>
let CHART, S = {}, SEL=null, PERIOD='6mo', CCY='$'; let CHART, S = {}, SEL=null, PERIOD='6mo', CCY='$';
const fmt = (n,d=0)=> n==null? '—' : Number(n).toLocaleString('fr-FR',{minimumFractionDigits:d,maximumFractionDigits:d}); const fmt = (n,d=0)=> n==null? '—' : Number(n).toLocaleString('fr-FR',{minimumFractionDigits:d,maximumFractionDigits:d});
@ -384,6 +398,26 @@ async function runPlan(){
}catch(e){ out.textContent='Erreur: '+e.message; } }catch(e){ out.textContent='Erreur: '+e.message; }
} }
// ── Import de relevé (image → scannée par la Console IA) ────
async function uploadImport(f){
const inp=document.getElementById('importFile');
if(!f){ return; }
const m=document.getElementById('importModal'), out=document.getElementById('importOut');
m.classList.add('on'); out.innerHTML='<div class="loading">Envoi de '+f.name+'…</div>';
try{
const fd=new FormData(); fd.append('file', f);
const r=await fetch('/api/import',{method:'POST',body:fd});
const j=await r.json();
if(j.error) throw new Error(j.error);
out.innerHTML = `<p style="color:var(--up)">✅ Relevé enregistré : <code>${j.rel}</code></p>
<p>Ouvre la <b>Console IA</b> et demande&nbsp;:</p>
<pre>importe le relevé ${j.rel} et mets à jour mon portefeuille</pre>
<p class="muted" style="font-size:12px">Je lirai l'image, je mapperai chaque ligne vers son ticker Yahoo (en vérifiant le cours), puis je mettrai à jour <code>portfolio.yaml</code> <b>après ta confirmation</b>.</p>
<a class="btn acc" href="/console/" target="_blank">Ouvrir la Console IA ↗</a>`;
}catch(e){ out.innerHTML='<div class="err">Erreur: '+e.message+'</div>'; }
finally{ inp.value=''; }
}
// ── Chaîne de valeur IA (couches) ────────────────────────── // ── Chaîne de valeur IA (couches) ──────────────────────────
const LAYER_META = { const LAYER_META = {
energy_power: {ic:'⚡', lb:'Énergie', ds:'alimente les datacenters'}, energy_power: {ic:'⚡', lb:'Énergie', ds:'alimente les datacenters'},

View file

@ -56,6 +56,30 @@ courtier + BNP Paribas PEA), **`watchlists.yaml`** (thèmes), **`alerts.yaml`**
les modifier à la demande (ex. « ajoute 5 actions Micron sur Revolut »), puis relancer les outils. les modifier à la demande (ex. « ajoute 5 actions Micron sur Revolut »), puis relancer les outils.
Les agrégats (`portfolio_summary`, alertes `portfolio`, scan) couvrent **tous les comptes**. Les agrégats (`portfolio_summary`, alertes `portfolio`, scan) couvrent **tous les comptes**.
## Import d'un relevé (image → portefeuille)
L'utilisateur peut uploader une **capture de relevé** depuis le dashboard (bouton « Importer un
relevé ») ; l'image atterrit dans **`imports/`** du workspace. Quand on te demande d'importer un
relevé (« importe le relevé imports/… et mets à jour mon portefeuille ») :
1. **Lis l'image** (`Read imports/<fichier>`). S'il n'y a pas de chemin, prends la plus récente
de `imports/` (`ls -t imports/`).
2. **Extrais** : le **nom du compte** (banque/courtier visible sur la capture) et son **type**
(`banque`/`courtier`/`pea`/`crypto`), les **liquidités**, et chaque ligne : libellé + **ISIN**
ou code si présent, **quantité**, **PRU** (prix de revient), devise.
3. **Mappe chaque ligne vers son ticker Yahoo Finance** (actions FR/EU → suffixe `.PA`/`.AS` ;
ETF par leur mnémonique, ex. `PAEEM.PA`). **VÉRIFIE** chaque ticker avec
`python -m finlab.cli` ou l'outil `price` : le **cours doit coller au relevé** (sinon mauvais
ticker, cherche la bonne variante).
4. **Montre le compte reconstruit** (tableau ticker/qté/PRU + total) et **demande confirmation
avant d'écrire**.
5. Mets à jour **`portfolio.yaml`** (workspace) : **ajoute** le compte, ou **remplace**-le s'il
existe déjà (même nom), au format `accounts:`. Respecte les nombres exactement.
6. Relance `portfolio_summary` pour valider que les totaux collent au relevé. Rappelle que
c'est analyse only (aucun ordre réel).
> C'est `portfolio.yaml` du **workspace** (PVC) qui fait foi au runtime — c'est lui que tu édites.
## Méthode de réponse ## Méthode de réponse
1. Question chiffrée → appelle l'outil pertinent **avant** de conclure. 1. Question chiffrée → appelle l'outil pertinent **avant** de conclure.