/* 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 을 처리설정 바로 아래로 이동 — 사용자가 같은
시야에 처리 상황을 보게. */}
{/* ---- 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}줄)
{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 로 채워집니다.
)}
페이지 분류 통계