From b598216d2429a7fdb5d25297cf9b291d85523fbe Mon Sep 17 00:00:00 2001 From: vakabunga Date: Fri, 13 Mar 2026 23:12:52 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(backend):=20=D0=BD=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=B8=D0=B2=D0=B0=D0=B5=D0=BC=D0=B0=D1=8F=20LLM=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D1=80=D1=82?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20PDF=20=D0=B2=20JSON=20-=20=D0=94?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D0=B0=D1=8F=20LLM=5FMODEL?= =?UTF-8?q?=20=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=20-=20=D0=A3?= =?UTF-8?q?=D0=B2=D0=B5=D0=BB=D0=B8=D1=87=D0=B5=D0=BD=20max=5Ftokens=20?= =?UTF-8?q?=D0=B4=D0=BE=2032768=20=D0=B4=D0=BB=D1=8F=20=D0=BA=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BD=D1=8B=D1=85=20=D0=B2=D1=8B=D0=BF=D0=B8=D1=81=D0=BE?= =?UTF-8?q?=D0=BA=20-=20=D0=92=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=20respo?= =?UTF-8?q?nse=5Fformat:=20json=5Fobject=20-=20=D0=94=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82?= =?UTF-8?q?=20test:llm=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B8=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA=20LLM-=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B5=D1=80=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 3 ++ backend/package.json | 2 +- backend/src/config.ts | 3 ++ backend/src/scripts/testLlm.ts | 42 ++++++++++++++++++++++++++ backend/src/services/pdfToStatement.ts | 4 ++- package-lock.json | 9 ------ 6 files changed, 52 insertions(+), 11 deletions(-) create mode 100644 backend/src/scripts/testLlm.ts diff --git a/backend/.env.example b/backend/.env.example index 9157af9..ca9339c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -18,3 +18,6 @@ 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= diff --git a/backend/package.json b/backend/package.json index 2800bf4..99e30be 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,7 +8,7 @@ "start": "node dist/app.js", "migrate": "tsx src/db/migrate.ts", "migrate:prod": "node dist/db/migrate.js", - "migrate:prod": "node dist/db/migrate.js" + "test:llm": "tsx src/scripts/testLlm.ts" }, "dependencies": { "@family-budget/shared": "*", diff --git a/backend/src/config.ts b/backend/src/config.ts index 376ba6a..984eb48 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -24,4 +24,7 @@ export const config = { /** Базовый 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', }; diff --git a/backend/src/scripts/testLlm.ts b/backend/src/scripts/testLlm.ts new file mode 100644 index 0000000..5a4fecf --- /dev/null +++ b/backend/src/scripts/testLlm.ts @@ -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(); diff --git a/backend/src/services/pdfToStatement.ts b/backend/src/services/pdfToStatement.ts index 120f160..def9b65 100644 --- a/backend/src/services/pdfToStatement.ts +++ b/backend/src/services/pdfToStatement.ts @@ -105,12 +105,14 @@ export async function convertPdfToStatement( try { const completion = await openai.chat.completions.create({ - model: 'gpt-4o-mini', + model: config.llmModel, messages: [ { role: 'system', content: PDF2JSON_PROMPT }, { role: 'user', content: `Текст выписки:\n\n${text}` }, ], temperature: 0, + max_tokens: 32768, + response_format: { type: 'json_object' }, }); const content = completion.choices[0]?.message?.content?.trim(); diff --git a/package-lock.json b/package-lock.json index c6b16d1..9b60560 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,7 +89,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1557,7 +1556,6 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -1636,7 +1634,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1776,7 +1773,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2926,7 +2922,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.12.0", @@ -3024,7 +3019,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3174,7 +3168,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3184,7 +3177,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4296,7 +4288,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", From feb756cfe289bcf00e6523b8a47406884383656c Mon Sep 17 00:00:00 2001 From: vakabunga Date: Sat, 14 Mar 2026 13:37:34 +0300 Subject: [PATCH 2/2] fix(docker): prevent backend crash loop caused by pdf-parse native deps pdf-parse@2.4.5 pulls in @napi-rs/canvas (native Skia binary) which crashes on import in Alpine containers. Moved to lazy require() so the app starts normally and pdf-parse loads only when PDF conversion is actually requested. - Lazy-load pdf-parse in pdfToStatement to avoid startup crash - Add libc6-compat, fontconfig, freetype to Alpine runner stage - Increase npm fetch timeouts in both Dockerfiles for slow networks - Add connectionTimeoutMillis to pg Pool for faster failure detection --- Dockerfile.backend | 9 +++++++++ Dockerfile.frontend | 5 +++++ backend/src/db/pool.ts | 1 + backend/src/services/pdfToStatement.ts | 14 +++++++++++++- frontend/src/components/ImportModal.tsx | 2 +- 5 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Dockerfile.backend b/Dockerfile.backend index 6229d9a..e866de5 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -5,6 +5,11 @@ COPY package.json package-lock.json* ./ COPY shared ./shared 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 FROM node:20-alpine AS build @@ -22,6 +27,10 @@ FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production +# @napi-rs/canvas (dep of pdf-parse) ships a musl pre-built binary that +# needs these compatibility / font libraries at runtime. +RUN apk add --no-cache libc6-compat fontconfig freetype + COPY --from=build /app/backend/dist ./dist COPY --from=build /app/backend/package.json ./package.json COPY --from=build /app/node_modules ./node_modules diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 0b3f1b1..3fdd469 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -9,6 +9,11 @@ COPY package.json package-lock.json* tsconfig.json* ./ COPY shared ./shared 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 diff --git a/backend/src/db/pool.ts b/backend/src/db/pool.ts index 2d5895c..8d64f43 100644 --- a/backend/src/db/pool.ts +++ b/backend/src/db/pool.ts @@ -7,4 +7,5 @@ export const pool = new Pool({ database: config.db.database, user: config.db.user, password: config.db.password, + connectionTimeoutMillis: 5000, }); diff --git a/backend/src/services/pdfToStatement.ts b/backend/src/services/pdfToStatement.ts index def9b65..3f1e16a 100644 --- a/backend/src/services/pdfToStatement.ts +++ b/backend/src/services/pdfToStatement.ts @@ -1,4 +1,3 @@ -import { PDFParse } from 'pdf-parse'; import OpenAI from 'openai'; import { config } from '../config'; import type { StatementFile } from '@family-budget/shared'; @@ -64,6 +63,18 @@ export function isPdfConversionError(r: unknown): r is PdfConversionError { ); } +// Lazy-loaded to avoid crashing the app at startup — pdf-parse pulls in +// @napi-rs/canvas (native Skia binary) which may fail on Alpine. +let _PDFParse: typeof import('pdf-parse')['PDFParse'] | undefined; +function loadPDFParse() { + if (!_PDFParse) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mod = require('pdf-parse') as typeof import('pdf-parse'); + _PDFParse = mod.PDFParse; + } + return _PDFParse; +} + export async function convertPdfToStatement( buffer: Buffer, ): Promise { @@ -77,6 +88,7 @@ export async function convertPdfToStatement( let text: string; try { + const PDFParse = loadPDFParse(); const parser = new PDFParse({ data: buffer }); const result = await parser.getText(); text = result.text || ''; diff --git a/frontend/src/components/ImportModal.tsx b/frontend/src/components/ImportModal.tsx index 8bc7208..107ffda 100644 --- a/frontend/src/components/ImportModal.tsx +++ b/frontend/src/components/ImportModal.tsx @@ -89,7 +89,7 @@ export function ImportModal({ onClose, onDone }: Props) { {result && (
-
+

Импорт завершён