From 2aaf56ce8b52bec689a99865e99551816934c6a7 Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 09:10:18 +0300 Subject: [PATCH] fix: validate nested analysis fields before merging and preparation --- docs/quality/uygulama-durumu.md | 8 ++++++ server/analysis-parts.mjs | 9 ++++-- server/analysis-quality.mjs | 3 +- server/analysis-schema.mjs | 50 +++++++++++++++++++++++++++++++++ tests/analysis-parts.test.mjs | 12 +++++++- tests/analysis-quality.test.mjs | 19 ++++++++++++- 6 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 server/analysis-schema.mjs diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 245e8bb..682dcc1 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -169,3 +169,11 @@ SQLite snapshot, referanslı belge kopyası, SHA-256 manifesti, bütünlük kont - Yaklaşan liste, haftalık özet, zeyilname takip haritası ve plan takip sayacı güncel tender_state kayıtlarını okuyor. Eski takip listesi uyumluluk için okunuyor ancak güncel açık takipten çıkarma kararı eski kopyaya üstün geliyor. Havuz dışında kalan takipli kayıtlar saklanan anlık görüntüden yaklaşan listeye katılıyor. - Haftalık özetin HTML/metin çıktısında saat ve Türkiye saati etiketi veya saat doğrulama uyarısı var. Pazartesi 08.00 zamanlama hesabı sunucu yerel saatinden bağımsız. İlan aciliyet günü de ortak tarih hesabını kullanıyor. - Kanıt: 51/51 otomatik test geçti. Yeni testler üç sunucu saat dilimi, İstanbul/UTC gün sınırı, geçersiz tarih, geçmiş saat, eksik saat, takipten çıkarma, havuz dışı kayıt, hesap ayrımı ve haftalık çalışma sınırını doğruluyor. Gerçek e-posta/Telegram/push gönderilmedi; teslim ve yeniden deneme senaryoları hâlâ açık. Resmi takvim içe aktarımı ve tüm zeyilname akışı tamamlanmış sayılmıyor. + +### D06 — AI iç alan şeması ve bozuk parça yalıtımı + +- Analiz listelerinin iç elemanları, metin alanları, belge zorunluluğunun gerçek boolean olması, teknik özellik listeleri, parasal sayı alanları, olasılık sınırı, yol haritası adımı ve kaynak alıntısı nesneleri sunucuda doğrulanıyor. Yanlış türler kullanıcı arayüzüne aktarılmadan hata üretiyor. +- Kalem adı/miktar veya belge adı/zorunluluk bilgisi eksikse kaynak incelemesi `partial` oluyor. Kaynakta bulunmayan miktar veya zorunluluk değeri uydurulmuyor. +- Parçalı analizde şema doğrulaması birleştirmeden önce yapılıyor. Null kalem/alıntı gibi hatalı bir parça tüm birleştirmeyi çökertmiyor; başarısız kaynak aralığı olarak korunuyor. Hiçbir geçerli parça yoksa analiz başarısız oluyor. +- Kanıt: 54/54 test geçti. Yeni regresyonlar hatalı nested alanlar, metin olarak false, eksik miktar/zorunluluk, bozuk parça ve tamamen başarısız belgeyi kapsıyor. Mevcut test örneklerinden iki olumlu fixture kaynak miktarı/zorunluluk bilgisi içerecek şekilde tamamlandı. +- Sınır: Bu şema doğrulaması anlam veya mevzuat doğruluğunu kanıtlamaz. Gerçek sağlayıcı yanıtları, kaynak belgeyle semantik çelişki kontrolü ve uzun işlerin kalıcı devam ettirilmesi hâlâ açık. diff --git a/server/analysis-parts.mjs b/server/analysis-parts.mjs index 430cd50..07ee092 100644 --- a/server/analysis-parts.mjs +++ b/server/analysis-parts.mjs @@ -1,4 +1,5 @@ import {qualifyAnalysis} from './analysis-quality.mjs'; +import {analysisArrayFields,validateAnalysisShape} from './analysis-schema.mjs'; export function splitAnalysisText(text,max=24000) { if(!Number.isInteger(max) || max<100)throw new Error('Geçersiz parça boyutu.'); const source=String(text || ''),parts=[]; @@ -11,13 +12,17 @@ export function splitAnalysisText(text,max=24000) { } return parts; } -const arrayFields=['qualificationCriteria','criticalRequirements','risksAndPenalties','disqualificationRisks','criticalDetails','itemsAndSpecs','documentChecklist','deadlines','costDrivers','roadmap','questionsForAdministration']; +const arrayFields=analysisArrayFields; 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{ - try {results.push({part,out:await runPart(part)});} + try { + const out=await runPart(part); + validateAnalysisShape(out?.result); + results.push({part,out}); + } catch(error){failures.push({part,error:String(error.message || error)});} })); } diff --git a/server/analysis-quality.mjs b/server/analysis-quality.mjs index 5d3940b..898d769 100644 --- a/server/analysis-quality.mjs +++ b/server/analysis-quality.mjs @@ -1,4 +1,5 @@ import { verifyAnalysisCitations } from './analysis-citations.mjs'; +import { validateAnalysisShape } from './analysis-schema.mjs'; import {createHash} from 'node:crypto'; export function qualifyAnalysis(result,{text='',limit=60000,fileName='',expectedItems=0,repaired=false}={}) { if(!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('Analiz nesne biçiminde değil.'); @@ -9,7 +10,7 @@ export function qualifyAnalysis(result,{text='',limit=60000,fileName='',expected if(clean[key] != null && !Array.isArray(clean[key])) throw new Error(`${key} liste biçiminde değil.`); sections[key]=clean[key]?.length ? 'extracted' : 'not_extracted'; } - const source=String(text),issues=[]; + const source=String(text),issues=validateAnalysisShape(clean); if(source.length>limit)issues.push('Kaynak metnin tamamı analiz edilmedi.'); if(repaired)issues.push('Analiz yanıtı kesildi; yalnız kurtarılabilen alanlar gösteriliyor.'); if(expectedItems>0 && (clean.itemsAndSpecs?.length || 0) v && typeof v === 'object' && !Array.isArray(v); +const fail = path => { throw new Error(`Analiz alanının biçimi geçersiz: ${path}`); }; +const text = (v,path) => { if (v != null && typeof v !== 'string') fail(path); }; +const number = (v,path) => { if (v != null && (typeof v !== 'number' || !Number.isFinite(v) || v < 0)) fail(path); }; + +export function validateAnalysisShape(result) { + if (!object(result)) fail('result'); + const issues = []; + for (const field of ['summary','scopeOfWork']) text(result[field],field); + for (const field of analysisArrayFields) { + if (result[field] == null) continue; + if (!Array.isArray(result[field])) fail(field); + result[field].forEach((row,i) => { + const path = `${field}.${i}`; + if (stringLists.has(field)) { if (typeof row !== 'string' || !row.trim()) fail(path); return; } + if (!object(row)) fail(path); + for (const key of shapes[field]) text(row[key],`${path}.${key}`); + if (field === 'itemsAndSpecs') { + if (!row.name?.trim() || !row.quantity?.trim()) issues.push(`${path}: kalem adı veya miktar/birim eksik.`); + if (row.keySpecs != null && (!Array.isArray(row.keySpecs) || row.keySpecs.some(v => typeof v !== 'string'))) fail(`${path}.keySpecs`); + for (const key of ['estimatedUnitPrice','estimatedTotal']) number(row[key],`${path}.${key}`); + } + if (field === 'documentChecklist') { + if (!row.document?.trim() || row.mandatory == null) issues.push(`${path}: belge adı veya zorunluluk bilgisi eksik.`); + if (row.mandatory != null && typeof row.mandatory !== 'boolean') fail(`${path}.mandatory`); + } + if (field === 'roadmap' && row.step != null && (!Number.isInteger(row.step) || row.step < 1)) fail(`${path}.step`); + }); + } + if (result.bidAnalysis != null) { + if (!object(result.bidAnalysis)) fail('bidAnalysis'); + for (const key of ['estimatedCost','recommendedBid','minBidLimit','maxBidLimit','winProbability']) number(result.bidAnalysis[key],`bidAnalysis.${key}`); + if (result.bidAnalysis.winProbability > 100) fail('bidAnalysis.winProbability'); + for (const key of ['competitionLevel','notes']) text(result.bidAnalysis[key],`bidAnalysis.${key}`); + } + if (result.citations != null) { + if (!Array.isArray(result.citations)) fail('citations'); + result.citations.forEach((c,i) => { + if (!object(c) || typeof c.path !== 'string' || typeof c.quote !== 'string') fail(`citations.${i}`); + }); + } + return issues; +} diff --git a/tests/analysis-parts.test.mjs b/tests/analysis-parts.test.mjs index 2228613..1a40c91 100644 --- a/tests/analysis-parts.test.mjs +++ b/tests/analysis-parts.test.mjs @@ -1,6 +1,16 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {splitAnalysisText,analyzeInParts} from '../server/analysis-parts.mjs'; +test('malformed part is isolated before merge and retained as a failed source range',async()=>{ + const out=await analyzeInParts({text:'a'.repeat(30000)},async p=>({result:p.index + ? {itemsAndSpecs:[{name:'Geçerli kit',quantity:'2 adet'}]} + : {itemsAndSpecs:[null],citations:[null]}})); + assert.equal(out.result.itemsAndSpecs.length,1); + assert.equal(out.result.sourceReview.status,'partial'); + assert.equal(out.result.sourceReview.parts[0].status,'failed'); + assert.equal(out.result.sourceReview.analyzedCharacters,6000); + await assert.rejects(()=>analyzeInParts({text:'kaynak'},async()=>({result:{citations:[null]}})),/hiçbir parçası/); +}); test('long Turkish source is covered once without loss, including final paragraph',()=>{ const text=('İğdır laboratuvar 🧪\n').repeat(7000)+'SON MADDE'; const parts=splitAnalysisText(text);assert.ok(parts.length>1); @@ -9,7 +19,7 @@ test('long Turkish source is covered once without loss, including final paragrap }); test('all source parts contribute requirements, failures remain explicit partial analysis',async()=>{ const text='a'.repeat(60000); - const complete=await analyzeInParts({text,fileName:'test'},async p=>({result:{documentChecklist:[{document:`Belge ${p.index}`}],sourceReview:{issues:[]}},attempts:[]})); + const complete=await analyzeInParts({text,fileName:'test'},async p=>({result:{documentChecklist:[{document:`Belge ${p.index}`,mandatory:true}],sourceReview:{issues:[]}},attempts:[]})); assert.equal(complete.result.documentChecklist.length,3);assert.equal(complete.result.sourceReview.analyzedCharacters,60000); assert.equal(complete.result.sourceReview.status,'review_required');assert.equal(complete.result.bidAnalysis,null); const partial=await analyzeInParts({text},async p=>{if(p.index===1)throw new Error('test failure');return {result:{itemsAndSpecs:[{name:'Kit',quantity:p.index?'3 adet':'2 adet'}]}};}); diff --git a/tests/analysis-quality.test.mjs b/tests/analysis-quality.test.mjs index 89fb1fd..c3efb42 100644 --- a/tests/analysis-quality.test.mjs +++ b/tests/analysis-quality.test.mjs @@ -1,8 +1,25 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {qualifyAnalysis} from '../server/analysis-quality.mjs'; +test('malformed nested model fields cannot reach preparation screens',()=>{ + for(const result of [ + {itemsAndSpecs:[null]}, {itemsAndSpecs:[{name:{text:'Kit'}}]}, + {itemsAndSpecs:[{name:'Kit',quantity:'2 adet',keySpecs:'steril'}]}, + {documentChecklist:[{document:'ÜTS',mandatory:'false'}]}, + {criticalRequirements:[{text:'Numune'}]}, {deadlines:'120 gün'}, + {roadmap:[{action:'Hazırla',step:'bir'}]}, + {bidAnalysis:{recommendedBid:'1000'}}, {bidAnalysis:{winProbability:101}}, + {citations:[null]}, {summary:{text:'Analiz'}} + ]) assert.throws(()=>qualifyAnalysis(result), /biçim/); +}); +test('missing item quantity or document obligation keeps analysis partial',()=>{ + const out=qualifyAnalysis({itemsAndSpecs:[{name:'Kit'}],documentChecklist:[{document:'ÜTS'}]}); + assert.equal(out.sourceReview.status,'partial'); + assert.ok(out.sourceReview.issues.some(v=>v.includes('miktar/birim'))); + assert.ok(out.sourceReview.issues.some(v=>v.includes('zorunluluk'))); +}); test('truncated input and repaired output are partial, never complete analysis',()=>{ - const r=qualifyAnalysis({itemsAndSpecs:[{name:'Kit'}]},{text:'a'.repeat(100),limit:50,expectedItems:100,repaired:true}); + const r=qualifyAnalysis({itemsAndSpecs:[{name:'Kit',quantity:'2 adet'}]},{text:'a'.repeat(100),limit:50,expectedItems:100,repaired:true}); assert.equal(r.sourceReview.status,'partial');assert.equal(r.sourceReview.issues.length,3); assert.equal(r.sourceReview.analyzedCharacters,50); assert.equal(r.sourceReview.sections.documentChecklist,'not_extracted');