- Parallel branch from Paperless Patch Metadati
- nomic-embed-text (768 dim) via Ollama for OCR text embedding
- Qdrant knowledge collection upsert with full doc metadata payload
- memory_facts upsert with source=paperless, dedup by paperless-{doc_id}
- Qdrant collections recreated at 768 dim (were 1536 legacy, 0 points)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
357 lines
18 KiB
Markdown
357 lines
18 KiB
Markdown
# ALPHA_PROJECT — Changelog
|
||
|
||
Tutte le modifiche significative al progetto ALPHA_PROJECT sono documentate qui.
|
||
|
||
---
|
||
|
||
## [2026-03-21] Paperless Upload — integrazione memoria Postgres + Qdrant
|
||
|
||
### Modifiche al workflow `📄 Paperless — Upload Documento [Multi]` (`GBPFFq8rmbdFrNn9`)
|
||
|
||
Aggiunto branch parallelo di salvataggio in memoria dopo `Paperless - Patch Metadati`:
|
||
|
||
```
|
||
Paperless - Patch Metadati ──┬──> Telegram - Conferma Upload (invariato)
|
||
└──> 🧠 Salva in Memoria ──> 💾 Upsert Memoria
|
||
```
|
||
|
||
**`🧠 Salva in Memoria` (Code):**
|
||
- Genera embedding del testo (`{title}\n\n{OCR excerpt}`) via Ollama `nomic-embed-text` (768 dim)
|
||
- Upsert in Qdrant collection `knowledge` con payload: `user_id`, `source`, `doc_id`, `title`, `category`, `doc_type`, `correspondent`, `created_date`, `tags`
|
||
- Prepara record per Postgres con `source_ref=paperless-{doc_id}`, TTL variabile per tipo doc (90gg ricevute, 180gg bollette, 365gg default, 730gg cedolini)
|
||
|
||
**`💾 Upsert Memoria` (Postgres → `mRqzxhSboGscolqI`):**
|
||
- `INSERT INTO memory_facts` con `source='paperless'`, dedup `ON CONFLICT memory_facts_dedup_idx DO UPDATE`
|
||
- Salva anche `qdrant_id` (UUID del punto Qdrant) per cross-reference futuro
|
||
|
||
### Qdrant collections riconfigurate
|
||
|
||
Ricreate `knowledge` e `episodes` con `size=768` (nomic-embed-text) — erano a 1536 (OpenAI legacy, 0 points).
|
||
|
||
---
|
||
|
||
## [2026-03-21] Jellyfin Playback Agent — Blocco A completato
|
||
|
||
### Nuovo workflow n8n
|
||
|
||
- **`🎬 Pompeo — Jellyfin Playback [Webhook]`** (`AyrKWvboPldzZPsM`): riceve webhook da Jellyfin (PlaybackStart / PlaybackStop), filtra per utente `martin` (userId whitelist), e scrive su Postgres:
|
||
- **PlaybackStart** → INSERT in `behavioral_context` (`event_type=watching_media`, `do_not_disturb=true`, notes con item/device/session_id) + INSERT in `agent_messages` (soggetto `▶️ <titolo> (<device>)`)
|
||
- **PlaybackStop** → UPDATE su riga aperta più recente (`end_at=now()`, `do_not_disturb=false`) + INSERT in `agent_messages` (soggetto `⏹️ ...`)
|
||
|
||
### Bug risolti (infrastruttura n8n)
|
||
|
||
- **Webhook path n8n v2**: per registrare un webhook con path statico via API, il campo `webhookId` va impostato come attributo top-level del nodo (non dentro `parameters`). Senza di esso n8n genera il path dinamico `{workflowId}/{nodeName}/{path}` che il webhook pod non carica correttamente in queue mode.
|
||
- **SSL Postgres / Patroni**: le credential Postgres create via API usavano SSL con `rejectUnauthorized=true` di default, incompatibile con il certificato self-signed di Patroni. Fix: aggiunto `NODE_TLS_REJECT_UNAUTHORIZED=0` ai deployment `n8n-app` e `n8n-app-worker`.
|
||
- **queryParams Postgres node**: `additionalFields.queryParams` con espressioni `$json.*` non funziona correttamente in n8n v2.5.2. Fix: valori inline nella SQL via espressioni n8n `{{ $json.field }}`.
|
||
|
||
### Configurazione Jellyfin
|
||
|
||
- Webhook plugin Jellyfin configurato su `http://n8n-app-webhook.automation.svc.cluster.local/webhook/jellyfin-playback` (POST, eventi: PlaybackStart + PlaybackStop)
|
||
|
||
---
|
||
|
||
## [2026-03-21] Daily Digest — integrazione memoria Postgres
|
||
|
||
### Modifiche al workflow `📬 Gmail — Daily Digest [Schedule]` (`1lIKvVJQIcva30YM`)
|
||
|
||
Aggiunto branch parallelo di salvataggio fatti in memoria dopo la classificazione GPT:
|
||
|
||
```
|
||
Parse risposta GPT-4.1 ──┬──> Telegram - Invia Report (invariato)
|
||
├──> Dividi Email (invariato)
|
||
└──> 🧠 Estrai Fatti ──> 🔀 Ha Fatti? ──> 💾 Upsert Memoria
|
||
```
|
||
|
||
**`🧠 Estrai Fatti` (Code):**
|
||
- Filtra le email con `action != 'trash'` e summary non vuoto
|
||
- Chiama GPT-4.1 in batch per estrarre per ogni email: `fact`, `category`, `ttl_days`, `pompeo_note`, `entity_refs`
|
||
- Calcola `expires_at` da `ttl_days` (14gg prenotazioni, 45gg bollette, 90gg lavoro/condominio, 30gg default)
|
||
- Restituisce un item per ogni fatto da persistere
|
||
|
||
**`💾 Upsert Memoria` (Postgres node → `mRqzxhSboGscolqI`):**
|
||
- `INSERT INTO memory_facts` con `source='email'`, `source_ref=threadId`
|
||
- `ON CONFLICT ON CONSTRAINT memory_facts_dedup_idx DO UPDATE` → aggiorna se lo stesso thread viene riprocessato
|
||
- Campi salvati: `category`, `subject`, `detail` (JSONB), `action_required`, `action_text`, `pompeo_note`, `entity_refs`, `expires_at`
|
||
|
||
### Fix contestuale
|
||
|
||
- Aggiunto `newer_than:1d` alla query Gmail su entrambi i nodi fetch — evitava di rifetchare email vecchie di mesi non marcate `Processed`
|
||
|
||
---
|
||
|
||
## [2026-03-21] Schema DB v2 — contacts, memory_facts_archive, entity_refs
|
||
|
||
### Nuove tabelle
|
||
|
||
- **`contacts`**: grafo di persone multi-tenant. Ogni riga modella una relazione `user_id → subject` con `relation`, `city`, `country`, `profession`, `aliases[]`, `born_year`, `details` (narrativa libera per LLM) e `metadata` JSONB. Traversabile ricorsivamente per inferire relazioni di secondo grado (es. Martin → zio Mujsi → figlio Euris → cugino di primo grado da parte di madre). Indici GIN su `subject` (trigram) e `aliases` per similarity search.
|
||
- **`memory_facts_archive`**: destinazione del cleanup settimanale dei fatti scaduti. Struttura identica a `memory_facts` + `archived_at` + `archive_reason` (`expired` | `superseded` | `merged`). I fatti archiviati vengono poi condensati in un episodio Qdrant settimanale.
|
||
|
||
### Colonne aggiunte a `memory_facts`
|
||
|
||
- **`pompeo_note TEXT`**: inner monologue dell'LLM al momento dell'insert — il "perché" del fatto (già in uso nel Calendar Agent, ora standardizzato su tutti i source).
|
||
- **`entity_refs JSONB`**: entità estratte dal fatto strutturato — `{people: [], places: [], products: [], amounts: []}`. Permette query SQL su persone/luoghi senza full-text scan (es. `entity_refs->'people' ? 'euris vruzhaj'`).
|
||
|
||
### Applicato a
|
||
|
||
- `alpha/db/postgres.sql` aggiornato (schema v2)
|
||
- Live su Patroni primary (`postgres-1`, namespace `persistence`, DB `pompeo`)
|
||
|
||
---
|
||
|
||
## [2026-03-21] Actual Budget — Import Estratto Conto via Telegram
|
||
|
||
### Nuovi workflow
|
||
|
||
- **`💰 Actual — Import Estratto Conto [Telegram]`** (`qtvB3r0cgejyCxUp`): importa l'estratto conto Banca Sella (CSV) in Actual Budget tramite Telegram.
|
||
- Trigger: documento Telegram con caption `Estratto conto`
|
||
- Parse CSV Banca Sella (separatore `;`, date `gg/mm/aaaa`, importi con `.` decimale)
|
||
- Skip automatico di `SALDO FINALE` e `SALDO INIZIALE`
|
||
- Classificazione GPT-4.1 in batch da 30 transazioni: assegna payee e categoria, crea automaticamente i mancanti su Actual
|
||
- Import via `/transactions/import` con dedup nativo tramite `imported_id` (pattern `banca-sella-{Id}` o hash fallback)
|
||
- Report Telegram con nuove transazioni importate, già presenti e totale CSV
|
||
- **`⏰ Actual — Reminder Estratto Conto [Schedule]`** (`w0oJ1i6sESvaB5W1`): reminder giornaliero (09:00) su Telegram se il task Google "Actual - Estratto conto" nella lista "Finanze" è scaduto.
|
||
|
||
### Note tecniche
|
||
|
||
- Binary data letta con `getBinaryDataBuffer()` (compatibile con filesystem binary mode di n8n)
|
||
- Loop GPT gestito con iterazione interna nel Code node (no `splitInBatches` — instabile con input multipli)
|
||
- Payee/categorie mancanti creati al volo e riutilizzati nei batch successivi della stessa run
|
||
- Dedup Actual: `added` = nuove, `updated` = già presenti
|
||
|
||
---
|
||
|
||
## [2026-03-21] Calendar Agent — fix sincronizzazione e schedule
|
||
|
||
### Problemi risolti
|
||
|
||
- **`ON CONFLICT DO NOTHING` → `DO UPDATE`**: gli eventi modificati (orario, titolo) venivano ignorati. Ora vengono aggiornati in Postgres.
|
||
- **Cleanup eventi cancellati**: aggiunto step `🗑️ Cleanup Cancellati` che esegue `DELETE FROM memory_facts WHERE source_ref NOT IN (UID attuali da HA)` per la finestra 7 giorni. Se Martin cancella un meeting, sparisce da Postgres al prossimo run.
|
||
- **Schedule `*/30 * * * *`**: da cron 06:30 giornaliero a ogni 30 minuti — il calendario Postgres è sempre allineato alla source of truth (HA/Google Calendar).
|
||
|
||
### Flusso aggiornato
|
||
|
||
```
|
||
... → 📋 Parse GPT → 🗑️ Cleanup Cancellati → 🔀 Riemetti → 💾 Upsert → 📦 → 📱
|
||
```
|
||
|
||
---
|
||
|
||
## [2026-03-20] Calendar Agent — primo workflow Pompeo in produzione
|
||
|
||
### Cosa è stato fatto
|
||
|
||
Primo agente Pompeo deployato e attivo su n8n: `📅 Pompeo — Calendar Agent [Schedule]` (ID `4ZIEGck9n4l5qaDt`).
|
||
|
||
### Design
|
||
|
||
- **Sorgente dati**: Home Assistant REST API usata come proxy Google Calendar — evita OAuth Google diretto in n8n e funziona per tutti i 25 calendari registrati in HA.
|
||
- **Calendari tracciati** (12): Lavoro, Famiglia, Spazzatura, Pulizie, Formula 1, WEC, Inter, Compleanni, Varie, Festività Italia, Films (Radarr), Serie TV (Sonarr).
|
||
- **LLM enrichment**: GPT-4.1 (via Copilot) classifica ogni evento: category, action_required, do_not_disturb, priority, behavioral_context, pompeo_note.
|
||
- **Dedup**: `memory_facts.source_ref` = HA event UID; `ON CONFLICT DO NOTHING` su indice unico parziale.
|
||
- **Telegram briefing**: ogni mattina alle 06:30, riepilogo eventi prossimi 7 giorni raggruppati per calendario.
|
||
|
||
### Migrazioni DB applicate
|
||
|
||
- `ALTER TABLE memory_facts ADD COLUMN source_ref TEXT` — colonna per ID esterno di dedup
|
||
- `CREATE UNIQUE INDEX memory_facts_dedup_idx ON memory_facts (user_id, source, source_ref) WHERE source_ref IS NOT NULL`
|
||
- `CREATE INDEX idx_memory_facts_source_ref ON memory_facts (source_ref) WHERE source_ref IS NOT NULL`
|
||
|
||
### Credential n8n create
|
||
|
||
| ID | Nome | Tipo |
|
||
|---|---|---|
|
||
| `u0JCseXGnDG5hS9F` | Home Assistant API | HTTP Header Auth |
|
||
| `mRqzxhSboGscolqI` | Pompeo — PostgreSQL | Postgres (pompeo/martin) |
|
||
|
||
### Flusso workflow
|
||
|
||
```
|
||
⏰ Schedule (06:30) → 📅 Range → 🔑 Token Copilot
|
||
→ 📋 Calendari (12 items) → 📡 HA Fetch (×12) → 🏷️ Estrai + Tag
|
||
→ 📝 Prompt (dedup) → 🤖 GPT-4.1 → 📋 Parse
|
||
→ 💾 Postgres Upsert (memory_facts) → 📦 Aggrega → 📱 Telegram
|
||
```
|
||
|
||
---
|
||
|
||
## [2026-03-21] ADR — Message Broker: nessun broker dedicato
|
||
|
||
### Decisione
|
||
|
||
**Non verrà deployato un message broker dedicato** (né NATS JetStream né Redis Streams). Il blackboard pattern viene implementato interamente su PostgreSQL + webhook n8n.
|
||
|
||
### Ragionamento
|
||
|
||
Al momento della progettazione iniziale, il broker era necessario per disaccoppiare gli agenti dall'Arbiter. Con l'introduzione della tabella `agent_messages` nel database `pompeo`, questo obiettivo è già raggiunto:
|
||
|
||
```
|
||
Agente n8n → INSERT agent_messages (arbiter_decision = NULL)
|
||
Arbiter → SELECT WHERE arbiter_decision IS NULL (polling a cron)
|
||
→ UPDATE arbiter_decision = 'notify' | 'defer' | 'discard'
|
||
```
|
||
|
||
Il flusso high-priority (bypass immediato dell'Arbiter) viene gestito con una chiamata diretta al **webhook n8n dell'Arbiter** da parte dell'agente — zero infrastruttura aggiuntiva.
|
||
|
||
### Alternative valutate
|
||
|
||
| Opzione | Esito | Motivazione |
|
||
|---|---|---|
|
||
| `agent_messages` su PostgreSQL | ✅ **Adottata** | Già deployata, persistente, queryabile, audit log gratuito |
|
||
| Redis Streams | ⏸ Rimandato | Già in cluster, valutabile se volume cresce |
|
||
| NATS JetStream | ❌ Scartato | Nuovo componente da operare, overkill per il volume attuale (pochi msg/ora) e per il caso d'uso single-household |
|
||
|
||
### Impatto su README.md
|
||
|
||
La sezione "Message Broker (Blackboard Pattern)" rimane valida concettualmente. Il campo `agent` e il message schema definiti nel README vengono rispettati nella tabella `agent_messages` — cambia solo il mezzo di trasporto (Postgres invece di NATS/Redis).
|
||
|
||
---
|
||
|
||
## [2026-03-21] PostgreSQL — Database "pompeo" e schema ALPHA_PROJECT
|
||
|
||
### Overview
|
||
|
||
Creato il database `pompeo` sul cluster Patroni (namespace `persistence`) e applicato lo schema iniziale per la memoria strutturata di Pompeo. Seconda milestone della Phase 0 — Infrastructure Bootstrap.
|
||
|
||
---
|
||
|
||
### Modifica manifest Patroni
|
||
|
||
Aggiunto `pompeo: martin` nella sezione `databases` di `infra/cluster/persistence/patroni/postgres.yaml`. Il database è stato creato automaticamente dallo Zalando Operator senza downtime sugli altri database.
|
||
|
||
Script DDL idempotente disponibile in: `alpha/db/postgres.sql`
|
||
|
||
---
|
||
|
||
### Design decision — Multi-tenancy anche in PostgreSQL
|
||
|
||
Coerentemente con la scelta adottata per Qdrant, tutte le tabelle includono il campo `user_id TEXT NOT NULL DEFAULT 'martin'`. I valori `'martin'` e `'shared'` sono seedati in `user_profile` come utenti iniziali del sistema.
|
||
|
||
Aggiungere un nuovo utente in futuro non richiede modifiche allo schema — è sufficiente inserire una riga in `user_profile` e usare il nuovo `user_id` negli INSERT.
|
||
|
||
---
|
||
|
||
### Design decision — agent_messages come blackboard persistente
|
||
|
||
La tabella `agent_messages` implementa il **blackboard pattern** del message broker: ogni agente n8n inserisce le proprie osservazioni con `arbiter_decision = NULL` (pending). Il Proactive Arbiter legge i messaggi in coda, decide (`notify` / `defer` / `discard`) e aggiorna `arbiter_decision`, `arbiter_reason` e `processed_at`.
|
||
|
||
Rispetto a usare solo NATS/Redis come broker, questo approccio garantisce un **audit log permanente** di tutte le osservazioni e decisioni, interrogabile via SQL per debug, tuning e analisi storiche.
|
||
|
||
---
|
||
|
||
### Schema creato
|
||
|
||
**5 tabelle** nel database `pompeo`:
|
||
|
||
| Tabella | Ruolo |
|
||
|---|---|
|
||
| `user_profile` | Preferenze statiche per utente (lingua, timezone, stile notifiche, quiet hours). Seed: `martin`, `shared` |
|
||
| `memory_facts` | Fatti episodici prodotti da tutti gli agenti, con TTL (`expires_at`) e riferimento al punto Qdrant (`qdrant_id`) |
|
||
| `finance_documents` | Documenti finanziari strutturati: bollette, fatture, cedolini. Include `raw_text` per embedding |
|
||
| `behavioral_context` | Contesto IoT/comportamentale per l'Arbiter: DND, home presence, tipo evento |
|
||
| `agent_messages` | Blackboard del message broker — osservazioni agenti + decisioni Arbiter |
|
||
|
||
**15 index** totali:
|
||
|
||
| Index | Tabella | Tipo |
|
||
|---|---|---|
|
||
| `idx_memory_facts_user_source_cat` | `memory_facts` | `(user_id, source, category)` |
|
||
| `idx_memory_facts_expires` | `memory_facts` | `(expires_at)` WHERE NOT NULL |
|
||
| `idx_memory_facts_action` | `memory_facts` | `(user_id, action_required)` WHERE true |
|
||
| `idx_finance_docs_user_date` | `finance_documents` | `(user_id, doc_date DESC)` |
|
||
| `idx_finance_docs_correspondent` | `finance_documents` | `(user_id, correspondent)` |
|
||
| `idx_behavioral_ctx_user_time` | `behavioral_context` | `(user_id, start_at, end_at)` |
|
||
| `idx_behavioral_ctx_dnd` | `behavioral_context` | `(user_id, do_not_disturb)` WHERE true |
|
||
| `idx_agent_msgs_pending` | `agent_messages` | `(user_id, priority, created_at)` WHERE pending |
|
||
| `idx_agent_msgs_agent_type` | `agent_messages` | `(agent, event_type, created_at)` |
|
||
| `idx_agent_msgs_expires` | `agent_messages` | `(expires_at)` WHERE pending AND NOT NULL |
|
||
|
||
---
|
||
|
||
### Phase 0 — Stato aggiornato
|
||
|
||
- [x] ~~Deploy **Qdrant** sul cluster~~ ✅ 2026-03-21
|
||
- [x] ~~Collections Qdrant con multi-tenancy `user_id`~~ ✅ 2026-03-21
|
||
- [x] ~~Payload indexes Qdrant~~ ✅ 2026-03-21
|
||
- [x] ~~Database `pompeo` + schema PostgreSQL~~ ✅ 2026-03-21
|
||
- [ ] Verify embedding endpoint via Copilot (`text-embedding-3-small`)
|
||
- [ ] Migrazione a Ollama `nomic-embed-text` (quando LLM server è online)
|
||
|
||
---
|
||
|
||
## [2026-03-21] Qdrant — Deploy e setup collections (Phase 0)
|
||
|
||
### Overview
|
||
|
||
Completato il deploy di **Qdrant v1.17.0** sul cluster Kubernetes (namespace `persistence`) e la creazione delle collections per la memoria semantica di Pompeo. Questa è la prima milestone della Phase 0 — Infrastructure Bootstrap.
|
||
|
||
---
|
||
|
||
### Deploy infrastruttura
|
||
|
||
Qdrant deployato via Helm chart ufficiale (`qdrant/qdrant`) nel namespace `persistence`, coerente con il pattern infrastrutturale esistente (Longhorn storage, Sealed Secrets, ServiceMonitor Prometheus).
|
||
|
||
**Risorse create:**
|
||
|
||
| Risorsa | Dettaglio |
|
||
|---|---|
|
||
| StatefulSet `qdrant` | 1/1 pod Running, image `qdrant/qdrant:v1.17.0` |
|
||
| PVC `qdrant-storage-qdrant-0` | 20Gi Longhorn RWO |
|
||
| Service `qdrant` | ClusterIP — porte 6333 (REST), 6334 (gRPC), 6335 (p2p) |
|
||
| SealedSecret `qdrant-api-secret` | API key cifrata, namespace `persistence` |
|
||
| ServiceMonitor `qdrant` | Prometheus scraping su `:6333/metrics`, label `release: monitoring` |
|
||
|
||
**Endpoint interno:** `qdrant.persistence.svc.cluster.local:6333`
|
||
|
||
Manifest in: `infra/cluster/persistence/qdrant/`
|
||
|
||
---
|
||
|
||
### Design decision — Multi-tenancy collections (Opzione B)
|
||
|
||
**Problema affrontato**: nominare le collections `martin_episodes`, `martin_knowledge`, `martin_preferences` avrebbe vincolato Pompeo ad essere esclusivamente un assistente personale singolo, rendendo impossibile — senza migration — estendere il sistema ad altri membri della famiglia in futuro.
|
||
|
||
**Scelta adottata**: architettura multi-tenant con 3 collection condivise e isolamento via campo `user_id` nel payload di ogni punto vettoriale.
|
||
|
||
```
|
||
episodes ← user_id: "martin" | "shared" | <futuri utenti>
|
||
knowledge ← user_id: "martin" | "shared" | <futuri utenti>
|
||
preferences ← user_id: "martin" | "shared" | <futuri utenti>
|
||
```
|
||
|
||
Il valore `"shared"` è riservato a dati della casa/famiglia visibili a tutti gli utenti (es. calendario condiviso, documenti di casa, finanze comuni). Le query n8n usano un filtro `should: [user_id=martin, user_id=shared]` per recuperare sia il contesto personale che quello condiviso.
|
||
|
||
**Vantaggi**: aggiungere un nuovo utente domani non richiede alcuna modifica infrastrutturale — solo includere il nuovo `user_id` negli upsert e nelle query.
|
||
|
||
---
|
||
|
||
### Collections create
|
||
|
||
Tutte e 3 le collections sono operative (status `green`):
|
||
|
||
| Collection | Contenuto |
|
||
|---|---|
|
||
| `episodes` | Fatti episodici con timestamp (email, IoT, calendario, conversazioni) |
|
||
| `knowledge` | Documenti, note Outline, newsletter, knowledge base |
|
||
| `preferences` | Preferenze, abitudini e pattern comportamentali per utente |
|
||
|
||
**Payload schema comune** (5 index su ogni collection):
|
||
|
||
| Campo | Tipo | Scopo |
|
||
|---|---|---|
|
||
| `user_id` | keyword | Filtro multi-tenant (`"martin"`, `"shared"`) |
|
||
| `source` | keyword | Origine del dato (`"email"`, `"calendar"`, `"iot"`, `"paperless"`, …) |
|
||
| `category` | keyword | Dominio semantico (`"finance"`, `"work"`, `"personal"`, …) |
|
||
| `date` | datetime | Timestamp del fatto — filtrabile per range |
|
||
| `action_required` | bool | Flag per il Proactive Arbiter |
|
||
|
||
**Dimensione vettori**: 1536 (compatibile con `text-embedding-3-small` via GitHub Copilot — bootstrap phase). Da rivedere alla migrazione verso `nomic-embed-text` su Ollama.
|
||
|
||
---
|
||
|
||
### Phase 0 — Stato al momento del deploy Qdrant
|
||
|
||
- [x] ~~Deploy **Qdrant** sul cluster~~
|
||
- [x] ~~Creazione collections con multi-tenancy `user_id`~~
|
||
- [x] ~~Payload indexes: `user_id`, `source`, `category`, `date`, `action_required`~~
|
||
- [x] ~~Run **PostgreSQL migrations** su Patroni~~ ✅ completato nella sessione stessa
|