feat: verify analysis citations against source text

This commit is contained in:
2026-09-06 09:00:16 +03:00
parent 0ae828163a
commit a9036cf909
7 changed files with 70 additions and 11 deletions
+8 -1
View File
@@ -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.
+1 -1
View File
@@ -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 = [];
+17
View File
@@ -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};
}
+14 -7
View File
@@ -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;
+5 -2
View File
@@ -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)<expectedItems)issues.push(`Kaynakta ${expectedItems} kalem var; analiz listesi daha kısa.`);
const citations=verifyAnalysisCitations(clean,source.slice(0,limit));
const version=createHash('sha256').update(source).digest('hex');
return {...clean,documentVersion:version,sourceReview:{status:issues.length?'partial':'review_required',
return {...clean,sourceEvidence:citations.evidence,documentVersion:version,sourceReview:{status:issues.length?'partial':'review_required',
fileName,sourceCharacters:source.length,analyzedCharacters:Math.min(source.length,limit),
outputRepaired:repaired,sections,issues}};
outputRepaired:repaired,sections,issues,unverifiedFindings:citations.missing}};
}
+14
View File
@@ -33,6 +33,8 @@ const FAZ_RENK = {
Sözleşme: 'bg-purple-600',
};
const evidenceLabel = path => { 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 }) => {
<p>Boş bir bölüm, ilgili şartın kaynakta bulunmadığı anlamına gelmez.</p>
</div>}
{results.sourceReview?.unverifiedFindings?.length > 0 && <p role="status" className="text-xs text-amber-800">
{results.sourceReview.unverifiedFindings.length} bulgunun tekil kaynak alıntısı doğrulanamadı. Resmi metinden kontrol edin.
</p>}
{results.sourceEvidence?.length > 0 && <details className="border rounded-xl p-3 text-xs space-y-2">
<summary className="font-bold cursor-pointer">Kaynak alıntıları ({results.sourceEvidence.length})</summary>
<p>Metinde bulunması, modelin yorumunun doğru olduğunu tek başına kanıtlamaz.</p>
{results.sourceEvidence.map((e,i)=><div key={i} className="border-t pt-2">
<p className="font-semibold">{evidenceLabel(e.path)} — {e.status==='located'?'Alıntı kaynakta bulundu':e.status==='ambiguous'?'Alıntı birden fazla yerde geçiyor':'Alıntı kaynakta bulunamadı'}</p>
<blockquote className="whitespace-pre-wrap border-l-2 pl-2 mt-1">{e.quote}</blockquote>
</div>)}
</details>}
{/* İşin kapsamı */}
{scopeOfWork && (
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-2">
+11
View File
@@ -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');
});