Files
reformix-hackaton/docs/superpowers/plans/2026-05-31-guion-agente-voz-preferencias.md
2026-05-31 16:05:13 +02:00

38 KiB

Guion del agente de voz + capa de preferencias — Plan de implementación

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Añadir un guion de agente de voz híbrido y una capa keyless que clasifica el texto de gustos del cliente en inputs del presupuesto (material, extras, render, ajustes etiquetados), sin modificar el motor.

Architecture: Enfoque A — pre + post alrededor de computeBudget. Un módulo nuevo src/lib/voice contiene tipos, léxicos, clasificador determinista (DeterministicExtractor, costura para GPT-4o), buildScript y apply (merge + extras). El funnel captura los nuevos campos en el form de fotos y el orquestador encadena extractor → merge → motor → apply.

Tech Stack: Next.js 16 (App Router, Server Actions), TypeScript strict, Drizzle ORM + Postgres, Vitest. Trabaja siempre dentro de mvp/b2c.

Spec: docs/superpowers/specs/2026-05-31-guion-agente-voz-preferencias-design.md

Nota de entorno: todos los comandos se ejecutan desde mvp/b2c. La herramienta Bash resetea el CWD en cada llamada; usa cd "c:/Users/carlo/Proyectos/reformix-hackaton/mvp/b2c" && <comando>. Postgres dev en postgresql://postgres:reformix@localhost:5433/reformix.


File Structure

  • src/lib/voice/preferences.tsCrear. Tipos: RawCallData, PreferenceExtra, PreferenceAjuste, AbstractedPreferences, Urgencia.
  • src/lib/voice/lexicon.tsCrear. Léxicos en español (calidad, materiales, estructural, estilo, elementos por tipo, ajustes).
  • src/lib/voice/extractor.tsCrear. PreferenceExtractor (interfaz) + DeterministicExtractor + deterministicExtractor (instancia).
  • src/lib/voice/apply.tsCrear. mergeIntoBudgetInputs + applyPreferences.
  • src/lib/voice/script.tsCrear. buildScript + tipos VoiceScript/ScriptBlock/ScriptTurn.
  • src/db/schema.tsModificar. Enum urgencia + columnas urgencia, presupuestoTarget, tasteText, preferencesSnapshot en leads.
  • src/app/solicitud/actions.tsModificar. guardarDetallesYFotos persiste los nuevos campos.
  • src/components/funnel/FotosUploader.tsxModificar. Campos urgencia, estructural, target, textarea de gustos.
  • src/lib/funnel/orchestrator.tsModificar. Encadena extractor → merge → motor → apply; guarda preferencesSnapshot.
  • src/app/panel/[id]/page.tsxModificar. Sección "Preferencias detectadas".
  • tests/voice/extractor.test.ts, tests/voice/apply.test.ts, tests/voice/script.test.tsCrear.

Task 1: Tipos del contrato (preferences.ts)

Files:

  • Create: src/lib/voice/preferences.ts

  • Step 1: Crear el archivo de tipos

// src/lib/voice/preferences.ts
import type { Calidad, CategoriaMaterial, TipoReforma } from '@/budget/types';

export type Urgencia = 'alta' | 'media' | 'baja';

export interface RawCallData {
  tipoReforma: TipoReforma;
  m2Suelo: number | null;
  calidad: Calidad | null;
  estructural: boolean | null;
  urgencia: Urgencia | null;
  presupuestoTarget: number | null; // céntimos
  tasteText: string;
}

export interface PreferenceExtra {
  key: string;
  label: string;
  importe: number; // céntimos (base, antes de factor zona)
}

export interface PreferenceAjuste {
  label: string;
  tipo: 'factor' | 'fijo';
  valor: number; // factor (p.ej. 1.1) o céntimos
  motivo: string;
}

export interface AbstractedPreferences {
  calidadGlobal: Calidad;
  materialSelections: Partial<Record<CategoriaMaterial, string>>;
  estructural: boolean;
  urgencia: Urgencia | null;
  presupuestoTarget: number | null;
  elementos: PreferenceExtra[];
  estiloRender: string[];
  ajustes: PreferenceAjuste[];
  confianza: 'baja' | 'media' | 'alta';
  resumen: string;
  camposFaltantes: string[];
}
  • Step 2: Verificar que compila

Run: cd "c:/Users/carlo/Proyectos/reformix-hackaton/mvp/b2c" && npx tsc --noEmit Expected: sin errores nuevos relacionados con preferences.ts.

  • Step 3: Commit
git add src/lib/voice/preferences.ts
git commit -m "Add tipos del contrato de preferencias del agente de voz"

Task 2: Léxicos (lexicon.ts)

Files:

  • Create: src/lib/voice/lexicon.ts

  • Step 1: Crear los léxicos

// src/lib/voice/lexicon.ts
import type { Calidad, CategoriaMaterial, TipoReforma } from '@/budget/types';

export const CALIDAD_LEXICON: { calidad: Calidad; keywords: string[] }[] = [
  { calidad: 'premium', keywords: ['premium', 'lujo', 'alta gama', 'gama alta', 'lo mejor', 'calidad maxima', 'exclusivo'] },
  { calidad: 'basica', keywords: ['basico', 'economico', 'barato', 'sencillo', 'lo justo', 'ajustado'] },
  { calidad: 'media', keywords: ['normal', 'estandar', 'equilibrado', 'medio'] },
];

