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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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'} 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(
|
||||
|
||||
@@ -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>> = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user