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 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]