From 636d629034c2d9f8d0ffd7df8bd76b7f6885ae57 Mon Sep 17 00:00:00 2001 From: Renato Date: Wed, 5 Aug 2026 05:57:55 +0800 Subject: [PATCH] fix: traduccion con rotacion de modelos + filtro anti-basura + benchmark de modelos --- scripts/clean_junk.js | 19 +++++ scripts/translate_existing.js | 137 +++++++++++++++++++++++++--------- src/lib/translate.ts | 43 ++++++++++- 3 files changed, 159 insertions(+), 40 deletions(-) create mode 100644 scripts/clean_junk.js diff --git a/scripts/clean_junk.js b/scripts/clean_junk.js new file mode 100644 index 0000000..084c839 --- /dev/null +++ b/scripts/clean_junk.js @@ -0,0 +1,19 @@ +const Database = require("better-sqlite3"); +const db = new Database("./data/worst-scan.db"); + +// Limpiar traducciones basura (las que repiten el prompt) +const junkPrefixes = ["The user wants", "The task", "I need to", "claro que", "esta es una traducción", "aquí tienes", "la traducción es", "por supuesto", "The translation", "Here is", "The title", "Sure", "Voy a traducir", "The user asked"]; +const rows = db.prepare("SELECT id, title, title_es FROM posts WHERE title_es IS NOT NULL AND title_es != ''").all(); +let cleaned = 0; +for (const r of rows) { + const es = String(r.title_es || ""); + const isJunk = junkPrefixes.some((j) => es.startsWith(j)) || es.length > 120; + if (isJunk) { + db.prepare("UPDATE posts SET title_es = NULL WHERE id = ?").run(r.id); + console.log(`limpiado: "${String(r.title).slice(0, 40)}" -> era basura: "${es.slice(0, 40)}"`); + cleaned++; + } +} +console.log(`\nLimpiadas ${cleaned} traducciones basura`); +console.log("Quedan limpias:", db.prepare("SELECT COUNT(*) c FROM posts WHERE title_es IS NOT NULL AND title_es != ''").get().c); +db.close(); \ No newline at end of file diff --git a/scripts/translate_existing.js b/scripts/translate_existing.js index 08195dd..cbe0458 100644 --- a/scripts/translate_existing.js +++ b/scripts/translate_existing.js @@ -1,13 +1,12 @@ // Traducción masiva de títulos existentes al español. -// Usa el proxy free-ide local (deepseek-v4-flash-free con fallback). -// Corre standalone: node translate_existing.js +// CON ROTACIÓN DE MODELOS: usa TODOS los modelos free del proxy y salta +// los que dan 429/vacío (rate limit). Con reintentos y progreso. +// Corre standalone: node translate_existing.js [LIMIT] const Database = require("better-sqlite3"); const db = new Database("./data/worst-scan.db"); // Asegurar columna title_es (misma migracion que src/lib/db.ts) -try { - db.exec("ALTER TABLE posts ADD COLUMN title_es TEXT"); -} catch {} // ya existe +try { db.exec("ALTER TABLE posts ADD COLUMN title_es TEXT"); } catch {} const PROXY_URL = "http://127.0.0.1:6446/v1"; const PROXY_KEY = "tw0rxo314kvga9lfbp2nj5sde7hiq6uc"; @@ -24,6 +23,31 @@ Normas: - Si hay números de capítulo/volumen/año, mantenelos. - Respondé en UNA línea.`; +// Modelos en orden de preferencia (benchmark real 2026-08): +// nemotron-3-nano-omni y openrouter/free traducen BIEN (no meta-pensamiento). +// Los demas son fallbacks que a veces dan basura (el filtro la descarta). +const MODEL_CANDIDATES = [ + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", // ⭐ traduce bien, 5.5s + "openrouter/free", // ⭐ traduce bien, 2.7s + "deepseek-v4-flash-free", // bueno cuando no esta cageado + "big-pickle", + "tencent/hy3:free", + "nvidia/nemotron-3-ultra-550b-a55b:free", + "mimo-v2.5-free", + "cohere/north-mini-code:free", + "inclusionai/ling-3.0-flash:free", + "nvidia/nemotron-3-super-120b-a12b:free", + "nemotron-3-ultra-free", + "north-mini-code-free", + "poolside/laguna-s-2.1:free", + "poolside/laguna-xs-2.1:free", + "kilo-auto/free", + "forever_free", +]; + +// Modelos que fallaron (rate limit / vacío) -> no reintentar por un rato +const deadModels = new Map(); // modelo -> timestamp cuando se puede reintentar + function isProbablySpanish(t) { if (/[\u3040-\u30ff\u4e00-\u9fff]/.test(t)) return false; const spanishWords = ["el ", "la ", "los ", "las ", " de ", " y ", " un ", " una ", " para ", "con ", "por "]; @@ -32,63 +56,102 @@ function isProbablySpanish(t) { return count >= 3; } +async function tryModel(model, title) { + const res = await fetch(`${PROXY_URL}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${PROXY_KEY}` }, + body: JSON.stringify({ + model, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: title }, + ], + max_tokens: 200, + temperature: 0.3, + }), + signal: AbortSignal.timeout(60000), + }); + if (!res.ok) return { ok: false, rateLimited: res.status === 429 }; + const data = await res.json(); + const content = data?.choices?.[0]?.message?.content; + const text = (content || "").trim().replace(/^["']|["']$/g, ""); + if (!text) return { ok: false, rateLimited: true }; // vacío = agotado + // Rechazar respuestas que NO son una traducción (repiten el prompt o razonan). + if (!isValidTranslation(text)) return { ok: false, rateLimited: false }; + return { ok: true, text }; +} + +// Filtra respuestas basura: si el modelo repite el prompt o razona en voz alta +// ("The user wants...", "I need to translate...", "Esta es una traducción...") +// no es una traducción válida. +function isValidTranslation(text) { + const t = text.toLowerCase(); + const junk = [ + "the user wants", "the task", "i need to", "i'll", "the manga title", + "esta es una traducción", "aquí tienes", "la traducción es", "claro que", + "por supuesto", "the translation", "here is", "the title", "sure", + "voy a traducir", "the user asked", "the user is", + ]; + // Si es muy larga (> 120 chars) probablemente es razonamiento, no título. + if (text.length > 120) return false; + for (const j of junk) if (t.includes(j)) return false; + return true; +} + async function translate(title) { const clean = (title || "").trim(); if (!clean) return null; if (isProbablySpanish(clean)) return clean; - for (const model of ["deepseek-v4-flash-free", "big-pickle"]) { - try { - const res = await fetch(`${PROXY_URL}/chat/completions`, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${PROXY_KEY}` }, - body: JSON.stringify({ - model, - messages: [ - { role: "system", content: SYSTEM_PROMPT }, - { role: "user", content: clean }, - ], - max_tokens: 200, - temperature: 0.3, - }), - signal: AbortSignal.timeout(90000), - }); - if (!res.ok) continue; - const data = await res.json(); - const content = data?.choices?.[0]?.message?.content; - if (!content || !content.trim()) continue; - return content.trim().replace(/^["']|["']$/g, ""); - } catch { - continue; + // Bucle sobre modelos con backtracking: si todos fallan, reintentar tras pausa + for (let attempt = 0; attempt < 3; attempt++) { + for (const model of MODEL_CANDIDATES) { + const retryAt = deadModels.get(model) || 0; + if (Date.now() < retryAt) continue; + try { + const r = await tryModel(model, clean); + if (r.ok) return r.text; + if (r.rateLimited) { + // Marcar como muerto por 5 min + deadModels.set(model, Date.now() + 5 * 60 * 1000); + console.log(` [rate-limit] ${model} -> pausado 5 min`); + } + } catch { + deadModels.set(model, Date.now() + 60 * 1000); + } + } + // Todos fallaron: esperar 30s antes del siguiente intento + if (attempt < 2) { + await new Promise((r) => setTimeout(r, 30000)); } } return null; } async function main() { - const limit = parseInt(process.env.LIMIT || "0", 10); - const posts = db.prepare("SELECT id, gid, title, title_es FROM posts WHERE published=1 OR published=0").all(); + const limit = parseInt(process.argv[2] || "0", 10); + const posts = db.prepare("SELECT id, gid, title, title_es FROM posts").all(); let toTranslate = posts.filter(p => !p.title_es); if (limit > 0) toTranslate = toTranslate.slice(0, limit); - console.log(`Total posts: ${posts.length} | sin traducción: ${toTranslate.length}${limit ? ` (probando ${limit})` : ""}`); + console.log(`Total: ${posts.length} | sin traducción: ${toTranslate.length}${limit ? ` (lote ${limit})` : ""}`); - let ok = 0, fail = 0, skip = 0; + let ok = 0, fail = 0; for (let i = 0; i < toTranslate.length; i++) { const p = toTranslate[i]; const t = await translate(p.title); if (t) { db.prepare("UPDATE posts SET title_es = ?, updated_at = datetime('now') WHERE id = ?").run(t, p.id); ok++; - console.log(`[${i + 1}/${toTranslate.length}] ✓ ${p.title.slice(0, 40)} -> ${t.slice(0, 50)}`); + console.log(`[${i + 1}/${toTranslate.length}] ✓ ${String(p.title).slice(0, 40)} -> ${t.slice(0, 50)}`); } else { fail++; - console.log(`[${i + 1}/${toTranslate.length}] ✗ ${p.title.slice(0, 40)} (sin traducción)`); + console.log(`[${i + 1}/${toTranslate.length}] ✗ ${String(p.title).slice(0, 40)}`); } - // Delay pequeño entre requests para no saturar el proxy - await new Promise((r) => setTimeout(r, 300)); + // Pausa corta entre títulos para no saturar + await new Promise((r) => setTimeout(r, 1000)); } - console.log(`\n=== RESULTADO: ${ok} traducidos, ${fail} fallaron, ${skip} omitidos ===`); + console.log(`\n=== RESULTADO: ${ok} traducidos, ${fail} fallaron ===`); db.close(); } diff --git a/src/lib/translate.ts b/src/lib/translate.ts index cc415ab..dd2f503 100644 --- a/src/lib/translate.ts +++ b/src/lib/translate.ts @@ -27,7 +27,26 @@ export async function translateTitle(title: string): Promise { // Si ya parece estar en español, no traducir (heurística ligera). if (isProbablySpanish(clean)) return clean - const models = ["deepseek-v4-flash-free", "big-pickle"] + // Modelos en orden de preferencia (benchmark real 2026-08): + // nemotron-3-nano-omni y openrouter/free traducen bien. + const models = [ + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", + "openrouter/free", + "deepseek-v4-flash-free", + "big-pickle", + "tencent/hy3:free", + "nvidia/nemotron-3-ultra-550b-a55b:free", + "mimo-v2.5-free", + "cohere/north-mini-code:free", + "inclusionai/ling-3.0-flash:free", + "nvidia/nemotron-3-super-120b-a12b:free", + "nemotron-3-ultra-free", + "north-mini-code-free", + "poolside/laguna-s-2.1:free", + "poolside/laguna-xs-2.1:free", + "kilo-auto/free", + "forever_free", + ] for (const model of models) { try { @@ -51,8 +70,11 @@ export async function translateTitle(title: string): Promise { if (!res.ok) continue const data = await res.json() const content: string | undefined = data?.choices?.[0]?.message?.content - if (!content || !content.trim()) continue - return content.trim().replace(/^["']|["']$/g, "") + const text = (content || "").trim().replace(/^["']|["']$/g, "") + if (!text) continue + // Rechazar respuestas que NO son traducción (repiten el prompt). + if (!isValidTranslation(text)) continue + return text } catch { continue } @@ -60,6 +82,21 @@ export async function translateTitle(title: string): Promise { return null } +// Filtra respuestas basura: si el modelo razona en voz alta ("The user +// wants...") o entrega explicaciones, no es una traducción válida. +function isValidTranslation(text: string): boolean { + const t = text.toLowerCase() + const junk = [ + "the user wants", "the task", "i need to", "i'll", "the manga title", + "esta es una traducción", "aquí tienes", "la traducción es", "claro que", + "por supuesto", "the translation", "here is", "the title", "sure", + "voy a traducir", "the user asked", "the user is", + ] + if (text.length > 120) return false + for (const j of junk) if (t.includes(j)) return false + return true +} + // Heurística: detecta si el título ya está mayormente en español (para no // re-traducir nombres propios o títulos ya en español). function isProbablySpanish(t: string): boolean {