feat: verify analysis citations against source text
This commit is contained in:
@@ -89,7 +89,7 @@ Tarayıcı kanıtı: [Yenileme sonrası beş kart](evidence/qa-kanban-after-relo
|
|||||||
|
|
||||||
## Doğrulama
|
## 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.
|
`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.
|
`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.
|
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.
|
Üç 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
@@ -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ı
|
// Derin analiz hem daha çok metin okur hem daha uzun JSON üretir; çıktı
|
||||||
// kesilirse JSON parse edilemez ve zincir boşuna tüketilir.
|
// kesilirse JSON parse edilemez ve zincir boşuna tüketilir.
|
||||||
const userPrompt = buildUserPrompt({ text, fileName, tender, limit: derin ? 90000 : 60000 });
|
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 maxTokens = derin ? 12000 : 4000;
|
||||||
const timeoutMs = derin ? 150000 : 90000;
|
const timeoutMs = derin ? 150000 : 90000;
|
||||||
const attempts = [];
|
const attempts = [];
|
||||||
|
|||||||
@@ -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};
|
||||||
|
}
|
||||||
@@ -24,16 +24,23 @@ export async function analyzeInParts(input,runPart) {
|
|||||||
results.sort((a,b)=>a.part.index-b.part.index);
|
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.');
|
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 merged={},issues=failures.map(f=>`${f.part.index+1}. parça analiz edilemedi.`);
|
||||||
|
const pathMap=new Map();
|
||||||
for(const field of arrayFields) {
|
for(const field of arrayFields) {
|
||||||
if(field==='itemsAndSpecs') {
|
merged[field]=[];
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
const entries=new Map();
|
const entries=new Map();
|
||||||
for(const {out} of results) for(const value of out.result[field] || [])entries.set(JSON.stringify(value),value);
|
for(const {part,out} of results) for(const [i,value] of (out.result[field] || []).entries()) {
|
||||||
merged[field]=[...entries.values()];
|
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');
|
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.
|
// Different part-level prices/strategies cannot be treated as a global bid.
|
||||||
merged.bidAnalysis=null;
|
merged.bidAnalysis=null;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { verifyAnalysisCitations } from './analysis-citations.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.');
|
||||||
const clean={...result};delete clean._outputRepaired;
|
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={};
|
const sections={};
|
||||||
for(const key of ['itemsAndSpecs','documentChecklist','qualificationCriteria','risksAndPenalties','roadmap']) {
|
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.`);
|
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(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.`);
|
||||||
|
const citations=verifyAnalysisCitations(clean,source.slice(0,limit));
|
||||||
const version=createHash('sha256').update(source).digest('hex');
|
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),
|
fileName,sourceCharacters:source.length,analyzedCharacters:Math.min(source.length,limit),
|
||||||
outputRepaired:repaired,sections,issues}};
|
outputRepaired:repaired,sections,issues,unverifiedFindings:citations.missing}};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ const FAZ_RENK = {
|
|||||||
Sözleşme: 'bg-purple-600',
|
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 dolu = (x) => Array.isArray(x) && x.length > 0;
|
||||||
|
|
||||||
const tl = (n) =>
|
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>
|
<p>Boş bir bölüm, ilgili şartın kaynakta bulunmadığı anlamına gelmez.</p>
|
||||||
</div>}
|
</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ı */}
|
{/* İşin kapsamı */}
|
||||||
{scopeOfWork && (
|
{scopeOfWork && (
|
||||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-2">
|
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-2">
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user