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:
+240
-1
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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. 메타 설명·인사 없이 풀이만 출력.
|
||||
|
||||
【풀이】
|
||||
@@ -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 { 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}
|
||||
</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 gap-2">
|
||||
<Button href={`/study/topics/${topicId}`} variant="ghost" icon={ArrowLeft}>주제로</Button>
|
||||
|
||||
@@ -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}
|
||||
</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">
|
||||
<Button onclick={next}>{cursor + 1 >= questions.length ? '결과 보기' : '다음 문제'}</Button>
|
||||
</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';
|
||||
Reference in New Issue
Block a user