feat(kice): pdftotext-based import pipeline — 7A.2-7A.8
- scanKiceData: 2025/2026 국어/영어/한국사/생활과 윤리 PDF 스캔 - extractText: pdftotext -raw (problems) / -layout (answers), pdf-parse 폐기 - parseAnswerTable: 홀/짝 분리 + ①..⑤ → 1..5 정규화 - parseProblemPaper: [N~M] passage 범위 + 번호 monotonic 체크 - importKiceSet: ProblemSet/Passage/Problem 멱등 upsert - seed.ts: DEMO_PROBLEMSET(2024 수학) 제거, User/Subject/Tag 만 유지 - CLI: cli:kice-import --year --subject --only --dry-run --sample 270 problems / 26 passages / 9 problem sets 로컬 검증 완료
This commit is contained in:
@@ -19,7 +19,8 @@
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:reset": "prisma migrate reset --force",
|
||||
"prisma:deploy": "prisma migrate deploy",
|
||||
"prisma:seed": "ts-node prisma/seed.ts"
|
||||
"prisma:seed": "ts-node prisma/seed.ts",
|
||||
"cli:kice-import": "ts-node scripts/kice-import.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
|
||||
@@ -19,42 +19,6 @@ interface SubjectSeed {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo ProblemSet — 2024학년도 수능 수학 (공통) 샘플 10 문제.
|
||||
*
|
||||
* ⚠️ 여기 적힌 난이도/정답률은 **데모용 임시 수치** 다. 실제 대학수학능력시험
|
||||
* 공식 정답률은 평가원이 문항별로 공개하지만, 여기서는 "문제집 import 플로우"
|
||||
* 를 보여주기 위한 대략값을 넣었다. 실제 서비스에서는 KICE 자료 수집 파이프라인
|
||||
* 이 채울 것.
|
||||
*/
|
||||
interface ProblemSeed {
|
||||
number: number;
|
||||
title: string;
|
||||
difficulty: number;
|
||||
baseCorrectRate: number;
|
||||
topic: string;
|
||||
}
|
||||
|
||||
const DEMO_PROBLEMSET = {
|
||||
title: '2024학년도 대학수학능력시험 수학',
|
||||
examType: 'sat',
|
||||
year: 2024,
|
||||
subjectName: '수학',
|
||||
sourceUrl: 'https://www.suneung.re.kr/',
|
||||
problems: [
|
||||
{ number: 1, title: '로그의 계산', difficulty: 0.18, baseCorrectRate: 0.92, topic: '수1 지수로그' },
|
||||
{ number: 5, title: '삼각함수 값 계산', difficulty: 0.25, baseCorrectRate: 0.86, topic: '수1 삼각함수' },
|
||||
{ number: 8, title: '수열의 합', difficulty: 0.32, baseCorrectRate: 0.78, topic: '수1 수열' },
|
||||
{ number: 11, title: '도함수의 활용', difficulty: 0.45, baseCorrectRate: 0.62, topic: '수2 미분' },
|
||||
{ number: 13, title: '정적분의 성질', difficulty: 0.53, baseCorrectRate: 0.55, topic: '수2 적분' },
|
||||
{ number: 15, title: '수열의 극한 응용', difficulty: 0.68, baseCorrectRate: 0.32, topic: '수1 수열' },
|
||||
{ number: 20, title: '삼각함수 그래프', difficulty: 0.58, baseCorrectRate: 0.48, topic: '수1 삼각함수' },
|
||||
{ number: 22, title: '합성함수의 미분', difficulty: 0.82, baseCorrectRate: 0.12, topic: '수2 미분' },
|
||||
{ number: 28, title: '정적분 킬러', difficulty: 0.88, baseCorrectRate: 0.07, topic: '수2 적분' },
|
||||
{ number: 30, title: '수열 킬러', difficulty: 0.93, baseCorrectRate: 0.03, topic: '수1 수열' },
|
||||
] as ProblemSeed[],
|
||||
};
|
||||
|
||||
const SUBJECTS: SubjectSeed[] = [
|
||||
{
|
||||
name: '국어',
|
||||
@@ -122,50 +86,6 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Demo ProblemSet (shared across users — read-only)
|
||||
const ps = await prisma.problemSet.upsert({
|
||||
where: {
|
||||
year_examType_subjectName: {
|
||||
year: DEMO_PROBLEMSET.year,
|
||||
examType: DEMO_PROBLEMSET.examType,
|
||||
subjectName: DEMO_PROBLEMSET.subjectName,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
title: DEMO_PROBLEMSET.title,
|
||||
sourceUrl: DEMO_PROBLEMSET.sourceUrl,
|
||||
},
|
||||
create: {
|
||||
title: DEMO_PROBLEMSET.title,
|
||||
examType: DEMO_PROBLEMSET.examType,
|
||||
year: DEMO_PROBLEMSET.year,
|
||||
subjectName: DEMO_PROBLEMSET.subjectName,
|
||||
sourceUrl: DEMO_PROBLEMSET.sourceUrl,
|
||||
},
|
||||
});
|
||||
for (const p of DEMO_PROBLEMSET.problems) {
|
||||
await prisma.problem.upsert({
|
||||
where: { problemSetId_number: { problemSetId: ps.id, number: p.number } },
|
||||
update: {
|
||||
title: p.title,
|
||||
difficulty: p.difficulty,
|
||||
baseCorrectRate: p.baseCorrectRate,
|
||||
topic: p.topic,
|
||||
},
|
||||
create: {
|
||||
problemSetId: ps.id,
|
||||
number: p.number,
|
||||
title: p.title,
|
||||
difficulty: p.difficulty,
|
||||
baseCorrectRate: p.baseCorrectRate,
|
||||
topic: p.topic,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(
|
||||
` problemSet: ${ps.title} (${DEMO_PROBLEMSET.problems.length} problems)`,
|
||||
);
|
||||
|
||||
console.log('✅ seed done');
|
||||
}
|
||||
|
||||
|
||||
631
backend/scripts/kice-import.ts
Normal file
631
backend/scripts/kice-import.ts
Normal file
@@ -0,0 +1,631 @@
|
||||
/**
|
||||
* KICE (한국교육과정평가원) 수능 기출 PDF import 스크립트.
|
||||
*
|
||||
* 사용법:
|
||||
* pnpm cli:kice-import --dry-run # 파일 목록만 출력
|
||||
* pnpm cli:kice-import --year=2026 # 특정 연도만
|
||||
* pnpm cli:kice-import --subject=국어 # 특정 과목만
|
||||
* pnpm cli:kice-import --year=2026 --subject=국어 --only=answer
|
||||
* pnpm cli:kice-import --year=2026 --subject=국어 --only=problems --sample=1
|
||||
* pnpm cli:kice-import # 전체 (2025/2026 × 4과목)
|
||||
*
|
||||
* 전제:
|
||||
* - poppler-utils (pdftotext) 가 PATH 에 있어야 함
|
||||
* - data/kice/ 하위에 PDF 파일이 배치돼있어야 함
|
||||
* - backend/.env 에 DATABASE_URL 설정
|
||||
*
|
||||
* MVP 범위: 국어 / 영어 / 한국사 / 생활과 윤리 (사탐 1과목). 2025/2026 학년도.
|
||||
* 수학/과탐은 수식이 벡터 그래픽이라 텍스트 파싱 부적합 — Phase 8 별도 트랙.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
|
||||
// ─── CLI 옵션 ────────────────────────────────────────────────────────
|
||||
interface CliOptions {
|
||||
dryRun: boolean;
|
||||
year?: number;
|
||||
subject?: string;
|
||||
only?: 'answer' | 'problems' | 'all';
|
||||
sample?: number;
|
||||
}
|
||||
|
||||
function parseArgs(): CliOptions {
|
||||
const args = process.argv.slice(2);
|
||||
const opts: CliOptions = { dryRun: false, only: 'all' };
|
||||
for (const a of args) {
|
||||
if (a === '--dry-run') opts.dryRun = true;
|
||||
else if (a.startsWith('--year=')) opts.year = Number(a.split('=')[1]);
|
||||
else if (a.startsWith('--subject=')) opts.subject = a.split('=')[1];
|
||||
else if (a.startsWith('--only=')) opts.only = a.split('=')[1] as CliOptions['only'];
|
||||
else if (a.startsWith('--sample=')) opts.sample = Number(a.split('=')[1]);
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
// ─── 경로 상수 ────────────────────────────────────────────────────────
|
||||
const DATA_DIR = path.resolve(__dirname, '../../data/kice');
|
||||
const TARGET_YEARS = [2025, 2026];
|
||||
const TARGET_SUBJECTS = ['국어', '영어', '한국사', '생활과 윤리'] as const;
|
||||
type TargetSubject = (typeof TARGET_SUBJECTS)[number];
|
||||
|
||||
interface PdfFile {
|
||||
year: number;
|
||||
subject: TargetSubject;
|
||||
role: 'problems' | 'answer' | 'audio-script';
|
||||
filepath: string;
|
||||
sizeKB: number;
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
files: PdfFile[];
|
||||
audioMp3s: Map<string, string[]>; // key: `${year}/영어`, value: mp3 상대경로 배열
|
||||
}
|
||||
|
||||
// ─── 1. 스캐너 ────────────────────────────────────────────────────────
|
||||
function scanKiceData(filter: { year?: number; subject?: string }): ScanResult {
|
||||
const files: PdfFile[] = [];
|
||||
const audioMp3s = new Map<string, string[]>();
|
||||
|
||||
for (const year of TARGET_YEARS) {
|
||||
if (filter.year && filter.year !== year) continue;
|
||||
|
||||
for (const subject of TARGET_SUBJECTS) {
|
||||
if (filter.subject && filter.subject !== subject) continue;
|
||||
|
||||
if (subject === '생활과 윤리') {
|
||||
// 사탐 1번 과목만 선별
|
||||
const problemPath = path.join(
|
||||
DATA_DIR,
|
||||
String(year),
|
||||
'사회탐구/사회탐구영역_문제지',
|
||||
);
|
||||
const answerPath = path.join(
|
||||
DATA_DIR,
|
||||
String(year),
|
||||
'사회탐구/사회탐구영역_정답표',
|
||||
);
|
||||
const problemFile = findFile(problemPath, /01 생활과 윤리_(문제|문제지)\.pdf$/);
|
||||
const answerFile = findFile(answerPath, /01 생활과 윤리_(정답|정답표)\.pdf$/);
|
||||
if (problemFile) {
|
||||
files.push({
|
||||
year,
|
||||
subject,
|
||||
role: 'problems',
|
||||
filepath: problemFile,
|
||||
sizeKB: Math.round(fs.statSync(problemFile).size / 1024),
|
||||
});
|
||||
}
|
||||
if (answerFile) {
|
||||
files.push({
|
||||
year,
|
||||
subject,
|
||||
role: 'answer',
|
||||
filepath: answerFile,
|
||||
sizeKB: Math.round(fs.statSync(answerFile).size / 1024),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 국어 / 영어 / 한국사
|
||||
const subjectDir = path.join(DATA_DIR, String(year), subject);
|
||||
if (!fs.existsSync(subjectDir)) continue;
|
||||
|
||||
const problemFile =
|
||||
findFile(subjectDir, /_문제지(_홀수형)?\.pdf$/) ||
|
||||
findFile(subjectDir, /_문제지_짝수형\.pdf$/) ||
|
||||
findFile(subjectDir, /_문제\.pdf$/);
|
||||
const answerFile = findFile(subjectDir, /_정답표?\.pdf$/);
|
||||
const scriptFile = findFile(subjectDir, /_듣기평가대본\.pdf$/);
|
||||
|
||||
if (problemFile) {
|
||||
files.push({
|
||||
year,
|
||||
subject,
|
||||
role: 'problems',
|
||||
filepath: problemFile,
|
||||
sizeKB: Math.round(fs.statSync(problemFile).size / 1024),
|
||||
});
|
||||
}
|
||||
if (answerFile) {
|
||||
files.push({
|
||||
year,
|
||||
subject,
|
||||
role: 'answer',
|
||||
filepath: answerFile,
|
||||
sizeKB: Math.round(fs.statSync(answerFile).size / 1024),
|
||||
});
|
||||
}
|
||||
if (scriptFile) {
|
||||
files.push({
|
||||
year,
|
||||
subject,
|
||||
role: 'audio-script',
|
||||
filepath: scriptFile,
|
||||
sizeKB: Math.round(fs.statSync(scriptFile).size / 1024),
|
||||
});
|
||||
}
|
||||
|
||||
// 영어 듣기 mp3 경로 수집 (상대경로)
|
||||
if (subject === '영어') {
|
||||
const audioDir1 = path.join(subjectDir, '영어영역_듣기평가음원');
|
||||
const audioDir2 = path.join(subjectDir, '영어영역듣기평가음원');
|
||||
const audioDir = fs.existsSync(audioDir1)
|
||||
? audioDir1
|
||||
: fs.existsSync(audioDir2)
|
||||
? audioDir2
|
||||
: null;
|
||||
if (audioDir) {
|
||||
const mp3s = fs
|
||||
.readdirSync(audioDir)
|
||||
.filter((f) => f.endsWith('.mp3'))
|
||||
.sort()
|
||||
.map((f) => path.relative(DATA_DIR, path.join(audioDir, f)));
|
||||
if (mp3s.length > 0) {
|
||||
audioMp3s.set(`${year}/영어`, mp3s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { files, audioMp3s };
|
||||
}
|
||||
|
||||
function findFile(dir: string, pattern: RegExp): string | null {
|
||||
if (!fs.existsSync(dir)) return null;
|
||||
const entries = fs.readdirSync(dir);
|
||||
const match = entries.find((e) => pattern.test(e));
|
||||
return match ? path.join(dir, match) : null;
|
||||
}
|
||||
|
||||
// ─── 2. pdftotext 래퍼 ──────────────────────────────────────────────
|
||||
/**
|
||||
* pdftotext 호출.
|
||||
*
|
||||
* mode:
|
||||
* 'layout' = 시각적 레이아웃 보존 — 정답표 같은 표 형식에 적합
|
||||
* 'raw' = physical reading order — 2단 컬럼 문제지에 적합 (좌→우 아님)
|
||||
*/
|
||||
function extractText(pdfPath: string, mode: 'layout' | 'raw' = 'layout'): string {
|
||||
const flag = mode === 'raw' ? '-raw' : '-layout';
|
||||
const cmd = `pdftotext ${flag} -enc UTF-8 "${pdfPath}" -`;
|
||||
return execSync(cmd, { encoding: 'utf-8', maxBuffer: 20 * 1024 * 1024 });
|
||||
}
|
||||
|
||||
// ─── 3. 정답표 파서 ──────────────────────────────────────────────────
|
||||
const CIRCLED: Record<string, number> = {
|
||||
'①': 1,
|
||||
'②': 2,
|
||||
'③': 3,
|
||||
'④': 4,
|
||||
'⑤': 5,
|
||||
};
|
||||
|
||||
interface AnswerEntry {
|
||||
number: number;
|
||||
answerNumber: number;
|
||||
}
|
||||
|
||||
function parseAnswerTable(text: string): AnswerEntry[] {
|
||||
// 홀수형 섹션만 사용 (짝수형은 무시 — 문항 번호는 같고 선택지 순서만 다름)
|
||||
const oddOnly = splitBeforeEvenForm(text);
|
||||
|
||||
const seen = new Map<number, number>();
|
||||
// 정규식: 숫자 (번) + (공백) + 원문자 ①②③④⑤
|
||||
// "1 ③ 2" 또는 "1 ③ 2" 또는 "1③218" 같은 패턴 모두.
|
||||
const re = /(\d{1,2})\s*[번]?\s*([①②③④⑤])/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(oddOnly)) !== null) {
|
||||
const number = Number(m[1]);
|
||||
const answer = CIRCLED[m[2]];
|
||||
if (number >= 1 && number <= 45 && !seen.has(number)) {
|
||||
seen.set(number, answer);
|
||||
}
|
||||
}
|
||||
|
||||
const entries: AnswerEntry[] = [];
|
||||
for (const [number, answerNumber] of Array.from(seen.entries()).sort((a, b) => a[0] - b[0])) {
|
||||
entries.push({ number, answerNumber });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** 정답표 텍스트에서 짝수형 섹션 제외하고 홀수형만 반환 */
|
||||
function splitBeforeEvenForm(text: string): string {
|
||||
// 짝수형 header 패턴: "( 짝수 ) 형" 또는 "( 짝 수 )" 또는 "짝수형"
|
||||
const evenIdx = text.search(/\(\s*짝수\s*\)\s*형|짝수형/);
|
||||
return evenIdx === -1 ? text : text.slice(0, evenIdx);
|
||||
}
|
||||
|
||||
// ─── 4. 문제지 파서 ──────────────────────────────────────────────────
|
||||
interface ParsedPassage {
|
||||
startNumber: number;
|
||||
endNumber: number;
|
||||
bodyText: string;
|
||||
}
|
||||
|
||||
interface ParsedProblem {
|
||||
number: number;
|
||||
bodyText: string;
|
||||
choices: Record<string, string>;
|
||||
passageStart?: number; // 연결된 passage 의 startNumber
|
||||
needsReview: boolean;
|
||||
reviewReason?: string;
|
||||
}
|
||||
|
||||
interface ParseProblemResult {
|
||||
passages: ParsedPassage[];
|
||||
problems: ParsedProblem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 문제지 PDF 텍스트에서 문항과 공통 지문을 분리.
|
||||
*
|
||||
* 평가원 포맷 기본 가정:
|
||||
* - "1." 로 시작하는 라인이 문항 시작
|
||||
* - 선택지는 ①②③④⑤ 5개
|
||||
* - "[1~3]" 같은 표기가 공통 지문 헤더
|
||||
* - 지문 본문은 [1~3] 바로 앞이 아니라 바로 **뒤** 몇 줄 ("다음 글을 읽고 물음에 답하시오")
|
||||
*/
|
||||
function parseProblemPaper(text: string): ParseProblemResult {
|
||||
// 홀수형만 사용 — 짝수형은 같은 문제의 선택지 순서 변형
|
||||
text = splitBeforeEvenForm(text);
|
||||
|
||||
// 공통 지문 범위 탐지: [1~3], [ 1 ~ 3 ] 등
|
||||
const passageRanges: Array<{ start: number; end: number; matchIdx: number }> = [];
|
||||
const passageRe = /\[\s*(\d+)\s*[~~--]\s*(\d+)\s*\]/g;
|
||||
let pm: RegExpExecArray | null;
|
||||
while ((pm = passageRe.exec(text)) !== null) {
|
||||
passageRanges.push({
|
||||
start: Number(pm[1]),
|
||||
end: Number(pm[2]),
|
||||
matchIdx: pm.index,
|
||||
});
|
||||
}
|
||||
|
||||
// 문항 분할
|
||||
// 문항 시작 정규식: 줄 시작 또는 공백 뒤 "숫자." + 공백
|
||||
// 평가원 PDF 에선 번호가 라인 앞 padding 이 있음
|
||||
const problemRe = /(?:^|\n)[ \t]*(\d{1,2})\.\s+/g;
|
||||
const problemStarts: Array<{ number: number; idx: number }> = [];
|
||||
let prm: RegExpExecArray | null;
|
||||
while ((prm = problemRe.exec(text)) !== null) {
|
||||
const num = Number(prm[1]);
|
||||
if (num >= 1 && num <= 45) {
|
||||
problemStarts.push({ number: num, idx: prm.index });
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 제거 + 번호 단조 증가 유지 (같은 번호 여러 매치 중 첫 번째만)
|
||||
const uniqueStarts: Array<{ number: number; idx: number }> = [];
|
||||
const seenNums = new Set<number>();
|
||||
for (const s of problemStarts) {
|
||||
if (seenNums.has(s.number)) continue;
|
||||
if (uniqueStarts.length > 0 && s.number <= uniqueStarts[uniqueStarts.length - 1].number) {
|
||||
continue; // 번호가 뒤로 가면 무시
|
||||
}
|
||||
uniqueStarts.push(s);
|
||||
seenNums.add(s.number);
|
||||
}
|
||||
|
||||
// passage bodyText 추출: 범위 start 직전에서 해당 번호의 problem start 까지 사이
|
||||
// 대부분 "[1~3] 다음 글을 읽고 물음에 답하시오.\n\n<본문>\n\n1. 뒤의 본문은 질문."
|
||||
// 구현: [N~M] 매칭 이후부터 첫 problem N 까지가 passage body
|
||||
const passages: ParsedPassage[] = [];
|
||||
for (const range of passageRanges) {
|
||||
const firstProblem = uniqueStarts.find((p) => p.number === range.start);
|
||||
if (!firstProblem) continue;
|
||||
const passageBody = text.slice(range.matchIdx + 10, firstProblem.idx).trim();
|
||||
// 너무 짧으면 passage 아님
|
||||
if (passageBody.length < 50) continue;
|
||||
passages.push({
|
||||
startNumber: range.start,
|
||||
endNumber: range.end,
|
||||
bodyText: normalizeWhitespace(passageBody),
|
||||
});
|
||||
}
|
||||
|
||||
// 각 문항의 bodyText 와 choices 추출
|
||||
const problems: ParsedProblem[] = [];
|
||||
for (let i = 0; i < uniqueStarts.length; i++) {
|
||||
const current = uniqueStarts[i];
|
||||
const next = uniqueStarts[i + 1];
|
||||
const spanStart = current.idx;
|
||||
const spanEnd = next ? next.idx : text.length;
|
||||
let span = text.slice(spanStart, spanEnd);
|
||||
|
||||
// 문항 번호 prefix 제거 ("1. ")
|
||||
span = span.replace(/^\s*\d{1,2}\.\s+/, '');
|
||||
|
||||
// 선택지 ①②③④⑤ 위치 찾기
|
||||
const choiceMatches: Array<{ key: string; idx: number }> = [];
|
||||
for (const [c, n] of Object.entries(CIRCLED)) {
|
||||
let idx = span.indexOf(c);
|
||||
while (idx !== -1) {
|
||||
choiceMatches.push({ key: String(n), idx });
|
||||
idx = span.indexOf(c, idx + 1);
|
||||
}
|
||||
}
|
||||
// 각 원문자는 여러 번 나올 수 있지만 선택지로 첫 번째 등장만 채택하고 key 1..5 순서 보장
|
||||
const firstByKey = new Map<string, number>();
|
||||
for (const m of choiceMatches) {
|
||||
if (!firstByKey.has(m.key)) firstByKey.set(m.key, m.idx);
|
||||
}
|
||||
const orderedKeys = ['1', '2', '3', '4', '5'].filter((k) => firstByKey.has(k));
|
||||
|
||||
let bodyText = '';
|
||||
const choices: Record<string, string> = {};
|
||||
|
||||
if (orderedKeys.length === 5) {
|
||||
// bodyText = 첫 ① 이전
|
||||
const firstIdx = firstByKey.get('1')!;
|
||||
bodyText = span.slice(0, firstIdx);
|
||||
// 각 선택지는 해당 원문자 직후부터 다음 원문자 직전까지
|
||||
for (let k = 0; k < orderedKeys.length; k++) {
|
||||
const key = orderedKeys[k];
|
||||
const start = firstByKey.get(key)! + 1;
|
||||
const nextKey = orderedKeys[k + 1];
|
||||
const end = nextKey ? firstByKey.get(nextKey)! : span.length;
|
||||
choices[key] = normalizeWhitespace(span.slice(start, end));
|
||||
}
|
||||
} else {
|
||||
// 선택지 5개 찾지 못함 — bodyText 에 전체 span 저장, needsReview
|
||||
bodyText = span;
|
||||
}
|
||||
|
||||
const reasons: string[] = [];
|
||||
if (bodyText.trim().length < 10) reasons.push('bodyText 10자 미만');
|
||||
if (Object.keys(choices).length !== 5) reasons.push(`choices ${Object.keys(choices).length}/5`);
|
||||
for (const [k, v] of Object.entries(choices)) {
|
||||
if (!v || v.trim().length === 0) reasons.push(`choice ${k} 빈값`);
|
||||
}
|
||||
|
||||
// 연결된 passage 의 start 번호
|
||||
const linkedPassage = passages.find(
|
||||
(p) => current.number >= p.startNumber && current.number <= p.endNumber,
|
||||
);
|
||||
|
||||
problems.push({
|
||||
number: current.number,
|
||||
bodyText: normalizeWhitespace(bodyText),
|
||||
choices,
|
||||
passageStart: linkedPassage?.startNumber,
|
||||
needsReview: reasons.length > 0,
|
||||
reviewReason: reasons.join(', ') || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return { passages, problems };
|
||||
}
|
||||
|
||||
function normalizeWhitespace(s: string): string {
|
||||
return s
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.replace(/^\s+|\s+$/g, '');
|
||||
}
|
||||
|
||||
// ─── 5. 제목 생성 ───────────────────────────────────────────────────
|
||||
function buildProblemSetTitle(year: number, subject: TargetSubject): string {
|
||||
return `${year}학년도 대학수학능력시험 ${subject}`;
|
||||
}
|
||||
|
||||
// ─── 6. DB import ────────────────────────────────────────────────────
|
||||
async function importKiceSet(
|
||||
prisma: PrismaClient,
|
||||
year: number,
|
||||
subject: TargetSubject,
|
||||
files: PdfFile[],
|
||||
audioMp3s: Map<string, string[]>,
|
||||
): Promise<{ problems: number; passages: number; needsReview: number }> {
|
||||
const answerFile = files.find((f) => f.role === 'answer');
|
||||
const problemFile = files.find((f) => f.role === 'problems');
|
||||
|
||||
if (!answerFile || !problemFile) {
|
||||
console.warn(`⚠ [${year} ${subject}] PDF 누락 (answer=${!!answerFile}, problems=${!!problemFile})`);
|
||||
return { problems: 0, passages: 0, needsReview: 0 };
|
||||
}
|
||||
|
||||
// 파싱
|
||||
const answerText = extractText(answerFile.filepath, 'layout');
|
||||
const answers = parseAnswerTable(answerText);
|
||||
const answerMap = new Map(answers.map((a) => [a.number, a.answerNumber]));
|
||||
if (answers.length === 0) {
|
||||
console.warn(
|
||||
`⚠ [${year} ${subject}] 정답표 추출 실패 (이미지 기반 PDF?) — answerNumber 전부 null`,
|
||||
);
|
||||
}
|
||||
|
||||
const problemText = extractText(problemFile.filepath, 'raw');
|
||||
const parsed = parseProblemPaper(problemText);
|
||||
|
||||
// 영어 과목이면 mp3 경로
|
||||
const audioUrls = subject === '영어' ? audioMp3s.get(`${year}/영어`) || null : null;
|
||||
|
||||
// upsert ProblemSet
|
||||
const ps = await prisma.problemSet.upsert({
|
||||
where: {
|
||||
year_examType_subjectName: {
|
||||
year,
|
||||
examType: 'sat',
|
||||
subjectName: subject,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
title: buildProblemSetTitle(year, subject),
|
||||
sourceUrl: 'https://www.suneung.re.kr/',
|
||||
audioUrls: audioUrls as Prisma.InputJsonValue | null | undefined,
|
||||
},
|
||||
create: {
|
||||
title: buildProblemSetTitle(year, subject),
|
||||
examType: 'sat',
|
||||
year,
|
||||
subjectName: subject,
|
||||
sourceUrl: 'https://www.suneung.re.kr/',
|
||||
audioUrls: audioUrls as Prisma.InputJsonValue | null | undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Passage upsert — 기존 것 모두 삭제 후 재삽입 (멱등성 + passageStart 매핑 단순화)
|
||||
await prisma.passage.deleteMany({ where: { problemSetId: ps.id } });
|
||||
const createdPassages = await Promise.all(
|
||||
parsed.passages.map((p) =>
|
||||
prisma.passage.create({
|
||||
data: {
|
||||
problemSetId: ps.id,
|
||||
startNumber: p.startNumber,
|
||||
endNumber: p.endNumber,
|
||||
bodyText: p.bodyText,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const passageIdByStart = new Map(
|
||||
createdPassages.map((p) => [p.startNumber, p.id]),
|
||||
);
|
||||
|
||||
// Problem upsert
|
||||
let needsReviewCount = 0;
|
||||
for (const p of parsed.problems) {
|
||||
const passageId = p.passageStart
|
||||
? passageIdByStart.get(p.passageStart) || null
|
||||
: null;
|
||||
const answerNumber = answerMap.get(p.number) || null;
|
||||
const nr = p.needsReview || !answerNumber;
|
||||
if (nr) needsReviewCount++;
|
||||
|
||||
await prisma.problem.upsert({
|
||||
where: {
|
||||
problemSetId_number: { problemSetId: ps.id, number: p.number },
|
||||
},
|
||||
update: {
|
||||
bodyText: p.bodyText,
|
||||
choices: p.choices as Prisma.InputJsonValue,
|
||||
answerNumber,
|
||||
passageId,
|
||||
needsReview: nr,
|
||||
},
|
||||
create: {
|
||||
problemSetId: ps.id,
|
||||
number: p.number,
|
||||
title: `${buildProblemSetTitle(year, subject)} ${p.number}번`,
|
||||
difficulty: 0.5, // 기본값 — Phase 8 에서 실제 정답률로 보정
|
||||
bodyText: p.bodyText,
|
||||
choices: p.choices as Prisma.InputJsonValue,
|
||||
answerNumber,
|
||||
passageId,
|
||||
needsReview: nr,
|
||||
},
|
||||
});
|
||||
|
||||
if (p.needsReview) {
|
||||
console.warn(
|
||||
` ⚠ [${year} ${subject}] Problem ${p.number} needsReview: ${p.reviewReason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
problems: parsed.problems.length,
|
||||
passages: parsed.passages.length,
|
||||
needsReview: needsReviewCount,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 7. main ─────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
const opts = parseArgs();
|
||||
console.log('🏫 KICE import 시작', opts);
|
||||
|
||||
const scan = scanKiceData({ year: opts.year, subject: opts.subject });
|
||||
console.log(`스캔: ${scan.files.length} files, ${scan.audioMp3s.size} audio sets`);
|
||||
|
||||
if (opts.dryRun) {
|
||||
console.log('\n=== 파일 목록 (dry-run) ===');
|
||||
for (const f of scan.files) {
|
||||
console.log(` ${f.year} / ${f.subject} / ${f.role.padEnd(14)} — ${f.sizeKB}KB — ${path.basename(f.filepath)}`);
|
||||
}
|
||||
for (const [key, mp3s] of scan.audioMp3s) {
|
||||
console.log(` audio ${key}: ${mp3s.length} mp3 files`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.only === 'answer') {
|
||||
console.log('\n=== 정답표 파서 테스트 ===');
|
||||
for (const f of scan.files.filter((f) => f.role === 'answer')) {
|
||||
const text = extractText(f.filepath);
|
||||
const answers = parseAnswerTable(text);
|
||||
console.log(` ${f.year} / ${f.subject}: ${answers.length} 문항`);
|
||||
if (opts.sample) {
|
||||
for (const a of answers.slice(0, 5)) {
|
||||
console.log(` ${a.number}번 → ${a.answerNumber}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.only === 'problems') {
|
||||
console.log('\n=== 문제지 파서 테스트 ===');
|
||||
for (const f of scan.files.filter((f) => f.role === 'problems')) {
|
||||
const text = extractText(f.filepath, 'raw');
|
||||
const parsed = parseProblemPaper(text);
|
||||
console.log(
|
||||
` ${f.year} / ${f.subject}: ${parsed.problems.length} problems, ${parsed.passages.length} passages`,
|
||||
);
|
||||
const badProblems = parsed.problems.filter((p) => p.needsReview);
|
||||
if (badProblems.length > 0) {
|
||||
console.log(` ⚠ needsReview: ${badProblems.length}`);
|
||||
for (const p of badProblems.slice(0, 5)) {
|
||||
console.log(` #${p.number}: ${p.reviewReason}`);
|
||||
}
|
||||
}
|
||||
if (opts.sample) {
|
||||
const sample = parsed.problems[0];
|
||||
if (sample) {
|
||||
console.log(` sample #${sample.number}: "${sample.bodyText.slice(0, 80)}..."`);
|
||||
console.log(` choices: ${Object.keys(sample.choices).length} keys`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 실제 DB import
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
let totalProblems = 0;
|
||||
let totalPassages = 0;
|
||||
let totalNeedsReview = 0;
|
||||
for (const year of TARGET_YEARS) {
|
||||
if (opts.year && opts.year !== year) continue;
|
||||
for (const subject of TARGET_SUBJECTS) {
|
||||
if (opts.subject && opts.subject !== subject) continue;
|
||||
const files = scan.files.filter((f) => f.year === year && f.subject === subject);
|
||||
if (files.length === 0) continue;
|
||||
const result = await importKiceSet(prisma, year, subject, files, scan.audioMp3s);
|
||||
console.log(
|
||||
`✓ ${year} ${subject}: ${result.problems} problems, ${result.passages} passages, ${result.needsReview} needsReview`,
|
||||
);
|
||||
totalProblems += result.problems;
|
||||
totalPassages += result.passages;
|
||||
totalNeedsReview += result.needsReview;
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`\n🎯 합계: ${totalProblems} problems, ${totalPassages} passages, ${totalNeedsReview} needsReview`,
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user