From 4f0c2d23de32294ead81f060b556fefab4c75855 Mon Sep 17 00:00:00 2001 From: IK Date: Sun, 20 Sep 2026 18:10:41 +0700 Subject: [PATCH] feat: preserve gear components through catch imports --- .../versions/0022_catch_tackle_components.py | 40 +++++++++ apps/api/app/community_review.py | 13 +++ apps/api/app/importer.py | 10 +++ apps/api/app/models.py | 28 ++++++ apps/api/app/routers/submissions.py | 10 +++ apps/api/app/tackle_components.py | 39 +++++++++ apps/api/tests/test_api.py | 7 +- .../api/tests/test_catch_tackle_components.py | 31 +++++++ apps/api/tests/test_community_importer.py | 4 +- apps/api/tests/test_importer.py | 3 +- docs/ROADMAP.md | 6 +- rf4_research/gear_components.py | 46 ++++++++++ rf4_research/gear_crosswalk.py | 86 +++++++++++++++++++ tests/test_gear_components.py | 20 +++++ tests/test_gear_crosswalk.py | 43 ++++++++++ 15 files changed, 379 insertions(+), 7 deletions(-) create mode 100644 apps/api/alembic/versions/0022_catch_tackle_components.py create mode 100644 apps/api/app/tackle_components.py create mode 100644 apps/api/tests/test_catch_tackle_components.py create mode 100644 rf4_research/gear_components.py create mode 100644 rf4_research/gear_crosswalk.py create mode 100644 tests/test_gear_components.py create mode 100644 tests/test_gear_crosswalk.py diff --git a/apps/api/alembic/versions/0022_catch_tackle_components.py b/apps/api/alembic/versions/0022_catch_tackle_components.py new file mode 100644 index 0000000..64a685b --- /dev/null +++ b/apps/api/alembic/versions/0022_catch_tackle_components.py @@ -0,0 +1,40 @@ +"""preserve ordered gear evidence on catches + +Revision ID: 0022 +Revises: 0021 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0022" +down_revision = "0021" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "catch_tackle_component", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("catch_report_id", sa.Uuid(), sa.ForeignKey("catch_report.id"), nullable=False), + sa.Column("tackle_item_id", sa.Uuid(), sa.ForeignKey("tackle_item.id")), + sa.Column("rig_id", sa.Uuid(), sa.ForeignKey("rig.id")), + sa.Column("role", sa.String(50), nullable=False), + sa.Column("position", sa.Integer(), nullable=False), + sa.Column("raw_value", sa.String(200), nullable=False), + sa.Column("source_system", sa.String(50)), + sa.Column("source_external_id", sa.String(200)), + sa.Column("source_url", sa.Text()), + sa.Column("raw_payload", sa.JSON()), + sa.UniqueConstraint("catch_report_id", "position"), + sa.CheckConstraint( + "NOT (tackle_item_id IS NOT NULL AND rig_id IS NOT NULL)", + name="ck_catch_tackle_one_canonical_target", + ), + ) + + +def downgrade() -> None: + op.drop_table("catch_tackle_component") diff --git a/apps/api/app/community_review.py b/apps/api/app/community_review.py index c1a3b40..74da105 100644 --- a/apps/api/app/community_review.py +++ b/apps/api/app/community_review.py @@ -11,6 +11,8 @@ from .models import ( Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation, Fish, ModerationStatus, SourceType, Spot, Waterbody, ) +from .tackle_components import replace_tackle_components +from rf4_research.gear_components import from_catch_fields class ExternalReviewError(ValueError): @@ -118,6 +120,17 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C setattr(report, key, value) session.add(report) session.flush() + replace_tackle_components( + session, + report, + from_catch_fields( + bait=observation.payload.get("bait"), + rig_type=observation.payload.get("rig_type"), + ), + source_system=observation.source_system, + source_url=observation.source_url, + raw_payload={"origin": "community_observation", "observation_id": str(observation.id)}, + ) observation.catch_report = report observation.status = "published" observation.reviewed_at = now diff --git a/apps/api/app/importer.py b/apps/api/app/importer.py index a5db920..33d43da 100644 --- a/apps/api/app/importer.py +++ b/apps/api/app/importer.py @@ -16,6 +16,8 @@ from .models import ( Bait, BaitKind, CatchReport, Fish, ImportRecordEvent, ImportStatus, ModerationStatus, OfficialRecordImport, SourceType, Waterbody, ) +from .tackle_components import replace_tackle_components +from rf4_research.gear_components import from_catch_fields USER_AGENT = "RF4-Spotter/0.1 (public records importer)" @@ -222,6 +224,14 @@ def _import_records_locked(session: Session, *, url: str, region: str, category: provenance={"source_system": "rf4-official", "source_url": url, "source_external_id": key}, )) run.rows_updated += 1 + replace_tackle_components( + session, + report, + from_catch_fields(bait=raw.bait, rig_type=None), + source_system="rf4-official", + source_url=url, + raw_payload={"origin": "official_record", "source_external_id": key}, + ) run.status = ImportStatus.success run.finished_at = datetime.now(timezone.utc) session.commit() diff --git a/apps/api/app/models.py b/apps/api/app/models.py index a51e8e5..34b63ec 100644 --- a/apps/api/app/models.py +++ b/apps/api/app/models.py @@ -165,6 +165,34 @@ class CatchReport(Base): spot: Mapped[Spot | None] = relationship() waterbody: Mapped[Waterbody] = relationship() bait: Mapped[Bait | None] = relationship() + tackle_components: Mapped[list["CatchTackleComponent"]] = relationship(back_populates="catch_report") + + +class CatchTackleComponent(Base): + """Ordered gear evidence; unresolved raw values are valid and preserved.""" + + __tablename__ = "catch_tackle_component" + __table_args__ = ( + UniqueConstraint("catch_report_id", "position"), + CheckConstraint( + "NOT (tackle_item_id IS NOT NULL AND rig_id IS NOT NULL)", + name="ck_catch_tackle_one_canonical_target", + ), + ) + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + catch_report_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("catch_report.id")) + tackle_item_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("tackle_item.id")) + rig_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("rig.id")) + role: Mapped[str] = mapped_column(String(50)) + position: Mapped[int] = mapped_column(Integer) + raw_value: Mapped[str] = mapped_column(String(200)) + source_system: Mapped[str | None] = mapped_column(String(50)) + source_external_id: Mapped[str | None] = mapped_column(String(200)) + source_url: Mapped[str | None] = mapped_column(Text) + raw_payload: Mapped[dict | None] = mapped_column(JSON) + catch_report: Mapped[CatchReport] = relationship(back_populates="tackle_components") + tackle_item: Mapped[TackleItem | None] = relationship() + rig: Mapped[Rig | None] = relationship() class OfficialRecordImport(Base): diff --git a/apps/api/app/routers/submissions.py b/apps/api/app/routers/submissions.py index b5b660b..d444faf 100644 --- a/apps/api/app/routers/submissions.py +++ b/apps/api/app/routers/submissions.py @@ -21,6 +21,8 @@ from ..models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, Source from ..schemas import CatchReportAccepted, CatchReportCreate from ..storage import ScreenshotError, upload_screenshot from ..submission_security import check_rate_limit +from ..tackle_components import replace_tackle_components +from rf4_research.gear_components import from_catch_fields router = APIRouter() logger = logging.getLogger("rf4.api.submissions") @@ -60,6 +62,14 @@ def create_catch_report(payload: CatchReportCreate, request: Request, db: Db, id upload_token = _replay_token(key_hash) if key_hash else secrets.token_urlsafe(32) report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None, screenshot_upload_token_hash=hashlib.sha256(upload_token.encode()).hexdigest()) db.add(report) + replace_tackle_components( + db, + report, + from_catch_fields(bait=payload.bait_name, rig_type=payload.rig_type), + source_system="user", + source_url=payload.source_url, + raw_payload={"origin": "user_submission"}, + ) if key_hash: db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, catch_report=report, payload_hash=payload_hash, created_at=datetime.now(timezone.utc))) try: diff --git a/apps/api/app/tackle_components.py b/apps/api/app/tackle_components.py new file mode 100644 index 0000000..78e925b --- /dev/null +++ b/apps/api/app/tackle_components.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from collections.abc import Iterable + +from sqlalchemy import delete +from sqlalchemy.orm import Session + +from rf4_research.gear_components import GearComponentIdentity + +from .models import CatchReport, CatchTackleComponent + + +def replace_tackle_components( + session: Session, + report: CatchReport, + components: Iterable[GearComponentIdentity], + *, + source_system: str | None, + source_url: str | None = None, + raw_payload: dict | None = None, +) -> None: + """Replace the ordered evidence for a report while keeping imports idempotent.""" + session.flush() + session.execute( + delete(CatchTackleComponent).where(CatchTackleComponent.catch_report_id == report.id) + ) + session.add_all( + CatchTackleComponent( + catch_report_id=report.id, + role=component.role, + position=component.position, + raw_value=component.raw_value, + source_system=source_system, + source_external_id=component.source_external_id, + source_url=source_url, + raw_payload=raw_payload, + ) + for component in components + ) diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 8211301..1b57835 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -12,7 +12,7 @@ from app.database import Base, get_session from app.community_importer import stage_observations from app.importer import ImportAlreadyRunning from app.main import app -from app.models import Bait, BaitKind, CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody +from app.models import Bait, BaitKind, CatchReport, CatchTackleComponent, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody from app.routers import admin as admin_router @@ -351,11 +351,14 @@ def test_records_pagination_returns_correct_total_and_offset() -> None: def test_user_report_requires_moderation_before_activity() -> None: - created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "player_name": "Reporter"}) + created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "rig_type": "Спиннинг", "player_name": "Reporter"}) assert created.status_code == 201 assert created.headers["Cache-Control"] == "no-store" assert created.json()["moderation_status"] == "pending" report_id = created.json()["id"] + with Session(engine) as db: + components = db.scalars(select(CatchTackleComponent).where(CatchTackleComponent.catch_report_id == UUID(report_id)).order_by(CatchTackleComponent.position)).all() + assert [(component.role, component.raw_value) for component in components] == [("lure", "Новая приманка"), ("rig", "Спиннинг")] headers = {"Authorization": "Bearer change-me-in-production"} pending = client.get("/api/v1/admin/catch-reports", headers=headers) assert pending.status_code == 200 diff --git a/apps/api/tests/test_catch_tackle_components.py b/apps/api/tests/test_catch_tackle_components.py new file mode 100644 index 0000000..7c8bba9 --- /dev/null +++ b/apps/api/tests/test_catch_tackle_components.py @@ -0,0 +1,31 @@ +from datetime import datetime, timezone + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from app.database import Base +from app.models import CatchReport, CatchTackleComponent, Fish, ModerationStatus, SourceType, Waterbody + + +def test_catch_keeps_ordered_unresolved_gear_evidence() -> None: + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + with Session(engine) as session: + fish = Fish(slug="pike", name_ru="Щука") + waterbody = Waterbody(slug="lake", name_ru="Озеро") + report = CatchReport( + fish=fish, waterbody=waterbody, weight_g=1000, + reported_at=datetime(2026, 9, 20, tzinfo=timezone.utc), source_type=SourceType.manual_import, + source_confidence=50, moderation_status=ModerationStatus.pending, + ) + report.tackle_components.extend([ + CatchTackleComponent(role="lure", position=0, raw_value="Spiker #2"), + CatchTackleComponent(role="rig", position=1, raw_value="Method Popup"), + ]) + session.add(report) + session.commit() + saved = session.get(CatchReport, report.id) + assert saved is not None + assert [(row.position, row.role, row.raw_value) for row in saved.tackle_components] == [ + (0, "lure", "Spiker #2"), (1, "rig", "Method Popup"), + ] diff --git a/apps/api/tests/test_community_importer.py b/apps/api/tests/test_community_importer.py index 74a152e..274c157 100644 --- a/apps/api/tests/test_community_importer.py +++ b/apps/api/tests/test_community_importer.py @@ -11,7 +11,7 @@ from app.community_importer import CommunityImportError, stage_observations, upd from app.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases from app.source_lifecycle import record_scheduled_source_check, record_source_check from app.database import Base -from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody +from app.models import CatchReport, CatchTackleComponent, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot @@ -239,6 +239,8 @@ def test_changed_published_record_requires_review_and_reuses_report(db: Session) assert updated.id == report_id assert updated.weight_g == 6000 assert updated.moderation_status.value == "approved" + components = db.scalars(select(CatchTackleComponent).order_by(CatchTackleComponent.position)).all() + assert [(component.position, component.raw_value) for component in components] == [(0, "Приманка")] assert db.scalar(select(func.count()).select_from(CatchReport)) == 1 diff --git a/apps/api/tests/test_importer.py b/apps/api/tests/test_importer.py index b555e4c..943c96d 100644 --- a/apps/api/tests/test_importer.py +++ b/apps/api/tests/test_importer.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from app.database import Base from app.importer import FetchResult, ImportAlreadyRunning, ImportSourceError, _lock_key, _official_import_lock, import_records, parse_html -from app.models import CatchReport, ImportStatus, OfficialRecordImport, SourceType +from app.models import CatchReport, CatchTackleComponent, ImportStatus, OfficialRecordImport, SourceType FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "records_ru_sample.html" @@ -50,6 +50,7 @@ def test_parser_and_import_are_idempotent() -> None: # A12: Second import of identical data creates no events (no fields changed) assert (second.rows_created, second.rows_updated) == (0, 0) assert db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record)) == 2 + assert db.scalar(select(func.count()).select_from(CatchTackleComponent)) == 2 assert db.scalar(select(func.count()).select_from(OfficialRecordImport)) == 2 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2ec0d17..879bb8f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -68,9 +68,9 @@ - [ ] **G01 · Canonical-каталог RF4DB.** Offline-контракт и строгий parser готовы для категорий `bait`, `lure`, `rod`, `reel`, `line`, `hook`, `rig`, `float`, `sinker`, `other`: сохраняются исходный slug/ID, русское название, категория, подкатегория, бренд, семейство, игровой уровень и source URL; duplicate ID и неизвестная категория отклоняются. Общий count остаётся `unknown`, пока не получен разрешённый реальный индекс; fixture не считается каталогом. - [ ] **G02 · Карточки предметов и оснасток.** Fixture-based parser списка и detail-страницы готов: характеристики, варианты, совместимость, изображения и связанные типы монтажа сохраняются; атрибуты различают фактический `0`, `not_applicable` и `missing`. Parser fail-closed при неполном/изменившемся DOM; реальные detail-запросы выполнять только для выбранных карточек и с общим 30-минутным cooldown домена. -- [ ] **G03 · Модель и provenance.** Добавлены отдельные `tackle_item`, `rig` и `rig_component` с миграцией `0021`; legacy `bait` и `catch_report.bait_id` не изменялись. `tackle_item` хранит `category`, `subcategory`, `brand`, `family`, `unlock_level`, source identity, timestamp и `raw_payload`; `rig_component` хранит роль, порядок, исходное значение и optional canonical item. Остаётся подключить эти сущности к import/review/API и выполнить безопасный backfill только после реального crosswalk. -- [ ] **G04 · Crosswalk и нормализация.** Построить offline-crosswalk между RF4DB, официальными рекордами, RF4MAP, RF4 Posts и локальным справочником. Нормализовать регистр, пробелы, дефисы, единицы и локализацию; предлагать совпадение только при точном имени/алиасе плюс совместимой категории. Неоднозначные, брендовые варианты и unmatched-строки отправлять на review без автоматического canonical key; исходное значение всегда сохранять. -- [ ] **G05 · Связи с уловами и источниками.** Протянуть канонические предметы и монтажи через community import, официальные записи и форму улова, сохранив `raw_payload` и список missing fields. Поддержать несколько предметов в одном комплекте, порядок/роль компонента и источник каждой связи; старый `bait_id` и текстовые значения не терять при миграции. Публикация полного наблюдения по-прежнему требует подтверждённых соответствий, а не простого совпадения строки. +- [ ] **G03 · Модель и provenance.** Добавлены отдельные `tackle_item`, `rig` и `rig_component` с миграцией `0021`; legacy `bait` и `catch_report.bait_id` не изменялись. `tackle_item` хранит `category`, `subcategory`, `brand`, `family`, `unlock_level`, source identity, timestamp и `raw_payload`; `rig_component` хранит роль, порядок, исходное значение и optional canonical item. Связи с catch-потоком добавлены в G05; безопасный backfill остаётся только после реального crosswalk. +- [ ] **G04 · Crosswalk и нормализация.** Добавлен offline `gear_crosswalk`: нормализация регистра/пробелов/`е/ё`, точное имя или alias плюс совместимая категория, консервативная проверка brand/family. Неоднозначные, несовместимые, брендовые и unmatched-строки получают review-статус без canonical key; исходное значение сохраняется. Остаётся подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты. +- [ ] **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 и публичный каталог.** Добавить пагинированные каталоги и detail endpoints с фильтрами по категории, бренду, семейству и уровню, а также безопасные ссылки из улова/точки на использованную приманку, снасть и монтаж. Показывать только подтверждённые характеристики, источник, свежесть и неполноту; не выдавать рейтинг эффективности, если его нельзя объяснить числом наблюдений, игроками, периодом и качеством источников. - [ ] **G07 · Аналитика сочетаний и рекомендации.** После появления достаточных данных считать отдельно «водоём + рыба + предмет», «точка + рыба + предмет» и «способ ловли + монтаж». Зафиксировать минимальный объём выборки, защиту от одного игрока/дубликатов и decay по свежести; разделить факт использования, частоту и рекомендацию. Пустая или малая выборка должна показывать «данных мало», а не советовать конкретную снасть. - [ ] **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. diff --git a/rf4_research/gear_components.py b/rf4_research/gear_components.py new file mode 100644 index 0000000..964db4c --- /dev/null +++ b/rf4_research/gear_components.py @@ -0,0 +1,46 @@ +"""Ordered gear evidence extracted from source records without auto-mapping.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .community_sources import EquipmentItem + + +@dataclass(frozen=True, slots=True) +class GearComponentIdentity: + role: str + position: int + raw_value: str + source_external_id: str | None + + +def _role_from_kind(kind: str) -> str: + value = kind.casefold() + terms = { + "удилищ": "rod", "катуш": "reel", "леск": "line", "крюч": "hook", + "поплав": "float", "груз": "sinker", "монтаж": "rig", "rig": "rig", + "приманк": "lure", "нажив": "bait", "bait": "bait", "lure": "lure", + } + return next((role for term, role in terms.items() if term in value), "other") + + +def from_equipment(items: tuple[EquipmentItem, ...]) -> tuple[GearComponentIdentity, ...]: + """Keep source order and raw text; canonical mapping happens only after review.""" + return tuple( + GearComponentIdentity( + role=_role_from_kind(item.kind), position=index, + raw_value=item.name, source_external_id=item.external_id, + ) + for index, item in enumerate(items) + if item.name.strip() + ) + + +def from_catch_fields(*, bait: str | None, rig_type: str | None) -> tuple[GearComponentIdentity, ...]: + values: list[GearComponentIdentity] = [] + if bait and bait.strip(): + values.append(GearComponentIdentity("lure", len(values), bait.strip(), None)) + if rig_type and rig_type.strip(): + values.append(GearComponentIdentity("rig", len(values), rig_type.strip(), None)) + return tuple(values) diff --git a/rf4_research/gear_crosswalk.py b/rf4_research/gear_crosswalk.py new file mode 100644 index 0000000..d9a3d86 --- /dev/null +++ b/rf4_research/gear_crosswalk.py @@ -0,0 +1,86 @@ +"""Conservative offline crosswalk suggestions for gear identities.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .community_sources import GEAR_CATEGORIES +from .media_assets import normalize_entity_label + + +@dataclass(frozen=True, slots=True) +class CanonicalGear: + key: str + name: str + category: str + aliases: tuple[str, ...] = () + brand: str | None = None + family: str | None = None + + +@dataclass(frozen=True, slots=True) +class GearIdentity: + source_system: str + external_id: str + name: str + category: str + brand: str | None = None + family: str | None = None + + +@dataclass(frozen=True, slots=True) +class GearCrosswalkSuggestion: + source_system: str + external_id: str + external_name: str + category: str + status: str + canonical_keys: tuple[str, ...] + + +def _compatible_category(value: str) -> bool: + return value.casefold().strip() in GEAR_CATEGORIES + + +def suggest_gear_crosswalk( + canonical: list[CanonicalGear], identities: list[GearIdentity], +) -> list[GearCrosswalkSuggestion]: + """Suggest only unique exact normalized-name matches in the same category. + + Brand/family differences never create an automatic match. Ambiguous, + category-mismatched and unmatched identities retain no canonical key. + """ + by_name: dict[str, set[str]] = {} + by_key = {item.key: item for item in canonical} + for item in canonical: + if not _compatible_category(item.category): + raise ValueError(f"invalid canonical gear category: {item.category!r}") + for name in (item.name, *item.aliases): + by_name.setdefault(normalize_entity_label(name), set()).add(item.key) + result: list[GearCrosswalkSuggestion] = [] + for identity in identities: + keys = tuple(sorted(by_name.get(normalize_entity_label(identity.name), set()))) + compatible = tuple( + key for key in keys + if by_key[key].category.casefold() == identity.category.casefold() + and not (by_key[key].brand and identity.brand and normalize_entity_label(by_key[key].brand) != normalize_entity_label(identity.brand)) + and not (by_key[key].family and identity.family and normalize_entity_label(by_key[key].family) != normalize_entity_label(identity.family)) + ) + if not _compatible_category(identity.category): + status = "category_mismatch" + selected: tuple[str, ...] = () + elif len(compatible) == 1: + status = "exact" + selected = compatible + elif len(compatible) > 1: + status = "ambiguous" + selected = () + else: + status = "unmatched" + selected = () + result.append(GearCrosswalkSuggestion( + source_system=identity.source_system, external_id=identity.external_id, + external_name=identity.name, category=identity.category, + status=status, canonical_keys=selected, + )) + return result diff --git a/tests/test_gear_components.py b/tests/test_gear_components.py new file mode 100644 index 0000000..a2f15dd --- /dev/null +++ b/tests/test_gear_components.py @@ -0,0 +1,20 @@ +from rf4_research.community_sources import EquipmentItem +from rf4_research.gear_components import from_catch_fields, from_equipment + + +def test_from_equipment_preserves_source_order_and_external_ids() -> None: + rows = from_equipment(( + EquipmentItem("Катушка", "Test Reel", "reel-1"), + EquipmentItem("Приманка поклёвки", "Test Lure", "lure-1"), + )) + assert [(row.role, row.position, row.raw_value, row.source_external_id) for row in rows] == [ + ("reel", 0, "Test Reel", "reel-1"), + ("lure", 1, "Test Lure", "lure-1"), + ] + + +def test_from_catch_fields_keeps_unresolved_raw_values() -> None: + rows = from_catch_fields(bait="Spiker #2", rig_type="Method Popup") + assert [(row.role, row.raw_value, row.source_external_id) for row in rows] == [ + ("lure", "Spiker #2", None), ("rig", "Method Popup", None), + ] diff --git a/tests/test_gear_crosswalk.py b/tests/test_gear_crosswalk.py new file mode 100644 index 0000000..f7585fb --- /dev/null +++ b/tests/test_gear_crosswalk.py @@ -0,0 +1,43 @@ +import pytest + +from rf4_research.gear_crosswalk import ( + CanonicalGear, + GearIdentity, + suggest_gear_crosswalk, +) + + +def test_gear_crosswalk_matches_exact_name_and_category_only() -> None: + canonical = [ + CanonicalGear("lure:spiker-2", "Spiker #2", "lure", ("Spiker 2",), brand="RF4"), + CanonicalGear("rig:method-popup", "Method Popup", "rig"), + ] + identities = [ + GearIdentity("rf4db", "spiker-2", " spiker #2 ", "lure", brand="RF4"), + GearIdentity("rf4db", "method-popup", "Method Popup", "lure"), + GearIdentity("rf4db", "unknown", "Unknown bait", "bait"), + ] + rows = suggest_gear_crosswalk(canonical, identities) + assert [(row.status, row.canonical_keys) for row in rows] == [ + ("exact", ("lure:spiker-2",)), ("unmatched", ()), ("unmatched", ()), + ] + + +def test_gear_crosswalk_does_not_auto_select_ambiguous_or_brand_mismatch() -> None: + canonical = [ + CanonicalGear("a", "Test Hook", "hook", brand="A"), + CanonicalGear("b", "Test Hook", "hook", brand="B"), + ] + identities = [ + GearIdentity("rf4db", "1", "Test Hook", "hook"), + GearIdentity("rf4db", "2", "Test Hook", "hook", brand="C"), + GearIdentity("rf4db", "3", "Test Hook", "float"), + ] + rows = suggest_gear_crosswalk(canonical, identities) + assert [row.status for row in rows] == ["ambiguous", "unmatched", "unmatched"] + assert all(row.canonical_keys == () for row in rows) + + +def test_gear_crosswalk_rejects_invalid_canonical_category() -> None: + with pytest.raises(ValueError, match="invalid canonical gear category"): + suggest_gear_crosswalk([CanonicalGear("bad", "Bad", "equipment")], [])