fix: preserve bid drafts and align tender source data

This commit is contained in:
2026-09-06 08:40:10 +03:00
parent c91631d99f
commit 456728b4bd
26 changed files with 510 additions and 81 deletions
+8 -6
View File
@@ -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 : ''}`,
+3
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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})`
+27
View File
@@ -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.');
}
}
+1
View File
@@ -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
View File
@@ -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`);
+37
View File
@@ -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;
}