fix: traduccion con rotacion de modelos + filtro anti-basura + benchmark de modelos
This commit is contained in:
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+40
-3
@@ -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).
|
||||
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<string | null> {
|
||||
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<string | 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
|
||||
// re-traducir nombres propios o títulos ya en español).
|
||||
function isProbablySpanish(t: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user