Agregar campo username a User entity y DTO

- Columna username (unique, nullable) en auth.users
- Campo username en CreateUserDto y UpdateUserDto

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 11:47:37 -04:00
parent 6e0ad420ab
commit 8b6483aa7d
971 changed files with 16339 additions and 752 deletions

332
scripts/generate-missing.js Executable file
View File

@@ -0,0 +1,332 @@
#!/usr/bin/env node
/**
* Script para generar descripciones y audios FALTANTES
*/
const { Client } = require('pg');
const { exec } = require('child_process');
const { promisify } = require('util');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const https = require('https');
const execAsync = promisify(exec);
// Configuración
const config = {
db: {
host: 'localhost',
port: 5432,
user: 'karibeo',
password: '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',
database: 'karibeo_db',
},
kimi: {
apiKey: process.env.KIMI_API_KEY || '',
baseUrl: 'https://api.moonshot.ai/v1',
model: 'moonshot-v1-128k',
},
tts: {
piperPath: '/home/karibeo-api/karibeo-api/tts/piper/piper',
voicesPath: '/home/karibeo-api/karibeo-api/tts/voices',
cachePath: '/home/karibeo-api/karibeo-api/tts/cache',
baseUrl: 'https://api.karibeo.ai:8443',
},
voiceMap: {
es: 'es_ES-davefx-medium',
en: 'en_US-amy-medium',
fr: 'fr_FR-siwis-medium',
it: 'it_IT-riccardo-x_low',
de: 'de_DE-thorsten-medium',
},
languages: ['es', 'en', 'fr', 'it', 'de'],
};
// Prompts por idioma
function getPrompt(placeName, country, language) {
const countryName = country === 'DO' ? 'República Dominicana' : 'Puerto Rico';
const countryNameEn = country === 'DO' ? 'Dominican Republic' : 'Puerto Rico';
const prompts = {
es: `Actúa como un guía turístico ${country === 'DO' ? 'dominicano' : 'puertorriqueño'} experto. Dame una descripción completa e interesante de ${placeName} en ${countryName}. Incluye su historia, importancia cultural, arquitectura, datos curiosos, horarios de visita si aplica. Habla en segunda persona directamente al turista usando "tú" (NO uses "vosotros" ni expresiones de España). Usa español latinoamericano natural. Máximo 250 palabras.`,
en: `Act as an expert ${country === 'DO' ? 'Dominican' : 'Puerto Rican'} tour guide. Give me a complete and interesting description of ${placeName} in ${countryNameEn}. Include its history, cultural importance, architecture, fun facts, and visiting hours if applicable. Speak in second person as if you were giving a tour. Maximum 250 words.`,
fr: `Agis comme un guide touristique ${country === 'DO' ? 'dominicain' : 'portoricain'} expert. Donne-moi une description complète et intéressante de ${placeName} en ${countryName}. Inclus son histoire, son importance culturelle, son architecture, des anecdotes et les horaires de visite si applicable. Parle à la deuxième personne comme si tu donnais une visite guidée. Maximum 250 mots.`,
it: `Agisci come una guida turistica ${country === 'DO' ? 'dominicana' : 'portoricana'} esperta. Dammi una descrizione completa e interessante di ${placeName} in ${countryName}. Includi la sua storia, importanza culturale, architettura, curiosità e orari di visita se applicabile. Parla in seconda persona come se stessi facendo un tour. Massimo 250 parole.`,
de: `Handle als erfahrener ${country === 'DO' ? 'dominikanischer' : 'puertoricanischer'} Reiseführer. Gib mir eine vollständige und interessante Beschreibung von ${placeName} in ${countryName}. Füge die Geschichte, kulturelle Bedeutung, Architektur, interessante Fakten und Besuchszeiten falls zutreffend hinzu. Sprich in der zweiten Person, als würdest du eine Tour geben. Maximal 250 Wörter.`,
};
return prompts[language] || prompts['en'];
}
function getLanguageName(code) {
const names = {
es: 'español latinoamericano',
en: 'English',
fr: 'français',
it: 'italiano',
de: 'Deutsch',
};
return names[code] || 'English';
}
// Llamar a Kimi API
async function callKimiAPI(placeName, country, language) {
const prompt = getPrompt(placeName, country, language);
const data = JSON.stringify({
model: config.kimi.model,
messages: [
{
role: 'system',
content: `Eres un guía turístico experto del Caribe. Responde siempre en ${getLanguageName(language)}. Sé informativo, amigable y entusiasta. No uses emojis.`,
},
{
role: 'user',
content: prompt,
},
],
temperature: 0.7,
max_tokens: 1000,
});
return new Promise((resolve, reject) => {
const url = new URL(`${config.kimi.baseUrl}/chat/completions`);
const options = {
hostname: url.hostname,
port: 443,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.kimi.apiKey}`,
'Content-Length': Buffer.byteLength(data),
},
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
try {
const json = JSON.parse(body);
if (json.choices && json.choices[0] && json.choices[0].message) {
resolve(json.choices[0].message.content);
} else {
reject(new Error('Invalid response: ' + body));
}
} catch (e) {
reject(e);
}
});
});
req.on('error', reject);
req.setTimeout(60000, () => {
req.destroy();
reject(new Error('Request timeout'));
});
req.write(data);
req.end();
});
}
// Limpiar texto para TTS
function cleanTextForSpeech(text) {
return text
.replace(/[\u4E00-\u9FFF]/g, '')
.replace(/[\u3400-\u4DBF]/g, '')
.replace(/[\u3040-\u309F]/g, '')
.replace(/[\u30A0-\u30FF]/g, '')
.replace(/[\uAC00-\uD7AF]/g, '')
.replace(/[\u{1F300}-\u{1F9FF}]/gu, '')
.replace(/[\u{2600}-\u{26FF}]/gu, '')
.replace(/[\u{2700}-\u{27BF}]/gu, '')
.replace(/[\u{1F600}-\u{1F64F}]/gu, '')
.replace(/[\u{1F680}-\u{1F6FF}]/gu, '')
.replace(/[\u{1F1E0}-\u{1F1FF}]/gu, '')
.replace(/[★☆✓✗✔✘●○◆◇▪▫►◄→←↑↓⇒⇐⇑⇓♠♣♥♦]/g, '')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/__([^_]+)__/g, '$1')
.replace(/_([^_]+)_/g, '$1')
.replace(/\s+/g, ' ')
.trim();
}
function getAudioHash(text, language) {
const content = `${language}:${text}`;
return crypto.createHash('md5').update(content).digest('hex');
}
async function generateAudio(text, language) {
const voice = config.voiceMap[language] || config.voiceMap['en'];
const voiceModel = path.join(config.tts.voicesPath, `${voice}.onnx`);
if (!fs.existsSync(voiceModel)) {
console.log(` ⚠️ Modelo de voz no encontrado: ${voiceModel}`);
return null;
}
const cleanText = cleanTextForSpeech(text)
.replace(/[\n\r]/g, ' ')
.replace(/"/g, "'")
.substring(0, 3000);
const hash = getAudioHash(cleanText, language);
const fileName = `${hash}.wav`;
const filePath = path.join(config.tts.cachePath, fileName);
if (fs.existsSync(filePath)) {
console.log(` 📦 Cache hit: ${fileName}`);
return `${config.tts.baseUrl}/api/v1/tts/audio/${fileName}`;
}
if (!fs.existsSync(config.tts.cachePath)) {
fs.mkdirSync(config.tts.cachePath, { recursive: true });
}
const tempTextFile = path.join(config.tts.cachePath, `${hash}.txt`);
fs.writeFileSync(tempTextFile, cleanText, 'utf-8');
try {
const command = `cat "${tempTextFile}" | "${config.tts.piperPath}" --model "${voiceModel}" --output_file "${filePath}" 2>/dev/null`;
await execAsync(command, { timeout: 120000 });
if (fs.existsSync(tempTextFile)) {
fs.unlinkSync(tempTextFile);
}
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath);
console.log(` ✅ Audio generado: ${fileName} (${Math.round(stats.size/1024)}KB)`);
return `${config.tts.baseUrl}/api/v1/tts/audio/${fileName}`;
}
return null;
} catch (error) {
console.log(` ❌ Error TTS: ${error.message}`);
if (fs.existsSync(tempTextFile)) {
fs.unlinkSync(tempTextFile);
}
return null;
}
}
async function main() {
console.log('🔧 Generador de Contenido Faltante');
console.log('===================================\n');
// Verificar API key
if (!config.kimi.apiKey) {
// Leer del .env
const envPath = '/home/karibeo-api/karibeo-api/.env';
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf-8');
const match = envContent.match(/KIMI_API_KEY=(.+)/);
if (match) {
config.kimi.apiKey = match[1].trim();
}
}
}
if (!config.kimi.apiKey) {
console.error('❌ KIMI_API_KEY no encontrada');
process.exit(1);
}
console.log('✅ Kimi API Key encontrada\n');
// Conectar a la DB
const client = new Client(config.db);
await client.connect();
console.log('✅ Conectado a la base de datos\n');
// Obtener lugares con contenido faltante
const query = `
SELECT id, name, country,
description_es, description_en, description_fr, description_it, description_de,
audio_url_es, audio_url_en, audio_url_fr, audio_url_it, audio_url_de
FROM tourism.places_of_interest
WHERE active = true
AND (
description_es IS NULL OR description_en IS NULL OR description_fr IS NULL OR description_it IS NULL OR description_de IS NULL
OR audio_url_es IS NULL OR audio_url_en IS NULL OR audio_url_fr IS NULL OR audio_url_it IS NULL OR audio_url_de IS NULL
)
ORDER BY name
`;
const result = await client.query(query);
const places = result.rows;
console.log(`📍 Encontrados ${places.length} lugares con contenido faltante\n`);
let descGenerated = 0;
let audioGenerated = 0;
let errors = 0;
for (let i = 0; i < places.length; i++) {
const place = places[i];
console.log(`\n[${i + 1}/${places.length}] ${place.name}`);
for (const lang of config.languages) {
const descCol = `description_${lang}`;
const audioCol = `audio_url_${lang}`;
let description = place[descCol];
let audioUrl = place[audioCol];
// Generar descripción si falta
if (!description) {
console.log(` 📝 Generando descripción (${lang})...`);
try {
description = await callKimiAPI(place.name, place.country || 'DO', lang);
if (description) {
await client.query(
`UPDATE tourism.places_of_interest SET ${descCol} = $1 WHERE id = $2`,
[description, place.id]
);
console.log(` ✅ Descripción generada (${lang})`);
descGenerated++;
}
} catch (error) {
console.log(` ❌ Error descripción (${lang}): ${error.message}`);
errors++;
}
await new Promise(r => setTimeout(r, 1000)); // Rate limit
}
// Generar audio si falta y tiene descripción
if (description && !audioUrl) {
console.log(` 🔊 Generando audio (${lang})...`);
audioUrl = await generateAudio(description, lang);
if (audioUrl) {
await client.query(
`UPDATE tourism.places_of_interest SET ${audioCol} = $1 WHERE id = $2`,
[audioUrl, place.id]
);
audioGenerated++;
} else {
errors++;
}
await new Promise(r => setTimeout(r, 100));
}
}
}
await client.end();
console.log('\n===================================');
console.log('📊 RESUMEN');
console.log(` 📝 Descripciones generadas: ${descGenerated}`);
console.log(` 🔊 Audios generados: ${audioGenerated}`);
console.log(` ❌ Errores: ${errors}`);
console.log('===================================\n');
}
main().catch(err => {
console.error('Error fatal:', err);
process.exit(1);
});