examforge / src /services /DocumentParserService.ts
Benjahmin's picture
feat(ai): implement asynchronous AI document parsing
c04a174
Raw
History Blame Contribute Delete
5.8 kB
import * as pdfjsLib from 'pdfjs-dist';
import mammoth from 'mammoth';
import { Question } from '../types';
// Configure a reliable CDN-hosted worker for PDF.js to prevent local asset bundle errors
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.10.38/pdf.worker.min.mjs`;
export class DocumentParserService {
/**
* Primary Entrypoint: Extract raw string content from an uploaded File (PDF, DOCX, or TXT)
*/
static async extractTextFromFile(file: File): Promise<string> {
const extension = file.name.split('.').pop()?.toLowerCase();
if (extension === 'txt') {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error("Unable to parse TXT document."));
reader.readAsText(file);
});
}
if (extension === 'docx') {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async () => {
try {
const arrayBuffer = reader.result as ArrayBuffer;
const result = await mammoth.extractRawText({ arrayBuffer });
resolve(result.value);
} catch (err: any) {
reject(new Error("Unable to parse Microsoft Word Document (.docx): " + err.message));
}
};
reader.onerror = () => reject(new Error("File load error in Word parser."));
reader.readAsArrayBuffer(file);
});
}
if (extension === 'pdf') {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async () => {
try {
const arrayBuffer = reader.result as ArrayBuffer;
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
const pdf = await loadingTask.promise;
let extractedText = "";
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items
.map((item: any) => item.str)
.join(" ");
extractedText += pageText + "\n";
}
resolve(extractedText);
} catch (err: any) {
reject(new Error("Unable to parse PDF document (.pdf): " + err.message));
}
};
reader.onerror = () => reject(new Error("File load error in PDF parser."));
reader.readAsArrayBuffer(file);
});
}
throw new Error("Unsupported file schema! Please upload .TXT, .PDF, or .DOCX files.");
}
/**
* Local Regex Parser Failsafe: Standard Regex pattern extraction in case the AI pipeline is offline
*/
static parseLocalRegex(text: string): Question[] {
const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0);
const questions: Question[] = [];
let currentQuestion: Partial<Question> | null = null;
lines.forEach((line) => {
// Look for a question beginning with numbers: e.g. "1. What is..." or "Question 13:"
const qMatch = line.match(/^(\d+)[\.\)]\s*(.*)$/i) || line.match(/^Question\s*(\d+)[:\.]?\s*(.*)$/i) || line.match(/^Q(\d+)[:\.]?\s*(.*)$/i);
if (qMatch) {
if (currentQuestion && currentQuestion.text) {
questions.push(this.normalizeCleanQuestion(currentQuestion));
}
currentQuestion = {
id: `manual-regex-${Date.now()}-${qMatch[1]}`,
text: qMatch[2],
options: [],
correctOptionId: 'opt-1',
category: 'General',
difficulty: 'medium',
tags: []
};
return;
}
// Look for option formats: e.g. "A) Option" or "B. Other option"
const oMatch = line.match(/^([A-D])[\.\)]\s*(.*)$/i) || line.match(/^\(([A-D])\)\s*(.*)$/i);
if (oMatch && currentQuestion) {
const letterIndex = oMatch[1].toUpperCase();
const optionIdMap: Record<string, string> = { A: 'opt-1', B: 'opt-2', C: 'opt-3', D: 'opt-4' };
currentQuestion.options?.push({
id: optionIdMap[letterIndex] || `opt-${letterIndex.toLowerCase()}`,
text: oMatch[2]
});
return;
}
// Look for declared answers: e.g. "Answer: B" or "ANS: C"
const aMatch = line.match(/^(?:Answer|ANS)[:\s]+([A-D])/i);
if (aMatch && currentQuestion) {
const letterIndex = aMatch[1].toUpperCase();
const optionIdMap: Record<string, string> = { A: 'opt-1', B: 'opt-2', C: 'opt-3', D: 'opt-4' };
currentQuestion.correctOptionId = optionIdMap[letterIndex] || 'opt-1';
return;
}
// Append multi-line question text if appropriate
if (currentQuestion && !oMatch && !aMatch) {
currentQuestion.text += ' ' + line;
}
});
if (currentQuestion && currentQuestion.text) {
questions.push(this.normalizeCleanQuestion(currentQuestion));
}
return questions;
}
private static normalizeCleanQuestion(q: Partial<Question>): Question {
const baseOptIds = ['opt-1', 'opt-2', 'opt-3', 'opt-4'];
// Ensure accurate 4-option shape
const options = q.options || [];
while (options.length < 4) {
options.push({ id: baseOptIds[options.length], text: `Option ${String.fromCharCode(65 + options.length)} placeholder` });
}
const slicedOptions = options.slice(0, 4);
return {
text: q.text || "Untitled Question",
options: slicedOptions,
correctOptionId: q.correctOptionId || 'opt-1',
category: q.category || 'General',
difficulty: q.difficulty || 'medium',
tags: q.tags || []
} as Question;
}
}