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:
@@ -149,7 +149,11 @@ export const TenderCard = ({ tender }) => {
|
||||
</span>
|
||||
)}
|
||||
{tender.aiMatchScore != null && (
|
||||
<span className="bg-blue-50 text-blue-700 border border-blue-200 text-[11px] font-bold px-2 py-1 rounded-md flex items-center gap-1">
|
||||
<span
|
||||
title={tender.uyumNedenleri?.length
|
||||
? tender.uyumNedenleri.map((n) => `‘${n.terim}’ ${n.alan}nde geçiyor`).join('\n')
|
||||
: 'Profilinizdeki terimlerden hiçbiri bu ihalede geçmiyor'}
|
||||
className="bg-blue-50 text-blue-700 border border-blue-200 text-[11px] font-bold px-2 py-1 rounded-md flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3 text-blue-600" />
|
||||
<span>%{tender.aiMatchScore} Uyumlu</span>
|
||||
</span>
|
||||
|
||||
@@ -450,6 +450,16 @@ END:VCALENDAR`;
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Skorun nedeni: hangi profil terimi ihalenin neresinde geçiyor */}
|
||||
{tender.uyumNedenleri?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{tender.uyumNedenleri.map((n, i) => (
|
||||
<span key={i} className="bg-white/10 border border-white/15 text-[11px] text-amber-100 px-2 py-0.5 rounded-lg">
|
||||
<strong className="text-white">{n.terim}</strong> · {n.alan}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
||||
<div className="bg-white/10 p-4 rounded-xl border border-white/10 space-y-1">
|
||||
|
||||
@@ -70,6 +70,7 @@ export const TenderList = () => {
|
||||
|
||||
return true;
|
||||
}).sort((a, b) => {
|
||||
if (sortBy === 'uyum') return (b.aiMatchScore ?? -1) - (a.aiMatchScore ?? -1) || b.id.localeCompare(a.id);
|
||||
if (sortBy === 'newest') return b.id.localeCompare(a.id);
|
||||
if (sortBy === 'urgent') return a.tarih.localeCompare(b.tarih);
|
||||
if (sortBy === 'distant') return b.tarih.localeCompare(a.tarih);
|
||||
@@ -162,6 +163,7 @@ export const TenderList = () => {
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="bg-slate-50 border border-slate-300 rounded-lg px-3 py-1.5 text-xs font-semibold text-slate-700 focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="uyum">Şirketime En Uyumlu</option>
|
||||
<option value="newest">En Yeni Eklenenler</option>
|
||||
<option value="urgent">Tarihi Yaklaşanlar (Acil)</option>
|
||||
<option value="distant">Tarihi Uzak Olanlar</option>
|
||||
|
||||
+58
-29
@@ -1,5 +1,7 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import React, {createContext, useContext, useState, useEffect, useMemo } from 'react';
|
||||
import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService';
|
||||
import { havuzuPuanla } from '../services/uyum';
|
||||
import { getToken } from '../services/auth';
|
||||
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
|
||||
import { restoreSession, clearSession, getStoredUser } from '../services/auth';
|
||||
import { getAllUserData, setUserData, getPlan } from '../services/account';
|
||||
@@ -406,36 +408,63 @@ export const AppProvider = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const [userProfile, setUserProfile] = useState({
|
||||
name: 'Hikmet Tanrıverdi',
|
||||
username: 'tanriverdi',
|
||||
company: 'Tanrıverdi Medikal & Sağlık Teknolojileri A.Ş.',
|
||||
website: 'https://www.tanriverdimedikal.com.tr',
|
||||
trialDaysLeft: 7,
|
||||
role: 'Yönetici Tedarikçi',
|
||||
scrapedCapabilities: {
|
||||
scrapedAt: null,
|
||||
extractedSectors: [],
|
||||
extractedProducts: [],
|
||||
extractedCertifications: [],
|
||||
matchScoreBoost: 0
|
||||
}
|
||||
const BOS_YETKINLIK = {
|
||||
scrapedAt: null, extractedSectors: [], extractedProducts: [], extractedCertifications: [],
|
||||
ihaleKonulari: [], matchScoreBoost: 0,
|
||||
};
|
||||
const [userProfile, setUserProfile] = useState(() => {
|
||||
// Yetkinlikler uyum skorunun girdisi — sayfa yenilenince sıfırlanmasın
|
||||
let kayitli = null;
|
||||
try { kayitli = JSON.parse(localStorage.getItem('ihaleist_firma_profil') || 'null'); } catch { /* yoksay */ }
|
||||
return {
|
||||
name: 'Hikmet Tanrıverdi',
|
||||
username: 'tanriverdi',
|
||||
company: kayitli?.company || 'Tanrıverdi Medikal & Sağlık Teknolojileri A.Ş.',
|
||||
website: kayitli?.website || 'https://www.tanriverdimedikal.com.tr',
|
||||
trialDaysLeft: 7,
|
||||
role: 'Yönetici Tedarikçi',
|
||||
scrapedCapabilities: { ...BOS_YETKINLIK, ...(kayitli?.scrapedCapabilities || {}) },
|
||||
};
|
||||
});
|
||||
useEffect(() => {
|
||||
const veri = { company: userProfile.company, website: userProfile.website, scrapedCapabilities: userProfile.scrapedCapabilities };
|
||||
yerelKaydet('ihaleist_firma_profil', veri);
|
||||
if (veriSenkron) sunucuyaYaz('firma_profil', veri);
|
||||
}, [userProfile.company, userProfile.website, userProfile.scrapedCapabilities, veriSenkron]);
|
||||
|
||||
const scanCompanyWebsite = (url) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const newCapabilities = parseWebsiteCapabilitiesDynamically(url);
|
||||
// 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]);
|
||||
|
||||
setUserProfile(prev => ({
|
||||
...prev,
|
||||
website: url,
|
||||
scrapedCapabilities: newCapabilities
|
||||
}));
|
||||
|
||||
resolve(newCapabilities);
|
||||
}, 1500);
|
||||
});
|
||||
// Ö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.
|
||||
const scanCompanyWebsite = async (url) => {
|
||||
let yeni = null;
|
||||
try {
|
||||
const r = await fetch('/api/company/scan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success && d.sonuc) {
|
||||
const so = d.sonuc;
|
||||
yeni = {
|
||||
scrapedAt: new Date().toLocaleString('tr-TR'),
|
||||
extractedSectors: so.sektorler || [],
|
||||
extractedProducts: so.urunler || [],
|
||||
extractedCertifications: so.sertifikalar || [],
|
||||
ihaleKonulari: so.ihaleKonulari || [],
|
||||
ozet: so.ozet || null,
|
||||
tarananSayfa: d.tarananSayfa,
|
||||
matchScoreBoost: 0,
|
||||
gercekTarama: true,
|
||||
};
|
||||
}
|
||||
} catch { /* aşağıda yedek yol */ }
|
||||
if (!yeni) yeni = { ...parseWebsiteCapabilitiesDynamically(url), ihaleKonulari: [], gercekTarama: false };
|
||||
setUserProfile((prev) => ({ ...prev, website: url, scrapedCapabilities: yeni }));
|
||||
return yeni;
|
||||
};
|
||||
|
||||
const updateCompanyCapabilities = (updatedCapabilities) => {
|
||||
@@ -539,7 +568,7 @@ export const AppProvider = ({ children }) => {
|
||||
<AppContext.Provider value={{
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
tenders,
|
||||
tenders: tendersSkorlu,
|
||||
setTenders,
|
||||
isLiveMode,
|
||||
setIsLiveMode,
|
||||
|
||||
@@ -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ı';
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user