feat: explain activity data quality

This commit is contained in:
ik
2026-09-03 18:23:43 +07:00
parent c3ddd71f59
commit 97afd2730e
9 changed files with 63 additions and 22 deletions
+25 -1
View File
@@ -68,7 +68,7 @@ def activity_rows(
average_weight_g=round(sum(r.weight_g for r in items) / len(items)),
max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest,
activity_score=activity, confidence_score=confidence,
explanation=f"{len(items)} свежих уловов от {len(players)} игроков. Последнее подтверждение {freshness_text}." + (" Данных мало." if len(items) < 3 else ""),
explanation=_explanation(len(items), len(players), freshness_text, activity, confidence),
))
return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True)
@@ -82,3 +82,27 @@ def _freshness_text(delta: timedelta) -> str:
if minutes < 60:
return f"{minutes} мин. назад"
return f"{minutes // 60} ч. назад"
def _explanation(catches: int, players: int, freshness: str, activity: int, confidence: int) -> str:
activity_label = "Высокая" if activity >= 60 else "Средняя" if activity >= 40 else "Низкая"
confidence_label = "высокая" if confidence >= 70 else "средняя" if confidence >= 40 else "низкая"
summary = (
f"{activity_label} активность: {_count(catches, 'свежий улов', 'свежих улова', 'свежих уловов')} "
f"от {_count(players, 'игрока', 'игроков', 'игроков')}. "
f"Последнее подтверждение {freshness}. Уверенность {confidence_label}."
)
return summary + (" Данных мало: нужно хотя бы 3 наблюдения." if catches < 3 else "")
def _count(value: int, one: str, few: str, many: str) -> str:
remainder = value % 100
if 11 <= remainder <= 14:
word = many
elif value % 10 == 1:
word = one
elif 2 <= value % 10 <= 4:
word = few
else:
word = many
return f"{value} {word}"
+2 -2
View File
@@ -78,7 +78,7 @@ def _spot_or_404(db: Session, spot_id: UUID) -> Spot:
@app.get("/api/v1/spots/{spot_id}", response_model=SpotOut)
def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
spot = _spot_or_404(db, spot_id)
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot.id, CatchReport.moderation_status == ModerationStatus.approved)))
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot.id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None))))
now = datetime.now(timezone.utc)
def count_since(delta: timedelta) -> int:
return sum(_aware(r.reported_at) >= now - delta for r in reports)
@@ -89,7 +89,7 @@ def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
@app.get("/api/v1/spots/{spot_id}/catches", response_model=list[CatchOut])
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).order_by(CatchReport.reported_at.desc()).offset(offset).limit(limit)))
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()).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]
+11 -1
View File
@@ -86,7 +86,17 @@ def test_repeated_reports_from_one_player_do_not_add_unique_player_weight(db: Se
assert row.catches == 3
assert row.unique_players == 1
assert "3 свежих уловов от 1 игроков" in row.explanation
assert "3 свежих улова от 1 игрока" in row.explanation
def test_explanation_describes_low_data_and_confidence(db: Session) -> None:
add_report(db, age_hours=2, player="Player", confidence=50)
row = activity_rows(db, hours=6, now=NOW)[0]
assert row.explanation.startswith("Низкая активность: 1 свежий улов от 1 игрока.")
assert "Уверенность низкая." in row.explanation
assert "Данных мало: нужно хотя бы 3 наблюдения." in row.explanation
def test_pending_rejected_and_deleted_reports_are_excluded(db: Session) -> None:
+2 -1
View File
@@ -46,11 +46,12 @@ def test_activity_filters_and_explains_score() -> None:
assert len(payload) == 1
assert payload[0]["catches"] == 3
assert payload[0]["unique_players"] == 3
assert "3 свежих уловов" in payload[0]["explanation"]
assert "3 свежих улова" in payload[0]["explanation"]
def test_invalid_period_is_rejected() -> None:
assert client.get("/api/v1/activity?hours=13").status_code == 422
assert client.get("/api/v1/activity?sort=unknown").status_code == 422
def test_spot_detail_and_catches() -> None: