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:
@@ -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
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user