/** * Anlamsal uyum bonusu — çok dilli gömme (multilingual-e5-small, ONNX q8). * * NEDEN BONUS, NEDEN EŞİK DEĞİL: ölçtük — e5 kosinüsleri 0,79-0,86 bandına * sıkışıyor; kırtasiye ile röntgen arasında 0,06 var. Mutlak eşik olarak * (kaanvp/kobi-ihale-eslestirme'nin 0,65'i) trafo bakımını da "eşleşti" * sayar. Ama sıralama doğru ve sözlükselin kaçırdığını yakalıyor ("GENEL * TIBBİ SARF" profilde birebir terim yokken kırtasiyenin üstünde). O yüzden * havuz içinde z-skorlanıp sözlüksel skora +0..20 puan eklenir. * * ÇALIŞMA: sunucuda, Python/GPU yok (@huggingface/transformers). Model ilk * seferde indirilip data/modeller'e (volume) yazılır; ~118 MB, soğuk yükleme * ~45 sn — o yüzden açılışta arka planda ısıtılır. Paket yoksa ya da yükleme * başarısızsa özellik sessizce kapanır, sözlüksel skor tek başına kalır. */ import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const MODEL_DIR = path.join(__dirname, 'data', 'modeller'); const MODEL = process.env.UYUM_GOMME_MODEL || 'Xenova/multilingual-e5-small'; const ACIK = process.env.UYUM_GOMME !== '0'; const MAX_BONUS = 20; let durum = { hazir: false, yukleniyor: false, hata: null, model: MODEL }; let enc = null; // ihale id → { imza, vek } — havuz her 10 dk yenileniyor, yalnızca metni // değişen/yeni ihale yeniden gömülür. const onbellek = new Map(); export function gommeDurumu() { return { ...durum, acik: ACIK, onbellek: onbellek.size }; } export async function modeliYukle() { if (!ACIK || enc || durum.yukleniyor) return enc; durum.yukleniyor = true; try { const tf = await import('@huggingface/transformers'); tf.env.cacheDir = MODEL_DIR; enc = await tf.pipeline('feature-extraction', MODEL, { dtype: 'q8' }); durum = { ...durum, hazir: true, hata: null }; console.log(`[uyum-gomme] model hazır: ${MODEL}`); } catch (e) { durum = { ...durum, hazir: false, hata: e.message }; console.warn('[uyum-gomme] kapalı:', e.message.slice(0, 160)); } finally { durum.yukleniyor = false; } return enc; } async function vektor(metin) { const out = await enc(metin, { pooling: 'mean', normalize: true }); return Float32Array.from(out.data); } const ihaleMetni = (t) => ['query:', t.baslik, t.cpvLabel, t.sektor, ...(t.kalemler || []).slice(0, 6)].filter(Boolean).join(' ').slice(0, 600); const imza = (s) => crypto.createHash('sha1').update(s).digest('hex').slice(0, 16); /** * Profil metni ile havuzdaki her ihale arasında kosinüs; havuz içi z-skor → * 0..MAX_BONUS. Dönüş: { hazir, bonus: { [id]: puan } }. */ export async function bonusHesapla(profilMetni, tenders) { if (!ACIK) return { hazir: false, bonus: {} }; if (!enc) await modeliYukle(); if (!enc || !profilMetni || !tenders?.length) return { hazir: !!enc, bonus: {} }; const p = await vektor('passage: ' + String(profilMetni).slice(0, 1000)); // Eksik/eskimiş gömmeleri tamamla (toplu, sırayla — CPU'da 5 ms/ihale) for (const t of tenders) { const metin = ihaleMetni(t); const iz = imza(metin); const mevcut = onbellek.get(t.id); if (mevcut && mevcut.imza === iz) continue; onbellek.set(t.id, { imza: iz, vek: await vektor(metin) }); } // Havuzdan düşenleri at if (onbellek.size > tenders.length * 2) { const canli = new Set(tenders.map((t) => t.id)); for (const k of onbellek.keys()) if (!canli.has(k)) onbellek.delete(k); } const kos = tenders.map((t) => { const v = onbellek.get(t.id)?.vek; let s = 0; if (v) for (let i = 0; i < v.length; i++) s += v[i] * p[i]; return s; }); const n = kos.length; const ort = kos.reduce((a, b) => a + b, 0) / n; const sd = Math.sqrt(kos.reduce((a, b) => a + (b - ort) ** 2, 0) / n) || 1e-6; const bonus = {}; tenders.forEach((t, i) => { const z = (kos[i] - ort) / sd; // z ≈ -0,5 → 0, z ≈ 2 → MAX: yalnızca havuz ortalamasının belirgin // üstündekiler puan alır const b = Math.round(MAX_BONUS * Math.max(0, Math.min(1, (z + 0.5) / 2.5))); if (b > 0) bonus[t.id] = b; }); return { hazir: true, bonus, ort: Number(ort.toFixed(3)), sd: Number(sd.toFixed(3)) }; } /** Açılışta arka planda ısıt — ilk kullanıcı 45 sn beklemesin. */ export function arkaPlandaIsit(gecikmeMs = 15000) { if (!ACIK) return; setTimeout(() => { modeliYukle().catch(() => {}); }, gecikmeMs); }