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 { 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<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) => {
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([
+41 -17
View File
@@ -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)
+47 -31
View File
@@ -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);
+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';
}