Lleva las preferencias del cliente (estilo, color, material) al render

Hasta ahora el render solo se condicionaba con tipo/m²/calidad + notas de texto
libre por zona; lo que el cliente decía hablando con Luisa o en la llamada
(estilo, colores, materiales) se guardaba en estilo/tasteText pero NO viajaba al
generador de imagen, así que el render no lo representaba.

- b2c (perfil.ts): el payload de PERFIL_WEBHOOK_URL incluye ahora
  preferencias:{estilo, gustos} (gustos = tasteText). Claves vacías se omiten.
- worker (webhook.dto): nuevo PreferenciasDto opcional.
- worker (prompt-builder): construirUserContent (función pura) inyecta el estilo
  y los gustos del cliente como bloque dedicado y omite el "modern" por defecto
  cuando hay preferencias; el system prompt prioriza colores/materiales del
  cliente sobre un estilo genérico.
- worker (pipeline): enhebra preferencias hasta generarPrompt.
- worker (sandbox): acepta estilo/gustos para poder probarlos.
- docs/arquitectura-integracion: documenta el campo preferencias.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-10 18:17:45 +02:00
parent f6e0347143
commit ad87e45892
7 changed files with 88 additions and 15 deletions

View File

@@ -194,6 +194,7 @@ POST {url}
"cliente": { "nombre": "...", "telefono": "...", "email": "...", "provincia": "..." }, "cliente": { "nombre": "...", "telefono": "...", "email": "...", "provincia": "..." },
"reforma": { "tipo": "cocina", "m2Suelo": 12, "calidad": "media", "reforma": { "tipo": "cocina", "m2Suelo": 12, "calidad": "media",
"estructural": false, "urgencia": "media", "presupuestoTarget": 800000 }, "estructural": false, "urgencia": "media", "presupuestoTarget": 800000 },
"preferencias": { "estilo": "nórdico", "gustos": "tonos azules, muebles de madera, encimera clara" },
"empresa": { "tenantId": "uuid", "nombre": "Reformas Ejemplo" }, "empresa": { "tenantId": "uuid", "nombre": "Reformas Ejemplo" },
"zonas": [ "zonas": [
{ "zona": "cocina", { "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 ### 5.3 WHATSAPP_WEBHOOK_URL — Entrega del PDF

View File

@@ -52,6 +52,13 @@ export async function señalarPerfilCompleto(leadId: string): Promise<boolean> {
urgencia: lead.urgencia, urgencia: lead.urgencia,
presupuestoTarget: lead.presupuestoTarget, 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 }, empresa: { tenantId: lead.tenantId, nombre: tenant.nombreEmpresa },
zonas: Array.from(zonas, ([zona, d]) => ({ zonas: Array.from(zonas, ([zona, d]) => ({
zona, zona,

View File

@@ -4,10 +4,16 @@ Your task is to generate a detailed, technical prompt in English for an image-to
The prompt must include: The prompt must include:
- Specific materials and finishes (tile type, countertop material, flooring) - Specific materials and finishes (tile type, countertop material, flooring)
- Lighting style (natural, warm artificial, accent) - Lighting style (natural, warm artificial, accent)
- Color palette aligned with the quality level - A color palette and style
- Style and atmosphere based on client notes
- Technical rendering keywords: photorealistic, 8k, architectural visualization, professional interior photography, high detail - 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: Quality level guide:
- basica: standard materials, functional design, clean finishes - basica: standard materials, functional design, clean finishes
- media: mid-range materials, modern design, quality finishes - media: mid-range materials, modern design, quality finishes

View File

@@ -31,7 +31,7 @@ export class PipelineService {
} }
async procesarLead(dto: PerfilCompletoDto): Promise<void> { async procesarLead(dto: PerfilCompletoDto): Promise<void> {
const { leadId, reforma, zonas } = dto; const { leadId, reforma, zonas, preferencias } = dto;
const zonasConFotos = zonas.filter((z) => z.fotos.antes.length > 0); const zonasConFotos = zonas.filter((z) => z.fotos.antes.length > 0);
const zonasSaltadas = 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) { for (const zona of zonasConFotos) {
try { 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); renders.push(render);
} catch (err: any) { } catch (err: any) {
this.logger.error(`[${leadId}] Zona ${zona.zona}: error fatal: ${err.message}`); this.logger.error(`[${leadId}] Zona ${zona.zona}: error fatal: ${err.message}`);
@@ -76,8 +76,9 @@ export class PipelineService {
reforma: PerfilCompletoDto['reforma'], reforma: PerfilCompletoDto['reforma'],
notas: string[], notas: string[],
fotoAntes: string, fotoAntes: string,
preferencias?: PerfilCompletoDto['preferencias'],
): Promise<ZonaRender> { ): Promise<ZonaRender> {
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`); this.logger.log(`[${leadId}] Zona ${zona}: prompt generado`);
let ultimaImagen: string | null = null; let ultimaImagen: string | null = null;

View File

@@ -10,6 +10,39 @@ export interface PromptBuilderOpts {
model?: string; 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'}`,
`- 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() @Injectable()
export class PromptBuilderService { export class PromptBuilderService {
private readonly logger = new Logger(PromptBuilderService.name); private readonly logger = new Logger(PromptBuilderService.name);
@@ -24,17 +57,14 @@ export class PromptBuilderService {
m2Suelo: number | null, m2Suelo: number | null,
calidad: string, calidad: string,
notas: string[], notas: string[],
preferencias?: PreferenciasCliente,
opts?: PromptBuilderOpts, opts?: PromptBuilderOpts,
): Promise<string> { ): Promise<string> {
const apiKey = this.config.get<string>('OPENROUTER_API_KEY'); const apiKey = this.config.get<string>('OPENROUTER_API_KEY');
const model = opts?.model?.trim() || this.settings.getModeloTexto(); const model = opts?.model?.trim() || this.settings.getModeloTexto();
const systemPrompt = opts?.systemPrompt ?? this.settings.getPromptBuilder(); const systemPrompt = opts?.systemPrompt ?? this.settings.getPromptBuilder();
const userContent = `Generate a render prompt for a ${tipoReforma} renovation. const userContent = construirUserContent(tipoReforma, m2Suelo, calidad, notas, preferencias);
- Area: ${m2Suelo ?? 'unknown'}
- Quality level: ${calidad}
- Client notes: ${notas.join('; ') || 'none'}
- Style: modern ${tipoReforma} renovation`;
try { try {
const response = await axios.post( const response = await axios.post(

View File

@@ -90,6 +90,8 @@ export class SandboxController {
const modeloImagen = this.soloTexto(body.modeloImagen); const modeloImagen = this.soloTexto(body.modeloImagen);
const systemPromptBuilder = this.soloTexto(body.systemPromptBuilder); const systemPromptBuilder = this.soloTexto(body.systemPromptBuilder);
const supervisorPrompt = this.soloTexto(body.supervisorPrompt); const supervisorPrompt = this.soloTexto(body.supervisorPrompt);
const estiloPref = this.soloTexto(body.estilo);
const gustosPref = this.soloTexto(body.gustos);
const supervisar = body.supervisar !== false; const supervisar = body.supervisar !== false;
const n = Math.min(4, Math.max(1, Number(body.nVariaciones) || 1)); const n = Math.min(4, Math.max(1, Number(body.nVariaciones) || 1));
const minScore = Number(this.config.get('SUPERVISOR_MIN_SCORE', 70)); const minScore = Number(this.config.get('SUPERVISOR_MIN_SCORE', 70));
@@ -100,10 +102,14 @@ export class SandboxController {
if (promptDirecto) { if (promptDirecto) {
promptUsado = promptDirecto; promptUsado = promptDirecto;
} else { } else {
promptUsado = await this.promptBuilder.generarPrompt(tipo, m2, calidad, notas, { promptUsado = await this.promptBuilder.generarPrompt(
systemPrompt: systemPromptBuilder, tipo,
model: modeloTexto, m2,
}); calidad,
notas,
{ estilo: estiloPref, gustos: gustosPref },
{ systemPrompt: systemPromptBuilder, model: modeloTexto },
);
} }
const variaciones: Array<Record<string, unknown>> = []; const variaciones: Array<Record<string, unknown>> = [];

View File

@@ -43,6 +43,16 @@ class ReformaDto {
presupuestoTarget?: number; presupuestoTarget?: number;
} }
class PreferenciasDto {
@IsOptional()
@IsString()
estilo?: string;
@IsOptional()
@IsString()
gustos?: string;
}
class EmpresaDto { class EmpresaDto {
@IsUUID() @IsUUID()
tenantId: string; tenantId: string;
@@ -86,6 +96,11 @@ export class PerfilCompletoDto {
@Type(() => ReformaDto) @Type(() => ReformaDto)
reforma: ReformaDto; reforma: ReformaDto;
@IsOptional()
@ValidateNested()
@Type(() => PreferenciasDto)
preferencias?: PreferenciasDto;
@ValidateNested() @ValidateNested()
@Type(() => EmpresaDto) @Type(() => EmpresaDto)
empresa: EmpresaDto; empresa: EmpresaDto;