From c3ec8c8ba8058477a73b2dd9758b438c219e7515 Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 10:18:28 +0300 Subject: [PATCH] Honor explicitly supplied sources and disclose draft source changes --- docs/quality/uygulama-durumu.md | 10 ++++++++++ server/ai-teklif.mjs | 9 ++++++--- src/components/TeklifAsistani.jsx | 13 +++++++++---- tests/offer-source.test.mjs | 17 +++++++++++++++++ 4 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 tests/offer-source.test.mjs diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 05c8672..1c9c782 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -503,3 +503,13 @@ Belge kontrol sürümüne tür ve İKN eklendi. Bu alanların değişmesi yenide Testler: başka ihale belgesi reddi, aynı ihale kabulü, firma belgesinin yeniden kullanımı, tüm hazırlık kontrolüne kapsamın yansıması, tür/bağ değişiminde inceleme geçersizliği. Tarayıcıda yeni seçici kapsamı kabulü halen açık. Sonuç: Tüm 108 test ve üretim derlemesi geçti. Büyük paket uyarıları sürüyor. Planın tümü bitmedi; canlı yayın yapılmadı. + +### D06 — Açıkça yüklenen kaynağın önceliği + +`ihaleMetni` 200 karakterden kısa yüklenmiş metni görmezden gelip EKAP dokümanı/ilanına geçiyordu. Kısa fakat önemli bir zeyilname veya şart böylece başka kaynaktan gelen yanıtla karışabiliyordu. Artık açıkça verilen, boş olmayan metin uzunluğundan bağımsız olarak aynen kullanılır. Boş/geçersiz açık metin hata verir. Denetim ve taslak üretiminin bu hatayı yakalayıp boş kaynakla devam etmesi de kaldırıldı. + +Ön yüzde seçilen dosya adı ve karakter sayısı görünür. Boş metin çıkarımı önceki kaynağı değiştirmez; aynı dosyayı tekrar seçmek mümkündür. İlan kaynağına dönüş açık bir kaldır düğmesiyle yapılır. Kaynak değiştirilince yanıt/denetim temizlenir; düzenlenmiş taslak bölümleri korunur ve eski kaynakla üretildikleri uyarısı gösterilir. + +Testler doğrudan `ihaleMetni`, `teklifDenetle` ve `teklifTaslak` üzerinden kısa 120 günlük kaynak ile farklı 60 günlük ilan önceliğini, boş/geçersiz metin reddini ve hata yutulmamasını doğrular; dış kaynak veya model çağrısı yapılmaz. Dosya seçici ve kaynak değiştirme tarayıcı kabulü açık. Tam kaynak sürümleme ve ortak analiz kapsamının tamamı kapanmış sayılmaz. + +Tüm 111 test ve üretim derlemesi geçti. 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 11839d3..3f63182 100644 --- a/server/ai-teklif.mjs +++ b/server/ai-teklif.mjs @@ -43,7 +43,10 @@ export function getir(parcalar, soru, k = 8) { /** İhalenin metni: verilen metin > EKAP dokümanı (tarayıcı varsa) > ilan metni. */ export async function ihaleMetni(ikn, verilen, ilan) { - if (verilen && String(verilen).trim().length > 200) return { text: String(verilen), kaynak: 'yüklenen' }; + if (verilen !== undefined) { + if (typeof verilen !== 'string' || !verilen.trim()) throw new Error('Yüklenen kaynak metni boş veya geçersiz. Okunabilir bir dosya seçin.'); + return {text:verilen, kaynak:'yüklenen'}; + } if (ikn && (await browserAvailable().catch(() => false))) { try { const text = documentContent(await documentText(ikn)); if (text.length > 200) return { text, kaynak: 'EKAP dokümanı' }; } catch { /* ilan metnine düş */ } } @@ -92,7 +95,7 @@ function gereklilikMetni(tender, gereklilikler, metin) { export async function teklifDenetle({ ikn, taslak, gereklilikler, tender, chain, text }) { const t = String(taslak ?? '').trim().slice(0, 60000); if (t.length < 200) throw new Error('Taslak en az 200 karakter olmalı.'); - const { text: metin } = await ihaleMetni(ikn, text, tender?.aciklama).catch(() => ({ text: '' })); + const { text: metin } = await ihaleMetni(ikn, text, tender?.aciklama); const liste = gereklilikMetni(tender, gereklilikler, metin); if (!liste.length) throw new Error('Gereklilik listesi yok — önce derin analiz çalıştırın veya dokümanı yükleyin.'); const sistem = `Sen kamu ihalelerinde teklif dosyası denetçisisin. Gereklilik listesi ve teklif TASLAĞI verilecek. Her gereklilik için taslağı kontrol et. "karsilaniyor" yalnızca taslakta açık karşılığı varsa (alıntıyı 'kanit'e aynen kopyala); değinilmiyorsa "eksik"; kısmen/belirsizse "belirsiz". Sayı üretme. Yalnızca JSON: {"ozet":"2-3 cümle","maddeler":[{"gereklilik":"","durum":"karsilaniyor"|"eksik"|"belirsiz","kanit":"veya null","oneri":"veya null"}]} — maddeleri verilen sırada döndür; gereklilik alanına verilen gereklilik metnini değiştirmeden aynen kopyala.`; @@ -109,7 +112,7 @@ const BOLUMLER = { export async function teklifTaslak({ ikn, tur, tender, firma, belgeler, fiyat, gereklilikler, chain, text }) { const istenen = BOLUMLER[tur] || BOLUMLER.tam; - const { text: metin } = await ihaleMetni(ikn, text, tender?.aciklama).catch(() => ({ text: '' })); + const { text: metin } = await ihaleMetni(ikn, text, tender?.aciklama); const liste = gereklilikMetni(tender, gereklilikler, metin); 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 : ''}`, diff --git a/src/components/TeklifAsistani.jsx b/src/components/TeklifAsistani.jsx index 734358f..28f7bbc 100644 --- a/src/components/TeklifAsistani.jsx +++ b/src/components/TeklifAsistani.jsx @@ -32,6 +32,7 @@ const OfferAssistantSession = ({ tender }) => { const post = (url, body, documents) => requestOfferAssistant({url, body, readToken:getToken, isCurrent:current, listDocuments:documents}); const [arac, setArac] = useState('sor'); const [metin, setMetin] = useState(''); // isteğe bağlı: yapıştırılan doküman metni + const [sourceName, setSourceName] = useState(''); const [soru, setSoru] = useState(''); const [cevap, setCevap] = useState(null); const [taslak, setTaslak] = useState(''); @@ -39,6 +40,7 @@ const OfferAssistantSession = ({ tender }) => { const [tur, setTur] = useState('tam'); const [bolumler, setBolumler] = useState([]); const [draftReview, setDraftReview] = useState(null); + const [draftSource, setDraftSource] = useState(null); const [mesgul, setMesgul] = useState(false); const [hata, setHata] = useState(null); @@ -59,7 +61,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 || []); setDraftReview(result.draftReview || null); } + if (current()) { setBolumler(result.bolumler || []); setDraftReview(result.draftReview || null); setDraftSource(metin); } }); const kopyala = (t) => { if (current()) navigator.clipboard?.writeText(t).catch(() => {}); }; const dosyaOku = (f) => { @@ -68,7 +70,8 @@ const OfferAssistantSession = ({ tender }) => { const { extractText } = await import('../services/pdfText'); if (!current()) return; const r = await extractText(f); - if (current()) { setMetin(r.text || ''); setCevap(null); setDenetim(null); } + if (typeof r.text !== 'string' || !r.text.trim()) throw new Error('Dosyada okunabilir metin bulunamadı. Önceki kaynak değiştirilmedi. Başka bir dosya seçin.'); + if (current()) { setMetin(r.text); setSourceName(f.name); setCevap(null); setDenetim(null); } }); }; @@ -86,10 +89,11 @@ const OfferAssistantSession = ({ tender }) => { Taslağı Denetle Taslak Üret + {metin && }

