Preserve full draft sections and disclose missing requested content

This commit is contained in:
2026-09-06 10:11:44 +03:00
parent a78aaef7e5
commit 996cc15245
5 changed files with 76 additions and 7 deletions
+10
View File
@@ -451,3 +451,13 @@ Model yanıtında boş/geçersiz metin, hiç atıf olmaması veya modele sunulma
Sınır: Atıf numarasının geçerliliği semantik doğruluk kanıtı değildir. Terim tabanlı ilk sekiz parçanın seçimi başka bölümdeki ek/çelişen koşulu kaçırabilir. Tam doküman/ortak kaynak modeli ve gerçek sağlayıcı kabulü halen açık. Sınır: Atıf numarasının geçerliliği semantik doğruluk kanıtı değildir. Terim tabanlı ilk sekiz parçanın seçimi başka bölümdeki ek/çelişen koşulu kaçırabilir. Tam doküman/ortak kaynak modeli ve gerçek sağlayıcı kabulü halen açık.
Doğrulama: uzun tek satır ve kısa son madde, Unicode sınırları, geçersiz boyut, sahte/eksik kaynak numarası ve yalnız gerçek atıfların tam içeriğini döndürme için dört test. Tüm 95 test ve üretim derlemesi başarılı. Mevcut büyük paket uyarıları sürüyor. Canlıya yayın yapılmadı. Doğrulama: uzun tek satır ve kısa son madde, Unicode sınırları, geçersiz boyut, sahte/eksik kaynak numarası ve yalnız gerçek atıfların tam içeriğini döndürme için dört test. Tüm 95 test ve üretim derlemesi başarılı. Mevcut büyük paket uyarıları sürüyor. Canlıya yayın yapılmadı.
### D06 — Üretilen taslağın bölüm bütünlüğü
Taslak üretimi önceki kodda istenen bölüm sayısını kontrol etmiyor, tek geçerli bölüm varsa başarılı yanıt dönüyor ve her bölümün içeriğini 12.000 karakterde sessizce kesiyordu. Yeni `qualifyOfferDraft` istenen başlıkları tekil metin eşleşmesiyle sıralar. Eksik/boş başlıklar için açık DOLDURUN alanları ve `partial` durumu döndürür. Eşleşmeyen ek içerik kaybolmaz fakat istenen bölümün yerine sayılmaz. Yinelenen başlık ve nesne gibi geçersiz alan türleri hata verir. Uzun bölümün son koşulları korunur.
Ekran, modele göre tamamlanmayan bölümleri ve eşleşmeyen ek bölümleri listeler. Tam bölüm kümesi bile yalnız `draft` durumudur; resmi hazır/onaylı durumu üretilmez. Kullanıcı düzenlediğinde ilk üretim kapsamı uyarısı otomatik doğrulanmış sayılmaz.
Doğrulama: eksik bölüm, doğru sıra, 12.000 karakterden uzun içerik ve son koşul, fazladan başlık, yinelenen/bozuk bölüm için dört yeni test. Tam test ve build sonuçları aşağıdaki commit öncesi kontrolle doğrulandı. Gerçek sağlayıcı bölüm üretimi ve tarayıcı kabulü halen açık; ortak gereklilik kaynağının kapsam sınırları bu değişiklikle kapanmış sayılmaz.
Sonuç: 99 test geçti; üretim derlemesi başarılı. Büyük paket uyarıları sürüyor. Canlı yayın yapılmadı.
+3 -6
View File
@@ -1,3 +1,4 @@
import { qualifyOfferDraft } from './offer-draft.mjs';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { questionChunks, qualifyQuestionAnswer } from './document-question.mjs'; import { questionChunks, qualifyQuestionAnswer } from './document-question.mjs';
import { qualifyOfferAudit } from './offer-audit.mjs'; import { qualifyOfferAudit } from './offer-audit.mjs';
@@ -117,11 +118,7 @@ export async function teklifTaslak({ ikn, tur, tender, firma, belgeler, fiyat, g
Array.isArray(belgeler) && belgeler.length ? 'DEPODAKİ BELGELER:\n' + belgeler.slice(0, 40).map((b) => `- ${b.ad || b}${b.kategori ? ' [' + b.kategori + ']' : ''}`).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, fiyat ? `FİYAT VERİSİ: ${typeof fiyat === 'string' ? fiyat : JSON.stringify(fiyat).slice(0, 1500)}` : null,
].filter(Boolean).join('\n\n'); ].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.`; 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, systemOverride: sistem, mode: 'derin' }); 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, systemOverride: sistem, mode: 'derin' });
const bolumler = (Array.isArray(out?.result?.bolumler) ? out.result.bolumler : []) return qualifyOfferDraft(out?.result, istenen);
.map((b) => ({ baslik: String(b?.baslik ?? '').slice(0, 200), icerik: String(b?.icerik ?? '').slice(0, 12000) }))
.filter((b) => b.baslik && b.icerik);
if (!bolumler.length) throw new Error('Model bölüm üretmedi; tekrar deneyin.');
return { bolumler };
} }
+30
View File
@@ -0,0 +1,30 @@
const normalize = value => value.normalize('NFC').replace(/\s+/g, ' ').trim();
/** Preserve useful draft text, while making missing requested sections explicit. */
export function qualifyOfferDraft(result, requested) {
if (!Array.isArray(result?.bolumler) || !result.bolumler.length)
throw new Error('Model bölüm üretmedi; tekrar deneyin.');
const byTitle = new Map();
for (const row of result.bolumler) {
if (typeof row?.baslik !== 'string' || typeof row?.icerik !== 'string' || !row.baslik.trim())
throw new Error('Model geçersiz bir taslak bölümü döndürdü. Yeniden deneyin.');
const key = normalize(row.baslik);
if (byTitle.has(key)) throw new Error('Model aynı taslak bölümünü birden fazla kez döndürdü. Yeniden deneyin.');
byTitle.set(key, {baslik:row.baslik.trim(), icerik:row.icerik.trim()});
}
const missingSections = [];
const bolumler = requested.map(baslik => {
const key = normalize(baslik), found = byTitle.get(key);
byTitle.delete(key);
if (found?.icerik) return {...found, baslik};
missingSections.push(baslik);
return {baslik, icerik:`[DOLDURUN: ${baslik} bölümü model tarafından tamamlanmadı. Kaynak belgelerle hazırlayın.]`};
});
// Keep unexpected useful content; it cannot substitute for a missing section.
const extraSections = [...byTitle.values()].filter(row => row.icerik);
return {bolumler:[...bolumler, ...extraSections], draftReview:{
status:missingSections.length || extraSections.length ? 'partial' : 'draft',
requestedSections:requested, missingSections, extraSections:extraSections.map(row=>row.baslik),
note:'Bu çıktı düzenlenebilir bir taslaktır. Bölümlerin bulunması, bilgilerin doğru veya resmi teklif dosyasının hazır olduğunu göstermez. Kaynak, firma bilgileri, fiyatlar ve zorunlu belgeler ayrıca kontrol edilmelidir.',
}};
}
+8 -1
View File
@@ -38,6 +38,7 @@ const OfferAssistantSession = ({ tender }) => {
const [denetim, setDenetim] = useState(null); const [denetim, setDenetim] = useState(null);
const [tur, setTur] = useState('tam'); const [tur, setTur] = useState('tam');
const [bolumler, setBolumler] = useState([]); const [bolumler, setBolumler] = useState([]);
const [draftReview, setDraftReview] = useState(null);
const [mesgul, setMesgul] = useState(false); const [mesgul, setMesgul] = useState(false);
const [hata, setHata] = useState(null); const [hata, setHata] = useState(null);
@@ -58,7 +59,7 @@ const OfferAssistantSession = ({ tender }) => {
}); });
const uret = () => calistir(async () => { const uret = () => calistir(async () => {
const result = await post('/api/ai/teklif-taslak', {ikn:tender.ikn, tur, tender, text:metin || undefined}, listDocuments); const result = await post('/api/ai/teklif-taslak', {ikn:tender.ikn, tur, tender, text:metin || undefined}, listDocuments);
if (current()) setBolumler(result.bolumler || []); if (current()) { setBolumler(result.bolumler || []); setDraftReview(result.draftReview || null); }
}); });
const kopyala = (t) => { if (current()) navigator.clipboard?.writeText(t).catch(() => {}); }; const kopyala = (t) => { if (current()) navigator.clipboard?.writeText(t).catch(() => {}); };
const dosyaOku = (f) => { const dosyaOku = (f) => {
@@ -154,6 +155,12 @@ const OfferAssistantSession = ({ tender }) => {
</button> </button>
{bolumler.length > 0 && <button type="button" onClick={() => kopyala(bolumler.map((b) => `${b.baslik}\n\n${b.icerik}`).join('\n\n\n'))} className="text-xs text-slate-600 border border-slate-200 rounded-lg px-2 py-1.5 flex items-center gap-1 hover:bg-slate-50"><Copy className="w-3.5 h-3.5" /> Tümünü kopyala</button>} {bolumler.length > 0 && <button type="button" onClick={() => kopyala(bolumler.map((b) => `${b.baslik}\n\n${b.icerik}`).join('\n\n\n'))} className="text-xs text-slate-600 border border-slate-200 rounded-lg px-2 py-1.5 flex items-center gap-1 hover:bg-slate-50"><Copy className="w-3.5 h-3.5" /> Tümünü kopyala</button>}
</div> </div>
{draftReview && <div role="status" className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900">
<p className="font-semibold">{draftReview.status === 'partial' ? 'Kısmi taslak — bölüm kontrolü gerekli' : 'Taslak — kullanıcı kontrolü gerekli'}</p>
<p>{draftReview.note}</p>
{draftReview.missingSections?.length > 0 && <p>Modelin tamamlamadığı bölümler: {draftReview.missingSections.join(', ')}. Aşağıdaki DOLDURUN alanlarını düzenleyin.</p>}
{draftReview.extraSections?.length > 0 && <p>İstenen başlıklara eşleşmeyen ek bölümler: {draftReview.extraSections.join(', ')}.</p>}
</div>}
{bolumler.map((b, i) => ( {bolumler.map((b, i) => (
<div key={i} className="space-y-1"> <div key={i} className="space-y-1">
<input value={b.baslik} onChange={(e) => setBolumler((p) => p.map((x, k) => (k === i ? { ...x, baslik: e.target.value } : x)))} className="w-full border border-slate-200 rounded-lg px-2 py-1 text-sm font-bold" /> <input value={b.baslik} onChange={(e) => setBolumler((p) => p.map((x, k) => (k === i ? { ...x, baslik: e.target.value } : x)))} className="w-full border border-slate-200 rounded-lg px-2 py-1 text-sm font-bold" />
+25
View File
@@ -0,0 +1,25 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {qualifyOfferDraft} from '../server/offer-draft.mjs';
const requested=['Kapak','Teknik','Ekler'];
test('missing sections are explicit editable placeholders, not a complete dossier',()=>{
const out=qualifyOfferDraft({bolumler:[{baslik:'Kapak',icerik:'Kapak metni'}]},requested);
assert.equal(out.draftReview.status,'partial');
assert.deepEqual(out.draftReview.missingSections,['Teknik','Ekler']);
assert.match(out.bolumler[1].icerik,/DOLDURUN/);assert.equal(out.bolumler[0].icerik,'Kapak metni');
});
test('long sections retain their last conditions and requested section order',()=>{
const text='Teknik şart '.repeat(1500)+'SON KOŞUL: Numune zorunludur.';
const out=qualifyOfferDraft({bolumler:[{baslik:'Ekler',icerik:'Ekler metni'},{baslik:'Teknik',icerik:text},{baslik:'Kapak',icerik:'Kapak'}]},requested);
assert.deepEqual(out.bolumler.map(row=>row.baslik),requested);
assert.equal(out.bolumler[1].icerik,text);assert.equal(out.draftReview.status,'draft');
});
test('unexpected content is preserved but never substitutes for a requested section',()=>{
const out=qualifyOfferDraft({bolumler:[{baslik:'Başka bölüm',icerik:'Ek içerik'}]},requested);
assert.equal(out.draftReview.status,'partial');assert.equal(out.draftReview.missingSections.length,3);
assert.equal(out.bolumler.at(-1).icerik,'Ek içerik');
});
test('malformed or repeated sections cannot silently overwrite user-visible content',()=>{
for(const bolumler of [[],[null],[{baslik:'Kapak',icerik:{text:'invalid'}}],[{baslik:'Kapak',icerik:'a'},{baslik:'Kapak',icerik:'b'}]])
assert.throws(()=>qualifyOfferDraft({bolumler},requested));
});