|
| 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