From ab20d884220d93262357532c28961bd82c0bdd24 Mon Sep 17 00:00:00 2001 From: Mustafa Yildiz Date: Thu, 20 Aug 2026 00:31:59 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20documents/analyses/drafts=20Postgres=20?= =?UTF-8?q?yazmalar=C4=B1n=C4=B1=20ge=C3=A7i=C5=9F=20bayra=C4=9F=C4=B1n?= =?UTF-8?q?=C4=B1n=20arkas=C4=B1na=20al?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yerel-öncelikli veri mimarisi (Faz 3) — registerLocalDocument, case. controller.ts'in 6 fonksiyonu (5 analiz + Dijital Stajyer) ve drafting. controller.ts'in generateDraft/generateAngarya'sı artık PERSIST_TO_ POSTGRES ortam değişkenine göre davranıyor. Bayrak varsayılan olarak true (mevcut davranış birebir korunuyor) — kod deploy'u gerçek davranışı DEĞİŞTİRMİYOR. Kullanıcı gerçek veriyle Faz 1/2'yi (yerel SQLite'a çift yazma + istekten metin kabul etme) test edip güvendiğinde, PERSIST_TO_POSTGRES=false ayarıyla (kod değişikliği gerekmeden) yazmalar durur; geri dönüş de aynı şekilde anlık. case_events ve chat_messages kapsam dışı, dokunulmadı. Angarya taslakları için bilinen bir sınırlama var: Faz 1'de yerel dual-write eklenmediğinden, bayrak kapatıldığında bu taslaklar hiçbir yerde kalıcı olmayacak. Co-Authored-By: Claude Sonnet 5 --- src/controllers/case.controller.ts | 79 ++++++++++++++------------ src/controllers/document.controller.ts | 58 +++++++++++++------ src/controllers/drafting.controller.ts | 78 +++++++++++++++---------- src/lib/localFirstFlag.ts | 16 ++++++ 4 files changed, 146 insertions(+), 85 deletions(-) create mode 100644 src/lib/localFirstFlag.ts diff --git a/src/controllers/case.controller.ts b/src/controllers/case.controller.ts index 2e05795..ce47422 100644 --- a/src/controllers/case.controller.ts +++ b/src/controllers/case.controller.ts @@ -4,6 +4,8 @@ import { AuthenticatedRequest } from '../middleware/auth'; import { runIddianameAnalysis, runCaseNarrativeAnalysis, runStatementAnalysis, runDeficiencyAnalysis, runStrategyAnalysis, runMediationAnalysis, runDigitalInternQa } from './document.controller'; import { findOrCreateCaseByTitle } from '../lib/caseLookup'; import { findRelevantLegislation, LegislationMatch } from '../lib/legislationLookup'; +import { shouldPersistLegacyTables } from '../lib/localFirstFlag'; +import crypto from 'crypto'; function formatLegislationBlock(matches: LegislationMatch[]): string { if (matches.length === 0) return ''; @@ -39,6 +41,32 @@ async function resolveCaseDocuments(caseId: string, bodyDocuments: unknown): Pro return data || []; } +// Faz 3 — analyses artık kalıcı olarak Postgres'e YAZILMIYOR (PERSIST_TO_POSTGRES=false +// olduğunda). Bayrak açıkken (varsayılan, geçiş güvenliği için) davranış birebir +// eskisi gibi kalır. Bayrak kapatılınca DB'ye insert yapılmaz, çağırana yine de +// aynı şekle sahip (id/created_at üretilmiş) bir kayıt döner ki yanıt formatı +// ve Electron'un yerel SQLite'a yazması etkilenmesin. +async function persistOrBuildAnalysis(caseId: string, documentId: string | null, summaryJson: unknown, aiModel: string, extraColumns: Record = {}) { + if (shouldPersistLegacyTables()) { + const { data, error } = await supabase + .from('analyses') + .insert([{ case_id: caseId, document_id: documentId, summary_json: summaryJson, model_used: aiModel, ...extraColumns }]) + .select() + .single(); + if (error) throw error; + return data; + } + return { + id: crypto.randomUUID(), + case_id: caseId, + document_id: documentId, + summary_json: summaryJson, + model_used: aiModel, + created_at: new Date().toISOString(), + ...extraColumns, + }; +} + export const createCase = async (req: AuthenticatedRequest, res: Response) => { try { const userId = req.user?.id; @@ -127,12 +155,7 @@ export const summarizeCase = async (req: AuthenticatedRequest, res: Response) => const { summaryJson, aiModel } = await runCaseNarrativeAnalysis(analysisInput); - const { data: analysisRecord, error: analysisError } = await supabase - .from('analyses') - .insert([{ case_id: caseId, document_id: null, summary_json: { ...summaryJson, kullanilan_mevzuat: toTraceableLegislation(legislationMatches) }, model_used: aiModel }]) - .select() - .single(); - if (analysisError) throw analysisError; + const analysisRecord = await persistOrBuildAnalysis(String(caseId), null, { ...summaryJson, kullanilan_mevzuat: toTraceableLegislation(legislationMatches) }, aiModel); res.json({ analysis: analysisRecord }); } catch (error: any) { @@ -177,13 +200,7 @@ export const analyzeStatements = async (req: AuthenticatedRequest, res: Response const { summaryJson, aiModel } = await runStatementAnalysis(analysisInput); - // Çelişki analizi sonucunu 'analyses' tablosuna kaydet - const { data: analysisRecord, error: analysisError } = await supabase - .from('analyses') - .insert([{ case_id: caseId, document_id: null, summary_json: { statementAnalysis: summaryJson, kullanilan_mevzuat: toTraceableLegislation(legislationMatches) }, model_used: aiModel }]) - .select() - .single(); - if (analysisError) throw analysisError; + const analysisRecord = await persistOrBuildAnalysis(String(caseId), null, { statementAnalysis: summaryJson, kullanilan_mevzuat: toTraceableLegislation(legislationMatches) }, aiModel); res.json({ analysis: analysisRecord }); } catch (error: any) { @@ -222,13 +239,7 @@ export const analyzeDeficiencies = async (req: AuthenticatedRequest, res: Respon const { summaryJson, aiModel } = await runDeficiencyAnalysis(analysisInput); - // Eksiklik analizi sonucunu 'analyses' tablosuna kaydet - const { data: analysisRecord, error: analysisError } = await supabase - .from('analyses') - .insert([{ case_id: caseId, document_id: null, summary_json: { deficiencyAnalysis: summaryJson }, model_used: aiModel }]) - .select() - .single(); - if (analysisError) throw analysisError; + const analysisRecord = await persistOrBuildAnalysis(String(caseId), null, { deficiencyAnalysis: summaryJson }, aiModel); res.json({ analysis: analysisRecord }); } catch (error: any) { @@ -275,13 +286,7 @@ export const analyzeStrategy = async (req: AuthenticatedRequest, res: Response) const { summaryJson, aiModel } = await runStrategyAnalysis(analysisInput, nameToUse, roleToUse); - // Strateji analizi sonucunu 'analyses' tablosuna kaydet - const { data: analysisRecord, error: analysisError } = await supabase - .from('analyses') - .insert([{ case_id: caseId, document_id: null, summary_json: { strategyAnalysis: summaryJson, clientName: nameToUse, clientRole: roleToUse }, model_used: aiModel }]) - .select() - .single(); - if (analysisError) throw analysisError; + const analysisRecord = await persistOrBuildAnalysis(String(caseId), null, { strategyAnalysis: summaryJson, clientName: nameToUse, clientRole: roleToUse }, aiModel); res.json({ analysis: analysisRecord }); } catch (error: any) { @@ -326,12 +331,7 @@ export const analyzeMediation = async (req: AuthenticatedRequest, res: Response) const { summaryJson, aiModel } = await runMediationAnalysis(analysisInput, nameToUse); - const { data: analysisRecord, error: analysisError } = await supabase - .from('analyses') - .insert([{ case_id: caseId, document_id: null, summary_json: { mediationAnalysis: summaryJson, clientName: nameToUse }, analysis_type: 'mediation', model_used: aiModel }]) - .select() - .single(); - if (analysisError) throw analysisError; + const analysisRecord = await persistOrBuildAnalysis(String(caseId), null, { mediationAnalysis: summaryJson, clientName: nameToUse }, aiModel, { analysis_type: 'mediation' }); res.json({ analysis: analysisRecord }); } catch (error: any) { @@ -420,10 +420,15 @@ export const analyzeDigitalIntern = async (req: AuthenticatedRequest, res: Respo const { summaryJson, aiModel } = await runDigitalInternQa(analysisInput, questionToUse); const userId = req.user?.id; - // Dijital Stajyer sonucunu hem 'analyses' hem de 'chat_messages' tablosuna kaydet - await supabase - .from('analyses') - .insert([{ case_id: caseId, document_id: null, summary_json: { digitalInternResult: summaryJson, question: questionToUse }, model_used: aiModel }]); + // Dijital Stajyer sonucunu 'chat_messages'a her zaman kaydet (kapsam dışı, + // dokunulmadı); 'analyses'a ise sadece PERSIST_TO_POSTGRES açıkken — + // bu zaten anlık/tek seferlik bir cevap, Faz 1'de yerel SQLite'a da hiç + // yazılmıyor, bu yüzden throwaway kayda gerek yok. + if (shouldPersistLegacyTables()) { + await supabase + .from('analyses') + .insert([{ case_id: caseId, document_id: null, summary_json: { digitalInternResult: summaryJson, question: questionToUse }, model_used: aiModel }]); + } if (userId) { await supabase.from('chat_messages').insert([ diff --git a/src/controllers/document.controller.ts b/src/controllers/document.controller.ts index 66e76f2..53a7e6d 100644 --- a/src/controllers/document.controller.ts +++ b/src/controllers/document.controller.ts @@ -8,6 +8,8 @@ import iconv from 'iconv-lite'; import { findOrCreateCaseByTitle } from '../lib/caseLookup'; import { callOllama, callOllamaVisionOcr } from '../lib/aiClient'; import { getAiConfig } from '../lib/aiConfig'; +import { shouldPersistLegacyTables } from '../lib/localFirstFlag'; +import crypto from 'crypto'; const heicConvert = require('heic-convert'); // Ceza davası analizlerinde (Dava Özeti, Dava İlerlemesi, Çelişki Avcısı, Strateji) @@ -330,6 +332,7 @@ export const registerLocalDocument = async (req: AuthenticatedRequest, res: Resp } const caseId = await findOrCreateCaseByTitle(userId as string, case_title); + const persistLegacy = shouldPersistLegacyTables(); // "Yeniden İşle" aynı case_title+filename ile bu uca tekrar tekrar istek // atabiliyor (OCR ilk seferde boş sonuç döndüğünde) — daha önce burada koşulsuz @@ -344,15 +347,21 @@ export const registerLocalDocument = async (req: AuthenticatedRequest, res: Resp .maybeSingle(); if (existingErr) throw existingErr; + // Faz 3 (PERSIST_TO_POSTGRES=false): documents artık extracted_text'i + // KALICI olarak saklamıyor — sadece case_events FK'sının ve Belgeler + // ekranındaki dosya listesinin çalışmaya devam etmesi için hafif bir + // metadata satırı tutulur. Yanıtta extracted_text yine de döner (Electron + // bunu kendi yerel SQLite'ına yazsın diye) — burada dönen değer DB'den + // değil, doğrudan istekten geliyor. + const docColumns = persistLegacy + ? { file_size: extracted_text.length, ocr_status: extracted_text ? 'done' : 'failed', extracted_text } + : { file_size: extracted_text.length, ocr_status: extracted_text ? 'done' : 'failed', extracted_text: null }; + let documentRecord; if (existingDoc) { const { data, error: updateErr } = await supabase .from('documents') - .update({ - file_size: extracted_text.length, - ocr_status: extracted_text ? 'done' : 'failed', - extracted_text, - }) + .update(docColumns) .eq('id', existingDoc.id) .select() .single(); @@ -367,29 +376,44 @@ export const registerLocalDocument = async (req: AuthenticatedRequest, res: Resp storage_path: 'local', // ham dosya sadece kullanıcının cihazında; Storage'da bir karşılığı yok filename, mime_type: null, - file_size: extracted_text.length, - ocr_status: extracted_text ? 'done' : 'failed', - extracted_text, + ...docColumns, }]) .select() .single(); if (docError) throw docError; documentRecord = data; } + if (!persistLegacy) { + // DB'ye yazılmadı ama yanıtta hâlâ ihtiyaç var (Electron'un yerel + // SQLite'a yazacağı değer) — istekten geleni doğrudan geri koy. + documentRecord = { ...documentRecord, extracted_text }; + } let analysisRecord = null; if (extracted_text.trim()) { try { const { summaryJson, aiModel } = await runDocumentAnalysis(extracted_text); - const { data: analysis, error: analysisError } = await supabase - .from('analyses') - .insert([{ case_id: caseId, document_id: documentRecord.id, summary_json: summaryJson, model_used: aiModel }]) - .select() - .single(); - if (analysisError) throw analysisError; - analysisRecord = analysis; - - // 5. Takvim (Case Events) kayıtlarını oluştur + if (persistLegacy) { + const { data: analysis, error: analysisError } = await supabase + .from('analyses') + .insert([{ case_id: caseId, document_id: documentRecord.id, summary_json: summaryJson, model_used: aiModel }]) + .select() + .single(); + if (analysisError) throw analysisError; + analysisRecord = analysis; + } else { + analysisRecord = { + id: crypto.randomUUID(), + case_id: caseId, + document_id: documentRecord.id, + summary_json: summaryJson, + model_used: aiModel, + created_at: new Date().toISOString(), + }; + } + + // 5. Takvim (Case Events) kayıtlarını oluştur — case_events kapsam + // dışı, PERSIST_TO_POSTGRES bayrağından bağımsız her zaman yazılır. if (summaryJson.events && Array.isArray(summaryJson.events)) { const eventsToInsert = summaryJson.events .filter((e: any) => e.date && e.title) diff --git a/src/controllers/drafting.controller.ts b/src/controllers/drafting.controller.ts index 1ec2b61..968776a 100644 --- a/src/controllers/drafting.controller.ts +++ b/src/controllers/drafting.controller.ts @@ -3,6 +3,8 @@ import { supabase } from '../lib/supabase'; import { AuthenticatedRequest } from '../middleware/auth'; import { callAiModelRaw, CEZA_MUHAKEMESI_REFERANSI, HUKUK_MAHKEMELERI_REFERANSI } from './document.controller'; import { findRelevantLegislation, LegislationMatch } from '../lib/legislationLookup'; +import { shouldPersistLegacyTables } from '../lib/localFirstFlag'; +import crypto from 'crypto'; const DRAFT_SYSTEM_PROMPT = ` Sen uzman bir Türk avukatısın. Sana verilen dosya bilgilerine göre, istenen türde @@ -118,22 +120,29 @@ export const generateDraft = async (req: AuthenticatedRequest, res: Response) => const { aiContent, aiModel } = await callAiModelRaw(DRAFT_SYSTEM_PROMPT, userContent, 2500, 180_000); - // Taslağı kalıcı olarak kaydediyoruz — hem "Dilekçelerim" listesinde hem ilgili - // dava dosyasının kendi "Dilekçeler" bölümünde görünsün, sayfadan çıkınca kaybolmasın. - const { data: draftRecord, error: draftError } = await supabase - .from('drafts') - .insert([{ - user_id: userId, - case_id, - petition_type, - content: aiContent, - template_id: template_id || null, - used_legislation: toTraceableLegislation(legislationMatches), - }]) - .select('id, created_at') - .single(); - if (draftError) { - console.error('Save Draft Error:', draftError); + // Faz 3 — taslak artık kalıcı olarak Postgres'e YAZILMIYOR (PERSIST_TO_POSTGRES=false + // olduğunda); "Dilekçelerim" listesi artık yerel SQLite'tan (Faz 1/2) besleniyor. + // Bayrak açıkken (varsayılan) davranış birebir eskisi gibi kalır. + let draftRecord: { id: string; created_at?: string } | null = null; + if (shouldPersistLegacyTables()) { + const { data, error: draftError } = await supabase + .from('drafts') + .insert([{ + user_id: userId, + case_id, + petition_type, + content: aiContent, + template_id: template_id || null, + used_legislation: toTraceableLegislation(legislationMatches), + }]) + .select('id, created_at') + .single(); + if (draftError) { + console.error('Save Draft Error:', draftError); + } + draftRecord = data; + } else { + draftRecord = { id: crypto.randomUUID(), created_at: new Date().toISOString() }; } res.json({ draft: aiContent, draftId: draftRecord?.id || null, model: aiModel, usedTemplate: !!template, usedLegislation: toTraceableLegislation(legislationMatches) }); @@ -205,22 +214,29 @@ export const generateAngarya = async (req: AuthenticatedRequest, res: Response) // Fast generation using a lightweight model or quick params const { aiContent, aiModel } = await callAiModelRaw(ANGARYA_SYSTEM_PROMPT, userContent, 500); - const { data: draftRecord, error: draftError } = await supabase - .from('drafts') - .insert([{ - user_id: userId, - case_id, - petition_type: petitionType, - content: aiContent, - template_id: null, - }]) - .select('id, created_at') - .single(); - - if (draftError) { - console.error('Save Draft Error:', draftError); + // Faz 3 — angarya taslakları da PERSIST_TO_POSTGRES=false olduğunda + // kalıcı yazılmıyor. NOT: Angarya için Faz 1'de bir yerel SQLite dual-write + // eklenmedi (kapsam dışı tutulan düşük öncelikli özellik) — bayrak + // kapatıldığında bu taslaklar sadece HTTP yanıtında var olacak, hiçbir + // yerde kalıcı olmayacak. Bilinen ve kabul edilmiş bir sınırlama. + let draftRecord: { id: string; created_at?: string } | null = null; + if (shouldPersistLegacyTables()) { + const { data, error: draftError } = await supabase + .from('drafts') + .insert([{ + user_id: userId, + case_id, + petition_type: petitionType, + content: aiContent, + template_id: null, + }]) + .select('id, created_at') + .single(); + if (draftError) console.error('Save Angarya Draft Error:', draftError); + draftRecord = data; + } else { + draftRecord = { id: crypto.randomUUID(), created_at: new Date().toISOString() }; } - res.json({ draft: aiContent, draftId: draftRecord?.id || null, model: aiModel, usedTemplate: false }); } catch (error: any) { console.error('Generate Angarya Error:', error); diff --git a/src/lib/localFirstFlag.ts b/src/lib/localFirstFlag.ts new file mode 100644 index 0000000..feab853 --- /dev/null +++ b/src/lib/localFirstFlag.ts @@ -0,0 +1,16 @@ +// Yerel-öncelikli veri mimarisi (Faz 3) geçiş bayrağı — bkz. +// laawos/docs/mimari-karar-yerel-oncelikli-veri-modeli.md +// +// documents/analyses/drafts artık Postgres'e kalıcı olarak YAZILMAMASI +// gerekiyor (belge/analiz içeriği artık kullanıcının kendi bilgisayarında + +// Drive'ında tutuluyor). Ama bu geçiş kod deploy'uyla AYNI ANDA gerçek +// davranışı değiştirmesin diye — kullanıcı önce gerçek veriyle Faz 1/2'yi +// (yerel SQLite'a çift yazma + istekten metin kabul etme) test edip güvenene +// kadar — varsayılan davranış DEĞİŞMİYOR: Postgres'e yazmaya devam ediyoruz. +// +// PERSIST_TO_POSTGRES=false ayarlanınca (kod deploy'u GEREKMEDEN, sadece ortam +// değişkeni) yazmalar durur. Geri dönüş de aynı şekilde anlık — değişkeni +// kaldırmak veya "true" yapmak yeterli. +export function shouldPersistLegacyTables(): boolean { + return process.env.PERSIST_TO_POSTGRES !== 'false'; +}