"""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.concept_parser import parse_concept, resolve_related 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} _CONCEPT_ONE_SQL = text( """ SELECT d.id AS doc_id, d.title AS title, d.md_content AS md_content, split_part(replace(d.user_tags::text, '"', ''), '/', 3) AS subject, (d.md_content LIKE '%★★★%') AS f3, (d.md_content LIKE '%★★%') AS f2, EXISTS ( SELECT 1 FROM document_reads r WHERE r.document_id = d.id AND r.user_id = :uid ) AS is_read, p.review_stage AS review_stage, p.due_at AS due_at FROM documents d LEFT JOIN study_concept_progress p ON p.concept_doc_id = d.id AND p.user_id = :uid WHERE d.id = :doc_id AND d.deleted_at IS NULL AND d.user_tags::text LIKE :like """ ) async def concept_detail( session: AsyncSession, user_id: int, topic_id: int, doc_id: int ) -> dict | None: """개념 리더 재료 — md 구조 파싱 + 관련개념 백링크 해소 + 회독/SR 상태 + 같은 과목 이전/다음.""" name = await _topic_name(session, topic_id) if not name: return None like = f"%@library/{name}/%" row = ( await session.execute( _CONCEPT_ONE_SQL, {"uid": user_id, "doc_id": doc_id, "like": like} ) ).mappings().first() if row is None: return None parsed = parse_concept(row["md_content"] or "") # 백링크 해소 + 이전/다음 = 같은 토픽 개념 title 인덱스(회독 rows 재사용) idx = await _concept_rows(session, user_id, name) title_index = [(r["doc_id"], r["title"], r["subject"]) for r in idx] resolved = resolve_related(parsed["related"], title_index) # 이전/다음 = 같은 과목, title 순 same = sorted( [(r["doc_id"], r["title"]) for r in idx if r["subject"] == row["subject"]], key=lambda x: (x[1] or "", x[0]), ) ids = [d for d, _ in same] prev_id = next_id = None if doc_id in ids: pos = ids.index(doc_id) if pos > 0: prev_id = ids[pos - 1] if pos < len(ids) - 1: next_id = ids[pos + 1] freq = 3 if row["f3"] else (2 if row["f2"] else 1) return { "doc_id": row["doc_id"], "db_title": row["title"], "title": parsed["title"] or row["title"], "subject": row["subject"], "freq": freq, "summary": parsed["summary"], "body": parsed["body"], "bincheol": parsed["bincheol"], "related": resolved, "is_read": row["is_read"], "review_stage": row["review_stage"], "due_at": row["due_at"], "prev_id": prev_id, "next_id": next_id, }