diff --git a/server/cpv-classify.mjs b/server/cpv-classify.mjs new file mode 100644 index 0000000..eda25c6 --- /dev/null +++ b/server/cpv-classify.mjs @@ -0,0 +1,226 @@ +/** + * CPV / OKAS ham kod sınıflandırması — YAYINLANMADAN ÖNCE tamamlanması + * GEREKEN alan seti (tender.cpv / cpvBolum / cpvLabel / cpvSource). + * + * siniflandir.mjs'teki OKAS_BOLUM/OKAS_GRUP zaten "sektör etiketi" tahmini + * için CPV kodlarını kullanıyordu; bu dosya AYNI sözlükleri kullanarak HAM + * kodu da tender nesnesine taşır. Üç kademe, sırayla ve İLKİ eşleşince durur: + * + * 1) existing — kalem listesinde (tender.items[].kod) zaten gerçek bir + * OKAS/CPV kodu varsa (DT / geçmiş sonuç kaynakları) onu + * kullan. En güvenilir kaynak: idarenin kendi kodu. + * 2) keyword — kalem yoksa başlık/kurum metnine CPV_KEYWORD_RULES + * uygulanır. I/O yok, anında sonuç. + * 3) ai — ikisi de boşsa (yalnızca ilan.gov.tr canlı akışında + * görülür) LLM'e OKAS_BOLUM listesi verilip seçim + * yaptırılır; modelin ürettiği kod sözlükte GERÇEKTEN VAR + * MI diye doğrulanır, yoksa reddedilir. + * + * Uydurma kod ilkesi: keyword kuralları ve AI çıktısı OKAS_BOLUM/OKAS_GRUP + * dışına ASLA kod üretmez/kabul etmez. "existing" kaynağı buna istisnadır — + * idarenin kendi verdiği kod zaten gerçek, bizim sözlüğümüzde etiketi + * olmasa bile reddedilmez (yalnızca cpvLabel 'Genel' kalır). + */ + +import { OKAS_BOLUM, OKAS_GRUP, normalizeTr, okasSektor } from './siniflandir.mjs'; +import { analyzeSpec as defaultAnalyzeSpec } from './ai.mjs'; + +function bolumGecerliMi(kod) { + return Object.prototype.hasOwnProperty.call(OKAS_BOLUM, kod); +} +function grupGecerliMi(kod) { + return Object.prototype.hasOwnProperty.call(OKAS_GRUP, kod); +} + +// --- 1) Kalemlerde zaten var olan OKAS/CPV kodu ---------------------------- +function existingCpvFromItems(tender) { + const items = Array.isArray(tender?.items) ? tender.items : []; + const kodlar = [...new Set( + items + .map((it) => String(it?.kod ?? '').replace(/\D/g, '')) + .filter((k) => k.length >= 2) + )]; + if (!kodlar.length) return null; + + const bolumler = [...new Set(kodlar.map((k) => k.slice(0, 2)))]; + const sayac = new Map(); + for (const k of kodlar) { + const sek = okasSektor(k); + if (sek) sayac.set(sek, (sayac.get(sek) || 0) + 1); + } + const label = sayac.size ? [...sayac.entries()].sort((a, b) => b[1] - a[1])[0][0] : 'Genel'; + + return { cpv: kodlar, cpvBolum: bolumler, cpvLabel: label, cpvSource: 'existing' }; +} + +// --- 2) Anahtar kelime kuralları -------------------------------------------- +// Her satır: [regex, bolumKod, grupKod?]. regex NORMALLEŞTİRİLMİŞ metne +// (normalizeTr) uygulanır. Sıra ÖNEMLİ — dar/özel grup eşleşmeleri önce, +// geniş bölüm eşleşmeleri sonra gelir (ilk eşleşen kazanır). +// OKAS_BOLUM'un 45 anahtarının TAMAMI için en az bir kural vardır. +export const CPV_KEYWORD_RULES = [ + // --- Grup (4-5 hane) düzeyinde belirgin eşleşmeler --- + [/\btemizlik urun|\bdeterjan alim|\bdezenfek(tan)? alim/i, '39', '3983'], + [/\bcop konteyner|\batik konteyner/i, '34', '34928'], + [/\btuvalet kagidi|\bkagit havlu/i, '33', '3376'], + [/\bpaspas|\byer ortusu/i, '39', '3953'], + [/\bsupurge|\bfirca alim/i, '39', '3922'], + [/\biklimlendirme cihaz|\bklima alim|\bbuzdolabi alim/i, '39', '3971'], + [/\btasit onarim|\barac onarim hizmet|\bbakim onarim.{0,15}(arac|tasit|otobus|kamyon)/i, '50', '5012'], + [/\bet alim|\bkirmizi et|\bdana\b|\bkuzu\b/i, '03', '0311'], + [/\btavuk alim|\bpiliç alim|\bpilic alim/i, '03', '0314'], + [/\bsut alim|\bpeynir alim/i, '03', '0331'], + [/\bekmek alim|\bunlu mamul/i, '03', '0321'], + [/\bsebze alim|\bmeyve alim/i, '03', '0313'], + [/\bguvenlik personel|\bozel guvenlik hizmet/i, '79', '7971'], + [/\bkamera sistem|\bcctv|\balarm sistem/i, '79', '7972'], + [/\bmatbaa hizmet|\bbaski hizmet|\bform basim/i, '79', '7981'], + [/\breklam hizmet|\btanitim hizmet/i, '79', '7982'], + [/\bfuar\b|\bfestival organizasyon/i, '79', '7995'], + [/\bpersonel calistir|\bisci calistir|\bdestek personel hizmet/i, '79', '7962'], + [/\boyun grubu|\bcocuk oyun/i, '37', '3753'], + [/\bboru\b|\bvana\b/i, '44', '4416'], + [/\batik su|\bkanalizasyon aritma/i, '90', '9042'], + + // --- Bölüm (2 hane) düzeyinde genel eşleşmeler --- + [/\btarim makine|\btraktor alim|\bbicerdover|\bpulluk/i, '16'], + [/\btarim|\btohum|\bgubre|\bfide|\bzirai|\bhayvan|\byem\b|\bveteriner|\bmama\b|\bsera\b|\borman|\bagaclandirma/i, '03'], + [/\bpetrol cikarma|\bdogalgaz cikarma|\bmaden cikar/i, '76'], + [/\bakaryakit|\bmotorin|\bbenzin|\blpg|\byakit alim|\bodun\b|\bkomur|\bpelet\b/i, '09'], + [/\bhafriyat|\btas ocagi|\bkum cakil/i, '14'], + [/\bgida|\byemek|\bcatering|\bkumanya|\bbakliyat|\bmutfak|\bgazoz|\bicecek|\bsu alimi|\bcips|\bkuru gida/i, '15'], + [/\bderi\b|\bkosele|\bayakkabi|\bbot alim/i, '19'], + [/\bgiyim|\btekstil|\belbise|\buniforma|\bkiyafet|\bcamasir|\bkumas|\bbattaniye|\bhavlu\b|\bnevresim/i, '18'], + [/\bkirtasiye|\bmatbaa|\bbaski|\bkagit|\btoner|\bkartus|\bfotokopi|\bburo malzeme|\bdefter|\bdosya alim|\bzarf\b|\bbasili evrak|\bklasor/i, '22'], + [/\bkimyasal madde|\basit\b|\bsolvent\b/i, '24'], + [/\bbilgisayar alim|\byazici alim|\bofis makine|\btablet alim|\bprojeksiyon/i, '30'], + [/\belektrik|\btrafo|\benerji|\baydinlatma|\bjenerator|\bgunes|\bsolar|\bkablo|\bpano\b|\bups\b|\barmatur|\bampul|\bled\b/i, '31'], + [/\btelsiz|\bhaberlesme cihaz|\bswitch\b|\brouter\b|\bag cihaz/i, '32'], + [/\btibbi|\bmedikal|\bsaglik malzeme|\bilac\b|\bhastane malzeme|\bpoliklinik|\bcerrahi|\bameliyat|\bdiyaliz|\bradyoloji|\brontgen|\btomografi|\bultrason|\bsteril|\benjektor|\beldiven|\bmaske|\bpansuman|\bsonda|\bkateter|\bprotez|\bortez|\bdis unit|\bdental|\bimplant|\bhemogram|\bserum|\baspirator|\bnebulizator/i, '33'], + [/\bis makinesi|\bgreyder|\bekskavator|\bkepce/i, '43'], + // Taşımacılık HİZMETİ, genel taşıt/otomotiv kuralından ÖNCE gelir — aksi + // halde "öğrenci taşıma hizmeti" gibi başlıklar salt "araç" kelimesi + // yüzünden yanlışlıkla taşıt ALIMI (34) sanılır (bkz. SECTOR_RULES'taki + // aynı öncelik: Taşımacılık & Kiralama, Otomotiv & İş Makinesi'nden önce). + [/\barac kirala|\btasit kirala|\bservis hizmet|\bpersonel tasima|\btasimali\b|\bogrenci.{0,40}tasi|\btasima merkezi|\btasinmasi isi|\btasima hizmet|\bnakliye/i, '60'], + [/\barac\b|\botomotiv|\blastik|\byedek parca|\botobus|\bkamyon|\bbalata|\bamortisor|\baku\b/i, '34'], + [/\bguvenlik sistem|\byangin sondur|\byangin tup|\byangin algila|\bitfaiye|\bturnike|\bx-ray/i, '35'], + [/\bspor malzeme|\bmuzik alet|\bhali saha/i, '37'], + [/\blaboratuvar|\banaliz cihaz|\bolcum cihaz|\btest cihaz|\bmikroskop|\bsantrifuj|\bkantar|\btarti\b|\bterazi|\bkalibrasyon|\bsayac\b/i, '38'], + [/\bmobilya|\bmasa\b|\bsandalye|\bdolap|\bokul sirasi|\braf\b|\bperde|\bkoltuk|\byatak\b|\bkaryola/i, '39'], + [/\bsu aritma|\baritma tesis|\bpompa|\bsondaj|\bkuyu\b|\bsu deposu|\bklorlama|\bdalgic/i, '41'], + [/\bpolisaj|\btestere|\btorna|\bkaynak makinesi|\bmatkap|\batolye|\bel aleti|\bkompresor/i, '42'], + [/\byapi malzeme|\btugla\b|\bcimento|\bkereste\b|\bfayans|\bseramik/i, '44'], + [/\binsaat|\byapim isi|\bbina\b|\bguclendirme|\bcati\b|\bcephe|\brestorasyon|\byikim|\bizolasyon|\bmantolama|\basfalt|\bkilit parke|\bbordur|\bkaldirim|\bkopru|\baltyapi|\bicme suyu|\bsulama sistem/i, '45'], + [/\byazilim|\blisans alim|\buygulama yazilim/i, '48'], + [/\bbakim onarim|\btamirat|\btadilat|\bbakim hizmet|\bboya\b|\bbadana|\bdograma|\bpvc pencere/i, '50'], + [/\bmontaj hizmet|\bkurulum hizmet/i, '51'], + [/\bkonaklama|\botel hizmet|\byemek hizmet/i, '55'], + [/\blojistik|\bdepo hizmet/i, '63'], + [/\btelekomunikasyon hizmet|\binternet hizmet|\bhat kiralama/i, '64'], + [/\belektrik dagitim|\bdogalgaz dagitim/i, '65'], + [/\bsigorta|\bzorunlu trafik|\bkasko/i, '66'], + [/\btasinmaz|\bparsel|\barsa\b|\bgayrimenkul|\bkiraya veril|\bsatis ihalesi|\becrimisil/i, '70'], + [/\bmuhendislik hizmet|\betut proje|\bmimarlik hizmet|\bkontrolluk/i, '71'], + [/\bbilisim hizmet|\byazilim gelistirme|\bbilgisayar hizmet/i, '72'], + [/\barastirma hizmet|\barge\b|\bar-ge/i, '73'], + [/\bidari hizmet|\bkamu yonetimi/i, '75'], + [/\bpeyzaj|\bpark bahce|\bagac\b|\bcim\b|\bfidan|\bkent mobilya|\bbank alim|\bcicek/i, '77'], + [/\bdanismanlik|\bmusavirlik|\bis hizmet/i, '79'], + [/\begitim hizmet|\bkurs\b|\bseminer/i, '80'], + [/\bsaglik hizmet|\bhastane hizmet/i, '85'], + [/\btemizlik|\bhijyen|\bilaclama|\batik toplama|\bgeri donusum/i, '90'], + [/\bkultur hizmet|\beglence hizmet|\bspor hizmet/i, '92'], + [/\bsosyal hizmet|\btoplum hizmet/i, '98'], +]; + +function keywordMatch(tender) { + const metin = normalizeTr(`${tender?.baslik || ''} ${tender?.kurum || ''}`); + for (const [re, bolum, grup] of CPV_KEYWORD_RULES) { + if (!re.test(metin)) continue; + if (!bolumGecerliMi(bolum)) continue; // güvenlik: sözlükte olmayan kod asla döndürülmez + const grupGecerli = grup && grupGecerliMi(grup); + return { + cpv: [grupGecerli ? grup : bolum], + cpvBolum: [bolum], + cpvLabel: grupGecerli ? OKAS_GRUP[grup] : OKAS_BOLUM[bolum], + cpvSource: 'keyword', + }; + } + return null; +} + +/** + * Kalemlerde gerçek kod var mı, yoksa başlık/kurum metnine kural uygulanır. + * Hiçbiri eşleşmezse null döner (çağıran AI'a düşer). + */ +export function classifyCpvByKeyword(tender) { + return existingCpvFromItems(tender) || keywordMatch(tender); +} + +// --- 3) AI ile sınıflandırma ------------------------------------------------- +const OKAS_BOLUM_LISTESI = Object.entries(OKAS_BOLUM) + .map(([kod, etiket]) => `${kod}: ${etiket}`) + .join('\n'); + +function cpvSistemPrompt() { + return `Sen Türkiye kamu ihaleleri için OKAS/CPV (Ortak Kamu Alımları Sözlüğü / Avrupa CPV) sınıflandırma uzmanısın. +Aşağıda GEÇERLİ OKAS bölüm kodları (2 haneli) ve etiketleri listelenmiştir: +${OKAS_BOLUM_LISTESI} + +Görevin: verilen ihale başlığı ve idare adına göre bu listeden EN UYGUN bölüm kodunu seçmek. +KURALLAR: +- Yalnızca yukarıdaki listede TAM OLARAK yer alan bir "cpvBolum" kodu seç. Listede olmayan veya uydurma bir kod ASLA üretme. +- Eğer 4-5 haneli daha alt kırılımlı bir OKAS/CPV kodu tahmin edebiliyorsan "cpvGrup" alanına yaz (örn. "4523"); emin değilsen null bırak. cpvGrup, seçtiğin cpvBolum ile aynı iki haneyle BAŞLAMALIDIR. +- Hiçbir kod uygun değilse veya emin değilsen "cpvBolum" alanını null yap. +SADECE şu JSON şemasında yanıt ver, başka hiçbir açıklama/markdown ekleme: +{"cpvBolum": "45" veya null, "cpvGrup": "4523" veya null, "guven": 0.0 ile 1.0 arası sayı}`; +} + +/** + * LLM'e OKAS_BOLUM listesini verip ihale için en uygun kodu seçtirir. + * Modelin ürettiği kod sözlükte gerçekten yoksa reddedilir (cpvSource:'none'). + */ +export async function classifyCpvWithAI(tender, { analyzeSpec, chain } = {}) { + const analyze = analyzeSpec || defaultAnalyzeSpec; + const none = { cpv: [], cpvBolum: [], cpvLabel: 'Genel', cpvSource: 'none' }; + + try { + const text = `İhale başlığı: ${tender?.baslik || '-'}\nİdare: ${tender?.kurum || '-'}`; + const { result } = await analyze({ + text, + tender, + chain, + systemOverride: cpvSistemPrompt(), + }); + + const bolum = result?.cpvBolum != null ? String(result.cpvBolum).replace(/\D/g, '') : ''; + if (!bolum || !bolumGecerliMi(bolum)) return none; + + const grupHam = result?.cpvGrup != null ? String(result.cpvGrup).replace(/\D/g, '') : ''; + // grup kodu hem sözlükte gerçekten var olmalı hem de seçilen bölümle tutarlı olmalı + const grupGecerli = grupHam && grupGecerliMi(grupHam) && grupHam.slice(0, 2) === bolum; + + return { + cpv: [grupGecerli ? grupHam : bolum], + cpvBolum: [bolum], + cpvLabel: grupGecerli ? OKAS_GRUP[grupHam] : OKAS_BOLUM[bolum], + cpvSource: 'ai', + }; + } catch { + return none; + } +} + +/** + * Tam sınıflandırma akışı: önce ücretsiz/hızlı yollar (kalem kodu, anahtar + * kelime), yalnızca ikisi de boşsa AI'a düşer. Kalıcı önbellek (ikn bazlı) + * BU FONKSİYONUN DIŞINDA, çağıran tarafta (server/index.js) uygulanır — + * böylece aynı ilan iki farklı kaynaktan (ör. arka plan doldurma + ilk + * yükleme) geçse bile AI yalnızca bir kez çağrılır. + */ +export async function classifyTenderCpv(tender, deps = {}) { + const hizli = classifyCpvByKeyword(tender); + if (hizli) return hizli; + return classifyCpvWithAI(tender, deps); +} diff --git a/server/dt-store.mjs b/server/dt-store.mjs index 1f843b9..e4a3462 100644 --- a/server/dt-store.mjs +++ b/server/dt-store.mjs @@ -5,6 +5,7 @@ import fs from 'node:fs'; import { sektorKarar } from './siniflandir.mjs'; +import { classifyCpvByKeyword } from './cpv-classify.mjs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { titleCaseTr } from './tr-text.mjs'; @@ -85,7 +86,7 @@ function classifyKurum(text) { function mapDtRow(row) { const det = parseDetail(row.detail_json); const kurum = det.idareTam || row.idare_adi || ''; - return { + const tender = { id: `dt-${row.dogrudan_temin_id}`, kaynak: 'EKAP · Doğrudan Temin', kaynakUrl: 'https://ekap.kik.gov.tr/EKAP/YeniIhaleArama.aspx', @@ -138,6 +139,15 @@ function mapDtRow(row) { sozlesmeBedeli: det.sozlesmeBedeli || null, kazananFirma: det.kazananFirma || null, }; + // CPV/OKAS — YAYINLANMADAN ÖNCE tamamlanır (bkz. cpv-classify.mjs). + // BransKodList zaten gerçek OKAS kodu taşır; 'existing' dalı bunu anında + // kullanır, AI'a nadiren düşer. + const cpv = classifyCpvByKeyword(tender) || { cpv: [], cpvBolum: [], cpvLabel: 'Genel', cpvSource: 'none' }; + tender.cpv = cpv.cpv; + tender.cpvBolum = cpv.cpvBolum; + tender.cpvLabel = cpv.cpvLabel; + tender.cpvSource = cpv.cpvSource; + return tender; } export function readDtTenders(limit = 300) { diff --git a/server/history-store.mjs b/server/history-store.mjs index cd9ac1c..e3d493e 100644 --- a/server/history-store.mjs +++ b/server/history-store.mjs @@ -7,6 +7,7 @@ import fs from 'node:fs'; import { sektorKarar } from './siniflandir.mjs'; +import { classifyCpvByKeyword } from './cpv-classify.mjs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { titleCaseTr } from './tr-text.mjs'; @@ -57,6 +58,15 @@ CREATE TABLE IF NOT EXISTS sektor_zengin ( zaman TEXT ); +CREATE TABLE IF NOT EXISTS cpv_cache ( + ikn TEXT PRIMARY KEY, -- İhale Kayıt Numarası — CPV sınıflandırmasının kalıcı anahtarı + cpv TEXT, -- JSON dizi: eşleşen tam CPV/OKAS kodları + cpv_bolum TEXT, -- JSON dizi: 2 haneli bölüm kodları + cpv_label TEXT, -- birincil gösterim etiketi + cpv_source TEXT, -- 'existing' | 'keyword' | 'ai' | 'none' + denenme_zamani INTEGER -- epoch ms +); + CREATE TABLE IF NOT EXISTS havuz_olcum ( id INTEGER PRIMARY KEY AUTOINCREMENT, zaman TEXT NOT NULL, @@ -170,7 +180,8 @@ function okasParse(json, bedel) { } function rowToTender(r) { - return { + const items = okasParse(r.okas_kalemler, r.sozlesme_bedeli); + const tender = { id: `hist-${r.ikn}-${r.kisim}`, kaynak: 'EKAP Sonuç İlanı', ikn: r.ikn, @@ -190,7 +201,7 @@ function rowToTender(r) { teklifSayisi: r.teklif_sayisi, gecerliTeklifSayisi: r.gecerli_teklif_sayisi, durum: 'Sonuçlandı', - items: okasParse(r.okas_kalemler, r.sozlesme_bedeli), + items, // Arayüzün beklediği alanlar (geçmiş kayıtta karşılığı yok — güvenli varsayılan) eklenmeZamani: 'arşivden', isYeni: false, @@ -206,6 +217,15 @@ function rowToTender(r) { takipte: false, incelendi: false, }; + // CPV/OKAS — YAYINLANMADAN ÖNCE tamamlanır (bkz. cpv-classify.mjs). Bu + // kaynakta kalemler (items[].kod) OKAS kodunu genelde zaten taşır, bu + // yüzden 'existing' dalı çoğunlukla anında sonuç verir; AI'a düşmez. + const cpv = classifyCpvByKeyword(tender) || { cpv: [], cpvBolum: [], cpvLabel: 'Genel', cpvSource: 'none' }; + tender.cpv = cpv.cpv; + tender.cpvBolum = cpv.cpvBolum; + tender.cpvLabel = cpv.cpvLabel; + tender.cpvSource = cpv.cpvSource; + return tender; } // OKAS kalemlerinden ad listesi — sektör tahminini güçlendirir. @@ -508,3 +528,94 @@ export function sektorZenginSayi() { db.close(); } } + + +// --- CPV / OKAS kalıcı önbelleği -------------------------------------------- +// AYNI ihale (IKN ile) bir daha ASLA yeniden sınıflandırılmasın — özellikle +// AI çağrısı gerektiren durumlarda (maliyet + gecikme). sektor_zengin ile +// aynı desen: id → sonuç haritası, toplu okuma/yazma, dosya başına tek +// açma/kapama. + +function cpvJsonDizi(ham) { + if (!ham) return []; + try { + const v = JSON.parse(ham); + return Array.isArray(v) ? v : []; + } catch { + return []; + } +} + +/** ikn → CPV sonucu haritası. Yalnızca istenen ikn'ler okunur. */ +export function cpvCacheOkuCoklu(iknler) { + const harita = new Map(); + const gecerli = [...new Set((iknler || []).filter(Boolean))]; + if (!gecerli.length) return harita; + const db = openDb(); + if (!db) return harita; + try { + for (let i = 0; i < gecerli.length; i += 400) { + const parca = gecerli.slice(i, i + 400); + const yer = parca.map(() => '?').join(','); + const satirlar = db + .prepare(`SELECT ikn, cpv, cpv_bolum, cpv_label, cpv_source FROM cpv_cache WHERE ikn IN (${yer})`) + .all(...parca); + for (const r of satirlar) { + harita.set(r.ikn, { + cpv: cpvJsonDizi(r.cpv), + cpvBolum: cpvJsonDizi(r.cpv_bolum), + cpvLabel: r.cpv_label || 'Genel', + cpvSource: r.cpv_source || 'none', + }); + } + } + return harita; + } finally { + db.close(); + } +} + +/** Tek bir ikn için kayıtlı CPV sonucu — yoksa null. */ +export function cpvCacheOku(ikn) { + if (!ikn) return null; + return cpvCacheOkuCoklu([ikn]).get(ikn) || null; +} + +/** Toplu yazma: [{ikn, cpv, cpvBolum, cpvLabel, cpvSource}, ...] */ +export function cpvCacheYazCoklu(kayitlar) { + if (!kayitlar?.length) return 0; + const db = openDb({ create: true }); + if (!db) return 0; + try { + const st = db.prepare(` + INSERT INTO cpv_cache (ikn, cpv, cpv_bolum, cpv_label, cpv_source, denenme_zamani) + VALUES (?,?,?,?,?,?) + ON CONFLICT(ikn) DO UPDATE SET + cpv=excluded.cpv, cpv_bolum=excluded.cpv_bolum, cpv_label=excluded.cpv_label, + cpv_source=excluded.cpv_source, denenme_zamani=excluded.denenme_zamani + `); + const now = Date.now(); + let n = 0; + for (const k of kayitlar) { + if (!k?.ikn) continue; + st.run( + k.ikn, + JSON.stringify(k.cpv || []), + JSON.stringify(k.cpvBolum || []), + k.cpvLabel || 'Genel', + k.cpvSource || 'none', + now, + ); + n++; + } + return n; + } finally { + db.close(); + } +} + +/** Tek bir ikn için CPV sonucunu kaydeder. */ +export function cpvCacheYaz(ikn, sonuc) { + if (!ikn || !sonuc) return false; + return cpvCacheYazCoklu([{ ikn, ...sonuc }]) > 0; +} diff --git a/server/index.js b/server/index.js index 6464dde..de4e1d5 100644 --- a/server/index.js +++ b/server/index.js @@ -17,16 +17,18 @@ import crypto from 'node:crypto'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { readDtTenders } from './dt-store.mjs'; -import { SECTOR_RULES, INSTITUTION_RULES, classify, sektorBul, SEKTOR_ETIKETLERI, KURUM_ETIKETLERI, ilanKonusu } from './siniflandir.mjs'; +import { SECTOR_RULES, INSTITUTION_RULES, classify, sektorBul, SEKTOR_ETIKETLERI, KURUM_ETIKETLERI, ilanKonusu, OKAS_BOLUM } from './siniflandir.mjs'; import { loginWithGoogle, userFromAuthHeader } from './auth.mjs'; import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email.mjs'; import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs'; +import { classifyTenderCpv } from './cpv-classify.mjs'; import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSession, verifyWebhook, planForPrice, cancelSubscription, IPTAL_NEDENLERI } from './billing.mjs'; import * as store from './user-store.mjs'; import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs'; import { searchTed } from './ted.mjs'; import { readHistory, historyStats, havuzOlc, havuzGecmisi, havuzOzet, - sektorZenginOku, sektorZenginYaz, sektorZenginDenenmis, sektorZenginSayi } from './history-store.mjs'; + sektorZenginOku, sektorZenginYaz, sektorZenginDenenmis, sektorZenginSayi, + cpvCacheOku, cpvCacheYaz } from './history-store.mjs'; import { startScheduler, schedulerStatus } from './scheduler.mjs'; import { searchKik, kararMetni } from './kik.mjs'; import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs'; @@ -262,7 +264,44 @@ async function ilanSayfalari(baslangic, adet) { return Promise.all(istekler); } -function ilanlariBirlestir(mevcut, yeniAds) { +// --- CPV/OKAS sınıflandırma GATE'i ----------------------------------------- +// "sektör" alanının aksine (bkz. sektorZenginlestir — önce yayınla, sonra +// arka planda zenginleştir), CPV alanı bir ihale cache.tenders'a GİRMEDEN +// ÖNCE tamamlanmış olmalı. Bu yüzden bu fonksiyon her zaman ilanlariBirlestir +// / getTenders içinde, sonuç diziye eklenmeden HEMEN ÖNCE await'lenir. +const CPV_ESZAMANLI = Number(process.env.CPV_ESZAMANLI || 4); // AI'ı yormamak için sınırlı paralellik + +async function cpvUygula(t) { + const ikn = t.ikn || ''; + if (ikn) { + const onbellek = cpvCacheOku(ikn); + if (onbellek) { + Object.assign(t, onbellek); + return t; + } + } + let sonuc; + try { + sonuc = await classifyTenderCpv(t, { analyzeSpec }); + } catch (e) { + console.warn(`[cpv-classify] ${t.id} sınıflandırılamadı:`, e.message); + sonuc = { cpv: [], cpvBolum: [], cpvLabel: 'Genel', cpvSource: 'none' }; + } + Object.assign(t, sonuc); + if (ikn) cpvCacheYaz(ikn, sonuc); + return t; +} + +/** Bir tender listesini, sınırlı paralellikle, CPV işlenmiş hale getirir. */ +async function cpvUygulaListe(tenders) { + for (let i = 0; i < tenders.length; i += CPV_ESZAMANLI) { + const grup = tenders.slice(i, i + CPV_ESZAMANLI); + await Promise.all(grup.map((t) => cpvUygula(t))); + } + return tenders; +} + +async function ilanlariBirlestir(mevcut, yeniAds) { const seen = new Set(mevcut.map((t) => t.id)); const eklenen = []; for (const ad of yeniAds) { @@ -271,6 +310,9 @@ function ilanlariBirlestir(mevcut, yeniAds) { seen.add(t.id); eklenen.push(t); } + // GATE: CPV sınıflandırması tamamlanmadan hiçbir ilan çağırana dönmez — + // bu dizi doğrudan arkaPlandaTamamla() içinde cache.tenders'a eklenir. + await cpvUygulaListe(eklenen); return eklenen; } @@ -289,7 +331,7 @@ async function arkaPlandaTamamla(numFound, ilkListe) { const sonuclar = await ilanSayfalari(page, grup); const ads = sonuclar.flatMap((r) => r.ads || []); if (!ads.length) break; - const eklenen = ilanlariBirlestir(yeni, ads); + const eklenen = await ilanlariBirlestir(yeni, ads); if (eklenen.length) yeni.push(...eklenen); page += grup; await new Promise((r) => setTimeout(r, 120)); // kaynağı yormamak için @@ -420,6 +462,9 @@ async function getTenders(force = false) { const ilkListe = ads .filter((ad) => (seen.has(ad.id) ? false : seen.add(ad.id))) .map(mapAd); + // GATE: CPV sınıflandırması tamamlanmadan bu liste ne hemen servis edilir + // ne de arkaPlandaTamamla()'nın temeli olur (bkz. cpvUygula yorumu). + await cpvUygulaListe(ilkListe); cache.totalCount = numFound || ilkListe.length; cache.fetchedAt = Date.now(); @@ -1027,6 +1072,7 @@ const server = http.createServer(async (req, res) => { success: true, sektorler: SEKTOR_ETIKETLERI, kurumTipleri: KURUM_ETIKETLERI, + cpvBolumler: Object.entries(OKAS_BOLUM).map(([kod, etiket]) => ({ kod, etiket })), }); } diff --git a/server/siniflandir.mjs b/server/siniflandir.mjs index ac149b1..e1db4c2 100644 --- a/server/siniflandir.mjs +++ b/server/siniflandir.mjs @@ -115,7 +115,7 @@ export function ilanKonusu(metin) { // temizlik ürünleri, 79 = hem güvenlik hem matbaa hem danışmanlık); onlar için // daha uzun ön ek tanımlanır ve uzun olan kazanır. -const OKAS_GRUP = { +export const OKAS_GRUP = { // 39 — mobilya / ev aletleri / temizlik ürünleri aynı bölümde '3971': 'Isıtma & İklimlendirme', // elektrikli ev aletleri (buzdolabı, klima) '3922': 'Temizlik & Hijyen', // süpürge, fırça, temizlik gereçleri @@ -158,7 +158,7 @@ const OKAS_GRUP = { '9042': 'Su & Arıtma', }; -const OKAS_BOLUM = { +export const OKAS_BOLUM = { '03': 'Tarım & Hayvancılık', '09': 'Akaryakıt & Yakıt', '14': 'İnşaat & Bakım', diff --git a/src/components/LandingPage.jsx b/src/components/LandingPage.jsx index d8b06fe..0f1b547 100644 --- a/src/components/LandingPage.jsx +++ b/src/components/LandingPage.jsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { ArrowRight, Bell, Building2, CalendarDays, ChartNoAxesCombined, CheckCircle2, - FileSearch, Gavel, Kanban, MapPin, Radar, Sparkles, Timer, TrendingUp, Zap, + FileSearch, Gavel, Kanban, MapPin, Radar, Sparkles, Tag, Timer, TrendingUp, Zap, } from 'lucide-react'; /* ---------- yardımcılar ---------- */ @@ -62,6 +62,7 @@ const OZELLIKLER = [ { icon: Radar, baslik: 'Rakip radarı', metin: 'Hangi firma hangi ihaleyi, hangi bedelle kazandı? Rakiplerinizin izini sürün, stratejinizi veriyle kurun.', renk: 'from-rose-400/20 to-rose-400/0', ikon: 'text-rose-300' }, { icon: TrendingUp, baslik: 'Fiyat & piyasa analizi', metin: 'Geçmiş sonuçlardan yaklaşık maliyet ve kırım oranı analizi. Teklifinizi tahminle değil, veriyle verin.', renk: 'from-emerald-400/20 to-emerald-400/0', ikon: 'text-emerald-300' }, { icon: Bell, baslik: 'Günlük bülten & uyarılar', metin: 'Kriterlerinize uyan yeni ilanlar her sabah e-postanızda. Son teklif tarihi yaklaşınca otomatik hatırlatma.', renk: 'from-sky-400/20 to-sky-400/0', ikon: 'text-sky-300' }, + { icon: Tag, baslik: 'AB uyumlu CPV sınıflandırması', metin: 'Türkiye\'de bir ilk: her ihale, Avrupa Birliği standardı CPV koduna göre otomatik sınıflandırılır. Sektörünüze uyan ihaleleri AB standardında filtreleyin.', renk: 'from-teal-400/20 to-teal-400/0', ikon: 'text-teal-300' }, ]; /* ---------- bileşenler ---------- */ diff --git a/src/components/LeftSidebar.jsx b/src/components/LeftSidebar.jsx index 2ab2868..cf758e0 100644 --- a/src/components/LeftSidebar.jsx +++ b/src/components/LeftSidebar.jsx @@ -3,7 +3,7 @@ import { useApp } from '../context/AppContext'; import { INSTITUTION_TYPES_LIST, CITIES_LIST } from '../data/constants'; import { Compass, Zap, Wrench, Calendar, Eye, EyeOff, FileText, CheckCircle2, XCircle, Archive, - Bot, PlusCircle, Trash2, ChevronDown, ChevronUp, Filter, MapPin, Building2, Tag, Star + Bot, PlusCircle, Trash2, ChevronDown, ChevronUp, Filter, MapPin, Building2, Tag, Star, Hash } from 'lucide-react'; export const LeftSidebar = () => { @@ -20,6 +20,10 @@ export const LeftSidebar = () => { setSelectedSektorler, toggleSektor, sektorListesi, + selectedCpvler, + setSelectedCpvler, + toggleCpv, + cpvBolumListesi, selectedKurumTipi, setSelectedKurumTipi, selectedCity, @@ -28,9 +32,11 @@ export const LeftSidebar = () => { } = useApp(); const [sektorAra, setSektorAra] = useState(''); + const [cpvAra, setCpvAra] = useState(''); const [openSections, setOpenSections] = useState({ alimTuru: true, sektorler: true, + cpv: false, kurumTipleri: false, iller: false }); @@ -62,7 +68,7 @@ export const LeftSidebar = () => { Hızlı Erişim - {(quickFilter !== 'all' || selectedSektorler.length || selectedCity || selectedAlimTuru || selectedKurumTipi) && ( + {(quickFilter !== 'all' || selectedSektorler.length || selectedCpvler.length || selectedCity || selectedAlimTuru || selectedKurumTipi) && ( + {openSections.cpv && ( +
+

+ CPV, AB'nin ihale konusunu sınıflandırdığı standart koddur. Türkiye'deki NACE/faaliyet kodunun yerine geçmez — NACE firmanızı, CPV ihalenin konusunu sınıflandırır. +

+ setCpvAra(e.target.value)} + placeholder="CPV ara…" + className="w-full border border-slate-200 rounded-lg px-2 py-1.5 mb-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-blue-500/30" + /> +
+ + {/* Çoklu seçim: kutucuklar birikir, liste kapanmaz */} + {cpvBolumListesi + .filter((c) => c.kod.includes(cpvAra) || c.etiket.toLocaleLowerCase('tr').includes(cpvAra.toLocaleLowerCase('tr'))) + .map(c => { + const secili = selectedCpvler.includes(c.kod); + return ( + + ); + })} +
+
+ )} + + {/* Kurum Tipleri */}