Sandbox de renders en el image-worker + fixes del pipeline

Sandbox web (/sandbox) servido por el worker, reusando los mismos servicios del
pipeline, para iterar prompts/modelos con una imagen y ver variaciones+score, y
guardar la config ganadora (aplica al worker al instante, persistida en volumen):
- SettingsService: store de config (system prompts + modelos) con defaults de
  prompts/*.txt + env y override persistido en /app/data (sobrevive a redeploys).
- /sandbox (HTML), GET /sandbox/config, POST /sandbox/render, POST /sandbox/save
  (auth Bearer FUNNEL_API_KEY). DOM seguro, sin innerHTML de contenido externo.
- prompt-builder/supervisor/image-generator aceptan overrides y leen de Settings.

Fixes del pipeline de generación:
- image-generator: pide modalities ['image','text'] y extrae la imagen de
  message.images[] (forma real de OpenRouter), no solo de content.
- main.ts: sube el límite de body a 30mb (las fotos en data URI rompían el 100kb).

Deja de versionar artefactos de build (dist/ + *.tsbuildinfo); .gitignore en
image-worker y Whatsapp-bot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-09 16:32:11 +02:00
parent 89166857e7
commit 062a34c144
33 changed files with 614 additions and 614 deletions

6
mvp/image-worker/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
*.tsbuildinfo
.env
.env.*
coverage/

View File

@@ -1,28 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AppModule = void 0;
const common_1 = require("@nestjs/common");
const config_1 = require("@nestjs/config");
const webhook_module_1 = require("./webhook/webhook.module");
const pipeline_module_1 = require("./pipeline/pipeline.module");
const reformix_module_1 = require("./reformix/reformix.module");
let AppModule = class AppModule {
};
exports.AppModule = AppModule;
exports.AppModule = AppModule = __decorate([
(0, common_1.Module)({
imports: [
config_1.ConfigModule.forRoot({ isGlobal: true }),
webhook_module_1.WebhookModule,
pipeline_module_1.PipelineModule,
reformix_module_1.ReformixModule,
],
})
], AppModule);
//# sourceMappingURL=app.module.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"app.module.js","sourceRoot":"","sources":["../src/app.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,2CAA8C;AAC9C,6DAAyD;AACzD,gEAA4D;AAC5D,gEAA4D;AAUrD,IAAM,SAAS,GAAf,MAAM,SAAS;CAAG,CAAA;AAAZ,8BAAS;oBAAT,SAAS;IARrB,IAAA,eAAM,EAAC;QACN,OAAO,EAAE;YACP,qBAAY,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;YACxC,8BAAa;YACb,gCAAc;YACd,gCAAc;SACf;KACF,CAAC;GACW,SAAS,CAAG"}

View File

@@ -1,19 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
require("reflect-metadata");
const core_1 = require("@nestjs/core");
const common_1 = require("@nestjs/common");
const config_1 = require("@nestjs/config");
const app_module_1 = require("./app.module");
async function bootstrap() {
const app = await core_1.NestFactory.create(app_module_1.AppModule, {
logger: ['error', 'warn', 'log', 'debug'],
});
app.useGlobalPipes(new common_1.ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }));
const config = app.get(config_1.ConfigService);
const port = config.get('PORT', 3001);
await app.listen(port);
console.log(`[Reformix Image Worker] corriendo en puerto ${port}`);
}
bootstrap();
//# sourceMappingURL=main.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":";;AAAA,4BAA0B;AAC1B,uCAA2C;AAC3C,2CAAgD;AAChD,2CAA+C;AAC/C,6CAAyC;AAEzC,KAAK,UAAU,SAAS;IACtB,MAAM,GAAG,GAAG,MAAM,kBAAW,CAAC,MAAM,CAAC,sBAAS,EAAE;QAC9C,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC;KAC1C,CAAC,CAAC;IAEH,GAAG,CAAC,cAAc,CAAC,IAAI,uBAAc,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,oBAAoB,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IAEzG,MAAM,MAAM,GAAG,GAAG,CAAC,GAAG,CAAC,sBAAa,CAAC,CAAC;IACtC,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IACtC,MAAM,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACvB,OAAO,CAAC,GAAG,CAAC,+CAA+C,IAAI,EAAE,CAAC,CAAC;AACrE,CAAC;AAED,SAAS,EAAE,CAAC"}

View File

@@ -1,94 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var ImageGeneratorService_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.ImageGeneratorService = void 0;
const common_1 = require("@nestjs/common");
const config_1 = require("@nestjs/config");
const axios_1 = require("axios");
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
let ImageGeneratorService = ImageGeneratorService_1 = class ImageGeneratorService {
constructor(config) {
this.config = config;
this.logger = new common_1.Logger(ImageGeneratorService_1.name);
}
async generarRender(prompt, fotoAntesDataUri) {
const apiKey = this.config.get('OPENROUTER_API_KEY');
const model = this.config.get('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_1.default.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) {
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');
}
extraerImagenDeRespuesta(content, rawResponse) {
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;
}
};
exports.ImageGeneratorService = ImageGeneratorService;
exports.ImageGeneratorService = ImageGeneratorService = ImageGeneratorService_1 = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [config_1.ConfigService])
], ImageGeneratorService);
//# sourceMappingURL=image-generator.service.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"image-generator.service.js","sourceRoot":"","sources":["../../src/pipeline/image-generator.service.ts"],"names":[],"mappings":"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"}

View File

@@ -1,26 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.PipelineModule = void 0;
const common_1 = require("@nestjs/common");
const pipeline_service_1 = require("./pipeline.service");
const prompt_builder_service_1 = require("./prompt-builder.service");
const image_generator_service_1 = require("./image-generator.service");
const supervisor_service_1 = require("./supervisor.service");
const reformix_module_1 = require("../reformix/reformix.module");
let PipelineModule = class PipelineModule {
};
exports.PipelineModule = PipelineModule;
exports.PipelineModule = PipelineModule = __decorate([
(0, common_1.Module)({
imports: [reformix_module_1.ReformixModule],
providers: [pipeline_service_1.PipelineService, prompt_builder_service_1.PromptBuilderService, image_generator_service_1.ImageGeneratorService, supervisor_service_1.SupervisorService],
exports: [pipeline_service_1.PipelineService],
})
], PipelineModule);
//# sourceMappingURL=pipeline.module.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"pipeline.module.js","sourceRoot":"","sources":["../../src/pipeline/pipeline.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,yDAAqD;AACrD,qEAAgE;AAChE,uEAAkE;AAClE,6DAAyD;AACzD,iEAA6D;AAOtD,IAAM,cAAc,GAApB,MAAM,cAAc;CAAG,CAAA;AAAjB,wCAAc;yBAAd,cAAc;IAL1B,IAAA,eAAM,EAAC;QACN,OAAO,EAAE,CAAC,gCAAc,CAAC;QACzB,SAAS,EAAE,CAAC,kCAAe,EAAE,6CAAoB,EAAE,+CAAqB,EAAE,sCAAiB,CAAC;QAC5F,OAAO,EAAE,CAAC,kCAAe,CAAC;KAC3B,CAAC;GACW,cAAc,CAAG"}

View File

@@ -1,96 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var PipelineService_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.PipelineService = void 0;
const common_1 = require("@nestjs/common");
const config_1 = require("@nestjs/config");
const prompt_builder_service_1 = require("./prompt-builder.service");
const image_generator_service_1 = require("./image-generator.service");
const supervisor_service_1 = require("./supervisor.service");
const reformix_service_1 = require("../reformix/reformix.service");
let PipelineService = PipelineService_1 = class PipelineService {
constructor(config, promptBuilder, imageGenerator, supervisor, reformix) {
this.config = config;
this.promptBuilder = promptBuilder;
this.imageGenerator = imageGenerator;
this.supervisor = supervisor;
this.reformix = reformix;
this.logger = new common_1.Logger(PipelineService_1.name);
this.maxRetries = this.config.get('MAX_RETRIES', 2);
this.minScore = this.config.get('SUPERVISOR_MIN_SCORE', 70);
}
async procesarLead(dto) {
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 = [];
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) {
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`);
}
}
async procesarZona(leadId, zona, reforma, notas, fotoAntes) {
const prompt = await this.promptBuilder.generarPrompt(reforma.tipo, reforma.m2Suelo, reforma.calidad, notas);
this.logger.log(`[${leadId}] Zona ${zona}: prompt generado`);
let ultimaImagen = 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 };
}
};
exports.PipelineService = PipelineService;
exports.PipelineService = PipelineService = PipelineService_1 = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [config_1.ConfigService,
prompt_builder_service_1.PromptBuilderService,
image_generator_service_1.ImageGeneratorService,
supervisor_service_1.SupervisorService,
reformix_service_1.ReformixService])
], PipelineService);
//# sourceMappingURL=pipeline.service.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"pipeline.service.js","sourceRoot":"","sources":["../../src/pipeline/pipeline.service.ts"],"names":[],"mappings":"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"}

View File

@@ -1,74 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var PromptBuilderService_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.PromptBuilderService = void 0;
const common_1 = require("@nestjs/common");
const config_1 = require("@nestjs/config");
const fs = require("fs");
const path = require("path");
const axios_1 = require("axios");
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
let PromptBuilderService = PromptBuilderService_1 = class PromptBuilderService {
constructor(config) {
this.config = config;
this.logger = new common_1.Logger(PromptBuilderService_1.name);
this.systemPrompt = '';
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, m2Suelo, calidad, notas) {
const apiKey = this.config.get('OPENROUTER_API_KEY');
const model = this.config.get('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_1.default.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) {
this.logger.error(`Error generando prompt: ${err.message}`);
throw err;
}
}
};
exports.PromptBuilderService = PromptBuilderService;
exports.PromptBuilderService = PromptBuilderService = PromptBuilderService_1 = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [config_1.ConfigService])
], PromptBuilderService);
//# sourceMappingURL=prompt-builder.service.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"prompt-builder.service.js","sourceRoot":"","sources":["../../src/pipeline/prompt-builder.service.ts"],"names":[],"mappings":";;;;;;;;;;;;;AAAA,2CAAoD;AACpD,2CAA+C;AAC/C,yBAAyB;AACzB,6BAA6B;AAC7B,iCAA0B;AAE1B,MAAM,cAAc,GAAG,+CAA+C,CAAC;AAGhE,IAAM,oBAAoB,4BAA1B,MAAM,oBAAoB;IAI/B,YAA6B,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;QAHjC,WAAM,GAAG,IAAI,eAAM,CAAC,sBAAoB,CAAC,IAAI,CAAC,CAAC;QACxD,iBAAY,GAAG,EAAE,CAAC;QAGxB,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,oBAAoB,CAAC,CAAC;QACvE,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;YACxB,IAAI,CAAC,YAAY,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QACrD,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,0DAA0D,CAAC,CAAC;QAC/E,CAAC;IACH,CAAC;IAED,KAAK,CAAC,aAAa,CACjB,WAAmB,EACnB,OAAsB,EACtB,OAAe,EACf,KAAe;QAEf,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAS,oBAAoB,CAAC,CAAC;QAC7D,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAS,wBAAwB,EAAE,qCAAqC,CAAC,CAAC;QAEvG,MAAM,WAAW,GAAG,kCAAkC,WAAW;UAC3D,OAAO,IAAI,SAAS;mBACX,OAAO;kBACR,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,MAAM;kBAC1B,WAAW,aAAa,CAAC;QAEvC,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,eAAK,CAAC,IAAI,CAC/B,cAAc,EACd;gBACE,KAAK;gBACL,QAAQ,EAAE;oBACR,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,CAAC,YAAY,EAAE;oBAC9C,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE;iBACvC;gBACD,UAAU,EAAE,GAAG;gBACf,WAAW,EAAE,GAAG;aACjB,EACD;gBACE,OAAO,EAAE;oBACP,aAAa,EAAE,UAAU,MAAM,EAAE;oBACjC,cAAc,EAAE,kBAAkB;oBAClC,cAAc,EAAE,qBAAqB;oBACrC,SAAS,EAAE,uBAAuB;iBACnC;aACF,CACF,CAAC;YAEF,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;YACpE,IAAI,CAAC,MAAM;gBAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;YACpE,OAAO,MAAM,CAAC;QAChB,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAClB,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,2BAA2B,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC;YAC5D,MAAM,GAAG,CAAC;QACZ,CAAC;IACH,CAAC;CACF,CAAA;AA1DY,oDAAoB;+BAApB,oBAAoB;IADhC,IAAA,mBAAU,GAAE;qCAK0B,sBAAa;GAJvC,oBAAoB,CA0DhC"}

View File

@@ -1,104 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var SupervisorService_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.SupervisorService = void 0;
const common_1 = require("@nestjs/common");
const config_1 = require("@nestjs/config");
const fs = require("fs");
const path = require("path");
const axios_1 = require("axios");
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
let SupervisorService = SupervisorService_1 = class SupervisorService {
constructor(config) {
this.config = config;
this.logger = new common_1.Logger(SupervisorService_1.name);
this.systemPrompt = '';
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, m2Suelo, calidad, notas, fotoAntes, renderDespues) {
const apiKey = this.config.get('OPENROUTER_API_KEY');
const model = this.config.get('OPENROUTER_MODEL_TEXTO', 'anthropic/claude-3.5-haiku-20241022');
const notasTexto = notas.join('; ') || 'sin notas';
try {
const response = await axios_1.default.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) {
this.logger.error(`Error en supervisor: ${err.message}`);
return { aprobado: false, score: 0, motivo: `Error del supervisor: ${err.message}` };
}
}
parsearRespuesta(texto) {
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' };
}
}
};
exports.SupervisorService = SupervisorService;
exports.SupervisorService = SupervisorService = SupervisorService_1 = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [config_1.ConfigService])
], SupervisorService);
//# sourceMappingURL=supervisor.service.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"supervisor.service.js","sourceRoot":"","sources":["../../src/pipeline/supervisor.service.ts"],"names":[],"mappings":"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"}

View File

@@ -1,47 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
var WebhookController_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebhookController = void 0;
const common_1 = require("@nestjs/common");
const webhook_dto_1 = require("./webhook.dto");
const pipeline_service_1 = require("../pipeline/pipeline.service");
let WebhookController = WebhookController_1 = class WebhookController {
constructor(pipelineService) {
this.pipelineService = pipelineService;
this.logger = new common_1.Logger(WebhookController_1.name);
}
recibirPerfil(dto) {
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...' };
}
};
exports.WebhookController = WebhookController;
__decorate([
(0, common_1.Post)('perfil-completo'),
__param(0, (0, common_1.Body)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [webhook_dto_1.PerfilCompletoDto]),
__metadata("design:returntype", void 0)
], WebhookController.prototype, "recibirPerfil", null);
exports.WebhookController = WebhookController = WebhookController_1 = __decorate([
(0, common_1.Controller)(),
__metadata("design:paramtypes", [pipeline_service_1.PipelineService])
], WebhookController);
//# sourceMappingURL=webhook.controller.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"webhook.controller.js","sourceRoot":"","sources":["../../src/webhook/webhook.controller.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,2CAAgE;AAChE,+CAAkD;AAClD,mEAA+D;AAGxD,IAAM,iBAAiB,yBAAvB,MAAM,iBAAiB;IAG5B,YAA6B,eAAgC;QAAhC,oBAAe,GAAf,eAAe,CAAiB;QAF5C,WAAM,GAAG,IAAI,eAAM,CAAC,mBAAiB,CAAC,IAAI,CAAC,CAAC;IAEG,CAAC;IAGjE,aAAa,CAAS,GAAsB;QAC1C,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,MAAM,uBAAuB,GAAG,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,CAAC;QAE/E,YAAY,CAAC,GAAG,EAAE;YAChB,IAAI,CAAC,eAAe,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;gBACnD,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,GAAG,CAAC,MAAM,qBAAqB,GAAG,CAAC,OAAO,EAAE,EAAE,GAAG,CAAC,KAAK,CAAC,CAAC;YACjF,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;QAEH,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,qCAAqC,EAAE,CAAC;IACtE,CAAC;CACF,CAAA;AAjBY,8CAAiB;AAM5B;IADC,IAAA,aAAI,EAAC,iBAAiB,CAAC;IACT,WAAA,IAAA,aAAI,GAAE,CAAA;;qCAAM,+BAAiB;;sDAU3C;4BAhBU,iBAAiB;IAD7B,IAAA,mBAAU,GAAE;qCAImC,kCAAe;GAHlD,iBAAiB,CAiB7B"}

View File

@@ -1,22 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebhookModule = void 0;
const common_1 = require("@nestjs/common");
const webhook_controller_1 = require("./webhook.controller");
const pipeline_module_1 = require("../pipeline/pipeline.module");
let WebhookModule = class WebhookModule {
};
exports.WebhookModule = WebhookModule;
exports.WebhookModule = WebhookModule = __decorate([
(0, common_1.Module)({
imports: [pipeline_module_1.PipelineModule],
controllers: [webhook_controller_1.WebhookController],
})
], WebhookModule);
//# sourceMappingURL=webhook.module.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"webhook.module.js","sourceRoot":"","sources":["../../src/webhook/webhook.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,6DAAyD;AACzD,iEAA6D;AAMtD,IAAM,aAAa,GAAnB,MAAM,aAAa;CAAG,CAAA;AAAhB,sCAAa;wBAAb,aAAa;IAJzB,IAAA,eAAM,EAAC;QACN,OAAO,EAAE,CAAC,gCAAc,CAAC;QACzB,WAAW,EAAE,CAAC,sCAAiB,CAAC;KACjC,CAAC;GACW,aAAa,CAAG"}

View File

@@ -3,13 +3,17 @@ import { ConfigModule } from '@nestjs/config';
import { WebhookModule } from './webhook/webhook.module';
import { PipelineModule } from './pipeline/pipeline.module';
import { ReformixModule } from './reformix/reformix.module';
import { SettingsModule } from './settings/settings.module';
import { SandboxModule } from './sandbox/sandbox.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
SettingsModule,
WebhookModule,
PipelineModule,
ReformixModule,
SandboxModule,
],
})
export class AppModule {}

View File

@@ -2,6 +2,7 @@ import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { json, urlencoded } from 'express';
import { AppModule } from './app.module';
async function bootstrap() {
@@ -9,6 +10,10 @@ async function bootstrap() {
logger: ['error', 'warn', 'log', 'debug'],
});
// Las fotos viajan como data URI (base64) → subir el límite por defecto de Express (100kb).
app.use(json({ limit: '30mb' }));
app.use(urlencoded({ limit: '30mb', extended: true }));
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }));
const config = app.get(ConfigService);

View File

@@ -1,84 +1,130 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { SettingsService } from '../settings/settings.service';
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
export interface GenerarRenderResultado {
imagen: string | null;
error?: string;
debug: Record<string, unknown>;
}
@Injectable()
export class ImageGeneratorService {
private readonly logger = new Logger(ImageGeneratorService.name);
constructor(private readonly config: ConfigService) {}
constructor(
private readonly config: ConfigService,
private readonly settings: SettingsService,
) {}
async generarRender(prompt: string, fotoAntesDataUri: string): Promise<string> {
// Usado por el pipeline real: devuelve la imagen o lanza.
async generarRender(prompt: string, fotoAntesDataUri: string, opts?: { model?: string }): Promise<string> {
const r = await this.generarConDebug(prompt, fotoAntesDataUri, opts);
if (!r.imagen) throw new Error(r.error || 'No se pudo extraer imagen de la respuesta');
return r.imagen;
}
// Usado por el sandbox: nunca lanza, devuelve imagen|null + info de depuración (sin volcar base64).
async generarConDebug(
prompt: string,
fotoAntesDataUri: string,
opts?: { model?: string },
): Promise<GenerarRenderResultado> {
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 model = opts?.model?.trim() || this.settings.getModeloImagen();
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',
try {
const response = await axios.post(
OPENROUTER_URL,
{
model,
// Necesario para que OpenRouter devuelva imagen además de texto.
modalities: ['image', 'text'],
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: fotoAntesDataUri } },
],
},
timeout: 60000,
],
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://reformix.es',
'X-Title': 'Reformix Image Worker',
},
);
timeout: 90000,
},
);
const content = response.data.choices?.[0]?.message?.content;
if (!content) throw new Error('OpenRouter no devolvio contenido');
const message = response.data?.choices?.[0]?.message;
const imagen = this.extraerImagen(message);
return {
imagen,
error: imagen ? undefined : 'No se encontró imagen en la respuesta de OpenRouter',
debug: this.resumenDebug(response.data, model, imagen),
};
} catch (err: any) {
const status = err.response?.status;
const msg = err.response?.data?.error?.message || err.message;
this.logger.error(`Error generando imagen (${status ?? 'sin status'}): ${msg}`);
return {
imagen: null,
error: msg,
debug: { model, status, error: msg },
};
}
}
const imagen = this.extraerImagenDeRespuesta(content, response.data);
if (!imagen) throw new Error('No se pudo extraer imagen de la respuesta');
private extraerImagen(message: any): string | null {
if (!message) return null;
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;
// 1) Forma de OpenRouter para image-gen: message.images[].image_url.url
if (Array.isArray(message.images)) {
for (const img of message.images) {
const url = img?.image_url?.url ?? img?.url ?? (typeof img === 'string' ? img : null);
if (typeof url === 'string' && url) return url;
}
}
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;
const content = message.content;
if (typeof content === 'string') {
if (content.startsWith('data:image')) return content;
const dataUri = content.match(/data:image\/[a-zA-Z+]+;base64,[A-Za-z0-9+/=]+/);
if (dataUri) return dataUri[0];
const url = content.match(/https?:\/\/[^\s"'()]+\.(?:png|jpg|jpeg|webp)/i);
if (url) return url[0];
} else if (Array.isArray(content)) {
for (const part of content) {
const url = part?.image_url?.url;
if (typeof url === 'string' && url) return url;
}
}
return null;
}
// Resumen compacto para depurar el formato real sin meter el base64 (enorme) en la respuesta.
private resumenDebug(data: any, model: string, imagen: string | null): Record<string, unknown> {
const msg = data?.choices?.[0]?.message;
return {
model,
modelDevuelto: data?.model,
finishReason: data?.choices?.[0]?.finish_reason,
messageKeys: msg ? Object.keys(msg) : [],
imagesCount: Array.isArray(msg?.images) ? msg.images.length : 0,
contentType: Array.isArray(msg?.content) ? 'array' : typeof msg?.content,
contentPreview: typeof msg?.content === 'string' ? msg.content.slice(0, 200) : undefined,
imagenEncontrada: !!imagen,
imagenTipo: imagen ? (imagen.startsWith('data:') ? 'data-uri' : 'url') : null,
usage: data?.usage,
};
}
}

View File

@@ -8,6 +8,6 @@ import { ReformixModule } from '../reformix/reformix.module';
@Module({
imports: [ReformixModule],
providers: [PipelineService, PromptBuilderService, ImageGeneratorService, SupervisorService],
exports: [PipelineService],
exports: [PipelineService, PromptBuilderService, ImageGeneratorService, SupervisorService],
})
export class PipelineModule {}

View File

@@ -1,33 +1,34 @@
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';
import { SettingsService } from '../settings/settings.service';
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
export interface PromptBuilderOpts {
systemPrompt?: string;
model?: string;
}
@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');
}
}
constructor(
private readonly config: ConfigService,
private readonly settings: SettingsService,
) {}
async generarPrompt(
tipoReforma: string,
m2Suelo: number | null,
calidad: string,
notas: string[],
opts?: PromptBuilderOpts,
): 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 model = opts?.model?.trim() || this.settings.getModeloTexto();
const systemPrompt = opts?.systemPrompt ?? this.settings.getPromptBuilder();
const userContent = `Generate a render prompt for a ${tipoReforma} renovation.
- Area: ${m2Suelo ?? 'unknown'}
@@ -41,7 +42,7 @@ export class PromptBuilderService {
{
model,
messages: [
{ role: 'system', content: this.systemPrompt },
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent },
],
max_tokens: 512,

View File

@@ -1,8 +1,7 @@
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';
import { SettingsService } from '../settings/settings.service';
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
@@ -12,19 +11,19 @@ export interface SupervisarResultado {
motivo: string;
}
export interface SupervisorOpts {
systemPrompt?: string;
model?: 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');
}
}
constructor(
private readonly config: ConfigService,
private readonly settings: SettingsService,
) {}
async supervisar(
tipoReforma: string,
@@ -33,10 +32,11 @@ export class SupervisorService {
notas: string[],
fotoAntes: string,
renderDespues: string,
opts?: SupervisorOpts,
): 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 model = opts?.model?.trim() || this.settings.getModeloTexto();
const systemPrompt = opts?.systemPrompt ?? this.settings.getSupervisor();
const notasTexto = notas.join('; ') || 'sin notas';
try {
@@ -45,7 +45,7 @@ export class SupervisorService {
{
model,
messages: [
{ role: 'system', content: this.systemPrompt },
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: [
@@ -53,14 +53,8 @@ export class SupervisorService {
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 },
},
{ type: 'image_url', image_url: { url: fotoAntes } },
{ type: 'image_url', image_url: { url: renderDespues } },
],
},
],

View File

@@ -0,0 +1,131 @@
import {
Controller,
Get,
Post,
Body,
Req,
Header,
UnauthorizedException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SettingsService } from '../settings/settings.service';
import { PromptBuilderService } from '../pipeline/prompt-builder.service';
import { ImageGeneratorService } from '../pipeline/image-generator.service';
import { SupervisorService } from '../pipeline/supervisor.service';
import { SANDBOX_HTML } from './sandbox.page';
@Controller('sandbox')
export class SandboxController {
private readonly logger = new Logger(SandboxController.name);
constructor(
private readonly config: ConfigService,
private readonly settings: SettingsService,
private readonly promptBuilder: PromptBuilderService,
private readonly imageGenerator: ImageGeneratorService,
private readonly supervisor: SupervisorService,
) {}
private autorizado(req: any): boolean {
const key = this.config.get<string>('FUNNEL_API_KEY');
if (!key) return false;
const auth = (req.headers?.authorization as string) || '';
return auth === `Bearer ${key}`;
}
private soloTexto(v: unknown): string | undefined {
return typeof v === 'string' && v.trim() ? v : undefined;
}
@Get()
@Header('Content-Type', 'text/html; charset=utf-8')
page(): string {
return SANDBOX_HTML;
}
@Get('config')
getConfig(@Req() req: any) {
if (!this.autorizado(req)) throw new UnauthorizedException('No autorizado');
return {
...this.settings.getAll(),
maxRetries: Number(this.config.get('MAX_RETRIES', 2)),
minScore: Number(this.config.get('SUPERVISOR_MIN_SCORE', 70)),
};
}
@Post('save')
save(@Req() req: any, @Body() body: Record<string, any>) {
if (!this.autorizado(req)) throw new UnauthorizedException('No autorizado');
const config = this.settings.guardar({
promptBuilder: body.promptBuilder,
supervisor: body.supervisor,
modeloTexto: body.modeloTexto,
modeloImagen: body.modeloImagen,
});
this.logger.log('Config guardada desde el sandbox');
return { ok: true, config };
}
@Post('render')
async render(@Req() req: any, @Body() body: Record<string, any>) {
if (!this.autorizado(req)) throw new UnauthorizedException('No autorizado');
const imagenAntes = body.imagenAntes;
if (typeof imagenAntes !== 'string' || !imagenAntes) {
throw new BadRequestException('Falta imagenAntes (data URI).');
}
const tipo = this.soloTexto(body.tipo) || 'otro';
const calidad = this.soloTexto(body.calidad) || 'media';
const m2 = typeof body.m2 === 'number' ? body.m2 : null;
const notas: string[] = Array.isArray(body.notas)
? body.notas.map((n: unknown) => String(n)).filter(Boolean)
: String(body.notas ?? '')
.split(';')
.map((s) => s.trim())
.filter(Boolean);
const modeloTexto = this.soloTexto(body.modeloTexto);
const modeloImagen = this.soloTexto(body.modeloImagen);
const systemPromptBuilder = this.soloTexto(body.systemPromptBuilder);
const supervisorPrompt = this.soloTexto(body.supervisorPrompt);
const supervisar = body.supervisar !== false;
const n = Math.min(4, Math.max(1, Number(body.nVariaciones) || 1));
const minScore = Number(this.config.get('SUPERVISOR_MIN_SCORE', 70));
// Prompt de imagen: directo (si lo pasan) o vía Prompt Builder.
const promptDirecto = this.soloTexto(body.promptDirecto);
let promptUsado: string;
if (promptDirecto) {
promptUsado = promptDirecto;
} else {
promptUsado = await this.promptBuilder.generarPrompt(tipo, m2, calidad, notas, {
systemPrompt: systemPromptBuilder,
model: modeloTexto,
});
}
const variaciones: Array<Record<string, unknown>> = [];
for (let i = 0; i < n; i++) {
const gen = await this.imageGenerator.generarConDebug(promptUsado, imagenAntes, { model: modeloImagen });
const v: Record<string, unknown> = {
imagen: gen.imagen,
error: gen.error,
debug: gen.debug,
};
if (supervisar && gen.imagen) {
const sup = await this.supervisor.supervisar(tipo, m2, calidad, notas, imagenAntes, gen.imagen, {
systemPrompt: supervisorPrompt,
model: modeloTexto,
});
v.score = sup.score;
v.motivo = sup.motivo;
v.aprobado = sup.aprobado && sup.score >= minScore;
}
variaciones.push(v);
}
return { promptUsado, variaciones };
}
}

View File

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

View File

@@ -0,0 +1,217 @@
// Página del sandbox (HTML autocontenido). El <script> evita backticks y la secuencia dólar+llave
// para no colisionar con el template literal que lo envuelve.
export const SANDBOX_HTML = `<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Reformix · Sandbox de renders</title>
<style>
:root { --bg:#0f1115; --panel:#181b22; --border:#2a2f3a; --fg:#e6e8ec; --muted:#9aa3b2; --accent:#4f8cff; --ok:#2ecc71; --bad:#ff5555; }
* { box-sizing:border-box; }
body { margin:0; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; background:var(--bg); color:var(--fg); }
header { padding:14px 20px; border-bottom:1px solid var(--border); display:flex; gap:16px; align-items:center; flex-wrap:wrap; }
header h1 { font-size:16px; margin:0; font-weight:700; }
header .key { margin-left:auto; display:flex; gap:8px; align-items:center; }
.layout { display:grid; grid-template-columns:380px 1fr; gap:16px; padding:16px; align-items:start; }
@media (max-width:900px){ .layout { grid-template-columns:1fr; } }
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:14px; }
label { display:block; font-size:12px; color:var(--muted); margin:10px 0 4px; font-weight:600; }
input, select, textarea, button { font:inherit; color:var(--fg); background:#11141a; border:1px solid var(--border); border-radius:7px; padding:8px 10px; width:100%; }
textarea { resize:vertical; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; line-height:1.4; }
.row { display:flex; gap:8px; } .row > * { flex:1; }
button { cursor:pointer; width:auto; }
.btn-primary { background:var(--accent); border-color:var(--accent); color:#fff; font-weight:700; }
.btn-ghost { background:transparent; }
.actions { display:flex; gap:8px; margin-top:14px; }
.preview { margin-top:8px; max-width:100%; border-radius:8px; border:1px solid var(--border); display:none; }
details { margin-top:10px; } summary { cursor:pointer; color:var(--muted); font-size:12px; }
.status { margin-top:10px; font-size:13px; color:var(--muted); min-height:18px; }
.promptUsado { white-space:pre-wrap; background:#11141a; border:1px solid var(--border); border-radius:8px; padding:10px; font-family:ui-monospace,monospace; font-size:12px; color:#cdd3dd; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:14px; margin-top:14px; }
.card { background:#11141a; border:1px solid var(--border); border-radius:10px; overflow:hidden; }
.card img { width:100%; display:block; background:#000; }
.card .meta { padding:10px; }
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; font-weight:700; }
.badge.ok { background:rgba(46,204,113,.15); color:var(--ok); } .badge.bad { background:rgba(255,85,85,.15); color:var(--bad); }
.motivo { font-size:12px; color:var(--muted); margin-top:6px; }
pre { white-space:pre-wrap; word-break:break-word; font-size:11px; color:#9aa3b2; background:#0c0e12; padding:8px; border-radius:6px; max-height:220px; overflow:auto; }
.hint { font-size:11px; color:var(--muted); margin-top:4px; }
</style>
</head>
<body>
<header>
<h1>🎨 Reformix · Sandbox de renders</h1>
<div class="key">
<input id="apiKey" type="password" placeholder="FUNNEL_API_KEY" style="width:240px" />
<button class="btn-ghost" onclick="guardarKey()">Recordar</button>
</div>
</header>
<div class="layout">
<div class="panel">
<label>Foto "antes"</label>
<input id="foto" type="file" accept="image/*" onchange="cargarFoto(event)" />
<img id="fotoPreview" class="preview" />
<div class="row">
<div>
<label>Tipo</label>
<select id="tipo">
<option>cocina</option><option>bano</option><option>salon</option>
<option>comedor</option><option>integral</option><option>otro</option>
</select>
</div>
<div>
<label>Calidad</label>
<select id="calidad"><option>basica</option><option selected>media</option><option>premium</option></select>
</div>
</div>
<div class="row">
<div><label>m²</label><input id="m2" type="number" value="12" /></div>
<div><label>Variaciones</label><input id="n" type="number" value="1" min="1" max="4" /></div>
</div>
<label>Notas del cliente (separa con ;)</label>
<input id="notas" placeholder="encimera de cuarzo; suelo porcelánico claro" />
<div class="row">
<div><label>Modelo texto</label><input id="modeloTexto" /></div>
<div><label>Modelo imagen</label><input id="modeloImagen" /></div>
</div>
<div class="hint">Imagen sugerida: google/gemini-2.5-flash-image-preview</div>
<label><input type="checkbox" id="supervisar" checked style="width:auto;margin-right:6px" />Pasar por el supervisor (puntúa)</label>
<label>Prompt de imagen directo (opcional — si lo rellenas, se salta el Prompt Builder)</label>
<textarea id="promptDirecto" rows="3" placeholder="Photorealistic render of a modern kitchen..."></textarea>
<details open>
<summary>System prompt · Prompt Builder</summary>
<textarea id="systemPromptBuilder" rows="10"></textarea>
</details>
<details>
<summary>System prompt · Supervisor (avanzado)</summary>
<textarea id="supervisorPrompt" rows="8"></textarea>
</details>
<div class="actions">
<button class="btn-primary" onclick="generar()">Generar</button>
<button class="btn-ghost" onclick="guardar()">Guardar config en el worker</button>
</div>
<div class="status" id="status"></div>
</div>
<div class="panel">
<label>Prompt usado</label>
<div class="promptUsado" id="promptUsado">—</div>
<div class="grid" id="resultados"></div>
</div>
</div>
<script>
function key(){ return document.getElementById('apiKey').value.trim(); }
function guardarKey(){ localStorage.setItem('reformix_funnel_key', key()); setStatus('Key recordada en este navegador.'); }
function setStatus(t){ document.getElementById('status').textContent = t || ''; }
function headers(){ return { 'Content-Type':'application/json', 'Authorization':'Bearer ' + key() }; }
function val(id){ return document.getElementById(id).value; }
var fotoDataUri = '';
function cargarFoto(e){
var f = e.target.files[0]; if(!f) return;
var r = new FileReader();
r.onload = function(){ fotoDataUri = r.result; var img = document.getElementById('fotoPreview'); img.src = fotoDataUri; img.style.display='block'; };
r.readAsDataURL(f);
}
function notasArray(){ return val('notas').split(';').map(function(s){return s.trim();}).filter(Boolean); }
async function cargarConfig(){
if(!key()) return;
try {
var res = await fetch('/sandbox/config', { headers: headers() });
if(!res.ok) return;
var c = await res.json();
if(!val('systemPromptBuilder')) document.getElementById('systemPromptBuilder').value = c.promptBuilder || '';
if(!val('supervisorPrompt')) document.getElementById('supervisorPrompt').value = c.supervisor || '';
if(!val('modeloTexto')) document.getElementById('modeloTexto').value = c.modeloTexto || '';
if(!val('modeloImagen')) document.getElementById('modeloImagen').value = c.modeloImagen || '';
setStatus('Config actual cargada (maxRetries=' + c.maxRetries + ', minScore=' + c.minScore + ').');
} catch(e){}
}
async function generar(){
if(!key()) return setStatus('Pon la FUNNEL_API_KEY arriba.');
if(!fotoDataUri) return setStatus('Sube una foto "antes".');
setStatus('Generando... (puede tardar bastantes segundos por variación)');
document.getElementById('resultados').innerHTML = '';
document.getElementById('promptUsado').textContent = '—';
var body = {
imagenAntes: fotoDataUri,
tipo: val('tipo'), calidad: val('calidad'),
m2: Number(val('m2')) || null, notas: notasArray(),
promptDirecto: val('promptDirecto').trim() || null,
systemPromptBuilder: val('systemPromptBuilder'),
supervisorPrompt: val('supervisorPrompt'),
modeloTexto: val('modeloTexto').trim() || null,
modeloImagen: val('modeloImagen').trim() || null,
nVariaciones: Number(val('n')) || 1,
supervisar: document.getElementById('supervisar').checked
};
try {
var res = await fetch('/sandbox/render', { method:'POST', headers: headers(), body: JSON.stringify(body) });
var data = await res.json();
if(!res.ok){ setStatus('Error: ' + (data.error || res.status)); return; }
document.getElementById('promptUsado').textContent = data.promptUsado || '(prompt directo)';
pintar(data.variaciones || []);
setStatus('Listo: ' + (data.variaciones||[]).length + ' variación(es).');
} catch(e){ setStatus('Fallo de red: ' + e.message); }
}
// Construido con DOM seguro (sin innerHTML de contenido externo): src/textContent no inyectan.
function el(tag, props){ var e = document.createElement(tag); if(props) Object.keys(props).forEach(function(k){ e[k] = props[k]; }); return e; }
function pintar(vars){
var cont = document.getElementById('resultados');
cont.replaceChildren();
vars.forEach(function(v, i){
var card = el('div', { className:'card' });
if(v.imagen){ card.appendChild(el('img', { src: v.imagen, alt: 'render ' + (i+1) })); }
else { card.appendChild(el('div', { textContent:'Sin imagen', style:'padding:20px;color:#ff5555;font-size:13px' })); }
var meta = el('div', { className:'meta' });
if(typeof v.score === 'number'){
meta.appendChild(el('span', { className:'badge ' + (v.aprobado?'ok':'bad'), textContent:'score ' + v.score + (v.aprobado?' · aprobada':' · rechazada') }));
}
if(v.motivo){ meta.appendChild(el('div', { className:'motivo', textContent: v.motivo })); }
if(v.error){ var er = el('div', { className:'motivo', textContent: v.error }); er.style.color = '#ff5555'; meta.appendChild(er); }
var det = el('details'); det.appendChild(el('summary', { textContent:'debug' }));
det.appendChild(el('pre', { textContent: JSON.stringify(v.debug||{}, null, 2) }));
meta.appendChild(det);
card.appendChild(meta);
cont.appendChild(card);
});
}
async function guardar(){
if(!key()) return setStatus('Pon la FUNNEL_API_KEY arriba.');
var body = {
promptBuilder: val('systemPromptBuilder'),
supervisor: val('supervisorPrompt'),
modeloTexto: val('modeloTexto').trim() || undefined,
modeloImagen: val('modeloImagen').trim() || undefined
};
if(!confirm('Guardar estos system prompts y modelos como la config del worker? Afecta a los renders reales de inmediato.')) return;
setStatus('Guardando...');
try {
var res = await fetch('/sandbox/save', { method:'POST', headers: headers(), body: JSON.stringify(body) });
var data = await res.json();
if(!res.ok){ setStatus('Error guardando: ' + (data.error || res.status)); return; }
setStatus('Guardado. El worker ya usa esta config (sin redeploy).');
} catch(e){ setStatus('Fallo de red: ' + e.message); }
}
(function init(){
var k = localStorage.getItem('reformix_funnel_key');
if(k){ document.getElementById('apiKey').value = k; cargarConfig(); }
})();
</script>
</body>
</html>`;

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { SettingsService } from './settings.service';
@Global()
@Module({
providers: [SettingsService],
exports: [SettingsService],
})
export class SettingsModule {}

View File

@@ -0,0 +1,98 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
export interface SandboxConfig {
promptBuilder: string;
supervisor: string;
modeloTexto: string;
modeloImagen: string;
}
const DATA_DIR = process.env.SANDBOX_DATA_DIR || path.join(process.cwd(), 'data');
const CONFIG_FILE = path.join(DATA_DIR, 'sandbox-config.json');
const CAMPOS = ['promptBuilder', 'supervisor', 'modeloTexto', 'modeloImagen'] as const;
// Config efectiva del pipeline (system prompts + modelos). Arranca de los defaults (ficheros
// prompts/*.txt + env) y se superpone lo guardado desde el sandbox, persistido en un volumen
// (CONFIG_FILE) para que sobreviva a redeploys. El pipeline real lee de aquí en cada llamada,
// así que "guardar" en el sandbox aplica al worker al instante sin redeploy.
@Injectable()
export class SettingsService {
private readonly logger = new Logger(SettingsService.name);
private config: SandboxConfig;
constructor(private readonly env: ConfigService) {
this.config = this.cargarDefaults();
this.overlayPersistido();
}
private leerPrompt(archivo: string): string {
try {
const ruta = path.join(process.cwd(), 'prompts', archivo);
if (fs.existsSync(ruta)) return fs.readFileSync(ruta, 'utf-8');
} catch {
/* defaults vacíos si no hay fichero */
}
return '';
}
private cargarDefaults(): SandboxConfig {
return {
promptBuilder: this.leerPrompt('prompt-builder.txt'),
supervisor: this.leerPrompt('supervisor.txt'),
modeloTexto: this.env.get<string>('OPENROUTER_MODEL_TEXTO', 'anthropic/claude-3.5-haiku-20241022'),
modeloImagen: this.env.get<string>('OPENROUTER_MODEL_IMAGEN', 'google/gemini-2.5-flash-image-preview'),
};
}
private overlayPersistido(): void {
try {
if (fs.existsSync(CONFIG_FILE)) {
const saved = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
this.config = { ...this.config, ...this.limpiar(saved) };
this.logger.log(`Config de sandbox cargada de ${CONFIG_FILE}`);
}
} catch (err: any) {
this.logger.warn(`No se pudo leer ${CONFIG_FILE}: ${err.message}`);
}
}
private limpiar(o: any): Partial<SandboxConfig> {
const out: Partial<SandboxConfig> = {};
for (const k of CAMPOS) {
if (typeof o?.[k] === 'string' && o[k].trim()) out[k] = o[k];
}
return out;
}
getAll(): SandboxConfig {
return { ...this.config };
}
getPromptBuilder(): string {
return this.config.promptBuilder;
}
getSupervisor(): string {
return this.config.supervisor;
}
getModeloTexto(): string {
return this.config.modeloTexto;
}
getModeloImagen(): string {
return this.config.modeloImagen;
}
guardar(partial: Partial<SandboxConfig>): SandboxConfig {
this.config = { ...this.config, ...this.limpiar(partial) };
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(CONFIG_FILE, JSON.stringify(this.config, null, 2), 'utf-8');
this.logger.log(`Config de sandbox guardada en ${CONFIG_FILE}`);
} catch (err: any) {
this.logger.error(`No se pudo guardar la config: ${err.message}`);
throw err;
}
return this.getAll();
}
}

File diff suppressed because one or more lines are too long