fix: preserve bid drafts and align tender source data
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { documentContent } from './document-content.mjs';
|
||||
// Teklif asistanı — İhaleAB'den taşınan üç AI özelliği, ihaleist'in mevcut
|
||||
// analyzeSpec zinciri (yedekli sağlayıcılar) üzerinde:
|
||||
// 1) belgeyeSor: ihale dokümanı/ilan metninde soru → atıflı yanıt
|
||||
@@ -47,21 +48,22 @@ 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) {
|
||||
export async function ihaleMetni(ikn, verilen, ilan) {
|
||||
if (verilen && String(verilen).trim().length > 200) return { text: String(verilen), kaynak: 'yüklenen' };
|
||||
if (ikn && (await browserAvailable().catch(() => false))) {
|
||||
try { const r = await documentText(ikn); if (r?.text && r.text.length > 200) return { text: r.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üş */ }
|
||||
}
|
||||
if (ikn) {
|
||||
try { const r = await ilanMetni(ikn); const text = typeof r === 'string' ? r : r?.text; if (text && text.length > 100) return { text, kaynak: 'ilan metni' }; } catch { /* yok */ }
|
||||
try { const text = documentContent(await ilanMetni(ikn)); if (text && text.length > 100) return { text, kaynak: 'ilan metni' }; } catch { /* yok */ }
|
||||
}
|
||||
if (String(ilan || '').trim().length > 100) return {text:String(ilan),kaynak:'İhale ilanı (teknik şartname değil)'};
|
||||
return { text: '', kaynak: null };
|
||||
}
|
||||
|
||||
export async function belgeyeSor({ ikn, soru, text, tender, chain }) {
|
||||
const q = String(soru ?? '').trim().slice(0, 500);
|
||||
if (q.length < 3) throw new Error('Soru en az 3 karakter olmalı.');
|
||||
const { text: metin, kaynak } = await ihaleMetni(ikn, text);
|
||||
const { text: metin, kaynak } = await ihaleMetni(ikn, text, tender?.aciklama);
|
||||
if (!metin) throw new Error('Bu ihale için doküman metni bulunamadı — PDF\'i "Yapay Zeka Asistanı"ndan yükleyip tekrar sorun.');
|
||||
const parcalar = parcala(metin);
|
||||
const kaynaklar = getir(parcalar, q, 8);
|
||||
@@ -96,7 +98,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).catch(() => ({ text: '' }));
|
||||
const { text: metin } = await ihaleMetni(ikn, text, tender?.aciklama).catch(() => ({ text: '' }));
|
||||
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.`;
|
||||
@@ -121,7 +123,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).catch(() => ({ text: '' }));
|
||||
const { text: metin } = await ihaleMetni(ikn, text, tender?.aciklama).catch(() => ({ text: '' }));
|
||||
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 : ''}`,
|
||||
|
||||
@@ -109,6 +109,9 @@ Metinde olmayan bilgiyi UYDURMA — bilinmiyorsa null veya boş dizi kullan.`;
|
||||
export const DERIN_SYSTEM_PROMPT = `Sen Türkiye kamu ihale mevzuatı (4734 sayılı Kamu İhale Kanunu, 4735 sayılı Kamu İhale Sözleşmeleri Kanunu ve ikincil mevzuat) konusunda çok deneyimli bir ihale danışmanısın.
|
||||
Sana verilen teknik şartname / idari şartname / sözleşme tasarısı / ihale ilanı metnini madde madde incele.
|
||||
İki şey üret: (1) derinlemesine analiz, (2) teklif anına kadar izlenecek somut yol haritası.
|
||||
Kaynak metinde bulunmayan hukuki sonuç çıkartma. Konsorsiyum ile iş ortaklığı farklı kavramlardır; birinin yasağını diğerine genişletme.
|
||||
Bir katsayıyı yaklaşık maliyet yüzdesi olarak yorumlama; formül metinde yoksa hesaplama yapma.
|
||||
Her kritik koşulun madde numarasını ve metindeki dayanağını belirt. Bölümler arasında bulundu/bulunamadı çelişkisi oluşturma.
|
||||
|
||||
Özellikle şunları ara — ihaleyi kaybettiren ama gözden kaçan detaylar bunlardır:
|
||||
- Teklif zarfında eksikse doğrudan değerlendirme dışı bırakılma sebepleri
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Normalize document providers without losing their source label. */
|
||||
export function documentContent(result) {
|
||||
if (typeof result === 'string') return result.trim();
|
||||
return String(result?.metin || result?.text || '').trim();
|
||||
}
|
||||
+2
-2
@@ -95,8 +95,8 @@ function mapDtRow(row) {
|
||||
kurum,
|
||||
sehir: row.ili && !row.ili.startsWith('Plaka') ? titleCaseTr(row.ili) : '',
|
||||
bolge: '',
|
||||
tarih: row.dt_tarihi || '',
|
||||
saat: (det.tarihSaat || '').split(' ')[1] || '',
|
||||
tarih: (row.dt_tarihi || '').split(' ')[0],
|
||||
saat: (det.tarihSaat || row.dt_tarihi || '').match(/\d{2}:\d{2}/)?.[0] || '',
|
||||
alimTuru: 'Doğrudan Temin',
|
||||
usul: det.yasaMaddesi
|
||||
? `Doğrudan Temin (4734 ${det.yasaMaddesi})`
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export const RATE_SOURCE = 'https://www.tcmb.gov.tr/kurlar/today.xml';
|
||||
export function parseRates(xml) {
|
||||
const bulletinDate = /<Tarih_Date\b[^>]*\bTarih="([^"]+)"/.exec(xml)?.[1];
|
||||
if (!bulletinDate) throw new Error('TCMB bülten tarihi okunamadı.');
|
||||
const rates={};
|
||||
for (const code of ['USD','EUR']) {
|
||||
const block=new RegExp(`<Currency\\b[^>]*CurrencyCode="${code}"[^>]*>([\\s\\S]*?)</Currency>`).exec(xml)?.[1];
|
||||
const unit=Number(/<Unit>([^<]+)<\/Unit>/.exec(block || '')?.[1]);
|
||||
const selling=Number(/<ForexSelling>([^<]+)<\/ForexSelling>/.exec(block || '')?.[1]);
|
||||
if (!(unit>0) || !(selling>0)) throw new Error(`${code} döviz satış kuru okunamadı.`);
|
||||
rates[code]=selling/unit;
|
||||
}
|
||||
return {bulletinDate,rates,type:'Döviz satış',source:RATE_SOURCE};
|
||||
}
|
||||
let cache;
|
||||
export async function exchangeRates() {
|
||||
if (cache && Date.now()-cache.at<15*60*1000) return {...cache.value,cached:true};
|
||||
try {
|
||||
const response=await fetch(RATE_SOURCE,{signal:AbortSignal.timeout(10000)});
|
||||
if (!response.ok) throw new Error('TCMB kaynağına ulaşılamadı.');
|
||||
const value={...parseRates(await response.text()),fetchedAt:new Date().toISOString()};
|
||||
cache={at:Date.now(),value};return {...value,cached:false};
|
||||
} catch(error) {
|
||||
if(cache) return {...cache.value,cached:true,refreshError:'Güncelleme alınamadı; gösterilen bülten tarihini kontrol edin.'};
|
||||
throw new Error('Kur alınamadı. Daha sonra yeniden deneyin.');
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,7 @@ function rowToTender(r) {
|
||||
id: `hist-${r.ikn}-${r.kisim}`,
|
||||
kaynak: 'EKAP Sonuç İlanı',
|
||||
ikn: r.ikn,
|
||||
kisim: r.kisim,
|
||||
baslik: r.ihale_adi || 'İhale',
|
||||
kurum: r.idare_adi || '—',
|
||||
sehir: titleCaseTr(r.il),
|
||||
|
||||
+13
-4
@@ -1,3 +1,5 @@
|
||||
import { exchangeRates } from './exchange-rates.mjs';
|
||||
import { uniqueTenders, announcementFields } from './tender-quality.mjs';
|
||||
/**
|
||||
* ihaleist API Proxy — ilan.gov.tr canlı ihale verisi
|
||||
*
|
||||
@@ -582,7 +584,7 @@ async function getDetail(ilanId) {
|
||||
if (cache.detail.has(ilanId)) return cache.detail.get(ilanId);
|
||||
const raw = await ilanDetail(ilanId);
|
||||
const text = htmlToText(raw.content || '');
|
||||
const detail = { aciklama: text.slice(0, 4000), adNo: raw.adNo || null };
|
||||
const detail = { ...announcementFields(text), adNo: raw.adNo || null };
|
||||
cache.detail.set(ilanId, detail);
|
||||
if (cache.detail.size > 500) cache.detail.delete(cache.detail.keys().next().value);
|
||||
return detail;
|
||||
@@ -661,22 +663,28 @@ async function tendersPayload(force = false) {
|
||||
console.warn('[kalem] özet uygulanamadı:', e.message);
|
||||
}
|
||||
|
||||
const combined = uniqueTenders([...dt.tenders, ...c.tenders, ...hist.tenders]);
|
||||
return {
|
||||
success: true,
|
||||
source: sources.join(' + '),
|
||||
fetchedAt: new Date(c.fetchedAt).toLocaleString('tr-TR'),
|
||||
totalCount: c.totalCount + dt.totalCount + hist.totalCount,
|
||||
ilanCount: c.totalCount,
|
||||
todayCount: c.todayCount,
|
||||
todayCount: bugunEklenenSayisi(combined),
|
||||
loadedUniqueCount: combined.length,
|
||||
dtCount: dt.totalCount,
|
||||
historyCount: hist.totalCount,
|
||||
tenders: [...dt.tenders, ...c.tenders, ...hist.tenders],
|
||||
tenders: combined,
|
||||
};
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||
try {
|
||||
if (url.pathname === '/api/exchange-rates') {
|
||||
try { return json(res,200,{success:true,...await exchangeRates()}); }
|
||||
catch(e) { return json(res,503,{success:false,error:e.message}); }
|
||||
}
|
||||
// --- Google ile Giriş ---
|
||||
if (url.pathname === '/api/auth/google/config') {
|
||||
return json(res, 200, { clientId: process.env.GOOGLE_CLIENT_ID || null });
|
||||
@@ -1507,7 +1515,8 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
server.listen(PORT, process.env.NODE_ENV === 'test' ? '127.0.0.1' : undefined, () => {
|
||||
if (process.env.NODE_ENV === 'test') return;
|
||||
// Gömme modeli soğuk yüklemesi ~45 sn; ilk kullanıcı beklemesin diye arka planda ısıt.
|
||||
arkaPlandaIsit();
|
||||
console.log(`[ihaleist-api] http://localhost:${PORT} — canlı veri: ilan.gov.tr`);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
const norm = v => String(v || '').trim().replace(/\s+/g,' ').toLocaleLowerCase('tr-TR');
|
||||
export function tenderIdentity(t) {
|
||||
const ikn = String(t.ikn || '').replace(/\s/g,'');
|
||||
// Awards from different lots must never be collapsed into one contract.
|
||||
if (t.durum === 'Sonuçlandı') return `award:${ikn}:${t.kisim ?? t.id}:${norm(t.kazananFirma)}`;
|
||||
if (/^\d{2}DT\d+$/i.test(ikn)) return `dt:${ikn.toUpperCase()}:${norm(t.kurum)}`;
|
||||
// Corrections/cancellations remain separate events until their source linkage is verified.
|
||||
if (/düzeltme|zeyilname|iptal/i.test(t.baslik || '')) return `event:${t.id}`;
|
||||
return /^\d{4}\/\d+$/.test(ikn) ? `tender:${ikn}` : `source:${t.id}`;
|
||||
}
|
||||
export function uniqueTenders(records) {
|
||||
const found = new Map();
|
||||
for (const t of records) {
|
||||
const key = tenderIdentity(t), old = found.get(key);
|
||||
if (!old) { found.set(key,{...t,sourceIds:[t.id]}); continue; }
|
||||
const sourceIds = [...new Set([...old.sourceIds,t.id])];
|
||||
const merged = {...old};
|
||||
for (const [field,value] of Object.entries(t)) {
|
||||
if (merged[field] == null || merged[field] === '' || (Array.isArray(merged[field]) && !merged[field].length)) merged[field] = value;
|
||||
}
|
||||
if ((t.aciklama || '').length > (old.aciklama || '').length) merged.aciklama = t.aciklama;
|
||||
found.set(key,{...merged,id:old.id,sourceIds});
|
||||
}
|
||||
return [...found.values()];
|
||||
}
|
||||
export function announcementFields(text) {
|
||||
const content = String(text || '');
|
||||
const result = {aciklama:content};
|
||||
const deadline = /Tarih\s*ve\s*Saati\s*:\s*(\d{2}\.\d{2}\.\d{4})\s*[-–]?\s*(\d{2}:\d{2})/i.exec(content);
|
||||
if (deadline) { result.tarih = deadline[1]; result.saat = deadline[2]; }
|
||||
const place = /3\.3\.?\s*[^:]{0,120}:\s*([\s\S]*?)(?=3\.4\.?)/i.exec(content);
|
||||
const duration = /3\.4\.?\s*[^:]{0,100}:\s*([\s\S]*?)(?=3\.5\.?|4\s*[-–])/i.exec(content);
|
||||
if (place) result.teslimYeri = place[1].trim();
|
||||
if (duration) result.teslimatSuresi = duration[1].trim();
|
||||
if (/hizmet alımı\s+4734/i.test(content)) result.alimTuru = 'Kiralama ve Hizmet Alımı';
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user