diff --git a/apps/api/app/activity.py b/apps/api/app/activity.py index 245f253..18ba30a 100644 --- a/apps/api/app/activity.py +++ b/apps/api/app/activity.py @@ -56,6 +56,11 @@ def activity_rows( activity = round(55 * min(1, weighted / 12) + 25 * min(1, len(players) / 6) + 20 * min(1, trophies / 3)) average_confidence = sum(r.source_confidence for r in items) / len(items) confidence = round(45 * min(1, len(items) / 10) + 35 * min(1, len(players) / 5) + 20 * average_confidence / 100) + # Cap confidence: 1 player → max 50%, 2 players → max 65% + if len(players) == 1: + confidence = min(confidence, 50) + elif len(players) == 2: + confidence = min(confidence, 65) latest = max(_aware(r.reported_at) for r in items) baits = Counter(r.bait.name for r in items if r.bait) freshness_text = _freshness_text(now - latest) diff --git a/apps/api/app/community_importer.py b/apps/api/app/community_importer.py index c128278..b908331 100644 --- a/apps/api/app/community_importer.py +++ b/apps/api/app/community_importer.py @@ -9,7 +9,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from .community_review import publish_observation -from .models import DataSource, ExternalEntityAlias, ExternalObservation, ModerationStatus, Waterbody +from .models import DataSource, ExternalEntityAlias, ExternalObservation, Fish, ModerationStatus, Waterbody SOURCE_DEFAULTS = { @@ -112,12 +112,22 @@ def _auto_publish(session: Session, observation: ExternalObservation) -> bool: or observation.weight_g is None ): return False - fish_alias = session.scalar(select(ExternalEntityAlias).where( - ExternalEntityAlias.source_system == observation.source_system, - ExternalEntityAlias.entity_type == "fish", - ExternalEntityAlias.external_id == observation.fish_external_id, - )) - if fish_alias is None or fish_alias.fish is None: + # Fish: prefer external alias, fall back to exact name match + fish = None + if observation.fish_external_id is not 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, + )) + if fish_alias and fish_alias.fish: + fish = fish_alias.fish + if fish is None: + # Fallback: exact name match + fish = session.scalar( + select(Fish).where(Fish.name_ru == observation.fish_name) + ) + if fish is None: return False # Waterbody: prefer external alias, fall back to exact name match waterbody = None @@ -136,7 +146,7 @@ def _auto_publish(session: Session, observation: ExternalObservation) -> bool: ) if waterbody is None: return False - observation.fish = fish_alias.fish + observation.fish = fish observation.waterbody = waterbody observation.status = "ready" observation.review_note = "Automatically matched by previously reviewed source aliases" diff --git a/apps/api/app/community_review.py b/apps/api/app/community_review.py index 76cc77c..0be9abc 100644 --- a/apps/api/app/community_review.py +++ b/apps/api/app/community_review.py @@ -80,7 +80,7 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C rig_type=observation.payload.get("rig_type"), retrieve_method=observation.payload.get("retrieve_method"), retrieve_speed=observation.payload.get("retrieve_speed"), - caught_at=observation.published_at, + caught_at=None, reported_at=observation.published_at or observation.first_seen_at, player_name=observation.payload.get("player_name"), source_type=SourceType.manual_import, diff --git a/apps/api/tests/test_activity.py b/apps/api/tests/test_activity.py index ccb32b0..bbcc2ec 100644 --- a/apps/api/tests/test_activity.py +++ b/apps/api/tests/test_activity.py @@ -129,3 +129,55 @@ def test_reports_without_coordinates_do_not_create_activity_group(db: Session) - db.commit() assert activity_rows(db, hours=72, now=NOW) == [] + + +def test_confidence_capped_at_50_with_single_player() -> None: + """D06: One player cannot artificially inflate confidence above 50%.""" + engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + Base.metadata.create_all(engine) + with Session(engine) as session: + waterbody = Waterbody(slug="test-lake", name_ru="Тестовое озеро", unlock_level=1) + fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000) + spot = Spot(waterbody=waterbody, x=10, y=20) + session.add_all([waterbody, fish, spot]) + session.flush() + # 10 reports from 1 player, all max confidence + for _ in range(10): + report = CatchReport( + fish=fish, spot=spot, waterbody=waterbody, bait=None, + weight_g=5_000, fishing_method="spinning", + caught_at=NOW - timedelta(hours=1), reported_at=NOW - timedelta(hours=1), + player_name="Single Player", source_type=SourceType.user, + source_confidence=100, moderation_status=ModerationStatus.approved, + ) + session.add(report) + session.commit() + row = activity_rows(session, hours=24, now=NOW)[0] + assert row.unique_players == 1 + assert row.confidence_score <= 50, f"Expected max 50 with 1 player, got {row.confidence_score}" + + +def test_confidence_capped_at_65_with_two_players() -> None: + """D06: Two players cannot get confidence above 65%.""" + engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + Base.metadata.create_all(engine) + with Session(engine) as session: + waterbody = Waterbody(slug="test-lake", name_ru="Тестовое озеро", unlock_level=1) + fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000) + spot = Spot(waterbody=waterbody, x=10, y=20) + session.add_all([waterbody, fish, spot]) + session.flush() + # 10 reports from 2 players, all max confidence + for i in range(10): + report = CatchReport( + fish=fish, spot=spot, waterbody=waterbody, bait=None, + weight_g=5_000, fishing_method="spinning", + caught_at=NOW - timedelta(hours=1), reported_at=NOW - timedelta(hours=1), + player_name=f"Player {i % 2}", source_type=SourceType.user, + source_confidence=100, moderation_status=ModerationStatus.approved, + ) + session.add(report) + session.commit() + row = activity_rows(session, hours=24, now=NOW)[0] + assert row.unique_players == 2 + assert row.confidence_score <= 65, f"Expected max 65 with 2 players, got {row.confidence_score}" diff --git a/apps/api/tests/test_community_importer.py b/apps/api/tests/test_community_importer.py index b41e05c..805f0d9 100644 --- a/apps/api/tests/test_community_importer.py +++ b/apps/api/tests/test_community_importer.py @@ -116,9 +116,11 @@ def test_changed_published_record_requires_review_and_reuses_report(db: Session) water = Waterbody(slug="test-lake", name_ru="Тестовое озеро") db.add_all([fish, water]) db.commit() + # D04: auto-publish now works with name match fallback stage_observations(db, [record() | {"weight_g": 5000}]) item = db.scalar(select(ExternalObservation)) - map_observation(db, item, fish, water) + # Item auto-published via name match fallback (D04) + assert item.status == "published" report = publish_observation(db, item) report_id = report.id stage_observations(db, [record() | {"weight_g": 6000}])