- lib/api.ts: GoalType('math-suneung'|'ps'|...), MathUnit, MATH_UNIT_LABEL,
MeUser{focusUnits,bojHandle}, ps.* helper (search/bookmark/sync/getProblem)
- onboarding/page.tsx: 5개 GoalType 옵션, math-suneung 단원 멀티셀렉트,
ps 인풋 + 즉시 동기화 토글, focusUnits/bojHandle PATCH
- profile/page.tsx: 동일 컨트롤 노출 + 검증
- ps/{page,bookmarks/page,sync/page}.tsx: 검색·필터·북마크·동기화 신규 섹션
- components/ps/PsTabs.tsx, layout/{SideNav,BottomNav}.tsx, ui/Icon.tsx:
PS 진입점 + Phosphor code/bookmark-simple 아이콘
- exam/shared.ts: getExamDurationSeconds → 수학 100분 고정
- 비수학 하드코딩 전면 제거: dashboard/exams/study/subjects/school/assignment
- 카피 갱신: landing/pricing/layout metadata + README + docs(user/admin/pipeline)
- .eslintrc.json: Next core lint 결정성 확보
Phase 10C DoD 통과 — pnpm build + lint 0 에러, /ps /ps/bookmarks /ps/sync 라우트 생성,
grep -rE '국어|영어|한국사|생활과 윤리|사회탐구' frontend/src docs = 0
7.5 KiB
7.5 KiB
ReLoop PDF 파싱 파이프라인
개요
ReLoop은 KICE(한국교육과정평가원) 수능 기출 PDF를 자동으로 파싱해 문제집 데이터로 변환합니다. 텍스트 추출과 이미지 크롭을 병행하며, 이미지가 주(primary) 표시이고 텍스트는 보조(검색/채점용)입니다.
파이프라인 흐름
KICE PDF 파일
│
▼
┌──────────────────────┐
│ 1. scanKiceData() │ — 파일 시스템 스캔, 과목/연도별 PDF 경로 수집
└──────────┬───────────┘
│
┌──────┴──────┐
▼ ▼
┌────────┐ ┌────────┐
│ 문제지 │ │ 정답표 │
│ PDF │ │ PDF │
└───┬────┘ └───┬────┘
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ 이미지 │ │ 텍스트 │
│ 크롭 │ │ 파싱 │
│(pdftoppm)│ │(pdftotext│
│ │ │ -layout) │
└───┬──────┘ └───┬──────┘
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ per- │ │ answer │
│ problem │ │ {number, │
│ PNG │ │ answer} │
└───┬──────┘ └───┬──────┘
│ │
└──────┬─────┘
▼
┌──────────────────────┐
│ 2. Prisma upsert │ — ProblemSet / Problem / Passage DB 저장
└──────────────────────┘
도구
| 도구 | 용도 | 패키지 |
|---|---|---|
pdfinfo |
페이지 수, 페이지 크기 확인 | poppler-utils |
pdftotext -bbox |
단어별 바운딩 박스 XML 추출 (문제 번호 Y좌표 감지) | poppler-utils |
pdftotext -layout |
정답표 텍스트 추출 (표 형태 보존) | poppler-utils |
pdftotext -raw |
문제지 텍스트 추출 (보조, 검색용) | poppler-utils |
pdftoppm |
PDF 페이지를 PNG 이미지로 렌더링 + 크롭 | poppler-utils |
codex exec -i |
이미지 기반 OCR (2025 정답표 등) | codex CLI |
핵심 모듈
backend/src/problem-sets/parsing/
├── index.ts # parseExamPaper() — 공개 API
├── types.ts # ParseResult, ParsedProblem 등 타입
├── extract-text.ts # pdftotext 래퍼
├── strip-page-chrome.ts # 페이지 헤더/푸터 제거
├── parse-answer-table.ts # 정답표 파서
├── parse-problem-paper.ts # 문제지 텍스트 파서 (보조)
├── parse-image-based-exam.ts # 문제별 이미지 크롭 (주)
├── strategies/
│ ├── kice.ts # KICE 수능 전용 규칙
│ └── math.ts # 수학 과목 전용 규칙
├── ocr-fallback/
│ ├── index.ts # ocrAnswerTable(), ocrProblemPage()
│ ├── render-page.ts # pdftoppm 래퍼
│ ├── codex-vision.ts # codex exec -i 래퍼
│ └── prompts/ # OCR 프롬프트 템플릿
└── README.md
이미지 크롭 알고리즘
문제 번호 위치 감지
- 각 페이지에
pdftotext -bbox실행 → XML 출력 - XML에서
\d{1,2}\.패턴의 단어 위치 추출 (yMin, xMin) [M~N]형태의 지문 범위 마커도 같이 추출
크롭 영역 계산
페이지 크기: 842pt × 1191pt (A3)
렌더 DPI: 300
변환 비율: pixel = point × 300/72 ≈ point × 4.167
문제 N의 크롭 영역:
Y 시작 = (문제 N의 yMin - 15pt 여유) × 4.167
Y 끝 = (문제 N+1의 yMin - 5pt) × 4.167 (마지막 문제면 페이지 하단)
X 시작 = 0
너비 = 페이지 전체 너비 × 4.167
최소 높이 = 200px (너무 작은 크롭 방지)
pdftoppm 크롭 실행
pdftoppm -r 300 -png -f {page} -l {page} \
-x 0 -y {startPx} -W {widthPx} -H {heightPx} \
input.pdf /tmp/crop-prefix
출력: /tmp/crop-prefix-{page}.png
폴백
bbox에서 감지되지 않은 문제 번호는 해당 페이지 전체를 이미지로 저장합니다.
정답표 파싱
텍스트 기반 (2026)
pdftotext -layout input.pdf -
출력 예:
1 ③ 2 18 ② 2
2 ⑤ 2 19 ④ 2
정규식 /(\d{1,2})\s*([①②③④⑤])/g 으로 추출, 원문자를 1-5로 변환.
OCR 기반 (2025)
2025 정답표는 PScript5.dll + Acrobat Distiller로 생성되어 텍스트 레이어가 없습니다.
pdftoppm -r 300으로 페이지 렌더링codex exec --sandbox read-only -i page.png으로 Vision API 호출- 구조화된 JSON 응답 파싱:
[{number, answerNumber}, ...] - Problem 레코드에 answerNumber 업데이트
페이지 헤더/푸터 제거 (strip-page-chrome.ts)
pdftotext 결과에는 페이지 헤더/푸터가 포함됩니다. 이를 제거하지 않으면 다음 문제의 본문에 섞입니다.
제거 대상 라인 패턴:
^\s*\d+\s*$— 페이지 번호만 있는 줄^\s*\d+\s+홀수형\s*$— "2 홀수형"^\s*제\s*\d+\s*교시— "제1 교시"^\s*\d{4}학년도.*대학수학능력시험.*문제지— 시험 제목^\s*이 문제지에 관한 저작권은— 저작권 문구\f— 폼 피드 문자
전략 패턴 (확장성)
새로운 시험 형식 (EBS, 사설 모의고사 등)을 추가하려면:
strategies/에 새 파일 생성 (예:ebs.ts)PageChromeStrategy인터페이스 구현 (헤더/푸터 정규식 패턴)parseExamPaper()호출 시format: 'ebs'전달
현재 구현된 전략:
kice— KICE 수능 (기본)math— 수학 과목 (이미지 전용)generic— 알 수 없는 형식 (최소 strip + 기본 정규식)
출력 데이터
Problem 레코드
| 필드 | 값 | 설명 |
|---|---|---|
imageUrl |
/uploads/problems/2026/math/common/001.png |
문제 크롭 이미지 (주 표시) |
pageImageUrl |
/uploads/problems/2026/math/common/page-1.png |
전체 페이지 (폴백) |
bodyText |
윗글의 내용과 일치하지 않는 것은? |
텍스트 추출 (검색/접근성) |
choices |
{"1":"...","2":"...","3":"...","4":"...","5":"..."} |
선택지 (보조) |
answerNumber |
3 |
정답 (자동 채점용) |
needsReview |
false |
파싱 품질 플래그 |
Passage 레코드
| 필드 | 값 |
|---|---|
imageUrl |
/uploads/problems/2026/math/common/passage-1-3.png |
bodyText |
[1~3] 다음 글을 읽고 물음에 답하시오. ... |
startNumber |
1 |
endNumber |
3 |
CLI 명령어 요약
# 전체 임포트 (스캔 + 크롭 + 파싱 + DB upsert)
pnpm cli:kice-import
# 특정 대상
pnpm cli:kice-import --year=2026 --subject=math
# 드라이 런
pnpm cli:kice-import --dry-run
# OCR 보정
pnpm cli:ocr-fix --year=2025 --answers-only
pnpm cli:ocr-fix --year=2025 --subject=math
# 검증
pnpm exec ts-node -e "
import { PrismaClient } from '@prisma/client';
const p = new PrismaClient();
(async () => {
const total = await p.problem.count();
const withImg = await p.problem.count({ where: { imageUrl: { not: null } } });
console.log(withImg + '/' + total + ' problems with images');
await p.\$disconnect();
})();
"