From 86515a9da1c8cadaf3980cd0b28b549902f2a19a Mon Sep 17 00:00:00 2001 From: IK Date: Mon, 21 Sep 2026 08:00:15 +0700 Subject: [PATCH] feat: expose reviewed media roles --- apps/api/app/media_catalog.py | 1 + apps/api/tests/test_api.py | 6 ++++++ apps/web/src/components/EntityMedia.astro | 3 ++- apps/web/src/lib/api.ts | 3 ++- apps/web/src/lib/media.ts | 9 +++++---- apps/web/src/pages/waterbodies/[slug].astro | 2 +- apps/web/tests/unit/presentation.test.ts | 10 ++++++++++ docs/ROADMAP.md | 2 +- 8 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/api/app/media_catalog.py b/apps/api/app/media_catalog.py index 332ca3d..6554428 100644 --- a/apps/api/app/media_catalog.py +++ b/apps/api/app/media_catalog.py @@ -22,6 +22,7 @@ def published_assets(entity_type: str | None = None) -> list[dict]: "id": item["sha256"], "entity_type": item.get("entity_type"), "entity_key": item.get("entity_key"), + "media_role": item.get("media_role"), "label": item.get("label"), "width": item.get("width"), "height": item.get("height"), diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 6087e0d..be05f30 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -102,6 +102,12 @@ def test_published_media_catalog_and_content_addressed_file() -> None: assert client.get("/api/v1/media/assets/not-a-hash").status_code == 404 +def test_published_media_catalog_exposes_reviewed_media_role() -> None: + catalog = client.get("/api/v1/media/catalog?entity_type=fish") + assert catalog.status_code == 200 + assert all("media_role" in item for item in catalog.json()) + + def test_review_queue_filters_before_pagination() -> None: with Session(engine) as db: stage_observations(db, [{ diff --git a/apps/web/src/components/EntityMedia.astro b/apps/web/src/components/EntityMedia.astro index 82d3bdd..16f8c61 100644 --- a/apps/web/src/components/EntityMedia.astro +++ b/apps/web/src/components/EntityMedia.astro @@ -2,6 +2,7 @@ import SourceBadge from "./SourceBadge.astro"; import type { MediaAsset } from "../lib/api"; const { asset, compact = false, sourceLink = false } = Astro.props as { asset: MediaAsset; compact?: boolean; sourceLink?: boolean }; +const roleLabels: Record = { waterbody_cover: "Заставка водоёма", waterbody_map: "Карта водоёма", waterbody_depth_map: "Карта глубин", waterbody_screenshot: "Скриншот водоёма", tackle_card: "Карточка снасти", tackle_detail: "Деталь снасти", rig_diagram: "Схема монтажа", tackle_screenshot: "Скриншот снасти" }; ---
@@ -11,5 +12,5 @@ const { asset, compact = false, sourceLink = false } = Astro.props as { asset: M })} {asset.label -
{asset.label ?? "Справочный материал"}
+
{asset.label ?? "Справочный материал"}{asset.media_role && {roleLabels[asset.media_role] ?? asset.media_role}}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 4ac941c..75d1286 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -30,7 +30,8 @@ export type PublicObservation = { id: string; source_system: string; source_name export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null }; export type SourceStatus = { source_system: string; name: string; status: "healthy" | "stale" | "temporarily_limited" | "source_changed" | "waiting" | "disabled"; last_started_at: string | null; last_success_at: string | null; observations: number }; export type MediaVariant = { role: "card" | "detail"; format: "webp" | "avif"; width: number; height: number; url: string }; -export type MediaAsset = { id: string; entity_type: "fish" | "waterbody" | "tackle" | "reference"; entity_key: string; label: string | null; width: number; height: number; content_type: string; image_url: string; source_system: string; source_url: string; variants?: MediaVariant[] }; +export type MediaRole = "waterbody_cover" | "waterbody_map" | "waterbody_depth_map" | "waterbody_screenshot" | "tackle_card" | "tackle_detail" | "rig_diagram" | "tackle_screenshot"; +export type MediaAsset = { id: string; entity_type: "fish" | "waterbody" | "tackle" | "reference"; entity_key: string; media_role?: MediaRole | null; label: string | null; width: number; height: number; content_type: string; image_url: string; source_system: string; source_url: string; variants?: MediaVariant[] }; export const spotPath = (item: Pick) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`; diff --git a/apps/web/src/lib/media.ts b/apps/web/src/lib/media.ts index ce7e7b3..fba8be3 100644 --- a/apps/web/src/lib/media.ts +++ b/apps/web/src/lib/media.ts @@ -1,4 +1,4 @@ -import type { MediaAsset } from "./api"; +import type { MediaAsset, MediaRole } from "./api"; const tokens = (value: string) => value .toLocaleLowerCase("ru") @@ -9,13 +9,14 @@ const tokens = (value: string) => value .split(/\s+/) .filter(Boolean); -export const findMediaByLabel = (assets: MediaAsset[], label: string): MediaAsset | undefined => { +export const findMediaByLabel = (assets: MediaAsset[], label: string, allowedRoles?: readonly MediaRole[]): MediaAsset | undefined => { + const filtered = allowedRoles ? assets.filter((asset) => asset.media_role && allowedRoles.includes(asset.media_role)) : assets; const wanted = tokens(label); - const exact = assets.filter((asset) => tokens(asset.label ?? "").join(" ") === wanted.join(" ")); + const exact = filtered.filter((asset) => tokens(asset.label ?? "").join(" ") === wanted.join(" ")); if (exact.length === 1) return exact[0]; const wantedSet = new Set(wanted); - const candidates = assets.filter((asset) => { + const candidates = filtered.filter((asset) => { const available = new Set(tokens(asset.label ?? "")); return wanted.every((token) => available.has(token)) || [...available].every((token) => wantedSet.has(token)); }); diff --git a/apps/web/src/pages/waterbodies/[slug].astro b/apps/web/src/pages/waterbodies/[slug].astro index 68ba258..413f261 100644 --- a/apps/web/src/pages/waterbodies/[slug].astro +++ b/apps/web/src/pages/waterbodies/[slug].astro @@ -24,7 +24,7 @@ if (unavailable) { } if (!water && !unavailable) Astro.response.status = 404; const fishes = [...new Map(items.map(item => [item.fish_slug, item.fish])).entries()]; -const image = water ? findMediaByLabel(media, water.name_ru) : undefined; +const image = water ? findMediaByLabel(media, water.name_ru, ["waterbody_cover", "waterbody_map", "waterbody_depth_map"]) : undefined; const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Что ловить на ${water.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water.slug}` } : null; --- diff --git a/apps/web/tests/unit/presentation.test.ts b/apps/web/tests/unit/presentation.test.ts index 74a8ffe..40f09ca 100644 --- a/apps/web/tests/unit/presentation.test.ts +++ b/apps/web/tests/unit/presentation.test.ts @@ -7,6 +7,7 @@ import { tackleVisualKind, tackleVisualTone } from "../../src/lib/tackle-visuals import { waterbodyVisual } from "../../src/lib/waterbody-visuals.ts"; import { pageHref, pageWindow } from "../../src/lib/pagination.ts"; import { adminEndsSession, adminErrorMessage } from "../../src/lib/admin-errors.ts"; +import { findMediaByLabel } from "../../src/lib/media.ts"; test("activity levels share one complete 0-100 scale", () => { assert.deepEqual( @@ -62,6 +63,15 @@ test("waterbody fingerprints are stable, bounded and entity-specific", () => { assert.match(kuori.code, /^[0-9A-Z]{2}$/); }); +test("waterbody media lookup only accepts reviewed roles when constrained", () => { + const assets = [ + { id: "screenshot", entity_type: "waterbody", entity_key: "kuori", media_role: "waterbody_screenshot", label: "Куори", width: 1, height: 1, content_type: "image/webp", image_url: "", source_system: "rf4db", source_url: "" }, + { id: "map", entity_type: "waterbody", entity_key: "kuori", media_role: "waterbody_map", label: "Куори", width: 1, height: 1, content_type: "image/webp", image_url: "", source_system: "rf4db", source_url: "" }, + ] as const; + assert.equal(findMediaByLabel([...assets], "Куори", ["waterbody_cover", "waterbody_map"])?.id, "map"); + assert.equal(findMediaByLabel([...assets], "Куори", ["waterbody_cover"]), undefined); +}); + test("45 records are reachable across three stable pages", () => { const pages = [pageWindow(45, 20, 0), pageWindow(45, 20, 20), pageWindow(45, 20, 40)]; assert.deepEqual(pages.map(page => [page.start, page.end]), [[1, 20], [21, 40], [41, 45]]); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6924887..898dae0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -55,7 +55,7 @@ - [ ] **W04 · Классификация изображений.** Добавлены допустимые роли `waterbody_cover`, `waterbody_map`, `waterbody_depth_map`, `waterbody_screenshot` и проверка их назначения только через review для canonical waterbody. Кандидаты по-прежнему не получают роль автоматически. Осталось наполнить очередь detail-изображениями и провести contact-sheet review с проверкой dimensions, MIME, SHA-256, соответствия названию и источника. - [ ] **W05 · Crosswalk источников.** Добавлен offline-конструктор консервативных предложений: нормализуются только точные имена/алиасы, неоднозначные и unmatched строки не получают canonical key; отсутствие ID выдаётся лишь диагностикой и не считается удалением. RF4MAP/RF4-STAT directory parsers теперь преобразуются в crosswalk identities без переноса метрик или автоматической привязки. Осталось подать полный набор реальных RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты, включая три ранее отмеченных отсутствующих RF4MAP объекта. - [ ] **W06 · Координаты и точность.** В `ExternalObservation`, staging, provenance опубликованного улова и публичных activity/spot-ответах добавлены `coordinate_raw`, `coordinate_precision = exact | approximate | area | missing` и список источников; RF4DB/RF4-STAT/RF4MAP/RF4 Posts parsers теперь протягивают исходную строку, включая строки без доступных числовых координат. Исправлена spot detail: точность (`точные`/`приблизительные`/`район`/`не указаны`) теперь видна рядом с координатами и покрыта smoke regression. In-app Chromium и focused smoke подтвердили `/spots/vyunok-321x654` на desktop/mobile без overflow; browser regression для отсутствующей точки проверяет недоступное состояние без ложных координат, а API regression параметризован по всем четырём precision-состояниям. Осталась полная browser QA матрица для всех precision/error-состояний. -- [ ] **W07 · Публичный API и страницы.** API и detail-страница теперь выводят подтверждённые detail-факты водоёма: описание, уровень, количество видов, алиасы, число ссылок на точки и отдельный счётчик изображений-кандидатов; источники и непроверенные media не смешиваются. Осталось подключить только проверенные waterbody media roles и завершить browser QA, включая различение карты, заставки и абстрактного отпечатка. +- [ ] **W07 · Публичный API и страницы.** API и detail-страница теперь выводят подтверждённые detail-факты водоёма: описание, уровень, количество видов, алиасы, число ссылок на точки и отдельный счётчик изображений-кандидатов; источники и непроверенные media не смешиваются. Public media API теперь передаёт reviewed `media_role`, waterbody detail принимает только `waterbody_cover`/`waterbody_map`/`waterbody_depth_map`, а UI подписывает карту/заставку/скриншот текстом. Осталось завершить browser QA для реальных role-вариантов и не показывать abstract fingerprint как внешнее изображение. - [ ] **W08 · Приёмка и эксплуатация.** Fixture-based parser tests, offline catalog/media audit и повторный idempotent import подтверждены: `created=0 updated=19`, в PostgreSQL ровно `19` RF4DB waterbodies плюс `2` legacy-записи, media audit сообщает `issues=[]` и `orphaned_files=[]`. In-app Chromium и focused smoke покрывают desktop/mobile основные страницы; 20.09 targeted waterbody E2E подтвердил detail с пустой активностью, not-found waterbody и not-found waterbody/fish pair, а spot regression — unavailable state без ложной координаты; полный web E2E прошёл `27 passed, 1 skipped`, visual-matrix также прошёл. Остаются полная browser QA error/empty matrix и закрытие внешних detail-данных. Сетевые тесты не выполнять; регулярный импорт оставить opt-in и под общим cooldown/backoff. ### Каталог снастей, наживок и прочей оснастки