Proeycto de images-worker creado

This commit is contained in:
unknown
2026-06-07 18:11:44 -04:00
parent fec365bb57
commit cb44779349
45 changed files with 6410 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { WebhookModule } from './webhook/webhook.module';
import { PipelineModule } from './pipeline/pipeline.module';
import { ReformixModule } from './reformix/reformix.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
WebhookModule,
PipelineModule,
ReformixModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,20 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log', 'debug'],
});
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }));
const config = app.get(ConfigService);
const port = config.get('PORT', 3001);
await app.listen(port);
console.log(`[Reformix Image Worker] corriendo en puerto ${port}`);
}
bootstrap();

View File

@@ -0,0 +1,84 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
@Injectable()
export class ImageGeneratorService {
private readonly logger = new Logger(ImageGeneratorService.name);
constructor(private readonly config: ConfigService) {}
async generarRender(prompt: string, fotoAntesDataUri: string): Promise<string> {
const apiKey = this.config.get<string>('OPENROUTER_API_KEY');
const model = this.config.get<string>('OPENROUTER_MODEL_IMAGEN', 'google/gemini-2.0-flash-exp-image-generation');
const intentosRateLimit = 1;
for (let attempt = 0; attempt <= intentosRateLimit; attempt++) {
try {
const response = await axios.post(
OPENROUTER_URL,
{
model,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: fotoAntesDataUri } },
],
},
],
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://reformix.es',
'X-Title': 'Reformix Image Worker',
},
timeout: 60000,
},
);
const content = response.data.choices?.[0]?.message?.content;
if (!content) throw new Error('OpenRouter no devolvio contenido');
const imagen = this.extraerImagenDeRespuesta(content, response.data);
if (!imagen) throw new Error('No se pudo extraer imagen de la respuesta');
return imagen;
} catch (err: any) {
if (err.response?.status === 429 && attempt < intentosRateLimit) {
this.logger.warn('Rate limit (429), esperando 5s y reintentando...');
await new Promise((r) => setTimeout(r, 5000));
continue;
}
throw err;
}
}
throw new Error('Fallaron todos los intentos de generacion de imagen');
}
private extraerImagenDeRespuesta(content: string, rawResponse?: any): string | null {
if (content.startsWith('data:image')) return content;
const dataUriMatch = content.match(/data:image\/[a-zA-Z]+;base64,[^\s"']+/);
if (dataUriMatch) return dataUriMatch[0];
const urlMatch = content.match(/https?:\/\/[^\s"'()]+\.(png|jpg|jpeg|webp)/i);
if (urlMatch) return urlMatch[0];
const parts = rawResponse?.choices?.[0]?.message?.content;
if (Array.isArray(parts)) {
for (const part of parts) {
if (part.type === 'image_url' && part.image_url?.url) return part.image_url.url;
if (part.image_url?.url?.startsWith('data:image')) return part.image_url.url;
}
}
return null;
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { PipelineService } from './pipeline.service';
import { PromptBuilderService } from './prompt-builder.service';
import { ImageGeneratorService } from './image-generator.service';
import { SupervisorService } from './supervisor.service';
import { ReformixModule } from '../reformix/reformix.module';
@Module({
imports: [ReformixModule],
providers: [PipelineService, PromptBuilderService, ImageGeneratorService, SupervisorService],
exports: [PipelineService],
})
export class PipelineModule {}

View File

@@ -0,0 +1,114 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PerfilCompletoDto } from '../webhook/webhook.dto';
import { PromptBuilderService } from './prompt-builder.service';
import { ImageGeneratorService } from './image-generator.service';
import { SupervisorService } from './supervisor.service';
import { ReformixService } from '../reformix/reformix.service';
interface ZonaRender {
zona: string;
imagen: string;
score: number;
aprobada: boolean;
}
@Injectable()
export class PipelineService {
private readonly logger = new Logger(PipelineService.name);
private readonly maxRetries: number;
private readonly minScore: number;
constructor(
private readonly config: ConfigService,
private readonly promptBuilder: PromptBuilderService,
private readonly imageGenerator: ImageGeneratorService,
private readonly supervisor: SupervisorService,
private readonly reformix: ReformixService,
) {
this.maxRetries = this.config.get<number>('MAX_RETRIES', 2);
this.minScore = this.config.get<number>('SUPERVISOR_MIN_SCORE', 70);
}
async procesarLead(dto: PerfilCompletoDto): Promise<void> {
const { leadId, reforma, zonas } = dto;
const zonasConFotos = zonas.filter((z) => z.fotos.antes.length > 0);
const zonasSaltadas = zonas.filter((z) => z.fotos.antes.length === 0);
this.logger.log(`[${leadId}] Iniciando pipeline para ${zonasConFotos.length} zonas`);
for (const z of zonasSaltadas) {
this.logger.log(`[${leadId}] Zona ${z.zona}: sin fotos "antes", saltando`);
}
const renders: ZonaRender[] = [];
for (const zona of zonasConFotos) {
try {
const render = await this.procesarZona(leadId, zona.zona, reforma, zona.notas, zona.fotos.antes[0]);
renders.push(render);
} catch (err: any) {
this.logger.error(`[${leadId}] Zona ${zona.zona}: error fatal: ${err.message}`);
}
}
if (renders.length === 0) {
this.logger.warn(`[${leadId}] No se generaron renders para ninguna zona`);
return;
}
const items = renders.map((r) => ({
zona: r.zona,
imagen: r.imagen,
}));
const ok = await this.reformix.entregarRenders(leadId, items);
if (ok) {
this.logger.log(`[${leadId}] Renders entregados correctamente (${renders.length} zonas)`);
} else {
this.logger.error(`[${leadId}] Error entregando renders a la app principal`);
}
}
private async procesarZona(
leadId: string,
zona: string,
reforma: PerfilCompletoDto['reforma'],
notas: string[],
fotoAntes: string,
): Promise<ZonaRender> {
const prompt = await this.promptBuilder.generarPrompt(reforma.tipo, reforma.m2Suelo, reforma.calidad, notas);
this.logger.log(`[${leadId}] Zona ${zona}: prompt generado`);
let ultimaImagen: string | null = null;
for (let intento = 0; intento <= this.maxRetries; intento++) {
if (intento > 0) {
this.logger.log(`[${leadId}] Zona ${zona}: reintento ${intento} de ${this.maxRetries}`);
}
const imagen = await this.imageGenerator.generarRender(prompt, fotoAntes);
ultimaImagen = imagen;
this.logger.log(`[${leadId}] Zona ${zona}: imagen generada`);
const resultado = await this.supervisor.supervisar(
reforma.tipo,
reforma.m2Suelo,
reforma.calidad,
notas,
fotoAntes,
imagen,
);
const aprobada = resultado.aprobado && resultado.score >= this.minScore;
this.logger.log(`[${leadId}] Zona ${zona}: ${aprobada ? 'aprobada' : 'rechazada'} (score: ${resultado.score}) - ${resultado.motivo}`);
if (aprobada) {
return { zona, imagen, score: resultado.score, aprobada: true };
}
}
this.logger.warn(`[${leadId}] Zona ${zona}: usando ultimo render pese a no superar validacion`);
return { zona, imagen: ultimaImagen!, score: 0, aprobada: false };
}
}

View File

@@ -0,0 +1,68 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
import axios from 'axios';
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
@Injectable()
export class PromptBuilderService {
private readonly logger = new Logger(PromptBuilderService.name);
private systemPrompt = '';
constructor(private readonly config: ConfigService) {
const ruta = path.join(process.cwd(), 'prompts', 'prompt-builder.txt');
if (fs.existsSync(ruta)) {
this.systemPrompt = fs.readFileSync(ruta, 'utf-8');
} else {
this.logger.warn('prompts/prompt-builder.txt no encontrado, usando default');
}
}
async generarPrompt(
tipoReforma: string,
m2Suelo: number | null,
calidad: string,
notas: string[],
): Promise<string> {
const apiKey = this.config.get<string>('OPENROUTER_API_KEY');
const model = this.config.get<string>('OPENROUTER_MODEL_TEXTO', 'anthropic/claude-3.5-haiku-20241022');
const userContent = `Generate a render prompt for a ${tipoReforma} renovation.
- Area: ${m2Suelo ?? 'unknown'}
- Quality level: ${calidad}
- Client notes: ${notas.join('; ') || 'none'}
- Style: modern ${tipoReforma} renovation`;
try {
const response = await axios.post(
OPENROUTER_URL,
{
model,
messages: [
{ role: 'system', content: this.systemPrompt },
{ role: 'user', content: userContent },
],
max_tokens: 512,
temperature: 0.5,
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://reformix.es',
'X-Title': 'Reformix Image Worker',
},
},
);
const prompt = response.data.choices?.[0]?.message?.content?.trim();
if (!prompt) throw new Error('OpenRouter devolvio respuesta vacia');
return prompt;
} catch (err: any) {
this.logger.error(`Error generando prompt: ${err.message}`);
throw err;
}
}
}

View File

@@ -0,0 +1,109 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
import axios from 'axios';
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
export interface SupervisarResultado {
aprobado: boolean;
score: number;
motivo: string;
}
@Injectable()
export class SupervisorService {
private readonly logger = new Logger(SupervisorService.name);
private systemPrompt = '';
constructor(private readonly config: ConfigService) {
const ruta = path.join(process.cwd(), 'prompts', 'supervisor.txt');
if (fs.existsSync(ruta)) {
this.systemPrompt = fs.readFileSync(ruta, 'utf-8');
} else {
this.logger.warn('prompts/supervisor.txt no encontrado, usando default');
}
}
async supervisar(
tipoReforma: string,
m2Suelo: number | null,
calidad: string,
notas: string[],
fotoAntes: string,
renderDespues: string,
): Promise<SupervisarResultado> {
const apiKey = this.config.get<string>('OPENROUTER_API_KEY');
const model = this.config.get<string>('OPENROUTER_MODEL_TEXTO', 'anthropic/claude-3.5-haiku-20241022');
const notasTexto = notas.join('; ') || 'sin notas';
try {
const response = await axios.post(
OPENROUTER_URL,
{
model,
messages: [
{ role: 'system', content: this.systemPrompt },
{
role: 'user',
content: [
{
type: 'text',
text: `Reforma tipo: ${tipoReforma}\nMetros: ${m2Suelo ?? 'desconocido'}\nCalidad: ${calidad}\nNotas del cliente: ${notasTexto}`,
},
{
type: 'image_url',
image_url: { url: fotoAntes },
},
{
type: 'image_url',
image_url: { url: renderDespues },
},
],
},
],
max_tokens: 256,
temperature: 0.2,
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://reformix.es',
'X-Title': 'Reformix Image Worker',
},
},
);
const textContent = response.data.choices?.[0]?.message?.content?.trim();
if (!textContent) {
return { aprobado: false, score: 0, motivo: 'Modelo devolvio respuesta vacia' };
}
return this.parsearRespuesta(textContent);
} catch (err: any) {
this.logger.error(`Error en supervisor: ${err.message}`);
return { aprobado: false, score: 0, motivo: `Error del supervisor: ${err.message}` };
}
}
private parsearRespuesta(texto: string): SupervisarResultado {
const jsonMatch = texto.match(/\{[^{}]*"aprobado"[^{}]*\}/i);
if (!jsonMatch) {
return { aprobado: false, score: 0, motivo: 'Error parseando respuesta del supervisor' };
}
try {
const parsed = JSON.parse(jsonMatch[0]);
return {
aprobado: Boolean(parsed.aprobado),
score: Math.min(100, Math.max(0, Number(parsed.score) || 0)),
motivo: String(parsed.motivo || 'Sin motivo'),
};
} catch {
return { aprobado: false, score: 0, motivo: 'Error parseando respuesta del supervisor' };
}
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ReformixService } from './reformix.service';
@Module({
providers: [ReformixService],
exports: [ReformixService],
})
export class ReformixModule {}

View File

@@ -0,0 +1,87 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
@Injectable()
export class ReformixService {
private readonly logger = new Logger(ReformixService.name);
private readonly baseUrl: string;
private readonly apiKey: string;
constructor(private readonly config: ConfigService) {
this.baseUrl = this.config.get<string>('REFORMIX_API_URL', 'http://localhost:3000');
this.apiKey = this.config.get<string>('FUNNEL_API_KEY', '');
}
async entregarRenders(
leadId: string,
items: Array<{ zona: string; imagen: string }>,
): Promise<boolean> {
const body = {
items: items.map((i) => ({
tipo: 'foto',
zona: i.zona,
momento: 'despues',
imagen: i.imagen,
})),
finalizar: true,
};
const maxRetries = 3;
const delay = 2000;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await axios.post(
`${this.baseUrl}/api/leads/${leadId}/ingesta`,
body,
{
headers: {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
timeout: 30000,
},
);
if (response.status === 200) {
this.logger.log(`[${leadId}] Renders entregados correctamente`);
return true;
}
if (response.status === 404) {
this.logger.error(`[${leadId}] Lead no encontrado (404), abandonando`);
return false;
}
if (response.status === 422) {
this.logger.error(`[${leadId}] Payload invalido (422): ${JSON.stringify(response.data)}`);
return false;
}
if (response.status >= 500 && attempt < maxRetries) {
this.logger.warn(`[${leadId}] Intento ${attempt}/${maxRetries} fallo (${response.status}), reintentando...`);
await new Promise((r) => setTimeout(r, delay));
continue;
}
this.logger.error(`[${leadId}] Error inesperado (${response.status}): ${JSON.stringify(response.data)}`);
return false;
} catch (err: any) {
if (err.response?.status === 404) {
this.logger.error(`[${leadId}] Lead no encontrado (404), abandonando`);
return false;
}
if (attempt < maxRetries) {
this.logger.warn(`[${leadId}] Intento ${attempt}/${maxRetries} error de red, reintentando...`);
await new Promise((r) => setTimeout(r, delay));
} else {
this.logger.error(`[${leadId}] Error critico entregando renders tras ${maxRetries} intentos: ${err.message}`);
return false;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,23 @@
import { Controller, Post, Body, Logger } from '@nestjs/common';
import { PerfilCompletoDto } from './webhook.dto';
import { PipelineService } from '../pipeline/pipeline.service';
@Controller()
export class WebhookController {
private readonly logger = new Logger(WebhookController.name);
constructor(private readonly pipelineService: PipelineService) {}
@Post('perfil-completo')
recibirPerfil(@Body() dto: PerfilCompletoDto) {
this.logger.log(`[${dto.leadId}] Webhook recibido: ${dto.zonas.length} zonas`);
setImmediate(() => {
this.pipelineService.procesarLead(dto).catch((err) => {
this.logger.error(`[${dto.leadId}] Pipeline fallo: ${err.message}`, err.stack);
});
});
return { ok: true, message: 'Procesando renders en background...' };
}
}

View File

@@ -0,0 +1,97 @@
import { IsString, IsOptional, IsNumber, IsBoolean, IsArray, ValidateNested, IsIn, IsUUID } from 'class-validator';
import { Type } from 'class-transformer';
class ClienteDto {
@IsString()
nombre: string;
@IsString()
telefono: string;
@IsString()
email: string;
@IsOptional()
@IsString()
provincia?: string;
}
class ReformaDto {
@IsString()
@IsIn(['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'])
tipo: string;
@IsOptional()
@IsNumber()
m2Suelo?: number;
@IsString()
@IsIn(['basica', 'media', 'premium'])
calidad: string;
@IsOptional()
@IsBoolean()
estructural?: boolean;
@IsOptional()
@IsString()
@IsIn(['alta', 'media', 'baja'])
urgencia?: string;
@IsOptional()
@IsNumber()
presupuestoTarget?: number;
}
class EmpresaDto {
@IsUUID()
tenantId: string;
@IsString()
nombre: string;
}
class FotosDto {
@IsArray()
@IsString({ each: true })
antes: string[];
@IsArray()
@IsString({ each: true })
despues: string[];
}
class ZonaDto {
@IsString()
zona: string;
@IsArray()
@IsString({ each: true })
notas: string[];
@ValidateNested()
@Type(() => FotosDto)
fotos: FotosDto;
}
export class PerfilCompletoDto {
@IsUUID()
leadId: string;
@ValidateNested()
@Type(() => ClienteDto)
cliente: ClienteDto;
@ValidateNested()
@Type(() => ReformaDto)
reforma: ReformaDto;
@ValidateNested()
@Type(() => EmpresaDto)
empresa: EmpresaDto;
@IsArray()
@ValidateNested({ each: true })
@Type(() => ZonaDto)
zonas: ZonaDto[];
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { WebhookController } from './webhook.controller';
import { PipelineModule } from '../pipeline/pipeline.module';
@Module({
imports: [PipelineModule],
controllers: [WebhookController],
})
export class WebhookModule {}