feat: link tackle analytics to canonical gear
This commit is contained in:
@@ -56,6 +56,20 @@ def tackle_combinations(
|
|||||||
result = []
|
result = []
|
||||||
for (role, value), reports in groups.items():
|
for (role, value), reports in groups.items():
|
||||||
unique_reports = {report.id: report for report in reports}
|
unique_reports = {report.id: report for report in reports}
|
||||||
|
item_ids = {
|
||||||
|
component.tackle_item_id
|
||||||
|
for report in unique_reports.values()
|
||||||
|
for component in report.tackle_components
|
||||||
|
if component.role == role and component.raw_value.strip() == value and component.tackle_item_id is not None
|
||||||
|
}
|
||||||
|
rig_ids = {
|
||||||
|
component.rig_id
|
||||||
|
for report in unique_reports.values()
|
||||||
|
for component in report.tackle_components
|
||||||
|
if component.role == role and component.raw_value.strip() == value and component.rig_id is not None
|
||||||
|
}
|
||||||
|
canonical_item_id = next(iter(item_ids)) if len(item_ids) == 1 and not rig_ids else None
|
||||||
|
canonical_rig_id = next(iter(rig_ids)) if len(rig_ids) == 1 and not item_ids else None
|
||||||
players = {report.player_name.strip().casefold() for report in unique_reports.values() if report.player_name and report.player_name.strip()}
|
players = {report.player_name.strip().casefold() for report in unique_reports.values() if report.player_name and report.player_name.strip()}
|
||||||
catches = len(unique_reports)
|
catches = len(unique_reports)
|
||||||
unique_players = len(players)
|
unique_players = len(players)
|
||||||
@@ -66,7 +80,8 @@ def tackle_combinations(
|
|||||||
) / catches * 100)
|
) / catches * 100)
|
||||||
enough = catches >= min_samples and unique_players >= min_players
|
enough = catches >= min_samples and unique_players >= min_players
|
||||||
result.append(TackleCombinationOut(
|
result.append(TackleCombinationOut(
|
||||||
role=role, value=value, catches=catches, unique_players=unique_players,
|
role=role, value=value, tackle_item_id=canonical_item_id, rig_id=canonical_rig_id,
|
||||||
|
catches=catches, unique_players=unique_players,
|
||||||
last_seen_at=last_seen, freshness_score=freshness_score,
|
last_seen_at=last_seen, freshness_score=freshness_score,
|
||||||
status="recommendation" if enough else "insufficient_data",
|
status="recommendation" if enough else "insufficient_data",
|
||||||
explanation=(
|
explanation=(
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ class PaginatedActivityOut(BaseModel):
|
|||||||
class TackleCombinationOut(BaseModel):
|
class TackleCombinationOut(BaseModel):
|
||||||
role: str
|
role: str
|
||||||
value: str
|
value: str
|
||||||
|
tackle_item_id: UUID | None = None
|
||||||
|
rig_id: UUID | None = None
|
||||||
catches: int
|
catches: int
|
||||||
unique_players: int
|
unique_players: int
|
||||||
last_seen_at: datetime
|
last_seen_at: datetime
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export type Catch = { id: string; fish: string; weight_g: number; bait: string |
|
|||||||
export type DictionaryItem = { id: string; slug: string; name_ru: string; unlock_level?: number | null; fish_species_count?: number | null; source_system?: string | null; source_external_id?: string | null; source_url?: string | null; description?: string | null; source_aliases?: string[] | null; source_fish_species?: string[] | null; source_image_urls?: string[] | null; source_point_urls?: string[] | null; source_checked_at?: string | null };
|
export type DictionaryItem = { id: string; slug: string; name_ru: string; unlock_level?: number | null; fish_species_count?: number | null; source_system?: string | null; source_external_id?: string | null; source_url?: string | null; description?: string | null; source_aliases?: string[] | null; source_fish_species?: string[] | null; source_image_urls?: string[] | null; source_point_urls?: string[] | null; source_checked_at?: string | null };
|
||||||
export type TackleItem = { id: string; name: string; category: string; subcategory: string | null; brand: string | null; family: string | null; unlock_level: number | null; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[] };
|
export type TackleItem = { id: string; name: string; category: string; subcategory: string | null; brand: string | null; family: string | null; unlock_level: number | null; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[] };
|
||||||
export type PaginatedTackleItems = { items: TackleItem[]; total: number; limit: number; offset: number };
|
export type PaginatedTackleItems = { items: TackleItem[]; total: number; limit: number; offset: number };
|
||||||
export type TackleCombination = { role: string; value: string; catches: number; unique_players: number; last_seen_at: string; freshness_score: number; status: "recommendation" | "insufficient_data"; explanation: string };
|
export type TackleCombination = { role: string; value: string; tackle_item_id: string | null; rig_id: string | null; catches: number; unique_players: number; last_seen_at: string; freshness_score: number; status: "recommendation" | "insufficient_data"; explanation: string };
|
||||||
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null; source_system: string };
|
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null; source_system: string };
|
||||||
export type PaginatedOfficialRecord = {
|
export type PaginatedOfficialRecord = {
|
||||||
items: OfficialRecord[];
|
items: OfficialRecord[];
|
||||||
|
|||||||
@@ -36,5 +36,5 @@ const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Н
|
|||||||
{(waterbody || fish || method || hours !== 72) && <a data-action="quiet" href="/tackle/analytics">Сбросить</a>}
|
{(waterbody || fish || method || hours !== 72) && <a data-action="quiet" href="/tackle/analytics">Сбросить</a>}
|
||||||
</form>
|
</form>
|
||||||
<p class="privacy content-grid">Порог рекомендации: минимум 3 наблюдения от 2 независимых игроков. Это не рейтинг снасти и не гарантия улова.</p>
|
<p class="privacy content-grid">Порог рекомендации: минимум 3 наблюдения от 2 независимых игроков. Это не рейтинг снасти и не гарантия улова.</p>
|
||||||
{unavailable ? <StatePanel tone="unavailable" title="Аналитика временно недоступна" description="Не показываем непроверенные сочетания. Попробуйте позже." /> : combinations.length ? <section class="signal-grid content-grid" aria-label="Сочетания снастей" data-analytics-results>{combinations.map(item => <article class="signal-card" data-analytics-status={item.status}><div class="signal-card__top"><span class="source-chip"><span>{roleLabels[item.role] ?? item.role}</span></span><span class="quality-chip">{item.status === "recommendation" ? "Рекомендация" : "Недостаточно данных"}</span></div><h2>{item.value}</h2><dl><div><dt>Наблюдения</dt><dd>{item.catches}</dd></div><div><dt>Игроки</dt><dd>{item.unique_players}</dd></div><div><dt>Свежесть выборки</dt><dd>{item.freshness_score}%</dd></div><div><dt>Последнее</dt><dd>{ago(item.last_seen_at)}</dd></div></dl><p>{item.explanation}</p></article>)}</section> : <div data-analytics-results><StatePanel title="Подтверждённых сочетаний пока нет" description="Сочетания появятся после новых одобренных наблюдений с указанием компонентов." /></div>}
|
{unavailable ? <StatePanel tone="unavailable" title="Аналитика временно недоступна" description="Не показываем непроверенные сочетания. Попробуйте позже." /> : combinations.length ? <section class="signal-grid content-grid" aria-label="Сочетания снастей" data-analytics-results>{combinations.map(item => { const canonicalHref = item.rig_id ? `/tackle/rigs/${item.rig_id}` : item.tackle_item_id ? `/tackle/items/${item.tackle_item_id}` : null; return <article class="signal-card" data-analytics-status={item.status}><div class="signal-card__top"><span class="source-chip"><span>{roleLabels[item.role] ?? item.role}</span></span><span class="quality-chip">{item.status === "recommendation" ? "Рекомендация" : "Недостаточно данных"}</span></div><h2>{canonicalHref ? <a href={canonicalHref}>{item.value}</a> : item.value}</h2><dl><div><dt>Наблюдения</dt><dd>{item.catches}</dd></div><div><dt>Игроки</dt><dd>{item.unique_players}</dd></div><div><dt>Свежесть выборки</dt><dd>{item.freshness_score}%</dd></div><div><dt>Последнее</dt><dd>{ago(item.last_seen_at)}</dd></div></dl><p>{item.explanation}</p></article>})}</section> : <div data-analytics-results><StatePanel title="Подтверждённых сочетаний пока нет" description="Сочетания появятся после новых одобренных наблюдений с указанием компонентов." /></div>}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
+1
-1
@@ -72,7 +72,7 @@
|
|||||||
- [ ] **G04 · Crosswalk и нормализация.** Добавлен offline `gear_crosswalk`: нормализация регистра/пробелов/`е/ё`, точное имя или alias плюс совместимая категория, консервативная проверка brand/family. Неоднозначные, несовместимые, брендовые и unmatched-строки получают review-статус без canonical key; исходное значение сохраняется. Остаётся подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты.
|
- [ ] **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.
|
- [ ] **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`; рейтинг эффективности не добавляется. Публичный каталог теперь имеет detail-routes `/tackle/items/:id` и `/tackle/rigs/:id` с 404/unavailable-различением, паспортом данных и явным списком недостающих полей; CatchList показывает ordered-компоненты и ведёт в canonical detail снасти или монтажа только при наличии соответствующего ID, а unresolved raw value оставляет текстом.
|
- [ ] **G06 · API и публичный каталог.** Добавлены пагинированный `/api/v1/tackle/items` с фильтрами по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Публичный каталог теперь имеет detail-routes `/tackle/items/:id` и `/tackle/rigs/:id` с 404/unavailable-различением, паспортом данных и явным списком недостающих полей; CatchList показывает ordered-компоненты и ведёт в canonical detail снасти или монтажа только при наличии соответствующего ID, а unresolved raw value оставляет текстом.
|
||||||
- [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Публичный `/tackle/analytics` показывает и сохраняет фильтры, имеет clean reset-link, выборку, независимых игроков и раздельные `recommendation`/`insufficient_data` states без рейтинга эффективности. Добавлен объяснимый `freshness_score` с half-life `12.5` часа и контрактом `0..100`: он не меняет raw-счётчики и пороги, но стабильно ставит более свежую равную выборку выше старой и виден на карточке. Остаётся дальнейшая визуальная приёмка.
|
- [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Публичный `/tackle/analytics` показывает и сохраняет фильтры, имеет clean reset-link, выборку, независимых игроков и раздельные `recommendation`/`insufficient_data` states без рейтинга эффективности. Добавлен объяснимый `freshness_score` с half-life `12.5` часа и контрактом `0..100`: он не меняет raw-счётчики и пороги, но стабильно ставит более свежую равную выборку выше старой и виден на карточке. При однозначной canonical-привязке аналитика ведёт в карточку снасти или монтажа, а при конфликте идентификаторов сохраняет raw-значение без ложной ссылки. Остаётся дальнейшая визуальная приёмка.
|
||||||
- [ ] **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 и атомарное продвижение сохраняются. Публичный и admin UI используют единый словарь человекочитаемых role labels с безопасным fallback для будущих ролей; admin-карточки дополнительно получают `data-media-role` для проверяемой инспекции, unit-регрессия словаря проходит. Остаётся провести реальный contact-sheet review для будущих tackle-кандидатов без автоматической публикации.
|
- [ ] **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 и атомарное продвижение сохраняются. Публичный и admin UI используют единый словарь человекочитаемых role labels с безопасным fallback для будущих ролей; admin-карточки дополнительно получают `data-media-role` для проверяемой инспекции, unit-регрессия словаря проходит. Остаётся провести реальный contact-sheet review для будущих tackle-кандидатов без автоматической публикации.
|
||||||
- [ ] **G09 · Приёмка и эксплуатация.** Добавлены fixture/regression tests для crosswalk, media roles, идемпотентных компонентов, пустых результатов и многокомпонентных наблюдений; каталог `/tackle` включён в visual-matrix, narrow smoke и accessibility routes. Offline catalog/media audits и сохранение старых данных при сбое импорта проходят. TEMP-only query-plan gate для фильтра каталога и группировки сочетаний использует индексы и укладывается в 250 мс. Chromium подтвердил empty-state публичного каталога и отсутствие overflow на 320 px; обычный E2E пропускает bootstrap без явных переменных, отдельный bootstrap с токеном проходит. Остаются HTTP/browser acceptance для неоднозначных и многокомпонентных комплектов, ручной visual review и проверяемый счётчик по категориям либо явный `unknown`. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff.
|
- [ ] **G09 · Приёмка и эксплуатация.** Добавлены fixture/regression tests для crosswalk, media roles, идемпотентных компонентов, пустых результатов и многокомпонентных наблюдений; каталог `/tackle` включён в visual-matrix, narrow smoke и accessibility routes. Offline catalog/media audits и сохранение старых данных при сбое импорта проходят. TEMP-only query-plan gate для фильтра каталога и группировки сочетаний использует индексы и укладывается в 250 мс. Chromium подтвердил empty-state публичного каталога и отсутствие overflow на 320 px; обычный E2E пропускает bootstrap без явных переменных, отдельный bootstrap с токеном проходит. Остаются HTTP/browser acceptance для неоднозначных и многокомпонентных комплектов, ручной visual review и проверяемый счётчик по категориям либо явный `unknown`. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user