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
+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`: 39/39 geçti (son yeniden çalıştırma: 6 Eylül 2026).
`npm test`: 42/42 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.
@@ -128,3 +128,10 @@ ExcelJS ayrı, ihtiyaç anında yüklenen pakettir. Node bağımlılığındaki
AI çıktısına kaynak içerik özeti (SHA-256 sürümü), kaynak/işlenen karakter sayısı, bölüm çıkarım durumu ve kısmi analiz gerekçeleri eklendi. Girdi sınırında kesilen metin, onarılan yarım JSON ve bilinen kaynak sayısından az kalem kısmi sayılır. Hazırlık formu bunu saklar ve çıktıyı engeller. Boş bölüm, şartın bulunmadığı olarak yorumlanmaz. Derin analizdeki 20 kalem kısıtı kaldırıldı; çıktı sınırını aşan analiz yine eksik işaretlenir.
İki test kesilme, onarma, eksik kalem, boş bölüm ve kaynak değişimini doğrular. 39 test ve derleme geçti. Bu adım tam uzun doküman işleme değildir: parçalı analiz/birleştirme, OCR, sayfa/madde atıfları ve hukuki çelişki denetimi hâlâ açık. Kısmi analizi tamamlamak için gerekli uzun doküman akışı ayrıca geliştirilmeli; yalnız uyarı eklenmiş olması D06 tamamlanması değildir.
## Uzun doküman analiz akışı
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.
+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}))};
}
+1
View File
@@ -77,6 +77,7 @@ export const DeepAnalysisReport = ({ results, tender: selectedTender }) => {
<p className="font-bold">{results.sourceReview.status === 'partial' ? 'Kısmi analiz — kontrol gerekli' : 'Analiz üretildi — kaynak doğrulaması gerekli'}</p>
<p>Kaynak: {results.sourceReview.fileName || 'Gönderilen metin'} · {results.sourceReview.analyzedCharacters} / {results.sourceReview.sourceCharacters} karakter işlendi</p>
{(results.sourceReview.issues || []).map(issue=><p key={issue} role="alert">{issue}</p>)}
{results.sourceReview.parts && <p>İşlenen bölümler: {results.sourceReview.parts.filter(p=>p.status==='processed').length} / {results.sourceReview.parts.length}</p>}
<p>Boş bir bölüm, ilgili şartın kaynakta bulunmadığı anlamına gelmez.</p>
</div>}
+25
View File
@@ -0,0 +1,25 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {splitAnalysisText,analyzeInParts} from '../server/analysis-parts.mjs';
test('long Turkish source is covered once without loss, including final paragraph',()=>{
const text=('İğdır laboratuvar 🧪\n').repeat(7000)+'SON MADDE';
const parts=splitAnalysisText(text);assert.ok(parts.length>1);
assert.equal(parts.map(p=>p.text).join(''),text);assert.ok(parts.every(p=>p.text.length<=24000));
assert.equal(parts.at(-1).end,text.length);
});
test('all source parts contribute requirements, failures remain explicit partial analysis',async()=>{
const text='a'.repeat(60000);
const complete=await analyzeInParts({text,fileName:'test'},async p=>({result:{documentChecklist:[{document:`Belge ${p.index}`}],sourceReview:{issues:[]}},attempts:[]}));
assert.equal(complete.result.documentChecklist.length,3);assert.equal(complete.result.sourceReview.analyzedCharacters,60000);
assert.equal(complete.result.sourceReview.status,'review_required');assert.equal(complete.result.bidAnalysis,null);
const partial=await analyzeInParts({text},async p=>{if(p.index===1)throw new Error('test failure');return {result:{itemsAndSpecs:[{name:'Kit',quantity:p.index?'3 adet':'2 adet'}]}};});
assert.equal(partial.result.sourceReview.status,'partial');assert.equal(partial.result.sourceReview.analyzedCharacters,36000);
assert.equal(partial.result.sourceReview.parts[1].status,'failed');
assert.ok(partial.result.sourceReview.issues.some(i=>i.includes('farklı miktar')));
});
test('identical-looking rows in different source parts are retained with origin',async()=>{
const out=await analyzeInParts({text:'a'.repeat(30000)},async()=>({result:{itemsAndSpecs:[{name:'Kit',quantity:'2 adet'}]}}));
assert.equal(out.result.itemsAndSpecs.length,2);
assert.deepEqual(out.result.itemsAndSpecs.map(r=>r.sourcePart),[1,2]);
});