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
+7 -7
View File
@@ -57,7 +57,7 @@ export async function ihaleMetni(ikn, verilen, ilan) {
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);
if (q.length < 3) throw new Error('Soru en az 3 karakter olmalı.');
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 };
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 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,
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.'};
@@ -92,7 +92,7 @@ export function gereklilikMetni(tender, gereklilikler, metin, allItems = false)
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();
if (t.length < 200) throw new Error('Taslak en az 200 karakter olmalı.');
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.`;
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.`,
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;
});
},{signal});
}
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'],
};
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 { text: metin } = await ihaleMetni(ikn, text, tender?.aciklama);
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,
].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 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);
}
+10 -4
View File
@@ -317,11 +317,12 @@ 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, 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) {
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}`,
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.',
}));
}
@@ -342,6 +343,7 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid
const attempts = [];
for (const id of order) {
signal?.throwIfAborted();
const p = PROVIDERS[id];
const route = routeFor(id);
if (!route) {
@@ -349,6 +351,8 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid
continue;
}
const ac = new AbortController();
const cancel = () => ac.abort();
signal?.addEventListener('abort',cancel,{once:true});
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
let raw;
@@ -361,6 +365,7 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid
} else {
raw = await callOpenAiStyle(p, route.key, route.model, userPrompt, ac.signal, sistem, maxTokens);
}
signal?.throwIfAborted();
const parsed = extractJson(raw);
const result = qualifyAnalysis(parsed,{text,fileName,extraction,limit:derin ? 90000 : 60000,
expectedItems:tender?.items?.length || 0,repaired:parsed._outputRepaired === true});
@@ -372,9 +377,10 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid
attempts,
};
} catch (e) {
signal?.throwIfAborted();
attempts.push({ id, ok: false, yol: route.yol, error: e.name === 'AbortError' ? 'zaman aşımı' : e.message });
} 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(', '));
+2
View File
@@ -17,6 +17,7 @@ export async function analyzeInParts(input,runPart) {
const parts=splitAnalysisText(input.text),results=[],failures=[];
// Two requests at a time bounds provider pressure while processing every part.
for(let i=0;i<parts.length;i+=2) {
input.signal?.throwIfAborted();
await Promise.all(parts.slice(i,i+2).map(async part=>{
try {
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)});}
}));
}
input.signal?.throwIfAborted();
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.');
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 { documentMetadataPatch, decodeDocumentUpload } from './document-metadata.mjs';
import { deadlineInfo } from '../src/services/tenderCalendar.js';
@@ -1457,9 +1458,10 @@ const server = http.createServer(async (req, res) => {
const user = userFromAuthHeader(req.headers['authorization']);
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.' });
const lifetime = requestLifetime(req,res);
try {
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'
? await belgeyeSor({ ...ortak, soru: body.soru })
: 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 });
return json(res, 200, { success: true, ...out });
} catch (e) {
if (lifetime.signal.aborted) return;
return json(res, e.attempts ? 502 : 400, { success: false, error: e.message });
}
} finally { lifetime.dispose(); }
}
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';
/** 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;
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.');
@@ -11,12 +12,13 @@ export async function auditOfferInParts(requirements, draft, run) {
}
if(current.length)groups.push(current);
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 {
const output=await run(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)});}
}));
})); }
signal?.throwIfAborted();
if(!results.some(result=>result.review))throw new Error('Taslağın hiçbir bölümü denetlenemedi. Yeniden deneyin.');
const merged=[];
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);}};
}