fix(course): правки по итогам слепого ревью лаб 07/08 (#22)

Зачем: два слепых ревью (Codex CODE-1..9, Claude TASK-1..2) и
перепроверки нашли дефекты в лабах и метадоках; финал лабы 08
переделан по решению пользователя.

Что:
- канонический сброс в README курса теперь снимает DAG'и с паузы
  (все создаются на паузе);
- имя дашборда исправлено на «E-commerce Analytics Dashboard»;
- финал лабы 08: вместо мифа «users == sessions на статике» —
  вернувшийся пользователь через границу заморозки и измеренное
  обещание свежести; §2 велит записать границы и время (§6 их требует);
- CONTEXT.md: эталонный мир — замороженный живой,
  4 056 пользователей / 26 083 визита, возвраты уже есть;
- уроки 5–6 ведут в обязательный маршрут, живое упражнение
  урока 5 требует канонический сброс;
- контрактные тесты: убран якорь на удалённую команду; добавленные
  на триаже тесты-цитаты срезаны до устойчивых инвариантов
  (прозаический легаси-жанр — issue #24);
- handoff обновлён до текущего состояния.

Проверка: make test (219 generator + 31 contract) и make lint зелёные;
SQL вернувшегося пользователя проверен на эфемерном ClickHouse 25.1
(на полном наборе — в #23).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-23 14:48:02 +03:00
co-authored by Claude Opus 4.8
parent 1dd79a3287
commit cbf8b2d770
8 changed files with 167 additions and 99 deletions
@@ -1,50 +1,68 @@
# Handoff: редизайн лаб курса (issue #7)
Дата: 2026-07-23. Ветка: `feature/course-labs-redesign` (создана, коммитов нет).
Одноразовый документ для продолжения работы в новой сессии (ADR-0003).
Обновлено: 2026-07-23, после ревью-цикла. Ветка `feature/course-labs-redesign`.
Одноразовый документ для продолжения в новой сессии (ADR-0003).
## Состояние
Работа идёт по плейбуку `/claude-subagent-playbook` (оркестратор Claude,
исполнитель Codex, слепые ревью). Пройдено:
Конвейер `/claude-subagent-playbook`; спека принята:
`docs/specs/2026-07-23-course-labs-redesign.md`. Разбиение — дочерние
тикеты #21#22#23 (один PR закроет всё, родитель #7).
1. **Аудит курса против стенда** — субагентом; отчёт (EN):
`/tmp/claude-1000/-home-dementev-sources-clickstream-ch-kafka-superset-demo/28f16040-6abd-4d92-a5b1-71ffd4ebcb99/scratchpad/audit-course-vs-stand.md`
(при потере scratchpad не критично: главное перенесено в спеку).
2. **Штурм педагогики лаб** с пользователем (`/brainstorm-with-docs`);
конспект решений (EN): тот же каталог, `brainstorm-labs-design.md`.
3. **Спека написана**`docs/specs/2026-07-23-course-labs-redesign.md`
(источник истины: маршрут, ядра лаб 07/08, вердикты по урокам 0–6 и
метадокам, границы, проверка). Ещё НЕ закоммичена.
- #21 (каркас + уроки 0–6) — сделан, коммит `cc1cffe`, тикет закрыт.
- #22 (лабы 07/08 + метадоки) — сделан, коммит `1dd79a3`, тикет закрыт.
- Слепые ревью полного диффа: Codex (CODE, 9 находок) + Claude (TASK, 2).
Триаж: всё починено; CODE-4 закрыт решением пользователя — финал лабы
08 переделан («вернувшийся пользователь через границу заморозки» в
правке + измеренная свежесть и обещание потребителю в финале),
CONTEXT.md поправлен (эталонный мир уже содержит возвраты:
4 056 users / 26 083 visits).
- Семь хрупких тестов-цитат, добавленных Codex на триаже, срезаны
Опусом до устойчивых инвариантов (1 оставлен / 2 ослаблены /
4 удалены). Урок зафиксирован в lessons.md скилла и памяти проекта
(claude-reviews-code-meaningfulness); чистка легаси-жанра — issue #24.
- Перепроверки: Codex — все 9 закрыты, но 2 новые MINOR:
CODE-10 (прибитый якорь `make clean` в старом тесте — решение: увести
в #24, в ветке не трогать) и CODE-11 (финал лабы 08 требует замеров,
которые §2 не велел записывать — чинить Codex-исполнителю).
Claude-перепроверка (TASK + taste-линза) — RECHECK_OK, новых дефектов
нет. CODE-10 увезён комментарием в #24; починка CODE-11 отправлена
Codex-исполнителю (resume треда триажа) — если отчёта
`$D/code11-result.md` нет, проверить логи `$D/code11-*.jsonl`.
- Правки после ревью НЕ закоммичены (7 файлов в дереве).
## Ключевые решения (детали — в спеке)
## Обменный каталог прогона ($D)
- Один PR на всю работу; дочерних issues нет, #7 — единственный.
- Урок 6 (Superset) становится обязательным; лабы 07 (next-day) и
08 (continue) — обязательные, после урока 6.
- Сброс для лаб и всего курса: чистый стенд + повторный `import`.
- Цифры уроков сверяются на живом стенде после `import` (финальный шаг).
`/tmp/claude-1000/-home-dementev-sources-clickstream-ch-kafka-superset-demo/28f16040-6abd-4d92-a5b1-71ffd4ebcb99/scratchpad/review-branch/`
— вердикты, отчёты, промпты. Треды Codex: исполнитель #22 и триаж —
`019f8e80-53c9-7b51-bb0b-29c375e1c646`; ревьюер CODE —
`019f8e8d-78a8-71e1-8f89-697799bec7e8`.
## Следующие шаги (конвейер плейбука)
## Следующие шаги
1. **Адверсарное ревью спеки** — Claude-субагент (deep-reasoner, без
контекста оркестратора), проверка спеки против кода/доков; вердикт
файлом в scratchpad. Этап «постановка» — самый дорогой для тихой
ошибки.
2. Триаж находок → правки спеки → коммит спеки в ветку
(`/conventional-commits`, docs(course)).
3. Обновить issue #7: ссылка на спеку + чек-лист шагов.
4. Самодостаточный файл-задача для Codex (EN, в scratchpad) → реализация
`codex exec` фоном → саморевью → два слепых ревью кода → триаж →
сверка цифр на живом стенде → один PR.
1. Дождаться вердикта Claude-перепроверки; CODE-11 → исполнителю
(resume треда триажа), CODE-10 → комментарием в #24.
2. Коммит правок ревью (docs + CONTEXT.md + тесты) по
`/conventional-commits`; обновить handoff.
3. #23 — сверка цифр на живом стенде: поднять стенд (докер пользователя!),
`import` + двойной `world_next_day` (детерминизм), переходящий визит,
вернувшийся пользователь, сценарий лабы 08, замена всех маркеров
`<!-- сверить-на-стенде -->`, `/ai-text-lint` по лабам, ссылки,
`make test`/`make lint`.
4. Финал: чекбоксы в #7 (fast-worker'ом), один PR, уборка обменного
каталога.
## Решения пользователя этого прогона
- Урок 6 обязателен; обе лабы обязательны (уже в спеке).
- Финал лабы 08: синтез «вернувшийся пользователь» (в правке) +
«обещание свежести» (в финале); users==sessions на статике — миф для
эталонного мира.
- Taste-линза: Claude-ревьюер судит осмысленность кода Codex; срезание
бессмысленного кода — Опусом (в памяти проекта).
## Подсказки для агента
- Скиллы: `/claude-subagent-playbook` (рабочий конвейер),
`/conventional-commits` (коммит), `/ai-text-lint` (тексты уроков перед
финалом — требование LESSON_STANDARD §2).
- Память проекта: субагентам модель/effort задавать явно; фоновым
запускам — ScheduleWakeup-подстраховка; о каждом запуске сообщать
пользователю (этап + чего ждём).
- Пользователь планирует `/compact` после ревью спеки — вся фактура
должна жить в файлах, не в контексте.
- Скиллы: `/claude-subagent-playbook`, `/conventional-commits`,
`/ai-text-lint`. Модель/effort субагентам — явно; фоновым запускам —
ScheduleWakeup-подстраховка; о запусках сообщать пользователю.
+4 -2
View File
@@ -63,8 +63,10 @@ user_domain_id (пользователь, постоянный)
### Возвращающийся пользователь (returning user)
Пользователь, открывающий **более одного** визита (`click_id`) во времени, с
межсессионными паузами. Именно возвраты дают расхождение `users < sessions`
то, чего нет на сиде (`users == sessions`) и что отличает поток от статики.
межсессионными паузами. Эталонный мир уже содержит возвраты: это замороженный
LIVE-мир с 4 056 пользователями и 26 083 визитами, поэтому уже при импорте
выполняется `users < sessions`. Режим `continue` продолжает этот мир: итоги
растут дальше и зависят от длительности запуска у каждого менти.
### Три значения слова «сид»
+6 -4
View File
@@ -35,10 +35,12 @@
1. Выполни `make clean`. Команда удалит данные стенда и метаданные Superset: сохранённые
в нём настройки и дашборды тоже придётся создать заново.
2. Выполни `make up`, открой Airflow на `http://localhost:8080` (`admin/admin`) и дождись
успешного завершения двух DAG-ов по порядку:
- `ddl_init` — запусти с пустой формой;
- `world_init` — после него запусти с пустой формой.
2. Выполни `make up` и открой Airflow на `http://localhost:8080` (`admin/admin`).
На свежем стенде DAG-и стоят на паузе. Подготовь и запусти их по порядку:
- `ddl_init` сними паузу и запусти с пустой формой;
- `etl_pipeline` — только сними паузу: его вызовет следующий DAG;
- `world_init` — сними паузу и запусти с пустой формой после успешного
`ddl_init`.
3. Когда `world_init` завершится успешно и витрины DM будут готовы, выполни
`make superset-init`.
+7 -4
View File
@@ -274,6 +274,10 @@ make generator-continue
make generator-down
```
Остановка генератора не удаляет уже приехавшие данные и сохранённое состояние.
После этого необязательного опыта верни эталонный мир по
[канонической инструкции курса](../README.md#подготовка-и-канонический-сброс).
---
## 4. Управляемая правка: остановим scheduler и увидим алерт
@@ -381,7 +385,6 @@ make recover-monitoring
## Мост к следующему шагу
Теперь стенд закрывает полный учебный маршрут: Kafka принимает поток, ClickHouse раскладывает
слои, Airflow управляет порядком, а Prometheus и Grafana показывают состояние системы. Дальше
этот же стенд можно использовать не как разовый набор уроков, а как тренажёр: менять данные,
ломать отдельные места, смотреть, где появляется сигнал, и объяснять по метрикам, что произошло.
Теперь ты видишь состояние пайплайна со стороны: где идут данные, где растёт
отставание и где сработал алерт. В уроке 6 у готовых витрин появится
потребитель — дашборд в Superset.
+4 -5
View File
@@ -425,9 +425,8 @@ metadata Superset.
---
## Мост после курса
## Мост к следующему шагу
Теперь у тебя есть сквозная цепочка: Kafka → ClickHouse STG → ODS → DDS → DM → мониторинг →
Superset. Следующий честный вопрос уже не про этот стенд, а про продакшен: какие витрины стоит
материализовать, какие права дать BI-пользователям и как не превратить dashboard в единственный
источник правды вместо версионированного SQL в репозитории.
Теперь у тебя есть сквозная цепочка от Kafka до дашборда в Superset. В лабе 07
ты добавишь в этот мир следующий модельный день и проверишь границу двух
суточных порций.
+8 -5
View File
@@ -142,10 +142,11 @@ ORDER BY visit_start;
### Смотрим день-к-дню
Открой в Superset дашборд `Clickstream Analytics`. Поставь фильтр
Открой в Superset дашборд `E-commerce Analytics Dashboard`. Поставь фильтр
**Date Range → No filter** и найди график `Events over Time`. После обновления
на нём должен появиться день 4. Точную высоту точки не угадывай: она должна
сойтись с данными ClickHouse и manifest.
на нём должны появиться непустые пятиминутные интервалы дня 4 после дня 3.
Высоту отдельной точки не сравнивай с накопительными числами manifest:
manifest уже сверили отдельно в начале секции.
---
@@ -274,10 +275,12 @@ Airflow проигрывать все пропущенные интервалы
После лабы сохрани:
- скрин зелёного ручного запуска `world_next_day`;
- скрин зелёного ручного запуска `world_next_day`, который добавил день 4;
- скрин зелёного запланированного запуска `world_next_day`, который добавил
день 5;
- вывод `make generated-history-chain-check` без ошибки;
- результат SQL-запроса с переходящим через полночь `click_id`;
- скрин дневного графика с добавленным днём;
- скрин дневного графика с днём 5 после дня 4;
- короткое объяснение своими словами: чем пакетный инкремент отличается от
полного пересчёта и зачем отдельно проверять границу порций.
+44 -41
View File
@@ -104,17 +104,18 @@ make generator-continue
Подожди несколько минут и снова выполни запрос по слоям. Правая граница STG
уйдёт вперёд. ODS, DDS и DM останутся на границе последнего
`etl_pipeline`.
`etl_pipeline`. Запиши время по обычным часам и границы STG и DM. Границу STG
считай контрольной.
### Догоняем пакетные слои
Открой Airflow и запусти `etl_pipeline` через **Trigger DAG** с пустой формой.
По умолчанию это полный пересчёт.
По умолчанию это полный пересчёт. При запуске включи секундомер.
После зелёного прогона повтори запрос. ODS, DDS и DM догонят данные, которые
успели попасть в STG к началу обработки. Генератор всё ещё работает, поэтому
STG вскоре снова может оказаться чуть свежее. Это ожидаемое расслоение, а не
потеря строк.
После зелёного прогона останови секундомер и повтори запрос. Запиши новые
границы STG и DM: DM должна достичь контрольной границы STG. Генератор всё ещё
работает, поэтому STG вскоре снова может оказаться чуть свежее. Это ожидаемое
расслоение, а не потеря строк.
---
@@ -216,24 +217,29 @@ make generator-continue
Запусти `etl_pipeline` с пустой формой. После зелёного прогона выполни:
```sql
WITH parseDateTime64BestEffort('<model_t_end>', 6, 'UTC') AS boundary
SELECT
uniqExact(user_domain_id) AS users,
uniqExact(click_id) AS sessions
FROM dm.v_events_enriched
WHERE user_domain_id IS NOT NULL;
user_domain_id,
countIf(visit_start < boundary) AS visits_before,
countIf(visit_start >= boundary) AS visits_after,
min(visit_start) AS first_visit_ts, max(visit_start) AS last_visit_ts
FROM (
SELECT user_domain_id, click_id, min(event_ts) AS visit_start
FROM dm.v_events_enriched
WHERE user_domain_id IS NOT NULL
GROUP BY user_domain_id, click_id
)
GROUP BY user_domain_id
HAVING min(visit_start) < boundary AND max(visit_start) >= boundary
LIMIT 10;
```
В живом мире один пользователь может вернуться и открыть новый визит. Поэтому
после достаточного продолжения ожидаем `users < sessions`. На эталонной
статике было `users == sessions`.
Точные числа здесь не печатаем: длительность живого запуска у каждого своя.
`users = <значение>`, `sessions = <значение>`.
<!-- сверить-на-стенде -->
Если равенство ещё сохранилось, дай генератору поработать несколько минут,
снова запусти `etl_pipeline` и повтори запрос. Важно увидеть сам возврат
пользователя, а не угадать конкретное число.
Подставь вместо `<model_t_end>` границу из манифеста. Успех — хотя бы одна
строка. Иначе подожди несколько минут, перезапусти `etl_pipeline` и повтори
запрос. Это симметрия лаб: в лабе 07 визит пересекал полночь, здесь
пользователь с разными визитами пересекает границу замороженного мира.
Мир расходится: итоги превышают эталонные и различаются у менти. Это правильно.
### Верни как было
@@ -256,22 +262,18 @@ make generator-down
| Действие | Где смотреть | Что ожидать |
|----------|--------------|-------------|
| выполнить `make generator-continue` | Prometheus Targets | `generator` переходит в `UP` |
| посмотреть живой генератор | `Generator Overview` | `Total Events/min (all 4 topics)` становится ненулевым |
| сравнить свежесть до ETL | SQL-запрос по слоям | STG свежее ODS, DDS и DM <!-- сверить-на-стенде --> |
| запустить `etl_pipeline` | Airflow и SQL-запрос | пакетные слои догоняют снимок STG |
| измерить свежесть до и после ETL | запрос из секции 2 и часы | записаны модельное отставание и настенное ожидание <!-- сверить-на-стенде --> |
| выполнить `make generator-down` | Grafana и Kafka | события перестают поступать, отставание читателей стекает к нулю |
| снова выполнить `make generator-continue` | Grafana, Kafka и STG | поток продолжается, offset-ы и правая граница снова растут |
| пересчитать аналитику | запрос `users` и `sessions` | после возвратов пользователей выполняется `users < sessions` <!-- сверить-на-стенде --> |
| остановить поток и пройти сброс | Grafana и SQL | `generator` выключен, слои снова совпадают с эталонным миром |
| найти вернувшегося пользователя | запрос по границе `model_t_end` | есть пользователь с визитами до и после границы |
Ответь своими словами:
- что означает свежесть данных и кто задаёт требование к ней;
- почему новые строки уже есть в STG, но их ещё нет в DDS;
- почему запуск `etl_pipeline` догоняет поток лишь до очередного снимка;
- что сохраняет режим `continue`;
- почему после живого продолжения числа разных менти расходятся;
- откуда берётся `users < sessions`.
- почему STG опережает DM, а `etl_pipeline` догоняет лишь очередной снимок;
- что сохраняет `continue` и почему итоги менти расходятся;
- какую свежесть можно честно обещать при ручном запуске и при расписании
каждые 30 минут;
- почему минутная свежесть лежит за пределами этого стенда.
---
@@ -279,13 +281,14 @@ make generator-down
После лабы сохрани:
- скрин Prometheus Targets с `generator` в `UP`;
- скрин живого `Total Events/min (all 4 topics)`;
- два результата запроса свежести: до и после `etl_pipeline`;
- наблюдение остановки и продолжения по offset-ам или правой границе STG;
- результат запроса с `users < sessions`;
- короткое объяснение своими словами: почему потоковый приём не гарантирует
потоковую обработку до витрины.
- скрин `generator` в `DOWN` после `make generator-down` и скрин `generator`
в `UP` после повторного `make generator-continue`;
- границы STG до остановки и после продолжения;
- строку пользователя с визитами до и после `model_t_end`;
- замеры до и после `etl_pipeline`: разницу `STG DM` в модельном времени и
настенное ожидание до появления зафиксированной границы STG в DM;
- вывод своими словами: что можно обещать при ручном запуске, что — при
расписании каждые 30 минут и почему этот стенд не обещает минутную свежесть.
В конце `generator` должен быть остановлен, а стенд — возвращён к эталонному
миру.
@@ -294,6 +297,6 @@ make generator-down
## Мост после курса
Теперь у тебя есть два способа растить один мир: повторяемая суточная порция и
живое продолжение. Следующий практический вопрос уже зависит от продукта:
какую свежесть обещать потребителю и какую цену платить за пересчёт.
Замер отвечает на вопрос из секции 1: ручной запуск не ограничивает ожидание,
а расписание раз в 30 минут дало бы около 30 минут плюс время прогона. Для
минутной свежести нужна другая обработка.
+39 -1
View File
@@ -188,7 +188,45 @@ def test_course_readme_lists_uv_before_first_command():
text = (REPO_ROOT / "docs" / "course" / "README.md").read_text(encoding="utf-8")
assert "uv" in text
assert text.index("uv") < text.index("make generated-history-analytics")
assert text.index("uv") < text.index("make clean")
def test_course_canonical_setup_unpauses_dags_before_world_init():
"""Канонический блок называет три DAG, снятие паузы и порядок etl→world_init."""
# Прозаические формулировки не проверяем — их сторожит ревью, не pytest.
# Держим только структурные факты: якорь-заголовок существует, в блоке
# названы три DAG-а, упомянута пауза, а etl_pipeline идёт до world_init.
text = (REPO_ROOT / "docs" / "course" / "README.md").read_text(encoding="utf-8")
setup = text.split("## Подготовка и канонический сброс", maxsplit=1)[1].split(
"## Уроки",
maxsplit=1,
)[0]
assert "пауз" in setup
for dag_id in ("ddl_init", "etl_pipeline", "world_init"):
assert dag_id in setup
assert setup.index("etl_pipeline") < setup.index("world_init")
def test_next_day_lab_uses_provisioned_superset_dashboard_name():
"""Лаба следующего дня ведёт на существующий дашборд Superset."""
text = (
REPO_ROOT / "docs" / "course" / "lessons" / "07_lab_next_day.md"
).read_text(encoding="utf-8")
assert "E-commerce Analytics Dashboard" in text
assert "Clickstream Analytics" not in text
def test_monitoring_lesson_links_to_canonical_reset():
"""Урок мониторинга ведёт обратно к каноническому сбросу курса."""
# Проверяем живую перекрёстную ссылку: битый якорь — это дефект, а не
# переписанная проза. Точные фразы упражнения не пиним.
text = (
REPO_ROOT / "docs" / "course" / "lessons" / "05_monitoring.md"
).read_text(encoding="utf-8")
assert "../README.md#подготовка-и-канонический-сброс" in text
def test_startup_history_runbook_warns_about_daily_wave_idle_gap():