chore: audit RF4 media quality
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-14 22:25:45 +07:00
parent b47a6cc366
commit 7d009c932f
5 changed files with 68 additions and 3 deletions
+3 -1
View File
@@ -48,9 +48,11 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL не завышают покрытие. На 14.09.2026 manifest содержит 704 записи: 456 approved, 227 duplicate, 10 queued и 11 invalid. Опубликованы 243 уникально подписанные рыбы из 252, все 149 найденных изображений снастей/приманок и 64 справочных материала; 10 оставшихся RF4DB URL будут загружены после cooldown. Карты 19 водоёмов ещё предстоит проиндексировать, а общий target снастей остаётся `null` до проверяемого полного счётчика. `python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL не завышают покрытие. На 14.09.2026 manifest содержит 704 записи: 456 approved, 227 duplicate, 10 queued и 11 invalid. Опубликованы 243 уникально подписанные рыбы из 252, все 149 найденных изображений снастей/приманок и 64 справочных материала; 10 оставшихся RF4DB URL будут загружены после cooldown. Карты 19 водоёмов ещё предстоит проиндексировать, а общий target снастей остаётся `null` до проверяемого полного счётчика.
`python -m rf4_research.media_cli --quality-report` выполняет offline-проверку разрешения опубликованных рыб. Текущий отчёт выявляет 227 PNG RF4MAP размером 48×48, которые увеличиваются в карточках до 180 px, и 16 WebP 1024×1024; для всех низких версий уже известны альтернативные RF4DB URL. План загрузки, сравнения, безопасного переключения и генерации производных размеров ведётся в B20–B25 ROADMAP.
`python -m rf4_research.media_cli --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Разрешённое media-окно загружается командой `--download-batch --batch-limit 40`: до 40 assets на домен под одной резервацией, затем 30 минут до нового batch. Блокировка/rate limit/сетевая ошибка останавливает домен сразу, три последовательных невалидных ответа — досрочно. Все файлы остаются в карантине до ручного review. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap. `python -m rf4_research.media_cli --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Разрешённое media-окно загружается командой `--download-batch --batch-limit 40`: до 40 assets на домен под одной резервацией, затем 30 минут до нового batch. Блокировка/rate limit/сетевая ошибка останавливает домен сразу, три последовательных невалидных ответа — досрочно. Все файлы остаются в карантине до ручного review. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap.
Актуальный внешний ориентир — 19 водоёмов и 252 вида рыб; локальная альфа пока содержит 2+2 сущности. Media-manifest включает 704 записи из RF4MAP, RF4DB и официального руководства: 24 одобрены, 417 сохранены для review, 227 являются альтернативными дубликатами, 25 новых рыб ожидают загрузки и 11 URL невалидны. В Git находятся 441 оригинал; подтверждённых entity-карт водоёмов пока нет. Полное число «снастей» не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками. Актуальный внешний ориентир — 19 водоёмов и 252 вида рыб; локальная альфа пока содержит 2+2 справочные сущности. Media-manifest включает 704 записи из RF4MAP, RF4DB и официального руководства: 456 опубликованы, 227 являются альтернативными дубликатами, 10 ожидают загрузки и 11 URL невалидны. Все 456 оригиналов находятся в Git; подтверждённых entity-карт водоёмов пока нет. Полное число «снастей» не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками.
Исследовательские RF4-ассеты в `data/media/files/` версионируются обычным Git вместе с `data/media/manifest.json`, чтобы клон репозитория был самодостаточным и не зависел от локального кэша. Это не относится к пользовательским скриншотам: они по-прежнему хранятся в MinIO/S3 и не попадают в Git. Исследовательские RF4-ассеты в `data/media/files/` версионируются обычным Git вместе с `data/media/manifest.json`, чтобы клон репозитория был самодостаточным и не зависел от локального кэша. Это не относится к пользовательским скриншотам: они по-прежнему хранятся в MinIO/S3 и не попадают в Git.
+6
View File
@@ -39,6 +39,12 @@
- [ ] **B17 · Полный каталог рыб RF4DB — финальная загрузка.** Разрешённый RF4DB дал все 252 подписанных кандидата; 227 совпадений с RF4MAP сохранены как provenance-only `duplicate`, 15 уникальных файлов скачаны, а 10 URL (9 уникальных названий) остаются в очереди после корректно остановленного TLS-сбоя. Все поддомены RF4DB объединены одним 30-минутным cooldown. Завершить следующим разрешённым batch-окном и отдельно утвердить новые файлы. - [ ] **B17 · Полный каталог рыб RF4DB — финальная загрузка.** Разрешённый RF4DB дал все 252 подписанных кандидата; 227 совпадений с RF4MAP сохранены как provenance-only `duplicate`, 15 уникальных файлов скачаны, а 10 URL (9 уникальных названий) остаются в очереди после корректно остановленного TLS-сбоя. Все поддомены RF4DB объединены одним 30-минутным cooldown. Завершить следующим разрешённым batch-окном и отдельно утвердить новые файлы.
- [ ] **B18 · Каталог водоёмов RF4DB.** После cooldown проиндексировать 19 русских карточек водоёмов, проверить полноразмерные карты и поставить только уникальные изображения в media queue. - [ ] **B18 · Каталог водоёмов RF4DB.** После cooldown проиндексировать 19 русских карточек водоёмов, проверить полноразмерные карты и поставить только уникальные изображения в media queue.
- [ ] **B19 · Каталог снастей RF4DB.** После водоёмов проиндексировать категории gear, построить crosswalk по типу, бренду, семейству и названию; не считать каталог полным до получения проверяемого общего счётчика. - [ ] **B19 · Каталог снастей RF4DB.** После водоёмов проиндексировать категории gear, построить crosswalk по типу, бренду, семейству и названию; не считать каталог полным до получения проверяемого общего счётчика.
- [x] **B20 · Аудит качества рыбных изображений.** Offline `media_cli --quality-report` проверяет фактические dimensions опубликованных файлов, отдельно считает неизбежный upscale в карточках и известные альтернативы. На 14.09: 227 из 243 опубликованных рыб имеют только 48×48 PNG RF4MAP и растягиваются до 180 px; 16 имеют 1024×1024 WebP. Для всех 227 низких файлов уже известны альтернативные URL RF4DB. Совпадение сущности больше нельзя считать достаточным основанием пропустить потенциально более качественный файл.
- [ ] **B21 · Очередь quality-upgrade.** Не менять текущие approved-файлы до готовности замены. Перевести 227 RF4DB `duplicate` в отдельное состояние `upgrade_queued`, сохранив `duplicate_of`, и загружать партиями до 40 в разрешённые 30-минутные окна. Ошибка останавливает домен по существующим правилам; публикация старой версии при этом не прерывается.
- [ ] **B22 · Сравнение вариантов разных источников.** После загрузки для каждой рыбы сравнить реальные dimensions, размер файла, MIME, прозрачность, aspect ratio и визуальное соответствие подписи. Минимальный технический порог для основной карточки — 256 px по меньшей стороне, предпочтительный — 512 px; маленький файл сохранять только как fallback. Источник RF4DB/RF4MAP/официальный RF4 не получает автоматического приоритета: выбирается лучший прошедший проверку файл.
- [ ] **B23 · Безопасное продвижение и provenance.** Добавить связь `supersedes`/`replaced_by`, отдельное решение review и атомарное переключение публичного `entity_key`; сохранить обе исходные ссылки и возможность отката. На сайте показывать источник именно выбранного изображения, а в раскрываемом provenance — все проверенные варианты. Не удалять старый Git-файл в том же коммите, где включается новый.
- [ ] **B24 · Производные размеры.** После выбора оригиналов генерировать детерминированные WebP/AVIF thumbnails для каталога и отдельный крупный вариант для detail, фиксировать хэши производных в manifest и отдавать `srcset`. Это уберёт загрузку 1024×1024 на каждой маленькой карточке и исключит browser-upscale 48×48.
- [ ] **B25 · Визуальная приёмка media.** Собирать контактный лист «старое / кандидат / выбранное» с названием и источником; вручную проверить минимум все замены и репрезентативные desktop/mobile страницы в light/dark. Gate: нет битых файлов, искажённых пропорций, ложных соответствий, обрезанного объекта и изображений ниже 256 px без явной пометки «низкое разрешение».
### Тёмная тема ### Тёмная тема
+39
View File
@@ -251,6 +251,45 @@ def media_coverage(root: Path) -> dict:
return {"baseline_date": baseline["verified_at"], "entities": result} return {"baseline_date": baseline["verified_at"], "entities": result}
def media_quality_report(root: Path, *, minimum_dimension: int = 256, display_dimension: int = 180) -> dict:
"""Report published raster quality and known higher-quality source alternatives offline."""
manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
assets = manifest.get("assets", [])
approved = [item for item in assets if item.get("status") == "approved" and item.get("entity_type") == "fish"]
low = [item for item in approved if min(int(item.get("width") or 0), int(item.get("height") or 0)) < minimum_dimension]
upsampled = [item for item in approved if min(int(item.get("width") or 0), int(item.get("height") or 0)) < display_dimension]
low_urls = {item.get("asset_url") for item in low}
alternatives = [
item for item in assets
if item.get("entity_type") == "fish"
and item.get("status") in {"duplicate", "queued"}
and (item.get("duplicate_of") in low_urls or item.get("status") == "queued")
]
by_source: dict[str, dict[str, int]] = {}
for item in approved:
host = urlsplit(str(item.get("asset_url") or "")).hostname or "unknown"
summary = by_source.setdefault(host, {"published": 0, "below_minimum": 0, "upsampled_in_cards": 0})
summary["published"] += 1
summary["below_minimum"] += item in low
summary["upsampled_in_cards"] += item in upsampled
return {
"entity_type": "fish",
"minimum_dimension": minimum_dimension,
"display_dimension": display_dimension,
"published": len(approved),
"below_minimum": len(low),
"upsampled_in_cards": len(upsampled),
"known_alternative_urls": len(alternatives),
"by_source": by_source,
"examples": [
{"label": item.get("label"), "width": item.get("width"), "height": item.get("height"), "asset_url": item.get("asset_url")}
for item in sorted(low, key=lambda row: str(row.get("label") or ""))[:20]
],
}
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:
+5 -1
View File
@@ -8,7 +8,7 @@ import urllib.error
import urllib.request import urllib.request
from .community_cli import MIN_FETCH_INTERVAL_SECONDS, USER_AGENT, _StrictRedirectHandler, _read_state, _validate_url_before_io, check_and_reserve, fetch_html, fetch_site_key from .community_cli import MIN_FETCH_INTERVAL_SECONDS, USER_AGENT, _StrictRedirectHandler, _read_state, _validate_url_before_io, check_and_reserve, fetch_html, fetch_site_key
from .media_assets import approve_stored_assets, audit_media_catalog, extract_media_candidates, media_coverage, merge_manifest, reconcile_queued_duplicates, reclassify_manifest, review_asset, store_asset from .media_assets import approve_stored_assets, audit_media_catalog, extract_media_candidates, media_coverage, media_quality_report, merge_manifest, reconcile_queued_duplicates, reclassify_manifest, review_asset, store_asset
DEFAULT_ROOT = Path("data/media") DEFAULT_ROOT = Path("data/media")
@@ -190,6 +190,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--note") parser.add_argument("--note")
parser.add_argument("--audit", action="store_true", help="Verify manifest metadata, hashes and local files without network access") parser.add_argument("--audit", action="store_true", help="Verify manifest metadata, hashes and local files without network access")
parser.add_argument("--coverage", action="store_true", help="Compare candidates and approvals with the catalog baseline") parser.add_argument("--coverage", action="store_true", help="Compare candidates and approvals with the catalog baseline")
parser.add_argument("--quality-report", action="store_true", help="Report low-resolution published fish and known alternatives without network access")
parser.add_argument("--queue-plan", action="store_true", help="Show the next useful queued asset per domain without network access") parser.add_argument("--queue-plan", action="store_true", help="Show the next useful queued asset per domain without network access")
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"))
@@ -202,6 +203,9 @@ def main(argv: list[str] | None = None) -> int:
if args.coverage: if args.coverage:
print(json.dumps(media_coverage(args.root), ensure_ascii=False, indent=2)) print(json.dumps(media_coverage(args.root), ensure_ascii=False, indent=2))
return 0 return 0
if args.quality_report:
print(json.dumps(media_quality_report(args.root), ensure_ascii=False, indent=2))
return 0
if args.queue_plan: if args.queue_plan:
print(json.dumps(media_queue_plan(args.root, args.state_file), ensure_ascii=False, indent=2)) print(json.dumps(media_queue_plan(args.root, args.state_file), ensure_ascii=False, indent=2))
return 0 return 0
+15 -1
View File
@@ -6,7 +6,7 @@ import io
import pytest import pytest
from PIL import Image from PIL import Image
from rf4_research.media_assets import approve_stored_assets, audit_media_catalog, extract_media_candidates, inspect_image, media_coverage, merge_manifest, reconcile_queued_duplicates, review_asset, store_asset from rf4_research.media_assets import approve_stored_assets, audit_media_catalog, extract_media_candidates, inspect_image, media_coverage, media_quality_report, merge_manifest, reconcile_queued_duplicates, review_asset, store_asset
def test_extracts_and_classifies_unique_https_media() -> None: def test_extracts_and_classifies_unique_https_media() -> None:
@@ -179,3 +179,17 @@ def test_bulk_approval_only_publishes_stored_assets(tmp_path: Path) -> None:
assert assets[0]["entity_key"] == "fish:pike" assert assets[0]["entity_key"] == "fish:pike"
assert assets[0]["review_note"] == "owner approved" assert assets[0]["review_note"] == "owner approved"
assert assets[1]["status"] == "queued" assert assets[1]["status"] == "queued"
def test_quality_report_finds_low_resolution_asset_and_known_alternative(tmp_path: Path) -> None:
(tmp_path / "manifest.json").write_text(json.dumps({"assets": [
{"entity_type": "fish", "label": "Щука", "status": "approved", "asset_url": "https://small.example/pike.png", "width": 48, "height": 48},
{"entity_type": "fish", "label": "Щука", "status": "duplicate", "asset_url": "https://large.example/pike.webp", "duplicate_of": "https://small.example/pike.png"},
{"entity_type": "fish", "label": "Окунь", "status": "approved", "asset_url": "https://large.example/perch.webp", "width": 1024, "height": 1024},
]}), encoding="utf-8")
report = media_quality_report(tmp_path)
assert (report["published"], report["below_minimum"], report["upsampled_in_cards"]) == (2, 1, 1)
assert report["known_alternative_urls"] == 1
assert report["by_source"]["small.example"]["below_minimum"] == 1