export const MATERIAL_LEXICON: { categoria: CategoriaMaterial; keywords: string[] }[] = [
  { categoria: 'suelo', keywords: ['suelo', 'tarima', 'parquet', 'porcelanico', 'ceramico', 'madera', 'vinilo'] },
  { categoria: 'pared', keywords: ['azulejo', 'alicatado', 'baldosa', 'microcemento', 'gres'] },
  { categoria: 'pintura', keywords: ['pintura', 'pintar', 'color de pared'] },
  { categoria: 'mobiliario', keywords: ['mueble', 'muebles', 'armario', 'encimera', 'mobiliario'] },
];

export const ESTRUCTURAL_LEXICON: string[] = [
  'tirar muro', 'tirar el muro', 'quitar pared', 'tirar pared', 'abrir la cocina',
  'mover el bano', 'mover sanitarios', 'cambiar la distribucion', 'derribar', 'tirar tabique',
];

export const ESTILO_LEXICON: string[] = [
  'nordico', 'industrial', 'minimalista', 'rustico', 'moderno', 'clasico', 'mediterraneo',
  'tonos calidos', 'tonos frios', 'blanco mate', 'madera clara', 'colores neutros',
];

export const ELEMENTOS_LEXICON: Record<
  TipoReforma,
  { key: string; label: string; importe: number; keywords: string[] }[]
> = {
  cocina: [
    { key: 'isla_cocina', label: 'Isla de cocina', importe: 120000, keywords: ['isla'] },
    { key: 'peninsula', label: 'Península', importe: 80000, keywords: ['peninsula'] },
    { key: 'electrodomesticos_integrados', label: 'Electrodomésticos integrados', importe: 150000, keywords: ['electrodomesticos integrados', 'integrados', 'encastrados'] },
  ],
  bano: [
    { key: 'ducha_obra', label: 'Ducha de obra', importe: 90000, keywords: ['ducha de obra', 'plato a ras', 'plato de obra'] },
    { key: 'doble_lavabo', label: 'Doble lavabo', importe: 45000, keywords: ['doble lavabo', 'dos senos', 'doble seno'] },
  ],
  salon: [],
  comedor: [],
  integral: [
    { key: 'isla_cocina', label: 'Isla de cocina', importe: 120000, keywords: ['isla'] },
  ],
  otro: [],
};

export const AJUSTE_LEXICON: {
  label: string;
  tipo: 'fijo' | 'factor';
  valor: number;
  motivo: string;
  keywords: string[];
}[] = [
  { label: 'Encimera de piedra natural', tipo: 'fijo', valor: 60000, motivo: 'mención de mármol/cuarzo/granito en la llamada', keywords: ['marmol', 'cuarzo', 'granito', 'silestone', 'piedra natural'] },
  { label: 'Domótica / iluminación inteligente', tipo: 'fijo', valor: 40000, motivo: 'mención de domótica en la llamada', keywords: ['domotica', 'inteligente', 'smart'] },
];

export function normalizeText(s: string): string {
  return s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
}
  • Step 2: Verificar que compila

Run: cd "c:/Users/carlo/Proyectos/reformix-hackaton/mvp/b2c" && npx tsc --noEmit Expected: sin errores nuevos.

  • Step 3: Commit
git add src/lib/voice/lexicon.ts
git commit -m "Add léxicos en español para el clasificador de preferencias"

Task 3: Clasificador determinista (extractor.ts)

Files:

  • Create: src/lib/voice/extractor.ts

  • Test: tests/voice/extractor.test.ts

  • Step 1: Escribir el test que falla

// tests/voice/extractor.test.ts
import { describe, it, expect } from 'vitest';
import { deterministicExtractor } from '@/lib/voice/extractor';
import type { RawCallData } from '@/lib/voice/preferences';
import type { CatalogItem } from '@/budget/types';

const catalog: CatalogItem[] = [
  { id: 'suelo-m', categoria: 'suelo', nombre: 'Cerámico', calidad: 'media', precioUnit: 2800, unidad: 'm2', descriptorRender: 'suelo cerámico gris', esDefault: true, sku: 'SUE-M' },
  { id: 'suelo-p', categoria: 'suelo', nombre: 'Tarima madera clara', calidad: 'premium', precioUnit: 4500, unidad: 'm2', descriptorRender: 'tarima madera clara', esDefault: true, sku: 'SUE-P' },
  { id: 'mob-p', categoria: 'mobiliario', nombre: 'Muebles premium con encimera', calidad: 'premium', precioUnit: 52000, unidad: 'ml', descriptorRender: 'muebles lacados blancos', esDefault: true, sku: 'MOB-P' },
];

function raw(partial: Partial<RawCallData>): RawCallData {
  return {
    tipoReforma: 'cocina',
    m2Suelo: 16,
    calidad: null,
    estructural: null,
    urgencia: 'media',
    presupuestoTarget: null,
    tasteText: '',
    ...partial,
  };
}

