Propuesta IA: corregir lo que encontro la auditoria de herramientas y plantillas

La auditoria confirma lo que se pedia verificar: la IA SOLO puede usar las tres
herramientas internas. Una por llamada, ninguna se ejecuta jamas (el input del
modelo solo va a schema.safeParse, no hay despachador), y no se envia ningun
tool del proveedor, web search, ejecucion de codigo, MCP ni beta.

Lo que NO cumplia era la otra mitad, la correspondencia con las plantillas:

CRITICO - el documento cobraba un IVA que la cotizacion no cobra. Cada partida
se imprimia a precio SIN IVA y debajo un unico total CON IVA, rematado con
"Importes con IVA incluido", mientras el PDF economico del mismo correo dice
"los precios no incluyen IVA" sobre las mismas cifras. Las lineas no sumaban su
propio total. Ahora la caja de totales desglosa subtotal / IVA / total como la
plantilla autorizada, las lineas siguen sin IVA igual que el otro documento, y
la nota al pie dice lo que de verdad hacen las lineas.

CRITICO - los avisos bloqueantes no bloqueaban nada. hayBloqueantes() no se
llamaba en ningun sitio y la ruta del PDF nunca leia avisos: el documento del
cliente se descargaba igual con una fuga de notas internas dentro. Ahora
devuelve 409 con la lista de lo que hay que corregir. El anexo interno si se
permite: es justo el que el asesor necesita para arreglarlo.

ALTO - seis campos que SI se imprimen al cliente no pasaban por los filtros de
marca, moneda, garantias y PII: el texto y el autor de la cita destacada, la
metrica y el periodo de cada resultado, quien decide cada pendiente, y el
momento del backlog. textoVisible ahora los cubre y queda documentado que debe
seguir a lo que dibuja el PDF.

