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>
This commit is contained in:
2026-09-03 22:32:23 +03:00
co-authored by Claude Fable 5.1
parent 2c70ed9d71
commit 1716b6365c
8 changed files with 236 additions and 16 deletions
+21
View File
@@ -25,6 +25,7 @@ import { arsivMetni, sunucuOkuyabilir } from './office-text.mjs';
import { kalemleriCikar } from './kalem-cikar.mjs';
import { searchInDocuments, searchByOkas, okasAra } from './ekap-v2.mjs';
import { iknCikar } from './kalem-cikar.mjs';
import { bonusHesapla, gommeDurumu, arkaPlandaIsit } from './uyum-gomme.mjs';
// Ürün araması EKAP'a uzak çağrı yapar; aynı sorgu tekrarlanırsa kaynağı yorma.
const urunAramaCache = new Map();
@@ -799,6 +800,24 @@ const server = http.createServer(async (req, res) => {
return json(res, 502, { success: false, error: e.message });
}
}
// --- Anlamsal uyum bonusu: profil metni ↔ havuz (sunucuda gömme) ---
if (url.pathname === '/api/uyum/gomme' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
try {
const body = await readBody(req, 64 * 1024);
const metin = String(body.profilMetni || '').trim();
if (metin.length < 10) return json(res, 400, { success: false, error: 'profil metni yok' });
const c = await getTenders(false);
const out = await bonusHesapla(metin, c.tenders || []);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 500, { success: false, error: e.message });
}
}
if (url.pathname === '/api/health/uyum') {
return json(res, 200, { success: true, ...gommeDurumu() });
}
if (url.pathname === '/api/health/kalem') {
return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() });
}
@@ -1343,6 +1362,8 @@ const server = http.createServer(async (req, res) => {
});
server.listen(PORT, () => {
// Gömme modeli soğuk yüklemesi ~45 sn; ilk kullanıcı beklemesin diye arka planda ısıt.
arkaPlandaIsit();
console.log(`[ihaleist-api] http://localhost:${PORT} — canlı veri: ilan.gov.tr`);
getTenders().catch((e) => console.error('[ihaleist-api] ilk yükleme hatası:', e.message));
// Bildirim işi tender verisine ihtiyaç duyar; enjekte ederek başlat
+115
View File
@@ -0,0 +1,115 @@
/**
* 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);
}