describe('deterministicExtractor', () => {
  it('abstrae calidad, material, elemento, estilo y ajuste de un texto de gustos', () => {
    const r = deterministicExtractor.extract(
      raw({ tasteText: 'Quiero algo premium, estilo nórdico con madera clara. Me encantaría una isla y encimera de cuarzo.' }),
      catalog,
    );
    expect(r.calidadGlobal).toBe('premium');
    expect(r.materialSelections.suelo).toBe('suelo-p');
    expect(r.elementos.map((e) => e.key)).toContain('isla_cocina');
    expect(r.estiloRender).toContain('nordico');
    expect(r.estiloRender).toContain('madera clara');
    expect(r.ajustes.map((a) => a.label)).toContain('Encimera de piedra natural');
    expect(r.confianza).toBe('alta'); // m² + ≥2 señales
  });

  it('el slot de calidad explícito gana al texto', () => {
    const r = deterministicExtractor.extract(raw({ calidad: 'basica', tasteText: 'algo premium de lujo' }), catalog);
    expect(r.calidadGlobal).toBe('basica');
  });

  it('detecta cambio estructural desde el texto libre', () => {
    const r = deterministicExtractor.extract(raw({ tasteText: 'quiero tirar el muro entre cocina y salón' }), catalog);
    expect(r.estructural).toBe(true);
  });

  it('texto vacío y sin m² → confianza baja y campos faltantes', () => {
    const r = deterministicExtractor.extract(raw({ m2Suelo: null, urgencia: null, tasteText: '' }), catalog);
    expect(r.confianza).toBe('baja');
    expect(r.camposFaltantes).toContain('m2');
    expect(r.camposFaltantes).toContain('preferencias');
  });
});
  • Step 2: Ejecutar el test para verlo fallar

Run: cd "c:/Users/carlo/Proyectos/reformix-hackaton/mvp/b2c" && npx vitest run tests/voice/extractor.test.ts Expected: FAIL — deterministicExtractor no existe.

  • Step 3: Implementar el clasificador
// src/lib/voice/extractor.ts
import type { Calidad, CategoriaMaterial, CatalogItem } from '@/budget/types';
import type { AbstractedPreferences, PreferenceAjuste, PreferenceExtra, RawCallData } from './preferences';
import {
  AJUSTE_LEXICON,
  CALIDAD_LEXICON,
  ELEMENTOS_LEXICON,
  ESTILO_LEXICON,
  ESTRUCTURAL_LEXICON,
  MATERIAL_LEXICON,
  normalizeText,
} from './lexicon';

export interface PreferenceExtractor {
  extract(raw: RawCallData, catalog: CatalogItem[]): AbstractedPreferences;
}

function hit(text: string, keywords: string[]): boolean {
  return keywords.some((k) => text.includes(k));
}

function pickItem(
  catalog: CatalogItem[],
  categoria: CategoriaMaterial,
  calidad: Calidad,
  matched: string[],
): CatalogItem | null {
  const ofCat = catalog.filter((c) => c.categoria === categoria);
  const byDescriptor = ofCat.filter((c) => {
    const hay = normalizeText(`${c.nombre} ${c.descriptorRender}`);
    return matched.some((k) => hay.includes(k));
  });
  return (
    byDescriptor.find((c) => c.calidad === calidad) ??
    byDescriptor[0] ??
    ofCat.find((c) => c.calidad === calidad && c.esDefault) ??
    null
  );
}

export class DeterministicExtractor implements PreferenceExtractor {
  extract(raw: RawCallData, catalog: CatalogItem[]): AbstractedPreferences {
    const text = normalizeText(raw.tasteText ?? '');

    let calidadGlobal: Calidad = raw.calidad ?? 'media';
    if (!raw.calidad) {
      for (const e of CALIDAD_LEXICON) {
        if (hit(text, e.keywords)) {
          calidadGlobal = e.calidad;
          break;
        }
      }
    }

    const materialSelections: Partial<Record<CategoriaMaterial, string>> = {};
    for (const e of MATERIAL_LEXICON) {
      const matched = e.keywords.filter((k) => text.includes(k));
      if (matched.length === 0) continue;
      const item = pickItem(catalog, e.categoria, calidadGlobal, matched);
      if (item) materialSelections[e.categoria] = item.id;
    }

    const estructural = raw.estructural ?? hit(text, ESTRUCTURAL_LEXICON);

    const elementos: PreferenceExtra[] = (ELEMENTOS_LEXICON[raw.tipoReforma] ?? [])
      .filter((el) => hit(text, el.keywords))
      .map((el) => ({ key: el.key, label: el.label, importe: el.importe }));

    const estiloRender = ESTILO_LEXICON.filter((s) => text.includes(s));

    const ajustes: PreferenceAjuste[] = AJUSTE_LEXICON.filter((a) => hit(text, a.keywords)).map(
      (a) => ({ label: a.label, tipo: a.tipo, valor: a.valor, motivo: a.motivo }),
    );

    const hasM2 = raw.m2Suelo != null && raw.m2Suelo > 0;
    const signals =
      (estiloRender.length > 0 ? 1 : 0) +
      (elementos.length > 0 ? 1 : 0) +
      (Object.keys(materialSelections).length > 0 ? 1 : 0);
    let confianza: AbstractedPreferences['confianza'] = 'baja';
    if (hasM2 && signals >= 2) confianza = 'alta';
    else if (hasM2 || signals >= 1) confianza = 'media';

    const camposFaltantes: string[] = [];
    if (!hasM2) camposFaltantes.push('m2');
    if (raw.urgencia == null) camposFaltantes.push('urgencia');
    if (raw.presupuestoTarget == null) camposFaltantes.push('presupuestoTarget');
    if (text.length === 0) camposFaltantes.push('preferencias');

    const partes: string[] = [`Acabado ${calidadGlobal}`];
    if (estiloRender.length) partes.push(`estilo ${estiloRender.join(', ')}`);
    if (elementos.length) partes.push(`pide ${elementos.map((e) => e.label.toLowerCase()).join(', ')}`);
    if (estructural) partes.push('con cambios estructurales');
    const resumen = partes.join('; ') + '.';

    return {
      calidadGlobal,
      materialSelections,
      estructural,
      urgencia: raw.urgencia,
      presupuestoTarget: raw.presupuestoTarget,
      elementos,
      estiloRender,
      ajustes,
      confianza,
      resumen,
      camposFaltantes,
    };
  }
}

