# Squilla — Piattaforma multi-tenant su LiveKit

Documentazione tecnica dello stato attuale. Aggiornata al 2026-07-14.

Piano di progettazione originale (contesto/decisioni architetturali): `/home/mitsus/.claude/plans/cuddly-soaring-lampson.md`.

---

## 1. Panoramica

Piattaforma SaaS multi-tenant per 3 servizi AI telefonici/video, costruita su un server LiveKit self-hosted già in produzione:

1. **Segreteria AI inbound** (`inbound_secretary`) — risponde a chiamate telefoniche in ingresso, audio-only.
2. **Dialer outbound** (`outbound_dialer`) — chiamate telefoniche automatiche per campagne marketing o smoke-test tecnici, audio-only.
3. **Bot visuale** (`visual_bot`) — assistente audio+video con avatar (Anam.ai), incorporabile su siti web, non telefonico.

Ogni tenant (cliente) ha il proprio numero/i, provider SIP, e configurazione agente (prompt, lingua, provider LLM/STT/TTS), tutto guidato da database — **non serve deployare codice diverso per cliente diverso**.

```
                         ┌─────────────────────────┐
   PSTN (Ehiweb, ecc.) ─►│  livekit-sip (/opt/livekit) │
                         └───────────┬─────────────┘
                                     │ dispatch rule per-DID
                                     ▼
                    ┌────────────────────────────────┐
                    │  workers/inbound_secretary/      │  worker pool condiviso
                    │  agent.py                         │
                    └────────────────┬─────────────────┘
                                     │ risolve tenant via sip.trunkPhoneNumber
                                     ▼
   ┌───────────────────────────────────────────────────────────┐
   │              MySQL "squilla" (172.16.0.3:3306)              │
   │  tenants / sip_providers / phone_numbers / agent_configs /   │
   │  crm_contacts / appointments / campaigns / campaign_targets / │
   │  call_logs                                                    │
   └───────────────────────────────────────────────────────────┘
                                     ▲
                     ┌───────────────┴────────────────┐
   campaign_runner.py│ scheduler (non è un worker LiveKit) │
   (poll DB, dispatch esplicito via API)                  │
                     ▼                                      │
    workers/outbound_dialer/agent.py ────────────────────────┘
    (chiama create_sip_participant, logga esito)

   Widget web (emcom-chatbot-js) ──► route.ts (mint token + tenant_id in room metadata)
                                  ──► workers/visual_bot/agent.py (Anam avatar, config da DB)
```

---

## 2. Infrastruttura sottostante (non parte di questo progetto, ma da cui dipende)

Directory `/opt/livekit/` (root-owned, gestita separatamente):

| Componente | Cosa fa | Note |
|---|---|---|
| `livekit-server` | Server LiveKit v1.9.2, `wss://ai.emcom.cloud` | API key/secret in `/opt/livekit/livekit.yaml` |
| `redis` | Stato interno di LiveKit | bind solo su localhost |
| `caddy` (caddyl4) | TLS + proxy TURN/HTTP | certificati Let's Encrypt auto-rinnovati |
| `livekit-sip` | Servizio SIP, deployato in questa sessione | vedi §2.1 |

### 2.1 Vincolo critico del servizio SIP

`livekit-sip` implementa **solo un modello IP-peering**: non supporta la REGISTER SIP verso provider terzi (verificato ispezionando i binari e i proto — nessuna funzionalità di registrazione client presente in nessuna versione testata). Questo significa:

- **Outbound**: funziona sempre — LiveKit invia INVITE autenticate direttamente al server SIP del provider.
- **Inbound**: funziona solo se il provider instrada le chiamate staticamente verso l'IP del nostro server. Provider "consumer"/a interno (come Ehiweb Vivavox Free) che richiedono la registrazione di un dispositivo **non funzionano out-of-the-box** — lo stato è tracciato esplicitamente in `phone_numbers.inbound_status = 'pending_provider_setup'`.

### 2.2 Credenziali API

L'API key/secret LiveKit corrente (generata 2026-03-18) è in `.env` di questo progetto. **Attenzione**: tutti i vecchi progetti prototipo in `/home/mitsus/livekit/*` (RealTime-Ai-agent, first-agent, outbound-caller-python, ecc.) usano ancora una chiave precedente ormai revocata (`APISrcQouSx9X8o`) — non funzionante contro il server live, da NON riusare.

---

## 3. Database — schema `squilla` (MySQL 8.0, 172.16.0.3:3306)

File sorgente: `db/schema.sql`. Applicato ed verificato in questa sessione (9 tabelle).

