feat(generator): добавлена модель связанного визита
- Зачем: - генератор должен создавать учебно полезный визит с общим click_id, правдоподобным путём и честным тиковым бюджетом событий. - Что: - добавлена марковская цепочка страниц, запланированные метки времени и потолок GEN_MAX_SESSION_EVENTS. - добавлен набор тикового батча из нескольких визитов до рассчитанного бюджета событий. - обновлены тесты, README, KNOWN_ISSUES и статусы задач 01/02. - Проверка: - uv run --with pytest --with-requirements generator/requirements.txt pytest generator/tests -q
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
Status: ready-for-agent
|
Status: ready-for-human
|
||||||
|
|
||||||
# Минимальный связанный визит в новом ядре
|
# Минимальный связанный визит в новом ядре
|
||||||
|
|
||||||
@@ -18,16 +18,16 @@ Kafka-топиков. Kafka на этом шаге не нужна: задача
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Тест через публичный интерфейс генератора создаёт один визит с несколькими
|
- [x] Тест через публичный интерфейс генератора создаёт один визит с несколькими
|
||||||
browser-событиями под одним `click_id`.
|
browser-событиями под одним `click_id`.
|
||||||
- [ ] У всех событий визита разные валидные `event_id`, а `click_id` не берётся
|
- [x] У всех событий визита разные валидные `event_id`, а `click_id` не берётся
|
||||||
из исходного сида.
|
из исходного сида.
|
||||||
- [ ] Для каждого browser-события есть связанная location-запись с тем же
|
- [x] Для каждого browser-события есть связанная location-запись с тем же
|
||||||
`event_id`.
|
`event_id`.
|
||||||
- [ ] Для каждого события визита публикуются device- и geo-записи с тем же
|
- [x] Для каждого события визита публикуются device- и geo-записи с тем же
|
||||||
`click_id`; их содержимое в рамках визита одинаковое, как в сиде.
|
`click_id`; их содержимое в рамках визита одинаковое, как в сиде.
|
||||||
- [ ] Один `click_id` связан ровно с одним `user_domain_id`.
|
- [x] Один `click_id` связан ровно с одним `user_domain_id`.
|
||||||
- [ ] Старый тестовый контракт "одно событие = один новый click_id" удалён или
|
- [x] Старый тестовый контракт "одно событие = один новый click_id" удалён или
|
||||||
заменён на новый контракт визита.
|
заменён на новый контракт визита.
|
||||||
|
|
||||||
## Blocked by
|
## Blocked by
|
||||||
@@ -35,4 +35,3 @@ Kafka-топиков. Kafka на этом шаге не нужна: задача
|
|||||||
None - can start immediately.
|
None - can start immediately.
|
||||||
|
|
||||||
## Comments
|
## Comments
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Status: ready-for-agent
|
Status: ready-for-human
|
||||||
|
|
||||||
# Путь визита по страницам и монотонное время
|
# Путь визита по страницам и монотонное время
|
||||||
|
|
||||||
@@ -17,17 +17,17 @@ Status: ready-for-agent
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Тест через публичный интерфейс генератора показывает, что
|
- [x] Тест через публичный интерфейс генератора показывает, что
|
||||||
`event_timestamp` внутри одного `click_id` строго возрастает.
|
`event_timestamp` внутри одного `click_id` строго возрастает.
|
||||||
- [ ] Метки времени событий являются запланированными моментами визита, а не
|
- [x] Метки времени событий являются запланированными моментами визита, а не
|
||||||
одинаковым `now()` для всего набора событий.
|
одинаковым `now()` для всего набора событий.
|
||||||
- [ ] Паузы внутри визита меньше 30 минут; 95-й перцентиль на симуляции лежит в
|
- [x] Паузы внутри визита меньше 30 минут; 95-й перцентиль на симуляции лежит в
|
||||||
масштабе единиц минут.
|
масштабе единиц минут.
|
||||||
- [ ] Визит не может зациклиться бесконечно: длина ограничена потолком
|
- [x] Визит не может зациклиться бесконечно: длина ограничена потолком
|
||||||
`GEN_MAX_SESSION_EVENTS` или его новым эквивалентом.
|
`GEN_MAX_SESSION_EVENTS` или его новым эквивалентом.
|
||||||
- [ ] Доля визитов, дошедших до `/confirmation`, на симуляции сопоставима с
|
- [x] Доля визитов, дошедших до `/confirmation`, на симуляции сопоставима с
|
||||||
ориентиром сида около 25%.
|
ориентиром сида около 25%.
|
||||||
- [ ] Визит может продолжаться после `/confirmation`; `/confirmation` не
|
- [x] Визит может продолжаться после `/confirmation`; `/confirmation` не
|
||||||
считается обязательным последним событием визита.
|
считается обязательным последним событием визита.
|
||||||
|
|
||||||
## Blocked by
|
## Blocked by
|
||||||
@@ -35,4 +35,3 @@ Status: ready-for-agent
|
|||||||
- `.scratch/feature-data-generator/issues/01-minimal-connected-visit.md`
|
- `.scratch/feature-data-generator/issues/01-minimal-connected-visit.md`
|
||||||
|
|
||||||
## Comments
|
## Comments
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
> Витрины и Superset-дашборд строятся на **статическом сиде** (`data/*.jsonl`).
|
> Витрины и Superset-дашборд строятся на **статическом сиде** (`data/*.jsonl`).
|
||||||
> Причина — ниже. Это не «сырой код по мелочи», а концептуальный дефект
|
> Причина — ниже. Это не «сырой код по мелочи», а концептуальный дефект
|
||||||
> генеративной модели, который надо осознанно чинить перед использованием.
|
> генеративной модели, который надо осознанно чинить перед использованием.
|
||||||
|
>
|
||||||
|
> **Обновление (2026-06-11):** первый срез модели визита реализован в
|
||||||
|
> `generate_batch()`: один публичный вызов строит один `click_id` с несколькими
|
||||||
|
> событиями, марковским путём по страницам и запланированными строго растущими
|
||||||
|
> метками времени. Остальные пункты ниже остаются полезным историческим
|
||||||
|
> контекстом и списком следующих шагов: популяция возвращающихся пользователей,
|
||||||
|
> межсессионные паузы и полноценное состояние активных визитов ещё не закрыты.
|
||||||
|
|
||||||
Заметка написана при дизайне Superset-дашборда (ветка
|
Заметка написана при дизайне Superset-дашборда (ветка
|
||||||
`docs/advanced-clickstream-course`): разбирались, почему на дашборде
|
`docs/advanced-clickstream-course`): разбирались, почему на дашборде
|
||||||
@@ -16,12 +23,13 @@
|
|||||||
|
|
||||||
- **Модель интенсивности потока (сколько событий и когда) — нормальная.**
|
- **Модель интенсивности потока (сколько событий и когда) — нормальная.**
|
||||||
Poisson по тикам + дневной коэффициент + jitter. Её можно оставить.
|
Poisson по тикам + дневной коэффициент + jitter. Её можно оставить.
|
||||||
- **Генеративная модель сущностей (кто, какая сессия, какое событие) — неверная.**
|
- **Генеративная модель сущностей исправляется по шагам.** Срез одного визита
|
||||||
На каждое событие штампуется свежий `click_id`, а атрибуты копируются из
|
уже не штампует свежий `click_id` на каждое событие, но полная иерархия
|
||||||
случайной сид-строки. Это уничтожает понятие визита/сессии.
|
пользователь → несколько визитов → события ещё требует популяции
|
||||||
- **Вывод:** прежде чем использовать генератор как источник, переделать именно
|
возвращающихся пользователей.
|
||||||
модель сущностей (иерархия пользователь → сессия → событие). Математику
|
- **Вывод:** прежде чем использовать генератор как полноценный источник,
|
||||||
интенсивности трогать не обязательно.
|
доделать оставшиеся уровни модели сущностей. Математику интенсивности
|
||||||
|
трогать не обязательно.
|
||||||
|
|
||||||
## Доменная модель (как задумано в DDL)
|
## Доменная модель (как задумано в DDL)
|
||||||
|
|
||||||
@@ -50,10 +58,10 @@ user_domain_id (постоянный пользователь, cookie)
|
|||||||
|
|
||||||
Это адекватная модель *интенсивности во времени*. Претензий к ней нет.
|
Это адекватная модель *интенсивности во времени*. Претензий к ней нет.
|
||||||
|
|
||||||
## Корневой дефект: модель сущностей в `generate_batch()`
|
## Исторический корневой дефект: модель сущностей в `generate_batch()`
|
||||||
|
|
||||||
Файл `generator/generator.py`, `generate_batch()` (стр. ~262–330). На каждое
|
До среза от 2026-06-11 файл `generator/generator.py`, `generate_batch()` на
|
||||||
событие в батче:
|
каждое событие в батче делал примерно следующее:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
base_browser = self.rng.choice(self.dictionary.browser_events) # случайная сид-строка
|
base_browser = self.rng.choice(self.dictionary.browser_events) # случайная сид-строка
|
||||||
|
|||||||
+12
-6
@@ -1,9 +1,10 @@
|
|||||||
# Генератор событий (MVP rev5)
|
# Генератор событий (MVP rev5)
|
||||||
|
|
||||||
> ⚠️ **Перед использованием как источник витрин — прочитать
|
> Перед использованием как источник витрин прочитать
|
||||||
> [KNOWN_ISSUES.md](./KNOWN_ISSUES.md).** Генеративная модель сущностей неверна
|
> [KNOWN_ISSUES.md](./KNOWN_ISSUES.md): часть старого дефекта уже исправлена
|
||||||
> (свежий `click_id` на каждое событие ломает семантику визита/сессии); ветка
|
> (один `click_id` на визит, путь по страницам, монотонное время), но популяция
|
||||||
> не влита в `main` именно поэтому. Математику интенсивности это не затрагивает.
|
> возвращающихся пользователей и полноценное состояние активных визитов ещё
|
||||||
|
> остаются следующими шагами.
|
||||||
|
|
||||||
Автономный генератор событий для Kafka с режимом `steady-stream`.
|
Автономный генератор событий для Kafka с режимом `steady-stream`.
|
||||||
|
|
||||||
@@ -19,13 +20,17 @@ generator-service -> Kafka topics -> (потребители отдельно)
|
|||||||
|
|
||||||
- Публикуем постепенно, **короткими тиками** (по умолчанию каждые 5 секунд)
|
- Публикуем постепенно, **короткими тиками** (по умолчанию каждые 5 секунд)
|
||||||
- На каждом тике отправляем небольшую порцию сообщений
|
- На каждом тике отправляем небольшую порцию сообщений
|
||||||
- Держим целевую интенсивность `events/min` без крупных минутных batch
|
- Держим целевую интенсивность `events/min` без крупных минутных batch: тик
|
||||||
|
набирается из одного или нескольких визитов до рассчитанного бюджета событий
|
||||||
- Распределяем события по 4 топикам:
|
- Распределяем события по 4 топикам:
|
||||||
- `browser_events`
|
- `browser_events`
|
||||||
- `location_events`
|
- `location_events`
|
||||||
- `device_events`
|
- `device_events`
|
||||||
- `geo_events`
|
- `geo_events`
|
||||||
- Сохраняем связи `event_id <-> location`, `click_id <-> device/geo`
|
- Публичный вызов генеративного ядра строит один визит: общий `click_id`,
|
||||||
|
разные `event_id`, общий device/geo-контекст, путь по страницам воронки и
|
||||||
|
строго растущие запланированные `event_timestamp`.
|
||||||
|
- Сохраняем связи `event_id <-> location`, `click_id <-> device/geo`.
|
||||||
|
|
||||||
## Конфигурация (env)
|
## Конфигурация (env)
|
||||||
|
|
||||||
@@ -37,6 +42,7 @@ generator-service -> Kafka topics -> (потребители отдельно)
|
|||||||
| `GEN_JITTER_PCT` | Процент вариативности | `20` |
|
| `GEN_JITTER_PCT` | Процент вариативности | `20` |
|
||||||
| `GEN_MIN_EVENTS_PER_TICK` | Минимум событий за тик | `5` |
|
| `GEN_MIN_EVENTS_PER_TICK` | Минимум событий за тик | `5` |
|
||||||
| `GEN_MAX_EVENTS_PER_TICK` | Максимум событий за тик | `50` |
|
| `GEN_MAX_EVENTS_PER_TICK` | Максимум событий за тик | `50` |
|
||||||
|
| `GEN_MAX_SESSION_EVENTS` | Потолок длины одного визита, защита от петель | `30` |
|
||||||
| `GEN_DATA_DIR` | Путь к JSONL файлам | `/data` |
|
| `GEN_DATA_DIR` | Путь к JSONL файлам | `/data` |
|
||||||
| `GEN_SEED` | Сид для воспроизводимости | — |
|
| `GEN_SEED` | Сид для воспроизводимости | — |
|
||||||
| `GEN_ENABLED` | Включить генерацию | `true` |
|
| `GEN_ENABLED` | Включить генерацию | `true` |
|
||||||
|
|||||||
+147
-10
@@ -15,7 +15,7 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -71,6 +71,62 @@ METRICS_LAST_SUCCESS = Gauge(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PAGE_START_DISTRIBUTION = [
|
||||||
|
("/home", 0.59),
|
||||||
|
("/product_a", 0.20),
|
||||||
|
("/product_b", 0.14),
|
||||||
|
("/cart", 0.04),
|
||||||
|
("/payment", 0.02),
|
||||||
|
("/confirmation", 0.01),
|
||||||
|
]
|
||||||
|
|
||||||
|
PAGE_TRANSITIONS = {
|
||||||
|
"/home": [
|
||||||
|
("/home", 0.40),
|
||||||
|
("/product_a", 0.28),
|
||||||
|
("/product_b", 0.18),
|
||||||
|
("/cart", 0.04),
|
||||||
|
(None, 0.10),
|
||||||
|
],
|
||||||
|
"/product_a": [
|
||||||
|
("/home", 0.30),
|
||||||
|
("/product_a", 0.16),
|
||||||
|
("/product_b", 0.18),
|
||||||
|
("/cart", 0.27),
|
||||||
|
(None, 0.09),
|
||||||
|
],
|
||||||
|
"/product_b": [
|
||||||
|
("/home", 0.32),
|
||||||
|
("/product_a", 0.15),
|
||||||
|
("/product_b", 0.18),
|
||||||
|
("/cart", 0.27),
|
||||||
|
(None, 0.08),
|
||||||
|
],
|
||||||
|
"/cart": [
|
||||||
|
("/home", 0.20),
|
||||||
|
("/product_a", 0.12),
|
||||||
|
("/product_b", 0.10),
|
||||||
|
("/cart", 0.10),
|
||||||
|
("/payment", 0.42),
|
||||||
|
(None, 0.06),
|
||||||
|
],
|
||||||
|
"/payment": [
|
||||||
|
("/home", 0.12),
|
||||||
|
("/cart", 0.24),
|
||||||
|
("/payment", 0.12),
|
||||||
|
("/confirmation", 0.38),
|
||||||
|
(None, 0.14),
|
||||||
|
],
|
||||||
|
"/confirmation": [
|
||||||
|
("/home", 0.35),
|
||||||
|
("/product_a", 0.15),
|
||||||
|
("/product_b", 0.10),
|
||||||
|
("/confirmation", 0.05),
|
||||||
|
(None, 0.35),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Конфигурация через env
|
# Конфигурация через env
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -99,6 +155,9 @@ class Config:
|
|||||||
max_events_per_tick: int = field(
|
max_events_per_tick: int = field(
|
||||||
default_factory=lambda: int(os.getenv("GEN_MAX_EVENTS_PER_TICK", "50"))
|
default_factory=lambda: int(os.getenv("GEN_MAX_EVENTS_PER_TICK", "50"))
|
||||||
)
|
)
|
||||||
|
max_session_events: int = field(
|
||||||
|
default_factory=lambda: int(os.getenv("GEN_MAX_SESSION_EVENTS", "30"))
|
||||||
|
)
|
||||||
|
|
||||||
# Пути к данным
|
# Пути к данным
|
||||||
data_dir: Path = field(
|
data_dir: Path = field(
|
||||||
@@ -136,6 +195,8 @@ class Config:
|
|||||||
raise ValueError("GEN_TICK_SECONDS must be >= 1")
|
raise ValueError("GEN_TICK_SECONDS must be >= 1")
|
||||||
if self.lambda_base_per_min < 1:
|
if self.lambda_base_per_min < 1:
|
||||||
raise ValueError("GEN_LAMBDA_BASE_PER_MIN must be >= 1")
|
raise ValueError("GEN_LAMBDA_BASE_PER_MIN must be >= 1")
|
||||||
|
if self.max_session_events < 1:
|
||||||
|
raise ValueError("GEN_MAX_SESSION_EVENTS must be >= 1")
|
||||||
if not self.data_dir.exists():
|
if not self.data_dir.exists():
|
||||||
raise ValueError(f"Data directory does not exist: {self.data_dir}")
|
raise ValueError(f"Data directory does not exist: {self.data_dir}")
|
||||||
|
|
||||||
@@ -220,6 +281,44 @@ class EventGenerator:
|
|||||||
"""Возвращает текущую метку времени в формате JSONL."""
|
"""Возвращает текущую метку времени в формате JSONL."""
|
||||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
|
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||||
|
|
||||||
|
def _format_timestamp(self, timestamp: datetime) -> str:
|
||||||
|
"""Форматирует запланированную метку времени для JSONL."""
|
||||||
|
return timestamp.strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||||
|
|
||||||
|
def _weighted_choice(self, choices: list[tuple[Any, float]]) -> Any:
|
||||||
|
"""Разыгрывает значение по списку весов."""
|
||||||
|
point = self.rng.random()
|
||||||
|
cumulative = 0.0
|
||||||
|
for value, weight in choices:
|
||||||
|
cumulative += weight
|
||||||
|
if point < cumulative:
|
||||||
|
return value
|
||||||
|
return choices[-1][0]
|
||||||
|
|
||||||
|
def _generate_visit_path(self, max_events: int, min_events: int = 1) -> list[str]:
|
||||||
|
"""Генерирует путь визита по страницам с защитой от бесконечных петель."""
|
||||||
|
page = self._weighted_choice(PAGE_START_DISTRIBUTION)
|
||||||
|
path = []
|
||||||
|
|
||||||
|
while page is not None and len(path) < max_events:
|
||||||
|
path.append(page)
|
||||||
|
page = self._weighted_choice(PAGE_TRANSITIONS[page])
|
||||||
|
|
||||||
|
while len(path) < min_events and len(path) < max_events:
|
||||||
|
transitions = [
|
||||||
|
(next_page, weight)
|
||||||
|
for next_page, weight in PAGE_TRANSITIONS[path[-1]]
|
||||||
|
if next_page is not None
|
||||||
|
]
|
||||||
|
path.append(self._weighted_choice(transitions))
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _visit_pause_seconds(self) -> float:
|
||||||
|
"""Разыгрывает паузу между событиями визита."""
|
||||||
|
pause = self.rng.lognormvariate(math.log(45.0), 1.0)
|
||||||
|
return max(1.0, min(pause, 29 * 60.0))
|
||||||
|
|
||||||
def _hour_factor(self) -> float:
|
def _hour_factor(self) -> float:
|
||||||
"""Возвращает коэффициент интенсивности в зависимости от часа дня."""
|
"""Возвращает коэффициент интенсивности в зависимости от часа дня."""
|
||||||
hour = datetime.now(timezone.utc).hour
|
hour = datetime.now(timezone.utc).hour
|
||||||
@@ -264,7 +363,7 @@ class EventGenerator:
|
|||||||
|
|
||||||
def generate_batch(self, batch_size: int) -> dict[str, list[dict]]:
|
def generate_batch(self, batch_size: int) -> dict[str, list[dict]]:
|
||||||
"""
|
"""
|
||||||
Генерирует батч событий с сохранением связей.
|
Генерирует один визит с сохранением связей.
|
||||||
|
|
||||||
Возвращает словарь {topic: [events]}
|
Возвращает словарь {topic: [events]}
|
||||||
"""
|
"""
|
||||||
@@ -287,38 +386,44 @@ class EventGenerator:
|
|||||||
if batch_size <= 0:
|
if batch_size <= 0:
|
||||||
return batch
|
return batch
|
||||||
|
|
||||||
|
max_visit_events = min(batch_size, self.config.max_session_events)
|
||||||
|
min_visit_events = min(2, max_visit_events)
|
||||||
|
visit_path = self._generate_visit_path(max_visit_events, min_visit_events)
|
||||||
|
|
||||||
visit_candidates = [
|
visit_candidates = [
|
||||||
click_id for click_id, browser_events in self.dictionary.browser_by_click_id.items()
|
click_id for click_id, browser_events in self.dictionary.browser_by_click_id.items()
|
||||||
if (
|
if (
|
||||||
len(browser_events) >= batch_size
|
len(browser_events) >= len(visit_path)
|
||||||
and click_id in self.dictionary.device_by_click_id
|
and click_id in self.dictionary.device_by_click_id
|
||||||
and click_id in self.dictionary.geo_by_click_id
|
and click_id in self.dictionary.geo_by_click_id
|
||||||
and all(
|
and all(
|
||||||
event["event_id"] in self.dictionary.location_by_event_id
|
event["event_id"] in self.dictionary.location_by_event_id
|
||||||
for event in browser_events[:batch_size]
|
for event in browser_events[:len(visit_path)]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
if visit_candidates:
|
if visit_candidates:
|
||||||
base_click_id = self.rng.choice(visit_candidates)
|
base_click_id = self.rng.choice(visit_candidates)
|
||||||
base_browser_events = self.dictionary.browser_by_click_id[base_click_id][:batch_size]
|
base_browser_events = self.dictionary.browser_by_click_id[base_click_id][:len(visit_path)]
|
||||||
else:
|
else:
|
||||||
# Крайний случай для очень малого сида: сохраняем форму визита,
|
# Крайний случай для очень малого сида: сохраняем форму визита,
|
||||||
# даже если приходится брать события с повторением.
|
# даже если приходится брать события с повторением.
|
||||||
base_browser = self.rng.choice(self.dictionary.browser_events)
|
base_browser = self.rng.choice(self.dictionary.browser_events)
|
||||||
base_click_id = base_browser["click_id"]
|
base_click_id = base_browser["click_id"]
|
||||||
base_browser_events = [base_browser for _ in range(batch_size)]
|
base_browser_events = [base_browser for _ in range(len(visit_path))]
|
||||||
|
|
||||||
base_device = self.dictionary.device_by_click_id.get(base_click_id)
|
base_device = self.dictionary.device_by_click_id.get(base_click_id)
|
||||||
base_geo = self.dictionary.geo_by_click_id.get(base_click_id)
|
base_geo = self.dictionary.geo_by_click_id.get(base_click_id)
|
||||||
new_click_id = self._new_uuid()
|
new_click_id = self._new_uuid()
|
||||||
|
|
||||||
for base_browser in base_browser_events:
|
planned_timestamp = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
for base_browser, page_url_path in zip(base_browser_events, visit_path):
|
||||||
base_location = self.dictionary.location_by_event_id.get(base_browser["event_id"])
|
base_location = self.dictionary.location_by_event_id.get(base_browser["event_id"])
|
||||||
|
|
||||||
# Генерируем новые ID
|
# Генерируем новые ID
|
||||||
new_event_id = self._new_uuid()
|
new_event_id = self._new_uuid()
|
||||||
new_timestamp = self._current_timestamp()
|
new_timestamp = self._format_timestamp(planned_timestamp)
|
||||||
|
|
||||||
# Создаём новое браузерное событие
|
# Создаём новое браузерное событие
|
||||||
browser_event = {
|
browser_event = {
|
||||||
@@ -334,6 +439,8 @@ class EventGenerator:
|
|||||||
location_event = {
|
location_event = {
|
||||||
**base_location,
|
**base_location,
|
||||||
"event_id": new_event_id,
|
"event_id": new_event_id,
|
||||||
|
"page_url": f"http://www.dummywebsite.com{page_url_path}",
|
||||||
|
"page_url_path": page_url_path,
|
||||||
}
|
}
|
||||||
batch["location_events"].append(location_event)
|
batch["location_events"].append(location_event)
|
||||||
|
|
||||||
@@ -353,8 +460,38 @@ class EventGenerator:
|
|||||||
}
|
}
|
||||||
batch["geo_events"].append(geo_event)
|
batch["geo_events"].append(geo_event)
|
||||||
|
|
||||||
|
planned_timestamp += timedelta(seconds=self._visit_pause_seconds())
|
||||||
|
|
||||||
return batch
|
return batch
|
||||||
|
|
||||||
|
def generate_tick_batch(self, event_budget: int) -> dict[str, list[dict]]:
|
||||||
|
"""
|
||||||
|
Генерирует батч тика из одного или нескольких визитов.
|
||||||
|
|
||||||
|
`generate_batch()` остаётся публичным срезом одного визита. Для тика
|
||||||
|
нужно набрать рассчитанный бюджет событий, поэтому здесь несколько
|
||||||
|
визитов объединяются в один набор записей для публикации.
|
||||||
|
"""
|
||||||
|
tick_batch = {
|
||||||
|
"browser_events": [],
|
||||||
|
"location_events": [],
|
||||||
|
"device_events": [],
|
||||||
|
"geo_events": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining_events = event_budget
|
||||||
|
while remaining_events > 0:
|
||||||
|
visit_batch = self.generate_batch(remaining_events)
|
||||||
|
generated_events = len(visit_batch["browser_events"])
|
||||||
|
if generated_events == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
for topic, events in visit_batch.items():
|
||||||
|
tick_batch[topic].extend(events)
|
||||||
|
remaining_events -= generated_events
|
||||||
|
|
||||||
|
return tick_batch
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Batch record для истории
|
# Batch record для истории
|
||||||
@@ -923,9 +1060,9 @@ class GeneratorService:
|
|||||||
events_count = self.generator._calculate_events_count()
|
events_count = self.generator._calculate_events_count()
|
||||||
logger.info(f"Generating ~{events_count} base events")
|
logger.info(f"Generating ~{events_count} base events")
|
||||||
|
|
||||||
# Генерируем батч
|
# Генерируем тиковый батч из одного или нескольких визитов
|
||||||
gen_start = time.time()
|
gen_start = time.time()
|
||||||
batch = self.generator.generate_batch(events_count)
|
batch = self.generator.generate_tick_batch(events_count)
|
||||||
gen_duration = time.time() - gen_start
|
gen_duration = time.time() - gen_start
|
||||||
|
|
||||||
# Публикуем в Kafka
|
# Публикуем в Kafka
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ class TestConfigValidation:
|
|||||||
with pytest.raises(ValueError, match="GEN_LAMBDA_BASE_PER_MIN"):
|
with pytest.raises(ValueError, match="GEN_LAMBDA_BASE_PER_MIN"):
|
||||||
replace(base_config, lambda_base_per_min=0)
|
replace(base_config, lambda_base_per_min=0)
|
||||||
|
|
||||||
|
def test_max_session_events_must_be_positive(self, base_config):
|
||||||
|
"""max_session_events должен быть >= 1."""
|
||||||
|
from dataclasses import replace
|
||||||
|
with pytest.raises(ValueError, match="GEN_MAX_SESSION_EVENTS"):
|
||||||
|
replace(base_config, max_session_events=0)
|
||||||
|
|
||||||
def test_data_dir_must_exist(self, base_config):
|
def test_data_dir_must_exist(self, base_config):
|
||||||
"""data_dir должен существовать."""
|
"""data_dir должен существовать."""
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
|
|||||||
@@ -3,12 +3,40 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import replace
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from generator import EventGenerator, EventDictionary
|
from generator import EventGenerator, EventDictionary
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_PAGE_PATHS = {
|
||||||
|
"/home",
|
||||||
|
"/product_a",
|
||||||
|
"/product_b",
|
||||||
|
"/cart",
|
||||||
|
"/payment",
|
||||||
|
"/confirmation",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_event_timestamps(batch):
|
||||||
|
return [
|
||||||
|
datetime.fromisoformat(event["event_timestamp"].replace(" ", "T"))
|
||||||
|
for event in batch["browser_events"]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _page_path_visits(generator, visits_count: int):
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
event["page_url_path"]
|
||||||
|
for event in generator.generate_batch(30)["location_events"]
|
||||||
|
]
|
||||||
|
for _ in range(visits_count)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TestEventGeneration:
|
class TestEventGeneration:
|
||||||
"""Тесты генерации событий."""
|
"""Тесты генерации событий."""
|
||||||
|
|
||||||
@@ -51,15 +79,28 @@ class TestEventGeneration:
|
|||||||
assert "device_events" in batch
|
assert "device_events" in batch
|
||||||
assert "geo_events" in batch
|
assert "geo_events" in batch
|
||||||
|
|
||||||
def test_generate_batch_size(self, event_dictionary, base_config):
|
def test_generate_batch_respects_requested_visit_budget(self, event_dictionary, base_config):
|
||||||
"""Батч содержит правильное количество событий."""
|
"""Визит не превышает запрошенный бюджет событий."""
|
||||||
generator = EventGenerator(event_dictionary, base_config)
|
generator = EventGenerator(event_dictionary, base_config)
|
||||||
batch = generator.generate_batch(10)
|
batch = generator.generate_batch(10)
|
||||||
|
|
||||||
assert len(batch["browser_events"]) == 10
|
browser_count = len(batch["browser_events"])
|
||||||
assert len(batch["location_events"]) == 10
|
|
||||||
assert len(batch["device_events"]) == 10
|
assert 1 <= browser_count <= 10
|
||||||
assert len(batch["geo_events"]) == 10
|
assert len(batch["location_events"]) == browser_count
|
||||||
|
assert len(batch["device_events"]) == browser_count
|
||||||
|
assert len(batch["geo_events"]) == browser_count
|
||||||
|
|
||||||
|
def test_generate_tick_batch_fills_requested_event_budget(self, event_dictionary, base_config):
|
||||||
|
"""Тиковый батч набирает целевой бюджет из одного или нескольких визитов."""
|
||||||
|
generator = EventGenerator(event_dictionary, base_config)
|
||||||
|
|
||||||
|
batch = generator.generate_tick_batch(20)
|
||||||
|
|
||||||
|
assert len(batch["browser_events"]) == 20
|
||||||
|
assert len(batch["location_events"]) == 20
|
||||||
|
assert len(batch["device_events"]) == 20
|
||||||
|
assert len(batch["geo_events"]) == 20
|
||||||
|
|
||||||
def test_generate_batch_creates_one_connected_visit(self, event_dictionary, base_config):
|
def test_generate_batch_creates_one_connected_visit(self, event_dictionary, base_config):
|
||||||
"""Публичный вызов генератора создаёт один связанный визит."""
|
"""Публичный вызов генератора создаёт один связанный визит."""
|
||||||
@@ -98,6 +139,17 @@ class TestEventGeneration:
|
|||||||
assert all(context == device_context[0] for context in device_context)
|
assert all(context == device_context[0] for context in device_context)
|
||||||
assert all(context == geo_context[0] for context in geo_context)
|
assert all(context == geo_context[0] for context in geo_context)
|
||||||
|
|
||||||
|
def test_generate_batch_creates_multi_event_visit_when_budget_allows(
|
||||||
|
self, event_dictionary, base_config
|
||||||
|
):
|
||||||
|
"""Минимальный связанный визит не схлопывается в одно событие."""
|
||||||
|
config = replace(base_config, seed=2)
|
||||||
|
generator = EventGenerator(event_dictionary, config)
|
||||||
|
|
||||||
|
batch = generator.generate_batch(3)
|
||||||
|
|
||||||
|
assert len(batch["browser_events"]) >= 2
|
||||||
|
|
||||||
def test_event_ids_are_new_uuids(self, event_dictionary, base_config):
|
def test_event_ids_are_new_uuids(self, event_dictionary, base_config):
|
||||||
"""event_id и click_id — новые UUID, не из оригинальных данных."""
|
"""event_id и click_id — новые UUID, не из оригинальных данных."""
|
||||||
generator = EventGenerator(event_dictionary, base_config)
|
generator = EventGenerator(event_dictionary, base_config)
|
||||||
@@ -126,6 +178,106 @@ class TestEventGeneration:
|
|||||||
dt = datetime.fromisoformat(timestamp.replace(" ", "T"))
|
dt = datetime.fromisoformat(timestamp.replace(" ", "T"))
|
||||||
assert dt.year >= 2024
|
assert dt.year >= 2024
|
||||||
|
|
||||||
|
def test_visit_event_timestamps_strictly_increase(self, event_dictionary, base_config):
|
||||||
|
"""Время событий внутри одного визита строго возрастает."""
|
||||||
|
generator = EventGenerator(event_dictionary, base_config)
|
||||||
|
batch = generator.generate_batch(8)
|
||||||
|
|
||||||
|
timestamps = _parse_event_timestamps(batch)
|
||||||
|
|
||||||
|
assert all(
|
||||||
|
previous < current
|
||||||
|
for previous, current in zip(timestamps, timestamps[1:])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_visit_event_timestamps_use_planned_user_pauses(self, event_dictionary, base_config):
|
||||||
|
"""Метки времени визита разделены пользовательскими паузами, а не временем цикла."""
|
||||||
|
generator = EventGenerator(event_dictionary, base_config)
|
||||||
|
batch = generator.generate_batch(8)
|
||||||
|
|
||||||
|
timestamps = _parse_event_timestamps(batch)
|
||||||
|
pauses_seconds = [
|
||||||
|
(current - previous).total_seconds()
|
||||||
|
for previous, current in zip(timestamps, timestamps[1:])
|
||||||
|
]
|
||||||
|
|
||||||
|
assert min(pauses_seconds) >= 1.0
|
||||||
|
|
||||||
|
def test_visit_path_uses_known_funnel_pages(self, event_dictionary, base_config):
|
||||||
|
"""Путь визита состоит из страниц воронки."""
|
||||||
|
generator = EventGenerator(event_dictionary, base_config)
|
||||||
|
batch = generator.generate_batch(30)
|
||||||
|
|
||||||
|
page_paths = [event["page_url_path"] for event in batch["location_events"]]
|
||||||
|
|
||||||
|
assert page_paths
|
||||||
|
assert set(page_paths) <= ALLOWED_PAGE_PATHS
|
||||||
|
|
||||||
|
def test_visit_starts_from_calibrated_start_distribution(self, event_dictionary, base_config):
|
||||||
|
"""Визиты стартуют не только с /home, а по стартовому распределению."""
|
||||||
|
generator = EventGenerator(event_dictionary, base_config)
|
||||||
|
|
||||||
|
visits = _page_path_visits(generator, 1000)
|
||||||
|
first_pages = [visit[0] for visit in visits]
|
||||||
|
home_share = first_pages.count("/home") / len(first_pages)
|
||||||
|
product_entry_share = (
|
||||||
|
first_pages.count("/product_a") + first_pages.count("/product_b")
|
||||||
|
) / len(first_pages)
|
||||||
|
|
||||||
|
assert 0.54 <= home_share <= 0.64
|
||||||
|
assert product_entry_share >= 0.25
|
||||||
|
|
||||||
|
def test_visit_length_is_capped_by_session_limit(self, event_dictionary, base_config):
|
||||||
|
"""Длина визита ограничена потолком, который защищает от петель."""
|
||||||
|
config = replace(base_config, max_session_events=4)
|
||||||
|
generator = EventGenerator(event_dictionary, config)
|
||||||
|
|
||||||
|
visits = _page_path_visits(generator, 200)
|
||||||
|
|
||||||
|
assert max(len(visit) for visit in visits) <= 4
|
||||||
|
assert any(len(visit) == 4 for visit in visits)
|
||||||
|
|
||||||
|
def test_visit_pauses_stay_below_session_timeout_scale(self, event_dictionary, base_config):
|
||||||
|
"""Паузы внутри визита остаются меньше 30 минут, p95 — единицы минут."""
|
||||||
|
generator = EventGenerator(event_dictionary, base_config)
|
||||||
|
pauses_seconds = []
|
||||||
|
|
||||||
|
for _ in range(500):
|
||||||
|
timestamps = _parse_event_timestamps(generator.generate_batch(30))
|
||||||
|
pauses_seconds.extend(
|
||||||
|
(current - previous).total_seconds()
|
||||||
|
for previous, current in zip(timestamps, timestamps[1:])
|
||||||
|
)
|
||||||
|
|
||||||
|
pauses_seconds.sort()
|
||||||
|
p95 = pauses_seconds[int(len(pauses_seconds) * 0.95)]
|
||||||
|
|
||||||
|
assert max(pauses_seconds) < 30 * 60
|
||||||
|
assert p95 < 5 * 60
|
||||||
|
|
||||||
|
def test_confirmation_share_matches_seed_scale(self, event_dictionary, base_config):
|
||||||
|
"""Около четверти визитов доходят до /confirmation."""
|
||||||
|
generator = EventGenerator(event_dictionary, base_config)
|
||||||
|
|
||||||
|
visits = _page_path_visits(generator, 1000)
|
||||||
|
confirmation_share = (
|
||||||
|
sum("/confirmation" in visit for visit in visits) / len(visits)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 0.20 <= confirmation_share <= 0.30
|
||||||
|
|
||||||
|
def test_visit_can_continue_after_confirmation(self, event_dictionary, base_config):
|
||||||
|
"""/confirmation не обязан быть последним событием визита."""
|
||||||
|
generator = EventGenerator(event_dictionary, base_config)
|
||||||
|
|
||||||
|
visits = _page_path_visits(generator, 1000)
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
page_path == "/confirmation" and index < len(visit) - 1
|
||||||
|
for visit in visits
|
||||||
|
for index, page_path in enumerate(visit)
|
||||||
|
)
|
||||||
|
|
||||||
def test_links_consistency(self, event_dictionary, base_config):
|
def test_links_consistency(self, event_dictionary, base_config):
|
||||||
"""Связи между событиями сохраняются."""
|
"""Связи между событиями сохраняются."""
|
||||||
generator = EventGenerator(event_dictionary, base_config)
|
generator = EventGenerator(event_dictionary, base_config)
|
||||||
|
|||||||
Reference in New Issue
Block a user