feat(parse): math + image-based problem pipeline — 7H.1

This commit is contained in:
reloop
2026-04-12 08:06:54 +09:00
parent a5dab35618
commit acc35e9284
15 changed files with 476 additions and 35 deletions

4
.gitignore vendored
View File

@@ -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

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `problems` ADD COLUMN `imageUrl` VARCHAR(191) NULL,
ADD COLUMN `pageImageUrl` VARCHAR(191) NULL;

View File

@@ -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 (국어 독서/문학, 영어 지문, 사탐 자료 등)

View File

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

View File

@@ -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 {

View File

@@ -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';

View File

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

View File

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

View File

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

View File

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

View 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': '',
};
}

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

View File

@@ -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[];
}

View File

@@ -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,

View File

@@ -0,0 +1 @@