feat(study): 이론공부 홈 — 오늘의 개념·진도·회독 SR (Stage S)
개념문서(가스기사 289) 소비 표면 개선 1단계. /study 허브를 데일리 랜딩으로.
- 마이그 381 study_concept_progress (개념 SR, sr_schedule 공용, documents FK 없음=락 회피)
- concept_curriculum 서비스 + /api/study (curriculum·today-concepts·concepts/{id}/read)
- read 상태 정본 = document_reads (is_read 컬럼 아님), mark_read=회독+SR 입고
- 문제풀이 표면 무접촉·additive
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
"""study_concepts API — 이론공부 홈(오늘의 개념 · 진도 · 회독 SR). prefix = /api/study.
|
||||
|
||||
문제풀이 표면 무접촉. 개념문서(가스기사 태그) 읽기 집계 + 회독 SR write 만. 단일 토픽(가스기사=4).
|
||||
경로: GET /curriculum · GET /today-concepts · POST /concepts/{doc_id}/read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.database import get_session
|
||||
from models.user import User
|
||||
from services.study import concept_curriculum as cc
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 가스기사 단일 토픽 운영(현행). 다토픽 확장 시 쿼리 파라미터로 승격.
|
||||
DEFAULT_TOPIC_ID = 4
|
||||
|
||||
|
||||
@router.get("/curriculum")
|
||||
async def get_curriculum(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
topic_id: int = DEFAULT_TOPIC_ID,
|
||||
):
|
||||
"""과목별 회독 진도 + 개념/문항 복습 due 요약."""
|
||||
return await cc.curriculum(session, user.id, topic_id)
|
||||
|
||||
|
||||
@router.get("/today-concepts")
|
||||
async def get_today_concepts(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
topic_id: int = DEFAULT_TOPIC_ID,
|
||||
limit: int = 6,
|
||||
):
|
||||
"""오늘 공부할 개념(재복습 → 미독 빈출순)."""
|
||||
return await cc.today_concepts(session, user.id, topic_id, limit)
|
||||
|
||||
|
||||
@router.post("/concepts/{doc_id}/read")
|
||||
async def post_concept_read(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
topic_id: int = DEFAULT_TOPIC_ID,
|
||||
):
|
||||
"""개념 회독 처리 → 회독 플래그 + SR 입고/전진."""
|
||||
return await cc.mark_read(session, user.id, topic_id, doc_id)
|
||||
@@ -33,6 +33,7 @@ from api.study_sessions import router as study_sessions_router
|
||||
from api.study_topics import router as study_topics_router
|
||||
from api.study_reminders import router as study_reminders_router
|
||||
from api.study_cards import router as study_cards_router
|
||||
from api.study_concepts import router as study_concepts_router
|
||||
from api.video import router as video_router
|
||||
from core.config import settings
|
||||
from core.database import async_session, engine, init_db
|
||||
@@ -249,6 +250,8 @@ app.include_router(study_reminders_router, prefix="/api/study-reminders", tags=[
|
||||
app.include_router(study_cards_router, prefix="/api/study-cards", tags=["study-cards"])
|
||||
# Phase 1: 학습 진행 상태 (review-complete + review-queue). prefix=/api/study-topics 안에 정의됨.
|
||||
app.include_router(study_question_progress_router, prefix="/api", tags=["study-progress"])
|
||||
# 이론공부 홈: 오늘의 개념·진도·회독 SR (개념문서 소비 표면, 문제풀이 무접촉).
|
||||
app.include_router(study_concepts_router, prefix="/api/study", tags=["study-theory"])
|
||||
|
||||
# TODO: Phase 5에서 추가
|
||||
# app.include_router(tasks.router, prefix="/api/tasks", tags=["tasks"])
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""study_concept_progress — 사용자 × 개념문서 단위 간격반복(SR) 진행 (이론공부 홈).
|
||||
|
||||
문제 SR(study_question_progress)의 개념(이론)판. '개념문서' = documents 한 건(가스기사 태그).
|
||||
회독(첫 read) → 복습 큐 진입, 이후 회독마다 sr_schedule 산술(1·3·7·14·졸업) 공용 전진.
|
||||
concept_doc_id 는 documents.id 를 가리키나 FK 미설정 — hot 테이블(documents) 락 회피(clause_study 선례).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, SmallInteger, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class StudyConceptProgress(Base):
|
||||
__tablename__ = "study_concept_progress"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"user_id", "concept_doc_id", name="uq_concept_progress_user_doc"
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
study_topic_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("study_topics.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
# documents.id 참조 — FK 없음(락 회피). 개념문서 삭제 시 고아 행은 read 집계에서 자연 제외.
|
||||
concept_doc_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
|
||||
# 복습 큐 (sr_schedule 공용): stage 0~3 = 1·3·7·14일, 4 = 졸업(due_at NULL)
|
||||
review_stage: Mapped[int | None] = mapped_column(SmallInteger)
|
||||
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False
|
||||
)
|
||||
@@ -0,0 +1,207 @@
|
||||
"""concept_curriculum — 이론공부 홈 재료 (오늘의 개념 · 진도 · 회독 SR).
|
||||
|
||||
개념문서 = documents (user_tags = @library/{topic}/{과목}/... , 가스기사). is_read = 회독,
|
||||
md_content 의 ★ 개수 = 빈출 tier(★★★=3 / ★★=2 / else 1). 회독 SR = study_concept_progress
|
||||
+ sr_schedule(문제 SR 공용 산술). 읽기 전용 집계 + mark_read(회독+SR 입고)만 write. LLM 0.
|
||||
|
||||
문제풀이 표면 무접촉 — 여기서 읽는 study_question_progress 는 '문항 due 카운트'만(홈 표시용).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, or_, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.document_read import DocumentRead
|
||||
from models.study_concept_progress import StudyConceptProgress
|
||||
from models.study_question_progress import StudyQuestionProgress
|
||||
from models.study_topic import StudyTopic
|
||||
from services.study.sr_schedule import advance, first_due
|
||||
|
||||
# 개념 행 조회 — 태그로 개념문서 필터 + 회독 진행 LEFT JOIN. md_content 는 전송 안 하고
|
||||
# ★ 유무만 서버측 boolean 으로(홈이 자주 호출돼도 페이로드 최소).
|
||||
# is_read = document_reads(회독 정본, is_read 컬럼 아님) EXISTS. library unread 와 동일 기준.
|
||||
_CONCEPT_ROWS_SQL = text(
|
||||
"""
|
||||
SELECT d.id AS doc_id,
|
||||
d.title AS title,
|
||||
EXISTS (
|
||||
SELECT 1 FROM document_reads r
|
||||
WHERE r.document_id = d.id AND r.user_id = :uid
|
||||
) AS is_read,
|
||||
(d.md_content LIKE '%★★★%') AS f3,
|
||||
(d.md_content LIKE '%★★%') AS f2,
|
||||
split_part(replace(d.user_tags::text, '"', ''), '/', 3) AS subject,
|
||||
p.review_stage AS review_stage,
|
||||
p.due_at AS due_at,
|
||||
p.last_read_at AS last_read_at
|
||||
FROM documents d
|
||||
LEFT JOIN study_concept_progress p
|
||||
ON p.concept_doc_id = d.id AND p.user_id = :uid
|
||||
WHERE d.user_tags::text LIKE :like
|
||||
AND d.deleted_at IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def _topic_name(session: AsyncSession, topic_id: int) -> str | None:
|
||||
return (
|
||||
await session.execute(select(StudyTopic.name).where(StudyTopic.id == topic_id))
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
async def _concept_rows(session: AsyncSession, user_id: int, topic_name: str):
|
||||
like = f"%@library/{topic_name}/%"
|
||||
return (
|
||||
await session.execute(_CONCEPT_ROWS_SQL, {"uid": user_id, "like": like})
|
||||
).mappings().all()
|
||||
|
||||
|
||||
def _freq(row) -> int:
|
||||
if row["f3"]:
|
||||
return 3
|
||||
if row["f2"]:
|
||||
return 2
|
||||
return 1
|
||||
|
||||
|
||||
def _is_due(row, now: datetime) -> bool:
|
||||
return (
|
||||
row["due_at"] is not None
|
||||
and row["due_at"] <= now
|
||||
and (row["review_stage"] or 0) < 4
|
||||
)
|
||||
|
||||
|
||||
def _item(row) -> dict:
|
||||
return {
|
||||
"doc_id": row["doc_id"],
|
||||
"title": row["title"],
|
||||
"subject": row["subject"],
|
||||
"freq": _freq(row),
|
||||
"review_stage": row["review_stage"],
|
||||
"due_at": row["due_at"],
|
||||
}
|
||||
|
||||
|
||||
async def _question_due_count(session: AsyncSession, user_id: int, topic_id: int, now: datetime) -> int:
|
||||
"""문항 복습 due (기존 study_question_progress 엔진 재사용, 홈 표시용)."""
|
||||
return (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(StudyQuestionProgress)
|
||||
.where(
|
||||
StudyQuestionProgress.user_id == user_id,
|
||||
StudyQuestionProgress.study_topic_id == topic_id,
|
||||
StudyQuestionProgress.due_at.is_not(None),
|
||||
StudyQuestionProgress.due_at <= now,
|
||||
or_(
|
||||
StudyQuestionProgress.review_stage.is_(None),
|
||||
StudyQuestionProgress.review_stage < 4,
|
||||
),
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
async def curriculum(session: AsyncSession, user_id: int, topic_id: int) -> dict:
|
||||
"""과목별 회독 진도 + 개념/문항 복습 due 요약 (진도 대시보드)."""
|
||||
name = await _topic_name(session, topic_id)
|
||||
rows = await _concept_rows(session, user_id, name) if name else []
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
subj: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
s = subj.setdefault(r["subject"], {"subject": r["subject"], "total": 0, "read": 0})
|
||||
s["total"] += 1
|
||||
if r["is_read"]:
|
||||
s["read"] += 1
|
||||
|
||||
total = len(rows)
|
||||
read = sum(1 for r in rows if r["is_read"])
|
||||
concept_due = sum(1 for r in rows if _is_due(r, now))
|
||||
question_due = await _question_due_count(session, user_id, topic_id, now)
|
||||
|
||||
return {
|
||||
"topic_id": topic_id,
|
||||
"topic_name": name,
|
||||
"subjects": sorted(subj.values(), key=lambda x: x["subject"]),
|
||||
"total": total,
|
||||
"read": read,
|
||||
"concept_due": concept_due,
|
||||
"question_due": question_due,
|
||||
}
|
||||
|
||||
|
||||
async def today_concepts(
|
||||
session: AsyncSession, user_id: int, topic_id: int, limit: int = 6
|
||||
) -> dict:
|
||||
"""오늘 공부할 개념 = 재복습(SR due) 먼저 → 미독(빈출 우선). 졸업/재복습대기 제외."""
|
||||
name = await _topic_name(session, topic_id)
|
||||
rows = await _concept_rows(session, user_id, name) if name else []
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
due = [r for r in rows if _is_due(r, now)]
|
||||
due.sort(key=lambda r: r["due_at"])
|
||||
|
||||
# 미독 & 아직 SR 큐 진입 전(due_at NULL) → 빈출 높은 순
|
||||
unread = [r for r in rows if not r["is_read"] and r["due_at"] is None]
|
||||
unread.sort(key=lambda r: (-_freq(r), r["subject"], r["title"]))
|
||||
|
||||
picked = [{**_item(r), "reason": "재복습"} for r in due]
|
||||
picked += [{**_item(r), "reason": "신규"} for r in unread]
|
||||
|
||||
return {
|
||||
"concepts": picked[:limit],
|
||||
"due_total": len(due),
|
||||
"unread_total": len(unread),
|
||||
}
|
||||
|
||||
|
||||
async def mark_read(
|
||||
session: AsyncSession, user_id: int, topic_id: int, doc_id: int, now: datetime | None = None
|
||||
) -> dict:
|
||||
"""개념 회독 처리 = document_reads(+1) + 회독 SR 입고/전진.
|
||||
|
||||
회독 정본 = document_reads(append-only), documents.is_read 컬럼 아님(library unread 와 정합).
|
||||
첫 회독 → first_due(stage 0, 내일). 이후 회독은 'due 도래(due_at<=now)' 때만 correct 로 전진
|
||||
(이른 재열람/다중클릭 과전진 방지). stage 4 졸업 후엔 due_at NULL 이라 전진 없음.
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
|
||||
# 회독 로그 append (+1) — 사용자 명시 회독. 자동 아님(엔드포인트 = 명시 POST).
|
||||
session.add(DocumentRead(user_id=user_id, document_id=doc_id, read_at=now))
|
||||
|
||||
prog = (
|
||||
await session.execute(
|
||||
select(StudyConceptProgress).where(
|
||||
StudyConceptProgress.user_id == user_id,
|
||||
StudyConceptProgress.concept_doc_id == doc_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if prog is None:
|
||||
stage, due = first_due(now)
|
||||
prog = StudyConceptProgress(
|
||||
user_id=user_id,
|
||||
study_topic_id=topic_id,
|
||||
concept_doc_id=doc_id,
|
||||
review_stage=stage,
|
||||
due_at=due,
|
||||
last_read_at=now,
|
||||
)
|
||||
session.add(prog)
|
||||
else:
|
||||
# due 도래 시에만 전진 — 미래 due(재열람 이른 클릭)는 stage 불변, last_read_at 만 갱신.
|
||||
if prog.due_at is not None and prog.due_at <= now:
|
||||
res = advance(prog.review_stage, "correct", now)
|
||||
if res is not None:
|
||||
prog.review_stage, prog.due_at = res
|
||||
prog.last_read_at = now
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(prog)
|
||||
return {"ok": True, "review_stage": prog.review_stage, "due_at": prog.due_at}
|
||||
Reference in New Issue
Block a user