Files
gw/backend/app/schemas/experiments.py
T
ponzischeme89 6d44e05de4 v1
2026-04-18 07:23:55 +12:00

155 lines
4.9 KiB
Python

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