Files
ihaleist/server/ted-ceviri.mjs
T

133 lines
6.4 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.
/**
* 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 OpenRouter ile toplu çevrilir (bir sayfa = tek istek), 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';
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 });
}
// Ölçüm (6 ilan): deepseek-v4-flash 10 sn/851 token; glm-5.3-flash 71 sn boyunca
// 3000 "düşünme" tokenı tüketip boş dönüyor; gemini-flash-latest OpenRouter'da
// geçersiz kimlik. Bu yüzden sıra: deepseek → gemini 3.7 flash → glm.
const 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. Yalnızca JSON döndür: {"ceviriler":[{"id":"...","baslik":"...","aciklama":"..."}]} — id alanını aynen kopyala; açıklama yoksa boş string.';
async function modeldenCevir(kalemler, signal) {
const key = openRouterKey();
if (!key) throw new Error('OPENROUTER_API_KEY yok');
const girdi = JSON.stringify({ ilanlar: kalemler.map((k) => ({ id: k.id, baslik: k.baslik, aciklama: (k.aciklama || '').slice(0, 600) })) });
let sonHata = null;
for (const model of MODELLER) {
try {
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: [{ role: 'system', content: SISTEM }, { role: 'user', content: girdi }] }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const icerik = data?.choices?.[0]?.message?.content || '';
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;
} catch (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 = String(x.baslik).trim(); t.aciklamaTr = x.aciklama ? 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;
if (c && c.baslik_tr) { t.baslikTr = c.baslik_tr; t.aciklamaTr = c.aciklama_tr || null; t.ceviriKaynagi = 'onbellek'; continue; }
if (!devamEden.has(t.ikn)) bekleyen.push(t);
}
if (!openRouterKey()) 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 3'lü eşzamanlı işlenir: 30 ilanlık liste ~5 yerine ~2 model turunda biter.
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(3, parcalar.length) }, isci));
})();
await Promise.race([is, new Promise((r) => setTimeout(r, blockMs))]);
return tenders.filter((t) => !t.baslikTr).length;
}