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
+1 -1
View File
@@ -21,7 +21,7 @@ WORKDIR /app
# Playwright + Chromium (yalnızca chromium; sistem bağımlılıklarıyla)
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
RUN npm init -y >/dev/null \
&& npm i --omit=dev playwright@1.62.1 \
&& npm i --omit=dev playwright@1.62.1 @huggingface/transformers@3 \
&& npx playwright install --with-deps chromium \
&& npm cache clean --force
+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);
}
+22 -2
View File
@@ -4,7 +4,7 @@ import { Globe, Bot, Sparkles, CheckCircle2, ShieldCheck, Tag, X, Loader2, Datab
import confetti from 'canvas-confetti';
export const CompanyScannerModal = () => {
const { isCompanyScannerOpen, setIsCompanyScannerOpen, userProfile, scanCompanyWebsite, updateCompanyCapabilities } = useApp();
const { isCompanyScannerOpen, setIsCompanyScannerOpen, userProfile, scanCompanyWebsite, updateCompanyCapabilities, uyumEsigi, setUyumEsigi, gommeDurum } = useApp();
const [urlInput, setUrlInput] = useState(userProfile.website || 'https://www.tanriverdimedikal.com.tr');
const [isScanning, setIsScanning] = useState(false);
@@ -274,10 +274,30 @@ export const CompanyScannerModal = () => {
</div>
{/* Firma eşiği: "uyumlu" filtresi ve bildirim bu skordan itibaren.
(Önceki "%98+ doğruluk" cümlesi ölçülmemiş bir iddiaydı, kaldırıldı.) */}
<div className="bg-white px-6 py-4 border-t border-slate-200 space-y-2">
<div className="flex items-center justify-between gap-3 flex-wrap">
<span className="text-xs font-bold text-slate-800">Uyum eşiği — "Uyumlu" filtresi bu skordan itibaren gösterir</span>
<span className="text-sm font-black text-blue-700">%{uyumEsigi}</span>
</div>
<input
type="range" min={30} max={95} step={5} value={uyumEsigi}
onChange={(e) => setUyumEsigi(e.target.value)}
className="w-full accent-blue-600 cursor-pointer"
/>
<p className="text-[11px] text-slate-500 leading-relaxed">
Skor = profil terimlerinin ihalede geçmesi (başlık, teklif cetveli, OKAS) + anlamsal yakınlık bonusu.
{gommeDurum === 'hazir' && <> Anlamsal bonus <strong className="text-emerald-700">etkin</strong>.</>}
{gommeDurum === 'bekliyor' && <> Anlamsal bonus hesaplanıyor…</>}
{(gommeDurum === 'kapali' || gommeDurum === 'hata') && <> Anlamsal bonus şu an kapalı; yalnızca terim eşleşmesi.</>}
</p>
</div>
{/* Footer */}
<div className="bg-slate-50 p-4 border-t border-slate-200 flex justify-between items-center">
<p className="text-[11px] text-slate-500 font-medium">
💡 Sayfalarınızdaki detaylı ürün kataloğu sayesinde AI ihale eşleşme doğruluk skorunuz <strong className="text-emerald-600">%98+</strong> seviyesine çıkarılır.
Terim ekledikçe skor keskinleşir; kalem havuzu doldukça teklif cetveli eşleşmeleri devreye girer.
</p>
<button
onClick={() => setIsCompanyScannerOpen(false)}
+3 -2
View File
@@ -14,6 +14,7 @@ export const TenderList = () => {
tenders,
searchQuery,
kalemEslesen,
uyumEsigi,
selectedCity,
selectedAlimTuru,
selectedSektorler,
@@ -59,7 +60,7 @@ export const TenderList = () => {
if (onlyValidOnly && !GECMIS_FILTRELER.has(quickFilter) && !acikIhale(tender, bugun)) return false;
// Hızlı filtre (üst kartlar + sol menü) — ölçütler ihaleTarih.js'de tek yerde
if (!hizliFiltreGecer(tender, quickFilter, { favoriteKurumlar: favoriteKurumlar || [], oncekiGirisMs: oncekiGiris })) return false;
if (!hizliFiltreGecer(tender, quickFilter, { favoriteKurumlar: favoriteKurumlar || [], oncekiGirisMs: oncekiGiris, uyumEsigi })) return false;
// Dropdown Filters
if (selectedCity && normTr(tender.sehir) !== normTr(selectedCity)) return false;
@@ -76,7 +77,7 @@ export const TenderList = () => {
if (sortBy === 'distant') return b.tarih.localeCompare(a.tarih);
return 0;
});
}, [tenders, searchQuery, kalemEslesen, selectedCity, selectedAlimTuru, selectedSektorler, selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly, bugun, favoriteKurumlar, oncekiGiris]);
}, [tenders, searchQuery, kalemEslesen, uyumEsigi, selectedCity, selectedAlimTuru, selectedSektorler, selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly, bugun, favoriteKurumlar, oncekiGiris]);
const gorunenTenders = filteredTenders.slice(0, gosterilen);
const dahaVar = gosterilen < filteredTenders.length;
+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,
+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 };
});
}