feat(ai): Z.ai GLM birincil sağlayıcı; OpenRouter yalnız yedek
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s

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>
This commit is contained in:
2026-09-10 23:11:41 +03:00
co-authored by Claude Fable 5.1
parent be41ec7f4a
commit 7fa71f1a4e
8 changed files with 323 additions and 48 deletions
+47 -26
View File
@@ -1,7 +1,8 @@
/**
* 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ç
* 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).
*/
@@ -9,6 +10,7 @@ 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');
@@ -44,33 +46,51 @@ function yaz(ikn, baslik_tr, aciklama_tr, kaynak) {
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'];
// 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) {
const key = openRouterKey();
if (!key) throw new Error('OPENROUTER_API_KEY yok');
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 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; }
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');
}
@@ -111,12 +131,13 @@ export async function tedCevir(tenders, { blockMs = 12000 } = {}) {
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 (!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 3'lü eşzamanlı işlenir: 30 ilanlık liste ~5 yerine ~2 model turunda biter.
// 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 () => {
@@ -125,7 +146,7 @@ export async function tedCevir(tenders, { blockMs = 12000 } = {}) {
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.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;