From 1c0b48655ef50cd4a39cbefc9e7fe949db51199b Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 10:39:42 +0300 Subject: [PATCH] Preserve complete input in multi-part offer draft generation --- docs/quality/uygulama-durumu.md | 10 +++++++++ server/ai-teklif.mjs | 18 +++++++++------ server/offer-draft-parts.mjs | 39 +++++++++++++++++++++++++++++++++ tests/offer-draft.test.mjs | 32 +++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 server/offer-draft-parts.mjs diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index a0be3f6..88f6eb7 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -613,3 +613,13 @@ Yeni, izole localhost:5176 test sunucusunda görünür test kontrol ekranıyla y Normal test hesabına görünür giriş ekranından geçildiğinde 0/0 limit, Kurumsal plan açıklaması ve kapalı oluşturma görüldü. Önceki anahtar adı görünmedi. Bu geçiş tam sayfa yüklemesiyle yapılmıştır; devam eden istek sırasında hesap değişimi bu tarayıcı kontrolünde kanıtlanmış değildir. Pano izin hatası da açık kabul maddesidir. Kanıt: qa-api-key-lifecycle.json, sekiz kontrol doğru. Anahtar değeri ve öneki kanıtta maskelendi; oluşturulan anahtar test sonunda iptal edildi. Test yardımcısındaki hata kontrolleri yalnız yerel QA sunucusundadır, üretime dahil değildir. Üretim kodu değişmediğinden önceki 132 test/build tekrarlanmadı; QA değişikliği çalışan tarayıcı akışıyla doğrulandı. Tam plan ve canlı yayın açık. + +### D06 — Çok kalemli taslak üretiminde kaynak kapsamı + +Taslak üretimi artık açık kalem/koşul listesinin tamamını, depodaki tüm belge adlarını ve kesilmemiş firma/fiyat bilgisini kullanır. Tam kaynak metni de bağlama eklenir; önceki altı anahtar sözcüklü özet tek kaynak değildir. Uzun taslak bağlamı 16.000 karakterlik kayıpsız parçalara ayrılır; en fazla iki sağlayıcı çağrısı eşzamanlıdır. Genel şartname analizinin farklı veri şemasıyla bölüm birleştirmesi kullanılmaz. + +Taslak bölümleri istenen başlıklara göre, kaynak sırasıyla birleştirilir; başarısız parçada açık DOLDURUN alanı kalır. Ek model bölümleri kaynak numarasıyla korunur. Çok parçalı sonuç her zaman kısmi inceleme durumundadır: tekrarların/çelişkilerin giderildiği veya bütün şartların karşılandığı iddia edilmez. Kaynak özeti, parça aralıkları ve başarısızlıklar kaydedilir. İptal yeni grupları durdurur. + +Doğrulama: 50 bin karakteri aşan kaynakta tüm karakterlerin sağlayıcı yardımcı çağrılarına kayıpsız ulaşması, son koşulun birleşik bölümde korunması, ara parça hatasının görünürlüğü, iptalde sonraki grupların durması. Sağlayıcı yanıtları yerel test taklididir; gerçek uzun belge üretim kalitesi/tarayıcı kabulü değildir. Tüm 135 test ve üretim derlemesi geçti. + +Sınırlar: Bir parçanın işlenmesi tüm şartlarının anlam bakımından taslağa doğru aktarıldığını kanıtlamaz. İki dakikalık istemci sınırı ile kalıcı iş/sürdürme ihtiyacı açık; gerçek firma ve beş resmi dosya kabulü yapılmadı. Büyük paket uyarıları sürüyor; canlı yayın yok. diff --git a/server/ai-teklif.mjs b/server/ai-teklif.mjs index 00f9020..28bac19 100644 --- a/server/ai-teklif.mjs +++ b/server/ai-teklif.mjs @@ -1,5 +1,5 @@ import { auditOfferInParts } from './offer-audit-parts.mjs'; -import { qualifyOfferDraft } from './offer-draft.mjs'; +import { draftOfferInParts } from './offer-draft-parts.mjs'; import { createHash } from 'node:crypto'; import { questionChunks, qualifyQuestionAnswer } from './document-question.mjs'; import { documentContent } from './document-content.mjs'; @@ -116,15 +116,19 @@ const BOLUMLER = { export async function teklifTaslak({ ikn, tur, tender, firma, belgeler, fiyat, gereklilikler, chain, text, signal }) { const istenen = BOLUMLER[tur] || BOLUMLER.tam; const { text: metin } = await ihaleMetni(ikn, text, tender?.aciklama); - const liste = gereklilikMetni(tender, gereklilikler, metin); + const liste = gereklilikMetni(tender, gereklilikler, metin, true); const baglam = [ `İHALE: ${tender?.baslik ?? '?'} · İdare: ${tender?.kurum ?? '?'} · İl: ${tender?.sehir ?? '?'} · Tarih: ${tender?.tarih ?? '?'} · Usul: ${tender?.usul ?? tender?.alimTuru ?? '?'}${tender?.yaklasikMaliyet ? ' · Yaklaşık maliyet: ' + tender.yaklasikMaliyet : ''}${ikn ? ' · İKN ' + ikn : ''}`, - firma ? `FİRMA: ${typeof firma === 'string' ? firma : JSON.stringify(firma).slice(0, 1500)}` : null, + firma ? `FİRMA: ${typeof firma === 'string' ? firma : JSON.stringify(firma)}` : null, liste.length ? 'GEREKLİLİKLER / KALEMLER:\n' + liste.map((g) => '- ' + g).join('\n') : null, - Array.isArray(belgeler) && belgeler.length ? 'DEPODAKİ BELGELER:\n' + belgeler.slice(0, 40).map((b) => `- ${b.ad || b}${b.kategori ? ' [' + b.kategori + ']' : ''}`).join('\n') : null, - fiyat ? `FİYAT VERİSİ: ${typeof fiyat === 'string' ? fiyat : JSON.stringify(fiyat).slice(0, 1500)}` : null, + Array.isArray(belgeler) && belgeler.length ? 'DEPODAKİ BELGELER:\n' + belgeler.map((b) => `- ${b.ad || b}${b.kategori ? ' [' + b.kategori + ']' : ''}`).join('\n') : null, + metin ? `KAYNAK METNİ (tam içerik):\n${metin}` : null, + fiyat ? `FİYAT VERİSİ: ${typeof fiyat === 'string' ? fiyat : JSON.stringify(fiyat)}` : null, ].filter(Boolean).join('\n\n'); const sistem = `Sen Türkiye kamu ihalelerinde (4734) deneyimli bir teklif yazarısın. Verilen bilgilerle resmî, kısa ve somut bir teklif dosyası taslağı yaz. Verilmeyen bilgiyi UYDURMA: firma adı, yetkili, tarih, fiyat, süre boşluklarına [DOLDURUN: ...] yaz. Fiyat yalnızca FİYAT VERİSİ varsa yazılır. Ekler bölümünde istenen belgeleri madde madde ver. Üslup resmî; abartı yok. Yalnızca JSON: {"bolumler":[{"baslik":"","icerik":""}]} — istenen bölümleri aynı sırada ver; bölüm başlıklarını değiştirmeden aynen kopyala.`; - const out = await analyzeSpec({ text: `BAĞLAM:\n${baglam}\n\nİSTENEN BÖLÜMLER:\n${istenen.map((x, i) => `${i + 1}. ${x}`).join('\n')}\n\nYalnızca JSON döndür.`, tender, chain, signal, systemOverride: sistem, mode: 'derin' }); - return qualifyOfferDraft(out?.result, istenen); + return draftOfferInParts(baglam, istenen, async (part,total) => { + const out = await analyzeSpec({ text: `BAĞLAM BÖLÜMÜ ${part.index + 1}/${total}:\n${part.text}\n\nİSTENEN BÖLÜMLER:\n${istenen.map((x,i)=>`${i+1}. ${x}`).join('\n')}\n\nBu kaynak bölümünde bulunmayan bilgiyi uydurma. Diğer kaynak bölümlerinde ek veya çelişen şartlar bulunabilir. Yalnızca JSON döndür.`, + tender:tender ? {...tender,items:[]} : undefined,chain,signal,systemOverride:sistem,mode:'derin',_singlePart:true}); + return out?.result; + },{signal}); } diff --git a/server/offer-draft-parts.mjs b/server/offer-draft-parts.mjs new file mode 100644 index 0000000..61e67ee --- /dev/null +++ b/server/offer-draft-parts.mjs @@ -0,0 +1,39 @@ +import { createHash } from 'node:crypto'; +import { splitAnalysisText } from './analysis-parts.mjs'; +import { qualifyOfferDraft } from './offer-draft.mjs'; + +/** Draft-specific merging: the general specification schema cannot merge sections. */ +export async function draftOfferInParts(context, requested, runPart, {signal} = {}) { + const parts = splitAnalysisText(context, 16000), results = new Map(); + for (let start = 0; start < parts.length; start += 2) { + signal?.throwIfAborted(); + await Promise.all(parts.slice(start, start + 2).map(async part => { + try { + const result = qualifyOfferDraft(await runPart(part, parts.length), requested); + results.set(part.index, result); + } catch (error) { + signal?.throwIfAborted(); + results.set(part.index, null); + } + })); + } + signal?.throwIfAborted(); + if (![...results.values()].some(Boolean)) throw new Error('Taslak için hiçbir kaynak bölümü işlenemedi. Yeniden deneyin.'); + if (parts.length === 1) return results.get(0); + const failedParts = parts.filter(part => !results.get(part.index)).map(part => part.index + 1); + const missingSections = requested.filter(title => parts.some(part => !results.get(part.index) || results.get(part.index).draftReview.missingSections.includes(title))); + const bolumler = requested.map(baslik => ({baslik, icerik:parts.map(part => { + const found = results.get(part.index)?.bolumler.find(row => row.baslik === baslik); + return `Kaynak bölümü ${part.index + 1}/${parts.length}:\n${found?.icerik || '[DOLDURUN: Bu kaynak bölümünün taslağı üretilemedi; kaynak metni kontrol edin.]'}`; + }).join('\n\n')})); + // Unexpected but useful sections remain visible and attributed to their input part. + for (const part of parts) for (const row of results.get(part.index)?.bolumler || []) { + if (!requested.includes(row.baslik)) bolumler.push({...row, baslik:`${row.baslik} (Kaynak bölümü ${part.index + 1})`}); + } + return {bolumler, draftReview:{status:'partial', requestedSections:requested, missingSections, + extraSections:bolumler.slice(requested.length).map(row => row.baslik), failedParts, + sourceHash:createHash('sha256').update(context).digest('hex'), + parts:parts.map(part => ({index:part.index + 1,start:part.start,end:part.end,status:results.get(part.index) ? 'processed' : 'failed'})), + note:`Uzun kaynak ${parts.length} bölümde işlendi. ${failedParts.length ? `${failedParts.length} bölüm üretilemedi. ` : ''}Bölüm taslakları kaynak sırasıyla birleştirildi; tekrarlar ve bölümler arası çelişkiler kullanıcı tarafından kontrol edilmelidir. Her kaynak parçasının işlenmesi, içindeki her şartın doğru karşılandığını kanıtlamaz. Bu çıktı resmi başvuruya hazır değildir.`, + }}; +} diff --git a/tests/offer-draft.test.mjs b/tests/offer-draft.test.mjs index 2fb09f9..cd1432c 100644 --- a/tests/offer-draft.test.mjs +++ b/tests/offer-draft.test.mjs @@ -23,3 +23,35 @@ test('malformed or repeated sections cannot silently overwrite user-visible cont for(const bolumler of [[],[null],[{baslik:'Kapak',icerik:{text:'invalid'}}],[{baslik:'Kapak',icerik:'a'},{baslik:'Kapak',icerik:'b'}]]) assert.throws(()=>qualifyOfferDraft({bolumler},requested)); }); + +const {draftOfferInParts}=await import('../server/offer-draft-parts.mjs'); +test('long draft input processes every character and retains late section text in deterministic order',async()=>{ + const source='Kalem ve firma şartları. '.repeat(2100)+'SON KALEM 101: cihaz kurulum yükümlülüğü'; + const seen=[]; + const out=await draftOfferInParts(source,requested,async(part)=>{ + seen.push(part); + return {bolumler:requested.map(baslik=>({baslik,icerik:part.text}))}; + }); + assert.equal(seen.sort((a,b)=>a.index-b.index).map(p=>p.text).join(''),source); + assert.ok(seen.every(p=>p.text.length<=16000)); + assert.match(out.bolumler[1].icerik,/SON KALEM 101/); + assert.equal(out.draftReview.status,'partial'); + assert.equal(out.draftReview.parts.at(-1).end,source.length); +}); +test('failed draft parts remain explicit, not silently omitted or marked complete',async()=>{ + const out=await draftOfferInParts('X'.repeat(40000),requested,async part=>{ + if(part.index===1)throw new Error('provider unavailable'); + return {bolumler:requested.map(baslik=>({baslik,icerik:'Useful draft'}))}; + }); + assert.deepEqual(out.draftReview.failedParts,[2]); + assert.equal(out.draftReview.parts[1].status,'failed'); + assert.match(out.bolumler[0].icerik,/üretilemedi/); + assert.equal(out.draftReview.status,'partial'); +}); +test('draft cancellation stops subsequent batches',async()=>{ + const controller=new AbortController();let calls=0; + await assert.rejects(draftOfferInParts('X'.repeat(80000),requested,async()=>{ + calls++;controller.abort();return {bolumler:[{baslik:'Kapak',icerik:'draft'}]}; + },{signal:controller.signal}),{name:'AbortError'}); + assert.ok(calls<=2); +});