From b4585cee7e028a35f5ce0cf84a33157bfd212ec0 Mon Sep 17 00:00:00 2001 From: IK Date: Mon, 21 Sep 2026 19:43:17 +0700 Subject: [PATCH] feat: add discoverable rig catalog --- apps/api/app/routers/catalog.py | 32 +++++++++++++++++++++++---- apps/api/app/schemas.py | 18 +++++++++++++++ apps/web/src/lib/api.ts | 2 ++ apps/web/src/pages/tackle/index.astro | 12 +++++++--- docs/ROADMAP.md | 2 +- 5 files changed, 58 insertions(+), 8 deletions(-) diff --git a/apps/api/app/routers/catalog.py b/apps/api/app/routers/catalog.py index cb0f62f..ed1329e 100644 --- a/apps/api/app/routers/catalog.py +++ b/apps/api/app/routers/catalog.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import selectinload from ..dependencies import Db from ..models import Bait, CatchReport, Fish, ModerationStatus, Rig, Spot, TackleItem, Waterbody -from ..schemas import BaitOut, FishOut, PaginatedTackleItemOut, RigOut, TackleItemOut, WaterbodyOut +from ..schemas import BaitOut, FishOut, PaginatedRigOut, PaginatedTackleItemOut, RigOut, RigSummaryOut, TackleItemOut, WaterbodyOut router = APIRouter() @@ -34,6 +34,12 @@ def _item_missing_fields(item: TackleItem) -> list[str]: ) if value is None] +def _rig_missing_fields(rig: Rig) -> list[str]: + return [field for field, value in ( + ("source_url", rig.source_url), ("source_checked_at", rig.source_checked_at), + ) if value is None] + + @router.get("/api/v1/tackle/items", response_model=PaginatedTackleItemOut) def tackle_items( db: Db, @@ -69,14 +75,32 @@ def tackle_item(item_id: UUID, db: Db) -> TackleItemOut: return TackleItemOut.model_validate(item).model_copy(update={"missing_fields": _item_missing_fields(item)}) +@router.get("/api/v1/tackle/rigs", response_model=PaginatedRigOut) +def tackle_rigs( + db: Db, + limit: int = Query(48, ge=1, le=100), + offset: int = Query(0, ge=0), +) -> PaginatedRigOut: + query = select(Rig).options(selectinload(Rig.components)) + total = db.scalar(select(func.count(Rig.id))) or 0 + rigs = list(db.scalars(query.order_by(Rig.name, Rig.id).offset(offset).limit(limit))) + return PaginatedRigOut( + items=[RigSummaryOut( + id=rig.id, name=rig.name, source_system=rig.source_system, + source_external_id=rig.source_external_id, source_url=rig.source_url, + source_checked_at=rig.source_checked_at, missing_fields=_rig_missing_fields(rig), + component_count=len(rig.components), + ) for rig in rigs], + total=total, limit=limit, offset=offset, + ) + + @router.get("/api/v1/tackle/rigs/{rig_id}", response_model=RigOut) def rig_detail(rig_id: UUID, db: Db) -> RigOut: rig = db.scalar(select(Rig).options(selectinload(Rig.components)).where(Rig.id == rig_id)) if rig is None: raise HTTPException(status_code=404, detail="rig not found") - missing = [field for field, value in ( - ("source_url", rig.source_url), ("source_checked_at", rig.source_checked_at), - ) if value is None] + missing = _rig_missing_fields(rig) return RigOut( id=rig.id, name=rig.name, source_system=rig.source_system, source_external_id=rig.source_external_id, source_url=rig.source_url, diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index 52f2626..6a8c0cd 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -63,6 +63,24 @@ class PaginatedTackleItemOut(BaseModel): offset: int +class RigSummaryOut(BaseModel): + id: UUID + name: str + source_system: str | None + source_external_id: str | None + source_url: str | None + source_checked_at: datetime | None + missing_fields: list[str] = Field(default_factory=list) + component_count: int + + +class PaginatedRigOut(BaseModel): + items: list[RigSummaryOut] + total: int + limit: int + offset: int + + class RigComponentOut(BaseModel): id: UUID role: str diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index abdc00f..079d2da 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -22,6 +22,8 @@ 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 RigSummary = { id: string; name: string; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[]; component_count: number }; +export type PaginatedRigs = { items: RigSummary[]; total: number; limit: number; offset: number }; export type TackleCombination = { role: string; value: string; tackle_item_id: string | null; rig_id: string | null; 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 = { diff --git a/apps/web/src/pages/tackle/index.astro b/apps/web/src/pages/tackle/index.astro index 178579b..878751a 100644 --- a/apps/web/src/pages/tackle/index.astro +++ b/apps/web/src/pages/tackle/index.astro @@ -5,7 +5,7 @@ import StatePanel from "../../components/StatePanel.astro"; import Pagination from "../../components/Pagination.astro"; import TackleGlyph from "../../components/TackleGlyph.astro"; import DataPassport from "../../components/DataPassport.astro"; -import { api, type PaginatedTackleItems } from "../../lib/api"; +import { api, type PaginatedRigs, type PaginatedTackleItems } from "../../lib/api"; const params = Astro.url.searchParams; const category = params.get("category") ?? ""; @@ -18,8 +18,13 @@ const query = new URLSearchParams({ limit: String(limit), offset: String(offset) if (category) query.set("category", category); if (brand) query.set("brand", brand); if (family) query.set("family", family); -let result: PaginatedTackleItems = { items: [], total: 0, limit, offset }, unavailable = false; -try { result = await api(`/api/v1/tackle/items?${query}`); } catch { unavailable = true; } +let result: PaginatedTackleItems = { items: [], total: 0, limit, offset }, rigs: PaginatedRigs = { items: [], total: 0, limit, offset }, unavailable = false, rigsUnavailable = false; +const [itemsResult, rigsResult] = await Promise.allSettled([ + api(`/api/v1/tackle/items?${query}`), + api(`/api/v1/tackle/rigs?limit=${limit}&offset=${offset}`), +]); +if (itemsResult.status === "fulfilled") result = itemsResult.value; else unavailable = true; +if (rigsResult.status === "fulfilled") rigs = rigsResult.value; else rigsUnavailable = true; if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); } const categoryLabels: Record = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Монтаж", float: "Поплавок", sinker: "Груз", other: "Другое" }; const filterParams = new URLSearchParams(); @@ -38,5 +43,6 @@ if (family) filterParams.set("family", family); Сочетания снастей {unavailable ? : result.items.length ?
{result.items.map(item => {categoryLabels[item.category] ?? item.category}{item.name}{[item.brand, item.family].filter(Boolean).join(" · ") || "Характеристики уточняются"}
)}
: } + {!unavailable &&
Сборки снастей

Монтажи

{rigs.total} всего
{rigsUnavailable ? : rigs.items.length ? : }
} {!unavailable && } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 13ac567..d055c77 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -71,7 +71,7 @@ - [ ] **G03 · Модель и provenance.** Добавлены отдельные `tackle_item`, `rig` и `rig_component` с миграцией `0021`; legacy `bait` и `catch_report.bait_id` не изменялись. `tackle_item` хранит `category`, `subcategory`, `brand`, `family`, `unlock_level`, source identity, timestamp и `raw_payload`; `rig_component` хранит роль, порядок, исходное значение и optional canonical item. Связи с catch-потоком добавлены в G05; безопасный backfill остаётся только после реального crosswalk. - [ ] **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-routes `/tackle/items/:id` и `/tackle/rigs/:id` с 404/unavailable-различением, паспортом данных и явным списком недостающих полей; CatchList показывает ordered-компоненты и ведёт в canonical detail снасти или монтажа только при наличии соответствующего ID, а unresolved raw value оставляет текстом. +- [ ] **G06 · API и публичный каталог.** Добавлены пагинированные `/api/v1/tackle/items` и `/api/v1/tackle/rigs` с фильтрами предметов по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Публичный каталог теперь показывает обнаруживаемые карточки снастей и монтажей, имеет detail-routes `/tackle/items/:id` и `/tackle/rigs/:id` с 404/unavailable-различением, паспортом данных и явным списком недостающих полей; CatchList показывает ordered-компоненты и ведёт в canonical detail снасти или монтажа только при наличии соответствующего ID, а unresolved raw value оставляет текстом. - [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Публичный `/tackle/analytics` показывает и сохраняет фильтры, имеет clean reset-link, выборку, независимых игроков и раздельные `recommendation`/`insufficient_data` states без рейтинга эффективности. Добавлен объяснимый `freshness_score` с half-life `12.5` часа и контрактом `0..100`: он не меняет raw-счётчики и пороги, но стабильно ставит более свежую равную выборку выше старой и виден на карточке. При однозначной canonical-привязке аналитика ведёт в карточку снасти или монтажа, а при конфликте идентификаторов сохраняет 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 и атомарное продвижение сохраняются. Публичный и admin UI используют единый словарь человекочитаемых role labels с безопасным fallback для будущих ролей; admin-карточки дополнительно получают `data-media-role` для проверяемой инспекции, unit-регрессия словаря проходит. Остаётся провести реальный 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.