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"
This commit is contained in:
2026-08-30 14:00:56 +03:00
parent f98568da8e
commit c5662d2e60
10 changed files with 490 additions and 15 deletions
+113 -2
View File
@@ -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;
}