Files
ihaleist/server/ted-ceviri.mjs
T
hikmettanandClaude Fable 5.1 7fa71f1a4e
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
feat(ai): Z.ai GLM birincil sağlayıcı; OpenRouter yalnız yedek
OpenRouter anahtarı iptal olup bakiye eksiye düşünce şartname analizi,
TED çevirisi ve site taraması durmuştu. Artık ZAI_API_KEY ile GLM 5.3 ve
GLM 5.3 Flash doğrudan Z.ai Coding Plan üzerinden çağrılır; kalan modeller
OpenRouter anahtarı varsa yedek olarak denenir.

- server/zai.mjs: doğrudan istemci; glm-5.x için reasoning_effort:low,
  glm-4.x için thinking kapalı; süreç genelinde en fazla 2 eşzamanlı istek
  (kullanıcı istekleri arka plan çevirisinden öncelikli); geçici 429'da
  aynı modelle yeniden deneme, 1113 bakiye hatasında hemen sıradaki model.
- ai.mjs: 'zai' (GLM 5.3) sağlayıcısı eklendi, Z.ai yolu yeni istemciyi
  kullanıyor; varsayılan sıra zai → glmflash → gemini → …
- ted-ceviri.mjs: önce ZAI_TRANSLATE_MODELS (glm-5.3-flash, glm-5.3),
  sonra OpenRouter; 2'li eşzamanlı parça işleme.
- Admin model kataloğuna GLM 5.3 eklendi, kayıtlı zincirler sürüm 4'e taşınır.
- tests/zai.test.mjs: düşünme ayarı, 429/1113 davranışı, eşzamanlılık,
  analyzeSpec ve TED çevirisinin Z.ai'ye gittiği.

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

154 lines
7.5 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. Yalnızca JSON döndür: {"ceviriler":[{"id":"...","baslik":"...","aciklama":"..."}]} — id alanını aynen kopyala; açıklama yoksa boş string.';
/** Ç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 = 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 (!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;
}