19f544fb5e
추출 파이프라인(287~298, 별 커밋) 위 HR/A. 신규 마이그레이션 0 (DDL은 295~298 재사용).
- HR 정정/삭제 훅: PATCH 본문 수정 → 파생 study_memo_cards needs_review=auto(source_changed),
soft-DELETE → source_deleted. flag_cards_for_source 헬퍼(임시 플래그, 최종정리는 워커 supersede).
- HR needs_review: PATCH set/clear(flagged_by='user' 서버강제) + GET /study-questions/needs-review
목록·count(부분인덱스 술어 일치, 동적 {id} 라우트보다 먼저 등록해 int 파싱 충돌 회피).
- A 알람 재료: study_topics.focused_at 공부중 토글 + study_reminder cron(09/13/19 KST, due 술어
quiz_selection SQL 재현·시간슬롯 truncate 멱등·LLM 0) + GET /api/study-reminders/latest(없으면 204).
- 테스트: 가드/정규화 18/18 (정량=evidence 원문·cue/cloze 누출·dedup·배치).
검증: 앱 부팅 import+mapper OK · 가드 18/18 PASS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
93 lines
3.7 KiB
Python
93 lines
3.7 KiB
Python
"""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)
|