Files
lagos-back/src/controllers/chat.controller.ts
T

288 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Response } from 'express';
import { supabase } from '../lib/supabase';
import { AuthenticatedRequest } from '../middleware/auth';
import { callOllama, streamOllama, ChatMsg } from '../lib/aiClient';
// İ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. Çı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 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;
}
}
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`);
}
interface ChatAttachment { filename: string; text: string; }
// Sohbet ekranlarındaki ataç (dosya ekleme) özelliği — dosyanın metni frontend'de
// /api/documents/extract-text ile önceden çıkarılıp buraya {filename, text} olarak
// geliyor. Ham dosya bu isteğe hiç dahil değil. Kaydedilen mesaj metnine kısa bir
// "[Ek dosya(lar): ...]" notu eklenir (geçmişte ne eklendiği görünsün diye); AI'ye
// giden bağlama ise dosyanın tam metni eklenir.
function buildAttachmentNote(attachments: unknown): string {
if (!Array.isArray(attachments) || attachments.length === 0) return '';
return `\n\n[Ek dosya(lar): ${attachments.map((a: ChatAttachment) => a?.filename || 'dosya').join(', ')}]`;
}
function buildAttachmentContext(attachments: unknown): string {
if (!Array.isArray(attachments) || attachments.length === 0) return '';
return '\n\nKULLANICININ BU MESAJA EKLEDİĞİ DOSYA(LAR):\n' +
(attachments as ChatAttachment[]).map(a => `--- ${a?.filename || 'dosya'} ---\n${String(a?.text || '').slice(0, 20000)}`).join('\n\n');
}
export const deleteChatHistory = async (req: AuthenticatedRequest, res: Response) => {
try {
const { caseId } = req.params;
const chat_mode = req.query.chat_mode || req.body.chat_mode || 'chat';
if (!caseId || caseId === 'general') {
return res.status(400).json({ error: 'Geçersiz caseId' });
}
// requireAuth zaten bu caseId'nin isteği yapan kullanıcıya ait olduğunu doğruladı.
// Silme işlemi burada (backend'de, service_role ile) yapılıyor — frontend'in anon
// client'ı chat_messages'a RLS nedeniyle yazamıyor/silemiyor olabilir.
const { error } = await supabase.from('chat_messages').delete().eq('case_id', caseId).eq('chat_mode', chat_mode);
if (error) throw error;
res.json({ ok: true });
} catch (error: any) {
console.error('Delete Chat History Error:', error);
res.status(500).json({ error: error.message || 'Internal Server Error' });
}
};
// 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, attachments, document_ids, chat_mode = 'chat', persona = 'judge' } = req.body;
const userId = req.user?.id;
if (!content || !caseId) {
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 persistedContent = content + buildAttachmentNote(attachments);
const { error: userMsgError } = await supabase
.from('chat_messages')
.insert([{ case_id: caseId, user_id: userId, role: 'user', content: persistedContent, chat_mode }]);
if (userMsgError) throw userMsgError;
const { data: analyses } = await supabase
.from('analyses')
.select('summary_json')
.eq('case_id', caseId);
const { data: documents } = await supabase
.from('documents')
.select('id, filename, extracted_text')
.eq('case_id', caseId);
const { data: pastMessages } = await supabase
.from('chat_messages')
.select('role, content')
.eq('case_id', caseId)
.eq('chat_mode', chat_mode)
.order('created_at', { ascending: true })
.limit(10);
// Belge bağlamı: her zaman TAM metin gönderiliyor (özet-önce yaklaşımı, kullanıcı
// uzun bir mesaj/belge verdiğinde sohbet ekranında soruna yol açtığı için geri alındı).
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');
// Kullanıcı sohbet ekranında dosyanın belirli belgelerini seçip vurgulamışsa
// (bkz. frontend'deki belge seçici), buna dikkat çekiyoruz — belgeler zaten
// yukarıda tam olarak mevcut, bu sadece hangisine öncelik vereceğini belirtiyor.
if (Array.isArray(document_ids) && document_ids.length > 0) {
const selected = documents.filter(d => document_ids.includes(d.id));
if (selected.length > 0) {
documentContext += '\n\nKULLANICININ BU MESAJDA ÖZELLİKLE İŞARET ETTİĞİ BELGE(LER): ' + selected.map(d => d.filename).join(', ') + '.';
}
}
} 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\nANALİZ ÖZETİ:\n' + JSON.stringify(analyses[analyses.length - 1].summary_json);
}
documentContext += buildAttachmentContext(attachments);
let 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}`;
if (chat_mode === 'intern') {
systemPrompt = `Sen bir dijital stajyersin (Canlı Arama Asistanı). Kullanıcı şu anda duruşmada veya acil bir durumda ve senden ÇOK HIZLI, KISA ve NOKTA ATIŞI bir bilgi bekliyor. Asla uzun cümleler kurma, merhaba/nasılsın deme. Yalnızca istenen tarihi, belge adını veya spesifik bilgiyi 1-2 cümleyle ver.\n${documentContext}`;
} else if (chat_mode === 'simulator') {
if (persona === 'opponent') {
systemPrompt = `Sen davada karşı tarafın son derece agresif, açık arayan ve zeki avukatısın. Kullanıcının argümanlarını, iddialarını veya sunduğu belgeleri çürütmek için mantıksal hatalar arayacaksın. Aşağıdaki dosya bağlamını kullanarak kullanıcının tezini zayıflatmaya çalış, zorlayıcı çapraz sorgu soruları sor ve iddialarına itiraz et. Asla yardımcı olma, karşı tarafı zorla.\n${documentContext}`;
} else { // judge
systemPrompt = `Sen bu davanın son derece sert, tarafsız, usul ve esasa titizlikle dikkat eden acımasız hakimi rolündesin. Kullanıcının argümanlarını ve belgelerini sert bir şekilde sorgulayacaksın. Hukuki dayanaktan yoksun, soyut veya çelişkili ifadeleri anında yakala ve izahını iste. Kullanıcının argümanlarındaki açıkları bulup hukuki geçerliliğini kanıtlamasını talep et.\n${documentContext}`;
}
}
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: persistedContent });
}
startSse(res);
const aiContent = await streamOllama(messages, (delta) => sseWrite(res, { delta }), { numPredict: 2048 });
const { data: aiMessage, error: aiMsgError } = await supabase
.from('chat_messages')
.insert([{ case_id: caseId, user_id: userId, role: 'assistant', content: aiContent, chat_mode }])
.select().single();
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
// bir başlık üretip yeniden adlandır.
let newTitle: string | null = null;
const isFirstMessage = !pastMessages || pastMessages.length <= 1;
if (isFirstMessage && caseKind === 'chat') {
newTitle = await generateShortTitle(content);
if (newTitle) {
const { error: titleError } = await supabase
.from('cases')
.update({ title: newTitle })
.eq('id', caseId);
if (titleError) {
console.error('Case Title Update Error:', titleError);
newTitle = null;
}
}
}
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();
}
}
};
// Genel (dosyasız) Sohbet/Asistan sayfası için — chat_threads/thread_messages
// 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, attachments } = req.body;
const userId = req.user?.id;
if (!content || !threadId) {
return res.status(400).json({ error: 'Missing threadId or content' });
}
try {
const { data: thread } = await supabase
.from('chat_threads')
.select('id, user_id')
.eq('id', threadId)
.maybeSingle();
if (!thread || thread.user_id !== userId) {
return res.status(403).json({ error: 'Bu sohbete erişim yetkiniz yok.' });
}
const persistedContent = content + buildAttachmentNote(attachments);
const { error: userMsgError } = await supabase
.from('thread_messages')
.insert([{ thread_id: threadId, user_id: userId, role: 'user', content: persistedContent }]);
if (userMsgError) throw userMsgError;
const { data: pastMessages } = await supabase
.from('thread_messages')
.select('role, content')
.eq('thread_id', threadId)
.order('created_at', { ascending: true })
.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.' + buildAttachmentContext(attachments);
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: persistedContent });
}
startSse(res);
const aiContent = await streamOllama(messages, (delta) => sseWrite(res, { delta }), { numPredict: 2048 });
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) console.error('Save Thread Message Error:', aiMsgError);
let newTitle: string | null = null;
const isFirstMessage = !pastMessages || pastMessages.length <= 1;
if (isFirstMessage) {
newTitle = await generateShortTitle(content);
if (newTitle) {
const { error: titleError } = await supabase
.from('chat_threads')
.update({ title: newTitle })
.eq('id', threadId);
if (titleError) {
console.error('Thread Title Update Error:', titleError);
newTitle = null;
}
}
}
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();
}
}
};