Il problema non e' Copilot, sono le sue tre condizioni
Copilot non funzionava per le mie riunioni. Non perche' il tool sia fatto male, ma perche' le mie riunioni non rispettano le sue regole.
Ospiti esterni senza licenza Microsoft. Webex invece di Teams. Webinar in cui sei spettatore e non organizzatore. Bastava che mancasse una delle tre condizioni ed ero fuori, e nella mia settimana tipo non ne reggeva praticamente mai nessuna.
C'e' poi una quarta condizione di cui non parla nessuno, ed e' quella che in azienda conta di piu': dove finiscono i dati della riunione. Una riunione contiene nomi di clienti, numeri, fornitori, strategie. Mandare quel testo a un servizio esterno e' una scelta che qualcuno deve giustificare, e in un settore regolamentato quella scelta spesso non si puo' proprio fare.
Quindi ho smesso di aspettare un aggiornamento e ho costruito qualcosa che funziona a prescindere da chi inviti, da quale piattaforma usi e da quali licenze hanno gli altri, e che non manda fuori una sola parola. Questo e' il racconto, dai primi errori all'architettura finale. Il codice e' pubblico, il link e' in fondo.
Il primo tentativo: uno script che funzionava solo sulla mia macchina
Il punto di partenza era semplice: prendere il video della riunione, estrarre l'audio, trascriverlo, darlo in pasto a un modello.
Ho scritto uno script Python. Funzionava sul mio ambiente di sviluppo. Non funzionava sul server. Non funzionava su Windows. Smetteva di funzionare appena cambiavo versione di Python.
Il problema classico: dipendenze implicite, librerie native che si comportano in modo diverso a seconda del sistema operativo, CUDA richiesta dove non c'era nessuna GPU. Faster-Whisper, Pyannote, ffmpeg, ognuna con le sue aspettative sull'ambiente.
La soluzione non era sistemare le dipendenze una per una. Era isolare l'ambiente una volta sola e non toccarlo piu'.
Docker, e la scelta di restare su CPU
Ho messo tutto dentro un container Linux. Le dipendenze diventano un problema del Dockerfile, non della macchina che lo ospita.
La scelta importante di questa fase e' stata un'altra: CPU-only.
Faster-Whisper gira bene su CPU con il modello large-v3. E' lento sui file molto lunghi, ma e' stabile. Zero dipendenza da CUDA, zero problemi sulle macchine senza scheda video. Quando stai costruendo qualcosa che deve girare in produzione senza che nessuno lo guardi, la stabilita' vale molto piu' della velocita'.
Stesso ragionamento per Pyannote (speaker-diarization-3.1), che riconosce chi parla: anche lui su CPU, nessuna GPU richiesta.
Il container riceveva il video via API, estraeva l'audio con ffmpeg, trascriveva e identificava gli speaker. In uscita, una trascrizione con l'indicazione di chi ha detto cosa. Funzionava. Era lento sui file lunghi. Ma funzionava.
Il difetto del batch: tre giga di modelli ricaricati a ogni richiesta
La versione batch aveva un problema strutturale: a ogni richiesta i modelli venivano caricati in memoria da zero.
Faster-Whisper large-v3 pesa circa 3 GB, e Pyannote aggiunge il suo. Ogni chiamata pagava il costo del caricamento prima ancora di cominciare a trascrivere.
La soluzione era tenere i modelli in RAM fra una chiamata e l'altra. Ho riscritto tutto come microservizio FastAPI usando il pattern lifespan:
@asynccontextmanager
async def lifespan(app: FastAPI):
# avvio: i modelli si caricano una volta sola
app.state.whisper_model = WhisperModel("large-v3", device="cpu")
app.state.diarization_pipeline = Pipeline.from_pretrained(...)
yield
# spegnimento: cleanup
app = FastAPI(lifespan=lifespan)
I modelli si caricano all'avvio del container e restano in memoria. Le chiamate successive partono subito.
Banale in teoria, non ovvio quando ci arrivi: questo pattern sta nella documentazione di FastAPI, ma lo capisci davvero solo dopo un'ora passata a guardare ogni singola richiesta pagare il cold start.
L'analisi: perche' un LLM locale e non una API
Avevo la trascrizione. Mi serviva l'analisi: riassunto, action item, partecipanti. La scelta era fra una API esterna e un modello locale.
Ho scelto locale per un motivo preciso, ed e' lo stesso per cui probabilmente sei arrivato fin qui: le riunioni contengono dati sensibili. Clienti, fornitori, strategie, numeri. Con un modello locale quei dati non escono mai dalla macchina, e non c'e' nessuna conversazione da avere con il cliente, con il legale o con chi si occupa di protezione dei dati.
Ho separato il pezzo di analisi in un secondo container, LLM Engine, che parla con l'Audio API via HTTP sulla rete interna di Docker Compose. Nessuna porta esposta verso l'esterno per la comunicazione fra i due.
Perche' Qwen 2.5 3B via Ollama
I modelli piccoli, da uno a tre miliardi di parametri, hanno limiti veri sull'analisi complessa. Ma qui il compito e' strutturato: data questa trascrizione, produci riassunto, action item e partecipanti in JSON. Per un compito cosi' un 3B con un prompt costruito bene basta.
Qwen 2.5 3B su Ollama gira su CPU di consumo in tempi accettabili, e sul server di produzione non ho una GPU. Configurazione per spremere il throughput:
OLLAMA_NUM_PARALLEL=4
OLLAMA_MAX_LOADED_MODELS=1
Riunioni lunghe: Map-Reduce
Un'ora di riunione produce una trascrizione che supera senza sforzo la finestra di contesto di un modello da 3B.
La trascrizione viene spezzata in blocchi da diecimila caratteri. Ogni blocco viene analizzato in parallelo, con un semaforo di concorrenza a tre per non saturare Ollama. I risultati parziali vengono poi aggregati in un report finale.
Trascrizione (50k caratteri)
|-- Blocco 1 (10k) --> analisi parziale
|-- Blocco 2 (10k) --> analisi parziale --> Reduce --> Report finale
|-- Blocco 3 (10k) --> analisi parziale
|-- ...
Non e' la soluzione piu' elegante. E' quella che funziona con un modello locale da 3B su CPU.
L'architettura finale
Video in ingresso
|
+--> Audio API (container 1)
| |- ffmpeg: estrazione audio
| |- Faster-Whisper large-v3: trascrizione (CPU)
| +- Pyannote 3.1: riconoscimento speaker (CPU)
|
+--> LLM Engine (container 2)
|- Qwen 2.5 3B via Ollama
|- Map-Reduce: blocchi da 10k, concorrenza 3
+- JSON in uscita: riassunto, action item, partecipanti
|
+--> webhook Make --> notifica Telegram
Endpoint principali dell'Audio API:
| Endpoint | Cosa fa |
|---|---|
POST /api/v1/process | Pipeline completa, dal video al report |
POST /api/v1/transcribe-only | Solo trascrizione, senza analisi |
POST /api/v1/summarize-only | Analisi su una trascrizione che hai gia' |
GET /api/v1/recover-final-report | Recupero del report dai log parziali |
L'output e' JSON, quindi si attacca a Power Platform, a un CRM o a qualunque flusso aziendale senza lavoro aggiuntivo.
Cosa rifarei uguale, e cosa no
Uguale:
- Docker dall'inizio, non come ripiego quando le dipendenze esplodono
- CPU-only come scelta di stabilita', non di risparmio
- Due container separati: dividere Audio API e LLM Engine ha reso semplice sia il debug sia gli aggiornamenti
- Il pattern
lifespandi FastAPI, banale da scrivere ed essenziale per le prestazioni
Diversamente:
- La Map-Reduce funziona, ma produce analisi parziali che ogni tanto si contraddicono nella fase di aggregazione. Con piu' tempo proverei un modello piu' grande, oppure una divisione in blocchi per argomento invece che per dimensione
- I path scritti a mano nel codice originale erano un problema. Li ho sistemati prima di pubblicare, ma andavano evitati dall'inizio
Il risultato
Uno strumento che funziona su qualsiasi riunione e qualsiasi piattaforma, senza dipendere dalle licenze di altri e senza che i contenuti escano dalla macchina.
L'ho usato internamente per mesi prima di pubblicare il codice, e i casi d'uso che non avevo previsto sono arrivati da soli: riunioni in cui ero solo spettatore e non avevo accesso agli strumenti dell'organizzatore, webinar tecnici da cui volevo tirare fuori gli action item, incontri con ospiti che un account Microsoft non ce l'hanno proprio. Tutti fuori dai confini di Copilot, tutti dentro i confini di questo.
Il codice
Il progetto e' pubblico su GitHub: holtibitri-arch/AI-Meeting-Assistant. Dentro trovi README, istruzioni di setup e la configurazione Docker Compose. Se lo provi e ti si rompe qualcosa, scrivimi.
Domande frequenti
Si puo' trascrivere una riunione senza mandare l'audio nel cloud?
Si'. Trascrizione, riconoscimento di chi parla e analisi girano tutti su container locali: Faster-Whisper per il testo, Pyannote per gli speaker, un LLM servito da Ollama per il riassunto. I due container si parlano su rete interna Docker Compose e non espongono porte verso l'esterno, quindi l'audio e la trascrizione non lasciano mai la macchina.
Serve una GPU per far girare Whisper e Pyannote?
No. Faster-Whisper large-v3 e Pyannote speaker-diarization-3.1 girano su CPU. E' piu' lento sui file lunghi, ma e' stabile e non dipende da CUDA, che su un server senza scheda video e' il primo motivo per cui una pipeline del genere non parte nemmeno.
Che differenza c'e' rispetto a Microsoft Copilot per le riunioni?
Copilot richiede tre condizioni insieme: che tu sia l'organizzatore, che tutti i partecipanti abbiano la licenza e che la riunione giri su Teams. Se ne manca una sei fuori. Una pipeline locale lavora sulla registrazione, quindi non dipende da chi organizza, da quale piattaforma si usa o da quali licenze hanno gli altri.
Come si analizza una riunione di un'ora se il modello ha una finestra di contesto piccola?
Con una Map-Reduce: la trascrizione viene divisa in blocchi da diecimila caratteri, ogni blocco viene analizzato in parallelo con un semaforo di concorrenza a tre per non saturare Ollama, e i risultati parziali vengono aggregati in un report unico.
Perche' un modello da 3 miliardi di parametri e non uno piu' grande?
Perche' il compito e' strutturato e non creativo: data una trascrizione, produrre riassunto, action item e partecipanti in JSON. Per un compito cosi' un 3B con un prompt costruito bene basta, e gira su CPU in tempi accettabili su un server che non ha GPU.
Funziona con Teams, Zoom e Webex?
Si', perche' non si aggancia alla piattaforma: parte dalla registrazione. Qualunque sistema produca un file audio o video utilizzabile va bene, e questo e' esattamente il motivo per cui e' nato.