-
Notifications
You must be signed in to change notification settings - Fork 1
Pipeline
Documento tecnico di riferimento. Ogni riga di codice che trasforma una transazione passa per questi stadi, in questo ordine.
FILE (CSV / XLSX)
β
βΌ
βββββββββββββββββββββ
β 1. CARICAMENTO β parse bytes, encoding, delimiter, header
ββββββββββ¬βββββββββββ
β
βΌ
ββββββββββββββββββββββββββ schema in DB?
β 2. SCHEMA DECISION ββββββββββββββββββββββββββββββββ
ββββββββββ¬ββββββββββββββββ β
β no (Flow 2) β sΓ¬ (Flow 1)
βΌ β
ββββββββββββββββββββββββββ β
β 2b. CLASSIFICAZIONE β LLM β DocumentSchema β
β DOCUMENTO [RF-01] β β
ββββββββββ¬ββββββββββββββββ β
ββββββββββββββββββββββββββββ¬ββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββ
β 3. NORMALIZZAZIONE β date, importi, ID SHA-256
β [RF-02] β tipo transazione
ββββββββββββββ¬ββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββ
β 4. DEDUP CHECK β salta tx giΓ importate
β β (ID calcolato al passo 3)
ββββββββββββββ¬ββββββββββββββ
β β nessuna LLM call su tx giΓ note
βΌ
ββββββββββββββββββββββββββββ
β 5. PULIZIA DESCRIZIONI β LLM estrae controparte
β [RF-02 pre-cat.] β (pagante o ricevente)
ββββββββββββββ¬ββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββ
β 6. RILEVAMENTO β accoppiamento importo+data
β GIROCONTI [RF-04] β o nome proprietario
ββββββββββββββ¬ββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββ
β 7. RICONCILIAZIONE β carta di credito β addebito
β CARTE [RF-03] β su conto corrente
ββββββββββββββ¬ββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββ
β 8. CATEGORIZZAZIONE β regole β LLM β fallback
β [RF-05] β
ββββββββββββββ¬ββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββ
β 9. PERSISTENZA DB β upsert idempotente
β [RF-06, RF-07] β
ββββββββββββββ¬ββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββ
β 10. REVISIONE MANUALE β to_review=True β utente
β + REGOLE [RF-08] β β riapplica regole
β β
β Pagine UI: β
β β’ Review β
β β’ Modifiche massive β
ββββββββββββββββββββββββββββ
Modulo: core/normalizer.py β load_raw_dataframe()
detect_encoding(raw_bytes)
ββ chardet β alias normalizzato (ascii β utf-8)
Per XLSX / XLS:
detect_best_sheet(workbook)
ββ esclude fogli con nome summary/totale/riepilogo
ββ punteggio = n_righe + (n_colonne_numeriche Γ 10)
pd.read_excel(sheet)
Per CSV / testo:
detect_delimiter(content)
ββ frequenza dei caratteri [, ; | TAB] β vince il piΓΉ frequente
detect_header_row(lines) β (skip_rows: int, certain: bool)
ββ prima riga con β₯ 2 campi non-numerici e non-vuoti
ββ certain=True β header trovato, usato silenziosamente
ββ certain=False β nessun match, fallback a 0 (ambiguo β UI chiede conferma)
pd.read_csv(sep=delimiter, skiprows=skip_rows)
Dopo il caricamento (sia CSV che Excel), vengono applicati i pre-processing Phase 0:
detect_and_strip_preheader_rows(df)
ββ conta celle non-null per riga β calcola mediana β soglia = mediana Γ 0.5
ββ righe contigue in cima con densitΓ < soglia β rimosse (max 20 righe / 10%)
ββ la prima riga non-sparsa diventa la nuova intestazione colonne
drop_low_variability_columns(df)
ββ per ogni colonna: nunique(col) / n_righe < 1.5% β colonna metadata
ββ colonne candidate rimosse (mai scende sotto 2 colonne)
Output: DataFrame ripulito + PreprocessInfo(skipped_rows, dropped_columns)
Modulo: core/normalizer.py β compute_header_sha256(), load_raw_head()
Per evitare di eseguire il classifier LLM ad ogni import dello stesso formato file, Spendify calcola lo SHA256 delle prime min(30, N) righe raw (prima di qualsiasi skip o pre-elaborazione) e lo memorizza insieme allo schema confermato.
compute_header_sha256(raw_bytes, filename, n=30)
ββ Excel: prime min(30, N) righe del foglio migliore β serializzate con "|" tra celle
ββ CSV: prime min(30, N) righe di testo grezze
ββ SHA256(contenuto.encode()) β hex string 64 caratteri
load_raw_head(raw_bytes, filename, n=10)
ββ carica N righe senza skiprows, senza preprocessing
ββ usato dall'UI di revisione schema per mostrare la struttura raw del file
Algoritmo al re-import:
- Calcola
header_sha256delle prime min(30, N) righe raw - Query DB:
SELECT * FROM document_schema WHERE header_sha256 = ? - Se trovato β usa lo schema salvato (include
skip_rows) β salta classifier e review UI - Se non trovato β Flow 2 (classificazione LLM + review UI obbligatoria)
PerchΓ© le prime righe? I file di estratto conto contengono tipicamente righe di intestazione istituzionale statiche (nome banca, numero conto, intervallo date) identiche in tutti gli export mensili dello stesso istituto. Queste righe sono un fingerprint affidabile del formato.
Rilevamento righe da saltare β flusso completo
detect_skip_rows(raw_bytes, filename) β (N, certain)
ββ CSV: detect_header_row(lines) β (N, certain)
ββ Excel: detect_header_row_excel(bytes) β (N, certain)
ββ stessa euristica CSV applicata ai valori delle celle
Al caricamento (prima del pulsante Elabora):
1. compute_header_sha256 β find_schema_by_header_sha256()
ββ HIT β skip_rows noto dallo schema; nessun input all'utente
ββ MISS β detect_skip_rows()
ββ certain=True β N usato silenziosamente (qualunque valore)
ββ certain=False β UI mostra number_input "Righe da saltare"
(default=0, utente puΓ² correggere)
skip_rows_override β process_file accetta skip_rows_override: int | None (dal form UI). Precede sempre known_schema.skip_rows. load_raw_dataframe accetta lo stesso parametro:
- CSV: sostituisce
detect_header_row() - Excel: passa
skiprows=Napd.read_excele saltadetect_and_strip_preheader_rows()
Modulo: core/orchestrator.py, core/classifier.py
_schema_is_usable(known_schema)
ββ richiede: date_col AND (amount_col OR (debit_col AND credit_col))
ββ se valido β salta classificazione
classify_document(df_raw, llm_backend)
FASE 0 β Python, deterministico
ββ Sinonimi colonne (nessuna LLM):
data_col β data, date, data operazione, buchungsdatum, β¦
amount_col β importo, amount, betrag, montant, β¦
debit/credit β dare/avere, addebiti/accrediti, uscite/entrate, β¦
description β descrizione, causale, memo, payee, β¦
FASE 0.5 β Ispezione segni
ββ Se amount_col semantica "neutral":
legge dati reali β se qualsiasi valore < 0 β invert_sign=False certo
FASE 1 β LLM, campi ambigui
input:
- nomi colonne
- prime 20 righe (dati sensibili redatti)
- risultati Fase 0 (come fatti certi)
output JSON:
{
doc_type: bank_account | credit_card | debit_card | prepaid_card | savings | unknown
date_format: strptime pattern (es. %d/%m/%Y)
sign_convention: signed_single | debit_positive | credit_negative
invert_sign: true/false (carte: spese tipicamente positive nel CSV)
internal_transfer_patterns: ["bonifico", "giroconto", β¦]
}
POST-LLM β Fase 0 prevale su LLM
ββ merge: i risultati certi della Fase 0 sovrascrivono il LLM
ββ safety: se doc_type = carta β invert_sign=True forzato
Output: DocumentSchema con mappatura colonne e convenzioni segno
Al primo import di un file sconosciuto (header SHA256 non trovato in DB), l'importazione si ferma sempre β indipendentemente dalla confidenza del classifier β e mostra all'utente un form di revisione con:
-
Preview raw: prime 10 righe del file senza preprocessing (via
load_raw_head()) - Campi dello schema: doc_type, account_label, colonna importo, data, segno, addebiti/accrediti, inverti segno
- Preview parsata: prime 8 transazioni elaborate con lo schema corrente β si aggiorna live ad ogni modifica
-
Pulsante "Conferma e importa": salva lo schema (con
header_sha256) e avvia l'import
Dal secondo import dello stesso formato, il header_sha256 viene trovato in DB e l'intero processo Γ¨ automatico (nessuna LLM call, nessuna UI).
Modulo: core/normalizer.py β _normalize_df_with_schema()
Per ogni riga del DataFrame:
parse_date_safe(valore, formato)
ββ prova formato schema β fallback a formati comuni IT/ISO/US
ββ None se fallisce (riga scartata)
apply_sign_convention(riga, convention)
ββ signed_single: usa amount_col cosΓ¬ com'Γ¨
ββ debit_positive: credito β debito (entrambi positivi nel CSV)
ββ credit_negative: credito as-is, β|debito|
parse_amount(valore)
ββ "1.234,56" (EU) β 1234.56
ββ "1,234.56" (US) β 1234.56
ββ "1234,56" β 1234.56
normalize_description(testo)
ββ NFC unicode + casefold + strip
compute_transaction_id(account_label, data_raw, importo_raw, descrizione_raw)
ββ SHA-256[:24] su valori GREZZI
ββ stabile tra versioni di normalizzazione
_infer_tx_type(importo, doc_type, descrizione, pattern_interni)
ββ matcha pattern_interni β internal_out (< 0) / internal_in (β₯ 0)
ββ carta di credito/debito/prepagata β card_tx
ββ altrimenti: income (β₯ 0) / expense (< 0)
Dedup intra-file:
Righe con stessa (account_label + data + importo + descrizione)
β somma importi, ricalcola hash
(evita doppio conteggio se la stessa tx appare piΓΉ volte nell'export)
Rimozione riga saldo carta:
remove_card_balance_row(txs, epsilon)
ββ rileva la riga il cui |importo| β Ξ£|altri importi|
ββ con owner_label β rinomina descrizione (il rilevamento giroconti la cattura)
ββ senza owner_label β rimuove la riga
Output: lista dict transazioni con tutti i campi canonici, raw_description immutabile
Modulo: db/repository.py β get_existing_tx_ids()
Gli ID transazione sono calcolati al passo 3 da valori grezzi, quindi la dedup avviene prima di qualsiasi chiamata LLM: nessun token sprecato su tx giΓ importate.
existing_ids = query DB WHERE id IN (tutti_gli_id_del_batch)
β filtra le tx giΓ presenti
β se tutte presenti β abort early (file giΓ importato, zero LLM calls)
β prosegue solo con le tx nuove
Modulo: core/description_cleaner.py β clean_descriptions_batch()
Estrae il nome della controparte dalla stringa grezza della banca.
Divisione per segno:
spese (importo < 0) β PASS 1: estrai DESTINATARIO
entrate (importo β₯ 0) β PASS 2: estrai MITTENTE
Privacy (obbligatoria prima di ogni LLM call):
redact_pii(descrizione, sanitize_config)
ββ Nomi proprietari β nomi fittizi plausibili (pool per lingua)
β IT: Carlo Brambilla, Marta Pellegrino, β¦
β EN: James Fletcher, Helen Norris, β¦
β DE: Klaus Hartmann, Monika Braun, β¦
β FR: Pierre Dumont, Claire Lebrun, β¦
ββ IBAN β <ACCOUNT_ID>
ββ PAN / carta (13-19 cifre) β <CARD_ID>
ββ Carta mascherata (****0178) β <CARD_ID>
ββ Codici transazione (CAU, NDS, CRO, RIF, TRNβ¦) β <TX_CODE>
ββ Codice fiscale β <FISCAL_ID>
LLM elabora descrizione redatta
restore_owner_placeholders(risultato_llm)
ββ riporta i nomi fittizi β nomi reali del proprietario
Cosa il LLM deve eliminare:
- Etichette tipo pagamento: POS, Bonifico, Virement, Lastschrift, SCT, wire transfer
- Marcatori beneficiario: Fv., F.V., Beg., BegΓΌnstigter, Pour, For the benefit of
- VOSTRA DISPOSIZIONE, Disposizione
- Importi e valute: "352,00 EUR", "9.798,76 EUR"
- Date: "23.12.2025", "2025-12-29", "29/10.41"
- Numeri carta, codici auth (CAU/NDS), riferimenti (RIF:/CRO:/INV/)
- Token ORD., codici paese (ITA)(FRA)
- CittΓ dopo il nome dell'azienda
- Frasi duplicate: "Rimborso spese rimborso spese" β "Rimborso spese"
Spese origine bancaria (nessuna controparte esterna):
β etichetta nella lingua configurata:
IT: "Interessi bancari", "Commissioni bancarie"
EN: "Bank fees", "Bank interest"
FR: "Frais bancaires", "IntΓ©rΓͺts bancaires"
DE: "BankgebΓΌhren", "Bankzinsen"
Fallback: se LLM fallisce β mantieni raw_description originale
Output: transaction["description"] aggiornato; raw_description mai modificato
Modulo: core/normalizer.py β detect_internal_transfers()
FASE 1 β Accoppiamento tra conti diversi
Per ogni coppia (i, j) con i.account_label β j.account_label:
amount_match = |importo_i + importo_j| β€ epsilon (0.01 β¬)
date_match = |data_i β data_j| β€ delta_days (5 gg)
Se entrambi:
high_symmetry = |importo_i + importo_j| β€ epsilon_strict (0.005 β¬)
AND |data_i β data_j| β€ delta_days_strict (1 gg)
Confidenza:
HIGH β keyword "bonifico/giroconto/transfer/β¦" in descrizione
MEDIUM β high_symmetry senza keyword
Se require_keyword_confirmation=True AND confidenza=MEDIUM:
β segna transfer_pair_id ma NON aggiorna tx_type (to_review)
Altrimenti:
β aggiorna tx_type: internal_out (uscita) / internal_in (entrata)
FASE 2 β Match per nome proprietario (tx non ancora accoppiate)
Per ogni tx senza coppia:
Se la descrizione contiene un nome proprietario
(regex con tutte le permutazioni dei token del nome):
β tx_type = internal_out / internal_in
β transfer_confidence = HIGH
(il proprietario Γ¨ la controparte: nessun accoppiamento necessario)
Parametri chiave:
| Parametro | Default | Significato |
|---|---|---|
tolerance |
0.01 β¬ | epsilon importo |
tolerance_strict |
0.005 β¬ | epsilon strict |
settlement_days |
5 gg | finestra date |
settlement_days_strict |
1 gg | finestra strict |
Modulo: core/normalizer.py β find_card_settlement_matches()
Abbina gli addebiti card_settlement (dal conto corrente) alle singole card_tx (dalla carta).
Per ogni addebito:
FASE 1 β Finestra temporale
ββ card_tx in [data_addebito β 45 gg, data_addebito + 7 gg]
FASE 2 β Sliding window (sottoinsiemi contigui)
Per ogni sottoinsieme contiguo [i..j]:
ββ verifica gap tra tx consecutive β€ max_gap_days (5 gg)
ββ somma = Ξ£ |importo[i..j]|
ββ Se |somma β importo_addebito| β€ epsilon β MATCH β
FASE 3 β Subset sum ai bordi (fallback)
ββ prende le k=10 tx prima + k=10 tx dopo la data addebito
ββ ricerca esaustiva su tutti i sottoinsiemi (n β€ 20 β 2^20 β 1M, sicuro)
ββ Se qualsiasi sottoinsieme somma all'importo β MATCH β
Se MATCH trovato:
β ReconciliationLink {settlement_id, matched_ids, delta, method}
β tx matched: reconciled=True
Modulo: core/categorizer.py β categorize_batch()
Elabora solo expense, income, card_tx, unknown. Salta giroconti e card_settlement.
Per ogni transazione β cascata a 4 livelli:
LIVELLO 0 β Regole utente (CategoryRule, ordinate per prioritΓ )
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Per ogni regola (in ordine di prioritΓ decrescente):
CategoryRule.matches(descrizione, doc_type):
ββ exact: descrizione.casefold() == pattern.casefold()
ββ contains: pattern.casefold() IN descrizione.casefold()
ββ regex: re.search(pattern, descrizione.casefold())
Se doc_type specificato nella regola β deve coincidere
VINCE la prima regola che fa match β
categoria, sottocategoria, confidenza=HIGH, sorgente=rule, to_review=False
LIVELLO 1 β Regole statiche per parola chiave (direction-aware)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Pattern hardcoded, separati per spese/entrate:
SPESE:
conad|coop|esselunga|lidl|carrefour|β¦ β Alimentari / Spesa supermercato
farmacia|pharma|β¦ β Salute / Farmaci
eni|shell|q8|tamoil|β¦ β Trasporti / Carburante
telepass|autostrad|β¦ β Trasporti / Parcheggio e ZTL
trenitalia|italo|frecciarossa|β¦ β Trasporti / Trasporto pubblico
enel|iren|a2a|hera|β¦ β Casa / Energia elettrica
netflix|spotify|amazon prime|β¦ β Svago / Streaming
ENTRATE:
stipendio|salary|busta paga|β¦ β Lavoro dipendente / Stipendio
pensione|inps rendita|β¦ β Prestazioni sociali / Pensione
β confidenza=HIGH, sorgente=rule, to_review=False
LIVELLO 2 β Modello ML (stub)
ββββββββββββββββββββββββββββββ
β restituisce None (riservato a sviluppi futuri)
LIVELLO 3 β LLM (due batch direzionali)
ββββββββββββββββββββββββββββββββββββββββ
Batch separati per spese ed entrate.
Privacy:
redact_pii(descrizione) prima di inviare al LLM
Payload per ogni tx:
{"amount": "β352.00", "description": "Notorious Cinemas"}
Risposta attesa:
{
"results": [
{
"category": "Svago e tempo libero",
"subcategory": "Cinema e teatro",
"confidence": "high",
"rationale": "Cinema"
},
β¦
]
}
Validazione risposta LLM:
ββ categoria + sottocategoria valida nella tassonomia?
ββ direzione corretta (spesa per spese, entrata per entrate)?
ββ Se sottocategoria non trovata β cerca categoria padre
ββ Se categoria non trovata β primo sub valido per quella categoria
ββ Se correzione necessaria β confidenza=low, to_review=True
Livelli confidenza:
HIGH β to_review=False
MEDIUM β to_review=False (sopra threshold 0.80)
LOW β to_review=True
LIVELLO 4 β Fallback (tutto fallisce)
ββββββββββββββββββββββββββββββββββββββ
spese: categoria=Altro, sub=Spese non classificate
entrate: categoria=Altro entrate, sub=Entrate non classificate
confidenza=LOW, sorgente=llm, to_review=True
Modulo: db/repository.py β persist_import_result()
Tutto in una transazione atomica, ogni operazione Γ¨ idempotente:
create_import_batch(sha256, filename, flow_used, n_transactions)
ββ se sha256 esiste giΓ β return existing (file giΓ importato)
upsert_document_schema(schema)
ββ se source_identifier esiste β aggiorna; altrimenti crea
Per ogni transazione:
upsert_transaction(tx)
ββ se tx.id esiste β skip (dedup finale)
ββ altrimenti: INSERT con tutti i campi
Per ogni riconciliazione:
create_reconciliation_link(settlement_id, detail_id, delta, method)
update tx: reconciled=True
Per ogni giroconto:
create_transfer_link(out_id, in_id, confidence, keyword_matched)
session.commit()
Pagina: ui/review_page.py, ui/rules_page.py
Auto-applicazione regole (ad ogni caricamento pagina Review):
apply_rules_to_review_transactions(session, user_rules)
ββ per ogni tx con to_review=True:
ββ prima regola che fa match β
categoria, sorgente=rule, to_review=False
Pulsante "βΆοΈ Esegui tutte le regole" (pagina Regole):
apply_all_rules_to_all_transactions(session, user_rules)
ββ applica tutte le regole a TUTTE le transazioni (non solo to_review=True)
ββ regole in ordine di prioritΓ decrescente, primo match vince
ββ restituisce (n_matched, n_cleared_review)
ββ richiede conferma tramite checkbox prima dell'esecuzione
Pulsante "Rielabora con LLM" (pagina Review):
_rerun_llm_on_review(engine)
ββ carica tutte le tx con to_review=True
(esclusi giroconti e card_settlement)
ββ ri-esegue clean_descriptions_batch()
ββ ri-esegue categorize_batch()
(salta le tx con category_source=manual o rule)
Correzione manuale:
update_transaction_category(tx_id, categoria, sotto)
ββ category_source=manual, to_review=False
Creazione regola:
create_category_rule(pattern, match_type, categoria, sotto, prioritΓ )
ββ si propaga immediatamente a tutte le tx simili
Bulk edit descrizione:
_apply_description_rule_bulk(engine, pattern, match_type, nuova_desc)
ββ aggiorna description per tutte le tx con raw_description matching
ββ ri-categorizza con LLM
Sorgente (category_source) |
Significato | to_review |
|---|---|---|
rule |
Regola utente o keyword statica | False |
llm confidenza HIGH/MEDIUM |
LLM sopra threshold | False |
llm confidenza LOW |
LLM sotto threshold | True |
manual |
Correzione manuale utente | False |
llm fallback (Altro) |
Tutto fallito | True |
| Parametro | Default | Dove si imposta |
|---|---|---|
llm_backend |
local_ollama |
Impostazioni |
description_language |
it |
Impostazioni |
confidence_threshold |
0.80 | ProcessingConfig |
tolerance (importo trasferimento) |
0.01 β¬ | ProcessingConfig |
settlement_days |
5 gg | ProcessingConfig |
window_days (riconciliazione carte) |
45 gg | ProcessingConfig |
require_keyword_confirmation |
True |
ProcessingConfig |
owner_names |
β | Impostazioni |
batch_size (LLM) |
20 tx/chiamata | categorize_batch() |