| Tabella | Scopo | Note chiave |
|---|---|---|
| `tenants` | Account cliente | `slug` univoco, `timezone` (usato per calcolare le finestre orarie delle campagne) |
| `sip_providers` | **Un account provider SIP per riga**, non un numero | Un provider (es. Ehiweb) può avere più DID; il trunk LiveKit si crea una volta e si riusa. `inbound_capability` = `static_ip_routing` / `registration_required` / `unknown` |
| `phone_numbers` | Un DID per riga, chiave di risoluzione tenant per inbound | `did_e164` **globalmente univoco** — è la chiave con cui il worker inbound trova il tenant. `inbound_status` traccia se le chiamate in ingresso funzionano davvero |
| `agent_configs` | Config per (tenant, service_type) | Prompt, lingua, provider LLM/STT/TTS, voce, avatar Anam. `is_active=FALSE` di default finché non impostata una configurazione reale — **nessun fallback silenzioso su un prompt placeholder per una chiamata vera** |
| `crm_contacts` | Anagrafica cliente-del-cliente (gestionale-lite) | consenso (`opted_in`/`opted_out`) rispettato dallo scheduler campagne |
| `appointments` | Appuntamenti | collegabile a `crm_contacts` |
| `campaigns` | Campagne outbound | `campaign_type`: `marketing` o `smoke_test`; orari/giorni/concorrenza/rate-limit configurabili |
| `campaign_targets` | Chi chiamare in una campagna | claim concorrente sicuro via `SELECT ... FOR UPDATE SKIP LOCKED` |
| `call_logs` | Log unificato di tutte le chiamate dei 3 servizi | esito, codice SIP, durata, trascrizione, collegato a campagna/target se pertinente |

---

## 4. Libreria condivisa `common/`