export const deterministicExtractor = new DeterministicExtractor();
  • Step 4: Ejecutar el test para verlo pasar

Run: cd "c:/Users/carlo/Proyectos/reformix-hackaton/mvp/b2c" && npx vitest run tests/voice/extractor.test.ts Expected: PASS (4 tests).

  • Step 5: Commit
git add src/lib/voice/extractor.ts tests/voice/extractor.test.ts
git commit -m "Add clasificador determinista de preferencias keyless"

Task 4: Merge + aplicación al presupuesto (apply.ts)

Files:

  • Create: src/lib/voice/apply.ts

  • Test: tests/voice/apply.test.ts

  • Step 1: Escribir el test que falla

// tests/voice/apply.test.ts
import { describe, it, expect } from 'vitest';
import { applyPreferences, mergeIntoBudgetInputs } from '@/lib/voice/apply';
import type { AbstractedPreferences } from '@/lib/voice/preferences';
import type { BudgetResult } from '@/budget/types';

function prefs(partial: Partial<AbstractedPreferences>): AbstractedPreferences {
  return {
    calidadGlobal: 'media',
    materialSelections: {},
    estructural: false,
    urgencia: null,
    presupuestoTarget: null,
    elementos: [],
    estiloRender: [],
    ajustes: [],
    confianza: 'media',
    resumen: '',
    camposFaltantes: [],
    ...partial,
  };
}

const base: BudgetResult = {
  partidas: [{ key: 'mano_de_obra', label: 'Mano de obra', importe: 100000 }],
  subtotal: 100000,
  factorZona: 1,
  total: 100000,
  rango: { min: 85000, max: 115000 },
  confianza: 'media',
  materialesRender: ['suelo cerámico gris'],
  avisos: [],
};

describe('applyPreferences', () => {
  it('añade elementos y ajustes fijos como partidas y recalcula totales', () => {
    const r = applyPreferences(base, prefs({
      elementos: [{ key: 'isla_cocina', label: 'Isla de cocina', importe: 120000 }],
      ajustes: [{ label: 'Encimera de piedra natural', tipo: 'fijo', valor: 50000, motivo: 'cuarzo' }],
      estiloRender: ['estilo nórdico'],
    }));
    const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe]));
    expect(byKey.isla_cocina).toBe(120000);
    expect(r.partidas.some((p) => p.label === 'Encimera de piedra natural' && p.importe === 50000)).toBe(true);
    expect(r.subtotal).toBe(270000);
    expect(r.total).toBe(270000);
    expect(r.rango.min).toBe(229500); // round(85000 * 2.7)
    expect(r.rango.max).toBe(310500); // round(115000 * 2.7)
    expect(r.materialesRender).toContain('estilo nórdico');
  });

  it('un ajuste tipo factor se aplica sobre el subtotal original como partida explícita', () => {
    const r = applyPreferences(base, prefs({
      ajustes: [{ label: 'Acabado premium global', tipo: 'factor', valor: 1.1, motivo: 'premium' }],
    }));
    expect(r.partidas.some((p) => p.label === 'Acabado premium global' && p.importe === 10000)).toBe(true);
    expect(r.subtotal).toBe(110000);
  });
});

describe('mergeIntoBudgetInputs', () => {
  it('vuelca preferencias y datos del lead en BudgetInputs', () => {
    const inputs = mergeIntoBudgetInputs(
      prefs({ calidadGlobal: 'premium', materialSelections: { suelo: 'suelo-p' }, estructural: true }),
      { tipoReforma: 'cocina', m2Suelo: 16, alturaTecho: null, provincia: 'Madrid' },
    );
    expect(inputs.calidadGlobal).toBe('premium');
    expect(inputs.materialSelections.suelo).toBe('suelo-p');
    expect(inputs.estructural).toBe(true);
    expect(inputs.m2Suelo).toBe(16);
    expect(inputs.provincia).toBe('Madrid');
  });
});
  • Step 2: Ejecutar el test para verlo fallar

Run: cd "c:/Users/carlo/Proyectos/reformix-hackaton/mvp/b2c" && npx vitest run tests/voice/apply.test.ts Expected: FAIL — apply no existe.

  • Step 3: Implementar apply.ts
// src/lib/voice/apply.ts
import type { BudgetInputs, BudgetResult, PartidaResult, TipoReforma } from '@/budget/types';
import type { AbstractedPreferences } from './preferences';

