diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 61a7b8d..3389c70 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -89,7 +89,7 @@ Tarayıcı kanıtı: [Yenileme sonrası beş kart](evidence/qa-kanban-after-relo ## Doğrulama -`npm test`: 42/42 geçti (son yeniden çalıştırma: 6 Eylül 2026). +`npm test`: 43/43 geçti (son yeniden çalıştırma: 6 Eylül 2026). `npm run build`: geçti; mevcut büyük paket/dinamik import uyarıları sürüyor. `node --check server/index.js`, `node --check server/user-store.mjs`, `git diff --check`: geçti. @@ -135,3 +135,10 @@ AI çıktısına kaynak içerik özeti (SHA-256 sürümü), kaynak/işlenen kara 24.000 karakter üzerindeki kaynak artık tamamı kapsanacak biçimde parçalara ayrılır; aynı anda en fazla iki bölüm işlenir. Kaynak aralıkları ve başarısız bölümler raporlanır. Kalemler kaynak bölümüyle korunur; birbirinin aynısı görünen farklı satırlar otomatik silinmez. Belgeler/şartlar birleştirilir; farklı miktarlar çelişki olarak işaretlenir. Parça bazlı fiyat önerileri küresel teklif önerisine taşınmaz. Üç kontrollü test metnin sonuna kadar kayıpsız kapsamayı, başarısız bölüm/çelişkiyi ve aynı görünen ayrı satırların korunmasını doğrular. Canlı AI sağlayıcısı ve üretim proxy zaman aşımı bu testlerde kullanılmadı. Uzun işlemleri sürdürülebilir iş kuyruğuna alma, kopma sonrası devam, gerçek kaynak sayfa/madde eşleştirmesi ve parçalar arasında bölünen tablo bağlamının doğrulanması açık. Önceki eksik analiz uyarısı tek başına çözüm değildi; bu akış işlemeyi ilerletir, D06'nın tümünü kapatmaz. + + +## Kaynak alıntıları + +Modelden bulguya bağlı birebir kaynak alıntısı istenir. Sunucu, bulgu indeksinin mevcut olduğunu ve alıntının gerçekten işlenen metinde yer aldığını denetler; tekil konum, tekrarlı alıntı ve bulunamayan alıntı ayrılır. Uzun analiz birleştirmesi bulgu indekslerini yeniden eşler. Raporda okunabilir bulgu başlığıyla alıntılar ve doğrulanamayan bulgu sayısı gösterilir. Modelin verdiği sayfa numarası doğrulanmış kabul edilmez. + +Bir test mevcut olmayan bulgu, yanlış kaynak, doğru alıntı ve tekrarlı konumu kapsar. Bu özellik anlamsal/hukuki çıkarımı doğrulamaz; belge görüntüsüne sayfa/madde bağlantısı ve gerçek sağlayıcı çıktılarıyla kabul ayrıca bekler. 43 test ve derleme geçti. diff --git a/server/ai.mjs b/server/ai.mjs index 3235e27..6dd571e 100644 --- a/server/ai.mjs +++ b/server/ai.mjs @@ -336,7 +336,7 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid // Derin analiz hem daha çok metin okur hem daha uzun JSON üretir; çıktı // kesilirse JSON parse edilemez ve zincir boşuna tüketilir. const userPrompt = buildUserPrompt({ text, fileName, tender, limit: derin ? 90000 : 60000 }); - const sistem = systemOverride || (derin ? DERIN_SYSTEM_PROMPT : SYSTEM_PROMPT); + const sistem = (systemOverride || (derin ? DERIN_SYSTEM_PROMPT : SYSTEM_PROMPT)) + '\nYanıta citations dizisi ekle: her kritik bulgu, kalem ve belge için {"path":"itemsAndSpecs.0","quote":"kaynak metinden aynen alınan 12-1500 karakterlik dayanak"}. path ilgili çıktı dizisinin sıfır tabanlı indeksidir. Alıntı metnini değiştirme, sayfa veya madde uydurma. Kaynakta dayanak yoksa alıntı üretme.'; const maxTokens = derin ? 12000 : 4000; const timeoutMs = derin ? 150000 : 90000; const attempts = []; diff --git a/server/analysis-citations.mjs b/server/analysis-citations.mjs new file mode 100644 index 0000000..5b73b96 --- /dev/null +++ b/server/analysis-citations.mjs @@ -0,0 +1,17 @@ +const fields=new Set(['itemsAndSpecs','documentChecklist','qualificationCriteria','criticalRequirements','risksAndPenalties','criticalDetails','disqualificationRisks','deadlines','roadmap']); +export function verifyAnalysisCitations(result,source) { + const evidence=[]; + for(const citation of Array.isArray(result.citations)?result.citations:[]) { + const match=/^([A-Za-z]+)\.(\d+)$/.exec(String(citation?.path || '')); + if(!match || !fields.has(match[1]) || !Array.isArray(result[match[1]]) || Number(match[2])>=result[match[1]].length)continue; + const quote=String(citation.quote || '').trim(); + if(quote.length<12 || quote.length>1500)continue; + const start=source.indexOf(quote); + evidence.push({path:citation.path,quote,status:start<0?'not_found':source.indexOf(quote,start+1)>=0?'ambiguous':'located', + start:start<0?null:start,end:start<0?null:start+quote.length}); + } + const missing=[]; + for(const field of fields)for(let i=0;i<(Array.isArray(result[field])?result[field].length:0);i++) + if(!evidence.some(e=>e.path===`${field}.${i}` && e.status==='located'))missing.push(`${field}.${i}`); + return {evidence,missing}; +} diff --git a/server/analysis-parts.mjs b/server/analysis-parts.mjs index aff6cb5..430cd50 100644 --- a/server/analysis-parts.mjs +++ b/server/analysis-parts.mjs @@ -24,16 +24,23 @@ export async function analyzeInParts(input,runPart) { 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.`); + const pathMap=new Map(); for(const field of arrayFields) { - if(field==='itemsAndSpecs') { - // Identical rows may be separate official items; retain them with origin. - merged[field]=results.flatMap(({part,out})=>(out.result[field] || []).map(row=>({...row,sourcePart:part.index+1,sourceRange:{start:part.start,end:part.end}}))); - continue; - } + merged[field]=[]; const entries=new Map(); - for(const {out} of results) for(const value of out.result[field] || [])entries.set(JSON.stringify(value),value); - merged[field]=[...entries.values()]; + for(const {part,out} of results) for(const [i,value] of (out.result[field] || []).entries()) { + const key=JSON.stringify(value); + let index=field==='itemsAndSpecs'?undefined:entries.get(key); + if(index===undefined) { + index=merged[field].length;entries.set(key,index); + merged[field].push(field==='itemsAndSpecs'?{...value,sourcePart:part.index+1,sourceRange:{start:part.start,end:part.end}}:value); + } + pathMap.set(`${part.index}:${field}.${i}`,`${field}.${index}`); + } } + merged.citations=results.flatMap(({part,out})=>(out.result.citations || []).flatMap(c=>{ + const path=pathMap.get(`${part.index}:${c.path}`);return path?[{path,quote:c.quote}]:[]; + })); for(const field of ['summary','scopeOfWork'])merged[field]=results.map(({part,out})=>out.result[field] ? `Bölüm ${part.index+1}: ${out.result[field]}` : '').filter(Boolean).join('\n\n'); // Different part-level prices/strategies cannot be treated as a global bid. merged.bidAnalysis=null; diff --git a/server/analysis-quality.mjs b/server/analysis-quality.mjs index 8c3e2cb..5d3940b 100644 --- a/server/analysis-quality.mjs +++ b/server/analysis-quality.mjs @@ -1,7 +1,9 @@ +import { verifyAnalysisCitations } from './analysis-citations.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.'); const clean={...result};delete clean._outputRepaired; + if(clean.citations != null && !Array.isArray(clean.citations)) throw new Error('Kaynak alıntıları liste biçiminde değil.'); const sections={}; for(const key of ['itemsAndSpecs','documentChecklist','qualificationCriteria','risksAndPenalties','roadmap']) { if(clean[key] != null && !Array.isArray(clean[key])) throw new Error(`${key} liste biçiminde değil.`); @@ -11,8 +13,9 @@ export function qualifyAnalysis(result,{text='',limit=60000,fileName='',expected 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) { const [field,index] = path.split('.'); return `${({itemsAndSpecs:'Kalem',documentChecklist:'Belge',qualificationCriteria:'Yeterlik',criticalRequirements:'Kritik koşul',risksAndPenalties:'Risk',criticalDetails:'Kritik ayrıntı',disqualificationRisks:'Eleme riski',deadlines:'Süre',roadmap:'Hazırlık adımı'})[field] || 'Bulgu'} ${Number(index)+1}`; }; + const dolu = (x) => Array.isArray(x) && x.length > 0; const tl = (n) => @@ -81,6 +83,18 @@ export const DeepAnalysisReport = ({ results, tender: selectedTender }) => {

Boş bir bölüm, ilgili şartın kaynakta bulunmadığı anlamına gelmez.

} + {results.sourceReview?.unverifiedFindings?.length > 0 &&

+ {results.sourceReview.unverifiedFindings.length} bulgunun tekil kaynak alıntısı doğrulanamadı. Resmi metinden kontrol edin. +

} + {results.sourceEvidence?.length > 0 &&
+ Kaynak alıntıları ({results.sourceEvidence.length}) +

Metinde bulunması, modelin yorumunun doğru olduğunu tek başına kanıtlamaz.

+ {results.sourceEvidence.map((e,i)=>
+

{evidenceLabel(e.path)} — {e.status==='located'?'Alıntı kaynakta bulundu':e.status==='ambiguous'?'Alıntı birden fazla yerde geçiyor':'Alıntı kaynakta bulunamadı'}

+
{e.quote}
+
)} +
} + {/* İşin kapsamı */} {scopeOfWork && (
diff --git a/tests/analysis-citations.test.mjs b/tests/analysis-citations.test.mjs new file mode 100644 index 0000000..d3d6dd6 --- /dev/null +++ b/tests/analysis-citations.test.mjs @@ -0,0 +1,11 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {verifyAnalysisCitations} from '../server/analysis-citations.mjs'; +test('citations must point to an existing finding and a literal source passage',()=>{ + const quote='Teklif geçerlilik süresi 120 gündür.'; + const result={documentChecklist:[{document:'Yetki'}],citations:[{path:'documentChecklist.0',quote},{path:'documentChecklist.9',quote}]}; + const out=verifyAnalysisCitations(result,'Madde 1: '+quote); + assert.equal(out.evidence.length,1);assert.equal(out.evidence[0].status,'located');assert.equal(out.evidence[0].start,9);assert.deepEqual(out.missing,[]); + assert.equal(verifyAnalysisCitations(result,'Başka bir kaynak metni').evidence[0].status,'not_found'); + assert.equal(verifyAnalysisCitations(result,quote+quote).evidence[0].status,'ambiguous'); +});