feat: analyze complete long documents in bounded source parts

This commit is contained in:
2026-09-06 08:58:54 +03:00
parent 90d85060f2
commit 0ae828163a
5 changed files with 98 additions and 2 deletions
+9 -1
View File
@@ -1,3 +1,4 @@
import { analyzeInParts } from './analysis-parts.mjs';
import { qualifyAnalysis } from './analysis-quality.mjs';
/**
* Gerçek AI şartname analizi — OpenRouter öncelikli, çoklu sağlayıcı fallback.
@@ -316,7 +317,14 @@ function kesikJsonOnar(s) {
}
/** Zinciri sırayla dener; ilk başarılı sonucu döndürür. */
export async function analyzeSpec({ text, fileName, tender, chain, systemOverride, mode }) {
export async function analyzeSpec({ text, fileName, tender, chain, systemOverride, mode, _singlePart = false }) {
if (!_singlePart && String(text || '').length > 24000) {
return analyzeInParts({text,fileName,tender,mode}, part => analyzeSpec({
text:part.text,fileName:`${fileName || 'Doküman'} — bölüm ${part.index+1}`,
tender:tender ? {...tender,items:[]} : undefined,chain,mode,_singlePart:true,
systemOverride:(systemOverride || (mode === 'derin' ? DERIN_SYSTEM_PROMPT : SYSTEM_PROMPT)) + '\nBu metin büyük dokümanın bir bölümüdür. Yalnız bu bölümde görülen koşulları çıkar; bu bölümde bulunmayan şartın bütün dokümanda bulunmadığı sonucunu çıkarma.',
}));
}
const derin = mode === 'derin';
// Ücretli yedek: Gemini Flash her zaman zincirin sonunda. Panelde seçilen
// modeller (ör. ücretsiz/limitli olanlar) tıkanınca analiz boş kalmasın;
+55
View File
@@ -0,0 +1,55 @@
import {qualifyAnalysis} from './analysis-quality.mjs';
export function splitAnalysisText(text,max=24000) {
if(!Number.isInteger(max) || max<100)throw new Error('Geçersiz parça boyutu.');
const source=String(text || ''),parts=[];
for(let start=0;start<source.length;) {
let end=Math.min(start+max,source.length);
if(end<source.length){const newline=source.lastIndexOf('\n',end-1);if(newline>start+max/2)end=newline+1;}
// Do not split a UTF-16 surrogate pair.
if(end<source.length && /[\uD800-\uDBFF]/.test(source[end-1]))end--;
parts.push({index:parts.length,start,end,text:source.slice(start,end)});start=end;
}
return parts;
}
const arrayFields=['qualificationCriteria','criticalRequirements','risksAndPenalties','disqualificationRisks','criticalDetails','itemsAndSpecs','documentChecklist','deadlines','costDrivers','roadmap','questionsForAdministration'];
export async function analyzeInParts(input,runPart) {
const parts=splitAnalysisText(input.text),results=[],failures=[];
// Two requests at a time bounds provider pressure while processing every part.
for(let i=0;i<parts.length;i+=2) {
await Promise.all(parts.slice(i,i+2).map(async part=>{
try {results.push({part,out:await runPart(part)});}
catch(error){failures.push({part,error:String(error.message || error)});}
}));
}
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.`);
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;
}
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 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;
const quantities=new Map();
for(const row of merged.itemsAndSpecs) {
const key=JSON.stringify([row.itemNumber ?? row.no ?? null,row.kisim ?? null,row.name]);
if(quantities.has(key) && quantities.get(key)!==row.quantity)issues.push(`${row.name}: bölümler arasında farklı miktar/birim bulundu.`);
quantities.set(key,row.quantity);
}
for(const {part,out} of results) for(const issue of out.result.sourceReview?.issues || [])issues.push(`Bölüm ${part.index+1}: ${issue}`);
const result=qualifyAnalysis(merged,{text:input.text,limit:String(input.text).length,fileName:input.fileName,expectedItems:input.tender?.items?.length || 0});
const ownIssues=[...result.sourceReview.issues,...issues];
result.sourceReview={...result.sourceReview,status:ownIssues.length?'partial':'review_required',issues:ownIssues,
analyzedCharacters:results.reduce((sum,{part})=>sum+part.end-part.start,0),
parts:parts.map(part=>({index:part.index+1,start:part.start,end:part.end,status:failures.some(f=>f.part.index===part.index)?'failed':'processed'}))};
return {result,mode:input.mode==='derin'?'derin':'standart',model:{id:'document-parts',label:'Doküman analizi',provider:'',model:'document-parts'},
attempts:results.flatMap(({part,out})=>(out.attempts || []).map(a=>({...a,part:part.index+1}))),
partFailures:failures.map(({part})=>({part:part.index+1}))};
}