feat: harden tackle analytics thresholds

This commit is contained in:
ik
2026-09-22 19:54:41 +07:00
parent 089b9cc11d
commit b0c1a05754
3 changed files with 25 additions and 21 deletions
+23 -19
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone
from math import exp, log from math import exp, log
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import select from sqlalchemy import and_, or_, select
from sqlalchemy.orm import Session, joinedload, selectinload from sqlalchemy.orm import Session, joinedload, selectinload
from ..dependencies import Db from ..dependencies import Db
@@ -29,8 +29,9 @@ def tackle_combinations(
method: str | None = None, method: str | None = None,
role: str | None = None, role: str | None = None,
hours: int = Query(72, ge=24, le=168), hours: int = Query(72, ge=24, le=168),
min_samples: int = Query(3, ge=1, le=100), min_samples: int = Query(3, ge=3, le=100),
min_players: int = Query(2, ge=1, le=100), min_players: int = Query(2, ge=2, le=100),
limit: int = Query(100, ge=1, le=500),
) -> list[TackleCombinationOut]: ) -> list[TackleCombinationOut]:
if role is not None and role not in {"lure", "bait", "rig", "rod", "reel", "line", "hook", "float", "sinker"}: 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") raise HTTPException(status_code=422, detail="invalid tackle role")
@@ -41,7 +42,10 @@ def tackle_combinations(
).where( ).where(
CatchReport.moderation_status == ModerationStatus.approved, CatchReport.moderation_status == ModerationStatus.approved,
CatchReport.deleted_at.is_(None), 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: if waterbody:
query = query.join(Waterbody, CatchReport.waterbody_id == Waterbody.id).where(Waterbody.slug == waterbody) query = query.join(Waterbody, CatchReport.waterbody_id == Waterbody.id).where(Waterbody.slug == waterbody)
@@ -61,26 +65,24 @@ def tackle_combinations(
result = [] result = []
for (component_role, value), reports in groups.items(): for (component_role, value), reports in groups.items():
unique_reports = {report.id: report for report in reports} unique_reports = {report.id: report for report in reports}
item_ids = { matching_components = [
component.tackle_item_id component
for report in unique_reports.values() for report in unique_reports.values()
for component in report.tackle_components 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 if component.role == component_role and component.raw_value.strip() == value
} ]
rig_ids = { item_ids = {component.tackle_item_id for component in matching_components if component.tackle_item_id is not None}
component.rig_id rig_ids = {component.rig_id for component in matching_components if component.rig_id is not None}
for report in unique_reports.values() has_unresolved = any(component.tackle_item_id is None and component.rig_id is None for component in matching_components)
for component in report.tackle_components canonical_item_id = next(iter(item_ids)) if len(item_ids) == 1 and not rig_ids and not has_unresolved else None
if component.role == component_role and component.raw_value.strip() == value and component.rig_id is not None canonical_rig_id = next(iter(rig_ids)) if len(rig_ids) == 1 and not item_ids and not has_unresolved else 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()} 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) catches = len(unique_reports)
unique_players = len(players) 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( 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() for report in unique_reports.values()
) / catches * 100) ) / catches * 100)
enough = catches >= min_samples and unique_players >= min_players enough = catches >= min_samples and unique_players >= min_players
@@ -95,4 +97,6 @@ def tackle_combinations(
f"Данных мало: нужно минимум {min_samples} наблюдения и {min_players} независимых игрока." 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]
+1 -1
View File
@@ -10,7 +10,7 @@ const fish = params.get("fish") ?? "";
const method = params.get("method") ?? ""; const method = params.get("method") ?? "";
const role = params.get("role") ?? ""; const role = params.get("role") ?? "";
const hours = [24, 72, 168].includes(Number(params.get("hours"))) ? Number(params.get("hours")) : 72; 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 (waterbody) query.set("waterbody", waterbody);
if (fish) query.set("fish", fish); if (fish) query.set("fish", fish);
if (method) query.set("method", method); if (method) query.set("method", method);
+1 -1
View File
@@ -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. - [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 не возвращает удалённое, старые данные отмечены. - [ ] **R06 · P1 · Надёжный перенос плана (U05).** Preview, объединение/замена с восстановлением, однократный импорт, storage errors, вычисляемая свежесть и точное описание передачи share-данных. Критерий: ссылка не стирает план без выбора, reload не возвращает удалённое, старые данные отмечены.
- [ ] **R07 · P1 · Схема координат по водоёмам (U04).** Разделить системы координат, показать точность и водоём, обработать совпадения/обрезку. Критерий: все точки достижимы на 320 px и с клавиатуры, включая одинаковые координаты и длинные названия. - [ ] **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/БД. Критерий: личные копии не остаются в компонентах, повторная очистка безопасна. - [ ] **R09 · P1 · Полное удаление личных данных.** Retention/delete учитывают дочерние source_url/raw_payload и восстановление при сбоях S3/БД. Критерий: личные копии не остаются в компонентах, повторная очистка безопасна.
- [ ] **R10 · P1 · Единый cooldown и стадии запроса.** Координация CLI/media/scheduler; release только до доказанного обращения. Критерий: DNS до первого запроса освобождает резерв, ошибка после redirect — нет; конкурентные пути делят одно окно. Проверять offline. - [ ] **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. - [ ] **R18 · P1 · Границы локального и production запуска.** Loopback для dev-портов с явным opt-in LAN; проверить доверенные proxy/client-IP и rate-limit на двух клиентах. Критерий: default dev не открыт в LAN, production различает клиентов. Реальные серверные gates — A07.