From 9618ff4513eb2073e41624525408e669e5d0ee39 Mon Sep 17 00:00:00 2001 From: mstfyldz Date: Mon, 10 Aug 2026 01:03:13 +0300 Subject: [PATCH] feat: file attachments in chat + document-select for case chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Yeni POST /api/documents/extract-text: dosyayı hiçbir yere kaydetmeden sadece metnini çıkarıp döner (chat ataç özelliği için) - chatMessage ve sendThreadMessage artık body'de attachments ({filename,text}[]) kabul ediyor; AI bağlamına ekleniyor, kayıtlı mesaja da "[Ek dosya(lar): ...]" notu düşülüyor - chatMessage ayrıca document_ids kabul ediyor — dosya sohbetinde kullanıcının bu mesajda özellikle işaret ettiği mevcut belge(ler) AI'ye vurgulanıyor Co-Authored-By: Claude Sonnet 5 --- src/controllers/chat.controller.ts | 47 +++++++++++++++++++++----- src/controllers/document.controller.ts | 23 +++++++++++++ src/routes/document.routes.ts | 3 +- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/controllers/chat.controller.ts b/src/controllers/chat.controller.ts index b4f28e7..47dd362 100644 --- a/src/controllers/chat.controller.ts +++ b/src/controllers/chat.controller.ts @@ -39,6 +39,24 @@ function sseWrite(res: Response, payload: Record) { 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; @@ -62,7 +80,7 @@ export const deleteChatHistory = async (req: AuthenticatedRequest, res: Response // 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 } = req.body; + const { content, attachments, document_ids } = req.body; const userId = req.user?.id; if (!content || !caseId) { @@ -76,9 +94,10 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => { 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 }]); + .insert([{ case_id: caseId, user_id: userId, role: 'user', content: persistedContent }]); if (userMsgError) throw userMsgError; const { data: analyses } = await supabase @@ -88,7 +107,7 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => { const { data: documents } = await supabase .from('documents') - .select('filename, extracted_text') + .select('id, filename, extracted_text') .eq('case_id', caseId); const { data: pastMessages } = await supabase @@ -104,6 +123,15 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => { 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.'; } @@ -112,13 +140,15 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => { documentContext += '\n\nANALİZ ÖZETİ:\n' + JSON.stringify(analyses[analyses.length - 1].summary_json); } + documentContext += buildAttachmentContext(attachments); + const 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}`; 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 }); + messages.push({ role: 'user', content: persistedContent }); } startSse(res); @@ -169,7 +199,7 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => { // chatMessage ile aynı SSE akış deseni kullanılır. export const sendThreadMessage = async (req: AuthenticatedRequest, res: Response) => { const { threadId } = req.params; - const { content } = req.body; + const { content, attachments } = req.body; const userId = req.user?.id; if (!content || !threadId) { @@ -186,9 +216,10 @@ export const sendThreadMessage = async (req: AuthenticatedRequest, res: Response 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 }]); + .insert([{ thread_id: threadId, user_id: userId, role: 'user', content: persistedContent }]); if (userMsgError) throw userMsgError; const { data: pastMessages } = await supabase @@ -198,12 +229,12 @@ export const sendThreadMessage = async (req: AuthenticatedRequest, res: Response .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.'; + 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 }); + messages.push({ role: 'user', content: persistedContent }); } startSse(res); diff --git a/src/controllers/document.controller.ts b/src/controllers/document.controller.ts index 21a0fcb..fe859e7 100644 --- a/src/controllers/document.controller.ts +++ b/src/controllers/document.controller.ts @@ -173,6 +173,29 @@ export async function extractTextFromUploadedFile(file: { buffer: Buffer; mimety return typeof result === 'string' ? result : (result?.toText ? result.toText() : String(result || '')); } +// Sohbet ekranlarındaki (genel Sohbet ve dosya bazlı "AyrisLegal'e Sor") ataç +// (dosya ekleme) özelliği için — dosyayı hiçbir yere kaydetmeden sadece metnini +// çıkarıp döner, çağıran taraf bu metni chat isteğine "attachments" olarak ekler. +export const extractAttachmentText = async (req: AuthenticatedRequest, res: Response) => { + try { + const file = req.file; + if (!file) { + return res.status(400).json({ error: 'Dosya bulunamadı.' }); + } + let extractedText = ''; + try { + extractedText = await extractTextFromUploadedFile(file); + } catch (err: any) { + console.error('Attachment Text Extraction Error:', err); + return res.status(400).json({ error: 'Dosya formatı okunamadı veya desteklenmiyor.' }); + } + res.json({ filename: file.originalname, extracted_text: extractedText }); + } catch (error: any) { + console.error('Extract Attachment Text Error:', error); + res.status(500).json({ error: error.message || 'Internal Server Error' }); + } +}; + export const uploadDocument = async (req: AuthenticatedRequest, res: Response) => { try { const file = req.file; diff --git a/src/routes/document.routes.ts b/src/routes/document.routes.ts index 1fc2175..4909ec5 100644 --- a/src/routes/document.routes.ts +++ b/src/routes/document.routes.ts @@ -1,12 +1,13 @@ import { Router } from 'express'; import multer from 'multer'; import { requireAuth } from '../middleware/auth'; -import { uploadDocument, registerLocalDocument } from '../controllers/document.controller'; +import { uploadDocument, registerLocalDocument, extractAttachmentText } from '../controllers/document.controller'; const router = Router(); const upload = multer({ storage: multer.memoryStorage() }); router.post('/upload', upload.single('file'), requireAuth, uploadDocument); router.post('/register-local', requireAuth, registerLocalDocument); +router.post('/extract-text', upload.single('file'), requireAuth, extractAttachmentText); export default router;