feat: explain activity data quality
This commit is contained in:
@@ -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}"
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user