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>
116 lines
4.4 KiB
JavaScript
116 lines
4.4 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|