feat(ocr): vision-based fallback for needsReview problems — 7F.5

This commit is contained in:
reloop
2026-04-12 06:29:47 +09:00
parent c9935cf303
commit dcf1eb6021
8 changed files with 1064 additions and 1 deletions

View File

@@ -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
View 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;
});

View 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.

View 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);
}

View 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 };

View File

@@ -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();

View File

@@ -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();
}

View 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 });
}
}