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
+45 -6
View File
@@ -1,6 +1,6 @@
import React, {createContext, useContext, useState, useEffect, useMemo } from 'react';
import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService';
import { havuzuPuanla } from '../services/uyum';
import { havuzuPuanla, profilMetni, profilTerimleri } from '../services/uyum';
import { getToken } from '../services/auth';
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
import { restoreSession, clearSession, getStoredUser } from '../services/auth';
@@ -424,17 +424,53 @@ export const AppProvider = ({ children }) => {
trialDaysLeft: 7,
role: 'Yönetici Tedarikçi',
scrapedCapabilities: { ...BOS_YETKINLIK, ...(kayitli?.scrapedCapabilities || {}) },
// Firma eşiği: "uyumlu" filtresi ve bildirim bu skordan itibaren
uyumEsigi: Number.isFinite(kayitli?.uyumEsigi) ? kayitli.uyumEsigi : 60,
};
});
useEffect(() => {
const veri = { company: userProfile.company, website: userProfile.website, scrapedCapabilities: userProfile.scrapedCapabilities };
const veri = {
company: userProfile.company, website: userProfile.website,
scrapedCapabilities: userProfile.scrapedCapabilities, uyumEsigi: userProfile.uyumEsigi,
};
yerelKaydet('ihaleist_firma_profil', veri);
if (veriSenkron) sunucuyaYaz('firma_profil', veri);
}, [userProfile.company, userProfile.website, userProfile.scrapedCapabilities, veriSenkron]);
}, [userProfile.company, userProfile.website, userProfile.scrapedCapabilities, userProfile.uyumEsigi, veriSenkron]);
const setUyumEsigi = (v) => setUserProfile((p) => ({ ...p, uyumEsigi: Math.max(0, Math.min(100, Number(v) || 0)) }));
// Uyum skoru: havuz ya da profil değişince tüm ihaleler yeniden puanlanır.
// Sözlüksel, dış servis yok — 8 bin ihalede anında.
const tendersSkorlu = useMemo(() => havuzuPuanla(tenders, userProfile), [tenders, userProfile.scrapedCapabilities]);
// Anlamsal bonus: profil metni sunucuda gömülür, havuzla kosinüs alınır,
// z-skor → 0..20. Profil terimi yoksa ya da oturum yoksa istenmez. Havuz
// 10 dk'da bir yenilendiği için ihale sayısı değişince tazelenir.
const [gommeBonus, setGommeBonus] = useState(null);
const [gommeDurum, setGommeDurum] = useState('kapali'); // kapali | bekliyor | hazir | hata
const profilMetin = useMemo(() => profilMetni(userProfile), [userProfile.scrapedCapabilities]);
useEffect(() => {
if (!profilTerimleri(userProfile).length || !getToken() || !tenders.length) { setGommeBonus(null); return; }
let iptal = false;
setGommeDurum('bekliyor');
const z = setTimeout(() => {
fetch('/api/uyum/gomme', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ profilMetni: profilMetin }),
})
.then((r) => r.json())
.then((d) => {
if (iptal) return;
if (d.success && d.hazir) { setGommeBonus(d.bonus || {}); setGommeDurum('hazir'); }
else { setGommeBonus(null); setGommeDurum(d.success ? 'kapali' : 'hata'); }
})
.catch(() => { if (!iptal) { setGommeBonus(null); setGommeDurum('hata'); } });
}, 800);
return () => { iptal = true; clearTimeout(z); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profilMetin, tenders.length]);
// Uyum skoru: sözlüksel (anında, açıklanabilir) + anlamsal bonus (sunucu).
const tendersSkorlu = useMemo(
() => havuzuPuanla(tenders, userProfile, gommeBonus),
[tenders, userProfile.scrapedCapabilities, gommeBonus],
);
// Önce gerçek tarama (/api/company/scan: site metni + model), başarısızsa
// alan adından tahmin eden eski yol. Sonuç uyum skorunun girdisi olur.
@@ -569,6 +605,9 @@ export const AppProvider = ({ children }) => {
activeTab,
setActiveTab,
tenders: tendersSkorlu,
uyumEsigi: userProfile.uyumEsigi,
setUyumEsigi,
gommeDurum,
setTenders,
isLiveMode,
setIsLiveMode,