feat: documents/analyses/drafts Postgres yazmalarını geçiş bayrağının arkasına al

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 <noreply@anthropic.com>
This commit is contained in:
Mustafa Yildiz
2026-08-20 00:31:59 +03:00
co-authored by Claude Sonnet 5
parent fd5cd8503a
commit ab20d88422
4 changed files with 146 additions and 85 deletions
+42 -37
View File
@@ -4,6 +4,8 @@ import { AuthenticatedRequest } from '../middleware/auth';
import { runIddianameAnalysis, runCaseNarrativeAnalysis, runStatementAnalysis, runDeficiencyAnalysis, runStrategyAnalysis, runMediationAnalysis, runDigitalInternQa } from './document.controller'; import { runIddianameAnalysis, runCaseNarrativeAnalysis, runStatementAnalysis, runDeficiencyAnalysis, runStrategyAnalysis, runMediationAnalysis, runDigitalInternQa } from './document.controller';
import { findOrCreateCaseByTitle } from '../lib/caseLookup'; import { findOrCreateCaseByTitle } from '../lib/caseLookup';
import { findRelevantLegislation, LegislationMatch } from '../lib/legislationLookup'; import { findRelevantLegislation, LegislationMatch } from '../lib/legislationLookup';
import { shouldPersistLegacyTables } from '../lib/localFirstFlag';
import crypto from 'crypto';
function formatLegislationBlock(matches: LegislationMatch[]): string { function formatLegislationBlock(matches: LegislationMatch[]): string {
if (matches.length === 0) return ''; if (matches.length === 0) return '';
@@ -39,6 +41,32 @@ async function resolveCaseDocuments(caseId: string, bodyDocuments: unknown): Pro
return data || []; 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<string, unknown> = {}) {
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) => { export const createCase = async (req: AuthenticatedRequest, res: Response) => {
try { try {
const userId = req.user?.id; const userId = req.user?.id;
@@ -127,12 +155,7 @@ export const summarizeCase = async (req: AuthenticatedRequest, res: Response) =>
const { summaryJson, aiModel } = await runCaseNarrativeAnalysis(analysisInput); const { summaryJson, aiModel } = await runCaseNarrativeAnalysis(analysisInput);
const { data: analysisRecord, error: analysisError } = await supabase const analysisRecord = await persistOrBuildAnalysis(String(caseId), null, { ...summaryJson, kullanilan_mevzuat: toTraceableLegislation(legislationMatches) }, aiModel);
.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;
res.json({ analysis: analysisRecord }); res.json({ analysis: analysisRecord });
} catch (error: any) { } catch (error: any) {
@@ -177,13 +200,7 @@ export const analyzeStatements = async (req: AuthenticatedRequest, res: Response
const { summaryJson, aiModel } = await runStatementAnalysis(analysisInput); const { summaryJson, aiModel } = await runStatementAnalysis(analysisInput);
// Çelişki analizi sonucunu 'analyses' tablosuna kaydet const analysisRecord = await persistOrBuildAnalysis(String(caseId), null, { statementAnalysis: summaryJson, kullanilan_mevzuat: toTraceableLegislation(legislationMatches) }, aiModel);
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;
res.json({ analysis: analysisRecord }); res.json({ analysis: analysisRecord });
} catch (error: any) { } catch (error: any) {
@@ -222,13 +239,7 @@ export const analyzeDeficiencies = async (req: AuthenticatedRequest, res: Respon
const { summaryJson, aiModel } = await runDeficiencyAnalysis(analysisInput); const { summaryJson, aiModel } = await runDeficiencyAnalysis(analysisInput);
// Eksiklik analizi sonucunu 'analyses' tablosuna kaydet const analysisRecord = await persistOrBuildAnalysis(String(caseId), null, { deficiencyAnalysis: summaryJson }, aiModel);
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;
res.json({ analysis: analysisRecord }); res.json({ analysis: analysisRecord });
} catch (error: any) { } catch (error: any) {
@@ -275,13 +286,7 @@ export const analyzeStrategy = async (req: AuthenticatedRequest, res: Response)
const { summaryJson, aiModel } = await runStrategyAnalysis(analysisInput, nameToUse, roleToUse); const { summaryJson, aiModel } = await runStrategyAnalysis(analysisInput, nameToUse, roleToUse);
// Strateji analizi sonucunu 'analyses' tablosuna kaydet const analysisRecord = await persistOrBuildAnalysis(String(caseId), null, { strategyAnalysis: summaryJson, clientName: nameToUse, clientRole: roleToUse }, aiModel);
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;
res.json({ analysis: analysisRecord }); res.json({ analysis: analysisRecord });
} catch (error: any) { } catch (error: any) {
@@ -326,12 +331,7 @@ export const analyzeMediation = async (req: AuthenticatedRequest, res: Response)
const { summaryJson, aiModel } = await runMediationAnalysis(analysisInput, nameToUse); const { summaryJson, aiModel } = await runMediationAnalysis(analysisInput, nameToUse);
const { data: analysisRecord, error: analysisError } = await supabase const analysisRecord = await persistOrBuildAnalysis(String(caseId), null, { mediationAnalysis: summaryJson, clientName: nameToUse }, aiModel, { analysis_type: 'mediation' });
.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;
res.json({ analysis: analysisRecord }); res.json({ analysis: analysisRecord });
} catch (error: any) { } catch (error: any) {
@@ -420,10 +420,15 @@ export const analyzeDigitalIntern = async (req: AuthenticatedRequest, res: Respo
const { summaryJson, aiModel } = await runDigitalInternQa(analysisInput, questionToUse); const { summaryJson, aiModel } = await runDigitalInternQa(analysisInput, questionToUse);
const userId = req.user?.id; const userId = req.user?.id;
// Dijital Stajyer sonucunu hem 'analyses' hem de 'chat_messages' tablosuna kaydet // Dijital Stajyer sonucunu 'chat_messages'a her zaman kaydet (kapsam dışı,
await supabase // dokunulmadı); 'analyses'a ise sadece PERSIST_TO_POSTGRES açıkken —
.from('analyses') // bu zaten anlık/tek seferlik bir cevap, Faz 1'de yerel SQLite'a da hiç
.insert([{ case_id: caseId, document_id: null, summary_json: { digitalInternResult: summaryJson, question: questionToUse }, model_used: aiModel }]); // 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) { if (userId) {
await supabase.from('chat_messages').insert([ await supabase.from('chat_messages').insert([
+41 -17
View File
@@ -8,6 +8,8 @@ import iconv from 'iconv-lite';
import { findOrCreateCaseByTitle } from '../lib/caseLookup'; import { findOrCreateCaseByTitle } from '../lib/caseLookup';
import { callOllama, callOllamaVisionOcr } from '../lib/aiClient'; import { callOllama, callOllamaVisionOcr } from '../lib/aiClient';
import { getAiConfig } from '../lib/aiConfig'; import { getAiConfig } from '../lib/aiConfig';
import { shouldPersistLegacyTables } from '../lib/localFirstFlag';
import crypto from 'crypto';
const heicConvert = require('heic-convert'); const heicConvert = require('heic-convert');
// Ceza davası analizlerinde (Dava Özeti, Dava İlerlemesi, Çelişki Avcısı, Strateji) // 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 caseId = await findOrCreateCaseByTitle(userId as string, case_title);
const persistLegacy = shouldPersistLegacyTables();
// "Yeniden İşle" aynı case_title+filename ile bu uca tekrar tekrar istek // "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 // 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(); .maybeSingle();
if (existingErr) throw existingErr; 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; let documentRecord;
if (existingDoc) { if (existingDoc) {
const { data, error: updateErr } = await supabase const { data, error: updateErr } = await supabase
.from('documents') .from('documents')
.update({ .update(docColumns)
file_size: extracted_text.length,
ocr_status: extracted_text ? 'done' : 'failed',
extracted_text,
})
.eq('id', existingDoc.id) .eq('id', existingDoc.id)
.select() .select()
.single(); .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 storage_path: 'local', // ham dosya sadece kullanıcının cihazında; Storage'da bir karşılığı yok
filename, filename,
mime_type: null, mime_type: null,
file_size: extracted_text.length, ...docColumns,
ocr_status: extracted_text ? 'done' : 'failed',
extracted_text,
}]) }])
.select() .select()
.single(); .single();
if (docError) throw docError; if (docError) throw docError;
documentRecord = data; 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; let analysisRecord = null;
if (extracted_text.trim()) { if (extracted_text.trim()) {
try { try {
const { summaryJson, aiModel } = await runDocumentAnalysis(extracted_text); const { summaryJson, aiModel } = await runDocumentAnalysis(extracted_text);
const { data: analysis, error: analysisError } = await supabase if (persistLegacy) {
.from('analyses') const { data: analysis, error: analysisError } = await supabase
.insert([{ case_id: caseId, document_id: documentRecord.id, summary_json: summaryJson, model_used: aiModel }]) .from('analyses')
.select() .insert([{ case_id: caseId, document_id: documentRecord.id, summary_json: summaryJson, model_used: aiModel }])
.single(); .select()
if (analysisError) throw analysisError; .single();
analysisRecord = analysis; if (analysisError) throw analysisError;
analysisRecord = analysis;
// 5. Takvim (Case Events) kayıtlarını oluştur } 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)) { if (summaryJson.events && Array.isArray(summaryJson.events)) {
const eventsToInsert = summaryJson.events const eventsToInsert = summaryJson.events
.filter((e: any) => e.date && e.title) .filter((e: any) => e.date && e.title)
+47 -31
View File
@@ -3,6 +3,8 @@ import { supabase } from '../lib/supabase';
import { AuthenticatedRequest } from '../middleware/auth'; import { AuthenticatedRequest } from '../middleware/auth';
import { callAiModelRaw, CEZA_MUHAKEMESI_REFERANSI, HUKUK_MAHKEMELERI_REFERANSI } from './document.controller'; import { callAiModelRaw, CEZA_MUHAKEMESI_REFERANSI, HUKUK_MAHKEMELERI_REFERANSI } from './document.controller';
import { findRelevantLegislation, LegislationMatch } from '../lib/legislationLookup'; import { findRelevantLegislation, LegislationMatch } from '../lib/legislationLookup';
import { shouldPersistLegacyTables } from '../lib/localFirstFlag';
import crypto from 'crypto';
const DRAFT_SYSTEM_PROMPT = ` const DRAFT_SYSTEM_PROMPT = `
Sen uzman bir Türk avukatısın. Sana verilen dosya bilgilerine göre, istenen türde 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); const { aiContent, aiModel } = await callAiModelRaw(DRAFT_SYSTEM_PROMPT, userContent, 2500, 180_000);
// Taslağı kalıcı olarak kaydediyoruz — hem "Dilekçelerim" listesinde hem ilgili // Faz 3 — taslak artık kalıcı olarak Postgres'e YAZILMIYOR (PERSIST_TO_POSTGRES=false
// dava dosyasının kendi "Dilekçeler" bölümünde görünsün, sayfadan çıkınca kaybolmasın. // olduğunda); "Dilekçelerim" listesi artık yerel SQLite'tan (Faz 1/2) besleniyor.
const { data: draftRecord, error: draftError } = await supabase // Bayrak açıkken (varsayılan) davranış birebir eskisi gibi kalır.
.from('drafts') let draftRecord: { id: string; created_at?: string } | null = null;
.insert([{ if (shouldPersistLegacyTables()) {
user_id: userId, const { data, error: draftError } = await supabase
case_id, .from('drafts')
petition_type, .insert([{
content: aiContent, user_id: userId,
template_id: template_id || null, case_id,
used_legislation: toTraceableLegislation(legislationMatches), petition_type,
}]) content: aiContent,
.select('id, created_at') template_id: template_id || null,
.single(); used_legislation: toTraceableLegislation(legislationMatches),
if (draftError) { }])
console.error('Save Draft Error:', draftError); .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) }); 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 // Fast generation using a lightweight model or quick params
const { aiContent, aiModel } = await callAiModelRaw(ANGARYA_SYSTEM_PROMPT, userContent, 500); const { aiContent, aiModel } = await callAiModelRaw(ANGARYA_SYSTEM_PROMPT, userContent, 500);
const { data: draftRecord, error: draftError } = await supabase // Faz 3 — angarya taslakları da PERSIST_TO_POSTGRES=false olduğunda
.from('drafts') // kalıcı yazılmıyor. NOT: Angarya için Faz 1'de bir yerel SQLite dual-write
.insert([{ // eklenmedi (kapsam dışı tutulan düşük öncelikli özellik) — bayrak
user_id: userId, // kapatıldığında bu taslaklar sadece HTTP yanıtında var olacak, hiçbir
case_id, // yerde kalıcı olmayacak. Bilinen ve kabul edilmiş bir sınırlama.
petition_type: petitionType, let draftRecord: { id: string; created_at?: string } | null = null;
content: aiContent, if (shouldPersistLegacyTables()) {
template_id: null, const { data, error: draftError } = await supabase
}]) .from('drafts')
.select('id, created_at') .insert([{
.single(); user_id: userId,
case_id,
if (draftError) { petition_type: petitionType,
console.error('Save Draft Error:', draftError); 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 }); res.json({ draft: aiContent, draftId: draftRecord?.id || null, model: aiModel, usedTemplate: false });
} catch (error: any) { } catch (error: any) {
console.error('Generate Angarya Error:', error); console.error('Generate Angarya Error:', error);
+16
View File
@@ -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';
}