Add clasificador determinista de preferencias keyless
This commit is contained in:
111
mvp/b2c/src/lib/voice/extractor.ts
Normal file
111
mvp/b2c/src/lib/voice/extractor.ts
Normal file
@@ -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<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();
|
||||||
56
mvp/b2c/tests/voice/extractor.test.ts
Normal file
56
mvp/b2c/tests/voice/extractor.test.ts
Normal file
@@ -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>): 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user