TL;DR: Assistente al cittadino che risponde esclusivamente sui contenuti certificati dell'ente, esposti tramite Model Context Protocol (MCP). Il contenuto non sta nel prompt del modello, e non sta nemmeno in due posti: nasce nel CMS dell'ente e arriva al modello attraverso un corpus con vocabolario, identificatori, versioni e impronte.
Prototipo dimostrativo sul Comune di Paperopoli (fittizio). I modelli AI (Gemini e Claude) rispondono solo sulla base di quei contenuti, citando la sezione da cui prendono ogni informazione.
Il corpus non è legato a questo CMS: parla il vocabolario del modello Comuni, che prescrive a ogni ente italiano quali attributi deve avere una scheda servizio. Per attaccare un altro gestore di contenuti si scrive un adattatore, anche in un altro linguaggio, e il resto non cambia.
| Progetto | Ruolo |
|---|---|
ChattyDuck.Corpus |
Servizio del corpus (porta 5200): custodisce i contenuti certificati di uno o più enti e li espone. Non conosce alcun CMS. Schema pubblicato su /schema/corpus-1.0.json. |
Duckburg.Ingestione |
Adattatore fra il CMS di Paperopoli e il corpus (porta 5250). È il progetto che si riscrive per ogni CMS. Temporizzato, con innesco manuale su POST /esegui. |
ChattyDuck.McpServer |
Server MCP dell'ente (porta 5000). Legge il corpus e lo espone ai modelli con gli strumenti cerca, scheda, elenca. Trasporto Streamable HTTP su /mcp. |
Duckburg.Portal |
Portale del Comune (porta 5100), Razor Pages in stile Designers Italia. Assistente come widget su tutte le pagine e a pagina intera su /assistente. Il "cittadino informato". |
Duckburg.ServiziOnline |
Portale dei servizi online (porta 5300), stesso layout e assistente del Portal. Il "cittadino attivo" (PNRR Missione 1): Area personale accessibile solo con SPID/CIE tramite Duckburg.Identity. |
Duckburg.Identity |
Sistema di accesso del Comune: Relying Party OpenID Connect Federation 1.0 (profilo SPID/CIE), fork di SPID-CIE-OIDC in stile Paperopoli. Entity id http://identity.paperopoli.test:8001; dopo il login rimanda al portale chiamante con un token firmato (SSO). |
Duckburg.Valutazione |
Modulo di valutazione (porta 5400): wrapper web del validatore ufficiale del modello Comuni. |
Duckburg.DockerLaunch |
Helper di avvio: esegue docker compose up -d --build per la federazione SPID/CIE prima degli altri progetti. Se Docker non è disponibile, avvisa e prosegue. |
ChattyDuck.Quack |
Razor Class Library dell'assistente: UI chat, endpoint POST /chat, GET /chat/usage, GET /debug/tools, orchestrazione dei modelli. |
ChattyDuck.Models |
Implementazioni intercambiabili di IModelService (Gemini, Claude), tracking dei consumi. |
Duckburg.Portal.Cms |
Modello dati del CMS: entità, DbContext e opzioni. Ha due consumatori, il portale che serve le pagine e l'adattatore che le legge. |
ChattyDuck.Mcp |
Client MCP verso il server MCP, usato dal ponte Gemini. |
Principio architetturale: il system prompt definisce solo il comportamento del modello; i contenuti risiedono unicamente nel corpus. Il corpus non è la proiezione di un CMS: parla il vocabolario del modello Comuni, che prescrive a ogni ente italiano quali attributi deve avere una scheda servizio. A monte, ogni CMS ha il proprio adattatore; a valle, nessuno sa più da dove i contenuti vengano. Vedi Dal CMS al corpus.
CMS del cliente ◀──legge── Ingestione ──scrive──▶ Corpus ◀──legge── MCP Server ◀──── ChattyDuck
(una per CMS) ◀──── Anthropic
◀──── client MCP di terzi
Ogni freccia indica chi chiama chi. L'ingestione è l'unica che si sveglia da sola, su un proprio temporizzatore. Il server MCP ha tre clienti e due non sono nostri: per questo dev'essere pubblico.
I due modelli si collegano al corpus in modo diverso:
- Gemini: non supporta MCP nativamente: il portale fa da bridge, traducendo i tool MCP in
functionDeclarationsed eseguendo le chiamate comefunctionResponse. - Claude: supporta MCP nativamente tramite il connettore della Messages API (parametro
mcp_servers, header betamcp-client-2025-11-20): si collega direttamente all'endpoint pubblico del server, senza bridge.
# 1. Configurazione: copia i template e inserisci le API key
Copy-Item Duckburg.Portal�ppsettings.template.json Duckburg.Portal�ppsettings.json
Copy-Item ChattyDuck.Corpus�ppsettings.template.json ChattyDuck.Corpus�ppsettings.json
Copy-Item Duckburg.Ingestione�ppsettings.template.json Duckburg.Ingestione�ppsettings.json
Copy-Item ChattyDuck.McpServer�ppsettings.template.json ChattyDuck.McpServer�ppsettings.json
# la stessa chiave in Corpus:Enti[0].ChiaveIngestione e Ingestione:ChiaveCorpus
# 2. Portale e CMS (porta 5100): al primo avvio crea il database e i contenuti
dotnet run --project Duckburg.Portal
# 3. Corpus (porta 5200) e adattatore (porta 5250)
dotnet run --project ChattyDuck.Corpus
dotnet run --project Duckburg.Ingestione
# 4. Server MCP (porta 5000)
dotnet run --project ChattyDuck.McpServerL'ordine non conta. I servizi partono comunque e si allineano da soli: finché il
corpus non è pronto, il server MCP risponde 503 con stato allineamento e i suoi
strumenti dicono al modello di avvisare l'utente invece di rispondere sul vuoto. Un
elenco vuoto il modello lo leggerebbe come "questa informazione non esiste".
L'adattatore rilegge il CMS ogni 15 minuti. Per non aspettare dopo una modifica:
curl -X POST http://localhost:5250/esegui # rilegge il CMS
curl -X POST http://localhost:5000/corpus/reload # riallinea il server MCPIn Visual Studio i profili di avvio multiplo sono in DuckburgSmartCity.slnLaunch:
- Assistente: corpus, adattatore, server MCP e portale. È quello che serve per l'assistente.
- Tutto (Docker + servizi): aggiunge la federazione SPID/CIE in Docker, identità, servizi online e valutazione.
Verifica senza API key:
GET http://localhost:5000/health: stato del corpus, contenuti e sezioni indicizzatiGET http://localhost:5250/health: esito dell'ultima ingestioneGET http://localhost:5100/debug/tools: strumenti MCP visibili al ponte GeminiGET http://localhost:5100/chat/usage: stato dei consumi per modello
I file appsettings*.json reali sono esclusi dal versioning: nel repository ci sono solo i template. In alternativa: variabili d'ambiente o dotnet user-secrets.
Portal
| Chiave | Variabile d'ambiente | Note |
|---|---|---|
Gemini:ApiKey |
Gemini__ApiKey |
Google AI Studio, free tier |
Gemini:Model |
- | default gemini-2.5-flash |
Anthropic:ApiKey |
Anthropic__ApiKey |
Anthropic Console, a consumo |
Anthropic:Model |
- | es. claude-haiku-4-5 |
Anthropic:McpEndpoint |
Anthropic__McpEndpoint |
URL pubblico del server MCP: deve essere raggiungibile dai server Anthropic (localhost non funziona) |
Registry:McpEndpoint |
- | endpoint del bridge Gemini (default http://localhost:5000/mcp) |
Cms:Database:Provider |
- | Sqlite (default), SqlServer, PostgreSql, MySql, Oracle; vedi CMS del portale |
Cms:Admin:Password |
Cms__Admin__Password |
password dell'area di amministrazione (l'utente è in Cms:Admin:Username) |
Corpus e Ingestione
| Chiave | Progetto | Note |
|---|---|---|
Corpus:Database:Provider |
Corpus | Sqlite (default), SqlServer, PostgreSql, MySql |
Corpus:Enti[].Id |
Corpus | ente servito; ognuno ha la propria chiave di scrittura |
Corpus:Enti[].ChiaveIngestione |
Corpus | l'adattatore di quell'ente non può toccare il corpus di un altro |
Corpus:ChiaveLettura |
Corpus | vuota significa lettura aperta: il corpus contiene solo ciò che l'ente pubblica già |
Ingestione:UrlCorpus |
Ingestione | dove pubblicare l'istantanea |
Ingestione:ChiaveCorpus |
Ingestione | deve corrispondere a ChiaveIngestione dell'ente |
Ingestione:IntervalloMinuti |
Ingestione | ogni quanto rileggere il CMS (default 15, 0 per disattivare) |
Ingestione:Cms |
Ingestione | provider e stringa di connessione del CMS di partenza |
Server MCP
| Chiave | Note |
|---|---|
Corpus:Url |
servizio del corpus (default http://localhost:5200) |
Corpus:Ente |
ente da servire (default comune-paperopoli) |
Corpus:Chiave |
chiave di lettura, se il corpus la richiede |
Corpus:RiallineamentoMinuti |
intervallo di riallineamento al corpus (default 5, 0 per disattivare) |
Registry:AccessToken |
opzionale; se valorizzato richiede Authorization: Bearer <token> o X-Access-Token |
Il percorso Claude e i client MCP esterni richiedono un endpoint raggiungibile da Internet:
- Sviluppo:
ngrok http 5000→https://<sottodominio>.ngrok-free.dev/mcp(da riportare inAnthropic:McpEndpoint; cambia a ogni riavvio del tunnel). - Produzione: dominio dedicato dietro reverse proxy, ambienti separati.
L'assistente è disponibile su http://localhost:5100 (widget) e su /assistente (pagina intera), con selettore del modello.
Client MCP esterni: qualunque client MCP può consumare il corpus. La voce "Configura il tuo chatbot" in /assistente mostra la configurazione:
{
"mcpServers": {
"comune-paperopoli": {
"type": "http",
"url": "https://<dominio-pubblico>/mcp"
}
}
}Verifica funzionale: domande di controllo, valide su ogni client:
- "Quanto costa il servizio mensa?" → la scheda
servizio:mensa-trasporto-scolastico, sezione Costi - "Quali eventi ci sono?" → l'elenco degli eventi, con quelli passati segnati come non validi
- "Quando scade la prima rata della TARI?" → 30 aprile, citando la sezione da cui viene
- "Che giorno passa l'umido nel quartiere Vesuvio?" → "Questa informazione non è nelle fonti."
- La stessa domanda su client diversi produce la stessa risposta, ancorata al corpus
Gli id delle sezioni sono nella forma contenuto#chiave, per esempio
servizio:tari#tempi-e-scadenze. Sono stabili: cambiarli spezzerebbe le citazioni già
date, ed è il motivo per cui li compone il corpus e non l'adattatore.
Test diretto del tool cerca, senza modelli:
curl -s http://localhost:5000/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"cerca","arguments":{"query":"prima rata TARI"}}}'Il flusso di login usa la federazione OIDC italiana emulata in locale con l'infrastruttura ufficiale AgID (italia/spid-cie-oidc-django in Docker):
Browser -> Duckburg.ServiziOnline (5300) -> /accedi
-> Duckburg.Identity (identity.paperopoli.test:8001) [RP federato]
-> OP SPID (trust-anchor.paperopoli.test:8000) o OP CIE (cie-provider.paperopoli.test:8002)
<- callback OIDC su Identity -> token SSO firmato -> /auth/callback su ServiziOnline
-> sessione cookie -> /area-personale
Setup (una volta sola):
# 1. Hostname locali (PowerShell da amministratore)
.\scripts\add-hosts.ps1
# 2. Chiavi demo del RP (gitignored)
.\scripts\setup-secrets.ps1
# 3. Federazione locale: Trust Anchor + OP CIE + Duckburg.Identity
docker compose up --buildPoi, dal repo:
dotnet run --project Duckburg.ServiziOnline # porta 5300Su http://localhost:5300 la card "Area personale del cittadino" chiede il login (credenziali demo user / oidcuser o admin / oidcadmin).
Le pagine Django della federazione non sono del Comune di Paperopoli: rappresentano gli enti centrali dello Stato fittizio di Palmipedia, con brand e palette distinti (override in infra/*/templates/), sullo stesso modello reale SPID/CIE:
| Servizio | Ruolo reale (Italia) | Equivalente Palmipedia | Stile |
|---|---|---|---|
trust-anchor.paperopoli.test (onboarding) |
AgID, autorità di federazione | AIDP: Agenzia per l'Identità Digitale di Palmipedia | navy/argento/oro, sigillo di Stato |
trust-anchor.paperopoli.test (login SPID locale) |
Gestore SPID privato (es. Poste, Aruba) | BeccoID S.p.A.: soggetto privato accreditato AIDP | viola/giallo, fumetto "da startup" |
cie-provider.paperopoli.test (login CIE) |
Istituto Poligrafico e Zecca dello Stato, per conto del Ministero dell'Interno | IPZP: Istituto Poligrafico e Zecca di Palmipedia, per conto del Ministero dell'Interno di Palmipedia | verde/oro, medaglione a conio |
Il test end-to-end della federazione (login CIE, refresh, logout via curl) è in e2e_test.sh.
I dump del Trust Anchor registrano il RP con entity id http://identity.paperopoli.test:8001; le chiavi private demo vivono in secrets/ (solo il .sample.json è versionato).
Monitoraggio dei consumi: il pannello "Limiti di utilizzo" sotto la chat riporta consumo e quota residua:
- Claude: valori reali dagli header
anthropic-ratelimit-*, intercettati da unDelegatingHandler(AnthropicRateLimitHandler). - Gemini: token da
usageMetadatadelle risposte; quota residua stimata localmente (Google non la espone via API, verificabile in AI Studio).
Il tracker (ModelUsageTracker) è in memoria e si azzera al riavvio.
Risposte non ancorate: se un client risponde con normativa nazionale generica anziché con i dati del corpus, non inserire i dati nel prompt: rinforzare le regole di comportamento e mostrare i passaggi recuperati accanto alla risposta (la UI lo fa già con il riquadro "Fonti recuperate").
Il portale (Duckburg.Portal) include un CMS completo: tutti i contenuti (servizi, novità, organi, uffici, luoghi, eventi, documenti, pagine, menu e impostazioni del sito) sono nel database e gestibili da un'area di amministrazione.
- Area admin:
http://localhost:5100/admin(credenziali inCms:Admin, defaultadmin/paperopoli). Dashboard, elenco e CRUD per ogni tipo di contenuto, allineati all'architettura dell'informazione del modello Comuni. - Contenuti di default: i contenuti seed in stile Paperopoli sono marcati
IsDefault. ConCms:ProtectDefaultContent: truenon sono modificabili né eliminabili (in sola lettura nell'admin, guardia lato server). Impostandofalsediventano gestibili. - Seed: al primo avvio, se
Cms:SeedOnStartup: true, lo schema viene creato e popolato. Idempotente: agisce solo su database vuoto. - Libreria media: upload di immagini e allegati da
/admin/media; i file finiscono inDuckburg.Portal/wwwroot/media(escluso dal versioning). - Posta in arrivo: le interazioni raccolte dalle pagine pubbliche finiscono anch'esse nel database e si consultano dall'admin: prenotazioni appuntamento (
/admin/appuntamenti), segnalazioni di disservizio (/admin/segnalazioni), valutazioni di chiarezza (/admin/valutazioni, alimentate daPOST /api/valutazione).
Il provider si cambia da appsettings.json senza toccare il codice:
Esempi di stringa di connessione per gli altri motori:
- PostgreSql:
Host=localhost;Database=paperopoli;Username=postgres;Password=... - SqlServer:
Server=localhost;Database=paperopoli;Trusted_Connection=True;TrustServerCertificate=True - MySql:
Server=localhost;Database=paperopoli;User=root;Password=... - Oracle:
User Id=paperopoli;Password=...;Data Source=localhost:1521/XEPDB1
Lo schema è creato con EnsureCreated (provider-agnostico) e le liste sono serializzate in JSON su colonne testo, così il modello resta portabile fra i motori.
I contenuti pubblicati nel CMS diventano il corpus su cui l'assistente risponde. Fra i due c'è un confine netto, ed è quello che permette di attaccare un CMS qualsiasi.
Il modello. Un contenuto porta tre cose distinte:
- attributi: fatti tipizzati. Una scadenza è una data, una tariffa è una tabella di importi, "prenotabile" è un booleano. Quanto costa la mensa smette di essere una ricerca testuale sperata e diventa la lettura di un attributo.
- sezioni: prosa citabile, ognuna con id, versione e impronta SHA-256. È l'unità che una risposta indica come fonte.
- relazioni: il grafo. Da un ufficio si risale ai servizi che eroga, alle novità che lo riguardano, ai documenti che pubblica.
Più la validità temporale, che rende riconoscibile un contenuto scaduto invece di lasciarlo citare come attuale.
L'adattatore. Duckburg.Ingestione è l'unico progetto che conosce le tabelle di partenza. Legge il CMS a intervalli, traduce nel vocabolario del corpus e pubblica un'istantanea intera, non le differenze: un adattatore che gira ogni ora non sa cosa è cambiato, sa solo com'è adesso.
Per un altro CMS si scrive un altro adattatore, anche in un altro linguaggio: il contratto è HTTP più uno schema JSON pubblicato, non un assembly .NET.
cp Duckburg.Ingestione/appsettings.template.json Duckburg.Ingestione/appsettings.json
dotnet run --project ChattyDuck.Corpus # porta 5200
dotnet run --project Duckburg.Ingestione # porta 5250
curl -X POST http://localhost:5250/esegui # senza aspettare il giroLa validazione sta nel corpus, non negli adattatori, perché il corpus è uno e gli adattatori sono tanti. Distingue errori bloccanti (identificatori duplicati, impronte incoerenti, tipi sconosciuti) da avvisi (relazioni pendenti, chiavi fuori vocabolario): rifiutare un'istantanea intera per un avviso lascerebbe un ente senza assistente per un dettaglio.
Chi scrive un adattatore può provarlo senza toccare il corpus vivo:
curl -X POST http://localhost:5200/api/enti/<ente>/istantanea/verifica -H "X-Corpus-Key: <chiave>" --data-binary @istantanea.jsonIl recupero è in due stadi. Prima si cerca la scheda, valutando titolo, tipo, fatti e prosa come un documento unico; poi, dentro le schede migliori, si scelgono le sezioni pertinenti e si restituiscono raggruppate. L'unità di significato è il contenuto, non il frammento: una sezione come "Costi: dipende dall'ISEE" non dice a quale servizio appartenga, e ordinare frammenti isolati fa vincere la lunghezza invece della pertinenza.
Il server MCP espone tre strumenti invece di uno:
| Strumento | Quando |
|---|---|
cerca |
domande in linguaggio naturale; restituisce schede con fatti e sezioni |
scheda |
una scheda intera per id, con collegamenti |
elenca |
"quali eventi ci sono", "quali uffici esistono"; nessuna ricerca per parole |
curl -s http://localhost:5000/health
# {"stato":"pronto","pronto":true,"messaggio":"Corpus allineato.",
# "ente":"comune-paperopoli","contenuti":67,"sezioni":201, ...}È il caso per cui il corpus esiste come servizio separato. Serve solo un adattatore: un programma qualsiasi, in qualsiasi linguaggio, che legga il gestore di contenuti di partenza e pubblichi un'istantanea. Non deve referenziare nulla di questo repository.
Il contratto è lo schema JSON servito dal corpus stesso:
curl -s http://localhost:5200/schema/corpus-1.0.jsonLe motivazioni delle scelte stanno dentro le descrizioni dello schema, non solo la forma dei campi.
Gli endpoint che un adattatore usa:
| Metodo | Percorso | A cosa serve |
|---|---|---|
POST |
/api/enti/{ente}/istantanea/verifica |
prova un'istantanea senza pubblicarla |
PUT |
/api/enti/{ente}/istantanea |
pubblica, sostituendo per intero |
GET |
/api/enti/{ente}/istantanea |
rilegge l'ultima pubblicata, con ETag |
GET |
/api/enti/{ente}/versioni |
la storia delle pubblicazioni |
La chiave va nell'intestazione X-Corpus-Key ed è per ente: non permette di scrivere sul
corpus di un altro.
Si comincia dalla verifica. L'endpoint di prova restituisce l'elenco puntuale dei problemi, con il percorso di ognuno, senza toccare il corpus vivo:
{
"valida": false,
"errori": [
{ "percorso": "contenuti[3].sezioni[1].hash",
"messaggio": "Impronta dichiarata non corrispondente al testo." }
],
"avvisi": [
{ "percorso": "contenuti[7].relazioni[0].verso",
"messaggio": "Relazione verso un contenuto assente dall'istantanea." }
]
}Errori bloccanti e avvisi sono cose diverse: i primi rendono il corpus non verificabile, i secondi ne riducono la qualità. Un'istantanea con soli avvisi viene pubblicata, e gli avvisi tornano nella risposta come lista di cose da migliorare.
Tre cose che un adattatore non deve fare.
Non deve calcolare le impronte: se le omette le calcola il corpus. Reimplementare SHA-256 in ogni adattatore è un modo di sbagliare che spezzerebbe la verificabilità di ogni risposta senza che nessuno se ne accorga.
Non deve comporre gli id delle sezioni: bastano id del contenuto e chiave della
sezione.
Non deve inventare fatti. Un campo va negli attributi solo se nel CMS di partenza è
già strutturato: una data che è una data, un booleano che è un booleano. Se nel CMS è
prosa libera, resta una sezione. Estrarre fatti dalla prosa con espressioni regolari
significa indovinare, e un corpus che si dichiara certificato non può contenere fatti
indovinati. La ricchezza del corpus resta così limitata da quella della sorgente, e il
modello lo rende visibile invece di nasconderlo.
Per una sorgente che non si controlla, dove nessuno espone un'API, il modello prevede
già provenienza.metodo: "estrazione" con una confidenza dichiarata, in alternativa a
"mappatura". È il gancio per un adattatore che ricavi i contenuti dalle pagine
pubblicate: stesso corpus, stessa citabilità, con l'incertezza dichiarata invece che
nascosta.
Il portale segue l'architettura dell'informazione e i criteri di conformità del pacchetto Cittadino Informato del modello Comuni (PNRR 1.4.1): menu e pagine di secondo livello del vocabolario ufficiale, schede servizio strutturate con indice e data-element per l'App di valutazione, Bootstrap Italia, tassonomia argomenti del modello, FAQ, segnalazione disservizio, prenotazione appuntamenti senza autenticazione, widget di valutazione della chiarezza su ogni pagina, licenza CC-BY 4.0 nelle note legali.
Wrapper web del validatore ufficiale pa-website-validator-ng (Lighthouse + Puppeteer):
bash scripts/setup-valutazione.sh: clona e compila il validatore inDuckburg.Valutazione/tool(richiede Node 18+, npm, git).dotnet run --project Duckburg.Valutazione: apre l'interfaccia suhttp://localhost:5400.- Dal footer del portale: "Valutazione adesione al modello" → avvia la scansione e consulta i report.
La pagina "Deviazioni dichiarate" documenta i criteri che la demo non supera per scelta (font display in stile fumetto, C.SI.1.1) o per natura dell'ambiente (HTTPS, dominio istituzionale, dichiarazione AgID: un ente immaginario non può registrarli).
Prototipo dimostrativo, non pronto per la produzione. Il deploy è documentato in
docs/DEPLOY.md.
Cosa non c'è, dichiarato. La ricerca nel corpus è lessicale, non semantica: nessun indice vettoriale. Migliorerebbe il richiamo sulle parafrasi, ma non risolve nessuno dei problemi che il modello del corpus affronta, e un indice di embedding è opaco e non riproducibile fra versioni del modello. Per un progetto la cui tesi è la verificabilità delle risposte, è un costo da pagare dopo la struttura, non prima.
Il tracker dei consumi vive in memoria e si azzera al riavvio. Le credenziali della demo stanno nella configurazione. Il modello dati del CMS copre l'architettura dell'informazione del modello Comuni, non i flussi di back office di un ente reale.
Licenze: il codice originale è sotto Apache 2.0 (LICENSE.txt). I componenti di terze parti inclusi nel repository (configurazione Django della federazione in infra/, Bootstrap Italia, pulsanti SPID/CIE, font) restano sotto le rispettive licenze: l'elenco è in NOTICE-spid-cie-oidc.txt.