Files
reloop-v2/backend/scripts/kice-import.ts
reloop d892d8298b feat(phase10A): KICE 비수학 제거 + 수학 단원 분리
- schema: MathUnit enum (common/prob_stat/calculus/geometry)
- User: focusUnits(Json), bojHandle(VarChar32)
- ProblemSet: mathUnit optional + unique(year,examType,subjectName,mathUnit)
- migration: phase10_math_units_and_ps
- scripts: wipe-non-math (dry/apply), kice-import refactor (math only,
  30문항을 공통22 + 확통/미적/기하 8씩으로 분할 → 2년×4 = 8 ProblemSet)
- parsing strategy/ocr-fix/seed/me/study-logs: 비수학 참조 전면 제거
- data/kice, backend/uploads/problems: 비수학 폴더 삭제
- Plans.md: Phase 10 계획 확정

Phase 10A DoD 통과 — prisma validate/format OK, nest build OK,
kice-import dry-run 2년 각 46문항 확인, wipe dry-run 8 set/260 problems 확인.
2026-04-14 18:27:00 +09:00

489 lines
15 KiB
TypeScript

/**
* KICE (한국교육과정평가원) 수능 기출 PDF import 스크립트.
*/
import * as fs from 'fs';
import * as path from 'path';
import { PrismaClient, Prisma, MathUnit } from '@prisma/client';
import { 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];
const SOURCE_URL = 'https://www.suneung.re.kr/';
const EXPECTED_PROBLEM_COUNT: Record<TargetSubject, number> = {
수학: 30,
};
const MATH_UNIT_CONFIGS: Array<{
unit: MathUnit;
displayName: string;
range: { start: number; end: number };
}> = [
{ unit: MathUnit.common, displayName: '공통', range: { start: 1, end: 22 } },
{ unit: MathUnit.prob_stat, displayName: '확률과 통계', range: { start: 23, end: 30 } },
{ unit: MathUnit.calculus, displayName: '미적분', range: { start: 23, end: 30 } },
{ unit: MathUnit.geometry, displayName: '기하', range: { start: 23, end: 30 } },
];
const ELECTIVE_PROBLEM_RANGE = { start: 23, end: 30 };
interface PdfFile {
year: number;
subject: TargetSubject;
role: 'problems' | 'answer' | 'audio-script';
filepath: string;
sizeKB: number;
}
interface ScanResult {
files: PdfFile[];
}
interface MathExamBundle {
parsed: ParseResult;
answerMap: Map<number, number>;
splits: MathUnitSplit[];
}
interface MathUnitSplit {
unit: MathUnit;
title: string;
problems: ParseResult['problems'];
passages: ParseResult['passages'];
usesFallback: boolean;
}
interface MathUnitSummary {
unit: MathUnit;
problems: number;
passages: number;
needsReview: number;
}
function scanKiceData(filter: { year?: number; subject?: string }): ScanResult {
const files: PdfFile[] = [];
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;
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$/);
if (problemFile) {
files.push(buildPdfFile(year, subject, 'problems', problemFile));
}
if (answerFile) {
files.push(buildPdfFile(year, subject, 'answer', answerFile));
}
}
}
return { files };
}
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 buildMathProblemSetTitle(year: number, displayName: string): string {
return `${year}학년도 수능 수학 (${displayName})`;
}
async function parseMathExamBundle(
year: number,
subject: TargetSubject,
files: PdfFile[],
): Promise<MathExamBundle | null> {
const answerFile = files.find((file) => file.role === 'answer');
const problemFile = files.find((file) => file.role === 'problems');
if (!problemFile) {
console.warn(`⚠ [${year} ${subject}] 문제지 PDF 누락`);
return null;
}
const parsed = await parseSubjectFiles(year, subject, problemFile.filepath, answerFile?.filepath);
const answerMap = new Map(parsed.answers.map((answer) => [answer.number, answer.answerNumber]));
const splits = splitMathProblemSets(year, parsed);
return { parsed, answerMap, splits };
}
function splitMathProblemSets(year: number, parsed: ParseResult): MathUnitSplit[] {
return MATH_UNIT_CONFIGS.map((config) => {
let problems = filterProblemsByRange(parsed.problems, config.range.start, config.range.end);
let passages = filterPassagesByRange(parsed.passages, config.range.start, config.range.end);
let usesFallback = false;
if (config.unit !== MathUnit.common && problems.length === 0) {
problems = filterProblemsByRange(
parsed.problems,
ELECTIVE_PROBLEM_RANGE.start,
ELECTIVE_PROBLEM_RANGE.end,
);
passages = filterPassagesByRange(
parsed.passages,
ELECTIVE_PROBLEM_RANGE.start,
ELECTIVE_PROBLEM_RANGE.end,
);
usesFallback = true;
// TODO: 선택과목 PDF 가 분리되면 파일명 기반으로 단원을 정확히 매핑하도록 개선한다.
}
return {
unit: config.unit,
title: buildMathProblemSetTitle(year, config.displayName),
problems,
passages,
usesFallback,
};
});
}
function filterProblemsByRange(
problems: ParseResult['problems'],
start: number,
end: number,
): ParseResult['problems'] {
return problems.filter((problem) => problem.number >= start && problem.number <= end);
}
function filterPassagesByRange(
passages: ParseResult['passages'],
start: number,
end: number,
): ParseResult['passages'] {
return passages.filter((passage) => passage.endNumber >= start && passage.startNumber <= end);
}
async function importKiceMathSet(
prisma: PrismaClient,
year: number,
subject: TargetSubject,
files: PdfFile[],
): Promise<MathUnitSummary[] | null> {
const bundle = await parseMathExamBundle(year, subject, files);
if (!bundle) {
return null;
}
for (const warning of bundle.parsed.warnings) {
console.warn(` ⚠ [${year} ${subject}] ${warning}`);
}
const summaries: MathUnitSummary[] = [];
for (const split of bundle.splits) {
const summary = await upsertMathUnitProblemSet(prisma, year, subject, split, bundle.answerMap);
summaries.push(summary);
}
return summaries;
}
async function upsertMathUnitProblemSet(
prisma: PrismaClient,
year: number,
subject: TargetSubject,
split: MathUnitSplit,
answerMap: Map<number, number>,
): Promise<MathUnitSummary> {
const problemSet = await prisma.problemSet.upsert({
where: {
year_examType_subjectName_mathUnit: {
year,
examType: 'sat',
subjectName: subject,
mathUnit: split.unit,
},
},
update: {
title: split.title,
sourceUrl: SOURCE_URL,
mathUnit: split.unit,
},
create: {
title: split.title,
examType: 'sat',
year,
subjectName: subject,
mathUnit: split.unit,
sourceUrl: SOURCE_URL,
},
});
await prisma.passage.deleteMany({ where: { problemSetId: problemSet.id } });
const createdPassages = await Promise.all(
split.passages.map((passage) =>
prisma.passage.create({
data: {
problemSetId: problemSet.id,
startNumber: passage.startNumber,
endNumber: passage.endNumber,
bodyText: passage.bodyText,
imageUrl: passage.imageUrl ?? null,
},
}),
),
);
const passageIdByStart = new Map(createdPassages.map((passage) => [passage.startNumber, passage.id]));
let needsReviewCount = 0;
for (const problem of split.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 += 1;
}
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: `${split.title} ${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 {
unit: split.unit,
problems: split.problems.length,
passages: split.passages.length,
needsReview: needsReviewCount,
};
}
async function inspectParseResult(
year: number,
subject: TargetSubject,
files: PdfFile[],
mode: 'answer' | 'problems',
sample?: number,
) {
const bundle = await parseMathExamBundle(year, subject, files);
if (!bundle) return;
const parsed = bundle.parsed;
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 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}`,
});
}
function formatMathSummaryLine(year: number, summaries: MathUnitSummary[]): string {
const parts = summaries.map((summary) => `${summary.unit} ${summary.problems}`);
const total = summaries.reduce((sum, summary) => sum + summary.problems, 0);
return `year ${year}: ${parts.join(' / ')} = ${total} 문항`;
}
async function runDryRun(scan: ScanResult, options: CliOptions) {
console.log('\n=== Dry-run (math unit split) ===');
let printed = false;
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 bundle = await parseMathExamBundle(year, subject, files);
if (!bundle) continue;
const summaries = bundle.splits.map((split) => ({
unit: split.unit,
problems: split.problems.length,
passages: split.passages.length,
needsReview: split.problems.filter((problem) => problem.needsReview).length,
}));
console.log(formatMathSummaryLine(year, summaries));
printed = true;
}
}
if (!printed) {
console.log('No matching math problem sets found.');
}
}
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`);
if (options.dryRun) {
await runDryRun(scan, options);
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 summaries = await importKiceMathSet(prisma, year, subject, files);
if (!summaries) continue;
console.log(`${formatMathSummaryLine(year, summaries)}`);
for (const summary of summaries) {
totalProblems += summary.problems;
totalPassages += summary.passages;
totalNeedsReview += summary.needsReview;
}
}
}
console.log(`\n🎯 합계: ${totalProblems} problems, ${totalPassages} passages, ${totalNeedsReview} needsReview`);
} finally {
await prisma.$disconnect();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});