diff --git a/src/components/TenderCard.jsx b/src/components/TenderCard.jsx index 870c9ab..5e850be 100644 --- a/src/components/TenderCard.jsx +++ b/src/components/TenderCard.jsx @@ -149,7 +149,11 @@ export const TenderCard = ({ tender }) => { )} {tender.aiMatchScore != null && ( - + `‘${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"> %{tender.aiMatchScore} Uyumlu diff --git a/src/components/TenderDetailModal.jsx b/src/components/TenderDetailModal.jsx index 4aefad4..97092c0 100644 --- a/src/components/TenderDetailModal.jsx +++ b/src/components/TenderDetailModal.jsx @@ -450,6 +450,16 @@ END:VCALENDAR`; )} + {/* Skorun nedeni: hangi profil terimi ihalenin neresinde geçiyor */} + {tender.uyumNedenleri?.length > 0 && ( +
+ {tender.uyumNedenleri.map((n, i) => ( + + {n.terim} · {n.alan} + + ))} +
+ )}
diff --git a/src/components/TenderList.jsx b/src/components/TenderList.jsx index 61501de..d0848a8 100644 --- a/src/components/TenderList.jsx +++ b/src/components/TenderList.jsx @@ -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" > + diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index e9e0f54..c6a78d7 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -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 }) => { { 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ı'; diff --git a/src/services/uyum.js b/src/services/uyum.js new file mode 100644 index 0000000..ae9c225 --- /dev/null +++ b/src/services/uyum.js @@ -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 }; + }); +}