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: [
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user