diff --git a/docs/arquitectura-integracion.md b/docs/arquitectura-integracion.md index 4632043..31860a6 100644 --- a/docs/arquitectura-integracion.md +++ b/docs/arquitectura-integracion.md @@ -194,6 +194,7 @@ POST {url} "cliente": { "nombre": "...", "telefono": "...", "email": "...", "provincia": "..." }, "reforma": { "tipo": "cocina", "m2Suelo": 12, "calidad": "media", "estructural": false, "urgencia": "media", "presupuestoTarget": 800000 }, + "preferencias": { "estilo": "nórdico", "gustos": "tonos azules, muebles de madera, encimera clara" }, "empresa": { "tenantId": "uuid", "nombre": "Reformas Ejemplo" }, "zonas": [ { "zona": "cocina", @@ -203,7 +204,14 @@ POST {url} } ``` -**La app espera que:** el worker externo genere renders "después" a partir de las fotos "antes", y los devuelva haciendo POST al endpoint `/api/leads/:id/ingesta` con `momento: "despues"` y opcionalmente `finalizar: true`. +**`preferencias`** (opcional): gustos estéticos del cliente capturados en la conversación (`estilo` = +campo `estilo` del lead; `gustos` = `tasteText`, resumen en texto libre de colores/materiales/acabados +que pidió). Cada clave se omite si está vacía. El worker los inyecta como bloque dedicado en el prompt +de imagen para que el render los represente; si no llegan, infiere un estilo neutro. + +**La app espera que:** el worker externo genere renders "después" a partir de las fotos "antes" +respetando las `preferencias` del cliente, y los devuelva haciendo POST al endpoint +`/api/leads/:id/ingesta` con `momento: "despues"` y opcionalmente `finalizar: true`. ### 5.3 WHATSAPP_WEBHOOK_URL — Entrega del PDF diff --git a/mvp/b2c/src/lib/funnel/perfil.ts b/mvp/b2c/src/lib/funnel/perfil.ts index 4f63be4..4359166 100644 --- a/mvp/b2c/src/lib/funnel/perfil.ts +++ b/mvp/b2c/src/lib/funnel/perfil.ts @@ -52,6 +52,13 @@ export async function señalarPerfilCompleto(leadId: string): Promise { urgencia: lead.urgencia, presupuestoTarget: lead.presupuestoTarget, }, + // Gustos estéticos del cliente (estilo + resumen en texto libre de lo que pidió hablando con + // Luisa / en la llamada): se mandan al generador para que el render los represente. Se omiten + // las claves vacías (JSON.stringify descarta undefined). + preferencias: { + estilo: lead.estilo || undefined, + gustos: lead.tasteText || undefined, + }, empresa: { tenantId: lead.tenantId, nombre: tenant.nombreEmpresa }, zonas: Array.from(zonas, ([zona, d]) => ({ zona, diff --git a/mvp/image-worker/prompts/prompt-builder.txt b/mvp/image-worker/prompts/prompt-builder.txt index dfc4251..dc4f2b4 100644 --- a/mvp/image-worker/prompts/prompt-builder.txt +++ b/mvp/image-worker/prompts/prompt-builder.txt @@ -4,10 +4,16 @@ Your task is to generate a detailed, technical prompt in English for an image-to The prompt must include: - Specific materials and finishes (tile type, countertop material, flooring) - Lighting style (natural, warm artificial, accent) -- Color palette aligned with the quality level -- Style and atmosphere based on client notes +- A color palette and style - Technical rendering keywords: photorealistic, 8k, architectural visualization, professional interior photography, high detail +Honoring the client's wishes is the top priority. When the input provides the client's desired +style or stated tastes (specific colors, materials, finishes or must-haves), you MUST reflect them +faithfully in the prompt: use the exact colors and materials they asked for and the style they want, +even if it differs from a generic "modern" look. Only when no style or tastes are given should you +infer a tasteful design. The color palette should follow the client's stated colors when provided, +otherwise align it with the quality level. Keep the existing layout/structure of the real photo. + Quality level guide: - basica: standard materials, functional design, clean finishes - media: mid-range materials, modern design, quality finishes diff --git a/mvp/image-worker/src/pipeline/pipeline.service.ts b/mvp/image-worker/src/pipeline/pipeline.service.ts index 2a1e1c6..9c4e660 100644 --- a/mvp/image-worker/src/pipeline/pipeline.service.ts +++ b/mvp/image-worker/src/pipeline/pipeline.service.ts @@ -31,7 +31,7 @@ export class PipelineService { } async procesarLead(dto: PerfilCompletoDto): Promise { - const { leadId, reforma, zonas } = dto; + const { leadId, reforma, zonas, preferencias } = dto; const zonasConFotos = zonas.filter((z) => z.fotos.antes.length > 0); const zonasSaltadas = zonas.filter((z) => z.fotos.antes.length === 0); @@ -45,7 +45,7 @@ export class PipelineService { for (const zona of zonasConFotos) { try { - const render = await this.procesarZona(leadId, zona.zona, reforma, zona.notas, zona.fotos.antes[0]); + const render = await this.procesarZona(leadId, zona.zona, reforma, zona.notas, zona.fotos.antes[0], preferencias); renders.push(render); } catch (err: any) { this.logger.error(`[${leadId}] Zona ${zona.zona}: error fatal: ${err.message}`); @@ -76,8 +76,9 @@ export class PipelineService { reforma: PerfilCompletoDto['reforma'], notas: string[], fotoAntes: string, + preferencias?: PerfilCompletoDto['preferencias'], ): Promise { - const prompt = await this.promptBuilder.generarPrompt(reforma.tipo, reforma.m2Suelo, reforma.calidad, notas); + const prompt = await this.promptBuilder.generarPrompt(reforma.tipo, reforma.m2Suelo, reforma.calidad, notas, preferencias); this.logger.log(`[${leadId}] Zona ${zona}: prompt generado`); let ultimaImagen: string | null = null; diff --git a/mvp/image-worker/src/pipeline/prompt-builder.service.ts b/mvp/image-worker/src/pipeline/prompt-builder.service.ts index 34de54e..2cb9d91 100644 --- a/mvp/image-worker/src/pipeline/prompt-builder.service.ts +++ b/mvp/image-worker/src/pipeline/prompt-builder.service.ts @@ -10,6 +10,39 @@ export interface PromptBuilderOpts { model?: string; } +export interface PreferenciasCliente { + estilo?: string; + gustos?: string; +} + +// Arma el mensaje de usuario para el LLM constructor de prompts. Función pura (sin red) para poder +// testearla. Si el cliente expresó estilo o gustos (color/material/acabados), se incluyen como bloque +// dedicado y se omite el "modern" por defecto, para que el render represente lo que pidió. +export function construirUserContent( + tipoReforma: string, + m2Suelo: number | null, + calidad: string, + notas: string[], + preferencias?: PreferenciasCliente, +): string { + const lineas = [ + `Generate a render prompt for a ${tipoReforma} renovation.`, + `- Area: ${m2Suelo ?? 'unknown'} m²`, + `- Quality level: ${calidad}`, + `- Client notes: ${notas.join('; ') || 'none'}`, + ]; + const estilo = preferencias?.estilo?.trim(); + const gustos = preferencias?.gustos?.trim(); + if (estilo) lineas.push(`- Client's desired style: ${estilo}`); + if (gustos) { + lineas.push( + `- Client's stated tastes (colors, materials, finishes, must-haves) — honor these in the render: ${gustos}`, + ); + } + if (!estilo && !gustos) lineas.push(`- Style: modern ${tipoReforma} renovation`); + return lineas.join('\n'); +} + @Injectable() export class PromptBuilderService { private readonly logger = new Logger(PromptBuilderService.name); @@ -24,17 +57,14 @@ export class PromptBuilderService { m2Suelo: number | null, calidad: string, notas: string[], + preferencias?: PreferenciasCliente, opts?: PromptBuilderOpts, ): Promise { const apiKey = this.config.get('OPENROUTER_API_KEY'); 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'} m² -- Quality level: ${calidad} -- Client notes: ${notas.join('; ') || 'none'} -- Style: modern ${tipoReforma} renovation`; + const userContent = construirUserContent(tipoReforma, m2Suelo, calidad, notas, preferencias); try { const response = await axios.post( diff --git a/mvp/image-worker/src/sandbox/sandbox.controller.ts b/mvp/image-worker/src/sandbox/sandbox.controller.ts index 0751b27..86301da 100644 --- a/mvp/image-worker/src/sandbox/sandbox.controller.ts +++ b/mvp/image-worker/src/sandbox/sandbox.controller.ts @@ -90,6 +90,8 @@ export class SandboxController { const modeloImagen = this.soloTexto(body.modeloImagen); const systemPromptBuilder = this.soloTexto(body.systemPromptBuilder); const supervisorPrompt = this.soloTexto(body.supervisorPrompt); + const estiloPref = this.soloTexto(body.estilo); + const gustosPref = this.soloTexto(body.gustos); 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)); @@ -100,10 +102,14 @@ export class SandboxController { if (promptDirecto) { promptUsado = promptDirecto; } else { - promptUsado = await this.promptBuilder.generarPrompt(tipo, m2, calidad, notas, { - systemPrompt: systemPromptBuilder, - model: modeloTexto, - }); + promptUsado = await this.promptBuilder.generarPrompt( + tipo, + m2, + calidad, + notas, + { estilo: estiloPref, gustos: gustosPref }, + { systemPrompt: systemPromptBuilder, model: modeloTexto }, + ); } const variaciones: Array> = []; diff --git a/mvp/image-worker/src/webhook/webhook.dto.ts b/mvp/image-worker/src/webhook/webhook.dto.ts index 5b14c75..2a68975 100644 --- a/mvp/image-worker/src/webhook/webhook.dto.ts +++ b/mvp/image-worker/src/webhook/webhook.dto.ts @@ -43,6 +43,16 @@ class ReformaDto { presupuestoTarget?: number; } +class PreferenciasDto { + @IsOptional() + @IsString() + estilo?: string; + + @IsOptional() + @IsString() + gustos?: string; +} + class EmpresaDto { @IsUUID() tenantId: string; @@ -86,6 +96,11 @@ export class PerfilCompletoDto { @Type(() => ReformaDto) reforma: ReformaDto; + @IsOptional() + @ValidateNested() + @Type(() => PreferenciasDto) + preferencias?: PreferenciasDto; + @ValidateNested() @Type(() => EmpresaDto) empresa: EmpresaDto;