fix: validate nested analysis fields before merging and preparation

This commit is contained in:
2026-09-06 09:10:18 +03:00
parent 144724484c
commit 2aaf56ce8b
6 changed files with 96 additions and 5 deletions
+8
View File
@@ -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. - 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. - 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. - 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.
+7 -2
View File
@@ -1,4 +1,5 @@
import {qualifyAnalysis} from './analysis-quality.mjs'; import {qualifyAnalysis} from './analysis-quality.mjs';
import {analysisArrayFields,validateAnalysisShape} from './analysis-schema.mjs';
export function splitAnalysisText(text,max=24000) { export function splitAnalysisText(text,max=24000) {
if(!Number.isInteger(max) || max<100)throw new Error('Geçersiz parça boyutu.'); if(!Number.isInteger(max) || max<100)throw new Error('Geçersiz parça boyutu.');
const source=String(text || ''),parts=[]; const source=String(text || ''),parts=[];
@@ -11,13 +12,17 @@ export function splitAnalysisText(text,max=24000) {
} }
return parts; return parts;
} }
const arrayFields=['qualificationCriteria','criticalRequirements','risksAndPenalties','disqualificationRisks','criticalDetails','itemsAndSpecs','documentChecklist','deadlines','costDrivers','roadmap','questionsForAdministration']; const arrayFields=analysisArrayFields;
export async function analyzeInParts(input,runPart) { 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) {
await Promise.all(parts.slice(i,i+2).map(async part=>{ await Promise.all(parts.slice(i,i+2).map(async part=>{
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)});} catch(error){failures.push({part,error:String(error.message || error)});}
})); }));
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import { verifyAnalysisCitations } from './analysis-citations.mjs'; import { verifyAnalysisCitations } from './analysis-citations.mjs';
import { validateAnalysisShape } from './analysis-schema.mjs';
import {createHash} from 'node:crypto'; import {createHash} from 'node:crypto';
export function qualifyAnalysis(result,{text='',limit=60000,fileName='',expectedItems=0,repaired=false}={}) { 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.'); 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.`); 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'; 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(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(repaired)issues.push('Analiz yanıtı kesildi; yalnız kurtarılabilen alanlar gösteriliyor.');
if(expectedItems>0 && (clean.itemsAndSpecs?.length || 0)<expectedItems)issues.push(`Kaynakta ${expectedItems} kalem var; analiz listesi daha kısa.`); if(expectedItems>0 && (clean.itemsAndSpecs?.length || 0)<expectedItems)issues.push(`Kaynakta ${expectedItems} kalem var; analiz listesi daha kısa.`);
+50
View File
@@ -0,0 +1,50 @@
export const analysisArrayFields = ['qualificationCriteria','criticalRequirements','risksAndPenalties','disqualificationRisks','criticalDetails','itemsAndSpecs','documentChecklist','deadlines','costDrivers','roadmap','questionsForAdministration'];
const stringLists = new Set(['qualificationCriteria','criticalRequirements','disqualificationRisks','costDrivers','questionsForAdministration']);
const shapes = {
itemsAndSpecs: ['name','quantity','note'], documentChecklist: ['document','note'],
risksAndPenalties: ['level','clause','text'], criticalDetails: ['level','title','where','detail','whyItMatters'],
deadlines: ['label','value','note'], roadmap: ['phase','action','owner','timing','output'],
};
const object = v => 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;
}
+11 -1
View File
@@ -1,6 +1,16 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import {splitAnalysisText,analyzeInParts} from '../server/analysis-parts.mjs'; 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',()=>{ test('long Turkish source is covered once without loss, including final paragraph',()=>{
const text=('İğdır laboratuvar 🧪\n').repeat(7000)+'SON MADDE'; const text=('İğdır laboratuvar 🧪\n').repeat(7000)+'SON MADDE';
const parts=splitAnalysisText(text);assert.ok(parts.length>1); 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()=>{ test('all source parts contribute requirements, failures remain explicit partial analysis',async()=>{
const text='a'.repeat(60000); 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.documentChecklist.length,3);assert.equal(complete.result.sourceReview.analyzedCharacters,60000);
assert.equal(complete.result.sourceReview.status,'review_required');assert.equal(complete.result.bidAnalysis,null); 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'}]}};}); 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'}]}};});
+18 -1
View File
@@ -1,8 +1,25 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import {qualifyAnalysis} from '../server/analysis-quality.mjs'; 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',()=>{ 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.status,'partial');assert.equal(r.sourceReview.issues.length,3);
assert.equal(r.sourceReview.analyzedCharacters,50); assert.equal(r.sourceReview.analyzedCharacters,50);
assert.equal(r.sourceReview.sections.documentChecklist,'not_extracted'); assert.equal(r.sourceReview.sections.documentChecklist,'not_extracted');