feat: suggest confirmed external aliases
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s

This commit is contained in:
ik
2026-09-07 18:57:09 +07:00
parent 214b328cd2
commit 8b2d7e2e1c
7 changed files with 67 additions and 4 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ RF4 Spotter — неофициальный сервис свежих точек
Функциональный MVP и локальный production-контур готовятся к открытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. На всех страницах подключён компактный баннер открытой альфы со ссылками на статус, правила и отправку улова. Автоматические импорты внешних источников выключены. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup, канал уведомлений и публичные страницы правил/privacy; публичный адрес обратной связи ещё не задан.
RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Для разрешённых community-источников действует интервал не менее 30 минут на источник. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации.
RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Admin API предлагает точные ранее подтверждённые алиасы отдельно от mapping-действия и запрещает молча переназначать alias другой сущности. Для разрешённых community-источников действует интервал не менее 30 минут на источник. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации.
На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. Лента раскрывается серверной кнопкой «Показать ещё», сохраняет выбранные фильтры и ограничена 48 сигналами на страницу. Совпадающие рыба, водоём, координаты и вес объединяются визуально, при этом карточка сохраняет все уникальные ссылки на исходные наблюдения. Sidebar лидера скрывается при единственном результате, чтобы не повторять ту же карточку.
+16
View File
@@ -34,6 +34,20 @@ def map_observation(
return observation
def suggest_aliases(session: Session, observation: ExternalObservation) -> tuple[Fish | None, Waterbody | None]:
fish_alias = session.scalar(select(ExternalEntityAlias).where(
ExternalEntityAlias.source_system == observation.source_system,
ExternalEntityAlias.entity_type == "fish",
ExternalEntityAlias.external_id == (observation.fish_external_id or observation.fish_name),
))
waterbody_alias = session.scalar(select(ExternalEntityAlias).where(
ExternalEntityAlias.source_system == observation.source_system,
ExternalEntityAlias.entity_type == "waterbody",
ExternalEntityAlias.external_id == (observation.waterbody_external_id or observation.waterbody_name),
))
return (fish_alias.fish if fish_alias else None, waterbody_alias.waterbody if waterbody_alias else None)
def reject_observation(session: Session, observation: ExternalObservation, *, reason: str) -> ExternalObservation:
if observation.status == "published":
raise ExternalReviewError("published observation cannot be rejected")
@@ -109,6 +123,8 @@ def _save_alias(
external_id=external_id, external_name=observation.fish_name if fish else observation.waterbody_name,
)
session.add(alias)
elif (fish is not None and alias.fish_id != fish.id) or (waterbody is not None and alias.waterbody_id != waterbody.id):
raise ExternalReviewError(f"confirmed {entity_type} alias points to another entity")
alias.fish = fish
alias.waterbody = waterbody
alias.updated_at = datetime.now(timezone.utc)
+16 -2
View File
@@ -21,13 +21,13 @@ from sqlalchemy.orm import Session, joinedload
from .activity import activity_rows
from .database import get_session
from .config import settings
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation, suggest_aliases
from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize
from .logging_config import configure_logging
from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
from .readiness import readiness_report
from .public_cache import public_cache
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
@@ -351,6 +351,20 @@ def admin_external_observations(
return [_external_out(item) for item in items]
@app.get("/api/v1/admin/external-observations/{observation_id}/alias-suggestions", response_model=ExternalAliasSuggestionOut)
def admin_external_alias_suggestions(
observation_id: UUID, db: Db, _: Annotated[str, Depends(_admin)],
) -> ExternalAliasSuggestionOut:
observation = db.get(ExternalObservation, observation_id)
if observation is None:
raise HTTPException(status_code=404, detail="external observation not found")
fish, waterbody = suggest_aliases(db, observation)
return ExternalAliasSuggestionOut(
fish_slug=fish.slug if fish else None,
waterbody_slug=waterbody.slug if waterbody else None,
)
@app.patch("/api/v1/admin/external-observations/{observation_id}/mapping", response_model=ExternalObservationOut)
def admin_map_external_observation(
observation_id: UUID, payload: ExternalObservationMapping, db: Db,
+6
View File
@@ -213,6 +213,12 @@ class ExternalObservationMapping(BaseModel):
note: str | None = Field(default=None, max_length=1000)
class ExternalAliasSuggestionOut(BaseModel):
fish_slug: str | None
waterbody_slug: str | None
requires_confirmation: bool = True
class ExternalObservationDecision(BaseModel):
reason: str = Field(min_length=1, max_length=1000)
+1
View File
@@ -64,6 +64,7 @@ def test_list_pagination_and_filter_validation() -> None:
assert client.get("/api/v1/admin/external-observations?status=unknown", headers=headers).status_code == 422
assert client.get("/api/v1/admin/catch-reports?offset=-1", headers=headers).status_code == 422
assert client.get("/api/v1/admin/catch-reports?limit=101", headers=headers).status_code == 422
assert client.get("/api/v1/admin/external-observations/00000000-0000-0000-0000-000000000000/alias-suggestions").status_code == 401
def test_public_source_status_hides_internal_details() -> None:
+26
View File
@@ -8,6 +8,7 @@ from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
from app.community_importer import CommunityImportError, stage_observations
from app.community_review import ExternalReviewError, map_observation, suggest_aliases
from app.database import Base
from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot
@@ -129,6 +130,31 @@ def test_auto_publication_requires_enabled_source(db: Session) -> None:
assert item is not None and item.status == "staged" and item.catch_report is None
def test_confirmed_aliases_are_suggestions_and_cannot_be_retargeted(db: Session) -> None:
source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=True)
pike = Fish(slug="pike", name_ru="Щука")
perch = Fish(slug="perch", name_ru="Окунь")
waterbody = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
db.add_all([source, pike, perch, waterbody])
db.commit()
stage_observations(db, [record(external_id="first")])
first = db.scalar(select(ExternalObservation).where(ExternalObservation.source_external_id == "first"))
assert first is not None
map_observation(db, first, pike, waterbody)
stage_observations(db, [record(external_id="second")])
second = db.scalar(select(ExternalObservation).where(ExternalObservation.source_external_id == "second"))
assert second is not None
suggested_fish, suggested_waterbody = suggest_aliases(db, second)
assert (suggested_fish.slug, suggested_waterbody.slug) == ("pike", "test-lake")
with pytest.raises(ExternalReviewError, match="confirmed fish alias"):
map_observation(db, second, perch, waterbody)
db.rollback()
alias = db.scalar(select(ExternalEntityAlias).where(ExternalEntityAlias.entity_type == "fish"))
assert alias is not None and alias.fish_id == pike.id
def test_parser_json_can_be_staged_without_losing_provenance(db: Session) -> None:
parsed = parse_rf4db_catches(FIXTURE.read_text(encoding="utf-8"))
payload = json.loads(json.dumps([asdict(item) for item in parsed], default=str))
+1 -1
View File
@@ -102,7 +102,7 @@
- [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест).
- [x] Добавить административную очередь сопоставления staging-записей с каноническими рыбами/водоёмами и явную публикацию в `catch_report` (`/admin/external-sources`, миграция `0009`; неполные записи публиковать запрещено).
- [ ] Найти разрешённый способ получать полные наблюдения с рыбой, водоёмом, координатами и весом из одного источника либо через подтверждённый общий ID; текущие 139 записей неполны и не публикуются.
- [ ] Добавить безопасные подсказки алиасов по уже подтверждённым сопоставлениям без автоматической публикации и тест конфликтующих алиасов.
- [x] Добавить защищённые подсказки алиасов по точным подтверждённым сопоставлениям без применения и запретить конфликтующую перезапись алиаса (7 сентября 2026).
- [ ] Добавить управляемый повторный импорт staging с отчётом `created/updated/rejected`, лимитами запросов и наблюдаемым отказом при изменении DOM; регулярный запуск оставить выключенным до фиксации условий.
- [ ] Определить процедуру повторной проверки опубликованного внешнего улова при изменении или удалении записи у источника.
- [ ] Согласовать один добровольный канал сообщества и правила происхождения, модерации и удаления сообщений.