feat: filter tackle analytics by role

This commit is contained in:
ik
2026-09-21 19:48:49 +07:00
parent b7d69619fa
commit 6fa669b4ef
3 changed files with 15 additions and 7 deletions
+10 -5
View File
@@ -4,7 +4,7 @@ from collections import defaultdict
from datetime import datetime, timedelta, timezone
from math import exp, log
from fastapi import APIRouter, Query
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload, selectinload
@@ -27,10 +27,13 @@ def tackle_combinations(
waterbody: str | None = None,
fish: str | None = None,
method: str | None = None,
role: str | None = None,
hours: int = Query(72, ge=24, le=168),
min_samples: int = Query(3, ge=1, le=100),
min_players: int = Query(2, ge=1, le=100),
) -> list[TackleCombinationOut]:
if role is not None and role not in {"lure", "bait", "rig", "rod", "reel", "line", "hook", "float", "sinker"}:
raise HTTPException(status_code=422, detail="invalid tackle role")
now = datetime.now(timezone.utc)
query = select(CatchReport).options(
joinedload(CatchReport.fish), joinedload(CatchReport.waterbody),
@@ -50,23 +53,25 @@ def tackle_combinations(
groups: dict[tuple[str, str], list[CatchReport]] = defaultdict(list)
for report in db.scalars(query):
for component in report.tackle_components:
if role and component.role != role:
continue
if component.raw_value.strip():
groups[(component.role, component.raw_value.strip())].append(report)
result = []
for (role, value), reports in groups.items():
for (component_role, value), reports in groups.items():
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
if component.role == component_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
if component.role == component_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
@@ -80,7 +85,7 @@ def tackle_combinations(
) / catches * 100)
enough = catches >= min_samples and unique_players >= min_players
result.append(TackleCombinationOut(
role=role, value=value, tackle_item_id=canonical_item_id, rig_id=canonical_rig_id,
role=component_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,
status="recommendation" if enough else "insufficient_data",
+4 -1
View File
@@ -8,11 +8,13 @@ const params = Astro.url.searchParams;
const waterbody = params.get("waterbody") ?? "";
const fish = params.get("fish") ?? "";
const method = params.get("method") ?? "";
const role = params.get("role") ?? "";
const hours = [24, 72, 168].includes(Number(params.get("hours"))) ? Number(params.get("hours")) : 72;
const query = new URLSearchParams({ hours: String(hours), min_samples: "3", min_players: "2" });
if (waterbody) query.set("waterbody", waterbody);
if (fish) query.set("fish", fish);
if (method) query.set("method", method);
if (role) query.set("role", role);
let combinations: TackleCombination[] = [], waters: DictionaryItem[] = [], fishes: DictionaryItem[] = [], unavailable = false;
try {
[combinations, waters, fishes] = await Promise.all([
@@ -31,9 +33,10 @@ const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Н
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waters.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label>
<label>Рыба<select name="fish"><option value="">Все виды</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label>
<label>Метод<select name="method"><option value="">Все методы</option>{Object.entries(methodLabels).map(([value, label]) => <option value={value} selected={method === value}>{label}</option>)}</select></label>
<label>Роль<select name="role"><option value="">Все роли</option>{Object.entries(roleLabels).map(([value, label]) => <option value={value} selected={role === value}>{label}</option>)}</select></label>
<label>Период<select name="hours">{[24, 72, 168].map(value => <option value={value} selected={hours === value}>{value} ч</option>)}</select></label>
<button data-action="primary">Показать</button>
{(waterbody || fish || method || hours !== 72) && <a data-action="quiet" href="/tackle/analytics">Сбросить</a>}
{(waterbody || fish || method || role || hours !== 72) && <a data-action="quiet" href="/tackle/analytics">Сбросить</a>}
</form>
<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 => { 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>}
+1 -1
View File
@@ -72,7 +72,7 @@
- [ ] **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 и публичный каталог.** Добавлены пагинированные `/api/v1/tackle/items` и `/api/v1/tackle/rigs` с фильтрами предметов по категории, бренду, семейству и уровню, 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-счётчики и пороги, но стабильно ставит более свежую равную выборку выше старой и виден на карточке. При однозначной canonical-привязке аналитика ведёт в карточку снасти или монтажа, а при конфликте идентификаторов сохраняет 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 для будущих ролей; публичная медиатека фильтрует разрешённые роли через URL/API, 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.