feat: DeepSeek-OCR endpoint + GPU request queue for AI calls

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).
This commit is contained in:
mstfyldz
2026-08-11 10:12:33 +03:00
parent 7915571130
commit 97ec7cd7f8
5 changed files with 162 additions and 20 deletions
+19 -1
View File
@@ -4,7 +4,7 @@ import { AuthenticatedRequest } from '../middleware/auth';
import * as officeParser from 'officeparser'; import * as officeParser from 'officeparser';
import Tesseract from 'tesseract.js'; import Tesseract from 'tesseract.js';
import { findOrCreateCaseByTitle } from '../lib/caseLookup'; import { findOrCreateCaseByTitle } from '../lib/caseLookup';
import { callOllama } from '../lib/aiClient'; import { callOllama, callOllamaVisionOcr } from '../lib/aiClient';
import { getAiConfig } from '../lib/aiConfig'; import { getAiConfig } from '../lib/aiConfig';
const heicConvert = require('heic-convert'); 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ığı // uploadDocument ve uploadTemplate'in (template.controller.ts) ortak kullandığı
// metin çıkarma adımı: resimler için Tesseract OCR, ofis belgeleri için officeParser. // 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<string> { export async function extractTextFromUploadedFile(file: { buffer: Buffer; mimetype: string; originalname: string }): Promise<string> {
+92 -17
View File
@@ -1,4 +1,5 @@
import { getAiConfig } from './aiConfig'; import { getAiConfig } from './aiConfig';
import { withAiQueue } from './aiQueue';
export interface ChatMsg { role: string; content: string; } export interface ChatMsg { role: string; content: string; }
@@ -35,24 +36,29 @@ export async function callOllama(
messages: ChatMsg[], messages: ChatMsg[],
opts: { numPredict?: number; timeoutMs?: number } = {} opts: { numPredict?: number; timeoutMs?: number } = {}
): Promise<string> { ): Promise<string> {
const { runpodUrl, aiModel } = getAiConfig(); // withAiQueue'nun İÇİNDE: zaman aşımı sayacı, istek GPU sırasında beklerken
const ctrl = new AbortController(); // değil, gerçekten gönderilmeye başladığında işlemeye başlasın — yoksa kuyrukta
const timeoutId = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); // uzun bekleyen bir istek, sırası gelir gelmez anında "zaman aşımı" olurdu.
try { return withAiQueue(async () => {
const response = await fetch(`${runpodUrl}/api/chat`, { const { runpodUrl, aiModel } = getAiConfig();
method: 'POST', const ctrl = new AbortController();
headers: { 'Content-Type': 'application/json' }, const timeoutId = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
body: JSON.stringify(buildBody(messages, aiModel, false, opts.numPredict ?? DEFAULT_NUM_PREDICT)), try {
signal: ctrl.signal, const response = await fetch(`${runpodUrl}/api/chat`, {
}); method: 'POST',
if (!response.ok) { headers: { 'Content-Type': 'application/json' },
throw new Error(`AI Model request failed: ${response.statusText}`); 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üğü // 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[], messages: ChatMsg[],
onToken: (delta: string) => void, onToken: (delta: string) => void,
opts: { numPredict?: number; stallTimeoutMs?: number } = {} opts: { numPredict?: number; stallTimeoutMs?: number } = {}
): Promise<string> {
return withAiQueue(() => streamOllamaInner(messages, onToken, opts));
}
async function streamOllamaInner(
messages: ChatMsg[],
onToken: (delta: string) => void,
opts: { numPredict?: number; stallTimeoutMs?: number } = {}
): Promise<string> { ): Promise<string> {
const { runpodUrl, aiModel } = getAiConfig(); const { runpodUrl, aiModel } = getAiConfig();
const stallMs = opts.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS; const stallMs = opts.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
@@ -131,3 +145,64 @@ export async function streamOllama(
clearTimeout(stallTimer!); 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<string> {
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 || '<image>\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);
}
});
}
+7 -1
View File
@@ -8,5 +8,11 @@ export function getAiConfig() {
throw new Error('RUNPOD_API_BASE_URL ortam değişkeni tanımlı değil.'); throw new Error('RUNPOD_API_BASE_URL ortam değişkeni tanımlı değil.');
} }
const aiModel = process.env.AI_MODEL || 'mizan-fixed'; 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 };
} }
+42
View File
@@ -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<void> {
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<T>(fn: () => Promise<T>): Promise<T> {
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 };
}
+2 -1
View File
@@ -1,7 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import multer from 'multer'; import multer from 'multer';
import { requireAuth } from '../middleware/auth'; 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 router = Router();
const upload = multer({ storage: multer.memoryStorage() }); 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('/upload', upload.single('file'), requireAuth, uploadDocument);
router.post('/register-local', requireAuth, registerLocalDocument); router.post('/register-local', requireAuth, registerLocalDocument);
router.post('/extract-text', upload.single('file'), requireAuth, extractAttachmentText); router.post('/extract-text', upload.single('file'), requireAuth, extractAttachmentText);
router.post('/ocr-image', requireAuth, ocrImage);
export default router; export default router;