402 lines
19 KiB
Markdown
402 lines
19 KiB
Markdown
# IN-SANITY LONGEVITY PROJECT — piattaforma dati cliente
|
|
|
|
**Data:** 2026-08-21
|
|
**Stato:** design approvato, implementazione non iniziata
|
|
**Branch:** `feat/longevity` — `main` non viene toccato, nessun deploy
|
|
**Committente:** In-Sanity Lab (Nicola Antonelli) — sviluppo Tielogic
|
|
|
|
---
|
|
|
|
## 1. Cos'è, e come si chiama
|
|
|
|
Il nome esiste già e viene dal cliente: **IN-SANITY LONGEVITY PROJECT** è
|
|
l'iniziativa — *«servizio premium basato su dati, con monitoraggio
|
|
longitudinale»* — e **IN-SANITY LONGEVITY SCORE** è il sistema di punteggio che
|
|
ne è il prodotto di punta. Questa piattaforma è l'implementazione software del
|
|
primo. Nel codice il nome tecnico è **`longevity`**.
|
|
|
|
Non è una raccolta di applicazioni separate: è **un fascicolo del cliente**.
|
|
Questionario, check-up in sala, Wellness Tower e VALD non sono quattro
|
|
sottosistemi, sono **quattro sorgenti che scrivono nello stesso posto**;
|
|
dashboard, vista trainer e punteggi sono **viste e funzioni derivate** di quel
|
|
posto. È la differenza fra progettare per dati e progettare per schermate, e
|
|
determina tutto quello che segue.
|
|
|
|
## 2. Perimetro
|
|
|
|
**Dentro:** anagrafica cliente, questionario in pagina, registrazione delle
|
|
misure da tutte le sorgenti, motore di calcolo degli score, dashboard cliente,
|
|
vista trainer, export per le statistiche.
|
|
|
|
**Fuori, e per ragioni diverse:**
|
|
|
|
- **I percentili interni ISL** — non per scelta: la documentazione del cliente
|
|
dichiara che sotto 15 campioni sono inutilizzabili e sotto 30 provvisori.
|
|
Vanno costruiti quando i dati esistono, non prima.
|
|
- **Gli 8 indicatori posturali** della Wellness Tower — esistono solo nel PDF
|
|
stampato, non nell'export a 55 colonne. Il cliente li ha già esclusi dallo
|
|
score.
|
|
- **Il blocco Trimestre / 2 Mesocicli** — dichiarato aperto dal cliente stesso e
|
|
ancora in analisi da parte sua.
|
|
- **Il deploy** — la piattaforma si sviluppa e si prova in locale. Quando mettere
|
|
qualcosa in produzione è una decisione separata, che non appartiene a questo
|
|
documento.
|
|
|
|
## 3. Collocazione
|
|
|
|
La piattaforma vive **dentro l'applicazione Astro esistente**, come già fanno
|
|
Biohacking Campus e Stress Index. Non è un secondo servizio, non è un secondo
|
|
container.
|
|
|
|
```
|
|
src/pages/longevity/ rotte visibili
|
|
src/pages/api/longevity/ endpoint
|
|
src/lib/longevity/ modello dati, motore, import
|
|
tests/longevity/ test
|
|
```
|
|
|
|
Il motivo per cui non è un progetto separato è che l'autenticazione, le sessioni
|
|
e l'anagrafica utenti esistono già qui e funzionano in produzione: rifarle altrove
|
|
significherebbe mantenerne due.
|
|
|
|
⚠️ **Il calcolo è in TypeScript, non in Python.** Il motore fornito dal cliente
|
|
(`isl_scoring_engine.py`, 537 righe) è aritmetica pura — interpolazioni lineari,
|
|
tabelle di soglie, medie pesate — e importa solo `dataclasses` e `typing`: nessuna
|
|
libreria numerica. Portarlo in TypeScript costa poco e evita un secondo runtime
|
|
dentro un'immagine `node:22-slim` a processo unico. La stessa scelta vale per il
|
|
prototipo del questionario, che calcola già in JavaScript: tenere le formule in
|
|
due linguaggi è il modo silenzioso di farle divergere.
|
|
|
|
## 4. I tre database
|
|
|
|
SQLite, tre file distinti, aperti da connessioni distinte.
|
|
|
|
| File | Contiene | Non contiene |
|
|
|---|---|---|
|
|
| `insanitylab.db` | il sito: articoli, contenuti, **utenti e sessioni** | niente di clinico |
|
|
| `identity.db` | `client_code` ↔ `user_id`, nome, cognome, nascita, recapiti | nessuna misura |
|
|
| `longevity.db` | misure, questionari, punteggi, registro test | **nessun nome, mai** |
|
|
|
|
La proprietà che ne discende è la richiesta esplicita del cliente del 21/08: *«la
|
|
mappatura codice↔nome va tenuta in un posto separato e più ristretto rispetto
|
|
all'export usato per le statistiche, così anche chi ha accesso ai dati clinici
|
|
non può risalire all'identità»*. Qui l'export per le statistiche **è**
|
|
`longevity.db`: già pseudonimizzato per costruzione, senza una funzione di
|
|
anonimizzazione che qualcuno debba ricordarsi di chiamare.
|
|
|
|
**Vincolo di implementazione, verificabile con un test:** esiste **un solo
|
|
modulo** che apre insieme `identity` e `longevity`. Nessun altro punto del codice
|
|
importa entrambe le connessioni.
|
|
|
|
## 5. Modello dati
|
|
|
|
```sql
|
|
-- ============ identity.db ============
|
|
CREATE TABLE clienti (
|
|
client_code TEXT PRIMARY KEY, -- es. 'ISL-0007'
|
|
user_id INTEGER, -- users.id in insanitylab.db (nessuna FK: file diverso)
|
|
nome TEXT NOT NULL,
|
|
cognome TEXT NOT NULL,
|
|
data_nascita TEXT, -- ISO; resta QUI, non passa in longevity
|
|
email TEXT,
|
|
telefono TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE UNIQUE INDEX idx_clienti_user ON clienti (user_id);
|
|
|
|
-- ============ longevity.db ============
|
|
CREATE TABLE soggetti (
|
|
client_code TEXT PRIMARY KEY,
|
|
sesso TEXT NOT NULL CHECK (sesso IN ('M','F')),
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE sessioni (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
|
|
data TEXT NOT NULL,
|
|
tipo TEXT NOT NULL CHECK (tipo IN ('checkup','questionario')),
|
|
eta_alla_data INTEGER, -- l'età serve al motore; la data di nascita no
|
|
operatore TEXT,
|
|
note TEXT,
|
|
quest_version TEXT, -- solo per tipo='questionario'
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE INDEX idx_sessioni_cliente ON sessioni (client_code, data DESC);
|
|
|
|
CREATE TABLE misure (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
sessione_id INTEGER NOT NULL REFERENCES sessioni(id) ON DELETE CASCADE,
|
|
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
|
|
test_id TEXT NOT NULL REFERENCES registro_test(test_id),
|
|
valore_num REAL,
|
|
valore_txt TEXT, -- per le risposte non numeriche
|
|
unita TEXT,
|
|
fonte TEXT NOT NULL CHECK (fonte IN
|
|
('manuale','questionario','wellness_tower','vald','calibre','stress_index')),
|
|
fuori_range INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE INDEX idx_misure_cliente_test ON misure (client_code, test_id);
|
|
CREATE INDEX idx_misure_sessione ON misure (sessione_id);
|
|
|
|
CREATE TABLE registro_test (
|
|
test_id TEXT PRIMARY KEY, -- 'handgrip_dx', 'plank', 'q_sonno_ore'
|
|
etichetta TEXT NOT NULL,
|
|
unita TEXT,
|
|
tipo_valore TEXT NOT NULL CHECK (tipo_valore IN ('num','txt')),
|
|
curva TEXT, -- 'bell','lin_dec','inc_plateau','x10','decstep','incstep'
|
|
params TEXT, -- JSON dei parametri della curva
|
|
asse TEXT, -- uno dei 7 assi; NULL = non entra nello score
|
|
sotto_dominio TEXT, -- a QUALE sotto-dominio contribuisce; il peso sta in `pesi`
|
|
range_min REAL, -- atteso, non vincolante: fuori range si marca
|
|
range_max REAL,
|
|
attivo_da TEXT NOT NULL,
|
|
attivo_a TEXT, -- NULL = ancora attivo
|
|
note TEXT
|
|
);
|
|
|
|
CREATE TABLE pesi (
|
|
model_version TEXT NOT NULL,
|
|
livello TEXT NOT NULL CHECK (livello IN ('asse','macro','fitness_age')),
|
|
contenitore TEXT NOT NULL, -- l'asse, il macro-score, o 'fitness_age'
|
|
elemento TEXT NOT NULL, -- il sotto-dominio, o l'asse, o la voce isolata
|
|
peso REAL NOT NULL,
|
|
PRIMARY KEY (model_version, livello, contenitore, elemento)
|
|
);
|
|
|
|
CREATE TABLE profilo_note (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
|
|
sessione_id INTEGER REFERENCES sessioni(id),
|
|
campo_id TEXT NOT NULL, -- 'farmaci', 'problematiche_attuali', 'obiettivi'
|
|
testo TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE score (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
|
|
sessione_id INTEGER NOT NULL REFERENCES sessioni(id),
|
|
tipo TEXT NOT NULL CHECK (tipo IN ('asse','macro','fitness_age')),
|
|
elemento TEXT NOT NULL, -- 'Forza & Struttura', 'PERFORMANCE', ...
|
|
valore REAL, -- NULL quando stato='insufficiente'
|
|
copertura REAL NOT NULL,
|
|
stato TEXT NOT NULL CHECK (stato IN ('ok','insufficiente')),
|
|
quest_version TEXT,
|
|
model_version TEXT NOT NULL,
|
|
calcolato_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE INDEX idx_score_cliente ON score (client_code, calcolato_at DESC);
|
|
```
|
|
|
|
### Perché l'età e non la data di nascita
|
|
|
|
Il motore ha bisogno dell'età — `score_handgrip(kg, eta, sex)`,
|
|
`score_vo2max(vo2max, eta, sex)`, le bande per decade — ma gli serve **un numero
|
|
al momento della misura**, non una data. Sesso ed età non identificano nessuno;
|
|
una data di nascita esatta, combinata con pochi altri campi, quasi sì. Costa una
|
|
colonna e toglie un quasi-identificatore dal database clinico.
|
|
|
|
### Perché il peso non sta sulla riga del test
|
|
|
|
Un sotto-dominio può essere coperto da **test alternativi**: la *spinta* si
|
|
misura con i push-up o con il 5RM di panca, a seconda del livello del cliente, e
|
|
il peso — 20% dentro *Forza & Struttura* — è del sotto-dominio, non del singolo
|
|
test. Se il peso stesse sulla riga del test si ripeterebbe su ogni alternativa,
|
|
e prima o poi due copie divergerebbero.
|
|
|
|
I pesi stanno quindi in una tabella loro, **con la `model_version` nella chiave**.
|
|
È ciò che rende reale il congelamento del §6: uno score del passato si può
|
|
rileggere con i pesi che erano in vigore quando è stato calcolato, invece che con
|
|
quelli di oggi. Senza questo, `model_version` sarebbe un'etichetta senza niente
|
|
dietro.
|
|
|
|
⚠️ Nota per l'implementazione: nel motore Python `plank` compare in **due** assi
|
|
— `core` in *Forza & Struttura* (0.15) e `plank` in *Stabilità & Mobilità* (0.20).
|
|
È lo stesso doppio conteggio ammesso dal cliente per l'handgrip nella Fitness Age,
|
|
ma quello del plank **non è dichiarato da nessuna parte**. Questo schema lo
|
|
rappresenta senza problemi (due righe in `pesi`), però va segnalato al cliente
|
|
invece che riprodotto in silenzio.
|
|
|
|
### Perché il registro dei test è dato e non codice
|
|
|
|
Il cliente ha già i test in forma di registro: un foglio di 30 righe con fonte e
|
|
stato per ciascuno. Qui diventa una tabella, e la conseguenza è che **il set dei
|
|
test è mobile senza migrazioni**, cosa che è già successa tre volte in un mese:
|
|
l'agilità è stata rimossa dallo score, la capacità vitale pure, e l'HRR è passato
|
|
dalla finestra a 2' a quella a 1'.
|
|
|
|
Con questo modello: disattivare un test è valorizzare `attivo_a`; cambiare la
|
|
finestra dell'HRR è **un `test_id` nuovo che convive col vecchio**, e le
|
|
misurazioni già fatte restano leggibili e restano marcate per quello che sono,
|
|
invece di diventare confrontabili per sbaglio con le nuove.
|
|
|
|
## 6. Versioni e congelamento
|
|
|
|
Decisione del cliente, 21/08: quando cambia una formula i punteggi storici si
|
|
**congelano**, non si ricalcolano. La ragione è di prodotto ed è corretta — se
|
|
lo score storico cambia, il cliente non distingue più il proprio miglioramento
|
|
da un cambio di matematica, e il confronto nel tempo perde valore.
|
|
|
|
L'implementazione ha due conseguenze:
|
|
|
|
1. **Gli score si salvano**, non si ricalcolano a ogni visualizzazione. La
|
|
tabella `score` è la memoria di ciò che il cliente ha visto.
|
|
2. **Le misure grezze si salvano tutte**, quindi un ricalcolo con formule nuove
|
|
resta sempre possibile in lettura, per analisi, senza sovrascrivere niente.
|
|
|
|
**Due versioni, non una.** Ogni score porta `quest_version` (quali domande) e
|
|
`model_version` (quali curve e quali pesi). Il cliente ne ha implementata una
|
|
sola perché dal suo lato coincidevano, ma i pesi degli assi possono cambiare
|
|
senza che cambi una domanda: con una versione sola un punteggio del passato non
|
|
saprebbe dire quale delle due cose è cambiata.
|
|
|
|
⚠️ **Il congelamento sposta il problema del confronto, non lo elimina.** Due
|
|
compilazioni con versioni diverse restano non confrontabili anche se nessuno le
|
|
ha riscritte. Il grafico della progressione deve quindi **dichiarare
|
|
visivamente il cambio di versione**, altrimenti il cliente vede una linea
|
|
continua di numeri calcolati con matematiche diverse — che è l'equivoco che la
|
|
decisione voleva evitare.
|
|
|
|
## 7. Flusso dei dati
|
|
|
|
### Ingresso: un solo varco
|
|
|
|
```
|
|
questionario in pagina ─┐
|
|
gestionale trainer ─┤
|
|
import file (WT, VALD) ─┼─→ registraMisure(sessione, misure[]) ─→ misure
|
|
API (VALD Hub) ─┘ ↑ valida contro registro_test
|
|
```
|
|
|
|
`registraMisure` è l'unica funzione che scrive in `misure`. Valida ogni riga
|
|
contro il registro: **un `test_id` sconosciuto viene rifiutato**, non scritto in
|
|
silenzio. Un valore fuori dal range atteso viene invece **scritto e marcato**
|
|
(`fuori_range = 1`), perché scartarlo perderebbe un dato vero e clamparlo lo
|
|
falserebbe — vedi §9.
|
|
|
|
### Calcolo
|
|
|
|
Il motore legge misure e registro, e produce gli score seguendo la cascata a
|
|
quattro livelli del cliente: sotto-metrica → asse → macro-score → Fitness Age,
|
|
con rinormalizzazione dei pesi disponibili a ogni livello e soglia di copertura
|
|
al **40%**.
|
|
|
|
Il tipo di ritorno impedisce la trappola presente nel motore Python, dove
|
|
`aggregate()` restituisce il punteggio pieno **anche quando lo dichiara
|
|
insufficiente**:
|
|
|
|
```ts
|
|
type Punteggio =
|
|
| { stato: 'ok'; valore: number; copertura: number }
|
|
| { stato: 'insufficiente'; copertura: number } // non c'è nessun valore da leggere
|
|
```
|
|
|
|
La regola di prodotto — *«tratteggiato, mai un numero pieno fasullo»* — diventa
|
|
così impossibile da violare per distrazione: chi disegna il radar non ha il campo
|
|
da cui prendere il numero.
|
|
|
|
### Uscita
|
|
|
|
Dashboard cliente (radar a 7 assi, 3 macro-score, Fitness Age), vista trainer, ed
|
|
export per le statistiche — che è il contenuto di `longevity.db`, senza passare
|
|
da nessuna funzione di anonimizzazione.
|
|
|
|
## 8. Ruoli e accessi
|
|
|
|
Il sito ha oggi `admin | superuser | user | piattaforme`, e nessuno di questi è
|
|
il cliente finale né il trainer. Se ne aggiungono **due**: `cliente` e `trainer`.
|
|
|
|
| Rotta | Chi entra |
|
|
|---|---|
|
|
| `/longevity/io` | `cliente` (solo il proprio fascicolo), `trainer`, `admin` |
|
|
| `/longevity/gestionale` | `trainer`, `admin` |
|
|
| `/api/longevity/**` | come sopra, verificato lato server |
|
|
|
|
**Il cliente vede solo i propri dati, e non è una regola di interfaccia:** gli
|
|
endpoint ricavano il `client_code` dalla sessione e **mai** dalla richiesta. Un
|
|
client_code che arriva dal browser viene ignorato.
|
|
|
|
## 9. Errori e casi limite
|
|
|
|
**Misura fuori dal range atteso: si registra e si marca.** Il caso è già
|
|
successo — la capacità vitale di 5148 mL su un fondoscala dichiarato di
|
|
2000-3000 mL non era una prestazione eccezionale, era il range del fornitore
|
|
sbagliato, e clamparla a 100 produceva un dato apparentemente ottimo. Le misure
|
|
marcate finiscono in una lista che il trainer vede; non entrano nello score
|
|
finché qualcuno non le conferma.
|
|
|
|
**Scala non comparabile: si esclude dal registro, non si corregge nel codice.**
|
|
L'agilità a 548 ms contro i 250 attesi dava 20/100 su Stabilità — falso, e la
|
|
causa era il protocollo diverso. Un caso così si risolve disattivando il test nel
|
|
registro, non aggiungendo un fattore di correzione da qualche parte.
|
|
|
|
**Copertura sotto il 40%:** l'elemento è `insufficiente` e non ha un valore.
|
|
|
|
**Test sconosciuto in ingresso:** errore esplicito, la sessione non viene scritta
|
|
a metà.
|
|
|
|
## 10. Test
|
|
|
|
Il test principale è il **verificatore contro il motore Python**: gli stessi
|
|
ingressi devono produrre gli stessi numeri, e ogni curva va confrontata su tutto
|
|
il suo dominio, non su un caso singolo. È l'unico modo per sapere che il porting
|
|
non ha spostato niente.
|
|
|
|
Poi: le regole di copertura e rinormalizzazione, il rifiuto dei test sconosciuti,
|
|
la marcatura del fuori range, l'isolamento dei tre database (nessun modulo, a
|
|
parte quello designato, apre insieme identity e longevity), e il fatto che un
|
|
`client_code` proveniente dalla richiesta non venga mai onorato.
|
|
|
|
⚠️ **I dati reali di Donata e Nicola non entrano nelle fixture.** Sono dati
|
|
sanitari di due persone identificabili, e le fixture stanno in git, dove restano
|
|
per sempre e le legge chiunque abbia accesso al repository. I casi di prova si
|
|
costruiscono sintetici; i referti veri restano dove sono, per il confronto a mano
|
|
quando serve.
|
|
|
|
## 11. Punti aperti che il codice non risolve
|
|
|
|
Questi non sono dettagli implementativi: sono decisioni che spettano al cliente,
|
|
e finché non arrivano il codice si comporterà come qui dichiarato — non come
|
|
capita.
|
|
|
|
1. **Base giuridica per i dati sanitari (art. 9 GDPR).** Il campo `consenso` del
|
|
questionario riguarda le comunicazioni promozionali, non il trattamento di
|
|
dati di salute, e la finalità statistica è ulteriore rispetto a quella di
|
|
cura. Serve un consenso esplicito dedicato. **Non è un blocco allo sviluppo,
|
|
è un blocco alla messa in produzione con clienti veri.**
|
|
2. **Chi è il medico supervisore**, nominato nella specifica del questionario. Se
|
|
è un professionista sanitario, cambia la base giuridica utilizzabile
|
|
(art. 9.2.h), in meglio.
|
|
3. **La regola dei buchi nel questionario.** Oggi esiste in due versioni: il
|
|
prototipo annulla un sotto-dominio solo se non c'è **nessuna** risposta, il
|
|
motore usa il 40%. La piattaforma userà **una soglia unica, dichiarata nel
|
|
registro come parametro**, e in attesa della risposta assume il **40%**,
|
|
coerente con la regola dei sette assi.
|
|
4. **Il cambio di versione sul grafico** della progressione (§6).
|
|
5. **Il registro delle versioni**: cosa cambia a ogni incremento di
|
|
`quest_version` e `model_version`, altrimenti fra sei mesi «v1.3» non dirà
|
|
niente a nessuno.
|
|
6. **Nomi dei Livelli 1/2/3** — SPARK/FLUX/APEX o PULSE/FLOW/PEAK, aperti da
|
|
sessioni precedenti del cliente. Il livello è già una colonna del profilo.
|
|
7. **Export CSV/API di Stress Index**, l'unico canale la cui automazione non è
|
|
confermata. L'HRV entra nel motore come passthrough, quindi senza quel canale
|
|
va inserito a mano.
|
|
|
|
## 12. Decisioni prese, e da chi
|
|
|
|
| Decisione | Chi | Quando |
|
|
|---|---|---|
|
|
| Un record per compilazione + export Excel | Nicola | 21/08 |
|
|
| Versione del questionario su ogni record | Nicola | 21/08 |
|
|
| Formula cambiata → punteggi storici congelati | Nicola | 21/08 |
|
|
| Export pseudonimizzato, mappatura separata e ristretta | Nicola | 21/08 |
|
|
| Curva alcol 0-7 = 100 (soglia NIAAA/WHO), voluta | Nicola | 21/08 |
|
|
| La piattaforma vive dentro il sito, area riservata | Adriano | 13/08 |
|
|
| Ramo separato, nessun deploy | Adriano | 21/08 |
|
|
| Tre database separati | Adriano | 21/08 |
|
|
| Registro di misure invece di colonne fisse | Adriano | 21/08 |
|
|
| Il progetto è unico: un framework sui dati del cliente | Adriano | 21/08 |
|
|
| Calcolo in TypeScript, non Python | Tielogic (verificato sul codice) | 21/08 |
|