Cancel disconnected offer analysis and stop remaining provider calls

This commit is contained in:
2026-09-06 10:22:29 +03:00
parent 673d6c1bc8
commit d534ea20b9
10 changed files with 110 additions and 22 deletions
+10
View File
@@ -525,3 +525,13 @@ Sınırlar: Bölümler arasına yayılan kanıt insan kontrolü gerektirebilir.
Testler: gerçek toplama fonksiyonunda 100 kalem + 80 açık koşul; denetim yürütücüsünde 100 gereklilik, 60.000 karakter sonrasındaki kanıt, bounded çağrı boyutu ve başarısız bölümün belirsizliğe dönüşmesi. Model yanıtları bu testte sentetiktir; gerçek sağlayıcı ve uzun tarayıcı kabulü değildir. Testler: gerçek toplama fonksiyonunda 100 kalem + 80 açık koşul; denetim yürütücüsünde 100 gereklilik, 60.000 karakter sonrasındaki kanıt, bounded çağrı boyutu ve başarısız bölümün belirsizliğe dönüşmesi. Model yanıtları bu testte sentetiktir; gerçek sağlayıcı ve uzun tarayıcı kabulü değildir.
Sonuç: Tüm 114 test ve üretim derlemesi geçti. Büyük paket uyarıları sürüyor. Planın tamamı bitmedi; canlı yayın yapılmadı. Sonuç: Tüm 114 test ve üretim derlemesi geçti. Büyük paket uyarıları sürüyor. Planın tamamı bitmedi; canlı yayın yapılmadı.
### D06 / D09 — Uzun AI denetiminde bağlantı iptali
Önceden istemci zaman aşımına uğrasa veya teklif asistanı kapansa da sunucu kalan denetim bölümlerini ve sağlayıcı yedeklerini çalıştırabiliyordu. Teklif Asistanı bileşeni kapanınca kendi HTTP isteğini iptal eder. Sunucu teklif araçlarında yanıt bağlantısının erken kapanması bir iptal sinyali üretir; normal istek gövdesinin bitmesi iptal sayılmaz.
Sinyal soru/denetim/taslak çağrılarından AI sağlayıcı fetch'lerine taşınır. İptal sonrasında yedek sağlayıcı denenmez; çok parçalı denetim ve genel analiz yeni gruplara devam etmez, iptal kısmi başarı gibi dönmez. Dinleyiciler ve zamanlayıcılar tamamlanmada temizlenir. Sağlayıcıya gönderilmiş işlemin sağlayıcı tarafında ücretlendirilmesinin/hesaplanmasının geri alınması garanti edilmez.
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ı.
+7 -7
View File
@@ -57,7 +57,7 @@ export async function ihaleMetni(ikn, verilen, ilan) {
return { text: '', kaynak: null }; return { text: '', kaynak: null };
} }
export async function belgeyeSor({ ikn, soru, text, tender, chain }) { export async function belgeyeSor({ ikn, soru, text, tender, chain, signal }) {
const q = String(soru ?? '').trim().slice(0, 500); const q = String(soru ?? '').trim().slice(0, 500);
if (q.length < 3) throw new Error('Soru en az 3 karakter olmalı.'); if (q.length < 3) throw new Error('Soru en az 3 karakter olmalı.');
const { text: metin, kaynak } = await ihaleMetni(ikn, text, tender?.aciklama); const { text: metin, kaynak } = await ihaleMetni(ikn, text, tender?.aciklama);
@@ -67,7 +67,7 @@ export async function belgeyeSor({ ikn, soru, text, tender, chain }) {
if (!kaynaklar.length) return { cevap: 'Terim aramasında bu soruyla eşleşen bir bölüm bulunamadı; bu, şartın belgede bulunmadığını kanıtlamaz; belgedeki terimlerle (teminat, teslim, ceza, adet) yeniden deneyin.', kaynaklar: [], kaynak }; if (!kaynaklar.length) return { cevap: 'Terim aramasında bu soruyla eşleşen bir bölüm bulunamadı; bu, şartın belgede bulunmadığını kanıtlamaz; belgedeki terimlerle (teminat, teslim, ceza, adet) yeniden deneyin.', kaynaklar: [], kaynak };
const baglam = kaynaklar.map((k) => `[${k.n}]\n${k.icerik}`).join('\n\n'); const baglam = kaynaklar.map((k) => `[${k.n}]\n${k.icerik}`).join('\n\n');
const sistem = `Sen Türkiye kamu ihalesi uzmanısın. Sana bir ihale dokümanının numaralı parçaları verilecek. YALNIZCA bu parçalardaki bilgiyle yanıt ver; parçalarda yoksa "Seçilen kaynak parçalarında bu bilgi bulunamadı" de ve kontrol edilen parçalara atıf ver, tahmin etme. Her iddianın sonuna dayandığı parça numarasını köşeli parantezle yaz: [1], [3]. Sayı, tarih, tutar, süre değerlerini aynen aktar. Kısa ve net Türkçe. Yalnızca JSON döndür: {"cevap":"..."}`; const sistem = `Sen Türkiye kamu ihalesi uzmanısın. Sana bir ihale dokümanının numaralı parçaları verilecek. YALNIZCA bu parçalardaki bilgiyle yanıt ver; parçalarda yoksa "Seçilen kaynak parçalarında bu bilgi bulunamadı" de ve kontrol edilen parçalara atıf ver, tahmin etme. Her iddianın sonuna dayandığı parça numarasını köşeli parantezle yaz: [1], [3]. Sayı, tarih, tutar, süre değerlerini aynen aktar. Kısa ve net Türkçe. Yalnızca JSON döndür: {"cevap":"..."}`;
const out = await analyzeSpec({ text: `PARÇALAR:\n\n${baglam}\n\nSORU: ${q}\n\nYalnızca {"cevap":"..."} JSON'u döndür.`, tender, chain, systemOverride: sistem }); const out = await analyzeSpec({ text: `PARÇALAR:\n\n${baglam}\n\nSORU: ${q}\n\nYalnızca {"cevap":"..."} JSON'u döndür.`, tender, chain, signal, systemOverride: sistem });
return {...qualifyQuestionAnswer(out?.result, kaynaklar), kaynak, return {...qualifyQuestionAnswer(out?.result, kaynaklar), kaynak,
sourceReview:{sourceHash:createHash('sha256').update(metin).digest('hex'), totalChunks:parcalar.length, selectedChunks:kaynaklar.length}, sourceReview:{sourceHash:createHash('sha256').update(metin).digest('hex'), totalChunks:parcalar.length, selectedChunks:kaynaklar.length},
kapsam:'Yanıt yalnız soruyla eşleşen kaynak parçalarına dayanır. Kaynak numarası kontrolü, yanıtın anlam bakımından doğruluğunu tek başına kanıtlamaz; diğer bölümlerde ek veya çelişen şartlar bulunabilir.'}; kapsam:'Yanıt yalnız soruyla eşleşen kaynak parçalarına dayanır. Kaynak numarası kontrolü, yanıtın anlam bakımından doğruluğunu tek başına kanıtlamaz; diğer bölümlerde ek veya çelişen şartlar bulunabilir.'};
@@ -92,7 +92,7 @@ export function gereklilikMetni(tender, gereklilikler, metin, allItems = false)
return [...new Set(g)].slice(0, allItems ? undefined : 60); return [...new Set(g)].slice(0, allItems ? undefined : 60);
} }
export async function teklifDenetle({ ikn, taslak, gereklilikler, tender, chain, text }) { export async function teklifDenetle({ ikn, taslak, gereklilikler, tender, chain, text, signal }) {
const t = String(taslak ?? '').trim(); const t = String(taslak ?? '').trim();
if (t.length < 200) throw new Error('Taslak en az 200 karakter olmalı.'); if (t.length < 200) throw new Error('Taslak en az 200 karakter olmalı.');
const { text: metin } = await ihaleMetni(ikn, text, tender?.aciklama); const { text: metin } = await ihaleMetni(ikn, text, tender?.aciklama);
@@ -101,9 +101,9 @@ export async function teklifDenetle({ ikn, taslak, gereklilikler, tender, chain,
const sistem = `Sen kamu ihalelerinde teklif dosyası denetçisisin. Gereklilik listesi ve teklif TASLAĞI verilecek. Her gereklilik için taslağı kontrol et. "karsilaniyor" yalnızca taslakta açık karşılığı varsa (alıntıyı 'kanit'e aynen kopyala); değinilmiyorsa "eksik"; kısmen/belirsizse "belirsiz". Sayı üretme. Yalnızca JSON: {"ozet":"2-3 cümle","maddeler":[{"gereklilik":"","durum":"karsilaniyor"|"eksik"|"belirsiz","kanit":"veya null","oneri":"veya null"}]} — maddeleri verilen sırada döndür; gereklilik alanına verilen gereklilik metnini değiştirmeden aynen kopyala.`; const sistem = `Sen kamu ihalelerinde teklif dosyası denetçisisin. Gereklilik listesi ve teklif TASLAĞI verilecek. Her gereklilik için taslağı kontrol et. "karsilaniyor" yalnızca taslakta açık karşılığı varsa (alıntıyı 'kanit'e aynen kopyala); değinilmiyorsa "eksik"; kısmen/belirsizse "belirsiz". Sayı üretme. Yalnızca JSON: {"ozet":"2-3 cümle","maddeler":[{"gereklilik":"","durum":"karsilaniyor"|"eksik"|"belirsiz","kanit":"veya null","oneri":"veya null"}]} — maddeleri verilen sırada döndür; gereklilik alanına verilen gereklilik metnini değiştirmeden aynen kopyala.`;
return auditOfferInParts(liste,t,async (group,draftPart) => { return auditOfferInParts(liste,t,async (group,draftPart) => {
const out = await analyzeSpec({text:`GEREKLİLİKLER:\n${group.map((g,i)=>`${i+1}. ${g}`).join('\n')}\n\nTEKLİF TASLAĞI BÖLÜMÜ:\n${draftPart}\n\nYalnızca JSON döndür.`, const out = await analyzeSpec({text:`GEREKLİLİKLER:\n${group.map((g,i)=>`${i+1}. ${g}`).join('\n')}\n\nTEKLİF TASLAĞI BÖLÜMÜ:\n${draftPart}\n\nYalnızca JSON döndür.`,
tender:tender ? {...tender,items:[]} : undefined,chain,systemOverride:sistem,mode:'derin',_singlePart:true}); tender:tender ? {...tender,items:[]} : undefined,chain,systemOverride:sistem,mode:'derin',signal,_singlePart:true});
return out?.result; return out?.result;
}); },{signal});
} }
const BOLUMLER = { const BOLUMLER = {
@@ -113,7 +113,7 @@ const BOLUMLER = {
tam: ['Teklif Mektubu (Kapak)', 'Firma Tanıtımı ve Yeterlilik', 'Teknik Teklif — Kapsam ve Yaklaşım', 'Ürün / Hizmet Uygunluğu (Şartname Maddeleriyle)', 'Teslim Planı ve Kalite Güvencesi', 'Ticari Teklif — Fiyat Özeti', 'Ödeme ve Teslim Koşulları', 'Teklif Geçerlilik Süresi ve Teminat', 'Ekler'], tam: ['Teklif Mektubu (Kapak)', 'Firma Tanıtımı ve Yeterlilik', 'Teknik Teklif — Kapsam ve Yaklaşım', 'Ürün / Hizmet Uygunluğu (Şartname Maddeleriyle)', 'Teslim Planı ve Kalite Güvencesi', 'Ticari Teklif — Fiyat Özeti', 'Ödeme ve Teslim Koşulları', 'Teklif Geçerlilik Süresi ve Teminat', 'Ekler'],
}; };
export async function teklifTaslak({ ikn, tur, tender, firma, belgeler, fiyat, gereklilikler, chain, text }) { export async function teklifTaslak({ ikn, tur, tender, firma, belgeler, fiyat, gereklilikler, chain, text, signal }) {
const istenen = BOLUMLER[tur] || BOLUMLER.tam; const istenen = BOLUMLER[tur] || BOLUMLER.tam;
const { text: metin } = await ihaleMetni(ikn, text, tender?.aciklama); const { text: metin } = await ihaleMetni(ikn, text, tender?.aciklama);
const liste = gereklilikMetni(tender, gereklilikler, metin); const liste = gereklilikMetni(tender, gereklilikler, metin);
@@ -125,6 +125,6 @@ export async function teklifTaslak({ ikn, tur, tender, firma, belgeler, fiyat, g
fiyat ? `FİYAT VERİSİ: ${typeof fiyat === 'string' ? fiyat : JSON.stringify(fiyat).slice(0, 1500)}` : null, fiyat ? `FİYAT VERİSİ: ${typeof fiyat === 'string' ? fiyat : JSON.stringify(fiyat).slice(0, 1500)}` : null,
].filter(Boolean).join('\n\n'); ].filter(Boolean).join('\n\n');
const sistem = `Sen Türkiye kamu ihalelerinde (4734) deneyimli bir teklif yazarısın. Verilen bilgilerle resmî, kısa ve somut bir teklif dosyası taslağı yaz. Verilmeyen bilgiyi UYDURMA: firma adı, yetkili, tarih, fiyat, süre boşluklarına [DOLDURUN: ...] yaz. Fiyat yalnızca FİYAT VERİSİ varsa yazılır. Ekler bölümünde istenen belgeleri madde madde ver. Üslup resmî; abartı yok. Yalnızca JSON: {"bolumler":[{"baslik":"","icerik":""}]} — istenen bölümleri aynı sırada ver; bölüm başlıklarını değiştirmeden aynen kopyala.`; const sistem = `Sen Türkiye kamu ihalelerinde (4734) deneyimli bir teklif yazarısın. Verilen bilgilerle resmî, kısa ve somut bir teklif dosyası taslağı yaz. Verilmeyen bilgiyi UYDURMA: firma adı, yetkili, tarih, fiyat, süre boşluklarına [DOLDURUN: ...] yaz. Fiyat yalnızca FİYAT VERİSİ varsa yazılır. Ekler bölümünde istenen belgeleri madde madde ver. Üslup resmî; abartı yok. Yalnızca JSON: {"bolumler":[{"baslik":"","icerik":""}]} — istenen bölümleri aynı sırada ver; bölüm başlıklarını değiştirmeden aynen kopyala.`;
const out = await analyzeSpec({ text: `BAĞLAM:\n${baglam}\n\nİSTENEN BÖLÜMLER:\n${istenen.map((x, i) => `${i + 1}. ${x}`).join('\n')}\n\nYalnızca JSON döndür.`, tender, chain, systemOverride: sistem, mode: 'derin' }); const out = await analyzeSpec({ text: `BAĞLAM:\n${baglam}\n\nİSTENEN BÖLÜMLER:\n${istenen.map((x, i) => `${i + 1}. ${x}`).join('\n')}\n\nYalnızca JSON döndür.`, tender, chain, signal, systemOverride: sistem, mode: 'derin' });
return qualifyOfferDraft(out?.result, istenen); return qualifyOfferDraft(out?.result, istenen);
} }
+10 -4
View File
@@ -317,11 +317,12 @@ function kesikJsonOnar(s) {
} }
/** Zinciri sırayla dener; ilk başarılı sonucu döndürür. */ /** Zinciri sırayla dener; ilk başarılı sonucu döndürür. */
export async function analyzeSpec({ text, fileName, tender, chain, systemOverride, mode, extraction, _singlePart = false }) { export async function analyzeSpec({ text, fileName, tender, chain, systemOverride, mode, extraction, signal, _singlePart = false }) {
signal?.throwIfAborted();
if (!_singlePart && String(text || '').length > 24000) { if (!_singlePart && String(text || '').length > 24000) {
return analyzeInParts({text,fileName,tender,mode,extraction}, part => analyzeSpec({ return analyzeInParts({text,fileName,tender,mode,extraction,signal}, part => analyzeSpec({
text:part.text,fileName:`${fileName || 'Doküman'} — bölüm ${part.index+1}`, text:part.text,fileName:`${fileName || 'Doküman'} — bölüm ${part.index+1}`,
tender:tender ? {...tender,items:[]} : undefined,chain,mode,_singlePart:true, tender:tender ? {...tender,items:[]} : undefined,chain,mode,signal,_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.', 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.',
})); }));
} }
@@ -342,6 +343,7 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid
const attempts = []; const attempts = [];
for (const id of order) { for (const id of order) {
signal?.throwIfAborted();
const p = PROVIDERS[id]; const p = PROVIDERS[id];
const route = routeFor(id); const route = routeFor(id);
if (!route) { if (!route) {
@@ -349,6 +351,8 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid
continue; continue;
} }
const ac = new AbortController(); const ac = new AbortController();
const cancel = () => ac.abort();
signal?.addEventListener('abort',cancel,{once:true});
const timer = setTimeout(() => ac.abort(), timeoutMs); const timer = setTimeout(() => ac.abort(), timeoutMs);
try { try {
let raw; let raw;
@@ -361,6 +365,7 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid
} else { } else {
raw = await callOpenAiStyle(p, route.key, route.model, userPrompt, ac.signal, sistem, maxTokens); raw = await callOpenAiStyle(p, route.key, route.model, userPrompt, ac.signal, sistem, maxTokens);
} }
signal?.throwIfAborted();
const parsed = extractJson(raw); const parsed = extractJson(raw);
const result = qualifyAnalysis(parsed,{text,fileName,extraction,limit:derin ? 90000 : 60000, const result = qualifyAnalysis(parsed,{text,fileName,extraction,limit:derin ? 90000 : 60000,
expectedItems:tender?.items?.length || 0,repaired:parsed._outputRepaired === true}); expectedItems:tender?.items?.length || 0,repaired:parsed._outputRepaired === true});
@@ -372,9 +377,10 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid
attempts, attempts,
}; };
} catch (e) { } catch (e) {
signal?.throwIfAborted();
attempts.push({ id, ok: false, yol: route.yol, error: e.name === 'AbortError' ? 'zaman aşımı' : e.message }); attempts.push({ id, ok: false, yol: route.yol, error: e.name === 'AbortError' ? 'zaman aşımı' : e.message });
} finally { } finally {
clearTimeout(timer); clearTimeout(timer);signal?.removeEventListener('abort',cancel);
} }
} }
const err = new Error('Tüm AI sağlayıcıları başarısız oldu: ' + attempts.map((a) => `${a.id}(${a.error})`).join(', ')); const err = new Error('Tüm AI sağlayıcıları başarısız oldu: ' + attempts.map((a) => `${a.id}(${a.error})`).join(', '));
+2
View File
@@ -17,6 +17,7 @@ export async function analyzeInParts(input,runPart) {
const parts=splitAnalysisText(input.text),results=[],failures=[]; const parts=splitAnalysisText(input.text),results=[],failures=[];
// Two requests at a time bounds provider pressure while processing every part. // Two requests at a time bounds provider pressure while processing every part.
for(let i=0;i<parts.length;i+=2) { for(let i=0;i<parts.length;i+=2) {
input.signal?.throwIfAborted();
await Promise.all(parts.slice(i,i+2).map(async part=>{ await Promise.all(parts.slice(i,i+2).map(async part=>{
try { try {
const out=await runPart(part); const out=await runPart(part);
@@ -26,6 +27,7 @@ export async function analyzeInParts(input,runPart) {
catch(error){failures.push({part,error:String(error.message || error)});} catch(error){failures.push({part,error:String(error.message || error)});}
})); }));
} }
input.signal?.throwIfAborted();
results.sort((a,b)=>a.part.index-b.part.index); results.sort((a,b)=>a.part.index-b.part.index);
if(!results.length)throw new Error('Dokümanın hiçbir parçası analiz edilemedi.'); if(!results.length)throw new Error('Dokümanın hiçbir parçası analiz edilemedi.');
const merged={},issues=failures.map(f=>`${f.part.index+1}. parça analiz edilemedi.`); const merged={},issues=failures.map(f=>`${f.part.index+1}. parça analiz edilemedi.`);
+5 -2
View File
@@ -1,3 +1,4 @@
import { requestLifetime } from './request-lifetime.mjs';
import {documentReview,saveDocumentReview,reviewedAnalysisSource} from './document-review.mjs'; import {documentReview,saveDocumentReview,reviewedAnalysisSource} from './document-review.mjs';
import { documentMetadataPatch, decodeDocumentUpload } from './document-metadata.mjs'; import { documentMetadataPatch, decodeDocumentUpload } from './document-metadata.mjs';
import { deadlineInfo } from '../src/services/tenderCalendar.js'; import { deadlineInfo } from '../src/services/tenderCalendar.js';
@@ -1457,9 +1458,10 @@ const server = http.createServer(async (req, res) => {
const user = userFromAuthHeader(req.headers['authorization']); const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' }); if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
if (!aiConfigured()) return json(res, 503, { success: false, error: 'AI hizmeti şu anda kullanılamıyor. Daha sonra yeniden deneyin; sorun sürerse uygulama yöneticinize bildirin.' }); if (!aiConfigured()) return json(res, 503, { success: false, error: 'AI hizmeti şu anda kullanılamıyor. Daha sonra yeniden deneyin; sorun sürerse uygulama yöneticinize bildirin.' });
const lifetime = requestLifetime(req,res);
try { try {
const body = await readBody(req, 2 * 1024 * 1024); const body = await readBody(req, 2 * 1024 * 1024);
const ortak = { ikn: body.ikn, tender: body.tender, chain: body.chain, text: body.text }; const ortak = { signal:lifetime.signal, ikn: body.ikn, tender: body.tender, chain: body.chain, text: body.text };
const out = url.pathname === '/api/ai/belge-sor' const out = url.pathname === '/api/ai/belge-sor'
? await belgeyeSor({ ...ortak, soru: body.soru }) ? await belgeyeSor({ ...ortak, soru: body.soru })
: url.pathname === '/api/ai/teklif-denetim' : url.pathname === '/api/ai/teklif-denetim'
@@ -1467,8 +1469,9 @@ const server = http.createServer(async (req, res) => {
: await teklifTaslak({ ...ortak, tur: body.tur, firma: body.firma, belgeler: body.belgeler, fiyat: body.fiyat, gereklilikler: body.gereklilikler }); : await teklifTaslak({ ...ortak, tur: body.tur, firma: body.firma, belgeler: body.belgeler, fiyat: body.fiyat, gereklilikler: body.gereklilikler });
return json(res, 200, { success: true, ...out }); return json(res, 200, { success: true, ...out });
} catch (e) { } catch (e) {
if (lifetime.signal.aborted) return;
return json(res, e.attempts ? 502 : 400, { success: false, error: e.message }); return json(res, e.attempts ? 502 : 400, { success: false, error: e.message });
} } finally { lifetime.dispose(); }
} }
if (url.pathname === '/api/tenders') { if (url.pathname === '/api/tenders') {
+5 -3
View File
@@ -2,7 +2,8 @@ import {splitAnalysisText} from './analysis-parts.mjs';
import {qualifyOfferAudit} from './offer-audit.mjs'; import {qualifyOfferAudit} from './offer-audit.mjs';
/** Audit every supplied requirement against every draft part, without generic AI-schema merging. */ /** Audit every supplied requirement against every draft part, without generic AI-schema merging. */
export async function auditOfferInParts(requirements, draft, run) { export async function auditOfferInParts(requirements, draft, run, {signal} = {}) {
signal?.throwIfAborted();
const groups=[];let current=[],size=0; const groups=[];let current=[],size=0;
for(const requirement of requirements) { for(const requirement of requirements) {
if(requirement.length>8000) throw new Error('Tek bir denetim gerekliliği çok uzun. Kaynak maddeyi ayrı gerekliliklere ayırın.'); if(requirement.length>8000) throw new Error('Tek bir denetim gerekliliği çok uzun. Kaynak maddeyi ayrı gerekliliklere ayırın.');
@@ -11,12 +12,13 @@ export async function auditOfferInParts(requirements, draft, run) {
} }
if(current.length)groups.push(current); if(current.length)groups.push(current);
const parts=splitAnalysisText(draft,10000),jobs=groups.flatMap((group,g)=>parts.map(part=>({group,g,part}))),results=[]; const parts=splitAnalysisText(draft,10000),jobs=groups.flatMap((group,g)=>parts.map(part=>({group,g,part}))),results=[];
for(let i=0;i<jobs.length;i+=2) await Promise.all(jobs.slice(i,i+2).map(async job=>{ for(let i=0;i<jobs.length;i+=2) { signal?.throwIfAborted(); await Promise.all(jobs.slice(i,i+2).map(async job=>{
try { try {
const output=await run(job.group,job.part.text); const output=await run(job.group,job.part.text);
results.push({...job,review:qualifyOfferAudit(output,job.group,job.part.text)}); results.push({...job,review:qualifyOfferAudit(output,job.group,job.part.text)});
} catch(error){results.push({...job,error:String(error.message || error)});} } catch(error){results.push({...job,error:String(error.message || error)});}
})); })); }
signal?.throwIfAborted();
if(!results.some(result=>result.review))throw new Error('Taslağın hiçbir bölümü denetlenemedi. Yeniden deneyin.'); if(!results.some(result=>result.review))throw new Error('Taslağın hiçbir bölümü denetlenemedi. Yeniden deneyin.');
const merged=[]; const merged=[];
for(const [g,group] of groups.entries()) for(const requirement of group) { for(const [g,group] of groups.entries()) for(const requirement of group) {
+9
View File
@@ -0,0 +1,9 @@
/** Request completion is normal; premature response closure cancels dependent work. */
export function requestLifetime(req, res) {
const controller = new AbortController();
const abort = () => controller.abort();
const closed = () => {if (!res.writableFinished) abort();};
req.once('aborted',abort);res.once('close',closed);
if(req.aborted || res.destroyed) abort();
return {signal:controller.signal,dispose(){req.off('aborted',abort);res.off('close',closed);}};
}
+3 -2
View File
@@ -27,9 +27,10 @@ const OfferAssistantSession = ({ tender }) => {
const live = useRef(true); const live = useRef(true);
const session = useRef(getToken()); const session = useRef(getToken());
const busy = useRef(false); const busy = useRef(false);
useEffect(() => { live.current = true; return () => { live.current = false; }; }, []); const lifetime = useRef(new AbortController());
useEffect(() => { live.current = true; lifetime.current = new AbortController(); return () => { live.current = false; lifetime.current.abort(); }; }, []);
const current = () => live.current && Boolean(session.current) && getToken() === session.current; const current = () => live.current && Boolean(session.current) && getToken() === session.current;
const post = (url, body, documents) => requestOfferAssistant({url, body, readToken:getToken, isCurrent:current, listDocuments:documents}); const post = (url, body, documents) => requestOfferAssistant({url, body, readToken:getToken, isCurrent:current, listDocuments:documents, signal:lifetime.current.signal});
const [arac, setArac] = useState('sor'); const [arac, setArac] = useState('sor');
const [metin, setMetin] = useState(''); // isteğe bağlı: yapıştırılan doküman metni const [metin, setMetin] = useState(''); // isteğe bağlı: yapıştırılan doküman metni
const [sourceName, setSourceName] = useState(''); const [sourceName, setSourceName] = useState('');
+9 -4
View File
@@ -1,8 +1,9 @@
import { validateOfferResponse } from './offerResponse.js'; import { validateOfferResponse } from './offerResponse.js';
/** Bind the entire multi-step request to its starting account and mounted view. */ /** Bind the entire multi-step request to its starting account and mounted view. */
export async function requestOfferAssistant({url, body, readToken, isCurrent = () => true, listDocuments, fetcher = fetch, timeoutMs = 120000}) { export async function requestOfferAssistant({url, body, readToken, isCurrent = () => true, listDocuments, fetcher = fetch, timeoutMs = 120000, signal}) {
const token = readToken(); const token = readToken();
const check = () => { const check = () => {
signal?.throwIfAborted();
if (!token || readToken() !== token || !isCurrent()) if (!token || readToken() !== token || !isCurrent())
throw new Error('Oturum veya ihale değişti. İşlemi güncel ekrandan yeniden başlatın.'); throw new Error('Oturum veya ihale değişti. İşlemi güncel ekrandan yeniden başlatın.');
}; };
@@ -19,7 +20,10 @@ export async function requestOfferAssistant({url, body, readToken, isCurrent = (
} }
check(); check();
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs); const cancel = () => controller.abort();
signal?.addEventListener('abort',cancel,{once:true});
let timedOut=false;
const timer = setTimeout(() => {timedOut=true;controller.abort();}, timeoutMs);
try { try {
const response = await fetcher(url, {signal:controller.signal, method:'POST', headers:{'Content-Type':'application/json', Authorization:`Bearer ${token}`}, body:JSON.stringify(payload)}); const response = await fetcher(url, {signal:controller.signal, method:'POST', headers:{'Content-Type':'application/json', Authorization:`Bearer ${token}`}, body:JSON.stringify(payload)});
const data = await response.json().catch(() => { throw new Error('Sunucudan okunabilir JSON yanıtı alınamadı. Yeniden deneyin.'); }); const data = await response.json().catch(() => { throw new Error('Sunucudan okunabilir JSON yanıtı alınamadı. Yeniden deneyin.'); });
@@ -28,7 +32,8 @@ export async function requestOfferAssistant({url, body, readToken, isCurrent = (
if (!response.ok || data?.success === false) throw new Error(typeof data?.error === 'string' ? data.error : `HTTP ${response.status}`); if (!response.ok || data?.success === false) throw new Error(typeof data?.error === 'string' ? data.error : `HTTP ${response.status}`);
return validateOfferResponse(url, data); return validateOfferResponse(url, data);
} catch (error) { } catch (error) {
if (controller.signal.aborted) throw new Error('AI yanıtı zaman aşımına uğradı. Önceki çalışmanız korundu; yeniden deneyin.'); if (timedOut) throw new Error('AI yanıtı zaman aşımına uğradı. Önceki çalışmanız korundu; yeniden deneyin.');
signal?.throwIfAborted();
throw error; throw error;
} finally { clearTimeout(timer); } } finally { clearTimeout(timer);signal?.removeEventListener('abort',cancel); }
} }
+50
View File
@@ -0,0 +1,50 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import {requestLifetime} from '../server/request-lifetime.mjs';
import {analyzeSpec} from '../server/ai.mjs';
import {auditOfferInParts} from '../server/offer-audit-parts.mjs';
import {requestOfferAssistant} from '../src/services/offerAssistant.js';
test('premature real HTTP response closure aborts work after the request body is complete', {timeout:3000},async()=>{
let ready,aborted;
const received=new Promise(resolve=>ready=resolve), cancelled=new Promise(resolve=>aborted=resolve);
const server=http.createServer((req,res)=>{
const lifetime=requestLifetime(req,res);
lifetime.signal.addEventListener('abort',()=>{lifetime.dispose();aborted();},{once:true});
req.resume();req.on('end',()=>ready(lifetime));
});
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
const client=http.request({hostname:'127.0.0.1',port:server.address().port,method:'POST'});
client.on('error',()=>{});client.end('complete request body');
try {
const lifetime=await received;assert.equal(lifetime.signal.aborted,false);
client.destroy();await cancelled;assert.equal(lifetime.signal.aborted,true);
} finally {client.destroy();await new Promise(resolve=>server.close(resolve));}
});
test('caller cancellation aborts provider fetch and never triggers fallback',async()=>{
const original=globalThis.fetch,old=process.env.GEMINI_API_KEY;
process.env.GEMINI_API_KEY='synthetic-test-only';let calls=0;
const c=new AbortController();
try {
globalThis.fetch=async(url,{signal})=>new Promise((resolve,reject)=>{
calls++;signal.addEventListener('abort',()=>reject(new DOMException('Aborted','AbortError')),{once:true});c.abort();
});
await assert.rejects(analyzeSpec({text:'Sentetik kaynak',chain:['gemini'],signal:c.signal}),{name:'AbortError'});
assert.equal(calls,1);
} finally {globalThis.fetch=original;if(old===undefined)delete process.env.GEMINI_API_KEY;else process.env.GEMINI_API_KEY=old;}
});
test('audit cancellation stops remaining batches and is not returned as a partial result',async()=>{
const c=new AbortController();let calls=0;
await assert.rejects(auditOfferInParts(['Numune'],'x'.repeat(50000),async()=>{
calls++;c.abort();throw new DOMException('Aborted','AbortError');
},{signal:c.signal}),{name:'AbortError'});
assert.ok(calls<=2);
});
test('closing assistant view aborts its pending HTTP fetch',async()=>{
const c=new AbortController();let aborted=false;
await assert.rejects(requestOfferAssistant({url:'/api/ai/belge-sor',body:{},readToken:()=>'test',signal:c.signal,
fetcher:async(url,{signal})=>new Promise((resolve,reject)=>{
signal.addEventListener('abort',()=>{aborted=true;reject(new DOMException('Aborted','AbortError'));},{once:true});c.abort();
})}),{name:'AbortError'});
assert.equal(aborted,true);
});