feat: filter tackle analytics by role
This commit is contained in:
@@ -4,7 +4,7 @@ from collections import defaultdict
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from math import exp, log
|
from math import exp, log
|
||||||
|
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session, joinedload, selectinload
|
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||||
|
|
||||||
@@ -27,10 +27,13 @@ def tackle_combinations(
|
|||||||
waterbody: str | None = None,
|
waterbody: str | None = None,
|
||||||
fish: str | None = None,
|
fish: str | None = None,
|
||||||
method: str | None = None,
|
method: str | None = None,
|
||||||
|
role: str | None = None,
|
||||||
hours: int = Query(72, ge=24, le=168),
|
hours: int = Query(72, ge=24, le=168),
|
||||||
min_samples: int = Query(3, ge=1, le=100),
|
min_samples: int = Query(3, ge=1, le=100),
|
||||||
min_players: int = Query(2, ge=1, le=100),
|
min_players: int = Query(2, ge=1, le=100),
|
||||||
) -> list[TackleCombinationOut]:
|
) -> 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)
|
now = datetime.now(timezone.utc)
|
||||||
query = select(CatchReport).options(
|
query = select(CatchReport).options(
|
||||||
joinedload(CatchReport.fish), joinedload(CatchReport.waterbody),
|
joinedload(CatchReport.fish), joinedload(CatchReport.waterbody),
|
||||||
@@ -50,23 +53,25 @@ def tackle_combinations(
|
|||||||
groups: dict[tuple[str, str], list[CatchReport]] = defaultdict(list)
|
groups: dict[tuple[str, str], list[CatchReport]] = defaultdict(list)
|
||||||
for report in db.scalars(query):
|
for report in db.scalars(query):
|
||||||
for component in report.tackle_components:
|
for component in report.tackle_components:
|
||||||
|
if role and component.role != role:
|
||||||
|
continue
|
||||||
if component.raw_value.strip():
|
if component.raw_value.strip():
|
||||||
groups[(component.role, component.raw_value.strip())].append(report)
|
groups[(component.role, component.raw_value.strip())].append(report)
|
||||||
|
|
||||||
result = []
|
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}
|
unique_reports = {report.id: report for report in reports}
|
||||||
item_ids = {
|
item_ids = {
|
||||||
component.tackle_item_id
|
component.tackle_item_id
|
||||||
for report in unique_reports.values()
|
for report in unique_reports.values()
|
||||||
for component in report.tackle_components
|
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 = {
|
rig_ids = {
|
||||||
component.rig_id
|
component.rig_id
|
||||||
for report in unique_reports.values()
|
for report in unique_reports.values()
|
||||||
for component in report.tackle_components
|
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_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
|
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)
|
) / 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, 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,
|
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",
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ const params = Astro.url.searchParams;
|
|||||||
const waterbody = params.get("waterbody") ?? "";
|
const waterbody = params.get("waterbody") ?? "";
|
||||||
const fish = params.get("fish") ?? "";
|
const fish = params.get("fish") ?? "";
|
||||||
const method = params.get("method") ?? "";
|
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 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" });
|
const query = new URLSearchParams({ hours: String(hours), min_samples: "3", min_players: "2" });
|
||||||
if (waterbody) query.set("waterbody", waterbody);
|
if (waterbody) query.set("waterbody", waterbody);
|
||||||
if (fish) query.set("fish", fish);
|
if (fish) query.set("fish", fish);
|
||||||
if (method) query.set("method", method);
|
if (method) query.set("method", method);
|
||||||
|
if (role) query.set("role", role);
|
||||||
let combinations: TackleCombination[] = [], waters: DictionaryItem[] = [], fishes: DictionaryItem[] = [], unavailable = false;
|
let combinations: TackleCombination[] = [], waters: DictionaryItem[] = [], fishes: DictionaryItem[] = [], unavailable = false;
|
||||||
try {
|
try {
|
||||||
[combinations, waters, fishes] = await Promise.all([
|
[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="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="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="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>
|
<label>Период<select name="hours">{[24, 72, 168].map(value => <option value={value} selected={hours === value}>{value} ч</option>)}</select></label>
|
||||||
<button data-action="primary">Показать</button>
|
<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>
|
</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 => { 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>}
|
{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
@@ -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` и `/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 оставляет текстом.
|
- [ ] **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-кандидатов без автоматической публикации.
|
- [ ] **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.
|
- [ ] **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