Response:
 ```python
"""
Sunaiva AI - Conversation Summary Generator
==========================================

Production-ready implementation for TW-INFRA-22.
Generates AI summaries using Gemini Flash with structured output extraction.

Architecture:
- FastAPI async endpoints
- PostgreSQL (asyncpg) for persistent storage
- Redis for caching and job queue
- Background task processing for non-blocking generation
- Circuit breaker pattern for external API resilience
"""

import asyncio
import json
import logging
from datetime import datetime
from enum import Enum
from typing import Optional, List, Dict, Any, AsyncGenerator
from uuid import UUID, uuid4
import os

import asyncpg
import redis.asyncio as redis
from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks, Query, status
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, Field, ConfigDict, field_validator
from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential,
    retry_if_exception_type,
    before_sleep_log
)
import google.generativeai as genai
from google.generativeai.types import GenerationConfig
from google.api_core import exceptions as google_exceptions

# Configure logging
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
struct_logger = logging.getLogger("sunaiva.structured")
struct_logger.setLevel(logging.INFO)

# ============================================================================
# Constants & Configuration
# ============================================================================

GEMINI_MODEL = "gemini-1.5-flash-002"
MAX_TRANSCRIPT_LENGTH = 100000  # Gemini Flash context window ~1M tokens, but we limit for cost
SUMMARY_CACHE_TTL = 3600  # 1 hour
BULK_BATCH_SIZE = 100

# Prompt template optimized for business conversation extraction
SUMMARY_PROMPT_TEMPLATE = """Analyze the following business phone conversation transcript and provide a structured summary.

Transcript:
```
{transcript}
```

Instructions:
1. Identify the caller's primary intent (why they called)
2. Determine the outcome (resolved, unresolved, appointment scheduled, etc.)
3. Extract specific action items mentioned (follow-ups, callbacks, documents needed)
4. Provide a brief 2-3 sentence narrative summary

Respond ONLY in valid JSON format with this structure:
{{
    "intent": "string describing caller's goal",
    "outcome": "string describing resolution status",
    "action_items": ["list", "of", "specific", "actions"],
    "narrative_summary": "brief human-readable summary"
}}

If the transcript is unclear, incomplete, or not a real conversation, set intent to "unclear" and provide minimal action items."""

# ============================================================================
# Database Models (SQL Schema)
# ============================================================================

"""
-- Migration: 004_add_conversation_summaries.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE IF NOT EXISTS conversation_summaries (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,

    -- Structured extraction fields
    intent VARCHAR(500),
    outcome VARCHAR(500),
    action_items JSONB DEFAULT '[]'::jsonb,
    narrative_summary TEXT,

    -- Metadata
    raw_response TEXT,  -- Full AI response for debugging
    confidence_score FLOAT CHECK (confidence_score >= 0 AND confidence_score <= 1),
    model_version VARCHAR(50) DEFAULT 'gemini-1.5-flash',

    -- Processing state
    status VARCHAR(50) DEFAULT 'pending', -- pending, completed, failed
    error_message TEXT,

    -- Timestamps
    generated_at TIMESTAMP WITH TIME ZONE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

    CONSTRAINT unique_conversation_summary UNIQUE (conversation_id)
);

CREATE INDEX idx_conversation_summaries_conversation_id ON conversation_summaries(conversation_id);
CREATE INDEX idx_conversation_summaries_status ON conversation_summaries(status) WHERE status = 'pending';
CREATE INDEX idx_conversation_summaries_generated_at ON conversation_summaries(generated_at);

-- Trigger for updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ language 'plpgsql';

CREATE TRIGGER update_conversation_summaries_updated_at
    BEFORE UPDATE ON conversation_summaries
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();
"""

# ============================================================================
# Pydantic Models (Request/Response/Domain)
# ============================================================================

class SummaryStatus(str, Enum):
    PENDING = "pending"
    COMPLETED = "completed"
    FAILED = "failed"

class ConversationSummaryBase(BaseModel):
    """Base model for conversation summary data."""
    intent: Optional[str] = Field(None, max_length=500, description="Caller's primary intent")
    outcome: Optional[str] = Field(None, max_length=500, description="Resolution outcome")
    action_items: List[str] = Field(default_factory=list, description="Extracted action items")
    narrative_summary: Optional[str] = Field(None, description="Human-readable summary")
    confidence_score: Optional[float] = Field(None, ge=0, le=1, description="AI confidence score")

class ConversationSummaryCreate(ConversationSummaryBase):
    """Model for creating a summary record."""
    conversation_id: UUID
    model_version: str = GEMINI_MODEL
    raw_response: Optional[str] = None
    status: SummaryStatus = SummaryStatus.COMPLETED
    generated_at: datetime = Field(default_factory=lambda: datetime.utcnow())

class ConversationSummaryUpdate(BaseModel):
    """Partial update model for regeneration."""
    intent: Optional[str] = None
    outcome: Optional[str] = None
    action_items: Optional[List[str]] = None
    narrative_summary: Optional[str] = None
    confidence_score: Optional[float] = None
    raw_response: Optional[str] = None
    status: Optional[SummaryStatus] = None
    error_message: Optional[str] = None
    generated_at: Optional[datetime] = None

class ConversationSummaryInDB(ConversationSummaryBase):
    """Full database model with metadata."""
    model_config = ConfigDict(from_attributes=True)

    id: UUID
    conversation_id: UUID
    model_version: str
    raw_response: Optional[str] = None
    status: SummaryStatus
    error_message: Optional[str] = None
    created_at: datetime
    updated_at: datetime
    generated_at: Optional[datetime] = None

class SummaryResponse(ConversationSummaryInDB):
    """API Response model."""
    pass

class BulkRegenerateRequest(BaseModel):
    """Request model for bulk regeneration."""
    business_id: Optional[UUID] = Field(None, description="Filter by business")
    date_from: Optional[datetime] = Field(None, description="Start date filter")
    date_to: Optional[datetime] = Field(None, description="End date filter")
    only_missing: bool = Field(True, description="Only regenerate missing summaries")
    dry_run: bool = Field(False, description="Count only, don't execute")

    @field_validator('date_to')
    @classmethod
    def validate_date_range(cls, v: Optional[datetime], info) -> Optional[datetime]:
        if v and info.data.get('date_from') and v < info.data['date_from']:
            raise ValueError('date_to must be after date_from')
        return v

class BulkRegenerateResponse(BaseModel):
    """Response model for bulk operations."""
    total_conversations: int
    processed: int
    failed: int
    job_id: Optional[str] = None
    estimated_completion_time: Optional[int] = None  # seconds

class ConversationTranscript(BaseModel):
    """Minimal conversation model for summary generation."""
    id: UUID
    business_id: UUID
    transcript: Optional[str] = None
    ended_at: Optional[datetime] = None

# ============================================================================
# Repository Layer (Database Access)
# ============================================================================

class SummaryRepository:
    """Repository for conversation summary database operations."""

    def __init__(self, pool: asyncpg.Pool):
        self.pool = pool

    async def get_by_conversation_id(
        self,
        conversation_id: UUID
    ) -> Optional[ConversationSummaryInDB]:
        """Fetch summary by conversation ID."""
        async with self.pool.acquire() as conn:
            row = await conn.fetchrow(
                """
                SELECT * FROM conversation_summaries
                WHERE conversation_id = $1
                """,
                conversation_id
            )
            if row:
                return ConversationSummaryInDB.model_validate(dict(row))
            return None

    async def create_or_update(
        self,
        data: ConversationSummaryCreate
    ) -> ConversationSummaryInDB:
        """Upsert summary record (idempotent operation)."""
        async with self.pool.acquire() as conn:
            row = await conn.fetchrow(
                """
                INSERT INTO conversation_summaries (
                    conversation_id, intent, outcome, action_items, narrative_summary,
                    confidence_score, model_version, raw_response, status, generated_at
                ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
                ON CONFLICT (conversation_id)
                DO UPDATE SET
                    intent = EXCLUDED.intent,
                    outcome = EXCLUDED.outcome,
                    action_items = EXCLUDED.action_items,
                    narrative_summary = EXCLUDED.narrative_summary,
                    confidence_score = EXCLUDED.confidence_score,
                    model_version = EXCLUDED.model_version,
                    raw_response = EXCLUDED.raw_response,
                    status = EXCLUDED.status,
                    generated_at = EXCLUDED.generated_at,
                    error_message = NULL,
                    updated_at = NOW()
                RETURNING *
                """,
                data.conversation_id,
                data.intent,
                data.outcome,
                json.dumps(data.action_items),
                data.narrative_summary,
                data.confidence_score,
                data.model_version,
                data.raw_response,
                data.status.value,
                data.generated_at
            )
            return ConversationSummaryInDB.model_validate(dict(row))

    async def mark_failed(
        self,
        conversation_id: UUID,
        error_message: str
    ) -> None:
        """Mark a summary generation as failed."""
        async with self.pool.acquire() as conn:
            await conn.execute(
                """
                INSERT INTO conversation_summaries (
                    conversation_id, status, error_message, generated_at
                ) VALUES ($1, $2, $3, NOW())
                ON CONFLICT (conversation_id)
                DO UPDATE SET
                    status = EXCLUDED.status,
                    error_message = EXCLUDED.error_message,
                    updated_at = NOW()
                """,
                conversation_id,
                SummaryStatus.FAILED.value,
                error_message[:1000]  # Limit error message size
            )

    async def get_conversations_needing_summary(
        self,
        business_id: Optional[UUID] = None,
        date_from: Optional[datetime] = None,
        date_to: Optional[datetime] = None,
        only_missing: bool = True,
        limit: int = BULK_BATCH_SIZE,
        offset: int = 0
    ) -> List[ConversationTranscript]:
        """Fetch conversations that need summary generation."""
        conditions = ["c.ended_at IS NOT NULL"]
        params = []
        param_idx = 1

        if business_id:
            conditions.append(f"c.business_id = ${param_idx}")
            params.append(business_id)
            param_idx += 1

        if date_from:
            conditions.append(f"c.ended_at >= ${param_idx}")
            params.append(date_from)
            param_idx += 1

        if date_to:
            conditions.append(f"c.ended_at <= ${param_idx}")
            params.append(date_to)
            param_idx += 1

        if only_missing:
            conditions.append(f"NOT EXISTS (SELECT 1 FROM conversation_summaries cs WHERE cs.conversation_id = c.id AND cs.status = 'completed')")

        where_clause = " AND ".join(conditions)

        query = f"""
            SELECT c.id, c.business_id, c.transcript, c.ended_at
            FROM conversations c
            WHERE {where_clause}
            ORDER BY c.ended_at DESC
            LIMIT ${param_idx} OFFSET ${param_idx + 1}
        """
        params.extend([limit, offset])

        async with self.pool.acquire() as conn:
            rows = await conn.fetch(query, *params)
            return [ConversationTranscript.model_validate(dict(row)) for row in rows]

    async def count_conversations_needing_summary(
        self,
        business_id: Optional[UUID] = None,
        date_from: Optional[datetime] = None,
        date_to: Optional[datetime] = None,
        only_missing: bool = True
    ) -> int:
        """Count conversations needing summaries (for pagination/job sizing)."""
        conditions = ["c.ended_at IS NOT NULL"]
        params = []
        param_idx = 1

        if business_id:
            conditions.append(f"c.business_id = ${param_idx}")
            params.append(business_id)
            param_idx += 1

        if date_from:
            conditions.append(f"c.ended_at >= ${param_idx}")
            params.append(date_from)
            param_idx += 1

        if date_to:
            conditions.append(f"c.ended_at <= ${param_idx}")
            params.append(date_to)
            param_idx += 1

        if only_missing:
            conditions.append(f"NOT EXISTS (SELECT 1 FROM conversation_summaries cs WHERE cs.conversation_id = c.id AND cs.status = 'completed')")

        where_clause = " AND ".join(conditions)

        query = f"""
            SELECT COUNT(*)
            FROM conversations c
            WHERE {where_clause}
        """

        async with self.pool.acquire() as conn:
            return await conn.fetchval(query, *params)

# ============================================================================
# Service Layer (AI Integration)
# ============================================================================

class GeminiService:
    """Service for interacting with Google's Gemini Flash API."""

    def __init__(self, api_key: str):
        genai.configure(api_key=api_key)
        self.model = genai.GenerativeModel(GEMINI_MODEL)
        self.generation_config = GenerationConfig(
            temperature=0.1,  # Low temperature for consistent structured output
            top_p=0.8,
            top_k=40,
            max_output_tokens=2048,
            response_mime_type="application/json"
        )

    @retry(
        retry=retry_if_exception_type((
            google_exceptions.ResourceExhausted,
            google_exceptions.ServiceUnavailable,
            google_exceptions.DeadlineExceeded,
            asyncio.TimeoutError
        )),
        stop=stop_after_attempt(3),
        wait=wait_exponential(multiplier=1, min=2, max=10),
        before_sleep=before_sleep_log(logger, logging.WARNING),
        reraise=True
    )
    async def generate_summary(self, transcript: str) -> Dict[str, Any]:
        """
        Generate structured summary from transcript using Gemini Flash.

        Args:
            transcript: Raw conversation transcript text

        Returns:
            Dict containing intent, outcome, action_items, narrative_summary

        Raises:
            ValueError: If transcript is empty or invalid
            google_exceptions.GoogleAPIError: For API failures after retries
        """
        if not transcript or not transcript.strip():
            raise ValueError("Transcript cannot be empty")

        # Truncate if too long (protect token limits)
        if len(transcript) > MAX_TRANSCRIPT_LENGTH:
            logger.warning(f"Transcript truncated from {len(transcript)} to {MAX_TRANSCRIPT_LENGTH} chars")
            transcript = transcript[:MAX_TRANSCRIPT_LENGTH] + "\n[TRANSCRIPT TRUNCATED]"

        prompt = SUMMARY_PROMPT_TEMPLATE.format(transcript=transcript)

        try:
            # Run sync SDK in threadpool to not block async event loop
            response = await run_in_threadpool(
                self.model.generate_content,
                prompt,
                generation_config=self.generation_config
            )

            if not response.text:
                raise ValueError("Empty response from Gemini API")

            # Parse JSON response
            try:
                parsed = json.loads(response.text)
            except json.JSONDecodeError as e:
                logger.error(f"Failed to parse Gemini JSON response: {response.text[:500]}")
                raise ValueError(f"Invalid JSON from AI model: {e}")

            # Validate required fields
            required_fields = ["intent", "outcome", "action_items", "narrative_summary"]
            for field in required_fields:
                if field not in parsed:
                    parsed[field] = "unclear" if field != "action_items" else []

            # Calculate confidence heuristic (simple version)
            confidence = self._calculate_confidence(parsed, response.text)
            parsed["confidence_score"] = confidence
            parsed["raw_response"] = response.text

            return parsed

        except google_exceptions.GoogleAPIError as e:
            logger.error(f"Gemini API error: {e}")
            raise
        except Exception as e:
            logger.error(f"Unexpected error in summary generation: {e}")
            raise

    def _calculate_confidence(self, parsed: Dict, raw_text: str) -> float:
        """Simple heuristic for confidence scoring."""
        score = 0.5  # Base score

        # Boost if we have substantive content
        if parsed.get("intent") and len(parsed["intent"]) > 10:
            score += 0.2
        if parsed.get("outcome") and len(parsed["outcome"]) > 5:
            score += 0.2
        if parsed.get("action_items") and len(parsed["action_items"]) > 0:
            score += 0.1

        # Penalize if narrative is very short (possibly failed extraction)
        if parsed.get("narrative_summary") and len(parsed["narrative_summary"]) < 20:
            score -= 0.2

        return max(0.0, min(1.0, score))

class SummaryService:
    """Orchestration service for summary generation workflow."""

    def __init__(
        self,
        gemini_service: GeminiService,
        repository: SummaryRepository,
        redis_client: redis.Redis
    ):
        self.gemini = gemini_service
        self.repo = repository
        self.redis = redis_client

    async def generate_and_store_summary(
        self,
        conversation: ConversationTranscript
    ) -> ConversationSummaryInDB:
        """
        End-to-end summary generation and storage.

        Args:
            conversation: Conversation data including transcript

        Returns:
            Created/updated summary record

        Raises:
            HTTPException: For business logic errors (no transcript, etc.)
        """
        # Check cache first (Redis)
        cache_key = f"summary:{conversation.id}"
        cached = await self.redis.get(cache_key)
        if cached:
            logger.info(f"Cache hit for summary {conversation.id}")
            return ConversationSummaryInDB.model_validate_json(cached)

        # Validate prerequisites
        if not conversation.transcript:
            await self.repo.mark_failed(conversation.id, "No transcript available")
            raise HTTPException(
                status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
                detail="Conversation has no transcript to summarize"
            )

        if not conversation.ended_at:
            await self.repo.mark_failed(conversation.id, "Conversation not ended")
            raise HTTPException(


Response:
Here is the production-ready implementation for the Notification Preferences API following Sunaiva's architecture standards.

## Project Structure

```
src/
├── notifications/
│   ├── __init__.py
│   ├── models.py          # Pydantic v2 schemas
│   ├── repository.py      # AsyncPG data access
│   ├── service.py         # Business logic & caching
│   └── routes.py          # FastAPI endpoints
├── core/
│   ├── database.py        # DB pool dependency
│   ├── redis.py           # Redis client
│   └── exceptions.py      # Custom exceptions
└── tests/
    └── test_notifications.py
