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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user