/** * Şirket–ihale uyum skoru (0-100) + nedenler. * * NEDEN SÖZLÜKSEL: karttaki %Uyumlu rozeti sahteydi (sabit 98/95/91/99). * Profil terimleri (ürünler, sektörler, sertifikalar, ihale konuları) ile * ihalenin başlığı, teklif cetveli kalemleri, OKAS/CPV etiketi ve sektörü * karşılaştırılır. Dış servis yok: 8 bin ihalede anında, deterministik ve * her skorun nedeni gösterilebilir ("‘eldiven’ teklif cetvelinde geçiyor"). * (Fikir: kaanvp/kobi-ihale-eslestirme'nin eşik + TF-IDF açıklama deseni.) */ const ASCII = { ı: 'i', İ: 'i', ş: 's', Ş: 's', ğ: 'g', Ğ: 'g', ç: 'c', Ç: 'c', ö: 'o', Ö: 'o', ü: 'u', Ü: 'u' }; export const norm = (s) => String(s || '').replace(/[ıİşŞğĞçÇöÖüÜ]/g, (c) => ASCII[c]).toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim(); // Tek başına anlam taşımayan kelimeler eşleşme sayılmasın const DUR = new Set(['ve', 'ile', 'icin', 'alimi', 'alim', 'hizmet', 'hizmeti', 'mal', 'malzeme', 'malzemesi', 'isi', 'is', 'adet', 'muhtelif', 'cesitli', 'genel', 'sistem', 'sistemi', 'urun', 'urunleri', 'san', 'tic', 'ltd', 'sti', 'as']); /** Profilden aranabilir terimleri çıkarır: [{terim, kaynak, agirlik}] */ export function profilTerimleri(userProfile) { const c = userProfile?.scrapedCapabilities || {}; const ekle = (liste, kaynak, agirlik) => (liste || []).map((t) => ({ terim: norm(t), ham: String(t).trim(), kaynak, agirlik })); const hepsi = [ ...ekle(c.extractedProducts, 'ürün', 1.0), ...ekle(c.ihaleKonulari, 'ihale konusu', 1.0), ...ekle(c.extractedSectors, 'sektör', 0.6), ...ekle(c.extractedCertifications, 'sertifika', 0.3), ].filter((t) => t.terim.length >= 3 && !DUR.has(t.terim)); // Aynı terim iki kaynaktan gelirse ağırlığı yükseği kalsın const m = new Map(); for (const t of hepsi) if (!m.has(t.terim) || m.get(t.terim).agirlik < t.agirlik) m.set(t.terim, t); return [...m.values()]; } // Terim metinde geçiyor mu — çok kelimeli terimde tüm anlamlı kelimeler aranır function gecerMi(terim, metin) { if (!terim || !metin) return false; if (metin.includes(terim)) return true; const kelimeler = terim.split(' ').filter((k) => k.length >= 4 && !DUR.has(k)); return kelimeler.length >= 2 && kelimeler.every((k) => metin.includes(k)); } /** * Tek ihale için skor. Profilde terim yoksa null döner — kartta sahte yüzde * göstermek yerine rozet çıkmaz. */ export function uyumSkoru(tender, terimler) { if (!terimler?.length) return { skor: null, nedenler: [] }; const alanlar = [ { ad: 'başlık', metin: norm(tender.baslik), agirlik: 1.0 }, { ad: 'teklif cetveli', metin: norm([...(tender.kalemler || []), ...(tender.items || []).map((i) => i.ad)].join(' | ')), agirlik: 1.1 }, { ad: 'OKAS/CPV', metin: norm(tender.cpvLabel), agirlik: 0.7 }, { ad: 'sektör', metin: norm(tender.sektor), agirlik: 0.5 }, { ad: 'ilan metni', metin: norm(String(tender.aciklama || '').slice(0, 3000)), agirlik: 0.6 }, ]; let puan = 0; const nedenler = []; for (const t of terimler) { let enIyi = null; for (const a of alanlar) { if (!a.metin || !gecerMi(t.terim, a.metin)) continue; const katki = t.agirlik * a.agirlik; if (!enIyi || katki > enIyi.katki) enIyi = { alan: a.ad, katki }; } if (enIyi) { puan += enIyi.katki; nedenler.push({ terim: t.ham, kaynak: t.kaynak, alan: enIyi.alan, katki: enIyi.katki }); } } if (!nedenler.length) return { skor: 0, nedenler: [] }; // Doygunluk: 1 güçlü eşleşme ≈ 55, 2 ≈ 75, 3+ ≈ 90; sektör-tek eşleşme ~35 const skor = Math.round(100 * (1 - Math.exp(-puan * 0.8))); nedenler.sort((a, b) => b.katki - a.katki); return { skor: Math.max(0, Math.min(100, skor)), nedenler: nedenler.slice(0, 5) }; } /** Sunucudaki gömme modeline gidecek profil metni (passage). */ export function profilMetni(userProfile) { const c = userProfile?.scrapedCapabilities || {}; const parca = [ c.ozet, c.extractedSectors?.length ? 'Sektör: ' + c.extractedSectors.join(', ') : '', c.extractedProducts?.length ? 'Ürünler: ' + c.extractedProducts.join(', ') : '', c.ihaleKonulari?.length ? 'İhale konuları: ' + c.ihaleKonulari.join(', ') : '', ].filter(Boolean); return parca.join('. ').slice(0, 1000); } /** * Havuzun tamamını puanlar; alan adları kartın ve detayın okuduğu adlar. * bonus: sunucudan gelen anlamsal bonus { [id]: 0..20 } — sözlüksel skora * eklenir. Sözlüksel 0 ise bonus tek başına yalnızca ≥10'sa görünür * (havuz ortalamasının belirgin üstü), "anlamsal yakınlık" nedeniyle. */ export function havuzuPuanla(tenders, userProfile, bonus = null) { const terimler = profilTerimleri(userProfile); if (!terimler.length) return tenders.map((t) => (t.aiMatchScore == null && !t.uyumNedenleri ? t : { ...t, aiMatchScore: null, uyumNedenleri: [] })); return tenders.map((t) => { const { skor, nedenler } = uyumSkoru(t, terimler); const b = bonus?.[t.id] || 0; let toplam = skor; const ned = [...nedenler]; if (b > 0 && (skor > 0 || b >= 10)) { toplam = Math.min(100, skor + b); ned.push({ terim: 'anlamsal yakınlık', kaynak: 'gömme', alan: `+${b} puan`, katki: b / 20 }); } return { ...t, aiMatchScore: toplam, uyumNedenleri: ned, uyumSozluksel: skor, uyumBonus: b }; }); }