"""study_reminder — focus 토픽 due 요약 cron (공부 암기노트 Phase 1, A 워크스트림). 09/13/19 KST 발화(main.py CronTrigger). '공부중'(focused_at IS NOT NULL) 토픽별 복습 due 건수를 집계해 study_reminders 에 append. LLM 0 (순수 집계 → GPU 분석 측). due 술어는 quiz_selection.py:141 의 due_review 와 동일하게 SQL 로 재현: due_at IS NOT NULL AND due_at <= now AND (review_stage IS NULL OR review_stage < 4) (= Python `(review_stage or 0) < 4` 와 NULL 의미 동일). quiz_selection 은 단일 토픽 ORM 순회라 import 불가 → 재현 + 측정 등가성 게이트(테스트). fired_at 은 시간 슬롯(분/초 절삭)으로 박아 UNIQUE(user, fired_at) on_conflict_do_nothing 멱등. due 0 이면 row 미생성(noise 방지). 놓친 시각은 그냥 skip(stale 복구 미적용 — 시각 민감). """ from __future__ import annotations import logging from collections import defaultdict from datetime import datetime, timezone from sqlalchemy import func, or_, select from sqlalchemy.dialects.postgresql import insert as pg_insert from core.database import async_session from models.study_question_progress import StudyQuestionProgress from models.study_reminder import StudyReminder from models.study_topic import StudyTopic from models.user import User # noqa: F401 (mapper 초기화 defensive) logger = logging.getLogger("study_reminder") async def run() -> None: """APScheduler cron 진입점. focus 토픽 due 집계 → study_reminders append.""" now = datetime.now(timezone.utc) slot = now.replace(minute=0, second=0, microsecond=0) # 시간 슬롯 truncate (멱등 키) async with async_session() as session: topics = ( await session.execute( select(StudyTopic.id, StudyTopic.user_id, StudyTopic.name) .where( StudyTopic.focused_at.is_not(None), StudyTopic.deleted_at.is_(None), ) ) ).all() if not topics: return by_user: dict[int, dict] = defaultdict(lambda: {"due": 0, "names": []}) for t in topics: due = ( await session.execute( select(func.count()) .select_from(StudyQuestionProgress) .where( StudyQuestionProgress.user_id == t.user_id, StudyQuestionProgress.study_topic_id == t.id, StudyQuestionProgress.due_at.is_not(None), StudyQuestionProgress.due_at <= now, or_( StudyQuestionProgress.review_stage.is_(None), StudyQuestionProgress.review_stage < 4, ), ) ) ).scalar_one() by_user[t.user_id]["due"] += due by_user[t.user_id]["names"].append( {"topic_id": t.id, "name": t.name, "due": due} ) inserted = 0 for uid, agg in by_user.items(): if agg["due"] <= 0: continue # due 0 → reminder 미생성 result = await session.execute( pg_insert(StudyReminder) .values( user_id=uid, study_topic_id=None, due_count=agg["due"], focus_topic_names=agg["names"], fired_at=slot, ) .on_conflict_do_nothing(index_elements=["user_id", "fired_at"]) ) inserted += result.rowcount or 0 await session.commit() if inserted: logger.info("study_reminder fired slot=%s users=%d", slot.isoformat(), inserted)