from datetime import datetime from decimal import Decimal from typing import Any from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, field_validator KEY_PATTERN = r"^[a-z0-9_]{3,64}$" SESSION_PATTERN = r"^[A-Za-z0-9_-]{8,128}$" def _validate_metadata(metadata: dict[str, Any] | None) -> dict[str, Any] | None: if metadata is None: return None if len(metadata) > 20: raise ValueError("metadata must contain at most 20 keys") clean: dict[str, Any] = {} for key, value in metadata.items(): if not isinstance(key, str) or len(key) > 48: raise ValueError("metadata keys must be strings up to 48 characters") if isinstance(value, (str, int, float, bool)) or value is None: clean[key] = value continue raise ValueError("metadata values must be scalar JSON types") return clean class ExperimentVariantDefinition(BaseModel): variant_key: str = Field(..., pattern=KEY_PATTERN) label: str = Field(..., min_length=1, max_length=120) allocation: int = Field(..., ge=0, le=100) is_control: bool class ExperimentDefinitionResponse(BaseModel): experiment_key: str = Field(..., pattern=KEY_PATTERN) cookie_name: str = Field(..., min_length=3, max_length=96) name: str description: str | None = None enabled: bool eligible_routes: list[str] variants: list[ExperimentVariantDefinition] class ExperimentDefinitionUpdate(BaseModel): cookie_name: str = Field(..., min_length=3, max_length=96) name: str = Field(..., min_length=1, max_length=120) description: str | None = Field(default=None, max_length=512) enabled: bool eligible_routes: list[str] = Field(default_factory=list, min_length=1) variants: list[ExperimentVariantDefinition] = Field(..., min_length=2) @field_validator("cookie_name") @classmethod def validate_cookie_name(cls, value: str) -> str: if not value.startswith("exp_"): raise ValueError("cookie_name must start with 'exp_'") return value @field_validator("eligible_routes") @classmethod def validate_routes(cls, value: list[str]) -> list[str]: normalized: list[str] = [] for route in value: if not route.startswith("/"): raise ValueError("eligible routes must start with '/'") normalized.append(route.rstrip("/") or "/") return normalized @field_validator("variants") @classmethod def validate_variants(cls, value: list[ExperimentVariantDefinition]) -> list[ExperimentVariantDefinition]: if sum(1 for item in value if item.is_control) != 1: raise ValueError("exactly one control variant is required") if sum(item.allocation for item in value) <= 0: raise ValueError("variant allocation total must be greater than zero") return value class ExperimentEventBase(BaseModel): experiment_key: str = Field(..., pattern=KEY_PATTERN) variant_key: str = Field(..., pattern=KEY_PATTERN) session_id: str = Field(..., pattern=SESSION_PATTERN) user_id: str | None = Field(None, max_length=64) path: str = Field(..., min_length=1, max_length=255) timestamp: datetime metadata: dict[str, Any] | None = None @field_validator("path") @classmethod def validate_path(cls, value: str) -> str: if not value.startswith("/"): raise ValueError("path must start with '/'") return value @field_validator("metadata") @classmethod def validate_metadata(cls, value: dict[str, Any] | None) -> dict[str, Any] | None: return _validate_metadata(value) class ExperimentImpressionCreate(ExperimentEventBase): event_name: str = Field(default="impression", pattern=r"^impression$") class ExperimentEventCreate(ExperimentEventBase): event_name: str = Field(..., pattern=r"^(cta_click|form_start|form_submit)$") class ExperimentConversionCreate(ExperimentEventBase): event_name: str = Field(default="conversion", pattern=r"^conversion$") conversion_value: Decimal | None = Field(default=None, max_digits=12, decimal_places=2) class ExperimentEventResponse(BaseModel): id: UUID experiment_key: str variant_key: str session_id: str user_id: str | None = None path: str event_type: str conversion_value: Decimal | None = None metadata: dict[str, Any] | None = None created_at: datetime model_config = ConfigDict(from_attributes=True) class ExperimentVariantResult(BaseModel): variant_key: str impressions: int cta_clicks: int form_starts: int form_submits: int conversions: int unique_sessions: int conversion_rate: float conversion_value_total: float class ExperimentResult(BaseModel): experiment_key: str generated_at: datetime variants: list[ExperimentVariantResult] class ExperimentIngestResponse(BaseModel): ok: bool accepted: bool