da4a2e81c3
개념문서(가스기사 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>
208 lines
7.5 KiB
Python
208 lines
7.5 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.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}
|