feat(study): AI 풀이 생성 — 수동 트리거 + RAG (PR-3)

복습 답 제출 후 또는 편집 화면에서 사용자가 명시적으로 누를 때만 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) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-28 08:41:46 +09:00
parent 0e2a430a6c
commit e1a2cdc677
9 changed files with 785 additions and 3 deletions
+240 -1
View File
@@ -9,6 +9,7 @@ PR-2 가드레일:
- wrong_only=true 정의 = 가장 최근 attempt 가 오답인 문제. "한 번이라도 틀린 적 있는" 아님. - wrong_only=true 정의 = 가장 최근 attempt 가 오답인 문제. "한 번이라도 틀린 적 있는" 아님.
""" """
import asyncio
import logging import logging
import random import random
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -16,14 +17,21 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field 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 sqlalchemy.ext.asyncio import AsyncSession
from ai.client import AIClient
from core.auth import get_current_user from core.auth import get_current_user
from core.database import get_session from core.database import get_session
from models.study_question import StudyQuestion, StudyQuestionAttempt from models.study_question import StudyQuestion, StudyQuestionAttempt
from models.study_topic import StudyTopic from models.study_topic import StudyTopic
from models.user import User 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__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -104,6 +112,10 @@ class StudyQuestionResponse(BaseModel):
explanation: str | None explanation: str | None
source_note: str | None source_note: str | None
is_active: bool 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 created_at: datetime
updated_at: datetime updated_at: datetime
stats: QuestionAttemptStats stats: QuestionAttemptStats
@@ -166,6 +178,32 @@ class AttemptResponse(BaseModel):
stats: QuestionAttemptStats 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, explanation=q.explanation,
source_note=q.source_note, source_note=q.source_note,
is_active=q.is_active, 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, created_at=q.created_at,
updated_at=q.updated_at, updated_at=q.updated_at,
stats=stats, stats=stats,
@@ -547,6 +588,9 @@ async def get_question(
explanation=q.explanation, explanation=q.explanation,
source_note=q.source_note, source_note=q.source_note,
is_active=q.is_active, 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, created_at=q.created_at,
updated_at=q.updated_at, updated_at=q.updated_at,
stats=stats, stats=stats,
@@ -573,6 +617,15 @@ async def update_question(
} }
for fname in SIMPLE_FIELDS & fields_set: for fname in SIMPLE_FIELDS & fields_set:
setattr(q, fname, getattr(body, fname)) 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) q.updated_at = datetime.now(timezone.utc)
await session.commit() await session.commit()
@@ -593,6 +646,9 @@ async def update_question(
explanation=q.explanation, explanation=q.explanation,
source_note=q.source_note, source_note=q.source_note,
is_active=q.is_active, 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, created_at=q.created_at,
updated_at=q.updated_at, updated_at=q.updated_at,
stats=stats, stats=stats,
@@ -648,3 +704,186 @@ async def submit_attempt(
explanation=q.explanation, explanation=q.explanation,
stats=stats, 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,
)
+11
View File
@@ -42,6 +42,17 @@ class StudyQuestion(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False DateTime(timezone=True), default=datetime.now, nullable=False
) )
@@ -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<id>] 형태.
3. 정답이 왜 맞는지 핵심 개념 → 오답 보기가 왜 틀렸는지 짧게 → 정리 순서.
4. 자료에 직접 근거가 없으면 "자료 근거 부족" 으로 명시하고, 일반 상식 풀이는 별도 단락에 표시.
5. 사용자 입력 정답과 자료 근거가 충돌하면 "근거에 따르면 정답이 X번일 가능성이 있습니다" 라고 충돌을 명시 (자동으로 다른 답으로 단정 금지).
6. **할루시네이션 방지 (절대 규칙)**:
- 자료 근거가 부족하면 법령명·조항·수치·기준값을 새로 만들어내지 않는다.
- 근거 없는 수치(예: "0.5 MPa", "10 mg/L")·공식·표준 번호(예: "KS B 6750", "ASME Section VIII")·통계는 작성하지 않는다.
- 자료에서 확인되지 않는 내용은 "자료에서 확인되지 않음" 이라고 명시한다.
- "보통 ~이다", "일반적으로 ~이다" 같은 모호한 단정도 자료 근거가 없으면 사용하지 않는다.
7. 한국어. 분량 200~400자. 마크다운(굵게·리스트) 사용 가능.
8. 메타 설명·인사 없이 풀이만 출력.
【풀이】
View File
+271
View File
@@ -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)
@@ -11,7 +11,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast'; 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 Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte'; import Card from '$lib/components/ui/Card.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte'; import Skeleton from '$lib/components/ui/Skeleton.svelte';
@@ -35,6 +35,42 @@
let is_active = $state(true); let is_active = $state(true);
let stats = $state({ attempt_count: 0, correct_count: 0, wrong_count: 0 }); 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() { async function load() {
loading = true; loading = true;
try { try {
@@ -54,6 +90,9 @@
source_note = q.source_note ?? ''; source_note = q.source_note ?? '';
is_active = q.is_active; is_active = q.is_active;
stats = q.stats; stats = q.stats;
aiStatus = q.ai_explanation_status ?? 'none';
aiGeneratedAt = q.ai_explanation_generated_at;
aiModel = q.ai_explanation_model;
} catch (err) { } catch (err) {
addToast('error', err.detail || '문제 로딩 실패'); addToast('error', err.detail || '문제 로딩 실패');
} finally { } finally {
@@ -184,6 +223,71 @@
{/snippet} {/snippet}
</Card> </Card>
<!-- PR-3: AI 풀이 섹션 -->
<Card class="mb-3">
{#snippet children()}
<div class="p-4 flex flex-col gap-3">
<div class="flex items-center gap-2 flex-wrap">
<Sparkles size={16} class="text-accent" />
<span class="text-sm font-semibold text-text">AI 풀이</span>
<span class="text-[10px] text-dim border border-default rounded px-1.5 py-0.5">{aiStatus}</span>
{#if aiGeneratedAt}
<span class="text-[10px] text-dim">{new Date(aiGeneratedAt).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })}{aiModel ? ` · ${aiModel}` : ''}</span>
{/if}
<span class="ml-auto flex items-center gap-2">
{#if aiStatus === 'pending'}
<Button size="sm" variant="ghost" disabled>생성 중...</Button>
{:else if aiStatus === 'none'}
<Button size="sm" onclick={() => fetchAiBody(false)} loading={aiLoading} icon={Sparkles}>AI 풀이 생성</Button>
{:else if aiStatus === 'failed'}
<Button size="sm" onclick={() => fetchAiBody(true)} loading={aiLoading} icon={Sparkles}>다시 시도</Button>
{:else if aiStatus === 'stale'}
{#if !aiOpen}
<Button size="sm" variant="ghost" onclick={() => fetchAiBody(false)} loading={aiLoading}>이전 풀이 보기</Button>
{/if}
<Button size="sm" onclick={() => fetchAiBody(true)} loading={aiLoading} icon={Sparkles}>AI 풀이 다시 생성</Button>
{:else if aiStatus === 'ready'}
{#if !aiOpen}
<Button size="sm" variant="ghost" onclick={() => fetchAiBody(false)} loading={aiLoading}>본문 보기</Button>
{:else}
<Button size="sm" variant="ghost" onclick={() => (aiOpen = false)}>접기</Button>
{/if}
<Button size="sm" onclick={() => fetchAiBody(true)} loading={aiLoading}>다시 생성</Button>
{/if}
</span>
</div>
{#if aiStatus === 'stale'}
<div class="text-[11px] text-warning bg-warning/5 border border-warning/30 rounded px-2 py-1.5 flex items-start gap-1.5">
<AlertCircle size={12} class="mt-0.5 shrink-0" />
<span>문제 본문·보기·정답이 수정된 후의 이전 풀이입니다. "AI 풀이 다시 생성" 으로 새로 만들 수 있습니다.</span>
</div>
{/if}
{#if aiError}
<div class="text-[11px] text-error">{aiError}</div>
{/if}
{#if aiOpen && aiBody}
<div class="text-sm text-text whitespace-pre-line leading-relaxed bg-bg/30 border border-default rounded p-3">{aiBody}</div>
{#if aiEvidence?.length}
<details class="text-[10px] text-dim">
<summary class="cursor-pointer hover:text-text">참고 근거 {aiEvidence.length}건</summary>
<ul class="mt-1 flex flex-col gap-1">
{#each aiEvidence as ev}
<li>
<span class="text-text">{ev.source_type === 'document' ? '📄' : '❓'} {ev.title}</span>
<div class="pl-4 truncate">{ev.snippet}</div>
</li>
{/each}
</ul>
</details>
{/if}
{/if}
</div>
{/snippet}
</Card>
<div class="flex items-center justify-between gap-2 flex-wrap"> <div class="flex items-center justify-between gap-2 flex-wrap">
<div class="flex gap-2"> <div class="flex gap-2">
<Button href={`/study/topics/${topicId}`} variant="ghost" icon={ArrowLeft}>주제로</Button> <Button href={`/study/topics/${topicId}`} variant="ghost" icon={ArrowLeft}>주제로</Button>
@@ -11,7 +11,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast'; 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 Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte'; import Card from '$lib/components/ui/Card.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte'; import Skeleton from '$lib/components/ui/Skeleton.svelte';
@@ -43,6 +43,45 @@
let loading = $state(true); 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() { async function loadTopic() {
try { try {
const t = await api(`/study-topics/${topicId}`); const t = await api(`/study-topics/${topicId}`);
@@ -113,6 +152,7 @@
cursor += 1; cursor += 1;
selected = null; selected = null;
submitted = null; submitted = null;
resetAiExpl();
} }
function restart() { function restart() {
@@ -248,6 +288,61 @@
누적 {submitted.stats.attempt_count}회 · 정답 {submitted.stats.correct_count} · 오답 {submitted.stats.wrong_count} 누적 {submitted.stats.attempt_count}회 · 정답 {submitted.stats.correct_count} · 오답 {submitted.stats.wrong_count}
</div> </div>
</div> </div>
<!-- PR-3: AI 해설 -->
<div class="rounded border border-default bg-bg/30 p-3 flex flex-col gap-2">
<div class="flex items-center gap-2 flex-wrap">
<Sparkles size={14} class="text-accent" />
<span class="text-xs font-semibold text-text">AI 풀이</span>
{#if aiExpl?.is_stale}
<span class="text-[10px] text-warning border border-warning/40 rounded px-1.5 py-0.5">stale</span>
{/if}
{#if aiExpl?.from_cache && !aiExpl?.is_stale}
<span class="text-[10px] text-dim">캐시</span>
{/if}
<span class="ml-auto flex items-center gap-1.5">
{#if !aiExplOpen}
<Button size="sm" variant="ghost" onclick={() => loadAiExplanation(false)} loading={aiExplLoading}>AI 해설 보기</Button>
{:else if aiExpl?.is_stale}
<Button size="sm" variant="ghost" onclick={() => (aiExplOpen = false)}>접기</Button>
<Button size="sm" onclick={() => loadAiExplanation(true)} loading={aiExplLoading}>AI 풀이 다시 생성</Button>
{:else if aiExpl?.ai_explanation_status === 'failed'}
<Button size="sm" onclick={() => loadAiExplanation(true)} loading={aiExplLoading}>다시 시도</Button>
{:else}
<Button size="sm" variant="ghost" onclick={() => (aiExplOpen = false)}>접기</Button>
{/if}
</span>
</div>
{#if aiExplOpen}
{#if aiExpl?.is_stale}
<div class="text-[11px] text-warning bg-warning/5 border border-warning/30 rounded px-2 py-1.5 flex items-start gap-1.5">
<AlertCircle size={12} class="mt-0.5 shrink-0" />
<span>이 풀이는 정답·문제가 수정된 후의 이전 풀이입니다. "AI 풀이 다시 생성" 으로 새로 만들 수 있습니다.</span>
</div>
{/if}
{#if aiExplError}
<div class="text-[11px] text-error">{aiExplError}</div>
{/if}
{#if aiExpl?.ai_explanation}
<div class="text-xs text-text whitespace-pre-line leading-relaxed">{aiExpl.ai_explanation}</div>
{/if}
{#if aiExpl?.evidence?.length}
<details class="text-[10px] text-dim">
<summary class="cursor-pointer hover:text-text">참고 근거 {aiExpl.evidence.length}</summary>
<ul class="mt-1 flex flex-col gap-1">
{#each aiExpl.evidence as ev}
<li>
<span class="text-text">{ev.source_type === 'document' ? '📄' : '❓'} {ev.title}</span>
<div class="pl-4 truncate">{ev.snippet}</div>
</li>
{/each}
</ul>
</details>
{/if}
{/if}
</div>
<div class="flex justify-end"> <div class="flex justify-end">
<Button onclick={next}>{cursor + 1 >= questions.length ? '결과 보기' : '다음 문제'}</Button> <Button onclick={next}>{cursor + 1 >= questions.length ? '결과 보기' : '다음 문제'}</Button>
</div> </div>
@@ -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);
@@ -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';