Metin sağlanmazsa sunucu EKAP dokümanını / ilan metnini kullanır. Yanıtların kaynak parçalarını kontrol edin; AI yanıtları hata veya eksik bilgi içerebilir.

{hata &&

{hata}

} @@ -155,6 +159,7 @@ const OfferAssistantSession = ({ tender }) => { {bolumler.length > 0 && } + {bolumler.length > 0 && draftSource !== metin &&

Kaynak değişti. Aşağıdaki taslak önceki kaynakla üretildi; düzenlemeleriniz korundu. Yeni kaynakla karşılaştırın veya yeniden üretin.

} {draftReview &&

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

{draftReview.note}

diff --git a/tests/offer-source.test.mjs b/tests/offer-source.test.mjs new file mode 100644 index 0000000..fcd8736 --- /dev/null +++ b/tests/offer-source.test.mjs @@ -0,0 +1,17 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {ihaleMetni,teklifDenetle,teklifTaslak} from '../server/ai-teklif.mjs'; +test('an explicitly supplied short source is used intact instead of another tender announcement',async()=>{ + const supplied='Geçerlilik 120 gündür. Numune istenir.'; + const result=await ihaleMetni('2026/123',supplied,'Geçerlilik 60 gündür. '.repeat(20)); + assert.deepEqual(result,{text:supplied,kaynak:'yüklenen'}); +}); +test('explicit empty or invalid sources fail without falling back to a different document',async()=>{ + for(const text of ['', ' ', null, {}, 123]) { + await assert.rejects(ihaleMetni('2026/123',text,'Başka ilan '.repeat(30)),/kaynak metni boş veya geçersiz/); + } +}); +test('audit and draft generation do not swallow explicit source validation errors',async()=>{ + await assert.rejects(teklifDenetle({ikn:'2026/123',text:' ',taslak:'Sentetik taslak '.repeat(30)}),/kaynak metni boş veya geçersiz/); + await assert.rejects(teklifTaslak({ikn:'2026/123',text:' '}),/kaynak metni boş veya geçersiz/); +});