feat(study): Phase 4-B v1 세션 단위 종합 분석 (자유 마크다운)
Phase 4-A 가 wrong/unsure 한 문제씩 풀이 캐시. 4-B 는 세션 전체 wrong/unsure
5~30건을 묶어 200~400자 자연어 요약 1건 생성. 결과 화면 헤더 카드.
큐 인프라는 4-A study_question_jobs 와 분리 — FK 단일 의미 + 운영 SQL 명확성
+ 4-A/4-B 가드/payload/재시도 정책 차이. 신규 study_quiz_session_jobs (큐) +
study_quiz_session_analysis (결과 캐시 PK=session_id, UPSERT) + 전용 consumer.
Backend:
- migrations/233 — study_quiz_session_jobs (FK study_quiz_sessions NOT NULL,
status pending/processing/completed/failed/skipped, max_attempts=2)
- migrations/234 — partial unique idx (session_id) WHERE pending/processing
- migrations/235 — study_quiz_session_analysis (session_id PK, summary_md,
confidence, model_name, generated_at, is_stale)
- models/study_quiz_session_job — ORM + enqueue_session_analysis_job() (멱등)
- models/study_quiz_session_analysis — ORM (PK = session_id)
- services/study/session_summary_guard — GUARD_PATTERN (정규식) +
normalize_confidence() 단일 source, worker + tests 가 import 공유
- services/study/session_summary_rag — gather_session_summary_context()
documents 만 (PR-3 _gather_document_evidence 재사용). evidence 없어도 호출
허용 (4-A 와 다른 정책 — 세션 기록 자체가 evidence)
- services/study/session_analysis_enqueue — auto (finalize/fallback) +
request_session_analysis_regenerate (manual). manual 은 wrong/unsure < 5
즉시 차단, active job 차단, 기존 analysis 있으면 is_stale=true 박기
- prompts/study_session_summary_envelope.txt — envelope JSON
{summary_md, confidence}. 정량 정수만 인용 가능, 비율/추세/범위/날짜 금지
- workers/study_session_analysis_worker — terminal status 분기:
· wrong/unsure < 5 → status=skipped, error_code=insufficient_attempts
· question_text/outcome 부족 → skipped, evidence_missing
· GUARD_PATTERN match → failed, guard_fail
· 800자 hard cap + confidence normalize
· timeout/parse/unknown → 재시도 후보
· UPSERT study_quiz_session_analysis ON CONFLICT DO UPDATE (PK session_id)
- workers/study_session_queue_consumer — 4-A consumer 패턴 복제. BATCH_SIZE=1
+ STALE_MINUTES=10. MLX gate 4-A 와 공유 (Semaphore(1))
- main.py — APScheduler add_job(consume_study_session_queue, ..., 1분 주기)
- session_finalize — 끝에서 enqueue_session_analysis_auto (best-effort)
- api/study_topics:
· QuizSessionAnalysisOut + ai_session_analysis 응답 필드 (analysis row +
최신 job status/error_code)
· GET fallback enqueue (기존 analysis 또는 active job 없으면만, non-blocking)
· POST /quiz-sessions/{sid}/regenerate-summary — manual 트리거
Frontend (quiz-sessions/[sid]/+page.svelte):
- 결과 헤더에 세션 요약 카드 (AI 풀이 indicator 직후, 바로 할 일 직전)
- summary_md 박혔으면 markdown 렌더, 없으면 job_status / error_code 분기:
· pending/processing → "AI 가 세션 분석 중"
· insufficient_attempts → "오답·모르겠음 5건 미만"
· evidence_missing → "자료 부족"
· guard_fail → "환각 검증 차단" + 재생성 링크
- confidence='low' 배지 + is_stale "재생성 중" 배지
- 재생성 버튼 + regenerateSummary() — reason 별 toast 분기
ship gate:
- tests/test_session_summary_guard_pattern.py — 허용 5 + 차단 7 케이스 +
normalize_confidence 표준/비표준 검증. python3 직접 실행 패스.
Plan: ~/.claude/plans/nifty-sparking-spindle.md (Phase 4-B v1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -99,6 +99,38 @@
|
||||
let summary = $derived(detail?.summary);
|
||||
let unreviewedCount = $derived(summary?.unreviewed_wrong_unsure_count ?? 0);
|
||||
|
||||
// Phase 4-B v1: 세션 단위 분석 카드 상태
|
||||
let aiAnalysis = $derived(detail?.ai_session_analysis ?? null);
|
||||
let regeneratingSummary = $state(false);
|
||||
|
||||
async function regenerateSummary() {
|
||||
if (regeneratingSummary) return;
|
||||
regeneratingSummary = true;
|
||||
try {
|
||||
const res = await api(`/study-topics/${topicId}/quiz-sessions/${sessionId}/regenerate-summary`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.enqueued) {
|
||||
const msg = ({
|
||||
insufficient_attempts: '오답·모르겠음이 5건 미만이라 분석을 생성하지 않습니다',
|
||||
already_active: '이미 분석을 생성하고 있습니다. 잠시 후 다시 확인하세요',
|
||||
not_done: '세션이 완료되지 않았습니다',
|
||||
not_found: '세션을 찾을 수 없습니다',
|
||||
race_lost: '다른 호출이 먼저 enqueue 했습니다. 잠시 후 다시 확인하세요',
|
||||
}[res.reason] ?? '재생성을 시작할 수 없습니다');
|
||||
addToast('warning', msg);
|
||||
} else {
|
||||
addToast('success', '분석을 다시 생성합니다 (1분 주기 처리)');
|
||||
// detail 의 ai_session_analysis 갱신을 위해 가벼운 재로드
|
||||
await load();
|
||||
}
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '재생성 호출 실패');
|
||||
} finally {
|
||||
regeneratingSummary = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4-A: AI 풀이 캐시 진척 — wrong/unsure attempts 와 question.ai_explanation_status 결합.
|
||||
let aiExplProgress = $derived.by(() => {
|
||||
if (!detail) return null;
|
||||
@@ -314,6 +346,43 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Phase 4-B v1: 세션 단위 자유 마크다운 분석 -->
|
||||
{#if aiAnalysis}
|
||||
<div class="rounded border border-accent/30 bg-accent/5 p-3 text-xs text-text flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<Sparkles size={12} class="text-accent" />
|
||||
<span class="font-medium">세션 요약</span>
|
||||
{#if aiAnalysis.confidence === 'low'}
|
||||
<span class="text-[10px] text-warning bg-warning/10 rounded px-1.5 py-0.5">신뢰도 낮음</span>
|
||||
{/if}
|
||||
{#if aiAnalysis.is_stale}
|
||||
<span class="text-[10px] text-dim">재생성 중</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto text-[10px] text-dim hover:text-text disabled:opacity-50"
|
||||
disabled={regeneratingSummary}
|
||||
onclick={regenerateSummary}
|
||||
>
|
||||
{regeneratingSummary ? '...' : '재생성'}
|
||||
</button>
|
||||
</div>
|
||||
{#if aiAnalysis.summary_md}
|
||||
<div class="markdown-body math-area leading-relaxed">{@html renderMathMarkdown(aiAnalysis.summary_md)}</div>
|
||||
{:else if aiAnalysis.job_status === 'pending' || aiAnalysis.job_status === 'processing'}
|
||||
<div class="text-dim">AI 가 세션 분석 중입니다 (1분 주기 처리)</div>
|
||||
{:else if aiAnalysis.job_error_code === 'insufficient_attempts'}
|
||||
<div class="text-dim">오답·모르겠음이 5건 미만이라 분석을 생성하지 않습니다.</div>
|
||||
{:else if aiAnalysis.job_error_code === 'evidence_missing'}
|
||||
<div class="text-dim">관련 자료가 부족해 분석을 건너뛰었습니다.</div>
|
||||
{:else if aiAnalysis.job_error_code === 'guard_fail'}
|
||||
<div class="text-dim">분석 결과가 환각 검증에서 차단됐습니다. <button type="button" class="text-accent hover:underline" onclick={regenerateSummary}>재생성</button></div>
|
||||
{:else}
|
||||
<div class="text-dim">분석 대기 중입니다.</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Phase 2-B: 바로 할 일 (지금 시점 progress 기반 동적 카운트) — 클릭 시 복습함 해당 탭 -->
|
||||
{#if (s.pending_review_count ?? 0) + (s.chronic_count ?? 0) + (s.regressed_count ?? 0) > 0}
|
||||
<div class="rounded border border-warning/30 bg-warning/5 p-3 text-xs text-text flex flex-col gap-1.5">
|
||||
|
||||
Reference in New Issue
Block a user