diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 0ca1e15..107b222 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -739,3 +739,11 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö - SQLite sütun geçişi işlem kilidi altında yapılır. Sahibe özel iş okuması sabitlenen girdiyi geri verir; eski tamamlanmış işlerin geçmişte edinilen kaynakları geriye dönük üretilemez. - Doğrulama: 163 otomatik test geçti; üretim derlemesi geçti (büyük paket uyarısı sürüyor). Kaynak A → sağlayıcı hatası → tekrar deneme sırasında A'nın korunması, hesap izolasyonu, idempotency, iptal sonrası geç kaynak ve boş kaynak test edildi. - Gerçek sağlayıcı/uzun belge ile canlı kabul ve genel AI ekranının kalıcı işlere taşınması henüz tamamlanmadı. Bu kayıt D06'nın bütünüyle bittiği anlamına gelmez. + +### Genel şartname analizi için kalıcı işler — 6 Eylül 2026 +- Genel/derin analiz istemcisi `analiz` türündeki kalıcı işe taşındı. İhale anahtarıyla listelenir; sayfa yenilense veya bağlantı kesilse de sunucu sonucu saklar. Eski doğrudan API uyumluluk için duruyor. +- Analiz ekranında kayıtlı genel analizleri açma, kaynak metnini görme, yeniden başlatma ve iptal kontrolleri eklendi. Teklif asistanının iş listesi kendi üç işlem türünü gösteriyor. +- Sunucu, insan incelemesi referansını sahibinin belgesinden çözer ve metni sabitler. İnceleme analizden önce veya sonra değişirse tamamlanmış sonuç üretilmez. Yeniden başlatma sabitlenmiş girdiyi kullanır; eski incelemeyi güncel gibi kabul etmez. +- Oturum başına eşzamanlı genel analiz başlatma kilidi eklendi; eski oturum işi yeni oturumun yükleniyor durumunu veya sonucunu değiştiremez. Farklı ihale seçilince önceki ihalenin analiz sonucu gösterilmez. +- Doğrulama: 167 otomatik test ve üretim derlemesi geçti. Yeni testler kaynak sabitleme, ihale bazlı iş bulma, sunucu incelemesi kullanımı, inceleme değişikliği ve iptal davranışını kapsıyor. Büyük paket uyarısı sürüyor. +- Bu yeni genel analiz ekranı için tarayıcıda kapat/aç/yenile kabul testi ve gerçek sağlayıcı testi henüz yapılmadı. Son 50 kayıt sınırı ve kesilen işi baştan başlatma davranışı sürüyor; ara parçadan devam etme yok. D06 ve tüm plan açık. diff --git a/server/ai-job-store.mjs b/server/ai-job-store.mjs index 957775d..0699021 100644 --- a/server/ai-job-store.mjs +++ b/server/ai-job-store.mjs @@ -4,7 +4,7 @@ import {dirname,join} from 'node:path'; import {fileURLToPath} from 'node:url'; import {randomUUID,createHash} from 'node:crypto'; const root=process.env.IHALEIST_USER_DATA_DIR || join(dirname(fileURLToPath(import.meta.url)),'data'); -const operations=new Set(['belge-sor','teklif-denetim','teklif-taslak']); +const operations=new Set(['analiz','belge-sor','teklif-denetim','teklif-taslak']); function dbWork(fn) { mkdirSync(root,{recursive:true,mode:0o700});const db=new DatabaseSync(join(root,'ai-jobs.sqlite'));chmodSync(join(root,'ai-jobs.sqlite'),0o600); try { @@ -40,7 +40,7 @@ export function createAiJob(email,requestId,operation,input) { if(db.prepare("SELECT COUNT(*) n FROM ai_jobs WHERE email=? AND state IN ('queued','running')").get(email).n>=5)throw new Error('En fazla beş etkin AI işi olabilir. Mevcut işleri tamamlayın veya iptal edin.'); const id=randomUUID(),now=new Date().toISOString(); db.prepare("INSERT INTO ai_jobs(id,email,request_id,operation,ikn,input,input_hash,state,created_at,updated_at) VALUES (?,?,?,?,?,?,?,'queued',?,?)") - .run(id,email,requestId,operation,input.ikn||null,serialized,hash,now,now); + .run(id,email,requestId,operation,input.ikn||input.tender?.ikn||null,serialized,hash,now,now); const result=publicJob(db.prepare('SELECT * FROM ai_jobs WHERE id=?').get(id));db.exec('COMMIT');return result; } catch(error){db.exec('ROLLBACK');throw error;} }); diff --git a/server/analysis-job.mjs b/server/analysis-job.mjs new file mode 100644 index 0000000..185f129 --- /dev/null +++ b/server/analysis-job.mjs @@ -0,0 +1,23 @@ +export async function executeAnalysisJob(job,{analyze,reviewSource,pin,signal}) { + signal?.throwIfAborted(); + let input=job.input; + const reviewed=input.reviewedSource ? reviewSource(job.email,input.reviewedSource,input.tender?.ikn) : null; + if(!job.sourcePinned) { + const text=reviewed?.text ?? input.text; + if(typeof text!=='string'||text.trim().length<40)throw new Error('Analiz için yeterli metin yok (en az 40 karakter).'); + input={...input,text,fileName:reviewed?.fileName || input.fileName, + extraction:reviewed ? {fileName:reviewed.fileName,complete:true,ocr:false} : input.extraction, + humanReview:reviewed?.humanReview || null}; + if(!pin(job.id,job.runId,input))throw new Error('İş artık etkin değil; kaynak kaydedilemedi.'); + } + if(reviewed && reviewed.humanReview.textHash!==input.humanReview?.textHash)throw new Error('İnceleme değişti; güncel kaynakla yeni analiz başlatın.'); + const out=await analyze({text:input.text,fileName:input.fileName,extraction:input.extraction,tender:input.tender, + chain:input.chain,mode:input.mode==='derin'?'derin':undefined,signal}); + signal?.throwIfAborted(); + if(reviewed) { + const latest=reviewSource(job.email,input.reviewedSource,input.tender?.ikn); + if(latest.humanReview.textHash!==input.humanReview.textHash)throw new Error('İnceleme analiz sırasında değişti.'); + out.result.sourceReview={...out.result.sourceReview,humanReview:input.humanReview}; + } + return {success:true,...out,analyzedAt:new Date().toISOString()}; +} diff --git a/server/index.js b/server/index.js index fb8faeb..6fd59cd 100644 --- a/server/index.js +++ b/server/index.js @@ -1,3 +1,4 @@ +import { executeAnalysisJob } from './analysis-job.mjs'; import { resolveAiJobInput } from './ai-job-source.mjs'; import * as aiJobs from './ai-job-store.mjs'; import { startAiJobRunner } from './ai-job-runner.mjs'; @@ -689,6 +690,7 @@ async function tendersPayload(force = false) { const aiJobRunner = startAiJobRunner(async (job,{signal}) => { if(!aiConfigured())throw new Error('AI hizmeti kullanılamıyor. Ayarlar düzeltildikten sonra yeniden başlatın.'); + if(job.operation==='analiz')return executeAnalysisJob(job,{analyze:analyzeSpec,reviewSource:reviewedAnalysisSource,pin:aiJobs.pinAiJobInput,signal}); const body=await resolveAiJobInput(job,ihaleMetni,aiJobs.pinAiJobInput,signal,input=>gereklilikMetni(input.tender,input.gereklilikler,'',true)); const shared={signal,ikn:body.ikn,tender:body.tender,chain:body.chain,text:body.text,resolvedSource:body.resolvedSource}; const result=job.operation==='belge-sor' ? await belgeyeSor({...shared,soru:body.soru}) diff --git a/src/components/AiJobHistory.jsx b/src/components/AiJobHistory.jsx index 2d2d6d3..1e9f452 100644 --- a/src/components/AiJobHistory.jsx +++ b/src/components/AiJobHistory.jsx @@ -1,8 +1,8 @@ import React,{useEffect,useState,useRef} from 'react'; import {listAiJobs,getAiJob,cancelAiJob,retryAiJob} from '../services/aiJobs'; const labels={queued:'Sırada',running:'İşleniyor',completed:'Tamamlandı',failed:'Hata',interrupted:'Kesildi',cancelled:'İptal edildi'}; -const operations={'belge-sor':'Belge sorusu','teklif-denetim':'Taslak denetimi','teklif-taslak':'Taslak üretimi'}; -export function AiJobHistory({ikn,onOpen,revision,locked=false}) { +const operations={'analiz':'Şartname analizi','belge-sor':'Belge sorusu','teklif-denetim':'Taslak denetimi','teklif-taslak':'Taslak üretimi'}; +export function AiJobHistory({ikn,onOpen,revision,locked=false,operationFilter='offer'}) { const [jobs,setJobs]=useState([]),[error,setError]=useState(null),[busy,setBusy]=useState(false); const alive=useRef(true); useEffect(()=>{alive.current=true;return()=>{alive.current=false;};},[]); @@ -10,8 +10,8 @@ export function AiJobHistory({ikn,onOpen,revision,locked=false}) { if(busy)return;setBusy(true);setError(null); try {await fn();}catch(e){if(alive.current)setError(e.message);}finally {if(alive.current)setBusy(false);} }; - const refresh=async()=>{const data=await listAiJobs(ikn);if(!Array.isArray(data.jobs))throw new Error('İş listesi alınamadı.');if(alive.current)setJobs(data.jobs);}; - useEffect(()=>{void act(refresh);},[ikn,revision]); + const refresh=async()=>{const data=await listAiJobs(ikn);if(!Array.isArray(data.jobs))throw new Error('İş listesi alınamadı.');if(alive.current)setJobs(data.jobs.filter(job=>operationFilter==='offer'?job.operation!=='analiz':job.operation===operationFilter));}; + useEffect(()=>{void act(refresh);},[ikn,revision,operationFilter]); return
Kayıtlı AI işleri

