feat: expose reviewed media roles

This commit is contained in:
ik
2026-09-21 08:00:15 +07:00
parent 81af8a02b6
commit 86515a9da1
8 changed files with 28 additions and 8 deletions
+1
View File
@@ -22,6 +22,7 @@ def published_assets(entity_type: str | None = None) -> list[dict]:
"id": item["sha256"], "id": item["sha256"],
"entity_type": item.get("entity_type"), "entity_type": item.get("entity_type"),
"entity_key": item.get("entity_key"), "entity_key": item.get("entity_key"),
"media_role": item.get("media_role"),
"label": item.get("label"), "label": item.get("label"),
"width": item.get("width"), "width": item.get("width"),
"height": item.get("height"), "height": item.get("height"),
+6
View File
@@ -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 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: def test_review_queue_filters_before_pagination() -> None:
with Session(engine) as db: with Session(engine) as db:
stage_observations(db, [{ stage_observations(db, [{
+2 -1
View File
@@ -2,6 +2,7 @@
import SourceBadge from "./SourceBadge.astro"; import SourceBadge from "./SourceBadge.astro";
import type { MediaAsset } from "../lib/api"; import type { MediaAsset } from "../lib/api";
const { asset, compact = false, sourceLink = false } = Astro.props as { asset: MediaAsset; compact?: boolean; sourceLink?: boolean }; const { asset, compact = false, sourceLink = false } = Astro.props as { asset: MediaAsset; compact?: boolean; sourceLink?: boolean };
const roleLabels: Record<string, string> = { waterbody_cover: "Заставка водоёма", waterbody_map: "Карта водоёма", waterbody_depth_map: "Карта глубин", waterbody_screenshot: "Скриншот водоёма", tackle_card: "Карточка снасти", tackle_detail: "Деталь снасти", rig_diagram: "Схема монтажа", tackle_screenshot: "Скриншот снасти" };
--- ---
<figure class:list={["entity-media", { "entity-media--compact": compact }]}> <figure class:list={["entity-media", { "entity-media--compact": compact }]}>
<span class="entity-media__frame"><picture> <span class="entity-media__frame"><picture>
@@ -11,5 +12,5 @@ const { asset, compact = false, sourceLink = false } = Astro.props as { asset: M
})} })}
<img src={asset.image_url} alt={asset.label ?? "Иллюстрация RF4"} width={asset.width} height={asset.height} loading="lazy" decoding="async" /> <img src={asset.image_url} alt={asset.label ?? "Иллюстрация RF4"} width={asset.width} height={asset.height} loading="lazy" decoding="async" />
</picture></span> </picture></span>
<figcaption><SourceBadge source={asset.source_system} href={sourceLink ? asset.source_url : undefined} /><span>{asset.label ?? "Справочный материал"}</span></figcaption> <figcaption><SourceBadge source={asset.source_system} href={sourceLink ? asset.source_url : undefined} /><span>{asset.label ?? "Справочный материал"}</span>{asset.media_role && <small data-media-role={asset.media_role}>{roleLabels[asset.media_role] ?? asset.media_role}</small>}</figcaption>
</figure> </figure>
+2 -1
View File
@@ -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 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 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 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<Activity, "waterbody_slug" | "x" | "y">) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`; export const spotPath = (item: Pick<Activity, "waterbody_slug" | "x" | "y">) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`;
+5 -4
View File
@@ -1,4 +1,4 @@
import type { MediaAsset } from "./api"; import type { MediaAsset, MediaRole } from "./api";
const tokens = (value: string) => value const tokens = (value: string) => value
.toLocaleLowerCase("ru") .toLocaleLowerCase("ru")
@@ -9,13 +9,14 @@ const tokens = (value: string) => value
.split(/\s+/) .split(/\s+/)
.filter(Boolean); .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 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]; if (exact.length === 1) return exact[0];
const wantedSet = new Set(wanted); const wantedSet = new Set(wanted);
const candidates = assets.filter((asset) => { const candidates = filtered.filter((asset) => {
const available = new Set(tokens(asset.label ?? "")); const available = new Set(tokens(asset.label ?? ""));
return wanted.every((token) => available.has(token)) || [...available].every((token) => wantedSet.has(token)); return wanted.every((token) => available.has(token)) || [...available].every((token) => wantedSet.has(token));
}); });
+1 -1
View File
@@ -24,7 +24,7 @@ if (unavailable) {
} }
if (!water && !unavailable) Astro.response.status = 404; if (!water && !unavailable) Astro.response.status = 404;
const fishes = [...new Map(items.map(item => [item.fish_slug, item.fish])).entries()]; 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; const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Что ловить на ${water.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water.slug}` } : null;
--- ---
<Layout title={water ? `${water.name_ru} в RF4 — рыба и свежие точки` : "Водоём не найден — RF4 Spotter"} description={water ? `${water.name_ru} в Russian Fishing 4: свежие координаты, активные виды рыб, приманки и источники наблюдений.` : "Такого водоёма нет в каталоге RF4 Spotter."} noindex={!water || unavailable} structuredData={schema} errorPage={!water || unavailable}> <Layout title={water ? `${water.name_ru} в RF4 — рыба и свежие точки` : "Водоём не найден — RF4 Spotter"} description={water ? `${water.name_ru} в Russian Fishing 4: свежие координаты, активные виды рыб, приманки и источники наблюдений.` : "Такого водоёма нет в каталоге RF4 Spotter."} noindex={!water || unavailable} structuredData={schema} errorPage={!water || unavailable}>
+10
View File
@@ -7,6 +7,7 @@ import { tackleVisualKind, tackleVisualTone } from "../../src/lib/tackle-visuals
import { waterbodyVisual } from "../../src/lib/waterbody-visuals.ts"; import { waterbodyVisual } from "../../src/lib/waterbody-visuals.ts";
import { pageHref, pageWindow } from "../../src/lib/pagination.ts"; import { pageHref, pageWindow } from "../../src/lib/pagination.ts";
import { adminEndsSession, adminErrorMessage } from "../../src/lib/admin-errors.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", () => { test("activity levels share one complete 0-100 scale", () => {
assert.deepEqual( assert.deepEqual(
@@ -62,6 +63,15 @@ test("waterbody fingerprints are stable, bounded and entity-specific", () => {
assert.match(kuori.code, /^[0-9A-Z]{2}$/); 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", () => { test("45 records are reachable across three stable pages", () => {
const pages = [pageWindow(45, 20, 0), pageWindow(45, 20, 20), pageWindow(45, 20, 40)]; 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]]); assert.deepEqual(pages.map(page => [page.start, page.end]), [[1, 20], [21, 40], [41, 45]]);
+1 -1
View File
@@ -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, соответствия названию и источника. - [ ] **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 объекта. - [ ] **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-состояний. - [ ] **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. - [ ] **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.
### Каталог снастей, наживок и прочей оснастки ### Каталог снастей, наживок и прочей оснастки