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
+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}};
}