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,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
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
// 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')
|
||||
@@ -133,7 +128,7 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
|
||||
.eq('case_id', caseId)
|
||||
.eq('chat_mode', chat_mode)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(10);
|
||||
.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ı).
|
||||
@@ -167,13 +162,53 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
|
||||
} 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 {
|
||||
messages.push({ role: 'user', content: persistedContent });
|
||||
} 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 });
|
||||
@@ -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