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.