diff --git a/mvp/b2c/src/lib/voice/extractor.ts b/mvp/b2c/src/lib/voice/extractor.ts new file mode 100644 index 0000000..c067a27 --- /dev/null +++ b/mvp/b2c/src/lib/voice/extractor.ts @@ -0,0 +1,111 @@ +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(); diff --git a/mvp/b2c/tests/voice/extractor.test.ts b/mvp/b2c/tests/voice/extractor.test.ts new file mode 100644 index 0000000..093f54e --- /dev/null +++ b/mvp/b2c/tests/voice/extractor.test.ts @@ -0,0 +1,56 @@ +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'); + }); +});