feat: suggest confirmed external aliases
This commit is contained in:
@@ -8,7 +8,7 @@ RF4 Spotter — неофициальный сервис свежих точек
|
|||||||
|
|
||||||
Функциональный MVP и локальный production-контур готовятся к открытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. На всех страницах подключён компактный баннер открытой альфы со ссылками на статус, правила и отправку улова. Автоматические импорты внешних источников выключены. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup, канал уведомлений и публичные страницы правил/privacy; публичный адрес обратной связи ещё не задан.
|
Функциональный 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 лидера скрывается при единственном результате, чтобы не повторять ту же карточку.
|
На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. Лента раскрывается серверной кнопкой «Показать ещё», сохраняет выбранные фильтры и ограничена 48 сигналами на страницу. Совпадающие рыба, водоём, координаты и вес объединяются визуально, при этом карточка сохраняет все уникальные ссылки на исходные наблюдения. Sidebar лидера скрывается при единственном результате, чтобы не повторять ту же карточку.
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ def map_observation(
|
|||||||
return 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:
|
def reject_observation(session: Session, observation: ExternalObservation, *, reason: str) -> ExternalObservation:
|
||||||
if observation.status == "published":
|
if observation.status == "published":
|
||||||
raise ExternalReviewError("published observation cannot be rejected")
|
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,
|
external_id=external_id, external_name=observation.fish_name if fish else observation.waterbody_name,
|
||||||
)
|
)
|
||||||
session.add(alias)
|
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.fish = fish
|
||||||
alias.waterbody = waterbody
|
alias.waterbody = waterbody
|
||||||
alias.updated_at = datetime.now(timezone.utc)
|
alias.updated_at = datetime.now(timezone.utc)
|
||||||
|
|||||||
+16
-2
@@ -21,13 +21,13 @@ from sqlalchemy.orm import Session, joinedload
|
|||||||
from .activity import activity_rows
|
from .activity import activity_rows
|
||||||
from .database import get_session
|
from .database import get_session
|
||||||
from .config import settings
|
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 .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize
|
||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||||
from .readiness import readiness_report
|
from .readiness import readiness_report
|
||||||
from .public_cache import public_cache
|
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
|
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]
|
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)
|
@app.patch("/api/v1/admin/external-observations/{observation_id}/mapping", response_model=ExternalObservationOut)
|
||||||
def admin_map_external_observation(
|
def admin_map_external_observation(
|
||||||
observation_id: UUID, payload: ExternalObservationMapping, db: Db,
|
observation_id: UUID, payload: ExternalObservationMapping, db: Db,
|
||||||
|
|||||||
@@ -213,6 +213,12 @@ class ExternalObservationMapping(BaseModel):
|
|||||||
note: str | None = Field(default=None, max_length=1000)
|
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):
|
class ExternalObservationDecision(BaseModel):
|
||||||
reason: str = Field(min_length=1, max_length=1000)
|
reason: str = Field(min_length=1, max_length=1000)
|
||||||
|
|
||||||
|
|||||||
@@ -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/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?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/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:
|
def test_public_source_status_hides_internal_details() -> None:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy import create_engine, func, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.community_importer import CommunityImportError, stage_observations
|
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.database import Base
|
||||||
from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
|
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
|
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
|
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:
|
def test_parser_json_can_be_staged_without_losing_provenance(db: Session) -> None:
|
||||||
parsed = parse_rf4db_catches(FIXTURE.read_text(encoding="utf-8"))
|
parsed = parse_rf4db_catches(FIXTURE.read_text(encoding="utf-8"))
|
||||||
payload = json.loads(json.dumps([asdict(item) for item in parsed], default=str))
|
payload = json.loads(json.dumps([asdict(item) for item in parsed], default=str))
|
||||||
|
|||||||
+1
-1
@@ -102,7 +102,7 @@
|
|||||||
- [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест).
|
- [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест).
|
||||||
- [x] Добавить административную очередь сопоставления staging-записей с каноническими рыбами/водоёмами и явную публикацию в `catch_report` (`/admin/external-sources`, миграция `0009`; неполные записи публиковать запрещено).
|
- [x] Добавить административную очередь сопоставления staging-записей с каноническими рыбами/водоёмами и явную публикацию в `catch_report` (`/admin/external-sources`, миграция `0009`; неполные записи публиковать запрещено).
|
||||||
- [ ] Найти разрешённый способ получать полные наблюдения с рыбой, водоёмом, координатами и весом из одного источника либо через подтверждённый общий ID; текущие 139 записей неполны и не публикуются.
|
- [ ] Найти разрешённый способ получать полные наблюдения с рыбой, водоёмом, координатами и весом из одного источника либо через подтверждённый общий ID; текущие 139 записей неполны и не публикуются.
|
||||||
- [ ] Добавить безопасные подсказки алиасов по уже подтверждённым сопоставлениям без автоматической публикации и тест конфликтующих алиасов.
|
- [x] Добавить защищённые подсказки алиасов по точным подтверждённым сопоставлениям без применения и запретить конфликтующую перезапись алиаса (7 сентября 2026).
|
||||||
- [ ] Добавить управляемый повторный импорт staging с отчётом `created/updated/rejected`, лимитами запросов и наблюдаемым отказом при изменении DOM; регулярный запуск оставить выключенным до фиксации условий.
|
- [ ] Добавить управляемый повторный импорт staging с отчётом `created/updated/rejected`, лимитами запросов и наблюдаемым отказом при изменении DOM; регулярный запуск оставить выключенным до фиксации условий.
|
||||||
- [ ] Определить процедуру повторной проверки опубликованного внешнего улова при изменении или удалении записи у источника.
|
- [ ] Определить процедуру повторной проверки опубликованного внешнего улова при изменении или удалении записи у источника.
|
||||||
- [ ] Согласовать один добровольный канал сообщества и правила происхождения, модерации и удаления сообщений.
|
- [ ] Согласовать один добровольный канал сообщества и правила происхождения, модерации и удаления сообщений.
|
||||||
|
|||||||
Reference in New Issue
Block a user