From e1a2cdc6778b6dd5b7ca955929299b43f921fdf2 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 28 Apr 2026 08:41:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(study):=20AI=20=ED=92=80=EC=9D=B4=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=E2=80=94=20=EC=88=98=EB=8F=99=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EA=B1=B0=20+=20RAG=20(PR-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 복습 답 제출 후 또는 편집 화면에서 사용자가 명시적으로 누를 때만 AI 가 4지선다 풀이 생성. 자동 일괄 생성 금지 (하루 100문제 입력 시 MLX 부하· 잘못 입력 문제 해설 위험). 데이터 모델 (migrations 191~192): - study_questions 4 컬럼 추가: ai_explanation TEXT, ai_explanation_status VARCHAR(20) DEFAULT 'none' (none/pending/ready/failed/stale), ai_explanation_generated_at, ai_explanation_model - partial idx (study_topic_id, ai_explanation_status) WHERE status != 'none' PATCH stale 자동 전이: question_text/choice_*/correct_choice 변경 시 status='ready' 만 'stale' 로. 본문은 보존, UI 배지 + "다시 생성" 동선. 신규 엔드포인트: POST /api/study-questions/{id}/ai-explanation - regenerate=false + ready/stale → 캐시 즉시 (MLX 호출 없음, is_stale 플래그) - pending → 409 (race-safe 조건부 UPDATE 로 동시 호출 차단) - 그 외 → 새 생성 RAG 입력 풀: - 1순위: study_topic 매핑 documents 청크 + ai_summary, bge-reranker top-5 - 2순위: 같은 토픽 다른 questions (자기 자신 제외, ai_explanation 은 ready 상태만 포함 — 재귀적 hallucination 방지), reranker top-3 - 제외: 필기 OCR / 외부 웹 / Premium 모델 모델: Mac mini MLX gemma-4-26b primary 단독. get_mlx_gate() Semaphore(1) 경유, 30s timeout. 실패 시 status='failed' + 직전 본문 보존. 프롬프트 (app/prompts/study_question_explanation.txt): 자료 우선순위·인용 형식·할루시네이션 방지 절대 규칙 (법령명·조항·수치·표준 번호 단정 금지, "자료에서 확인되지 않음" 명시). 프론트: - 복습 화면 답 제출 후 인라인 expand. status별 버튼 분기 (ready 캐시 / stale "이전 풀이"+"다시 생성" / failed "다시 시도") - 편집 화면 별도 카드. 상태 배지 + "이전 풀이 보기" / "다시 생성" 분리 - 참고 근거 토글 (source_type 별 아이콘 📄/❓ + 제목 + snippet) 후속 PR 보류: 오답노트/통계, AI 일괄 백그라운드 생성, 필기 OCR RAG, Premium/Claude 재생성, /api/search/ask retrieval scope 통합. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/study_questions.py | 241 +++++++++++++++- app/models/study_question.py | 11 + app/prompts/study_question_explanation.txt | 38 +++ app/services/study/__init__.py | 0 app/services/study/explanation_rag.py | 271 ++++++++++++++++++ .../[id]/questions/[qid]/edit/+page.svelte | 106 ++++++- .../study/topics/[id]/review/+page.svelte | 97 ++++++- .../191_study_questions_ai_explanation.sql | 17 ++ .../192_study_questions_ai_status_idx.sql | 7 + 9 files changed, 785 insertions(+), 3 deletions(-) create mode 100644 app/prompts/study_question_explanation.txt create mode 100644 app/services/study/__init__.py create mode 100644 app/services/study/explanation_rag.py create mode 100644 migrations/191_study_questions_ai_explanation.sql create mode 100644 migrations/192_study_questions_ai_status_idx.sql diff --git a/app/api/study_questions.py b/app/api/study_questions.py index 31b7c58..52c9d86 100644 --- a/app/api/study_questions.py +++ b/app/api/study_questions.py @@ -9,6 +9,7 @@ PR-2 가드레일: - wrong_only=true 정의 = 가장 최근 attempt 가 오답인 문제. "한 번이라도 틀린 적 있는" 아님. """ +import asyncio import logging import random from datetime import datetime, timezone @@ -16,14 +17,21 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field -from sqlalchemy import and_, case, func, select +from sqlalchemy import and_, case, func, select, text as sql_text, update from sqlalchemy.ext.asyncio import AsyncSession +from ai.client import AIClient from core.auth import get_current_user from core.database import get_session from models.study_question import StudyQuestion, StudyQuestionAttempt from models.study_topic import StudyTopic from models.user import User +from services.search.llm_gate import get_mlx_gate +from services.study.explanation_rag import ( + EvidenceItem, + gather_explanation_context, + render_evidence_block, +) logger = logging.getLogger(__name__) router = APIRouter() @@ -104,6 +112,10 @@ class StudyQuestionResponse(BaseModel): explanation: str | None source_note: str | None is_active: bool + # PR-3: AI 풀이 상태 (편집 화면에서 사용). 본문은 별도 GET /ai-explanation 으로 + ai_explanation_status: str = "none" + ai_explanation_generated_at: datetime | None = None + ai_explanation_model: str | None = None created_at: datetime updated_at: datetime stats: QuestionAttemptStats @@ -166,6 +178,32 @@ class AttemptResponse(BaseModel): stats: QuestionAttemptStats +# ─── PR-3: AI 풀이 생성 ─── + + +class AIExplanationCreate(BaseModel): + """수동 트리거. regenerate=true 면 캐시 무시하고 새 본문 생성.""" + regenerate: bool = False + + +class AIExplanationEvidence(BaseModel): + source_type: str # 'document' | 'question' + source_id: int + title: str + snippet: str + + +class AIExplanationResponse(BaseModel): + ai_explanation: str | None + ai_explanation_status: str # ready | stale | failed | none + ai_explanation_generated_at: datetime | None + ai_explanation_model: str | None + evidence: list[AIExplanationEvidence] = [] + from_cache: bool = False + is_stale: bool = False + can_regenerate: bool = True + + # ─── 통계 헬퍼 ─── @@ -356,6 +394,9 @@ async def create_question_in_topic( explanation=q.explanation, source_note=q.source_note, is_active=q.is_active, + ai_explanation_status=q.ai_explanation_status, + ai_explanation_generated_at=q.ai_explanation_generated_at, + ai_explanation_model=q.ai_explanation_model, created_at=q.created_at, updated_at=q.updated_at, stats=stats, @@ -547,6 +588,9 @@ async def get_question( explanation=q.explanation, source_note=q.source_note, is_active=q.is_active, + ai_explanation_status=q.ai_explanation_status, + ai_explanation_generated_at=q.ai_explanation_generated_at, + ai_explanation_model=q.ai_explanation_model, created_at=q.created_at, updated_at=q.updated_at, stats=stats, @@ -573,6 +617,15 @@ async def update_question( } for fname in SIMPLE_FIELDS & fields_set: setattr(q, fname, getattr(body, fname)) + + # PR-3: 문제 핵심 필드 변경 시 AI 해설 stale 전이 (본문은 보존, UI 배지로 안내). + # ready 상태에서만 stale 로 전이 — pending/failed/none/stale 은 변경 안 함. + STALE_TRIGGER = { + "question_text", "choice_1", "choice_2", "choice_3", "choice_4", "correct_choice", + } + if STALE_TRIGGER & fields_set and q.ai_explanation_status == "ready": + q.ai_explanation_status = "stale" + q.updated_at = datetime.now(timezone.utc) await session.commit() @@ -593,6 +646,9 @@ async def update_question( explanation=q.explanation, source_note=q.source_note, is_active=q.is_active, + ai_explanation_status=q.ai_explanation_status, + ai_explanation_generated_at=q.ai_explanation_generated_at, + ai_explanation_model=q.ai_explanation_model, created_at=q.created_at, updated_at=q.updated_at, stats=stats, @@ -648,3 +704,186 @@ async def submit_attempt( explanation=q.explanation, stats=stats, ) + + +# ─── PR-3: AI 풀이 생성 엔드포인트 ─── + +# MLX 호출 timeout (초). MLX gate + 26B 추론 평균 ~10s, 안전 마진. +LLM_TIMEOUT_S = 30.0 +# 프롬프트 템플릿 lazy load +_PROMPT_PATH = "study_question_explanation.txt" +_prompt_cache: str | None = None + + +def _load_explanation_prompt() -> str: + global _prompt_cache + if _prompt_cache is None: + from pathlib import Path + prompts_dir = Path(__file__).resolve().parent.parent / "prompts" + _prompt_cache = (prompts_dir / _PROMPT_PATH).read_text(encoding="utf-8") + return _prompt_cache + + +def _render_prompt(q: StudyQuestion, doc_block: str, question_block: str) -> str: + template = _load_explanation_prompt() + return ( + template + .replace("{question_text}", q.question_text) + .replace("{choice_1}", q.choice_1) + .replace("{choice_2}", q.choice_2) + .replace("{choice_3}", q.choice_3) + .replace("{choice_4}", q.choice_4) + .replace("{correct_choice}", str(q.correct_choice)) + .replace("{documents_evidence_block}", doc_block) + .replace("{questions_evidence_block}", question_block) + ) + + +def _make_cache_response(q: StudyQuestion, is_stale: bool) -> AIExplanationResponse: + """캐시 본문만 반환 (evidence 재계산 안 함). status/메타는 q row 그대로.""" + return AIExplanationResponse( + ai_explanation=q.ai_explanation, + ai_explanation_status=q.ai_explanation_status, + ai_explanation_generated_at=q.ai_explanation_generated_at, + ai_explanation_model=q.ai_explanation_model, + evidence=[], + from_cache=True, + is_stale=is_stale, + can_regenerate=True, + ) + + +@router.post( + "/study-questions/{question_id}/ai-explanation", + response_model=AIExplanationResponse, +) +async def generate_ai_explanation( + question_id: int, + body: AIExplanationCreate, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """수동 트리거 AI 풀이 생성. RAG = 매핑 자료 + 같은 토픽 다른 문제 (자기 자신 제외). + primary 모델 단독 (Mac mini MLX gemma-4-26b). MLX gate Semaphore(1) 경유. + + - regenerate=false 이고 status=ready → 캐시 즉시 반환 (MLX 호출 없음) + - regenerate=false 이고 status=stale → 캐시 본문 반환, is_stale=true 플래그 + - status=pending → 409 (이미 다른 호출 진행 중, race-safe 조건부 UPDATE 로 보장) + - 그 외 (none/failed/regenerate=true) → 새로 생성 + + 실패 시 status='failed', 직전 본문은 보존. + """ + q = await session.get(StudyQuestion, question_id) + q = _verify_question_ownership(q, user) + + # 1) 캐시 단축 분기 (regenerate=false) + if not body.regenerate: + if q.ai_explanation_status == "ready": + return _make_cache_response(q, is_stale=False) + if q.ai_explanation_status == "stale": + return _make_cache_response(q, is_stale=True) + if q.ai_explanation_status == "pending": + raise HTTPException( + status_code=409, + detail={"status": "pending", "detail": "이미 생성 중입니다"}, + ) + # status in {none, failed} → 신규 생성 + + else: + # regenerate=true 라도 pending 은 차단 (동시 호출 방어) + if q.ai_explanation_status == "pending": + raise HTTPException( + status_code=409, + detail={"status": "pending", "detail": "이미 생성 중입니다"}, + ) + + # 2) Race-safe pending 전이 (조건부 UPDATE — 동시 호출 차단) + lock_result = await session.execute( + update(StudyQuestion) + .where( + StudyQuestion.id == question_id, + StudyQuestion.user_id == user.id, + StudyQuestion.ai_explanation_status != "pending", + ) + .values(ai_explanation_status="pending", updated_at=datetime.now(timezone.utc)) + .returning(StudyQuestion.id) + ) + if lock_result.scalar_one_or_none() is None: + # 다른 호출이 먼저 pending 박음 + raise HTTPException( + status_code=409, + detail={"status": "pending", "detail": "이미 생성 중입니다"}, + ) + await session.commit() + await session.refresh(q) + + # 3) RAG 근거 수집 + try: + ctx = await gather_explanation_context(session, user.id, q) + except Exception as e: + logger.warning("study_explanation_rag_failed: %s: %s", type(e).__name__, e) + # RAG 실패 시 빈 evidence 로 진행 (프롬프트가 "자료 근거 부족" 분기 처리) + from services.study.explanation_rag import ExplanationContext + ctx = ExplanationContext(documents=[], questions=[]) + + # 4) 프롬프트 조립 + MLX gate 안에서 primary 호출 + timeout + doc_block = render_evidence_block(ctx.documents) + q_block = render_evidence_block(ctx.questions) + prompt = _render_prompt(q, doc_block, q_block) + + ai_client = AIClient() + raw_text: str | None = None + error_message: str | None = None + try: + async with get_mlx_gate(): + async with asyncio.timeout(LLM_TIMEOUT_S): + raw_text = await ai_client.call_primary(prompt) + except asyncio.TimeoutError: + error_message = f"MLX timeout ({LLM_TIMEOUT_S}s)" + logger.warning("study_explanation_mlx_timeout qid=%s", question_id) + except Exception as e: + error_message = f"{type(e).__name__}: {e}" + logger.exception("study_explanation_mlx_failed qid=%s", question_id) + finally: + await ai_client.close() + + # 5) 결과 저장 (성공/실패 분기) — q 객체 fresh refresh + q = await session.get(StudyQuestion, question_id) + if raw_text is None or not raw_text.strip(): + # 실패 — 직전 본문 보존, status='failed' + q.ai_explanation_status = "failed" + q.updated_at = datetime.now(timezone.utc) + await session.commit() + return AIExplanationResponse( + ai_explanation=q.ai_explanation, + ai_explanation_status="failed", + ai_explanation_generated_at=q.ai_explanation_generated_at, + ai_explanation_model=q.ai_explanation_model, + evidence=[e.to_dict() for e in ctx.all], + from_cache=False, + is_stale=False, + can_regenerate=True, + ) + + # 성공 — Qwen think 태그 등 제거 + from ai.client import strip_thinking + cleaned = strip_thinking(raw_text).strip() + + q.ai_explanation = cleaned + q.ai_explanation_status = "ready" + q.ai_explanation_generated_at = datetime.now(timezone.utc) + primary_name = ai_client.ai.primary.model if hasattr(ai_client.ai.primary, "model") else "primary" + q.ai_explanation_model = f"mlx:{primary_name}" + q.updated_at = q.ai_explanation_generated_at + await session.commit() + + return AIExplanationResponse( + ai_explanation=q.ai_explanation, + ai_explanation_status="ready", + ai_explanation_generated_at=q.ai_explanation_generated_at, + ai_explanation_model=q.ai_explanation_model, + evidence=[e.to_dict() for e in ctx.all], + from_cache=False, + is_stale=False, + can_regenerate=True, + ) diff --git a/app/models/study_question.py b/app/models/study_question.py index 609ae56..0d5bef0 100644 --- a/app/models/study_question.py +++ b/app/models/study_question.py @@ -42,6 +42,17 @@ class StudyQuestion(Base): is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + # PR-3: AI 풀이 캐시 (수동 트리거) + # status: none | pending | ready | failed | stale (강한 enum 미사용, VARCHAR 권장값) + ai_explanation: Mapped[str | None] = mapped_column(Text) + ai_explanation_status: Mapped[str] = mapped_column( + String(20), default="none", nullable=False + ) + ai_explanation_generated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True) + ) + ai_explanation_model: Mapped[str | None] = mapped_column(String(120)) + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.now, nullable=False ) diff --git a/app/prompts/study_question_explanation.txt b/app/prompts/study_question_explanation.txt new file mode 100644 index 0000000..e2c2f28 --- /dev/null +++ b/app/prompts/study_question_explanation.txt @@ -0,0 +1,38 @@ +당신은 한국 기사시험(가스기사·산업안전기사 등) 필기 학습 보조 AI 입니다. +4지선다 객관식 문제를 분석하고 정답 풀이를 작성합니다. + +【문제】 +{question_text} + +【보기】 +1. {choice_1} +2. {choice_2} +3. {choice_3} +4. {choice_4} + +【사용자가 입력한 정답】 +{correct_choice}번 + +【참고 자료 — 우선순위 순서】 + +▼ 자료 (1순위: 자료실 매핑 문서) +{documents_evidence_block} + +▼ 같은 주제의 다른 문제 (2순위: 보조 근거) +{questions_evidence_block} + +【지침】 +1. 자료를 1순위 근거로 사용. 다른 문제는 보조 근거로만. +2. 자료 인용은 [자료: 제목] 형태. 문제 인용은 [관련: Q] 형태. +3. 정답이 왜 맞는지 핵심 개념 → 오답 보기가 왜 틀렸는지 짧게 → 정리 순서. +4. 자료에 직접 근거가 없으면 "자료 근거 부족" 으로 명시하고, 일반 상식 풀이는 별도 단락에 표시. +5. 사용자 입력 정답과 자료 근거가 충돌하면 "근거에 따르면 정답이 X번일 가능성이 있습니다" 라고 충돌을 명시 (자동으로 다른 답으로 단정 금지). +6. **할루시네이션 방지 (절대 규칙)**: + - 자료 근거가 부족하면 법령명·조항·수치·기준값을 새로 만들어내지 않는다. + - 근거 없는 수치(예: "0.5 MPa", "10 mg/L")·공식·표준 번호(예: "KS B 6750", "ASME Section VIII")·통계는 작성하지 않는다. + - 자료에서 확인되지 않는 내용은 "자료에서 확인되지 않음" 이라고 명시한다. + - "보통 ~이다", "일반적으로 ~이다" 같은 모호한 단정도 자료 근거가 없으면 사용하지 않는다. +7. 한국어. 분량 200~400자. 마크다운(굵게·리스트) 사용 가능. +8. 메타 설명·인사 없이 풀이만 출력. + +【풀이】 diff --git a/app/services/study/__init__.py b/app/services/study/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/study/explanation_rag.py b/app/services/study/explanation_rag.py new file mode 100644 index 0000000..4fc5330 --- /dev/null +++ b/app/services/study/explanation_rag.py @@ -0,0 +1,271 @@ +"""study_questions AI 풀이 RAG 근거 수집 — PR-3. + +RAG 입력 풀: + - 1순위: 같은 study_topic 매핑된 documents 본문 (study_topic_documents) → ai_summary + extracted_text 청크 + - 2순위: 같은 토픽의 다른 study_questions question_text + explanation + (status='ready') ai_explanation + 현재 question_id 자기 자신 제외. 재귀적 hallucination 방지를 위해 ai_explanation 은 ready 상태만. + - 제외: study_sessions.ocr_text (필기) / 외부 웹 / 다른 토픽 + +bge-reranker-v2-m3 (TEI :80/rerank) 로 top-K 줄임. +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass + +import httpx +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ai.client import AIClient +from models.chunk import DocumentChunk +from models.document import Document +from models.study_question import StudyQuestion +from models.study_topic import StudyTopicDocument + +logger = logging.getLogger(__name__) + +# top-K +DOC_TOPK = 5 +Q_TOPK = 3 +# document 본문 청크가 너무 길면 reranker 입력 제한 (TEI VRAM 보호) — 기존 rerank_service 와 일관 +MAX_RERANK_INPUT = 60 +# 각 evidence snippet 길이 제한 (LLM 컨텍스트 관리) +DOC_SNIPPET_LEN = 400 +Q_SNIPPET_LEN = 300 +# 토픽 내 다른 문제 후보 hard cap (reranker 부담 방지) +MAX_QUESTION_CANDIDATES = 30 +# reranker timeout +RERANK_TIMEOUT_S = 5.0 + + +@dataclass +class EvidenceItem: + source_type: str # 'document' | 'question' + source_id: int + title: str + snippet: str + + def to_dict(self) -> dict: + return { + "source_type": self.source_type, + "source_id": self.source_id, + "title": self.title, + "snippet": self.snippet, + } + + +@dataclass +class ExplanationContext: + """LLM 프롬프트 조립용. evidence 는 응답에 그대로 노출.""" + documents: list[EvidenceItem] + questions: list[EvidenceItem] + + @property + def all(self) -> list[EvidenceItem]: + return [*self.documents, *self.questions] + + +def _truncate(text: str, n: int) -> str: + if not text: + return "" + s = text.strip() + return s if len(s) <= n else s[:n].rstrip() + "…" + + +def _build_query(question: StudyQuestion) -> str: + """rerank query — 문제 본문 + 보기 1~4 합쳐 1 문자열.""" + return " | ".join([ + question.question_text or "", + question.choice_1 or "", + question.choice_2 or "", + question.choice_3 or "", + question.choice_4 or "", + ]) + + +async def _rerank(client: AIClient, query: str, texts: list[str], top_k: int) -> list[int]: + """bge-reranker 호출. 실패 시 [0..N] 자연 순서 fallback. 반환: top_k indices.""" + if not texts: + return [] + if len(texts) > MAX_RERANK_INPUT: + texts = texts[:MAX_RERANK_INPUT] + try: + async with asyncio.timeout(RERANK_TIMEOUT_S): + results = await client.rerank(query, texts) + # results: [{"index": int, "score": float}, ...] + idxs = [r["index"] for r in results if 0 <= r.get("index", -1) < len(texts)] + return idxs[:top_k] + except (asyncio.TimeoutError, httpx.HTTPError) as e: + logger.warning("study_explanation_rerank_fallback: %s: %s", type(e).__name__, e) + return list(range(min(top_k, len(texts)))) + + +async def _gather_document_evidence( + session: AsyncSession, + user_id: int, + study_topic_id: int, + query: str, + client: AIClient, +) -> list[EvidenceItem]: + """study_topic 매핑 documents 의 청크 + ai_summary 를 reranker 통과해 top-K 5건.""" + doc_id_rows = ( + await session.execute( + select(StudyTopicDocument.document_id).where( + StudyTopicDocument.study_topic_id == study_topic_id, + StudyTopicDocument.user_id == user_id, + ) + ) + ).scalars().all() + doc_ids = list(doc_id_rows) + if not doc_ids: + return [] + + # 매핑된 documents 메타 (제목·요약 표기) + doc_meta_rows = ( + await session.execute( + select(Document.id, Document.title, Document.ai_summary).where( + Document.id.in_(doc_ids), + Document.deleted_at.is_(None), + ) + ) + ).all() + doc_meta: dict[int, tuple[str | None, str | None]] = { + r.id: (r.title, r.ai_summary) for r in doc_meta_rows + } + if not doc_meta: + return [] + + valid_doc_ids = list(doc_meta.keys()) + + # 1) 청크 후보 — doc_id 별 chunk_index 0..3 (앞부분 우선) 으로 hard cap + chunk_rows = ( + await session.execute( + select(DocumentChunk.doc_id, DocumentChunk.chunk_index, DocumentChunk.text) + .where( + DocumentChunk.doc_id.in_(valid_doc_ids), + DocumentChunk.chunk_index < 4, + ) + .order_by(DocumentChunk.doc_id, DocumentChunk.chunk_index) + ) + ).all() + + # 2) ai_summary 도 후보로 추가 (요약 자체가 핵심 근거가 될 때 많음) + candidates: list[tuple[int, str]] = [] # (doc_id, text) + for r in chunk_rows: + if r.text: + candidates.append((r.doc_id, r.text)) + for did, (_title, summary) in doc_meta.items(): + if summary: + candidates.append((did, summary)) + + if not candidates: + return [] + + texts = [_truncate(t, 800) for _, t in candidates] + top_idxs = await _rerank(client, query, texts, DOC_TOPK) + seen_doc_ids: set[int] = set() + out: list[EvidenceItem] = [] + for i in top_idxs: + did, text = candidates[i] + # 한 문서당 1 evidence (다양성 확보) + if did in seen_doc_ids: + continue + seen_doc_ids.add(did) + title = doc_meta.get(did, (None, None))[0] or f"문서 #{did}" + out.append(EvidenceItem( + source_type="document", + source_id=did, + title=title, + snippet=_truncate(text, DOC_SNIPPET_LEN), + )) + if len(out) >= DOC_TOPK: + break + return out + + +async def _gather_question_evidence( + session: AsyncSession, + user_id: int, + study_topic_id: int, + current_question_id: int, + query: str, + client: AIClient, +) -> list[EvidenceItem]: + """같은 토픽의 다른 문제 + 해설 reranker 통과 top-K 3건. 자기 자신 제외.""" + rows = ( + await session.execute( + select(StudyQuestion).where( + StudyQuestion.user_id == user_id, + StudyQuestion.study_topic_id == study_topic_id, + StudyQuestion.id != current_question_id, + StudyQuestion.deleted_at.is_(None), + ) + .order_by(StudyQuestion.created_at.desc()) + .limit(MAX_QUESTION_CANDIDATES) + ) + ).scalars().all() + if not rows: + return [] + + candidates_text: list[str] = [] + for q in rows: + parts = [q.question_text or ""] + if q.explanation: + parts.append(q.explanation) + # ai_explanation 은 ready 상태만 — 재귀적 hallucination 방지 + if q.ai_explanation and q.ai_explanation_status == "ready": + parts.append(q.ai_explanation) + candidates_text.append(" | ".join(parts)) + + top_idxs = await _rerank(client, query, candidates_text, Q_TOPK) + out: list[EvidenceItem] = [] + for i in top_idxs: + q = rows[i] + title_head = _truncate(q.question_text or "", 40) + # 자기 자신 제외 보장 (이중 안전장치) + if q.id == current_question_id: + continue + out.append(EvidenceItem( + source_type="question", + source_id=q.id, + title=f"Q{q.id}: {title_head}", + snippet=_truncate(candidates_text[i], Q_SNIPPET_LEN), + )) + return out + + +async def gather_explanation_context( + session: AsyncSession, + user_id: int, + question: StudyQuestion, +) -> ExplanationContext: + """현재 문제에 대한 RAG 근거 수집 (자료 + 같은 토픽 다른 문제). 자기 자신 제외.""" + client = AIClient() + query = _build_query(question) + try: + # 두 조회 병렬화 (rerank 호출이 별개라 lock 충돌 없음) + docs, questions = await asyncio.gather( + _gather_document_evidence(session, user_id, question.study_topic_id, query, client), + _gather_question_evidence( + session, user_id, question.study_topic_id, question.id, query, client + ), + ) + return ExplanationContext(documents=docs, questions=questions) + finally: + await client.close() + + +def render_evidence_block(items: list[EvidenceItem]) -> str: + """프롬프트 본문에 끼울 텍스트 블록. 비어있으면 '(없음)'.""" + if not items: + return "(없음)" + lines = [] + for it in items: + if it.source_type == "document": + lines.append(f"- [자료: {it.title}] {it.snippet}") + else: + lines.append(f"- [{it.title}] {it.snippet}") + return "\n".join(lines) diff --git a/frontend/src/routes/study/topics/[id]/questions/[qid]/edit/+page.svelte b/frontend/src/routes/study/topics/[id]/questions/[qid]/edit/+page.svelte index b045325..ab201f9 100644 --- a/frontend/src/routes/study/topics/[id]/questions/[qid]/edit/+page.svelte +++ b/frontend/src/routes/study/topics/[id]/questions/[qid]/edit/+page.svelte @@ -11,7 +11,7 @@ import { goto } from '$app/navigation'; import { api } from '$lib/api'; import { addToast } from '$lib/stores/toast'; - import { ArrowLeft, Save, Trash2, AlertCircle } from 'lucide-svelte'; + import { ArrowLeft, Save, Trash2, AlertCircle, Sparkles } from 'lucide-svelte'; import Button from '$lib/components/ui/Button.svelte'; import Card from '$lib/components/ui/Card.svelte'; import Skeleton from '$lib/components/ui/Skeleton.svelte'; @@ -35,6 +35,42 @@ let is_active = $state(true); let stats = $state({ attempt_count: 0, correct_count: 0, wrong_count: 0 }); + // PR-3: AI 풀이 상태 + 본문 캐시 + let aiStatus = $state('none'); // none | pending | ready | failed | stale + let aiGeneratedAt = $state(null); + let aiModel = $state(null); + let aiBody = $state(null); + let aiEvidence = $state([]); + let aiOpen = $state(false); + let aiLoading = $state(false); + let aiError = $state(null); + + async function fetchAiBody(regenerate = false) { + aiLoading = true; + aiError = null; + try { + const res = await api(`/study-questions/${questionId}/ai-explanation`, { + method: 'POST', + body: JSON.stringify({ regenerate }), + }); + aiBody = res.ai_explanation; + aiStatus = res.ai_explanation_status; + aiGeneratedAt = res.ai_explanation_generated_at; + aiModel = res.ai_explanation_model; + aiEvidence = res.evidence ?? []; + aiOpen = true; + if (aiStatus === 'failed') aiError = '풀이 생성 실패. "다시 시도" 를 눌러주세요.'; + } catch (err) { + if (err?.status === 409) { + aiError = '다른 호출이 풀이를 생성 중입니다. 잠시 후 다시 시도.'; + } else { + aiError = err?.detail || 'AI 풀이 생성 실패'; + } + } finally { + aiLoading = false; + } + } + async function load() { loading = true; try { @@ -54,6 +90,9 @@ source_note = q.source_note ?? ''; is_active = q.is_active; stats = q.stats; + aiStatus = q.ai_explanation_status ?? 'none'; + aiGeneratedAt = q.ai_explanation_generated_at; + aiModel = q.ai_explanation_model; } catch (err) { addToast('error', err.detail || '문제 로딩 실패'); } finally { @@ -184,6 +223,71 @@ {/snippet} + + + {#snippet children()} +
+
+ + AI 풀이 + {aiStatus} + {#if aiGeneratedAt} + {new Date(aiGeneratedAt).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })}{aiModel ? ` · ${aiModel}` : ''} + {/if} + + {#if aiStatus === 'pending'} + + {:else if aiStatus === 'none'} + + {:else if aiStatus === 'failed'} + + {:else if aiStatus === 'stale'} + {#if !aiOpen} + + {/if} + + {:else if aiStatus === 'ready'} + {#if !aiOpen} + + {:else} + + {/if} + + {/if} + +
+ + {#if aiStatus === 'stale'} +
+ + 문제 본문·보기·정답이 수정된 후의 이전 풀이입니다. "AI 풀이 다시 생성" 으로 새로 만들 수 있습니다. +
+ {/if} + + {#if aiError} +
{aiError}
+ {/if} + + {#if aiOpen && aiBody} +
{aiBody}
+ {#if aiEvidence?.length} +
+ 참고 근거 {aiEvidence.length}건 +
    + {#each aiEvidence as ev} +
  • + {ev.source_type === 'document' ? '📄' : '❓'} {ev.title} +
    {ev.snippet}
    +
  • + {/each} +
+
+ {/if} + {/if} +
+ {/snippet} +
+
diff --git a/frontend/src/routes/study/topics/[id]/review/+page.svelte b/frontend/src/routes/study/topics/[id]/review/+page.svelte index d539d7c..e30b1ba 100644 --- a/frontend/src/routes/study/topics/[id]/review/+page.svelte +++ b/frontend/src/routes/study/topics/[id]/review/+page.svelte @@ -11,7 +11,7 @@ import { page } from '$app/stores'; import { api } from '$lib/api'; import { addToast } from '$lib/stores/toast'; - import { ArrowLeft, Play, CheckCircle2, XCircle, RotateCcw } from 'lucide-svelte'; + import { ArrowLeft, Play, CheckCircle2, XCircle, RotateCcw, Sparkles, AlertCircle } from 'lucide-svelte'; import Button from '$lib/components/ui/Button.svelte'; import Card from '$lib/components/ui/Card.svelte'; import Skeleton from '$lib/components/ui/Skeleton.svelte'; @@ -43,6 +43,45 @@ let loading = $state(true); + // PR-3: AI 해설 캐시 (현재 출제 중인 문제 단위) + let aiExplOpen = $state(false); + let aiExplLoading = $state(false); + let aiExpl = $state(null); // {ai_explanation, ai_explanation_status, evidence, from_cache, is_stale} + let aiExplError = $state(null); + + function resetAiExpl() { + aiExplOpen = false; + aiExpl = null; + aiExplError = null; + aiExplLoading = false; + } + + async function loadAiExplanation(regenerate = false) { + const q = questions[cursor]; + if (!q) return; + aiExplLoading = true; + aiExplError = null; + try { + const res = await api(`/study-questions/${q.id}/ai-explanation`, { + method: 'POST', + body: JSON.stringify({ regenerate }), + }); + aiExpl = res; + aiExplOpen = true; + if (res.ai_explanation_status === 'failed') { + aiExplError = '풀이 생성 실패. "다시 시도" 를 눌러주세요.'; + } + } catch (err) { + if (err?.status === 409) { + aiExplError = '다른 호출이 풀이를 생성 중입니다. 잠시 후 다시 시도해주세요.'; + } else { + aiExplError = err?.detail || 'AI 풀이 생성 실패'; + } + } finally { + aiExplLoading = false; + } + } + async function loadTopic() { try { const t = await api(`/study-topics/${topicId}`); @@ -113,6 +152,7 @@ cursor += 1; selected = null; submitted = null; + resetAiExpl(); } function restart() { @@ -248,6 +288,61 @@ 누적 {submitted.stats.attempt_count}회 · 정답 {submitted.stats.correct_count} · 오답 {submitted.stats.wrong_count}
+ + +
+
+ + AI 풀이 + {#if aiExpl?.is_stale} + stale + {/if} + {#if aiExpl?.from_cache && !aiExpl?.is_stale} + 캐시 + {/if} + + {#if !aiExplOpen} + + {:else if aiExpl?.is_stale} + + + {:else if aiExpl?.ai_explanation_status === 'failed'} + + {:else} + + {/if} + +
+ + {#if aiExplOpen} + {#if aiExpl?.is_stale} +
+ + 이 풀이는 정답·문제가 수정된 후의 이전 풀이입니다. "AI 풀이 다시 생성" 으로 새로 만들 수 있습니다. +
+ {/if} + {#if aiExplError} +
{aiExplError}
+ {/if} + {#if aiExpl?.ai_explanation} +
{aiExpl.ai_explanation}
+ {/if} + {#if aiExpl?.evidence?.length} +
+ 참고 근거 {aiExpl.evidence.length}건 +
    + {#each aiExpl.evidence as ev} +
  • + {ev.source_type === 'document' ? '📄' : '❓'} {ev.title} +
    {ev.snippet}
    +
  • + {/each} +
+
+ {/if} + {/if} +
+
diff --git a/migrations/191_study_questions_ai_explanation.sql b/migrations/191_study_questions_ai_explanation.sql new file mode 100644 index 0000000..6f2903b --- /dev/null +++ b/migrations/191_study_questions_ai_explanation.sql @@ -0,0 +1,17 @@ +-- 191_study_questions_ai_explanation.sql (1/2) +-- study_questions 에 AI 풀이 캐시 4 컬럼 추가. +-- +-- ai_explanation_status 권장값 (강한 enum 미사용): +-- none — 미생성 (default) +-- pending — 동기 생성 진행 중. race-safe 전이 (조건부 UPDATE) 로 보호. +-- ready — 본문 사용 가능 +-- failed — 생성 실패 (MLX 에러 / 네트워크 / JSON 파싱). 재시도 가능. 직전 본문은 보존. +-- stale — question_text/choice_*/correct_choice 변경 후 outdated. 본문은 표시하되 배지 + "다시 생성" 버튼. +-- +-- ALTER 4 컬럼은 콤마 분리한 단일 statement (asyncpg exec_driver_sql 호환). + +ALTER TABLE study_questions + ADD COLUMN IF NOT EXISTS ai_explanation TEXT, + ADD COLUMN IF NOT EXISTS ai_explanation_status VARCHAR(20) NOT NULL DEFAULT 'none', + ADD COLUMN IF NOT EXISTS ai_explanation_generated_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS ai_explanation_model VARCHAR(120); diff --git a/migrations/192_study_questions_ai_status_idx.sql b/migrations/192_study_questions_ai_status_idx.sql new file mode 100644 index 0000000..e4a96ed --- /dev/null +++ b/migrations/192_study_questions_ai_status_idx.sql @@ -0,0 +1,7 @@ +-- 192_study_questions_ai_status_idx.sql (2/2) +-- 후속 PR (오답노트·통계) 에서 토픽별 AI 해설 ready 비율 / stale 누적 등 쿼리에 활용. +-- partial index — 'none' 행 제외해서 인덱스 부피 절약 (대부분 자료는 'none' 으로 시작). + +CREATE INDEX IF NOT EXISTS idx_study_questions_ai_status + ON study_questions (study_topic_id, ai_explanation_status) + WHERE deleted_at IS NULL AND ai_explanation_status != 'none';