feat(study): 이론↔문제 브리지 (Stage B) — 개념별 정답률·약점 개념 지도
이론공부 B→A→C 의 B. 완성된 문제풀이에 이론 연결(약점 구동).
- 마이그 382 study_concept_links(개념 doc↔기출, FK 없음) + 백필 SQL(임베딩 코사인 top-k=10·threshold 0.62 → 2362링크·284개념·964문항)
- concept_links 서비스(related_questions·weakness_map 롤업) + GET /concepts/{id}/questions·/concepts/weakness-map(라우트 순서=weakness-map 먼저)
- 리더 관련기출 섹션(정답률·문항 stub→문항상세) + 홈 약점개념 위젯
- 적대리뷰 반영: Promise.all 격리(weakness-map 실패→코어 대시보드 블랙아웃 방지)·q.subject null 폴백. 백필=배포 후 트랜잭션 래핑 실행. 문제풀이 무접촉
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ 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
|
||||
from services.study import concept_links as cl
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -43,6 +44,20 @@ async def get_today_concepts(
|
||||
return await cc.today_concepts(session, user.id, topic_id, limit)
|
||||
|
||||
|
||||
@router.get("/concepts/weakness-map")
|
||||
async def get_weakness_map(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
topic_id: int = DEFAULT_TOPIC_ID,
|
||||
limit: int = 12,
|
||||
):
|
||||
"""개념 약점 지도 — 링크된 기출 정답률로 약점 개념(정답률<60%) 우선(이론↔문제)."""
|
||||
name = await cc._topic_name(session, topic_id)
|
||||
if not name:
|
||||
return {"weak": [], "weak_total": 0, "evaluated_total": 0}
|
||||
return await cl.weakness_map(session, user.id, name, limit)
|
||||
|
||||
|
||||
@router.get("/concepts/{doc_id}")
|
||||
async def get_concept_detail(
|
||||
doc_id: int,
|
||||
@@ -57,6 +72,17 @@ async def get_concept_detail(
|
||||
return detail
|
||||
|
||||
|
||||
@router.get("/concepts/{doc_id}/questions")
|
||||
async def get_concept_questions(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
limit: int = 20,
|
||||
):
|
||||
"""개념 관련 기출 + 내 정답률 (이론↔문제 브리지)."""
|
||||
return await cl.related_questions(session, user.id, doc_id, limit)
|
||||
|
||||
|
||||
@router.post("/concepts/{doc_id}/read")
|
||||
async def post_concept_read(
|
||||
doc_id: int,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""concept_links — 이론↔문제 브리지 롤업 (Stage B).
|
||||
|
||||
study_concept_links(개념 doc ↔ 기출문항, 임베딩 코사인) + study_question_progress(내 풀이상태)를
|
||||
조인해 (a) 개념별 관련 기출 + 내 정답률(related_questions), (b) 개념 약점 지도(weakness_map) 산출.
|
||||
읽기 전용 집계 · LLM 0. 링크 적재는 scripts/concept_links_backfill.sql(임베딩) 배치.
|
||||
정답률 = 링크된 문항 중 progress.last_outcome 기준(attempted=풀이이력 보유, correct=최근정답).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
_ACCURACY_WEAK_PCT = 60 # 정답률 < 60% = 약점(attempted>0 일 때만)
|
||||
|
||||
_AGG_SQL = text(
|
||||
"""
|
||||
SELECT count(*) AS linked,
|
||||
count(pr.study_question_id) FILTER (WHERE pr.last_outcome IS NOT NULL) AS attempted,
|
||||
count(*) FILTER (WHERE pr.last_outcome = 'correct') AS correct
|
||||
FROM study_concept_links l
|
||||
LEFT JOIN study_question_progress pr
|
||||
ON pr.study_question_id = l.question_id AND pr.user_id = :uid
|
||||
WHERE l.concept_doc_id = :doc_id AND l.link_source = 'embedding'
|
||||
"""
|
||||
)
|
||||
|
||||
_QROWS_SQL = text(
|
||||
"""
|
||||
SELECT q.id AS id, q.subject AS subject, q.exam_round AS exam_round,
|
||||
q.exam_question_number AS qnum, l.score AS score,
|
||||
pr.last_outcome AS last_outcome, pr.review_stage AS review_stage
|
||||
FROM study_concept_links l
|
||||
JOIN study_questions q ON q.id = l.question_id AND q.deleted_at IS NULL AND q.is_active
|
||||
LEFT JOIN study_question_progress pr
|
||||
ON pr.study_question_id = q.id AND pr.user_id = :uid
|
||||
WHERE l.concept_doc_id = :doc_id AND l.link_source = 'embedding'
|
||||
ORDER BY l.score DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
|
||||
_WEAKNESS_SQL = text(
|
||||
"""
|
||||
SELECT d.id AS doc_id, d.title AS title,
|
||||
split_part(replace(d.user_tags::text, '"', ''), '/', 3) AS subject,
|
||||
count(l.id) AS linked,
|
||||
count(pr.study_question_id) FILTER (WHERE pr.last_outcome IS NOT NULL) AS attempted,
|
||||
count(*) FILTER (WHERE pr.last_outcome = 'correct') AS correct
|
||||
FROM documents d
|
||||
JOIN study_concept_links l ON l.concept_doc_id = d.id AND l.link_source = 'embedding'
|
||||
LEFT JOIN study_question_progress pr
|
||||
ON pr.study_question_id = l.question_id AND pr.user_id = :uid
|
||||
WHERE d.user_tags::text LIKE :like AND d.deleted_at IS NULL
|
||||
GROUP BY d.id, d.title, subject
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def related_questions(
|
||||
session: AsyncSession, user_id: int, doc_id: int, limit: int = 20
|
||||
) -> dict:
|
||||
"""개념 doc 의 관련 기출 + 내 정답률(전체 링크 기준 집계 + 상위 N 표시용)."""
|
||||
agg = (
|
||||
await session.execute(_AGG_SQL, {"uid": user_id, "doc_id": doc_id})
|
||||
).mappings().first()
|
||||
rows = (
|
||||
await session.execute(
|
||||
_QROWS_SQL, {"uid": user_id, "doc_id": doc_id, "limit": limit}
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
linked = (agg["linked"] if agg else 0) or 0
|
||||
attempted = (agg["attempted"] if agg else 0) or 0
|
||||
correct = (agg["correct"] if agg else 0) or 0
|
||||
accuracy = round(100 * correct / attempted) if attempted else None
|
||||
|
||||
return {
|
||||
"linked": linked,
|
||||
"attempted": attempted,
|
||||
"correct": correct,
|
||||
"accuracy": accuracy,
|
||||
"questions": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"subject": r["subject"],
|
||||
"exam_round": r["exam_round"],
|
||||
"qnum": r["qnum"],
|
||||
"score": round(r["score"], 3) if r["score"] is not None else None,
|
||||
"last_outcome": r["last_outcome"],
|
||||
"review_stage": r["review_stage"],
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def weakness_map(
|
||||
session: AsyncSession, user_id: int, topic_name: str, limit: int = 12
|
||||
) -> dict:
|
||||
"""개념 약점 지도 — 링크된 기출 정답률로 개념 채색. 약점(attempted>0·정답률<60%) 우선 정렬."""
|
||||
like = f"%@library/{topic_name}/%"
|
||||
rows = (
|
||||
await session.execute(_WEAKNESS_SQL, {"uid": user_id, "like": like})
|
||||
).mappings().all()
|
||||
|
||||
concepts = []
|
||||
for r in rows:
|
||||
attempted = r["attempted"] or 0
|
||||
correct = r["correct"] or 0
|
||||
accuracy = round(100 * correct / attempted) if attempted else None
|
||||
if accuracy is None:
|
||||
state = "unattempted"
|
||||
elif accuracy < _ACCURACY_WEAK_PCT:
|
||||
state = "weak"
|
||||
else:
|
||||
state = "ok"
|
||||
concepts.append(
|
||||
{
|
||||
"doc_id": r["doc_id"],
|
||||
"title": r["title"],
|
||||
"subject": r["subject"],
|
||||
"linked": r["linked"] or 0,
|
||||
"attempted": attempted,
|
||||
"accuracy": accuracy,
|
||||
"state": state,
|
||||
}
|
||||
)
|
||||
|
||||
# 약점 우선(정답률 오름차순) → 미평가는 뒤로. 홈 위젯용 상위 N.
|
||||
weak = sorted(
|
||||
[c for c in concepts if c["state"] == "weak"],
|
||||
key=lambda c: (c["accuracy"], -c["attempted"], c["doc_id"]),
|
||||
)
|
||||
return {
|
||||
"weak": weak[:limit],
|
||||
"weak_total": len(weak),
|
||||
"evaluated_total": sum(1 for c in concepts if c["state"] != "unattempted"),
|
||||
}
|
||||
Reference in New Issue
Block a user