Anlamsal bonus: profil metni sunucuda gomulur (multilingual-e5-small, ONNX q8, @huggingface/transformers — Python/GPU yok), havuzdaki her ihaleyle kosinus alinir, havuz ici z-skor → +0..20 puan. Sozluksel skor ana eksen kalir; bonus eklenir. Sozluksel 0 iken bonus yalniz ≥10'sa gorunur. NEDEN BONUS, ESIK DEGIL (olculdu): e5 kosinusleri 0,79-0,86 bandina sikisiyor (havuz ort 0,814 sd 0,026); kirtasiye ile rontgen arasi 0,06. kaanvp/kobi-ihale-eslestirme'nin 0,65 esigiyle trafo bakimi da "eslesti" sayilirdi. Ama siralama dogru ve sozlukselin kacirdigini yakaliyor: "GENEL TIBBI SARF" profilde birebir terim yokken kirtasiyenin ustune cikiyor. - server/uyum-gomme.mjs: acilista arka planda isitma (~45 sn soguk), ihale gommeleri bellekte (metin imzasi degisince yenilenir), model data/modeller volume'unda bir kez indirilir; paket/yukleme yoksa sessizce kapali - POST /api/uyum/gomme, GET /api/health/uyum - Dockerfile: calisma imajina @huggingface/transformers@3 (package.json'a degil — sunucu sifir-bagimlilik, calisma katmani ayri npm i) - Olculen: 3.2 bin ihale ilk cagri 7,6 sn (tumu gomuluyor), ikinci 17 ms; +20 alan 170, ≥10 alan 610 ihale Firma esigi: profilde uyumEsigi (varsayilan 60, kalici); "Uyumlu" hizli filtresi bu esigi kullanir; profil modalinda kaydirici + bonus durumu. Modaldaki "%98+ dogruluk" cumlesi kaldirildi — olculmemis iddiaydi. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
111 lines
5.3 KiB
JavaScript
111 lines
5.3 KiB
JavaScript
/**
|
||
* Ş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 };
|
||
});
|
||
}
|