interface LeadInputsSource {
  tipoReforma: TipoReforma | null;
  m2Suelo: number | null;
  alturaTecho: number | null;
  provincia: string | null;
}

export function mergeIntoBudgetInputs(
  prefs: AbstractedPreferences,
  lead: LeadInputsSource,
): BudgetInputs {
  return {
    tipoReforma: lead.tipoReforma ?? 'otro',
    m2Suelo: lead.m2Suelo,
    alturaTecho: lead.alturaTecho,
    calidadGlobal: prefs.calidadGlobal,
    estructural: prefs.estructural,
    provincia: lead.provincia,
    materialSelections: prefs.materialSelections,
  };
}

export function applyPreferences(
  result: BudgetResult,
  prefs: AbstractedPreferences,
): BudgetResult {
  const extras: PartidaResult[] = prefs.elementos.map((e) => ({
    key: e.key,
    label: e.label,
    importe: e.importe,
  }));

  prefs.ajustes.forEach((a, i) => {
    const importe =
      a.tipo === 'fijo' ? a.valor : Math.round(result.subtotal * (a.valor - 1));
    if (importe === 0) return;
    extras.push({ key: `ajuste_${i}`, label: a.label, importe });
  });

  if (extras.length === 0 && prefs.estiloRender.length === 0) return result;

  const partidas = [...result.partidas, ...extras];
  const subtotal = partidas.reduce((s, p) => s + p.importe, 0);
  const total = Math.round(subtotal * result.factorZona);
  const scale = result.total > 0 ? total / result.total : 1;
  const rango = {
    min: Math.round(result.rango.min * scale),
    max: Math.round(result.rango.max * scale),
  };
  const materialesRender = [...result.materialesRender, ...prefs.estiloRender];

  return { ...result, partidas, subtotal, total, rango, materialesRender };
}
  • Step 4: Ejecutar el test para verlo pasar

Run: cd "c:/Users/carlo/Proyectos/reformix-hackaton/mvp/b2c" && npx vitest run tests/voice/apply.test.ts Expected: PASS (3 tests).

  • Step 5: Commit
git add src/lib/voice/apply.ts tests/voice/apply.test.ts
git commit -m "Add merge a BudgetInputs y aplicación de extras/ajustes al presupuesto"

Task 5: Guion del agente (script.ts)

Files:

  • Create: src/lib/voice/script.ts

  • Test: tests/voice/script.test.ts

  • Step 1: Escribir el test que falla

// tests/voice/script.test.ts
import { describe, it, expect } from 'vitest';
import { buildScript } from '@/lib/voice/script';

describe('buildScript', () => {
  const script = buildScript(
    { nombreEmpresa: 'Reformas Ejemplo' },
    { nombre: 'Ana Pérez', tipoReforma: 'cocina', m2Suelo: 16 },
  );

  it('abre con identificación de IA y aviso de grabación (RF-C-12, RNF-LEG-05)', () => {
    const preambulo = script.bloques[0].turnos.map((t) => t.texto).join(' ');
    expect(preambulo).toContain('inteligencia artificial');
    expect(preambulo).toContain('grabada');
    expect(preambulo).toContain('Reformas Ejemplo');
    expect(preambulo).toContain('Ana');
  });

  it('incluye los bloques de slots fijos y el bloque abierto de gustos', () => {
    const ids = script.bloques.map((b) => b.id);
    expect(ids).toEqual(['preambulo', 'confirmacion', 'slots', 'gustos', 'cierre']);
    const gustos = script.bloques.find((b) => b.id === 'gustos')!;
    expect(gustos.turnos.some((t) => t.texto.includes('imaginas'))).toBe(true);
  });
});
  • Step 2: Ejecutar el test para verlo fallar

Run: cd "c:/Users/carlo/Proyectos/reformix-hackaton/mvp/b2c" && npx vitest run tests/voice/script.test.ts Expected: FAIL — buildScript no existe.

  • Step 3: Implementar script.ts
// src/lib/voice/script.ts
import type { TipoReforma } from '@/budget/types';

export interface ScriptTurn {
  quien: 'agente' | 'cliente';
  texto: string;
}
export interface ScriptBlock {
  id: 'preambulo' | 'confirmacion' | 'slots' | 'gustos' | 'cierre';
  titulo: string;
  turnos: ScriptTurn[];
}
export interface VoiceScript {
  bloques: ScriptBlock[];
}

const TIPO_TEXTO: Record<TipoReforma, string> = {
  cocina: 'la cocina',
  bano: 'el baño',
  salon: 'el salón',
  comedor: 'el comedor',
  integral: 'el piso entero',
  otro: 'la reforma',
};

