Files
reloop-v2/backend/scripts/kice-import.ts

423 lines
14 KiB
TypeScript

/**
* KICE (한국교육과정평가원) 수능 기출 PDF import 스크립트.
*/
import * as fs from 'fs';
import * as path from 'path';
import { PrismaClient, Prisma } from '@prisma/client';
import { parseExamPaper, parseImageBasedExam, ParseResult } from '../src/problem-sets/parsing';
interface CliOptions {
dryRun: boolean;
year?: number;
subject?: string;
only?: 'answer' | 'problems' | 'all';
sample?: number;
}
function parseArgs(): CliOptions {
const args = process.argv.slice(2);
const options: CliOptions = { dryRun: false, only: 'all' };
for (const arg of args) {
if (arg === '--dry-run') options.dryRun = true;
else if (arg.startsWith('--year=')) options.year = Number(arg.split('=')[1]);
else if (arg.startsWith('--subject=')) options.subject = arg.split('=')[1];
else if (arg.startsWith('--only=')) options.only = arg.split('=')[1] as CliOptions['only'];
else if (arg.startsWith('--sample=')) options.sample = Number(arg.split('=')[1]);
}
return options;
}
const DATA_DIR = path.resolve(__dirname, '../../data/kice');
const PROBLEM_UPLOAD_DIR = path.resolve(__dirname, '../uploads/problems');
const TARGET_YEARS = [2025, 2026];
const TARGET_SUBJECTS = ['국어', '영어', '한국사', '생활과 윤리', '수학'] as const;
type TargetSubject = (typeof TARGET_SUBJECTS)[number];
type SubjectImportFormat = 'text' | 'image';
const EXPECTED_PROBLEM_COUNT: Record<TargetSubject, number> = {
국어: 45,
영어: 45,
한국사: 20,
'생활과 윤리': 20,
수학: 30,
};
const SUBJECT_IMPORT_FORMAT: Record<TargetSubject, SubjectImportFormat> = {
: 'text',
: 'text',
: 'text',
'생활과 윤리': 'text',
: 'image',
};
interface PdfFile {
year: number;
subject: TargetSubject;
role: 'problems' | 'answer' | 'audio-script';
filepath: string;
sizeKB: number;
}
interface ScanResult {
files: PdfFile[];
audioMp3s: Map<string, string[]>;
}
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 === '생활과 윤리') {
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(buildPdfFile(year, subject, 'problems', problemFile));
}
if (answerFile) {
files.push(buildPdfFile(year, subject, 'answer', answerFile));
}
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$/) ||
findFile(subjectDir, /_문제\.pdf$/);
const answerFile = findFile(subjectDir, /_정답표?\.pdf$/);
const scriptFile = findFile(subjectDir, /_듣기평가대본\.pdf$/);
if (problemFile) {
files.push(buildPdfFile(year, subject, 'problems', problemFile));
}
if (answerFile) {
files.push(buildPdfFile(year, subject, 'answer', answerFile));
}
if (scriptFile) {
files.push(buildPdfFile(year, subject, 'audio-script', scriptFile));
}
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((entry) => entry.endsWith('.mp3'))
.sort()
.map((entry) => path.relative(DATA_DIR, path.join(audioDir, entry)));
if (mp3s.length > 0) {
audioMp3s.set(`${year}/영어`, mp3s);
}
}
}
}
}
return { files, audioMp3s };
}
function buildPdfFile(
year: number,
subject: TargetSubject,
role: PdfFile['role'],
filepath: string,
): PdfFile {
return {
year,
subject,
role,
filepath,
sizeKB: Math.round(fs.statSync(filepath).size / 1024),
};
}
function findFile(dir: string, pattern: RegExp): string | null {
if (!fs.existsSync(dir)) return null;
const match = fs.readdirSync(dir).find((entry) => pattern.test(entry));
return match ? path.join(dir, match) : null;
}
function buildProblemSetTitle(year: number, subject: TargetSubject): string {
return `${year}학년도 대학수학능력시험 ${subject}`;
}
async function importKiceSet(
prisma: PrismaClient,
year: number,
subject: TargetSubject,
files: PdfFile[],
audioMp3s: Map<string, string[]>,
): Promise<{ problems: number; passages: number; needsReview: number; warnings: string[] }> {
const answerFile = files.find((file) => file.role === 'answer');
const problemFile = files.find((file) => file.role === 'problems');
if (!problemFile) {
console.warn(`⚠ [${year} ${subject}] 문제지 PDF 누락`);
return { problems: 0, passages: 0, needsReview: 0, warnings: ['missing problem PDF'] };
}
const parsed = await parseSubjectFiles(year, subject, problemFile.filepath, answerFile?.filepath);
const answerMap = new Map(parsed.answers.map((answer) => [answer.number, answer.answerNumber]));
if (answerFile && parsed.answers.length === 0) {
console.warn(`⚠ [${year} ${subject}] 정답표 추출 실패 (이미지 기반 PDF?) — answerNumber 전부 null`);
}
for (const warning of parsed.warnings) {
console.warn(` ⚠ [${year} ${subject}] ${warning}`);
}
const audioUrls = subject === '영어' ? audioMp3s.get(`${year}/영어`) || null : null;
const problemSet = 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,
},
});
await prisma.passage.deleteMany({ where: { problemSetId: problemSet.id } });
const createdPassages = await Promise.all(
parsed.passages.map((passage) =>
prisma.passage.create({
data: {
problemSetId: problemSet.id,
startNumber: passage.startNumber,
endNumber: passage.endNumber,
bodyText: passage.bodyText,
},
}),
),
);
const passageIdByStart = new Map(createdPassages.map((passage) => [passage.startNumber, passage.id]));
let needsReviewCount = 0;
for (const problem of parsed.problems) {
const passageId = problem.passageStart
? passageIdByStart.get(problem.passageStart) || null
: null;
const answerNumber = answerMap.get(problem.number) || null;
const needsReview = problem.needsReview || !answerNumber;
if (needsReview) {
needsReviewCount++;
}
await prisma.problem.upsert({
where: {
problemSetId_number: { problemSetId: problemSet.id, number: problem.number },
},
update: {
bodyText: problem.bodyText,
choices: problem.choices as Prisma.InputJsonValue,
answerNumber,
passageId,
imageUrl: problem.imageUrl ?? null,
pageImageUrl: problem.pageImageUrl ?? null,
needsReview,
},
create: {
problemSetId: problemSet.id,
number: problem.number,
title: `${buildProblemSetTitle(year, subject)} ${problem.number}`,
difficulty: 0.5,
bodyText: problem.bodyText,
choices: problem.choices as Prisma.InputJsonValue,
answerNumber,
passageId,
imageUrl: problem.imageUrl ?? null,
pageImageUrl: problem.pageImageUrl ?? null,
needsReview,
},
});
if (problem.needsReview) {
console.warn(
` ⚠ [${year} ${subject}] Problem ${problem.number} needsReview: ${problem.needsReviewReasons.join(', ')}`,
);
}
}
return {
problems: parsed.problems.length,
passages: parsed.passages.length,
needsReview: needsReviewCount,
warnings: parsed.warnings,
};
}
async function inspectParseResult(
year: number,
subject: TargetSubject,
files: PdfFile[],
mode: 'answer' | 'problems',
sample?: number,
) {
const answerFile = files.find((file) => file.role === 'answer');
const problemFile = files.find((file) => file.role === 'problems');
if (!problemFile) {
console.warn(`⚠ [${year} ${subject}] 문제지 PDF 누락`);
return;
}
const parsed = await parseSubjectFiles(year, subject, problemFile.filepath, answerFile?.filepath);
if (mode === 'answer') {
console.log(` ${year} / ${subject}: ${parsed.answers.length} 문항`);
for (const answer of parsed.answers.slice(0, sample ? 5 : 0)) {
console.log(` ${answer.number}번 → ${answer.answerNumber}`);
}
return;
}
console.log(` ${year} / ${subject}: ${parsed.problems.length} problems, ${parsed.passages.length} passages`);
const badProblems = parsed.problems.filter((problem) => problem.needsReview);
if (badProblems.length > 0) {
console.log(` ⚠ needsReview: ${badProblems.length}`);
for (const problem of badProblems.slice(0, 5)) {
console.log(` #${problem.number}: ${problem.needsReviewReasons.join(', ')}`);
}
}
if (sample) {
const first = parsed.problems[0];
if (first) {
console.log(` sample #${first.number}: "${first.bodyText.slice(0, 80)}..."`);
console.log(` choices: ${Object.keys(first.choices).length} keys`);
}
}
}
async function parseSubjectFiles(
year: number,
subject: TargetSubject,
paperPdfPath: string,
answerPdfPath?: string,
): Promise<ParseResult> {
const expectedProblemCount = EXPECTED_PROBLEM_COUNT[subject];
const format = SUBJECT_IMPORT_FORMAT[subject];
if (format === 'image') {
const renderedImageDir = path.join(PROBLEM_UPLOAD_DIR, String(year), subject);
fs.mkdirSync(renderedImageDir, { recursive: true });
return parseImageBasedExam({
paperPdfPath,
answerPdfPath,
format: 'kice-math',
expectedProblemCount,
renderedImageDir,
renderedImageBaseUrl: `/uploads/problems/${year}/${subject}`,
});
}
return parseExamPaper({
paperPdfPath,
answerPdfPath,
format: 'kice',
expectedProblemCount,
});
}
async function main() {
const options = parseArgs();
console.log('🏫 KICE import 시작', options);
const scan = scanKiceData({ year: options.year, subject: options.subject });
console.log(`스캔: ${scan.files.length} files, ${scan.audioMp3s.size} audio sets`);
if (options.dryRun) {
console.log('\n=== 파일 목록 (dry-run) ===');
for (const file of scan.files) {
console.log(` ${file.year} / ${file.subject} / ${file.role.padEnd(14)}${file.sizeKB}KB — ${path.basename(file.filepath)}`);
}
for (const [key, mp3s] of scan.audioMp3s) {
console.log(` audio ${key}: ${mp3s.length} mp3 files`);
}
return;
}
if (options.only === 'answer' || options.only === 'problems') {
console.log(options.only === 'answer' ? '\n=== 정답표 파서 테스트 ===' : '\n=== 문제지 파서 테스트 ===');
for (const year of TARGET_YEARS) {
if (options.year && options.year !== year) continue;
for (const subject of TARGET_SUBJECTS) {
if (options.subject && options.subject !== subject) continue;
const files = scan.files.filter((file) => file.year === year && file.subject === subject);
if (files.length === 0) continue;
await inspectParseResult(year, subject, files, options.only, options.sample);
}
}
return;
}
const prisma = new PrismaClient();
try {
let totalProblems = 0;
let totalPassages = 0;
let totalNeedsReview = 0;
for (const year of TARGET_YEARS) {
if (options.year && options.year !== year) continue;
for (const subject of TARGET_SUBJECTS) {
if (options.subject && options.subject !== subject) continue;
const files = scan.files.filter((file) => file.year === year && file.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((error) => {
console.error(error);
process.exit(1);
});