Persist versioned document text reviews against source file hashes
This commit is contained in:
@@ -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.
|
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ı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.
|
||||||
|
|||||||
@@ -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();}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {documentReview,saveDocumentReview} from './document-review.mjs';
|
||||||
import { documentMetadataPatch, decodeDocumentUpload } from './document-metadata.mjs';
|
import { documentMetadataPatch, decodeDocumentUpload } from './document-metadata.mjs';
|
||||||
import { deadlineInfo } from '../src/services/tenderCalendar.js';
|
import { deadlineInfo } from '../src/services/tenderCalendar.js';
|
||||||
import { exchangeRates } from './exchange-rates.mjs';
|
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) });
|
return json(res, 200, { success: true, belgeler: store.listDocuments(user.email) });
|
||||||
} catch (e) { return json(res, 400, { success: false, error: e.message }); }
|
} 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') {
|
if (url.pathname === '/api/me/documents/read-error' && req.method === 'POST') {
|
||||||
const user = userFromAuthHeader(req.headers['authorization']);
|
const user = userFromAuthHeader(req.headers['authorization']);
|
||||||
if (!user) return json(res,401,{success:false,error:'oturum yok'});
|
if (!user) return json(res,401,{success:false,error:'oturum yok'});
|
||||||
|
|||||||
+17
-1
@@ -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_hash ON api_anahtarlari(hash);
|
||||||
CREATE INDEX IF NOT EXISTS idx_apikey_email ON api_anahtarlari(email);
|
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 (
|
CREATE TABLE IF NOT EXISTS belgeler (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
email TEXT NOT NULL,
|
email TEXT NOT NULL,
|
||||||
@@ -107,6 +114,8 @@ export function openDb() {
|
|||||||
.run('20260906_document_type', new Date().toISOString());
|
.run('20260906_document_type', new Date().toISOString());
|
||||||
db.prepare('INSERT OR IGNORE INTO schema_migrations VALUES (?,?)')
|
db.prepare('INSERT OR IGNORE INTO schema_migrations VALUES (?,?)')
|
||||||
.run('20260906_document_processing', new Date().toISOString());
|
.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');
|
db.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try { db.exec('ROLLBACK'); } catch { /* original error is authoritative */ }
|
try { db.exec('ROLLBACK'); } catch { /* original error is authoritative */ }
|
||||||
@@ -315,7 +324,14 @@ export function deleteDocument(email, id) {
|
|||||||
if (!rec) return false;
|
if (!rec) return false;
|
||||||
try { fs.unlinkSync(path.join(BELGE_DIR, rec.dosya)); }
|
try { fs.unlinkSync(path.join(BELGE_DIR, rec.dosya)); }
|
||||||
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
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) {
|
export function documentUsage(email) {
|
||||||
|
|||||||
@@ -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});}
|
||||||
|
});
|
||||||
@@ -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}});
|
const retained=await fetch(base+'/api/me/documents/download?id='+tender.data.id,{headers:{Authorization:'Bearer '+token}});
|
||||||
assert.equal(retained.status,200);
|
assert.equal(retained.status,200);
|
||||||
assert.equal(await retained.text(),Buffer.from(body.icerikBase64,'base64').toString());
|
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);
|
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='+company.data.id,'DELETE');
|
||||||
await request('/api/me/documents?id='+tender.data.id,'DELETE');
|
await request('/api/me/documents?id='+tender.data.id,'DELETE');
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ after(() => rmSync(dir, { recursive: true, force: true }));
|
|||||||
test('old volume migrates before indexing, preserves files and migration is repeatable', () => {
|
test('old volume migrates before indexing, preserves files and migration is repeatable', () => {
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
const db = store.openDb();
|
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'));
|
assert.ok(db.prepare('PRAGMA table_info(belgeler)').all().some(c => c.name === 'ikn'));
|
||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user