Expose document text and OCR page provenance for review

This commit is contained in:
2026-09-06 09:44:06 +03:00
parent b340cf5772
commit 49965a2688
5 changed files with 341 additions and 5 deletions
@@ -0,0 +1,17 @@
document-mixed.pdf — okunan metin
2/2 sayfa okundu. OCR kullanılan sayfalar: 2. Metni resmi dokümanla karşılaştırın; okunmuş olması doğruluğunun onaylandığı anlamına gelmez.
SYNTHETIC MIXED PDF - QA ONLY - NOT AN OFFICIAL DOCUMENT
Reference 2026/1582991. This page has a native text layer.
The following page is an image and requires OCR processing.
This fixture contains no actual procurement requirements or prices.
SENTETİK OCR TESTİ
İhale Kayıt Numarası: 2026/1582991
Laboratuvar ürünleri iXlin örnek belge.
Bu sayfa yalnız görüntü iDlerir.
Resmi ihale dokümanı değildir.
Metin önizlemesini kapat
+8
View File
@@ -335,3 +335,11 @@ Varsayılan 40 metin sayfası ve 8 OCR sayfası sınırı kaldırıldı; tüm sa
76 test ve üretim derlemesi geçti. Testler 45 sayfalık gerçek PDF baytlarını pdfjs ile okuyarak son sayfa maddesinin korunduğunu; karma sayfa sırasını ve OCR hatasının başarısızlık olarak iletildiğini doğruluyor. Gerçek PDF testi kütüphanede DocumentProxy.destroy bulunmadığını ortaya çıkardı; kapatma LoadingTask.destroy üzerinden düzeltildi. 76 test ve üretim derlemesi geçti. Testler 45 sayfalık gerçek PDF baytlarını pdfjs ile okuyarak son sayfa maddesinin korunduğunu; karma sayfa sırasını ve OCR hatasının başarısızlık olarak iletildiğini doğruluyor. Gerçek PDF testi kütüphanede DocumentProxy.destroy bulunmadığını ortaya çıkardı; kapatma LoadingTask.destroy üzerinden düzeltildi.
Sınırlar: gerçek tarayıcıda uzun/taranmış karma PDF performansı ve OCR doğruluğu kabulü açık. Sayfa okunmuş olması OCR metninin anlamsal doğruluğunu kanıtlamaz. OCR varsayılanı metni 100 karakterden kısa sayfalarda çalışır; küçük metinli sayfalarda ek işlem maliyeti oluşabilir. Resmi dosya ve beş ihale kabulü tamamlanmadı. Sınırlar: gerçek tarayıcıda uzun/taranmış karma PDF performansı ve OCR doğruluğu kabulü açık. Sayfa okunmuş olması OCR metninin anlamsal doğruluğunu kanıtlamaz. OCR varsayılanı metni 100 karakterden kısa sayfalarda çalışır; küçük metinli sayfalarda ek işlem maliyeti oluşabilir. Resmi dosya ve beş ihale kabulü tamamlanmadı.
### Karma PDF tarayıcı OCR kabulü ve kaynak önizlemesi
Test dosyası document-mixed.pdf bir yerel metin sayfası ve Türkçe metin içeren görüntü sayfasından oluşuyor. Gerçek in-app tarayıcıda yükleme tamamlandı, kayıt korundu ve cetvel bulunmadığı gösterildi. Yeni “Metni incele” işlemi kaydedilmiş dosyayı okuyor; toplam/okunan sayfa sayısı, OCR uygulanan sayfalar ve tam çıkarılan metni AI çağrısı yapmadan gösteriyor. Önizleme ihale değişiminde temizleniyor; geç gelen başka ihale/oturum yanıtı uygulanmıyor.
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.
+29 -2
View File
@@ -1,4 +1,4 @@
import { readableDocument, readStoredDocument, retryDocumentExtraction } from '../services/storedDocument'; import { readableDocument, readStoredDocument, inspectStoredDocument, retryDocumentExtraction } from '../services/storedDocument';
import { documentProcessingLabel } from '../services/documentProcessing'; import { documentProcessingLabel } from '../services/documentProcessing';
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
@@ -18,6 +18,7 @@ export const TenderDocuments = ({ tender }) => {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [bilgi, setBilgi] = useState(null); const [bilgi, setBilgi] = useState(null);
const [ilerleme, setIlerleme] = useState(''); const [ilerleme, setIlerleme] = useState('');
const [metinOnizleme, setMetinOnizleme] = useState(null);
// Son üretilen EKAP doküman adresi — yeni sekme engellenirse kullanıcı // Son üretilen EKAP doküman adresi — yeni sekme engellenirse kullanıcı
// gerçek bir bağlantıya tıklayarak açabilsin (bağlantı tıklaması engellenmez). // gerçek bir bağlantıya tıklayarak açabilsin (bağlantı tıklaması engellenmez).
const [ekapUrl, setEkapUrl] = useState(null); const [ekapUrl, setEkapUrl] = useState(null);
@@ -30,6 +31,7 @@ export const TenderDocuments = ({ tender }) => {
const [listeHata, setListeHata] = useState(null); const [listeHata, setListeHata] = useState(null);
const [listeBekliyor, setListeBekliyor] = useState(false); const [listeBekliyor, setListeBekliyor] = useState(false);
const listeIstegi = useRef(0); const listeIstegi = useRef(0);
const onizlemeIstegi = useRef(0);
const dosyaRef = useRef(null); const dosyaRef = useRef(null);
const belgeleriYukle = useCallback(async () => { const belgeleriYukle = useCallback(async () => {
@@ -45,7 +47,7 @@ export const TenderDocuments = ({ tender }) => {
if (request === listeIstegi.current) setListeBekliyor(false); if (request === listeIstegi.current) setListeBekliyor(false);
} }
}, [ikn]); }, [ikn]);
useEffect(() => { setBelgeler([]); belgeleriYukle(); return () => { listeIstegi.current++; }; }, [belgeleriYukle]); useEffect(() => { setBelgeler([]); setMetinOnizleme(null); belgeleriYukle(); return () => { listeIstegi.current++; onizlemeIstegi.current++; }; }, [belgeleriYukle]);
// EKAP dokümanı kullanıcının tarayıcısına iniyor; indirdiği dosyayı buraya // EKAP dokümanı kullanıcının tarayıcısına iniyor; indirdiği dosyayı buraya
// yükleyince ihaleye bağlanır ve metni AI analizine verilebilir. // yükleyince ihaleye bağlanır ve metni AI analizine verilebilir.
@@ -92,6 +94,18 @@ export const TenderDocuments = ({ tender }) => {
const okunabilirMi = readableDocument; const okunabilirMi = readableDocument;
const belgeMetni = readStoredDocument; const belgeMetni = readStoredDocument;
const metniIncele = async (b) => {
setBusy('metin'); setError(null); setMetinOnizleme(null);
const token = getToken();
const request = ++onizlemeIstegi.current;
try {
const result = await inspectStoredDocument(b);
if (token !== getToken() || request !== onizlemeIstegi.current) return;
setMetinOnizleme({...result,name:b.ad});
} catch (e) { if (token === getToken() && request === onizlemeIstegi.current) setError(e.message); }
finally { if (token === getToken() && request === onizlemeIstegi.current) setBusy(null); }
};
const kalemleriYenidenCikar = async (b) => { const kalemleriYenidenCikar = async (b) => {
setBusy('kalem'); setError(null); setBilgi(null); setBusy('kalem'); setError(null); setBilgi(null);
try { try {
@@ -373,6 +387,8 @@ export const TenderDocuments = ({ tender }) => {
> >
Derin Derin
</button> </button>
{okunabilirMi(b.ad) && <button onClick={() => metniIncele(b)} disabled={busy !== null || !b.erisilebilir}
aria-label={`${b.ad} metnini incele`} className="text-[10px] text-blue-700 disabled:opacity-40">Metni incele</button>}
{b.tur === 'ihale' && okunabilirMi(b.ad) && ( {b.tur === 'ihale' && okunabilirMi(b.ad) && (
<button onClick={() => kalemleriYenidenCikar(b)} disabled={busy !== null || yukleniyor || !b.erisilebilir} <button onClick={() => kalemleriYenidenCikar(b)} disabled={busy !== null || yukleniyor || !b.erisilebilir}
aria-label={`${b.ad} için kalem çıkarımını yeniden dene`} aria-label={`${b.ad} için kalem çıkarımını yeniden dene`}
@@ -388,6 +404,17 @@ export const TenderDocuments = ({ tender }) => {
</div> </div>
)} )}
{metinOnizleme && <section aria-label="Okunan belge metni" className="border rounded-xl p-3 space-y-2">
<h5 className="font-bold text-xs">{metinOnizleme.name} — okunan metin</h5>
<p className="text-xs text-slate-600">
{metinOnizleme.pageCount != null ? `${metinOnizleme.usedPages}/${metinOnizleme.pageCount} sayfa okundu. ` : 'Sunucuda okunan belge metni. '}
{metinOnizleme.ocr ? `OCR kullanılan sayfalar: ${metinOnizleme.pages.filter(p=>p.method === 'ocr').map(p=>p.page).join(', ')}. ` : ''}
Metni resmi dokümanla karşılaştırın; okunmuş olması doğruluğunun onaylandığı anlamına gelmez.
</p>
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words text-xs">{metinOnizleme.text || 'Okunabilir metin bulunamadı.'}</pre>
<button onClick={()=>setMetinOnizleme(null)} className="text-xs underline">Metin önizlemesini kapat</button>
</section>}
{(ekapIhalesi || belgeler.length > 0) && ( {(ekapIhalesi || belgeler.length > 0) && (
<div className="bg-linear-to-r from-slate-900 via-indigo-950 to-slate-900 rounded-xl p-4 space-y-2.5"> <div className="bg-linear-to-r from-slate-900 via-indigo-950 to-slate-900 rounded-xl p-4 space-y-2.5">
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
+5 -3
View File
@@ -3,7 +3,7 @@ import { reportDocumentReadError } from './account.js';
export const readableDocument = (name) => /\.(pdf|txt|md|docx|xlsx|zip)$/i.test(String(name || '')); export const readableDocument = (name) => /\.(pdf|txt|md|docx|xlsx|zip)$/i.test(String(name || ''));
export async function readStoredDocument(document) { export async function inspectStoredDocument(document) {
if (!readableDocument(document.ad)) throw new Error('Bu dosya biçiminden metin çıkarımı desteklenmiyor.'); if (!readableDocument(document.ad)) throw new Error('Bu dosya biçiminden metin çıkarımı desteklenmiyor.');
const office = /\.(docx|xlsx|zip)$/i.test(document.ad); const office = /\.(docx|xlsx|zip)$/i.test(document.ad);
const response = await fetch(`/api/me/documents/${office ? 'text' : 'download'}?id=${encodeURIComponent(document.id)}`, { const response = await fetch(`/api/me/documents/${office ? 'text' : 'download'}?id=${encodeURIComponent(document.id)}`, {
@@ -12,14 +12,16 @@ export async function readStoredDocument(document) {
if (office) { if (office) {
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
if (!response.ok || !result.success) throw new Error(result.error || 'Belge metni alınamadı.'); if (!response.ok || !result.success) throw new Error(result.error || 'Belge metni alınamadı.');
return result.metin; return {text:result.metin,format:document.ad.split('.').pop()};
} }
if (!response.ok) throw new Error('Belge indirilemedi.'); if (!response.ok) throw new Error('Belge indirilemedi.');
const blob = await response.blob(); const blob = await response.blob();
const { extractText } = await import('./pdfText'); const { extractText } = await import('./pdfText');
return (await extractText(new File([blob],document.ad,{type:document.mime || 'application/octet-stream'}))).text; return extractText(new File([blob],document.ad,{type:document.mime || 'application/octet-stream'}));
} }
export async function readStoredDocument(document) { return (await inspectStoredDocument(document)).text; }
export async function retryDocumentExtraction(document) { export async function retryDocumentExtraction(document) {
if (document.tur !== 'ihale' || !document.ikn) throw new Error('İhaleye bağlı ihale dokümanı gerekli.'); if (document.tur !== 'ihale' || !document.ikn) throw new Error('İhaleye bağlı ihale dokümanı gerekli.');
const token = getToken(); const token = getToken();
File diff suppressed because one or more lines are too long