diff --git a/apps/api/app/routers/analytics.py b/apps/api/app/routers/analytics.py index a812291..5a19e56 100644 --- a/apps/api/app/routers/analytics.py +++ b/apps/api/app/routers/analytics.py @@ -4,7 +4,7 @@ from collections import defaultdict from datetime import datetime, timedelta, timezone from math import exp, log -from fastapi import APIRouter, Query +from fastapi import APIRouter, HTTPException, Query from sqlalchemy import select from sqlalchemy.orm import Session, joinedload, selectinload @@ -27,10 +27,13 @@ def tackle_combinations( waterbody: str | None = None, fish: str | None = None, method: str | None = None, + role: str | None = None, hours: int = Query(72, ge=24, le=168), min_samples: int = Query(3, ge=1, le=100), min_players: int = Query(2, ge=1, le=100), ) -> list[TackleCombinationOut]: + if role is not None and role not in {"lure", "bait", "rig", "rod", "reel", "line", "hook", "float", "sinker"}: + raise HTTPException(status_code=422, detail="invalid tackle role") now = datetime.now(timezone.utc) query = select(CatchReport).options( joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), @@ -50,23 +53,25 @@ def tackle_combinations( groups: dict[tuple[str, str], list[CatchReport]] = defaultdict(list) for report in db.scalars(query): for component in report.tackle_components: + if role and component.role != role: + continue if component.raw_value.strip(): groups[(component.role, component.raw_value.strip())].append(report) result = [] - for (role, value), reports in groups.items(): + for (component_role, value), reports in groups.items(): unique_reports = {report.id: report for report in reports} item_ids = { component.tackle_item_id for report in unique_reports.values() for component in report.tackle_components - if component.role == role and component.raw_value.strip() == value and component.tackle_item_id is not None + if component.role == component_role and component.raw_value.strip() == value and component.tackle_item_id is not None } rig_ids = { component.rig_id for report in unique_reports.values() for component in report.tackle_components - if component.role == role and component.raw_value.strip() == value and component.rig_id is not None + if component.role == component_role and component.raw_value.strip() == value and component.rig_id is not None } canonical_item_id = next(iter(item_ids)) if len(item_ids) == 1 and not rig_ids else None canonical_rig_id = next(iter(rig_ids)) if len(rig_ids) == 1 and not item_ids else None @@ -80,7 +85,7 @@ def tackle_combinations( ) / catches * 100) enough = catches >= min_samples and unique_players >= min_players result.append(TackleCombinationOut( - role=role, value=value, tackle_item_id=canonical_item_id, rig_id=canonical_rig_id, + role=component_role, value=value, tackle_item_id=canonical_item_id, rig_id=canonical_rig_id, catches=catches, unique_players=unique_players, last_seen_at=last_seen, freshness_score=freshness_score, status="recommendation" if enough else "insufficient_data", diff --git a/apps/web/src/pages/tackle/analytics.astro b/apps/web/src/pages/tackle/analytics.astro index fd988c8..fe0d52d 100644 --- a/apps/web/src/pages/tackle/analytics.astro +++ b/apps/web/src/pages/tackle/analytics.astro @@ -8,11 +8,13 @@ const params = Astro.url.searchParams; const waterbody = params.get("waterbody") ?? ""; const fish = params.get("fish") ?? ""; const method = params.get("method") ?? ""; +const role = params.get("role") ?? ""; const hours = [24, 72, 168].includes(Number(params.get("hours"))) ? Number(params.get("hours")) : 72; const query = new URLSearchParams({ hours: String(hours), min_samples: "3", min_players: "2" }); if (waterbody) query.set("waterbody", waterbody); if (fish) query.set("fish", fish); if (method) query.set("method", method); +if (role) query.set("role", role); let combinations: TackleCombination[] = [], waters: DictionaryItem[] = [], fishes: DictionaryItem[] = [], unavailable = false; try { [combinations, waters, fishes] = await Promise.all([ @@ -31,9 +33,10 @@ const roleLabels: Record = { lure: "Приманка", bait: "Н + - {(waterbody || fish || method || hours !== 72) && Сбросить} + {(waterbody || fish || method || role || hours !== 72) && Сбросить}

Порог рекомендации: минимум 3 наблюдения от 2 независимых игроков. Это не рейтинг снасти и не гарантия улова.

{unavailable ? : combinations.length ?
{combinations.map(item => { const canonicalHref = item.rig_id ? `/tackle/rigs/${item.rig_id}` : item.tackle_item_id ? `/tackle/items/${item.tackle_item_id}` : null; return
{roleLabels[item.role] ?? item.role}{item.status === "recommendation" ? "Рекомендация" : "Недостаточно данных"}

{canonicalHref ? {item.value} : item.value}

Наблюдения
{item.catches}
Игроки
{item.unique_players}
Свежесть выборки
{item.freshness_score}%
Последнее
{ago(item.last_seen_at)}

{item.explanation}

})}
:
} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 40bef1f..b5f265f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -72,7 +72,7 @@ - [ ] **G04 · Crosswalk и нормализация.** Добавлен offline `gear_crosswalk`: нормализация регистра/пробелов/`е/ё`, точное имя или alias плюс совместимая категория, консервативная проверка brand/family. Неоднозначные, несовместимые, брендовые и unmatched-строки получают review-статус без canonical key; исходное значение сохраняется. Остаётся подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты. - [ ] **G05 · Связи с уловами и источниками.** Добавлены `catch_tackle_component` и offline `gear_components`: можно сохранять несколько unresolved/canonical компонентов с ролью, порядком, исходным значением, source identity и `raw_payload`; legacy `bait_id` не меняется. Parser сохраняет порядок оборудования из detail и разделяет bait/rig в catch-полях. Запись компонентов подключена к community import, официальному импорту и пользовательской форме; повторная обработка идемпотентна. Canonical-привязка и безопасный backfill остаются только после подтверждённого crosswalk. - [ ] **G06 · API и публичный каталог.** Добавлены пагинированные `/api/v1/tackle/items` и `/api/v1/tackle/rigs` с фильтрами предметов по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Публичный каталог теперь показывает обнаруживаемые карточки снастей и монтажей, имеет detail-routes `/tackle/items/:id` и `/tackle/rigs/:id` с 404/unavailable-различением, паспортом данных и явным списком недостающих полей; CatchList показывает ordered-компоненты и ведёт в canonical detail снасти или монтажа только при наличии соответствующего ID, а unresolved raw value оставляет текстом. -- [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Публичный `/tackle/analytics` показывает и сохраняет фильтры, имеет clean reset-link, выборку, независимых игроков и раздельные `recommendation`/`insufficient_data` states без рейтинга эффективности. Добавлен объяснимый `freshness_score` с half-life `12.5` часа и контрактом `0..100`: он не меняет raw-счётчики и пороги, но стабильно ставит более свежую равную выборку выше старой и виден на карточке. При однозначной canonical-привязке аналитика ведёт в карточку снасти или монтажа, а при конфликте идентификаторов сохраняет raw-значение без ложной ссылки. Остаётся дальнейшая визуальная приёмка. +- [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода, роли и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Публичный `/tackle/analytics` показывает и сохраняет фильтры, имеет clean reset-link, выборку, независимых игроков и раздельные `recommendation`/`insufficient_data` states без рейтинга эффективности. Добавлен объяснимый `freshness_score` с half-life `12.5` часа и контрактом `0..100`: он не меняет raw-счётчики и пороги, но стабильно ставит более свежую равную выборку выше старой и виден на карточке. При однозначной canonical-привязке аналитика ведёт в карточку снасти или монтажа, а при конфликте идентификаторов сохраняет raw-значение без ложной ссылки. Остаётся дальнейшая визуальная приёмка. - [ ] **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 и атомарное продвижение сохраняются. Публичный и admin UI используют единый словарь человекочитаемых role labels с безопасным fallback для будущих ролей; публичная медиатека фильтрует разрешённые роли через URL/API, admin-карточки дополнительно получают `data-media-role` для проверяемой инспекции, unit-регрессия словаря проходит. Остаётся провести реальный contact-sheet review для будущих tackle-кандидатов без автоматической публикации. - [ ] **G09 · Приёмка и эксплуатация.** Добавлены fixture/regression tests для crosswalk, media roles, идемпотентных компонентов, пустых результатов и многокомпонентных наблюдений; каталог `/tackle` включён в visual-matrix, narrow smoke и accessibility routes. Offline catalog/media audits и сохранение старых данных при сбое импорта проходят. TEMP-only query-plan gate для фильтра каталога и группировки сочетаний использует индексы и укладывается в 250 мс. Chromium подтвердил empty-state публичного каталога и отсутствие overflow на 320 px; обычный E2E пропускает bootstrap без явных переменных, отдельный bootstrap с токеном проходит. Остаются HTTP/browser acceptance для неоднозначных и многокомпонентных комплектов, ручной visual review и проверяемый счётчик по категориям либо явный `unknown`. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff.