diff --git a/Dockerfile b/Dockerfile index 7b28168..eac58b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ WORKDIR /app # Playwright + Chromium (yalnızca chromium; sistem bağımlılıklarıyla) ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright RUN npm init -y >/dev/null \ - && npm i --omit=dev playwright@1.62.1 \ + && npm i --omit=dev playwright@1.62.1 @huggingface/transformers@3 \ && npx playwright install --with-deps chromium \ && npm cache clean --force diff --git a/server/index.js b/server/index.js index 08c7984..7cbf8c0 100644 --- a/server/index.js +++ b/server/index.js @@ -25,6 +25,7 @@ import { arsivMetni, sunucuOkuyabilir } from './office-text.mjs'; import { kalemleriCikar } from './kalem-cikar.mjs'; import { searchInDocuments, searchByOkas, okasAra } from './ekap-v2.mjs'; import { iknCikar } from './kalem-cikar.mjs'; +import { bonusHesapla, gommeDurumu, arkaPlandaIsit } from './uyum-gomme.mjs'; // Ürün araması EKAP'a uzak çağrı yapar; aynı sorgu tekrarlanırsa kaynağı yorma. const urunAramaCache = new Map(); @@ -799,6 +800,24 @@ const server = http.createServer(async (req, res) => { return json(res, 502, { success: false, error: e.message }); } } + // --- Anlamsal uyum bonusu: profil metni ↔ havuz (sunucuda gömme) --- + if (url.pathname === '/api/uyum/gomme' && req.method === 'POST') { + const user = userFromAuthHeader(req.headers['authorization']); + if (!user) return json(res, 401, { success: false, error: 'oturum yok' }); + try { + const body = await readBody(req, 64 * 1024); + const metin = String(body.profilMetni || '').trim(); + if (metin.length < 10) return json(res, 400, { success: false, error: 'profil metni yok' }); + const c = await getTenders(false); + const out = await bonusHesapla(metin, c.tenders || []); + return json(res, 200, { success: true, ...out }); + } catch (e) { + return json(res, 500, { success: false, error: e.message }); + } + } + if (url.pathname === '/api/health/uyum') { + return json(res, 200, { success: true, ...gommeDurumu() }); + } if (url.pathname === '/api/health/kalem') { return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() }); } @@ -1343,6 +1362,8 @@ const server = http.createServer(async (req, res) => { }); server.listen(PORT, () => { +// Gömme modeli soğuk yüklemesi ~45 sn; ilk kullanıcı beklemesin diye arka planda ısıt. +arkaPlandaIsit(); console.log(`[ihaleist-api] http://localhost:${PORT} — canlı veri: ilan.gov.tr`); getTenders().catch((e) => console.error('[ihaleist-api] ilk yükleme hatası:', e.message)); // Bildirim işi tender verisine ihtiyaç duyar; enjekte ederek başlat diff --git a/server/uyum-gomme.mjs b/server/uyum-gomme.mjs new file mode 100644 index 0000000..d43baab --- /dev/null +++ b/server/uyum-gomme.mjs @@ -0,0 +1,115 @@ +/** + * Anlamsal uyum bonusu — çok dilli gömme (multilingual-e5-small, ONNX q8). + * + * NEDEN BONUS, NEDEN EŞİK DEĞİL: ölçtük — e5 kosinüsleri 0,79-0,86 bandına + * sıkışıyor; kırtasiye ile röntgen arasında 0,06 var. Mutlak eşik olarak + * (kaanvp/kobi-ihale-eslestirme'nin 0,65'i) trafo bakımını da "eşleşti" + * sayar. Ama sıralama doğru ve sözlükselin kaçırdığını yakalıyor ("GENEL + * TIBBİ SARF" profilde birebir terim yokken kırtasiyenin üstünde). O yüzden + * havuz içinde z-skorlanıp sözlüksel skora +0..20 puan eklenir. + * + * ÇALIŞMA: sunucuda, Python/GPU yok (@huggingface/transformers). Model ilk + * seferde indirilip data/modeller'e (volume) yazılır; ~118 MB, soğuk yükleme + * ~45 sn — o yüzden açılışta arka planda ısıtılır. Paket yoksa ya da yükleme + * başarısızsa özellik sessizce kapanır, sözlüksel skor tek başına kalır. + */ + +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MODEL_DIR = path.join(__dirname, 'data', 'modeller'); +const MODEL = process.env.UYUM_GOMME_MODEL || 'Xenova/multilingual-e5-small'; +const ACIK = process.env.UYUM_GOMME !== '0'; +const MAX_BONUS = 20; + +let durum = { hazir: false, yukleniyor: false, hata: null, model: MODEL }; +let enc = null; +// ihale id → { imza, vek } — havuz her 10 dk yenileniyor, yalnızca metni +// değişen/yeni ihale yeniden gömülür. +const onbellek = new Map(); + +export function gommeDurumu() { + return { ...durum, acik: ACIK, onbellek: onbellek.size }; +} + +export async function modeliYukle() { + if (!ACIK || enc || durum.yukleniyor) return enc; + durum.yukleniyor = true; + try { + const tf = await import('@huggingface/transformers'); + tf.env.cacheDir = MODEL_DIR; + enc = await tf.pipeline('feature-extraction', MODEL, { dtype: 'q8' }); + durum = { ...durum, hazir: true, hata: null }; + console.log(`[uyum-gomme] model hazır: ${MODEL}`); + } catch (e) { + durum = { ...durum, hazir: false, hata: e.message }; + console.warn('[uyum-gomme] kapalı:', e.message.slice(0, 160)); + } finally { + durum.yukleniyor = false; + } + return enc; +} + +async function vektor(metin) { + const out = await enc(metin, { pooling: 'mean', normalize: true }); + return Float32Array.from(out.data); +} + +const ihaleMetni = (t) => + ['query:', t.baslik, t.cpvLabel, t.sektor, ...(t.kalemler || []).slice(0, 6)].filter(Boolean).join(' ').slice(0, 600); + +const imza = (s) => crypto.createHash('sha1').update(s).digest('hex').slice(0, 16); + +/** + * Profil metni ile havuzdaki her ihale arasında kosinüs; havuz içi z-skor → + * 0..MAX_BONUS. Dönüş: { hazir, bonus: { [id]: puan } }. + */ +export async function bonusHesapla(profilMetni, tenders) { + if (!ACIK) return { hazir: false, bonus: {} }; + if (!enc) await modeliYukle(); + if (!enc || !profilMetni || !tenders?.length) return { hazir: !!enc, bonus: {} }; + + const p = await vektor('passage: ' + String(profilMetni).slice(0, 1000)); + + // Eksik/eskimiş gömmeleri tamamla (toplu, sırayla — CPU'da 5 ms/ihale) + for (const t of tenders) { + const metin = ihaleMetni(t); + const iz = imza(metin); + const mevcut = onbellek.get(t.id); + if (mevcut && mevcut.imza === iz) continue; + onbellek.set(t.id, { imza: iz, vek: await vektor(metin) }); + } + // Havuzdan düşenleri at + if (onbellek.size > tenders.length * 2) { + const canli = new Set(tenders.map((t) => t.id)); + for (const k of onbellek.keys()) if (!canli.has(k)) onbellek.delete(k); + } + + const kos = tenders.map((t) => { + const v = onbellek.get(t.id)?.vek; + let s = 0; + if (v) for (let i = 0; i < v.length; i++) s += v[i] * p[i]; + return s; + }); + const n = kos.length; + const ort = kos.reduce((a, b) => a + b, 0) / n; + const sd = Math.sqrt(kos.reduce((a, b) => a + (b - ort) ** 2, 0) / n) || 1e-6; + + const bonus = {}; + tenders.forEach((t, i) => { + const z = (kos[i] - ort) / sd; + // z ≈ -0,5 → 0, z ≈ 2 → MAX: yalnızca havuz ortalamasının belirgin + // üstündekiler puan alır + const b = Math.round(MAX_BONUS * Math.max(0, Math.min(1, (z + 0.5) / 2.5))); + if (b > 0) bonus[t.id] = b; + }); + return { hazir: true, bonus, ort: Number(ort.toFixed(3)), sd: Number(sd.toFixed(3)) }; +} + +/** Açılışta arka planda ısıt — ilk kullanıcı 45 sn beklemesin. */ +export function arkaPlandaIsit(gecikmeMs = 15000) { + if (!ACIK) return; + setTimeout(() => { modeliYukle().catch(() => {}); }, gecikmeMs); +} diff --git a/src/components/CompanyScannerModal.jsx b/src/components/CompanyScannerModal.jsx index 32ccdf8..e6efc47 100644 --- a/src/components/CompanyScannerModal.jsx +++ b/src/components/CompanyScannerModal.jsx @@ -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 = () => { + {/* Firma eşiği: "uyumlu" filtresi ve bildirim bu skordan itibaren. + (Önceki "%98+ doğruluk" cümlesi ölçülmemiş bir iddiaydı, kaldırıldı.) */} +
+
+ Uyum eşiği — "Uyumlu" filtresi bu skordan itibaren gösterir + %{uyumEsigi} +
+ setUyumEsigi(e.target.value)} + className="w-full accent-blue-600 cursor-pointer" + /> +

+ Skor = profil terimlerinin ihalede geçmesi (başlık, teklif cetveli, OKAS) + anlamsal yakınlık bonusu. + {gommeDurum === 'hazir' && <> Anlamsal bonus etkin.} + {gommeDurum === 'bekliyor' && <> Anlamsal bonus hesaplanıyor…} + {(gommeDurum === 'kapali' || gommeDurum === 'hata') && <> Anlamsal bonus şu an kapalı; yalnızca terim eşleşmesi.} +

+
+ {/* Footer */}

- 💡 Sayfalarınızdaki detaylı ürün kataloğu sayesinde AI ihale eşleşme doğruluk skorunuz %98+ seviyesine çıkarılır. + Terim ekledikçe skor keskinleşir; kalem havuzu doldukça teklif cetveli eşleşmeleri devreye girer.