Files
ihaleist/server/uyum-gomme.mjs
T
hikmettanandClaude Fable 5.1 1716b6365c feat: hibrit uyum skoru (sozluksel + anlamsal bonus) ve firma esigi
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>
2026-09-03 22:32:23 +03:00

116 lines
4.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}