Persist versioned document text reviews against source file hashes

This commit is contained in:
2026-09-06 09:47:12 +03:00
parent f073438922
commit 559a2feeda
7 changed files with 120 additions and 2 deletions
+8
View File
@@ -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.
+43
View File
@@ -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();}
}
+12
View File
@@ -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'});
+17 -1
View File
@@ -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) {
+24
View File
@@ -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});}
});
+15
View File
@@ -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');
+1 -1
View File
@@ -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();
}