diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 26bbe70..d2eb876 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -461,3 +461,13 @@ Ekran, modele göre tamamlanmayan bölümleri ve eşleşmeyen ek bölümleri lis Doğrulama: eksik bölüm, doğru sıra, 12.000 karakterden uzun içerik ve son koşul, fazladan başlık, yinelenen/bozuk bölüm için dört yeni test. Tam test ve build sonuçları aşağıdaki commit öncesi kontrolle doğrulandı. Gerçek sağlayıcı bölüm üretimi ve tarayıcı kabulü halen açık; ortak gereklilik kaynağının kapsam sınırları bu değişiklikle kapanmış sayılmaz. Sonuç: 99 test geçti; üretim derlemesi başarılı. Büyük paket uyarıları sürüyor. Canlı yayın yapılmadı. + +### D06 / D09 — İstemci AI yanıt sözleşmesi ve bekleme sınırı + +İstemci JSON ayrıştırma hatasını `{}` yaparak başarılı HTTP yanıtını kabul ediyordu. Boş/HTML/null yanıtları ve bozuk iç alanlar artık hata verir. Belgeye Sor kaynakları, denetim durumları/sayısal oranları ve taslak bölümleri/inceleme bilgileri ekrana aktarılmadan doğrulanır. Bu kontrol render sırasında `.map`, `.split`, durum renk tablosu veya nesne metni nedeniyle ekranın çökmesini önler. Başarısız yanıt önceki kullanıcı çalışmasının yerine atanmaz. + +AI HTTP isteği ve yanıt okumasına 120 saniyelik istemci zaman aşımı eklendi; süre dolunca hata gösterilir ve çalışma düğmeleri yeniden kullanılabilir. Bu, sunucudaki sağlayıcı işini iptal ettiğini veya kalıcı iş kuyruğu sağladığını göstermez. Belge envanteri ve OCR işlemleri bu HTTP süresinin dışında; bunların ayrı süre/iptal denetimleri halen kapsam içindedir. + +Doğrulama: HTML/null/boş yanıt, geçersiz iç durum, askıda istek ve üç gerçek sunucu doğrulayıcısının ürettiği yanıtın istemci sözleşmesiyle uyumu test edildi. Tarayıcıdaki gerçek sağlayıcı arızası kabulü ayrıca açık. + +Sonuç: Tüm 103 test ve üretim derlemesi geçti. Büyük paket uyarıları devam ediyor. Canlı yayın yapılmadı; tüm plan tamamlanmış değildir. diff --git a/src/services/offerAssistant.js b/src/services/offerAssistant.js index 20833c1..65c6ab5 100644 --- a/src/services/offerAssistant.js +++ b/src/services/offerAssistant.js @@ -1,5 +1,6 @@ +import { validateOfferResponse } from './offerResponse.js'; /** 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}) { +export async function requestOfferAssistant({url, body, readToken, isCurrent = () => true, listDocuments, fetcher = fetch, timeoutMs = 120000}) { const token = readToken(); const check = () => { if (!token || readToken() !== token || !isCurrent()) @@ -17,9 +18,16 @@ export async function requestOfferAssistant({url, body, readToken, isCurrent = ( .map(b => ({ad:b.ad, kategori:b.kategori}))}; } check(); - const response = await fetcher(url, {method:'POST', headers:{'Content-Type':'application/json', Authorization:`Bearer ${token}`}, body:JSON.stringify(payload)}); - const data = await response.json().catch(() => ({})); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + 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.'); }); check(); - if (!response.ok || data.success === false) throw new Error(data.error || `HTTP ${response.status}`); - return data; + if (!response.ok || data?.success === false) throw new Error(typeof data?.error === 'string' ? data.error : `HTTP ${response.status}`); + return validateOfferResponse(url, data); + } 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.'); + throw error; + } finally { clearTimeout(timer); } } diff --git a/src/services/offerResponse.js b/src/services/offerResponse.js new file mode 100644 index 0000000..8d5bf63 --- /dev/null +++ b/src/services/offerResponse.js @@ -0,0 +1,25 @@ +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const text = value => typeof value === 'string'; +const texts = value => Array.isArray(value) && value.every(text); +const optionalText = value => value == null || text(value); +export function validateOfferResponse(url, data) { + let valid = object(data) && data.success === true; + if (valid && url === '/api/ai/belge-sor') { + valid = text(data.cevap) && Boolean(data.cevap.trim()) && Array.isArray(data.kaynaklar) + && data.kaynaklar.every(row => object(row) && Number.isInteger(row.n) && row.n > 0 && text(row.alinti)) + && optionalText(data.kaynak) && optionalText(data.kapsam) + && (data.sourceReview == null || (object(data.sourceReview) && Number.isInteger(data.sourceReview.totalChunks) && Number.isInteger(data.sourceReview.selectedChunks))); + } else if (valid && url === '/api/ai/teklif-denetim') { + valid = Array.isArray(data.maddeler) && data.maddeler.length > 0 + && data.maddeler.every(row => object(row) && text(row.gereklilik) && ['karsilaniyor','eksik','belirsiz'].includes(row.durum) && optionalText(row.kanit) && optionalText(row.oneri)) + && texts(data.eksikler) && Number.isFinite(data.uyum_yuzde) && data.uyum_yuzde >= 0 && data.uyum_yuzde <= 100 + && Number.isInteger(data.belirsizSayisi) && data.belirsizSayisi >= 0 && optionalText(data.ozet) && optionalText(data.kapsam); + } else if (valid && url === '/api/ai/teklif-taslak') { + valid = Array.isArray(data.bolumler) && data.bolumler.length > 0 + && data.bolumler.every(row => object(row) && text(row.baslik) && row.baslik.trim() && text(row.icerik) && row.icerik.trim()) + && object(data.draftReview) && ['partial','draft'].includes(data.draftReview.status) + && texts(data.draftReview.requestedSections) && texts(data.draftReview.missingSections) && texts(data.draftReview.extraSections) && text(data.draftReview.note); + } + if (!valid) throw new Error('Sunucu yanıtı eksik veya beklenen biçimde değil. Önceki çalışmanız korundu; yeniden deneyin.'); + return data; +} diff --git a/tests/offer-assistant.test.mjs b/tests/offer-assistant.test.mjs index 88d329c..a49dd17 100644 --- a/tests/offer-assistant.test.mjs +++ b/tests/offer-assistant.test.mjs @@ -34,3 +34,12 @@ test('late answer is rejected after account or active tender changes',async()=>{ fetcher:async()=>{if(change==='account')token='second';else mounted=false;return Response.json({cevap:'Private answer'});}}),/Oturum veya ihale değişti/); } }); +test('HTML, null and incomplete success responses cannot replace a valid existing result',async()=>{ + for(const response of [new Response('Gateway'),Response.json(null),Response.json({}),Response.json({success:true,maddeler:[{durum:'unknown'}]})]) { + await assert.rejects(requestOfferAssistant({url:'/api/ai/teklif-denetim',body,readToken:()=>'first',fetcher:async()=>response})); + } +}); +test('a stalled AI request times out instead of locking the assistant indefinitely',async()=>{ + await assert.rejects(requestOfferAssistant({url:'/api/ai/belge-sor',body,readToken:()=>'first',timeoutMs:5, + fetcher:async(url,{signal})=>new Promise((resolve,reject)=>signal.addEventListener('abort',()=>reject(new DOMException('Aborted','AbortError')),{once:true}))}),/zaman aşımı/); +}); diff --git a/tests/offer-response.test.mjs b/tests/offer-response.test.mjs new file mode 100644 index 0000000..bdf84d0 --- /dev/null +++ b/tests/offer-response.test.mjs @@ -0,0 +1,18 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {validateOfferResponse} from '../src/services/offerResponse.js'; +import {qualifyOfferDraft} from '../server/offer-draft.mjs'; +import {qualifyOfferAudit} from '../server/offer-audit.mjs'; +import {qualifyQuestionAnswer} from '../server/document-question.mjs'; +test('all three server output contracts can be rendered by the client',()=>{ + const draft={success:true,...qualifyOfferDraft({bolumler:[{baslik:'Kapak',icerik:'Taslak metni'}]},['Kapak','Ekler'])}; + const audit={success:true,...qualifyOfferAudit({maddeler:[{gereklilik:'Numune',durum:'eksik'}]},['Numune'],'Taslak')}; + const answer={success:true,...qualifyQuestionAnswer({cevap:'Numune zorunlu [1].'},[{n:1,icerik:'Numune zorunlu.'}])}; + for(const [url,data] of [['teklif-taslak',draft],['teklif-denetim',audit],['belge-sor',answer]]) assert.equal(validateOfferResponse('/api/ai/'+url,data),data); +}); +test('nested fields that would crash the view are rejected at the boundary',()=>{ + for(const [url,data] of [ + ['belge-sor',{cevap:'Yanıt',kaynaklar:{}}], + ['teklif-denetim',{maddeler:[{gereklilik:'Numune',durum:'unexpected'}],eksikler:[],uyum_yuzde:100,belirsizSayisi:0}], + ['teklif-taslak',{bolumler:[{baslik:'Kapak',icerik:{text:'invalid'}}]}]]) assert.throws(()=>validateOfferResponse('/api/ai/'+url,{success:true,...data}),/beklenen biçimde/); +});