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
+2 -2
View File
@@ -57,9 +57,9 @@ export const acikIhale = (t, bugun) => {
export const GECMIS_FILTRELER = new Set(['arsiv', 'sonuclananlar', 'sozlesmeler', 'iptal']);
// Hızlı filtre (üst kartlar + sol menü) ölçütü. Bilinmeyen id → her şey geçer.
export const hizliFiltreGecer = (t, id, { favoriteKurumlar = [], oncekiGirisMs = null } = {}) => {
export const hizliFiltreGecer = (t, id, { favoriteKurumlar = [], oncekiGirisMs = null, uyumEsigi = 60 } = {}) => {
switch (id) {
case 'uyumlu': return (t.aiMatchScore ?? 0) >= 60; // profil terimleri ihalede geçiyor
case 'uyumlu': return (t.aiMatchScore ?? 0) >= uyumEsigi; // firma eşiği (profil ayarı)
case 'favori_kurumlar': return favoriteKurumlar.includes((t.kurum || '').trim());
case 'dogrudan_temin': return t.alimTuru === 'Doğrudan Temin';
case 'ihaleler': return t.alimTuru !== 'Doğrudan Temin' && t.durum !== 'Sonuçlandı';
+27 -3
View File
@@ -75,12 +75,36 @@ export function uyumSkoru(tender, terimler) {
return { skor: Math.max(0, Math.min(100, skor)), nedenler: nedenler.slice(0, 5) };
}
/** Havuzun tamamını puanlar; alan adları kartın ve detayın okuduğu adlar. */
export function havuzuPuanla(tenders, userProfile) {
/** 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);
return { ...t, aiMatchScore: skor, uyumNedenleri: nedenler };
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 };
});
}