diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 0b5e282..26bbe70 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -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. 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ı. diff --git a/server/ai-teklif.mjs b/server/ai-teklif.mjs index 2204bb2..11839d3 100644 --- a/server/ai-teklif.mjs +++ b/server/ai-teklif.mjs @@ -1,3 +1,4 @@ +import { qualifyOfferDraft } from './offer-draft.mjs'; import { createHash } from 'node:crypto'; import { questionChunks, qualifyQuestionAnswer } from './document-question.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, fiyat ? `FİYAT VERİSİ: ${typeof fiyat === 'string' ? fiyat : JSON.stringify(fiyat).slice(0, 1500)}` : 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.`; + 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 bolumler = (Array.isArray(out?.result?.bolumler) ? out.result.bolumler : []) - .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 }; + return qualifyOfferDraft(out?.result, istenen); } diff --git a/server/offer-draft.mjs b/server/offer-draft.mjs new file mode 100644 index 0000000..1c827bc --- /dev/null +++ b/server/offer-draft.mjs @@ -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.', + }}; +} diff --git a/src/components/TeklifAsistani.jsx b/src/components/TeklifAsistani.jsx index 5b16ff5..9f9e08c 100644 --- a/src/components/TeklifAsistani.jsx +++ b/src/components/TeklifAsistani.jsx @@ -38,6 +38,7 @@ const OfferAssistantSession = ({ tender }) => { const [denetim, setDenetim] = useState(null); const [tur, setTur] = useState('tam'); const [bolumler, setBolumler] = useState([]); + const [draftReview, setDraftReview] = useState(null); const [mesgul, setMesgul] = useState(false); const [hata, setHata] = useState(null); @@ -58,7 +59,7 @@ const OfferAssistantSession = ({ tender }) => { }); const uret = () => calistir(async () => { 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 dosyaOku = (f) => { @@ -154,6 +155,12 @@ const OfferAssistantSession = ({ tender }) => { {bolumler.length > 0 && } + {draftReview &&
+

{draftReview.status === 'partial' ? 'Kısmi taslak — bölüm kontrolü gerekli' : 'Taslak — kullanıcı kontrolü gerekli'}

+

{draftReview.note}

+ {draftReview.missingSections?.length > 0 &&

Modelin tamamlamadığı bölümler: {draftReview.missingSections.join(', ')}. Aşağıdaki DOLDURUN alanlarını düzenleyin.

} + {draftReview.extraSections?.length > 0 &&

İstenen başlıklara eşleşmeyen ek bölümler: {draftReview.extraSections.join(', ')}.

} +
} {bolumler.map((b, i) => (
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" /> diff --git a/tests/offer-draft.test.mjs b/tests/offer-draft.test.mjs new file mode 100644 index 0000000..2fb09f9 --- /dev/null +++ b/tests/offer-draft.test.mjs @@ -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)); +});