diff --git a/README.md b/README.md index 84c3d30..6af6ee4 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ RF4 Spotter — неофициальный сервис свежих точек - этапы 0 и 1 завершены; - этап 2, официальный импорт, завершён технически; автоматический профиль остаётся выключенным до явного разрешения владельца источника; -- этап 3 выполнен частично: форма, MinIO, модерация, удаление с аудитом и постоянный rate limit готовы; -- ближайшие задачи — сквозной тест полного пользовательского сценария и раздельные состояния ошибок формы; -- затем начинается этап 4: формализация и расширенное тестирование индекса клёва. +- этап 3 функционально завершён: форма, раздельные ошибки создания/скриншота с повторной загрузкой, MinIO, модерация, удаление с аудитом и постоянный rate limit готовы; +- для полного пользовательского сценария добавлен E2E-тест `отправка → pending → модерация → публичная статистика`; +- начат этап 4: формула индекса зафиксирована, детерминированные агрегаты и правила включения данных покрыты тестами; далее — состояния карточек и сквозная проверка фильтров. Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). @@ -54,10 +54,12 @@ docker compose up --build - справочники рыб, водоёмов и приманок; - Astro SSR-интерфейс с адаптивным дизайном из `design-reference` без переноса React/Vinext-стека; - объяснимые индексы активности и уверенности по формуле спецификации; +- документированная формула и детерминированные тесты окон 6/12/24/72 часа; - состояния «нет данных» и «источник недоступен»; - идемпотентный импорт официальных записей с журналом запусков; - публичная страница `/records` с источником и временем последнего импорта; - форма `/report`, защищённые admin API и журнал модерации; +- отдельные состояния ошибки создания заявки и загрузки скриншота; неудачный скриншот можно добавить повторно по ID уже сохранённой заявки; - honeypot и постоянный rate limit в PostgreSQL с HMAC-отпечатками вместо исходных IP; - скриншоты уловов в MinIO/S3 с проверкой MIME, расширения, размера и фактического содержимого, повторным кодированием и очисткой метаданных; - административная очередь `/admin/moderation` с одобрением, отклонением и обезличенным удалением записи с аудитом. @@ -75,7 +77,7 @@ python3 -m venv .venv .venv/bin/pytest -q ``` -На текущем этапе набор содержит 21 backend/parser-тест. +Актуальное число тестов выводит команда `pytest`; набор включает backend, импорт, расчёт активности и исследовательский парсер. 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. +Если создание записи прошло успешно, а загрузка скриншота завершилась ошибкой, форма сохраняет ID заявки и предлагает повторить только загрузку изображения. Повторно отправлять сам улов не требуется. + Очередь модерации доступна по адресу . Администратор вводит `ADMIN_TOKEN`; интерфейс держит его только в памяти открытой страницы и не сохраняет в URL или браузерном хранилище. Администратор может одобрить, отклонить или удалить сообщение. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита. diff --git a/apps/api/app/activity.py b/apps/api/app/activity.py index a66ab8a..085a501 100644 --- a/apps/api/app/activity.py +++ b/apps/api/app/activity.py @@ -18,8 +18,9 @@ def activity_rows( waterbody: str | None = None, fish: str | None = None, method: str | None = None, + now: datetime | None = None, ) -> list[ActivityOut]: - now = datetime.now(timezone.utc) + now = _aware(now or datetime.now(timezone.utc)) query = ( select(CatchReport) .options( @@ -28,6 +29,7 @@ def activity_rows( ) .where( CatchReport.moderation_status == ModerationStatus.approved, + CatchReport.deleted_at.is_(None), CatchReport.spot_id.is_not(None), CatchReport.reported_at >= now - timedelta(hours=hours), ) diff --git a/apps/api/tests/test_activity.py b/apps/api/tests/test_activity.py new file mode 100644 index 0000000..78adf0e --- /dev/null +++ b/apps/api/tests/test_activity.py @@ -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) == [] diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3e79421..6b030c4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -34,17 +34,17 @@ - [x] Добавить удаление пользовательского сообщения администратором с аудитом действия (обезличивание записи, удаление объекта MinIO, миграция `0006`). - [x] Заменить in-memory rate limit на общее хранилище, пригодное для нескольких API-процессов и перезапусков (PostgreSQL, HMAC-отпечаток без хранения исходного IP, миграция `0007`). - [x] Валидировать одновременно содержимое, MIME, расширение и лимит изображения; добавить тесты каждого отказа. -- [ ] Добавить сквозной тест: отправка → pending → модерация → появление одобренного улова в публичной статистике. -- [ ] Добавить понятные состояния успеха и ошибок загрузки в форму, включая отдельную ошибку скриншота без потери уже созданной заявки. +- [x] Добавить сквозной тест: отправка → pending → модерация → появление одобренного улова в публичной статистике (Compose/Playwright: `2 passed`). +- [x] Добавить понятные состояния успеха и ошибок загрузки в форму, включая отдельную ошибку скриншота без потери уже созданной заявки (повторная загрузка по ID сохранённой заявки). Критерий готовности: полный пользовательский сценарий проходит через браузер, а модератору не нужен ручной вызов API. ## Этап 4 — индекс клёва -- [ ] Сверить текущую формулу активности и уверенности с разделом 9 спецификации и зафиксировать формулу в `docs/activity-index.md`. -- [ ] Покрыть unit-тестами затухание по свежести, вес официальных и пользовательских источников, дубликаты и вклад разных игроков. -- [ ] Не учитывать pending/rejected записи и доказать это тестами. -- [ ] Добавить детерминированные агрегаты для окон 6, 12, 24 и 72 часа. +- [x] Сверить текущую формулу активности и уверенности с разделом 11 спецификации и зафиксировать формулу в `docs/activity-index.md`. +- [x] Покрыть unit-тестами затухание по свежести, доверие к официальным и пользовательским источникам, повторные сообщения одного игрока и вклад разных игроков. +- [x] Не учитывать pending/rejected/удалённые записи и доказать это тестами. +- [x] Добавить детерминированные агрегаты для окон 6, 12, 24 и 72 часа. - [ ] На карточке и странице точки показывать человекочитаемое объяснение оценки и объём данных, на котором она основана. - [ ] Реализовать и проверить состояния «данных мало», «данных нет», «источник недоступен» и ошибки валидации фильтров. - [ ] Проверить фильтры главной страницы сквозным тестом на desktop и mobile. @@ -75,12 +75,12 @@ ## Ближайший рабочий пакет -Следующим завершается этап 3: +Этап 3 завершён. Следующий пакет продолжает этап 4: -1. сквозной тест `отправка → pending → модерация → публичная статистика`; -2. раздельные состояния успеха, ошибки создания заявки и ошибки загрузки скриншота без потери заявки; -3. финальная проверка этапа 3 в Docker и браузере на desktop и 390 px; -4. переход к документации и тестам индекса клёва из этапа 4. +1. человекочитаемые состояния и объём данных на карточках этапа 4; +2. состояния «данных мало», «данных нет», «источник недоступен» и ошибки фильтров; +3. сквозная проверка фильтров главной страницы на desktop и mobile; +4. переход к health/readiness и структурированным логам подготовки MVP. После каждого пункта необходимо: diff --git a/docs/activity-index.md b/docs/activity-index.md new file mode 100644 index 0000000..ecf838f --- /dev/null +++ b/docs/activity-index.md @@ -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` и должна быть откалибрована после накопления реальных данных. Она пока не выявляет семантически похожие пользовательские сообщения от разных имён и не заменяет модерацию.