/** * TED ilan başlık/açıklamalarının Türkçesi. * 1) TED çok dilli alanında Türkçe varsa (tur) doğrudan o kullanılır — model gerekmez. * 2) Yoksa model ile toplu çevrilir (6 ilan = tek istek): önce Z.ai GLM * (ZAI_TRANSLATE_MODELS), o da olmazsa OpenRouter. Sonuç * server/data/ted.sqlite'ta ilan numarasına göre saklanır; ikinci görüntüleme ücretsizdir. * Çeviri başarısız olursa özgün metin döner; liste asla bekletilmez (üst sınır ~40 sn). */ import path from 'node:path'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { openRouterKey } from './ai.mjs'; import { zaiKey, zaiChat, zaiTranslateModels } from './zai.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DATA_DIR = path.join(__dirname, 'data'); const DB_PATH = path.join(DATA_DIR, 'ted.sqlite'); let db = null; function open() { if (db) return db; try { const { DatabaseSync } = require_sqlite(); fs.mkdirSync(DATA_DIR, { recursive: true }); db = new DatabaseSync(DB_PATH); db.exec(`CREATE TABLE IF NOT EXISTS ceviri ( ikn TEXT PRIMARY KEY, baslik_tr TEXT, aciklama_tr TEXT, kaynak TEXT, zaman TEXT NOT NULL )`); } catch (e) { console.warn('[ted-ceviri] sqlite açılamadı, bellek içi çalışılıyor:', e.message); db = null; } return db; } function require_sqlite() { return process.getBuiltinModule('node:sqlite'); } const bellek = new Map(); // sqlite yoksa function oku(ikn) { const d = open(); if (d) return d.prepare('SELECT baslik_tr, aciklama_tr FROM ceviri WHERE ikn = ?').get(ikn) || null; return bellek.get(ikn) || null; } function yaz(ikn, baslik_tr, aciklama_tr, kaynak) { const d = open(); if (d) d.prepare('INSERT OR REPLACE INTO ceviri (ikn, baslik_tr, aciklama_tr, kaynak, zaman) VALUES (?,?,?,?,?)').run(ikn, baslik_tr, aciklama_tr, kaynak, new Date().toISOString()); else bellek.set(ikn, { baslik_tr, aciklama_tr }); } // OpenRouter yedek sırası (yalnız Z.ai başarısız olursa ve OPENROUTER_API_KEY // varsa). Ölçüm (6 ilan, OpenRouter): deepseek-v4-flash 10 sn/851 token; // glm-5.3-flash OpenRouter üzerinden 71 sn düşünüp boş dönüyor (doğrudan // Z.ai'de reasoning_effort:low ile bu sorun yok, bkz. zai.mjs). const OR_MODELLER = ['deepseek/deepseek-v4-flash', 'google/gemini-3.7-flash', 'z-ai/glm-5.3-flash']; const SISTEM = 'Sen kamu ihale terminolojisine hâkim bir çevirmensin. Verilen AB (TED) ihale başlık ve açıklamalarını doğal, resmî Türkçeye çevir. Başlık "Ülke – Konu – İlan adı" biçimindedir: ülke adını da Türkçeleştir (Austria→Avusturya). Kurum adlarını çevirme, sayıları/tarihleri aynen koru. Türkçe yazım kurallarına uy: kelime içinde büyük İ veya I kullanma (doğru: "elektrikli otobüsler", yanlış: "Elektrİklİ"), yalnız cümle başı ve özel adlar büyük harfle başlar, i/ı ayrımını koru (Türkiye, temini, istasyonları). Yalnızca JSON döndür: {"ceviriler":[{"id":"...","baslik":"...","aciklama":"..."}]} — id alanını aynen kopyala; açıklama yoksa boş string.'; /** * GLM çıktısında görülen harf bozulmasını onarır (2026-09-11 canlı ölçümü: * 22 başlığın 4'ünde "Arazİ araçları", "Elektrİklİ", "temİNİ" gibi kelime * içi büyük İ ve büyük harfe dönen kuyruklar). Türkçede bir kelime küçük * harfle devam ettikten sonra büyük harfe geri dönmez; bu yüzden ilk küçük * harften sonraki büyükler Türkçe kuralıyla (İ→i, I→ı) küçültülür. Tamamı * büyük kısaltmalara (NATURA, MEB) dokunmaz. */ export function harfOnar(metin) { return String(metin || '').replace(/\p{L}+/gu, (kelime) => { const ilkKucuk = kelime.search(/\p{Ll}/u); if (ilkKucuk === -1) return kelime; const kuyruk = kelime.slice(ilkKucuk + 1); if (!/\p{Lu}/u.test(kuyruk)) return kelime; return kelime.slice(0, ilkKucuk + 1) + kuyruk.toLocaleLowerCase('tr'); }); } /** Çeviri yapılabilir mi: Z.ai ya da OpenRouter anahtarından biri yeter. */ export const ceviriMumkun = () => Boolean(zaiKey() || openRouterKey()); function cevirileriAyikla(icerik) { const s = icerik.slice(icerik.indexOf('{'), icerik.lastIndexOf('}') + 1); const out = JSON.parse(s); if (!Array.isArray(out?.ceviriler)) throw new Error('beklenen JSON değil'); return out.ceviriler; } async function openRouterdanCevir(model, key, mesajlar, signal) { const res = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', signal, headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://ihaleist.com', 'X-Title': 'ihaleist' }, body: JSON.stringify({ model, temperature: 0.1, max_tokens: 2500, response_format: { type: 'json_object' }, messages: mesajlar }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(`HTTP ${res.status}`); return data?.choices?.[0]?.message?.content || ''; } /** Sırayla dener: Z.ai modelleri → OpenRouter modelleri. İlk geçerli JSON döner. */ async function modeldenCevir(kalemler, signal) { if (!ceviriMumkun()) throw new Error('ZAI_API_KEY veya OPENROUTER_API_KEY yok'); const girdi = JSON.stringify({ ilanlar: kalemler.map((k) => ({ id: k.id, baslik: k.baslik, aciklama: (k.aciklama || '').slice(0, 600) })) }); const mesajlar = [{ role: 'system', content: SISTEM }, { role: 'user', content: girdi }]; const adimlar = []; if (zaiKey()) for (const model of zaiTranslateModels()) { // Arka plan işi: kullanıcının beklediği analiz istekleri sırada öne geçer. adimlar.push(() => zaiChat(model, mesajlar, { signal, temperature: 0.1, maxTokens: 2500, json: true, oncelik: 'dusuk' })); } if (openRouterKey()) for (const model of OR_MODELLER) { adimlar.push(() => openRouterdanCevir(model, openRouterKey(), mesajlar, signal)); } let sonHata = null; for (const adim of adimlar) { signal?.throwIfAborted(); try { return cevirileriAyikla(await adim()); } catch (e) { if (e?.name === 'AbortError') throw e; sonHata = e; } } throw sonHata || new Error('çeviri başarısız'); } const devamEden = new Set(); // arka planda çevrilmekte olan ikn'ler async function parcayiCevir(parca) { const ctrl = new AbortController(); const zamanlayici = setTimeout(() => ctrl.abort(), 90000); try { const sonuc = await modeldenCevir(parca, ctrl.signal); const map = new Map(sonuc.map((x) => [String(x.id), x])); for (const t of parca) { const x = map.get(String(t.id)); if (!x || !x.baslik) continue; t.baslikTr = harfOnar(String(x.baslik).trim()); t.aciklamaTr = x.aciklama ? harfOnar(String(x.aciklama).trim()) : null; t.ceviriKaynagi = 'model'; if (t.ikn) yaz(t.ikn, t.baslikTr, t.aciklamaTr, 'model'); } } finally { clearTimeout(zamanlayici); for (const t of parca) devamEden.delete(t.ikn); } } /** * Listeyi yerinde zenginleştirir: baslikTr, aciklamaTr, ceviriKaynagi. * Önbellekte olmayanlar için model çağrısı en fazla `blockMs` beklenir; kalan * parçalar arka planda sürer (liste nesneleri arama önbelleğinde durduğu için * bir sonraki istek çevirileri hazır bulur). Dönüş: kaç kaydın hâlâ beklediği. */ export async function tedCevir(tenders, { blockMs = 12000 } = {}) { if (!Array.isArray(tenders) || tenders.length === 0) return 0; const bekleyen = []; for (const t of tenders) { if (t.baslikTr) continue; if (t.baslikTur) { t.baslikTr = t.baslikTur; t.aciklamaTr = t.aciklamaTur || null; t.ceviriKaynagi = 'ted'; continue; } const c = t.ikn ? oku(t.ikn) : null; // harfOnar: düzeltme öncesi önbelleğe girmiş bozuk başlıklar da okunurken onarılır. if (c && c.baslik_tr) { t.baslikTr = harfOnar(c.baslik_tr); t.aciklamaTr = c.aciklama_tr ? harfOnar(c.aciklama_tr) : null; t.ceviriKaynagi = 'onbellek'; continue; } if (!devamEden.has(t.ikn)) bekleyen.push(t); } if (!ceviriMumkun()) return 0; if (!bekleyen.length) return tenders.filter((t) => !t.baslikTr).length; for (const t of bekleyen) devamEden.add(t.ikn); const parcalar = []; for (let i = 0; i < bekleyen.length; i += 6) parcalar.push(bekleyen.slice(i, i + 6)); // Parçalar 2'li eşzamanlı işlenir: Z.ai Coding Plan aynı anda en fazla 2 // isteğe izin veriyor; fazlası zaten zai.mjs'teki sınırda beklerdi. const is = (async () => { const kuyruk = [...parcalar]; const isci = async () => { while (kuyruk.length) { const parca = kuyruk.shift(); try { await parcayiCevir(parca); } catch (e) { console.warn('[ted-ceviri] parça atlandı:', e.message); } } }; await Promise.all(Array.from({ length: Math.min(2, parcalar.length) }, isci)); })(); await Promise.race([is, new Promise((r) => setTimeout(r, blockMs))]); return tenders.filter((t) => !t.baslikTr).length; }