Honor explicitly supplied sources and disclose draft source changes
This commit is contained in:
@@ -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ı.
|
||||||
|
|||||||
@@ -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 : ''}`,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user