```

## 1. Data Models (`src/notifications/models.py`)

```python
"""
Notification Preferences Models
Pydantic v2 schemas for type-safe preference management.
"""

from enum import Enum
from typing import Dict, Optional
from datetime import datetime
from uuid import UUID

from pydantic import BaseModel, Field, ConfigDict, field_validator

class NotificationChannel(str, Enum):
    """Supported notification delivery channels."""
    EMAIL = "email"
    SMS = "sms"
    WEBHOOK = "webhook"
    DASHBOARD = "dashboard"

class EventType(str, Enum):
    """Business events that trigger notifications."""
    NEW_LEAD = "new_lead"
    MISSED_CALL = "missed_call"
    PAYMENT = "payment"
    USAGE_ALERT = "usage_alert"
    SYSTEM = "system"

class ChannelConfig(BaseModel):
    """Configuration for a specific channel on a specific event."""
    enabled: bool = False
    # Future extensibility: rate limits, quiet hours, webhook URLs
    metadata: Optional[Dict] = Field(default=None, description="Channel-specific config")

class EventPreferences(BaseModel):
    """Preferences for all channels on a specific event type."""
    email: ChannelConfig = Field(default_factory=lambda: ChannelConfig(enabled=True))
    dashboard: ChannelConfig = Field(default_factory=lambda: ChannelConfig(enabled=True))
    sms: ChannelConfig = Field(default_factory=lambda: ChannelConfig(enabled=False))
    webhook: ChannelConfig = Field(default_factory=lambda: ChannelConfig(enabled=False))

    @field_validator('*', mode='before')
    @classmethod
    def ensure_channel_config(cls, v):
        """Coerce boolean shorthand to ChannelConfig."""
        if isinstance(v, bool):
            return ChannelConfig(enabled=v)
        return v

