feat: gercek sirket-ihale uyum skoru — sahte %98 rozeti gitti

Karttaki "%N Uyumlu" rozeti sabit yazili degerlerdi (liveTenderService
98/95/91/99), sunucudan null geliyordu. Artik profil terimleri ile ihale
metni arasinda agirlikli sozcuk eslesmesi (src/services/uyum.js):
- terimler: urunler (1.0), ihale konulari (1.0), sektorler (0.6),
  sertifikalar (0.3); "ve/alimi/malzeme" gibi durma kelimeleri sayilmaz
- alanlar: baslik (1.0), teklif cetveli kalemleri (1.1 — en guclu sinyal),
  OKAS/CPV (0.7), ilan metni (0.6), sektor (0.5)
- doygunluk egrisi: 1 guclu eslesme ≈ 55, 2 ≈ 75, 3+ ≈ 90
- profilde terim yoksa skor null → sahte yuzde gosterilmez
- her skorun nedeni var: kartta ipucu, detayda "‘eldiven’ · teklif cetveli"
Olculen: "VETERINER ILAC VE SARF MALZEME" basligi hicbir urunu icermiyor ama
kalemlerinde ELDIVEN + HEMOGRAM KITI var → %86; kirtasiye → 0.

- Siralama: "Sirketime En Uyumlu"; hizli filtre 'uyumlu' (≥60)
- Profil yetkinlikleri artik kalici (localStorage + sunucu 'firma_profil');
  onceden sayfa yenilenince siliniyordu
- Web sitesi taramasi gercek uca bagli (/api/company/scan: site metni +
  model → sektorler/urunler/sertifikalar/ihaleKonulari); basarisizsa eski
  alan-adi tahmini yedek. Onceden 1,5 sn setTimeout ile taklitti.

Sozluksel yol bilincli: dis servis yok, 8 bin ihalede aninda, deterministik,
aciklanabilir. Embedding katmani istenirse ustune eklenebilir.
(Fikir: kaanvp/kobi-ihale-eslestirme'nin esik + aciklama deseni; kod alinmadi.)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 21:51:28 +03:00
co-authored by Claude Fable 5.1
parent 1fce9df06f
commit 17c781a9ba
6 changed files with 162 additions and 30 deletions
+1
View File
@@ -59,6 +59,7 @@ export const GECMIS_FILTRELER = new Set(['arsiv', 'sonuclananlar', 'sozlesmeler'
// Hızlı filtre (üst kartlar + sol menü) ölçütü. Bilinmeyen id → her şey geçer.
export const hizliFiltreGecer = (t, id, { favoriteKurumlar = [], oncekiGirisMs = null } = {}) => {
switch (id) {
case 'uyumlu': return (t.aiMatchScore ?? 0) >= 60; // profil terimleri ihalede geçiyor
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ı';
+86
View File
@@ -0,0 +1,86 @@
/**
* Ş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) };
}
/** Havuzun tamamını puanlar; alan adları kartın ve detayın okuduğu adlar. */
export function havuzuPuanla(tenders, userProfile) {
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 };
});
}