ALTO - el plan Bucefalo se caia del documento y de sus totales, aunque si
aparece en el PDF economico y en el Excel: cargarEconomia nunca leia la
relacion. El cliente recibia dos documentos con alcances distintos.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
urieljareth
2026-07-28 21:01:34 -06:00
co-authored by Claude Opus 5
parent 3f46d8a734
commit bdd9319fd0
8 changed files with 147 additions and 133 deletions
-113
View File
@@ -1,113 +0,0 @@
/**
* Caza del error intermitente: corre el pipeline real contra UJ2606UR001 hasta que
* falle, e imprime TODO lo que sepamos de la peticion que lo provoco.
*/
import { cargarEconomia } from "@/lib/propuesta/economia";
import { crearCliente, modelo } from "@/lib/propuesta/cliente-ia";
import { hechosSchema, diagnosticoSchema, redaccionSchema, aJsonSchema, type Hechos, type Diagnostico } from "@/lib/propuesta/schemas";
import { SYSTEM_BASE, mensajePaso1, mensajePaso2, mensajePaso3, type ContextoEntrada } from "@/lib/propuesta/prompts";
import { prisma } from "@/lib/db";
import type Anthropic from "@anthropic-ai/sdk";
import type { z } from "zod";
import fs from "node:fs";
const LOG = process.argv[2];
// Escritura directa: el buffer de stdout se pierde si el proceso muere.
function log(...a: unknown[]) {
const linea = a.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(" ");
fs.appendFileSync(LOG, linea + "\n");
}
const console = { log } as unknown as Console;
const NUMERO = "UJ2606UR001";
const TRANSCRIPCION = "Una propuesta de zero to hero para un negocio inicial";
/** Igual que llamarConHerramienta pero registra cada peticion y explota con detalle. */
async function correrPaso(nombre: string, msg: string, schema: z.ZodType, maxTokens: number, maxIntentos: number) {
const cliente = crearCliente();
const tools = [{ name: nombre, description: "Registra el resultado.", input_schema: aJsonSchema(schema) as never }];
const mensajes: Anthropic.MessageParam[] = [{ role: "user", content: msg }];
for (let intento = 1; intento <= maxIntentos; intento++) {
let res: Anthropic.Message;
try {
res = await cliente.messages.create({
model: modelo(), max_tokens: maxTokens,
system: [{ type: "text", text: SYSTEM_BASE, cache_control: { type: "ephemeral" } }],
tools, messages: mensajes,
});
} catch (e) {
const err = e as { status?: number; message?: string; error?: unknown };
console.log(`\n*** ERROR EN ${nombre} intento ${intento} ***`);
console.log(" status:", err.status);
console.log(" message:", String(err.message).slice(0, 600));
if (err.error) console.log(" error:", JSON.stringify(err.error).slice(0, 800));
console.log("\n --- FORMA DE LOS MENSAJES ENVIADOS ---");
mensajes.forEach((m, i) => {
const c = m.content;
const tipos = typeof c === "string" ? "string" : (c as { type: string }[]).map((b) => b.type).join(",");
console.log(` [${i}] role=${m.role} bloques=${tipos}`);
});
throw new Error("REPRODUCIDO");
}
const bloques = res.content.map((b) => b.type).join(",");
console.log(` ${nombre} intento ${intento}: stop=${res.stop_reason} bloques=[${bloques}] out=${res.usage.output_tokens}`);
const tus = res.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use");
const ok = tus.find((b) => b.name === nombre);
if (ok) {
const parsed = schema.safeParse(ok.input);
if (parsed.success) return parsed.data;
const errTxt = (await import("zod")).z.prettifyError(parsed.error);
console.log(` zod rechazo: ${errTxt.split("\n")[0]}`);
mensajes.push(
{ role: "assistant", content: res.content },
{ role: "user", content: [
...tus.map((b) => ({ type: "tool_result" as const, tool_use_id: b.id, is_error: true, content: errTxt })),
{ type: "text" as const, text: "Corrige y reintenta." },
] },
);
continue;
}
mensajes.push(
{ role: "assistant", content: res.content },
{ role: "user", content: `Debes llamar a ${nombre}.` },
);
}
return null;
}
async function unaVuelta(entrada: ContextoEntrada, econ: Awaited<ReturnType<typeof cargarEconomia>>, n: number) {
console.log(`\n===== VUELTA ${n} =====`);
const h = (await correrPaso("registrar_hechos", mensajePaso1(entrada), hechosSchema, 8000, 5)) as Hechos | null;
if (!h) { console.log(" extraccion agoto intentos"); return; }
const d = (await correrPaso("registrar_diagnostico", mensajePaso2(entrada, h, econ), diagnosticoSchema, 8000, 4)) as Diagnostico | null;
if (!d) { console.log(" diagnostico agoto intentos"); return; }
const r = await correrPaso("registrar_propuesta", mensajePaso3(entrada, h, d, econ), redaccionSchema, 12000, 4);
console.log(r ? " vuelta completa OK" : " redaccion agoto intentos");
}
async function main() {
const cot = await prisma.cotizacion.findFirst({ where: { numero: NUMERO }, include: { cliente: true, servicios: true } });
if (!cot) throw new Error("no encontrada");
const econ = await cargarEconomia(cot.id);
const entrada: ContextoEntrada = {
transcripcion: TRANSCRIPCION,
notas: cot.servicios.map((s) => s.notas).filter(Boolean).join("\n"),
observaciones: cot.observaciones || "",
cliente: cot.cliente.nombre, empresa: cot.cliente.empresa || "", proyecto: cot.proyecto,
};
console.log(`${econ.partidas.length} partidas`);
for (let i = 1; i <= 3; i++) {
try { await unaVuelta(entrada, econ, i); }
catch (e) {
if (e instanceof Error && e.message === "REPRODUCIDO") { console.log("\n>>> error reproducido, deteniendo"); break; }
throw e;
}
}
await prisma.$disconnect();
}
main().catch((e) => { console.error("fallo:", e); process.exit(1); });
+18 -3
View File
@@ -55,6 +55,7 @@ const econ: DatosEconomicos = {
],
totales: { subtotalUnico: 12000, subtotalMensual: 8000, ivaUnico: 1920, ivaMensual: 1280, totalUnico: 13920, totalMensual: 9280, totalPrimerAnio: 125280, incluyeIva: true },
moneda: "MXN",
planBucefalo: null,
};
const propuesta: PropuestaConsultiva = {
@@ -127,15 +128,29 @@ async function main() {
check("imprime el precio de P02", txt.includes("8,000"));
check("imprime el total unico con IVA", txt.includes("13,920"));
check("imprime la mensualidad con IVA", txt.includes("9,280"));
check("declara que los importes llevan IVA", txt.includes("IVA incluido"));
// El desglose debe cuadrar: lineas sin IVA + IVA como renglon propio.
check("imprime el subtotal sin IVA", txt.includes("Subtotal pago unico") && txt.includes("12,000"));
check("imprime el IVA como renglon propio", txt.includes("IVA 16%") && txt.includes("1,920"));
check("imprime el total con IVA", txt.includes("Total pago unico"));
check("la nota NO dice que las lineas incluyan IVA", !txt.includes("Importes con IVA incluido"));
check("la nota aclara que las partidas van sin IVA", txt.includes("no incluyen IVA"));
check("etiqueta el semaforo", txt.includes("Problema critico") && txt.includes("Ventaja existente"));
check("etiqueta la confianza", txt.includes("Confirmado") && txt.includes("Estimado"));
check("el backlog se marca como no comprometido", txt.includes("No comprometido"));
// Sin IVA la nota cambia.
// Sin IVA no debe aparecer el renglon de IVA.
const sinIva = await generarPropuestaPDF({ ...datos, economia: { ...econ, totales: { ...econ.totales, incluyeIva: false } } });
check("sin IVA la nota legal cambia", textoDePdf(sinIva).includes("sin IVA"));
const txtSinIva = textoDePdf(sinIva);
check("sin IVA no se imprime el renglon de IVA", !txtSinIva.includes("IVA 16%"));
check("sin IVA el total lo declara", txtSinIva.includes("sin IVA"));
// El plan Bucefalo tiene que aparecer: esta en la cotizacion y en los otros dos exportadores.
const conPlan = await generarPropuestaPDF({ ...datos, economia: { ...econ, planBucefalo: { nivel: "premium", precio: 4500 } } });
const txtPlan = textoDePdf(conPlan);
check("el plan Bucefalo aparece en el documento", txtPlan.includes("Bucefalo"));
check("el plan aparece con su nivel", txtPlan.includes("Premium"));
check("el plan aparece con su precio mensual", txtPlan.includes("4,500"));
// Una partida que la IA invento no se dibuja (R2 ya la marco).
const conFantasma = await generarPropuestaPDF({
+1
View File
@@ -49,6 +49,7 @@ const econ: DatosEconomicos = {
partidas: [{ refPartida: "P01", servicioCotizadoId: "cuid-secreto-abc123", nombre: "Sitio web", fase: 1, tipoPago: "unico", precio: 12345, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }],
totales: { subtotalUnico: 12345, subtotalMensual: 0, ivaUnico: 1975.2, ivaMensual: 0, totalUnico: 14320.2, totalMensual: 0, totalPrimerAnio: 14320.2, incluyeIva: true },
moneda: "MXN",
planBucefalo: null,
};
const ctx = { transcripcion: "t", notas: "", observaciones: "", cliente: "Ana", empresa: "ACME", proyecto: "P" };
const hechos = { citas: [], hechos: [], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] } as Hechos;
@@ -15,6 +15,7 @@ const econ: DatosEconomicos = {
partidas: [{ refPartida: "P01", servicioCotizadoId: "c1", nombre: "Sitio web", fase: 1, tipoPago: "unico", precio: 12000, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }],
totales: { subtotalUnico: 12000, subtotalMensual: 0, ivaUnico: 1920, ivaMensual: 0, totalUnico: 13920, totalMensual: 0, totalPrimerAnio: 13920, incluyeIva: true },
moneda: "MXN",
planBucefalo: null,
};
const base = (): PropuestaConsultiva => ({
@@ -4,6 +4,7 @@ import { getConfigBranding } from "@/lib/config-helpers";
import { sanitizeFilename } from "@/lib/calculators";
import { cargarEconomia } from "@/lib/propuesta/economia";
import { generarPropuestaPDF, generarAnexoInternoPDF } from "@/lib/propuesta/pdf";
import { hayBloqueantes, type Aviso } from "@/lib/propuesta/validacion";
import type { PropuestaConsultiva } from "@/lib/propuesta/schemas";
/** GET /api/propuesta-ia/:id/pdf -> documento del cliente
@@ -22,6 +23,26 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (!cot) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
if (!fila) return NextResponse.json({ error: "Todavia no se ha generado la propuesta" }, { status: 404 });
// Los avisos bloqueantes tienen que bloquear DE VERDAD el documento del cliente.
// Antes esta ruta ni siquiera leia `avisos`: la severidad "bloqueante" era
// decorativa y el PDF se descargaba igual con una fuga de notas internas o un
// monto en USD dentro. El anexo interno SI se permite: es justamente el que el
// asesor necesita para entender que hay que corregir.
const avisos = Array.isArray(fila.avisos) ? (fila.avisos as unknown as Aviso[]) : [];
if (!anexo && hayBloqueantes(avisos)) {
const cuales = avisos
.filter((a) => a.severidad === "bloqueante")
.map((a) => `${a.regla}${a.ruta ? ` (${a.ruta})` : ""}: ${a.mensaje}`);
return NextResponse.json(
{
error:
"La propuesta tiene avisos bloqueantes sin resolver. Corrigelos en el panel y guarda antes de descargar el documento del cliente.",
bloqueantes: cuales,
},
{ status: 409 }
);
}
const propuesta = (fila.contenidoEditado ?? fila.contenidoIA) as unknown as PropuestaConsultiva;
const economia = await cargarEconomia(id);
+18 -1
View File
@@ -31,6 +31,10 @@ export interface DatosEconomicos {
partidas: PartidaCanonica[];
totales: TotalesCotizacion;
moneda: string;
/** Plan Bucefalo contratado, si lo hay. Se cobra mensual y aparece en el PDF
* economico y en el Excel; tiene que aparecer tambien en el consultivo o el
* cliente recibe dos documentos con alcances distintos. */
planBucefalo: { nivel: string; precio: number } | null;
}
export async function cargarEconomia(cotizacionId: string): Promise<DatosEconomicos> {
@@ -43,6 +47,7 @@ export async function cargarEconomia(cotizacionId: string): Promise<DatosEconomi
// correo y deben listar las partidas igual.
orderBy: [{ fase: "asc" }, { createdAt: "asc" }],
},
planBucefalo: true,
},
});
if (!cot) throw new Error(`Cotizacion ${cotizacionId} no encontrada`);
@@ -62,10 +67,22 @@ export async function cargarEconomia(cotizacionId: string): Promise<DatosEconomi
entregables: Array.isArray(s.entregables) ? (s.entregables as string[]) : [],
}));
const plan =
cot.planBucefalo && cot.planBucefalo.seleccionado
? { nivel: cot.planBucefalo.nivel, precio: cot.planBucefalo.precio }
: null;
// El plan Bucefalo se cobra mensual: entra a los totales como una partida mensual
// mas, igual que en el PDF economico.
const paraTotales = plan
? [...activos, { tipoPago: "mensual", precio: plan.precio, seleccionado: true }]
: activos;
return {
partidas,
totales: calcularTotalesCotizacion(activos, { incluirIva: cot.incluirIva }),
totales: calcularTotalesCotizacion(paraTotales, { incluirIva: cot.incluirIva }),
moneda: cot.moneda,
planBucefalo: plan,
};
}
+57 -11
View File
@@ -252,32 +252,78 @@ export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise<Buffer>
}
}
// El plan Bucefalo es una partida mas del acuerdo: si no se imprime aqui, el
// cliente recibe un documento consultivo con menos alcance que su cotizacion.
if (d.economia.planBucefalo) {
const nivel = d.economia.planBucefalo.nivel;
const etiqueta = `CRM Bucefalo — plan ${nivel.charAt(0).toUpperCase() + nivel.slice(1)}`;
const hE = txtH(etiqueta, W * 0.68, 9.5);
y = need(hE + 22, y);
doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text(etiqueta, L, y, { width: W * 0.68 });
doc
.font("Helvetica-Bold")
.fontSize(9.5)
.fillColor(PRIMARY)
.text(`${formatCurrency(d.economia.planBucefalo.precio)} / mes`, L + W * 0.7, y, { width: W * 0.3, align: "right" });
y += hE + 2;
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text("Servicio mensual", L, y, { width: W * 0.68 });
y += 20;
}
y = need(58, y);
doc.moveTo(L, y).lineTo(L + W, y).strokeColor(BORDER).lineWidth(0.5).stroke();
y += 10;
// ── CAJA DE TOTALES ──
// Desglose completo (subtotal / IVA / total), como la plantilla autorizada.
//
// Antes se imprimia cada partida a su precio SIN IVA y debajo un unico total CON
// IVA, rematado con la leyenda "Importes con IVA incluido": las lineas no sumaban
// el total, la leyenda contradecia a sus propias lineas, y el PDF economico que va
// en el MISMO correo dice "los precios no incluyen IVA" sobre las mismas cifras.
// Ahora las lineas siguen siendo sin IVA —igual que el otro documento— y el IVA
// aparece como renglon propio.
const t = d.economia.totales;
const filaTotal = (etiqueta: string, valor: string) => {
y = need(16, y);
doc.font("Helvetica-Bold").fontSize(10).fillColor(DARK).text(etiqueta, L, y);
doc.font("Helvetica-Bold").fontSize(10).fillColor(PRIMARY).text(valor, L + W * 0.6, y, { width: W * 0.4, align: "right" });
y += 16;
const fila = (etiqueta: string, valor: string, fuerte = false) => {
y = need(fuerte ? 18 : 15, y);
doc
.font(fuerte ? "Helvetica-Bold" : "Helvetica")
.fontSize(fuerte ? 10.5 : 9)
.fillColor(fuerte ? DARK : MUTED)
.text(etiqueta, L, y);
doc
.font("Helvetica-Bold")
.fontSize(fuerte ? 10.5 : 9)
.fillColor(fuerte ? PRIMARY : DARK)
.text(valor, L + W * 0.6, y, { width: W * 0.4, align: "right" });
y += fuerte ? 18 : 15;
};
if (t.subtotalUnico > 0) filaTotal("Pago unico", formatCurrency(t.totalUnico));
if (t.subtotalMensual > 0) filaTotal("Mensualidad", formatCurrency(t.totalMensual));
y = need(14, y);
if (t.subtotalUnico > 0) {
fila("Subtotal pago unico", formatCurrency(t.subtotalUnico));
if (t.incluyeIva) fila("IVA 16%", formatCurrency(t.ivaUnico));
fila(t.incluyeIva ? "Total pago unico" : "Total pago unico (sin IVA)", formatCurrency(t.totalUnico), true);
}
if (t.subtotalMensual > 0) {
if (t.subtotalUnico > 0) y += 4;
fila("Subtotal mensual", formatCurrency(t.subtotalMensual));
if (t.incluyeIva) fila("IVA 16%", formatCurrency(t.ivaMensual));
fila(t.incluyeIva ? "Total mensual" : "Total mensual (sin IVA)", formatCurrency(t.totalMensual), true);
}
y = need(16, y);
doc
.font("Helvetica")
.fontSize(7.5)
.fillColor(MUTED)
.text(
t.incluyeIva
? "Importes con IVA incluido. Moneda nacional (MXN). Facturacion CFDI."
: "Importes sin IVA. Moneda nacional (MXN). Facturacion CFDI.",
? "Los precios por partida son en Moneda Nacional (MXN) y no incluyen IVA; el IVA se desglosa arriba. Facturacion CFDI."
: "Los precios son en Moneda Nacional (MXN) y no incluyen IVA. Facturacion CFDI.",
L,
y,
{ width: W }
);
y += 22;
y += 24;
// ── BENEFICIOS ──
if (d.propuesta.redaccion.beneficios.length) {
+31 -5
View File
@@ -35,13 +35,29 @@ const PROMESA_GARANTIA = /\bgarantiza(mos|do|da|r|remos)?\b|\baseguramos\s+(vent
const MONTO_USD = /\b(usd|d[oó]lares|dlls?)\b|\$\s*[\d,.]+\s*(usd|dls)\b/i;
const TEMA_SENSIBLE = /\b(sueldo|salario|n[oó]mina|despido|demanda laboral|incapacidad|embarazo|enfermedad)\b/i;
/** Todo el texto que termina impreso en el documento del cliente. */
function textoVisible(p: PropuestaConsultiva): { ruta: string; texto: string }[] {
/**
* TODO el texto que termina impreso en el documento del cliente.
*
* Esta lista tiene que cubrir exactamente lo que dibuja `generarPropuestaPDF`. Es la
* unica fuente de los filtros de marca, moneda, garantias y PII: un campo que se
* imprime y no esta aqui llega al cliente sin que nadie lo mire.
*
* Faltaban seis, todos impresos: el texto y el autor de la cita destacada, la metrica
* y el periodo de cada resultado, quien decide cada pendiente, y el momento sugerido
* del backlog. Si agregas algo al PDF, agregalo aqui en el mismo cambio.
*/
export function textoVisible(p: PropuestaConsultiva): { ruta: string; texto: string }[] {
const out: { ruta: string; texto: string }[] = [
{ ruta: "hero.titulo", texto: p.redaccion.hero.titulo },
{ ruta: "hero.subtitulo", texto: p.redaccion.hero.subtitulo },
{ ruta: "valorProblema.notaMetodologia", texto: p.diagnostico.valorProblema.notaMetodologia },
];
// La cita destacada se imprime literal, con su autor (pdf.ts, seccion Diagnostico).
const destacada = p.hechos.citas.find((c) => c.id === p.redaccion.citaDestacadaId);
if (destacada) {
out.push({ ruta: `citas.${destacada.id}.textoLiteral`, texto: destacada.textoLiteral });
out.push({ ruta: `citas.${destacada.id}.quienLoDijo`, texto: destacada.quienLoDijo });
}
p.diagnostico.hallazgos.forEach((h, i) => {
out.push({ ruta: `hallazgos[${i}].titulo`, texto: h.titulo });
out.push({ ruta: `hallazgos[${i}].descripcion`, texto: h.descripcion });
@@ -49,15 +65,25 @@ function textoVisible(p: PropuestaConsultiva): { ruta: string; texto: string }[]
p.diagnostico.valorProblema.dimensiones.forEach((d, i) =>
out.push({ ruta: `valorProblema.dimensiones[${i}]`, texto: d.descripcion })
);
p.diagnostico.resultados.forEach((r, i) => out.push({ ruta: `resultados[${i}]`, texto: r.enunciado }));
p.diagnostico.resultados.forEach((r, i) => {
out.push({ ruta: `resultados[${i}].enunciado`, texto: r.enunciado });
out.push({ ruta: `resultados[${i}].metrica`, texto: r.metrica });
out.push({ ruta: `resultados[${i}].periodoMedicion`, texto: r.periodoMedicion });
});
p.redaccion.alcance.forEach((a, i) => out.push({ ruta: `alcance[${i}]`, texto: a.descripcionResultado }));
p.redaccion.beneficios.forEach((b, i) =>
out.push({ ruta: `beneficios[${i}]`, texto: `${b.etiqueta} ${b.texto}` })
);
p.redaccion.exclusiones.forEach((e, i) => out.push({ ruta: `exclusiones[${i}]`, texto: e.texto }));
p.redaccion.backlogEvolucion.forEach((b, i) => out.push({ ruta: `backlog[${i}]`, texto: b.problema }));
p.redaccion.backlogEvolucion.forEach((b, i) => {
out.push({ ruta: `backlog[${i}].problema`, texto: b.problema });
out.push({ ruta: `backlog[${i}].momentoSugerido`, texto: b.momentoSugerido });
});
p.hechos.materialesPendientes.forEach((m, i) => out.push({ ruta: `materiales[${i}]`, texto: m.texto }));
p.hechos.decisionesPendientes.forEach((d, i) => out.push({ ruta: `decisiones[${i}]`, texto: d.texto }));
p.hechos.decisionesPendientes.forEach((d, i) => {
out.push({ ruta: `decisiones[${i}].texto`, texto: d.texto });
out.push({ ruta: `decisiones[${i}].quienDecide`, texto: d.quienDecide });
});
return out;
}