Add clasificador determinista de preferencias keyless

This commit is contained in:
Carlos Narro
2026-05-31 16:13:38 +02:00
parent 18e900dd52
commit a84d513c5b
2 changed files with 167 additions and 0 deletions

View 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();