feat: add freshness decay to tackle analytics

This commit is contained in:
ik
2026-09-21 18:02:23 +07:00
parent 91019cbf3a
commit e4e10bb4e9
7 changed files with 819 additions and 6 deletions
+14 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from math import exp, log
from fastapi import APIRouter, Query
from sqlalchemy import select
@@ -12,6 +13,12 @@ from ..models import CatchReport, Fish, ModerationStatus, Spot, Waterbody
from ..schemas import TackleCombinationOut
router = APIRouter()
FRESHNESS_HALF_LIFE_HOURS = 12.5
def _age_hours(value: datetime, now: datetime) -> float:
observed_at = value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
return max(0.0, (now - observed_at).total_seconds() / 3600)
@router.get("/api/v1/analytics/tackle", response_model=list[TackleCombinationOut])
@@ -53,14 +60,19 @@ def tackle_combinations(
catches = len(unique_reports)
unique_players = len(players)
last_seen = max(report.reported_at for report in unique_reports.values())
freshness_score = round(sum(
exp(-_age_hours(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
result.append(TackleCombinationOut(
role=role, value=value, catches=catches, unique_players=unique_players,
last_seen_at=last_seen, status="recommendation" if enough else "insufficient_data",
last_seen_at=last_seen, freshness_score=freshness_score,
status="recommendation" if enough else "insufficient_data",
explanation=(
"Достаточно независимых наблюдений для рекомендации."
if enough else
f"Данных мало: нужно минимум {min_samples} наблюдения и {min_players} независимых игрока."
),
))
return sorted(result, key=lambda item: (item.status != "recommendation", -item.catches, -item.unique_players, item.role, item.value))
return sorted(result, key=lambda item: (item.status != "recommendation", -item.freshness_score, -item.catches, -item.unique_players, item.role, item.value))
+1
View File
@@ -118,6 +118,7 @@ class TackleCombinationOut(BaseModel):
catches: int
unique_players: int
last_seen_at: datetime
freshness_score: int
status: str
explanation: str
+771 -1
View File
@@ -62,6 +62,13 @@
"title": "Max Weight G",
"type": "integer"
},
"source_conflicts": {
"items": {
"type": "string"
},
"title": "Source Conflicts",
"type": "array"
},
"sources": {
"items": {
"type": "string"
@@ -660,6 +667,17 @@
"title": "Fish",
"type": "string"
},
"fishing_method": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Fishing Method"
},
"id": {
"format": "uuid",
"title": "Id",
@@ -718,6 +736,13 @@
],
"title": "Source Url"
},
"tackle_components": {
"items": {
"$ref": "#/components/schemas/CatchTackleComponentOut"
},
"title": "Tackle Components",
"type": "array"
},
"weight_g": {
"title": "Weight G",
"type": "integer"
@@ -731,10 +756,12 @@
"player_name",
"caught_at",
"reported_at",
"fishing_method",
"retrieve_method",
"retrieve_speed",
"source_system",
"source_url"
"source_url",
"tackle_components"
],
"title": "CatchOut",
"type": "object"
@@ -941,6 +968,85 @@
"title": "CatchReportCreated",
"type": "object"
},
"CatchTackleComponentOut": {
"properties": {
"id": {
"format": "uuid",
"title": "Id",
"type": "string"
},
"position": {
"title": "Position",
"type": "integer"
},
"raw_value": {
"title": "Raw Value",
"type": "string"
},
"rig_id": {
"anyOf": [
{
"format": "uuid",
"type": "string"
},
{
"type": "null"
}
],
"title": "Rig Id"
},
"role": {
"title": "Role",
"type": "string"
},
"source_system": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source System"
},
"source_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Url"
},
"tackle_item_id": {
"anyOf": [
{
"format": "uuid",
"type": "string"
},
{
"type": "null"
}
],
"title": "Tackle Item Id"
}
},
"required": [
"id",
"role",
"position",
"raw_value",
"tackle_item_id",
"rig_id",
"source_system",
"source_url"
],
"title": "CatchTackleComponentOut",
"type": "object"
},
"ExternalAliasSuggestionOut": {
"properties": {
"fish_slug": {
@@ -1773,6 +1879,37 @@
"title": "PaginatedOfficialRecordOut",
"type": "object"
},
"PaginatedTackleItemOut": {
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/TackleItemOut"
},
"title": "Items",
"type": "array"
},
"limit": {
"title": "Limit",
"type": "integer"
},
"offset": {
"title": "Offset",
"type": "integer"
},
"total": {
"title": "Total",
"type": "integer"
}
},
"required": [
"items",
"total",
"limit",
"offset"
],
"title": "PaginatedTackleItemOut",
"type": "object"
},
"PublicObservationOut": {
"properties": {
"fish_name": {
@@ -1867,6 +2004,138 @@
"title": "PublicObservationOut",
"type": "object"
},
"RigComponentOut": {
"properties": {
"id": {
"format": "uuid",
"title": "Id",
"type": "string"
},
"position": {
"title": "Position",
"type": "integer"
},
"raw_value": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Raw Value"
},
"role": {
"title": "Role",
"type": "string"
},
"tackle_item_id": {
"anyOf": [
{
"format": "uuid",
"type": "string"
},
{
"type": "null"
}
],
"title": "Tackle Item Id"
}
},
"required": [
"id",
"role",
"position",
"raw_value",
"tackle_item_id"
],
"title": "RigComponentOut",
"type": "object"
},
"RigOut": {
"properties": {
"components": {
"items": {
"$ref": "#/components/schemas/RigComponentOut"
},
"title": "Components",
"type": "array"
},
"id": {
"format": "uuid",
"title": "Id",
"type": "string"
},
"missing_fields": {
"items": {
"type": "string"
},
"title": "Missing Fields",
"type": "array"
},
"name": {
"title": "Name",
"type": "string"
},
"source_checked_at": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Checked At"
},
"source_external_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source External Id"
},
"source_system": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source System"
},
"source_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Url"
}
},
"required": [
"id",
"name",
"source_system",
"source_external_id",
"source_url",
"source_checked_at",
"components"
],
"title": "RigOut",
"type": "object"
},
"SourceStatusOut": {
"properties": {
"last_started_at": {
@@ -1962,6 +2231,13 @@
"title": "Id",
"type": "string"
},
"source_conflicts": {
"items": {
"type": "string"
},
"title": "Source Conflicts",
"type": "array"
},
"top_baits": {
"items": {
"type": "string"
@@ -2003,6 +2279,183 @@
"title": "SpotOut",
"type": "object"
},
"TackleCombinationOut": {
"properties": {
"catches": {
"title": "Catches",
"type": "integer"
},
"explanation": {
"title": "Explanation",
"type": "string"
},
"freshness_score": {
"title": "Freshness Score",
"type": "integer"
},
"last_seen_at": {
"format": "date-time",
"title": "Last Seen At",
"type": "string"
},
"role": {
"title": "Role",
"type": "string"
},
"status": {
"title": "Status",
"type": "string"
},
"unique_players": {
"title": "Unique Players",
"type": "integer"
},
"value": {
"title": "Value",
"type": "string"
}
},
"required": [
"role",
"value",
"catches",
"unique_players",
"last_seen_at",
"freshness_score",
"status",
"explanation"
],
"title": "TackleCombinationOut",
"type": "object"
},
"TackleItemOut": {
"properties": {
"brand": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Brand"
},
"category": {
"title": "Category",
"type": "string"
},
"family": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Family"
},
"id": {
"format": "uuid",
"title": "Id",
"type": "string"
},
"missing_fields": {
"items": {
"type": "string"
},
"title": "Missing Fields",
"type": "array"
},
"name": {
"title": "Name",
"type": "string"
},
"source_checked_at": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Checked At"
},
"source_external_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source External Id"
},
"source_system": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source System"
},
"source_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Url"
},
"subcategory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Subcategory"
},
"unlock_level": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Unlock Level"
}
},
"required": [
"id",
"name",
"category",
"subcategory",
"brand",
"family",
"unlock_level",
"source_system",
"source_external_id",
"source_url",
"source_checked_at"
],
"title": "TackleItemOut",
"type": "object"
},
"ValidationError": {
"properties": {
"loc": {
@@ -3580,6 +4033,124 @@
"summary": "Admin Source Status"
}
},
"/api/v1/analytics/tackle": {
"get": {
"operationId": "tackle_combinations_api_v1_analytics_tackle_get",
"parameters": [
{
"in": "query",
"name": "waterbody",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Waterbody"
}
},
{
"in": "query",
"name": "fish",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Fish"
}
},
{
"in": "query",
"name": "method",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Method"
}
},
{
"in": "query",
"name": "hours",
"required": false,
"schema": {
"default": 72,
"maximum": 168,
"minimum": 24,
"title": "Hours",
"type": "integer"
}
},
{
"in": "query",
"name": "min_samples",
"required": false,
"schema": {
"default": 3,
"maximum": 100,
"minimum": 1,
"title": "Min Samples",
"type": "integer"
}
},
{
"in": "query",
"name": "min_players",
"required": false,
"schema": {
"default": 2,
"maximum": 100,
"minimum": 1,
"title": "Min Players",
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/TackleCombinationOut"
},
"title": "Response Tackle Combinations Api V1 Analytics Tackle Get",
"type": "array"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Tackle Combinations"
}
},
"/api/v1/baits": {
"get": {
"operationId": "baits_api_v1_baits_get",
@@ -4432,6 +5003,205 @@
"summary": "Spot Timeline"
}
},
"/api/v1/tackle/items": {
"get": {
"operationId": "tackle_items_api_v1_tackle_items_get",
"parameters": [
{
"in": "query",
"name": "category",
"required": false,
"schema": {
"anyOf": [
{
"pattern": "^(bait|lure|rod|reel|line|hook|rig|float|sinker|other)$",
"type": "string"
},
{
"type": "null"
}
],
"title": "Category"
}
},
{
"in": "query",
"name": "brand",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Brand"
}
},
{
"in": "query",
"name": "family",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Family"
}
},
{
"in": "query",
"name": "unlock_level",
"required": false,
"schema": {
"anyOf": [
{
"minimum": 0,
"type": "integer"
},
{
"type": "null"
}
],
"title": "Unlock Level"
}
},
{
"in": "query",
"name": "limit",
"required": false,
"schema": {
"default": 50,
"maximum": 100,
"minimum": 1,
"title": "Limit",
"type": "integer"
}
},
{
"in": "query",
"name": "offset",
"required": false,
"schema": {
"default": 0,
"minimum": 0,
"title": "Offset",
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PaginatedTackleItemOut"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Tackle Items"
}
},
"/api/v1/tackle/items/{item_id}": {
"get": {
"operationId": "tackle_item_api_v1_tackle_items__item_id__get",
"parameters": [
{
"in": "path",
"name": "item_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Item Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TackleItemOut"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Tackle Item"
}
},
"/api/v1/tackle/rigs/{rig_id}": {
"get": {
"operationId": "rig_detail_api_v1_tackle_rigs__rig_id__get",
"parameters": [
{
"in": "path",
"name": "rig_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Rig Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RigOut"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Rig Detail"
}
},
"/api/v1/waterbodies": {
"get": {
"operationId": "waterbodies_api_v1_waterbodies_get",
+30
View File
@@ -69,3 +69,33 @@ def test_tackle_analytics_handles_empty_and_multicomponent_observations() -> Non
assert tackle_combinations(db, waterbody="missing", fish=None, method=None, hours=72) == []
engine.dispose()
def test_tackle_analytics_exposes_decay_and_prefers_fresher_equal_samples() -> None:
now = datetime.now(timezone.utc)
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
waterbody = Waterbody(slug="decay-check", name_ru="Свежесть", unlock_level=1)
fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000)
spot = Spot(waterbody=waterbody, x=3, y=4)
db.add_all([waterbody, fish, spot])
db.flush()
for value, age in (("Fresh spinner", 1), ("Old spinner", 48)):
for index, player in enumerate(("One", "One", "Two")):
report = CatchReport(
fish=fish, waterbody=waterbody, spot=spot, weight_g=1000,
caught_at=now - timedelta(hours=age), reported_at=now - timedelta(hours=age),
player_name=player, source_type=SourceType.user, source_confidence=80,
moderation_status=ModerationStatus.approved,
)
report.tackle_components.append(CatchTackleComponent(role="lure", position=index, raw_value=value))
db.add(report)
db.commit()
rows = tackle_combinations(db, waterbody="decay-check", fish="pike", method=None, hours=72, min_samples=3, min_players=2)
assert [row.value for row in rows] == ["Fresh spinner", "Old spinner"]
assert rows[0].freshness_score > rows[1].freshness_score
assert rows[0].freshness_score <= 100
engine.dispose()