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": "..." },
"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

View File

@@ -52,6 +52,13 @@ export async function señalarPerfilCompleto(leadId: string): Promise<boolean> {
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,

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:
- 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

View File

@@ -31,7 +31,7 @@ export class PipelineService {
}
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 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<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`);
let ultimaImagen: string | null = null;

View File

@@ -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'}`,
`- 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<string> {
const apiKey = this.config.get<string>('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'}
- 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(

View File

@@ -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<Record<string, unknown>> = [];

View File

@@ -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;