This commit is contained in:
Marcelo Dares
2026-04-29 01:15:50 +02:00
parent 65aaf9275e
commit ea23136288
172 changed files with 30358 additions and 353 deletions

View File

@@ -0,0 +1,612 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { type NormativeConfidence, type NormativeDocumentType as PrismaNormativeDocumentType, type NormativeRiskLevel, Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { requireAdminApiUser } from "@/lib/auth/admin";
import { isLikelyPdfUrl, parseLicitationDocumentLinks, pickPrimaryLicitationDocument, pickPrimaryLicitationPdfDocument } from "@/lib/licitations/documents";
import { type NormativeDocumentType } from "@/lib/normative-analysis/analyze";
import { analyzeNormativeTextWithAi } from "@/lib/normative-analysis/ai-analyze";
import { toNormativeHistoryView } from "@/lib/normative-analysis/history";
import { analyzePdf } from "@/lib/pdf/analyzePdf";
import { OcrFailedError, OcrUnavailableError, PdfEncryptedError, PdfNoTextDetectedError, PdfUnreadableError } from "@/lib/pdf/errors";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs";
const MAX_NORMATIVE_PDF_BYTES = 20 * 1024 * 1024;
const SOURCE_FETCH_TIMEOUT_MS = 45_000;
const STORAGE_ROOT = path.resolve(process.cwd(), "storage");
function isPdfFile(file: File) {
const extension = file.name.toLowerCase().endsWith(".pdf");
const mimeType = file.type === "application/pdf";
return extension || mimeType;
}
function hasPdfSignature(buffer: Buffer) {
return buffer.subarray(0, 5).toString("utf8") === "%PDF-";
}
function guessFileNameFromUrl(url: string) {
try {
const parsed = new URL(url);
const segments = parsed.pathname.split("/").filter(Boolean);
const lastSegment = segments.at(-1) ?? "";
const decoded = decodeURIComponent(lastSegment).trim();
if (!decoded) {
return "documento-principal.pdf";
}
return decoded.toLowerCase().endsWith(".pdf") ? decoded : `${decoded}.pdf`;
} catch {
return "documento-principal.pdf";
}
}
async function fetchSourcePdf(url: string) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), SOURCE_FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, {
method: "GET",
redirect: "follow",
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`SOURCE_FETCH_FAILED_${response.status}`);
}
const headerLength = response.headers.get("content-length");
const sizeHint = headerLength ? Number.parseInt(headerLength, 10) : 0;
if (Number.isFinite(sizeHint) && sizeHint > MAX_NORMATIVE_PDF_BYTES) {
throw new Error("SOURCE_PDF_TOO_LARGE");
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
if (!buffer.byteLength) {
throw new Error("SOURCE_PDF_EMPTY");
}
if (buffer.byteLength > MAX_NORMATIVE_PDF_BYTES) {
throw new Error("SOURCE_PDF_TOO_LARGE");
}
return buffer;
} finally {
clearTimeout(timer);
}
}
type LicitationSourcePdf = {
fileBuffer: Buffer;
fileName: string;
linkedLicitationId: string;
issuingEntity: string | null;
};
type SourcePdfCandidate = {
url: string;
fileName: string;
};
function buildSourcePdfCandidates(documentsRaw: unknown, rawSourceUrl: string | null) {
const sourceDocuments = parseLicitationDocumentLinks(documentsRaw);
const primaryPdf = pickPrimaryLicitationPdfDocument(sourceDocuments);
const primaryAny = pickPrimaryLicitationDocument(sourceDocuments);
const candidates: SourcePdfCandidate[] = [];
const seen = new Set<string>();
function pushCandidate(url: string | null | undefined, fileName: string | null | undefined) {
const normalizedUrl = typeof url === "string" ? url.trim() : "";
if (!normalizedUrl || seen.has(normalizedUrl)) {
return;
}
seen.add(normalizedUrl);
candidates.push({
url: normalizedUrl,
fileName: (fileName?.trim() || guessFileNameFromUrl(normalizedUrl)),
});
}
pushCandidate(primaryPdf?.url, primaryPdf?.name);
pushCandidate(primaryAny?.url, primaryAny?.name);
for (const document of sourceDocuments) {
pushCandidate(document.url, document.name);
}
if (rawSourceUrl) {
const normalizedSourceUrl = rawSourceUrl.trim();
const existingIndex = candidates.findIndex((item) => item.url === normalizedSourceUrl);
if (existingIndex >= 0) {
if (isLikelyPdfUrl(normalizedSourceUrl) && existingIndex > 0) {
const [entry] = candidates.splice(existingIndex, 1);
candidates.unshift(entry);
}
} else if (isLikelyPdfUrl(normalizedSourceUrl)) {
candidates.unshift({
url: normalizedSourceUrl,
fileName: guessFileNameFromUrl(normalizedSourceUrl),
});
} else {
pushCandidate(normalizedSourceUrl, guessFileNameFromUrl(normalizedSourceUrl));
}
}
return candidates;
}
async function resolveLicitationSourcePdf(sourceLicitationId: string, currentIssuingEntity: string | null): Promise<LicitationSourcePdf> {
const sourceLicitation = await prisma.licitation.findUnique({
where: { id: sourceLicitationId },
select: {
id: true,
supplierAwarded: true,
documents: true,
rawSourceUrl: true,
},
});
if (!sourceLicitation) {
throw new Error("SOURCE_LICITATION_NOT_FOUND");
}
const candidates = buildSourcePdfCandidates(sourceLicitation.documents, sourceLicitation.rawSourceUrl);
if (candidates.length === 0) {
throw new Error("SOURCE_LICITATION_NO_PDF");
}
let sawSourceTooLarge = false;
let sawSourceTimeout = false;
for (const candidate of candidates) {
try {
const fileBuffer = await fetchSourcePdf(candidate.url);
if (!hasPdfSignature(fileBuffer)) {
continue;
}
return {
fileBuffer,
fileName: candidate.fileName,
linkedLicitationId: sourceLicitation.id,
issuingEntity: currentIssuingEntity || sourceLicitation.supplierAwarded?.trim() || null,
};
} catch (error) {
if (error instanceof Error) {
if (error.message === "SOURCE_PDF_TOO_LARGE") {
sawSourceTooLarge = true;
}
if (error.name === "AbortError") {
sawSourceTimeout = true;
}
}
}
}
if (sawSourceTooLarge) {
throw new Error("SOURCE_LICITATION_PDF_TOO_LARGE");
}
if (sawSourceTimeout) {
throw new Error("SOURCE_LICITATION_PDF_TIMEOUT");
}
throw new Error("SOURCE_LICITATION_NO_VALID_PDF");
}
type ProposalDocumentSource = {
fileName: string;
filePath: string;
mimeType: string;
};
function isPdfProposalDocument(document: ProposalDocumentSource) {
return document.mimeType === "application/pdf" || document.fileName.toLowerCase().endsWith(".pdf");
}
function pickPrimaryProposalPdfDocument(documents: ProposalDocumentSource[]) {
return documents.find((document) => isPdfProposalDocument(document)) ?? null;
}
async function readStoredProposalPdf(filePath: string) {
const absolutePath = path.resolve(process.cwd(), filePath);
if (!absolutePath.startsWith(`${STORAGE_ROOT}${path.sep}`)) {
throw new Error("PROPOSAL_PDF_INVALID_PATH");
}
try {
const buffer = await readFile(absolutePath);
if (!buffer.byteLength) {
throw new Error("PROPOSAL_PDF_EMPTY");
}
if (buffer.byteLength > MAX_NORMATIVE_PDF_BYTES) {
throw new Error("PROPOSAL_PDF_TOO_LARGE");
}
return buffer;
} catch (error) {
if (
error &&
typeof error === "object" &&
"code" in error &&
(error as NodeJS.ErrnoException).code === "ENOENT"
) {
throw new Error("PROPOSAL_PDF_NOT_FOUND");
}
throw error;
}
}
function parseDocumentType(value: unknown): NormativeDocumentType {
if (typeof value !== "string") {
return "OTRO";
}
const normalized = value.trim().toUpperCase();
if (normalized === "BASES_LICITACION" || normalized === "CONVOCATORIA" || normalized === "ANEXOS" || normalized === "REGLAMENTO" || normalized === "LEY") {
return normalized;
}
return "OTRO";
}
function toPrismaDocumentType(value: NormativeDocumentType): PrismaNormativeDocumentType {
if (value === "BASES_LICITACION" || value === "CONVOCATORIA" || value === "REGLAMENTO" || value === "LEY") {
return value;
}
return "OTRO";
}
function toPrismaRiskLevel(value: "alto" | "medio" | "bajo"): NormativeRiskLevel {
if (value === "alto") {
return "ALTO";
}
if (value === "medio") {
return "MEDIO";
}
return "BAJO";
}
function toPrismaConfidence(value: "low" | "medium" | "high"): NormativeConfidence {
if (value === "high") {
return "HIGH";
}
if (value === "medium") {
return "MEDIUM";
}
return "LOW";
}
function mapAnalysisError(error: unknown) {
if (error instanceof PdfEncryptedError) {
return {
status: 422,
error: "El PDF esta protegido/encriptado. Sube una version sin bloqueo para extraer texto.",
code: error.code,
};
}
if (error instanceof PdfUnreadableError) {
return {
status: 422,
error: "No fue posible leer el PDF. Verifica que el archivo no este danado.",
code: error.code,
};
}
if (error instanceof OcrUnavailableError) {
return {
status: 503,
error: "No se detecto texto suficiente y OCRmyPDF no esta disponible.",
code: error.code,
};
}
if (error instanceof PdfNoTextDetectedError) {
return {
status: 422,
error: "No se detecto texto util en el PDF para analizar.",
code: error.code,
};
}
if (error instanceof OcrFailedError) {
return {
status: 422,
error: "No se detecto texto suficiente y el OCR fallo durante el procesamiento.",
code: error.code,
};
}
return {
status: 422,
error: "No fue posible analizar el PDF.",
code: "NORMATIVE_ANALYSIS_FAILED",
};
}
function isSchemaNotReadyError(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022");
}
export async function POST(request: Request) {
const user = await requireAdminApiUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const documentType = parseDocumentType(formData.get("documentType"));
const issuingEntityRaw = formData.get("issuingEntity");
let issuingEntity = typeof issuingEntityRaw === "string" ? issuingEntityRaw.trim() || null : null;
const sourceLicitationIdRaw = formData.get("sourceLicitationId");
const sourceLicitationId = typeof sourceLicitationIdRaw === "string" ? sourceLicitationIdRaw.trim() || null : null;
const sourceProposalIdRaw = formData.get("sourceProposalId");
const sourceProposalId = typeof sourceProposalIdRaw === "string" ? sourceProposalIdRaw.trim() || null : null;
const fileInput = formData.get("file");
const uploadedFile = fileInput instanceof File && fileInput.size > 0 ? fileInput : null;
const useSourceDocument = String(formData.get("useSourceDocument") ?? "") === "1";
try {
let fileName = "";
let fileBuffer: Buffer | null = null;
let linkedLicitationId: string | null = null;
if (uploadedFile) {
if (uploadedFile.size > MAX_NORMATIVE_PDF_BYTES) {
return NextResponse.json({ error: "El archivo excede el limite de 20MB." }, { status: 400 });
}
if (!isPdfFile(uploadedFile)) {
return NextResponse.json({ error: "Solo se permiten archivos PDF." }, { status: 400 });
}
fileBuffer = Buffer.from(await uploadedFile.arrayBuffer());
fileName = uploadedFile.name;
if (!hasPdfSignature(fileBuffer)) {
return NextResponse.json({ error: "El archivo no parece ser un PDF valido." }, { status: 400 });
}
} else if (sourceProposalId && useSourceDocument) {
const sourceProposal = await prisma.proposal.findFirst({
where: {
id: sourceProposalId,
userId: user.id,
},
select: {
id: true,
issuingEntity: true,
sourceLicitationId: true,
documents: {
orderBy: [{ createdAt: "desc" }],
select: {
fileName: true,
filePath: true,
mimeType: true,
},
},
},
});
if (!sourceProposal) {
return NextResponse.json({ error: "No se encontro la propuesta vinculada de Modulo 5." }, { status: 404 });
}
if (!issuingEntity) {
issuingEntity = sourceProposal.issuingEntity?.trim() || null;
}
linkedLicitationId = sourceProposal.sourceLicitationId ?? null;
const proposalPdf = pickPrimaryProposalPdfDocument(sourceProposal.documents);
if (proposalPdf) {
fileBuffer = await readStoredProposalPdf(proposalPdf.filePath);
fileName = proposalPdf.fileName;
if (!hasPdfSignature(fileBuffer)) {
return NextResponse.json({ error: "El PDF principal de Modulo 5 no tiene una firma valida." }, { status: 422 });
}
} else if (sourceProposal.sourceLicitationId) {
const licitationSource = await resolveLicitationSourcePdf(sourceProposal.sourceLicitationId, issuingEntity);
fileBuffer = licitationSource.fileBuffer;
fileName = licitationSource.fileName;
linkedLicitationId = licitationSource.linkedLicitationId;
issuingEntity = licitationSource.issuingEntity;
} else {
return NextResponse.json(
{ error: "La propuesta de Modulo 5 no tiene un PDF disponible para analisis automatico. Sube un PDF manualmente." },
{ status: 400 },
);
}
} else if (sourceLicitationId && useSourceDocument) {
const licitationSource = await resolveLicitationSourcePdf(sourceLicitationId, issuingEntity);
fileBuffer = licitationSource.fileBuffer;
fileName = licitationSource.fileName;
linkedLicitationId = licitationSource.linkedLicitationId;
issuingEntity = licitationSource.issuingEntity;
} else {
return NextResponse.json(
{ error: "Debes adjuntar un PDF o vincular una propuesta de Modulo 5 / oportunidad de Modulo 3 para usar su documento principal." },
{ status: 400 },
);
}
if (!fileBuffer) {
return NextResponse.json({ error: "No fue posible obtener un documento PDF para analizar." }, { status: 400 });
}
const analyzed = await analyzePdf(fileBuffer);
const aiAnalysis = await analyzeNormativeTextWithAi({
text: analyzed.text,
fileName,
documentType,
issuingEntity,
methodUsed: analyzed.methodUsed,
numPages: analyzed.numPages,
warnings: analyzed.warnings,
});
const result = aiAnalysis.result;
const combinedWarnings = [
...analyzed.warnings,
...aiAnalysis.warnings,
`Motor de analisis: ${aiAnalysis.engine}${aiAnalysis.model ? ` (${aiAnalysis.model})` : ""}`,
];
if (!linkedLicitationId && sourceLicitationId) {
const linkedLicitation = await prisma.licitation.findUnique({
where: { id: sourceLicitationId },
select: { id: true },
});
linkedLicitationId = linkedLicitation?.id ?? null;
}
if (!linkedLicitationId && sourceProposalId) {
const linkedProposal = await prisma.proposal.findFirst({
where: {
id: sourceProposalId,
userId: user.id,
},
select: {
sourceLicitationId: true,
},
});
linkedLicitationId = linkedProposal?.sourceLicitationId ?? null;
}
const created = await prisma.normativeAnalysisHistory.create({
data: {
userId: user.id,
sourceLicitationId: linkedLicitationId,
fileName,
documentType: toPrismaDocumentType(documentType),
issuingEntity,
methodUsed: analyzed.methodUsed === "ocr" ? "OCR" : "DIRECT",
numPages: analyzed.numPages,
warnings: combinedWarnings,
extractedChars: analyzed.text.length,
confidence: toPrismaConfidence(result.confidence),
viabilityScore: result.participationViability.score,
riskLevel: toPrismaRiskLevel(result.risk.level),
executiveSummary: result.executiveSummary,
result,
analyzedAt: new Date(),
},
select: {
id: true,
sourceLicitationId: true,
fileName: true,
documentType: true,
issuingEntity: true,
methodUsed: true,
numPages: true,
warnings: true,
extractedChars: true,
analyzedAt: true,
viabilityScore: true,
riskLevel: true,
confidence: true,
result: true,
},
});
return NextResponse.json({
ok: true,
item: toNormativeHistoryView(created),
});
} catch (error) {
if (isSchemaNotReadyError(error)) {
return NextResponse.json(
{ error: "La base de datos aun no tiene las tablas de Analisis Normativo. Ejecuta prisma migrate para continuar." },
{ status: 503 },
);
}
if (error instanceof Error) {
if (error.message === "SOURCE_LICITATION_NOT_FOUND") {
return NextResponse.json({ error: "No se encontro la oportunidad vinculada de Modulo 3." }, { status: 404 });
}
if (error.message === "SOURCE_LICITATION_NO_PDF") {
return NextResponse.json(
{ error: "La oportunidad de Modulo 3 no tiene un PDF principal disponible para analisis automatico." },
{ status: 400 },
);
}
if (error.message === "SOURCE_LICITATION_NO_VALID_PDF") {
return NextResponse.json(
{ error: "No se pudo obtener un PDF valido desde los enlaces de la oportunidad en Modulo 3." },
{ status: 422 },
);
}
if (error.message === "SOURCE_LICITATION_PDF_TOO_LARGE") {
return NextResponse.json({ error: "Los documentos vinculados de Modulo 3 exceden el limite de 20MB." }, { status: 400 });
}
if (error.message === "SOURCE_LICITATION_PDF_TIMEOUT") {
return NextResponse.json({ error: "Se excedio el tiempo al descargar documentos vinculados de Modulo 3." }, { status: 504 });
}
if (error.message === "SOURCE_LICITATION_INVALID_PDF") {
return NextResponse.json(
{ error: "El documento principal de Modulo 3 no tiene una firma PDF valida." },
{ status: 422 },
);
}
if (error.message === "PROPOSAL_PDF_INVALID_PATH") {
return NextResponse.json({ error: "El archivo de Modulo 5 no esta disponible para lectura segura." }, { status: 422 });
}
if (error.message === "PROPOSAL_PDF_NOT_FOUND") {
return NextResponse.json({ error: "No se encontro el PDF principal cargado en Modulo 5." }, { status: 422 });
}
if (error.message === "PROPOSAL_PDF_EMPTY") {
return NextResponse.json({ error: "El PDF principal de Modulo 5 esta vacio." }, { status: 422 });
}
if (error.message === "PROPOSAL_PDF_TOO_LARGE") {
return NextResponse.json({ error: "El PDF principal de Modulo 5 excede el limite de 20MB." }, { status: 400 });
}
if (error.name === "AbortError") {
return NextResponse.json({ error: "Se excedio el tiempo al descargar el PDF principal de Modulo 3." }, { status: 504 });
}
if (error.message === "SOURCE_PDF_TOO_LARGE") {
return NextResponse.json({ error: "El PDF principal de Modulo 3 excede el limite de 20MB." }, { status: 400 });
}
if (error.message === "SOURCE_PDF_EMPTY") {
return NextResponse.json({ error: "El PDF principal de Modulo 3 esta vacio." }, { status: 422 });
}
if (error.message.startsWith("SOURCE_FETCH_FAILED_")) {
return NextResponse.json({ error: "No fue posible descargar el PDF principal desde la fuente de Modulo 3." }, { status: 422 });
}
}
const mapped = mapAnalysisError(error);
return NextResponse.json({ error: mapped.error, code: mapped.code }, { status: mapped.status });
}
}