feat: add explicit media review workflow
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s

This commit is contained in:
ik
2026-09-12 15:55:39 +07:00
parent 26724c7675
commit 215af73388
5 changed files with 54 additions and 4 deletions
+1 -1
View File
@@ -30,7 +30,7 @@
- [x] **B08 · Семейства силуэтов рыб.** Три случайных hash-варианта заменены классификатором и отдельными формами `pike`, `salmonid`, `cyprinid`, `perch`, `catfish`, `eel`, `flatfish`, `marine`, плюс честный `generic`. Компонент допускает ручное переопределение family; название остаётся главным идентификатором. - [x] **B08 · Семейства силуэтов рыб.** Три случайных hash-варианта заменены классификатором и отдельными формами `pike`, `salmonid`, `cyprinid`, `perch`, `catfish`, `eel`, `flatfish`, `marine`, плюс честный `generic`. Компонент допускает ручное переопределение family; название остаётся главным идентификатором.
- [x] **B09 · Глифы снастей и приманок.** Добавлены SVG-глифы `spinner`, `wobbler`, `soft`, `boilie`, `worm`, `rig`, `unknown` и стабильная палитра по normalized name. Классификация срабатывает только по явным словам; глиф сопровождает текст в activity, лидере, уловах, рекордах и списке лучших приманок, не выдавая категорию за точную модель. - [x] **B09 · Глифы снастей и приманок.** Добавлены SVG-глифы `spinner`, `wobbler`, `soft`, `boilie`, `worm`, `rig`, `unknown` и стабильная палитра по normalized name. Классификация срабатывает только по явным словам; глиф сопровождает текст в activity, лидере, уловах, рекордах и списке лучших приманок, не выдавая категорию за точную модель.
- [x] **B10 · Визуальные отпечатки водоёмов.** Для каждого slug воспроизводимо выбираются один из восьми береговых контуров, число волн, положение точки и двухсимвольный индекс. Знак используется в каталоге и detail-hero; это явно абстрактный отпечаток, а не карта или игровая география. - [x] **B10 · Визуальные отпечатки водоёмов.** Для каждого slug воспроизводимо выбираются один из восьми береговых контуров, число волн, положение точки и двухсимвольный индекс. Знак используется в каталоге и detail-hero; это явно абстрактный отпечаток, а не карта или игровая география.
- [ ] **B11 · Разрешённый media pipeline — в работе.** Получено явное разрешение на локальный сбор. Добавлены allowlist, индекс кандидатов, provenance-manifest, очередь и content-addressed хранение без hotlink; первый официальный обход нашёл 69 кандидатов. Downloader проверяет реальный формат/MIME, лимит 40 Мп, сохраняет dimensions и не блокирует очередь постоянными `missing`/`blocked`/`invalid`. Осталось постепенно скачать очередь с общим cooldown 30 минут и вручную подтвердить соответствия сущностям перед публикацией. - [ ] **B11 · Разрешённый media pipeline — в работе.** Получено явное разрешение на локальный сбор. Добавлены allowlist, индекс кандидатов, provenance-manifest, очередь и content-addressed хранение без hotlink; первый официальный обход нашёл 69 кандидатов. Downloader проверяет реальный формат/MIME, лимит 40 Мп, сохраняет dimensions и не блокирует очередь постоянными `missing`/`blocked`/`invalid`. Review допускает `approved` только для сохранённого файла с типом и каноническим ключом сущности; доступен явный `rejected`. Осталось постепенно скачать очередь с общим cooldown 30 минут и проверить соответствия перед публикацией.
- [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений. - [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений.
- [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`. - [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`.
- [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания. - [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
+1 -1
View File
@@ -56,7 +56,7 @@
Fallback строится на собственных лёгких SVG: осмысленные семейства силуэтов рыб, типовые глифы снастей и детерминированные абстрактные отпечатки водоёмов. Текстовое название всегда остаётся основным идентификатором; абстрактный контур водоёма не называется картой, а глиф приманки — изображением конкретной модели. Fallback строится на собственных лёгких SVG: осмысленные семейства силуэтов рыб, типовые глифы снастей и детерминированные абстрактные отпечатки водоёмов. Текстовое название всегда остаётся основным идентификатором; абстрактный контур водоёма не называется картой, а глиф приманки — изображением конкретной модели.
С 12 сентября 2026 года разрешён локальный сбор медиаресурсов. `python -m rf4_research.media_cli URL` делает один учтённый HTML-запрос и добавляет кандидатов в `data/media/manifest.json`; `--download-one` сохраняет ровно один ожидающий asset по SHA-256 в `data/media/files/`. Оба режима используют общий cooldown площадки. Перед записью проверяются raster-формат, совпадение MIME и предел 40 Мп; manifest получает размеры, объём и время загрузки. Постоянные 404/410, запрет доступа и невалидный файл получают отдельные статусы и не запирают начало очереди. Статус `queued` или `stored` не означает соответствие сущности или разрешение на публичный вывод: до ручной проверки файл остаётся исследовательским материалом. С 12 сентября 2026 года разрешён локальный сбор медиаресурсов. `python -m rf4_research.media_cli URL` делает один учтённый HTML-запрос и добавляет кандидатов в `data/media/manifest.json`; `--download-one` сохраняет ровно один ожидающий asset по SHA-256 в `data/media/files/`. Оба режима используют общий cooldown площадки. Перед записью проверяются raster-формат, совпадение MIME и предел 40 Мп; manifest получает размеры, объём и время загрузки. Постоянные 404/410, запрет доступа и невалидный файл получают отдельные статусы и не запирают начало очереди. Статус `queued` или `stored` не означает соответствие сущности или разрешение на публичный вывод. Ручная команда с `--review-url`, `--decision`, `--entity-type` и `--entity-key` переводит сохранённый файл в `approved`; неподходящий материал явно получает `rejected`.
Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU. Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU.
+25
View File
@@ -95,6 +95,31 @@ def reclassify_manifest(path: Path) -> dict:
return manifest return manifest
def review_asset(
path: Path, *, asset_url: str, decision: str, entity_type: str | None = None,
entity_key: str | None = None, note: str | None = None,
) -> dict:
if decision not in {"approved", "rejected"}:
raise ValueError("decision must be approved or rejected")
manifest = json.loads(path.read_text(encoding="utf-8"))
item = next((asset for asset in manifest.get("assets", []) if asset["asset_url"] == asset_url), None)
if item is None:
raise ValueError("asset URL is not present in manifest")
if decision == "approved":
if item.get("status") != "stored":
raise ValueError("only a stored asset can be approved")
if entity_type not in {"fish", "waterbody", "tackle", "reference"} or not entity_key:
raise ValueError("approved asset requires entity type and canonical key")
item.update({"entity_type": entity_type, "entity_key": entity_key})
item.update({
"status": decision,
"reviewed_at": datetime.now(timezone.utc).isoformat(),
"review_note": note or None,
})
path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return item
def inspect_image(body: bytes) -> tuple[int, int, str]: def inspect_image(body: bytes) -> tuple[int, int, str]:
try: try:
with Image.open(io.BytesIO(body)) as image: with Image.open(io.BytesIO(body)) as image:
+12 -1
View File
@@ -8,7 +8,7 @@ import urllib.error
import urllib.request import urllib.request
from .community_cli import USER_AGENT, _StrictRedirectHandler, _validate_url_before_io, check_and_reserve, fetch_html, fetch_site_key from .community_cli import USER_AGENT, _StrictRedirectHandler, _validate_url_before_io, check_and_reserve, fetch_html, fetch_site_key
from .media_assets import extract_media_candidates, merge_manifest, reclassify_manifest, store_asset from .media_assets import extract_media_candidates, merge_manifest, reclassify_manifest, review_asset, store_asset
DEFAULT_ROOT = Path("data/media") DEFAULT_ROOT = Path("data/media")
@@ -52,10 +52,21 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("url", nargs="?") parser.add_argument("url", nargs="?")
parser.add_argument("--download-one", action="store_true", help="Store one queued asset while respecting site cooldown") parser.add_argument("--download-one", action="store_true", help="Store one queued asset while respecting site cooldown")
parser.add_argument("--reclassify", action="store_true", help="Reapply current conservative classifier without network access") parser.add_argument("--reclassify", action="store_true", help="Reapply current conservative classifier without network access")
parser.add_argument("--review-url", help="Review an asset already present in the manifest")
parser.add_argument("--decision", choices=("approved", "rejected"))
parser.add_argument("--entity-type", choices=("fish", "waterbody", "tackle", "reference"))
parser.add_argument("--entity-key")
parser.add_argument("--note")
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT) parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
parser.add_argument("--state-file", type=Path, default=Path(".cache/community-fetch-state.json")) parser.add_argument("--state-file", type=Path, default=Path(".cache/community-fetch-state.json"))
args = parser.parse_args(argv) args = parser.parse_args(argv)
try: try:
if args.review_url:
if not args.decision:
parser.error("--decision is required with --review-url")
item = review_asset(args.root / "manifest.json", asset_url=args.review_url, decision=args.decision, entity_type=args.entity_type, entity_key=args.entity_key, note=args.note)
print(f"{item['status']} {item['asset_url']}")
return 0
if args.reclassify: if args.reclassify:
manifest = reclassify_manifest(args.root / "manifest.json") manifest = reclassify_manifest(args.root / "manifest.json")
print(f"reclassified {len(manifest['assets'])} candidates") print(f"reclassified {len(manifest['assets'])} candidates")
+15 -1
View File
@@ -5,7 +5,7 @@ import io
import pytest import pytest
from PIL import Image from PIL import Image
from rf4_research.media_assets import extract_media_candidates, inspect_image, merge_manifest, store_asset from rf4_research.media_assets import extract_media_candidates, inspect_image, merge_manifest, review_asset, store_asset
def test_extracts_and_classifies_unique_https_media() -> None: def test_extracts_and_classifies_unique_https_media() -> None:
@@ -45,3 +45,17 @@ def test_image_inspection_rejects_invalid_body_and_mime_mismatch(tmp_path: Path)
Image.new("RGB", (1, 1)).save(image, format="PNG") Image.new("RGB", (1, 1)).save(image, format="PNG")
with pytest.raises(ValueError, match="MIME mismatch"): with pytest.raises(ValueError, match="MIME mismatch"):
store_asset(tmp_path, image.getvalue(), content_type="image/jpeg", source_url="https://example.test/a.jpg") store_asset(tmp_path, image.getvalue(), content_type="image/jpeg", source_url="https://example.test/a.jpg")
def test_review_requires_stored_asset_and_canonical_mapping(tmp_path: Path) -> None:
item = extract_media_candidates('<img src="/fish/pike.png" alt="Щука">', source_page="https://example.test")[0]
path = tmp_path / "manifest.json"
manifest = merge_manifest(path, [item])
with pytest.raises(ValueError, match="stored"):
review_asset(path, asset_url=item.asset_url, decision="approved", entity_type="fish", entity_key="pike")
manifest["assets"][0]["status"] = "stored"
path.write_text(__import__("json").dumps(manifest), encoding="utf-8")
with pytest.raises(ValueError, match="canonical key"):
review_asset(path, asset_url=item.asset_url, decision="approved", entity_type="fish")
reviewed = review_asset(path, asset_url=item.asset_url, decision="approved", entity_type="fish", entity_key="pike", note="matched by name")
assert (reviewed["status"], reviewed["entity_key"]) == ("approved", "pike")