Files
family_budget/backend/src/services/pdfToStatement.ts
vakabunga 78c4730196 fix: adaptive LLM progress estimation and emit 85% on stream end
Hardcoded EXPECTED_CHARS (15k) caused progress to stall at ~20-25% for
short statements. Now expected size is derived from input text length.
Also emit an explicit 85% event when the LLM stream finishes, and
throttle SSE events to 300ms to reduce browser overhead.
2026-03-14 16:41:12 +03:00

281 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import OpenAI from 'openai';
import { config } from '../config';
import type { StatementFile } from '@family-budget/shared';
const PDF2JSON_PROMPT = `Ты — конвертер банковских выписок. Твоя задача: извлечь данные из текста банковской выписки ниже и вернуть строго один валидный JSON-объект в формате ниже. Никакого текста до или после JSON, только сам объект.
## Структура выходного JSON
{
"schemaVersion": "1.0",
"bank": "<название банка из выписки>",
"statement": {
"accountNumber": "<номер счёта, только цифры, без пробелов>",
"currency": "RUB",
"openingBalance": <число в копейках, целое>,
"closingBalance": <число в копейках, целое>,
"exportedAt": "<дата экспорта в формате ISO 8601 с offset, например 2026-02-27T13:23:00+03:00>"
},
"transactions": [
{
"operationAt": "<дата и время операции в формате ISO 8601 с offset>",
"amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>,
"commission": <число, целое, >= 0, в копейках>,
"description": "<полное описание операции из выписки>"
}
]
}
## Правила конвертации
1. Суммы — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456.
2. amountSigned: приход — положительное, расход — отрицательное.
3. operationAt — дата и время, если не указано — 00:00:00, offset +03:00 для МСК.
4. commission — если не указана — 0.
5. description — полный текст операции как в выписке.
6. accountNumber — только цифры, без пробелов и дефисов.
7. openingBalance / closingBalance — в копейках.
8. bank — краткое название (VTB, Sberbank, Тинькофф).
9. exportedAt — дата формирования выписки.
10. transactions — хронологический порядок.
## Требования
- transactions не должен быть пустым.
- Все числа — целые.
- Даты — ISO 8601 с offset.
- currency всегда "RUB".
- schemaVersion всегда "1.0".`;
export interface PdfConversionError {
status: number;
error: string;
message: string;
}
export function isPdfConversionError(r: unknown): r is PdfConversionError {
return (
typeof r === 'object' &&
r !== null &&
'status' in r &&
'error' in r &&
'message' in r
);
}
// Lazy-loaded so the app starts even if pdf-parse is missing from node_modules.
let _pdfParse: ((buf: Buffer) => Promise<{ text: string }>) | undefined;
function getPdfParse() {
if (!_pdfParse) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
_pdfParse = require('pdf-parse') as (buf: Buffer) => Promise<{ text: string }>;
}
return _pdfParse;
}
export async function convertPdfToStatement(
buffer: Buffer,
): Promise<StatementFile | PdfConversionError> {
if (!config.llmApiKey || config.llmApiKey.trim() === '') {
return {
status: 503,
error: 'SERVICE_UNAVAILABLE',
message: 'Конвертация PDF недоступна: не задан LLM_API_KEY',
};
}
let text: string;
try {
const result = await getPdfParse()(buffer);
text = result.text || '';
} catch (err) {
console.error('PDF extraction error:', err);
return {
status: 400,
error: 'BAD_REQUEST',
message: 'Не удалось обработать PDF-файл',
};
}
if (!text || text.trim().length === 0) {
return {
status: 400,
error: 'BAD_REQUEST',
message: 'Не удалось извлечь текст из PDF',
};
}
const openai = new OpenAI({
apiKey: config.llmApiKey,
...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }),
timeout: 5 * 60 * 1000,
});
try {
const completion = await openai.chat.completions.create({
model: config.llmModel,
messages: [
{ role: 'system', content: PDF2JSON_PROMPT },
{ role: 'user', content: `Текст выписки:\n\n${text}` },
],
temperature: 0,
max_tokens: 32768,
});
const content = completion.choices[0]?.message?.content?.trim();
if (!content) {
return {
status: 422,
error: 'VALIDATION_ERROR',
message: 'Результат конвертации пуст',
};
}
return parseConversionResult(content);
} catch (err) {
console.error('LLM conversion error:', err);
return {
status: 502,
error: 'BAD_GATEWAY',
message: 'Временная ошибка конвертации',
};
}
}
export type ProgressStage = 'pdf' | 'llm' | 'import';
export type OnProgress = (stage: ProgressStage, progress: number, message: string) => void;
const LLM_PROGRESS_MIN = 10;
const LLM_PROGRESS_MAX = 85;
const LLM_PROGRESS_RANGE = LLM_PROGRESS_MAX - LLM_PROGRESS_MIN;
const THROTTLE_MS = 300;
export async function convertPdfToStatementStreaming(
buffer: Buffer,
onProgress: OnProgress,
): Promise<StatementFile | PdfConversionError> {
if (!config.llmApiKey || config.llmApiKey.trim() === '') {
return {
status: 503,
error: 'SERVICE_UNAVAILABLE',
message: 'Конвертация PDF недоступна: не задан LLM_API_KEY',
};
}
onProgress('pdf', 2, 'Извлечение текста из PDF...');
let text: string;
try {
const result = await getPdfParse()(buffer);
text = result.text || '';
} catch (err) {
console.error('PDF extraction error:', err);
return {
status: 400,
error: 'BAD_REQUEST',
message: 'Не удалось обработать PDF-файл',
};
}
if (!text || text.trim().length === 0) {
return {
status: 400,
error: 'BAD_REQUEST',
message: 'Не удалось извлечь текст из PDF',
};
}
onProgress('pdf', 8, 'Текст извлечён, отправка в LLM...');
const openai = new OpenAI({
apiKey: config.llmApiKey,
...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }),
timeout: 5 * 60 * 1000,
});
try {
const stream = await openai.chat.completions.create({
model: config.llmModel,
messages: [
{ role: 'system', content: PDF2JSON_PROMPT },
{ role: 'user', content: `Текст выписки:\n\n${text}` },
],
temperature: 0,
max_tokens: 32768,
stream: true,
});
// Estimate expected output size as ~2x the input PDF text length, clamped
const expectedChars = Math.max(2_000, Math.min(text.length * 2, 30_000));
let accumulated = '';
let charsReceived = 0;
let lastEmitTime = 0;
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) {
accumulated += delta;
charsReceived += delta.length;
const now = Date.now();
if (now - lastEmitTime >= THROTTLE_MS) {
const ratio = Math.min(1, charsReceived / expectedChars);
const llmProgress = Math.min(
LLM_PROGRESS_MAX,
Math.round(ratio * LLM_PROGRESS_RANGE + LLM_PROGRESS_MIN),
);
onProgress('llm', llmProgress, 'Конвертация через LLM...');
lastEmitTime = now;
}
}
}
onProgress('llm', LLM_PROGRESS_MAX, 'LLM завершил, обработка результата...');
const content = accumulated.trim();
if (!content) {
return {
status: 422,
error: 'VALIDATION_ERROR',
message: 'Результат конвертации пуст',
};
}
return parseConversionResult(content);
} catch (err) {
console.error('LLM streaming error:', err);
return {
status: 502,
error: 'BAD_GATEWAY',
message: 'Временная ошибка конвертации',
};
}
}
function parseConversionResult(content: string): StatementFile | PdfConversionError {
const jsonMatch = content.match(/\{[\s\S]*\}/);
const jsonStr = jsonMatch ? jsonMatch[0] : content;
let parsed: unknown;
try {
parsed = JSON.parse(jsonStr);
} catch {
return {
status: 422,
error: 'VALIDATION_ERROR',
message: 'Результат конвертации не является валидным JSON',
};
}
const data = parsed as Record<string, unknown>;
if (data.schemaVersion !== '1.0') {
return {
status: 422,
error: 'VALIDATION_ERROR',
message: 'Результат конвертации не соответствует схеме 1.0',
};
}
return parsed as StatementFile;
}