export function buildScript(
  empresa: { nombreEmpresa: string },
  lead: { nombre: string; tipoReforma: TipoReforma | null; m2Suelo: number | null },
): VoiceScript {
  const nombre = lead.nombre.split(' ')[0];
  const tipo = TIPO_TEXTO[lead.tipoReforma ?? 'otro'];
  const m2 = lead.m2Suelo ? `${Math.round(lead.m2Suelo)} metros` : 'el espacio';

  return {
    bloques: [
      {
        id: 'preambulo',
        titulo: 'Preámbulo legal',
        turnos: [
          {
            quien: 'agente',
            texto: `Hola ${nombre}, te llamo de ${empresa.nombreEmpresa}. Soy un asistente virtual con inteligencia artificial y esta llamada queda grabada y transcrita para preparar tu presupuesto. ¿Te parece bien que sigamos?`,
          },
        ],
      },
      {
        id: 'confirmacion',
        titulo: 'Confirmación de datos',
        turnos: [
          { quien: 'agente', texto: `Me dices que quieres reformar ${tipo}, ¿unos ${m2} aproximadamente, verdad?` },
        ],
      },
      {
        id: 'slots',
        titulo: 'Datos clave',
        turnos: [
          { quien: 'agente', texto: '¿Qué nivel de acabado buscas: básico, medio o premium?' },
          { quien: 'agente', texto: '¿Hay que mover sanitarios principales, tirar algún muro o cambiar la distribución?' },
          { quien: 'agente', texto: '¿Para cuándo te gustaría tenerlo?' },
          { quien: 'agente', texto: '¿Tienes una cifra en mente, aunque sea aproximada?' },
        ],
      },
      {
        id: 'gustos',
        titulo: 'Gustos y preferencias',
        turnos: [
          { quien: 'agente', texto: `Cuéntame cómo te imaginas ${tipo}: estilo, colores, materiales que te gusten… y si hay algún capricho que no quieras que falte.` },
          { quien: 'agente', texto: '¿Te tira más la madera, el porcelánico, algo más industrial?' },
          { quien: 'agente', texto: '¿Hay algo imprescindible: una isla, una ducha de obra, electrodomésticos integrados?' },
        ],
      },
      {
        id: 'cierre',
        titulo: 'Cierre',
        turnos: [
          { quien: 'agente', texto: `Genial, con esto te preparo el render y el presupuesto orientativo y te lo enviamos por WhatsApp. ¡Muchas gracias, ${nombre}!` },
        ],
      },
    ],
  };
}
  • Step 4: Ejecutar el test para verlo pasar

Run: cd "c:/Users/carlo/Proyectos/reformix-hackaton/mvp/b2c" && npx vitest run tests/voice/script.test.ts Expected: PASS (2 tests).

  • Step 5: Commit
git add src/lib/voice/script.ts tests/voice/script.test.ts
git commit -m "Add guion del agente de voz (preámbulo legal + slots + gustos)"

Task 6: Esquema de DB (campos nuevos en leads)

Files:

  • Modify: src/db/schema.ts

  • Step 1: Añadir el enum urgencia tras el enum calidad (línea 48)

En src/db/schema.ts, justo después de export const calidad = pgEnum('calidad', ['basica', 'media', 'premium']);, añade:

export const urgencia = pgEnum('urgencia', ['alta', 'media', 'baja']);
  • Step 2: Añadir las columnas nuevas en la tabla leads

En la definición de leads, justo después de la línea desgloseSnapshot: jsonb('desglose_snapshot'), (línea 182), añade:

    urgencia: urgencia('urgencia'),
    presupuestoTarget: integer('presupuesto_target'), // céntimos
    tasteText: text('taste_text'),
    preferencesSnapshot: jsonb('preferences_snapshot'),
  • Step 3: Generar y aplicar la migración

Run: cd "c:/Users/carlo/Proyectos/reformix-hackaton/mvp/b2c" && npm run db:generate && npm run db:migrate Expected: nueva migración 00XX_*.sql creada y aplicada sin error; crea el tipo urgencia y 4 columnas en leads.

  • Step 4: Verificar tipos

Run: cd "c:/Users/carlo/Proyectos/reformix-hackaton/mvp/b2c" && npx tsc --noEmit Expected: sin errores. Lead (línea 273, typeof leads.$inferSelect) ahora incluye urgencia, presupuestoTarget, tasteText, preferencesSnapshot.

  • Step 5: Commit
git add src/db/schema.ts drizzle/
git commit -m "Add campos de urgencia, target, gustos y snapshot de preferencias a leads"

Task 7: Captura en el form de fotos

Files:

  • Modify: src/components/funnel/FotosUploader.tsx

  • Modify: src/app/solicitud/actions.ts

  • Step 1: Añadir los campos al formulario

En src/components/funnel/FotosUploader.tsx, añade las constantes de urgencia tras CALIDADES (línea 12):

const URGENCIAS = [
  { value: 'alta', label: 'Cuanto antes' },
  { value: 'media', label: 'En unos meses' },
  { value: 'baja', label: 'Sin prisa' },
] as const;

