perf: switch all AI calls to native Ollama endpoint, add streaming + keep_alive
Tüm AI çağrıları artık ortak lib/aiClient.ts üzerinden gidiyor: - /v1/chat/completions yerine native /api/chat (think:false burada güvenilir çalışıyor) - keep_alive:30m ile model her istekte bellekten atılıp soğuk başlamıyor - num_predict ile öngörülemez uzunlukta üretime üst sınır - İnteraktif sohbet uç noktaları (chatMessage, sendThreadMessage) artık SSE ile token token akıtıyor - Dosya sohbetinde varsayılan olarak belgenin tam metni değil analiz özeti gönderiliyor; tam metin sadece kullanıcı açıkça isterse eklenir Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0318f2ac77
commit
bcc78ce304
@@ -1,58 +1,48 @@
|
||||
import { Response } from 'express';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AuthenticatedRequest } from '../middleware/auth';
|
||||
import { getAiConfig } from '../lib/aiConfig';
|
||||
import { callOllama, streamOllama, ChatMsg } from '../lib/aiClient';
|
||||
|
||||
// Kullanıcı bir belgenin TAM metnine bakılmasını açıkça istediğinde (örn. "belgenin
|
||||
// tamamına bak", "tam metni incele") sistem promptuna extracted_text'in tamamı
|
||||
// eklenir. Aksi halde sadece analiz özeti (summary_json) gönderilir — hem promptu
|
||||
// küçültüp yanıtı hızlandırır hem gereksiz token maliyetini önler.
|
||||
const FULL_TEXT_TRIGGER = /tam metn|belgenin tamam|dosyanın tamam|tüm metn|tüm belge|belgeyi (baştan sona|detaylı)/i;
|
||||
|
||||
// İlk mesajdan kısa bir başlık üretir (Claude/ChatGPT'nin yaptığı gibi). Başarısız
|
||||
// olursa null döner — çağıran taraf bu durumda varsayılan başlığı korur, asıl
|
||||
// sohbet cevabını etkilemez. Hem dava-dosyası-siz "Yeni Sohbet" (cases.kind='chat')
|
||||
// hem de chat_threads kayıtları için ortak kullanılır.
|
||||
// hem de chat_threads kayıtları için ortak kullanılır. Çıktı birkaç kelimelik bir
|
||||
// başlık olduğu için num_predict düşük tutulup çağrı hızlandırılıyor.
|
||||
async function generateShortTitle(firstMessage: string): Promise<string | null> {
|
||||
try {
|
||||
const { runpodUrl, aiModel } = getAiConfig();
|
||||
const response = await fetch(`${runpodUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: aiModel,
|
||||
messages: [
|
||||
const raw = await callOllama([
|
||||
{
|
||||
role: 'system',
|
||||
content: 'Kullanıcının hukuki sorusunu en fazla 5 kelimelik, kısa ve açıklayıcı bir Türkçe başlığa çevir. Sadece başlığı yaz; tırnak işareti, noktalama veya başka açıklama ekleme.'
|
||||
},
|
||||
{ role: 'user', content: firstMessage.slice(0, 2000) },
|
||||
],
|
||||
think: false,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
const raw = (data.message?.content || '').toString().trim().replace(/^["'“”]+|["'“”]+$/g, '');
|
||||
if (!raw) return null;
|
||||
return raw.slice(0, 80);
|
||||
], { numPredict: 30, timeoutMs: 20_000 });
|
||||
const cleaned = raw.trim().replace(/^["'“”]+|["'“”]+$/g, '');
|
||||
return cleaned ? cleaned.slice(0, 80) : null;
|
||||
} catch (err) {
|
||||
console.error('Generate Short Title Error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// AI modeline chat completion isteği atan ortak yardımcı. RunPod worker uykudaysa
|
||||
// soğuk başlangıç birkaç dakika sürebilir; burada sunucu tarafında bir zaman aşımı
|
||||
// dayatmıyoruz (istemci kendi AbortController'ıyla bekleme süresini yönetiyor) —
|
||||
// böylece yavaş ama başarılı olacak bir yanıtı erkenden kesmiyoruz.
|
||||
async function callAiChat(messages: { role: string; content: string }[]): Promise<string> {
|
||||
const { runpodUrl, aiModel } = getAiConfig();
|
||||
const response = await fetch(`${runpodUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: aiModel, messages, think: false, stream: false }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`AI Model request failed: ${response.statusText}`);
|
||||
function startSse(res: Response) {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
// Bazı reverse proxy'lerin (nginx) chunked yanıtı ara belleğe alıp token'ları
|
||||
// toplu halde göndermesini önlemek için.
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
(res as any).flushHeaders?.();
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.message?.content || 'Yanıt alınamadı.';
|
||||
|
||||
function sseWrite(res: Response, payload: Record<string, unknown>) {
|
||||
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
}
|
||||
|
||||
export const deleteChatHistory = async (req: AuthenticatedRequest, res: Response) => {
|
||||
@@ -73,8 +63,10 @@ export const deleteChatHistory = async (req: AuthenticatedRequest, res: Response
|
||||
}
|
||||
};
|
||||
|
||||
// Bu dosyanın "AyrisLegal'e Sor" sohbeti — yanıtı Server-Sent Events ile token
|
||||
// token akıtır (bkz. lib/aiClient.ts streamOllama). Kullanıcı tüm yanıtı beklemek
|
||||
// yerine yazılırken görür; gerçek üretim süresi aynı kalsa da algılanan hız artar.
|
||||
export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { caseId } = req.params;
|
||||
const { content } = req.body;
|
||||
const userId = req.user?.id;
|
||||
@@ -83,19 +75,18 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
|
||||
return res.status(400).json({ error: 'Missing caseId or content' });
|
||||
}
|
||||
|
||||
try {
|
||||
// kind='case' olan gerçek dava dosyalarının başlığı UYAP'tan/kullanıcıdan geliyor —
|
||||
// bu asla otomatik yeniden adlandırılmamalı. Otomatik başlık üretimi SADECE
|
||||
// kind='chat' (Sohbet'teki "Yeni Sohbet") kayıtları için geçerli.
|
||||
const { data: caseRow } = await supabase.from('cases').select('kind').eq('id', caseId).maybeSingle();
|
||||
const caseKind = caseRow?.kind || 'case';
|
||||
|
||||
const { data: userMessage, error: userMsgError } = await supabase
|
||||
const { error: userMsgError } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert([{ case_id: caseId, user_id: userId, role: 'user', content }])
|
||||
.select().single();
|
||||
.insert([{ case_id: caseId, user_id: userId, role: 'user', content }]);
|
||||
if (userMsgError) throw userMsgError;
|
||||
|
||||
// 1. Dosyaya ait belge analizlerini çek (EKSİK OLAN KISIM)
|
||||
const { data: analyses } = await supabase
|
||||
.from('analyses')
|
||||
.select('summary_json')
|
||||
@@ -106,7 +97,6 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
|
||||
.select('filename, extracted_text')
|
||||
.eq('case_id', caseId);
|
||||
|
||||
// 2. Geçmiş sohbet
|
||||
const { data: pastMessages } = await supabase
|
||||
.from('chat_messages')
|
||||
.select('role, content')
|
||||
@@ -114,35 +104,45 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(10);
|
||||
|
||||
// 3. Belge bağlamını sistem promptuna göm
|
||||
// Belge bağlamı: varsayılan olarak sadece dosya adları + analiz özeti gönderilir.
|
||||
// Kullanıcı açıkça tam metni isterse (FULL_TEXT_TRIGGER) extracted_text'lerin
|
||||
// tamamı eklenir. Bu, her mesajda tüm belgelerin tam metnini gömüp promptu
|
||||
// şişirmek yerine, çoğu soruda çok daha küçük ve hızlı bir istek üretir.
|
||||
const wantsFullText = FULL_TEXT_TRIGGER.test(content);
|
||||
let documentContext = '';
|
||||
if (documents && documents.length > 0) {
|
||||
documentContext = '\n\nDOSYAYA AİT BELGELER:\n' +
|
||||
if (wantsFullText) {
|
||||
documentContext = '\n\nDOSYAYA AİT BELGELERİN TAM METNİ:\n' +
|
||||
documents.map(d => `--- ${d.filename} ---\n${d.extracted_text}`).join('\n\n');
|
||||
} else {
|
||||
documentContext = '\n\nDOSYAYA AİT BELGELER: ' + documents.map(d => d.filename).join(', ') +
|
||||
'\n(Aşağıda bu belgelerin analiz özeti var. Kullanıcı tam metni isterse — "belgenin tamamına bak" gibi — tam metni isteyebileceğini kendisine hatırlatabilirsin.)';
|
||||
}
|
||||
} else {
|
||||
documentContext = '\n\nBu dosyaya henüz hiçbir belge yüklenmemiştir. Genel hukuki bilgiyle cevap ver, ama bunu kullanıcıya açıkça belirt.';
|
||||
}
|
||||
|
||||
if (analyses && analyses.length > 0) {
|
||||
documentContext += '\n\nÖNCEKİ ANALİZ ÖZETİ:\n' + JSON.stringify(analyses[analyses.length - 1].summary_json);
|
||||
documentContext += '\n\nANALİZ ÖZETİ:\n' + JSON.stringify(analyses[analyses.length - 1].summary_json);
|
||||
}
|
||||
|
||||
const systemPrompt = `Sen bir hukuk asistanısın. Bu dava dosyasıyla ilgili kullanıcının sorularını, aşağıda verilen belge içeriğine dayanarak cevaplıyorsun. Eğer belge yoksa veya soruyu cevaplamaya yetmiyorsa, bunu açıkça belirt, uydurma bilgi verme.${documentContext}`;
|
||||
|
||||
const messages: any[] = [{ role: 'system', content: systemPrompt }];
|
||||
if (pastMessages) {
|
||||
const messages: ChatMsg[] = [{ role: 'system', content: systemPrompt }];
|
||||
if (pastMessages && pastMessages.length > 0) {
|
||||
pastMessages.forEach(m => messages.push({ role: m.role, content: m.content }));
|
||||
} else {
|
||||
messages.push({ role: 'user', content });
|
||||
}
|
||||
|
||||
const aiContent = await callAiChat(messages);
|
||||
startSse(res);
|
||||
const aiContent = await streamOllama(messages, (delta) => sseWrite(res, { delta }), { numPredict: 800 });
|
||||
|
||||
const { data: aiMessage, error: aiMsgError } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert([{ case_id: caseId, user_id: userId, role: 'assistant', content: aiContent }])
|
||||
.select().single();
|
||||
if (aiMsgError) throw aiMsgError;
|
||||
if (aiMsgError) console.error('Save AI Message Error:', aiMsgError);
|
||||
|
||||
// Bu dosyanın ilk mesajıysa (kullanıcının bu isteklen önce hiç mesajı yoktu) VE
|
||||
// bu gerçek bir dava dosyası değil de dosyasız bir sohbetse, ilk mesajdan kısa
|
||||
@@ -163,10 +163,16 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: aiMessage, newTitle });
|
||||
sseWrite(res, { done: true, newTitle, messageId: aiMessage?.id || null });
|
||||
res.end();
|
||||
} catch (error: any) {
|
||||
console.error('Chat Error:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: error.message || 'Internal Server Error' });
|
||||
} else {
|
||||
sseWrite(res, { error: error.message || 'Internal Server Error' });
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -174,8 +180,8 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
|
||||
// tabloları cases ile hiçbir ilişkiye sahip değil (bkz. sql/9_chat_threads.sql).
|
||||
// Thread oluşturma/silme/geçmiş okuma doğrudan frontend'den Supabase ile yapılıyor
|
||||
// (events/clients/drafts ile aynı desen); burada sadece AI çağrısı + mesaj kaydı var.
|
||||
// chatMessage ile aynı SSE akış deseni kullanılır.
|
||||
export const sendThreadMessage = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { threadId } = req.params;
|
||||
const { content } = req.body;
|
||||
const userId = req.user?.id;
|
||||
@@ -184,6 +190,7 @@ export const sendThreadMessage = async (req: AuthenticatedRequest, res: Response
|
||||
return res.status(400).json({ error: 'Missing threadId or content' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: thread } = await supabase
|
||||
.from('chat_threads')
|
||||
.select('id, user_id')
|
||||
@@ -206,20 +213,21 @@ export const sendThreadMessage = async (req: AuthenticatedRequest, res: Response
|
||||
.limit(20);
|
||||
|
||||
const generalSystemPrompt = 'Sen bir hukuk asistanısın. Belirli bir dava dosyasına bağlı olmayan genel hukuki soruları cevaplıyorsun. Emin olmadığın noktalarda bunu açıkça belirt, uydurma bilgi verme.';
|
||||
const messages: any[] = [{ role: 'system', content: generalSystemPrompt }];
|
||||
const messages: ChatMsg[] = [{ role: 'system', content: generalSystemPrompt }];
|
||||
if (pastMessages && pastMessages.length > 0) {
|
||||
pastMessages.forEach(m => messages.push({ role: m.role, content: m.content }));
|
||||
} else {
|
||||
messages.push({ role: 'user', content });
|
||||
}
|
||||
|
||||
const aiContent = await callAiChat(messages);
|
||||
startSse(res);
|
||||
const aiContent = await streamOllama(messages, (delta) => sseWrite(res, { delta }), { numPredict: 800 });
|
||||
|
||||
const { data: aiMessage, error: aiMsgError } = await supabase
|
||||
.from('thread_messages')
|
||||
.insert([{ thread_id: threadId, user_id: userId, role: 'assistant', content: aiContent }])
|
||||
.select().single();
|
||||
if (aiMsgError) throw aiMsgError;
|
||||
if (aiMsgError) console.error('Save Thread Message Error:', aiMsgError);
|
||||
|
||||
let newTitle: string | null = null;
|
||||
const isFirstMessage = !pastMessages || pastMessages.length <= 1;
|
||||
@@ -237,9 +245,15 @@ export const sendThreadMessage = async (req: AuthenticatedRequest, res: Response
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: aiMessage, newTitle });
|
||||
sseWrite(res, { done: true, newTitle, messageId: aiMessage?.id || null });
|
||||
res.end();
|
||||
} catch (error: any) {
|
||||
console.error('Send Thread Message Error:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: error.message || 'Internal Server Error' });
|
||||
} else {
|
||||
sseWrite(res, { error: error.message || 'Internal Server Error' });
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,6 +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 { getAiConfig } from '../lib/aiConfig';
|
||||
const heicConvert = require('heic-convert');
|
||||
|
||||
@@ -68,30 +69,13 @@ async function callAiModel(systemPrompt: string, userText: string): Promise<{ re
|
||||
return { resultJson, aiModel };
|
||||
}
|
||||
|
||||
export async function callAiModelRaw(systemPrompt: string, userText: string): Promise<{ aiContent: string; aiModel: string }> {
|
||||
const { runpodUrl, aiModel } = getAiConfig();
|
||||
|
||||
const response = await fetch(`${runpodUrl}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: aiModel,
|
||||
messages: [
|
||||
export async function callAiModelRaw(systemPrompt: string, userText: string, numPredict = 1500): Promise<{ aiContent: string; aiModel: string }> {
|
||||
const { aiModel } = getAiConfig();
|
||||
const aiContent = await callOllama([
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userText.substring(0, 15000) }
|
||||
],
|
||||
think: false,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`AI Model request failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const aiContent = data.message?.content || (data.choices && data.choices[0]?.message?.content) || '';
|
||||
return { aiContent: String(aiContent).trim(), aiModel };
|
||||
{ role: 'user', content: userText.substring(0, 15000) },
|
||||
], { numPredict });
|
||||
return { aiContent, aiModel };
|
||||
}
|
||||
|
||||
// uploadDocument, registerLocalDocument'in kullandığı genel (kısa, alan-alan) analiz adımı.
|
||||
@@ -109,7 +93,7 @@ export async function runIddianameAnalysis(extractedText: string): Promise<{ sum
|
||||
// summarizeCase'in Dosya Özeti raporu için kullandığı adım — çıktı serbest Markdown
|
||||
// metni, JSON'a çevrilmeye çalışılmıyor.
|
||||
export async function runCaseNarrativeAnalysis(combinedText: string): Promise<{ summaryJson: any; aiModel: string }> {
|
||||
const { aiContent, aiModel } = await callAiModelRaw(CASE_NARRATIVE_SYSTEM_PROMPT, combinedText);
|
||||
const { aiContent, aiModel } = await callAiModelRaw(CASE_NARRATIVE_SYSTEM_PROMPT, combinedText, 2500);
|
||||
return { summaryJson: { ozet: aiContent }, aiModel };
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ export const generateDraft = async (req: AuthenticatedRequest, res: Response) =>
|
||||
userContent += `\n---\nBÜRO ŞABLONU "${template.name}" (SADECE dil/format/üslup referansı — içeriğini kopyalama):\n${template.extracted_text.slice(0, 6000)}\n`;
|
||||
}
|
||||
|
||||
const { aiContent, aiModel } = await callAiModelRaw(DRAFT_SYSTEM_PROMPT, userContent);
|
||||
const { aiContent, aiModel } = await callAiModelRaw(DRAFT_SYSTEM_PROMPT, userContent, 2500);
|
||||
|
||||
// Taslağı kalıcı olarak kaydediyoruz — hem "Dilekçelerim" listesinde hem ilgili
|
||||
// dava dosyasının kendi "Dilekçeler" bölümünde görünsün, sayfadan çıkınca kaybolmasın.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { AuthenticatedRequest } from '../middleware/auth';
|
||||
import { getAiConfig } from '../lib/aiConfig';
|
||||
import { callOllama } from '../lib/aiClient';
|
||||
|
||||
export const searchPrecedents = async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -21,34 +21,19 @@ SADECE anahtar kelimeleri aralarına boşluk koyarak yaz. Noktalama işareti, a
|
||||
|
||||
// AI ile sorgu optimizasyonu isteğe bağlı bir iyileştirme — config eksikse veya
|
||||
// istek başarısız olursa arama yine de optimize edilmemiş phrase ile devam eder.
|
||||
// Çıktı birkaç kelimelik bir anahtar kelime listesi olduğu için num_predict düşük
|
||||
// tutuluyor — hem daha hızlı yanıt hem modelin gereksiz uzatmasını önler.
|
||||
try {
|
||||
const { runpodUrl, aiModel } = getAiConfig();
|
||||
const aiResponse = await fetch(`${runpodUrl}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: aiModel,
|
||||
messages: [
|
||||
const aiContent = await callOllama([
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: phrase }
|
||||
],
|
||||
think: false,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (aiResponse.ok) {
|
||||
const data = await aiResponse.json();
|
||||
const aiContent = data.message?.content || (data.choices && data.choices[0]?.message?.content);
|
||||
{ role: 'user', content: phrase },
|
||||
], { numPredict: 40, timeoutMs: 20_000 });
|
||||
if (aiContent) {
|
||||
optimizedPhrase = aiContent.trim().replace(/["'\n]/g, '');
|
||||
console.log(`[AI Search] Orijinal: "${phrase}" -> Optimize: "${optimizedPhrase}"`);
|
||||
}
|
||||
} else {
|
||||
console.error('AI Query Optimization API returned status:', aiResponse.status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('AI Query Optimization Fetch Error:', err);
|
||||
console.error('AI Query Optimization Error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,28 +98,10 @@ export const analyzePrecedents = async (req: AuthenticatedRequest, res: Response
|
||||
const systemPrompt = 'Sen uzman bir Türk avukatısın. Kullanıcının anlattığı hukuki durumu, aşağıda listelenen emsal karar arama sonuçları ışığında kısaca değerlendir. Hangi kararların duruma daha yakın olabileceğini belirt ve olası bir hukuki değerlendirme sun. Emin olmadığın noktalarda bunu açıkça belirt, uydurma bilgi verme. Cevabın yaklaşık 150-250 kelime olsun.';
|
||||
const userPrompt = `DURUM:\n${String(situation).slice(0, 3000)}\n\nBULUNAN KARARLAR:\n${listText}`;
|
||||
|
||||
const { runpodUrl, aiModel } = getAiConfig();
|
||||
|
||||
const response = await fetch(`${runpodUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: aiModel,
|
||||
messages: [
|
||||
const analysis = await callOllama([
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
think: false,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`AI Model request failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const analysis = data.message?.content || 'Analiz üretilemedi.';
|
||||
], { numPredict: 500 }) || 'Analiz üretilemedi.';
|
||||
res.json({ analysis });
|
||||
} catch (error: any) {
|
||||
console.error('Analyze Precedents Error:', error);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { getAiConfig } from './aiConfig';
|
||||
|
||||
export interface ChatMsg { role: string; content: string; }
|
||||
|
||||
// Model her istekte bellekten atılıp yeniden yüklenmesin diye (varsayılan Ollama
|
||||
// davranışı: 5 dakika kullanılmazsa boşalır, sıradaki istek soğuk başlangıç
|
||||
// gecikmesi yaşar) worker'ı bu süre boyunca bellekte tutmasını istiyoruz.
|
||||
const KEEP_ALIVE = '30m';
|
||||
const DEFAULT_NUM_PREDICT = 800;
|
||||
const DEFAULT_TIMEOUT_MS = 90_000;
|
||||
const DEFAULT_STALL_TIMEOUT_MS = 60_000;
|
||||
|
||||
function buildBody(messages: ChatMsg[], model: string, stream: boolean, numPredict: number) {
|
||||
return {
|
||||
model,
|
||||
messages,
|
||||
think: false,
|
||||
stream,
|
||||
keep_alive: KEEP_ALIVE,
|
||||
// num_predict: modelin üretebileceği maksimum token sayısı — öngörülemez
|
||||
// uzunlukta "düşünme"/üretim sürelerine karşı üst sınır.
|
||||
options: { num_predict: numPredict },
|
||||
};
|
||||
}
|
||||
|
||||
// Tek seferlik (streaming olmayan) çağrı — başlık üretimi, belge analizi, dilekçe
|
||||
// taslağı, içtihat sorgu optimizasyonu gibi kullanıcının token token izlemediği,
|
||||
// arka planda tamamlanması yeterli olan işler için. Ollama'nın native /api/chat
|
||||
// uç noktasını kullanır — think:false burada güvenilir çalışıyor (OpenAI-uyumluluk
|
||||
// katmanı /v1/chat/completions'ta güvenilir değil).
|
||||
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}`);
|
||||
}
|
||||
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üğü
|
||||
// etkileşimli sohbet uç noktaları için. Ollama /api/chat stream:true ile
|
||||
// newline-delimited JSON döner (her satır { message: { content }, done } içerir).
|
||||
// onToken her parça geldiğinde çağrılır; dönüş değeri tüm yanıtın birleştirilmiş hali.
|
||||
// Sabit bir toplam süre sınırı YOK (kullanıcı üretilmekte olan metni zaten görüyor) —
|
||||
// bunun yerine art arda gelen parçalar arasında stallTimeoutMs kadar sessizlik olursa
|
||||
// (model takıldı / bağlantı koptu) isteği iptal ediyoruz.
|
||||
export async function streamOllama(
|
||||
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;
|
||||
const ctrl = new AbortController();
|
||||
let stallTimer: ReturnType<typeof setTimeout>;
|
||||
const resetStall = () => {
|
||||
clearTimeout(stallTimer);
|
||||
stallTimer = setTimeout(() => ctrl.abort(), stallMs);
|
||||
};
|
||||
resetStall();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${runpodUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildBody(messages, aiModel, true, opts.numPredict ?? DEFAULT_NUM_PREDICT)),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`AI Model request failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
let full = '';
|
||||
let buffer = '';
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
const consumeLine = (line: string) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
let json: any;
|
||||
try {
|
||||
json = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const delta = json.message?.content || '';
|
||||
if (delta) {
|
||||
full += delta;
|
||||
onToken(delta);
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
resetStall();
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
lines.forEach(consumeLine);
|
||||
}
|
||||
if (buffer.trim()) consumeLine(buffer);
|
||||
|
||||
return full.trim();
|
||||
} finally {
|
||||
clearTimeout(stallTimer!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user