From 9997ce11cc26af3103c87c0cb6e99ce85565d810 Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Sun, 31 May 2026 16:05:13 +0200 Subject: [PATCH] =?UTF-8?q?Add=20plan=20de=20implementaci=C3=B3n=20del=20g?= =?UTF-8?q?uion=20de=20voz=20+=20capa=20de=20preferencias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- ...026-05-31-guion-agente-voz-preferencias.md | 1028 +++++++++++++++++ 1 file changed, 1028 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-31-guion-agente-voz-preferencias.md diff --git a/docs/superpowers/plans/2026-05-31-guion-agente-voz-preferencias.md b/docs/superpowers/plans/2026-05-31-guion-agente-voz-preferencias.md new file mode 100644 index 0000000..78afdd1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-guion-agente-voz-preferencias.md @@ -0,0 +1,1028 @@ +# 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" && `. Postgres dev en `postgresql://postgres:reformix@localhost:5433/reformix`. + +--- + +## File Structure + +- `src/lib/voice/preferences.ts` — **Crear.** Tipos: `RawCallData`, `PreferenceExtra`, `PreferenceAjuste`, `AbstractedPreferences`, `Urgencia`. +- `src/lib/voice/lexicon.ts` — **Crear.** Léxicos en español (calidad, materiales, estructural, estilo, elementos por tipo, ajustes). +- `src/lib/voice/extractor.ts` — **Crear.** `PreferenceExtractor` (interfaz) + `DeterministicExtractor` + `deterministicExtractor` (instancia). +- `src/lib/voice/apply.ts` — **Crear.** `mergeIntoBudgetInputs` + `applyPreferences`. +- `src/lib/voice/script.ts` — **Crear.** `buildScript` + tipos `VoiceScript`/`ScriptBlock`/`ScriptTurn`. +- `src/db/schema.ts` — **Modificar.** Enum `urgencia` + columnas `urgencia`, `presupuestoTarget`, `tasteText`, `preferencesSnapshot` en `leads`. +- `src/app/solicitud/actions.ts` — **Modificar.** `guardarDetallesYFotos` persiste los nuevos campos. +- `src/components/funnel/FotosUploader.tsx` — **Modificar.** Campos urgencia, estructural, target, textarea de gustos. +- `src/lib/funnel/orchestrator.ts` — **Modificar.** Encadena extractor → merge → motor → apply; guarda `preferencesSnapshot`. +- `src/app/panel/[id]/page.tsx` — **Modificar.** Sección "Preferencias detectadas". +- `tests/voice/extractor.test.ts`, `tests/voice/apply.test.ts`, `tests/voice/script.test.ts` — **Crear.** + +--- + +## Task 1: Tipos del contrato (`preferences.ts`) + +**Files:** +- Create: `src/lib/voice/preferences.ts` + +- [ ] **Step 1: Crear el archivo de tipos** + +```ts +// 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>; + 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** + +```bash +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** + +```ts +// 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** + +```bash +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** + +```ts +// 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 { + 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** + +```ts +// 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> = {}; + 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** + +```bash +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** + +```ts +// 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 { + 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`** + +```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** + +```bash +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** + +```ts +// 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`** + +```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 = { + 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** + +```bash +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: + +```ts +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: + +```ts + 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** + +```bash +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): + +```tsx +const URGENCIAS = [ + { value: 'alta', label: 'Cuanto antes' }, + { value: 'media', label: 'En unos meses' }, + { value: 'baja', label: 'Sin prisa' }, +] as const; +``` + +Luego, dentro del `
`, justo antes de `` (línea 147), inserta: + +```tsx + {/* Urgencia + presupuesto objetivo */} +
+
+ + +
+
+ + +
+
+ + {/* Cambios estructurales */} + + + {/* Bloque abierto de gustos */} +
+ +