feat: preserve gear components through catch imports
This commit is contained in:
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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")], [])
|
||||
Reference in New Issue
Block a user