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 collections import defaultdict
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from math import exp, log
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from sqlalchemy import select from sqlalchemy import select
@@ -12,6 +13,12 @@ from ..models import CatchReport, Fish, ModerationStatus, Spot, Waterbody
from ..schemas import TackleCombinationOut from ..schemas import TackleCombinationOut
router = APIRouter() 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]) @router.get("/api/v1/analytics/tackle", response_model=list[TackleCombinationOut])
@@ -53,14 +60,19 @@ def tackle_combinations(
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()) 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 enough = catches >= min_samples and unique_players >= min_players
result.append(TackleCombinationOut( result.append(TackleCombinationOut(
role=role, value=value, catches=catches, unique_players=unique_players, 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=( explanation=(
"Достаточно независимых наблюдений для рекомендации." "Достаточно независимых наблюдений для рекомендации."
if enough else if enough else
f"Данных мало: нужно минимум {min_samples} наблюдения и {min_players} независимых игрока." 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 catches: int
unique_players: int unique_players: int
last_seen_at: datetime last_seen_at: datetime
freshness_score: int
status: str status: str
explanation: str explanation: str
+771 -1
View File
@@ -62,6 +62,13 @@
"title": "Max Weight G", "title": "Max Weight G",
"type": "integer" "type": "integer"
}, },
"source_conflicts": {
"items": {
"type": "string"
},
"title": "Source Conflicts",
"type": "array"
},
"sources": { "sources": {
"items": { "items": {
"type": "string" "type": "string"
@@ -660,6 +667,17 @@
"title": "Fish", "title": "Fish",
"type": "string" "type": "string"
}, },
"fishing_method": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Fishing Method"
},
"id": { "id": {
"format": "uuid", "format": "uuid",
"title": "Id", "title": "Id",
@@ -718,6 +736,13 @@
], ],
"title": "Source Url" "title": "Source Url"
}, },
"tackle_components": {
"items": {
"$ref": "#/components/schemas/CatchTackleComponentOut"
},
"title": "Tackle Components",
"type": "array"
},
"weight_g": { "weight_g": {
"title": "Weight G", "title": "Weight G",
"type": "integer" "type": "integer"
@@ -731,10 +756,12 @@
"player_name", "player_name",
"caught_at", "caught_at",
"reported_at", "reported_at",
"fishing_method",
"retrieve_method", "retrieve_method",
"retrieve_speed", "retrieve_speed",
"source_system", "source_system",
"source_url" "source_url",
"tackle_components"
], ],
"title": "CatchOut", "title": "CatchOut",
"type": "object" "type": "object"
@@ -941,6 +968,85 @@
"title": "CatchReportCreated", "title": "CatchReportCreated",
"type": "object" "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": { "ExternalAliasSuggestionOut": {
"properties": { "properties": {
"fish_slug": { "fish_slug": {
@@ -1773,6 +1879,37 @@
"title": "PaginatedOfficialRecordOut", "title": "PaginatedOfficialRecordOut",
"type": "object" "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": { "PublicObservationOut": {
"properties": { "properties": {
"fish_name": { "fish_name": {
@@ -1867,6 +2004,138 @@
"title": "PublicObservationOut", "title": "PublicObservationOut",
"type": "object" "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": { "SourceStatusOut": {
"properties": { "properties": {
"last_started_at": { "last_started_at": {
@@ -1962,6 +2231,13 @@
"title": "Id", "title": "Id",
"type": "string" "type": "string"
}, },
"source_conflicts": {
"items": {
"type": "string"
},
"title": "Source Conflicts",
"type": "array"
},
"top_baits": { "top_baits": {
"items": { "items": {
"type": "string" "type": "string"
@@ -2003,6 +2279,183 @@
"title": "SpotOut", "title": "SpotOut",
"type": "object" "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": { "ValidationError": {
"properties": { "properties": {
"loc": { "loc": {
@@ -3580,6 +4033,124 @@
"summary": "Admin Source Status" "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": { "/api/v1/baits": {
"get": { "get": {
"operationId": "baits_api_v1_baits_get", "operationId": "baits_api_v1_baits_get",
@@ -4432,6 +5003,205 @@
"summary": "Spot Timeline" "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": { "/api/v1/waterbodies": {
"get": { "get": {
"operationId": "waterbodies_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) == [] assert tackle_combinations(db, waterbody="missing", fish=None, method=None, hours=72) == []
engine.dispose() 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()
+1 -1
View File
@@ -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 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 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 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 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 = { export type PaginatedOfficialRecord = {
items: OfficialRecord[]; items: OfficialRecord[];
+1 -1
View File
@@ -36,5 +36,5 @@ const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Н
{(waterbody || fish || method || hours !== 72) && <a data-action="quiet" href="/tackle/analytics">Сбросить</a>} {(waterbody || fish || method || hours !== 72) && <a data-action="quiet" href="/tackle/analytics">Сбросить</a>}
</form> </form>
<p class="privacy content-grid">Порог рекомендации: минимум 3 наблюдения от 2 независимых игроков. Это не рейтинг снасти и не гарантия улова.</p> <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> </Layout>
+1 -1
View File
@@ -72,7 +72,7 @@
- [ ] **G04 · Crosswalk и нормализация.** Добавлен offline `gear_crosswalk`: нормализация регистра/пробелов/`е/ё`, точное имя или alias плюс совместимая категория, консервативная проверка brand/family. Неоднозначные, несовместимые, брендовые и unmatched-строки получают review-статус без canonical key; исходное значение сохраняется. Остаётся подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты. - [ ] **G04 · Crosswalk и нормализация.** Добавлен offline `gear_crosswalk`: нормализация регистра/пробелов/`е/ё`, точное имя или alias плюс совместимая категория, консервативная проверка brand/family. Неоднозначные, несовместимые, брендовые и unmatched-строки получают review-статус без canonical key; исходное значение сохраняется. Остаётся подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты.
- [ ] **G05 · Связи с уловами и источниками.** Добавлены `catch_tackle_component` и offline `gear_components`: можно сохранять несколько unresolved/canonical компонентов с ролью, порядком, исходным значением, source identity и `raw_payload`; legacy `bait_id` не меняется. Parser сохраняет порядок оборудования из detail и разделяет bait/rig в catch-полях. Запись компонентов подключена к community import, официальному импорту и пользовательской форме; повторная обработка идемпотентна. Canonical-привязка и безопасный backfill остаются только после подтверждённого crosswalk. - [ ] **G05 · Связи с уловами и источниками.** Добавлены `catch_tackle_component` и offline `gear_components`: можно сохранять несколько unresolved/canonical компонентов с ролью, порядком, исходным значением, source identity и `raw_payload`; legacy `bait_id` не меняется. Parser сохраняет порядок оборудования из detail и разделяет bait/rig в catch-полях. Запись компонентов подключена к community import, официальному импорту и пользовательской форме; повторная обработка идемпотентна. Canonical-привязка и безопасный backfill остаются только после подтверждённого crosswalk.
- [ ] **G06 · API и публичный каталог.** Добавлены пагинированный `/api/v1/tackle/items` с фильтрами по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Публичный каталог теперь имеет detail-route `/tackle/items/:id` с 404/unavailable-различением, паспортом данных и явным списком недостающих полей; CatchList показывает ordered-компоненты, ведёт в canonical detail только при наличии `tackle_item_id`, а unresolved raw value оставляет текстом. - [ ] **G06 · API и публичный каталог.** Добавлены пагинированный `/api/v1/tackle/items` с фильтрами по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Публичный каталог теперь имеет detail-route `/tackle/items/:id` с 404/unavailable-различением, паспортом данных и явным списком недостающих полей; CatchList показывает ordered-компоненты, ведёт в canonical detail только при наличии `tackle_item_id`, а unresolved raw value оставляет текстом.
- [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Публичный `/tackle/analytics` показывает фильтры, выборку, независимых игроков и раздельные `recommendation`/`insufficient_data` states без рейтинга эффективности. Decay по свежести и дальнейшая визуальная приёмка остаются следующим шагом. - [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Публичный `/tackle/analytics` показывает фильтры, выборку, независимых игроков и раздельные `recommendation`/`insufficient_data` states без рейтинга эффективности. Добавлен объяснимый `freshness_score` с half-life `12.5` часа: он не меняет raw-счётчики и пороги, но стабильно ставит более свежую равную выборку выше старой и виден на карточке. Остаётся дальнейшая визуальная приёмка.
- [ ] **G08 · Медиа и качество.** Добавлены отдельные reviewed-роли `tackle_card`, `tackle_detail`, `rig_diagram`, `tackle_screenshot` для `tackle`, а также CLI-параметр `--media-role`; offline audit отклоняет неизвестную роль и несовпадение роли с entity type. Существующие dimensions, MIME, SHA-256, прозрачность, aspect ratio, provenance и атомарное продвижение сохраняются. Остаётся провести реальный contact-sheet review для будущих tackle-кандидатов без автоматической публикации. - [ ] **G08 · Медиа и качество.** Добавлены отдельные reviewed-роли `tackle_card`, `tackle_detail`, `rig_diagram`, `tackle_screenshot` для `tackle`, а также CLI-параметр `--media-role`; offline audit отклоняет неизвестную роль и несовпадение роли с entity type. Существующие dimensions, MIME, SHA-256, прозрачность, aspect ratio, provenance и атомарное продвижение сохраняются. Остаётся провести реальный contact-sheet review для будущих tackle-кандидатов без автоматической публикации.
- [ ] **G09 · Приёмка и эксплуатация.** Добавлены fixture/regression tests для crosswalk, media roles, идемпотентных компонентов, пустых результатов и многокомпонентных наблюдений; каталог `/tackle` включён в visual-matrix, narrow smoke и accessibility routes. Offline catalog/media audits и сохранение старых данных при сбое импорта проходят. TEMP-only query-plan gate для фильтра каталога и группировки сочетаний использует индексы и укладывается в 250 мс. Chromium подтвердил empty-state публичного каталога и отсутствие overflow на 320 px; обычный E2E пропускает bootstrap без явных переменных, отдельный bootstrap с токеном проходит. Остаются HTTP/browser acceptance для неоднозначных и многокомпонентных комплектов, ручной visual review и проверяемый счётчик по категориям либо явный `unknown`. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff. - [ ] **G09 · Приёмка и эксплуатация.** Добавлены fixture/regression tests для crosswalk, media roles, идемпотентных компонентов, пустых результатов и многокомпонентных наблюдений; каталог `/tackle` включён в visual-matrix, narrow smoke и accessibility routes. Offline catalog/media audits и сохранение старых данных при сбое импорта проходят. TEMP-only query-plan gate для фильтра каталога и группировки сочетаний использует индексы и укладывается в 250 мс. Chromium подтвердил empty-state публичного каталога и отсутствие overflow на 320 px; обычный E2E пропускает bootstrap без явных переменных, отдельный bootstrap с токеном проходит. Остаются HTTP/browser acceptance для неоднозначных и многокомпонентных комплектов, ручной visual review и проверяемый счётчик по категориям либо явный `unknown`. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff.