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 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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user