Ci siamo messi alla prova con il caricamento di documenti Word e fogli Excel dentro un LLM che gira in locale. Non è una funzione esotica, nei lavori d’ufficio è semplicemente irrinunciabile, perché il novanta per cento di quello che una persona vorrebbe far leggere a un modello sta dentro un .docx o un .xlsx, e l’alternativa è aprire il file, selezionare, copiare e incollare in chat a mano.
La funzione aggiunta a Private Mind è quella che ci si aspetta. Due pulsanti nella barra di input, “Word” ed “Excel”, un file che sale al backend, il testo che torna indietro e compare come una piccola etichetta sopra il campo di scrittura prima di premere invio. Quello che vale la pena raccontare non è il risultato, che è banale, ma il numero che ha deciso quasi tutto il resto: 8192.
Il numero che decide cosa può esistere
Private Mind gira interamente in locale, con un modello Gemma in formato GGUF. Il contesto configurato è di 8192 token, che sono all’incirca ventimila caratteri di testo italiano, meno se ci sono numeri e simboli. Dentro quello spazio devono starci il messaggio dell’utente, la cronologia della conversazione fino a quel punto e la risposta che il modello deve ancora generare. Un foglio Excel di venti colonne per cinquemila righe, che in un’azienda è un file di media dimensione, produce centinaia di migliaia di caratteri. Non entra per un ordine di grandezza, non per un margine.
Questa è la parte che di solito viene scoperta a valle, quando la funzione è già scritta e qualcuno prova ad allegare il file vero. Qui è stata scritta prima, in chiaro, nel documento di progettazione: questa funzione serve a leggere una tabella di spese, un listino, un estratto conto, un preventivo. Non serve ad analizzare un gestionale. Il tetto non è un limite temporaneo in attesa di hardware migliore, è la definizione di cosa la funzione è.
La differenza tra le due formulazioni sembra retorica e invece cambia il codice. Se il tetto è un problema da aggirare, si scrive una funzione che prova a leggere tutto e si spera che i file grossi siano rari. Se il tetto è lo scopo, i limiti finiscono nel file di configurazione, dichiarati accanto al motivo per cui esistono:
XLSX_MAX_BYTES = 10 * 1024 * 1024 # il file caricato
XLSX_MAX_UNCOMPRESSED_BYTES = 50 * 1024 * 1024 # zip bomb
XLSX_MAX_CHARS = 12000 # il testo estratto
XLSX_MAX_ROWS = 200 # righe per foglioL’ultimo è quello che il Word non ha, ed è il più interessante, perché nasce da un difetto che si vede solo se lo si cerca. Troncare a dodicimila caratteri secchi su della prosa funziona: si perde la coda del documento e l’utente lo capisce. Troncare a dodicimila caratteri secchi su un foglio di calcolo taglia a metà tabella, e se la cartella ha tre fogli il secondo e il terzo spariscono senza che nessuno lo dica, né all’utente né al modello. Il risultato non è un errore: è una risposta plausibile costruita su un terzo dei dati.
Con il tetto per foglio ogni foglio contribuisce le sue prime duecento righe, e sotto la tabella compare una riga scritta dentro al testo, non nell’interfaccia:
if troncato:
# Dichiarato dentro al testo, non solo nel chip: e' il modello che
# deve sapere di non avere tutto il foglio sotto gli occhi.
linee.append("")
linee.append(f"[foglio troncato: mostrate le prime {XLSX_MAX_ROWS} righe]")È una scelta piccola e ha un effetto sproporzionato. Un modello che sa di avere una vista parziale può dirlo nella risposta, un modello che non lo sa risponde con la sicurezza di chi ha letto tutto.
Dove Excel smette di somigliare a Word
Il caricamento del Word era arrivato per primo e sembrava un modello da ricopiare. Sono entrambi file zip pieni di XML, si aprono entrambi con una libreria Python, restituiscono entrambi del testo. Le somiglianze finiscono più o meno lì.
La prima trappola è che un foglio di calcolo non sa quanto è grande. La proprietà ws.max_row riporta la dimensione dichiarata nel file, non quella occupata dai dati: basta una colonna formattata e vuota perché un foglio con dodici righe di contenuto ne dichiari 1.048.576. Chiedere al file quanto è lungo e poi ciclare fin lì è la strada diretta verso un’attesa infinita su un listino di due pagine. Le righe si scorrono e ci si ferma da soli:
for riga in foglio.iter_rows(values_only=True):
celle = [_cella_a_testo(valore) for valore in riga]
if not any(celle):
continue # le righe vuote separano i blocchi, non portano dati
if len(righe) == XLSX_MAX_ROWS:
return righe, True
righe.append(celle)Le righe completamente vuote si scartano perché nei fogli veri sono ovunque (è così che le persone separano i blocchi), e insieme a loro si scartano le colonne che restano vuote in tutte le righe lette, altrimenti una colonna usata come spaziatura diventa una colonna di celle vuote in ogni riga della tabella.
La seconda trappola è più insidiosa. Quando si apre un file con openpyxl si può chiedere il valore delle celle oppure la formula che le calcola, e data_only=True è ovviamente la scelta giusta, visto che al modello serve sapere che il totale è 4.320 e non che quella cella contiene una somma. Il punto è che openpyxl non calcola niente: restituisce il valore che Excel ha salvato in cache l’ultima volta che ha aperto e salvato il file. Un file generato da uno script, esportato da un gestionale, prodotto da un altro programma e mai passato per Excel non ha nessuna cache, e ogni cella con una formula torna None.
Un foglio di soli totali calcolati sarebbe quindi arrivato al modello completamente vuoto, senza errori, senza avvisi, senza niente. L’utente avrebbe visto la sua etichetta con il nome del file e avrebbe ricevuto una risposta costruita sul nulla.
La soluzione non è elegante ma è onesta, quando l’estrazione non produce nemmeno un carattere, il file viene riaperto una seconda volta chiedendo stavolta le formule, per distinguere due situazioni che l’utente vive in modo diverso.
workbook = _carica(data, data_only=False)
for foglio in workbook.worksheets:
for riga in foglio.iter_rows(values_only=True):
if any(_e_formula(valore) for valore in riga):
raise XlsxReadError(
"Il foglio contiene solo formule mai calcolate: "
"aprilo con Excel e risalvalo."
)
raise XlsxReadError("Il file non contiene dati.")Il primo messaggio è azionabile: chi lo legge sa esattamente cosa fare. Il secondo chiude la questione. Un messaggio generico del tipo “impossibile leggere il file” avrebbe coperto entrambi i casi e non avrebbe aiutato in nessuno dei due.
Resta un caso scoperto, ed è scritto nella documentazione invece che nascosto: un file che contiene sia dati letterali sia qualche formula mai calcolata (tipicamente la colonna dei totali) viene letto con successo, e quelle celle arrivano vuote in silenzio. Con data_only=True una cella vuota e una formula non calcolata sono indistinguibili, e distinguerle richiederebbe di aprire ogni file due volte sempre, non solo nel caso degenere. In pratica succede di rado, perché Excel e LibreOffice ricalcolano e salvano tutta la cache quando salvano: o il file ha tutti i valori, o non ne ha nessuno. È un rischio accettato consapevolmente, non un difetto ignorato.
C’è poi una difesa che vale per entrambi i formati e che sta in un modulo condiviso, perché un .docx e un .xlsx visti da lì sono la stessa cosa. Il limite sui byte caricati non protegge da un archivio di trecento kilobyte che contiene un XML da un gigabyte, e quando il parser se ne accorge il danno è già fatto. La somma delle dimensioni dichiarate si legge dall’indice dello zip senza toccare i dati compressi:
with zipfile.ZipFile(io.BytesIO(data)) as archivio:
return sum(info.file_size for info in archivio.infolist())È una dichiarazione, quindi può mentire per difetto, ma una zip bomb mente per eccesso, dichiara il vero proprio perché il suo obiettivo è esplodere.
Quello che si perde
C’è una tentazione forte, quando si costruisce un ponte tra due formati, a far credere che il ponte non perda niente. Un foglio di calcolo che diventa una tabella di testo perde parecchio, e vale la pena elencarlo perché sembra tutto uguale all’originale e non lo è.
Si perde il formato numero, che in Excel è pura presentazione: una cella che sullo schermo mostra 30% ha come valore 0,3 e arriva così, una che mostra 1.234,50 € arriva come 1234.5. Si perdono le celle unite, che in lettura conservano il valore solo in alto a sinistra. Si perdono grafici, immagini, tabelle pivot, formattazione condizionale, colori e commenti. Le date invece vengono scritte in formato ISO, che ha il vantaggio di non essere ambiguo come 05/08/2026, dove nessuno sa se agosto è il mese o il giorno.
Il formato scelto per la resa è la tabella Markdown, il CSV sarebbe stato più compatto (niente barre verticali di riempimento, e su ottomila token la compattezza vale) ma avrebbe significato dare al modello la stessa struttura scritta in due modi diversi a seconda di quale pulsante l’utente aveva premuto. La coerenza ha vinto sulla densità.
L’ultima decisione è quella che sembra più discutibile e che probabilmente è la più difendibile, i pulsanti sono rimasti due, distinti, invece di diventare un unico pulsante “File” che smista in base all’estensione. Il pulsante unico è più pulito, e con tre o quattro formati diventerà la scelta giusta. Con due, però, avrebbe significato riscrivere qualcosa che già funziona come prezzo d’ingresso per aggiungere una funzione nuova. L’unificazione è annotata come lavoro futuro, con accanto il momento in cui avrà senso farla.
C’è una cosa che questo lavoro ha reso evidente e che vale oltre il caso specifico. La domanda difficile non era come leggere un file Excel, era decidere in anticipo quale versione ridotta della realtà valesse la pena mandare al modello, e assicurarsi che chi la riceve sappia che è ridotta. Un sistema che dichiara il proprio soffitto è utilizzabile. Uno che lo nasconde restituisce risposte che sembrano complete, e quelle costano molto più di un errore.
Se l’articolo ti è piaciuto restiamo in contatto su linkedin a: https://www.linkedin.com/in/andreatonin/
#contestolimitato #troncamentoesplicito #falliresilenzioso #estrazionetesto #tabelleMarkdown #cachedelleformule #zipbomb #decisionitecniche #astrazioneprematura #AIlocale
| Editorial Manager at Pills for Nerds | CTO & Technology Consultant |
Technology professional, editor and lifelong nerd with over 30 years of experience in the digital and innovation sectors. I work as a CTO and technology consultant, designing complex and scalable software ecosystems, and I am also active in IT education and professional training. Alongside my technology career, I serve as Editorial Manager and contributor at Pills for Nerds, online publication covering video games, game development, emerging technologies, artificial intelligence, digital culture and industry events. In my editorial role, I coordinate content planning and contribute articles, interviews, event reports and in-depth features. You can learn more about my consulting work at lucedigitale.com and connect with me on LinkedIn
















