feat: analiz/sohbet/dilekçe endpoint'lerine istekten belge/analiz kabul etme desteği ekle

Yerel-öncelikli veri mimarisi (Faz 2) — case.controller.ts'in 5 analiz
fonksiyonu ve analyzeDigitalIntern artık req.body.documents'ı öncelikli
kaynak olarak kullanıyor (Electron'un yerel SQLite'tan gönderdiği),
yoksa mevcut Postgres sorgusuna düşüyor. buildCaseChatContext ve
generateDraft da aynı şekilde caseDocuments/latestAnalysisSummaryJson
opsiyonel alanlarını kabul ediyor. WhatsApp entegrasyonu (whatsapp.
controller.ts) bilinçli olarak dokunulmadı — yerel dosya erişimi
olmadığından mevcut Postgres yedek yoluna düşmeye devam edecek.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mustafa Yildiz
2026-08-20 00:23:07 +03:00
co-authored by Claude Sonnet 5
parent 7c76913cf9
commit fd5cd8503a
3 changed files with 74 additions and 61 deletions
+34 -42
View File
@@ -17,6 +17,28 @@ function toTraceableLegislation(matches: LegislationMatch[]) {
return matches.map(({ maddeNo, baslik, kanun }) => ({ maddeNo, baslik, kanun })); return matches.map(({ maddeNo, baslik, kanun }) => ({ maddeNo, baslik, kanun }));
} }
interface RequestDocument { filename: string; extracted_text: string | null; }
// Faz 2 — documents artık kalıcı olarak Postgres'te tutulmuyor (Faz 3'te
// yazma da kaldırılacak); istek gövdesinde gönderilen belgeler (Electron'un
// yerel SQLite'tan okuyup gönderdiği) ÖNCELİKLİ kaynak. Gövdede belge yoksa
// (eski istemci sürümü, web vb.) mevcut Postgres sorgusuna düşülür — geçiş
// süresince iki kaynak da destekleniyor.
async function resolveCaseDocuments(caseId: string, bodyDocuments: unknown): Promise<RequestDocument[]> {
if (Array.isArray(bodyDocuments) && bodyDocuments.length > 0) {
return bodyDocuments
.map((d: any) => ({ filename: String(d?.filename ?? d?.name ?? ''), extracted_text: d?.extracted_text ?? null }))
.filter((d) => d.filename);
}
const { data, error } = await supabase
.from('documents')
.select('filename, extracted_text')
.eq('case_id', caseId)
.order('uploaded_at', { ascending: true });
if (error) throw error;
return data || [];
}
export const createCase = async (req: AuthenticatedRequest, res: Response) => { export const createCase = async (req: AuthenticatedRequest, res: Response) => {
try { try {
const userId = req.user?.id; const userId = req.user?.id;
@@ -68,14 +90,9 @@ export const summarizeCase = async (req: AuthenticatedRequest, res: Response) =>
return res.status(400).json({ error: 'Geçersiz caseId' }); return res.status(400).json({ error: 'Geçersiz caseId' });
} }
const { data: docs, error: docsError } = await supabase const docs = await resolveCaseDocuments(String(caseId), req.body?.documents);
.from('documents')
.select('filename, extracted_text')
.eq('case_id', caseId)
.order('uploaded_at', { ascending: true });
if (docsError) throw docsError;
const withText = (docs || []).filter((d) => d.extracted_text && String(d.extracted_text).trim()); const withText = docs.filter((d) => d.extracted_text && String(d.extracted_text).trim());
if (withText.length === 0) { if (withText.length === 0) {
return res.status(400).json({ error: 'Bu dosyada metni çıkarılmış belge yok, özet hazırlanamıyor.' }); return res.status(400).json({ error: 'Bu dosyada metni çıkarılmış belge yok, özet hazırlanamıyor.' });
} }
@@ -132,19 +149,14 @@ export const analyzeStatements = async (req: AuthenticatedRequest, res: Response
return res.status(400).json({ error: 'Geçersiz caseId' }); return res.status(400).json({ error: 'Geçersiz caseId' });
} }
const { data: docs, error: docsError } = await supabase const docs = await resolveCaseDocuments(String(caseId), req.body?.documents);
.from('documents')
.select('filename, extracted_text')
.eq('case_id', caseId)
.order('uploaded_at', { ascending: true });
if (docsError) throw docsError;
// Sadece ifade, sorgu, beyan, tutanak, duruşma içeren belgeleri filtrele. // Sadece ifade, sorgu, beyan, tutanak, duruşma içeren belgeleri filtrele.
// "tutanak" tek başına çok geniş — "Tebligat Tutanağı" gibi ifade/beyan // "tutanak" tek başına çok geniş — "Tebligat Tutanağı" gibi ifade/beyan
// İÇERMEYEN evrakları da yakalıyordu, çelişki analizine gürültü katıyordu. // İÇERMEYEN evrakları da yakalıyordu, çelişki analizine gürültü katıyordu.
const keywords = ['ifade', 'sorgu', 'beyan', 'tutanak', 'duruşma']; const keywords = ['ifade', 'sorgu', 'beyan', 'tutanak', 'duruşma'];
const excludeKeywords = ['tebligat', 'tebliğ']; const excludeKeywords = ['tebligat', 'tebliğ'];
const statementDocs = (docs || []).filter((d) => { const statementDocs = docs.filter((d) => {
if (!d.extracted_text || !String(d.extracted_text).trim()) return false; if (!d.extracted_text || !String(d.extracted_text).trim()) return false;
const fn = normalizeTr(d.filename); const fn = normalizeTr(d.filename);
if (excludeKeywords.some(kw => fn.includes(kw))) return false; if (excludeKeywords.some(kw => fn.includes(kw))) return false;
@@ -187,16 +199,11 @@ export const analyzeDeficiencies = async (req: AuthenticatedRequest, res: Respon
return res.status(400).json({ error: 'Geçersiz caseId' }); return res.status(400).json({ error: 'Geçersiz caseId' });
} }
const { data: docs, error: docsError } = await supabase const docs = await resolveCaseDocuments(String(caseId), req.body?.documents);
.from('documents')
.select('filename, extracted_text')
.eq('case_id', caseId)
.order('uploaded_at', { ascending: true });
if (docsError) throw docsError;
// Çöp/makbuz niteliğindeki teknik zarf belgeleri hariç metni olan tüm evrakları dahil et // Çöp/makbuz niteliğindeki teknik zarf belgeleri hariç metni olan tüm evrakları dahil et
const trashKeywords = ['makbuz', 'pul', 'zarf', 'masraf', 'havale', 'barkod', 'alındı', 'görüldü', 'ilan']; const trashKeywords = ['makbuz', 'pul', 'zarf', 'masraf', 'havale', 'barkod', 'alındı', 'görüldü', 'ilan'];
const targetDocs = (docs || []).filter((d) => { const targetDocs = docs.filter((d) => {
if (!d.extracted_text || !String(d.extracted_text).trim()) return false; if (!d.extracted_text || !String(d.extracted_text).trim()) return false;
const fn = normalizeTr(d.filename); const fn = normalizeTr(d.filename);
return !trashKeywords.some((kw) => fn.includes(kw)); return !trashKeywords.some((kw) => fn.includes(kw));
@@ -245,16 +252,11 @@ export const analyzeStrategy = async (req: AuthenticatedRequest, res: Response)
return res.status(400).json({ error: 'clientName (Müvekkil Adı) zorunludur' }); return res.status(400).json({ error: 'clientName (Müvekkil Adı) zorunludur' });
} }
const { data: docs, error: docsError } = await supabase const docs = await resolveCaseDocuments(String(caseId), req.body?.documents);
.from('documents')
.select('filename, extracted_text')
.eq('case_id', caseId)
.order('uploaded_at', { ascending: true });
if (docsError) throw docsError;
// Gereksiz makbuz, pul vb. hariç tüm evrakları strateji için kullan // Gereksiz makbuz, pul vb. hariç tüm evrakları strateji için kullan
const trashKeywords = ['tebligat', 'makbuz', 'pul', 'zarf', 'masraf', 'havale', 'barkod', 'alındı', 'görüldü', 'ilan', 'talimat', 'nüfus', 'sicil', 'sabıka']; const trashKeywords = ['tebligat', 'makbuz', 'pul', 'zarf', 'masraf', 'havale', 'barkod', 'alındı', 'görüldü', 'ilan', 'talimat', 'nüfus', 'sicil', 'sabıka'];
const targetDocs = (docs || []).filter(d => { const targetDocs = docs.filter(d => {
if (!d.extracted_text || !String(d.extracted_text).trim()) return false; if (!d.extracted_text || !String(d.extracted_text).trim()) return false;
const fn = normalizeTr(d.filename); const fn = normalizeTr(d.filename);
return !trashKeywords.some(kw => fn.includes(kw)); return !trashKeywords.some(kw => fn.includes(kw));
@@ -302,15 +304,10 @@ export const analyzeMediation = async (req: AuthenticatedRequest, res: Response)
return res.status(400).json({ error: 'clientName (Müvekkil Adı) zorunludur' }); return res.status(400).json({ error: 'clientName (Müvekkil Adı) zorunludur' });
} }
const { data: docs, error: docsError } = await supabase const docs = await resolveCaseDocuments(String(caseId), req.body?.documents);
.from('documents')
.select('filename, extracted_text')
.eq('case_id', caseId)
.order('uploaded_at', { ascending: true });
if (docsError) throw docsError;
const trashKeywords = ['tebligat', 'makbuz', 'pul', 'zarf', 'masraf', 'havale', 'barkod', 'alındı', 'görüldü', 'ilan', 'talimat', 'nüfus', 'sicil', 'sabıka']; const trashKeywords = ['tebligat', 'makbuz', 'pul', 'zarf', 'masraf', 'havale', 'barkod', 'alındı', 'görüldü', 'ilan', 'talimat', 'nüfus', 'sicil', 'sabıka'];
const targetDocs = (docs || []).filter(d => { const targetDocs = docs.filter(d => {
if (!d.extracted_text || !String(d.extracted_text).trim()) return false; if (!d.extracted_text || !String(d.extracted_text).trim()) return false;
const fn = (d.filename || '').toLowerCase().replace(/ı/g, 'i').replace(/ğ/g, 'g').replace(/ü/g, 'u').replace(/ş/g, 's').replace(/ö/g, 'o').replace(/ç/g, 'c'); const fn = (d.filename || '').toLowerCase().replace(/ı/g, 'i').replace(/ğ/g, 'g').replace(/ü/g, 'u').replace(/ş/g, 's').replace(/ö/g, 'o').replace(/ç/g, 'c');
return !trashKeywords.some(kw => fn.includes(kw)); return !trashKeywords.some(kw => fn.includes(kw));
@@ -405,14 +402,9 @@ export const analyzeDigitalIntern = async (req: AuthenticatedRequest, res: Respo
return res.status(400).json({ error: 'Soru (question) girmelisiniz.' }); return res.status(400).json({ error: 'Soru (question) girmelisiniz.' });
} }
const { data: docs, error: docsError } = await supabase const docs = await resolveCaseDocuments(String(caseId), req.body?.documents);
.from('documents')
.select('filename, extracted_text')
.eq('case_id', caseId)
.order('uploaded_at', { ascending: true });
if (docsError) throw docsError;
const targetDocs = (docs || []).filter(d => d.extracted_text && String(d.extracted_text).trim()); const targetDocs = docs.filter(d => d.extracted_text && String(d.extracted_text).trim());
if (targetDocs.length === 0) { if (targetDocs.length === 0) {
return res.status(400).json({ error: 'Bu dosyada arama yapılabilecek evrak bulunamadı.' }); return res.status(400).json({ error: 'Bu dosyada arama yapılabilecek evrak bulunamadı.' });
+23 -11
View File
@@ -100,6 +100,12 @@ interface CaseChatContextOpts {
// mesajını insert ettikten SONRA bu fonksiyonu çağırıyor olmalı) kullanılacak // 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ış. // son çare — orijinal chatMessage'daki else dalıyla aynı savunma amaçlı davranış.
fallbackUserContent?: string; fallbackUserContent?: string;
// Faz 2 — documents/analyses artık kalıcı olarak Postgres'te tutulmuyor.
// Electron (yerel SQLite'tan okuyarak) bunları isteğe ekleyebilir; verilirse
// ÖNCELİKLİ kaynaktır. Verilmezse (örn. WhatsApp — yerel dosya erişimi yok)
// aşağıdaki Postgres sorgularına düşülür, davranış aynen korunur.
caseDocuments?: { id?: string; filename: string; extracted_text: string | null }[];
latestAnalysisSummaryJson?: unknown;
} }
// chatMessage (masaüstü "AyrisLegal'e Sor" sohbeti) ve WhatsApp uç noktasının // chatMessage (masaüstü "AyrisLegal'e Sor" sohbeti) ve WhatsApp uç noktasının
@@ -109,18 +115,22 @@ interface CaseChatContextOpts {
// kullanıcı mesajını chat_messages'a insert ETTİKTEN SONRA çağırmalı, ki pastMessages // kullanıcı mesajını chat_messages'a insert ETTİKTEN SONRA çağırmalı, ki pastMessages
// sorgusu o mesajı da (en sonda) içersin. // sorgusu o mesajı da (en sonda) içersin.
export async function buildCaseChatContext(caseId: string, opts: CaseChatContextOpts = {}): Promise<ChatMsg[]> { export async function buildCaseChatContext(caseId: string, opts: CaseChatContextOpts = {}): Promise<ChatMsg[]> {
const { chat_mode = 'chat', persona, document_ids, attachments, historyLimit = 10, fallbackUserContent } = opts; const { chat_mode = 'chat', persona, document_ids, attachments, historyLimit = 10, fallbackUserContent, caseDocuments, latestAnalysisSummaryJson } = opts;
const targetPersona = persona || 'judge'; const targetPersona = persona || 'judge';
const { data: analyses } = await supabase const hasBodyDocuments = Array.isArray(caseDocuments) && caseDocuments.length > 0;
.from('analyses') const documents = hasBodyDocuments ? caseDocuments : (await supabase
.select('summary_json')
.eq('case_id', caseId);
const { data: documents } = await supabase
.from('documents') .from('documents')
.select('id, filename, extracted_text') .select('id, filename, extracted_text')
.eq('case_id', caseId); .eq('case_id', caseId)).data;
const latestAnalysis = latestAnalysisSummaryJson !== undefined ? latestAnalysisSummaryJson : (await (async () => {
const { data: analyses } = await supabase
.from('analyses')
.select('summary_json')
.eq('case_id', caseId);
return analyses && analyses.length > 0 ? analyses[analyses.length - 1].summary_json : undefined;
})());
const { data: pastMessages } = await supabase const { data: pastMessages } = await supabase
.from('chat_messages') .from('chat_messages')
@@ -146,8 +156,8 @@ export async function buildCaseChatContext(caseId: string, opts: CaseChatContext
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.'; 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) { if (latestAnalysis) {
documentContext += '\n\nANALİZ ÖZETİ:\n' + JSON.stringify(analyses[analyses.length - 1].summary_json); documentContext += '\n\nANALİZ ÖZETİ:\n' + JSON.stringify(latestAnalysis);
} }
documentContext += buildAttachmentContext(attachments); documentContext += buildAttachmentContext(attachments);
@@ -180,7 +190,7 @@ export async function buildCaseChatContext(caseId: string, opts: CaseChatContext
// yerine yazılırken görür; gerçek üretim süresi aynı kalsa da algılanan hız artar. // 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) => { export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
const { caseId } = req.params; const { caseId } = req.params;
const { content, message, attachments, document_ids, chat_mode = 'chat', persona, simulator_persona } = req.body || {}; const { content, message, attachments, document_ids, chat_mode = 'chat', persona, simulator_persona, case_documents, latest_analysis_summary_json } = req.body || {};
const messageText = content || message; const messageText = content || message;
const targetPersona = simulator_persona || persona || 'judge'; const targetPersona = simulator_persona || persona || 'judge';
const userId = req.user?.id; const userId = req.user?.id;
@@ -208,6 +218,8 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
document_ids, document_ids,
attachments, attachments,
fallbackUserContent: persistedContent, fallbackUserContent: persistedContent,
caseDocuments: Array.isArray(case_documents) ? case_documents : undefined,
latestAnalysisSummaryJson: latest_analysis_summary_json,
}); });
startSse(res); startSse(res);
+17 -8
View File
@@ -47,7 +47,7 @@ function toTraceableLegislation(matches: LegislationMatch[]) {
// gerçek bir AI taslağı üretir. // gerçek bir AI taslağı üretir.
export const generateDraft = async (req: AuthenticatedRequest, res: Response) => { export const generateDraft = async (req: AuthenticatedRequest, res: Response) => {
try { try {
const { case_id, petition_type, notes, template_id } = req.body || {}; const { case_id, petition_type, notes, template_id, latest_analysis_summary_json } = req.body || {};
const userId = req.user?.id; const userId = req.user?.id;
if (!case_id || !petition_type) { if (!case_id || !petition_type) {
@@ -66,13 +66,22 @@ export const generateDraft = async (req: AuthenticatedRequest, res: Response) =>
return res.status(403).json({ error: 'Bu dosyaya erişim yetkiniz yok.' }); return res.status(403).json({ error: 'Bu dosyaya erişim yetkiniz yok.' });
} }
const { data: lastAnalysis } = await supabase // Faz 2 — analyses artık kalıcı olarak Postgres'te tutulmuyor. İstekte
.from('analyses') // gönderilmişse (Electron'un yerel SQLite'tan okuduğu son analiz) öncelikli
.select('summary_json') // kaynak; yoksa (eski istemci/web) mevcut Postgres sorgusuna düşülür.
.eq('case_id', case_id) let lastAnalysis: { summary_json: any } | null = latest_analysis_summary_json !== undefined
.order('created_at', { ascending: false }) ? { summary_json: latest_analysis_summary_json }
.limit(1) : null;
.maybeSingle(); if (!lastAnalysis) {
const { data } = await supabase
.from('analyses')
.select('summary_json')
.eq('case_id', case_id)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
lastAnalysis = data;
}
let template: { name: string; extracted_text: string | null } | null = null; let template: { name: string; extracted_text: string | null } | null = null;
if (template_id) { if (template_id) {