From 17c781a9ba1f7ca63f720b35483426bfc05d27ca Mon Sep 17 00:00:00 2001 From: ht Date: Thu, 3 Sep 2026 21:51:28 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20gercek=20sirket-ihale=20uyum=20skoru=20?= =?UTF-8?q?=E2=80=94=20sahte=20%98=20rozeti=20gitti?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/TenderCard.jsx | 6 +- src/components/TenderDetailModal.jsx | 10 ++++ src/components/TenderList.jsx | 2 + src/context/AppContext.jsx | 87 ++++++++++++++++++---------- src/services/ihaleTarih.js | 1 + src/services/uyum.js | 86 +++++++++++++++++++++++++++ 6 files changed, 162 insertions(+), 30 deletions(-) create mode 100644 src/services/uyum.js 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 }; + }); +}