| File | Responsabilità |
|---|---|
| `db.py` | Motore MySQL asincrono (SQLAlchemy + `asyncmy`), **singleton lazy per-processo**. Necessario perché LiveKit Agents esegue ogni chiamata in un processo forkato separato (`forkserver`): un pool creato a import-time nel processo padre non è utilizzabile nei figli. `pool_pre_ping=True` per evitare che una connessione scaduta durante l'idle rompa la prima query di una chiamata in corso |
| `models.py` | Tabelle SQLAlchemy Core (non ORM) + funzioni di inserimento/lettura base per tenant, provider, numeri, config |
| `config_loader.py` | Letture usate dai worker a inizio chiamata: `resolve_tenant_by_did()`, `load_agent_config()`, `load_crm_contact()` |
| `sip_provisioning.py` | `provision_phone_number()` — crea/riusa trunk LiveKit outbound+inbound e la dispatch rule per un DID. **Persistenza incrementale**: ogni ID viene scritto su DB subito dopo la creazione, non in blocco alla fine (bug reale trovato e corretto durante l'implementazione — un fallimento a metà altrimenti orfanizza risorse su LiveKit) |
| `session_factory.py` | Mappa `agent_configs` → istanze concrete dei plugin LiveKit (STT/LLM/TTS/turn-detection). **Punto di estensione per nuovi provider** — vedi §7 |
| `call_logger.py` | `start_call_log()` / `mark_answered()` / `finish_call_log()` / `finish_call_log_if_open()` |
| `campaign_status.py` | Scheduler: claim concorrente dei target, finestre orarie per timezone tenant, mappa codici SIP → esito |

---

## 5. Risoluzione tenant per chiamate inbound

Meccanismo a due livelli (in `workers/inbound_secretary/agent.py`):

1. **Primario**: l'attributo `sip.trunkPhoneNumber` (il DID composto, sempre popolato da LiveKit su ogni partecipante SIP in ingresso) → lookup in `phone_numbers.did_e164`.
2. **Secondario/difensivo**: l'attributo `tenant_id` impostato esplicitamente sulla dispatch rule in fase di provisioning — se in disaccordo con la risoluzione via DID, viene solo loggato un warning, non blocca la chiamata (il DID è la fonte di verità, più robusto contro dispatch rule modificate a mano).

Se il DID non è riconosciuto, la chiamata viene chiusa (nessun agente non configurato/non brandizzato risponde mai per un numero sconosciuto).

---

## 6. I tre worker

Tutti e tre girano come **pool di processi condiviso per servizio**, non un processo per tenant — la configurazione si carica da MySQL all'inizio di ogni chiamata.

### 6.1 `workers/inbound_secretary/agent.py`
Trigger: chiamata SIP in ingresso (dispatch rule creata da `sip_provisioning.py`). Risolve tenant → carica `agent_config` + eventuale contatto CRM → apre `call_logs` → avvia la sessione. La chiusura del log avviene tramite `ctx.add_shutdown_callback()`, **non** in linea dopo `session.start()` (che ritorna appena la sessione è avviata, non a fine chiamata — dettaglio verificato sul codice reale della SDK).

### 6.2 `workers/outbound_dialer/` — Servizio 2
Due componenti separati (pattern idiomatico per LiveKit Agents 1.3.x, verificato):

- **`campaign_runner.py`**: scheduler standalone (non è un worker LiveKit). Ogni 5s, per ogni campagna `running`: calcola gli slot liberi (`max_concurrent_calls` - chiamate in corso), verifica la finestra oraria/giorni della campagna, reclama un batch di target `pending` con lock concorrente sicuro, e dispatcha ciascuno con `agent_dispatch.create_dispatch()` — **non chiama mai `create_sip_participant` direttamente**, lascia che sia LiveKit a bilanciare il carico tra i processi worker disponibili.
- **`agent.py`**: riceve il job dispatchato, legge `dial_info` (tenant, numero, trunk, tipo campagna) dai metadata del job, chiama davvero `create_sip_participant`, gestisce l'esito (`api.TwirpError` → codice SIP → esito mappato: busy/no_answer/failed), logga tutto.

**Campagne `smoke_test`**: flusso minimo, nessuna conversazione LLM — un messaggio TTS fisso ("Test di linea in corso...") poi hangup. Nessun obiettivo di conversione.

**Testato end-to-end in questa sessione con successo completo**: dispatch → composizione SIP → risposta → trascrizione STT corretta → sintesi vocale ElevenLabs udita realmente dal chiamante → chiusura pulita (`ROOM_DELETED`) → log corretto in `call_logs`/`campaign_targets`. Percorso di debug seguito, utile da ricordare:
1. Prima chiamata: OpenAI TTS falliva con `429 insufficient_quota` (quota account esaurita) — risolto passando a ElevenLabs.
2. Seconda chiamata: ElevenLabs falliva con `voice_id_does_not_exist` — il `tts_voice` salvato nel DB non corrispondeva più alla chiave API corrente in `.env` (file modificato tra un passaggio e l'altro). Risolto interrogando `GET /v2/voices` con la chiave attuale per ottenere un voice_id realmente valido.
3. Terza chiamata: ElevenLabs falliva con `402 payment_required` ("Free users cannot use library voices via the API") — il voice_id scelto era una **voce "library"** (community, es. "Luca Brasi" italiana), non utilizzabile via API sul piano gratuito. Risolto passando a una voce **default/premade** dell'account (es. "Sarah", `EXAVITQu4vr4xnSDxMaL`), utilizzabile anche su piano free.
4. Quarta chiamata: successo, nessun errore, audio udito dal chiamante.

**Nota per il piano ElevenLabs**: finché l'account resta sul piano gratuito, `agent_configs.tts_voice` deve essere una voce **default/premade**, non una voce "library"/community — verificare sempre con `GET https://api.elevenlabs.io/v2/voices` quali ID sono realmente selezionabili per l'account in uso.

**Gap noto**: `campaigns.retry_policy` è presente nello schema ma **non ancora implementato** — `campaign_runner.py` non rimette mai a `pending` un target dopo un esito `no_answer`/`busy`, quindi oggi ogni target viene tentato una sola volta indipendentemente da `retry_policy.max_attempts`.

### 6.3 `workers/visual_bot/agent.py` — Servizio 3
Non telefonico: tenant risolto da `ctx.room.metadata` (JSON, impostato dal widget web al momento del mint del token, non da SIP). Se `agent_configs.anam_avatar_id` è impostato, aggancia l'avatar Anam **dopo** l'avvio della sessione audio (per non perdere le prime parole del chiamante), con retry/backoff su rate-limit (429) e fallback automatico ad audio-only se l'avatar fallisce — pattern portato via dal prototipo funzionante `test-agent/voice_agent.py`.

Modifica lato widget: `emcom-chatbot-js/emcom-client-agent/app/api/connection-details/route.ts` ora accetta un `tenant_id` nel body della richiesta e lo scrive in `RoomConfiguration.metadata` al mint del token. `hooks/use-connection-details.ts` e `app-config.ts` sono stati modificati per inviarlo davvero e per impostare `agentName: 'visual-bot'` (senza dispatch esplicito il worker non riceve mai il job, essendo registrato con un `agent_name` specifico).

**Testato end-to-end nel browser con successo** (audio + video avatar confermati). Percorso di debug seguito, utile da ricordare:
1. Node.js di sistema (18.19.1) troppo vecchio per Next.js 16 (richiede ≥20.9) — installato `nvm` in user-space (non tocca la Node di sistema) e Node 20 solo per questo progetto.
2. Prima ipotesi sbagliata: il video sembrava non renderizzarsi per colpa di `publication.dimensions` non popolato (che fa collassare il `<video>` a 0×0) — fix applicato in `popup-view.tsx` (fallback a 1280×720 invece di 0), utile ma non risolutivo da solo.
3. **Causa reale**: la pagina `http://localhost:3000` di default mostra la variante **iframe** del widget (`selectedTab` default è `'iframe'` in `components/welcome.tsx`), che non ha *nessun* rendering video per progettazione (solo `BarVisualizer`, vedi `components/embed-iframe/session-view.tsx`). Il fix andava testato sulla variante **Popup** (`?tab=popup`), l'unica con `<VideoTrack>` per l'agente.
4. Confermato con un monitor in tempo reale sui partecipanti/track della room (via API LiveKit) che l'avatar Anam pubblica **sempre** correttamente una traccia video valida — il problema non è mai stato lato server/Anam.

**Nota**: durante il debug ho temporaneamente rimosso l'animazione di comparsa dell'avatar (dissolvenza + maschera radiale) in `popup-view.tsx` per isolare il problema — il video ora appare senza transizione. Da ripristinare (o ridisegnare) quando si passa dalla fase di test a quella di rifinitura visiva, ora che sappiamo che il rendering di base funziona.

---

## 7. Come cambiare provider LLM / STT / TTS

Il punto di estensione è **`common/session_factory.py`** — tre funzioni `_build_stt()`, `_build_llm()`, `_build_tts()`, ciascuna un semplice `if/elif` su `agent_configs.{stt,llm,tts}_provider`. Per aggiungere un provider:

1. `pip install livekit-plugins-<nome>` (aggiungere anche a `requirements.txt`)
2. Aggiungere la chiave API richiesta a `.env` (e a `docker-compose.yaml` se serve un nuovo nome variabile)
3. Aggiungere un branch nella funzione corrispondente in `session_factory.py`
4. Impostare `agent_configs.llm_provider` / `tts_provider` (+ eventuale `_model`/`_voice`) per il tenant interessato

**Verificato disponibile su PyPI in questa sessione**: `livekit-plugins-anthropic` (LLM, v1.6.5), `livekit-plugins-azure`, `livekit-plugins-rime`, `livekit-plugins-playai` (TTS). Attualmente implementati in `session_factory.py`: LLM=openai; STT=deepgram; TTS=openai/elevenlabs/cartesia.

**Nota pratica**: la chiave `ELEVEN_API_KEY` (ElevenLabs) è già presente in `.env` e risulta l'unica TTS già validata su italiano nei prototipi precedenti (Cartesia è documentata come inaffidabile su lingue diverse dall'inglese) — è l'opzione più rapida per sbloccare i test vocali senza aspettare di risolvere la quota OpenAI.

---

## 8. Onboarding di un nuovo tenant

`scripts/onboard_tenant.py` — crea tenant + provider SIP + numero + config placeholder, e provisiona i trunk/dispatch-rule su LiveKit in un solo comando:

```bash
cd /home/mitsus/livekit/squilla-platform
set -a && source .env && set +a
./venv/bin/python3 scripts/onboard_tenant.py \
  --slug acme-dental --name "Acme Dental" \
  --provider-key ehiweb --sip-address sip.vivavox.it \
  --auth-username 0114410364 --auth-password 'xxx' \
  --did 0114410364 \
  --inbound-capability registration_required
```

Dopo l'onboarding, `agent_configs` per il tenant è creato con `is_active=FALSE` e un prompt placeholder — va attivato manualmente (o da una futura UI admin) con un prompt reale prima di andare in produzione.

---

## 9. Stato attuale / cosa manca

| Elemento | Stato |
|---|---|
| Schema DB | ✅ Applicato e verificato |
| Provisioning SIP multi-provider | ✅ Verificato con Ehiweb reale (tenant `emcom`, id=1) |
| Servizio 2 (outbound/smoke-test) | ✅ Verificato end-to-end con successo completo (SIP, STT, LLM Anthropic, TTS ElevenLabs tutti confermati funzionanti su chiamata reale) |
| Servizio 1 (inbound) | ⚠️ Codice scritto, non testabile con chiamata PSTN reale finché Ehiweb non risolve l'inoltro (vedi `phone_numbers.inbound_status_note` per il tenant `emcom`) |
| Servizio 3 (visual bot) | ✅ Verificato end-to-end nel browser (audio + video avatar Anam confermati funzionanti) |
| UI di amministrazione self-service | ✅ `admin_ui/` (FastAPI + Jinja2/Tabler) costruita — vedi §14. Non ancora deployata pubblicamente su `console.squilla.me` |
| Rilevamento segreteria telefonica (AMD) | ❌ Solo inferenza LLM (il modello stesso deve riconoscere una segreteria dalla trascrizione), nessun segnale audio dedicato |
| Cifratura credenziali provider SIP | ❌ In chiaro nella colonna `sip_providers.auth_password` (deciso deliberatamente per velocità in questa fase) |

---

## 10. Come avviare i worker e generare chiamate reali

### 10.0 Prerequisiti (sempre validi, indipendentemente dal servizio)

```bash
cd /home/mitsus/livekit/squilla-platform
set -a && source .env && set +a          # carica LIVEKIT_*, MYSQL_*, OPENAI/DEEPGRAM/ELEVEN/ANAM/ANTHROPIC_API_KEY
```

Verificare che l'infrastruttura di base sia su (gestita separatamente in `/opt/livekit/`):
```bash
docker ps --filter name=livekit-   # attesi: livekit-livekit-1, livekit-redis-1, livekit-caddy-1, livekit-sip-1
```

Ogni worker, una volta avviato, resta in esecuzione e si **registra** presso il server LiveKit (log `"registered worker"`) — da quel momento riceve automaticamente i job (chiamate) che gli vengono instradati, senza bisogno di altre azioni manuali finché resta acceso.

### 10.1 Servizio 1 — Segreteria inbound (ricevere chiamate)

```bash
./venv/bin/python3 workers/inbound_secretary/agent.py start
```

Una volta registrato, riceve automaticamente ogni chiamata SIP in ingresso instradata da una dispatch rule con `agent_name="inbound-secretary"` (creata da `provision_phone_number()` per ogni DID onboardato — vedi §8). **Non serve nessun'altra azione**: il worker resta in ascolto e risponde da solo quando arriva una chiamata reale sul numero del tenant.

- **Per testare con una chiamata PSTN reale**: basta comporre il numero del tenant da un telefono vero. Oggi bloccato per il tenant `emcom` (Ehiweb richiede registrazione, non supportata — vedi §2.1); funzionerà appena un provider con `inbound_capability='static_ip_routing'` viene onboardato, o Ehiweb risolve l'inoltro.
- **Per testare senza attendere il PSTN**: dispatchare esplicitamente un job verso una room con gli attributi SIP simulati (`sip.trunkPhoneNumber`, `sip.phoneNumber`) su un partecipante — utile per validare la logica di risoluzione tenant/caricamento config senza una vera chiamata telefonica.

### 10.2 Servizio 2 — Dialer outbound (fare chiamate)

Due processi separati, entrambi necessari:

```bash
# 1. Il worker che esegue le chiamate (resta sempre acceso, come un pool pronto a ricevere lavoro)
./venv/bin/python3 workers/outbound_dialer/agent.py start &

# 2. Lo scheduler, da avviare quando c'e' almeno una campagna con status='running'
#    (esce da solo se non trova campagne 'running'; altrimenti resta in poll ogni 5s)
./venv/bin/python3 workers/outbound_dialer/campaign_runner.py
```

**Per generare davvero una chiamata outbound** (sequenza minima verificata in questa sessione):

```python
# crea una campagna + un target — esempio eseguibile con:
#   ./venv/bin/python3 -c "..." (con l'ambiente caricato come in §10.0)
import asyncio, sys; sys.path.insert(0, ".")
from datetime import time
from common import models
from common.db import get_engine

async def main():
    engine = await get_engine()
    async with engine.begin() as conn:
        r = await conn.execute(models.campaigns.insert().values(
            tenant_id=1, name="La mia campagna", campaign_type="smoke_test",  # o "marketing"
            phone_number_id=1, status="running", max_concurrent_calls=1,
            rate_limit_per_minute=10, calling_hours_start=time(0,0,0), calling_hours_end=time(23,59,59),
            calling_days_of_week=[1,2,3,4,5,6,7],
        ))
        campaign_id = r.inserted_primary_key[0]
        await conn.execute(models.campaign_targets.insert().values(
            campaign_id=campaign_id, phone_e164="+39...", status="pending",
        ))
asyncio.run(main())
```

Non appena `campaign_runner.py` è in esecuzione (o al prossimo avvio), reclama il target e dispatcha la chiamata al worker — la telefonata parte entro pochi secondi. `campaign_type="smoke_test"` fa solo un annuncio fisso e riaggancia; `campaign_type="marketing"` avvia la conversazione LLM guidata dal `system_prompt`/`greeting_text` di `agent_configs`.

### 10.3 Servizio 3 — Bot visuale (widget web)

```bash
./venv/bin/python3 workers/visual_bot/agent.py start
```

Poi lato web: avviare `emcom-chatbot-js/emcom-client-agent` (Next.js) e far sì che la chiamata a `/api/connection-details` includa `tenant_id` nel body — l'agente si dispatcha automaticamente sulla room aperta dal widget quando un visitatore clicca "parla con l'assistente". Non ancora testato end-to-end in questa sessione (prossimo passo).

### 10.4 Tutto insieme, in produzione

```bash
docker compose up -d
```

Build context alla radice del progetto, ogni Dockerfile copia `common/` + il proprio worker (vedi `docker-compose.yaml`). Il container `campaign-runner` va lasciato sempre acceso: esce da solo (in loop-idle) se non ci sono campagne attive, quindi non c'è rischio a tenerlo sempre su.

---

## 11. Cosa manca per renderlo un prodotto vendibile

Lo stato attuale è un'**ossatura tecnica funzionante e multi-tenant-ready** (schema dati, provisioning automatizzato, 3 agenti configurabili da DB) — non ancora un prodotto SaaS completo. Elenco delle aree da coprire, in ordine indicativo di priorità:

### 11.1 Onboarding self-service
`admin_ui/` (vedi §14) copre ora la creazione tenant/provider/DID da form web e la modifica di `agent_configs` (prompt, provider LLM/STT/TTS, attivazione) senza SQL diretto o accesso SSH — `scripts/onboard_tenant.py` resta utilizzabile per l'onboarding scriptabile/bulk. Resta da fare: collegamento a un provider SIP con API (es. Twilio/Telnyx) per eliminare il passaggio manuale di creazione account, e un ruolo utente per-tenant (oggi `admin_ui` ha un solo utente amministratore globale, non self-service per il cliente finale).

### 11.2 Fatturazione e metering
`call_logs` già registra durata/esito per ogni chiamata — è la base per il metering, ma manca: aggregazione periodica per tenant, integrazione con un gestore pagamenti (Stripe è lo standard più diretto da integrare con un modello a consumo o abbonamento), piani/limiti (es. numero massimo di chiamate simultanee o al mese per tenant).

### 11.3 Sicurezza da irrobustire prima di andare in produzione con clienti reali
- **Credenziali provider SIP in chiaro** (`sip_providers.auth_password`) — deciso deliberatamente per velocità in questa fase, da cifrare (es. Fernet con chiave in variabile d'ambiente, mai nel DB) prima di gestire clienti paganti.
- **Porta SIP pubblica (5060) esposta a internet** — durante i test ho osservato traffico SIP non riconducibile ai nostri test (una risposta 486 verso un mittente/destinatario con numeri anomali, tipico di scanner automatici che bersagliano porte SIP pubbliche) — da valutare `allowed_addresses` più stringenti per provider IP-trunk, rate limiting, e monitoraggio per tentativi di toll fraud (uso non autorizzato del trunk outbound per chiamate a pagamento verso numeri a tariffazione speciale).
- **API key LiveKit unica per tutta la piattaforma** — oggi tutti i tenant condividono lo stesso progetto/chiave LiveKit (isolamento logico via nome room/dispatch rule, non via credenziali separate). Accettabile alla scala attuale, da rivalutare se un cliente enterprise richiede isolamento più forte.

### 11.4 Conformità normativa (importante per il Servizio 2 — chiamate outbound)
- **GDPR**: le chiamate vengono trascritte e loggate (`call_logs.transcript`) — servono base giuridica per il trattamento, informativa ai chiamati, tempi di conservazione/cancellazione dati, e un DPA (Data Processing Agreement) con ogni tenant se tratti dati per conto loro.
- **Normativa italiana sul telemarketing**: Registro Pubblico delle Opposizioni (RPO) — i numeri iscritti vanno esclusi dalle campagne `marketing`; oggi `crm_contacts.consent_status` gestisce l'opt-out manuale ma non c'è integrazione automatica con il RPO. Fasce orarie consentite per le chiamate commerciali (oggi gestite via `campaigns.calling_hours_*`, ma da verificare che i default rispettino la normativa vigente, non solo buon senso).

### 11.5 Affidabilità operativa
- **Retry delle chiamate non risposte** — `campaigns.retry_policy` esiste nello schema ma non è collegato a nessuna logica in `campaign_runner.py` (vedi nota in §6.2): oggi ogni target viene tentato una sola volta.
- **Rilevamento segreteria telefonica (AMD)** — oggi solo inferenza LLM (il modello deve "capirlo" dalla trascrizione), lento e non sempre affidabile; un vero segnale audio (rilevamento beep/pattern) renderebbe le campagne outbound più efficienti ed eviterebbe di "parlare" a una segreteria.
- **Registrazione delle chiamate** — `call_logs.recording_url` esiste nello schema ma non è ancora popolato: serve collegare l'Egress di LiveKit per registrare e salvare l'audio (utile sia per QA sia come prova in caso di contestazioni).
- **Osservabilità** — nessuna dashboard/alerting oggi; utile sapere in tempo reale se una chiave esterna (OpenAI/ElevenLabs/Deepgram) va in quota-exceeded, dato che è già successo durante i test di questa sessione senza alcun avviso proattivo.

### 11.6 Il blocco Ehiweb resta aperto
Finché non si risolve (deviazione URI lato provider, trunk business alternativo, o bridge/registrar dedicato — opzioni già discusse), il Servizio 1 (segreteria inbound) **non è vendibile per clienti che vogliono usare un provider a registrazione come Ehiweb Vivavox Free** — funziona invece regolarmente con qualunque provider a instradamento statico (business/trunk IP).

---

## 12. Come esporre i 3 servizi al pubblico (oggi sono raggiungibili solo da questa macchina)

Tutto quello che abbiamo testato finora gira in locale su questo server (worker Python avviati a mano, widget web su `localhost:3000` raggiunto solo tramite inoltro di porta). Per rendere ciascun servizio realmente utilizzabile da un cliente, manca un passaggio di esposizione pubblica diverso per ciascuno:

### 12.1 Servizio 1 — Segreteria inbound
Non richiede un sito web o un dominio dedicato: **è già "esposto" nel momento in cui un numero di telefono reale è collegato** (via `onboard_tenant.py`, §8) e il worker `inbound_secretary` è avviato in modo permanente (systemd o il `docker compose up -d` di produzione, non un processo lanciato a mano che muore alla chiusura del terminale). L'unico intervento residuo prima che sia vendibile per un cliente reale è la scelta del provider SIP (§2.1, §11.6) — con un provider a instradamento statico (non Ehiweb Free) funziona da subito.

### 12.2 Servizio 2 — Dialer outbound
Come sopra: nessun sito web necessario. Va reso **permanente** (`docker compose up -d`, non processi manuali) sia il worker `outbound_dialer` sia — soprattutto — `campaign_runner.py`, altrimenti le campagne create restano ferme a `status='running'` senza che nessuno le esegua non appena chiudi il terminale/la sessione corrente.

### 12.3 Servizio 3 — Bot visuale
Qui serve un'esposizione web vera:
- **Build di produzione**, non `pnpm dev` (server di sviluppo, lento e non pensato per traffico reale): `pnpm build && pnpm start`, oppure containerizzato come gli altri worker.
- **Un dominio pubblico con TLS** davanti al widget — l'infrastruttura Caddy già in uso in `/opt/livekit/` per LiveKit può essere estesa con un secondo `route` per il dominio del widget (es. `bot.squilla.me`), oppure un reverse proxy separato.
- **Lo script di embed** (`popup-embed-url` generato dalla UI stessa, vedi `components/welcome.tsx`) è il modo pensato per un cliente di incorporare il widget sul proprio sito con un tag `<script>` — oggi funziona già tecnicamente, va solo pubblicato su un dominio raggiungibile invece che su `localhost`.
- Ricordarsi di **ripristinare l'animazione dell'avatar** (nota in §6.3) prima di mostrarlo a un cliente/prospect — oggi è volutamente spoglia per il debug.

---

## 13. Spunti per pubblicizzare i 3 servizi (posizionamento, non marketing esecutivo)

Non è compito di questa piattaforma tecnica scrivere il piano marketing, ma alcuni angoli emergono direttamente dalle caratteristiche già costruite e testate:

- **Servizio 1 (segreteria inbound)** — il messaggio vendibile è "non perdi mai una chiamata, e chi risponde conosce già il cliente" (grazie al collegamento `crm_contacts`/`appointments` — la sezione gestionale non è un accessorio, è parte del prodotto). Target naturale: attività con alto volume di chiamate ripetitive (prenotazioni, informazioni orari, primo filtro clienti) dove un umano dedicato full-time non si giustifica economicamente.
- **Servizio 2 (dialer outbound)** — due messaggi distinti da non confondere nel materiale di vendita, dato che tecnicamente sono già due modalità separate (`campaign_type`): "campagne di recall/marketing automatizzate rispettose del consenso" (opt-out già nello schema) per chi fa attività commerciale telefonica, e "verifica automatica che le tue linee funzionino" (smoke-test) come servizio tecnico complementare, potenzialmente vendibile anche a chi non fa marketing ma vuole solo monitorare l'affidabilità della propria infrastruttura telefonica.
- **Servizio 3 (bot visuale)** — il differenziatore reale è "non un chatbot testuale, un assistente con cui si parla e ci si vede in faccia" sul sito del cliente — utile enfatizzare la demo dal vivo (proprio quella appena testata) più che descriverlo a parole, dato che l'impatto è principalmente visivo/esperienziale.

Per tutti e tre, il fatto che sia **la stessa piattaforma multi-tenant** dietro le quinte è un argomento B2B (per chi rivende a sua volta, es. agenzie/system integrator) più che B2C — vale la pena decidere se il go-to-market è diretto (un cliente finale per numero di telefono/sito) o a canale (partner che onboardano i propri clienti tramite `onboard_tenant.py` o una futura UI).

---

## 14. `admin_ui/` — UI di amministrazione (FastAPI + Tabler)

Sostituisce `scripts/onboard_tenant.py` come punto di ingresso principale per creare/gestire tenant. Riusa direttamente `common/models.py`, `common/db.py` e `common/sip_provisioning.py` — nessuna API separata, stesso DB `squilla`.

**Stack**: FastAPI, template server-side Jinja2 su [Tabler](https://tabler.io/) (tema admin open-source MIT, caricato via CDN jsDelivr — nessuna build JS necessaria), sessione cookie-firmata (`SessionMiddleware` + `SESSION_SECRET_KEY`), login singolo utente admin con password hashata bcrypt.

**Route principali**:
| Percorso | Funzione |
|---|---|
| `/login` | Form di accesso |
| `/` | Elenco tenant |
| `/tenants/new` | Crea tenant + provider SIP + primo DID, provisiona su LiveKit (equivalente a `onboard_tenant.py`) |
| `/tenants/{id}` | Anagrafica, provider, numeri (con `inbound_status`), agent configs, chiamate recenti |
| `/tenants/{id}/numbers/new` | Aggiunge un DID (provider esistente o nuovo) |
| `/tenants/{id}/agent-configs/{service_type}/edit` | Modifica prompt/lingua/provider LLM-STT-TTS/attivazione |

**Variabili `.env` richieste** (aggiunte a `.env.example`): `ADMIN_USERNAME`, `ADMIN_PASSWORD_HASH`, `SESSION_SECRET_KEY`. Generarle con:
```bash
./venv/bin/python3 scripts/generate_admin_credentials.py
```

**Avvio locale**:
```bash
set -a && source .env && set +a
./venv/bin/uvicorn admin_ui.main:app --host 127.0.0.1 --port 8000
```

**Produzione**: servizio `admin-ui` già aggiunto a `docker-compose.yaml` (ascolta solo su `127.0.0.1:8000`, host networking come gli altri worker — non esporlo direttamente). Davanti serve un reverse proxy con TLS per il vhost pubblico:

```caddyfile
# esempio Caddy — adattare se il server BackendPublicServices usa nginx/apache
console.squilla.me {
    reverse_proxy 127.0.0.1:8000
}
```

```nginx
# esempio nginx
server {
    server_name console.squilla.me;
    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

**Punto da verificare prima del deploy su un server diverso da questo** (`BackendPublicServices`, 51.38.165.250): `admin_ui` deve raggiungere MySQL (`172.16.0.3:3306`, oggi raggiungibile solo dalla rete privata di questo server) e l'API LiveKit (`LIVEKIT_URL`). Se `BackendPublicServices` non è sulla stessa rete privata, va aperta la porta MySQL verso quell'host (o instradato tramite VPN/tunnel) prima che l'app funzioni — altrimenti ogni pagina che legge/scrive tenant fallirà in produzione anche se il vhost e il TLS sono a posto.

**Nota di sicurezza**: `admin_ui` espone le credenziali SIP dei tenant (`auth_password`, oggi in chiaro nel DB, vedi §11.3) a chiunque acceda con le credenziali admin — un solo utente globale oggi, nessun ruolo per-tenant. Da irrobustire prima di condividere l'accesso con altre persone oltre l'amministratore.