class NotificationPreferencesBase(BaseModel):
    """Base preferences structure with all event types."""
    new_lead: EventPreferences = Field(default_factory=EventPreferences)
    missed_call: EventPreferences = Field(default_factory=EventPreferences)
    payment: EventPreferences = Field(default_factory=EventPreferences)
    usage_alert: EventPreferences = Field(default_factory=EventPreferences)
    system: EventPreferences = Field(default_factory=EventPreferences)

class NotificationPreferencesResponse(BaseModel):
    """API response model including metadata."""
    model_config = ConfigDict(from_attributes=True)

    account_id: UUID
    preferences: NotificationPreferencesBase
    created_at: datetime
    updated_at: datetime

class NotificationPreferencesUpdate(BaseModel):
    """Partial update model - only provided fields are updated."""
    new_lead: Optional[EventPreferences] = None
    missed_call: Optional[EventPreferences] = None
    payment: Optional[EventPreferences] = None
    usage_alert: Optional[EventPreferences] = None
    system: Optional[EventPreferences] = None

class RoutingDecision(BaseModel):
    """Result of should_notify check for Black Box testing."""
    account_id: UUID
    event_type: EventType
    channel: NotificationChannel
    should_send: bool
    reason: Optional[str] = None  # e.g., "disabled_by_user", "default_off"
```

## 2. Database Repository (`src/notifications/repository.py`)

```python
"""
Notification Preferences Repository
Handles asyncpg operations with proper connection pooling and error handling.
"""

import json
import logging
from typing import Optional, Dict, Any
from uuid import UUID

import asyncpg
from asyncpg import Pool, Record

from .models import NotificationPreferencesBase

logger = logging.getLogger(__name__)

class PreferencesRepositoryError(Exception):
    """Repository layer errors."""
    pass

class NotificationPreferencesRepository:
    """PostgreSQL repository for notification preferences."""

    def __init__(self, pool: Pool):
        self._pool = pool

    async def get_by_account_id(
        self,
        account_id: UUID
    ) -> Optional[Dict[str, Any]]:
        """
        Retrieve preferences by account ID.

        Returns None if not found (triggers default initialization).
        """
        query = """
            SELECT id, account_id, preferences, created_at, updated_at
            FROM notification_preferences
            WHERE account_id = $1
        """

        try:
            async with self._pool.acquire() as conn:
                record: Optional[Record] = await conn.fetchrow(query, account_id)

                if not record:
                    return None

                # asyncpg automatically deserializes JSONB to Python dict
                return {
                    "id": record["id"],
                    "account_id": record["account_id"],
                    "preferences": record["preferences"],
                    "created_at": record["created_at"],
                    "updated_at": record["updated_at"]
                }

        except asyncpg.PostgresError as e:
            logger.error(f"Database error fetching preferences for {account_id}: {e}")
            raise PreferencesRepositoryError(f"Failed to retrieve preferences: {e}")
        except Exception as e:
            logger.error(f"Unexpected error in get_by_account_id: {e}")
            raise PreferencesRepositoryError("Internal repository error")

    async def create(
        self,
        account_id: UUID,
        preferences: NotificationPreferencesBase
    ) -> Dict[str, Any]:
        """
        Create new preferences record with defaults.

        Uses INSERT ... ON CONFLICT for upsert safety in race conditions.
        """
        query = """
            INSERT INTO notification_preferences (account_id, preferences)
            VALUES ($1, $2)
            ON CONFLICT (account_id)
            DO UPDATE SET
                preferences = EXCLUDED.preferences,
                updated_at = NOW()
            RETURNING id, account_id, preferences, created_at, updated_at
        """

        try:
            # Pydantic v2 serialization
            prefs_json = preferences.model_dump(mode='json')

            async with self._pool.acquire() as conn:
                record: Record = await conn.fetchrow(query, account_id, prefs_json)

                logger.info(f"Created preferences for account {account_id}")
                return {
                    "id": record["id"],
                    "account_id": record["account_id"],
                    "preferences": record["preferences"],
                    "created_at": record["created_at"],
                    "updated_at": record["updated_at"]
                }

        except asyncpg.PostgresError as e:
            logger.error(f"Database error creating preferences for {account_id}: {e}")
            raise PreferencesRepositoryError(f"Failed to create preferences: {e}")

    async def update(
        self,
        account_id: UUID,
        preferences: NotificationPreferencesBase
    ) -> Optional[Dict[str, Any]]:
        """
        Update existing preferences.
        Returns None if record doesn't exist (caller should create first).
        """
        query = """
            UPDATE notification_preferences
            SET preferences = $2, updated_at = NOW()
            WHERE account_id = $1
            RETURNING id, account_id, preferences, created_at, updated_at
        """

        try:
            prefs_json = preferences.model_dump(mode='json')

            async with self._pool.acquire() as conn:
                record: Optional[Record] = await conn.fetchrow(query, account_id, prefs_json)

                if not record:
                    return None

                logger.info(f"Updated preferences for account {account_id}")
                return {
                    "id": record["id"],
                    "account_id": record["account_id"],
                    "preferences": record["preferences"],
                    "created_at": record["created_at"],
                    "updated_at": record["updated_at"]
                }

        except asyncpg.PostgresError as e:
            logger.error(f"Database error updating preferences for {account_id}: {e}")
            raise PreferencesRepositoryError(f"Failed to update preferences: {e}")

# SQL Schema (to be run in migrations):
"""
CREATE TABLE IF NOT EXISTS notification_preferences (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    preferences JSONB NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE(account_id)
);

CREATE INDEX CONCURRENTLY IF NOT EXISTS
    idx_notification_preferences_account_id
    ON notification_preferences(account_id);
"""
```

## 3. Business Logic Service (`src/notifications/service.py`)

```python
"""
Notification Preferences Service
Handles caching, default initialization, and routing decisions.
"""

import json
import logging
from typing import Optional
from uuid import UUID

from redis.asyncio import Redis
from pydantic import ValidationError

from .models import (
    NotificationPreferencesBase,
    NotificationPreferencesResponse,
    NotificationPreferencesUpdate,
    NotificationChannel,
    EventType,
    RoutingDecision
)
from .repository import NotificationPreferencesRepository, PreferencesRepositoryError

logger = logging.getLogger(__name__)

CACHE_KEY_PREFIX = "notification_prefs"
CACHE_TTL_SECONDS = 3600  # 1 hour

class PreferencesServiceError(Exception):
    """Service layer errors."""
    pass

