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:
mstfyldz
2026-08-10 00:21:49 +03:00
co-authored by Claude Sonnet 5
parent 0318f2ac77
commit bcc78ce304
5 changed files with 239 additions and 150 deletions
+90 -76
View File
@@ -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: [
{
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);
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) },
], { 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}`);
}
const data = await response.json();
return data.message?.content || 'Yanıt alınamadı.';
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?.();
}
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,29 +63,30 @@ 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) => {
const { caseId } = req.params;
const { content } = req.body;
const userId = req.user?.id;
if (!content || !caseId) {
return res.status(400).json({ error: 'Missing caseId or content' });
}
try {
const { caseId } = req.params;
const { content } = req.body;
const userId = req.user?.id;
if (!content || !caseId) {
return res.status(400).json({ error: 'Missing caseId or content' });
}
// 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' +
documents.map(d => `--- ${d.filename} ---\n${d.extracted_text}`).join('\n\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);
res.status(500).json({ error: error.message || 'Internal Server 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,16 +180,17 @@ 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) => {
const { threadId } = req.params;
const { content } = req.body;
const userId = req.user?.id;
if (!content || !threadId) {
return res.status(400).json({ error: 'Missing threadId or content' });
}
try {
const { threadId } = req.params;
const { content } = req.body;
const userId = req.user?.id;
if (!content || !threadId) {
return res.status(400).json({ error: 'Missing threadId or content' });
}
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);
res.status(500).json({ error: error.message || 'Internal Server 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();
}
}
};