From cbf8b2d77002842f211484a2bcf5962e18537f4b Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Thu, 23 Jul 2026 14:48:02 +0300 Subject: [PATCH] =?UTF-8?q?fix(course):=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D0=B8=D1=82=D0=BE=D0=B3=D0=B0=D0=BC=20?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=D0=BF=D0=BE=D0=B3=D0=BE=20=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E=20=D0=BB=D0=B0=D0=B1=2007/08=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: два слепых ревью (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) --- .../20260723-1248-course-labs-redesign.md | 92 +++++++++++-------- CONTEXT.md | 6 +- docs/course/README.md | 10 +- docs/course/lessons/05_monitoring.md | 11 ++- docs/course/lessons/06_superset_bi.md | 9 +- docs/course/lessons/07_lab_next_day.md | 13 ++- docs/course/lessons/08_lab_continue.md | 85 ++++++++--------- generator/tests/test_world_dags_contract.py | 40 +++++++- 8 files changed, 167 insertions(+), 99 deletions(-) diff --git a/.scratch/handoffs/20260723-1248-course-labs-redesign.md b/.scratch/handoffs/20260723-1248-course-labs-redesign.md index 1990662..0c1c64c 100644 --- a/.scratch/handoffs/20260723-1248-course-labs-redesign.md +++ b/.scratch/handoffs/20260723-1248-course-labs-redesign.md @@ -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-подстраховка; о запусках сообщать пользователю. diff --git a/CONTEXT.md b/CONTEXT.md index 8c600bf..2b436fb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -63,8 +63,10 @@ user_domain_id (пользователь, постоянный) ### Возвращающийся пользователь (returning user) Пользователь, открывающий **более одного** визита (`click_id`) во времени, с -межсессионными паузами. Именно возвраты дают расхождение `users < sessions` — -то, чего нет на сиде (`users == sessions`) и что отличает поток от статики. +межсессионными паузами. Эталонный мир уже содержит возвраты: это замороженный +LIVE-мир с 4 056 пользователями и 26 083 визитами, поэтому уже при импорте +выполняется `users < sessions`. Режим `continue` продолжает этот мир: итоги +растут дальше и зависят от длительности запуска у каждого менти. ### Три значения слова «сид» diff --git a/docs/course/README.md b/docs/course/README.md index 18e4a3b..55c14a1 100644 --- a/docs/course/README.md +++ b/docs/course/README.md @@ -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`. diff --git a/docs/course/lessons/05_monitoring.md b/docs/course/lessons/05_monitoring.md index 64980cf..01bd03f 100644 --- a/docs/course/lessons/05_monitoring.md +++ b/docs/course/lessons/05_monitoring.md @@ -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. diff --git a/docs/course/lessons/06_superset_bi.md b/docs/course/lessons/06_superset_bi.md index 12c07af..8411d6e 100644 --- a/docs/course/lessons/06_superset_bi.md +++ b/docs/course/lessons/06_superset_bi.md @@ -425,9 +425,8 @@ metadata Superset. --- -## Мост после курса +## Мост к следующему шагу -Теперь у тебя есть сквозная цепочка: Kafka → ClickHouse STG → ODS → DDS → DM → мониторинг → -Superset. Следующий честный вопрос уже не про этот стенд, а про продакшен: какие витрины стоит -материализовать, какие права дать BI-пользователям и как не превратить dashboard в единственный -источник правды вместо версионированного SQL в репозитории. +Теперь у тебя есть сквозная цепочка от Kafka до дашборда в Superset. В лабе 07 +ты добавишь в этот мир следующий модельный день и проверишь границу двух +суточных порций. diff --git a/docs/course/lessons/07_lab_next_day.md b/docs/course/lessons/07_lab_next_day.md index 44e3264..4022e34 100644 --- a/docs/course/lessons/07_lab_next_day.md +++ b/docs/course/lessons/07_lab_next_day.md @@ -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; - короткое объяснение своими словами: чем пакетный инкремент отличается от полного пересчёта и зачем отдельно проверять границу порций. diff --git a/docs/course/lessons/08_lab_continue.md b/docs/course/lessons/08_lab_continue.md index dd35157..2e2aa96 100644 --- a/docs/course/lessons/08_lab_continue.md +++ b/docs/course/lessons/08_lab_continue.md @@ -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('', 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` и повтори запрос. Важно увидеть сам возврат -пользователя, а не угадать конкретное число. +Подставь вместо `` границу из манифеста. Успех — хотя бы одна +строка. Иначе подожди несколько минут, перезапусти `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 минут плюс время прогона. Для +минутной свежести нужна другая обработка. diff --git a/generator/tests/test_world_dags_contract.py b/generator/tests/test_world_dags_contract.py index 748cd82..7be7f3b 100644 --- a/generator/tests/test_world_dags_contract.py +++ b/generator/tests/test_world_dags_contract.py @@ -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():