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:
co-authored by
Claude Opus 5
parent
434a427f70
commit
0c127b44c4
@@ -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;
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,13 +545,26 @@ export default function PropuestaIAPanel({ cotizacionId }: { cotizacionId: strin
|
|||||||
{guardando ? "Guardando..." : sucio ? "Guardar cambios" : "Sin cambios"}
|
{guardando ? "Guardando..." : sucio ? "Guardar cambios" : "Sin cambios"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<a
|
{/* El servidor devuelve 409 si hay bloqueantes; aqui se refleja para no
|
||||||
href={`/api/propuesta-ia/${cotizacionId}/pdf`}
|
invitar a un clic que va a fallar. La descarga del anexo interno
|
||||||
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm hover:bg-gray-50"
|
sigue disponible: es la que ayuda a entender que corregir. */}
|
||||||
>
|
{bloqueantes.length > 0 ? (
|
||||||
<Download className="w-4 h-4" />
|
<span
|
||||||
Propuesta consultiva (PDF)
|
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"
|
||||||
</a>
|
title="Resuelve los avisos bloqueantes y guarda para habilitar la descarga"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Propuesta consultiva (PDF) — bloqueada
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Propuesta consultiva (PDF)
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={`/api/propuesta-ia/${cotizacionId}/pdf?anexo=1`}
|
href={`/api/propuesta-ia/${cotizacionId}/pdf?anexo=1`}
|
||||||
|
|||||||
Reference in New Issue
Block a user