From 97ec7cd7f8be120bf6cc51376d34b63c35a718c6 Mon Sep 17 00:00:00 2001 From: mstfyldz Date: Tue, 11 Aug 2026 10:12:33 +0300 Subject: [PATCH] feat: DeepSeek-OCR endpoint + GPU request queue for AI calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mizan ve DeepSeek-OCR aynı self-hosted GPU'yu paylaşıyor; backend'de hiçbir eşzamanlılık sınırı yoktu, yoğun anlarda istekler Ollama kuyruğunda 90sn'yi aşıp sessizce "başarısız" görünüyordu. aiQueue.ts, GPU'ya giden istek sayısını sınırlayıp fazlasını backend'de bekletiyor. Yeni POST /documents/ocr-image, tek bir görseli DeepSeek-OCR ile metne çeviriyor (Electron tarafındaki çok sayfalı TIFF/taranmış PDF işleme adımının kullanacağı uç, ayrı bir PR'da bağlanacak). --- src/controllers/document.controller.ts | 20 ++++- src/lib/aiClient.ts | 109 +++++++++++++++++++++---- src/lib/aiConfig.ts | 8 +- src/lib/aiQueue.ts | 42 ++++++++++ src/routes/document.routes.ts | 3 +- 5 files changed, 162 insertions(+), 20 deletions(-) create mode 100644 src/lib/aiQueue.ts diff --git a/src/controllers/document.controller.ts b/src/controllers/document.controller.ts index fe859e7..e249e69 100644 --- a/src/controllers/document.controller.ts +++ b/src/controllers/document.controller.ts @@ -4,7 +4,7 @@ import { AuthenticatedRequest } from '../middleware/auth'; import * as officeParser from 'officeparser'; import Tesseract from 'tesseract.js'; import { findOrCreateCaseByTitle } from '../lib/caseLookup'; -import { callOllama } from '../lib/aiClient'; +import { callOllama, callOllamaVisionOcr } from '../lib/aiClient'; import { getAiConfig } from '../lib/aiConfig'; const heicConvert = require('heic-convert'); @@ -152,6 +152,24 @@ export const registerLocalDocument = async (req: AuthenticatedRequest, res: Resp } }; +// Electron'daki extractText.js'in (taranmış görsel/TIFF sayfa/render edilmiş PDF +// sayfası için) çağırdığı tek-sayfa OCR ucu. Tek bir görsel alır, DeepSeek-OCR ile +// metne çevirir, sadece metni döner — sayfa birleştirme/dosya kaydı Electron +// tarafında (extractText.js) yapılıyor, bu uç sadece OCR işini yapıyor. +export const ocrImage = async (req: AuthenticatedRequest, res: Response) => { + try { + const { image_base64 } = req.body || {}; + if (!image_base64 || typeof image_base64 !== 'string') { + return res.status(400).json({ error: 'image_base64 zorunludur.' }); + } + const text = await callOllamaVisionOcr(image_base64); + res.json({ text }); + } catch (error: any) { + console.error('OCR Image Error:', error); + res.status(500).json({ error: error.message || 'Internal Server Error' }); + } +}; + // uploadDocument ve uploadTemplate'in (template.controller.ts) ortak kullandığı // metin çıkarma adımı: resimler için Tesseract OCR, ofis belgeleri için officeParser. export async function extractTextFromUploadedFile(file: { buffer: Buffer; mimetype: string; originalname: string }): Promise { diff --git a/src/lib/aiClient.ts b/src/lib/aiClient.ts index 1e40bea..7271966 100644 --- a/src/lib/aiClient.ts +++ b/src/lib/aiClient.ts @@ -1,4 +1,5 @@ import { getAiConfig } from './aiConfig'; +import { withAiQueue } from './aiQueue'; export interface ChatMsg { role: string; content: string; } @@ -35,24 +36,29 @@ export async function callOllama( messages: ChatMsg[], opts: { numPredict?: number; timeoutMs?: number } = {} ): Promise { - const { runpodUrl, aiModel } = getAiConfig(); - const ctrl = new AbortController(); - const timeoutId = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); - try { - const response = await fetch(`${runpodUrl}/api/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(buildBody(messages, aiModel, false, opts.numPredict ?? DEFAULT_NUM_PREDICT)), - signal: ctrl.signal, - }); - if (!response.ok) { - throw new Error(`AI Model request failed: ${response.statusText}`); + // withAiQueue'nun İÇİNDE: zaman aşımı sayacı, istek GPU sırasında beklerken + // değil, gerçekten gönderilmeye başladığında işlemeye başlasın — yoksa kuyrukta + // uzun bekleyen bir istek, sırası gelir gelmez anında "zaman aşımı" olurdu. + return withAiQueue(async () => { + const { runpodUrl, aiModel } = getAiConfig(); + const ctrl = new AbortController(); + const timeoutId = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); + try { + const response = await fetch(`${runpodUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildBody(messages, aiModel, false, opts.numPredict ?? DEFAULT_NUM_PREDICT)), + signal: ctrl.signal, + }); + if (!response.ok) { + throw new Error(`AI Model request failed: ${response.statusText}`); + } + const data = await response.json(); + return (data.message?.content || '').toString().trim(); + } finally { + clearTimeout(timeoutId); } - const data = await response.json(); - return (data.message?.content || '').toString().trim(); - } finally { - clearTimeout(timeoutId); - } + }); } // Akışlı (streaming) çağrı — kullanıcının yanıtı token token canlı gördüğü @@ -66,6 +72,14 @@ export async function streamOllama( messages: ChatMsg[], onToken: (delta: string) => void, opts: { numPredict?: number; stallTimeoutMs?: number } = {} +): Promise { + return withAiQueue(() => streamOllamaInner(messages, onToken, opts)); +} + +async function streamOllamaInner( + messages: ChatMsg[], + onToken: (delta: string) => void, + opts: { numPredict?: number; stallTimeoutMs?: number } = {} ): Promise { const { runpodUrl, aiModel } = getAiConfig(); const stallMs = opts.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS; @@ -131,3 +145,64 @@ export async function streamOllama( clearTimeout(stallTimer!); } } + +const DEFAULT_OCR_NUM_PREDICT = 1200; +const DEFAULT_OCR_TIMEOUT_MS = 60_000; + +// DeepSeek-OCR ("deepseek-ocr-fixed") ile bir görseli metne çevirir. Mizan'dan +// (sohbet/analiz) tamamen ayrı bir model — aynı Ollama sunucusunda, farklı +// model adıyla çağrılıyor, withAiQueue ikisini de aynı GPU sırasında bekletiyor. +// +// NOT — "deepseek-ocr-fixed" modeli Ollama kütüphanesindeki resmi paketten +// FARKLI: resmi paketin şablonu ("template": "{{ .Prompt }}") görsel için hiçbir +// yer tutucu içermiyordu, bu yüzden model gönderilen görseli hiç görmüyordu. +// Sunucuda elle şu şekilde düzeltilmiş bir kopya oluşturuldu: +// +// curl -X POST $RUNPOD_API_BASE_URL/api/create -d '{ +// "model": "deepseek-ocr-fixed", +// "from": "deepseek-ocr", +// "template": "{{ if .Images }}{{ range .Images }}[img-0]{{ end }}\n{{ end }}{{ .Prompt }}", +// "parameters": { "temperature": 0 } +// }' +// +// Ollama'nın model verisi silinir/sıfırlanırsa (örn. volume resetlenirse) bu +// komut tekrar çalıştırılmalı — yoksa OCR istekleri sessizce boş metin döner. +export async function callOllamaVisionOcr( + imageBase64: string, + opts: { numPredict?: number; timeoutMs?: number; prompt?: string } = {} +): Promise { + return withAiQueue(async () => { + const { runpodUrl, ocrModel } = getAiConfig(); + const ctrl = new AbortController(); + const timeoutId = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? DEFAULT_OCR_TIMEOUT_MS); + try { + const response = await fetch(`${runpodUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: ocrModel, + messages: [ + { + role: 'user', + content: opts.prompt || '\nFree OCR.', + images: [imageBase64], + }, + ], + think: false, + stream: false, + keep_alive: KEEP_ALIVE, + options: { num_predict: opts.numPredict ?? DEFAULT_OCR_NUM_PREDICT }, + }), + signal: ctrl.signal, + }); + if (!response.ok) { + const errText = await response.text().catch(() => ''); + throw new Error(`OCR model request failed: ${response.statusText} ${errText}`.trim()); + } + const data = await response.json(); + return (data.message?.content || '').toString().trim(); + } finally { + clearTimeout(timeoutId); + } + }); +} diff --git a/src/lib/aiConfig.ts b/src/lib/aiConfig.ts index 309c802..cb61157 100644 --- a/src/lib/aiConfig.ts +++ b/src/lib/aiConfig.ts @@ -8,5 +8,11 @@ export function getAiConfig() { throw new Error('RUNPOD_API_BASE_URL ortam değişkeni tanımlı değil.'); } const aiModel = process.env.AI_MODEL || 'mizan-fixed'; - return { runpodUrl, aiModel }; + // Ollama'daki resmi "deepseek-ocr" paketinin şablonu görsel yer tutucusu + // içermiyordu (bkz. /api/show → "template": "{{ .Prompt }}") — bu yüzden + // aynı sunucuda, düzeltilmiş şablonla "deepseek-ocr-fixed" adında ayrı bir + // model oluşturduk (/api/create ile). Ollama'nın model verisi silinir/sıfırlanırsa + // bu adım tekrarlanmalı — komut için ocrClient.ts'teki yorum satırına bakın. + const ocrModel = process.env.OCR_MODEL || 'deepseek-ocr-fixed'; + return { runpodUrl, aiModel, ocrModel }; } diff --git a/src/lib/aiQueue.ts b/src/lib/aiQueue.ts new file mode 100644 index 0000000..ad6e2e2 --- /dev/null +++ b/src/lib/aiQueue.ts @@ -0,0 +1,42 @@ +// Tek bir self-hosted GPU'ya (RunPod) gidiyoruz — Mizan (sohbet/analiz) ve +// DeepSeek-OCR (görsel OCR) aynı kartı paylaşıyor. Backend'de hiçbir eşzamanlılık +// sınırı yoksa, çok sayıda kullanıcı aynı anda istek atarsa hepsi anında Ollama'ya +// gidiyor — bu da kuyrukta 90 saniyeyi aşıp "başarısız" olarak görünmesine yol +// açabiliyor. Bu modül, aynı anda GPU'ya giden istek sayısını sınırlayıp fazlasını +// burada (backend'de) bekletiyor — kullanıcı "başarısız" yerine sadece biraz +// daha geç bir sonuç görüyor. +const MAX_CONCURRENT = parseInt(process.env.AI_MAX_CONCURRENT || '4', 10); + +let active = 0; +const waiting: Array<() => void> = []; + +function acquire(): Promise { + if (active < MAX_CONCURRENT) { + active++; + return Promise.resolve(); + } + return new Promise((resolve) => waiting.push(resolve)); +} + +function release() { + active--; + const next = waiting.shift(); + if (next) { + active++; + next(); + } +} + +export async function withAiQueue(fn: () => Promise): Promise { + await acquire(); + try { + return await fn(); + } finally { + release(); + } +} + +// Ayarlar/monitoring için — kuyrukta kaç istek beklediğini dışarı açar. +export function getAiQueueStatus() { + return { active, waiting: waiting.length, max: MAX_CONCURRENT }; +} diff --git a/src/routes/document.routes.ts b/src/routes/document.routes.ts index 4909ec5..3b7b9f9 100644 --- a/src/routes/document.routes.ts +++ b/src/routes/document.routes.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import multer from 'multer'; import { requireAuth } from '../middleware/auth'; -import { uploadDocument, registerLocalDocument, extractAttachmentText } from '../controllers/document.controller'; +import { uploadDocument, registerLocalDocument, extractAttachmentText, ocrImage } from '../controllers/document.controller'; const router = Router(); const upload = multer({ storage: multer.memoryStorage() }); @@ -9,5 +9,6 @@ const upload = multer({ storage: multer.memoryStorage() }); router.post('/upload', upload.single('file'), requireAuth, uploadDocument); router.post('/register-local', requireAuth, registerLocalDocument); router.post('/extract-text', upload.single('file'), requireAuth, extractAttachmentText); +router.post('/ocr-image', requireAuth, ocrImage); export default router;