From 742e05933e9eee2c2f5bf6b704c1cfe3949d9bd7 Mon Sep 17 00:00:00 2001
From: ht
Date: Sun, 6 Sep 2026 09:49:30 +0300
Subject: [PATCH] Analyze server-resolved document reviews with revision checks
---
docs/quality/uygulama-durumu.md | 8 ++++++++
server/document-review.mjs | 18 ++++++++++++++++++
server/index.js | 19 +++++++++++++------
src/components/BidPackageBuilder.jsx | 3 +++
src/components/DocumentTextReview.jsx | 25 +++++++++++++++++++------
src/components/TenderDocuments.jsx | 5 ++++-
src/context/AppContext.jsx | 4 ++--
src/services/ai.js | 4 ++--
src/services/bidSource.js | 1 +
tests/bid-source.test.mjs | 7 +++++++
tests/document-review.test.mjs | 11 ++++++++++-
11 files changed, 87 insertions(+), 18 deletions(-)
diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md
index d472e68..28d14b4 100644
--- a/docs/quality/uygulama-durumu.md
+++ b/docs/quality/uygulama-durumu.md
@@ -365,3 +365,11 @@ Bu yalnız sunucu altyapısıdır. Metin düzenleme arayüzü, bu kaydın analiz
İhale dokümanı “Metni incele” paneline DocumentTextReview eklendi. Kaynakla karşılaştırılmış düzeltme metni, sayfa/düzeltme açıklaması ve tüm sayfaları karşılaştırma beyanı alınır. Metin veya not değişince beyan sıfırlanır. Başarısız kayıt taslağı korur; sürüm uyuşmazlığında kullanıcıya taslağını kopyalayıp güncel kaydı yükleme seçeneği sunulur. Son kayıt sürümü/zamanı ve kaynak değişikliği açık gösterilir. Kayıt başarısı sunucu yanıtıyla gösterilir; bileşen kapanması/oturum değişiminden sonraki yanıtlar uygulanmaz.
79 test ve üretim derlemesi geçti. Formun gerçek tarayıcı kabulü ve kaydedilen incelemenin analizde sunucu tarafından çözülmesi henüz tamamlanmadı. Form, kayıt işleminin tek başına hazır teklif üretmediğini açıkça belirtiyor; mevcut OCR engeli devam ediyor.
+
+### Kaydedilmiş inceleme üzerinden analiz
+
+İnceleme formunda kaydedilmiş metin/not değişmemişse “Kaydedilmiş incelemeyi analiz et” sunuluyor. İstemci belge ID ve inceleme sürümünü gönderir; sunucu kaynak dosya özeti, sahiplik, İKN ve son inceleme sürümünü kontrol ederek metni DB'den alır. İstemcinin gönderdiği alternatif metin veya OCR onay bayrağı bu yolda kullanılmaz. AI işi bittikten sonra sürüm tekrar doğrulanır; arada değişiklik varsa sonuç reddedilir. Sonuçta sunucu kaynaklı inceleyen hesap, zaman, sürüm, dosya/metin özeti ve not tutulur. Bu yol OCR uyarısını kullanıcı incelemesiyle açıklayabilir; diğer analiz kusurları ve hazırlık kontrolleri aynen uygulanır.
+
+Hazırlık formu inceleme kaynağını gösterir. İnceleme sürümü kaynak snapshot'ına katıldı; aynı kalemlerle yeni inceleme sonucu geldiğinde eski kaynak kontrolü geçersizleşir.
+
+79 mevcut test ve ek snapshot testi geçti (80 ayrı test); üretim derlemesi geçti. Kaynak çözümleme testleri başka hesap/İKN, eski revizyon ve değişmiş dosyayı reddediyor. Gerçek AI sağlayıcısıyla tarayıcı kabulü ve çıktı anında sunucudan incelemenin hâlâ güncel olduğunun doğrulanması henüz açık. İnceleme kullanıcı beyanıdır, resmi doğruluk garantisi değildir.
diff --git a/server/document-review.mjs b/server/document-review.mjs
index 052470a..a46941c 100644
--- a/server/document-review.mjs
+++ b/server/document-review.mjs
@@ -41,3 +41,21 @@ export function saveDocumentReview(email,id,input) {
} catch(error) {try {db.exec('ROLLBACK');} catch {} throw error;}
finally {db.close();}
}
+
+/** Resolve a requested review to server-owned text; caller text/approval is never used. */
+export function reviewedAnalysisSource(email,reference,tenderKey) {
+ if(!reference || typeof reference.documentId !== 'string' || !Number.isInteger(reference.revision))
+ throw fail(400,'Geçerli belge ve inceleme sürümü gerekli.');
+ const db=openDb();
+ try {
+ const fileHash=source(db,email,reference.documentId);
+ const doc=db.prepare('SELECT ad,ikn FROM belgeler WHERE id=? AND email=?').get(reference.documentId,email);
+ if(!doc.ikn || doc.ikn!==tenderKey) throw fail(409,'İnceleme bu ihaleye bağlı değil.');
+ const review=latest(db,email,reference.documentId);
+ if(!review || review.revision!==reference.revision || review.file_hash!==fileHash)
+ throw fail(409,'İnceleme veya kaynak dosya değişti; güncel incelemeyi açın.');
+ return {text:review.corrected_text,fileName:doc.ad,
+ humanReview:{documentId:reference.documentId,revision:review.revision,fileHash,textHash:review.text_hash,
+ reviewedBy:email,reviewedAt:review.reviewed_at,note:review.review_note}};
+ } finally {db.close();}
+}
diff --git a/server/index.js b/server/index.js
index b31ac06..c671882 100644
--- a/server/index.js
+++ b/server/index.js
@@ -1,4 +1,4 @@
-import {documentReview,saveDocumentReview} from './document-review.mjs';
+import {documentReview,saveDocumentReview,reviewedAnalysisSource} from './document-review.mjs';
import { documentMetadataPatch, decodeDocumentUpload } from './document-metadata.mjs';
import { deadlineInfo } from '../src/services/tenderCalendar.js';
import { exchangeRates } from './exchange-rates.mjs';
@@ -1300,20 +1300,27 @@ const server = http.createServer(async (req, res) => {
}
try {
const body = await readBody(req, 2 * 1024 * 1024);
- if (!body.text || String(body.text).trim().length < 40) {
+ const reviewed=body.reviewedSource ? reviewedAnalysisSource(user.email,body.reviewedSource,body.tender?.ikn) : null;
+ const sourceText=reviewed ? reviewed.text : body.text;
+ if (!sourceText || String(sourceText).trim().length < 40) {
return json(res, 400, { success: false, error: 'Analiz için yeterli metin yok (en az 40 karakter).' });
}
const out = await analyzeSpec({
- text: body.text,
- fileName: body.fileName,
- extraction: body.extraction,
+ text: sourceText,
+ fileName: reviewed?.fileName || body.fileName,
+ extraction: reviewed ? {fileName:reviewed.fileName,complete:true,ocr:false} : body.extraction,
tender: body.tender,
chain: body.chain,
mode: body.mode === 'derin' ? 'derin' : undefined,
});
+ if (reviewed) {
+ const latest=reviewedAnalysisSource(user.email,body.reviewedSource,body.tender?.ikn);
+ if(latest.humanReview.textHash!==reviewed.humanReview.textHash) throw Object.assign(new Error('İnceleme analiz sırasında değişti.'),{status:409});
+ out.result.sourceReview={...out.result.sourceReview,humanReview:reviewed.humanReview};
+ }
return json(res, 200, { success: true, ...out, analyzedAt: new Date().toISOString() });
} catch (e) {
- return json(res, 502, { success: false, error: e.message, attempts: e.attempts || null });
+ return json(res, e.status || 502, { success: false, error: e.message, attempts: e.attempts || null });
}
}
diff --git a/src/components/BidPackageBuilder.jsx b/src/components/BidPackageBuilder.jsx
index 7f3782d..9823fc5 100644
--- a/src/components/BidPackageBuilder.jsx
+++ b/src/components/BidPackageBuilder.jsx
@@ -245,6 +245,9 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
{kaynakGecerlilik.status === 'conflict' && Kaynaklarda farklı süreler var. Güncel resmi dokümanı belirleyip analizi yenileyin; çelişki çözülmeden çıktı alınamaz.
}
{[...new Set(kaynakGecerlilik.evidence.map(e=>e.quote))].map(quote=>{quote} )}
}
+ {effectiveResults.sourceReview?.humanReview &&
+ Metin incelemesi: sürüm {effectiveResults.sourceReview.humanReview.revision} · {effectiveResults.sourceReview.humanReview.reviewedBy} · {effectiveResults.sourceReview.humanReview.reviewedAt}. Kullanıcı karşılaştırmasıdır; resmi onay değildir.
+
}
{analizEksik && Analiz kısmi: kaynak, OCR veya kalem bilgisi kontrol gerektiriyor. {(effectiveResults.sourceReview.issues || []).join(' ')} Tam ve doğrulanabilir kaynakla analizi tamamlayın.
}
{/* Sizden beklenen bilgiler */}
{eksikler.length > 0 && (
diff --git a/src/components/DocumentTextReview.jsx b/src/components/DocumentTextReview.jsx
index 6927ed8..1f41cdf 100644
--- a/src/components/DocumentTextReview.jsx
+++ b/src/components/DocumentTextReview.jsx
@@ -2,7 +2,7 @@ import React,{useEffect,useRef,useState} from 'react';
import {getDocumentTextReview,saveDocumentTextReview} from '../services/account';
import {getToken} from '../services/auth';
-export function DocumentTextReview({documentId,extractedText}) {
+export function DocumentTextReview({documentId,extractedText,onAnalyze}) {
const [source,setSource]=useState(null);
const [text,setText]=useState(extractedText || '');
const [note,setNote]=useState('');
@@ -10,6 +10,7 @@ export function DocumentTextReview({documentId,extractedText}) {
const [busy,setBusy]=useState(false);
const [error,setError]=useState(null);
const [saved,setSaved]=useState(null);
+ const [analyzing,setAnalyzing]=useState(false);
const live=useRef(true);
const sequence=useRef(0);
const load=async()=>{
@@ -38,27 +39,39 @@ export function DocumentTextReview({documentId,extractedText}) {
} catch(e) {if(live.current && token===getToken())setError(e.message);}
finally {if(live.current && token===getToken())setBusy(false);}
};
+ const analyze=async()=>{
+ if(!source?.review || source.stale || busy || analyzing)return;
+ const token=getToken();setAnalyzing(true);setError(null);
+ try {await onAnalyze({documentId,revision:source.review.revision});}
+ catch(e) {if(live.current && token===getToken())setError(e.message);}
+ finally {if(live.current && token===getToken())setAnalyzing(false);}
+ };
return
Metni karşılaştır ve düzelt
Belgenin tüm sayfalarını açıp metin, miktar ve birimleri karşılaştırın. Bu kayıt kullanıcı incelemesidir; resmî onay veya e-imza değildir. Kaydetmek tek başına teklif dosyasını hazır yapmaz.
{source?.stale && Kaynak dosya değişmiş. Önceki inceleme geçerli değil; tüm sayfaları yeniden karşılaştırın.
}
{source?.review && Son kayıt: sürüm {source.review.revision} · {new Date(source.review.reviewed_at).toLocaleString('tr-TR')}
}
Kontrol edilen metin
-
Karşılaştırılan sayfalar ve düzeltmeler
- {setNote(e.target.value);setCompared(false);setSaved(null);}} disabled={busy}
+ {setNote(e.target.value);setCompared(false);setSaved(null);}} disabled={busy || analyzing}
placeholder="Örnek: Sayfa 1–3 karşılaştırıldı; 2. sayfadaki miktar 100 olarak düzeltildi."
className="block w-full border rounded p-2 font-normal mt-1" />
- setCompared(e.target.checked)} disabled={busy}/>
+ setCompared(e.target.checked)} disabled={busy || analyzing}/>
Bu metni kaynak dosyanın tüm sayfalarıyla karşılaştırdım.
- {busy ? 'İşleniyor…' : 'İncelemeyi kaydet'}
+ {onAnalyze && source?.review && !source.stale &&
+ {analyzing ? 'İncelenmiş metin analiz ediliyor…' : 'Kaydedilmiş incelemeyi analiz et'}
+ }
{error && {error}
Metin taslağınız bu ekranda korunuyor. Sürüm değiştiyse taslağı kopyalayıp güncel kaydı yükleyin.
-
Güncel kaydı yükle (taslağı değiştirir)
+
Güncel kaydı yükle (taslağı değiştirir)
}
{saved && İnceleme sürüm {saved.revision} sunucuda kaydedildi. Analiz ve teklif kontrolleri ayrıca tamamlanmalıdır.
}
;
diff --git a/src/components/TenderDocuments.jsx b/src/components/TenderDocuments.jsx
index e9ce513..96d8fd6 100644
--- a/src/components/TenderDocuments.jsx
+++ b/src/components/TenderDocuments.jsx
@@ -420,7 +420,10 @@ export const TenderDocuments = ({ tender }) => {
Metni resmi dokümanla karşılaştırın; okunmuş olması doğruluğunun onaylandığı anlamına gelmez.
{metinOnizleme.text || 'Okunabilir metin bulunamadı.'}
- {metinOnizleme.type === 'ihale' && }
+ {metinOnizleme.type === 'ihale' && {
+ await analyzeSpecPdfWithGemini({reviewedSource,tender,fileName:metinOnizleme.name});
+ setIsAiSpecAnalyzerOpen(true);
+ }} />}
setMetinOnizleme(null)} className="text-xs underline">Metin önizlemesini kapat
}
diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx
index 3b255f3..14622b9 100644
--- a/src/context/AppContext.jsx
+++ b/src/context/AppContext.jsx
@@ -428,13 +428,13 @@ export const AppProvider = ({ children }) => {
// Gerçek AI analizi — backend /api/ai/analyze üzerinden, admin panelde
// seçilen model zinciri (fallback) ile.
// mode='derin' → ince detaylar, belge kontrol listesi ve yol haritası da üretilir
- const analyzeSpecPdfWithGemini = async ({ text, fileName, tender, mode, extraction } = {}) => {
+ const analyzeSpecPdfWithGemini = async ({ text, fileName, tender, mode, extraction, reviewedSource } = {}) => {
const session = getToken();
setAiAnalyzing(true);
setAiAnalysisResults(null);
try {
const { analyzeSpecText } = await import('../services/ai');
- const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain, mode, extraction });
+ const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain, mode, extraction, reviewedSource });
const result = {
...data.result,
tenderKey: tender?.ikn || tender?.id || null,
diff --git a/src/services/ai.js b/src/services/ai.js
index 9f6f63a..03743f3 100644
--- a/src/services/ai.js
+++ b/src/services/ai.js
@@ -13,11 +13,11 @@ export async function getAiConfig() {
}
}
-export async function analyzeSpecText({ text, fileName, tender, chain, mode, extraction }) {
+export async function analyzeSpecText({ text, fileName, tender, chain, mode, extraction, reviewedSource }) {
const res = await fetch('/api/ai/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
- body: JSON.stringify({ text, fileName, tender, chain, mode, extraction }),
+ body: JSON.stringify({ text, fileName, tender, chain, mode, extraction, reviewedSource }),
});
const data = await res.json().catch(() => ({}));
if (!data.success) {
diff --git a/src/services/bidSource.js b/src/services/bidSource.js
index ea81a54..1461ead 100644
--- a/src/services/bidSource.js
+++ b/src/services/bidSource.js
@@ -33,6 +33,7 @@ export function bidSourceSnapshot(tender, results, rows = sourceRows(tender, res
deadline: [tender?.tarih || '', tender?.saat || ''],
documentVersion: results?.documentVersion ?? tender?.documentVersion ?? null,
procurementTerms: results?.procurementTerms || null,
+ humanReview: results?.sourceReview?.humanReview || null,
analyzedItems: results?.itemsAndSpecs || [],
rows: rows.map(r => r.priceKey).sort(),
documents: results?.documentChecklist || [],
diff --git a/tests/bid-source.test.mjs b/tests/bid-source.test.mjs
index c24867e..680e4bf 100644
--- a/tests/bid-source.test.mjs
+++ b/tests/bid-source.test.mjs
@@ -37,3 +37,10 @@ test('changed quantity, requirements and announcement invalidate reviewed source
test('legacy positional prices are not silently assigned to potentially changed source rows',()=>{
assert.deepEqual(restoreBidPrices({fiyatlar:{0:'15',1:'900'}}),{});
});
+
+test('review revision changes invalidate the preparation source even with identical extracted rows',()=>{
+ const tender={ikn:'2026/1',items:[]};
+ const first={sourceReview:{humanReview:{documentId:'doc',revision:1,textHash:'same'}}};
+ const second={sourceReview:{humanReview:{documentId:'doc',revision:2,textHash:'same'}}};
+ assert.notEqual(bidSourceSnapshot(tender,first),bidSourceSnapshot(tender,second));
+});
diff --git a/tests/document-review.test.mjs b/tests/document-review.test.mjs
index 81f4514..3ed398a 100644
--- a/tests/document-review.test.mjs
+++ b/tests/document-review.test.mjs
@@ -7,15 +7,24 @@ test('document review becomes stale when actual source bytes change; deletion re
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');
+ const {documentReview,saveDocumentReview,reviewedAnalysisSource}=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 resolved=reviewedAnalysisSource('owner',{documentId:id,revision:1},'2026/1');
+ assert.equal(resolved.text,input.correctedText);
+ assert.equal(resolved.humanReview.reviewedBy,'owner');
+ assert.throws(()=>reviewedAnalysisSource('other',{documentId:id,revision:1},'2026/1'),e=>e.status===404);
+ assert.throws(()=>reviewedAnalysisSource('owner',{documentId:id,revision:1},'2026/2'),e=>e.status===409);
+ saveDocumentReview('owner',id,{...input,expectedRevision:1,correctedText:input.correctedText+' Revised.'});
+ assert.throws(()=>reviewedAnalysisSource('owner',{documentId:id,revision:1},'2026/1'),e=>e.status===409);
+
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(()=>reviewedAnalysisSource('owner',{documentId:id,revision:2},'2026/1'),e=>e.status===409);
assert.throws(()=>saveDocumentReview('owner',id,{...input,expectedRevision:1}),e=>e.status===409);
store.deleteDocument('owner',id);
const db=store.openDb();