Files
ihaleist/server/cpv-classify.mjs
T
hikmettan c5662d2e60 CPV/OKAS ham kod sınıflandırması: yayınlanmadan önce tamamlanan bir gate + sidebar filtresi
- 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"
2026-08-30 14:00:56 +03:00

227 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}