From b0c1a05754b9e2cc91c96205489db001d6abfd0b Mon Sep 17 00:00:00 2001 From: IK Date: Tue, 22 Sep 2026 19:54:41 +0700 Subject: [PATCH] feat: harden tackle analytics thresholds --- apps/api/app/routers/analytics.py | 42 +++++++++++++---------- apps/web/src/pages/tackle/analytics.astro | 2 +- docs/ROADMAP.md | 2 +- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/apps/api/app/routers/analytics.py b/apps/api/app/routers/analytics.py index 5a19e56..86d9696 100644 --- a/apps/api/app/routers/analytics.py +++ b/apps/api/app/routers/analytics.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone from math import exp, log from fastapi import APIRouter, HTTPException, Query -from sqlalchemy import select +from sqlalchemy import and_, or_, select from sqlalchemy.orm import Session, joinedload, selectinload from ..dependencies import Db @@ -29,8 +29,9 @@ def tackle_combinations( 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), + min_samples: int = Query(3, ge=3, le=100), + min_players: int = Query(2, ge=2, le=100), + limit: int = Query(100, ge=1, le=500), ) -> 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") @@ -41,7 +42,10 @@ def tackle_combinations( ).where( CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None), - CatchReport.reported_at >= now - timedelta(hours=hours), + or_( + and_(CatchReport.caught_at.is_not(None), CatchReport.caught_at >= now - timedelta(hours=hours)), + and_(CatchReport.caught_at.is_(None), CatchReport.reported_at >= now - timedelta(hours=hours)), + ), ) if waterbody: query = query.join(Waterbody, CatchReport.waterbody_id == Waterbody.id).where(Waterbody.slug == waterbody) @@ -61,26 +65,24 @@ def tackle_combinations( result = [] for (component_role, value), reports in groups.items(): unique_reports = {report.id: report for report in reports} - item_ids = { - component.tackle_item_id + matching_components = [ + component for report in unique_reports.values() for component in report.tackle_components - 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 == 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 + if component.role == component_role and component.raw_value.strip() == value + ] + item_ids = {component.tackle_item_id for component in matching_components if component.tackle_item_id is not None} + rig_ids = {component.rig_id for component in matching_components if component.rig_id is not None} + has_unresolved = any(component.tackle_item_id is None and component.rig_id is None for component in matching_components) + canonical_item_id = next(iter(item_ids)) if len(item_ids) == 1 and not rig_ids and not has_unresolved else None + canonical_rig_id = next(iter(rig_ids)) if len(rig_ids) == 1 and not item_ids and not has_unresolved 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) - last_seen = max(report.reported_at for report in unique_reports.values()) + observed_times = [report.caught_at or report.reported_at for report in unique_reports.values()] + last_seen = max(observed_times) freshness_score = round(sum( - exp(-_age_hours(report.reported_at, now) * log(2) / FRESHNESS_HALF_LIFE_HOURS) + exp(-_age_hours(report.caught_at or report.reported_at, now) * log(2) / FRESHNESS_HALF_LIFE_HOURS) for report in unique_reports.values() ) / catches * 100) enough = catches >= min_samples and unique_players >= min_players @@ -95,4 +97,6 @@ def tackle_combinations( f"Данных мало: нужно минимум {min_samples} наблюдения и {min_players} независимых игрока." ), )) - return sorted(result, key=lambda item: (item.status != "recommendation", -item.freshness_score, -item.catches, -item.unique_players, item.role, item.value)) + ranked = sorted(result, key=lambda item: (item.status != "recommendation", -item.freshness_score, -item.catches, -item.unique_players, item.role, item.value)) + effective_limit = limit if isinstance(limit, int) else 100 + return ranked[:effective_limit] diff --git a/apps/web/src/pages/tackle/analytics.astro b/apps/web/src/pages/tackle/analytics.astro index 3e97b3a..53454d7 100644 --- a/apps/web/src/pages/tackle/analytics.astro +++ b/apps/web/src/pages/tackle/analytics.astro @@ -10,7 +10,7 @@ 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" }); +const query = new URLSearchParams({ hours: String(hours), min_samples: "3", min_players: "2", limit: "100" }); if (waterbody) query.set("waterbody", waterbody); if (fish) query.set("fish", fish); if (method) query.set("method", method); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 53335ba..19cc340 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -30,7 +30,7 @@ R-пункты уточняют критерии существующих B/G/U/ - [x] **R05 · P1 · Полноценный каталог снастей (G06/G09).** Предметы и сборки получили независимые offsets и пагинаторы (`items_offset`/`rigs_offset`), один сбой endpoint больше не скрывает второй, 422 фильтров отделён от 503, пустой поиск отделён от незаполненного каталога. Карточки стали article-блоками с отдельной ссылкой названия и валидным source-link внутри паспорта без вложенных ссылок. `astro check` и production build проходят; browser/keyboard acceptance наполненного каталога остаётся частью G06/G09. - [ ] **R06 · P1 · Надёжный перенос плана (U05).** Preview, объединение/замена с восстановлением, однократный импорт, storage errors, вычисляемая свежесть и точное описание передачи share-данных. Критерий: ссылка не стирает план без выбора, reload не возвращает удалённое, старые данные отмечены. - [ ] **R07 · P1 · Схема координат по водоёмам (U04).** Разделить системы координат, показать точность и водоём, обработать совпадения/обрезку. Критерий: все точки достижимы на 320 px и с клавиатуры, включая одинаковые координаты и длинные названия. -- [ ] **R08 · P1 · Достоверная аналитика снастей (G07).** Зафиксировать публичный минимум 3 наблюдения/2 игрока, разделить время улова и импорта, conservative canonical grouping, ограничить ответ. Критерий: 1/1 не рекомендация, unresolved не получает ложную привязку, старый улов не выглядит свежим. +- [x] **R08 · P1 · Достоверная аналитика снастей (G07).** Публичные пороги закреплены минимум на 3 наблюдениях и 2 игроках независимо от URL-параметров; окно и freshness используют время улова с fallback на время импорта, ответ ограничен `limit`, а canonical ID назначается только при полном однозначном покрытии компонентных наблюдений. `1/1` нельзя превратить в рекомендацию, unresolved не получает ложную привязку. - [ ] **R09 · P1 · Полное удаление личных данных.** Retention/delete учитывают дочерние source_url/raw_payload и восстановление при сбоях S3/БД. Критерий: личные копии не остаются в компонентах, повторная очистка безопасна. - [ ] **R10 · P1 · Единый cooldown и стадии запроса.** Координация CLI/media/scheduler; release только до доказанного обращения. Критерий: DNS до первого запроса освобождает резерв, ошибка после redirect — нет; конкурентные пути делят одно окно. Проверять offline. - [ ] **R18 · P1 · Границы локального и production запуска.** Loopback для dev-портов с явным opt-in LAN; проверить доверенные proxy/client-IP и rate-limit на двух клиентах. Критерий: default dev не открыт в LAN, production различает клиентов. Реальные серверные gates — A07.