chat.controller.ts'teki dosya-bağlamı toplama mantığı (belgeler + analiz özeti + geçmiş mesajlar) buildCaseChatContext() olarak paylaşılan hale getirildi — mevcut SSE sohbeti (chatMessage) davranış değişmeden bunu kullanıyor, yeni POST /api/whatsapp/message uç noktası da aynı motoru tek-seferlik (non-streaming) çağırıyor. Kimlik doğrulama farklı: bu uç nokta bir Supabase oturumu taşımıyor (n8n'den gelen anonim WhatsApp mesajı), X-Webhook-Secret ile korunuyor. Telefon → hesap eşleştirmesi profiles.phone üzerinden, hangi dava dosyasıyla konuşulduğu whatsapp_sessions'ta tutuluyor (bkz. laawos sql/20_whatsapp_integration.sql). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
347 lines
16 KiB
TypeScript
347 lines
16 KiB
TypeScript
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?.();
|
||
// Cloudflare gibi proxy'lerin buffer'ını aşmak için ~2KB dummy padding gönder.
|
||
res.write(`: ${' '.repeat(2048)}\n\n`);
|
||
if (typeof (res as any).flush === 'function') {
|
||
(res as any).flush();
|
||
}
|
||
}
|
||
|
||
function sseWrite(res: Response, payload: Record<string, unknown>) {
|
||
if (res.destroyed || res.writableEnded) return;
|
||
try {
|
||
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||
if (typeof (res as any).flush === 'function') {
|
||
(res as any).flush();
|
||
}
|
||
} catch (err) {
|
||
// Client disconnect olduysa yazma hatasını yoksay, arka planda AI'ın
|
||
// yanıt üretmeyi bitirip veritabanına kaydetmesine izin ver.
|
||
}
|
||
}
|
||
|
||
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' });
|
||
}
|
||
};
|
||
|
||
interface CaseChatContextOpts {
|
||
chat_mode?: string;
|
||
persona?: string;
|
||
document_ids?: string[];
|
||
attachments?: unknown;
|
||
historyLimit?: number;
|
||
// pastMessages sorgusu boş dönerse (normalde olmaz, çağıran taraf zaten kendi
|
||
// mesajını insert ettikten SONRA bu fonksiyonu çağırıyor olmalı) kullanılacak
|
||
// son çare — orijinal chatMessage'daki else dalıyla aynı savunma amaçlı davranış.
|
||
fallbackUserContent?: string;
|
||
}
|
||
|
||
// chatMessage (masaüstü "AyrisLegal'e Sor" sohbeti) ve WhatsApp uç noktasının
|
||
// (whatsapp.controller.ts) İKİSİ de aynı dosya bağlamını (belgeler + analiz özeti +
|
||
// geçmiş mesajlar) aynı şekilde topluyor — tek fark biri SSE ile akıtıyor, diğeri
|
||
// tek seferde JSON dönüyor. Bu fonksiyon o ortak mantığı taşıyor; caller kendi
|
||
// kullanıcı mesajını chat_messages'a insert ETTİKTEN SONRA çağırmalı, ki pastMessages
|
||
// sorgusu o mesajı da (en sonda) içersin.
|
||
export async function buildCaseChatContext(caseId: string, opts: CaseChatContextOpts = {}): Promise<ChatMsg[]> {
|
||
const { chat_mode = 'chat', persona, document_ids, attachments, historyLimit = 10, fallbackUserContent } = opts;
|
||
const targetPersona = persona || 'judge';
|
||
|
||
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(historyLimit);
|
||
|
||
// 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');
|
||
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 (targetPersona === 'opponent') {
|
||
systemPrompt = `Sen bu davanın KARŞI TARAF AVUKATISIN. Amacın avukat kullanıcının tezlerindeki çelişkileri, mantık hatalarını ve delil eksikliklerini sert bir şekilde VURMAK ve YÜZÜNE VURMAKTIR. Çapraz sorgu yap, açık arayan zorlayıcı sorular sor ve onun tezini zayıflatmak için çaba sarf et. Asla kibarlık veya tavsiye yapma, tamamen karşı taraf rolünü oyna!\n${documentContext}`;
|
||
} else { // judge
|
||
systemPrompt = `Sen bu davanın MAHKEME HAKİMİSİN. HMK/CMK/İYUK usul kurallarına son derece bağlı, titiz ve sorgulayıcı bir duruşma hakimisin. Avukat kullanıcının her iddiasını somut delil ve kanun maddesiyle ispatlamasını iste, açıklarını yüzüne vur ve çapraz sorgula.\n${documentContext}`;
|
||
}
|
||
} else if (chat_mode === 'whatsapp') {
|
||
systemPrompt = `Sen bir hukuk asistanısın. Bu dava dosyasıyla ilgili kullanıcının WhatsApp üzerinden sorduğu soruları, aşağıda verilen belge içeriğine dayanarak cevaplıyorsun. KISA VE ÖZ cevap ver — bir WhatsApp mesajı yazıyorsun, uzun paragraflar yerine net ve aksiyona dönük bilgi ver. Eğer belge yoksa veya soruyu cevaplamaya yetmiyorsa, bunu açıkça belirt, uydurma bilgi verme.${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 if (fallbackUserContent) {
|
||
messages.push({ role: 'user', content: fallbackUserContent });
|
||
}
|
||
return messages;
|
||
}
|
||
|
||
// 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, message, attachments, document_ids, chat_mode = 'chat', persona, simulator_persona } = req.body || {};
|
||
const messageText = content || message;
|
||
const targetPersona = simulator_persona || persona || 'judge';
|
||
const userId = req.user?.id;
|
||
|
||
if (!messageText || !caseId) {
|
||
return res.status(400).json({ error: 'Missing caseId or message 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 = messageText + 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 messages = await buildCaseChatContext(caseId as string, {
|
||
chat_mode,
|
||
persona: targetPersona,
|
||
document_ids,
|
||
attachments,
|
||
fallbackUserContent: 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;
|
||
// messages = [system, ...pastMessages]; pastMessages her zaman en az bu isteğin
|
||
// kendi mesajını içeriyor (buildCaseChatContext'ten önce insert edildi) — bu
|
||
// yüzden messages.length<=2 ("ilk mesaj" hali) eskiden !pastMessages||pastMessages.length<=1 ile aynı anlama geliyor.
|
||
const isFirstMessage = messages.length <= 2;
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!res.destroyed && !res.writableEnded) {
|
||
sseWrite(res, { done: true, newTitle, messageId: aiMessage?.id || null });
|
||
res.end();
|
||
}
|
||
} catch (error: any) {
|
||
console.error('Chat Error:', error);
|
||
if (!res.destroyed && !res.writableEnded) {
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!res.destroyed && !res.writableEnded) {
|
||
sseWrite(res, { done: true, newTitle, messageId: aiMessage?.id || null });
|
||
res.end();
|
||
}
|
||
} catch (error: any) {
|
||
console.error('Send Thread Message Error:', error);
|
||
if (!res.destroyed && !res.writableEnded) {
|
||
if (!res.headersSent) {
|
||
res.status(500).json({ error: error.message || 'Internal Server Error' });
|
||
} else {
|
||
sseWrite(res, { error: error.message || 'Internal Server Error' });
|
||
res.end();
|
||
}
|
||
}
|
||
}
|
||
};
|