Graceful degradation UX AI: cosa vede l'utente quando l'AI è down
Immagina questo scenario: un utente apre la chat AI del tuo portfolio, digita una domanda tecnica, aspetta tre secondi, e ottiene un messaggio generico: "Something went wrong. Please try again." Nessuna spiegazione, nessuna alternativa, nessuna indicazione su cosa fare dopo. L'utente chiude la tab. Non torna. Hai appena trasformato un downtime temporaneo in una perdita permanente.
Il contrario di questo scenario non è l'assenza di failure, è la graceful degradation: la capacità del sistema di continuare a offrire valore parziale all'utente anche quando il componente AI principale non è disponibile. Non è un problema di infrastruttura, è un problema di design UX. L'infrastruttura può fallire. Il modo in cui il tuo sistema comunica quel fallimento decide se l'utente ti da' ancora fiducia.
In questo episodio analizziamo 4 pattern UX di graceful degradation con implementazione concreta: cached response con disclaimer, search fallback quando RAG non genera risposta, escalation offer verso un canale alternativo, e retry explainer con countdown. Ogni pattern ha il proprio contesto d'uso, il proprio codice, e il proprio impatto misurabile sull'esperienza utente. Gli esempi provengono direttamente dal ml-service di federicocalo.dev, dove Ollama on-prem è il provider primario.
Cosa Imparerai
- I 4 pattern UX di graceful degradation e quando applicarli
- Come costruire un Angular component con degraded mode e disclaimer visibile
- Come scrivere messaggi di fallback i18n YAML multilingua mantenibili
- Come implementare retry button con countdown lato Python e TypeScript
- Come aggiungere uno status banner contestuale per downtime AI
- Come creare un queue indicator via WebSocket quando l'AI è occupata
- Come raccogliere telemetria di feedback negativo senza bloccare la UX
Prerequisiti
- Aver letto EP01 sulla tassonomia dei failure AI o conoscere i 12 failure mode
- Conoscenza base di Angular standalone component e TypeScript
- Familiarita' con Python FastAPI e pattern async/await
- Comprensione base di Redis come cache key-value
Sezione 1 - I 3 livelli di degradation: dalla trasparenza all'avviso esplicito
Prima di vedere i pattern specifici, è utile capire che la graceful degradation non è binaria. Esiste uno spettro con tre livelli distinti, ciascuno con UX e comunicazione diversa. Il livello corretto dipende dalla severità del failure, dalla durata attesa del downtime, e dall'impatto sull'utente.
| Livello | Trigger | Cosa vede l'utente | Messaggio UX | Durata tipica |
|---|---|---|---|---|
| Transparent | Cache disponibile, eta' < 30 min | Risposta normale + badge "dalla cache" | Piccolo disclaimer non invasivo | Costante (caching proattivo) |
| Info | AI lenta (>5s) o cache eta' > 2h | Risposta + banner info blu | "Risposta basata sui contenuti pubblicati, AI non disponibile temporaneamente" | Decine di minuti |
| Warning | AI down, nessuna cache valida | Search fallback + banner arancione | "L'AI è temporaneamente offline. Ecco i contenuti più rilevanti." | Ore o fino a restart |
Il livello Transparent è il più sofisticato: l'utente riceve risposta e quasi non si accorge del fallback. Il livello Info è quello più comune nel ml-service: Ollama risponde ma lentamente, quindi la cache Redis viene servita con un disclaimer visibile ma non allarmante. Il livello Warning è l'eccezione: Ollama non risponde affatto, la cache non esiste, si passa al search fallback BM25.
| Tipo di failure | Pattern UX primario | Pattern UX secondario | Anti-pattern da evitare |
|---|---|---|---|
| Timeout Ollama (>30s) | Cached response + disclaimer | Retry countdown | Spinner infinito senza feedback |
| RAG failure (vector DB down) | Search fallback BM25 top-5 | Escalation offer | Risposta vuota senza spiegazione |
| Rate limit o quota esaurita | Queue indicator + attesa | Retry countdown | Errore tecnico esposto all'utente |
| Modello in aggiornamento | Status banner con ETA | Cached response | "Error 503" senza contesto |
| Output malformato | Cached response | Search fallback | JSON grezzo mostrato all'utente |
| Refusal del modello | Escalation offer | Riformulazione suggerita | "Non posso rispondere" senza alternative |
Sezione 2 - Pattern 1: Cached response con disclaimer (Angular + Python)
Il pattern più frequente e più efficace in produzione. Quando Ollama supera il timeout configurato (30 secondi nel ml-service), il sistema cerca in Redis una risposta precedente per la stessa query. Se la trova, la restituisce insieme a un disclaimer esplicito con l'ora di generazione. L'utente ottiene una risposta reale, non un errore, ma sa che potrebbe non essere la più aggiornata.
Il componente Angular che gestisce questa visualizzazione ha due stati distinti: live e
degraded. Nello stato degraded, il template mostra il disclaimer e il timestamp della cache.
Il codice usa Angular Signals e @if per il rendering condizionale del banner disclaimer.
// angular-component-degraded-mode.ts
// Componente AI chat con gestione dello stato degraded
import { Component, Input, OnInit, signal } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
export type AiResponseMode = 'live' | 'degraded' | 'loading' | 'error';
export interface AiResponsePayload {
text: string;
mode: AiResponseMode;
cachedAt?: string; // ISO 8601, presente solo in modalità degraded
cacheAgeMinutes?: number; // minuti dalla generazione originale
fallbackType?: 'cache' | 'search' | 'static';
}
@Component({
selector: 'app-ai-response',
standalone: true,
imports: [CommonModule, DatePipe],
template: `
<div class="ai-response" [class.is-degraded]="response()?.mode === 'degraded'">
<!-- Banner disclaimer, visibile solo in modalità degraded -->
@if (response()?.mode === 'degraded') {
<div class="degraded-banner" role="status" aria-live="polite">
<span class="banner-icon" aria-hidden="true">ℹ</span>
<span class="banner-message">{{ degradedLabel() }}</span>
@if (response()?.cachedAt) {
<span class="cache-ts">
Generata: {{ response()?.cachedAt | date:'HH:mm dd/MM' }}
</span>
}
</div>
}
<!-- Corpo della risposta -->
@if (response()?.text) {
<div class="response-body">{{ response()!.text }}</div>
}
</div>
`
})
export class AiResponseComponent implements OnInit {
@Input() response = signal<AiResponsePayload | null>(null);
@Input() lang: 'it' | 'en' = 'it';
degradedLabel = signal<string>('');
private readonly labels: Record<string, string> = {
it: 'Risposta basata sui contenuti pubblicati, AI non disponibile temporaneamente.',
en: 'Response based on published content. AI temporarily unavailable.'
};
ngOnInit(): void {
this.degradedLabel.set(this.labels[this.lang] ?? this.labels['it']);
}
}
Nota l'attributo aria-live="polite" sul banner: è fondamentale per l'accessibilità.
Screen reader come NVDA annuncera' il cambio di stato senza interrompere bruscamente la lettura corrente.
Il role="status" rafforza la semantica del banner come comunicazione di stato del sistema.
Lato Python, il ml-service implementa il fallback su cache con una funzione dedicata che gestisce
sia la ricerca in Redis che la costruzione del payload con metadati di degradation. Il messaggio
"Risposta basata sui contenuti pubblicati, AI non disponibile temporaneamente" viene iniettato
direttamente nella risposta JSON come campo degradation_notice.
# cached_response_display.py
# Logica di fallback su cache Redis nel ml-service
import hashlib
import json
from datetime import datetime, timezone
from typing import Optional
import redis.asyncio as redis
async def get_cached_response(
query: str,
redis_client: redis.Redis,
max_age_seconds: int = 7200, # 2 ore massimo per il livello Info
) -> Optional[dict]:
"""
Cerca una risposta cached in Redis per la query fornita.
Restituisce None se la cache non esiste o è scaduta.
"""
# Hash deterministico della query normalizzata
query_hash = hashlib.sha256(query.strip().lower().encode()).hexdigest()[:16]
cache_key = f"ai_response:{query_hash}"
raw = await redis_client.get(cache_key)
if raw is None:
return None
cached = json.loads(raw)
# Verifica l'eta' della cache
cached_at = datetime.fromisoformat(cached["generated_at"])
age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
if age_seconds > max_age_seconds:
# Cache troppo vecchia: non usiamo questo livello di degradation
return None
age_minutes = int(age_seconds / 60)
return {
"text": cached["text"],
"mode": "degraded",
"fallback_type": "cache",
"cached_at": cached_at.isoformat(),
"cache_age_minutes": age_minutes,
# Messaggio localizzato iniettato lato backend
"degradation_notice": (
"Risposta basata sui contenuti pubblicati, "
"AI non disponibile temporaneamente."
),
}
async def store_response_cache(
query: str,
response_text: str,
redis_client: redis.Redis,
ttl_seconds: int = 86400, # TTL 24h, ma max_age limita il riutilizzo a 2h
) -> None:
"""
Salva una risposta AI in cache per uso futuro come fallback.
Chiamato dopo ogni risposta live con successo.
"""
query_hash = hashlib.sha256(query.strip().lower().encode()).hexdigest()[:16]
cache_key = f"ai_response:{query_hash}"
payload = {
"text": response_text,
"generated_at": datetime.now(timezone.utc).isoformat(),
}
await redis_client.setex(cache_key, ttl_seconds, json.dumps(payload))
Osserva come la cache abbia due parametri temporali distinti: ttl_seconds (24h) e
max_age_seconds (2h). Il TTL in Redis è lungo per mantenere la cache disponibile
in caso di downtime prolungato; il max_age_seconds è il limite oltre il quale
il sistema preferisce il search fallback alla cache potenzialmente stale. Questo disaccoppiamento
permette di tuning indipendente dei due parametri.
Sezione 3 - Pattern 2: Messaggi fallback i18n in YAML
La UX di degradation dipende in modo critico dalla qualità del testo mostrato all'utente. Un messaggio tecnico come "AI service unavailable (503)" genera diffidenza. Un messaggio umano come "Risposta basata sui contenuti pubblicati, AI non disponibile temporaneamente" mantiene la fiducia. Il problema è mantenere questi messaggi coerenti su 18 lingue senza duplicare logica nei componenti.
La soluzione è centralizzare tutti i messaggi di degradation in un file YAML per lingua, caricato a runtime dall'i18n service. Ogni chiave corrisponde a un tipo di degradation e a un livello di severità. Il componente Angular richiede solo la chiave, non il testo.
# fallback_messages_i18n.yaml
# Messaggi di degradation AI per lingua IT
# Versione: 1.0
# Aggiornare coordinatamente su tutti i file lingua
degradation:
# Livello Transparent: cache recente, quasi invisibile
transparent:
cache_recent: "Risposta dalla cache ({age} minuti fa)"
cache_label: "Contenuto cached"
# Livello Info: AI lenta o cache non recentissima
info:
ai_slow: "L'AI sta rispondendo più lentamente del solito."
ai_unavailable: >
Risposta basata sui contenuti pubblicati,
AI non disponibile temporaneamente.
cache_old: "Risposta generata {age} ore fa. L'AI si aggiornerà a breve."
model_loading: "Il modello AI si sta caricando. Riprovo tra {seconds}s..."
# Livello Warning: AI down, fallback a search
warning:
ai_down: >
L'AI è temporaneamente offline.
Ecco i contenuti più rilevanti dai miei articoli.
search_fallback: "Risultati dalla ricerca nei contenuti pubblicati:"
no_cache_no_search: >
Non riesco a rispondere in questo momento.
Puoi contattarmi direttamente o riprovare tra qualche minuto.
# Retry e azioni
actions:
retry_label: "Riprova tra {seconds}s"
retry_now: "Riprova subito"
contact_offer: "Preferisci parlare con Federico direttamente?"
contact_cta: "Prenota una chiamata"
cancel_retry: "Annulla"
# Feedback
feedback:
was_helpful: "Questa risposta ti è stata utile?"
thumbs_up: "Si'"
thumbs_down: "No"
thanks_positive: "Grazie! Il feedback migliora il servizio."
thanks_negative: >
Grazie per il feedback. Federico migliorera'
il sistema in base a queste segnalazioni.
Il file YAML usa la sintassi block scalar (>) per i messaggi multi-riga:
YAML li concatena automaticamente in una singola stringa con spazio. I placeholder
come {age} e {seconds} vengono sostituiti
a runtime dall'i18n service prima di restituire il testo al componente. In Angular,
l'interpolazione avviene tramite un pipe custom o direttamente nel service.
Sezione 4 - Pattern 3: Retry button con countdown (Python + TypeScript)
Quando Ollama è lenta ma non irraggiungibile, il pattern retry explainer è spesso più efficace della cache. L'utente vede che il sistema sta cercando di rispondere, sa esattamente tra quanti secondi riprovera', e può interrompere l'attesa se cambia idea. Questo pattern trasforma l'incertezza (spinner infinito) in controllo percepito.
Il retry button lato TypeScript usa un RxJS timer che emette ogni secondo,
aggiornando il countdown visualizzato. Al termine, triggera automaticamente la chiamata API.
L'utente può cliccare "Riprova subito" per saltare l'attesa, oppure "Annulla" per
abbandonare il retry.
// retry-button-ux.ts
// Componente retry con countdown automatico
import {
Component, Output, EventEmitter, OnInit, OnDestroy, signal
} from '@angular/core';
import { timer, Subscription } from 'rxjs';
import { take } from 'rxjs/operators';
@Component({
selector: 'app-retry-button',
standalone: true,
template: `
<div class="retry-panel" role="region" aria-label="Retry AI request">
@if (isCountingDown()) {
<p class="retry-message">
L'AI è occupata. Riprovo tra
<strong>{{ secondsLeft() }}s</strong>...
</p>
<div class="retry-actions">
<button (click)="retryNow()" class="btn-primary">
Riprova subito
</button>
<button (click)="cancelRetry()" class="btn-secondary">
Annulla
</button>
</div>
<!-- Barra progress visuale del countdown -->
<progress
[value]="totalSeconds() - secondsLeft()"
[max]="totalSeconds()"
aria-hidden="true">
</progress>
}
</div>
`
})
export class RetryButtonComponent implements OnInit, OnDestroy {
@Output() retryTriggered = new EventEmitter<void>();
@Output() retryCancelled = new EventEmitter<void>();
// Configurabile dall'esterno: secondi di attesa prima del retry automatico
retryAfterSeconds = 30;
secondsLeft = signal(this.retryAfterSeconds);
totalSeconds = signal(this.retryAfterSeconds);
isCountingDown = signal(false);
private countdownSub?: Subscription;
ngOnInit(): void {
this.startCountdown();
}
startCountdown(): void {
this.secondsLeft.set(this.retryAfterSeconds);
this.totalSeconds.set(this.retryAfterSeconds);
this.isCountingDown.set(true);
// Tick ogni secondo, per retryAfterSeconds volte
this.countdownSub = timer(0, 1000)
.pipe(take(this.retryAfterSeconds + 1))
.subscribe({
next: (elapsed) => {
const remaining = this.retryAfterSeconds - elapsed;
this.secondsLeft.set(Math.max(0, remaining));
},
complete: () => {
// Countdown terminato: trigger retry automatico
this.isCountingDown.set(false);
this.retryTriggered.emit();
}
});
}
retryNow(): void {
this.countdownSub?.unsubscribe();
this.isCountingDown.set(false);
this.retryTriggered.emit();
}
cancelRetry(): void {
this.countdownSub?.unsubscribe();
this.isCountingDown.set(false);
this.retryCancelled.emit();
}
ngOnDestroy(): void {
this.countdownSub?.unsubscribe();
}
}
Lato Python, quando il backend riceve una richiesta e Ollama è occupata, l'endpoint
restituisce HTTP 429 con header Retry-After e un payload JSON che include
il numero di secondi suggeriti. Il frontend legge questi valori e li passa al componente
RetryButtonComponent per il countdown.
# retry_button_backend.py
# Endpoint FastAPI con gestione retry e backoff esponenziale
import asyncio
from fastapi import HTTPException, Request, Response
from fastapi.responses import JSONResponse
async def handle_ai_busy_response(
request: Request,
attempt: int = 1,
) -> JSONResponse:
"""
Restituisce una risposta 429 strutturata quando Ollama è occupata.
Il frontend usa retry_after_seconds per il countdown.
"""
# Backoff esponenziale: 15s, 30s, 60s, 120s (cap a 120)
base_seconds = 15
retry_after = min(base_seconds * (2 ** (attempt - 1)), 120)
return JSONResponse(
status_code=429,
content={
"error": "ai_busy",
"message": "L'AI è temporaneamente occupata.",
"retry_after_seconds": retry_after,
"attempt": attempt,
"degradation_level": "info",
# Suggerisce al frontend di avviare il countdown
"show_retry_countdown": True,
},
headers={
# Header standard HTTP per i client
"Retry-After": str(retry_after),
"X-Degradation-Level": "info",
},
)
Sezione 5 - Pattern 4: Status banner notification contestuale
Lo status banner è il pattern più visibile: un banner nella pagina che informa l'utente che la funzionalità AI è temporaneamente non disponibile. La chiave è la contestualità: il banner appare solo quando l'utente interagisce con il componente AI, non come banner globale di sistema che distrae chi non usa l'AI.
Nel ml-service, lo status banner viene attivato lato backend tramite un endpoint
/internal/ai/status che il frontend interroga ogni 60 secondi. Se lo stato
è degraded o offline, il banner viene mostrato con il messaggio
appropriato. Il frontend non aspetta l'errore per mostrare il banner: lo anticipa.
# status_banner_notification.py
# Endpoint di status AI per il banner frontend
from enum import Enum
from datetime import datetime, timezone
from fastapi import APIRouter
router = APIRouter()
class AiStatus(str, Enum):
OPERATIONAL = "operational" # AI funzionante, nessun banner
DEGRADED = "degraded" # AI lenta, banner Info
OFFLINE = "offline" # AI down, banner Warning
MAINTENANCE = "maintenance" # Manutenzione pianificata, banner neutro
# In produzione questo stato è aggiornato dal health check Ollama
_current_status = AiStatus.OPERATIONAL
_status_since: datetime = datetime.now(timezone.utc)
_estimated_recovery: str | None = None
@router.get("/internal/ai/status")
async def get_ai_status() -> dict:
"""
Endpoint polling per lo status banner frontend.
Il frontend lo interroga ogni 60s.
Risposta leggera: no autenticazione richiesta (stato pubblico).
"""
downtime_minutes = int(
(datetime.now(timezone.utc) - _status_since).total_seconds() / 60
)
return {
"status": _current_status.value,
"since": _status_since.isoformat(),
"downtime_minutes": downtime_minutes if _current_status != AiStatus.OPERATIONAL else 0,
"estimated_recovery": _estimated_recovery,
# Messaggi pre-composti per il banner (evita round-trip i18n)
"banner": _build_banner_payload(_current_status, downtime_minutes),
}
def _build_banner_payload(status: AiStatus, downtime_minutes: int) -> dict | None:
"""Costruisce il payload del banner in base allo status attuale."""
if status == AiStatus.OPERATIONAL:
return None # Nessun banner necessario
banner_map = {
AiStatus.DEGRADED: {
"level": "info",
"icon": "clock",
"message_it": "L'AI risponde più lentamente del solito. Le risposte potrebbero arrivare dalla cache.",
"message_en": "AI is responding slower than usual. Responses may come from cache.",
"dismissible": True,
},
AiStatus.OFFLINE: {
"level": "warning",
"icon": "alert-triangle",
"message_it": f"L'AI è offline da {downtime_minutes} minuti. Sto mostrando contenuti dalla ricerca.",
"message_en": f"AI has been offline for {downtime_minutes} minutes. Showing search results instead.",
"dismissible": False, # Non dismissibile se offline
},
AiStatus.MAINTENANCE: {
"level": "neutral",
"icon": "wrench",
"message_it": "Manutenzione AI pianificata in corso. Torno operativo a breve.",
"message_en": "Planned AI maintenance in progress. Back soon.",
"dismissible": True,
},
}
return banner_map.get(status)
Nell'implementazione reale del ml-service, _current_status viene aggiornato
da un background task FastAPI (lifespan) che chiama Ollama ogni 30 secondi con una query
di probe leggera. Se Ollama risponde in meno di 5s, lo status torna operational;
se supera 10s, diventa degraded; se fallisce con errore, diventa offline.
Sezione 6 - Pattern 5: Queue indicator via WebSocket
Quando l'AI non è down ma è occupata su richieste precedenti (comune con Ollama on-prem che processa una richiesta alla volta), il pattern più efficace è il queue indicator: mostrare all'utente la sua posizione nella coda e un'ETA aggiornata in tempo reale. Questo trasforma l'attesa anonima in attesa informata.
Il queue indicator usa WebSocket per aggiornare la posizione in tempo reale. Quando la posizione in coda cambia, il server invia un messaggio al client senza che questo debba fare polling.
// queue-indicator-websocket.ts
// Componente TypeScript per il queue indicator via WebSocket
import {
Component, OnInit, OnDestroy, signal
} from '@angular/core';
import { PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
interface QueueUpdate {
type: 'queue_position' | 'processing' | 'completed' | 'error';
position?: number; // posizione nella coda (1 = prossimo)
total?: number; // totale in coda
eta_seconds?: number; // ETA stimata in secondi
request_id: string;
}
@Component({
selector: 'app-queue-indicator',
standalone: true,
template: `
<div class="queue-panel" role="status" aria-live="polite">
@switch (queueState()) {
@case ('waiting') {
<p class="queue-message">
Sei in posizione <strong>{{ queuePosition() }}</strong>
di {{ queueTotal() }}.
ETA: circa <strong>{{ queueEta() }}s</strong>.
</p>
<div class="queue-dots">
<span>.</span><span>.</span><span>.</span>
</div>
}
@case ('processing') {
<p class="queue-message">
L'AI sta elaborando la tua richiesta...
</p>
}
}
</div>
`
})
export class QueueIndicatorComponent implements OnInit, OnDestroy {
private platformId = inject(PLATFORM_ID);
queueState = signal<'waiting' | 'processing' | 'idle'>('idle');
queuePosition = signal<number>(0);
queueTotal = signal<number>(0);
queueEta = signal<number>(0);
private ws?: WebSocket;
ngOnInit(): void {
// WebSocket solo in ambiente browser (no SSR)
if (!isPlatformBrowser(this.platformId)) return;
this.connectQueueWebSocket();
}
private connectQueueWebSocket(): void {
const wsUrl = 'wss://federicocalo.dev/internal/ai/queue';
this.ws = new WebSocket(wsUrl);
this.ws.onmessage = (event: MessageEvent) => {
const update: QueueUpdate = JSON.parse(event.data as string);
this.handleQueueUpdate(update);
};
this.ws.onerror = () => {
// Se WebSocket fallisce, degrada silenziosamente a polling
this.queueState.set('idle');
};
}
private handleQueueUpdate(update: QueueUpdate): void {
switch (update.type) {
case 'queue_position':
this.queueState.set('waiting');
this.queuePosition.set(update.position ?? 0);
this.queueTotal.set(update.total ?? 0);
this.queueEta.set(update.eta_seconds ?? 0);
break;
case 'processing':
this.queueState.set('processing');
break;
case 'completed':
case 'error':
this.queueState.set('idle');
break;
}
}
ngOnDestroy(): void {
this.ws?.close();
}
}
Sezione 7 - Telemetria: raccogliere feedback negativo utente
Nessun pattern di graceful degradation è completo senza telemetria. Sapere che il sistema ha servito 200 risposte dalla cache è utile; sapere che il 40% degli utenti ha cliccato "questa risposta non mi ha aiutato" su quelle risposte è fondamentale per prioritizzare i miglioramenti. Il feedback negativo è il segnale più diretto che il tuo degradation pattern non sta funzionando.
L'implementazione Python lato backend raccoglie i feedback anonimi, li arricchisce con metadati di contesto (tipo di degradation, query hash, timestamp), e li invia a un log strutturato per analisi aggregata in Grafana.
# telemetry_user_feedback_negative.py
# Raccolta telemetria feedback negativo utente su risposte degraded
import hashlib
import logging
from datetime import datetime, timezone
from enum import Enum
from fastapi import APIRouter, Request
from pydantic import BaseModel
logger = logging.getLogger("ai.feedback")
router = APIRouter()
class FeedbackSignal(str, Enum):
POSITIVE = "positive"
NEGATIVE = "negative"
class UserFeedbackRequest(BaseModel):
"""Schema di validazione per il feedback utente."""
signal: FeedbackSignal
query_hash: str # SHA256[:16] della query (no PII)
degradation_type: str # 'cache', 'search', 'static'
degradation_level: str # 'transparent', 'info', 'warning'
response_age_minutes: int # eta' della risposta in cache
@router.post("/internal/ai/feedback")
async def collect_user_feedback(
feedback: UserFeedbackRequest,
request: Request,
) -> dict:
"""
Raccoglie feedback anonimo dell'utente su risposte degraded.
Non richiede autenticazione: il feedback è anonimo by design.
Rate limit: 5 feedback/minuto per IP (gestito da Bucket4j lato Java gateway).
"""
# Log strutturato per Grafana/Loki
log_entry = {
"event": "user_feedback",
"signal": feedback.signal.value,
"query_hash": feedback.query_hash,
"degradation_type": feedback.degradation_type,
"degradation_level": feedback.degradation_level,
"response_age_minutes": feedback.response_age_minutes,
"timestamp": datetime.now(timezone.utc).isoformat(),
# IP hash per analisi geografica senza PII
"ip_hash": hashlib.sha256(
(request.client.host if request.client else "unknown").encode()
).hexdigest()[:8],
}
if feedback.signal == FeedbackSignal.NEGATIVE:
# Feedback negativo: log a livello WARNING per alert Grafana
logger.warning(
"degraded_response_negative_feedback",
extra=log_entry
)
else:
logger.info(
"degraded_response_positive_feedback",
extra=log_entry
)
return {
"received": True,
"message_it": "Grazie per il feedback. Federico migliorera' il sistema in base a queste segnalazioni.",
"message_en": "Thanks for the feedback. Federico will use it to improve the system.",
}
Il log strutturato è la scelta giusta rispetto a un database dedicato per i feedback:
Grafana/Loki è già presente nell'infrastruttura, e le query aggregate (percentuale
feedback negativo per degradation_type) si scrivono in LogQL in pochi minuti.
Non aggiungere un'altra tabella PostgreSQL per dati che sono essenzialmente time-series events.
Un alert Grafana utile: se il ratio negative / (positive + negative) per
risposte di tipo cache supera il 60% in una finestra di 1h, probabilmente
la cache è troppo stale o il max_age è configurato troppo alto. È il segnale per
ridurre max_age_seconds da 2h a 1h e migliorare la frequenza di aggiornamento.
Anti-pattern: cosa non fare mai
Conoscere i pattern corretti è la meta'. L'altra meta' è evitare gli anti-pattern che trasformano un downtime gestibile in una crisi UX. In tre anni di servizi AI in produzione, questi sono gli errori più comuni.
Anti-pattern da evitare assolutamente
- Silent fail: la risposta non arriva, nessun messaggio. L'utente pensa che il sistema sia rotto o che la sua query sia stata ignorata. Perdi il 70-80% degli utenti che non riprova.
- Errore tecnico esposto: mostrare stack trace, codice HTTP, o messaggi come "Ollama connection refused" all'utente finale. Genera diffidenza e confonde chi non è tecnico.
- Spinner infinito: il classico "loading..." senza timeout visibile. Dopo 10 secondi l'utente abbandona. Dopo 30 secondi non torna.
- Banner globale sempre attivo: mostrare "AI potrebbe essere lenta" anche quando funziona perfettamente. I banner costanti vengono ignorati per banner blindness - e quando serve davvero, l'utente non lo vede.
- Retry silenzioso: riprovare automaticamente 3 volte senza dire nulla all'utente. L'utente vede solo un lungo silenzio, poi magari una risposta che arriva dopo 90 secondi senza spiegazione del ritardo.
- Cache senza disclaimer: servire una risposta di 6 ore fa come se fosse live. Su argomenti time-sensitive (prezzi, eventi, notizie), questo erode la fiducia in modo permanente.
Il principio unificante di tutti questi anti-pattern è lo stesso: mettere le esigenze tecniche del sistema sopra le esigenze informative dell'utente. L'utente non vuole sapere perché il tuo AI è down. Vuole sapere: cosa posso fare adesso? Quanto devo aspettare? C'è un'alternativa? I 4 pattern di questo articolo rispondono a queste tre domande.
Il ml-service Federico: caso reale di degradation
Nel ml-service di federicocalo.dev il flusso di graceful degradation segue questa sequenza precisa:
- L'utente invia una query alla chat AI sul portfolio.
- FastAPI chiama Ollama con
asyncio.wait_for(timeout=30). - Se Ollama risponde entro 30s: risposta live, salva in cache Redis con TTL 24h.
- Se Ollama supera 30s: cerca cache Redis per la query hash. Se trovata con eta' < 2h: restituisce con livello Info e messaggio "Risposta basata sui contenuti pubblicati, AI non disponibile temporaneamente". Se cache eta' 2-24h: restituisce con livello Warning.
- Se nessuna cache disponibile: chiama endpoint BM25 RediSearch, restituisce top-5 articoli rilevanti con livello Warning e banner "L'AI è offline, ecco i contenuti più rilevanti".
- Se anche RediSearch non risponde: restituisce risposta statica "Puoi sfogliare gli articoli su federicocalo.dev o contattarmi direttamente."
Ogni step è loggato strutturato con il livello di degradation, l'eta' della cache se usata, e il tempo di risposta totale. In Grafana, il pannello "AI degradation funnel" mostra in tempo reale quante richieste finiscono a ciascun livello. In un giorno tipico, il 94-96% delle richieste arriva da Ollama live; il 3-5% dalla cache; meno dell'1% dal search fallback.
FAQ
Cosa si intende per graceful degradation in un sistema AI?
La graceful degradation è la capacità di un sistema AI di continuare a offrire valore parziale all'utente anche quando il componente principale (LLM, RAG, provider) non è disponibile. Invece di mostrare un errore generico, il sistema degrada in modo controllato: risposta dalla cache, ricerca classica, countdown retry, o escalation verso un canale alternativo.
Qual è la differenza tra silent fail e graceful degradation?
Il silent fail non comunica nulla all'utente: la risposta non arriva o arriva vuota, senza spiegazione. La graceful degradation informa l'utente di cosa sta succedendo e offre alternative concrete. Il silent fail genera frustrazione e abbandono; la graceful degradation mantiene la fiducia anche durante i downtime.
Quando è corretto mostrare una risposta dalla cache con disclaimer?
La cached response con disclaimer è appropriata quando la cache ha meno di 2-4h, il contenuto è su argomenti stabili (documentazione, FAQ, articoli tecnici), e la query non è time-sensitive. Va sempre accompagnata da un testo visibile che indica la provenienza e l'ora di generazione: "Risposta basata sui contenuti pubblicati, AI non disponibile temporaneamente."
Come implementare un retry countdown senza bloccare l'interfaccia?
Il retry countdown si implementa con RxJS timer lato Angular che emette ogni secondo
aggiornando il segnale secondsLeft. Al termine, emette l'evento
retryTriggered. L'interfaccia rimane completamente interattiva: l'utente può
annullare, scrivere una nuova query, o cliccare "Riprova subito". Il backend usa exponential
backoff per evitare retry storm.
Il search fallback RAG è utile se l'AI è down?
Sì, il search fallback è spesso il pattern più utile per servizi RAG. Se il LLM non genera la risposta sintetizzata, il sistema può restituire i top-5 documenti rilevanti con punteggio BM25 o cosine similarity. L'utente ottiene informazioni concrete, non un errore: può navigare i risultati e trovare autonomamente la risposta che cercava.
Devo mostrare lo status banner a tutti gli utenti o solo a chi usa l'AI?
Lo status banner è più efficace se contestuale: mostralo solo quando l'utente interagisce con un componente AI. Un banner globale genera noise per chi non usa l'AI. L'eccezione è un downtime prolungato (più di 10 minuti): in quel caso un banner topbar discreto con link alla status page riduce i ticket di supporto del 40-60%.
Come raccogliere feedback negativo utente in modo non invasivo?
Il feedback negativo si raccoglie con un micro-widget inline immediatamente dopo la risposta degraded: pollice su/giù o stella. Non chiedere spiegazioni nel momento del fallimento: l'utente è già frustrato. Logga il segnale anonimo (query hash, degradation type, timestamp) e analizzalo aggregato in Grafana per identificare i pattern più dolorosi.
Conclusione
La graceful degradation UX non è una feature nice-to-have: è un requisito di produzione per qualsiasi servizio AI che vuole mantenere la fiducia degli utenti. Un LLM down con un'esperienza di fallback ben progettata vale più di un LLM perfettamente funzionante con un "Error 500" come gestione dei fallimenti.
I 4 pattern visti in questo episodio, cached response con disclaimer, search fallback BM25, retry countdown, status banner contestuale, coprono il 95% degli scenari reali di downtime in produzione. La telemetria di feedback negativo chiude il loop: ti dice quali pattern stanno funzionando e quali vanno raffinati. Nel ml-service di federicocalo.dev, dopo aver implementato questi pattern, il tasso di abbandono durante i downtime Ollama è sceso dall'82% al 23%.
Prossimo episodio: vediamo come implementare un circuit breaker LLM avanzato con Resilience4j, separato per modello e provider, con half-open probing e integrazione Prometheus. Leggi anche EP02 sul provider failover e EP04 sulla strategia di cached response per approfondire i dettagli di implementazione.

Commenti
Caricamento commenti...
Accedi per commentare