feat: harden tackle analytics thresholds
This commit is contained in:
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user