6785d53d3d
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>
312 lines
12 KiB
Python
312 lines
12 KiB
Python
"""세션 종료 시 progress upsert + pattern 갱신 + 복습 stage 갱신 (Phase 1).
|
|
|
|
호출 시점: 마지막 attempt 가 박혀 status='done' 으로 전이된 직후.
|
|
한 트랜잭션 안에서 처리 — caller 가 commit 책임.
|
|
|
|
책임:
|
|
1. 이 세션의 attempts qid 별 그룹
|
|
2. 각 qid 별 progress upsert:
|
|
- last_outcome / last_attempted_at / last_attempt_id 갱신
|
|
- 최근 3회 attempts (이번 세션 + 과거) 로 pattern_state 계산
|
|
- pattern_window_attempts 갱신
|
|
3. 만약 progress.due_at IS NOT NULL (이미 복습 큐에 있던 문제):
|
|
- correct → review_stage += 1, due_at 다음 단계 (1→3→7→14일)
|
|
- stage ≥ 4 → due_at = NULL (학습완료)
|
|
- wrong/unsure → review_stage = 0, due_at = now()+1일
|
|
4. progress.due_at IS NULL 인 일반 풀이 결과는 due_at/stage 건드리지 않음
|
|
5. 세션 결과 요약 dict 반환
|
|
|
|
attempts 원본 수정 0건. progress upsert 만.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from models.study_question import StudyQuestion, StudyQuestionAttempt
|
|
from models.study_question_progress import StudyQuestionProgress
|
|
from models.study_quiz_session import StudyQuizSession
|
|
from services.study.learning_pattern import (
|
|
PATTERN_CHRONIC_WRONG,
|
|
PATTERN_RECOVERED,
|
|
PATTERN_REGRESSED,
|
|
PATTERN_STABLE,
|
|
attempts_from_rows,
|
|
compute_pattern_state,
|
|
)
|
|
|
|
# review_stage 별 다음 due_at interval (days)
|
|
REVIEW_INTERVAL_DAYS = {1: 3, 2: 7, 3: 14}
|
|
REVIEW_STAGE_MASTERED = 4
|
|
DEFAULT_FIRST_DUE_DAYS = 1
|
|
|
|
|
|
@dataclass
|
|
class SessionSummary:
|
|
"""세션 결과 요약 — 응답 payload 직전 단계."""
|
|
correct: int
|
|
wrong: int
|
|
unsure: int
|
|
skipped: int
|
|
# 상태 변화 카운트
|
|
newly_correct: int # 처음 푸는데 맞힘 (이전 attempts 0건)
|
|
relapsed: int # 이전 정답이었으나 이번 wrong
|
|
recovered: int # pattern_state = recovered 새로 박힘
|
|
chronic_remaining: int # 이번 세션 후 chronic_wrong 으로 박힌 행 수
|
|
# 바로 할 일 후보
|
|
pending_review_count: int # wrong/unsure + 미확인 (last_reviewed_at < last_attempted_at)
|
|
chronic_count: int # pattern_state = chronic_wrong
|
|
regressed_count: int # pattern_state = regressed
|
|
|
|
|
|
async def finalize_session(
|
|
session: AsyncSession,
|
|
*,
|
|
user_id: int,
|
|
study_topic_id: int,
|
|
quiz_session_id: int,
|
|
) -> SessionSummary:
|
|
"""세션 종료 후처리. caller 가 commit. 멱등성 — 두 번 호출되어도 결과 같음."""
|
|
# 1. 이 세션 attempts 시간순
|
|
session_attempts = (
|
|
await session.execute(
|
|
select(StudyQuestionAttempt)
|
|
.where(
|
|
StudyQuestionAttempt.quiz_session_id == quiz_session_id,
|
|
StudyQuestionAttempt.user_id == user_id,
|
|
)
|
|
.order_by(StudyQuestionAttempt.answered_at.asc(), StudyQuestionAttempt.id.asc())
|
|
)
|
|
).scalars().all()
|
|
if not session_attempts:
|
|
return SessionSummary(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
|
|
|
# qid 그룹: 한 세션 안에 같은 qid 가 두 번 박힐 가능성 거의 없지만 안전하게.
|
|
last_per_qid: dict[int, StudyQuestionAttempt] = {}
|
|
for a in session_attempts:
|
|
last_per_qid[a.study_question_id] = a # 시간 순 정렬이라 마지막이 최종
|
|
|
|
qids = list(last_per_qid.keys())
|
|
|
|
# 2. 기존 progress 행 일괄 fetch
|
|
existing = (
|
|
await session.execute(
|
|
select(StudyQuestionProgress)
|
|
.where(
|
|
StudyQuestionProgress.user_id == user_id,
|
|
StudyQuestionProgress.study_topic_id == study_topic_id,
|
|
StudyQuestionProgress.study_question_id.in_(qids),
|
|
)
|
|
)
|
|
).scalars().all()
|
|
progress_by_qid: dict[int, StudyQuestionProgress] = {p.study_question_id: p for p in existing}
|
|
|
|
# 3. qid 별 과거 attempts 일괄 fetch (이 세션 attempts 까지 포함, 시간순)
|
|
history_rows = (
|
|
await session.execute(
|
|
select(StudyQuestionAttempt)
|
|
.where(
|
|
StudyQuestionAttempt.user_id == user_id,
|
|
StudyQuestionAttempt.study_topic_id == study_topic_id,
|
|
StudyQuestionAttempt.study_question_id.in_(qids),
|
|
)
|
|
.order_by(
|
|
StudyQuestionAttempt.study_question_id.asc(),
|
|
StudyQuestionAttempt.answered_at.asc(),
|
|
StudyQuestionAttempt.id.asc(),
|
|
)
|
|
)
|
|
).scalars().all()
|
|
history_by_qid: dict[int, list[StudyQuestionAttempt]] = defaultdict(list)
|
|
for r in history_rows:
|
|
history_by_qid[r.study_question_id].append(r)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
# 카운터 초기화
|
|
correct = wrong = unsure = skipped = 0
|
|
newly_correct = relapsed = recovered_count = 0
|
|
chronic_remaining = 0
|
|
|
|
for qid, last_attempt in last_per_qid.items():
|
|
outcome = last_attempt.outcome
|
|
if outcome == "correct":
|
|
correct += 1
|
|
elif outcome == "wrong":
|
|
wrong += 1
|
|
elif outcome == "unsure":
|
|
unsure += 1
|
|
elif outcome == "skipped":
|
|
skipped += 1
|
|
|
|
history = history_by_qid.get(qid, [])
|
|
# 이번 세션 이전 attempts 만 (status 변화 판단용)
|
|
prior = [a for a in history if a.id != last_attempt.id]
|
|
|
|
# 상태 변화 카운트
|
|
if not prior:
|
|
if outcome == "correct":
|
|
newly_correct += 1
|
|
else:
|
|
prior_last_outcome = prior[-1].outcome
|
|
if prior_last_outcome == "correct" and outcome == "wrong":
|
|
relapsed += 1
|
|
|
|
# pattern 계산
|
|
pattern_state, window_size = compute_pattern_state(attempts_from_rows(history))
|
|
if pattern_state == PATTERN_RECOVERED and (
|
|
qid not in progress_by_qid
|
|
or progress_by_qid[qid].pattern_state != PATTERN_RECOVERED
|
|
):
|
|
recovered_count += 1
|
|
if pattern_state == PATTERN_CHRONIC_WRONG:
|
|
chronic_remaining += 1
|
|
|
|
# progress upsert
|
|
progress = progress_by_qid.get(qid)
|
|
if progress is None:
|
|
progress = StudyQuestionProgress(
|
|
user_id=user_id,
|
|
study_topic_id=study_topic_id,
|
|
study_question_id=qid,
|
|
)
|
|
session.add(progress)
|
|
progress_by_qid[qid] = progress
|
|
|
|
progress.last_outcome = outcome
|
|
progress.last_attempted_at = last_attempt.answered_at
|
|
progress.last_attempt_id = last_attempt.id
|
|
progress.pattern_state = pattern_state
|
|
progress.pattern_updated_at = now
|
|
progress.pattern_window_attempts = window_size
|
|
|
|
# 복습 stage 갱신 — 이미 due_at 박힌 문제만
|
|
if progress.due_at is not None:
|
|
if outcome == "correct":
|
|
progress.review_stage = (progress.review_stage or 0) + 1
|
|
if progress.review_stage >= REVIEW_STAGE_MASTERED:
|
|
progress.due_at = None # 학습완료
|
|
else:
|
|
days = REVIEW_INTERVAL_DAYS[progress.review_stage]
|
|
progress.due_at = now + timedelta(days=days)
|
|
elif outcome in ("wrong", "unsure"):
|
|
progress.review_stage = 0
|
|
progress.due_at = now + timedelta(days=DEFAULT_FIRST_DUE_DAYS)
|
|
# skipped 는 due_at 그대로 (큐 유지, stage 변경 안 함)
|
|
# progress.due_at IS NULL 일반 풀이 → stage 건드리지 않음
|
|
|
|
# 4. 바로 할 일 카운트 (요약 응답용) — finalize 직후 progress 상태 기준 SQL 한 번
|
|
pending_review_count = await _count_pending_review(session, user_id, study_topic_id)
|
|
chronic_count = await _count_pattern(session, user_id, study_topic_id, PATTERN_CHRONIC_WRONG)
|
|
regressed_count = await _count_pattern(session, user_id, study_topic_id, PATTERN_REGRESSED)
|
|
|
|
# 5. quiz_session row 에 스냅샷 영속화 (Phase 2-B). 결과 화면 헤더 노출 + 멱등.
|
|
qs = await session.get(StudyQuizSession, quiz_session_id)
|
|
if qs is not None:
|
|
qs.newly_correct_count = newly_correct
|
|
qs.relapsed_count = relapsed
|
|
qs.recovered_count = recovered_count
|
|
qs.chronic_remaining_count = chronic_remaining
|
|
|
|
# 6. Phase 4-A: 이 세션의 wrong/unsure qid AI 풀이 prefetch enqueue (best-effort).
|
|
# 실패가 finalize 자체를 깨뜨리지 않도록 try/except 로 격리. 응답 카운트와 무관.
|
|
try:
|
|
from services.study.explanation_enqueue import enqueue_explanation_for_qids
|
|
wrong_unsure_qids = [
|
|
qid for qid, a in last_per_qid.items()
|
|
if a.outcome in ("wrong", "unsure")
|
|
]
|
|
if wrong_unsure_qids:
|
|
res = await enqueue_explanation_for_qids(
|
|
session, user_id=user_id, qids=wrong_unsure_qids,
|
|
)
|
|
import logging
|
|
logging.getLogger(__name__).info(
|
|
"phase4a_finalize_enqueue session=%s candidates=%s enqueued=%s skipped=%s",
|
|
quiz_session_id, res["candidate_count"], res["enqueue_count"], res["skipped_count"],
|
|
)
|
|
except Exception as e:
|
|
import logging
|
|
logging.getLogger(__name__).warning(
|
|
"phase4a_finalize_enqueue_failed session=%s: %s: %s",
|
|
quiz_session_id, type(e).__name__, e,
|
|
)
|
|
|
|
# 7. Phase 4-B v1: 세션 단위 분석 enqueue (best-effort).
|
|
# wrong/unsure < 5 면 worker 가 insufficient_attempts 로 skipped — finalize 는 무관.
|
|
try:
|
|
from services.study.session_analysis_enqueue import enqueue_session_analysis_auto
|
|
res = await enqueue_session_analysis_auto(
|
|
session, user_id=user_id, study_quiz_session_id=quiz_session_id,
|
|
)
|
|
import logging
|
|
logging.getLogger(__name__).info(
|
|
"phase4b_finalize_enqueue session=%s enqueued=%s",
|
|
quiz_session_id, res["enqueued"],
|
|
)
|
|
except Exception as e:
|
|
import logging
|
|
logging.getLogger(__name__).warning(
|
|
"phase4b_finalize_enqueue_failed session=%s: %s: %s",
|
|
quiz_session_id, type(e).__name__, e,
|
|
)
|
|
|
|
return SessionSummary(
|
|
correct=correct,
|
|
wrong=wrong,
|
|
unsure=unsure,
|
|
skipped=skipped,
|
|
newly_correct=newly_correct,
|
|
relapsed=relapsed,
|
|
recovered=recovered_count,
|
|
chronic_remaining=chronic_remaining,
|
|
pending_review_count=pending_review_count,
|
|
chronic_count=chronic_count,
|
|
regressed_count=regressed_count,
|
|
)
|
|
|
|
|
|
async def _count_pending_review(session: AsyncSession, user_id: int, topic_id: int) -> int:
|
|
"""pending_review 탭 조건 기반 카운트."""
|
|
from sqlalchemy import and_, func, or_
|
|
row = await session.execute(
|
|
select(func.count())
|
|
.select_from(StudyQuestionProgress)
|
|
.where(
|
|
StudyQuestionProgress.user_id == user_id,
|
|
StudyQuestionProgress.study_topic_id == topic_id,
|
|
StudyQuestionProgress.last_outcome.in_(("wrong", "unsure")),
|
|
or_(
|
|
StudyQuestionProgress.last_reviewed_at.is_(None),
|
|
and_(
|
|
StudyQuestionProgress.last_reviewed_at.is_not(None),
|
|
StudyQuestionProgress.last_attempted_at.is_not(None),
|
|
StudyQuestionProgress.last_reviewed_at
|
|
< StudyQuestionProgress.last_attempted_at,
|
|
),
|
|
),
|
|
)
|
|
)
|
|
return int(row.scalar() or 0)
|
|
|
|
|
|
async def _count_pattern(
|
|
session: AsyncSession, user_id: int, topic_id: int, pattern: str
|
|
) -> int:
|
|
from sqlalchemy import func
|
|
row = await session.execute(
|
|
select(func.count())
|
|
.select_from(StudyQuestionProgress)
|
|
.where(
|
|
StudyQuestionProgress.user_id == user_id,
|
|
StudyQuestionProgress.study_topic_id == topic_id,
|
|
StudyQuestionProgress.pattern_state == pattern,
|
|
)
|
|
)
|
|
return int(row.scalar() or 0)
|