diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 2c4c314..4dd77cc 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -351,3 +351,11 @@ Doğrudan dosya analizi, kayıtlı belge analizi ve toplu derin analiz; OCR, top 78 test geçti; üretim derlemesi geçti (sonraki form uyarı metni değişikliği ayrıca diff kontrolünden geçti). Testler modelin complete iddiasının OCR/eksik sayfa uyarısını silemediğini ve atlanan dosyanın birleşik kaynak incelemesinde korunduğunu doğruluyor. Sınırlar: OCR metnini resmi kaynakla karşılaştırıp düzelterek sürümlü insan onayı verme akışı henüz yok; kontrolsüz bir onay kutusuyla engel kaldırılmadı. Eski kayıtların veya metadata göndermeyen eski istemcilerin OCR geçmişi geriye dönük çıkarılamaz. Gerçek AI sağlayıcısıyla uçtan uca kabul ve beş resmi dosya kabulü açık. + +### Sürümlü düzeltilmiş belge metni — sunucu altyapısı + +Belge sahibinin OCR/metin incelemesini kaydetmesi için document_text_reviews tablosu ve migration eklendi. Kayıtlar kaynak dosyanın SHA256 özeti, düzeltilmiş metnin özeti, düzeltme metni, karşılaştırma notu, hesap kimliği, sunucu zamanı ve artan sürüm içeriyor. GET/POST text-review uç noktaları yalnız sahibinin ihale dokümanında çalışıyor. Beklenen dosya özeti ve önceki sürüm uyuşmazsa 409; tüm sayfaların karşılaştırıldığı beyanı, yeterli metin ve açıklama olmadan kayıt reddediliyor. Belge silinince özel inceleme geçmişi aynı DB işleminde siliniyor. + +Doğrulama: mevcut 78 test ve üretim derlemesi geçti; ek kaynak-dosyası-değişimi testi de geçti (toplam 79 ayrı test). HTTP testinde sahiplik, zorunlu karşılaştırma, kalıcı okuma ve eski sürüm reddi; ek testte gerçek dosya baytları değişince incelemenin stale olması ve belge silinince geçmişin kaldırılması doğrulandı. + +Bu yalnız sunucu altyapısıdır. Metin düzenleme arayüzü, bu kaydın analizde sunucu tarafından çözülmesi ve kontrollü hazırlık ilerlemesi henüz bağlanmadı; mevcut OCR engeli sürüyor. Kullanıcı beyanı hukuki veya otomatik doğruluk garantisi değildir. Gerçek resmi dosya kabulü açık. diff --git a/server/document-review.mjs b/server/document-review.mjs new file mode 100644 index 0000000..052470a --- /dev/null +++ b/server/document-review.mjs @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import {createHash} from 'node:crypto'; +import {openDb,BELGE_DIR} from './user-store.mjs'; +const hash=value=>createHash('sha256').update(value).digest('hex'); +const fail=(status,message)=>Object.assign(new Error(message),{status}); +function source(db,email,id) { + const record=db.prepare('SELECT * FROM belgeler WHERE id=? AND email=?').get(id,email); + if(!record || record.tur !== 'ihale') throw fail(404,'İhale dokümanı bulunamadı.'); + let bytes; + try {bytes=fs.readFileSync(path.join(BELGE_DIR,record.dosya));} + catch {throw fail(409,'Kaynak dosya okunamıyor; inceleme kaydedilemez.');} + return hash(bytes); +} +function latest(db,email,id) { + return db.prepare('SELECT revision,file_hash,text_hash,corrected_text,review_note,reviewed_at FROM document_text_reviews WHERE document_id=? AND email=? ORDER BY revision DESC LIMIT 1').get(id,email) || null; +} +export function documentReview(email,id) { + const db=openDb(); + try { + const fileHash=source(db,email,id), review=latest(db,email,id); + return {documentId:id,fileHash,review,stale:Boolean(review && review.file_hash!==fileHash)}; + } finally {db.close();} +} +export function saveDocumentReview(email,id,input) { + if(typeof input.correctedText !== 'string' || input.correctedText.trim().length<40 || Buffer.byteLength(input.correctedText)>1024*1024) + throw fail(400,'Kontrol edilen metin 40 karakter ile 1 MB arasında olmalı.'); + if(typeof input.note !== 'string' || input.note.trim().length<20 || input.note.length>2000) + throw fail(400,'Karşılaştırılan sayfaları ve yapılan düzeltmeleri en az 20 karakterle açıklayın.'); + if(input.allPagesCompared !== true) throw fail(400,'Tüm kaynak sayfalarıyla karşılaştırma gerekli.'); + if(!Number.isInteger(input.expectedRevision) || input.expectedRevision<0) throw fail(400,'Geçerli inceleme sürümü gerekli.'); + const db=openDb(); + try { + db.exec('BEGIN IMMEDIATE'); + const fileHash=source(db,email,id), previous=latest(db,email,id); + if(input.fileHash!==fileHash || input.expectedRevision!==(previous?.revision || 0)) throw fail(409,'Dosya veya inceleme sürümü değişti. Güncel kaydı yeniden açın.'); + const text=input.correctedText.trim(); + db.prepare('INSERT INTO document_text_reviews VALUES (?,?,?,?,?,?,?,?)').run(id,email,input.expectedRevision+1,fileHash,hash(text),text,input.note.trim(),new Date().toISOString()); + const review=latest(db,email,id); + db.exec('COMMIT');return {documentId:id,fileHash,review,stale:false}; + } catch(error) {try {db.exec('ROLLBACK');} catch {} throw error;} + finally {db.close();} +} diff --git a/server/index.js b/server/index.js index d90c90a..b31ac06 100644 --- a/server/index.js +++ b/server/index.js @@ -1,3 +1,4 @@ +import {documentReview,saveDocumentReview} from './document-review.mjs'; import { documentMetadataPatch, decodeDocumentUpload } from './document-metadata.mjs'; import { deadlineInfo } from '../src/services/tenderCalendar.js'; import { exchangeRates } from './exchange-rates.mjs'; @@ -1064,6 +1065,17 @@ const server = http.createServer(async (req, res) => { return json(res, 200, { success: true, belgeler: store.listDocuments(user.email) }); } catch (e) { return json(res, 400, { success: false, error: e.message }); } } + if (url.pathname === '/api/me/documents/text-review') { + const user=userFromAuthHeader(req.headers['authorization']); + if(!user) return json(res,401,{success:false,error:'oturum yok'}); + try { + if(req.method === 'GET') return json(res,200,{success:true,...documentReview(user.email,url.searchParams.get('id'))}); + if(req.method !== 'POST') return json(res,405,{success:false,error:'desteklenmeyen metot'}); + const body=await readBody(req,1100*1024); + if(typeof body.id !== 'string') return json(res,400,{success:false,error:'belge id gerekli'}); + return json(res,200,{success:true,...saveDocumentReview(user.email,body.id,body)}); + } catch(error) {return json(res,error.status || 500,{success:false,error:error.message});} + } if (url.pathname === '/api/me/documents/read-error' && req.method === 'POST') { const user = userFromAuthHeader(req.headers['authorization']); if (!user) return json(res,401,{success:false,error:'oturum yok'}); diff --git a/server/user-store.mjs b/server/user-store.mjs index 935aa36..c555bcd 100644 --- a/server/user-store.mjs +++ b/server/user-store.mjs @@ -65,6 +65,13 @@ CREATE TABLE IF NOT EXISTS api_anahtarlari ( CREATE INDEX IF NOT EXISTS idx_apikey_hash ON api_anahtarlari(hash); CREATE INDEX IF NOT EXISTS idx_apikey_email ON api_anahtarlari(email); +CREATE TABLE IF NOT EXISTS document_text_reviews ( + document_id TEXT NOT NULL, email TEXT NOT NULL, revision INTEGER NOT NULL, + file_hash TEXT NOT NULL, text_hash TEXT NOT NULL, corrected_text TEXT NOT NULL, + review_note TEXT NOT NULL, reviewed_at TEXT NOT NULL, + PRIMARY KEY (document_id,email,revision) +); + CREATE TABLE IF NOT EXISTS belgeler ( id TEXT PRIMARY KEY, email TEXT NOT NULL, @@ -107,6 +114,8 @@ export function openDb() { .run('20260906_document_type', new Date().toISOString()); db.prepare('INSERT OR IGNORE INTO schema_migrations VALUES (?,?)') .run('20260906_document_processing', new Date().toISOString()); + db.prepare('INSERT OR IGNORE INTO schema_migrations VALUES (?,?)') + .run('20260906_document_text_reviews', new Date().toISOString()); db.exec('COMMIT'); } catch (error) { try { db.exec('ROLLBACK'); } catch { /* original error is authoritative */ } @@ -315,7 +324,14 @@ export function deleteDocument(email, id) { if (!rec) return false; try { fs.unlinkSync(path.join(BELGE_DIR, rec.dosya)); } catch (error) { if (error.code !== 'ENOENT') throw error; } - return withDb((db) => db.prepare('DELETE FROM belgeler WHERE id=? AND email=?').run(id, email).changes > 0, false); + return withDb((db) => { + db.exec('BEGIN IMMEDIATE'); + try { + db.prepare('DELETE FROM document_text_reviews WHERE document_id=? AND email=?').run(id,email); + const changed=db.prepare('DELETE FROM belgeler WHERE id=? AND email=?').run(id,email).changes>0; + db.exec('COMMIT');return changed; + } catch(error) {db.exec('ROLLBACK');throw error;} + }); } export function documentUsage(email) { diff --git a/tests/document-review.test.mjs b/tests/document-review.test.mjs new file mode 100644 index 0000000..81f4514 --- /dev/null +++ b/tests/document-review.test.mjs @@ -0,0 +1,24 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +test('document review becomes stale when actual source bytes change; deletion removes private history',async()=>{ + const dir=fs.mkdtempSync(path.join(tmpdir(),'ihale-review-')); + process.env.IHALEIST_USER_DATA_DIR=dir; + const store=await import('../server/user-store.mjs'); + const {documentReview,saveDocumentReview}=await import('../server/document-review.mjs'); + try { + const id=store.saveDocumentFile('owner',{ad:'qa.txt',tur:'ihale',ikn:'2026/1'},Buffer.from('Original source')); + const source=documentReview('owner',id); + const input={fileHash:source.fileHash,expectedRevision:0,correctedText:'A complete synthetic corrected document text for the test.',note:'All pages compared in this isolated synthetic test.',allPagesCompared:true}; + saveDocumentReview('owner',id,input); + const record=store.getDocument('owner',id); + fs.writeFileSync(path.join(store.BELGE_DIR,record.dosya),'Changed source bytes'); + assert.equal(documentReview('owner',id).stale,true); + assert.throws(()=>saveDocumentReview('owner',id,{...input,expectedRevision:1}),e=>e.status===409); + store.deleteDocument('owner',id); + const db=store.openDb(); + try {assert.equal(db.prepare('SELECT COUNT(*) n FROM document_text_reviews').get().n,0);}finally{db.close();} + } finally {fs.rmSync(dir,{recursive:true,force:true});} +}); diff --git a/tests/http-state.test.mjs b/tests/http-state.test.mjs index dfd9026..0b014f9 100644 --- a/tests/http-state.test.mjs +++ b/tests/http-state.test.mjs @@ -106,6 +106,21 @@ test('shared item extraction requires an owned tender document and matching tend const retained=await fetch(base+'/api/me/documents/download?id='+tender.data.id,{headers:{Authorization:'Bearer '+token}}); assert.equal(retained.status,200); assert.equal(await retained.text(),Buffer.from(body.icerikBase64,'base64').toString()); + const reviewUrl='/api/me/documents/text-review?id='+tender.data.id; + assert.equal((await request(reviewUrl,'GET',null,other)).status,404); + const initialReview=await request(reviewUrl); + assert.equal(initialReview.data.review,null); + const correction={id:tender.data.id,fileHash:initialReview.data.fileHash,expectedRevision:0, + correctedText:'Sentetik belge metni tüm sayfalarla karşılaştırıldı. Bu gerçek ihale incelemesi değildir.', + note:'Sayfa 1 üzerinde sentetik test karşılaştırması yapıldı.',allPagesCompared:true}; + assert.equal((await request('/api/me/documents/text-review','POST',{...correction,allPagesCompared:false})).status,400); + assert.equal((await request('/api/me/documents/text-review','POST',correction,other)).status,404); + const savedReview=await request('/api/me/documents/text-review','POST',correction); + assert.equal(savedReview.status,200);assert.equal(savedReview.data.review.revision,1); + assert.equal((await request(reviewUrl)).data.review.corrected_text,correction.correctedText); + assert.equal((await request('/api/me/documents/text-review','POST',correction)).status,409); + assert.equal((await request('/api/me/documents/text-review','POST',{...correction,expectedRevision:1,fileHash:'old-hash'})).status,409); + assert.equal((await request('/api/kalem/cikar','POST',{ikn:body.ikn,metin:'test'})).status,403); await request('/api/me/documents?id='+company.data.id,'DELETE'); await request('/api/me/documents?id='+tender.data.id,'DELETE'); diff --git a/tests/user-store.test.mjs b/tests/user-store.test.mjs index 1177d12..5722351 100644 --- a/tests/user-store.test.mjs +++ b/tests/user-store.test.mjs @@ -15,7 +15,7 @@ after(() => rmSync(dir, { recursive: true, force: true })); test('old volume migrates before indexing, preserves files and migration is repeatable', () => { for (let i = 0; i < 2; i++) { const db = store.openDb(); - assert.equal(db.prepare('SELECT COUNT(*) n FROM schema_migrations').get().n, 3); + assert.equal(db.prepare('SELECT COUNT(*) n FROM schema_migrations').get().n, 4); assert.ok(db.prepare('PRAGMA table_info(belgeler)').all().some(c => c.name === 'ikn')); db.close(); }