diff --git a/README.md b/README.md index 8fc6051..e2ffbe7 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ RF4 Spotter — неофициальный сервис свежих точек RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Для разрешённых community-источников действует интервал не менее 30 минут на источник. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации. +На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. + Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md). Production-контур для домена `rf4spotter.ru`, TLS, секреты, backup/restore и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. Production seed добавляет только справочники — демонстрационные уловы отключены. Изолированные проверки `deploy/test-production-bootstrap.sh` и `deploy/test-backup-restore.sh` подтверждают старт с пустых volumes и восстановление данных. diff --git a/apps/api/app/activity.py b/apps/api/app/activity.py index a6e7d02..01928e9 100644 --- a/apps/api/app/activity.py +++ b/apps/api/app/activity.py @@ -69,10 +69,22 @@ def activity_rows( max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest, activity_score=activity, confidence_score=confidence, explanation=_explanation(len(items), len(players), freshness_text, activity, confidence), + sources=sorted({_source_system(item) for item in items}), )) return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True) +def _source_system(report: CatchReport) -> str: + provenance = (report.raw_payload or {}).get("provenance", {}) + if isinstance(provenance, dict) and provenance.get("source_system"): + return str(provenance["source_system"]) + if report.source_type.value == "official_record": + return "rf4-official" + if report.source_type.value == "user": + return "players" + return "manual-import" + + def _aware(value: datetime) -> datetime: return value if value.tzinfo else value.replace(tzinfo=timezone.utc) diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 941a584..9d9406b 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -24,9 +24,9 @@ from .config import settings from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize from .logging_config import configure_logging -from .models import Bait, BaitKind, CatchReport, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody +from .models import Bait, BaitKind, CatchReport, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody from .readiness import readiness_report -from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut +from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SpotOut, WaterbodyOut from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot @@ -150,7 +150,50 @@ def spot_detail(spot_id: UUID, db: Db) -> SpotOut: def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]: _spot_or_404(db, spot_id) reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit))) - return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed) for r in reports] + return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed, source_system=_report_source(r), source_url=r.source_url) for r in reports] + + +def _report_source(report: CatchReport) -> str: + provenance = (report.raw_payload or {}).get("provenance", {}) + if isinstance(provenance, dict) and provenance.get("source_system"): + return str(provenance["source_system"]) + if report.source_type == SourceType.official_record: + return "rf4-official" + if report.source_type == SourceType.user: + return "players" + return "manual-import" + + +@app.get("/api/v1/community-observations", response_model=list[PublicObservationOut]) +def community_observations( + db: Db, limit: int = Query(12, ge=1, le=50), offset: int = Query(0, ge=0), +) -> list[PublicObservationOut]: + items = list(db.scalars( + select(ExternalObservation) + .join(ExternalObservation.source) + .options(joinedload(ExternalObservation.source)) + .where( + ExternalObservation.catch_report_id.is_(None), + ExternalObservation.status != "rejected", + DataSource.enabled.is_(True), + ) + .order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc()) + .offset(offset).limit(limit) + )) + result: list[PublicObservationOut] = [] + for item in items: + missing = [] + if item.x is None or item.y is None: + missing.append("координаты") + if item.weight_g is None: + missing.append("вес") + result.append(PublicObservationOut( + id=item.id, source_system=item.source_system, source_name=item.source.name, + source_url=item.source_url, fish_name=item.fish_name, waterbody_name=item.waterbody_name, + x=item.x, y=item.y, weight_g=item.weight_g, last_seen_at=item.last_seen_at, + missing_fields=missing, quality="incomplete" if missing else "unverified", + )) + return result @app.get("/api/v1/records", response_model=list[OfficialRecordOut]) diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index df2f0ff..bc20fb9 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -47,6 +47,7 @@ class ActivityOut(BaseModel): activity_score: int confidence_score: int explanation: str + sources: list[str] class CatchOut(BaseModel): @@ -59,6 +60,8 @@ class CatchOut(BaseModel): reported_at: datetime retrieve_method: str | None retrieve_speed: int | None + source_system: str + source_url: str | None class SpotOut(BaseModel): @@ -85,6 +88,22 @@ class OfficialRecordOut(BaseModel): category: str | None region: str | None source_url: str | None + source_system: str = "rf4-official" + + +class PublicObservationOut(BaseModel): + id: UUID + source_system: str + source_name: str + source_url: str + fish_name: str + waterbody_name: str + x: int | None + y: int | None + weight_g: int | None + last_seen_at: datetime + missing_fields: list[str] + quality: str class ImportRunOut(BaseModel): diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 4c8e9e1..0d3ce04 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -49,6 +49,7 @@ def test_activity_filters_and_explains_score() -> None: assert payload[0]["catches"] == 3 assert payload[0]["unique_players"] == 3 assert "3 свежих улова" in payload[0]["explanation"] + assert payload[0]["sources"] == ["manual-import"] def test_invalid_period_is_rejected() -> None: @@ -81,6 +82,7 @@ def test_spot_detail_and_catches() -> None: assert detail.json()["catches_24h"] == 3 assert catches.status_code == 200 assert len(catches.json()) == 3 + assert catches.json()[0]["source_system"] == "manual-import" def test_records_list_is_empty_before_import() -> None: @@ -164,7 +166,7 @@ def test_external_observation_requires_mapping_and_complete_data_before_publicat assert {alias.entity_type for alias in aliases} == {"fish", "waterbody"} -def test_incomplete_external_observation_stays_out_of_public_data() -> None: +def test_incomplete_external_observation_is_publicly_labelled_but_not_counted() -> None: with Session(engine) as db: stage_observations(db, [{ "source_system": "rf4db", "source_external_id": "review-incomplete", @@ -174,6 +176,13 @@ def test_incomplete_external_observation_stays_out_of_public_data() -> None: observation_id = db.scalar(select(ExternalObservation.id).where( ExternalObservation.source_external_id == "review-incomplete" )) + public = client.get("/api/v1/community-observations") + assert public.status_code == 200 + signal = next(item for item in public.json() if item["id"] == str(observation_id)) + assert signal["source_system"] == "rf4db" + assert signal["quality"] == "incomplete" + assert signal["missing_fields"] == ["вес"] + assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()) headers = {"Authorization": "Bearer change-me-in-production"} mapped = client.patch( f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers, @@ -186,6 +195,7 @@ def test_incomplete_external_observation_stays_out_of_public_data() -> None: json={"reason": "weight is absent"}, ) assert rejected.json()["status"] == "rejected" + assert all(item["id"] != str(observation_id) for item in client.get("/api/v1/community-observations").json()) def test_admin_can_start_and_list_official_import(monkeypatch) -> None: diff --git a/apps/web/src/components/ActivityCard.astro b/apps/web/src/components/ActivityCard.astro index ee1e6c3..8c74183 100644 --- a/apps/web/src/components/ActivityCard.astro +++ b/apps/web/src/components/ActivityCard.astro @@ -2,12 +2,13 @@ import type { Activity } from "../lib/api"; import { activityLevel, ago, kg, plural } from "../lib/api"; import FishingIcon from "./FishingIcon.astro"; +import SourceBadge from "./SourceBadge.astro"; const { item } = Astro.props as { item: Activity }; const level = activityLevel(item.activity_score); const limited = item.catches < 3; --- {String(item.activity_score).padStart(2,"0")} -
{item.waterbody}{level.short}{limited && Данных мало}

{item.fish}

{item.x}:{item.y} {ago(item.last_confirmed_at)}

{item.explanation}

Работает сейчас{item.best_bait ?? "не указана"}
+
{item.waterbody}{level.short}{limited && Данных мало}

{item.fish}

{item.x}:{item.y} {ago(item.last_confirmed_at)}
{item.sources.map(source => )}

{item.explanation}

Работает сейчас{item.best_bait ?? "не указана"}
{item.catches}{plural(item.catches, ["улов", "улова", "уловов"])}
{item.unique_players}{plural(item.unique_players, ["игрок", "игрока", "игроков"])}
{kg(item.average_weight_g)}средний вес
{item.confidence_score}%уверенность
diff --git a/apps/web/src/components/SourceBadge.astro b/apps/web/src/components/SourceBadge.astro new file mode 100644 index 0000000..44e8e9d --- /dev/null +++ b/apps/web/src/components/SourceBadge.astro @@ -0,0 +1,16 @@ +--- +const { source, href, tone = "source" } = Astro.props as { source: string; href?: string | null; tone?: "source" | "incomplete" | "verified" }; +const labels: Record = { + "rf4-official": "RF4 · официальный", + rf4db: "RF4DB", + "rf4stat-fishing": "RF4-STAT · уловы", + "rf4stat-post": "RF4-STAT · посты", + rf4map: "RF4MAP", + "rf4posts-spot": "RF4 Posts", + players: "Игроки RF4", + "manual-import": "Архивный импорт", +}; +const label = labels[source] ?? source; +const mark = source === "players" ? "♟" : source === "rf4-official" ? "★" : "↗"; +--- +{href ? {mark}{label} : {mark}{label}} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index bfc0924..5be2b72 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -3,13 +3,14 @@ export type Activity = { fish: string; x: number; y: number; best_bait: string | null; catches: number; unique_players: number; average_weight_g: number; max_weight_g: number; last_confirmed_at: string; activity_score: number; confidence_score: number; - explanation: string; + explanation: string; sources: string[]; }; export type Spot = { id: string; waterbody_slug: string; waterbody: string; x: number; y: number; description: string | null; catches_24h: number; catches_3d: number; catches_7d: number; top_baits: string[] }; -export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; retrieve_method: string | null; retrieve_speed: number | null }; +export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; source_url: string | null }; export type DictionaryItem = { id: string; slug: string; name_ru: 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 }; +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 PublicObservation = { id: string; source_system: string; source_name: string; source_url: string; fish_name: string; waterbody_name: string; x: number | null; y: number | null; weight_g: number | null; last_seen_at: string; missing_fields: string[]; quality: "incomplete" | "unverified" }; export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null }; export { activityLevel, ago, kg, plural } from "./presentation"; diff --git a/apps/web/src/pages/index.astro b/apps/web/src/pages/index.astro index 328ef54..7d9cfbd 100644 --- a/apps/web/src/pages/index.astro +++ b/apps/web/src/pages/index.astro @@ -2,18 +2,19 @@ import Layout from "../layouts/Layout.astro"; import ActivityCard from "../components/ActivityCard.astro"; import FishingIcon from "../components/FishingIcon.astro"; -import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem } from "../lib/api"; +import SourceBadge from "../components/SourceBadge.astro"; +import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PublicObservation } from "../lib/api"; const params = Astro.url.searchParams; const hours = params.get("hours") ?? "24"; const waterbody = params.get("waterbody") ?? ""; const fish = params.get("fish") ?? ""; const sort = params.get("sort") ?? "activity"; -let items: Activity[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = []; +let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = []; const filterError = !["6", "12", "24", "72"].includes(hours) || !["activity", "confidence", "freshness"].includes(sort); let unavailable = false; try { - [fishes, waterbodies] = await Promise.all([api("/api/v1/fishes"), api("/api/v1/waterbodies")]); + [fishes, waterbodies, signals] = await Promise.all([api("/api/v1/fishes"), api("/api/v1/waterbodies"), api("/api/v1/community-observations?limit=12")]); if (!filterError) { const query = new URLSearchParams({ hours, waterbody, fish, sort }); items = await api(`/api/v1/activity?${query}`); @@ -39,8 +40,9 @@ const filtersChanged = Boolean(waterbody || fish || hours !== "24" || sort !== "
{selectedWaterbody}{selectedFish}{periodLabel}{sortLabel}{filtersChanged && Сбросить}
За выбранный период

Горячие точки

{items.length} {plural(items.length, ["точка", "точки", "точек"])}
{filterError ?

Некорректные фильтры

Выберите период и сортировку из предложенных значений.

Сбросить фильтры
: unavailable ?

Источник временно недоступен

Не показываем устаревшие догадки. Попробуйте позже.

: items.length ?
{items.map(item => )}
:

Пока нет свежих данных

Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.

}
- {items[0] && leaderLevel && } + {items[0] && leaderLevel && }
+ {signals.length > 0 &&
Сырые данные источников

Полевые сигналы

Показываем сразу, даже если часть полей отсутствует. Эти карточки не участвуют в расчёте активности до полного подтверждения.

{signals.length} новых
{signals.map(signal =>
{signal.quality === "incomplete" ? "!" : "?"} {signal.quality === "incomplete" ? "Неполные данные" : "Ждёт проверки"}

{signal.fish_name}

{signal.waterbody_name}

Точка
{signal.x == null || signal.y == null ? "не указана" : `${signal.x}:${signal.y}`}
Вес
{signal.weight_g == null ? "не указан" : kg(signal.weight_g)}
Получено
{ago(signal.last_seen_at)}

{signal.missing_fields.length ? "Не хватает:" : "Статус:"} {signal.missing_fields.join(", ") || "проверяется соответствие справочнику"}

)}
}
Как читать данные

Не обещаем рыбу.
Показываем факты.

01

Свежесть

Чем старше сообщение, тем меньше оно влияет на активность.

02

Разные игроки

Десять уловов одного человека не равны десяти подтверждениям.

03

Уверенность

Каждая оценка объясняет, сколько данных за ней стоит.