Luego, dentro del <form>, justo antes de <SubmitButton disabled={false} /> (línea 147), inserta:

      {/* Urgencia + presupuesto objetivo */}
      <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
        <div className="flex flex-col gap-2">
          <label htmlFor="urgencia" className="text-sm font-semibold text-dark">
            ¿Para cuándo?
          </label>
          <select id="urgencia" name="urgencia" defaultValue="media" className={inputClass}>
            {URGENCIAS.map((u) => (
              <option key={u.value} value={u.value}>
                {u.label}
              </option>
            ))}
          </select>
        </div>
        <div className="flex flex-col gap-2">
          <label htmlFor="presupuestoTarget" className="text-sm font-semibold text-dark">
            Presupuesto objetivo <span className="text-gray-400 font-normal">(opcional, )</span>
          </label>
          <input
            id="presupuestoTarget"
            name="presupuestoTarget"
            type="number"
            min="0"
            step="100"
            inputMode="numeric"
            placeholder="8000"
            className={inputClass}
          />
        </div>
      </div>

      {/* Cambios estructurales */}
      <label className="flex items-center gap-3 text-sm font-medium text-dark cursor-pointer">
        <input type="checkbox" name="estructural" className="w-4 h-4 accent-black" />
        Hay que mover sanitarios, tirar algún muro o cambiar la distribución
      </label>

      {/* Bloque abierto de gustos */}
      <div className="flex flex-col gap-2">
        <label htmlFor="tasteText" className="text-sm font-semibold text-dark">
          Cuéntanos cómo lo imaginas
        </label>
        <textarea
          id="tasteText"
          name="tasteText"
          rows={4}
          placeholder="Estilo, colores, materiales que te gusten… y cualquier capricho que no quieras que falte (una isla, ducha de obra, encimera de cuarzo…)."
          className={inputClass}
        />
      </div>
  • Step 2: Persistir los campos en la server action

En src/app/solicitud/actions.ts, dentro de guardarDetallesYFotos, tras la línea const provincia = String(formData.get('provincia') ?? '').trim() || null; (línea 94), añade:

  const urgenciaRaw = String(formData.get('urgencia') ?? '');
  const urgencia = (['alta', 'media', 'baja'] as const).includes(urgenciaRaw as 'alta')
    ? (urgenciaRaw as 'alta' | 'media' | 'baja')
    : null;
  const targetEuros = Number(formData.get('presupuestoTarget'));
  const presupuestoTarget =
    Number.isFinite(targetEuros) && targetEuros > 0 ? Math.round(targetEuros * 100) : null;
  const estructural = formData.get('estructural') === 'on';
  const tasteText = String(formData.get('tasteText') ?? '').trim() || null;

Y en el .set({ ... }) del update(leads) (líneas 119-125), añade estos campos junto a los existentes:

      urgencia,
      presupuestoTarget,
      estructural,
      tasteText,
  • Step 3: Verificar tipos y lint

Run: cd "c:/Users/carlo/Proyectos/reformix-hackaton/mvp/b2c" && npx tsc --noEmit && npm run lint Expected: sin errores nuevos.

  • Step 4: Commit
git add src/components/funnel/FotosUploader.tsx src/app/solicitud/actions.ts
git commit -m "Add captura de urgencia, target, estructural y gustos en el form de fotos"

Task 8: Encadenar en el orquestador

Files:

  • Modify: src/lib/funnel/orchestrator.ts

  • Step 1: Importar la capa de voz

En src/lib/funnel/orchestrator.ts, tras los imports existentes (línea 7), añade:

import { deterministicExtractor } from '@/lib/voice/extractor';
import { mergeIntoBudgetInputs, applyPreferences } from '@/lib/voice/apply';
import type { RawCallData } from '@/lib/voice/preferences';
  • Step 2: Construir RawCallData y encadenar extractor → merge → motor → apply

Reemplaza el bloque "Paso 6b" (líneas 87-114, desde const [config, catalog] = ... hasta el .where(eq(leads.id, leadId)); del primer update) por:

  // Paso 6b: presupuesto calculado (REAL) con catálogo + preferencias abstraídas
  const [config, catalog] = await Promise.all([
    getPricingConfigFor(lead.tenantId),
    getCatalogFor(lead.tenantId),
  ]);

  const raw: RawCallData = {
    tipoReforma: tipo,
    m2Suelo: lead.m2Suelo ?? null,
    calidad: lead.calidadGlobal ?? null,
    estructural: lead.estructural,
    urgencia: lead.urgencia ?? null,
    presupuestoTarget: lead.presupuestoTarget ?? null,
    tasteText: lead.tasteText ?? '',
  };
  const prefs = deterministicExtractor.extract(raw, catalog);
  const inputs = mergeIntoBudgetInputs(prefs, {
    tipoReforma: lead.tipoReforma,
    m2Suelo: lead.m2Suelo ?? null,
    alturaTecho: lead.alturaTecho ?? null,
    provincia: lead.provincia ?? null,
  });
  const result = applyPreferences(computeBudget(inputs, config, catalog), prefs);

  await db
    .update(leads)
    .set({
      transcripcion,
      entidades,
      renderUrl,
      presupuestoEstimado: result.total,
      desgloseSnapshot: { stage: 'presupuesto_generado', result },
      preferencesSnapshot: prefs,
      pipelineStage: 'presupuesto_generado',
      updatedAt: new Date(),
    })
    .where(eq(leads.id, leadId));

Nota: elimina el import { BudgetInputs }-only usage previo si queda sin usar (el inputs ahora viene de mergeIntoBudgetInputs). Mantén el import de computeBudget.

  • Step 3: Verificar tipos y lint

Run: cd "c:/Users/carlo/Proyectos/reformix-hackaton/mvp/b2c" && npx tsc --noEmit && npm run lint Expected: sin errores. Si BudgetInputs queda importado sin usar, elimínalo del import de la línea 6.

  • Step 4: Ejecutar toda la suite

