Propuesta IA: cerrar los hallazgos medios de la auditoria

El mas urgente lo cause yo al hacer que el bloqueo bloqueara de verdad: si un
aviso bloqueante caia en un campo que el panel no dejaba editar, el asesor
quedaba sin salida salvo regenerar. Ocho campos eran editables y bastantes mas
se imprimen al cliente. Ahora se pueden editar tambien la cita destacada y su
autor, las dimensiones del valor y su nota de metodologia, los resultados con su
metrica y periodo, los materiales, las decisiones con quien decide, y el backlog.
El boton de descarga del documento del cliente aparece deshabilitado cuando hay
bloqueantes, en vez de invitar a un clic que devuelve 409.

Doble propuesta: se rechaza generar. El documento asume UNA lista de alcance y
UNA caja de totales, asi que sumaria las dos opciones —que son alternativas
excluyentes— y mostraria un total que no existe. Soportarlas es rediseniar el
documento; mientras tanto es mejor no producir uno incorrecto. Hoy no hay
cotizaciones dobles en produccion, asi que no bloquea a nadie.

La transcripcion ahora se guarda con la propuesta (columna nueva, migracion
aditiva). Solo vivia en memoria durante la generacion, asi que al editar y
revalidar, R3 se quedaba sin fuente contra la cual comprobar que la cita
destacada siguiera siendo literal, y el aviso desaparecia solo.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
urieljareth
2026-07-28 21:15:03 -06:00
co-authored by Claude Opus 5
parent 434a427f70
commit 0c127b44c4
4 changed files with 208 additions and 8 deletions
@@ -0,0 +1,3 @@
-- Guardar la transcripcion usada al generar la propuesta.
-- ADITIVA: columna nullable, no toca ninguna fila existente.
ALTER TABLE "PropuestaIA" ADD COLUMN IF NOT EXISTS "transcripcion" TEXT;
+3
View File
@@ -230,6 +230,9 @@ model PropuestaIA {
estado String @default("borrador") // borrador | aprobada estado String @default("borrador") // borrador | aprobada
contenidoIA Json contenidoIA Json
contenidoEditado Json? contenidoEditado Json?
// Transcripcion sanitizada usada al generar. Sin esto, el PATCH revalida con
// fuentes incompletas y R3 no puede comprobar que la cita destacada sea literal.
transcripcion String?
// Traza de la generacion: modelo, intentos por paso, tokens y aciertos de cache. // Traza de la generacion: modelo, intentos por paso, tokens y aciertos de cache.
traza Json? traza Json?
// Hallazgos de validacion y de contenido que el asesor debe revisar antes de enviar. // Hallazgos de validacion y de contenido que el asesor debe revisar antes de enviar.
+20 -1
View File
@@ -32,6 +32,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
if (!ctx) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 }); if (!ctx) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
const { cot, notasPartidas } = ctx; const { cot, notasPartidas } = ctx;
// La doble propuesta todavia no esta soportada en el documento consultivo: su
// estructura asume UNA lista de alcance y UNA caja de totales, asi que sumaria las
// dos opciones —que son alternativas excluyentes— y mostraria un total inflado.
// Mejor no generar nada que generar un documento con un total que no existe.
if (cot.esDoble) {
return NextResponse.json(
{
error:
"Esta cotizacion es de doble propuesta y el documento consultivo todavia no las soporta: sumaria las dos opciones como si fueran una sola. Genera la propuesta desde una cotizacion de opcion unica.",
},
{ status: 400 }
);
}
const economia = await cargarEconomia(id); const economia = await cargarEconomia(id);
if (!economia.partidas.length) { if (!economia.partidas.length) {
return NextResponse.json( return NextResponse.json(
@@ -64,6 +78,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
traza: traza as unknown as object, traza: traza as unknown as object,
avisos: avisos as unknown as object, avisos: avisos as unknown as object,
modelo: traza.modelo, modelo: traza.modelo,
transcripcion: transcripcion || null,
}, },
}); });
@@ -113,9 +128,13 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
// La edicion del asesor se revalida: pudo introducir una fuga, un USD o una // La edicion del asesor se revalida: pudo introducir una fuga, un USD o una
// promesa de resultado sin darse cuenta. // promesa de resultado sin darse cuenta.
//
// Se incluye la transcripcion GUARDADA con la propuesta. Antes solo vivia en
// memoria durante la peticion de generacion, asi que al editar R3 se quedaba sin
// fuente contra la cual comprobar la cita literal y el aviso desaparecia solo.
const [economia, ctx] = await Promise.all([cargarEconomia(id), contextoDe(id)]); const [economia, ctx] = await Promise.all([cargarEconomia(id), contextoDe(id)]);
const avisos = validarPropuesta(parsed.data as PropuestaConsultiva, economia, { const avisos = validarPropuesta(parsed.data as PropuestaConsultiva, economia, {
textoCliente: [ctx?.cot.observaciones || "", ctx?.notasPartidas || ""].join("\n"), textoCliente: [fila.transcripcion || "", ctx?.cot.observaciones || "", ctx?.notasPartidas || ""].join("\n"),
textoInterno: ctx?.cot.observacionesInternas || "", textoInterno: ctx?.cot.observacionesInternas || "",
}); });
+175
View File
@@ -172,6 +172,7 @@ export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: strin
const bloqueantes = avisos.filter((a) => a.severidad === "bloqueante"); const bloqueantes = avisos.filter((a) => a.severidad === "bloqueante");
const advertencias = avisos.filter((a) => a.severidad === "advertencia"); const advertencias = avisos.filter((a) => a.severidad === "advertencia");
const citaDestacada = propuesta?.hechos.citas.find((c) => c.id === propuesta.redaccion.citaDestacadaId);
if (cargando) { if (cargando) {
return ( return (
@@ -371,6 +372,167 @@ export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: strin
))} ))}
</div> </div>
</div> </div>
{/* Los grupos de abajo tambien se imprimen al cliente, asi que tambien
pueden disparar un aviso bloqueante. Si no fueran editables, un
bloqueante ahi no tendria mas salida que regenerar la propuesta. */}
{citaDestacada && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-1">
Cita destacada
</label>
<p className="text-xs text-muted mb-2">
Se imprime literal. Editala solo para quitar un dato sensible, nunca para
mejorarle la redaccion al cliente.
</p>
<textarea
value={citaDestacada.textoLiteral}
onChange={(e) =>
editar((p) => {
const c = p.hechos.citas.find((x) => x.id === p.redaccion.citaDestacadaId);
if (c) c.textoLiteral = e.target.value;
})
}
rows={2}
className={`${INPUT} mb-2`}
/>
<input
value={citaDestacada.quienLoDijo}
onChange={(e) =>
editar((p) => {
const c = p.hechos.citas.find((x) => x.id === p.redaccion.citaDestacadaId);
if (c) c.quienLoDijo = e.target.value;
})
}
placeholder="Quien lo dijo (usa el rol, no el nombre)"
className={INPUT}
/>
</div>
)}
{propuesta.diagnostico.valorProblema.dimensiones.length > 0 && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Lo que cuesta no resolverlo
</label>
<div className="space-y-2">
{propuesta.diagnostico.valorProblema.dimensiones.map((dim, i) => (
<div key={i} className="flex gap-2 items-start">
<span className="text-xs text-muted mt-2.5 shrink-0 w-20">{dim.tipo}</span>
<textarea
value={dim.descripcion}
onChange={(e) => editar((p) => { p.diagnostico.valorProblema.dimensiones[i].descripcion = e.target.value; })}
rows={2}
className={INPUT}
/>
</div>
))}
</div>
<textarea
value={propuesta.diagnostico.valorProblema.notaMetodologia}
onChange={(e) => editar((p) => { p.diagnostico.valorProblema.notaMetodologia = e.target.value; })}
rows={2}
placeholder="Nota de metodologia"
className={`${INPUT} mt-2`}
/>
</div>
)}
{propuesta.diagnostico.resultados.length > 0 && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Resultados a lograr
</label>
<div className="space-y-2">
{propuesta.diagnostico.resultados.map((r, i) => (
<div key={i} className="space-y-1">
<textarea
value={r.enunciado}
onChange={(e) => editar((p) => { p.diagnostico.resultados[i].enunciado = e.target.value; })}
rows={2}
className={INPUT}
/>
<div className="flex gap-2">
<input
value={r.metrica}
onChange={(e) => editar((p) => { p.diagnostico.resultados[i].metrica = e.target.value; })}
placeholder="Metrica"
className={INPUT}
/>
<input
value={r.periodoMedicion}
onChange={(e) => editar((p) => { p.diagnostico.resultados[i].periodoMedicion = e.target.value; })}
placeholder="Periodo"
className={INPUT}
/>
</div>
</div>
))}
</div>
</div>
)}
{(propuesta.hechos.materialesPendientes.length > 0 ||
propuesta.hechos.decisionesPendientes.length > 0) && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Que necesitamos de ustedes
</label>
<div className="space-y-2">
{propuesta.hechos.materialesPendientes.map((m, i) => (
<input
key={`mat-${i}`}
value={m.texto}
onChange={(e) => editar((p) => { p.hechos.materialesPendientes[i].texto = e.target.value; })}
className={INPUT}
/>
))}
{propuesta.hechos.decisionesPendientes.map((dd, i) => (
<div key={`dec-${i}`} className="flex gap-2">
<textarea
value={dd.texto}
onChange={(e) => editar((p) => { p.hechos.decisionesPendientes[i].texto = e.target.value; })}
rows={1}
className={INPUT}
/>
<input
value={dd.quienDecide}
onChange={(e) => editar((p) => { p.hechos.decisionesPendientes[i].quienDecide = e.target.value; })}
placeholder="Quien decide"
className={`${INPUT} max-w-[11rem] shrink-0`}
/>
</div>
))}
</div>
</div>
)}
{propuesta.redaccion.backlogEvolucion.length > 0 && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wide text-muted mb-2">
Registrado para mas adelante
</label>
<div className="space-y-2">
{propuesta.redaccion.backlogEvolucion.map((b, i) => (
<div key={i} className="flex gap-2">
<textarea
value={b.problema}
onChange={(e) => editar((p) => { p.redaccion.backlogEvolucion[i].problema = e.target.value; })}
rows={1}
className={INPUT}
/>
<input
value={b.momentoSugerido}
onChange={(e) => editar((p) => { p.redaccion.backlogEvolucion[i].momentoSugerido = e.target.value; })}
placeholder="Cuando"
className={`${INPUT} max-w-[11rem] shrink-0`}
/>
</div>
))}
</div>
</div>
)}
</div> </div>
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border"> <div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
@@ -383,6 +545,18 @@ export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: strin
{guardando ? "Guardando..." : sucio ? "Guardar cambios" : "Sin cambios"} {guardando ? "Guardando..." : sucio ? "Guardar cambios" : "Sin cambios"}
</button> </button>
{/* El servidor devuelve 409 si hay bloqueantes; aqui se refleja para no
invitar a un clic que va a fallar. La descarga del anexo interno
sigue disponible: es la que ayuda a entender que corregir. */}
{bloqueantes.length > 0 ? (
<span
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm text-muted bg-gray-50 cursor-not-allowed"
title="Resuelve los avisos bloqueantes y guarda para habilitar la descarga"
>
<Download className="w-4 h-4" />
Propuesta consultiva (PDF) bloqueada
</span>
) : (
<a <a
href={`/api/propuesta-ia/${cotizacionId}/pdf`} href={`/api/propuesta-ia/${cotizacionId}/pdf`}
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm hover:bg-gray-50" className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm hover:bg-gray-50"
@@ -390,6 +564,7 @@ export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: strin
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
Propuesta consultiva (PDF) Propuesta consultiva (PDF)
</a> </a>
)}
<a <a
href={`/api/propuesta-ia/${cotizacionId}/pdf?anexo=1`} href={`/api/propuesta-ia/${cotizacionId}/pdf?anexo=1`}