feat: add freshness decay to tackle analytics
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -20,7 +20,7 @@ export type Catch = { id: string; fish: string; weight_g: number; bait: string |
|
||||
export type DictionaryItem = { id: string; slug: string; name_ru: string; unlock_level?: number | null; fish_species_count?: number | null; source_system?: string | null; source_external_id?: string | null; source_url?: string | null; description?: string | null; source_aliases?: string[] | null; source_fish_species?: string[] | null; source_image_urls?: string[] | null; source_point_urls?: string[] | null; source_checked_at?: string | null };
|
||||
export type TackleItem = { id: string; name: string; category: string; subcategory: string | null; brand: string | null; family: string | null; unlock_level: number | null; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[] };
|
||||
export type PaginatedTackleItems = { items: TackleItem[]; total: number; limit: number; offset: number };
|
||||
export type TackleCombination = { role: string; value: string; catches: number; unique_players: number; last_seen_at: string; status: "recommendation" | "insufficient_data"; explanation: string };
|
||||
export type TackleCombination = { role: string; value: string; catches: number; unique_players: number; last_seen_at: string; freshness_score: number; status: "recommendation" | "insufficient_data"; explanation: string };
|
||||
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null; source_system: string };
|
||||
export type PaginatedOfficialRecord = {
|
||||
items: OfficialRecord[];
|
||||
|
||||
@@ -36,5 +36,5 @@ const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Н
|
||||
{(waterbody || fish || method || hours !== 72) && <a data-action="quiet" href="/tackle/analytics">Сбросить</a>}
|
||||
</form>
|
||||
<p class="privacy content-grid">Порог рекомендации: минимум 3 наблюдения от 2 независимых игроков. Это не рейтинг снасти и не гарантия улова.</p>
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Аналитика временно недоступна" description="Не показываем непроверенные сочетания. Попробуйте позже." /> : combinations.length ? <section class="signal-grid content-grid" aria-label="Сочетания снастей" data-analytics-results>{combinations.map(item => <article class="signal-card" data-analytics-status={item.status}><div class="signal-card__top"><span class="source-chip"><span>{roleLabels[item.role] ?? item.role}</span></span><span class="quality-chip">{item.status === "recommendation" ? "Рекомендация" : "Недостаточно данных"}</span></div><h2>{item.value}</h2><dl><div><dt>Наблюдения</dt><dd>{item.catches}</dd></div><div><dt>Игроки</dt><dd>{item.unique_players}</dd></div><div><dt>Последнее</dt><dd>{ago(item.last_seen_at)}</dd></div></dl><p>{item.explanation}</p></article>)}</section> : <div data-analytics-results><StatePanel title="Подтверждённых сочетаний пока нет" description="Сочетания появятся после новых одобренных наблюдений с указанием компонентов." /></div>}
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Аналитика временно недоступна" description="Не показываем непроверенные сочетания. Попробуйте позже." /> : combinations.length ? <section class="signal-grid content-grid" aria-label="Сочетания снастей" data-analytics-results>{combinations.map(item => <article class="signal-card" data-analytics-status={item.status}><div class="signal-card__top"><span class="source-chip"><span>{roleLabels[item.role] ?? item.role}</span></span><span class="quality-chip">{item.status === "recommendation" ? "Рекомендация" : "Недостаточно данных"}</span></div><h2>{item.value}</h2><dl><div><dt>Наблюдения</dt><dd>{item.catches}</dd></div><div><dt>Игроки</dt><dd>{item.unique_players}</dd></div><div><dt>Свежесть выборки</dt><dd>{item.freshness_score}%</dd></div><div><dt>Последнее</dt><dd>{ago(item.last_seen_at)}</dd></div></dl><p>{item.explanation}</p></article>)}</section> : <div data-analytics-results><StatePanel title="Подтверждённых сочетаний пока нет" description="Сочетания появятся после новых одобренных наблюдений с указанием компонентов." /></div>}
|
||||
</Layout>
|
||||
|
||||
Reference in New Issue
Block a user