feat(ocr): vision-based fallback for needsReview problems — 7F.5
This commit is contained in:
@@ -20,7 +20,8 @@
|
||||
"prisma:reset": "prisma migrate reset --force",
|
||||
"prisma:deploy": "prisma migrate deploy",
|
||||
"prisma:seed": "ts-node prisma/seed.ts",
|
||||
"cli:kice-import": "ts-node scripts/kice-import.ts"
|
||||
"cli:kice-import": "ts-node scripts/kice-import.ts",
|
||||
"cli:ocr-fix": "ts-node scripts/ocr-fix.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
|
||||
584
backend/scripts/ocr-fix.ts
Normal file
584
backend/scripts/ocr-fix.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
import { ocrAnswerTable, ocrProblemPage } from '../src/problem-sets/parsing/ocr-fallback';
|
||||
|
||||
type TargetSubject = '국어' | '영어' | '한국사' | '생활과 윤리';
|
||||
type ChoiceKey = '1' | '2' | '3' | '4' | '5';
|
||||
|
||||
interface CliOptions {
|
||||
dryRun: boolean;
|
||||
year?: number;
|
||||
subject?: string;
|
||||
answersOnly: boolean;
|
||||
}
|
||||
|
||||
interface SubjectFiles {
|
||||
subject: TargetSubject;
|
||||
answerPdfPath: string;
|
||||
problemPdfPath: string;
|
||||
}
|
||||
|
||||
interface ProblemSnapshot {
|
||||
id: number;
|
||||
number: number;
|
||||
bodyText: string | null;
|
||||
choices: Prisma.JsonValue | null;
|
||||
answerNumber: number | null;
|
||||
needsReview: boolean;
|
||||
}
|
||||
|
||||
interface SetSummary {
|
||||
year: number;
|
||||
subject: TargetSubject;
|
||||
fixedAnswers: number;
|
||||
fixedProblems: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface AnswerUpdate {
|
||||
problemId: number;
|
||||
number: number;
|
||||
answerNumber: 1 | 2 | 3 | 4 | 5;
|
||||
needsReview: boolean;
|
||||
shouldWrite: boolean;
|
||||
}
|
||||
|
||||
interface ProblemUpdate {
|
||||
kind: 'update';
|
||||
problemId: number;
|
||||
number: number;
|
||||
bodyText: string;
|
||||
choices: Record<ChoiceKey, string>;
|
||||
needsReview: boolean;
|
||||
shouldWrite: boolean;
|
||||
}
|
||||
|
||||
interface ProblemCreate {
|
||||
kind: 'create';
|
||||
number: number;
|
||||
title: string;
|
||||
difficulty: number;
|
||||
bodyText: string;
|
||||
choices: Record<ChoiceKey, string>;
|
||||
answerNumber: number | null;
|
||||
needsReview: boolean;
|
||||
shouldWrite: boolean;
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const DATA_DIR = path.resolve(__dirname, '../../data/kice');
|
||||
const TARGET_SUBJECTS: TargetSubject[] = ['국어', '영어', '한국사', '생활과 윤리'];
|
||||
const EXPECTED_PROBLEM_COUNT: Record<TargetSubject, number> = {
|
||||
국어: 45,
|
||||
영어: 45,
|
||||
한국사: 20,
|
||||
'생활과 윤리': 20,
|
||||
};
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseArgs();
|
||||
const years = options.year ? [options.year] : [2025];
|
||||
const subjects = options.subject ? [options.subject] : TARGET_SUBJECTS;
|
||||
const summaries: SetSummary[] = [];
|
||||
|
||||
try {
|
||||
for (const year of years) {
|
||||
for (const subjectName of subjects) {
|
||||
if (!TARGET_SUBJECTS.includes(subjectName as TargetSubject)) {
|
||||
console.warn(`skip unsupported subject: ${subjectName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const subject = subjectName as TargetSubject;
|
||||
const files = resolveSubjectFiles(year, subject);
|
||||
const problemSet = await prisma.problemSet.findFirst({
|
||||
where: { year, examType: 'sat', subjectName: subject },
|
||||
include: { problems: { orderBy: { number: 'asc' } } },
|
||||
});
|
||||
|
||||
if (!problemSet) {
|
||||
console.warn(`skip ${year} ${subject}: problem set not found in DB`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const summary = await processProblemSet(problemSet, files, options);
|
||||
summaries.push(summary);
|
||||
console.log(
|
||||
`${year} ${subject}: fixed ${summary.fixedAnswers} answers, fixed ${summary.fixedProblems} problems, skipped ${summary.skipped}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
if (summaries.length === 0) {
|
||||
console.log('no matching problem sets processed');
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(): CliOptions {
|
||||
const args = process.argv.slice(2);
|
||||
const options: CliOptions = {
|
||||
dryRun: false,
|
||||
answersOnly: false,
|
||||
};
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg === '--dry-run') {
|
||||
options.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--answers-only') {
|
||||
options.answersOnly = true;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--year=')) {
|
||||
options.year = Number(arg.split('=')[1]);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--subject=')) {
|
||||
options.subject = arg.split('=')[1];
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async function processProblemSet(
|
||||
problemSet: {
|
||||
id: number;
|
||||
year: number;
|
||||
subjectName: string;
|
||||
problems: ProblemSnapshot[];
|
||||
},
|
||||
files: SubjectFiles,
|
||||
options: CliOptions,
|
||||
): Promise<SetSummary> {
|
||||
const answerUpdates = await collectAnswerUpdates(problemSet.problems, files.answerPdfPath);
|
||||
const problemUpdates: Array<ProblemUpdate | ProblemCreate> = options.answersOnly
|
||||
? []
|
||||
: await collectProblemUpdates(
|
||||
problemSet.year,
|
||||
files.subject,
|
||||
files.problemPdfPath,
|
||||
problemSet.problems,
|
||||
);
|
||||
|
||||
if (!options.answersOnly) {
|
||||
const missingProblemUpdate = await collectMissingProblemUpdate(problemSet, files.problemPdfPath);
|
||||
if (missingProblemUpdate) {
|
||||
problemUpdates.push(missingProblemUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
const skipped =
|
||||
answerUpdates.filter((update) => !update.shouldWrite).length +
|
||||
problemUpdates.filter((update) => !update.shouldWrite).length;
|
||||
|
||||
if (options.dryRun) {
|
||||
printDryRun(problemSet.year, files.subject, answerUpdates, problemUpdates);
|
||||
return {
|
||||
year: problemSet.year,
|
||||
subject: files.subject,
|
||||
fixedAnswers: answerUpdates.filter((update) => update.shouldWrite).length,
|
||||
fixedProblems: problemUpdates.filter((update) => update.shouldWrite).length,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const update of answerUpdates) {
|
||||
if (!update.shouldWrite) continue;
|
||||
await tx.problem.update({
|
||||
where: { id: update.problemId },
|
||||
data: {
|
||||
answerNumber: update.answerNumber,
|
||||
needsReview: update.needsReview,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const update of problemUpdates) {
|
||||
if (!update.shouldWrite) continue;
|
||||
|
||||
if (update.kind === 'update') {
|
||||
await tx.problem.update({
|
||||
where: { id: update.problemId },
|
||||
data: {
|
||||
bodyText: update.bodyText,
|
||||
choices: update.choices as Prisma.InputJsonValue,
|
||||
needsReview: update.needsReview,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await tx.problem.upsert({
|
||||
where: {
|
||||
problemSetId_number: {
|
||||
problemSetId: problemSet.id,
|
||||
number: update.number,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
bodyText: update.bodyText,
|
||||
choices: update.choices as Prisma.InputJsonValue,
|
||||
answerNumber: update.answerNumber,
|
||||
needsReview: update.needsReview,
|
||||
title: update.title,
|
||||
difficulty: update.difficulty,
|
||||
},
|
||||
create: {
|
||||
problemSetId: problemSet.id,
|
||||
number: update.number,
|
||||
title: update.title,
|
||||
difficulty: update.difficulty,
|
||||
bodyText: update.bodyText,
|
||||
choices: update.choices as Prisma.InputJsonValue,
|
||||
answerNumber: update.answerNumber,
|
||||
needsReview: update.needsReview,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
year: problemSet.year,
|
||||
subject: files.subject,
|
||||
fixedAnswers: answerUpdates.filter((update) => update.shouldWrite).length,
|
||||
fixedProblems: problemUpdates.filter((update) => update.shouldWrite).length,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectAnswerUpdates(
|
||||
problems: ProblemSnapshot[],
|
||||
answerPdfPath: string,
|
||||
): Promise<AnswerUpdate[]> {
|
||||
const allAnswersMissing = problems.length > 0 && problems.every((problem) => problem.answerNumber === null);
|
||||
if (!allAnswersMissing) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const answers = await ocrAnswerTable(answerPdfPath);
|
||||
const answersByNumber = new Map(answers.map((answer) => [answer.number, answer.answerNumber]));
|
||||
|
||||
return problems.flatMap((problem) => {
|
||||
const answerNumber = answersByNumber.get(problem.number);
|
||||
if (!answerNumber) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const currentChoices = normalizeStoredChoices(problem.choices);
|
||||
const currentBodyText = (problem.bodyText || '').trim();
|
||||
const needsReview = computeNeedsReview(currentBodyText, currentChoices, answerNumber);
|
||||
|
||||
return [
|
||||
{
|
||||
problemId: problem.id,
|
||||
number: problem.number,
|
||||
answerNumber,
|
||||
needsReview,
|
||||
shouldWrite: problem.answerNumber !== answerNumber || problem.needsReview !== needsReview,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
async function collectProblemUpdates(
|
||||
year: number,
|
||||
subject: TargetSubject,
|
||||
problemPdfPath: string,
|
||||
problems: ProblemSnapshot[],
|
||||
): Promise<ProblemUpdate[]> {
|
||||
const pageCount = getPdfPageCount(problemPdfPath);
|
||||
const reviewProblems = problems.filter((problem) => problem.needsReview);
|
||||
const updates: ProblemUpdate[] = [];
|
||||
|
||||
for (const problem of reviewProblems) {
|
||||
const pageNumber =
|
||||
findProblemPageByText(problemPdfPath, pageCount, problem.number) ??
|
||||
estimateProblemPage(subject, problem.number, pageCount);
|
||||
const ocrProblems = await ocrProblemPage(problemPdfPath, pageNumber, [problem.number]);
|
||||
const ocrProblem = ocrProblems.find((entry) => entry.number === problem.number);
|
||||
|
||||
if (!ocrProblem) {
|
||||
updates.push({
|
||||
kind: 'update',
|
||||
problemId: problem.id,
|
||||
number: problem.number,
|
||||
bodyText: (problem.bodyText || '').trim(),
|
||||
choices: normalizeStoredChoices(problem.choices),
|
||||
needsReview: problem.needsReview,
|
||||
shouldWrite: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentBodyText = (problem.bodyText || '').trim();
|
||||
const currentChoices = normalizeStoredChoices(problem.choices);
|
||||
const ocrChoices = normalizeChoiceRecord(ocrProblem.choices);
|
||||
const shouldWrite = isClearlyBetter(currentBodyText, currentChoices, ocrProblem.bodyText, ocrChoices);
|
||||
const answerNumber = coerceAnswerNumber(problem.answerNumber);
|
||||
const needsReview = computeNeedsReview(ocrProblem.bodyText, ocrChoices, answerNumber);
|
||||
|
||||
updates.push({
|
||||
kind: 'update',
|
||||
problemId: problem.id,
|
||||
number: problem.number,
|
||||
bodyText: shouldWrite ? ocrProblem.bodyText : currentBodyText,
|
||||
choices: shouldWrite ? ocrChoices : currentChoices,
|
||||
needsReview: shouldWrite ? needsReview : problem.needsReview,
|
||||
shouldWrite,
|
||||
});
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
async function collectMissingProblemUpdate(
|
||||
problemSet: {
|
||||
id: number;
|
||||
year: number;
|
||||
subjectName: string;
|
||||
problems: ProblemSnapshot[];
|
||||
},
|
||||
problemPdfPath: string,
|
||||
): Promise<ProblemCreate | null> {
|
||||
if (problemSet.subjectName !== '한국사') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expectedNumber = 20;
|
||||
if (problemSet.problems.some((problem) => problem.number === expectedNumber)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pageCount = getPdfPageCount(problemPdfPath);
|
||||
const pageNumber =
|
||||
findProblemPageByText(problemPdfPath, pageCount, expectedNumber) ??
|
||||
estimateProblemPage('한국사', expectedNumber, pageCount);
|
||||
const ocrProblems = await ocrProblemPage(problemPdfPath, pageNumber, [expectedNumber]);
|
||||
const ocrProblem = ocrProblems.find((problem) => problem.number === expectedNumber);
|
||||
|
||||
if (!ocrProblem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const choices = normalizeChoiceRecord(ocrProblem.choices);
|
||||
return {
|
||||
kind: 'create',
|
||||
number: expectedNumber,
|
||||
title: `${problemSet.year} 한국사 ${expectedNumber}번`,
|
||||
difficulty: 0,
|
||||
bodyText: ocrProblem.bodyText,
|
||||
choices,
|
||||
answerNumber: null,
|
||||
needsReview: computeNeedsReview(ocrProblem.bodyText, choices, null),
|
||||
shouldWrite: true,
|
||||
};
|
||||
}
|
||||
|
||||
function printDryRun(
|
||||
year: number,
|
||||
subject: TargetSubject,
|
||||
answerUpdates: AnswerUpdate[],
|
||||
problemUpdates: Array<ProblemUpdate | ProblemCreate>,
|
||||
): void {
|
||||
for (const update of answerUpdates) {
|
||||
if (!update.shouldWrite) continue;
|
||||
console.log(
|
||||
`[dry-run] ${year} ${subject} answer #${update.number} -> ${update.answerNumber}, needsReview=${update.needsReview}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const update of problemUpdates) {
|
||||
if (!update.shouldWrite) continue;
|
||||
console.log(
|
||||
`[dry-run] ${year} ${subject} problem #${update.number} OCR body=${truncate(update.bodyText, 80)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSubjectFiles(year: number, subject: TargetSubject): SubjectFiles {
|
||||
if (subject === '생활과 윤리') {
|
||||
return {
|
||||
subject,
|
||||
answerPdfPath: requireExistingFile(
|
||||
path.join(DATA_DIR, String(year), '사회탐구/사회탐구영역_정답표', '01 생활과 윤리_정답표.pdf'),
|
||||
),
|
||||
problemPdfPath: requireExistingFile(
|
||||
path.join(DATA_DIR, String(year), '사회탐구/사회탐구영역_문제지', '01 생활과 윤리_문제지.pdf'),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const subjectDir = path.join(DATA_DIR, String(year), subject);
|
||||
const answerPdfPath = findFile(subjectDir, /_정답표?\.pdf$/);
|
||||
const problemPdfPath =
|
||||
findFile(subjectDir, /_문제지_홀수형\.pdf$/) ||
|
||||
findFile(subjectDir, /_문제지(_홀수형)?\.pdf$/) ||
|
||||
findFile(subjectDir, /_문제\.pdf$/);
|
||||
|
||||
if (!answerPdfPath || !problemPdfPath) {
|
||||
throw new Error(`missing PDFs for ${year} ${subject}`);
|
||||
}
|
||||
|
||||
return {
|
||||
subject,
|
||||
answerPdfPath,
|
||||
problemPdfPath,
|
||||
};
|
||||
}
|
||||
|
||||
function findFile(dir: string, pattern: RegExp): string | null {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = fs.readdirSync(dir).find((value) => pattern.test(value));
|
||||
return entry ? path.join(dir, entry) : null;
|
||||
}
|
||||
|
||||
function requireExistingFile(filePath: string): string {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`missing file: ${filePath}`);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
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(`unable to determine page count for ${pdfPath}`);
|
||||
}
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
function findProblemPageByText(
|
||||
pdfPath: string,
|
||||
pageCount: number,
|
||||
problemNumber: number,
|
||||
): number | null {
|
||||
const pattern = new RegExp(`(^|\\s)${problemNumber}\\.\\s`, 'm');
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber++) {
|
||||
const text = execFileSync(
|
||||
'pdftotext',
|
||||
['-raw', '-enc', 'UTF-8', '-f', String(pageNumber), '-l', String(pageNumber), pdfPath, '-'],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
|
||||
if (pattern.test(text)) {
|
||||
return pageNumber;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function estimateProblemPage(subject: TargetSubject, problemNumber: number, pageCount: number): number {
|
||||
const problemsPerPage = subject === '한국사' || subject === '생활과 윤리' ? 10 : 4;
|
||||
return Math.max(1, Math.min(pageCount, Math.ceil(problemNumber / problemsPerPage)));
|
||||
}
|
||||
|
||||
function normalizeStoredChoices(value: Prisma.JsonValue | null): Record<ChoiceKey, string> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return emptyChoices();
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
return {
|
||||
'1': typeof record['1'] === 'string' ? record['1'].trim() : '',
|
||||
'2': typeof record['2'] === 'string' ? record['2'].trim() : '',
|
||||
'3': typeof record['3'] === 'string' ? record['3'].trim() : '',
|
||||
'4': typeof record['4'] === 'string' ? record['4'].trim() : '',
|
||||
'5': typeof record['5'] === 'string' ? record['5'].trim() : '',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeChoiceRecord(value: Record<ChoiceKey, string>): Record<ChoiceKey, string> {
|
||||
return {
|
||||
'1': value['1'].trim(),
|
||||
'2': value['2'].trim(),
|
||||
'3': value['3'].trim(),
|
||||
'4': value['4'].trim(),
|
||||
'5': value['5'].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyChoices(): Record<ChoiceKey, string> {
|
||||
return {
|
||||
'1': '',
|
||||
'2': '',
|
||||
'3': '',
|
||||
'4': '',
|
||||
'5': '',
|
||||
};
|
||||
}
|
||||
|
||||
function countFilledChoices(choices: Record<ChoiceKey, string>): number {
|
||||
return (Object.keys(choices) as ChoiceKey[]).filter((key) => choices[key].length > 0).length;
|
||||
}
|
||||
|
||||
function scoreProblem(bodyText: string, choices: Record<ChoiceKey, string>): number {
|
||||
const filledChoices = countFilledChoices(choices);
|
||||
const choiceChars = Object.values(choices).reduce((sum, value) => sum + value.length, 0);
|
||||
return filledChoices * 1_000 + choiceChars * 10 + bodyText.length;
|
||||
}
|
||||
|
||||
function isClearlyBetter(
|
||||
currentBodyText: string,
|
||||
currentChoices: Record<ChoiceKey, string>,
|
||||
nextBodyText: string,
|
||||
nextChoices: Record<ChoiceKey, string>,
|
||||
): boolean {
|
||||
const currentScore = scoreProblem(currentBodyText, currentChoices);
|
||||
const nextScore = scoreProblem(nextBodyText, nextChoices);
|
||||
return nextScore > currentScore + 100;
|
||||
}
|
||||
|
||||
function computeNeedsReview(
|
||||
bodyText: string,
|
||||
choices: Record<ChoiceKey, string>,
|
||||
answerNumber: number | null,
|
||||
): boolean {
|
||||
if (!answerNumber) {
|
||||
return true;
|
||||
}
|
||||
if (bodyText.trim().length < 10) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const key of ['1', '2', '3', '4', '5'] as const) {
|
||||
const value = choices[key].trim();
|
||||
if (value.length < 3) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
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 truncate(value: string, length: number): string {
|
||||
return value.length <= length ? value : `${value.slice(0, length - 3)}...`;
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
16
backend/src/problem-sets/parsing/ocr-fallback/README.md
Normal file
16
backend/src/problem-sets/parsing/ocr-fallback/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# OCR Fallback
|
||||
|
||||
Vision-based fallback for image-only answer sheets and table-heavy problem pages.
|
||||
|
||||
## Flow
|
||||
|
||||
1. Render a single PDF page to PNG with `pdftoppm`
|
||||
2. Send the PNG to `codex exec` with a strict JSON prompt
|
||||
3. Parse and validate the returned JSON
|
||||
|
||||
## Public API
|
||||
|
||||
- `ocrAnswerTable(pdfPath)`
|
||||
- `ocrProblemPage(pdfPath, pageNumber, targetProblemNumbers?)`
|
||||
|
||||
Both helpers clean up their temporary rendered images after each call.
|
||||
183
backend/src/problem-sets/parsing/ocr-fallback/codex-vision.ts
Normal file
183
backend/src/problem-sets/parsing/ocr-fallback/codex-vision.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
export async function invokeCodexVision(
|
||||
imagePath: string,
|
||||
prompt: string,
|
||||
options?: { timeout?: number },
|
||||
): Promise<string> {
|
||||
const result = spawnSync(
|
||||
'codex',
|
||||
['exec', '--sandbox', 'read-only', '--skip-git-repo-check', '-i', imagePath, '-'],
|
||||
{
|
||||
input: prompt,
|
||||
encoding: 'utf-8',
|
||||
timeout: options?.timeout ?? 180_000,
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = `${result.stderr || ''}`.trim();
|
||||
const stdout = `${result.stdout || ''}`.trim();
|
||||
throw new Error(
|
||||
`codex exec failed with status ${result.status}: ${stderr || stdout || 'unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
return cleanCodexResponse(result.stdout || '');
|
||||
}
|
||||
|
||||
export function parseJsonFromResponse(text: string): unknown {
|
||||
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
const candidate = (fenced ? fenced[1] : text).trim();
|
||||
const direct = extractJsonCandidate(candidate);
|
||||
return JSON.parse(direct);
|
||||
}
|
||||
|
||||
function cleanCodexResponse(stdout: string): string {
|
||||
const lines = stdout.split(/\r?\n/);
|
||||
const kept: string[] = [];
|
||||
let skipNextNumericLine = false;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd();
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
if (kept.length > 0 && kept[kept.length - 1] !== '') {
|
||||
kept.push('');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (skipNextNumericLine && /^[\d,]+$/.test(trimmed)) {
|
||||
skipNextNumericLine = false;
|
||||
continue;
|
||||
}
|
||||
skipNextNumericLine = false;
|
||||
|
||||
if (shouldStripLine(trimmed)) {
|
||||
if (trimmed === 'tokens used') {
|
||||
skipNextNumericLine = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
kept.push(line);
|
||||
}
|
||||
|
||||
const cleaned = kept.join('\n').trim();
|
||||
if (!cleaned) {
|
||||
throw new Error('codex exec returned no assistant response');
|
||||
}
|
||||
|
||||
return dedupeRepeatedJson(cleaned);
|
||||
}
|
||||
|
||||
function shouldStripLine(line: string): boolean {
|
||||
if (
|
||||
line === 'codex' ||
|
||||
line === 'user' ||
|
||||
line === 'assistant' ||
|
||||
line === 'tokens used' ||
|
||||
line === '--------'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
line.startsWith('[codex]') ||
|
||||
line.startsWith('Reading prompt from stdin') ||
|
||||
line.startsWith('OpenAI Codex') ||
|
||||
line.startsWith('workdir:') ||
|
||||
line.startsWith('model:') ||
|
||||
line.startsWith('provider:') ||
|
||||
line.startsWith('approval:') ||
|
||||
line.startsWith('sandbox:') ||
|
||||
line.startsWith('reasoning effort:') ||
|
||||
line.startsWith('reasoning summaries:') ||
|
||||
line.startsWith('session id:') ||
|
||||
line.startsWith('warning:')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function dedupeRepeatedJson(text: string): string {
|
||||
const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||
if (lines.length === 2 && lines[0].trim() === lines[1].trim()) {
|
||||
return lines[0].trim();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function extractJsonCandidate(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('empty response');
|
||||
}
|
||||
|
||||
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const start = findFirstJsonStart(trimmed);
|
||||
if (start === -1) {
|
||||
throw new Error('no JSON object found in response');
|
||||
}
|
||||
|
||||
const opening = trimmed[start];
|
||||
const closing = opening === '{' ? '}' : ']';
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = start; index < trimmed.length; index++) {
|
||||
const char = trimmed[index];
|
||||
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === '\\') {
|
||||
escaped = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === opening) {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === closing) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return trimmed.slice(start, index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('unterminated JSON object in response');
|
||||
}
|
||||
|
||||
function findFirstJsonStart(text: string): number {
|
||||
const objectStart = text.indexOf('{');
|
||||
const arrayStart = text.indexOf('[');
|
||||
|
||||
if (objectStart === -1) return arrayStart;
|
||||
if (arrayStart === -1) return objectStart;
|
||||
return Math.min(objectStart, arrayStart);
|
||||
}
|
||||
169
backend/src/problem-sets/parsing/ocr-fallback/index.ts
Normal file
169
backend/src/problem-sets/parsing/ocr-fallback/index.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { invokeCodexVision, parseJsonFromResponse } from './codex-vision';
|
||||
import { ANSWER_TABLE_PROMPT } from './prompts/answer-table';
|
||||
import { buildProblemPagePrompt } from './prompts/problem-page';
|
||||
import { renderPdfPageToPng } from './render-page';
|
||||
|
||||
type ChoiceKey = '1' | '2' | '3' | '4' | '5';
|
||||
|
||||
export interface OcrAnswer {
|
||||
number: number;
|
||||
answerNumber: 1 | 2 | 3 | 4 | 5;
|
||||
}
|
||||
|
||||
export interface OcrProblem {
|
||||
number: number;
|
||||
bodyText: string;
|
||||
choices: Record<ChoiceKey, string>;
|
||||
}
|
||||
|
||||
export async function ocrAnswerTable(pdfPath: string): Promise<OcrAnswer[]> {
|
||||
const answers = new Map<number, OcrAnswer['answerNumber']>();
|
||||
const pageCount = Math.min(getPdfPageCount(pdfPath) ?? 2, 2);
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber++) {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ocr-answer-table-'));
|
||||
const outPrefix = path.join(tempDir, 'page');
|
||||
|
||||
try {
|
||||
const imagePath = await renderPdfPageToPng(pdfPath, pageNumber, outPrefix);
|
||||
const response = await invokeCodexVision(imagePath, ANSWER_TABLE_PROMPT);
|
||||
const parsed = parseAnswerTableResponse(parseJsonFromResponse(response));
|
||||
|
||||
for (const answer of parsed) {
|
||||
if (!answers.has(answer.number)) {
|
||||
answers.set(answer.number, answer.answerNumber);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(answers.entries())
|
||||
.sort((left, right) => left[0] - right[0])
|
||||
.map(([number, answerNumber]) => ({ number, answerNumber }));
|
||||
}
|
||||
|
||||
export async function ocrProblemPage(
|
||||
pdfPath: string,
|
||||
pageNumber: number,
|
||||
targetProblemNumbers?: number[],
|
||||
): Promise<OcrProblem[]> {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ocr-problem-page-'));
|
||||
const outPrefix = path.join(tempDir, 'page');
|
||||
|
||||
try {
|
||||
const imagePath = await renderPdfPageToPng(pdfPath, pageNumber, outPrefix);
|
||||
const response = await invokeCodexVision(
|
||||
imagePath,
|
||||
buildProblemPagePrompt(targetProblemNumbers),
|
||||
);
|
||||
const parsed = parseProblemPageResponse(parseJsonFromResponse(response));
|
||||
const targetSet =
|
||||
targetProblemNumbers && targetProblemNumbers.length > 0
|
||||
? new Set(targetProblemNumbers)
|
||||
: null;
|
||||
|
||||
return parsed.filter((problem) => !targetSet || targetSet.has(problem.number));
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function getPdfPageCount(pdfPath: string): number | null {
|
||||
try {
|
||||
const output = execFileSync('pdfinfo', [pdfPath], {
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const match = output.match(/^Pages:\s+(\d+)/m);
|
||||
return match ? Number(match[1]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseAnswerTableResponse(value: unknown): OcrAnswer[] {
|
||||
if (!isObject(value) || !Array.isArray(value.answers)) {
|
||||
throw new Error('OCR answer-table response did not contain an answers array');
|
||||
}
|
||||
|
||||
return value.answers.flatMap((entry) => {
|
||||
if (!isObject(entry)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const number = toInteger(entry.number);
|
||||
const answerNumber = toAnswerNumber(entry.answerNumber);
|
||||
if (!number || !answerNumber) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ number, answerNumber }];
|
||||
});
|
||||
}
|
||||
|
||||
function parseProblemPageResponse(value: unknown): OcrProblem[] {
|
||||
if (!isObject(value) || !Array.isArray(value.problems)) {
|
||||
throw new Error('OCR problem-page response did not contain a problems array');
|
||||
}
|
||||
|
||||
return value.problems.flatMap((entry) => {
|
||||
if (!isObject(entry)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const number = toInteger(entry.number);
|
||||
const bodyText = typeof entry.bodyText === 'string' ? entry.bodyText.trim() : '';
|
||||
const choices = normalizeChoices(entry.choices);
|
||||
|
||||
if (!number || !bodyText) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ number, bodyText, choices }];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeChoices(value: unknown): Record<ChoiceKey, string> {
|
||||
const fallback: Record<ChoiceKey, string> = {
|
||||
'1': '',
|
||||
'2': '',
|
||||
'3': '',
|
||||
'4': '',
|
||||
'5': '',
|
||||
};
|
||||
|
||||
if (!isObject(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return {
|
||||
'1': typeof value['1'] === 'string' ? value['1'].trim() : '',
|
||||
'2': typeof value['2'] === 'string' ? value['2'].trim() : '',
|
||||
'3': typeof value['3'] === 'string' ? value['3'].trim() : '',
|
||||
'4': typeof value['4'] === 'string' ? value['4'].trim() : '',
|
||||
'5': typeof value['5'] === 'string' ? value['5'].trim() : '',
|
||||
};
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function toInteger(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
function toAnswerNumber(value: unknown): OcrAnswer['answerNumber'] | null {
|
||||
if (value === 1 || value === 2 || value === 3 || value === 4 || value === 5) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export { invokeCodexVision, parseJsonFromResponse, renderPdfPageToPng };
|
||||
@@ -0,0 +1,20 @@
|
||||
export const ANSWER_TABLE_PROMPT = `
|
||||
You are given an image of a Korean college-entrance exam (수능) answer sheet. Extract every visible answer row into JSON.
|
||||
|
||||
Output format (STRICT JSON, no markdown, no commentary):
|
||||
{
|
||||
"answers": [
|
||||
{ "number": 1, "answerNumber": 3 },
|
||||
{ "number": 2, "answerNumber": 5 }
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Numbers 1-45 are possible; include only numbers that actually appear in the table
|
||||
- Convert ①②③④⑤ to 1/2/3/4/5 respectively
|
||||
- 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 only one form is shown, extract all visible rows
|
||||
- Return only the JSON object
|
||||
`.trim();
|
||||
@@ -0,0 +1,37 @@
|
||||
export function buildProblemPagePrompt(targetProblemNumbers?: number[]): string {
|
||||
const targetLine =
|
||||
targetProblemNumbers && targetProblemNumbers.length > 0
|
||||
? `Focus only on problem numbers: ${targetProblemNumbers.join(', ')}. If other problems appear on the page, ignore them.`
|
||||
: 'Extract every complete problem that is visible on the page.';
|
||||
|
||||
return `
|
||||
You are given a rendered page image from a Korean college-entrance exam (수능) problem booklet.
|
||||
The page may contain multiple problems, diagrams, or table-based choices.
|
||||
${targetLine}
|
||||
|
||||
Output format (STRICT JSON, no markdown, no commentary):
|
||||
{
|
||||
"problems": [
|
||||
{
|
||||
"number": 3,
|
||||
"bodyText": "question stem text",
|
||||
"choices": {
|
||||
"1": "choice text",
|
||||
"2": "choice text",
|
||||
"3": "choice text",
|
||||
"4": "choice text",
|
||||
"5": "choice text"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Preserve Korean text faithfully
|
||||
- Keep the problem stem in bodyText and keep answer choices only in choices
|
||||
- Always return choices as keys "1" through "5"; use an empty string when a choice is not readable
|
||||
- Include only problems that are clearly visible on the page
|
||||
- Do not invent missing text
|
||||
- Return only the JSON object
|
||||
`.trim();
|
||||
}
|
||||
53
backend/src/problem-sets/parsing/ocr-fallback/render-page.ts
Normal file
53
backend/src/problem-sets/parsing/ocr-fallback/render-page.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
|
||||
export async function renderPdfPageToPng(
|
||||
pdfPath: string,
|
||||
pageNumber: number,
|
||||
outPath: string,
|
||||
): Promise<string> {
|
||||
const outPrefix = stripPngExtension(outPath);
|
||||
const outputPath = `${outPrefix}-${pageNumber}.png`;
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
'pdftoppm',
|
||||
['-r', '300', '-png', '-f', String(pageNumber), '-l', String(pageNumber), pdfPath, outPrefix],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
cleanupGeneratedFiles(outPrefix);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
cleanupGeneratedFiles(outPrefix);
|
||||
throw new Error(`pdftoppm did not produce expected file: ${outputPath}`);
|
||||
}
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
function stripPngExtension(value: string): string {
|
||||
return value.endsWith('.png') ? value.slice(0, -4) : value;
|
||||
}
|
||||
|
||||
function cleanupGeneratedFiles(outPrefix: string): void {
|
||||
const dir = path.dirname(outPrefix);
|
||||
const base = path.basename(outPrefix);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
if (!entry.startsWith(`${base}-`) || !entry.endsWith('.png')) {
|
||||
continue;
|
||||
}
|
||||
fs.rmSync(path.join(dir, entry), { force: true });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user