Compare commits
25 Commits
50154f304c
...
feat/add-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba3105bbe5 | ||
| f32a21f87a | |||
|
|
8b57dd987e | ||
| ea234ea007 | |||
|
|
db4d5e4d00 | ||
| 358fcaeff5 | |||
|
|
67fed57118 | ||
| 45a6f3d374 | |||
|
|
aaf8cacf75 | ||
|
|
e28d0f46d0 | ||
| 22be09c101 | |||
|
|
78c4730196 | ||
| f2d0c91488 | |||
|
|
1d7fbea9ef | ||
|
|
627706228b | ||
|
|
a5f2294440 | ||
| 25ddd6b7ed | |||
|
|
3e481d5a55 | ||
| cf24d5dc26 | |||
|
|
723df494ca | ||
| 5fa6b921d8 | |||
|
|
feb756cfe2 | ||
|
|
b598216d24 | ||
| 0638885812 | |||
|
|
975f2c4fd2 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,3 +22,4 @@ jan-feb.json
|
|||||||
history.xlsx
|
history.xlsx
|
||||||
match_analysis.py
|
match_analysis.py
|
||||||
match_report.txt
|
match_report.txt
|
||||||
|
statements/
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ COPY package.json package-lock.json* ./
|
|||||||
COPY shared ./shared
|
COPY shared ./shared
|
||||||
COPY backend ./backend
|
COPY backend ./backend
|
||||||
|
|
||||||
|
# Увеличенные таймауты для нестабильной сети
|
||||||
|
RUN npm config set fetch-retry-mintimeout 20000 && \
|
||||||
|
npm config set fetch-retry-maxtimeout 120000 && \
|
||||||
|
npm config set fetch-timeout 300000
|
||||||
|
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
FROM node:20-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
@@ -25,6 +30,7 @@ ENV NODE_ENV=production
|
|||||||
COPY --from=build /app/backend/dist ./dist
|
COPY --from=build /app/backend/dist ./dist
|
||||||
COPY --from=build /app/backend/package.json ./package.json
|
COPY --from=build /app/backend/package.json ./package.json
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/backend/node_modules/ ./node_modules/
|
||||||
COPY backend/.env .env
|
COPY backend/.env .env
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ COPY package.json package-lock.json* tsconfig.json* ./
|
|||||||
COPY shared ./shared
|
COPY shared ./shared
|
||||||
COPY frontend ./frontend
|
COPY frontend ./frontend
|
||||||
|
|
||||||
|
# Увеличиваем таймауты и повторы для нестабильной сети
|
||||||
|
RUN npm config set fetch-retry-mintimeout 20000 && \
|
||||||
|
npm config set fetch-retry-maxtimeout 120000 && \
|
||||||
|
npm config set fetch-timeout 300000
|
||||||
|
|
||||||
# Устанавливаем зависимости из корня
|
# Устанавливаем зависимости из корня
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,14 @@ APP_USER_PASSWORD=changeme
|
|||||||
SESSION_TIMEOUT_MS=10800000
|
SESSION_TIMEOUT_MS=10800000
|
||||||
|
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
# Ключ OpenAI API для конвертации PDF-выписок в JSON (опционально)
|
||||||
|
# Без него импорт PDF будет недоступен (503)
|
||||||
|
LLM_API_KEY=
|
||||||
|
|
||||||
|
# Базовый URL API LLM (опционально). По умолчанию https://api.openai.com
|
||||||
|
# Примеры: Ollama — http://localhost:11434/v1, Azure — https://YOUR_RESOURCE.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT
|
||||||
|
LLM_API_BASE_URL=
|
||||||
|
|
||||||
|
# Имя модели LLM (опционально). Для OpenAI: gpt-4o-mini. Для Ollama: qwen2.5:7b, qwen3:7b
|
||||||
|
LLM_MODEL=
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ npm run dev -w backend
|
|||||||
| `APP_USER_PASSWORD` | `changeme` | Пароль для входа в приложение |
|
| `APP_USER_PASSWORD` | `changeme` | Пароль для входа в приложение |
|
||||||
| `SESSION_TIMEOUT_MS` | `10800000` | Таймаут сессии по бездействию (3 часа) |
|
| `SESSION_TIMEOUT_MS` | `10800000` | Таймаут сессии по бездействию (3 часа) |
|
||||||
| `PORT` | `3000` | Порт HTTP-сервера |
|
| `PORT` | `3000` | Порт HTTP-сервера |
|
||||||
|
| `LLM_API_KEY` | — | Ключ OpenAI API для конвертации PDF в JSON; без него импорт PDF возвращает 503 |
|
||||||
|
| `LLM_API_BASE_URL` | `https://api.openai.com/v1` | Адрес LLM API (OpenAI-совместимый); для локальной модели, напр. Ollama: `http://localhost:11434/v1` |
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
@@ -61,6 +63,7 @@ backend/src/
|
|||||||
├── services/
|
├── services/
|
||||||
│ ├── auth.ts — login / logout / me
|
│ ├── auth.ts — login / logout / me
|
||||||
│ ├── import.ts — валидация, fingerprint, direction, атомарный импорт
|
│ ├── import.ts — валидация, fingerprint, direction, атомарный импорт
|
||||||
|
│ ├── pdfToStatement.ts — конвертация PDF → JSON 1.0 через LLM (OpenAI)
|
||||||
│ ├── transactions.ts — список с фильтрами + обновление (categoryId, comment)
|
│ ├── transactions.ts — список с фильтрами + обновление (categoryId, comment)
|
||||||
│ ├── accounts.ts — список счетов, обновление алиаса
|
│ ├── accounts.ts — список счетов, обновление алиаса
|
||||||
│ ├── categories.ts — список категорий (фильтр isActive)
|
│ ├── categories.ts — список категорий (фильтр isActive)
|
||||||
@@ -90,7 +93,7 @@ backend/src/
|
|||||||
|
|
||||||
| Метод | URL | Описание |
|
| Метод | URL | Описание |
|
||||||
|--------|----------------------------|-----------------------------------------|
|
|--------|----------------------------|-----------------------------------------|
|
||||||
| POST | `/api/import/statement` | Импорт банковской выписки (JSON 1.0) |
|
| POST | `/api/import/statement` | Импорт банковской выписки (PDF или JSON 1.0; PDF конвертируется через LLM) |
|
||||||
|
|
||||||
### Транзакции
|
### Транзакции
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"start": "node dist/app.js",
|
"start": "node dist/app.js",
|
||||||
"migrate": "tsx src/db/migrate.ts",
|
"migrate": "tsx src/db/migrate.ts",
|
||||||
"migrate:prod": "node dist/db/migrate.js",
|
"migrate:prod": "node dist/db/migrate.js",
|
||||||
"migrate:prod": "node dist/db/migrate.js"
|
"test:llm": "tsx src/scripts/testLlm.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@family-budget/shared": "*",
|
"@family-budget/shared": "*",
|
||||||
@@ -16,6 +16,9 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"multer": "^2.1.1",
|
||||||
|
"openai": "^6.27.0",
|
||||||
|
"pdf-parse": "1.1.1",
|
||||||
"pg": "^8.19.0",
|
"pg": "^8.19.0",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
@@ -23,6 +26,7 @@
|
|||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^25.3.3",
|
"@types/node": "^25.3.3",
|
||||||
"@types/pg": "^8.18.0",
|
"@types/pg": "^8.18.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
|||||||
@@ -18,4 +18,13 @@ export const config = {
|
|||||||
appUserPassword: process.env.APP_USER_PASSWORD || 'changeme',
|
appUserPassword: process.env.APP_USER_PASSWORD || 'changeme',
|
||||||
|
|
||||||
sessionTimeoutMs: parseInt(process.env.SESSION_TIMEOUT_MS || '10800000', 10),
|
sessionTimeoutMs: parseInt(process.env.SESSION_TIMEOUT_MS || '10800000', 10),
|
||||||
|
|
||||||
|
/** API-ключ для LLM (OpenAI или совместимый). Обязателен для конвертации PDF. */
|
||||||
|
llmApiKey: process.env.LLM_API_KEY || '',
|
||||||
|
|
||||||
|
/** Базовый URL API LLM. По умолчанию https://api.openai.com. Для Ollama: http://localhost:11434/v1 */
|
||||||
|
llmApiBaseUrl: process.env.LLM_API_BASE_URL || undefined,
|
||||||
|
|
||||||
|
/** Имя модели LLM. Для OpenAI: gpt-4o-mini. Для Ollama: qwen2.5:7b, qwen3:7b и т.п. */
|
||||||
|
llmModel: process.env.LLM_MODEL || 'gpt-4o-mini',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ export const pool = new Pool({
|
|||||||
database: config.db.database,
|
database: config.db.database,
|
||||||
user: config.db.user,
|
user: config.db.user,
|
||||||
password: config.db.password,
|
password: config.db.password,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,81 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
import { asyncHandler } from '../utils';
|
import { asyncHandler } from '../utils';
|
||||||
import { importStatement, isValidationError } from '../services/import';
|
import { importStatement, isValidationError } from '../services/import';
|
||||||
|
import {
|
||||||
|
convertPdfToStatement,
|
||||||
|
isPdfConversionError,
|
||||||
|
} from '../services/pdfToStatement';
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 15 * 1024 * 1024 },
|
||||||
|
});
|
||||||
|
|
||||||
|
function isPdfFile(file: { mimetype: string; originalname: string }): boolean {
|
||||||
|
const name = file.originalname.toLowerCase();
|
||||||
|
return (
|
||||||
|
file.mimetype === 'application/pdf' ||
|
||||||
|
name.endsWith('.pdf')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonFile(file: { mimetype: string; originalname: string }): boolean {
|
||||||
|
const name = file.originalname.toLowerCase();
|
||||||
|
return (
|
||||||
|
file.mimetype === 'application/json' ||
|
||||||
|
name.endsWith('.json')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/statement',
|
'/statement',
|
||||||
|
upload.single('file'),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const result = await importStatement(req.body);
|
const file = req.file;
|
||||||
|
if (!file) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'BAD_REQUEST',
|
||||||
|
message: 'Файл не загружен',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPdfFile(file) && !isJsonFile(file)) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'BAD_REQUEST',
|
||||||
|
message: 'Допустимы только файлы PDF или JSON',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
|
||||||
|
if (isPdfFile(file)) {
|
||||||
|
const converted = await convertPdfToStatement(file.buffer);
|
||||||
|
if (isPdfConversionError(converted)) {
|
||||||
|
res.status(converted.status).json({
|
||||||
|
error: converted.error,
|
||||||
|
message: converted.message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body = converted;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
body = JSON.parse(file.buffer.toString('utf-8'));
|
||||||
|
} catch {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'BAD_REQUEST',
|
||||||
|
message: 'Некорректный JSON-файл',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await importStatement(body);
|
||||||
if (isValidationError(result)) {
|
if (isValidationError(result)) {
|
||||||
res.status((result as { status: number }).status).json({
|
res.status((result as { status: number }).status).json({
|
||||||
error: (result as { error: string }).error,
|
error: (result as { error: string }).error,
|
||||||
|
|||||||
42
backend/src/scripts/testLlm.ts
Normal file
42
backend/src/scripts/testLlm.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Тестовый запрос к LLM серверу.
|
||||||
|
* Запуск: npm run test:llm
|
||||||
|
*/
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!config.llmApiKey || config.llmApiKey.trim() === '') {
|
||||||
|
console.error('Ошибка: LLM_API_KEY не задан в .env');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: config.llmApiKey,
|
||||||
|
...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = config.llmApiBaseUrl || 'https://api.openai.com/v1';
|
||||||
|
console.log('LLM сервер:', url);
|
||||||
|
console.log('Модель:', config.llmModel);
|
||||||
|
console.log('---');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const completion = await openai.chat.completions.create({
|
||||||
|
model: config.llmModel,
|
||||||
|
messages: [{ role: 'user', content: 'Ответь одним словом: какой цвет у неба?' }],
|
||||||
|
temperature: 0,
|
||||||
|
max_tokens: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = completion.choices[0]?.message?.content;
|
||||||
|
console.log('Ответ:', content || '(пусто)');
|
||||||
|
console.log('---');
|
||||||
|
console.log('OK');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка:', err instanceof Error ? err.message : err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
179
backend/src/services/pdfToStatement.ts
Normal file
179
backend/src/services/pdfToStatement.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
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: 'Результат конвертации пуст',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('LLM conversion error:', err);
|
||||||
|
return {
|
||||||
|
status: 502,
|
||||||
|
error: 'BAD_GATEWAY',
|
||||||
|
message: extractLlmErrorMessage(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLlmErrorMessage(err: unknown): string {
|
||||||
|
const raw = String(
|
||||||
|
(err as Record<string, unknown>)?.message ??
|
||||||
|
(err as Record<string, Record<string, unknown>>)?.error?.message ?? '',
|
||||||
|
);
|
||||||
|
if (/context.length|n_ctx|too.many.tokens|maximum.context/i.test(raw)) {
|
||||||
|
return 'PDF-файл слишком большой для обработки. Попробуйте файл с меньшим количеством операций или используйте модель с большим контекстным окном.';
|
||||||
|
}
|
||||||
|
if (/timeout|timed?\s*out|ETIMEDOUT|ECONNREFUSED/i.test(raw)) {
|
||||||
|
return 'LLM-сервер не отвечает. Проверьте, что сервер запущен и доступен.';
|
||||||
|
}
|
||||||
|
return 'Временная ошибка конвертации';
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Назначение
|
## Назначение
|
||||||
|
|
||||||
Эндпоинт принимает банковскую выписку в формате JSON 1.0 (см. `format.md`) и атомарно импортирует транзакции в БД.
|
Эндпоинт принимает банковскую выписку (PDF или JSON) и атомарно импортирует транзакции в БД.
|
||||||
|
|
||||||
## Метод и URL
|
## Метод и URL
|
||||||
|
|
||||||
@@ -16,7 +16,15 @@
|
|||||||
|
|
||||||
## Тело запроса
|
## Тело запроса
|
||||||
|
|
||||||
JSON строго по схеме 1.0 (`format.md`). Content-Type: `application/json`.
|
**Content-Type:** `multipart/form-data`. Поле: `file`.
|
||||||
|
|
||||||
|
Допустимые типы файлов:
|
||||||
|
- **PDF** — банковская выписка. Конвертируется в JSON 1.0 через LLM (требуется `LLM_API_KEY`).
|
||||||
|
- **JSON** — файл по схеме 1.0 (см. `format.md`).
|
||||||
|
|
||||||
|
При другом типе файла — `400 Bad Request`: «Допустимы только файлы PDF или JSON».
|
||||||
|
|
||||||
|
Пример структуры JSON 1.0:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -207,6 +215,8 @@ accountNumber|operationAt|amountSigned|commission|normalizedDescription
|
|||||||
| Код | Ситуация |
|
| Код | Ситуация |
|
||||||
|-----|--------------------------------------------------------------------------|
|
|-----|--------------------------------------------------------------------------|
|
||||||
| 200 | Импорт выполнен успешно |
|
| 200 | Импорт выполнен успешно |
|
||||||
| 400 | Невалидный JSON или нарушение структуры схемы 1.0 |
|
| 400 | Файл не загружен; неверный тип (не PDF и не JSON); некорректный JSON; ошибка извлечения текста из PDF |
|
||||||
| 401 | Нет действующей сессии |
|
| 401 | Нет действующей сессии |
|
||||||
| 422 | Семантическая ошибка валидации (некорректные данные, ошибка при вставке) |
|
| 422 | Семантическая ошибка валидации; результат конвертации PDF не соответствует схеме 1.0 |
|
||||||
|
| 502 | Ошибка LLM при конвертации PDF |
|
||||||
|
| 503 | Конвертация PDF недоступна (не задан LLM_API_KEY) |
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0f172a" />
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<title>Семейный бюджет</title>
|
<title>Семейный бюджет</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
@@ -3,6 +3,20 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Import endpoint — long timeout for LLM processing
|
||||||
|
location /api/import {
|
||||||
|
proxy_pass http://family-budget-backend:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cookie_path / /;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
client_max_body_size 15m;
|
||||||
|
}
|
||||||
|
|
||||||
# API — проксируем на backend (сервис из docker-compose)
|
# API — проксируем на backend (сервис из docker-compose)
|
||||||
location /api {
|
location /api {
|
||||||
proxy_pass http://family-budget-backend:3000;
|
proxy_pass http://family-budget-backend:3000;
|
||||||
|
|||||||
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#0f172a"/>
|
||||||
|
<text x="16" y="23" font-family="Georgia, serif" font-size="20" font-weight="bold" fill="#3b82f6" text-anchor="middle">₽</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 271 B |
@@ -53,6 +53,40 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestFormData<T>(url: string, formData: FormData): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include',
|
||||||
|
// Do not set Content-Type — browser sets multipart/form-data with boundary
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
let body: ApiError;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
body = { error: 'UNAUTHORIZED', message: 'Сессия истекла' };
|
||||||
|
}
|
||||||
|
if (!url.includes('/api/auth/login')) {
|
||||||
|
onUnauthorized?.();
|
||||||
|
}
|
||||||
|
throw new ApiException(401, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let body: ApiError;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
body = { error: 'UNKNOWN', message: res.statusText };
|
||||||
|
}
|
||||||
|
throw new ApiException(res.status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(url: string) => request<T>(url),
|
get: <T>(url: string) => request<T>(url),
|
||||||
|
|
||||||
@@ -62,6 +96,9 @@ export const api = {
|
|||||||
body: body != null ? JSON.stringify(body) : undefined,
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
postFormData: <T>(url: string, formData: FormData) =>
|
||||||
|
requestFormData<T>(url, formData),
|
||||||
|
|
||||||
put: <T>(url: string, body: unknown) =>
|
put: <T>(url: string, body: unknown) =>
|
||||||
request<T>(url, { method: 'PUT', body: JSON.stringify(body) }),
|
request<T>(url, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import type { ImportStatementResponse } from '@family-budget/shared';
|
|||||||
import { api } from './client';
|
import { api } from './client';
|
||||||
|
|
||||||
export async function importStatement(
|
export async function importStatement(
|
||||||
file: unknown,
|
file: File,
|
||||||
): Promise<ImportStatementResponse> {
|
): Promise<ImportStatementResponse> {
|
||||||
return api.post<ImportStatementResponse>('/api/import/statement', file);
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
return api.postFormData<ImportStatementResponse>(
|
||||||
|
'/api/import/statement',
|
||||||
|
formData,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,23 +22,27 @@ export function ImportModal({ onClose, onDone }: Props) {
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
const type = file.type;
|
||||||
|
const isPdf = type === 'application/pdf' || name.endsWith('.pdf');
|
||||||
|
const isJson = type === 'application/json' || name.endsWith('.json');
|
||||||
|
|
||||||
|
if (!isPdf && !isJson) {
|
||||||
|
setError('Допустимы только файлы PDF или JSON');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const resp = await importStatement(file);
|
||||||
const json = JSON.parse(text);
|
|
||||||
const resp = await importStatement(json);
|
|
||||||
setResult(resp);
|
setResult(resp);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof SyntaxError) {
|
const msg =
|
||||||
setError('Некорректный JSON-файл');
|
err instanceof Error ? err.message : 'Ошибка импорта';
|
||||||
} else {
|
setError(msg);
|
||||||
const msg =
|
|
||||||
err instanceof Error ? err.message : 'Ошибка импорта';
|
|
||||||
setError(msg);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -69,11 +73,11 @@ export function ImportModal({ onClose, onDone }: Props) {
|
|||||||
|
|
||||||
{!result && (
|
{!result && (
|
||||||
<div className="import-upload">
|
<div className="import-upload">
|
||||||
<p>Выберите JSON-файл выписки (формат 1.0)</p>
|
<p>Выберите файл выписки (PDF или JSON, формат 1.0)</p>
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".json"
|
accept=".pdf,.json,application/pdf,application/json"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
className="file-input"
|
className="file-input"
|
||||||
/>
|
/>
|
||||||
@@ -85,7 +89,7 @@ export function ImportModal({ onClose, onDone }: Props) {
|
|||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className="import-result">
|
<div className="import-result">
|
||||||
<div className="import-result-icon">✓</div>
|
<div className="import-result-icon" aria-hidden="true">✓</div>
|
||||||
<h3>Импорт завершён</h3>
|
<h3>Импорт завершён</h3>
|
||||||
<table className="import-stats">
|
<table className="import-stats">
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
252
package-lock.json
generated
252
package-lock.json
generated
@@ -22,6 +22,9 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"multer": "^2.1.1",
|
||||||
|
"openai": "^6.27.0",
|
||||||
|
"pdf-parse": "1.1.1",
|
||||||
"pg": "^8.19.0",
|
"pg": "^8.19.0",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
@@ -29,6 +32,7 @@
|
|||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^25.3.3",
|
"@types/node": "^25.3.3",
|
||||||
"@types/pg": "^8.18.0",
|
"@types/pg": "^8.18.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
@@ -36,6 +40,28 @@
|
|||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"backend/node_modules/debug": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backend/node_modules/pdf-parse": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^3.1.0",
|
||||||
|
"node-ensure": "^0.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"name": "@family-budget/frontend",
|
"name": "@family-budget/frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -1394,6 +1420,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/multer": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/express": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.3.3",
|
"version": "25.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||||
@@ -1512,6 +1548,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/append-field": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||||
@@ -1583,6 +1625,23 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-from": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/busboy": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||||
|
"dependencies": {
|
||||||
|
"streamsearch": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -1651,6 +1710,21 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/concat-stream": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
|
"engines": [
|
||||||
|
"node >= 6.0"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-from": "^1.0.0",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.0.2",
|
||||||
|
"typedarray": "^0.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||||
@@ -2470,6 +2544,68 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/multer": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"append-field": "^1.0.0",
|
||||||
|
"busboy": "^1.6.0",
|
||||||
|
"concat-stream": "^2.0.0",
|
||||||
|
"type-is": "^1.6.18"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/multer/node_modules/media-typer": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/multer/node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/multer/node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/multer/node_modules/type-is": {
|
||||||
|
"version": "1.6.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
|
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"media-typer": "0.3.0",
|
||||||
|
"mime-types": "~2.1.24"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -2498,6 +2634,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-ensure": {
|
||||||
|
"version": "0.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
|
||||||
|
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.27",
|
"version": "2.0.27",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||||
@@ -2547,6 +2689,27 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openai": {
|
||||||
|
"version": "6.27.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/openai/-/openai-6.27.0.tgz",
|
||||||
|
"integrity": "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"openai": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"zod": "^3.25 || ^4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"ws": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -2931,6 +3094,20 @@
|
|||||||
"react-dom": ">=16.6.0"
|
"react-dom": ">=16.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/recharts": {
|
"node_modules/recharts": {
|
||||||
"version": "2.15.4",
|
"version": "2.15.4",
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||||
@@ -3034,6 +3211,26 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
@@ -3213,6 +3410,23 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/streamsearch": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tiny-invariant": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
@@ -3278,7 +3492,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"aix"
|
"aix"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3296,7 +3509,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3314,7 +3526,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3332,7 +3543,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3350,7 +3560,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3368,7 +3577,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3386,7 +3594,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3404,7 +3611,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3422,7 +3628,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3440,7 +3645,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3458,7 +3662,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3476,7 +3679,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3494,7 +3696,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3512,7 +3713,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3530,7 +3730,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3548,7 +3747,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3566,7 +3764,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3584,7 +3781,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3602,7 +3798,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3620,7 +3815,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3638,7 +3832,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3656,7 +3849,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"openharmony"
|
"openharmony"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3674,7 +3866,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"sunos"
|
"sunos"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3692,7 +3883,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3710,7 +3900,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3728,7 +3917,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3789,6 +3977,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typedarray": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -3850,6 +4044,12 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "13.0.0",
|
"version": "13.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
# Промпт для конвертации PDF банковской выписки в JSON
|
|
||||||
|
|
||||||
Ты — конвертер банковских выписок. Твоя задача: извлечь данные из прикреплённого PDF банковской выписки и вернуть строго один валидный JSON-объект в формате ниже. Никакого текста до или после JSON, только сам объект.
|
Ты — конвертер банковских выписок. Твоя задача: извлечь данные из прикреплённого PDF банковской выписки и вернуть строго один валидный JSON-объект в формате ниже. Никакого текста до или после JSON, только сам объект.
|
||||||
|
|
||||||
## Структура выходного JSON
|
## Структура выходного JSON
|
||||||
|
|||||||
Reference in New Issue
Block a user