Files
reloop-v2/docs/pdf-pipeline.md
reloop 78371cacf8 feat(phase10C): 온보딩 + 프론트 재구성 — 수학 수능 4단원 + PS 트랙
- 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
2026-04-14 19:38:16 +09:00

7.5 KiB
Raw Permalink Blame History

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

이미지 크롭 알고리즘

문제 번호 위치 감지

  1. 각 페이지에 pdftotext -bbox 실행 → XML 출력
  2. XML에서 \d{1,2}\. 패턴의 단어 위치 추출 (yMin, xMin)
  3. [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로 생성되어 텍스트 레이어가 없습니다.

  1. pdftoppm -r 300 으로 페이지 렌더링
  2. codex exec --sandbox read-only -i page.png 으로 Vision API 호출
  3. 구조화된 JSON 응답 파싱: [{number, answerNumber}, ...]
  4. 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, 사설 모의고사 등)을 추가하려면:

  1. strategies/ 에 새 파일 생성 (예: ebs.ts)
  2. PageChromeStrategy 인터페이스 구현 (헤더/푸터 정규식 패턴)
  3. 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();
})();
"