feat: extend media roles and acceptance coverage
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run

This commit is contained in:
ik
2026-09-20 18:30:16 +07:00
parent e94d247096
commit a547d09fcd
6 changed files with 42 additions and 7 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
const routes = [ const routes = [
"/", "/waterbodies", "/records", "/report", "/status", "/media", "/rules", "/", "/waterbodies", "/tackle", "/records", "/report", "/status", "/media", "/rules",
"/waterbodies/р-вьюнок", "/fish/pike", "/admin", "/admin/moderation", "/waterbodies/р-вьюнок", "/fish/pike", "/admin", "/admin/moderation",
"/admin/external-sources", "/admin/media", "/admin/external-sources", "/admin/media",
]; ];
+2 -2
View File
@@ -73,8 +73,8 @@
- [ ] **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. - [ ] **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`; рейтинг эффективности не добавляется. Остаётся подключить публичные Astro-карточки и ссылки из всех нужных представлений улова. - [ ] **G06 · API и публичный каталог.** Добавлены пагинированный `/api/v1/tackle/items` с фильтрами по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Остаётся подключить публичные Astro-карточки и ссылки из всех нужных представлений улова.
- [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Пустая или малая выборка получает `insufficient_data`; decay по свежести и отдельные UI-состояния остаются следующим шагом. - [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Пустая или малая выборка получает `insufficient_data`; decay по свежести и отдельные UI-состояния остаются следующим шагом.
- [ ] **G08 · Медиа и качество.** Разнести media roles для `tackle_item`, `bait`, `rig` и общего reference; связать варианты через `entity_key`, `duplicate_of`, `supersedes`/`replaced_by`. Проверять dimensions, MIME, SHA-256, прозрачность, aspect ratio, подпись и категорию; не переключать approved-файл автоматически, не считать userguide-скриншот карточкой предмета и не публиковать media-кандидатов без review и разрешённого provenance. - [ ] **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 и атомарное продвижение сохраняются. Остаётся провести реальный contact-sheet review для будущих tackle-кандидатов без автоматической публикации.
- [ ] **G09 · Приёмка и эксплуатация.** Добавить fixture/regression tests, offline catalog/crosswalk/media audits, проверку идемпотентности и сохранения старых данных при сбое, API/UI acceptance для пустых, неоднозначных и многокомпонентных комплектов, browser QA desktop/mobile и query-plan gate для фильтров/сочетаний. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff. Закрывать пакет только после проверяемого счётчика по каждой категории либо явной фиксации `unknown`. - [ ] **G09 · Приёмка и эксплуатация.** Добавлены fixture/regression tests для crosswalk, media roles, идемпотентных компонентов и малой выборки рекомендаций; каталог `/tackle` включён в visual-matrix regression. Offline catalog/media audits и сохранение старых данных при сбое проходят. Остаются HTTP/browser acceptance для пустых, неоднозначных и многокомпонентных комплектов и query-plan gate для production-фильтров. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff. Закрывать пакет только после проверяемого счётчика по каждой категории либо явной фиксации `unknown`.
### Тёмная тема ### Тёмная тема
+13 -2
View File
@@ -18,6 +18,13 @@ DERIVATIVE_TARGETS = {"card": 256, "detail": 1024}
WATERBODY_MEDIA_ROLES = frozenset({ WATERBODY_MEDIA_ROLES = frozenset({
"waterbody_cover", "waterbody_map", "waterbody_depth_map", "waterbody_screenshot", "waterbody_cover", "waterbody_map", "waterbody_depth_map", "waterbody_screenshot",
}) })
TACKLE_MEDIA_ROLES = frozenset({
"tackle_card", "tackle_detail", "rig_diagram", "tackle_screenshot",
})
MEDIA_ROLES_BY_ENTITY = {
"waterbody": WATERBODY_MEDIA_ROLES,
"tackle": TACKLE_MEDIA_ROLES,
}
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -276,8 +283,8 @@ def review_asset(
item = next((asset for asset in manifest.get("assets", []) if asset["asset_url"] == asset_url), None) item = next((asset for asset in manifest.get("assets", []) if asset["asset_url"] == asset_url), None)
if item is None: if item is None:
raise ValueError("asset URL is not present in manifest") raise ValueError("asset URL is not present in manifest")
if media_role is not None and (entity_type != "waterbody" or media_role not in WATERBODY_MEDIA_ROLES): if media_role is not None and media_role not in MEDIA_ROLES_BY_ENTITY.get(entity_type or "", set()):
raise ValueError("media role is only valid for a waterbody and must be a known waterbody role") raise ValueError(f"media role is only valid for {entity_type}; use a known {entity_type} role")
if decision == "approved": if decision == "approved":
if item.get("status") != "stored": if item.get("status") != "stored":
raise ValueError("only a stored asset can be approved") raise ValueError("only a stored asset can be approved")
@@ -345,6 +352,10 @@ def audit_media_catalog(root: Path) -> dict:
issues.append(f"{item['asset_url']}: derivative {exc}") issues.append(f"{item['asset_url']}: derivative {exc}")
if status == "approved" and (not item.get("entity_key") or item.get("entity_type") not in {"fish", "waterbody", "tackle", "reference"}): if status == "approved" and (not item.get("entity_key") or item.get("entity_type") not in {"fish", "waterbody", "tackle", "reference"}):
issues.append(f"{item['asset_url']}: approved asset has no valid canonical mapping") issues.append(f"{item['asset_url']}: approved asset has no valid canonical mapping")
if item.get("media_role") not in {None, *WATERBODY_MEDIA_ROLES, *TACKLE_MEDIA_ROLES}:
issues.append(f"{item['asset_url']}: unknown media role")
elif item.get("media_role") is not None and item.get("media_role") not in MEDIA_ROLES_BY_ENTITY.get(item.get("entity_type"), set()):
issues.append(f"{item['asset_url']}: media role does not match entity type")
files_root = root / "files" files_root = root / "files"
orphaned = sorted(str(path.relative_to(root)) for path in files_root.rglob("*") if path.is_file() and str(path.relative_to(root)) not in referenced) if files_root.exists() else [] orphaned = sorted(str(path.relative_to(root)) for path in files_root.rglob("*") if path.is_file() and str(path.relative_to(root)) not in referenced) if files_root.exists() else []
return {"total": sum(statuses.values()), "statuses": statuses, "issues": issues, "orphaned_files": orphaned} return {"total": sum(statuses.values()), "statuses": statuses, "issues": issues, "orphaned_files": orphaned}
+2 -1
View File
@@ -192,6 +192,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--decision", choices=("approved", "rejected")) parser.add_argument("--decision", choices=("approved", "rejected"))
parser.add_argument("--entity-type", choices=("fish", "waterbody", "tackle", "reference")) parser.add_argument("--entity-type", choices=("fish", "waterbody", "tackle", "reference"))
parser.add_argument("--entity-key") parser.add_argument("--entity-key")
parser.add_argument("--media-role", help="Explicit reviewed role for waterbody/tackle media")
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")
@@ -232,7 +233,7 @@ def main(argv: list[str] | None = None) -> int:
if args.review_url: if args.review_url:
if not args.decision: if not args.decision:
parser.error("--decision is required with --review-url") 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) 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, media_role=args.media_role)
print(f"{item['status']} {item['asset_url']}") print(f"{item['status']} {item['asset_url']}")
return 0 return 0
if args.reclassify: if args.reclassify:
+1 -1
View File
@@ -6,7 +6,7 @@ import process from "node:process";
import { chromium } from "../apps/web/node_modules/playwright/index.mjs"; import { chromium } from "../apps/web/node_modules/playwright/index.mjs";
const routes = [ const routes = [
"/", "/waterbodies", "/records", "/report", "/status", "/media", "/rules", "/", "/waterbodies", "/tackle", "/records", "/report", "/status", "/media", "/rules",
"/waterbodies/р-вьюнок", "/fish/pike", "/admin", "/admin/moderation", "/waterbodies/р-вьюнок", "/fish/pike", "/admin", "/admin/moderation",
"/admin/external-sources", "/admin/media", "/admin/external-sources", "/admin/media",
]; ];
+23
View File
@@ -173,6 +173,29 @@ def test_waterbody_media_role_rejects_unknown_role(tmp_path: Path) -> None:
) )
def test_tackle_media_roles_require_explicit_review_and_matching_entity(tmp_path: Path) -> None:
manifest = tmp_path / "manifest.json"
candidate = extract_media_candidates(
'<img src="/lure/spiker.webp" alt="Блесна Спайкер">',
source_page="https://rf4db.com/ru/wiki/lures/spiker",
)[0]
merge_manifest(manifest, [candidate])
with pytest.raises(ValueError, match="known tackle role"):
review_asset(
manifest, asset_url=candidate.asset_url, decision="rejected",
entity_type="tackle", entity_key="tackle:spiker", media_role="waterbody_map",
)
saved = json.loads(manifest.read_text(encoding="utf-8"))
saved["assets"][0].update({"status": "stored", "sha256": "a" * 64, "local_path": "files/spiker.webp", "width": 256, "height": 256, "content_type": "image/webp"})
manifest.write_text(json.dumps(saved), encoding="utf-8")
reviewed = review_asset(
manifest, asset_url=candidate.asset_url, decision="approved",
entity_type="tackle", entity_key="tackle:spiker", media_role="tackle_card",
)
assert reviewed["media_role"] == "tackle_card"
def test_image_inspection_rejects_invalid_body_and_mime_mismatch(tmp_path: Path) -> None: def test_image_inspection_rejects_invalid_body_and_mime_mismatch(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="valid raster"): with pytest.raises(ValueError, match="valid raster"):
inspect_image(b"not an image") inspect_image(b"not an image")