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>
127 lines
4.4 KiB
Python
127 lines
4.4 KiB
Python
"""Phase 4-B v1 세션 분석 enqueue 헬퍼 — finalize/fallback/manual 공유.
|
|
|
|
3 가지 진입점이 다른 정책으로 동작:
|
|
- auto (finalize/fallback): wrong/unsure < 5 도 enqueue 허용 (worker 가 skipped 처리).
|
|
GET fallback 은 best-effort + idempotent + non-blocking.
|
|
- manual (regenerate endpoint): wrong/unsure < 5 또는 active job 존재 시 즉시 차단.
|
|
사용자 즉시 안내 (UX).
|
|
|
|
is_stale 정책:
|
|
- active job 있음: is_stale 변경 X
|
|
- active 없음 + 기존 analysis 있음: is_stale=TRUE 박기 + 새 job
|
|
- active 없음 + 기존 analysis 없음: is_stale 처리 X + 새 job
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Literal
|
|
|
|
from sqlalchemy import func, select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from models.study_question import StudyQuestionAttempt
|
|
from models.study_quiz_session import StudyQuizSession
|
|
from models.study_quiz_session_analysis import StudyQuizSessionAnalysis
|
|
from models.study_quiz_session_job import StudyQuizSessionJob, enqueue_session_analysis_job
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MIN_ATTEMPTS_FOR_ANALYSIS = 5
|
|
|
|
|
|
async def _count_wrong_unsure(session: AsyncSession, study_quiz_session_id: int) -> int:
|
|
row = await session.execute(
|
|
select(func.count())
|
|
.select_from(StudyQuestionAttempt)
|
|
.where(
|
|
StudyQuestionAttempt.quiz_session_id == study_quiz_session_id,
|
|
StudyQuestionAttempt.outcome.in_(("wrong", "unsure")),
|
|
)
|
|
)
|
|
return int(row.scalar() or 0)
|
|
|
|
|
|
async def _has_active_job(session: AsyncSession, study_quiz_session_id: int) -> bool:
|
|
row = await session.execute(
|
|
select(func.count())
|
|
.select_from(StudyQuizSessionJob)
|
|
.where(
|
|
StudyQuizSessionJob.study_quiz_session_id == study_quiz_session_id,
|
|
StudyQuizSessionJob.status.in_(("pending", "processing")),
|
|
)
|
|
)
|
|
return int(row.scalar() or 0) > 0
|
|
|
|
|
|
async def _has_existing_analysis(session: AsyncSession, study_quiz_session_id: int) -> bool:
|
|
row = await session.execute(
|
|
select(StudyQuizSessionAnalysis.study_quiz_session_id).where(
|
|
StudyQuizSessionAnalysis.study_quiz_session_id == study_quiz_session_id
|
|
)
|
|
)
|
|
return row.scalar_one_or_none() is not None
|
|
|
|
|
|
async def enqueue_session_analysis_auto(
|
|
session: AsyncSession,
|
|
*,
|
|
user_id: int,
|
|
study_quiz_session_id: int,
|
|
) -> dict:
|
|
"""finalize_session 끝 + GET fallback 공통 자동 트리거.
|
|
|
|
wrong/unsure 카운트 무관 enqueue (worker 가 < 5 면 insufficient_attempts skipped).
|
|
active 행은 partial unique idx 가 차단. is_stale 은 안 건드림 (자동 트리거라 UX 무관).
|
|
"""
|
|
enqueued = await enqueue_session_analysis_job(
|
|
session,
|
|
study_quiz_session_id=study_quiz_session_id,
|
|
user_id=user_id,
|
|
)
|
|
return {"enqueued": enqueued}
|
|
|
|
|
|
async def request_session_analysis_regenerate(
|
|
session: AsyncSession,
|
|
*,
|
|
user_id: int,
|
|
study_quiz_session_id: int,
|
|
) -> dict:
|
|
"""사용자 [재생성] 버튼 — manual endpoint 가 호출.
|
|
|
|
Returns:
|
|
{'enqueued': bool, 'reason': Literal['insufficient_attempts','already_active'] | None}
|
|
"""
|
|
qs = await session.get(StudyQuizSession, study_quiz_session_id)
|
|
if qs is None or qs.user_id != user_id:
|
|
return {"enqueued": False, "reason": "not_found"}
|
|
if qs.status != "done":
|
|
return {"enqueued": False, "reason": "not_done"}
|
|
|
|
# 1. wrong/unsure < 5 즉시 차단
|
|
cnt = await _count_wrong_unsure(session, study_quiz_session_id)
|
|
if cnt < MIN_ATTEMPTS_FOR_ANALYSIS:
|
|
return {"enqueued": False, "reason": "insufficient_attempts"}
|
|
|
|
# 2. active job 있으면 즉시 차단 (is_stale 변경 X)
|
|
if await _has_active_job(session, study_quiz_session_id):
|
|
return {"enqueued": False, "reason": "already_active"}
|
|
|
|
# 3. 기존 analysis 있으면 is_stale=TRUE
|
|
if await _has_existing_analysis(session, study_quiz_session_id):
|
|
await session.execute(
|
|
update(StudyQuizSessionAnalysis)
|
|
.where(StudyQuizSessionAnalysis.study_quiz_session_id == study_quiz_session_id)
|
|
.values(is_stale=True)
|
|
)
|
|
|
|
# 4. 새 job
|
|
enqueued = await enqueue_session_analysis_job(
|
|
session,
|
|
study_quiz_session_id=study_quiz_session_id,
|
|
user_id=user_id,
|
|
payload={"trigger": "manual"},
|
|
)
|
|
return {"enqueued": enqueued, "reason": None if enqueued else "race_lost"}
|