class NotificationPreferencesService:
    """
    Manages notification preferences with Redis caching.

    Implements read-through caching pattern:
    1. Check Redis
    2. If miss, check PostgreSQL
    3. If DB miss, return defaults (don't cache defaults to avoid polluting cache for non-existent accounts)
    """

    def __init__(
        self,
        repository: NotificationPreferencesRepository,
        redis_client: Redis
    ):
        self._repo = repository
        self._redis = redis_client

    def _cache_key(self, account_id: UUID) -> str:
        """Generate Redis cache key."""
        return f"{CACHE_KEY_PREFIX}:{account_id}"

    async def get_preferences(
        self,
        account_id: UUID,
        create_if_missing: bool = True
    ) -> NotificationPreferencesResponse:
        """
        Get preferences with caching and default initialization.

        Args:
            account_id: The account to fetch preferences for
            create_if_missing: If True, persists defaults to DB when no record exists

        Returns:
            NotificationPreferencesResponse with current settings
        """
        cache_key = self._cache_key(account_id)

        # 1. Try cache
        try:
            cached_data = await self._redis.get(cache_key)
            if cached_data:
                prefs_dict = json.loads(cached_data)
                return NotificationPreferencesResponse(**prefs_dict)
        except Exception as e:
            logger.warning(f"Redis read error for {account_id}: {e}")
            # Fail open - continue to database

        # 2. Try database
        try:
            record = await self._repo.get_by_account_id(account_id)

            if record:
                # Cache hit in DB, store in Redis for next time
                response = NotificationPreferencesResponse(**record)
                await self._cache_preferences(account_id, response)
                return response

            # 3. No record exists - return defaults
            defaults = NotificationPreferencesBase()

            if create_if_missing:
                # Persist defaults for future requests
                record = await self._repo.create(account_id, defaults)
                response = NotificationPreferencesResponse(**record)
                await self._cache_preferences(account_id, response)
                return response
            else:
                # Return ephemeral defaults without persisting
                return NotificationPreferencesResponse(
                    account_id=account_id,
                    preferences=defaults,
                    created_at=None,  # type: ignore
                    updated_at=None  # type: ignore
                )

        except PreferencesRepositoryError:
            raise
        except Exception as e:
            logger.error(f"Unexpected error getting preferences: {e}")
            raise PreferencesServiceError("Failed to retrieve preferences")

    async def update_preferences(
        self,
        account_id: UUID,
        update_data: NotificationPreferencesUpdate
    ) -> NotificationPreferencesResponse:
        """
        Update preferences with partial merge strategy.

        Only updates provided fields; preserves existing settings for omitted fields.
        """
        try:
            # Get current settings (creates defaults if missing)
            current = await self.get_preferences(account_id, create_if_missing=True)

            # Merge update into current preferences
            current_dict = current.preferences.model_dump()
            update_dict = update_data.model_dump(exclude_unset=True)

            # Deep merge for nested structures
            for event_type, channels in update_dict.items():
                if channels and event_type in current_dict:
                    for channel, config in channels.items():
                        if config is not None:
                            current_dict[event_type][channel] = config

            # Validate merged result
            new_preferences = NotificationPreferencesBase(**current_dict)

            # Persist to database
            record = await self._repo.update(account_id, new_preferences)

            if not record:
                # Race condition: record deleted between get and update
                record = await self._repo.create(account_id, new_preferences)

            response = NotificationPreferencesResponse(**record)

            # Invalidate cache
            await self._invalidate_cache(account_id)

            return response

        except ValidationError as e:
            logger.error(f"Validation error updating preferences: {


Response:
I'll implement a production-grade widget configuration validator using Pydantic v2 with FastAPI. This includes field-level validators, database validation for voice IDs, comprehensive error handling, and full test coverage.

## 1. Core Domain Models & Validators

```python
# src/models/widget_config.py
import re
from typing import Optional, Literal
from datetime import datetime
from enum import Enum

import phonenumbers
from pydantic import (
    BaseModel,
    Field,
    field_validator,
    ConfigDict,
    ValidationError as PydanticValidationError
)
from phonenumbers import NumberParseException

class WidgetPosition(str, Enum):
    """Widget position options."""
    BOTTOM_RIGHT = "bottom-right"
    BOTTOM_LEFT = "bottom-left"
    TOP_RIGHT = "top-right"
    TOP_LEFT = "top-left"

class WidgetTheme(str, Enum):
    """Predefined widget themes."""
    LIGHT = "light"
    DARK = "dark"
    AUTO = "auto"

class WidgetConfigBase(BaseModel):
    """
    Base widget configuration with validation rules.

    All validators follow Pydantic v2 patterns with clear error messages
    for field-level debugging.
    """
    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
        extra="forbid"  # Prevent unexpected fields
    )

    # Required fields
    widget_id: str = Field(
        ...,  # Required
        min_length=8,
        max_length=64,
        pattern=r'^[a-zA-Z0-9_-]+$',
        description="Unique widget identifier"
    )

    # Color configuration with hex validation
    primary_color: str = Field(
        default="#3B82F6",  # Default blue
        description="Primary brand color in hex format"
    )
    secondary_color: Optional[str] = Field(
        default=None,
        description="Secondary accent color in hex format"
    )

    # Content configuration
    greeting_message: str = Field(
        default="Hi! How can we help you today?",
        max_length=200,
        description="Initial greeting shown to users"
    )
    placeholder_text: Optional[str] = Field(
        default="Type your message...",
        max_length=100,
        description="Input placeholder text"
    )

    # Voice configuration
    voice_id: str = Field(
        ...,
        min_length=4,
        max_length=64,
        description="External voice provider ID (e.g., ElevenLabs voice ID)"
    )

    # Contact configuration
    business_phone: Optional[str] = Field(
        default=None,
        description="Business phone in E.164 format (+1234567890)"
    )
    support_email: Optional[str] = Field(
        default=None,
        pattern=r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
        description="Support contact email"
    )

    # UI Configuration with defaults
    position: WidgetPosition = Field(
        default=WidgetPosition.BOTTOM_RIGHT,
        description="Widget screen position"
    )
    theme: WidgetTheme = Field(
        default=WidgetTheme.AUTO,
        description="Color scheme preference"
    )
    show_branding: bool = Field(
        default=True,
        description="Show Sunaiva powered-by badge"
    )
    auto_open_delay: Optional[int] = Field(
        default=None,
        ge=0,
        le=60,
        description="Auto-open delay in seconds (0-60)"
    )

    @field_validator('primary_color', 'secondary_color')
    @classmethod
    def validate_hex_color(cls, v: Optional[str]) -> Optional[str]:
        """
        Validate hexadecimal color format.

        Supports: #RGB, #RRGGBB, #RGBA, #RRGGBBAA
        Returns uppercase normalized hex.
        """
        if v is None:
            return v

        # Normalize to uppercase for consistency
        v = v.upper().strip()

        # Hex color regex pattern
        hex_pattern = r'^#([A-F0-9]{3}|[A-F0-9]{6}|[A-F0-9]{4}|[A-F0-9]{8})$'

        if not re.match(hex_pattern, v):
            raise ValueError(
                f"Invalid hex color format: '{v}'. "
                "Expected formats: #RGB, #RRGGBB, #RGBA, #RRGGBBAA"
            )

        # Normalize short hex to full hex (#RGB -> #RRGGBB)
        if len(v) == 4:  # #RGB
            v = f"#{v[1]}{v[1]}{v[2]}{v[2]}{v[3]}{v[3]}"
        elif len(v) == 5:  # #RGBA
            v = f"#{v[1]}{v[1]}{v[2]}{v[2]}{v[3]}{v[3]}{v[4]}{v[4]}"

        return v

    @field_validator('greeting_message')
    @classmethod
    def validate_greeting_length(cls, v: str) -> str:
        """
        Validate greeting message length and content.

        - Max 200 characters
        - No HTML/script injection
        - Strip excessive whitespace
        """
        if len(v) > 200:
            raise ValueError(
                f"Greeting message too long: {len(v)} chars (max 200)"
            )

        # Basic XSS prevention - no script tags
        if re.search(r'<script|javascript:|onerror=|onload=', v, re.IGNORECASE):
            raise ValueError("Greeting contains forbidden content")

        return v.strip()

    @field_validator('business_phone')
    @classmethod
    def validate_phone_e164(cls, v: Optional[str]) -> Optional[str]:
        """
        Validate phone number using Google's libphonenumber.

        Enforces E.164 format for international compatibility.
        """
        if v is None or v == "":
            return None

        try:
            # Parse assuming US if no country code, but require explicit + for E.164
            if not v.startswith('+'):
                raise ValueError("Phone must include country code (e.g., +1, +44)")

            parsed = phonenumbers.parse(v, None)

            if not phonenumbers.is_valid_number(parsed):
                raise ValueError(f"Invalid phone number: {v}")

            # Return formatted E.164
            return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)

        except NumberParseException as e:
            raise ValueError(f"Invalid phone format: {str(e)}")

    @field_validator('voice_id')
    @classmethod
    def validate_voice_id_format(cls, v: str) -> str:
        """
        Basic format validation for voice ID.

        Database existence check happens at service layer due to async requirements.
        """
        if not re.match(r'^[a-zA-Z0-9_-]+$', v):
            raise ValueError(
                "Voice ID contains invalid characters. "
                "Use only alphanumeric, hyphens, and underscores."
            )
        return v.strip()

class WidgetConfigCreate(WidgetConfigBase):
    """Model for creating new widget configurations."""
    tenant_id: str = Field(
        ...,
        description="Organization/tenant identifier"
    )

class WidgetConfigUpdate(BaseModel):
    """Partial update model - all fields optional."""
    model_config = ConfigDict(extra="forbid")

    primary_color: Optional[str] = None
    secondary_color: Optional[str] = None
    greeting_message: Optional[str] = Field(default=None, max_length=200)
    voice_id: Optional[str] = None
    business_phone: Optional[str] = None
    position: Optional[WidgetPosition] = None
    theme: Optional[WidgetTheme] = None

    @field_validator('primary_color', 'secondary_color')
    @classmethod
    def validate_hex_color(cls, v: Optional[str]) -> Optional[str]:
        if v is None:
            return v
        return WidgetConfigBase.validate_hex_color(v)

    @field_validator('greeting_message')
    @classmethod
    def validate_greeting(cls, v: Optional[str]) -> Optional[str]:
        if v is None:
            return v
        return WidgetConfigBase.validate_greeting_length(v)
```

## 2. Service Layer with Async Validation

```python
# src/services/widget_service.py
import logging
from typing import Optional
from datetime import datetime

import asyncpg
from redis.asyncio import Redis

from src.models.widget_config import WidgetConfigCreate, WidgetConfigUpdate
from src.exceptions.widget_exceptions import (
    VoiceNotFoundError,
    WidgetConfigError,
    DuplicateWidgetError
)

logger = logging.getLogger(__name__)

class VoiceValidationService:
    """
    Service for validating voice IDs against external providers.

    Implements caching to reduce API calls to voice providers (ElevenLabs, etc.).
    """

    def __init__(self, db: asyncpg.Pool, redis: Redis):
        self.db = db
        self.redis = redis
        self.cache_ttl = 3600  # 1 hour cache for voice metadata

    async def validate_voice_exists(self, voice_id: str, tenant_id: str) -> bool:
        """
        Verify voice ID exists and is accessible to tenant.

        Checks:
        1. Redis cache (fast path)
        2. Database (tenant's voice library)
        3. External API (if not cached, with circuit breaker pattern)
        """
        cache_key = f"voice:{tenant_id}:{voice_id}"

        try:
            # Check cache first
            cached = await self.redis.get(cache_key)
            if cached:
                return True

            # Check database
            async with self.db.acquire() as conn:
                row = await conn.fetchrow(
                    """
                    SELECT id, is_active, provider
                    FROM voices
                    WHERE voice_id = $1
                    AND (tenant_id = $2 OR is_public = true)
                    AND is_active = true
                    """,
                    voice_id, tenant_id
                )

                if row:
                    # Cache successful lookup
                    await self.redis.setex(cache_key, self.cache_ttl, "1")
                    return True

                logger.warning(
                    f"Voice ID '{voice_id}' not found for tenant '{tenant_id}'"
                )
                return False

        except asyncpg.PostgresError as e:
            logger.error(f"Database error validating voice: {e}")
            raise WidgetConfigError("Database error during voice validation")
        except Exception as e:
            logger.error(f"Unexpected error validating voice: {e}")
            raise

class WidgetConfigService:
    """
    Business logic for widget configuration management.

    Handles validation orchestration and database operations.
    """

    def __init__(
        self,
        db: asyncpg.Pool,
        redis: Redis,
        voice_service: VoiceValidationService
    ):
        self.db = db
        self.redis = redis
        self.voice_service = voice_service

    async def create_widget_config(
        self,
        config: WidgetConfigCreate
    ) -> dict:
        """
        Create new widget configuration with full validation.

        Raises:
            VoiceNotFoundError: If voice_id doesn't exist
            DuplicateWidgetError: If widget_id already exists
            WidgetConfigError: General validation errors
        """
        # Async validation: Check voice exists
        voice_exists = await self.voice_service.validate_voice_exists(
            config.voice_id,
            config.tenant_id
        )

        if not voice_exists:
            raise VoiceNotFoundError(
                field="voice_id",
                value=config.voice_id,
                message=f"Voice ID '{config.voice_id}' not found or inactive"
            )

        try:
            async with self.db.acquire() as conn:
                async with conn.transaction():
                    # Check for duplicate widget_id
                    existing = await conn.fetchval(
                        "SELECT 1 FROM widget_configs WHERE widget_id = $1",
                        config.widget_id
                    )

                    if existing:
                        raise DuplicateWidgetError(
                            f"Widget ID '{config.widget_id}' already exists"
                        )

                    # Insert configuration
                    row = await conn.fetchrow(
                        """
                        INSERT INTO widget_configs (
                            widget_id, tenant_id, primary_color, secondary_color,
                            greeting_message, placeholder_text, voice_id,
                            business_phone, support_email, position, theme,
                            show_branding, auto_open_delay, created_at, updated_at
                        ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW())
                        RETURNING *
                        """,
                        config.widget_id,
                        config.tenant_id,
                        config.primary_color,
                        config.secondary_color,
                        config.greeting_message,
                        config.placeholder_text,
                        config.voice_id,
                        config.business_phone,
                        config.support_email,
                        config.position.value,
                        config.theme.value,
                        config.show_branding,
                        config.auto_open_delay
                    )

                    logger.info(
                        f"Created widget config: {config.widget_id} "
                        f"for tenant: {config.tenant_id}"
                    )

                    return dict(row)

        except asyncpg.UniqueViolationError:
            raise DuplicateWidgetError(f"Widget ID '{config.widget_id}' already exists")
        except asyncpg.PostgresError as e:
            logger.error(f"Database error creating widget: {e}")
            raise WidgetConfigError("Failed to save widget configuration")

    async def update_widget_config(
        self,
        widget_id: str,
        tenant_id: str,
        update: WidgetConfigUpdate
    ) -> dict:
        """
        Partial update with validation for changed fields only.
        """
        # Build update fields dynamically
        updates = {}
        if update.primary_color is not None:
            updates['primary_color'] = update.primary_color
        if update.voice_id is not None:
            # Validate new voice exists
            exists = await self.voice_service.validate_voice_exists(
                update.voice_id, tenant_id
            )
            if not exists:
                raise VoiceNotFoundError(
                    field="voice_id",
                    value=update.voice_id
                )
            updates['voice_id'] = update.voice_id
        if update.greeting_message is not None:
            updates['greeting_message'] = update.greeting_message
        if update.business_phone is not None:
            updates['business_phone'] = update.business_phone
        if update.position is not None:
            updates['position'] = update.position.value
        if update.theme is not None:
            updates['theme'] = update.theme.value

        if not updates:
            raise WidgetConfigError("No fields to update")

        # Construct dynamic SQL
        set_clause = ", ".join([f"{k} = ${i+3}" for i, k in enumerate(updates.keys())])
        values = list(updates.values())

        async with self.db.acquire() as conn:
            row = await conn.fetchrow(
                f"""
                UPDATE widget_configs
                SET {set_clause}, updated_at = NOW()
                WHERE widget_id = $1 AND tenant_id = $2
                RETURNING *
                """,
                widget_id, tenant_id, *values
            )

            if not row:
                raise WidgetConfigError("Widget not found or access denied")

            return dict(row)
```

## 3. Custom Exceptions

```python
# src/exceptions/widget_exceptions.py
from typing import Optional, Any

class SunaivaException(Exception):
    """Base exception for Sunaiva platform."""
    def __init__(self, message: str, status_code: int = 500):
        self.message = message
        self.status_code = status_code
        super().__init__(self.message)

class WidgetConfigError(SunaivaException):
    """General widget configuration error."""
    def __init__(self, message: str = "Widget configuration error"):
        super().__init__(message, status_code=400)

class ValidationError(SunaivaException):
    """Field-level validation error."""
    def __init__(
        self,
        field: str,
        message: str,
        value: Optional[Any] = None
    ):
        self.field = field
        self.value = value
        super().__init__(message, status_code=422)

class VoiceNotFoundError(ValidationError):
    """Voice ID not found or unauthorized."""
    def __init__(
        self,
        field: str = "voice_id",
        value: Optional[str] = None,
        message: Optional[str] = None
    ):
        msg = message or f"Voice ID '{value}' not found"
        super().__init__(field, msg, value)

class DuplicateWidgetError(SunaivaException):
    """Widget ID already exists."""
    def __init__(self, message: str):
        super().__init__(message, status_code=409)
```

## 4. FastAPI Integration & Error Handlers

```python
# src/api/routes/widgets.py
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse

from src.models.widget_config import WidgetConfigCreate, WidgetConfigUpdate
from src.services.widget_service import WidgetConfigService, VoiceValidationService
from src.exceptions.widget_exceptions import (
    ValidationError,
    WidgetConfigError,
    VoiceNotFoundError,
    DuplicateWidgetError
)
from src.api.dependencies import get_db, get_redis

router = APIRouter(prefix="/api/v1/widgets", tags=["widgets"])

async def get_widget_service(
    db=Depends(get_db),
    redis=Depends(get_redis)
) -> WidgetConfigService:
    """Dependency injection for widget service."""
    voice_service = VoiceValidationService(db, redis)
    return WidgetConfigService(db, redis, voice_service)

@router.post(
    "",
    response_model=dict,
    status_code=status.HTTP_201_CREATED,
    summary="Create widget configuration"
)
async def create_widget(
    config: WidgetConfigCreate,
    service: Annotated[WidgetConfigService, Depends(get_widget_service)]
):
    """
    Create new widget configuration with server-side validation.

    Validation includes:
    - Hex color format checking
    - Greeting length limits (200 chars)
    - Phone E.164 formatting
    - Voice ID existence verification
    """
    try:
        result


Response:
 ```python
#!/usr/bin/env python3
"""
Sunaiva Database Seeder for Demo Environments

This script populates the database with realistic demo data for sales presentations.
Run with: python seed_demo.py

Environment Variables:
    DATABASE_URL: PostgreSQL connection string (required)
    REDIS_URL: Redis connection string (optional, for cache clearing)
"""

import asyncio
import json
import logging
import os
import random
import sys
from datetime import datetime, timedelta
from decimal import Decimal
from enum import Enum
from typing import Any, Optional
from uuid import UUID, uuid4

import asyncpg
from faker import Faker
from pydantic import BaseModel, Field, field_validator

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
    handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger("sunaiva_seeder")

class Industry(str, Enum):
    """Supported widget industries."""
    DENTAL = "dental"
    LEGAL = "legal_services"
    HOME_SERVICES = "home_services"
    HEALTHCARE = "healthcare"
    REAL_ESTATE = "real_estate"

class ConversationOutcome(str, Enum):
    """Possible conversation outcomes."""
    COMPLETED = "completed"
    MISSED = "missed"
    ABANDONED = "abandoned"
    VOICEMAIL = "voicemail"
    TRANSFERRED = "transferred"

class LeadStatus(str, Enum):
    """Lead pipeline statuses."""
    NEW = "new"
    CONTACTED = "contacted"
    QUALIFIED = "qualified"
    CONVERTED = "converted"
    LOST = "lost"

class WidgetConfig(BaseModel):
    """Widget configuration schema."""
    greeting_text: str = "Hello! How can I help you today?"
    voice_id: str = "alloy"
    language: str = "en-US"
    business_hours: dict[str, Any] = Field(default_factory=dict)
    custom_intents: list[str] = Field(default_factory=list)

    @field_validator('business_hours', mode='before')
    @classmethod
    def set_default_hours(cls, v):
        if not v:
            return {
                "monday": {"open": "09:00", "close": "17:00"},
                "tuesday": {"open": "09:00", "close": "17:00"},
                "wednesday": {"open": "09:00", "close": "17:00"},
                "thursday": {"open": "09:00", "close": "17:00"},
                "friday": {"open": "09:00", "close": "17:00"}
            }
        return v

class WidgetSeed(BaseModel):
    """Widget seed data model."""
    id: UUID = Field(default_factory=uuid4)
    name: str
    industry: Industry
    config: WidgetConfig = Field(default_factory=WidgetConfig)
    created_at: datetime = Field(default_factory=datetime.utcnow)

class ConversationSeed(BaseModel):
    """Conversation seed data model."""
    id: UUID = Field(default_factory=uuid4)
    widget_id: UUID
    duration_seconds: int = Field(ge=10, le=600)
    outcome: ConversationOutcome
    transcript: Optional[str] = None
    sentiment_score: float = Field(ge=-1.0, le=1.0)
    caller_phone: str
    created_at: datetime

class LeadSeed(BaseModel):
    """Lead seed data model."""
    id: UUID = Field(default_factory=uuid4)
    widget_id: UUID
    name: str
    email: str
    phone: str
    status: LeadStatus
    estimated_value: Optional[Decimal] = None
    notes: Optional[str] = None
    created_at: datetime

class AnalyticsSeed(BaseModel):
    """Daily analytics seed data model."""
    id: UUID = Field(default_factory=uuid4)
    widget_id: UUID
    date: datetime
    total_calls: int = Field(ge=0)
    answered_calls: int = Field(ge=0)
    missed_calls: int = Field(ge=0)
    avg_call_duration: int = Field(ge=0)
    leads_generated: int = Field(ge=0)
    conversion_rate: float = Field(ge=0.0, le=1.0)

class DatabaseSeeder:
    """
    Production-grade database seeder for Sunaiva demo environments.

    Handles connection pooling, transaction management, and realistic data generation
    across multiple entities with proper referential integrity.
    """

    def __init__(self, database_url: str, redis_url: Optional[str] = None) -> None:
        self.database_url = database_url
        self.redis_url = redis_url
        self.pool: Optional[asyncpg.Pool] = None
        self.faker = Faker()
        self.widgets: list[WidgetSeed] = []

    async def initialize(self) -> None:
        """Initialize database connection pool."""
        try:
            self.pool = await asyncpg.create_pool(
                self.database_url,
                min_size=5,
                max_size=20,
                command_timeout=60,
                server_settings={
                    'jit': 'off'  # Disable JIT for faster bulk inserts
                }
            )
            logger.info("Database connection pool initialized")
        except asyncpg.PostgresError as e:
            logger.error(f"Failed to initialize database pool: {e}")
            raise RuntimeError(f"Database connection failed: {e}") from e

    async def cleanup(self) -> None:
        """Close database connections gracefully."""
        if self.pool:
            await self.pool.close()
            logger.info("Database connections closed")

    async def execute_seed(self) -> dict[str, int]:
        """
        Execute full seeding transaction.

        Returns:
            Dictionary with counts of created entities
        """
        if not self.pool:
            raise RuntimeError("Seeder not initialized. Call initialize() first.")

        async with self.pool.acquire() as conn:
            async with conn.transaction():
                try:
                    # Clear existing demo data if exists (idempotent)
                    await self._clear_existing_data(conn)

                    # Create widgets first (parent entities)
                    await self._seed_widgets(conn)

                    # Create dependent entities
                    conversations_count = await self._seed_conversations(conn)
                    leads_count = await self._seed_leads(conn)
                    analytics_count = await self._seed_analytics(conn)

                    logger.info("Seed transaction committed successfully")

                    return {
                        "widgets": len(self.widgets),
                        "conversations": conversations_count,
                        "leads": leads_count,
                        "analytics_days": analytics_count
                    }

                except Exception as e:
                    logger.error(f"Seeding failed, rolling back transaction: {e}")
                    raise

    async def _clear_existing_data(self, conn: asyncpg.Connection) -> None:
        """
        Clear existing demo data to ensure idempotency.
        Identifies demo widgets by specific naming pattern.
        """
        logger.info("Checking for existing demo data...")

        # Delete in reverse order of dependencies to avoid FK violations
        await conn.execute("""
            DELETE FROM analytics
            WHERE widget_id IN (
                SELECT id FROM widgets
                WHERE name LIKE 'Demo: %'
            )
        """)

        await conn.execute("""
            DELETE FROM leads
            WHERE widget_id IN (
                SELECT id FROM widgets
                WHERE name LIKE 'Demo: %'
            )
        """)

        await conn.execute("""
            DELETE FROM conversations
            WHERE widget_id IN (
                SELECT id FROM widgets
                WHERE name LIKE 'Demo: %'
            )
        """)

        result = await conn.execute("""
            DELETE FROM widgets
            WHERE name LIKE 'Demo: %'
        """)

        if result != "DELETE 0":
            logger.info("Cleared existing demo data")

    async def _seed_widgets(self, conn: asyncpg.Connection) -> None:
        """
        Create 3 demo widgets across different industries.
        """
        logger.info("Seeding widgets...")

        widget_definitions = [
            {
                "name": "Demo: Bright Smile Dental",
                "industry": Industry.DENTAL,
                "config": WidgetConfig(
                    greeting_text="Welcome to Bright Smile Dental. Are you calling to schedule an appointment?",
                    voice_id="shimmer",
                    custom_intents=["schedule_cleaning", "emergency_care", "insurance_inquiry"]
                )
            },
            {
                "name": "Demo: LegalEase Attorneys",
                "industry": Industry.LEGAL,
                "config": WidgetConfig(
                    greeting_text="Thank you for calling LegalEase. For which legal matter do you need assistance?",
                    voice_id="echo",
                    custom_intents=["consultation_request", "case_status", "document_request"]
                )
            },
            {
                "name": "Demo: Rapid Repair HVAC",
                "industry": Industry.HOME_SERVICES,
                "config": WidgetConfig(
                    greeting_text="Rapid Repair here! Is this regarding heating or cooling service?",
                    voice_id="alloy",
                    custom_intents=["emergency_repair", "maintenance", "quote_request"]
                )
            }
        ]

        for widget_def in widget_definitions:
            widget = WidgetSeed(**widget_def)

            await conn.execute(
                """
                INSERT INTO widgets (id, name, industry, config, created_at, updated_at)
                VALUES ($1, $2, $3, $4, $5, $5)
                ON CONFLICT (id) DO NOTHING
                """,
                widget.id,
                widget.name,
                widget.industry.value,
                json.dumps(widget.config.model_dump()),
                widget.created_at
            )
            self.widgets.append(widget)
            logger.info(f"Created widget: {widget.name} ({widget.industry.value})")

    async def _seed_conversations(self, conn: asyncpg.Connection) -> int:
        """
        Create 50 conversations with varied outcomes distributed across widgets.
        """
        logger.info("Seeding conversations...")
        count = 0

        # Distribution: 50 conversations across 3 widgets
        conversations_per_widget = [18, 16, 16]  # Slight imbalance for realism

        for widget, num_conversations in zip(self.widgets, conversations_per_widget):
            for _ in range(num_conversations):
                # Weighted outcomes: 70% completed, 10% missed, 10% abandoned, 5% voicemail, 5% transferred
                outcome = random.choices(
                    list(ConversationOutcome),
                    weights=[0.70, 0.10, 0.10, 0.05, 0.05],
                    k=1
                )[0]

                # Duration correlates with outcome
                if outcome == ConversationOutcome.COMPLETED:
                    duration = random.randint(60, 600)
                elif outcome == ConversationOutcome.MISSED:
                    duration = random.randint(5, 15)
                elif outcome == ConversationOutcome.VOICEMAIL:
                    duration = random.randint(30, 120)
                else:
                    duration = random.randint(10, 300)

                # Create realistic transcript for completed calls
                transcript = None
                if outcome == ConversationOutcome.COMPLETED:
                    transcript = self._generate_transcript(widget.industry)

                conversation = ConversationSeed(
                    widget_id=widget.id,
                    duration_seconds=duration,
                    outcome=outcome,
                    transcript=transcript,
                    sentiment_score=random.uniform(-0.8, 0.9) if outcome == ConversationOutcome.COMPLETED else random.uniform(-0.9, 0.2),
                    caller_phone=self.faker.phone_number(),
                    created_at=self._random_date_within_last_days(30)
                )

                await conn.execute(
                    """
                    INSERT INTO conversations
                    (id, widget_id, duration_seconds, outcome, transcript, sentiment_score, caller_phone, created_at)
                    VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
                    """,
                    conversation.id,
                    conversation.widget_id,
                    conversation.duration_seconds,
                    conversation.outcome.value,
                    conversation.transcript,
                    conversation.sentiment_score,
                    conversation.caller_phone,
                    conversation.created_at
                )
                count += 1

        logger.info(f"Created {count} conversations")
        return count

    async def _seed_leads(self, conn: asyncpg.Connection) -> int:
        """
        Create 20 leads with different statuses and realistic value estimates.
        """
        logger.info("Seeding leads...")
        count = 0

        # Status distribution for realistic pipeline
        status_weights = {
            LeadStatus.NEW: 0.30,
            LeadStatus.CONTACTED: 0.25,
            LeadStatus.QUALIFIED: 0.20,
            LeadStatus.CONVERTED: 0.15,
            LeadStatus.LOST: 0.10
        }

        # Industry-specific value ranges
        value_ranges = {
            Industry.DENTAL: (100, 500),
            Industry.LEGAL: (1000, 10000),
            Industry.HOME_SERVICES: (150, 2000)
        }

        for _ in range(20):
            widget = random.choice(self.widgets)
            status = random.choices(
                list(status_weights.keys()),
                weights=list(status_weights.values()),
                k=1
            )[0]

            # Value only for qualified or converted leads
            estimated_value = None
            if status in [LeadStatus.QUALIFIED, LeadStatus.CONVERTED]:
                min_val, max_val = value_ranges[widget.industry]
                estimated_value = Decimal(str(random.uniform(min_val, max_val))).quantize(Decimal("0.01"))

            lead = LeadSeed(
                widget_id=widget.id,
                name=self.faker.name(),
                email=self.faker.email(),
                phone=self.faker.phone_number(),
                status=status,
                estimated_value=estimated_value,
                notes=self._generate_lead_notes(widget.industry, status) if random.random() > 0.5 else None,
                created_at=self._random_date_within_last_days(30)
            )

            await conn.execute(
                """
                INSERT INTO leads
                (id, widget_id, name, email, phone, status, estimated_value, notes, created_at)
                VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
                """,
                lead.id,
                lead.widget_id,
                lead.name,
                lead.email,
                lead.phone,
                lead.status.value,
                lead.estimated_value,
                lead.notes,
                lead.created_at
            )
            count += 1

        logger.info(f"Created {count} leads")
        return count

    async def _seed_analytics(self, conn: asyncpg.Connection) -> int:
        """
        Create 30 days of analytics data for each widget.
        """
        logger.info("Seeding analytics...")
        count = 0
        end_date = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)

        for widget in self.widgets:
            for days_ago in range(30):
                current_date = end_date - timedelta(days=days_ago)

                # Realistic daily variation using sine wave + random noise
                base_calls = 15 + int(10 * (1 + (days_ago % 7) / 7))  # Weekly pattern
                total_calls = max(0, base_calls + random.randint(-5, 8))

                # Answer rate varies by day (weekends lower)
                is_weekend = current_date.weekday() >= 5
                answer_rate = 0.75 if not is_weekend else 0.60

                answered = int(total_calls * answer_rate)
                missed = total_calls - answered

                # Leads correlate with answered calls
                leads = int(answered * random.uniform(0.15, 0.35))

                # Conversion rate trends upward over time (simulating improvement)
                base_conversion = 0.10 + (days_ago / 300)  # Slight upward trend
                conversion_rate = min(0.45, max(0.05, base_conversion + random.uniform(-0.05, 0.05)))

                analytics = AnalyticsSeed(
                    widget_id=widget.id,
                    date=current_date,
                    total_calls=total_calls,
                    answered_calls=answered,
                    missed_calls=missed,
                    avg_call_duration=random.randint(120, 400) if answered > 0 else 0,
                    leads_generated=leads,
                    conversion_rate=round(conversion_rate, 3)
                )

                await conn.execute(
                    """
                    INSERT INTO analytics
                    (id, widget_id, date, total_calls, answered_calls, missed_calls,
                     avg_call_duration, leads_generated, conversion_rate)
                    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
                    ON CONFLICT (widget_id, date) DO UPDATE SET
                    total_calls = EXCLUDED.total_calls,
                    answered_calls = EXCLUDED.answered_calls,
                    missed_calls = EXCLUDED.missed_calls,
                    avg_call_duration = EXCLUDED.avg_call_duration,
                    leads_generated = EXCLUDED.leads_generated,
                    conversion_rate = EXCLUDED.conversion_rate
                    """,
                    analytics.id,
                    analytics.widget_id,
                    analytics.date,
                    analytics.total_calls,
                    analytics.answered_calls,
                    analytics.missed_calls,
                    analytics.avg_call_duration,
                    analytics.leads_generated,
                    analytics.conversion_rate
                )
                count += 1

        logger.info(f"Created {count} analytics records")
        return count

    def _random_date_within_last_days(self, days: int) -> datetime:
        """Generate a random datetime within the last N days."""
        now = datetime.utcnow()
        random_days = random.randint(0, days)
        random_hours = random.randint(0, 23)
        random_minutes = random.randint(0, 59)
        return now - timedelta(days=random_days, hours=random_hours, minutes=random_minutes)

    def _generate_transcript(self, industry: Industry) -> str:
        """Generate industry-specific conversation transcript samples."""
        transcripts = {
            Industry.DENTAL: [
                "Caller: Hi, I'd like to schedule a cleaning for next week.",
                "AI: I'd be happy to help you schedule a cleaning. Do you prefer morning or afternoon appointments?",
                "Caller: Morning works best, around 9 AM if available.",
                "AI: I have Tuesday and Thursday at 9 AM available. Which would you prefer?",
                "Caller: Thursday please.",
                "AI: Perfect, I've scheduled you for Thursday at 9 AM. Is there anything else I can help with?"
            ],
            Industry.LEGAL: [
                "Caller: I need to speak with someone about a contract dispute.",
                "AI: I can help schedule a consultation for contract review. What type of contract is this regarding?",
                "Caller: It's a vendor agreement that was breached.",
                "AI: Understood. Our contract law team is available Thursday at 2 PM or Friday at 10 AM.",
                "Caller: Friday at 10 works.",
                "AI: Great, I've scheduled your consultation for Friday at 10 AM. You'll receive a confirmation email."
            ],
            Industry.HOME_SERVICES: [
                "Caller: My air conditioning stopped working and it's 90 degrees out!",
                "AI: I'm sorry to hear that. Is this an emergency repair situation?",
                "Caller: Yes, we have elderly family members at home.",
                "AI


Response:
 I'll implement a comprehensive error handling system for Sunaiva with structured error codes, automatic handler registration, and searchable documentation. This follows production-grade patterns with full observability.

## 1. Error Code Registry & Definitions

```python
# app/core/errors/codes.py
"""
Centralized error code registry for Sunaiva API.
All error codes are registered here with metadata for documentation and handling.
"""

from dataclasses import dataclass
from typing import Dict, List, Optional, Callable
from enum import Enum
import re

@dataclass(frozen=True)
class ErrorMetadata:
    """Immutable metadata for each error code."""
    code: str
    name: str
    description: str
    http_status: int
    resolution: str
    category: str

    def to_dict(self) -> dict:
        return {
            "code": self.code,
            "name": self.name,
            "description": self.description,
            "http_status": self.http_status,
            "resolution": self.resolution,
            "category": self.category
        }

class ErrorRegistry:
    """Thread-safe registry for all API error codes."""

    def __init__(self):
        self._errors: Dict[str, ErrorMetadata] = {}
        self._handlers: Dict[str, Callable] = {}

    def register(
        self,
        code: str,
        name: str,
        description: str,
        http_status: int,
        resolution: str,
        category: str
    ) -> str:
        """
        Register a new error code.

        Args:
            code: Unique error code (e.g., "AUTH_001")
            name: Human-readable error name
            description: Detailed description of when this error occurs
            http_status: HTTP status code to return
            resolution: Steps to resolve the error
            category: Error category (AUTH, WIDGET, BILLING, RATE, SYS)

        Returns:
            The error code string for use in exceptions

        Raises:
            ValueError: If code format is invalid or code already exists
        """
        # Validate code format: CATEGORY_###
        if not re.match(r'^[A-Z]+_\d{3}$', code):
            raise ValueError(f"Invalid error code format: {code}. Expected format: CATEGORY_###")

        if code in self._errors:
            raise ValueError(f"Duplicate error code registration: {code}")

        self._errors[code] = ErrorMetadata(
            code=code,
            name=name,
            description=description,
            http_status=http_status,
            resolution=resolution,
            category=category
        )
        return code

    def get(self, code: str) -> Optional[ErrorMetadata]:
        """Retrieve metadata for an error code."""
        return self._errors.get(code)

    def list_by_category(self, category: Optional[str] = None) -> List[ErrorMetadata]:
        """List all error codes, optionally filtered by category."""
        errors = list(self._errors.values())
        if category:
            errors = [e for e in errors if e.category == category.upper()]
        return sorted(errors, key=lambda x: x.code)

    def search(self, query: str) -> List[ErrorMetadata]:
        """Search error codes by name, description, or code."""
        query_lower = query.lower()
        return [
            e for e in self._errors.values()
            if query_lower in e.code.lower()
            or query_lower in e.name.lower()
            or query_lower in e.description.lower()
        ]

# Global registry instance
registry = ErrorRegistry()

class ErrorCodes:
    """
    Error code constants. All codes follow pattern: CATEGORY_###
    Categories: AUTH, WIDGET, BILLING, RATE, SYS
    """

    # Authentication Errors (AUTH_001 - AUTH_005)
    AUTH_001 = registry.register(
        code="AUTH_001",
        name="Invalid API Key",
        description="The provided API key is invalid, malformed, or has been revoked.",
        http_status=401,
        resolution="Verify your API key in the Sunaiva dashboard. Ensure the 'X-API-Key' header is set correctly.",
        category="AUTH"
    )

    AUTH_002 = registry.register(
        code="AUTH_002",
        name="Expired Token",
        description="The authentication token has expired.",
        http_status=401,
        resolution="Refresh your access token using the /auth/refresh endpoint or re-authenticate.",
        category="AUTH"
    )

    AUTH_003 = registry.register(
        code="AUTH_003",
        name="Insufficient Permissions",
        description="Your account does not have permission to access this resource.",
        http_status=403,
        resolution="Upgrade your subscription plan or contact the account administrator to request access.",
        category="AUTH"
    )

    AUTH_004 = registry.register(
        code="AUTH_004",
        name="Missing Authentication",
        description="No API key or authentication token was provided.",
        http_status=401,
        resolution="Include your API key in the 'X-API-Key' header or provide a valid Bearer token.",
        category="AUTH"
    )

    AUTH_005 = registry.register(
        code="AUTH_005",
        name="Account Suspended",
        description="Your account has been suspended due to billing issues or policy violations.",
        http_status=403,
        resolution="Contact support@sunaiva.com to resolve account status issues.",
        category="AUTH"
    )

    # Widget Errors (WIDGET_001 - WIDGET_010)
    WIDGET_001 = registry.register(
        code="WIDGET_001",
        name="Widget Not Found",
        description="The requested widget ID does not exist or has been deleted.",
        http_status=404,
        resolution="Verify the widget ID in your dashboard. Widget IDs are case-sensitive.",
        category="WIDGET"
    )

    WIDGET_002 = registry.register(
        code="WIDGET_002",
        name="Widget Disabled",
        description="The widget exists but is currently disabled by the account owner.",
        http_status=403,
        resolution="Enable the widget in your Sunaiva dashboard under Widget Settings > Status.",
        category="WIDGET"
    )

    WIDGET_003 = registry.register(
        code="WIDGET_003",
        name="Invalid Domain",
        description="The widget is being accessed from a domain not authorized in the allowlist.",
        http_status=403,
        resolution="Add the current domain to your widget's authorized domains list in the dashboard.",
        category="WIDGET"
    )

    WIDGET_004 = registry.register(
        code="WIDGET_004",
        name="Widget Configuration Error",
        description="The widget configuration is incomplete or contains invalid parameters.",
        http_status=400,
        resolution="Check that all required fields (voice_id, welcome_message) are configured in the dashboard.",
        category="WIDGET"
    )

    WIDGET_005 = registry.register(
        code="WIDGET_005",
        name="Voice Not Available",
        description="The requested voice ID is not available for your subscription tier or region.",
        http_status=400,
        resolution="Select a voice from the available voices list returned by GET /voices or upgrade your plan.",
        category="WIDGET"
    )

    WIDGET_006 = registry.register(
        code="WIDGET_006",
        name="Script Generation Failed",
        description="The AI failed to generate a voice script for the given context.",
        http_status=500,
        resolution="Try again with a different context or contact support if the issue persists.",
        category="WIDGET"
    )

    WIDGET_007 = registry.register(
        code="WIDGET_007",
        name="Invalid Widget Settings",
        description="The provided widget settings violate constraints (e.g., text too long, invalid color format).",
        http_status=400,
        resolution="Review the settings schema documentation and ensure all values meet format requirements.",
        category="WIDGET"
    )

    WIDGET_008 = registry.register(
        code="WIDGET_008",
        name="Widget Already Exists",
        description="A widget with this identifier already exists for your account.",
        http_status=409,
        resolution="Use a unique identifier or update the existing widget via PUT /widgets/{id}.",
        category="WIDGET"
    )

    WIDGET_009 = registry.register(
        code="WIDGET_009",
        name="Domain Not Whitelisted",
        description="The origin domain is not in the widget's whitelist (CORS policy violation).",
        http_status=403,
        resolution="Add the domain to your widget's CORS origins in the Security settings tab.",
        category="WIDGET"
    )

    WIDGET_010 = registry.register(
        code="WIDGET_010",
        name="Widget Session Expired",
        description="The widget session token has expired. Common for embedded widgets.",
        http_status=401,
        resolution="Generate a new session token using POST /widgets/{id}/session.",
        category="WIDGET"
    )

    # Billing Errors (BILLING_001 - BILLING_005)
    BILLING_001 = registry.register(
        code="BILLING_001",
        name="Payment Required",
        description="Your account has an outstanding invoice or payment method is required.",
        http_status=402,
        resolution="Update your payment method in the Billing section of the dashboard.",
        category="BILLING"
    )

    BILLING_002 = registry.register(
        code="BILLING_002",
        name="Subscription Expired",
        description="Your subscription has expired and API access is restricted.",
        http_status=403,
        resolution="Renew your subscription to restore full API access.",
        category="BILLING"
    )

    BILLING_003 = registry.register(
        code="BILLING_003",
        name="Quota Exceeded",
        description="You have exceeded your monthly API call or widget impression quota.",
        http_status=429,
        resolution="Upgrade your plan for higher limits or wait until your quota resets next billing cycle.",
        category="BILLING"
    )

    BILLING_004 = registry.register(
        code="BILLING_004",
        name="Payment Method Failed",
        description="The charge for your subscription was declined by the payment processor.",
        http_status=402,
        resolution="Update your payment method or contact your bank to authorize the transaction.",
        category="BILLING"
    )

    BILLING_005 = registry.register(
        code="BILLING_005",
        name="Invoice Not Found",
        description="The requested invoice ID does not exist for your account.",
        http_status=404,
        resolution="Verify the invoice ID or list available invoices via GET /billing/invoices.",
        category="BILLING"
    )

    # Rate Limiting Errors (RATE_001 - RATE_003)
    RATE_001 = registry.register(
        code="RATE_001",
        name="Too Many Requests",
        description="Standard rate limit exceeded (requests per minute).",
        http_status=429,
        resolution="Implement exponential backoff. Current limit: 100 requests/minute for standard plans.",
        category="RATE"
    )

    RATE_002 = registry.register(
        code="RATE_002",
        name="Burst Limit Exceeded",
        description="Short-term burst rate limit exceeded (requests per second).",
        http_status=429,
        resolution="Space out your requests or contact sales for burst capacity increases.",
        category="RATE"
    )

    RATE_003 = registry.register(
        code="RATE_003",
        name="Daily Limit Reached",
        description="Daily API call limit reached for your subscription tier.",
        http_status=429,
        resolution="Wait until UTC midnight for reset, or upgrade to a higher tier for increased daily limits.",
        category="RATE"
    )

    # System Errors (SYS_###) - Internal use for unhandled cases
    SYS_001 = registry.register(
        code="SYS_001",
        name="Internal Server Error",
        description="An unexpected server error occurred.",
        http_status=500,
        resolution="Contact support@sunaiva.com with the request ID and timestamp.",
        category="SYS"
    )

    SYS_002 = registry.register(
        code="SYS_002",
        name="Service Unavailable",
        description="The service is temporarily unavailable or undergoing maintenance.",
        http_status=503,
        resolution="Retry after the Retry-After header timestamp or check status.sunaiva.com.",
        category="SYS"
    )

    SYS_003 = registry.register(
        code="SYS_003",
        name="Database Connection Error",
        description="Unable to connect to the database. Temporary infrastructure issue.",
        http_status=503,
        resolution="Retry the request. If persistent, check status.sunaiva.com for outages.",
        category="SYS"
    )

# Export convenience function
get_error = registry.get
```

## 2. Custom Exception Classes

```python
# app/core/errors/exceptions.py
"""
Custom exception classes for Sunaiva API.
All exceptions include error codes, request tracking, and structured logging support.
"""

from typing import Optional, Dict, Any, Union
import uuid
from datetime import datetime
import logging

from app.core.errors.codes import registry, ErrorMetadata

logger = logging.getLogger("sunaiva.errors")

class SunaivaException(Exception):
    """
    Base exception for all Sunaiva API errors.

    Attributes:
        error_code: The registered error code (e.g., "AUTH_001")
        request_id: Unique identifier for request tracing
        details: Additional context-specific error details
        metadata: Reference to the error code metadata