feat: display provenance and incomplete signals
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 13:21:15 +07:00
parent 7217356594
commit 9275799ce7
17 changed files with 137 additions and 15 deletions
+12
View File
@@ -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)
+46 -3
View File
@@ -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])
+19
View File
@@ -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):
+11 -1
View File
@@ -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: