v1
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
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
|
||||
Reference in New Issue
Block a user