AB İhaleleri (TED): başlık ve açıklamalar Türkçe
- server/ted-ceviri.mjs: TED'in kendi Türkçe alanı varsa onu kullanır; yoksa OpenRouter ile toplu çevirir (deepseek-v4-flash → gemini 3.7 flash → glm), sonucu server/data/ted.sqlite'ta ilan numarasına göre saklar - İstek en fazla 12 sn bekler, kalan çeviriler arka planda sürer; yanıt ceviriBekleniyor bayrağı taşır, ön yüz sessizce yeniden çeker - Kartta Türkçe başlık + küçük özgün başlık, Türkçe açıklama (3 satır), ülke kodları Türkçe ad (TUR → Türkiye, AUT → Avusturya…) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 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));
|
||||
const is = (async () => {
|
||||
for (const parca of parcalar) {
|
||||
try { await parcayiCevir(parca); } catch (e) { console.warn('[ted-ceviri] parça atlandı:', e.message); }
|
||||
}
|
||||
})();
|
||||
await Promise.race([is, new Promise((r) => setTimeout(r, blockMs))]);
|
||||
return tenders.filter((t) => !t.baslikTr).length;
|
||||
}
|
||||
Reference in New Issue
Block a user