test: make activity index deterministic
This commit is contained in:
@@ -6,9 +6,9 @@ RF4 Spotter — неофициальный сервис свежих точек
|
|||||||
|
|
||||||
- этапы 0 и 1 завершены;
|
- этапы 0 и 1 завершены;
|
||||||
- этап 2, официальный импорт, завершён технически; автоматический профиль остаётся выключенным до явного разрешения владельца источника;
|
- этап 2, официальный импорт, завершён технически; автоматический профиль остаётся выключенным до явного разрешения владельца источника;
|
||||||
- этап 3 выполнен частично: форма, MinIO, модерация, удаление с аудитом и постоянный rate limit готовы;
|
- этап 3 функционально завершён: форма, раздельные ошибки создания/скриншота с повторной загрузкой, MinIO, модерация, удаление с аудитом и постоянный rate limit готовы;
|
||||||
- ближайшие задачи — сквозной тест полного пользовательского сценария и раздельные состояния ошибок формы;
|
- для полного пользовательского сценария добавлен E2E-тест `отправка → pending → модерация → публичная статистика`;
|
||||||
- затем начинается этап 4: формализация и расширенное тестирование индекса клёва.
|
- начат этап 4: формула индекса зафиксирована, детерминированные агрегаты и правила включения данных покрыты тестами; далее — состояния карточек и сквозная проверка фильтров.
|
||||||
|
|
||||||
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md).
|
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md).
|
||||||
|
|
||||||
@@ -54,10 +54,12 @@ docker compose up --build
|
|||||||
- справочники рыб, водоёмов и приманок;
|
- справочники рыб, водоёмов и приманок;
|
||||||
- Astro SSR-интерфейс с адаптивным дизайном из `design-reference` без переноса React/Vinext-стека;
|
- Astro SSR-интерфейс с адаптивным дизайном из `design-reference` без переноса React/Vinext-стека;
|
||||||
- объяснимые индексы активности и уверенности по формуле спецификации;
|
- объяснимые индексы активности и уверенности по формуле спецификации;
|
||||||
|
- документированная формула и детерминированные тесты окон 6/12/24/72 часа;
|
||||||
- состояния «нет данных» и «источник недоступен»;
|
- состояния «нет данных» и «источник недоступен»;
|
||||||
- идемпотентный импорт официальных записей с журналом запусков;
|
- идемпотентный импорт официальных записей с журналом запусков;
|
||||||
- публичная страница `/records` с источником и временем последнего импорта;
|
- публичная страница `/records` с источником и временем последнего импорта;
|
||||||
- форма `/report`, защищённые admin API и журнал модерации;
|
- форма `/report`, защищённые admin API и журнал модерации;
|
||||||
|
- отдельные состояния ошибки создания заявки и загрузки скриншота; неудачный скриншот можно добавить повторно по ID уже сохранённой заявки;
|
||||||
- honeypot и постоянный rate limit в PostgreSQL с HMAC-отпечатками вместо исходных IP;
|
- honeypot и постоянный rate limit в PostgreSQL с HMAC-отпечатками вместо исходных IP;
|
||||||
- скриншоты уловов в MinIO/S3 с проверкой MIME, расширения, размера и фактического содержимого, повторным кодированием и очисткой метаданных;
|
- скриншоты уловов в MinIO/S3 с проверкой MIME, расширения, размера и фактического содержимого, повторным кодированием и очисткой метаданных;
|
||||||
- административная очередь `/admin/moderation` с одобрением, отклонением и обезличенным удалением записи с аудитом.
|
- административная очередь `/admin/moderation` с одобрением, отклонением и обезличенным удалением записи с аудитом.
|
||||||
@@ -75,7 +77,7 @@ python3 -m venv .venv
|
|||||||
.venv/bin/pytest -q
|
.venv/bin/pytest -q
|
||||||
```
|
```
|
||||||
|
|
||||||
На текущем этапе набор содержит 21 backend/parser-тест.
|
Актуальное число тестов выводит команда `pytest`; набор включает backend, импорт, расчёт активности и исследовательский парсер.
|
||||||
|
|
||||||
Frontend:
|
Frontend:
|
||||||
|
|
||||||
@@ -125,6 +127,8 @@ curl -H "Authorization: Bearer change-me-in-production" \
|
|||||||
|
|
||||||
Перед внешним развёртыванием обязательно замените демонстрационные `ADMIN_TOKEN`, `RATE_LIMIT_SECRET`, `S3_ACCESS_KEY` и `S3_SECRET_KEY`. Форма принимает JPEG, PNG и WebP до 8 МБ; API сверяет MIME и расширение с фактическим форматом, повторно кодирует изображение и удаляет EXIF перед сохранением в MinIO. Модератор получает временную подписанную ссылку через admin API.
|
Перед внешним развёртыванием обязательно замените демонстрационные `ADMIN_TOKEN`, `RATE_LIMIT_SECRET`, `S3_ACCESS_KEY` и `S3_SECRET_KEY`. Форма принимает JPEG, PNG и WebP до 8 МБ; API сверяет MIME и расширение с фактическим форматом, повторно кодирует изображение и удаляет EXIF перед сохранением в MinIO. Модератор получает временную подписанную ссылку через admin API.
|
||||||
|
|
||||||
|
Если создание записи прошло успешно, а загрузка скриншота завершилась ошибкой, форма сохраняет ID заявки и предлагает повторить только загрузку изображения. Повторно отправлять сам улов не требуется.
|
||||||
|
|
||||||
Очередь модерации доступна по адресу <http://localhost:4321/admin/moderation>. Администратор вводит `ADMIN_TOKEN`; интерфейс держит его только в памяти открытой страницы и не сохраняет в URL или браузерном хранилище.
|
Очередь модерации доступна по адресу <http://localhost:4321/admin/moderation>. Администратор вводит `ADMIN_TOKEN`; интерфейс держит его только в памяти открытой страницы и не сохраняет в URL или браузерном хранилище.
|
||||||
|
|
||||||
Администратор может одобрить, отклонить или удалить сообщение. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита.
|
Администратор может одобрить, отклонить или удалить сообщение. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита.
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ def activity_rows(
|
|||||||
waterbody: str | None = None,
|
waterbody: str | None = None,
|
||||||
fish: str | None = None,
|
fish: str | None = None,
|
||||||
method: str | None = None,
|
method: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
) -> list[ActivityOut]:
|
) -> list[ActivityOut]:
|
||||||
now = datetime.now(timezone.utc)
|
now = _aware(now or datetime.now(timezone.utc))
|
||||||
query = (
|
query = (
|
||||||
select(CatchReport)
|
select(CatchReport)
|
||||||
.options(
|
.options(
|
||||||
@@ -28,6 +29,7 @@ def activity_rows(
|
|||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
CatchReport.moderation_status == ModerationStatus.approved,
|
CatchReport.moderation_status == ModerationStatus.approved,
|
||||||
|
CatchReport.deleted_at.is_(None),
|
||||||
CatchReport.spot_id.is_not(None),
|
CatchReport.spot_id.is_not(None),
|
||||||
CatchReport.reported_at >= now - timedelta(hours=hours),
|
CatchReport.reported_at >= now - timedelta(hours=hours),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.activity import activity_rows
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 9, 3, 6, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db() -> Session:
|
||||||
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as session:
|
||||||
|
yield session
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def add_report(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
age_hours: float,
|
||||||
|
player: str | None = "Player",
|
||||||
|
confidence: int = 100,
|
||||||
|
status: ModerationStatus = ModerationStatus.approved,
|
||||||
|
deleted: bool = False,
|
||||||
|
source: SourceType = SourceType.user,
|
||||||
|
weight_g: int = 5_000,
|
||||||
|
) -> CatchReport:
|
||||||
|
waterbody = db.query(Waterbody).first()
|
||||||
|
fish = db.query(Fish).first()
|
||||||
|
spot = db.query(Spot).first()
|
||||||
|
if waterbody is None:
|
||||||
|
waterbody = Waterbody(slug="test-lake", name_ru="Тестовое озеро", unlock_level=1)
|
||||||
|
fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000)
|
||||||
|
spot = Spot(waterbody=waterbody, x=10, y=20, description=None)
|
||||||
|
db.add_all([waterbody, fish, spot])
|
||||||
|
db.flush()
|
||||||
|
report = CatchReport(
|
||||||
|
fish=fish,
|
||||||
|
spot=spot,
|
||||||
|
waterbody=waterbody,
|
||||||
|
bait=None,
|
||||||
|
weight_g=weight_g,
|
||||||
|
fishing_method="spinning",
|
||||||
|
caught_at=NOW - timedelta(hours=age_hours),
|
||||||
|
reported_at=NOW - timedelta(hours=age_hours),
|
||||||
|
player_name=player,
|
||||||
|
source_type=source,
|
||||||
|
source_confidence=confidence,
|
||||||
|
moderation_status=status,
|
||||||
|
deleted_at=NOW if deleted else None,
|
||||||
|
)
|
||||||
|
db.add(report)
|
||||||
|
db.commit()
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def test_freshness_and_source_confidence_follow_documented_formula(db: Session) -> None:
|
||||||
|
add_report(db, age_hours=18, confidence=80)
|
||||||
|
|
||||||
|
row = activity_rows(db, hours=24, now=NOW)[0]
|
||||||
|
weighted = math.exp(-1) * 0.8
|
||||||
|
expected_activity = round(55 * weighted / 12 + 25 / 6)
|
||||||
|
expected_confidence = round(45 / 10 + 35 / 5 + 20 * 0.8)
|
||||||
|
|
||||||
|
assert row.activity_score == expected_activity
|
||||||
|
assert row.confidence_score == expected_confidence
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_reports_from_one_player_do_not_add_unique_player_weight(db: Session) -> None:
|
||||||
|
add_report(db, age_hours=1, player=" Same Player ")
|
||||||
|
add_report(db, age_hours=1, player="same player")
|
||||||
|
add_report(db, age_hours=1, player=None)
|
||||||
|
|
||||||
|
row = activity_rows(db, hours=6, now=NOW)[0]
|
||||||
|
|
||||||
|
assert row.catches == 3
|
||||||
|
assert row.unique_players == 1
|
||||||
|
assert "3 свежих уловов от 1 игроков" in row.explanation
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_rejected_and_deleted_reports_are_excluded(db: Session) -> None:
|
||||||
|
add_report(db, age_hours=1)
|
||||||
|
add_report(db, age_hours=1, status=ModerationStatus.pending)
|
||||||
|
add_report(db, age_hours=1, status=ModerationStatus.rejected)
|
||||||
|
add_report(db, age_hours=1, deleted=True)
|
||||||
|
|
||||||
|
row = activity_rows(db, hours=6, now=NOW)[0]
|
||||||
|
|
||||||
|
assert row.catches == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("hours", "expected_catches"),
|
||||||
|
[(6, 1), (12, 2), (24, 3), (72, 4)],
|
||||||
|
)
|
||||||
|
def test_supported_windows_are_deterministic(db: Session, hours: int, expected_catches: int) -> None:
|
||||||
|
for age in (5, 11, 23, 71, 73):
|
||||||
|
add_report(db, age_hours=age, player=f"Player {age}")
|
||||||
|
|
||||||
|
row = activity_rows(db, hours=hours, now=NOW)[0]
|
||||||
|
|
||||||
|
assert row.catches == expected_catches
|
||||||
|
|
||||||
|
|
||||||
|
def test_reports_without_coordinates_do_not_create_activity_group(db: Session) -> None:
|
||||||
|
report = add_report(db, age_hours=1, source=SourceType.official_record)
|
||||||
|
report.spot = None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert activity_rows(db, hours=72, now=NOW) == []
|
||||||
+11
-11
@@ -34,17 +34,17 @@
|
|||||||
- [x] Добавить удаление пользовательского сообщения администратором с аудитом действия (обезличивание записи, удаление объекта MinIO, миграция `0006`).
|
- [x] Добавить удаление пользовательского сообщения администратором с аудитом действия (обезличивание записи, удаление объекта MinIO, миграция `0006`).
|
||||||
- [x] Заменить in-memory rate limit на общее хранилище, пригодное для нескольких API-процессов и перезапусков (PostgreSQL, HMAC-отпечаток без хранения исходного IP, миграция `0007`).
|
- [x] Заменить in-memory rate limit на общее хранилище, пригодное для нескольких API-процессов и перезапусков (PostgreSQL, HMAC-отпечаток без хранения исходного IP, миграция `0007`).
|
||||||
- [x] Валидировать одновременно содержимое, MIME, расширение и лимит изображения; добавить тесты каждого отказа.
|
- [x] Валидировать одновременно содержимое, MIME, расширение и лимит изображения; добавить тесты каждого отказа.
|
||||||
- [ ] Добавить сквозной тест: отправка → pending → модерация → появление одобренного улова в публичной статистике.
|
- [x] Добавить сквозной тест: отправка → pending → модерация → появление одобренного улова в публичной статистике (Compose/Playwright: `2 passed`).
|
||||||
- [ ] Добавить понятные состояния успеха и ошибок загрузки в форму, включая отдельную ошибку скриншота без потери уже созданной заявки.
|
- [x] Добавить понятные состояния успеха и ошибок загрузки в форму, включая отдельную ошибку скриншота без потери уже созданной заявки (повторная загрузка по ID сохранённой заявки).
|
||||||
|
|
||||||
Критерий готовности: полный пользовательский сценарий проходит через браузер, а модератору не нужен ручной вызов API.
|
Критерий готовности: полный пользовательский сценарий проходит через браузер, а модератору не нужен ручной вызов API.
|
||||||
|
|
||||||
## Этап 4 — индекс клёва
|
## Этап 4 — индекс клёва
|
||||||
|
|
||||||
- [ ] Сверить текущую формулу активности и уверенности с разделом 9 спецификации и зафиксировать формулу в `docs/activity-index.md`.
|
- [x] Сверить текущую формулу активности и уверенности с разделом 11 спецификации и зафиксировать формулу в `docs/activity-index.md`.
|
||||||
- [ ] Покрыть unit-тестами затухание по свежести, вес официальных и пользовательских источников, дубликаты и вклад разных игроков.
|
- [x] Покрыть unit-тестами затухание по свежести, доверие к официальным и пользовательским источникам, повторные сообщения одного игрока и вклад разных игроков.
|
||||||
- [ ] Не учитывать pending/rejected записи и доказать это тестами.
|
- [x] Не учитывать pending/rejected/удалённые записи и доказать это тестами.
|
||||||
- [ ] Добавить детерминированные агрегаты для окон 6, 12, 24 и 72 часа.
|
- [x] Добавить детерминированные агрегаты для окон 6, 12, 24 и 72 часа.
|
||||||
- [ ] На карточке и странице точки показывать человекочитаемое объяснение оценки и объём данных, на котором она основана.
|
- [ ] На карточке и странице точки показывать человекочитаемое объяснение оценки и объём данных, на котором она основана.
|
||||||
- [ ] Реализовать и проверить состояния «данных мало», «данных нет», «источник недоступен» и ошибки валидации фильтров.
|
- [ ] Реализовать и проверить состояния «данных мало», «данных нет», «источник недоступен» и ошибки валидации фильтров.
|
||||||
- [ ] Проверить фильтры главной страницы сквозным тестом на desktop и mobile.
|
- [ ] Проверить фильтры главной страницы сквозным тестом на desktop и mobile.
|
||||||
@@ -75,12 +75,12 @@
|
|||||||
|
|
||||||
## Ближайший рабочий пакет
|
## Ближайший рабочий пакет
|
||||||
|
|
||||||
Следующим завершается этап 3:
|
Этап 3 завершён. Следующий пакет продолжает этап 4:
|
||||||
|
|
||||||
1. сквозной тест `отправка → pending → модерация → публичная статистика`;
|
1. человекочитаемые состояния и объём данных на карточках этапа 4;
|
||||||
2. раздельные состояния успеха, ошибки создания заявки и ошибки загрузки скриншота без потери заявки;
|
2. состояния «данных мало», «данных нет», «источник недоступен» и ошибки фильтров;
|
||||||
3. финальная проверка этапа 3 в Docker и браузере на desktop и 390 px;
|
3. сквозная проверка фильтров главной страницы на desktop и mobile;
|
||||||
4. переход к документации и тестам индекса клёва из этапа 4.
|
4. переход к health/readiness и структурированным логам подготовки MVP.
|
||||||
|
|
||||||
После каждого пункта необходимо:
|
После каждого пункта необходимо:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Индекс активности RF4 Spotter
|
||||||
|
|
||||||
|
Индекс — сравнительная оценка свежести подтверждённых наблюдений, а не вероятность поклёвки. Он рассчитывается отдельно для комбинации «водоём + точка + рыба» в выбранном окне 6, 12, 24 или 72 часа.
|
||||||
|
|
||||||
|
## Какие данные участвуют
|
||||||
|
|
||||||
|
В расчёт входят только одобренные и не удалённые записи с координатами точки. `pending`, `rejected`, удалённые записи и официальные рекорды без координат не участвуют. Фильтры водоёма, рыбы и способа ловли применяются до агрегации.
|
||||||
|
|
||||||
|
Официальный импорт устраняет точные дубликаты по `source_external_id`. Несколько пользовательских сообщений остаются отдельными наблюдениями, но одинаковое имя игрока после удаления пробелов и приведения регистра учитывается в `unique_players` только один раз. Пустое имя игроком не считается. Поэтому один игрок может увеличить объём и свежесть данных, но не может имитировать несколько независимых источников.
|
||||||
|
|
||||||
|
## Формула активности
|
||||||
|
|
||||||
|
Для каждого сообщения с возрастом `age_hours` и доверием к источнику `source_confidence` от 0 до 100:
|
||||||
|
|
||||||
|
```text
|
||||||
|
freshness_i = exp(-age_hours_i / 18)
|
||||||
|
weighted_reports = sum(freshness_i * source_confidence_i / 100)
|
||||||
|
unique_players = количество уникальных непустых player_name
|
||||||
|
trophy_bonus = min(1, trophy_count / 3)
|
||||||
|
|
||||||
|
activity_score = round(
|
||||||
|
55 * min(1, weighted_reports / 12)
|
||||||
|
+ 25 * min(1, unique_players / 6)
|
||||||
|
+ 20 * trophy_bonus
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Период полураспада вклада по свежести равен примерно 12,5 часа. Трофей определяется по порогу `trophy_weight_g` конкретного вида рыбы.
|
||||||
|
|
||||||
|
## Формула уверенности
|
||||||
|
|
||||||
|
```text
|
||||||
|
confidence_score = round(
|
||||||
|
45 * min(1, approved_reports / 10)
|
||||||
|
+ 35 * min(1, unique_players / 5)
|
||||||
|
+ 20 * average_source_confidence / 100
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Обе оценки ограничены диапазоном 0–100 составляющими формулы. На карточке рядом с числом показываются число уловов, число уникальных игроков, время последнего подтверждения и пометка «Данных мало» при числе наблюдений меньше трёх.
|
||||||
|
|
||||||
|
## Воспроизводимость и ограничения
|
||||||
|
|
||||||
|
Расчёт использует время сервера в UTC; в тестах опорное время задаётся явно. Граница окна включительна: запись ровно на границе входит в расчёт. Формула соответствует разделу 11 `RF4_MVP_SPEC.md` и должна быть откалибрована после накопления реальных данных. Она пока не выявляет семантически похожие пользовательские сообщения от разных имён и не заменяет модерацию.
|
||||||
Reference in New Issue
Block a user