- server/cpv-classify.mjs (yeni): existing (kalem kodu) → keyword (66 kural, 45 OKAS_BOLUM kodunun tamamı kapsanıyor) → AI (analyzeSpec, sadece sözlükte gerçekten var olan kod kabul edilir) hibrit sınıflandırma - server/index.js: cache.tenders'a eklenmeden ÖNCE her ihale için CPV sınıflandırması await'leniyor (mevcut "sektör" alanının aksine, sonradan zenginleştirilmiyor) — hem ilk yükleme hem arka plan tamamlama yolunda - İKN bazlı kalıcı SQLite önbellek (history-store.mjs) — aynı ilan bir daha asla yeniden sınıflandırılmıyor - /api/meta/filtreler artık cpvBolumler döndürüyor - Frontend: Sektörler'in hemen altına CPV Kodu filtresi (arama + çoklu seçim), ihale kartında CPV rozeti, landing page'de yeni özellik: "AB uyumlu CPV sınıflandırması — Türkiye'de ilk"
227 lines
12 KiB
JavaScript
227 lines
12 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|