Skip to content

Commit f33df73

Browse files
Cespialclaude
andcommitted
feat: add real-time pre-evaluation for municipality wizard
Allow municipio users to run the evaluation rubric against their current form data without persisting results, so they can iterate and improve before formal submission. Batches LLM calls per etapa for speed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 294015c commit f33df73

2 files changed

Lines changed: 590 additions & 3 deletions

File tree

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import { NextResponse } from "next/server";
2+
import { createClient } from "@/lib/supabase/server";
3+
import { getProfile } from "@/lib/auth";
4+
import { createLlmAdapter } from "@/lib/ai/adapter";
5+
import type {
6+
Submission,
7+
LegacyRubric as Rubric,
8+
RubricCriterio,
9+
MgaTemplate,
10+
MgaEtapa,
11+
EvaluationScore,
12+
} from "@/lib/types/database";
13+
14+
interface EtapaResult {
15+
etapa_id: string;
16+
etapa_nombre: string;
17+
score: number;
18+
scores: (EvaluationScore & { recomendacion: string | null })[];
19+
}
20+
21+
export async function POST(request: Request) {
22+
const startTime = Date.now();
23+
24+
// 1. Auth check — accepts municipio_user
25+
const profile = await getProfile();
26+
if (!profile || profile.role !== "municipio_user") {
27+
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
28+
}
29+
30+
// 2. Parse request
31+
let body: { submission_id: string; convocatoria_id: string };
32+
try {
33+
body = await request.json();
34+
} catch {
35+
return NextResponse.json({ error: "Request inválido" }, { status: 400 });
36+
}
37+
38+
if (!body.submission_id || !body.convocatoria_id) {
39+
return NextResponse.json(
40+
{ error: "submission_id y convocatoria_id requeridos" },
41+
{ status: 400 },
42+
);
43+
}
44+
45+
const supabase = await createClient();
46+
47+
// 3. Fetch submission and verify ownership via municipio_id
48+
const { data: sub } = await supabase
49+
.from("submissions")
50+
.select("*")
51+
.eq("id", body.submission_id)
52+
.eq("convocatoria_id", body.convocatoria_id)
53+
.single();
54+
55+
if (!sub) {
56+
return NextResponse.json(
57+
{ error: "Submission no encontrada" },
58+
{ status: 404 },
59+
);
60+
}
61+
const submission = sub as Submission;
62+
63+
if (submission.municipio_id !== profile.municipio_id) {
64+
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
65+
}
66+
67+
// 4. Fetch rubric
68+
const { data: rub } = await supabase
69+
.from("rubrics")
70+
.select("*")
71+
.eq("convocatoria_id", body.convocatoria_id)
72+
.single();
73+
74+
if (!rub) {
75+
return NextResponse.json(
76+
{ error: "No hay rúbrica definida para esta convocatoria" },
77+
{ status: 404 },
78+
);
79+
}
80+
const rubric = rub as Rubric;
81+
82+
// 5. Fetch MGA template
83+
const { data: tmpl } = await supabase
84+
.from("mga_templates")
85+
.select("*")
86+
.eq("convocatoria_id", body.convocatoria_id)
87+
.single();
88+
89+
if (!tmpl) {
90+
return NextResponse.json(
91+
{ error: "Plantilla MGA no encontrada" },
92+
{ status: 404 },
93+
);
94+
}
95+
const template = tmpl as MgaTemplate;
96+
97+
// 6. For EACH etapa with rubric criteria, evaluate in batch (1 LLM call per etapa)
98+
const etapas: EtapaResult[] = [];
99+
const allRecomendaciones: string[] = [];
100+
let llmModel = "unknown";
101+
102+
try {
103+
const adapter = createLlmAdapter();
104+
105+
const systemPrompt = `Eres un evaluador experto de proyectos MGA (Metodología General Ajustada) para inversión pública en Colombia.
106+
107+
Tu tarea es evaluar TODAS las respuestas de un municipio para una etapa completa del proyecto MGA, usando la rúbrica proporcionada.
108+
109+
Responde SIEMPRE en formato JSON válido con esta estructura exacta:
110+
{
111+
"criterios": [
112+
{
113+
"campo_id": "<id del campo evaluado>",
114+
"score": <número del nivel asignado>,
115+
"justificacion": "<explicación breve de por qué se asignó este score>",
116+
"recomendacion": "<recomendación específica para mejorar, o null si el score es máximo>"
117+
}
118+
]
119+
}
120+
121+
Evalúa TODOS los criterios proporcionados. Se preciso y constructivo en las recomendaciones.`;
122+
123+
for (const etapa of template.etapas_json) {
124+
const etapaCampoIds = new Set(etapa.campos.map((c) => c.id));
125+
const relevantCriteria = rubric.criterios_json.filter(
126+
(c: RubricCriterio) => etapaCampoIds.has(c.campo_id),
127+
);
128+
129+
if (relevantCriteria.length === 0) continue;
130+
131+
// Build batch prompt with ALL criteria for this etapa
132+
const criteriosText = relevantCriteria
133+
.map((criterio: RubricCriterio) => {
134+
const campo = etapa.campos.find((c) => c.id === criterio.campo_id);
135+
const campoValue = submission.data_json[criterio.campo_id] ?? "";
136+
const campoNombre = campo?.nombre ?? criterio.campo_id;
137+
138+
const nivelesText = criterio.niveles
139+
.map(
140+
(n) => ` - Score ${n.score} (${n.label}): ${n.descripcion}`,
141+
)
142+
.join("\n");
143+
144+
return `<criterio campo_id="${criterio.campo_id}">
145+
Campo: ${campoNombre}
146+
Descripción del criterio: ${criterio.descripcion}
147+
Peso: ${criterio.peso}
148+
149+
Niveles de evaluación:
150+
${nivelesText}
151+
152+
Respuesta del municipio:
153+
${campoValue.trim() || "(Campo vacío — el municipio no ha respondido)"}
154+
</criterio>`;
155+
})
156+
.join("\n\n");
157+
158+
const userPrompt = `Evalúa la siguiente etapa "${etapa.nombre}" con ${relevantCriteria.length} criterios.
159+
160+
${criteriosText}
161+
162+
Evalúa CADA criterio según sus niveles definidos. Responde ÚNICAMENTE con el JSON especificado.`;
163+
164+
const llmResponse = await adapter.chat([
165+
{ role: "system", content: systemPrompt },
166+
{ role: "user", content: userPrompt },
167+
]);
168+
llmModel = llmResponse.model;
169+
170+
let parsed: { criterios: Array<{ campo_id: string; score: number; justificacion: string; recomendacion: string | null }> };
171+
try {
172+
parsed = JSON.parse(llmResponse.content);
173+
} catch {
174+
// Fallback: assign score 1 to all criteria in this etapa
175+
parsed = {
176+
criterios: relevantCriteria.map((c: RubricCriterio) => ({
177+
campo_id: c.campo_id,
178+
score: 1,
179+
justificacion: "Error al evaluar este criterio",
180+
recomendacion: null,
181+
})),
182+
};
183+
}
184+
185+
// Map parsed results to scores
186+
const etapaScores: (EvaluationScore & { recomendacion: string | null })[] = [];
187+
for (const criterio of relevantCriteria) {
188+
const campo = etapa.campos.find((c) => c.id === criterio.campo_id);
189+
const campoNombre = campo?.nombre ?? criterio.campo_id;
190+
const maxScore = Math.max(...criterio.niveles.map((n) => n.score));
191+
const parsedCriterio = parsed.criterios?.find(
192+
(p) => p.campo_id === criterio.campo_id,
193+
);
194+
195+
const score = parsedCriterio?.score ?? 1;
196+
const justificacion = parsedCriterio?.justificacion ?? "Sin evaluación";
197+
const recomendacion = parsedCriterio?.recomendacion ?? null;
198+
199+
etapaScores.push({
200+
campo_id: criterio.campo_id,
201+
campo_nombre: campoNombre,
202+
score,
203+
max_score: maxScore,
204+
justificacion,
205+
recomendacion,
206+
});
207+
208+
if (recomendacion) {
209+
allRecomendaciones.push(`[${campoNombre}] ${recomendacion}`);
210+
}
211+
}
212+
213+
// Calculate weighted score for this etapa
214+
const totalWeight = relevantCriteria.reduce(
215+
(sum: number, c: RubricCriterio) => sum + c.peso,
216+
0,
217+
);
218+
const weightedScore = relevantCriteria.reduce(
219+
(sum: number, c: RubricCriterio) => {
220+
const maxScore = Math.max(...c.niveles.map((n) => n.score));
221+
const campoScore = etapaScores.find((s) => s.campo_id === c.campo_id)?.score ?? 0;
222+
const normalizedScore = maxScore > 0 ? campoScore / maxScore : 0;
223+
return sum + normalizedScore * c.peso;
224+
},
225+
0,
226+
);
227+
const etapaScore = totalWeight > 0
228+
? Math.round((weightedScore / totalWeight) * 100 * 100) / 100
229+
: 0;
230+
231+
etapas.push({
232+
etapa_id: etapa.id,
233+
etapa_nombre: etapa.nombre,
234+
score: etapaScore,
235+
scores: etapaScores,
236+
});
237+
}
238+
} catch (err) {
239+
const message =
240+
err instanceof Error ? err.message : "Error al pre-evaluar";
241+
return NextResponse.json({ error: message }, { status: 502 });
242+
}
243+
244+
// 7. Calculate total score across all etapas
245+
const allCriteria = rubric.criterios_json;
246+
const totalWeight = allCriteria.reduce(
247+
(sum: number, c: RubricCriterio) => sum + c.peso,
248+
0,
249+
);
250+
const allScores = etapas.flatMap((e) => e.scores);
251+
const totalWeightedScore = allCriteria.reduce(
252+
(sum: number, c: RubricCriterio) => {
253+
const scoreEntry = allScores.find((s) => s.campo_id === c.campo_id);
254+
if (!scoreEntry) return sum;
255+
const normalizedScore = scoreEntry.max_score > 0 ? scoreEntry.score / scoreEntry.max_score : 0;
256+
return sum + normalizedScore * c.peso;
257+
},
258+
0,
259+
);
260+
const totalScore = totalWeight > 0
261+
? Math.round((totalWeightedScore / totalWeight) * 100 * 100) / 100
262+
: 0;
263+
264+
// 8. Generate executive summary (1 extra LLM call)
265+
let resumen = `Tu proyecto sacaría ~${Math.round(totalScore)}/100.`;
266+
try {
267+
const adapter = createLlmAdapter();
268+
const summaryResponse = await adapter.chat([
269+
{
270+
role: "system",
271+
content: "Eres un asesor de proyectos MGA. Genera un resumen ejecutivo breve (2-3 oraciones) del estado del proyecto basado en los scores. Sé constructivo y específico. Responde solo con el texto del resumen, sin formato JSON.",
272+
},
273+
{
274+
role: "user",
275+
content: `Score total: ${totalScore}/100\n\nScores por etapa:\n${etapas.map((e) => `- ${e.etapa_nombre}: ${e.score}/100`).join("\n")}\n\nRecomendaciones:\n${allRecomendaciones.map((r, i) => `${i + 1}. ${r}`).join("\n")}`,
276+
},
277+
]);
278+
resumen = summaryResponse.content;
279+
} catch {
280+
// Keep default summary if this call fails
281+
}
282+
283+
const durationMs = Date.now() - startTime;
284+
285+
// 9. Return results WITHOUT persisting to any table
286+
return NextResponse.json({
287+
total_score: totalScore,
288+
etapas,
289+
recomendaciones_generales: allRecomendaciones,
290+
resumen,
291+
_meta: { model: llmModel, duration_ms: durationMs },
292+
});
293+
}

0 commit comments

Comments
 (0)