From e4e10bb4e9bad63eb3c8a82a5ef95043b88e38de Mon Sep 17 00:00:00 2001 From: IK Date: Mon, 21 Sep 2026 18:02:23 +0700 Subject: [PATCH] feat: add freshness decay to tackle analytics --- apps/api/app/routers/analytics.py | 16 +- apps/api/app/schemas.py | 1 + apps/api/openapi.json | 772 +++++++++++++++++++++- apps/api/tests/test_analytics.py | 30 + apps/web/src/lib/api.ts | 2 +- apps/web/src/pages/tackle/analytics.astro | 2 +- docs/ROADMAP.md | 2 +- 7 files changed, 819 insertions(+), 6 deletions(-) diff --git a/apps/api/app/routers/analytics.py b/apps/api/app/routers/analytics.py index 32b7ebc..4d00d8f 100644 --- a/apps/api/app/routers/analytics.py +++ b/apps/api/app/routers/analytics.py @@ -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)) diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index dbbe51e..f708210 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -118,6 +118,7 @@ class TackleCombinationOut(BaseModel): catches: int unique_players: int last_seen_at: datetime + freshness_score: int status: str explanation: str diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 17d7545..b50648c 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -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", diff --git a/apps/api/tests/test_analytics.py b/apps/api/tests/test_analytics.py index f91663f..5da9ec5 100644 --- a/apps/api/tests/test_analytics.py +++ b/apps/api/tests/test_analytics.py @@ -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() diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 8d6f5cc..2efa8da 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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[]; diff --git a/apps/web/src/pages/tackle/analytics.astro b/apps/web/src/pages/tackle/analytics.astro index c35d4a3..702c45f 100644 --- a/apps/web/src/pages/tackle/analytics.astro +++ b/apps/web/src/pages/tackle/analytics.astro @@ -36,5 +36,5 @@ const roleLabels: Record = { lure: "Приманка", bait: "Н {(waterbody || fish || method || hours !== 72) && Сбросить}

Порог рекомендации: минимум 3 наблюдения от 2 независимых игроков. Это не рейтинг снасти и не гарантия улова.

- {unavailable ? : combinations.length ?
{combinations.map(item =>
{roleLabels[item.role] ?? item.role}{item.status === "recommendation" ? "Рекомендация" : "Недостаточно данных"}

{item.value}

Наблюдения
{item.catches}
Игроки
{item.unique_players}
Последнее
{ago(item.last_seen_at)}

{item.explanation}

)}
:
} + {unavailable ? : combinations.length ?
{combinations.map(item =>
{roleLabels[item.role] ?? item.role}{item.status === "recommendation" ? "Рекомендация" : "Недостаточно данных"}

{item.value}

Наблюдения
{item.catches}
Игроки
{item.unique_players}
Свежесть выборки
{item.freshness_score}%
Последнее
{ago(item.last_seen_at)}

{item.explanation}

)}
:
} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f721db1..ea5eb60 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -72,7 +72,7 @@ - [ ] **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. - [ ] **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-кандидатов без автоматической публикации. - [ ] **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.