diff --git a/app/api/study_concepts.py b/app/api/study_concepts.py new file mode 100644 index 0000000..1c6d36e --- /dev/null +++ b/app/api/study_concepts.py @@ -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) diff --git a/app/main.py b/app/main.py index 61a55b6..53ca820 100644 --- a/app/main.py +++ b/app/main.py @@ -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"]) diff --git a/app/models/study_concept_progress.py b/app/models/study_concept_progress.py new file mode 100644 index 0000000..9f7c4b9 --- /dev/null +++ b/app/models/study_concept_progress.py @@ -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 + ) diff --git a/app/services/study/concept_curriculum.py b/app/services/study/concept_curriculum.py new file mode 100644 index 0000000..9c48e63 --- /dev/null +++ b/app/services/study/concept_curriculum.py @@ -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} diff --git a/frontend/src/routes/study/+page.svelte b/frontend/src/routes/study/+page.svelte index 12fa5c5..34625a8 100644 --- a/frontend/src/routes/study/+page.svelte +++ b/frontend/src/routes/study/+page.svelte @@ -1,13 +1,52 @@