feat(parse): math + image-based problem pipeline — 7H.1
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -20,3 +20,7 @@ backend/dist
|
||||
|
||||
# KICE PDFs (저작권 자료 — repo 에 커밋 금지, mailu-dev 에 직접 배치)
|
||||
data/kice/
|
||||
backend/uploads/avatars/**
|
||||
backend/uploads/problems/**
|
||||
!backend/uploads/avatars/.gitkeep
|
||||
!backend/uploads/problems/.gitkeep
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `problems` ADD COLUMN `imageUrl` VARCHAR(191) NULL,
|
||||
ADD COLUMN `pageImageUrl` VARCHAR(191) NULL;
|
||||
@@ -193,6 +193,10 @@ model Problem {
|
||||
bodyText String? @db.Text
|
||||
/// KICE 파싱 결과 — 선택지 객체 { "1": "...", "2": "...", "3": "...", "4": "...", "5": "..." }
|
||||
choices Json?
|
||||
/// 렌더링된 문제 이미지 URL (수학/도표형 문제 대응)
|
||||
imageUrl String?
|
||||
/// 렌더링된 원본 페이지 이미지 URL
|
||||
pageImageUrl String?
|
||||
/// KICE 파싱 결과 — 정답 번호 1..5
|
||||
answerNumber Int?
|
||||
/// 공통 지문 FK (국어 독서/문학, 영어 지문, 사탐 자료 등)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
import { parseExamPaper } from '../src/problem-sets/parsing';
|
||||
import { parseExamPaper, parseImageBasedExam, ParseResult } from '../src/problem-sets/parsing';
|
||||
|
||||
interface CliOptions {
|
||||
dryRun: boolean;
|
||||
@@ -31,15 +31,26 @@ function parseArgs(): CliOptions {
|
||||
}
|
||||
|
||||
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;
|
||||
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 {
|
||||
@@ -84,7 +95,8 @@ function scanKiceData(filter: { year?: number; subject?: string }): ScanResult {
|
||||
if (!fs.existsSync(subjectDir)) continue;
|
||||
|
||||
const problemFile =
|
||||
findFile(subjectDir, /_문제지(_홀수형)?\.pdf$/) ||
|
||||
findFile(subjectDir, /_문제지_홀수형\.pdf$/) ||
|
||||
findFile(subjectDir, /_문제지\.pdf$/) ||
|
||||
findFile(subjectDir, /_문제지_짝수형\.pdf$/) ||
|
||||
findFile(subjectDir, /_문제\.pdf$/);
|
||||
const answerFile = findFile(subjectDir, /_정답표?\.pdf$/);
|
||||
@@ -166,12 +178,7 @@ async function importKiceSet(
|
||||
return { problems: 0, passages: 0, needsReview: 0, warnings: ['missing problem PDF'] };
|
||||
}
|
||||
|
||||
const parsed = await parseExamPaper({
|
||||
paperPdfPath: problemFile.filepath,
|
||||
answerPdfPath: answerFile?.filepath,
|
||||
format: 'kice',
|
||||
expectedProblemCount: EXPECTED_PROBLEM_COUNT[subject],
|
||||
});
|
||||
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) {
|
||||
@@ -241,6 +248,8 @@ async function importKiceSet(
|
||||
choices: problem.choices as Prisma.InputJsonValue,
|
||||
answerNumber,
|
||||
passageId,
|
||||
imageUrl: problem.imageUrl ?? null,
|
||||
pageImageUrl: problem.pageImageUrl ?? null,
|
||||
needsReview,
|
||||
},
|
||||
create: {
|
||||
@@ -252,6 +261,8 @@ async function importKiceSet(
|
||||
choices: problem.choices as Prisma.InputJsonValue,
|
||||
answerNumber,
|
||||
passageId,
|
||||
imageUrl: problem.imageUrl ?? null,
|
||||
pageImageUrl: problem.pageImageUrl ?? null,
|
||||
needsReview,
|
||||
},
|
||||
});
|
||||
@@ -286,12 +297,7 @@ async function inspectParseResult(
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = await parseExamPaper({
|
||||
paperPdfPath: problemFile.filepath,
|
||||
answerPdfPath: answerFile?.filepath,
|
||||
format: 'kice',
|
||||
expectedProblemCount: EXPECTED_PROBLEM_COUNT[subject],
|
||||
});
|
||||
const parsed = await parseSubjectFiles(year, subject, problemFile.filepath, answerFile?.filepath);
|
||||
|
||||
if (mode === 'answer') {
|
||||
console.log(` ${year} / ${subject}: ${parsed.answers.length} 문항`);
|
||||
@@ -318,6 +324,37 @@ async function inspectParseResult(
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { execFileSync } from 'child_process';
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
import { ocrAnswerTable, ocrProblemPage } from '../src/problem-sets/parsing/ocr-fallback';
|
||||
|
||||
type TargetSubject = '국어' | '영어' | '한국사' | '생활과 윤리';
|
||||
type TargetSubject = '국어' | '영어' | '한국사' | '생활과 윤리' | '수학';
|
||||
type ChoiceKey = '1' | '2' | '3' | '4' | '5';
|
||||
|
||||
interface CliOptions {
|
||||
@@ -40,7 +40,7 @@ interface SetSummary {
|
||||
interface AnswerUpdate {
|
||||
problemId: number;
|
||||
number: number;
|
||||
answerNumber: 1 | 2 | 3 | 4 | 5;
|
||||
answerNumber: number;
|
||||
needsReview: boolean;
|
||||
shouldWrite: boolean;
|
||||
}
|
||||
@@ -69,12 +69,13 @@ interface ProblemCreate {
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const DATA_DIR = path.resolve(__dirname, '../../data/kice');
|
||||
const TARGET_SUBJECTS: TargetSubject[] = ['국어', '영어', '한국사', '생활과 윤리'];
|
||||
const TARGET_SUBJECTS: TargetSubject[] = ['국어', '영어', '한국사', '생활과 윤리', '수학'];
|
||||
const EXPECTED_PROBLEM_COUNT: Record<TargetSubject, number> = {
|
||||
국어: 45,
|
||||
영어: 45,
|
||||
한국사: 20,
|
||||
'생활과 윤리': 20,
|
||||
수학: 30,
|
||||
};
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -570,8 +571,8 @@ function computeNeedsReview(
|
||||
return false;
|
||||
}
|
||||
|
||||
function coerceAnswerNumber(value: number | null): 1 | 2 | 3 | 4 | 5 | null {
|
||||
return value === 1 || value === 2 || value === 3 || value === 4 || value === 5 ? value : null;
|
||||
function coerceAnswerNumber(value: number | null): number | null {
|
||||
return typeof value === 'number' && Number.isInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
function truncate(value: string, length: number): string {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { extractText } from './extract-text';
|
||||
import { parseAnswerTable } from './parse-answer-table';
|
||||
import { parseImageBasedExam } from './parse-image-based-exam';
|
||||
import { parseProblemPaper } from './parse-problem-paper';
|
||||
import { kiceStrategy } from './strategies/kice';
|
||||
import { kiceMathStrategy } from './strategies/math';
|
||||
import { ParseOptions, ParseResult, PageStripStrategy } from './types';
|
||||
|
||||
const genericStrategy: PageStripStrategy = {
|
||||
@@ -56,6 +58,8 @@ export function resolveParseStrategy(
|
||||
switch (format) {
|
||||
case 'kice':
|
||||
return kiceStrategy;
|
||||
case 'kice-math':
|
||||
return kiceMathStrategy;
|
||||
case 'ebs':
|
||||
return ebsStrategy;
|
||||
case 'generic':
|
||||
@@ -66,6 +70,7 @@ export function resolveParseStrategy(
|
||||
|
||||
export * from './types';
|
||||
export { extractText } from './extract-text';
|
||||
export { parseImageBasedExam } from './parse-image-based-exam';
|
||||
export { parseAnswerTable } from './parse-answer-table';
|
||||
export { parseProblemPaper } from './parse-problem-paper';
|
||||
export { stripPageChrome } from './strip-page-chrome';
|
||||
|
||||
@@ -11,7 +11,7 @@ type ChoiceKey = '1' | '2' | '3' | '4' | '5';
|
||||
|
||||
export interface OcrAnswer {
|
||||
number: number;
|
||||
answerNumber: 1 | 2 | 3 | 4 | 5;
|
||||
answerNumber: number;
|
||||
}
|
||||
|
||||
export interface OcrProblem {
|
||||
@@ -160,10 +160,10 @@ function toInteger(value: unknown): number | null {
|
||||
}
|
||||
|
||||
function toAnswerNumber(value: unknown): OcrAnswer['answerNumber'] | null {
|
||||
if (value === 1 || value === 2 || value === 3 || value === 4 || value === 5) {
|
||||
return value;
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export { invokeCodexVision, parseJsonFromResponse, renderPdfPageToPng };
|
||||
|
||||
@@ -5,16 +5,18 @@ Output format (STRICT JSON, no markdown, no commentary):
|
||||
{
|
||||
"answers": [
|
||||
{ "number": 1, "answerNumber": 3 },
|
||||
{ "number": 2, "answerNumber": 5 }
|
||||
{ "number": 16, "answerNumber": 9 }
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Numbers 1-45 are possible; include only numbers that actually appear in the table
|
||||
- Convert ①②③④⑤ to 1/2/3/4/5 respectively
|
||||
- For numeric-response rows, return the integer exactly as printed
|
||||
- Ignore rows with no clear answer marker
|
||||
- Do not invent numbers or answers
|
||||
- The answer sheet may show both 홀수형 and 짝수형; extract ONLY the 홀수형 column if both are present
|
||||
- If multiple 선택 과목 columns are present for the same problem number, extract ONLY the leftmost elective column
|
||||
- If only one form is shown, extract all visible rows
|
||||
- Return only the JSON object
|
||||
`.trim();
|
||||
|
||||
@@ -24,11 +24,17 @@ export async function renderPdfPageToPng(
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
if (fs.existsSync(outputPath)) {
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
const generatedPath = findGeneratedPng(outPrefix);
|
||||
if (!generatedPath) {
|
||||
cleanupGeneratedFiles(outPrefix);
|
||||
throw new Error(`pdftoppm did not produce expected file: ${outputPath}`);
|
||||
}
|
||||
|
||||
fs.renameSync(generatedPath, outputPath);
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
@@ -51,3 +57,19 @@ function cleanupGeneratedFiles(outPrefix: string): void {
|
||||
fs.rmSync(path.join(dir, entry), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function findGeneratedPng(outPrefix: string): string | null {
|
||||
const dir = path.dirname(outPrefix);
|
||||
const base = path.basename(outPrefix);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matches = fs
|
||||
.readdirSync(dir)
|
||||
.filter((entry) => entry.startsWith(`${base}-`) && entry.endsWith('.png'))
|
||||
.sort();
|
||||
|
||||
return matches[0] ? path.join(dir, matches[0]) : null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ParseAnswerTableResult, PageStripStrategy } from './types';
|
||||
|
||||
const CIRCLED_TO_NUMBER: Record<string, 1 | 2 | 3 | 4 | 5> = {
|
||||
const CIRCLED_TO_NUMBER: Record<string, number> = {
|
||||
'①': 1,
|
||||
'②': 2,
|
||||
'③': 3,
|
||||
@@ -14,18 +14,24 @@ export function parseAnswerTable(
|
||||
): ParseAnswerTableResult {
|
||||
const warnings: string[] = [];
|
||||
const scoped = splitBeforeEvenForm(text, strategy);
|
||||
const pattern = strategy.answerNumberPattern ?? /(\d{1,2})\s*[번]?\s*([①②③④⑤])/g;
|
||||
const seen = new Map<number, 1 | 2 | 3 | 4 | 5>();
|
||||
|
||||
if (strategy.format === 'kice-math') {
|
||||
return parseMathAnswerTable(scoped, strategy.maxProblemNumber, warnings);
|
||||
}
|
||||
|
||||
const pattern =
|
||||
strategy.answerNumberPattern ?? /(\d{1,2})\s*[번]?\s*(?:([①②③④⑤])|(\d{1,3}))/g;
|
||||
const seen = new Map<number, number>();
|
||||
|
||||
pattern.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(scoped)) !== null) {
|
||||
const number = Number(match[1]);
|
||||
const answerNumber = CIRCLED_TO_NUMBER[match[2]];
|
||||
const answerNumber = parseAnswerToken(match[2], match[3]);
|
||||
if (
|
||||
number >= 1 &&
|
||||
number <= strategy.maxProblemNumber &&
|
||||
answerNumber &&
|
||||
answerNumber !== null &&
|
||||
!seen.has(number)
|
||||
) {
|
||||
seen.set(number, answerNumber);
|
||||
@@ -43,6 +49,65 @@ export function parseAnswerTable(
|
||||
return { answers, warnings };
|
||||
}
|
||||
|
||||
function parseMathAnswerTable(
|
||||
text: string,
|
||||
maxProblemNumber: number,
|
||||
warnings: string[],
|
||||
): ParseAnswerTableResult {
|
||||
const seen = new Map<number, number>();
|
||||
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const tokens = line.trim().split(/\s+/).filter(Boolean);
|
||||
if (tokens.length < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let index = 0; index <= tokens.length - 3; index++) {
|
||||
const number = Number(tokens[index]);
|
||||
const answerNumber = parseAnswerToken(tokens[index + 1], undefined);
|
||||
const scoreToken = tokens[index + 2];
|
||||
|
||||
if (
|
||||
!Number.isInteger(number) ||
|
||||
number < 1 ||
|
||||
number > maxProblemNumber ||
|
||||
answerNumber === null ||
|
||||
!/^\d+$/.test(scoreToken) ||
|
||||
seen.has(number)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.set(number, answerNumber);
|
||||
}
|
||||
}
|
||||
|
||||
const answers = Array.from(seen.entries())
|
||||
.sort((left, right) => left[0] - right[0])
|
||||
.map(([number, answerNumber]) => ({ number, answerNumber }));
|
||||
|
||||
if (answers.length === 0) {
|
||||
warnings.push('answer table extraction returned 0 answers');
|
||||
}
|
||||
|
||||
return { answers, warnings };
|
||||
}
|
||||
|
||||
function parseAnswerToken(
|
||||
circledToken: string | undefined,
|
||||
numericToken: string | undefined,
|
||||
): number | null {
|
||||
if (circledToken && circledToken in CIRCLED_TO_NUMBER) {
|
||||
return CIRCLED_TO_NUMBER[circledToken];
|
||||
}
|
||||
|
||||
if (numericToken && /^\d+$/.test(numericToken)) {
|
||||
return Number(numericToken);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function splitBeforeEvenForm(text: string, strategy: PageStripStrategy): string {
|
||||
if (!strategy.evenFormSplitPattern) {
|
||||
return text;
|
||||
|
||||
255
backend/src/problem-sets/parsing/parse-image-based-exam.ts
Normal file
255
backend/src/problem-sets/parsing/parse-image-based-exam.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import * as fs from 'fs';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { parseAnswerTable } from './parse-answer-table';
|
||||
import { parseProblemPaper } from './parse-problem-paper';
|
||||
import { extractText } from './extract-text';
|
||||
import { ocrAnswerTable, renderPdfPageToPng } from './ocr-fallback';
|
||||
import { kiceMathStrategy } from './strategies/math';
|
||||
import { ImageBasedParseOptions, ParseResult, ParsedProblem } from './types';
|
||||
|
||||
const PAGE_PROBLEM_RE = /^(\d{1,2})\.\s+/gm;
|
||||
|
||||
export async function parseImageBasedExam(
|
||||
options: ImageBasedParseOptions,
|
||||
): Promise<ParseResult> {
|
||||
const strategy = kiceMathStrategy;
|
||||
const warnings: string[] = [];
|
||||
const pageCount = getPdfPageCount(options.paperPdfPath);
|
||||
const textResult = parseProblemPaper(extractText(options.paperPdfPath, 'raw'), strategy);
|
||||
const textProblemByNumber = new Map(
|
||||
textResult.problems.map((problem) => [problem.number, problem]),
|
||||
);
|
||||
|
||||
warnings.push(...textResult.warnings);
|
||||
|
||||
let answers: ParseResult['answers'] = [];
|
||||
if (options.answerPdfPath) {
|
||||
const answerText = extractText(options.answerPdfPath, 'layout');
|
||||
const answerResult = parseAnswerTable(answerText, strategy);
|
||||
answers = answerResult.answers;
|
||||
warnings.push(...answerResult.warnings);
|
||||
|
||||
const expectedAnswerCount = options.expectedProblemCount ?? strategy.maxProblemNumber;
|
||||
if (answers.length < expectedAnswerCount) {
|
||||
const ocrAnswers = await ocrAnswerTable(options.answerPdfPath);
|
||||
const merged = new Map(answers.map((answer) => [answer.number, answer.answerNumber]));
|
||||
|
||||
for (const answer of ocrAnswers) {
|
||||
if (!merged.has(answer.number)) {
|
||||
merged.set(answer.number, answer.answerNumber);
|
||||
}
|
||||
}
|
||||
|
||||
if (merged.size > answers.length) {
|
||||
warnings.push(
|
||||
`math answer OCR fallback merged ${merged.size - answers.length} missing answers`,
|
||||
);
|
||||
}
|
||||
|
||||
answers = Array.from(merged.entries())
|
||||
.sort((left, right) => left[0] - right[0])
|
||||
.map(([number, answerNumber]) => ({ number, answerNumber }));
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(options.renderedImageDir, { recursive: true });
|
||||
|
||||
const pageAssignments = buildProblemPageAssignments(
|
||||
options.paperPdfPath,
|
||||
pageCount,
|
||||
options.expectedProblemCount ?? strategy.maxProblemNumber,
|
||||
);
|
||||
|
||||
const problems: ParsedProblem[] = [];
|
||||
const problemCount = options.expectedProblemCount ?? inferProblemCount(textProblemByNumber, strategy);
|
||||
const renderedPages = new Map<number, { imagePath: string; imageUrl: string }>();
|
||||
|
||||
for (let problemNumber = 1; problemNumber <= problemCount; problemNumber++) {
|
||||
const pageNumber = pageAssignments.get(problemNumber) ?? 1;
|
||||
const renderedPage =
|
||||
renderedPages.get(pageNumber) ??
|
||||
(await renderAndRegisterPage(
|
||||
options.paperPdfPath,
|
||||
pageNumber,
|
||||
options.renderedImageDir,
|
||||
options.renderedImageBaseUrl,
|
||||
));
|
||||
|
||||
renderedPages.set(pageNumber, renderedPage);
|
||||
|
||||
const textProblem = textProblemByNumber.get(problemNumber);
|
||||
const needsReviewReasons = Array.from(
|
||||
new Set([...(textProblem?.needsReviewReasons ?? []), 'image-based']),
|
||||
);
|
||||
|
||||
problems.push({
|
||||
number: problemNumber,
|
||||
bodyText: textProblem?.bodyText ?? '',
|
||||
choices: textProblem?.choices ?? emptyChoices(),
|
||||
passageStart: textProblem?.passageStart,
|
||||
passageEnd: textProblem?.passageEnd,
|
||||
imageUrl: renderedPage.imageUrl,
|
||||
pageImageUrl: renderedPage.imageUrl,
|
||||
needsReview: true,
|
||||
needsReviewReasons,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
typeof options.expectedProblemCount === 'number' &&
|
||||
problems.length !== options.expectedProblemCount
|
||||
) {
|
||||
warnings.push(
|
||||
`expected ${options.expectedProblemCount} problems, parsed ${problems.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
problems,
|
||||
passages: textResult.passages,
|
||||
answers,
|
||||
warnings: Array.from(new Set(warnings)),
|
||||
};
|
||||
}
|
||||
|
||||
function getPdfPageCount(pdfPath: string): number {
|
||||
const output = execFileSync('pdfinfo', [pdfPath], {
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const match = output.match(/^Pages:\s+(\d+)/m);
|
||||
if (!match) {
|
||||
throw new Error(`failed to read page count from pdfinfo output for ${pdfPath}`);
|
||||
}
|
||||
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
function buildProblemPageAssignments(
|
||||
pdfPath: string,
|
||||
pageCount: number,
|
||||
expectedProblemCount: number,
|
||||
): Map<number, number> {
|
||||
const anchors: Array<{ number: number; pageNumber: number }> = [];
|
||||
let lastNumber = 0;
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber++) {
|
||||
const pageText = extractPageText(pdfPath, pageNumber);
|
||||
const numbers = collectPageProblemNumbers(pageText, expectedProblemCount);
|
||||
|
||||
for (const number of numbers) {
|
||||
if (number <= lastNumber) {
|
||||
continue;
|
||||
}
|
||||
|
||||
anchors.push({ number, pageNumber });
|
||||
lastNumber = number;
|
||||
}
|
||||
}
|
||||
|
||||
const assignments = new Map<number, number>();
|
||||
if (anchors.length === 0) {
|
||||
for (let problemNumber = 1; problemNumber <= expectedProblemCount; problemNumber++) {
|
||||
assignments.set(problemNumber, 1);
|
||||
}
|
||||
return assignments;
|
||||
}
|
||||
|
||||
const firstAnchor = anchors[0];
|
||||
for (let problemNumber = 1; problemNumber < firstAnchor.number; problemNumber++) {
|
||||
assignments.set(problemNumber, firstAnchor.pageNumber);
|
||||
}
|
||||
|
||||
for (let index = 0; index < anchors.length; index++) {
|
||||
const current = anchors[index];
|
||||
const next = anchors[index + 1];
|
||||
const endNumber = Math.min(next?.number ? next.number - 1 : expectedProblemCount, expectedProblemCount);
|
||||
|
||||
for (let problemNumber = current.number; problemNumber <= endNumber; problemNumber++) {
|
||||
assignments.set(problemNumber, current.pageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackPage = anchors[anchors.length - 1]?.pageNumber ?? 1;
|
||||
for (let problemNumber = 1; problemNumber <= expectedProblemCount; problemNumber++) {
|
||||
if (!assignments.has(problemNumber)) {
|
||||
assignments.set(problemNumber, fallbackPage);
|
||||
}
|
||||
}
|
||||
|
||||
return assignments;
|
||||
}
|
||||
|
||||
function extractPageText(pdfPath: string, pageNumber: number): string {
|
||||
return execFileSync(
|
||||
'pdftotext',
|
||||
['-layout', '-f', String(pageNumber), '-l', String(pageNumber), '-enc', 'UTF-8', pdfPath, '-'],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function collectPageProblemNumbers(pageText: string, maxProblemNumber: number): number[] {
|
||||
const numbers: number[] = [];
|
||||
const seen = new Set<number>();
|
||||
PAGE_PROBLEM_RE.lastIndex = 0;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = PAGE_PROBLEM_RE.exec(pageText)) !== null) {
|
||||
const number = Number(match[1]);
|
||||
if (number < 1 || number > maxProblemNumber || seen.has(number)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(number);
|
||||
numbers.push(number);
|
||||
}
|
||||
|
||||
return numbers.sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
async function renderAndRegisterPage(
|
||||
pdfPath: string,
|
||||
pageNumber: number,
|
||||
renderedImageDir: string,
|
||||
renderedImageBaseUrl: string,
|
||||
): Promise<{ imagePath: string; imageUrl: string }> {
|
||||
const imagePath = await renderPdfPageToPng(
|
||||
pdfPath,
|
||||
pageNumber,
|
||||
`${renderedImageDir}/page.png`,
|
||||
);
|
||||
|
||||
return {
|
||||
imagePath,
|
||||
imageUrl: `${stripTrailingSlash(renderedImageBaseUrl)}/page-${pageNumber}.png`,
|
||||
};
|
||||
}
|
||||
|
||||
function inferProblemCount(
|
||||
textProblemByNumber: Map<number, ParsedProblem>,
|
||||
strategy: typeof kiceMathStrategy,
|
||||
): number {
|
||||
const parsedNumbers = Array.from(textProblemByNumber.keys());
|
||||
if (parsedNumbers.length === 0) {
|
||||
return strategy.maxProblemNumber;
|
||||
}
|
||||
|
||||
return Math.max(...parsedNumbers);
|
||||
}
|
||||
|
||||
function stripTrailingSlash(value: string): string {
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function emptyChoices(): Record<'1' | '2' | '3' | '4' | '5', string> {
|
||||
return {
|
||||
'1': '',
|
||||
'2': '',
|
||||
'3': '',
|
||||
'4': '',
|
||||
'5': '',
|
||||
};
|
||||
}
|
||||
34
backend/src/problem-sets/parsing/strategies/math.ts
Normal file
34
backend/src/problem-sets/parsing/strategies/math.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { PageStripStrategy } from '../types';
|
||||
|
||||
export const kiceMathStrategy: PageStripStrategy = {
|
||||
name: 'kice-math',
|
||||
format: 'kice-math',
|
||||
maxProblemNumber: 30,
|
||||
chromeLinePatterns: [
|
||||
/^\s*\d+\s*$/,
|
||||
/^\s*\d+\s+홀수형\s*$/,
|
||||
/^\s*\d+\s+짝수형\s*$/,
|
||||
/^\s*홀수형\s+\d+\s*$/,
|
||||
/^\s*짝수형\s+\d+\s*$/,
|
||||
/^\s*홀수형\s*$/,
|
||||
/^\s*짝수형\s*$/,
|
||||
/^\s*제\s*\d+\s*교시\s*.*$/,
|
||||
/^\s*\d{4}학년도.*대학수학능력시험.*문제지.*$/,
|
||||
/^\s*이 문제지에 관한 저작권은.*$/,
|
||||
/^\s*수학\s+영역\s*$/,
|
||||
/^\s*수학\s+영역\s+\d+\s*$/,
|
||||
/^\s*수학\s+영역\s+홀수형\s*$/,
|
||||
/^\s*수학\s+영역\s+짝수형\s*$/,
|
||||
/^\s*성명\s+수험\s+번호.*$/,
|
||||
/^\s*\*\s*확인\s*사항\s*$/,
|
||||
],
|
||||
leakedChromePatterns: [
|
||||
/대학수학능력시험\s+문제지/,
|
||||
/이 문제지에 관한 저작권은/,
|
||||
/(?:^|\s)홀수형(?:\s|$)/,
|
||||
/(?:^|\s)짝수형(?:\s|$)/,
|
||||
/제\s*\d+\s*교시/,
|
||||
],
|
||||
evenFormSplitPattern: /\(\s*짝수\s*\)\s*형|짝수형/,
|
||||
answerNumberPattern: /(\d{1,2})\s*[번]?\s*(?:([①②③④⑤])|(\d{1,3}))/g,
|
||||
};
|
||||
@@ -1,16 +1,24 @@
|
||||
export interface ParseOptions {
|
||||
paperPdfPath: string;
|
||||
answerPdfPath?: string;
|
||||
format?: 'kice' | 'ebs' | 'generic';
|
||||
format?: 'kice' | 'kice-math' | 'ebs' | 'generic';
|
||||
expectedProblemCount?: number;
|
||||
}
|
||||
|
||||
export interface ImageBasedParseOptions extends ParseOptions {
|
||||
format: 'kice-math';
|
||||
renderedImageDir: string;
|
||||
renderedImageBaseUrl: string;
|
||||
}
|
||||
|
||||
export interface ParsedProblem {
|
||||
number: number;
|
||||
bodyText: string;
|
||||
choices: Record<'1' | '2' | '3' | '4' | '5', string>;
|
||||
passageStart?: number;
|
||||
passageEnd?: number;
|
||||
imageUrl?: string;
|
||||
pageImageUrl?: string;
|
||||
needsReview: boolean;
|
||||
needsReviewReasons: string[];
|
||||
}
|
||||
@@ -24,7 +32,7 @@ export interface ParsedPassage {
|
||||
export interface ParseResult {
|
||||
problems: ParsedProblem[];
|
||||
passages: ParsedPassage[];
|
||||
answers: Array<{ number: number; answerNumber: 1 | 2 | 3 | 4 | 5 }>;
|
||||
answers: Array<{ number: number; answerNumber: number }>;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
@@ -43,7 +51,7 @@ export interface PageStripStrategy {
|
||||
}
|
||||
|
||||
export interface ParseAnswerTableResult {
|
||||
answers: Array<{ number: number; answerNumber: 1 | 2 | 3 | 4 | 5 }>;
|
||||
answers: Array<{ number: number; answerNumber: number }>;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@ export class StudyLogsService {
|
||||
bodyText: log.problem.bodyText,
|
||||
choices: log.problem.choices,
|
||||
answerNumber: log.problem.answerNumber,
|
||||
imageUrl: null,
|
||||
imageUrl: log.problem.imageUrl,
|
||||
problemSet: log.problem.problemSet,
|
||||
}
|
||||
: null,
|
||||
|
||||
1
backend/uploads/problems/.gitkeep
Normal file
1
backend/uploads/problems/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user