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>
This commit is contained in:
@@ -4,7 +4,7 @@ import { Globe, Bot, Sparkles, CheckCircle2, ShieldCheck, Tag, X, Loader2, Datab
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
export const CompanyScannerModal = () => {
|
||||
const { isCompanyScannerOpen, setIsCompanyScannerOpen, userProfile, scanCompanyWebsite, updateCompanyCapabilities } = useApp();
|
||||
const { isCompanyScannerOpen, setIsCompanyScannerOpen, userProfile, scanCompanyWebsite, updateCompanyCapabilities, uyumEsigi, setUyumEsigi, gommeDurum } = useApp();
|
||||
|
||||
const [urlInput, setUrlInput] = useState(userProfile.website || 'https://www.tanriverdimedikal.com.tr');
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
@@ -274,10 +274,30 @@ export const CompanyScannerModal = () => {
|
||||
|
||||
</div>
|
||||
|
||||
{/* Firma eşiği: "uyumlu" filtresi ve bildirim bu skordan itibaren.
|
||||
(Önceki "%98+ doğruluk" cümlesi ölçülmemiş bir iddiaydı, kaldırıldı.) */}
|
||||
<div className="bg-white px-6 py-4 border-t border-slate-200 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<span className="text-xs font-bold text-slate-800">Uyum eşiği — "Uyumlu" filtresi bu skordan itibaren gösterir</span>
|
||||
<span className="text-sm font-black text-blue-700">%{uyumEsigi}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range" min={30} max={95} step={5} value={uyumEsigi}
|
||||
onChange={(e) => setUyumEsigi(e.target.value)}
|
||||
className="w-full accent-blue-600 cursor-pointer"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-500 leading-relaxed">
|
||||
Skor = profil terimlerinin ihalede geçmesi (başlık, teklif cetveli, OKAS) + anlamsal yakınlık bonusu.
|
||||
{gommeDurum === 'hazir' && <> Anlamsal bonus <strong className="text-emerald-700">etkin</strong>.</>}
|
||||
{gommeDurum === 'bekliyor' && <> Anlamsal bonus hesaplanıyor…</>}
|
||||
{(gommeDurum === 'kapali' || gommeDurum === 'hata') && <> Anlamsal bonus şu an kapalı; yalnızca terim eşleşmesi.</>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-slate-50 p-4 border-t border-slate-200 flex justify-between items-center">
|
||||
<p className="text-[11px] text-slate-500 font-medium">
|
||||
💡 Sayfalarınızdaki detaylı ürün kataloğu sayesinde AI ihale eşleşme doğruluk skorunuz <strong className="text-emerald-600">%98+</strong> seviyesine çıkarılır.
|
||||
Terim ekledikçe skor keskinleşir; kalem havuzu doldukça teklif cetveli eşleşmeleri devreye girer.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setIsCompanyScannerOpen(false)}
|
||||
|
||||
@@ -14,6 +14,7 @@ export const TenderList = () => {
|
||||
tenders,
|
||||
searchQuery,
|
||||
kalemEslesen,
|
||||
uyumEsigi,
|
||||
selectedCity,
|
||||
selectedAlimTuru,
|
||||
selectedSektorler,
|
||||
@@ -59,7 +60,7 @@ export const TenderList = () => {
|
||||
if (onlyValidOnly && !GECMIS_FILTRELER.has(quickFilter) && !acikIhale(tender, bugun)) return false;
|
||||
|
||||
// Hızlı filtre (üst kartlar + sol menü) — ölçütler ihaleTarih.js'de tek yerde
|
||||
if (!hizliFiltreGecer(tender, quickFilter, { favoriteKurumlar: favoriteKurumlar || [], oncekiGirisMs: oncekiGiris })) return false;
|
||||
if (!hizliFiltreGecer(tender, quickFilter, { favoriteKurumlar: favoriteKurumlar || [], oncekiGirisMs: oncekiGiris, uyumEsigi })) return false;
|
||||
|
||||
// Dropdown Filters
|
||||
if (selectedCity && normTr(tender.sehir) !== normTr(selectedCity)) return false;
|
||||
@@ -76,7 +77,7 @@ export const TenderList = () => {
|
||||
if (sortBy === 'distant') return b.tarih.localeCompare(a.tarih);
|
||||
return 0;
|
||||
});
|
||||
}, [tenders, searchQuery, kalemEslesen, selectedCity, selectedAlimTuru, selectedSektorler, selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly, bugun, favoriteKurumlar, oncekiGiris]);
|
||||
}, [tenders, searchQuery, kalemEslesen, uyumEsigi, selectedCity, selectedAlimTuru, selectedSektorler, selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly, bugun, favoriteKurumlar, oncekiGiris]);
|
||||
|
||||
const gorunenTenders = filteredTenders.slice(0, gosterilen);
|
||||
const dahaVar = gosterilen < filteredTenders.length;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, {createContext, useContext, useState, useEffect, useMemo } from 'react';
|
||||
import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService';
|
||||
import { havuzuPuanla } from '../services/uyum';
|
||||
import { havuzuPuanla, profilMetni, profilTerimleri } from '../services/uyum';
|
||||
import { getToken } from '../services/auth';
|
||||
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
|
||||
import { restoreSession, clearSession, getStoredUser } from '../services/auth';
|
||||
@@ -424,17 +424,53 @@ export const AppProvider = ({ children }) => {
|
||||
trialDaysLeft: 7,
|
||||
role: 'Yönetici Tedarikçi',
|
||||
scrapedCapabilities: { ...BOS_YETKINLIK, ...(kayitli?.scrapedCapabilities || {}) },
|
||||
// Firma eşiği: "uyumlu" filtresi ve bildirim bu skordan itibaren
|
||||
uyumEsigi: Number.isFinite(kayitli?.uyumEsigi) ? kayitli.uyumEsigi : 60,
|
||||
};
|
||||
});
|
||||
useEffect(() => {
|
||||
const veri = { company: userProfile.company, website: userProfile.website, scrapedCapabilities: userProfile.scrapedCapabilities };
|
||||
const veri = {
|
||||
company: userProfile.company, website: userProfile.website,
|
||||
scrapedCapabilities: userProfile.scrapedCapabilities, uyumEsigi: userProfile.uyumEsigi,
|
||||
};
|
||||
yerelKaydet('ihaleist_firma_profil', veri);
|
||||
if (veriSenkron) sunucuyaYaz('firma_profil', veri);
|
||||
}, [userProfile.company, userProfile.website, userProfile.scrapedCapabilities, veriSenkron]);
|
||||
}, [userProfile.company, userProfile.website, userProfile.scrapedCapabilities, userProfile.uyumEsigi, veriSenkron]);
|
||||
const setUyumEsigi = (v) => setUserProfile((p) => ({ ...p, uyumEsigi: Math.max(0, Math.min(100, Number(v) || 0)) }));
|
||||
|
||||
// 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]);
|
||||
// Anlamsal bonus: profil metni sunucuda gömülür, havuzla kosinüs alınır,
|
||||
// z-skor → 0..20. Profil terimi yoksa ya da oturum yoksa istenmez. Havuz
|
||||
// 10 dk'da bir yenilendiği için ihale sayısı değişince tazelenir.
|
||||
const [gommeBonus, setGommeBonus] = useState(null);
|
||||
const [gommeDurum, setGommeDurum] = useState('kapali'); // kapali | bekliyor | hazir | hata
|
||||
const profilMetin = useMemo(() => profilMetni(userProfile), [userProfile.scrapedCapabilities]);
|
||||
useEffect(() => {
|
||||
if (!profilTerimleri(userProfile).length || !getToken() || !tenders.length) { setGommeBonus(null); return; }
|
||||
let iptal = false;
|
||||
setGommeDurum('bekliyor');
|
||||
const z = setTimeout(() => {
|
||||
fetch('/api/uyum/gomme', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify({ profilMetni: profilMetin }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (iptal) return;
|
||||
if (d.success && d.hazir) { setGommeBonus(d.bonus || {}); setGommeDurum('hazir'); }
|
||||
else { setGommeBonus(null); setGommeDurum(d.success ? 'kapali' : 'hata'); }
|
||||
})
|
||||
.catch(() => { if (!iptal) { setGommeBonus(null); setGommeDurum('hata'); } });
|
||||
}, 800);
|
||||
return () => { iptal = true; clearTimeout(z); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [profilMetin, tenders.length]);
|
||||
|
||||
// Uyum skoru: sözlüksel (anında, açıklanabilir) + anlamsal bonus (sunucu).
|
||||
const tendersSkorlu = useMemo(
|
||||
() => havuzuPuanla(tenders, userProfile, gommeBonus),
|
||||
[tenders, userProfile.scrapedCapabilities, gommeBonus],
|
||||
);
|
||||
|
||||
// Ö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.
|
||||
@@ -569,6 +605,9 @@ export const AppProvider = ({ children }) => {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
tenders: tendersSkorlu,
|
||||
uyumEsigi: userProfile.uyumEsigi,
|
||||
setUyumEsigi,
|
||||
gommeDurum,
|
||||
setTenders,
|
||||
isLiveMode,
|
||||
setIsLiveMode,
|
||||
|
||||
@@ -57,9 +57,9 @@ export const acikIhale = (t, bugun) => {
|
||||
export const GECMIS_FILTRELER = new Set(['arsiv', 'sonuclananlar', 'sozlesmeler', 'iptal']);
|
||||
|
||||
// Hızlı filtre (üst kartlar + sol menü) ölçütü. Bilinmeyen id → her şey geçer.
|
||||
export const hizliFiltreGecer = (t, id, { favoriteKurumlar = [], oncekiGirisMs = null } = {}) => {
|
||||
export const hizliFiltreGecer = (t, id, { favoriteKurumlar = [], oncekiGirisMs = null, uyumEsigi = 60 } = {}) => {
|
||||
switch (id) {
|
||||
case 'uyumlu': return (t.aiMatchScore ?? 0) >= 60; // profil terimleri ihalede geçiyor
|
||||
case 'uyumlu': return (t.aiMatchScore ?? 0) >= uyumEsigi; // firma eşiği (profil ayarı)
|
||||
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ı';
|
||||
|
||||
+27
-3
@@ -75,12 +75,36 @@ export function uyumSkoru(tender, terimler) {
|
||||
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) {
|
||||
/** 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);
|
||||
return { ...t, aiMatchScore: skor, uyumNedenleri: nedenler };
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user