fix: traduccion con rotacion de modelos + filtro anti-basura + benchmark de modelos

This commit is contained in:
Renato
2026-08-05 05:57:55 +08:00
parent 13e507fbb9
commit 636d629034
3 changed files with 159 additions and 40 deletions
+19
View File
@@ -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();
+100 -37
View File
@@ -1,13 +1,12 @@
// Traducción masiva de títulos existentes al español. // Traducción masiva de títulos existentes al español.
// Usa el proxy free-ide local (deepseek-v4-flash-free con fallback). // CON ROTACIÓN DE MODELOS: usa TODOS los modelos free del proxy y salta
// Corre standalone: node translate_existing.js // 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 Database = require("better-sqlite3");
const db = new Database("./data/worst-scan.db"); const db = new Database("./data/worst-scan.db");
// Asegurar columna title_es (misma migracion que src/lib/db.ts) // Asegurar columna title_es (misma migracion que src/lib/db.ts)
try { try { db.exec("ALTER TABLE posts ADD COLUMN title_es TEXT"); } catch {}
db.exec("ALTER TABLE posts ADD COLUMN title_es TEXT");
} catch {} // ya existe
const PROXY_URL = "http://127.0.0.1:6446/v1"; const PROXY_URL = "http://127.0.0.1:6446/v1";
const PROXY_KEY = "tw0rxo314kvga9lfbp2nj5sde7hiq6uc"; const PROXY_KEY = "tw0rxo314kvga9lfbp2nj5sde7hiq6uc";
@@ -24,6 +23,31 @@ Normas:
- Si hay números de capítulo/volumen/año, mantenelos. - Si hay números de capítulo/volumen/año, mantenelos.
- Respondé en UNA línea.`; - 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) { function isProbablySpanish(t) {
if (/[\u3040-\u30ff\u4e00-\u9fff]/.test(t)) return false; if (/[\u3040-\u30ff\u4e00-\u9fff]/.test(t)) return false;
const spanishWords = ["el ", "la ", "los ", "las ", " de ", " y ", " un ", " una ", " para ", "con ", "por "]; const spanishWords = ["el ", "la ", "los ", "las ", " de ", " y ", " un ", " una ", " para ", "con ", "por "];
@@ -32,63 +56,102 @@ function isProbablySpanish(t) {
return count >= 3; 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) { async function translate(title) {
const clean = (title || "").trim(); const clean = (title || "").trim();
if (!clean) return null; if (!clean) return null;
if (isProbablySpanish(clean)) return clean; if (isProbablySpanish(clean)) return clean;
for (const model of ["deepseek-v4-flash-free", "big-pickle"]) { // Bucle sobre modelos con backtracking: si todos fallan, reintentar tras pausa
try { for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(`${PROXY_URL}/chat/completions`, { for (const model of MODEL_CANDIDATES) {
method: "POST", const retryAt = deadModels.get(model) || 0;
headers: { "Content-Type": "application/json", Authorization: `Bearer ${PROXY_KEY}` }, if (Date.now() < retryAt) continue;
body: JSON.stringify({ try {
model, const r = await tryModel(model, clean);
messages: [ if (r.ok) return r.text;
{ role: "system", content: SYSTEM_PROMPT }, if (r.rateLimited) {
{ role: "user", content: clean }, // Marcar como muerto por 5 min
], deadModels.set(model, Date.now() + 5 * 60 * 1000);
max_tokens: 200, console.log(` [rate-limit] ${model} -> pausado 5 min`);
temperature: 0.3, }
}), } catch {
signal: AbortSignal.timeout(90000), deadModels.set(model, Date.now() + 60 * 1000);
}); }
if (!res.ok) continue; }
const data = await res.json(); // Todos fallaron: esperar 30s antes del siguiente intento
const content = data?.choices?.[0]?.message?.content; if (attempt < 2) {
if (!content || !content.trim()) continue; await new Promise((r) => setTimeout(r, 30000));
return content.trim().replace(/^["']|["']$/g, "");
} catch {
continue;
} }
} }
return null; return null;
} }
async function main() { async function main() {
const limit = parseInt(process.env.LIMIT || "0", 10); const limit = parseInt(process.argv[2] || "0", 10);
const posts = db.prepare("SELECT id, gid, title, title_es FROM posts WHERE published=1 OR published=0").all(); const posts = db.prepare("SELECT id, gid, title, title_es FROM posts").all();
let toTranslate = posts.filter(p => !p.title_es); let toTranslate = posts.filter(p => !p.title_es);
if (limit > 0) toTranslate = toTranslate.slice(0, limit); 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++) { for (let i = 0; i < toTranslate.length; i++) {
const p = toTranslate[i]; const p = toTranslate[i];
const t = await translate(p.title); const t = await translate(p.title);
if (t) { if (t) {
db.prepare("UPDATE posts SET title_es = ?, updated_at = datetime('now') WHERE id = ?").run(t, p.id); db.prepare("UPDATE posts SET title_es = ?, updated_at = datetime('now') WHERE id = ?").run(t, p.id);
ok++; 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 { } else {
fail++; 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 // Pausa corta entre títulos para no saturar
await new Promise((r) => setTimeout(r, 300)); 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(); db.close();
} }
+40 -3
View File
@@ -27,7 +27,26 @@ export async function translateTitle(title: string): Promise<string | null> {
// Si ya parece estar en español, no traducir (heurística ligera). // Si ya parece estar en español, no traducir (heurística ligera).
if (isProbablySpanish(clean)) return clean 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) { for (const model of models) {
try { try {
@@ -51,8 +70,11 @@ export async function translateTitle(title: string): Promise<string | null> {
if (!res.ok) continue if (!res.ok) continue
const data = await res.json() const data = await res.json()
const content: string | undefined = data?.choices?.[0]?.message?.content const content: string | undefined = data?.choices?.[0]?.message?.content
if (!content || !content.trim()) continue const text = (content || "").trim().replace(/^["']|["']$/g, "")
return content.trim().replace(/^["']|["']$/g, "") if (!text) continue
// Rechazar respuestas que NO son traducción (repiten el prompt).
if (!isValidTranslation(text)) continue
return text
} catch { } catch {
continue continue
} }
@@ -60,6 +82,21 @@ export async function translateTitle(title: string): Promise<string | null> {
return null 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 // 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). // re-traducir nombres propios o títulos ya en español).
function isProbablySpanish(t: string): boolean { function isProbablySpanish(t: string): boolean {