feat: persist general chat as chat_threads, decoupled from cases

Sohbet/Asistan sayfasındaki genel sohbetler artık kalıcı: yeni
chat_threads/thread_messages tabloları (cases ile ilişkisiz) ile
sendThreadMessage endpoint'i eklendi. Eski caseId==='general'
(kalıcı olmayan) dalı kaldırıldı, AI çağrısı ortak callAiChat
yardımcısına taşındı.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mstfyldz
2026-08-10 00:07:44 +03:00
co-authored by Claude Sonnet 5
parent 982f908bf9
commit 0318f2ac77
2 changed files with 102 additions and 59 deletions
+100 -58
View File
@@ -3,10 +3,11 @@ import { supabase } from '../lib/supabase';
import { AuthenticatedRequest } from '../middleware/auth'; import { AuthenticatedRequest } from '../middleware/auth';
import { getAiConfig } from '../lib/aiConfig'; import { getAiConfig } from '../lib/aiConfig';
// Bir dosyanın ilk mesajından kısa bir başlık üretir (Claude/ChatGPT'nin yaptığı gibi). // İlk mesajdan kısa bir başlık üretir (Claude/ChatGPT'nin yaptığı gibi). Başarısız
// Başarısız olursa null döner — çağıran taraf bu durumda "Yeni Sohbet" başlığını korur, // olursa null döner — çağıran taraf bu durumda varsayılan başlığı korur, asıl
// asıl sohbet cevabını etkilemez. // sohbet cevabını etkilemez. Hem dava-dosyası-siz "Yeni Sohbet" (cases.kind='chat')
async function generateCaseTitle(firstMessage: string): Promise<string | null> { // hem de chat_threads kayıtları için ortak kullanılır.
async function generateShortTitle(firstMessage: string): Promise<string | null> {
try { try {
const { runpodUrl, aiModel } = getAiConfig(); const { runpodUrl, aiModel } = getAiConfig();
const response = await fetch(`${runpodUrl}/api/chat`, { const response = await fetch(`${runpodUrl}/api/chat`, {
@@ -31,11 +32,29 @@ async function generateCaseTitle(firstMessage: string): Promise<string | null> {
if (!raw) return null; if (!raw) return null;
return raw.slice(0, 80); return raw.slice(0, 80);
} catch (err) { } catch (err) {
console.error('Generate Case Title Error:', err); console.error('Generate Short Title Error:', err);
return null; return null;
} }
} }
// AI modeline chat completion isteği atan ortak yardımcı. RunPod worker uykudaysa
// soğuk başlangıç birkaç dakika sürebilir; burada sunucu tarafında bir zaman aşımı
// dayatmıyoruz (istemci kendi AbortController'ıyla bekleme süresini yönetiyor) —
// böylece yavaş ama başarılı olacak bir yanıtı erkenden kesmiyoruz.
async function callAiChat(messages: { role: string; content: string }[]): Promise<string> {
const { runpodUrl, aiModel } = getAiConfig();
const response = await fetch(`${runpodUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: aiModel, messages, think: false, stream: false }),
});
if (!response.ok) {
throw new Error(`AI Model request failed: ${response.statusText}`);
}
const data = await response.json();
return data.message?.content || 'Yanıt alınamadı.';
}
export const deleteChatHistory = async (req: AuthenticatedRequest, res: Response) => { export const deleteChatHistory = async (req: AuthenticatedRequest, res: Response) => {
try { try {
const { caseId } = req.params; const { caseId } = req.params;
@@ -64,39 +83,6 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
return res.status(400).json({ error: 'Missing caseId or content' }); return res.status(400).json({ error: 'Missing caseId or content' });
} }
// "general" = dosyasız/genel sohbet. Şemada chat_messages.case_id NOT NULL bir
// cases FK'si olduğundan bu moddaki mesajları veritabanına yazmıyoruz — sadece
// AI'ye iletip yanıtı doğrudan döndürüyoruz.
if (caseId === 'general') {
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 { runpodUrl, aiModel } = getAiConfig();
const response = await fetch(`${runpodUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: aiModel,
messages: [
{ role: 'system', content: generalSystemPrompt },
{ role: 'user', content },
],
think: false,
stream: false,
}),
});
if (!response.ok) {
throw new Error(`AI Model request failed: ${response.statusText}`);
}
const data = await response.json();
const aiContent = data.message?.content || 'Yanıt alınamadı.';
return res.json({
message: { id: `general-${Date.now()}`, case_id: 'general', role: 'assistant', content: aiContent },
});
}
// kind='case' olan gerçek dava dosyalarının başlığı UYAP'tan/kullanıcıdan geliyor — // 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 // 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. // kind='chat' (Sohbet'teki "Yeni Sohbet") kayıtları için geçerli.
@@ -150,25 +136,7 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
messages.push({ role: 'user', content }); messages.push({ role: 'user', content });
} }
const { runpodUrl, aiModel } = getAiConfig(); const aiContent = await callAiChat(messages);
const response = await fetch(`${runpodUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: aiModel,
messages: messages,
think: false,
stream: false,
}),
});
if (!response.ok) {
throw new Error(`AI Model request failed: ${response.statusText}`);
}
const data = await response.json();
const aiContent = data.message?.content || 'Yanıt alınamadı.';
const { data: aiMessage, error: aiMsgError } = await supabase const { data: aiMessage, error: aiMsgError } = await supabase
.from('chat_messages') .from('chat_messages')
@@ -182,7 +150,7 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
let newTitle: string | null = null; let newTitle: string | null = null;
const isFirstMessage = !pastMessages || pastMessages.length <= 1; const isFirstMessage = !pastMessages || pastMessages.length <= 1;
if (isFirstMessage && caseKind === 'chat') { if (isFirstMessage && caseKind === 'chat') {
newTitle = await generateCaseTitle(content); newTitle = await generateShortTitle(content);
if (newTitle) { if (newTitle) {
const { error: titleError } = await supabase const { error: titleError } = await supabase
.from('cases') .from('cases')
@@ -201,3 +169,77 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
res.status(500).json({ error: error.message || 'Internal Server Error' }); res.status(500).json({ error: error.message || 'Internal Server Error' });
} }
}; };
// 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.
export const sendThreadMessage = async (req: AuthenticatedRequest, res: Response) => {
try {
const { threadId } = req.params;
const { content } = req.body;
const userId = req.user?.id;
if (!content || !threadId) {
return res.status(400).json({ error: 'Missing threadId or content' });
}
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 { error: userMsgError } = await supabase
.from('thread_messages')
.insert([{ thread_id: threadId, user_id: userId, role: 'user', content }]);
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.';
const messages: any[] = [{ 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 });
}
const aiContent = await callAiChat(messages);
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) throw 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;
}
}
}
res.json({ message: aiMessage, newTitle });
} catch (error: any) {
console.error('Send Thread Message Error:', error);
res.status(500).json({ error: error.message || 'Internal Server Error' });
}
};
+2 -1
View File
@@ -1,9 +1,10 @@
import { Router } from 'express'; import { Router } from 'express';
import { requireAuth } from '../middleware/auth'; import { requireAuth } from '../middleware/auth';
import { chatMessage, deleteChatHistory } from '../controllers/chat.controller'; import { chatMessage, deleteChatHistory, sendThreadMessage } from '../controllers/chat.controller';
const router = Router(); const router = Router();
router.post('/threads/:threadId/message', requireAuth, sendThreadMessage);
router.post('/:caseId/message', requireAuth, chatMessage); router.post('/:caseId/message', requireAuth, chatMessage);
router.delete('/:caseId', requireAuth, deleteChatHistory); router.delete('/:caseId', requireAuth, deleteChatHistory);