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
+92 -17
View File
@@ -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<string> {
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<string> {
return withAiQueue(() => streamOllamaInner(messages, onToken, opts));
}
async function streamOllamaInner(
messages: ChatMsg[],
onToken: (delta: string) => void,
opts: { numPredict?: number; stallTimeoutMs?: number } = {}
): Promise<string> {
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<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.');
}
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 };
}