feat(generator): добавлен поток активных визитов по тикам

- Зачем:
  - генератор должен выпускать события визита по запланированным тикам, не приклеивая весь визит к одному запуску.
- Что:
  - добавлен тиковый слой `TickStreamGenerator` с активными визитами и выпуском созревших событий.
  - добавлена валидация потолка активных визитов относительно потолка популяции.
  - сохранён событийный смысл `event_budget` и добавлен регрессионный тест против разгона интенсивности.
- Проверка:
  - `uv run --with-requirements generator/requirements.txt pytest generator/tests -q` — 81 passed.
This commit is contained in:
Dmitry Dementiev
2026-06-11 15:32:53 +03:00
parent 642789d484
commit d8da27187e
9 changed files with 316 additions and 48 deletions
@@ -1,4 +1,4 @@
Status: ready-for-agent Status: ready-for-human
# Поток по тикам с активными визитами # Поток по тикам с активными визитами
@@ -17,15 +17,15 @@ Status: ready-for-agent
## Acceptance criteria ## Acceptance criteria
- [ ] Тест через публичный интерфейс генератора показывает, что один `click_id` - [x] Тест через публичный интерфейс генератора показывает, что один `click_id`
может появляться в выходе нескольких последовательных тиков. может появляться в выходе нескольких последовательных тиков.
- [ ] На каждом тике выпускаются только созревшие события активных визитов. - [x] На каждом тике выпускаются только созревшие события активных визитов.
- [ ] `event_timestamp` отражает запланированное время события, а не время - [x] `event_timestamp` отражает запланированное время события, а не время
фактической отправки тика. фактической отправки тика.
- [ ] Завершённые визиты больше не выпускают события в следующих тиках. - [x] Завершённые визиты больше не выпускают события в следующих тиках.
- [ ] При достижении потолка активных визитов новые рождения в этот тик - [x] При достижении потолка активных визитов новые рождения в этот тик
пропускаются, а бюджет не копится бесконечно. пропускаются, а бюджет не копится бесконечно.
- [ ] Конфигурация запрещает состояние, где потолок активных визитов не меньше - [x] Конфигурация запрещает состояние, где потолок активных визитов не меньше
потолка популяции пользователей. потолка популяции пользователей.
## Blocked by ## Blocked by
@@ -34,3 +34,8 @@ Status: ready-for-agent
- `.scratch/feature-data-generator/issues/02-5-generator-service-cleanup.md` - `.scratch/feature-data-generator/issues/02-5-generator-service-cleanup.md`
## Comments ## Comments
2026-06-11: реализовано через `TickStreamGenerator` в тиковом слое. Проверка:
`uv run --with-requirements generator/requirements.txt pytest generator/tests -q`
— 81 passed. После ревью исправлено: входной `event_budget` снова трактуется
как бюджет событий, а не как число рождений визитов.
+12 -6
View File
@@ -2,9 +2,9 @@
> Перед использованием как источник витрин прочитать > Перед использованием как источник витрин прочитать
> [KNOWN_ISSUES.md](./KNOWN_ISSUES.md): часть старого дефекта уже исправлена > [KNOWN_ISSUES.md](./KNOWN_ISSUES.md): часть старого дефекта уже исправлена
> (один `click_id` на визит, путь по страницам, монотонное время), но популяция > (один `click_id` на визит, путь по страницам, монотонное время, активные
> возвращающихся пользователей и полноценное состояние активных визитов ещё > визиты между тиками), но популяция возвращающихся пользователей и
> остаются следующими шагами. > восстановление активных визитов после рестарта ещё остаются следующими шагами.
Автономный генератор событий для Kafka с режимом `steady-stream`. Автономный генератор событий для Kafka с режимом `steady-stream`.
@@ -27,7 +27,7 @@ generator-service -> Kafka topics -> (потребители отдельно)
| `src/clickstream_generator/dictionary.py` | загрузка и индексы исходных JSONL | | `src/clickstream_generator/dictionary.py` | загрузка и индексы исходных JSONL |
| `src/clickstream_generator/generation.py` | генерация одного связанного визита | | `src/clickstream_generator/generation.py` | генерация одного связанного визита |
| `src/clickstream_generator/intensity.py` | расчёт событийного бюджета тика | | `src/clickstream_generator/intensity.py` | расчёт событийного бюджета тика |
| `src/clickstream_generator/runtime.py` | временный тиковый слой до активных визитов | | `src/clickstream_generator/runtime.py` | тиковый слой: активные визиты и выпуск созревших событий |
| `src/clickstream_generator/kafka_io.py` | Kafka publisher, история batch, Kafka-state и служебные топики | | `src/clickstream_generator/kafka_io.py` | Kafka publisher, история batch, Kafka-state и служебные топики |
| `src/clickstream_generator/state.py` | сериализуемое состояние генератора | | `src/clickstream_generator/state.py` | сериализуемое состояние генератора |
| `src/clickstream_generator/metrics.py` | Prometheus-метрики | | `src/clickstream_generator/metrics.py` | Prometheus-метрики |
@@ -38,8 +38,10 @@ generator-service -> Kafka topics -> (потребители отдельно)
- Публикуем постепенно, **короткими тиками** (по умолчанию каждые 5 секунд) - Публикуем постепенно, **короткими тиками** (по умолчанию каждые 5 секунд)
- На каждом тике отправляем небольшую порцию сообщений - На каждом тике отправляем небольшую порцию сообщений
- Держим целевую интенсивность `events/min` без крупных минутных batch: тик - Держим целевую интенсивность `events/min` без крупных минутных batch:
набирается из одного или нескольких визитов до рассчитанного бюджета событий рассчитанный событийный бюджет тика планирует новые визиты и списывается по
фактической длине этих визитов, а события выходят позже по своим
запланированным меткам времени
- Распределяем события по 4 топикам: - Распределяем события по 4 топикам:
- `browser_events` - `browser_events`
- `location_events` - `location_events`
@@ -48,6 +50,8 @@ generator-service -> Kafka topics -> (потребители отдельно)
- Публичный вызов генеративного ядра строит один визит: общий `click_id`, - Публичный вызов генеративного ядра строит один визит: общий `click_id`,
разные `event_id`, общий device/geo-контекст, путь по страницам воронки и разные `event_id`, общий device/geo-контекст, путь по страницам воронки и
строго растущие запланированные `event_timestamp`. строго растущие запланированные `event_timestamp`.
- Тиковый слой хранит активные визиты между вызовами и выпускает только события,
у которых наступил `event_timestamp`; завершённые визиты удаляются из памяти.
- Сохраняем связи `event_id <-> location`, `click_id <-> device/geo`. - Сохраняем связи `event_id <-> location`, `click_id <-> device/geo`.
## Конфигурация (env) ## Конфигурация (env)
@@ -61,6 +65,8 @@ generator-service -> Kafka topics -> (потребители отдельно)
| `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_MAX_SESSION_EVENTS` | Потолок длины одного визита, защита от петель | `30` |
| `GEN_MAX_ACTIVE_SESSIONS` | Потолок одновременных активных визитов | `200` |
| `GEN_POPULATION_MAX` | Будущий потолок популяции пользователей; сейчас нужен для валидации активных визитов | `300` |
| `GEN_DATA_DIR` | Путь к JSONL файлам | `/data` | | `GEN_DATA_DIR` | Путь к JSONL файлам | `/data` |
| `GEN_SEED` | Сид для воспроизводимости | — | | `GEN_SEED` | Сид для воспроизводимости | — |
| `GEN_ENABLED` | Включить генерацию | `true` | | `GEN_ENABLED` | Включить генерацию | `true` |
+2 -1
View File
@@ -34,7 +34,7 @@ from clickstream_generator.metrics import (
METRICS_LAST_SUCCESS, METRICS_LAST_SUCCESS,
METRICS_TICK_DURATION, METRICS_TICK_DURATION,
) )
from clickstream_generator.runtime import generate_tick_batch from clickstream_generator.runtime import TickStreamGenerator, generate_tick_batch
from clickstream_generator.service import GeneratorService, main from clickstream_generator.service import GeneratorService, main
from clickstream_generator.state import GeneratorState, _nested_list_to_tuple from clickstream_generator.state import GeneratorState, _nested_list_to_tuple
@@ -59,6 +59,7 @@ __all__ = [
"METRICS_EVENTS_TOTAL", "METRICS_EVENTS_TOTAL",
"METRICS_LAST_SUCCESS", "METRICS_LAST_SUCCESS",
"METRICS_TICK_DURATION", "METRICS_TICK_DURATION",
"TickStreamGenerator",
"_import_kafka", "_import_kafka",
"_nested_list_to_tuple", "_nested_list_to_tuple",
"_with_retry", "_with_retry",
@@ -30,6 +30,12 @@ class Config:
max_session_events: int = field( max_session_events: int = field(
default_factory=lambda: int(os.getenv("GEN_MAX_SESSION_EVENTS", "30")) default_factory=lambda: int(os.getenv("GEN_MAX_SESSION_EVENTS", "30"))
) )
max_active_sessions: int = field(
default_factory=lambda: int(os.getenv("GEN_MAX_ACTIVE_SESSIONS", "200"))
)
population_max: int = field(
default_factory=lambda: int(os.getenv("GEN_POPULATION_MAX", "300"))
)
data_dir: Path = field( data_dir: Path = field(
default_factory=lambda: Path(os.getenv("GEN_DATA_DIR", "/data")) default_factory=lambda: Path(os.getenv("GEN_DATA_DIR", "/data"))
) )
@@ -58,5 +64,11 @@ class Config:
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: if self.max_session_events < 1:
raise ValueError("GEN_MAX_SESSION_EVENTS must be >= 1") raise ValueError("GEN_MAX_SESSION_EVENTS must be >= 1")
if self.max_active_sessions < 1:
raise ValueError("GEN_MAX_ACTIVE_SESSIONS must be >= 1")
if self.population_max < 1:
raise ValueError("GEN_POPULATION_MAX must be >= 1")
if self.max_active_sessions >= self.population_max:
raise ValueError("GEN_MAX_ACTIVE_SESSIONS must be < GEN_POPULATION_MAX")
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}")
@@ -129,7 +129,11 @@ class EventGenerator:
"""Совместимый wrapper над расчётом событийного бюджета.""" """Совместимый wrapper над расчётом событийного бюджета."""
return calculate_events_count(self.config, self.rng) return calculate_events_count(self.config, self.rng)
def generate_batch(self, batch_size: int) -> dict[str, list[dict]]: def generate_batch(
self,
batch_size: int,
planned_start_at: datetime | None = None,
) -> dict[str, list[dict]]:
"""Генерирует один визит с сохранением связей.""" """Генерирует один визит с сохранением связей."""
if not self.dictionary.browser_events: if not self.dictionary.browser_events:
return { return {
@@ -176,7 +180,9 @@ class EventGenerator:
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()
planned_timestamp = datetime.now(timezone.utc) planned_timestamp = planned_start_at or datetime.now(timezone.utc)
if planned_timestamp.tzinfo is not None:
planned_timestamp = planned_timestamp.astimezone(timezone.utc).replace(tzinfo=None)
for base_browser, page_url_path in zip(base_browser_events, visit_path): 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"])
+124 -21
View File
@@ -1,30 +1,133 @@
"""Переходный тиковый слой генератора.""" """Тиковый слой генератора с активными визитами между вызовами."""
from dataclasses import dataclass
from datetime import datetime, timezone
from weakref import WeakKeyDictionary
from clickstream_generator.generation import EventGenerator from clickstream_generator.generation import EventGenerator
def generate_tick_batch(generator: EventGenerator, event_budget: int) -> dict[str, list[dict]]: TOPICS = ("browser_events", "location_events", "device_events", "geo_events")
"""Набирает тиковый батч из одного или нескольких полных визитов.
Это временный механизм до задачи 03. В ней тиковый слой начнёт хранить
активные визиты и выпускать только созревшие события.
"""
tick_batch = {
"browser_events": [],
"location_events": [],
"device_events": [],
"geo_events": [],
}
remaining_events = event_budget @dataclass
while remaining_events > 0: class ActiveVisit:
visit_batch = generator.generate_batch(remaining_events) """Запланированный визит, который выпускается по тикам."""
generated_events = len(visit_batch["browser_events"])
if generated_events == 0:
break
for topic, events in visit_batch.items(): batch: dict[str, list[dict]]
tick_batch[topic].extend(events) timestamps: list[datetime]
remaining_events -= generated_events next_index: int = 0
@property
def is_finished(self) -> bool:
return self.next_index >= len(self.timestamps)
def _empty_batch() -> dict[str, list[dict]]:
return {topic: [] for topic in TOPICS}
def _parse_timestamp(value: str) -> datetime:
return datetime.fromisoformat(value.replace(" ", "T"))
def _normalize_tick_time(tick_started_at: datetime | None) -> datetime:
tick_time = tick_started_at or datetime.now(timezone.utc)
if tick_time.tzinfo is not None:
return tick_time.astimezone(timezone.utc).replace(tzinfo=None)
return tick_time
class TickStreamGenerator:
"""Раскладывает визиты по тикам и хранит активные визиты."""
def __init__(self, generator: EventGenerator):
self.generator = generator
self.active_visits: list[ActiveVisit] = []
self._pending_event_budget = 0
def generate_tick(
self,
event_budget: int,
tick_started_at: datetime | None = None,
) -> dict[str, list[dict]]:
"""Возвращает события, созревшие к текущему тику."""
tick_time = _normalize_tick_time(tick_started_at)
tick_batch = _empty_batch()
self._release_due_events(tick_time, tick_batch)
self._drop_finished_visits()
self._birth_visits(event_budget, tick_time)
self._release_due_events(tick_time, tick_batch)
self._drop_finished_visits()
return tick_batch return tick_batch
def _birth_visits(self, event_budget: int, tick_time: datetime) -> None:
if len(self.active_visits) >= self.generator.config.max_active_sessions:
self._pending_event_budget = 0
return
self._pending_event_budget += max(0, event_budget)
while (
self._pending_event_budget > 0
and len(self.active_visits) < self.generator.config.max_active_sessions
):
visit_batch = self.generator.generate_batch(
self.generator.config.max_session_events,
planned_start_at=tick_time,
)
timestamps = [
_parse_timestamp(event["event_timestamp"])
for event in visit_batch["browser_events"]
]
if not timestamps:
break
self.active_visits.append(ActiveVisit(batch=visit_batch, timestamps=timestamps))
self._pending_event_budget -= len(timestamps)
if len(self.active_visits) >= self.generator.config.max_active_sessions:
self._pending_event_budget = 0
def _release_due_events(
self,
tick_time: datetime,
tick_batch: dict[str, list[dict]],
) -> None:
for visit in self.active_visits:
while not visit.is_finished and visit.timestamps[visit.next_index] <= tick_time:
event_index = visit.next_index
for topic in TOPICS:
tick_batch[topic].append(visit.batch[topic][event_index])
visit.next_index += 1
def _drop_finished_visits(self) -> None:
self.active_visits = [
visit
for visit in self.active_visits
if not visit.is_finished
]
_STREAMS: WeakKeyDictionary[EventGenerator, TickStreamGenerator] = WeakKeyDictionary()
def generate_tick_batch(
generator: EventGenerator,
event_budget: int,
tick_started_at: datetime | None = None,
) -> dict[str, list[dict]]:
"""Совместимый фасад тикового генератора.
`event_budget` здесь трактуется как бюджет событий за тик. Если потолок
активных визитов достигнут, новые рождения пропускаются и бюджет не
переносится на следующие тики.
"""
stream = _STREAMS.get(generator)
if stream is None:
stream = TickStreamGenerator(generator)
_STREAMS[generator] = stream
return stream.generate_tick(event_budget, tick_started_at=tick_started_at)
@@ -133,7 +133,7 @@ class GeneratorService:
try: try:
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 with event budget ~{events_count}")
gen_start = time.time() gen_start = time.time()
batch = generate_tick_batch(self.generator, events_count) batch = generate_tick_batch(self.generator, events_count)
+7
View File
@@ -29,6 +29,12 @@ class TestConfigValidation:
with pytest.raises(ValueError, match="GEN_MAX_SESSION_EVENTS"): with pytest.raises(ValueError, match="GEN_MAX_SESSION_EVENTS"):
replace(base_config, max_session_events=0) replace(base_config, max_session_events=0)
def test_max_active_sessions_must_be_less_than_population(self, base_config):
"""Потолок активных визитов должен быть меньше потолка популяции."""
from dataclasses import replace
with pytest.raises(ValueError, match="GEN_MAX_ACTIVE_SESSIONS"):
replace(base_config, max_active_sessions=10, population_max=10)
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
@@ -40,6 +46,7 @@ class TestConfigValidation:
assert base_config.tick_seconds == 5 assert base_config.tick_seconds == 5
assert base_config.lambda_base_per_min == 200 assert base_config.lambda_base_per_min == 200
assert base_config.jitter_pct == 20 assert base_config.jitter_pct == 20
assert base_config.max_active_sessions < base_config.population_max
class TestConfigDefaults: class TestConfigDefaults:
+136 -8
View File
@@ -4,10 +4,10 @@
import uuid import uuid
from dataclasses import replace from dataclasses import replace
from datetime import datetime from datetime import datetime, timedelta
import pytest import pytest
from generator import EventGenerator, EventDictionary, generate_tick_batch from generator import EventGenerator, EventDictionary, TickStreamGenerator, generate_tick_batch
ALLOWED_PAGE_PATHS = { ALLOWED_PAGE_PATHS = {
@@ -91,16 +91,144 @@ class TestEventGeneration:
assert len(batch["device_events"]) == browser_count assert len(batch["device_events"]) == browser_count
assert len(batch["geo_events"]) == browser_count assert len(batch["geo_events"]) == browser_count
def test_generate_tick_batch_fills_requested_event_budget(self, event_dictionary, base_config): def test_generate_tick_batch_uses_requested_event_budget(self, event_dictionary, base_config):
"""Тиковый батч набирает целевой бюджет из одного или нескольких визитов.""" """Тиковый батч трактует входное число как событийный бюджет."""
generator = EventGenerator(event_dictionary, base_config) generator = EventGenerator(event_dictionary, base_config)
batch = generate_tick_batch(generator, 20) batch = generate_tick_batch(generator, 20)
assert len(batch["browser_events"]) == 20 browser_count = len(batch["browser_events"])
assert len(batch["location_events"]) == 20
assert len(batch["device_events"]) == 20 assert 1 <= browser_count <= 20
assert len(batch["geo_events"]) == 20 assert len(batch["location_events"]) == browser_count
assert len(batch["device_events"]) == browser_count
assert len(batch["geo_events"]) == browser_count
assert len({event["click_id"] for event in batch["browser_events"]}) == browser_count
def test_tick_stream_keeps_long_window_intensity_near_event_budget(
self, event_dictionary, base_config
):
"""Длинное окно не разгоняется от событийного бюджета к бюджету визитов."""
config = replace(
base_config,
tick_seconds=5,
jitter_pct=0,
min_events_per_tick=17,
max_events_per_tick=17,
max_active_sessions=10_000,
population_max=10_001,
)
generator = EventGenerator(event_dictionary, config)
stream = TickStreamGenerator(generator)
tick_at = datetime(2026, 6, 11, 12, 0)
ticks_count = 12 * 60
total_events = 0
for tick_index in range(ticks_count):
batch = stream.generate_tick(
event_budget=17,
tick_started_at=tick_at + timedelta(seconds=5 * tick_index),
)
total_events += len(batch["browser_events"])
events_per_minute = total_events / (ticks_count * config.tick_seconds / 60)
assert events_per_minute < 300
def test_tick_stream_keeps_active_visit_between_ticks(self, event_dictionary, base_config):
"""Один визит может выпускать события в нескольких последовательных тиках."""
config = replace(base_config, tick_seconds=60, max_session_events=5)
generator = EventGenerator(event_dictionary, config)
stream = TickStreamGenerator(generator)
first_tick_at = datetime(2026, 6, 11, 12, 0)
first_tick = stream.generate_tick(event_budget=1, tick_started_at=first_tick_at)
second_tick = stream.generate_tick(
event_budget=0,
tick_started_at=first_tick_at + timedelta(seconds=config.tick_seconds),
)
first_click_id = first_tick["browser_events"][0]["click_id"]
second_click_ids = {
event["click_id"]
for event in second_tick["browser_events"]
}
second_timestamps = _parse_event_timestamps(second_tick)
assert first_click_id in second_click_ids
assert all(
timestamp < first_tick_at + timedelta(seconds=config.tick_seconds)
for timestamp in second_timestamps
)
def test_tick_stream_releases_only_matured_events(self, event_dictionary, base_config):
"""Тик не выпускает будущие события активного визита."""
generator = EventGenerator(event_dictionary, base_config)
stream = TickStreamGenerator(generator)
tick_at = datetime(2026, 6, 11, 12, 0)
first_tick = stream.generate_tick(event_budget=1, tick_started_at=tick_at)
same_time_tick = stream.generate_tick(event_budget=0, tick_started_at=tick_at)
assert len(first_tick["browser_events"]) == 1
assert same_time_tick["browser_events"] == []
assert same_time_tick["location_events"] == []
assert same_time_tick["device_events"] == []
assert same_time_tick["geo_events"] == []
def test_tick_stream_does_not_reemit_finished_visit(self, event_dictionary, base_config):
"""Завершённый визит больше не выпускает события в следующих тиках."""
config = replace(base_config, max_session_events=2)
generator = EventGenerator(event_dictionary, config)
stream = TickStreamGenerator(generator)
tick_at = datetime(2026, 6, 11, 12, 0)
first_tick = stream.generate_tick(event_budget=1, tick_started_at=tick_at)
final_tick = stream.generate_tick(
event_budget=0,
tick_started_at=tick_at + timedelta(hours=1),
)
later_tick = stream.generate_tick(
event_budget=0,
tick_started_at=tick_at + timedelta(hours=2),
)
click_id = first_tick["browser_events"][0]["click_id"]
assert {event["click_id"] for event in final_tick["browser_events"]} == {click_id}
assert later_tick["browser_events"] == []
def test_tick_stream_drops_births_when_active_limit_is_reached(
self, event_dictionary, base_config
):
"""При заполненном потолке новые рождения пропускаются без накопления бюджета."""
config = replace(
base_config,
max_session_events=2,
max_active_sessions=1,
population_max=2,
)
generator = EventGenerator(event_dictionary, config)
stream = TickStreamGenerator(generator)
tick_at = datetime(2026, 6, 11, 12, 0)
first_tick = stream.generate_tick(event_budget=5, tick_started_at=tick_at)
blocked_tick = stream.generate_tick(event_budget=5, tick_started_at=tick_at)
final_tick = stream.generate_tick(
event_budget=0,
tick_started_at=tick_at + timedelta(hours=1),
)
later_tick = stream.generate_tick(
event_budget=0,
tick_started_at=tick_at + timedelta(hours=2),
)
click_id = first_tick["browser_events"][0]["click_id"]
assert len(first_tick["browser_events"]) == 1
assert blocked_tick["browser_events"] == []
assert {event["click_id"] for event in final_tick["browser_events"]} == {click_id}
assert later_tick["browser_events"] == []
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):
"""Публичный вызов генератора создаёт один связанный визит.""" """Публичный вызов генератора создаёт один связанный визит."""