From f07343892288b8beb97f521311fc683c2ce11d37 Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 09:45:33 +0300 Subject: [PATCH] Carry OCR and missing-source warnings into analysis readiness --- docs/quality/uygulama-durumu.md | 8 ++++++++ server/ai.mjs | 6 +++--- server/analysis-parts.mjs | 2 +- server/analysis-quality.mjs | 8 +++++--- server/index.js | 1 + src/components/AiSpecAnalyzerModal.jsx | 5 +++-- src/components/BidPackageBuilder.jsx | 2 +- src/components/TenderDocuments.jsx | 21 ++++++++++++++------- src/context/AppContext.jsx | 4 ++-- src/services/ai.js | 4 ++-- src/services/extractionReview.js | 18 ++++++++++++++++++ tests/extraction-review.test.mjs | 17 +++++++++++++++++ 12 files changed, 75 insertions(+), 21 deletions(-) create mode 100644 src/services/extractionReview.js create mode 100644 tests/extraction-review.test.mjs diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index bb78e2d..2c4c314 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -343,3 +343,11 @@ Test dosyası document-mixed.pdf bir yerel metin sayfası ve Türkçe metin içe Tarayıcı kanıtı: evidence/qa-mixed-pdf-ocr.txt. 2/2 sayfa okundu, yalnız sayfa 2 OCR olarak işaretlendi. İKN doğru okundu; “için” ve “içerir” sözcüklerinde OCR hataları gözlendi. Dolayısıyla bu test OCR çalışmasını ve sayfa kapsamını doğrular, kusursuz metin veya resmi kalem doğruluğunu doğrulamaz. Önizlemede resmi dokümanla karşılaştırma uyarısı var. OCR doğruluğunu iyileştirme, insan kontrolünün sonraki hazırlık adımlarına zorunlu aktarımı ve uzun tarama performansı açık. 76 test ve üretim derlemesi geçti. Test dosyası sentetiktir; beş resmi ihale kabulü yerine geçmez. + +### OCR ve kaynak kapsamının analiz sonucuna aktarımı + +Doğrudan dosya analizi, kayıtlı belge analizi ve toplu derin analiz; OCR, toplam/okunan sayfa ve eksik kaynak bilgisini API'ye taşıyor. Sunucudaki analiz nitelendirmesi bu bilgiyi modelin tamamlanma iddiasından bağımsız değerlendiriyor. OCR veya okunmayan kaynak varsa sourceReview.partial ve açık uyarı üretiliyor; uzun metin parçaları birleştirilirken bu bilgi korunuyor. Hazırlık formundaki mevcut partial engeli bu sonuçlara da uygulanıyor ve nedenler formda gösteriliyor. İstemcinin verified gibi alanları kabul edilmiyor. + +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. diff --git a/server/ai.mjs b/server/ai.mjs index 6dd571e..f95ffe9 100644 --- a/server/ai.mjs +++ b/server/ai.mjs @@ -317,9 +317,9 @@ function kesikJsonOnar(s) { } /** Zinciri sırayla dener; ilk başarılı sonucu döndürür. */ -export async function analyzeSpec({ text, fileName, tender, chain, systemOverride, mode, _singlePart = false }) { +export async function analyzeSpec({ text, fileName, tender, chain, systemOverride, mode, extraction, _singlePart = false }) { if (!_singlePart && String(text || '').length > 24000) { - return analyzeInParts({text,fileName,tender,mode}, part => analyzeSpec({ + return analyzeInParts({text,fileName,tender,mode,extraction}, part => analyzeSpec({ text:part.text,fileName:`${fileName || 'Doküman'} — bölüm ${part.index+1}`, tender:tender ? {...tender,items:[]} : undefined,chain,mode,_singlePart:true, systemOverride:(systemOverride || (mode === 'derin' ? DERIN_SYSTEM_PROMPT : SYSTEM_PROMPT)) + '\nBu metin büyük dokümanın bir bölümüdür. Yalnız bu bölümde görülen koşulları çıkar; bu bölümde bulunmayan şartın bütün dokümanda bulunmadığı sonucunu çıkarma.', @@ -362,7 +362,7 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid raw = await callOpenAiStyle(p, route.key, route.model, userPrompt, ac.signal, sistem, maxTokens); } const parsed = extractJson(raw); - const result = qualifyAnalysis(parsed,{text,fileName,limit:derin ? 90000 : 60000, + const result = qualifyAnalysis(parsed,{text,fileName,extraction,limit:derin ? 90000 : 60000, expectedItems:tender?.items?.length || 0,repaired:parsed._outputRepaired === true}); attempts.push({ id, ok: true, yol: route.yol }); return { diff --git a/server/analysis-parts.mjs b/server/analysis-parts.mjs index 07ee092..fb38169 100644 --- a/server/analysis-parts.mjs +++ b/server/analysis-parts.mjs @@ -56,7 +56,7 @@ export async function analyzeInParts(input,runPart) { quantities.set(key,row.quantity); } for(const {part,out} of results) for(const issue of out.result.sourceReview?.issues || [])issues.push(`Bölüm ${part.index+1}: ${issue}`); - const result=qualifyAnalysis(merged,{text:input.text,limit:String(input.text).length,fileName:input.fileName,expectedItems:input.tender?.items?.length || 0}); + const result=qualifyAnalysis(merged,{text:input.text,limit:String(input.text).length,fileName:input.fileName,extraction:input.extraction,expectedItems:input.tender?.items?.length || 0}); const ownIssues=[...result.sourceReview.issues,...issues]; result.sourceReview={...result.sourceReview,status:ownIssues.length?'partial':'review_required',issues:ownIssues, analyzedCharacters:results.reduce((sum,{part})=>sum+part.end-part.start,0), diff --git a/server/analysis-quality.mjs b/server/analysis-quality.mjs index 37ae714..a7c52ef 100644 --- a/server/analysis-quality.mjs +++ b/server/analysis-quality.mjs @@ -1,8 +1,9 @@ +import { extractionReview } from '../src/services/extractionReview.js'; import { verifyAnalysisCitations } from './analysis-citations.mjs'; import { validateAnalysisShape } from './analysis-schema.mjs'; import {createHash} from 'node:crypto'; import {extractBidValidity,extractBidType} from '../src/services/bidTerms.js'; -export function qualifyAnalysis(result,{text='',limit=60000,fileName='',expectedItems=0,repaired=false}={}) { +export function qualifyAnalysis(result,{text='',limit=60000,fileName='',expectedItems=0,repaired=false,extraction}={}) { if(!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('Analiz nesne biçiminde değil.'); const clean={...result};delete clean._outputRepaired; if(clean.citations != null && !Array.isArray(clean.citations)) throw new Error('Kaynak alıntıları liste biçiminde değil.'); @@ -11,7 +12,8 @@ export function qualifyAnalysis(result,{text='',limit=60000,fileName='',expected if(clean[key] != null && !Array.isArray(clean[key])) throw new Error(`${key} liste biçiminde değil.`); sections[key]=clean[key]?.length ? 'extracted' : 'not_extracted'; } - const source=String(text),issues=validateAnalysisShape(clean); + const reading=extractionReview(extraction); + const source=String(text),issues=[...validateAnalysisShape(clean),...reading.issues]; if(source.length>limit)issues.push('Kaynak metnin tamamı analiz edilmedi.'); if(repaired)issues.push('Analiz yanıtı kesildi; yalnız kurtarılabilen alanlar gösteriliyor.'); if(expectedItems>0 && (clean.itemsAndSpecs?.length || 0) { const out = await analyzeSpec({ text: body.text, fileName: body.fileName, + extraction: body.extraction, tender: body.tender, chain: body.chain, mode: body.mode === 'derin' ? 'derin' : undefined, diff --git a/src/components/AiSpecAnalyzerModal.jsx b/src/components/AiSpecAnalyzerModal.jsx index e9796da..40fba80 100644 --- a/src/components/AiSpecAnalyzerModal.jsx +++ b/src/components/AiSpecAnalyzerModal.jsx @@ -59,12 +59,13 @@ export const AiSpecAnalyzerModal = () => { try { setProgress('Belge metni çıkarılıyor…'); const { extractText } = await import('../services/pdfText'); - const { text, usedPages, pageCount } = await extractText(file); + const reading = await extractText(file); + const { text, usedPages, pageCount } = reading; if (!text || text.length < 40) { throw new Error('Belgeden metin çıkarılamadı. Taranmış (görüntü) PDF olabilir — metin tabanlı bir dosya deneyin.'); } setProgress(`${usedPages}/${pageCount} sayfa okundu, ${derin ? 'derin analiz üretiliyor (1-2 dk sürebilir)…' : 'model analiz ediyor…'}`); - const r = await analyzeSpecPdfWithGemini({ text, fileName: file.name, tender: activeTenderDetail, mode: derin ? 'derin' : undefined }); + const r = await analyzeSpecPdfWithGemini({ text, fileName: file.name, extraction:{ocr:reading.ocr,pageCount,usedPages,complete:reading.complete,fileName:file.name}, tender: activeTenderDetail, mode: derin ? 'derin' : undefined }); if (typeof r?.bidAnalysis?.recommendedBid === 'number') setCustomBid(r.bidAnalysis.recommendedBid); confetti({ particleCount: 80, spread: 70 }); } catch (e) { diff --git a/src/components/BidPackageBuilder.jsx b/src/components/BidPackageBuilder.jsx index aea56fa..7f3782d 100644 --- a/src/components/BidPackageBuilder.jsx +++ b/src/components/BidPackageBuilder.jsx @@ -245,7 +245,7 @@ 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}
)} } - {analizEksik &&

Analiz kısmi: kaynak veya kalem listesi eksik. Tam dokümanı ve resmi cetveli kontrol ederek analizi tamamlayın.

} + {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/TenderDocuments.jsx b/src/components/TenderDocuments.jsx index 754a33c..3bc10e0 100644 --- a/src/components/TenderDocuments.jsx +++ b/src/components/TenderDocuments.jsx @@ -120,10 +120,11 @@ export const TenderDocuments = ({ tender }) => { setBusy('belge'); setError(null); try { if (!okunabilirMi(b.ad)) throw new Error('Bu dosya türünden metin çıkarılamıyor (PDF, DOCX, XLSX, ZIP ve TXT destekleniyor).'); - const metin = await belgeMetni(b); + const reading = await inspectStoredDocument(b); + const metin = reading.text; if (!metin || metin.length < 100) throw new Error('Belgeden analiz için yeterli metin çıkarılamadı (taranmış PDF olabilir).'); setIsAiSpecAnalyzerOpen(true); - await analyzeSpecPdfWithGemini({ text: metin, fileName: b.ad, tender, mode: mod }); + await analyzeSpecPdfWithGemini({ text: metin, fileName: b.ad, extraction:{ocr:reading.ocr,pageCount:reading.pageCount,usedPages:reading.usedPages,complete:reading.complete,fileName:b.ad}, tender, mode: mod }); } catch (e) { setError(e.message); } finally { setBusy(null); } @@ -188,6 +189,7 @@ export const TenderDocuments = ({ tender }) => { const tumEvrakiTopla = async () => { const parcalar = []; const atlanan = []; + const extraction = []; if (ekapIhalesi) { setIlerleme('EKAP ilan metni alınıyor…'); @@ -198,28 +200,32 @@ export const TenderDocuments = ({ tender }) => { } } catch { atlanan.push('EKAP ilan metni (alınamadı)'); + extraction.push({fileName:'EKAP ilan metni',complete:false}); } } for (const b of belgeler) { - if (!okunabilirMi(b.ad)) { atlanan.push(`${b.ad} (metin çıkarılamıyor)`); continue; } + if (!okunabilirMi(b.ad)) { atlanan.push(`${b.ad} (metin çıkarılamıyor)`); extraction.push({fileName:b.ad,complete:false}); continue; } setIlerleme(`${b.ad} okunuyor…`); try { - const t = await belgeMetni(b); + const reading = await inspectStoredDocument(b); + const t = reading.text; + extraction.push({ocr:reading.ocr,pageCount:reading.pageCount,usedPages:reading.usedPages,complete:reading.complete,fileName:b.ad}); if (t && t.length >= 200) parcalar.push(`===== BELGE: ${b.ad} =====\n${t}`); - else atlanan.push(`${b.ad} (yeterli metin yok — taranmış olabilir)`); + else { atlanan.push(`${b.ad} (yeterli metin yok — taranmış olabilir)`); extraction.push({fileName:b.ad,complete:false}); } } catch (e) { atlanan.push(`${b.ad} (${e.message})`); + extraction.push({fileName:b.ad,complete:false}); } } - return { metin: parcalar.join('\n\n'), parcaSayisi: parcalar.length, atlanan }; + return { metin: parcalar.join('\n\n'), parcaSayisi: parcalar.length, atlanan, extraction }; }; const derinAnalizEt = async () => { setBusy('derin'); setError(null); setBilgi(null); try { - const { metin, parcaSayisi, atlanan } = await tumEvrakiTopla(); + const { metin, parcaSayisi, atlanan, extraction } = await tumEvrakiTopla(); if (!metin || metin.length < 200) { throw new Error('Derin analiz için yeterli metin toplanamadı. EKAP dokümanını indirip PDF olarak yükleyin.'); } @@ -228,6 +234,7 @@ export const TenderDocuments = ({ tender }) => { setIsAiSpecAnalyzerOpen(true); await analyzeSpecPdfWithGemini({ text: metin, + extraction, fileName: `${ikn || 'ihale'} — ${parcaSayisi} evrak birlikte`, tender, mode: 'derin', diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 696da2e..3b255f3 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 } = {}) => { + const analyzeSpecPdfWithGemini = async ({ text, fileName, tender, mode, extraction } = {}) => { 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 }); + const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain, mode, extraction }); const result = { ...data.result, tenderKey: tender?.ikn || tender?.id || null, diff --git a/src/services/ai.js b/src/services/ai.js index 2198d61..9f6f63a 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 }) { +export async function analyzeSpecText({ text, fileName, tender, chain, mode, extraction }) { 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 }), + body: JSON.stringify({ text, fileName, tender, chain, mode, extraction }), }); const data = await res.json().catch(() => ({})); if (!data.success) { diff --git a/src/services/extractionReview.js b/src/services/extractionReview.js new file mode 100644 index 0000000..0065e5f --- /dev/null +++ b/src/services/extractionReview.js @@ -0,0 +1,18 @@ +/** Only extraction provenance crosses the AI boundary, never a client approval flag. */ +export function extractionReview(input) { + const sources=(Array.isArray(input) ? input : input ? [input] : []).map(source=>({ + fileName:typeof source.fileName === 'string' ? source.fileName.slice(0,200) : '', + ocr:source.ocr === true, + pageCount:Number.isInteger(source.pageCount) && source.pageCount>0 ? source.pageCount : null, + usedPages:Number.isInteger(source.usedPages) && source.usedPages>=0 ? source.usedPages : null, + complete:source.complete !== false, + })); + const issues=[]; + for(const source of sources) { + const name=source.fileName || 'Belge'; + if(source.ocr) issues.push(`${name}: OCR metni resmi belgeyle karşılaştırılmalı; karakter ve miktar hataları olabilir.`); + if(!source.complete || (source.pageCount != null && source.usedPages != null && source.usedPages{ + const result=qualifyAnalysis({sourceReview:{status:'complete'}},{text:'Source clause',extraction:{fileName:'Scan.pdf',ocr:true,pageCount:45,usedPages:40,verified:true}}); + assert.equal(result.sourceReview.status,'partial'); + assert.equal(result.sourceReview.issues.length,2); + assert.equal(result.sourceReview.extraction[0].verified,undefined); + assert.match(result.sourceReview.issues.join(' '),/OCR/); +}); +test('combined-source analysis retains a skipped document warning',()=>{ + const result=qualifyAnalysis({}, {text:'Available announcement',extraction:[{fileName:'Announcement',complete:true},{fileName:'Missing specification',complete:false}]}); + assert.equal(result.sourceReview.status,'partial'); + assert.match(result.sourceReview.issues[0],/Missing specification/); + assert.deepEqual(extractionReview({ocr:false,pageCount:45,usedPages:45,complete:true}).issues,[]); +});