feat: WhatsApp AI hattı (n8n + Evolution API) için yeni uç nokta
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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
fed881de49
commit
5fe4b0b8bc
@@ -90,6 +90,91 @@ export const deleteChatHistory = async (req: AuthenticatedRequest, res: Response
|
||||
}
|
||||
};
|
||||
|
||||
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.
|
||||
@@ -117,63 +202,13 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
|
||||
.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');
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
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 });
|
||||
@@ -188,7 +223,10 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
|
||||
// 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;
|
||||
// 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) {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { callOllama } from '../lib/aiClient';
|
||||
import { buildCaseChatContext } from './chat.controller';
|
||||
|
||||
// n8n'in Evolution API'den aldığı gelen mesajı ilettiği uç nokta — bir Supabase
|
||||
// oturumu taşımıyor (WhatsApp'tan gelen anonim bir mesaj), bu yüzden requireAuth
|
||||
// yerine paylaşılan bir sır kontrolü kullanılıyor (bkz. routes/whatsapp.routes.ts).
|
||||
|
||||
// Telefon numaralarını karşılaştırmadan önce normalize et — n8n'den gelen format
|
||||
// admin panelinde elle girilenle birebir aynı olmayabilir (boşluk, tire, parantez vb.).
|
||||
// Ülke kodu hariç sadece rakamları bırakıp başa '+' ekliyoruz.
|
||||
function normalizePhone(raw: string): string {
|
||||
const digits = String(raw || '').replace(/[^\d]/g, '');
|
||||
return digits ? `+${digits}` : '';
|
||||
}
|
||||
|
||||
interface CaseRow {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
async function findCasePicker(phone: string, userId: string): Promise<string> {
|
||||
const { data: cases } = await supabase
|
||||
.from('cases')
|
||||
.select('id, title')
|
||||
.eq('user_id', userId)
|
||||
.eq('status', 'active')
|
||||
.order('updated_at', { ascending: false });
|
||||
|
||||
const activeCases = (cases || []) as CaseRow[];
|
||||
|
||||
if (activeCases.length === 0) {
|
||||
return 'Aktif bir dava dosyanız görünmüyor. Bir dosya eklendiğinde buradan sorabilirsiniz.';
|
||||
}
|
||||
|
||||
if (activeCases.length === 1) {
|
||||
await supabase.from('whatsapp_sessions').upsert({
|
||||
phone,
|
||||
user_id: userId,
|
||||
active_case_id: activeCases[0].id,
|
||||
awaiting_case_selection: false,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
return '';
|
||||
}
|
||||
|
||||
const list = activeCases.map((c, i) => `${i + 1}. ${c.title}`).join('\n');
|
||||
await supabase.from('whatsapp_sessions').upsert({
|
||||
phone,
|
||||
user_id: userId,
|
||||
active_case_id: null,
|
||||
awaiting_case_selection: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
return `Hangi dosyayla ilgili konuşmak istersiniz? Numarasını yazın:\n\n${list}`;
|
||||
}
|
||||
|
||||
export const handleWhatsappMessage = async (req: Request, res: Response) => {
|
||||
const secret = req.headers['x-webhook-secret'];
|
||||
if (!process.env.WHATSAPP_WEBHOOK_SECRET || secret !== process.env.WHATSAPP_WEBHOOK_SECRET) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const phoneRaw = String(req.body?.phone || '');
|
||||
const text = String(req.body?.text || '').trim();
|
||||
const phone = normalizePhone(phoneRaw);
|
||||
|
||||
if (!phone || !text) {
|
||||
return res.status(400).json({ error: 'phone ve text zorunludur' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('id')
|
||||
.eq('phone', phone)
|
||||
.maybeSingle();
|
||||
|
||||
if (!profile) {
|
||||
return res.json({ reply: 'Bu numara AyrisLegal hesabınıza bağlı değil, lütfen bizimle iletişime geçin.' });
|
||||
}
|
||||
|
||||
if (text.toLowerCase() === 'dosya değiştir') {
|
||||
const reply = await findCasePicker(phone, profile.id);
|
||||
return res.json({ reply: reply || 'Dosyanız otomatik seçildi, sorabilirsiniz.' });
|
||||
}
|
||||
|
||||
const { data: session } = await supabase
|
||||
.from('whatsapp_sessions')
|
||||
.select('active_case_id, awaiting_case_selection')
|
||||
.eq('phone', phone)
|
||||
.maybeSingle();
|
||||
|
||||
let activeCaseId = session?.active_case_id || null;
|
||||
|
||||
if (session?.awaiting_case_selection) {
|
||||
const choice = parseInt(text, 10);
|
||||
const { data: cases } = await supabase
|
||||
.from('cases')
|
||||
.select('id, title')
|
||||
.eq('user_id', profile.id)
|
||||
.eq('status', 'active')
|
||||
.order('updated_at', { ascending: false });
|
||||
const activeCases = (cases || []) as CaseRow[];
|
||||
const picked = !isNaN(choice) ? activeCases[choice - 1] : null;
|
||||
if (!picked) {
|
||||
const list = activeCases.map((c, i) => `${i + 1}. ${c.title}`).join('\n');
|
||||
return res.json({ reply: `Geçerli bir numara yazın:\n\n${list}` });
|
||||
}
|
||||
activeCaseId = picked.id;
|
||||
await supabase.from('whatsapp_sessions').upsert({
|
||||
phone,
|
||||
user_id: profile.id,
|
||||
active_case_id: activeCaseId,
|
||||
awaiting_case_selection: false,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
return res.json({ reply: `"${picked.title}" seçildi. Şimdi bu dosya hakkında sorabilirsiniz.` });
|
||||
}
|
||||
|
||||
if (!activeCaseId) {
|
||||
const reply = await findCasePicker(phone, profile.id);
|
||||
if (reply) return res.json({ reply });
|
||||
const { data: refreshed } = await supabase
|
||||
.from('whatsapp_sessions')
|
||||
.select('active_case_id')
|
||||
.eq('phone', phone)
|
||||
.maybeSingle();
|
||||
activeCaseId = refreshed?.active_case_id || null;
|
||||
if (!activeCaseId) {
|
||||
return res.json({ reply: 'Dosya seçilemedi, lütfen tekrar deneyin.' });
|
||||
}
|
||||
}
|
||||
|
||||
const { error: userMsgError } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert([{ case_id: activeCaseId, user_id: profile.id, role: 'user', content: text, chat_mode: 'whatsapp' }]);
|
||||
if (userMsgError) throw userMsgError;
|
||||
|
||||
const messages = await buildCaseChatContext(activeCaseId, {
|
||||
chat_mode: 'whatsapp',
|
||||
fallbackUserContent: text,
|
||||
});
|
||||
|
||||
const aiContent = await callOllama(messages, { numPredict: 1024, timeoutMs: 60_000 });
|
||||
|
||||
await supabase
|
||||
.from('chat_messages')
|
||||
.insert([{ case_id: activeCaseId, user_id: profile.id, role: 'assistant', content: aiContent, chat_mode: 'whatsapp' }]);
|
||||
|
||||
return res.json({ reply: aiContent });
|
||||
} catch (error: any) {
|
||||
console.error('WhatsApp Message Error:', error);
|
||||
return res.status(500).json({ error: error.message || 'Internal Server Error' });
|
||||
}
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import templateRoutes from './template.routes';
|
||||
import draftingRoutes from './drafting.routes';
|
||||
import googleDriveRoutes from './googleDrive.routes';
|
||||
import authRoutes from './auth.routes';
|
||||
import whatsappRoutes from './whatsapp.routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -21,6 +22,7 @@ router.use('/cases', caseRoutes);
|
||||
router.use('/templates', templateRoutes);
|
||||
router.use('/drafting', draftingRoutes);
|
||||
router.use('/google-drive', googleDriveRoutes);
|
||||
router.use('/whatsapp', whatsappRoutes); // POST /api/whatsapp/message (n8n webhook, ayrı X-Webhook-Secret doğrulaması)
|
||||
router.use('/', precedentRoutes); // Keeps the same paths like /api/search-precedents
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import { handleWhatsappMessage } from '../controllers/whatsapp.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// requireAuth YOK — n8n'in çağırdığı bu uç nokta bir Supabase oturumu taşımıyor,
|
||||
// yetkilendirme handleWhatsappMessage içinde X-Webhook-Secret ile yapılıyor.
|
||||
router.post('/message', handleWhatsappMessage);
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user