Honor explicitly supplied sources and disclose draft source changes

This commit is contained in:
2026-09-06 10:18:28 +03:00
parent 2b9e1953e4
commit c3ec8c8ba8
4 changed files with 42 additions and 7 deletions
+10
View File
@@ -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. 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ı. 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ı.
+6 -3
View File
@@ -43,7 +43,10 @@ export function getir(parcalar, soru, k = 8) {
/** İhalenin metni: verilen metin > EKAP dokümanı (tarayıcı varsa) > ilan metni. */ /** İhalenin metni: verilen metin > EKAP dokümanı (tarayıcı varsa) > ilan metni. */
export async function ihaleMetni(ikn, verilen, ilan) { 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))) { 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üş */ } 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 }) { export async function teklifDenetle({ ikn, taslak, gereklilikler, tender, chain, text }) {
const t = String(taslak ?? '').trim().slice(0, 60000); const t = String(taslak ?? '').trim().slice(0, 60000);
if (t.length < 200) throw new Error('Taslak en az 200 karakter olmalı.'); 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); 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.'); 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.`; 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 }) { export async function teklifTaslak({ ikn, tur, tender, firma, belgeler, fiyat, gereklilikler, chain, text }) {
const istenen = BOLUMLER[tur] || BOLUMLER.tam; 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 liste = gereklilikMetni(tender, gereklilikler, metin);
const baglam = [ 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 : ''}`, `İ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 : ''}`,
+9 -4
View File
@@ -32,6 +32,7 @@ const OfferAssistantSession = ({ tender }) => {
const post = (url, body, documents) => requestOfferAssistant({url, body, readToken:getToken, isCurrent:current, listDocuments:documents}); const post = (url, body, documents) => requestOfferAssistant({url, body, readToken:getToken, isCurrent:current, listDocuments:documents});
const [arac, setArac] = useState('sor'); const [arac, setArac] = useState('sor');
const [metin, setMetin] = useState(''); // isteğe bağlı: yapıştırılan doküman metni const [metin, setMetin] = useState(''); // isteğe bağlı: yapıştırılan doküman metni
const [sourceName, setSourceName] = useState('');
const [soru, setSoru] = useState(''); const [soru, setSoru] = useState('');
const [cevap, setCevap] = useState(null); const [cevap, setCevap] = useState(null);
const [taslak, setTaslak] = useState(''); const [taslak, setTaslak] = useState('');
@@ -39,6 +40,7 @@ const OfferAssistantSession = ({ tender }) => {
const [tur, setTur] = useState('tam'); const [tur, setTur] = useState('tam');
const [bolumler, setBolumler] = useState([]); const [bolumler, setBolumler] = useState([]);
const [draftReview, setDraftReview] = useState(null); const [draftReview, setDraftReview] = useState(null);
const [draftSource, setDraftSource] = useState(null);
const [mesgul, setMesgul] = useState(false); const [mesgul, setMesgul] = useState(false);
const [hata, setHata] = useState(null); const [hata, setHata] = useState(null);
@@ -59,7 +61,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 || []); 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 kopyala = (t) => { if (current()) navigator.clipboard?.writeText(t).catch(() => {}); };
const dosyaOku = (f) => { const dosyaOku = (f) => {
@@ -68,7 +70,8 @@ const OfferAssistantSession = ({ tender }) => {
const { extractText } = await import('../services/pdfText'); const { extractText } = await import('../services/pdfText');
if (!current()) return; if (!current()) return;
const r = await extractText(f); 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 }) => {
<Sekme id="denet" icon={ClipboardCheck}>Taslağı Denetle</Sekme> <Sekme id="denet" icon={ClipboardCheck}>Taslağı Denetle</Sekme>
<Sekme id="uret" icon={PenLine}>Taslak Üret</Sekme> <Sekme id="uret" icon={PenLine}>Taslak Üret</Sekme>
<label className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-slate-600 cursor-pointer border border-slate-200 rounded-lg px-2 py-1 hover:bg-slate-50"> <label className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-slate-600 cursor-pointer border border-slate-200 rounded-lg px-2 py-1 hover:bg-slate-50">
<Upload className="w-3.5 h-3.5" /> {metin ? `Doküman metni yüklendi (${metin.length.toLocaleString('tr-TR')} kr.)` : 'Doküman yükle (.pdf/.txt) — isteğe bağlı'} <Upload className="w-3.5 h-3.5" /> {metin ? `Kaynak: ${sourceName} (${metin.length.toLocaleString('tr-TR')} kr.)` : 'Doküman yükle (.pdf/.txt) — isteğe bağlı'}
<input type="file" disabled={mesgul} accept=".pdf,.txt,.md" className="hidden" onChange={(e) => dosyaOku(e.target.files?.[0])} /> <input type="file" disabled={mesgul} accept=".pdf,.txt,.md" className="hidden" onChange={(e) => {const file=e.target.files?.[0];e.target.value='';dosyaOku(file);}} />
</label> </label>
</div> </div>
{metin && <button type="button" disabled={mesgul} className="text-xs text-blue-700 underline" onClick={() => {setMetin('');setSourceName('');setCevap(null);setDenetim(null);}}>Yüklenen kaynağı kaldır ve ilan kaynağını kullan</button>}
<p className="text-[11px] text-slate-500">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.</p> <p className="text-[11px] text-slate-500">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.</p>
{hata && <p role="alert" className="text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg p-2">{hata}</p>} {hata && <p role="alert" className="text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg p-2">{hata}</p>}
@@ -155,6 +159,7 @@ 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>
{bolumler.length > 0 && draftSource !== metin && <p role="alert" className="text-xs text-amber-800">Kaynak değişti. Aşağıdaki taslak önceki kaynakla üretildi; düzenlemeleriniz korundu. Yeni kaynakla karşılaştırın veya yeniden üretin.</p>}
{draftReview && <div role="status" className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900"> {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 className="font-semibold">{draftReview.status === 'partial' ? 'Kısmi taslak — bölüm kontrolü gerekli' : 'Taslak — kullanıcı kontrolü gerekli'}</p>
<p>{draftReview.note}</p> <p>{draftReview.note}</p>
+17
View File
@@ -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/);
});