Cancel PDF reading and OCR when the offer assistant closes

This commit is contained in:
2026-09-06 10:24:07 +03:00
parent d534ea20b9
commit 3ad9348c20
6 changed files with 109 additions and 25 deletions
+10
View File
@@ -535,3 +535,13 @@ Sinyal soru/denetim/taslak çağrılarından AI sağlayıcı fetch'lerine taşı
Testler: gerçek yerel HTTP isteğinde gövde tamamlandıktan sonra bağlantıyı keserek iptal; sağlayıcı fetch'ini iptal edip tek çağrı ve sıfır yedek deneme; sonraki denetim gruplarının durması; görünüm iptalinde istemci fetch'inin sonlanması. Sağlayıcı yanıtları ağ dışına çıkmayan test taklididir. Tüm 118 test ve üretim derlemesi geçti. Testler: gerçek yerel HTTP isteğinde gövde tamamlandıktan sonra bağlantıyı keserek iptal; sağlayıcı fetch'ini iptal edip tek çağrı ve sıfır yedek deneme; sonraki denetim gruplarının durması; görünüm iptalinde istemci fetch'inin sonlanması. Sağlayıcı yanıtları ağ dışına çıkmayan test taklididir. Tüm 118 test ve üretim derlemesi geçti.
Sınırlar: İlan/doküman kaynak ediniminin kendi alt istekleri ve OCR bu iptal zincirinin tamamına henüz bağlı değil. Kalıcı iş kuyruğu, yeniden başlatmada sürdürme ve tarayıcıdan uzun başarılı denetim kabulü açık. Büyük paket uyarıları sürüyor. Canlı yayın yapılmadı. Sınırlar: İlan/doküman kaynak ediniminin kendi alt istekleri ve OCR bu iptal zincirinin tamamına henüz bağlı değil. Kalıcı iş kuyruğu, yeniden başlatmada sürdürme ve tarayıcıdan uzun başarılı denetim kabulü açık. Büyük paket uyarıları sürüyor. Canlı yayın yapılmadı.
### D06 — PDF / OCR iptal desteği
Belge okuyucuya isteğe bağlı AbortSignal eklendi. İptal bekleyen dosya metni, PDF açılışı, sayfa edinimi/metni ve OCR sonucunu beklemeyi keser; sonraki sayfalara geçilmez. PDF yükleme görevi, etkin çizim ve OCR çalışanı iptal/sonlanma yolunda temizlenir. Çalışan oluşturulması veya sayfa edinimi iptalden sonra sonuçlanırsa geç gelen kaynak kapatılır. İptal hatası normal bozuk dosya/OCR hatasına çevrilmez.
Teklif Asistanı dosya okuması görünümün yaşam süresi sinyalini kullanır. Böylece modal/sekme bileşeni kapanınca yalnız sonuç atılmakla kalmaz, okuyucu da iptal edilir. Diğer belge okuma girişlerinin tamamı henüz bu sinyali iletmiyor; uygulama genelinde OCR iptali tamamlandı sayılmaz.
Testler: bekleyen OCR sırasında iptalde tek sayfa ve temizlik; geciken sayfa ediniminde temizlik; geç sonuçta iptal callback'inin ikinci kez çağrılmaması; normal tamamlanma sonrasında dinleyici temizliği. Mevcut gerçek 45 sayfalık PDF okuma testi de geçti. Gerçek tarayıcıda Tesseract çalışanı sırasında iptal kabulü bu turda yapılmadı.
Sonuç: Tüm 122 test ve üretim derlemesi geçti. Büyük paket uyarıları sürüyor. Planın kalan kapsamı açık; canlı yayın yapılmadı.
+1 -1
View File
@@ -70,7 +70,7 @@ const OfferAssistantSession = ({ tender }) => {
return calistir(async () => { return calistir(async () => {
const { extractText } = await import('../services/pdfText'); const { extractText } = await import('../services/pdfText');
if (!current()) return; if (!current()) return;
const r = await extractText(f); const r = await extractText(f,{signal:lifetime.current.signal});
if (typeof r.text !== 'string' || !r.text.trim()) throw new Error('Dosyada okunabilir metin bulunamadı. Önceki kaynak değiştirilmedi. Başka bir dosya seçin.'); if (typeof r.text !== 'string' || !r.text.trim()) throw new Error('Dosyada okunabilir metin bulunamadı. Önceki kaynak değiştirilmedi. Başka bir dosya seçin.');
if (current()) { setMetin(r.text); setSourceName(f.name); setCevap(null); setDenetim(null); } if (current()) { setMetin(r.text); setSourceName(f.name); setCevap(null); setDenetim(null); }
}); });
+23
View File
@@ -0,0 +1,23 @@
/** Stop awaiting work on cancellation; retain handlers for late settlement. */
export function awaitWithAbort(work, signal, cancel) {
if (!signal) return Promise.resolve(work);
return new Promise((resolve,reject)=>{
let settled=false;
const cleanup=()=>signal.removeEventListener('abort',abort);
const abort=()=>{
if(settled)return;
settled=true;cleanup();
try {Promise.resolve(cancel?.()).catch(()=>{});} catch { /* cancellation is best effort */ }
reject(signal.reason || new DOMException('İşlem iptal edildi.','AbortError'));
};
Promise.resolve(work).then(value=>{
if(settled)return;
if(signal.aborted){abort();return;}
settled=true;cleanup();resolve(value);
},error=>{
if(settled)return;
settled=true;cleanup();reject(signal.aborted ? signal.reason : error);
});
if(signal.aborted)abort();else signal.addEventListener('abort',abort,{once:true});
});
}
+9 -5
View File
@@ -1,19 +1,23 @@
import {awaitWithAbort} from './abortable.js';
/** Read every requested page in order, retaining text/OCR provenance per page. */ /** Read every requested page in order, retaining text/OCR provenance per page. */
export async function readPdfPages(doc, {maxPages=Infinity, ocrPage, forceOcr=false, onProgress}={}) { export async function readPdfPages(doc, {maxPages=Infinity, ocrPage, forceOcr=false, onProgress, signal}={}) {
if (!(maxPages === Infinity || (Number.isInteger(maxPages) && maxPages > 0))) throw new Error('Geçersiz sayfa sınırı.'); if (!(maxPages === Infinity || (Number.isInteger(maxPages) && maxPages > 0))) throw new Error('Geçersiz sayfa sınırı.');
signal?.throwIfAborted();
const pages = Math.min(doc.numPages,maxPages); const pages = Math.min(doc.numPages,maxPages);
const results=[]; const results=[];
for(let number=1;number<=pages;number++) { for(let number=1;number<=pages;number++) {
signal?.throwIfAborted();
onProgress?.({sayfa:number,toplam:pages}); onProgress?.({sayfa:number,toplam:pages});
const page=await doc.getPage(number); const page=await awaitWithAbort(doc.getPage(number).then(page=>{if(signal?.aborted){page.cleanup?.();signal.throwIfAborted();}return page;}),signal);
try { try {
const content=await page.getTextContent(); const content=await awaitWithAbort(page.getTextContent(),signal);
let text=content.items.map(item=>item.str + (item.hasEOL ? '\n' : ' ')).join('').trim(); let text=content.items.map(item=>item.str + (item.hasEOL ? '\n' : ' ')).join('').trim();
let method='text'; let method='text';
if(ocrPage && (forceOcr || text.replace(/\s/g,'').length < 100)) { if(ocrPage && (forceOcr || text.replace(/\s/g,'').length < 100)) {
try { text=await ocrPage(page); method='ocr'; } try { text=await awaitWithAbort(ocrPage(page),signal); method='ocr'; }
catch { throw new Error(`${number}. sayfada OCR tamamlanamadı. Okunabilir belgeyle yeniden deneyin.`); } catch { signal?.throwIfAborted(); throw new Error(`${number}. sayfada OCR tamamlanamadı. Okunabilir belgeyle yeniden deneyin.`); }
} }
signal?.throwIfAborted();
results.push({page:number,text,method}); results.push({page:number,text,method});
} finally { page.cleanup?.(); } } finally { page.cleanup?.(); }
} }
+38 -19
View File
@@ -1,52 +1,71 @@
import {awaitWithAbort} from './abortable.js';
import { readPdfPages } from './pdfDocumentReader.js'; import { readPdfPages } from './pdfDocumentReader.js';
async function openPdf(file) { async function openPdf(file,signal) {
signal?.throwIfAborted();
const pdfjs = await import('pdfjs-dist'); const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default; pdfjs.GlobalWorkerOptions.workerSrc = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default;
let task; let task,destruction;
const destroy=()=>destruction ||= task?.destroy();
try { try {
task = pdfjs.getDocument({data:await file.arrayBuffer()}); const data=await awaitWithAbort(file.arrayBuffer(),signal);
return {doc:await task.promise,task}; signal?.throwIfAborted();
task = pdfjs.getDocument({data});
return {doc:await awaitWithAbort(task.promise,signal,destroy),task:{destroy}};
} }
catch(error) { catch(error) {
await task?.destroy(); await destroy();
signal?.throwIfAborted();
if(error.name === 'InvalidPDFException') throw new Error('PDF dosyası bozuk veya geçerli PDF biçiminde değil.'); if(error.name === 'InvalidPDFException') throw new Error('PDF dosyası bozuk veya geçerli PDF biçiminde değil.');
if(error.name === 'PasswordException') throw new Error('PDF parola korumalı; okunabilir bir kopyasını yükleyin.'); if(error.name === 'PasswordException') throw new Error('PDF parola korumalı; okunabilir bir kopyasını yükleyin.');
throw new Error('PDF okunamadı. Dosyayı kontrol edip yeniden deneyin.'); throw new Error('PDF okunamadı. Dosyayı kontrol edip yeniden deneyin.');
} }
} }
async function readPdf(file,{ocr=false,forceOcr=false,maxPages=Infinity,onProgress}={}) { async function readPdf(file,{ocr=false,forceOcr=false,maxPages=Infinity,onProgress,signal}={}) {
const {doc,task}=await openPdf(file); const {doc,task}=await openPdf(file,signal);
let worker; let worker,termination,render;
const stopWorker=()=>termination ||= worker?.terminate();
const cancel=()=>{render?.cancel();void Promise.resolve(stopWorker()).catch(()=>{});void Promise.resolve(task.destroy()).catch(()=>{});};
signal?.addEventListener('abort',cancel,{once:true});
try { try {
const result=await readPdfPages(doc,{maxPages,forceOcr,onProgress,ocrPage:ocr ? async(page)=>{ signal?.throwIfAborted();
const result=await readPdfPages(doc,{maxPages,forceOcr,onProgress,signal,ocrPage:ocr ? async(page)=>{
if(!worker) { if(!worker) {
const {createWorker}=await import('tesseract.js'); const {createWorker}=await import('tesseract.js');
worker=await createWorker('tur'); signal?.throwIfAborted();
worker=await awaitWithAbort(createWorker('tur').then(created=>{
if(signal?.aborted){void created.terminate().catch(()=>{});signal.throwIfAborted();}
return created;
}),signal);
} }
const viewport=page.getViewport({scale:2}); const viewport=page.getViewport({scale:2});
const canvas=document.createElement('canvas'); const canvas=document.createElement('canvas');
canvas.width=viewport.width;canvas.height=viewport.height; canvas.width=viewport.width;canvas.height=viewport.height;
try { try {
await page.render({canvasContext:canvas.getContext('2d'),viewport}).promise; signal?.throwIfAborted();
return (await worker.recognize(canvas)).data.text; render=page.render({canvasContext:canvas.getContext('2d'),viewport});
} finally { canvas.width=0;canvas.height=0; } await awaitWithAbort(render.promise,signal,()=>render.cancel());
signal?.throwIfAborted();
return (await awaitWithAbort(worker.recognize(canvas),signal,stopWorker)).data.text;
} finally { render=null;canvas.width=0;canvas.height=0; }
} : undefined}); } : undefined});
return result; return result;
} finally { } finally {
try { if(worker) await worker.terminate(); } signal?.removeEventListener('abort',cancel);
try { await stopWorker(); }
finally { await task.destroy(); } finally { await task.destroy(); }
} }
} }
export const extractPdfText=(file,options={})=>readPdf(file,{...options,ocr:false}); export const extractPdfText=(file,options={})=>readPdf(file,{...options,ocr:false});
export const ocrPdfText=(file,options={})=>readPdf(file,{...options,ocr:true,forceOcr:true}); export const ocrPdfText=(file,options={})=>readPdf(file,{...options,ocr:true,forceOcr:true});
export async function extractPlainText(file) { export async function extractPlainText(file,{signal}={}) {
return {text:(await file.text()).trim(),pageCount:1,usedPages:1,complete:true}; signal?.throwIfAborted();
return {text:(await awaitWithAbort(file.text(),signal)).trim(),pageCount:1,usedPages:1,complete:true};
} }
export async function extractText(file,{ocr=true,onProgress}={}) { export async function extractText(file,{ocr=true,onProgress,signal}={}) {
if((file.name || '').toLowerCase().endsWith('.pdf') || file.type==='application/pdf') if((file.name || '').toLowerCase().endsWith('.pdf') || file.type==='application/pdf')
return readPdf(file,{ocr,onProgress}); return readPdf(file,{ocr,onProgress,signal});
return extractPlainText(file); return extractPlainText(file,{signal});
} }
+28
View File
@@ -0,0 +1,28 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readPdfPages} from '../src/services/pdfDocumentReader.js';
import {awaitWithAbort} from '../src/services/abortable.js';
test('cancelling pending OCR cleans the page and prevents reading subsequent pages',async()=>{
const c=new AbortController();let reads=0,cleanups=0;
const doc={numPages:3,getPage:async()=>{reads++;return {getTextContent:async()=>({items:[]}),cleanup:()=>cleanups++};}};
await assert.rejects(readPdfPages(doc,{signal:c.signal,ocrPage:async()=>{c.abort();return new Promise(()=>{});}}),{name:'AbortError'});
assert.equal(reads,1);assert.equal(cleanups,1);
});
test('late page acquisition after cancellation cleans the acquired page',async()=>{
const c=new AbortController();let release,cleanups=0;
const pending=readPdfPages({numPages:1,getPage:()=>new Promise(resolve=>release=resolve)},{signal:c.signal});
c.abort();await assert.rejects(pending,{name:'AbortError'});
release({cleanup:()=>cleanups++});await Promise.resolve();await Promise.resolve();
assert.equal(cleanups,1);
});
test('abort invokes resource cancellation once even if work settles later',async()=>{
const c=new AbortController();let release,cancelled=0;
const pending=awaitWithAbort(new Promise(resolve=>release=resolve),c.signal,()=>cancelled++);
c.abort();await assert.rejects(pending,{name:'AbortError'});
release('late');await Promise.resolve();assert.equal(cancelled,1);
});
test('normal completion removes cancellation callbacks',async()=>{
const c=new AbortController();let cancelled=0;
assert.equal(await awaitWithAbort(Promise.resolve('source'),c.signal,()=>cancelled++),'source');
c.abort();assert.equal(cancelled,0);
});