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
+50 -4
View File
@@ -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 })),
});
}