diff --git a/apps/api/app/routers/analytics.py b/apps/api/app/routers/analytics.py index 4d00d8f..a812291 100644 --- a/apps/api/app/routers/analytics.py +++ b/apps/api/app/routers/analytics.py @@ -56,6 +56,20 @@ def tackle_combinations( result = [] for (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 + } + 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 + } + 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 players = {report.player_name.strip().casefold() for report in unique_reports.values() if report.player_name and report.player_name.strip()} catches = len(unique_reports) unique_players = len(players) @@ -66,7 +80,8 @@ def tackle_combinations( ) / catches * 100) enough = catches >= min_samples and unique_players >= min_players result.append(TackleCombinationOut( - role=role, value=value, catches=catches, unique_players=unique_players, + role=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", explanation=( diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index f84dea6..52f2626 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -115,6 +115,8 @@ class PaginatedActivityOut(BaseModel): class TackleCombinationOut(BaseModel): role: str value: str + tackle_item_id: UUID | None = None + rig_id: UUID | None = None catches: int unique_players: int last_seen_at: datetime diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 5ffc658..abdc00f 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -22,7 +22,7 @@ export type Catch = { id: string; fish: string; weight_g: number; bait: string | export type DictionaryItem = { id: string; slug: string; name_ru: string; unlock_level?: number | null; fish_species_count?: number | null; source_system?: string | null; source_external_id?: string | null; source_url?: string | null; description?: string | null; source_aliases?: string[] | null; source_fish_species?: string[] | null; source_image_urls?: string[] | null; source_point_urls?: string[] | null; source_checked_at?: string | null }; export type TackleItem = { id: string; name: string; category: string; subcategory: string | null; brand: string | null; family: string | null; unlock_level: number | null; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[] }; export type PaginatedTackleItems = { items: TackleItem[]; total: number; limit: number; offset: number }; -export type TackleCombination = { role: string; value: string; catches: number; unique_players: number; last_seen_at: string; freshness_score: number; status: "recommendation" | "insufficient_data"; explanation: string }; +export type TackleCombination = { role: string; value: string; tackle_item_id: string | null; rig_id: string | null; catches: number; unique_players: number; last_seen_at: string; freshness_score: number; status: "recommendation" | "insufficient_data"; explanation: string }; export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null; source_system: string }; export type PaginatedOfficialRecord = { items: OfficialRecord[]; diff --git a/apps/web/src/pages/tackle/analytics.astro b/apps/web/src/pages/tackle/analytics.astro index c680258..fd988c8 100644 --- a/apps/web/src/pages/tackle/analytics.astro +++ b/apps/web/src/pages/tackle/analytics.astro @@ -36,5 +36,5 @@ const roleLabels: Record = { lure: "Приманка", bait: "Н {(waterbody || fish || method || hours !== 72) && Сбросить}

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

- {unavailable ? : combinations.length ?
{combinations.map(item =>
{roleLabels[item.role] ?? item.role}{item.status === "recommendation" ? "Рекомендация" : "Недостаточно данных"}

{item.value}

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

{item.explanation}

)}
:
} + {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 1b7fb41..9a6c420 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` с фильтрами по категории, бренду, семейству и уровню, 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-счётчики и пороги, но стабильно ставит более свежую равную выборку выше старой и виден на карточке. Остаётся дальнейшая визуальная приёмка. +- [ ] **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 для будущих ролей; 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.