"""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"), }