diff --git a/.gitignore b/.gitignore index c1a893e..c91983a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/backend/prisma/migrations/20260411225510_add_problem_images/migration.sql b/backend/prisma/migrations/20260411225510_add_problem_images/migration.sql new file mode 100644 index 0000000..76cff1c --- /dev/null +++ b/backend/prisma/migrations/20260411225510_add_problem_images/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE `problems` ADD COLUMN `imageUrl` VARCHAR(191) NULL, + ADD COLUMN `pageImageUrl` VARCHAR(191) NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d2f1451..b870c66 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 (국어 독서/문학, 영어 지문, 사탐 자료 등) diff --git a/backend/scripts/kice-import.ts b/backend/scripts/kice-import.ts index 75d9531..2ab1858 100644 --- a/backend/scripts/kice-import.ts +++ b/backend/scripts/kice-import.ts @@ -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 = { 국어: 45, 영어: 45, 한국사: 20, '생활과 윤리': 20, + 수학: 30, +}; + +const SUBJECT_IMPORT_FORMAT: Record = { + 국어: '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 { + 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); diff --git a/backend/scripts/ocr-fix.ts b/backend/scripts/ocr-fix.ts index 99f99f0..198b68a 100644 --- a/backend/scripts/ocr-fix.ts +++ b/backend/scripts/ocr-fix.ts @@ -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 = { 국어: 45, 영어: 45, 한국사: 20, '생활과 윤리': 20, + 수학: 30, }; async function main(): Promise { @@ -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 { diff --git a/backend/src/problem-sets/parsing/index.ts b/backend/src/problem-sets/parsing/index.ts index 5cca581..38a2254 100644 --- a/backend/src/problem-sets/parsing/index.ts +++ b/backend/src/problem-sets/parsing/index.ts @@ -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'; diff --git a/backend/src/problem-sets/parsing/ocr-fallback/index.ts b/backend/src/problem-sets/parsing/ocr-fallback/index.ts index 618b9df..50ee840 100644 --- a/backend/src/problem-sets/parsing/ocr-fallback/index.ts +++ b/backend/src/problem-sets/parsing/ocr-fallback/index.ts @@ -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 }; diff --git a/backend/src/problem-sets/parsing/ocr-fallback/prompts/answer-table.ts b/backend/src/problem-sets/parsing/ocr-fallback/prompts/answer-table.ts index b14b594..38c1f8e 100644 --- a/backend/src/problem-sets/parsing/ocr-fallback/prompts/answer-table.ts +++ b/backend/src/problem-sets/parsing/ocr-fallback/prompts/answer-table.ts @@ -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(); diff --git a/backend/src/problem-sets/parsing/ocr-fallback/render-page.ts b/backend/src/problem-sets/parsing/ocr-fallback/render-page.ts index a03f547..69e52ce 100644 --- a/backend/src/problem-sets/parsing/ocr-fallback/render-page.ts +++ b/backend/src/problem-sets/parsing/ocr-fallback/render-page.ts @@ -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; +} diff --git a/backend/src/problem-sets/parsing/parse-answer-table.ts b/backend/src/problem-sets/parsing/parse-answer-table.ts index 2819a61..b2831dd 100644 --- a/backend/src/problem-sets/parsing/parse-answer-table.ts +++ b/backend/src/problem-sets/parsing/parse-answer-table.ts @@ -1,6 +1,6 @@ import { ParseAnswerTableResult, PageStripStrategy } from './types'; -const CIRCLED_TO_NUMBER: Record = { +const CIRCLED_TO_NUMBER: Record = { '①': 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(); + + 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(); 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(); + + 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; diff --git a/backend/src/problem-sets/parsing/parse-image-based-exam.ts b/backend/src/problem-sets/parsing/parse-image-based-exam.ts new file mode 100644 index 0000000..f001277 --- /dev/null +++ b/backend/src/problem-sets/parsing/parse-image-based-exam.ts @@ -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 { + 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(); + + 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 { + 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(); + 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(); + 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, + 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': '', + }; +} diff --git a/backend/src/problem-sets/parsing/strategies/math.ts b/backend/src/problem-sets/parsing/strategies/math.ts new file mode 100644 index 0000000..67a003d --- /dev/null +++ b/backend/src/problem-sets/parsing/strategies/math.ts @@ -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, +}; diff --git a/backend/src/problem-sets/parsing/types.ts b/backend/src/problem-sets/parsing/types.ts index 5feb08a..7a30081 100644 --- a/backend/src/problem-sets/parsing/types.ts +++ b/backend/src/problem-sets/parsing/types.ts @@ -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[]; } diff --git a/backend/src/study-logs/study-logs.service.ts b/backend/src/study-logs/study-logs.service.ts index df46578..8dde69c 100644 --- a/backend/src/study-logs/study-logs.service.ts +++ b/backend/src/study-logs/study-logs.service.ts @@ -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, diff --git a/backend/uploads/problems/.gitkeep b/backend/uploads/problems/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/uploads/problems/.gitkeep @@ -0,0 +1 @@ +