Files
ihaleist/src/services/uyum.js
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

111 lines
5.3 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.
/**
* Şirket–ihale uyum skoru (0-100) + nedenler.
*
* NEDEN SÖZLÜKSEL: karttaki %Uyumlu rozeti sahteydi (sabit 98/95/91/99).
* Profil terimleri (ürünler, sektörler, sertifikalar, ihale konuları) ile
* ihalenin başlığı, teklif cetveli kalemleri, OKAS/CPV etiketi ve sektörü
* karşılaştırılır. Dış servis yok: 8 bin ihalede anında, deterministik ve
* her skorun nedeni gösterilebilir ("‘eldiven’ teklif cetvelinde geçiyor").
* (Fikir: kaanvp/kobi-ihale-eslestirme'nin eşik + TF-IDF açıklama deseni.)
*/
const ASCII = { ı: 'i', İ: 'i', ş: 's', Ş: 's', ğ: 'g', Ğ: 'g', ç: 'c', Ç: 'c', ö: 'o', Ö: 'o', ü: 'u', Ü: 'u' };
export const norm = (s) =>
String(s || '').replace(/[ıİşŞğĞçÇöÖüÜ]/g, (c) => ASCII[c]).toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
// Tek başına anlam taşımayan kelimeler eşleşme sayılmasın
const DUR = new Set(['ve', 'ile', 'icin', 'alimi', 'alim', 'hizmet', 'hizmeti', 'mal', 'malzeme', 'malzemesi',
'isi', 'is', 'adet', 'muhtelif', 'cesitli', 'genel', 'sistem', 'sistemi', 'urun', 'urunleri', 'san', 'tic', 'ltd', 'sti', 'as']);
/** Profilden aranabilir terimleri çıkarır: [{terim, kaynak, agirlik}] */
export function profilTerimleri(userProfile) {
const c = userProfile?.scrapedCapabilities || {};
const ekle = (liste, kaynak, agirlik) => (liste || []).map((t) => ({ terim: norm(t), ham: String(t).trim(), kaynak, agirlik }));
const hepsi = [
...ekle(c.extractedProducts, 'ürün', 1.0),
...ekle(c.ihaleKonulari, 'ihale konusu', 1.0),
...ekle(c.extractedSectors, 'sektör', 0.6),
...ekle(c.extractedCertifications, 'sertifika', 0.3),
].filter((t) => t.terim.length >= 3 && !DUR.has(t.terim));
// Aynı terim iki kaynaktan gelirse ağırlığı yükseği kalsın
const m = new Map();
for (const t of hepsi) if (!m.has(t.terim) || m.get(t.terim).agirlik < t.agirlik) m.set(t.terim, t);
return [...m.values()];
}
// Terim metinde geçiyor mu — çok kelimeli terimde tüm anlamlı kelimeler aranır
function gecerMi(terim, metin) {
if (!terim || !metin) return false;
if (metin.includes(terim)) return true;
const kelimeler = terim.split(' ').filter((k) => k.length >= 4 && !DUR.has(k));
return kelimeler.length >= 2 && kelimeler.every((k) => metin.includes(k));
}
/**
* Tek ihale için skor. Profilde terim yoksa null döner — kartta sahte yüzde
* göstermek yerine rozet çıkmaz.
*/
export function uyumSkoru(tender, terimler) {
if (!terimler?.length) return { skor: null, nedenler: [] };
const alanlar = [
{ ad: 'başlık', metin: norm(tender.baslik), agirlik: 1.0 },
{ ad: 'teklif cetveli', metin: norm([...(tender.kalemler || []), ...(tender.items || []).map((i) => i.ad)].join(' | ')), agirlik: 1.1 },
{ ad: 'OKAS/CPV', metin: norm(tender.cpvLabel), agirlik: 0.7 },
{ ad: 'sektör', metin: norm(tender.sektor), agirlik: 0.5 },
{ ad: 'ilan metni', metin: norm(String(tender.aciklama || '').slice(0, 3000)), agirlik: 0.6 },
];
let puan = 0;
const nedenler = [];
for (const t of terimler) {
let enIyi = null;
for (const a of alanlar) {
if (!a.metin || !gecerMi(t.terim, a.metin)) continue;
const katki = t.agirlik * a.agirlik;
if (!enIyi || katki > enIyi.katki) enIyi = { alan: a.ad, katki };
}
if (enIyi) {
puan += enIyi.katki;
nedenler.push({ terim: t.ham, kaynak: t.kaynak, alan: enIyi.alan, katki: enIyi.katki });
}
}
if (!nedenler.length) return { skor: 0, nedenler: [] };
// Doygunluk: 1 güçlü eşleşme ≈ 55, 2 ≈ 75, 3+ ≈ 90; sektör-tek eşleşme ~35
const skor = Math.round(100 * (1 - Math.exp(-puan * 0.8)));
nedenler.sort((a, b) => b.katki - a.katki);
return { skor: Math.max(0, Math.min(100, skor)), nedenler: nedenler.slice(0, 5) };
}
/** 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);
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 };
});
}