Files
ihaleist/server/ted-ceviri.mjs
T
hikmettanandClaude Fable 5.1 9b3809e21b
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
fix(ted-ceviri): GLM harf bozulmasını onar, TED çevirisinde önce GLM 5.3
Canlı ölçüm (2026-09-11): glm-5.3-flash 22 başlığın 4'ünde kelime içi
büyük İ ve büyük harfe dönen kuyruk üretti ("Arazİ araçları",
"Elektrİklİ", "temİNİ"); açıklamalar temizdi.

- harfOnar: kelime küçük harfle devam ettikten sonra gelen büyükleri Türkçe
  kuralıyla küçültür; tamamı büyük kısaltmalara dokunmaz. Model çıktısına
  ve daha önce önbelleğe girmiş kayıtlara okunurken uygulanır.
- Çeviri prompt'una Türkçe yazım kuralı uyarısı eklendi.
- ZAI_TRANSLATE_MODELS varsayılanı glm-5.3 → glm-5.3-flash (paketler 6
  ilanlık, hız kaybı küçük; başlıklar kullanıcıya doğrudan görünüyor).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 00:26:33 +03:00

173 lines
8.7 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 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;
}