/* blueyetutor — /upload * Subject family (Physics | Calculus BC[준비 중]) + AI model + multi-PDF. * After 처리 시작: classifying → running → done, with cancel mid-flight. * Result panel: per-subject group cards w/ category_counts metrics + * MCQ/FRQ tabs of cropped problems. * * Assumed endpoints (mock now): * POST /api/uploads { subject_family, label_model, files[multipart] } → job * GET /api/uploads/{id} → job (poll) * POST /api/uploads/{id}/cancel */ const { useState: upUseState, useEffect: upUseEffect, useMemo: upUseMemo } = React; function UploadPage() { const store = useStore(); const job = store.job; const [family, setFamily] = upUseState("Physics"); const [model, setModel] = upUseState(LABEL_MODELS[0].id); const [files, setFiles] = upUseState([]); // 처리 경로는 항상 자동: 백엔드(wrapper.py)가 텍스트 레이어 + CB 형식을 // 보고 결정론 / AI vision 중 적절한 쪽으로 라우팅한다. UI 토글 없음. const running = job && job.status !== "done" && job.status !== "error" && job.status !== "cancelled"; const fileInput = React.useRef(null); // 박현우 명시 2026-05-21: 파일 선택 누적 (이전 선택 유지) + 합계 용량 상한. // 학원이 큰 batch 올리면 OCR + Anthropic API 비용·시간 폭증해서 상한 필수. // 1 권 시험지 ≈ 30-50MB → 500MB 면 10-15개 동시 (한 batch 의 합리적 상한). const MAX_TOTAL_MB = 500; const onPick = e => { const incoming = Array.from(e.target.files || []); // 중복 제거 (이름+사이즈 기준). const seen = new Set(files.map(f => f.name + "|" + f.size)); const fresh = incoming.filter(f => !seen.has(f.name + "|" + f.size)); const next = [...files, ...fresh]; const totalMB = next.reduce((s, f) => s + f.size, 0) / (1024 * 1024); if (totalMB > MAX_TOTAL_MB) { alert(`전체 용량이 ${MAX_TOTAL_MB}MB 를 넘어요 (현재 ${totalMB.toFixed(1)}MB). ` + `한 번에 너무 많이 올리면 처리 시간·비용이 폭증합니다. 일부 파일을 빼고 다시 선택해 주세요.`); if (e.target) e.target.value = ""; return; } setFiles(next); if (e.target) e.target.value = ""; // 같은 파일 재선택도 동작하게. }; const removeFile = (idx) => setFiles(files.filter((_, i) => i !== idx)); const clearFiles = () => setFiles([]); const start = () => { if (files.length === 0) return; window.storeActions.startUpload(files, model, family); }; return (
Upload

PDF를 올려 문제를 채워 주세요

{/* ---- 좌측 col: 처리 설정 + Job Panel (처리 중 / 결과 직후) ---- 박현우 명시 2026-05-21: Job Panel 이 우측 "지원 과목" 카드 아래에 뜨던 옛 layout 을 처리설정 바로 아래로 이동 — 사용자가 같은 시야에 처리 상황을 보게. */}
Claude
{files.length > 0 && ( <> {files.length}개 선택됨 ·{" "} {(files.reduce((s, f) => s + f.size, 0) / 1024 / 1024).toFixed(1)} / {MAX_TOTAL_MB} MB )}
{files.length > 0 && (
{files.map((f, i) => (
{f.name} {(f.size / 1024 / 1024).toFixed(1)} MB
))}
)}
{job && !running && ( )}
{/* ---- Side panel: supported subjects catalog ---- 처리 가능한 과목 + 향후 합류 예정 과목들을 한눈에. 활성 6 과목만 클릭 가능 (현재는 자동 분류라 실제 동작은 없고 시각적 강조용), 나머지는 disabled + 작은 '준비 중'. */} {/* Job Panel — 처리 설정 바로 아래 (좌측 col 안). */} {job && }
); } // 학원이 보기 좋은 한 줄 단계 메시지. 마지막 의미있는 로그를 보고 단계명을 // 결정한다. 검정 터미널 출력은 details 안으로 숨김 (혹시 디버그 필요할 때만). function _stageMessage(log) { const lines = (log || []).slice().reverse(); for (const raw of lines) { const l = (raw || "").trim(); if (!l) continue; // 가장 구체적인 키워드부터 매칭 if (/persist|web\.db|Library/i.test(l)) return "최종 저장 중입니다"; if (/라벨링|labeling|RULE|AI/i.test(l) && /라벨/i.test(l)) return "AI 가 단원·답을 라벨링 중입니다"; if (/정답지|answer_key|answers/i.test(l)) return "정답지 처리 중입니다"; if (/crop|분할|자르/i.test(l)) return "문제별로 자르는 중입니다"; if (/cache 합성|text_class|blocks\.json/i.test(l)) return "구조 분석 중입니다"; if (/VLM|vision|p\d{3}/i.test(l)) return "AI 가 이미지 분석 중입니다"; if (/렌더|render/i.test(l)) return "페이지 이미지 만드는 중입니다"; if (/OCR|글자 인식/i.test(l)) return "글자 인식 중입니다"; if (/분류 중:|분류 완료|연도|과목/i.test(l)) return "과목·연도 분류 중입니다"; if (/합치는|combine/i.test(l)) return "PDF 합치는 중입니다"; if (/^\s*\d+\/\d+\s+(.+)/.test(l)) { const m = l.match(/^\s*\d+\/\d+\s+(.+)/); return (m && m[1].slice(0, 60)) || "처리 중입니다"; } return "처리 중입니다"; } return "준비 중입니다"; } function LogTail({ log }) { const lines = log || []; if (!lines.length) return null; return (
자세한 처리 로그 보기 ({lines.length}줄)
{lines.slice(-300).join("\n")}
); } function UploadJobPanel({ job }) { const [, setTick] = upUseState(0); upUseEffect(() => { if (job.status !== "running" && job.status !== "classifying") return; const t = setInterval(() => setTick(n => n + 1), 1000); return () => clearInterval(t); }, [job.status]); const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - job.started_at)); const mm = String(Math.floor(elapsed / 60)).padStart(2, "0"); const ss = String(elapsed % 60).padStart(2, "0"); const gd = job.groups_done || 0, gt = job.groups_total || 0; const running = job.status === "classifying" || job.status === "running"; return (
{running && ( window.storeActions.cancelUpload()}> 중지}>
{_stageMessage(job.log)}
{job.status === "classifying" ? `과목·연도 분류 단계 · 경과 ${mm}:${ss}` : `파일 ${gd}/${gt || "?"} 완료 · 경과 ${mm}:${ss}`} {" · "}끝나는 파일부터 바로 Library 에 추가됩니다
{job.cancel_requested && 중지 요청됨}
)} {job.status === "cancelled" && ( 이미 끝난 파일은 Library 에 남아 있어요. 아래·Library 에서 확인하세요. )} {job.status === "error" && ( {job.error || "원인 미상"} )} {(job.results || []).length > 0 && }
); } function UploadResults({ job }) { const results = job.results || []; if (results.length === 0) return null; const isBad = r => r.status === "failed" || r.status === "unsupported" || !((r.result && r.result.problems) || []).length; const bad = results.filter(isBad).length; const ok = results.length - bad; const done = job.status === "done" || job.status === "cancelled"; return (
{done && (bad > 0 ? 0 ? "warn" : "error"} title={`성공 ${ok} · 실패/미지원 ${bad}`}> 성공한 파일은 Library 에 추가됐어요. 실패·미지원 파일은 아래에 따로 표시됩니다. : 업로드한 자료가 모두 Library 에 추가됐어요. )} {results.map((r, i) => )}
); } function UploadOneResult({ item }) { const [tab, setTab] = upUseState("MCQ"); const r = item.result || {}; const files = (item.files || []).join(", "); const mcq = (r.problems || []).filter(p => p.qtype === "MCQ"); const frq = (r.problems || []).filter(p => p.qtype === "FRQ"); const total = mcq.length + frq.length; const labeled = r.labeled_count || 0; const failed = item.status === "failed" || item.status === "unsupported" || total === 0; const titleName = files || r.pdf_id || "파일"; if (failed) { return ( 지원 안 함}> {item.status === "failed" ? <>처리 중 오류로 건너뛰었습니다{item.error ? <> — {item.error} : null}. 다른 파일 처리에는 영향이 없습니다. : <>College Board 공식 형식이 아닐 수 있어요 (학원·출판사 자료 / 한글 혼재 / 스캔 품질). collegeboard.org 원본 PDF 로 다시 시도하세요.} ); } return ( {files} : null}>
{total > 0 && labeled < total && ( 동일 PDF 를 다시 처리하면 누락분만 resumable 로 채워집니다. )}
페이지 분류 통계
{Object.entries(r.category_counts || {}).map(([k, v]) => {k} × {v})}
{tab === "MCQ" ? (
{mcq.slice(0, 12).map(p => )}
) : (
{frq.slice(0, 4).map(p => ( ))}
)}
표시는 12개까지 · 전체 확인은 Library 에서.
); } function SupportedSubjects() { return (
{Object.keys(PROGRAMS).map(prog => { const subs = PROGRAMS[prog].subjects; return (
{prog}
{subs.map(s => { const ready = READY_SUBJECTS.has(s); return ( ); })}
); })}
); } Object.assign(window, { UploadPage });