f38ec177d7
이론공부 개선 B→A→C 의 A. 개념노트를 구조(요약/본문/빈출★/관련개념)로 렌더 + 능동 회상(떠올리기) + 관련개념 백링크 + 이전/다음.
- concept_parser: md 골격 파서(273/273 불변식) + 관련개념 백링크 해소(exact→title⊆phrase substring, 과대매치 가드)
- concept_curriculum.concept_detail + GET /api/study/concepts/{id} (개념문서 태그 스코프)
- /study/read/[docId] 리더(MarkdownDoc KaTeX+docimg 재사용·읽기/떠올리기 모드) + 홈 오늘의개념 링크 연결
- 적대리뷰 5건 반영(이중로드·substring 오결선·엔드포인트 스코프·prev/next 결정성·in-flight 가드). 마이그 없음·문제풀이 무접촉
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
285 lines
10 KiB
Python
285 lines
10 KiB
Python
"""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,
|
|
}
|