From e721c7d7cb1631489b4f82b4e63dc99fdc375140 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Thu, 5 Nov 2026 15:07:12 +0900 Subject: [PATCH] fix(reports): reissue a cached report when more of its calls are analysed Analysis finishes a few minutes after a call ends. A report opened in that window was cached, and because the call's end time was earlier than the snapshot the cache kept serving it without the new analysis. Past periods were never reissued at all, so a Sunday-night call analysed on Monday stayed out of last week's report. Reuse the snapshot only when its analyzedCallCount still matches the analysed calls in the period. No migration; snapshots without the field are rebuilt once. --- backend/app/services/reports.py | 16 +++-- backend/tests/test_report_snapshot.py | 84 +++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 backend/tests/test_report_snapshot.py diff --git a/backend/app/services/reports.py b/backend/app/services/reports.py index ce08c7c..afbe389 100644 --- a/backend/app/services/reports.py +++ b/backend/app/services/reports.py @@ -76,13 +76,19 @@ async def get_or_issue( latest_call_at = max( (aware_datetime(call.ended_at) for call in calls if call.ended_at), default=None ) - if existing and ( - to_date < date.today() - or latest_call_at is None - or latest_call_at <= aware_datetime(existing.issued_at) + analyzed_ids = [call.id for call in calls if call.state == "ANALYZED"] + # 분석은 통화가 끝나고 몇 분 뒤에 끝난다. 그 사이에 만든 스냅샷은 종료 시각만 보면 + # 최신으로 보이므로, 분석된 통화 수가 달라졌으면(지난 기간도) 다시 만든다. + if ( + existing + and existing.snapshot.get("analyzedCallCount") == len(analyzed_ids) + and ( + to_date < date.today() + or latest_call_at is None + or latest_call_at <= aware_datetime(existing.issued_at) + ) ): return {**existing.snapshot, "issuedAt": existing.issued_at} - analyzed_ids = [call.id for call in calls if call.state == "ANALYZED"] extractions = [] features = [] signals = [] diff --git a/backend/tests/test_report_snapshot.py b/backend/tests/test_report_snapshot.py new file mode 100644 index 0000000..642571a --- /dev/null +++ b/backend/tests/test_report_snapshot.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from sqlalchemy import update + +from app.database import Database +from app.models import CallRecord, CallState, User +from app.services.reports import ReportService, aware_datetime, period_bounds + + +@pytest.fixture +async def database(tmp_path: Path) -> AsyncIterator[Database]: + database = Database(f"sqlite+aiosqlite:///{tmp_path / 'snapshot.db'}") + await database.ensure_schema(auto_reset=True) + yield database + await database.close() + + +async def seed_processing_call(database: Database, ended_at: datetime) -> tuple[str, str]: + async with database.sessions() as session: + parent = User(phone="01012345678", name="Parent", role="PARENT") + child = User(phone="01087654321", name="Child", role="CHILD") + session.add_all([parent, child]) + await session.flush() + call = CallRecord( + parent_id=parent.id, + child_id=child.id, + room_name="snapshot-test", + state=CallState.PROCESSING.value, + ended_at=ended_at, + ) + session.add(call) + await session.commit() + return call.id, parent.id + + +async def finish_analysis(database: Database, call_id: str) -> None: + async with database.sessions() as session: + await session.execute( + update(CallRecord) + .where(CallRecord.id == call_id) + .values(state=CallState.ANALYZED.value) + ) + await session.commit() + + +async def weekly(database: Database, parent_id: str, target) -> dict: + async with database.sessions() as session: + return await ReportService().get_or_issue(session, parent_id, "WEEKLY", target) + + +@pytest.mark.asyncio +async def test_report_issued_while_analysing_picks_up_the_call(database: Database) -> None: + # 통화가 끝나고 분석이 도는 동안 리포트를 열면 스냅샷이 먼저 저장된다. 분석이 끝난 뒤에는 + # 그 통화의 종료 시각이 스냅샷보다 이르더라도 다시 만들어 새 분석을 넣어야 한다. + ended = datetime.now(UTC) - timedelta(minutes=5) + call_id, parent_id = await seed_processing_call(database, ended) + first = await weekly(database, parent_id, None) + assert first["analyzedCallCount"] == 0 + + await finish_analysis(database, call_id) + second = await weekly(database, parent_id, None) + assert second["analyzedCallCount"] == 1 + + # 바뀐 것이 없으면 저장된 스냅샷을 그대로 준다. + third = await weekly(database, parent_id, None) + assert aware_datetime(third["issuedAt"]) == aware_datetime(second["issuedAt"]) + + +@pytest.mark.asyncio +async def test_past_week_report_picks_up_late_analysis(database: Database) -> None: + # 일요일 밤 통화의 분석이 월요일에 끝나도 지난주 리포트에 들어가야 한다. + last_week_start, _ = period_bounds("WEEKLY", (datetime.now(UTC) - timedelta(days=7)).date()) + ended = datetime.combine(last_week_start + timedelta(days=2), datetime.min.time(), tzinfo=UTC) + call_id, parent_id = await seed_processing_call(database, ended) + target = last_week_start + timedelta(days=2) + assert (await weekly(database, parent_id, target))["analyzedCallCount"] == 0 + + await finish_analysis(database, call_id) + assert (await weekly(database, parent_id, target))["analyzedCallCount"] == 1