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.
|
// 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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user