Ekran kapansa da sunucudaki işler devam eder. Son 50 iş gösterilir. Kesilen işi yeniden başlatmak sağlayıcıya yeni istek gönderir. Eski sonucu açmak asistanın mevcut metinlerini değiştirir.

@@ -22,7 +22,7 @@ export function AiJobHistory({ikn,onOpen,revision,locked=false}) { diff --git a/src/components/AiSpecAnalyzerModal.jsx b/src/components/AiSpecAnalyzerModal.jsx index 40fba80..18fa959 100644 --- a/src/components/AiSpecAnalyzerModal.jsx +++ b/src/components/AiSpecAnalyzerModal.jsx @@ -1,3 +1,5 @@ +import { AiJobHistory } from './AiJobHistory'; +import {getToken} from '../services/auth'; import React, { useState, useEffect } from 'react'; import { useApp } from '../context/AppContext'; import { Sparkles, Upload, FileText, CheckCircle2, AlertTriangle, X, Loader2, TrendingUp, Sliders, Info } from 'lucide-react'; @@ -10,13 +12,14 @@ const tl = (n) => (typeof n === 'number' && isFinite(n) ? '₺' + n.toLocaleStri export const AiSpecAnalyzerModal = () => { const { isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen, - analyzeSpecPdfWithGemini, aiAnalysisResults, aiAnalyzing, activeAiModel, activeTenderDetail, + analyzeSpecPdfWithGemini, openSavedAnalysis, authUser, aiAnalysisResults, aiAnalyzing, activeAiModel, activeTenderDetail, } = useApp(); const [isAnalyzing, setIsAnalyzing] = useState(false); const [selectedFile, setSelectedFile] = useState(null); const [error, setError] = useState(null); const [progress, setProgress] = useState(''); + const [restoredSource,setRestoredSource]=useState(null); const [customBid, setCustomBid] = useState(null); const [aiCfg, setAiCfg] = useState(null); // Derin mod: ince detaylar, belge kontrol listesi ve yol haritası da üretilir. @@ -32,11 +35,11 @@ export const AiSpecAnalyzerModal = () => { // Analiz doküman panelinden de başlatılabiliyor — o durumda isAnalyzing false // kalır, bu yüzden context'teki ortak sinyalle birleştiriyoruz. const mesgul = isAnalyzing || aiAnalyzing; - const results = aiAnalysisResults; + const results = !activeTenderDetail || aiAnalysisResults?.tenderKey===(activeTenderDetail.ikn || activeTenderDetail.id) ? aiAnalysisResults : null; const bid = results?.bidAnalysis || {}; const runAnalysis = async (text, fileName) => { - setError(null); + setError(null);setRestoredSource(null); setIsAnalyzing(true); setSelectedFile(fileName); try { @@ -53,7 +56,7 @@ export const AiSpecAnalyzerModal = () => { }; const handleFile = async (file) => { - setError(null); + setError(null);setRestoredSource(null); setIsAnalyzing(true); setSelectedFile(file.name); try { @@ -117,6 +120,11 @@ export const AiSpecAnalyzerModal = () => {
+ {const result=await openSavedAnalysis(job);setRestoredSource({text:job.input.text,tenderKey:result.tenderKey});setSelectedFile(result.fileName || null);setCustomBid(result.bidAnalysis?.recommendedBid ?? null);setError(null);}} /> + {results && restoredSource?.tenderKey===results.tenderKey &&
Kaydedilen analiz kaynak metni
{restoredSource.text || 'Bu eski kayıtta kaynak metni bulunmuyor.'}
} + {mesgul&&

Analiz sunucuda sürüyor. Bu ekranı kapatabilirsiniz; sonucu Kayıtlı AI işleri bölümünden açabilirsiniz.

} {/* AI yapılandırılmamış uyarısı */} {aiCfg && !aiCfg.configured && ( diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index a1d53e5..313db72 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -159,6 +159,7 @@ export const AppProvider = ({ children }) => { // Giriş yapınca kullanıcı verisini SUNUCUDAN yükle (localStorage'ı ezer) useEffect(() => { + aiRunRef.current=null;setAiAnalyzing(false); setVeriSenkron(false); setPlan(null); setSaveError(null); setFavoriteKurumlar([]); setTeamMembers([]); setSavedFilters([]); setCompetitorWatchlist([]); setReminderSettings(defaultReminder()); setUserProfile(defaultProfile(authUser)); @@ -336,6 +337,7 @@ export const AppProvider = ({ children }) => { // Analiz modal dışından (ör. doküman panelindeki derin analiz düğmesi) da // tetiklenebiliyor; modalın beklerken boş görünmemesi için ortak sinyal. const [aiAnalyzing, setAiAnalyzing] = useState(false); + const aiRunRef=useRef(null); // Enterprise Module States // Rakip izleme listesi — kullanıcı gerçek kazanan firma analizinden ekler (hesaba özel sunucu kaydı) @@ -441,35 +443,38 @@ export const AppProvider = ({ children }) => { const getModelById = (id) => AI_MODEL_CATALOG.find(m => m.id === id); const activeAiModel = getModelById(aiModelConfig.chain[0]) || AI_MODEL_CATALOG[0]; - // Gerçek AI analizi — backend /api/ai/analyze üzerinden, admin panelde + // Gerçek AI analizi — kalıcı sunucu işi ü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, reviewedSource } = {}) => { const session = getToken(); + if(aiRunRef.current?.session===session)throw new Error('Bir analiz zaten sürüyor. Kayıtlı işlerden durumunu kontrol edin.'); + const run={session};aiRunRef.current=run; setAiAnalyzing(true); setAiAnalysisResults(null); try { const { analyzeSpecText } = await import('../services/ai'); const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain, mode, extraction, reviewedSource }); - const result = { - ...data.result, - tenderKey: tender?.ikn || tender?.id || null, - mode: data.mode || (mode === 'derin' ? 'derin' : 'standart'), - fileName, - model: `${data.model.label} (${data.model.provider})`, - modelId: data.model.model, - fallbackChain: aiModelConfig.chain.map((id) => getModelById(id)?.model).filter(Boolean), - attempts: data.attempts, - analyzedAt: new Date(data.analyzedAt).toLocaleString('tr-TR'), - }; - if(getToken() !== session) throw new Error('Oturum değişti; analiz sonucu bu hesaba aktarılmadı.'); + const {analysisView}=await import('../services/ai'); + const result=analysisView(data,{tender,mode,fileName}); + if(getToken() !== session || aiRunRef.current!==run) throw new Error('Oturum değişti; analiz sonucu bu hesaba aktarılmadı.'); setAiAnalysisResults(result); return result; } finally { - setAiAnalyzing(false); + if(aiRunRef.current===run){aiRunRef.current=null;setAiAnalyzing(false);} } }; + const openSavedAnalysis = async job => { + const session=getToken(); + if(job?.operation!=='analiz'||job.state!=='completed')throw new Error('Tamamlanmış analiz seçin.'); + const {analysisView}=await import('../services/ai'); + const result=analysisView(job.result,job.input); + if(getToken()!==session)throw new Error('Oturum değişti.'); + setAiAnalysisResults(result); + return result; + }; + const [userProfile, setUserProfile] = useState(() => defaultProfile(getStoredUser())); useEffect(() => { const veri = { @@ -777,6 +782,7 @@ export const AppProvider = ({ children }) => { setUrunAramaBaslangic, setIsAiSpecAnalyzerOpen, analyzeSpecPdfWithGemini, + openSavedAnalysis, aiAnalysisResults, aiAnalyzing, competitorWatchlist, diff --git a/src/services/ai.js b/src/services/ai.js index 03743f3..b5f328b 100644 --- a/src/services/ai.js +++ b/src/services/ai.js @@ -1,7 +1,8 @@ /** * Gerçek AI şartname analizi istemcisi (backend /api/ai üzerinden). */ -import { getToken } from './auth'; +import { getToken } from './auth.js'; +import {runDurableAiJob} from './aiJobs.js'; export async function getAiConfig() { try { @@ -13,19 +14,16 @@ export async function getAiConfig() { } } -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, reviewedSource }), - }); - const data = await res.json().catch(() => ({})); - if (!data.success) { - const proxyMesaj = { - 413: 'Doküman metni sunucu sınırını aşıyor; daha küçük bir dosya veya yalnızca ilgili sayfaları deneyin.', - 504: 'Analiz zaman aşımına uğradı; tekrar deneyin (derin analiz 1-2 dk sürebilir).', - }[res.status]; - throw new Error(data.error || proxyMesaj || `Analiz başarısız (HTTP ${res.status})`); - } - return data; +export async function analyzeSpecText(input) { + const session=getToken(); + const check=()=>{if(getToken()!==session)throw new Error('Oturum değişti; kayıtlı analiz bu hesaba aktarılmadı.');}; + return runDurableAiJob('analiz',input,{check}); +} + +export function analysisView(data,input) { + if(data?.success!==true || !data.result || typeof data.result!=='object' || Array.isArray(data.result) || !data.model?.label || !data.model?.provider || !Number.isFinite(Date.parse(data.analyzedAt)))throw new Error('Kayıtlı analiz sonucu geçersiz.'); + return {...data.result,tenderKey:input.tender?.ikn || input.tender?.id || null, + mode:data.mode || (input.mode==='derin'?'derin':'standart'),fileName:input.fileName, + model:`${data.model.label} (${data.model.provider})`,modelId:data.model.model, + attempts:data.attempts,analyzedAt:new Date(data.analyzedAt).toLocaleString('tr-TR')}; } diff --git a/src/services/aiJobs.js b/src/services/aiJobs.js index 42d71ab..0d229d0 100644 --- a/src/services/aiJobs.js +++ b/src/services/aiJobs.js @@ -1,6 +1,6 @@ import {accountRequest} from './accountRequest.js'; import {awaitWithAbort} from './abortable.js'; -export const listAiJobs=ikn=>accountRequest('/api/me/ai-jobs?'+new URLSearchParams({ikn})); +export const listAiJobs=ikn=>accountRequest('/api/me/ai-jobs?'+new URLSearchParams(ikn?{ikn}:{})); export const getAiJob=id=>accountRequest('/api/me/ai-jobs?'+new URLSearchParams({id})); export const cancelAiJob=id=>accountRequest('/api/me/ai-jobs?'+new URLSearchParams({id}),{method:'DELETE'}); export const retryAiJob=id=>accountRequest('/api/me/ai-jobs?'+new URLSearchParams({id}),{method:'POST',body:JSON.stringify({action:'retry'})}); diff --git a/tests/ai-jobs.test.mjs b/tests/ai-jobs.test.mjs index 4adb2c2..6c95834 100644 --- a/tests/ai-jobs.test.mjs +++ b/tests/ai-jobs.test.mjs @@ -83,3 +83,11 @@ test('cancel during source fetch rejects late source, and empty resolution remai assert.deepEqual(await resolveAiJobInput(retry,()=>assert.fail('must not refetch'),jobs.pinAiJobInput),input); jobs.cancelAiJob('source-owner',empty.id); }); + +test('general analysis jobs are indexed by tender for recovery',()=>{ + const job=jobs.createAiJob('analysis-owner','request-analysis','analiz',{tender:{ikn:'2026/777'},text:'Analiz kaynağı '.repeat(5)}); + assert.equal(job.ikn,'2026/777'); + assert.equal(jobs.listAiJobs('analysis-owner','2026/777')[0].id,job.id); + assert.equal(jobs.listAiJobs('analysis-owner','2026/778').length,0); + jobs.cancelAiJob('analysis-owner',job.id); +}); diff --git a/tests/analysis-job.test.mjs b/tests/analysis-job.test.mjs new file mode 100644 index 0000000..01f14ee --- /dev/null +++ b/tests/analysis-job.test.mjs @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {executeAnalysisJob} from '../server/analysis-job.mjs'; +const input={text:'Kaynak metni '.repeat(10),tender:{ikn:'2026/1'},mode:'derin'}; +const output=()=>({result:{summary:'Analiz'},model:{label:'QA',provider:'test'}}); +test('general analysis pins source and passes cancellation signal to provider',async()=>{ + let pinned,request;const signal=new AbortController().signal; + const result=await executeAnalysisJob({id:'a',runId:'r',input},{signal,pin:(id,runId,data)=>{pinned=data;return true;},analyze:async data=>{request=data;return output();}}); + assert.equal(pinned.text,input.text);assert.equal(request.signal,signal);assert.equal(request.mode,'derin');assert.equal(result.success,true); + assert.ok(Number.isFinite(Date.parse(result.analyzedAt))); + await assert.rejects(executeAnalysisJob({input:{text:'short'}},{pin:()=>assert.fail(),analyze:()=>assert.fail()}),/en az 40/); +}); +test('reviewed analysis uses owner source and refuses changed review before or after provider',async()=>{ + const reviewed={text:'Sunucu inceleme metni '.repeat(5),fileName:'checked.pdf',humanReview:{textHash:'A'}}; + const job={id:'a',runId:'r',email:'owner',input:{...input,reviewedSource:{documentId:'doc',revision:1},humanReview:{textHash:'forged'}}}; + let pinned,calls=0; + await assert.rejects(executeAnalysisJob(job,{pin:(id,runId,data)=>{pinned=data;return true;},reviewSource:(email,ref,key)=>{ + assert.equal(email,'owner');assert.equal(key,'2026/1');calls++;return calls===1?reviewed:{...reviewed,humanReview:{textHash:'B'}}; + },analyze:async request=>{assert.equal(request.text,reviewed.text);return output();}}),/analiz sırasında değişti/); + assert.equal(pinned.humanReview.textHash,'A'); + await assert.rejects(executeAnalysisJob({...job,sourcePinned:true,input:pinned},{reviewSource:()=>({...reviewed,humanReview:{textHash:'B'}}),analyze:()=>assert.fail('stale source must not run')}),/İnceleme değişti/); +}); +test('cancelled source pin prevents provider call and cancellation after provider rejects result',async()=>{ + await assert.rejects(executeAnalysisJob({input},{pin:()=>false,analyze:()=>assert.fail()}),/artık etkin değil/); + const controller=new AbortController(); + await assert.rejects(executeAnalysisJob({input},{signal:controller.signal,pin:()=>true,analyze:async()=>{controller.abort();return output();}}),{name:'AbortError'}); +});