Run: cd "c:/Users/carlo/Proyectos/reformix-hackaton/mvp/b2c" && npm test Expected: PASS — incluye los nuevos tests/voice/* y los tests/budget/* existentes sin regresiones.

  • Step 5: Commit
git add src/lib/funnel/orchestrator.ts
git commit -m "Encadenar extractor de preferencias y aplicación de extras en el orquestador"

Task 9: Mostrar "Preferencias detectadas" en el panel

Files:

  • Modify: src/app/panel/[id]/page.tsx

  • Step 1: Leer el snapshot de preferencias

En src/app/panel/[id]/page.tsx, tras const desglose = snapshot?.result ?? null; (línea 37), añade:

  const prefs = lead.preferencesSnapshot as import('@/lib/voice/preferences').AbstractedPreferences | null;
  • Step 2: Añadir la sección tras el bloque "Render generado"

Dentro del <div className="grid md:grid-cols-2 gap-6">, añade una nueva <Section> tras la de "Render generado" (después de la línea 129):

        {/* Preferencias detectadas */}
        <Section title="Preferencias detectadas">
          {prefs ? (
            <div className="flex flex-col gap-3 text-sm">
              <p className="text-gray-700">{prefs.resumen}</p>
              {prefs.estiloRender.length > 0 && (
                <div className="flex flex-wrap gap-2">
                  {prefs.estiloRender.map((e) => (
                    <span key={e} className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700 text-xs">
                      {e}
                    </span>
                  ))}
                </div>
              )}
              {prefs.elementos.length > 0 && (
                <ul className="flex flex-col gap-1">
                  {prefs.elementos.map((el) => (
                    <li key={el.key} className="flex justify-between">
                      <span className="text-gray-600">{el.label}</span>
                      <span className="text-black font-medium">{formatEuros(el.importe)}</span>
                    </li>
                  ))}
                </ul>
              )}
              {prefs.ajustes.length > 0 && (
                <ul className="text-xs text-gray-500 flex flex-col gap-1">
                  {prefs.ajustes.map((a, i) => (
                    <li key={i}>
                      <span className="font-medium text-gray-700">{a.label}</span>  {a.motivo}
                    </li>
                  ))}
                </ul>
              )}
              <div className="text-xs text-gray-400">
                Confianza de la extracción: {prefs.confianza}
                {prefs.camposFaltantes.length > 0 && ` · faltan: ${prefs.camposFaltantes.join(', ')}`}
              </div>
            </div>
          ) : (
            <p className="text-sm text-gray-400">Sin preferencias procesadas aún.</p>
          )}
        </Section>

formatEuros ya está importado (línea 11).

  • Step 3: Verificar tipos y lint

Run: cd "c:/Users/carlo/Proyectos/reformix-hackaton/mvp/b2c" && npx tsc --noEmit && npm run lint Expected: sin errores.

  • Step 4: Verificación manual en navegador

Run: cd "c:/Users/carlo/Proyectos/reformix-hackaton/mvp/b2c" && npm run dev (déjalo corriendo).

  1. Abre http://localhost:3000, completa el form del Hero → llega a /solicitud/[id]/fotos.
  2. Rellena: tipo cocina, m² 16, calidad media, urgencia, marca estructural, y en el textarea escribe: "Quiero algo premium estilo nórdico con madera clara, una isla y encimera de cuarzo."
  3. Envía → en /solicitud/[id]/estado el presupuesto debe incluir la partida "Isla de cocina" y "Encimera de piedra natural".
  4. Entra al panel (demo@reformas-ejemplo.es / DemoReformix2026!), abre el lead → la sección "Preferencias detectadas" muestra resumen, etiquetas de estilo, elementos con importe y ajustes con motivo. Expected: todo lo anterior visible y el total refleja los extras.
  • Step 5: Commit
git add src/app/panel/[id]/page.tsx
git commit -m "Add sección de preferencias detectadas en el detalle del lead"

Self-Review

Spec coverage:

  • Contrato AbstractedPreferences → Task 1. ✓
  • Guion híbrido + preámbulo legal (RF-C-12/13/15/16, RNF-LEG-05) → Task 5. ✓
  • Clasificador keyless + costura GPT-4o (PreferenceExtractor) → Task 3. ✓
  • Cuatro palancas: material+calidad (Task 3 materialSelections), extras (Task 3 elementos + Task 4 partidas), render (Task 3 estiloRender + Task 4), ajustes etiquetados (Task 3 ajustes + Task 4 partidas con label/motivo). ✓
  • Enfoque A pre+post, motor intacto → Task 4 + Task 8. ✓
  • Captura en funnel (cierra RF-C-15) → Task 6/7. ✓
  • Panel "Preferencias detectadas" → Task 9. ✓

Placeholder scan: sin TBD/TODO; todos los steps con código o comandos concretos. ✓

Type consistency: AbstractedPreferences, RawCallData, PreferenceExtra ({key,label,importe}), PreferenceAjuste ({label,tipo,valor,motivo}) usados igual en Tasks 1, 3, 4, 8, 9. mergeIntoBudgetInputs(prefs, lead) y applyPreferences(result, prefs) con las firmas de Task 4 reusadas en Task 8. buildScript(empresa, lead) de Task 5 (su uso en el transcript del orquestador es opcional; el orquestador mantiene construirTranscripcion). ✓