Compare commits

..

2 Commits

Author SHA1 Message Date
hyungi bb9e0905f2 feat(study): 습관 루프 — 오늘의 몫·스트릭/잔디·아침 리마인더 webhook
organic 사용 0 진단(트리거 없음·due 산더미·반복 감각 부재) 대응 3조각:
- 오늘의 몫: /api/study/daily (문제5·카드5·개념1·검수3, 가용량 보정) + /study 홈 최상단
  체크리스트/완료 상태. 진도·복습 백로그는 접이식 강등(무변경+cap, D2).
- 스트릭/잔디: KST 일 단위 활동 집계(attempts+document_reads+카드평가 근사).
- 아침 리마인더: study_reminder 09 KST 슬롯 한정 Synology Chat incoming webhook
  (STUDY_REMINDER_WEBHOOK_URL, 빈 값=off, 몫 완료 시 skip). 07-01 push 폐기 결정의
  flip 조건(홈 pull 실패) 충족에 따른 사용자 합의 D1.
- 마이그 383: study_memo_cards.reviewed_at (검수 처리 집계용, 승인/수정확정/폐기 시 박힘).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:18:02 +09:00
hyungi 371ee4ebe6 feat(presegment): 영구 실패 Chat 알람 — ALERT_FAIL_STAGES allowlist(기본 deep_summary,summarize) 2026-07-03 08:14:12 +09:00
9 changed files with 460 additions and 10 deletions
+11 -1
View File
@@ -249,7 +249,12 @@ async def approve_batch(
StudyMemoCard.deleted_at.is_(None), StudyMemoCard.deleted_at.is_(None),
StudyMemoCard.needs_review, StudyMemoCard.needs_review,
) )
.values(needs_review=False, flagged_by=None, flagged_at=None) .values(
needs_review=False,
flagged_by=None,
flagged_at=None,
reviewed_at=datetime.now(timezone.utc),
)
.returning(StudyMemoCard.id) .returning(StudyMemoCard.id)
) )
approved_ids = list(result.scalars().all()) approved_ids = list(result.scalars().all())
@@ -397,6 +402,7 @@ async def update_card(
card.needs_review = False card.needs_review = False
card.flagged_by = None card.flagged_by = None
card.flagged_at = None card.flagged_at = None
card.reviewed_at = datetime.now(timezone.utc)
elif "needs_review" in fields_set: elif "needs_review" in fields_set:
card.needs_review = bool(body.needs_review) card.needs_review = bool(body.needs_review)
if card.needs_review: if card.needs_review:
@@ -405,6 +411,7 @@ async def update_card(
else: else:
card.flagged_by = None card.flagged_by = None
card.flagged_at = None card.flagged_at = None
card.reviewed_at = datetime.now(timezone.utc)
# 발행 재투영/tombstone(같은 tx) — 검수완료=발행·검수대기복귀=tombstone(상태 기반). S-2. # 발행 재투영/tombstone(같은 tx) — 검수완료=발행·검수대기복귀=tombstone(상태 기반). S-2.
if settings.study_publish_enabled: if settings.study_publish_enabled:
@@ -431,6 +438,9 @@ async def delete_card(
card = await session.get(StudyMemoCard, card_id) card = await session.get(StudyMemoCard, card_id)
card = _verify_card(card, user) card = _verify_card(card, user)
card.deleted_at = datetime.now(timezone.utc) card.deleted_at = datetime.now(timezone.utc)
if card.needs_review:
# 검수 대기분의 폐기 = 검수 처리의 한 형태 ('오늘의 몫' 검수 집계 포함).
card.reviewed_at = card.deleted_at
# 발행 tombstone(같은 tx) — 삭제는 feed 1급 이벤트. S-2. # 발행 tombstone(같은 tx) — 삭제는 feed 1급 이벤트. S-2.
if settings.study_publish_enabled: if settings.study_publish_enabled:
await enqueue_card_publish(session, card) await enqueue_card_publish(session, card)
+13
View File
@@ -16,6 +16,7 @@ from core.database import get_session
from models.user import User from models.user import User
from services.study import concept_curriculum as cc from services.study import concept_curriculum as cc
from services.study import concept_links as cl from services.study import concept_links as cl
from services.study import daily_unit as du
router = APIRouter() router = APIRouter()
@@ -23,6 +24,18 @@ router = APIRouter()
DEFAULT_TOPIC_ID = 4 DEFAULT_TOPIC_ID = 4
@router.get("/daily")
async def get_daily(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
topic_id: int = DEFAULT_TOPIC_ID,
):
"""오늘의 몫(유한한 데일리 단위) + 스트릭/잔디 — 습관 루프 홈 재료. read-only."""
state = await du.daily_state(session, user.id, topic_id)
sg = await du.streak_and_grass(session, user.id)
return {**state, **sg}
@router.get("/curriculum") @router.get("/curriculum")
async def get_curriculum( async def get_curriculum(
user: Annotated[User, Depends(get_current_user)], user: Annotated[User, Depends(get_current_user)],
+5
View File
@@ -209,6 +209,9 @@ class Settings(BaseModel):
maintenance_note: str = "" maintenance_note: str = ""
# 뷰어 write-back ingest(study-to-viewer P2) 게이트. /ingest/study/attempts 활성. 기본 false=inert(503). # 뷰어 write-back ingest(study-to-viewer P2) 게이트. /ingest/study/attempts 활성. 기본 false=inert(503).
study_ingest_enabled: bool = False study_ingest_enabled: bool = False
# 습관 루프(2026-07-03 D1): '오늘의 몫' 아침 리마인더 → Synology Chat incoming webhook URL.
# 빈 값 = 발신 안 함 (명시적 off = kill switch. study_reminder 워커가 skip 로그 남김).
study_reminder_webhook_url: str = ""
# internal endpoint Bearer token (Mac mini derived-worker 호출용) # internal endpoint Bearer token (Mac mini derived-worker 호출용)
internal_worker_token: str = "" internal_worker_token: str = ""
@@ -228,6 +231,7 @@ def load_settings() -> Settings:
maintenance_mode = os.getenv("MAINTENANCE_MODE", "false").lower() in ("1", "true", "yes") maintenance_mode = os.getenv("MAINTENANCE_MODE", "false").lower() in ("1", "true", "yes")
maintenance_note = os.getenv("MAINTENANCE_NOTE", "") maintenance_note = os.getenv("MAINTENANCE_NOTE", "")
study_ingest_enabled = os.getenv("STUDY_INGEST_ENABLED", "false").lower() in ("1", "true", "yes") study_ingest_enabled = os.getenv("STUDY_INGEST_ENABLED", "false").lower() in ("1", "true", "yes")
study_reminder_webhook_url = os.getenv("STUDY_REMINDER_WEBHOOK_URL", "")
internal_worker_token = os.getenv("INTERNAL_WORKER_TOKEN", "") internal_worker_token = os.getenv("INTERNAL_WORKER_TOKEN", "")
viewer_sync_token = os.getenv("VIEWER_SYNC_TOKEN", "") viewer_sync_token = os.getenv("VIEWER_SYNC_TOKEN", "")
jwt_secret = os.getenv("JWT_SECRET", "") jwt_secret = os.getenv("JWT_SECRET", "")
@@ -371,6 +375,7 @@ def load_settings() -> Settings:
maintenance_mode=maintenance_mode, maintenance_mode=maintenance_mode,
maintenance_note=maintenance_note, maintenance_note=maintenance_note,
study_ingest_enabled=study_ingest_enabled, study_ingest_enabled=study_ingest_enabled,
study_reminder_webhook_url=study_reminder_webhook_url,
internal_worker_token=internal_worker_token, internal_worker_token=internal_worker_token,
viewer_sync_token=viewer_sync_token, viewer_sync_token=viewer_sync_token,
pipeline_held_stages=pipeline_held_stages, pipeline_held_stages=pipeline_held_stages,
+2
View File
@@ -65,6 +65,8 @@ class StudyMemoCard(Base):
needs_review: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) needs_review: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
flagged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) flagged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
flagged_by: Mapped[str | None] = mapped_column(String(40)) flagged_by: Mapped[str | None] = mapped_column(String(40))
# 검수 처리 시각 — 승인/수정확정/soft-delete 시 박힘 (migration 383, '오늘의 몫' 검수 집계).
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
model: Mapped[str | None] = mapped_column(String(120)) model: Mapped[str | None] = mapped_column(String(120))
generated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) generated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+215
View File
@@ -0,0 +1,215 @@
"""daily_unit — '오늘의 몫' (유한한 데일리 학습 단위) 집계 + 스트릭/잔디.
습관 루프 트랙(2026-07-03): 문제엔진·SR·이론 홈·암기카드까지 완성인데 organic 사용 0.
진단 = 트리거 없음 + 열면 due 산더미 + 반복의 감각 부재. 이 모듈은 그 중 '유한한 단위'
'반복의 감각(스트릭)'의 read-only 재료를 만든다. write 0 (기존 attempt/rate/read 가 정본).
targets 기본(사용자 합의 D4) = 문제 5 · 카드 5 · 개념 1 · 카드검수 3.
가용량(effective) 보정: 남은 재료가 target 미만이면 target 을 낮춰 '영영 완주 불가' 방지.
'오늘' 경계 = KST(Asia/Seoul, 스케줄러 tz 와 동일). DB 저장은 UTC 라 경계 변환 후 비교.
스트릭/잔디 근사 주의: 카드 평가는 per-event 로그가 없어(progress.last_reviewed_at 만)
과거일이 과소집계될 수 있다(같은 카드 재평가 시 이전 날짜 증발). 문제풀이·회독은 전이력
정확. 오늘 몫에 문제풀이가 포함되므로 완주일은 attempts 로 정확히 남는다.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from sqlalchemy import and_, func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from models.study_concept_progress import StudyConceptProgress
from models.study_memo_card import StudyMemoCard
from models.study_memo_card_progress import StudyMemoCardProgress
from models.study_question import StudyQuestionAttempt
from services.study import concept_curriculum as cc
KST = ZoneInfo("Asia/Seoul")
# 기본 targets (사용자 합의 2026-07-03 D4 — 착수 장벽 최소화가 관건이라 소량).
TARGET_QUESTIONS = 5
TARGET_CARDS = 5
TARGET_CONCEPTS = 1
TARGET_REVIEWS = 3
GRASS_DAYS = 84 # 12주
def kst_day_start(now: datetime) -> datetime:
"""오늘(KST) 0시의 UTC datetime."""
local = now.astimezone(KST)
start_local = local.replace(hour=0, minute=0, second=0, microsecond=0)
return start_local.astimezone(timezone.utc)
async def _count(session: AsyncSession, stmt) -> int:
return (await session.execute(stmt)).scalar_one()
async def daily_state(
session: AsyncSession, user_id: int, topic_id: int, now: datetime | None = None
) -> dict:
"""오늘의 몫 상태 — 항목별 done/target + 완주 여부 + 다음 개념 CTA 재료."""
now = now or datetime.now(timezone.utc)
start = kst_day_start(now)
# ── 금일 완료량 ──
q_done = await _count(
session,
select(func.count())
.select_from(StudyQuestionAttempt)
.where(
StudyQuestionAttempt.user_id == user_id,
StudyQuestionAttempt.answered_at >= start,
),
)
card_done = await _count(
session,
select(func.count())
.select_from(StudyMemoCardProgress)
.where(
StudyMemoCardProgress.user_id == user_id,
StudyMemoCardProgress.last_reviewed_at.is_not(None),
StudyMemoCardProgress.last_reviewed_at >= start,
),
)
concept_done = await _count(
session,
select(func.count())
.select_from(StudyConceptProgress)
.where(
StudyConceptProgress.user_id == user_id,
StudyConceptProgress.last_read_at.is_not(None),
StudyConceptProgress.last_read_at >= start,
),
)
review_done = await _count(
session,
select(func.count())
.select_from(StudyMemoCard)
.where(
StudyMemoCard.user_id == user_id,
StudyMemoCard.reviewed_at.is_not(None),
StudyMemoCard.reviewed_at >= start,
),
)
# ── 가용량 (effective target 보정용) ──
# 카드: /study-cards/due 와 동일 술어(신규 progress 없음 OR 예정 due) — cold-start 정합.
P = StudyMemoCardProgress
card_avail = await _count(
session,
select(func.count())
.select_from(StudyMemoCard)
.outerjoin(P, and_(P.card_id == StudyMemoCard.id, P.user_id == user_id))
.where(
StudyMemoCard.user_id == user_id,
StudyMemoCard.deleted_at.is_(None),
StudyMemoCard.needs_review.is_(False),
or_(
P.id.is_(None),
and_(
P.due_at.is_not(None),
P.due_at <= now,
or_(P.review_stage.is_(None), P.review_stage < 4),
),
),
),
)
review_avail = await _count(
session,
select(func.count())
.select_from(StudyMemoCard)
.where(
StudyMemoCard.user_id == user_id,
StudyMemoCard.deleted_at.is_(None),
StudyMemoCard.needs_review.is_(True),
),
)
# 개념: today_concepts 재사용 (재복습 due + 미독) — 첫 항목은 CTA 딥링크 재료.
today = await cc.today_concepts(session, user_id, topic_id, limit=1)
concept_avail = today["due_total"] + today["unread_total"]
next_concept = today["concepts"][0] if today["concepts"] else None
def eff(target: int, done: int, avail: int) -> int:
# 남은 재료가 target 에 못 미치면 낮춤 (완주 가능 보장). 이미 done>=target 이면 target.
return min(target, done + avail)
items = {
"questions": {"done": q_done, "target": TARGET_QUESTIONS}, # 문제는 2,100 — 가용 보정 불요
"cards": {"done": card_done, "target": eff(TARGET_CARDS, card_done, card_avail)},
"concepts": {"done": concept_done, "target": eff(TARGET_CONCEPTS, concept_done, concept_avail)},
"reviews": {"done": review_done, "target": eff(TARGET_REVIEWS, review_done, review_avail)},
}
for it in items.values():
it["complete"] = it["target"] == 0 or it["done"] >= it["target"]
active_targets = [it for it in items.values() if it["target"] > 0]
complete = bool(active_targets) and all(it["complete"] for it in active_targets)
return {
"topic_id": topic_id,
"date_kst": now.astimezone(KST).date().isoformat(),
"items": items,
"complete": complete,
"next_concept": (
{"doc_id": next_concept["doc_id"], "title": next_concept["title"]}
if next_concept
else None
),
}
# 잔디 = KST 일 단위 활동량. attempts + document_reads 는 전이력 정확, 카드 평가일은 근사(docstring).
_GRASS_SQL = text(
"""
SELECT d, sum(n)::int AS n FROM (
SELECT (answered_at AT TIME ZONE 'Asia/Seoul')::date AS d, count(*) AS n
FROM study_question_attempts
WHERE user_id = :uid AND answered_at >= :since
GROUP BY 1
UNION ALL
SELECT (read_at AT TIME ZONE 'Asia/Seoul')::date AS d, count(*) AS n
FROM document_reads
WHERE user_id = :uid AND read_at >= :since
GROUP BY 1
UNION ALL
SELECT (last_reviewed_at AT TIME ZONE 'Asia/Seoul')::date AS d, count(*) AS n
FROM study_memo_card_progress
WHERE user_id = :uid AND last_reviewed_at IS NOT NULL AND last_reviewed_at >= :since
GROUP BY 1
) u GROUP BY d
"""
)
async def streak_and_grass(
session: AsyncSession, user_id: int, now: datetime | None = None, days: int = GRASS_DAYS
) -> dict:
"""잔디(최근 days일, KST 일 단위 활동량) + 스트릭(오늘 또는 어제부터 역산 연속 활동일)."""
now = now or datetime.now(timezone.utc)
today_local = now.astimezone(KST).date()
since = kst_day_start(now) - timedelta(days=days - 1)
rows = (
await session.execute(_GRASS_SQL, {"uid": user_id, "since": since})
).all()
by_day = {r.d: r.n for r in rows}
grass = []
for i in range(days - 1, -1, -1):
d = today_local - timedelta(days=i)
grass.append({"d": d.isoformat(), "n": int(by_day.get(d, 0))})
# 스트릭: 오늘 활동 있으면 오늘부터, 없으면 어제부터 역산 (오늘은 아직 진행 중일 수 있음).
streak = 0
cursor = today_local if by_day.get(today_local, 0) > 0 else today_local - timedelta(days=1)
while by_day.get(cursor, 0) > 0:
streak += 1
cursor -= timedelta(days=1)
return {"streak": streak, "today_active": by_day.get(today_local, 0) > 0, "grass": grass}
+32
View File
@@ -23,6 +23,15 @@ logger = setup_logger("queue_consumer")
# pipeline.held_stages 안내 로그는 1분 사이클마다 반복하지 않고 최초 1회만. # pipeline.held_stages 안내 로그는 1분 사이클마다 반복하지 않고 최초 1회만.
_hold_logged = False _hold_logged = False
# PR3 후속(2026-07-03): 영구 실패 알람 — 사람이 개입해야 풀리는 상태라 Chat 웹훅 발화.
# allowlist 로 소음 제한: embed/chunk 류 대량 배치가 일제히 실패하면 문서 수만큼 알람이
# 쏟아지므로, 건당 가치가 높고 발생률이 낮은 LLM 스테이지만 기본 대상으로 한다.
_ALERT_FAIL_STAGES = {
s.strip()
for s in os.getenv("ALERT_FAIL_STAGES", "deep_summary,summarize").split(",")
if s.strip()
}
# stage별 배치 크기 # stage별 배치 크기
# stt 는 GPU 단일 점유 + 회의 30분짜리도 가능 → 배치 1. thumbnail 은 ffmpeg subprocess 로 가벼움. # stt 는 GPU 단일 점유 + 회의 30분짜리도 가능 → 배치 1. thumbnail 은 ffmpeg subprocess 로 가벼움.
# deep_summary (PR-B B-1) 는 MLX 26B 단일 Semaphore(1) 경유 → 배치 1. # deep_summary (PR-B B-1) 는 MLX 26B 단일 Semaphore(1) 경유 → 배치 1.
@@ -353,6 +362,8 @@ async def _process_stage(stage, worker_fn):
except Exception as e: except Exception as e:
# 실패 처리 # 실패 처리
permanently_failed = False
doc_title = None
async with async_session() as session: async with async_session() as session:
item = await session.get(ProcessingQueue, queue_id) item = await session.get(ProcessingQueue, queue_id)
if not item: if not item:
@@ -363,7 +374,16 @@ async def _process_stage(stage, worker_fn):
item.error_message = err_text[:500] item.error_message = err_text[:500]
if item.attempts >= item.max_attempts: if item.attempts >= item.max_attempts:
item.status = "failed" item.status = "failed"
permanently_failed = True
logger.error(f"[{stage}] document_id={document_id} 영구 실패: {e}") logger.error(f"[{stage}] document_id={document_id} 영구 실패: {e}")
if stage in _ALERT_FAIL_STAGES:
# 알람용 제목 best-effort — 실패해도 알람 자체는 발화한다.
try:
from models.document import Document
_doc = await session.get(Document, document_id)
doc_title = getattr(_doc, "title", None)
except Exception:
doc_title = None
# B3: marker_worker 는 변환 시작 시 doc.md_status='processing' 으로 표시한다. # B3: marker_worker 는 변환 시작 시 doc.md_status='processing' 으로 표시한다.
# 변환이 _fail()/_set_skipped() 를 거치지 않고 예외로 죽으면(예: 대형 # 변환이 _fail()/_set_skipped() 를 거치지 않고 예외로 죽으면(예: 대형
# batch ReadTimeout) doc.md_status 가 'processing' 에 영구 고착 = orphan # batch ReadTimeout) doc.md_status 가 'processing' 에 영구 고착 = orphan
@@ -385,6 +405,18 @@ async def _process_stage(stage, worker_fn):
item.started_at = None item.started_at = None
logger.warning(f"[{stage}] document_id={document_id} 재시도 예정 ({item.attempts}/{item.max_attempts}): {e}") logger.warning(f"[{stage}] document_id={document_id} 재시도 예정 ({item.attempts}/{item.max_attempts}): {e}")
await session.commit() await session.commit()
if permanently_failed and stage in _ALERT_FAIL_STAGES:
# 영구 실패 = 무인 파이프라인이 스스로 못 푸는 상태 → 유인 전환 알람.
# send_alert 는 절대 raise 하지 않음(no-op/실패 = False 반환뿐).
from services.alerts import send_alert
await send_alert(
f"[DS] {stage} 영구 실패 — doc {document_id}",
(
f"{doc_title or '(제목 미상)'}\n"
f"에러: {err_text[:300]}\n"
f"확인: scripts/presegment_attended.py list (보류/거부 사유) 또는 큐 재큐"
),
)
async def consume_queue(): async def consume_queue():
+56
View File
@@ -14,21 +14,72 @@ due 0 이면 row 미생성(noise 방지). 놓친 시각은 그냥 skip(stale 복
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timezone from datetime import datetime, timezone
import httpx
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from core.config import settings
from core.database import async_session from core.database import async_session
from models.study_question_progress import StudyQuestionProgress from models.study_question_progress import StudyQuestionProgress
from models.study_reminder import StudyReminder from models.study_reminder import StudyReminder
from models.study_topic import StudyTopic from models.study_topic import StudyTopic
from models.user import User # noqa: F401 (mapper 초기화 defensive) from models.user import User # noqa: F401 (mapper 초기화 defensive)
from services.study.daily_unit import KST, daily_state
logger = logging.getLogger("study_reminder") logger = logging.getLogger("study_reminder")
# 습관 루프 D1(2026-07-03): push 채널 flip — Synology Chat incoming webhook, 아침 1회만.
# 13/19 KST 슬롯은 집계만(노이즈 방지). '오늘의 몫' 완료 시 발신 skip(초대 프레임, 위협 아님).
WEBHOOK_HOUR_KST = 9
STUDY_HOME_URL = "https://document.hyungi.net/study"
_ITEM_LABELS = [("questions", "문제"), ("cards", "카드"), ("concepts", "개념"), ("reviews", "검수")]
async def _send_daily_nudge(by_user: dict[int, dict], now: datetime) -> None:
"""아침 슬롯 한정 — 오늘의 몫 미완 사용자에게 Synology Chat webhook 1건."""
url = settings.study_reminder_webhook_url
if not url:
logger.info("study_reminder nudge skip — STUDY_REMINDER_WEBHOOK_URL 미설정(off)")
return
async with async_session() as session:
for uid, agg in by_user.items():
if not agg["names"]:
continue
topic_id = agg["names"][0]["topic_id"]
try:
state = await daily_state(session, uid, topic_id, now)
except Exception:
logger.exception("study_reminder nudge daily_state 실패 user=%s", uid)
continue
if state["complete"]:
continue # 오늘 몫 완료 — 발신 없음
parts = [
f"{label} {it['target']}"
for key, label in _ITEM_LABELS
if (it := state["items"][key])["target"] > 0 and not it["complete"]
]
if not parts:
continue
msg = f"공부 오늘의 몫 — {' · '.join(parts)} (약 6분)\n{STUDY_HOME_URL}"
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
url, data={"payload": json.dumps({"text": msg}, ensure_ascii=False)}
)
resp.raise_for_status()
logger.info("study_reminder nudge sent user=%s", uid)
except Exception:
# 발신 실패 = 내일 다시 (재시도 없음 — 아침 1회 원칙, 실패는 로그만)
logger.exception("study_reminder nudge 발신 실패 user=%s", uid)
async def run() -> None: async def run() -> None:
"""APScheduler cron 진입점. focus 토픽 due 집계 → study_reminders append.""" """APScheduler cron 진입점. focus 토픽 due 집계 → study_reminders append."""
@@ -90,3 +141,8 @@ async def run() -> None:
await session.commit() await session.commit()
if inserted: if inserted:
logger.info("study_reminder fired slot=%s users=%d", slot.isoformat(), inserted) logger.info("study_reminder fired slot=%s users=%d", slot.isoformat(), inserted)
# 아침(09 KST) 슬롯만 채널 발신 — due 유무와 무관하게 '오늘의 몫' 미완이 기준
# (due 0 이어도 미독 개념/신규 카드가 몫을 구성할 수 있음).
if now.astimezone(KST).hour == WEBHOOK_HOUR_KST:
await _send_daily_nudge(by_user, now)
+123 -9
View File
@@ -1,15 +1,20 @@
<script> <script>
// /study — 학습 hub + 데일리 랜딩('오늘의 공부' 대시보드). // /study — 학습 hub + 데일리 랜딩. 최상단 = '오늘의 몫'(유한한 데일리 단위 + 스트릭/잔디,
// 상단 = 이론 홈(진도·오늘의 개념·복습 due, 재노출 트리거). 하단 = 기존 모드 진입. // 습관 루프 2026-07-03). 진도·복습 백로그는 접이식으로 강등(열자마자 due 산더미 금지, D2).
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast'; import { addToast } from '$lib/stores/toast';
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag, Inbox, Activity, CalendarCheck, Target } from 'lucide-svelte'; import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag, Inbox, Activity, CalendarCheck, Target, Flame, Play, CheckCircle2 } from 'lucide-svelte';
let cardReviewCount = $state(0); let cardReviewCount = $state(0);
let questionFlagCount = $state(0); let questionFlagCount = $state(0);
// 오늘의 공부 (이론 홈) // 오늘의 몫 (데일리 단위)
let daily = $state(null);
let dailyLoading = $state(true);
// 이론 홈 (백로그 접이식 내부)
let curriculum = $state(null); let curriculum = $state(null);
let todayConcepts = $state([]); let todayConcepts = $state([]);
let weakConcepts = $state([]); // 약점 개념(관련 기출 정답률 낮음) let weakConcepts = $state([]); // 약점 개념(관련 기출 정답률 낮음)
@@ -19,6 +24,46 @@
curriculum && curriculum.total ? Math.round((curriculum.read / curriculum.total) * 100) : 0 curriculum && curriculum.total ? Math.round((curriculum.read / curriculum.total) * 100) : 0
); );
// 오늘의 몫 4항목 (target 0 = 재료 없음 → 행 숨김)
const DAILY_ITEMS = [
{ key: 'questions', label: '문제 풀이', icon: GraduationCap },
{ key: 'cards', label: '카드 복습', icon: Repeat },
{ key: 'concepts', label: '개념 읽기', icon: BookOpen },
{ key: 'reviews', label: '카드 검수', icon: Layers },
];
function grassClass(n) {
if (n <= 0) return 'bg-bg border border-default/50';
if (n <= 2) return 'bg-accent/30';
if (n <= 9) return 'bg-accent/60';
return 'bg-accent';
}
async function loadDaily() {
dailyLoading = true;
try {
daily = await api('/study/daily');
} catch {
// 실패해도 허브 나머지는 동작 (조용히)
} finally {
dailyLoading = false;
}
}
/** 오늘의 몫 문제 5 — learning stage 5문항 세션 시작(진행 중 세션 있으면 이어풀기). */
async function startDailyQuiz() {
const topicId = daily?.topic_id ?? 4;
try {
const res = await api(`/study-topics/${topicId}/quiz-sessions`, {
method: 'POST',
body: JSON.stringify({ stage: 'learning', size: 5, quiz_mode: 'random' }),
});
goto(`/study/topics/${topicId}/review?session=${res.id}`);
} catch (err) {
addToast('error', err?.detail || '문제풀이 시작 실패');
}
}
async function loadDashboard() { async function loadDashboard() {
dashLoading = true; dashLoading = true;
try { try {
@@ -46,12 +91,14 @@
todayConcepts = todayConcepts.filter((c) => c.doc_id !== doc.doc_id); todayConcepts = todayConcepts.filter((c) => c.doc_id !== doc.doc_id);
addToast('success', `회독: ${doc.title}`); addToast('success', `회독: ${doc.title}`);
loadDashboard(); // 진도 갱신 loadDashboard(); // 진도 갱신
loadDaily(); // 오늘의 몫 갱신
} catch { } catch {
addToast('error', '회독 처리 실패'); addToast('error', '회독 처리 실패');
} }
} }
onMount(async () => { onMount(async () => {
loadDaily();
loadDashboard(); loadDashboard();
try { try {
const r = await api('/study-cards/needs-review/count'); const r = await api('/study-cards/needs-review/count');
@@ -72,16 +119,82 @@
<p class="text-sm text-dim mt-1">주제별 퀴즈·복습(SRS)·통계 / 학습 자료 회독 / 손글씨 필사 세션.</p> <p class="text-sm text-dim mt-1">주제별 퀴즈·복습(SRS)·통계 / 학습 자료 회독 / 손글씨 필사 세션.</p>
</header> </header>
<!-- 오늘의 공부 (이론 홈 대시보드 = 데일리 트리거) --> <!-- 오늘의 몫 — 유한한 데일리 단위 + 스트릭/잔디 (첫 화면은 이것과 완료 상태만) -->
<section class="mb-5 rounded-lg border border-default bg-surface p-4 md:p-5"> <section class="mb-5 rounded-lg border border-default bg-surface p-4 md:p-5">
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
<CalendarCheck size={18} class="text-accent" /> <CalendarCheck size={18} class="text-accent" />
<h2 class="text-base font-semibold text-text">오늘의 공부</h2> <h2 class="text-base font-semibold text-text">오늘의 </h2>
{#if curriculum} {#if daily}
<span class="ml-auto text-xs text-dim">이론 회독 <span class="text-text font-medium">{curriculum.read}</span> / {curriculum.total} ({readPct}%)</span> <span class="ml-auto flex items-center gap-1.5 text-xs {daily.streak > 0 ? 'text-accent' : 'text-dim'}">
<Flame size={14} />
{#if daily.streak > 0}{daily.streak}일 연속{:else}오늘부터 시작{/if}
</span>
{/if} {/if}
</div> </div>
{#if dailyLoading}
<p class="text-xs text-dim">불러오는 중…</p>
{:else if daily}
{#if daily.complete}
<div class="mb-3 flex items-center gap-2 rounded border border-accent/40 bg-accent/10 px-3 py-2.5 text-sm text-accent">
<CheckCircle2 size={16} /> 오늘 몫 끝. 내일 또 만나요.
</div>
{/if}
<ul class="space-y-1.5 mb-4">
{#each DAILY_ITEMS as item (item.key)}
{@const it = daily.items[item.key]}
{#if it && it.target > 0}
<li class="flex items-center gap-2.5 rounded border px-3 py-2 {it.complete ? 'border-accent/40 bg-accent/5' : 'border-default'}">
<item.icon size={15} class={it.complete ? 'text-accent' : 'text-dim'} />
<span class="text-sm {it.complete ? 'text-dim line-through' : 'text-text'}">{item.label}</span>
<span class="text-xs {it.complete ? 'text-accent' : 'text-dim'} font-medium">{Math.min(it.done, it.target)}/{it.target}</span>
<span class="ml-auto"></span>
{#if it.complete}
<CheckCircle2 size={15} class="text-accent shrink-0" />
{:else if item.key === 'questions'}
<button
type="button"
onclick={startDailyQuiz}
class="shrink-0 flex items-center gap-1 rounded bg-accent px-2.5 py-1 text-xs font-medium text-white hover:opacity-90 transition-opacity"
><Play size={11} /> 풀기</button>
{:else if item.key === 'cards'}
<a href="/study/cards-study" class="shrink-0 rounded border border-default px-2.5 py-1 text-xs text-dim hover:border-accent hover:text-accent transition-colors">복습</a>
{:else if item.key === 'concepts'}
<a href={daily.next_concept ? `/study/read/${daily.next_concept.doc_id}` : '/study/sources'}
class="shrink-0 rounded border border-default px-2.5 py-1 text-xs text-dim hover:border-accent hover:text-accent transition-colors">읽기</a>
{:else}
<a href="/study/cards-review" class="shrink-0 rounded border border-default px-2.5 py-1 text-xs text-dim hover:border-accent hover:text-accent transition-colors">검수</a>
{/if}
</li>
{/if}
{/each}
</ul>
<!-- 잔디 (최근 12주, KST 일 단위 활동량) -->
{#if daily.grass?.length}
<div class="overflow-x-auto">
<div class="grid grid-rows-7 grid-flow-col gap-[3px] w-max">
{#each daily.grass as g (g.d)}
<span class="w-2.5 h-2.5 rounded-[2px] {grassClass(g.n)}" title="{g.d}: {g.n}"></span>
{/each}
</div>
</div>
{/if}
{:else}
<p class="text-xs text-dim">오늘의 몫을 불러오지 못했습니다.</p>
{/if}
</section>
<!-- 진도·복습 백로그 (강등 — 첫 화면에서 due 산더미 노출 금지) -->
<details class="mb-5 rounded-lg border border-default bg-surface">
<summary class="cursor-pointer select-none px-4 py-3 text-sm text-dim hover:text-text transition-colors">
진도 · 복습 백로그
{#if curriculum}
<span class="text-xs text-faint">(이론 회독 {readPct}% · 문항 복습 {curriculum.question_due} · 개념 재복습 {curriculum.concept_due})</span>
{/if}
</summary>
<div class="px-4 pb-4 md:px-5 md:pb-5">
{#if dashLoading} {#if dashLoading}
<p class="text-xs text-dim">불러오는 중…</p> <p class="text-xs text-dim">불러오는 중…</p>
{:else} {:else}
@@ -144,7 +257,8 @@
</div> </div>
{/if} {/if}
{/if} {/if}
</section> </div>
</details>
<a <a
href="/study/topics" href="/study/topics"
@@ -0,0 +1,3 @@
-- 383: 카드 검수 처리 시각 (습관 루프 '오늘의 몫' 검수 N장/일 집계용).
-- 승인(needs_review false 전환)·수정확정·soft-delete 시 박힘. NULL = 미처리.
ALTER TABLE study_memo_cards ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