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:
Mustafa Yildiz
2026-08-18 13:08:02 +03:00
co-authored by Claude Sonnet 5
parent fed881de49
commit 5fe4b0b8bc
4 changed files with 265 additions and 58 deletions
+67 -29
View File
@@ -90,32 +90,27 @@ export const deleteChatHistory = async (req: AuthenticatedRequest, res: Response
} }
}; };
// Bu dosyanın "AyrisLegal'e Sor" sohbeti — yanıtı Server-Sent Events ile token interface CaseChatContextOpts {
// token akıtır (bkz. lib/aiClient.ts streamOllama). Kullanıcı tüm yanıtı beklemek chat_mode?: string;
// yerine yazılırken görür; gerçek üretim süresi aynı kalsa da algılanan hız artar. persona?: string;
export const chatMessage = async (req: AuthenticatedRequest, res: Response) => { document_ids?: string[];
const { caseId } = req.params; attachments?: unknown;
const { content, message, attachments, document_ids, chat_mode = 'chat', persona, simulator_persona } = req.body || {}; historyLimit?: number;
const messageText = content || message; // pastMessages sorgusu boş dönerse (normalde olmaz, çağıran taraf zaten kendi
const targetPersona = simulator_persona || persona || 'judge'; // mesajını insert ettikten SONRA bu fonksiyonu çağırıyor olmalı) kullanılacak
const userId = req.user?.id; // son çare — orijinal chatMessage'daki else dalıyla aynı savunma amaçlı davranış.
fallbackUserContent?: string;
}
if (!messageText || !caseId) { // chatMessage (masaüstü "AyrisLegal'e Sor" sohbeti) ve WhatsApp uç noktasının
return res.status(400).json({ error: 'Missing caseId or message content' }); // (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
try { // kullanıcı mesajını chat_messages'a insert ETTİKTEN SONRA çağırmalı, ki pastMessages
// kind='case' olan gerçek dava dosyalarının başlığı UYAP'tan/kullanıcıdan geliyor — // sorgusu o mesajı da (en sonda) içersin.
// bu asla otomatik yeniden adlandırılmamalı. Otomatik başlık üretimi SADECE export async function buildCaseChatContext(caseId: string, opts: CaseChatContextOpts = {}): Promise<ChatMsg[]> {
// kind='chat' (Sohbet'teki "Yeni Sohbet") kayıtları için geçerli. const { chat_mode = 'chat', persona, document_ids, attachments, historyLimit = 10, fallbackUserContent } = opts;
const { data: caseRow } = await supabase.from('cases').select('kind').eq('id', caseId).maybeSingle(); const targetPersona = persona || 'judge';
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 { data: analyses } = await supabase const { data: analyses } = await supabase
.from('analyses') .from('analyses')
@@ -133,7 +128,7 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
.eq('case_id', caseId) .eq('case_id', caseId)
.eq('chat_mode', chat_mode) .eq('chat_mode', chat_mode)
.order('created_at', { ascending: true }) .order('created_at', { ascending: true })
.limit(10); .limit(historyLimit);
// Belge bağlamı: her zaman TAM metin gönderiliyor (özet-önce yaklaşımı, kullanıcı // 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ı). // uzun bir mesaj/belge verdiğinde sohbet ekranında soruna yol açtığı için geri alındı).
@@ -167,13 +162,53 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
} else { // judge } 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}`; 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 }]; const messages: ChatMsg[] = [{ role: 'system', content: systemPrompt }];
if (pastMessages && pastMessages.length > 0) { if (pastMessages && pastMessages.length > 0) {
pastMessages.forEach(m => messages.push({ role: m.role, content: m.content })); pastMessages.forEach(m => messages.push({ role: m.role, content: m.content }));
} else { } else if (fallbackUserContent) {
messages.push({ role: 'user', content: persistedContent }); 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); startSse(res);
const aiContent = await streamOllama(messages, (delta) => sseWrite(res, { delta }), { numPredict: 2048 }); 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 // 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. // bir başlık üretip yeniden adlandır.
let newTitle: string | null = null; 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') { if (isFirstMessage && caseKind === 'chat') {
newTitle = await generateShortTitle(content); newTitle = await generateShortTitle(content);
if (newTitle) { if (newTitle) {
+157
View File
@@ -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' });
}
};
+2
View File
@@ -7,6 +7,7 @@ import templateRoutes from './template.routes';
import draftingRoutes from './drafting.routes'; import draftingRoutes from './drafting.routes';
import googleDriveRoutes from './googleDrive.routes'; import googleDriveRoutes from './googleDrive.routes';
import authRoutes from './auth.routes'; import authRoutes from './auth.routes';
import whatsappRoutes from './whatsapp.routes';
const router = Router(); const router = Router();
@@ -21,6 +22,7 @@ router.use('/cases', caseRoutes);
router.use('/templates', templateRoutes); router.use('/templates', templateRoutes);
router.use('/drafting', draftingRoutes); router.use('/drafting', draftingRoutes);
router.use('/google-drive', googleDriveRoutes); 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 router.use('/', precedentRoutes); // Keeps the same paths like /api/search-precedents
export default router; export default router;
+10
View File
@@ -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;