diff --git a/apps/api/tests/test_analytics.py b/apps/api/tests/test_analytics.py index 0754e01..f91663f 100644 --- a/apps/api/tests/test_analytics.py +++ b/apps/api/tests/test_analytics.py @@ -37,3 +37,35 @@ def test_tackle_recommendation_requires_samples_and_independent_players() -> Non assert rows[0].status == "insufficient_data" engine.dispose() + + +def test_tackle_analytics_handles_empty_and_multicomponent_observations() -> None: + now = datetime.now(timezone.utc) + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with Session(engine) as db: + waterbody = Waterbody(slug="empty-check", name_ru="Проверка", unlock_level=1) + fish = Fish(slug="perch", name_ru="Окунь", trophy_weight_g=5_000) + spot = Spot(waterbody=waterbody, x=1, y=2) + report = CatchReport( + fish=fish, waterbody=waterbody, spot=spot, weight_g=500, + caught_at=now, reported_at=now, player_name="Player", + source_type=SourceType.user, source_confidence=80, + moderation_status=ModerationStatus.approved, + ) + report.tackle_components.extend([ + CatchTackleComponent(role="lure", position=0, raw_value="Spinner #1"), + CatchTackleComponent(role="rig", position=1, raw_value="Rig #1"), + CatchTackleComponent(role="lure", position=2, raw_value="Spinner #1"), + CatchTackleComponent(role="lure", position=3, raw_value=" "), + ]) + db.add(report) + db.commit() + + rows = tackle_combinations(db, waterbody="empty-check", fish="perch", method=None, hours=72, min_samples=1, min_players=1) + assert {(row.role, row.value, row.catches) for row in rows} == { + ("lure", "Spinner #1", 1), ("rig", "Rig #1", 1), + } + assert tackle_combinations(db, waterbody="missing", fish=None, method=None, hours=72) == [] + + engine.dispose() diff --git a/deploy/tackle-query-plan-gate.sql b/deploy/tackle-query-plan-gate.sql new file mode 100644 index 0000000..abd7174 --- /dev/null +++ b/deploy/tackle-query-plan-gate.sql @@ -0,0 +1,77 @@ +\set ON_ERROR_STOP on +\timing off + +-- Session-local fixture for the G06/G07 public tackle paths. +-- No production table or row is modified. +SET search_path = pg_temp, public; +SET jit = off; +SET work_mem = '16MB'; + +CREATE TEMP TABLE tackle_item ( + id uuid PRIMARY KEY, name varchar(200) NOT NULL, category varchar(20) NOT NULL, + brand varchar(100), family varchar(100), unlock_level integer +); +CREATE TEMP TABLE catch_report ( + id uuid PRIMARY KEY, moderation_status moderationstatus NOT NULL, + deleted_at timestamptz, reported_at timestamptz NOT NULL +); +CREATE TEMP TABLE catch_tackle_component ( + id uuid PRIMARY KEY, catch_report_id uuid NOT NULL, role varchar(50) NOT NULL, + raw_value varchar(200) NOT NULL, position integer NOT NULL +); + +INSERT INTO tackle_item (id, name, category, brand, family, unlock_level) +SELECT md5('tackle-' || g)::uuid, 'Предмет ' || g, + CASE WHEN g % 4 = 0 THEN 'bait' ELSE 'lure' END, + 'Brand-' || (g % 25), 'Family-' || (g % 100), g % 30 +FROM generate_series(1, 5000) AS g; + +INSERT INTO catch_report (id, moderation_status, deleted_at, reported_at) +SELECT md5('tackle-report-' || g)::uuid, + CASE WHEN g % 10 = 0 THEN 'pending'::moderationstatus ELSE 'approved'::moderationstatus END, + CASE WHEN g % 50 = 0 THEN clock_timestamp() ELSE NULL END, + clock_timestamp() - (((g * 97) % 7776000) * interval '1 second') +FROM generate_series(1, 100000) AS g; + +INSERT INTO catch_tackle_component (id, catch_report_id, role, raw_value, position) +SELECT md5('component-' || g)::uuid, + md5('tackle-report-' || g)::uuid, + CASE WHEN g % 3 = 0 THEN 'rig' ELSE 'lure' END, + 'Предмет ' || ((g - 1) % 5000 + 1), 0 +FROM generate_series(1, 100000) AS g; + +CREATE INDEX tackle_item_catalog_filter_idx + ON tackle_item (category, brand, family, unlock_level, name, id); +CREATE INDEX catch_report_tackle_feed_idx + ON catch_report (moderation_status, deleted_at, reported_at, id); +CREATE INDEX catch_tackle_component_report_idx + ON catch_tackle_component (catch_report_id, role, raw_value); + +ANALYZE tackle_item; +ANALYZE catch_report; +ANALYZE catch_tackle_component; + +\echo 'DATASET' +SELECT count(*) AS tackle_items, (SELECT count(*) FROM catch_report) AS reports, + (SELECT count(*) FROM catch_tackle_component) AS components +FROM tackle_item; + +\echo 'PLAN tackle_catalog_filter' +EXPLAIN (ANALYZE, BUFFERS, SETTINGS) +SELECT id, name, category, brand, family, unlock_level +FROM tackle_item +WHERE category = 'lure' AND brand = 'Brand-7' AND family = 'Family-7' +ORDER BY name, id +LIMIT 50; + +\echo 'PLAN tackle_combinations' +EXPLAIN (ANALYZE, BUFFERS, SETTINGS) +SELECT c.role, c.raw_value, count(DISTINCT r.id) +FROM catch_report AS r +JOIN catch_tackle_component AS c ON c.catch_report_id = r.id +WHERE r.moderation_status = 'approved' + AND r.deleted_at IS NULL + AND r.reported_at >= now() - interval '72 hours' +GROUP BY c.role, c.raw_value +ORDER BY count(DISTINCT r.id) DESC, c.role, c.raw_value +LIMIT 50; diff --git a/deploy/test-tackle-query-plans.sh b/deploy/test-tackle-query-plans.sh new file mode 100755 index 0000000..858d0a2 --- /dev/null +++ b/deploy/test-tackle-query-plans.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +output=$(mktemp) +cleanup() { rm -f "$output"; } +trap cleanup EXIT INT TERM + +docker compose exec -T db psql -X -U rf4 -d rf4_spotter \ + < "$repo/deploy/tackle-query-plan-gate.sql" > "$output" + +for plan in tackle_catalog_filter tackle_combinations; do + grep -F "PLAN $plan" "$output" >/dev/null +done + +awk ' + /Execution Time:/ { count += 1; if (($3 + 0) > 250) { print "Tackle query plan exceeded 250 ms: " $0 > "/dev/stderr"; failed = 1 } } + END { if (count != 2 || failed) exit 1 } +' "$output" + +grep -A40 'PLAN tackle_catalog_filter' "$output" | grep -Eq 'Index Scan|Index Only Scan|Bitmap Index Scan' +grep -A60 'PLAN tackle_combinations' "$output" | grep -Eq 'Index Scan|Bitmap Index Scan' +grep 'Execution Time:' "$output" +echo "Tackle query-plan gate passed: indexed catalog and combination paths under 250 ms" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1ad3bf2..8c51d63 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -74,7 +74,7 @@ - [ ] **G06 · API и публичный каталог.** Добавлены пагинированный `/api/v1/tackle/items` с фильтрами по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Остаётся подключить публичные Astro-карточки и ссылки из всех нужных представлений улова. - [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Пустая или малая выборка получает `insufficient_data`; decay по свежести и отдельные UI-состояния остаются следующим шагом. - [ ] **G08 · Медиа и качество.** Добавлены отдельные reviewed-роли `tackle_card`, `tackle_detail`, `rig_diagram`, `tackle_screenshot` для `tackle`, а также CLI-параметр `--media-role`; offline audit отклоняет неизвестную роль и несовпадение роли с entity type. Существующие dimensions, MIME, SHA-256, прозрачность, aspect ratio, provenance и атомарное продвижение сохраняются. Остаётся провести реальный contact-sheet review для будущих tackle-кандидатов без автоматической публикации. -- [ ] **G09 · Приёмка и эксплуатация.** Добавлены fixture/regression tests для crosswalk, media roles, идемпотентных компонентов и малой выборки рекомендаций; каталог `/tackle` включён в visual-matrix regression. Offline catalog/media audits и сохранение старых данных при сбое проходят. Остаются HTTP/browser acceptance для пустых, неоднозначных и многокомпонентных комплектов и query-plan gate для production-фильтров. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff. Закрывать пакет только после проверяемого счётчика по каждой категории либо явной фиксации `unknown`. +- [ ] **G09 · Приёмка и эксплуатация.** Добавлены fixture/regression tests для crosswalk, media roles, идемпотентных компонентов, пустых результатов и многокомпонентных наблюдений; каталог `/tackle` включён в visual-matrix, narrow smoke и accessibility routes. Offline catalog/media audits и сохранение старых данных при сбое проходят. Добавлен TEMP-only query-plan gate для фильтра каталога и группировки сочетаний: production-фильтры должны использовать индексы и укладываться в 250 мс. Остаются HTTP/browser acceptance для пустых, неоднозначных и многокомпонентных комплектов. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff. Закрывать пакет только после проверяемого счётчика по каждой категории либо явной фиксации `unknown`. ### Тёмная тема diff --git a/docs/query-performance.md b/docs/query-performance.md index c82418d..c66172d 100644 --- a/docs/query-performance.md +++ b/docs/query-performance.md @@ -47,4 +47,12 @@ После наполнения выполнить `EXPLAIN (ANALYZE, BUFFERS)` для activity, moderation queue, records, staging queue и удаления старых submission attempts. Проверять фактическое время, `Rows Removed by Filter`, объём buffers и соответствие выбранного индекса фильтрам. Если таблица превышает 10 000 строк, а план остаётся последовательным и выходит за бюджет, сохранить план в журнал релиза и скорректировать индекс или форму запроса до открытия альфы. +Для новых каталогов снастей и аналитики сочетаний используется отдельный TEMP-only gate: + +```bash +./deploy/test-tackle-query-plans.sh +``` + +Он создаёт 5 000 предметов, 100 000 уловов и 100 000 ordered-компонентов только в текущей psql-сессии, проверяет фильтр каталога и группировку approved-компонентов за 72 часа. Фильтр каталога должен использовать индексный путь, аналитика — селективный индексный путь по временной ленте и уложиться вместе с каталогом в 250 мс; широкий scan компонентов допустим после ограничения уловов. Production-таблицы не изменяются. + Bootstrap-тест отдельно проверяет, что Alembic прошёл миграцию `0011` и все восемь составных индексов созданы на чистой PostgreSQL. После запуска на сервере локальный gate не заменяет три полевых замера p95 из [load-testing.md](load-testing.md).