Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb9e0905f2 | |||
| 371ee4ebe6 |
+11
-1
@@ -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)
|
||||||
|
|||||||
@@ -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)],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user