Spaces:
Runtime error
Runtime error
| from fastapi import FastAPI, UploadFile, File, HTTPException, Form, BackgroundTasks, Header, Depends | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from typing import List | |
| from pydantic import BaseModel | |
| import tempfile | |
| import os | |
| try: | |
| import truststore | |
| truststore.inject_into_ssl() | |
| except Exception: | |
| pass | |
| try: | |
| import certifi | |
| CERTIFI_CA_BUNDLE = certifi.where() | |
| os.environ.setdefault("SSL_CERT_FILE", CERTIFI_CA_BUNDLE) | |
| os.environ.setdefault("REQUESTS_CA_BUNDLE", CERTIFI_CA_BUNDLE) | |
| os.environ.setdefault("GRPC_DEFAULT_SSL_ROOTS_FILE_PATH", CERTIFI_CA_BUNDLE) | |
| except Exception: | |
| pass | |
| import json | |
| import imaplib | |
| import email | |
| from email.header import decode_header | |
| import re | |
| import hashlib | |
| import time | |
| import threading | |
| from datetime import datetime | |
| import pandas as pd | |
| import io | |
| from urllib.parse import urljoin | |
| from dotenv import load_dotenv | |
| import database as db | |
| import logging | |
| from logging.handlers import RotatingFileHandler | |
| from bs4 import BeautifulSoup | |
| import sys | |
| import asyncio | |
| import crypto | |
| if sys.platform == "win32": | |
| asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) | |
| load_dotenv() | |
| GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash") | |
| GEMINI_FALLBACK_MODELS = [ | |
| model.strip() | |
| for model in os.getenv("GEMINI_FALLBACK_MODELS", "gemini-2.5-flash-lite").split(",") | |
| if model.strip() | |
| ] | |
| def gemini_model_candidates(): | |
| models = [GEMINI_MODEL, *GEMINI_FALLBACK_MODELS] | |
| unique = [] | |
| for model in models: | |
| if model and model not in unique: | |
| unique.append(model) | |
| return unique | |
| def is_retryable_gemini_error(exc): | |
| text = str(exc or "").lower() | |
| return any( | |
| marker in text | |
| for marker in [ | |
| "503", | |
| "unavailable", | |
| "high demand", | |
| "temporarily", | |
| "overloaded", | |
| "resource exhausted", | |
| ] | |
| ) | |
| def get_gemini_client(api_key): | |
| from google import genai | |
| return genai.Client(api_key=str(api_key or "").strip()) | |
| def gemini_generate_with_fallback(client, contents, response_mime_type=None): | |
| from google.genai import types | |
| config = None | |
| if response_mime_type: | |
| config = types.GenerateContentConfig(response_mime_type=response_mime_type) | |
| last_error = None | |
| for idx, model in enumerate(gemini_model_candidates()): | |
| kwargs = {"model": model, "contents": contents} | |
| if config: | |
| kwargs["config"] = config | |
| try: | |
| response = client.models.generate_content(**kwargs) | |
| return response, model | |
| except Exception as exc: | |
| last_error = exc | |
| if not is_retryable_gemini_error(exc) or idx == len(gemini_model_candidates()) - 1: | |
| raise | |
| logger.warning(f"Gemini modelo {model} no disponible temporalmente. Intentando fallback.") | |
| time.sleep(1 + idx) | |
| raise last_error | |
| def gemini_generate_content(api_key, contents, response_mime_type=None): | |
| client = get_gemini_client(api_key) | |
| response, _ = gemini_generate_with_fallback(client, contents, response_mime_type=response_mime_type) | |
| return response | |
| def gemini_upload_file(client, path): | |
| return client.files.upload(file=path) | |
| def gemini_delete_file(client, uploaded_file): | |
| try: | |
| file_name = getattr(uploaded_file, "name", None) | |
| if file_name: | |
| client.files.delete(name=file_name) | |
| except Exception as e: | |
| logger.warning(f"No se pudo borrar archivo temporal de Gemini: {e}") | |
| # --- 1. LOGGING CON ROTACIÓN (max 2MB, 3 backups) --- | |
| log_handler = RotatingFileHandler('backend.log', maxBytes=2*1024*1024, backupCount=3, encoding='utf-8') | |
| log_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')) | |
| logger = logging.getLogger(__name__) | |
| logger.setLevel(logging.INFO) | |
| logger.addHandler(log_handler) | |
| # --- 2. SEGURIDAD Y CIFRADO --- | |
| # Se utiliza el módulo centralizado `crypto.py` | |
| INTERNAL_API_TOKEN = os.getenv("INTERNAL_API_TOKEN", "default-dev-token") | |
| RADAR_AUTO_SCAN_ENABLED = os.getenv("RADAR_AUTO_SCAN_ENABLED", "true").strip().lower() in ["1", "true", "yes", "si", "sí", "on"] | |
| RADAR_AUTO_SCAN_INTERVAL_MINUTES = max(5, int(os.getenv("RADAR_AUTO_SCAN_INTERVAL_MINUTES", "25") or 25)) | |
| RADAR_AUTO_SCAN_ON_STARTUP = os.getenv("RADAR_AUTO_SCAN_ON_STARTUP", "false").strip().lower() in ["1", "true", "yes", "si", "sí", "on"] | |
| RADAR_SCHEDULER_STATE = { | |
| "enabled": RADAR_AUTO_SCAN_ENABLED, | |
| "interval_minutes": RADAR_AUTO_SCAN_INTERVAL_MINUTES, | |
| "running": False, | |
| "last_started": None, | |
| "last_finished": None, | |
| "last_result": None, | |
| "last_error": None, | |
| "next_run_at": None, | |
| } | |
| _radar_scheduler_stop = threading.Event() | |
| _radar_scan_lock = threading.Lock() | |
| _radar_scheduler_thread = None | |
| def verify_internal_token(x_internal_token: str = Header(None)): | |
| if x_internal_token != INTERNAL_API_TOKEN: | |
| raise HTTPException(status_code=403, detail="Acceso denegado: Token interno inválido.") | |
| return x_internal_token | |
| # --- 3. APP FASTAPI --- | |
| app = FastAPI(title="Proyelec Core API v6.1") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["http://localhost:8501", "http://127.0.0.1:8501", "http://localhost:3000"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| def run_radar_auto_scan(source="scheduler"): | |
| """Ejecuta el escaneo del Radar SLI de forma segura para jobs automáticos.""" | |
| if not _radar_scan_lock.acquire(blocking=False): | |
| logger.info("[RADAR AUTO] Escaneo omitido: ya hay un escaneo en curso.") | |
| return {"status": "skipped", "reason": "scan_already_running"} | |
| RADAR_SCHEDULER_STATE["running"] = True | |
| RADAR_SCHEDULER_STATE["last_started"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
| RADAR_SCHEDULER_STATE["last_error"] = None | |
| try: | |
| import sli_scraper | |
| logger.info(f"[RADAR AUTO] Iniciando escaneo SLI ({source}).") | |
| result = sli_scraper.ejecutar_radar_detallado(db_module=db) | |
| RADAR_SCHEDULER_STATE["last_result"] = result | |
| RADAR_SCHEDULER_STATE["last_finished"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
| logger.info(f"[RADAR AUTO] Escaneo completado: {result}") | |
| return {"status": "success", "result": result} | |
| except Exception as exc: | |
| error_text = str(exc) | |
| RADAR_SCHEDULER_STATE["last_error"] = error_text | |
| RADAR_SCHEDULER_STATE["last_finished"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
| try: | |
| db.registrar_escaneo_radar(0, 0, f"Auto radar error: {error_text}") | |
| except Exception: | |
| logger.exception("[RADAR AUTO] No se pudo registrar error de escaneo.") | |
| logger.exception(f"[RADAR AUTO] Error en escaneo: {error_text}") | |
| return {"status": "error", "error": error_text} | |
| finally: | |
| RADAR_SCHEDULER_STATE["running"] = False | |
| _radar_scan_lock.release() | |
| def radar_scheduler_loop(): | |
| interval_seconds = RADAR_AUTO_SCAN_INTERVAL_MINUTES * 60 | |
| if RADAR_AUTO_SCAN_ON_STARTUP: | |
| run_radar_auto_scan(source="startup") | |
| while not _radar_scheduler_stop.is_set(): | |
| next_run_ts = time.time() + interval_seconds | |
| RADAR_SCHEDULER_STATE["next_run_at"] = datetime.fromtimestamp(next_run_ts).strftime("%Y-%m-%d %H:%M:%S") | |
| if _radar_scheduler_stop.wait(interval_seconds): | |
| break | |
| run_radar_auto_scan() | |
| def start_radar_scheduler(): | |
| global _radar_scheduler_thread | |
| if not RADAR_AUTO_SCAN_ENABLED: | |
| logger.info("[RADAR AUTO] Scheduler desactivado por variable de entorno.") | |
| return | |
| if _radar_scheduler_thread and _radar_scheduler_thread.is_alive(): | |
| return | |
| _radar_scheduler_stop.clear() | |
| _radar_scheduler_thread = threading.Thread(target=radar_scheduler_loop, name="radar-sli-scheduler", daemon=True) | |
| _radar_scheduler_thread.start() | |
| logger.info(f"[RADAR AUTO] Scheduler iniciado cada {RADAR_AUTO_SCAN_INTERVAL_MINUTES} minutos.") | |
| def stop_radar_scheduler(): | |
| _radar_scheduler_stop.set() | |
| logger.info("[RADAR AUTO] Scheduler detenido.") | |
| def radar_scheduler_status(_token: str = Depends(verify_internal_token)): | |
| return RADAR_SCHEDULER_STATE | |
| def radar_scan_now(_token: str = Depends(verify_internal_token)): | |
| return run_radar_auto_scan(source="manual_api") | |
| def estado(): | |
| return {"status": "Online", "engine": "Proyelec Core v6.1 — Token-Optimized"} | |
| # --- 4. PROMPT ANALISTA DE PLIEGOS (Multi-documento) --- | |
| PROMPT_ANALISTA_MULTI = """ | |
| Eres un Analista Senior de Procura. Analiza TODO el conjunto de documentos proporcionados (Pliego Principal y Anexos Técnicos). | |
| Cruza la información de todos los documentos para obtener descripciones técnicas exactas. | |
| REGLA DE ORO ESTRICTA: | |
| Tu análisis debe basarse ÚNICA Y EXCLUSIVAMENTE en el texto, tablas y datos contenidos en los documentos adjuntos. | |
| No busques información en internet, no deduzcas, no asumas y no uses conocimiento externo sobre leyes, fabricantes, estándares o prácticas comerciales. | |
| Si un dato no aparece explícitamente en los documentos, devuelve exactamente: "No especificado en los documentos adjuntos". | |
| IMPORTANTE: El campo 'ficha_tecnica_completa' debe ser redactado como una Checklist técnica. Lista todos los requerimientos, materiales, normativas y entregables que exige la ACP para ese renglón usando el formato '- [ ] Requisito'. | |
| IMPORTANTE: No confundas 'ficha_tecnica_completa' con 'requiere_ficha_tecnica'. | |
| - 'ficha_tecnica_completa' resume las especificaciones técnicas del producto/renglón. | |
| - 'requiere_ficha_tecnica' solo indica si el oferente debe ENTREGAR/ADJUNTAR un documento técnico en la oferta. | |
| Extrae también controles técnicos críticos para decidir participación: | |
| - restriccion_marca_proveedor: Si el pliego exige o restringe explícitamente a una marca, fabricante, suplidor, proponente o distribuidor autorizado que no sea la ACP, descríbelo aquí (ej. 'Solo se acepta marca X' o 'Solo distribuidor autorizado Y'). Si no hay restricciones, devuelve null. | |
| - permite_equivalentes: true si el pliego permite marcas/modelos equivalentes, alternativas técnicas o "igual o superior"; false si exige una marca/modelo exacto sin alternativas; null si no se puede determinar. | |
| - permite_carta_obsolescencia: true si el pliego (usualmente en el Inciso 9 o similar) permite entregar actualizaciones de números de parte obsoletos acompañadas de una carta del fabricante, false si no. | |
| - evidencia_restricciones: cita corta o referencia de la cláusula/inciso donde se detectó restricción, equivalentes, carta de fabricante u obsolescencia. Si no aplica, devuelve "". | |
| - riesgo_tecnico_global: "Bajo", "Medio" o "Alto" según restricciones de marca/proveedor, falta de equivalentes, fichas técnicas obligatorias y riesgo de obsolescencia. | |
| - propuesta_tecnica_requerida: "Si" si el pliego exige adjuntar propuesta técnica; "No" si explícitamente no la exige; "No especificado en los documentos adjuntos" si no se menciona. Si aplica solo a ciertos renglones, devuelve "Si (aplica solo a líneas X, Y)". | |
| - propuesta_tecnica_aplica_renglones: lista de renglones/líneas donde aplica la propuesta técnica. Si aplica globalmente, usa ["Todos"]. Si no se especifica, []. | |
| - evidencia_propuesta_tecnica: cita corta exacta donde se pide la propuesta técnica y se indica a qué líneas aplica. | |
| - persona_encargada_licitacion: nombre del agente de compras, contacto, responsable o persona encargada de la licitación si aparece en el documento. Si no aparece, devuelve "No especificado en los documentos adjuntos". | |
| - correo_encargado_licitacion: correo electrónico del contacto de la licitación si aparece. Si no aparece, devuelve "No especificado en los documentos adjuntos". | |
| - telefono_encargado_licitacion: teléfono del contacto de la licitación si aparece. Si no aparece, devuelve "No especificado en los documentos adjuntos". | |
| - requiere_presencia_local: true si el pliego exige explícitamente empresa local, presencia local, oficina local, representante local o condición similar para participar; false si no se detecta ese requisito en los documentos o el pliego permite participar sin esa condición; null si el texto es contradictorio o no se puede determinar. | |
| - evidencia_presencia_local: cita corta exacta donde se detecta el requisito de presencia local o la ausencia/permiso relevante. Si no hay evidencia textual clara, devuelve "". | |
| - empresa_recomendada_participacion: "EP" si requiere_presencia_local es true; "Proyelec" si requiere_presencia_local es false; "Validar" si requiere_presencia_local es null. | |
| Para cada renglón extrae: | |
| - codigo_articulo: código ACP del renglón ÚNICAMENTE si aparece con formato de 3 letras, guion, 3 letras, guion y 5 números, por ejemplo ABC-DEF-12345. No incluyas descripciones, números de parte, marcas ni texto adicional. Si el código no aparece con ese formato exacto, devuelve "". | |
| - requiere_propuesta_tecnica: true si la propuesta técnica aplica a ese renglón/línea; false si no aplica. | |
| - requiere_ficha_tecnica: true SOLO si el pliego exige entregar/presentar/adjuntar ficha técnica, catálogo, datasheet, plano, certificado, muestra, manual, ficha de seguridad o submittal técnico junto con la oferta/propuesta. false si el texto solo describe especificaciones técnicas, marca, modelo, número de parte o cumplimiento técnico sin pedir un documento entregable. | |
| - marca_modelo_requerido: marca, fabricante, modelo o número de parte exigido para ese renglón. Si no hay, null. | |
| - acepta_equivalente: true si ese renglón acepta equivalente; false si no acepta; null si no se puede determinar. | |
| - posible_obsolescencia: true si el texto menciona número de parte obsoleto, reemplazo, actualización, discontinued, superseded, obsolete o carta del fabricante para ese renglón; false si no. | |
| - evidencia_tecnica: cita corta o inciso relevante para ficha técnica, marca/modelo, equivalentes u obsolescencia de ese renglón. | |
| Regla especial para 'requiere_ficha_tecnica': | |
| - NO marques true solo porque exista una marca restringida. | |
| - NO marques true solo porque exista número de parte, modelo, material, dimensión, norma o especificación técnica. | |
| - Marca true únicamente si el documento exige un ENTREGABLE DOCUMENTAL como "presentar ficha técnica", "adjuntar catálogo", "entregar datasheet", "certificado", "manual", "carta del fabricante", "plano", "muestra" o frase equivalente. | |
| - Si la evidencia no contiene una exigencia documental clara, requiere_ficha_tecnica debe ser false. | |
| Regla especial para 'requiere_propuesta_tecnica': | |
| - Si el documento dice "Se requiere propuesta técnica" y luego limita con frases como "(APLICA SOLO PARA LAS LÍNEAS 3 Y 4)", marca true únicamente en esos renglones. | |
| - No confundas "propuesta técnica" con "marca restringida". Una licitación puede permitir alternativas técnicas y aun así exigir propuesta técnica para comprobar cumplimiento. | |
| - Si la propuesta técnica debe incluir marca/modelo/dimensiones para líneas específicas, eso es requiere_propuesta_tecnica=true para esas líneas, no necesariamente requiere_ficha_tecnica=true. | |
| Responde ÚNICAMENTE con el siguiente JSON estricto, sin texto adicional: | |
| {"condiciones_generales": {"numero_licitacion": "", "tiempo_de_entrega_global": "", "garantia_exigida": "", "lugar_de_entrega": "", "validez_de_la_oferta": "", "persona_encargada_licitacion": "No especificado en los documentos adjuntos", "correo_encargado_licitacion": "No especificado en los documentos adjuntos", "telefono_encargado_licitacion": "No especificado en los documentos adjuntos", "requiere_presencia_local": null, "evidencia_presencia_local": "", "empresa_recomendada_participacion": "Validar", "propuesta_tecnica_requerida": "Si/No/No especificado en los documentos adjuntos", "propuesta_tecnica_aplica_renglones": [], "evidencia_propuesta_tecnica": "", "restriccion_marca_proveedor": null, "permite_equivalentes": null, "permite_carta_obsolescencia": false, "evidencia_restricciones": "", "riesgo_tecnico_global": "Bajo"}, | |
| "items": [{"renglon": "", "codigo_articulo": "", "cantidad": 0, "unidad_de_medida": "", "ficha_tecnica_completa": "", "termino_de_busqueda_corto": "", "requiere_propuesta_tecnica": false, "requiere_ficha_tecnica": false, "marca_modelo_requerido": null, "acepta_equivalente": null, "posible_obsolescencia": false, "evidencia_tecnica": ""}]} | |
| """ | |
| DOCUMENTAL_KEYWORDS = [ | |
| "presentar ficha", "adjuntar ficha", "entregar ficha", "ficha tecnica", "ficha técnica", | |
| "catalogo", "catálogo", "datasheet", "data sheet", "certificado", "certificacion", | |
| "certificación", "manual", "carta del fabricante", "carta de fabricante", "plano", | |
| "muestra", "submittal", "hoja de seguridad", "ficha de seguridad", "msds", | |
| ] | |
| ACP_CODE_RE = re.compile(r"\b([A-Z]{3})-([A-Z]{3})-(\d{5})\b", re.IGNORECASE) | |
| def _normalize_acp_code(value): | |
| text = str(value or "").strip().upper() | |
| match = ACP_CODE_RE.search(text) | |
| if not match: | |
| return "" | |
| return f"{match.group(1).upper()}-{match.group(2).upper()}-{match.group(3)}" | |
| def _parse_rows_from_scope_text(value): | |
| rows = set() | |
| if value is None: | |
| return rows, False | |
| if isinstance(value, (list, tuple, set)): | |
| all_rows = False | |
| for item in value: | |
| item_text = str(item or "").strip().lower() | |
| if item_text in ["todos", "todas", "all"]: | |
| all_rows = True | |
| rows.update(re.findall(r"\d+", item_text)) | |
| return rows, all_rows | |
| text = str(value or "").strip() | |
| if not text: | |
| return rows, False | |
| if re.search(r"\b(todos|todas|global|all)\b", text, re.IGNORECASE): | |
| return rows, True | |
| scoped_matches = re.findall( | |
| r"(?:l[ií]neas?|renglones?)\s+([0-9][0-9,\s\-yY]*)", | |
| text, | |
| flags=re.IGNORECASE, | |
| ) | |
| for match in scoped_matches: | |
| rows.update(re.findall(r"\d+", match)) | |
| return rows, False | |
| def _to_bool(value, default=False): | |
| if isinstance(value, bool): | |
| return value | |
| if value is None: | |
| return default | |
| text = str(value).strip().lower() | |
| if text in ["true", "si", "sí", "yes", "1"]: | |
| return True | |
| if text in ["false", "no", "0", "none", "null", "n/a", ""]: | |
| return False | |
| return default | |
| def _to_optional_bool(value): | |
| if isinstance(value, bool): | |
| return value | |
| if value is None: | |
| return None | |
| text = str(value).strip().lower() | |
| if text in ["true", "si", "sí", "yes", "1"]: | |
| return True | |
| if text in ["false", "no", "0"]: | |
| return False | |
| return None | |
| def postprocess_technical_analysis(data: dict) -> dict: | |
| """Reduce falsos positivos entre especificaciones técnicas y entregables documentales.""" | |
| items = data.get("items", []) if isinstance(data, dict) else [] | |
| proposal_rows = [] | |
| for item in items: | |
| if not isinstance(item, dict): | |
| continue | |
| item["codigo_articulo"] = _normalize_acp_code(item.get("codigo_articulo")) | |
| requiere_propuesta = _to_bool(item.get("requiere_propuesta_tecnica"), default=False) | |
| item["requiere_propuesta_tecnica"] = requiere_propuesta | |
| if requiere_propuesta: | |
| renglon = str(item.get("renglon") or "").strip() | |
| if renglon: | |
| proposal_rows.append(renglon) | |
| requiere = _to_bool(item.get("requiere_ficha_tecnica"), default=False) | |
| evidencia = str(item.get("evidencia_tecnica") or "").lower() | |
| combined = evidencia | |
| has_documental_evidence = any(keyword in combined for keyword in DOCUMENTAL_KEYWORDS) | |
| if requiere and not has_documental_evidence: | |
| item["requiere_ficha_tecnica"] = False | |
| if evidencia: | |
| item["evidencia_tecnica"] = f"{item.get('evidencia_tecnica')} | Nota sistema: no se detectó entregable documental explícito." | |
| else: | |
| item["evidencia_tecnica"] = "No especificado en los documentos adjuntos" | |
| else: | |
| item["requiere_ficha_tecnica"] = requiere | |
| item["posible_obsolescencia"] = _to_bool(item.get("posible_obsolescencia"), default=False) | |
| cg = data.get("condiciones_generales", {}) if isinstance(data, dict) else {} | |
| if isinstance(cg, dict): | |
| scope_rows, scope_all = _parse_rows_from_scope_text(cg.get("propuesta_tecnica_aplica_renglones")) | |
| text_scope_rows, text_scope_all = _parse_rows_from_scope_text( | |
| f"{cg.get('propuesta_tecnica_requerida', '')} {cg.get('evidencia_propuesta_tecnica', '')}" | |
| ) | |
| scope_rows.update(text_scope_rows) | |
| scope_all = scope_all or text_scope_all | |
| proposal_required = str(cg.get("propuesta_tecnica_requerida", "") or "").strip().lower().startswith(("si", "sí")) or bool(scope_rows) or scope_all | |
| if proposal_required and (scope_all or scope_rows): | |
| proposal_rows = [] | |
| for item in items: | |
| if not isinstance(item, dict): | |
| continue | |
| renglon = str(item.get("renglon") or "").strip() | |
| row_number_match = re.search(r"\d+", renglon) | |
| row_number = row_number_match.group(0) if row_number_match else "" | |
| applies = scope_all or row_number in scope_rows | |
| item["requiere_propuesta_tecnica"] = bool(applies) | |
| if applies and row_number: | |
| proposal_rows.append(row_number) | |
| if scope_rows: | |
| proposal_rows = sorted(set(scope_rows), key=lambda x: int(x) if x.isdigit() else x) | |
| elif scope_all: | |
| cg["propuesta_tecnica_requerida"] = "Si (aplica a todos los renglones)" | |
| cg["propuesta_tecnica_aplica_renglones"] = ["Todos"] | |
| if proposal_rows: | |
| unique_rows = sorted(set(proposal_rows), key=lambda x: int(x) if x.isdigit() else x) | |
| cg["propuesta_tecnica_requerida"] = f"Si (aplica líneas {', '.join(unique_rows)})" | |
| cg["propuesta_tecnica_aplica_renglones"] = unique_rows | |
| elif str(cg.get("propuesta_tecnica_requerida", "")).strip().lower() in ["", "n/a", "none", "null"]: | |
| cg["propuesta_tecnica_requerida"] = "No especificado en los documentos adjuntos" | |
| for key in [ | |
| "persona_encargada_licitacion", | |
| "correo_encargado_licitacion", | |
| "telefono_encargado_licitacion", | |
| ]: | |
| if str(cg.get(key, "") or "").strip().lower() in ["", "n/a", "none", "null"]: | |
| cg[key] = "No especificado en los documentos adjuntos" | |
| presencia_local = _to_optional_bool(cg.get("requiere_presencia_local")) | |
| cg["requiere_presencia_local"] = presencia_local | |
| if presencia_local is True: | |
| cg["empresa_recomendada_participacion"] = "EP" | |
| elif presencia_local is False: | |
| cg["empresa_recomendada_participacion"] = "Proyelec" | |
| else: | |
| cg["empresa_recomendada_participacion"] = "Validar" | |
| if str(cg.get("evidencia_presencia_local", "") or "").strip().lower() in ["n/a", "none", "null"]: | |
| cg["evidencia_presencia_local"] = "" | |
| return data | |
| async def analizar_pliego( | |
| archivos_pdf: List[UploadFile] = File(...), | |
| gemini_key: str = Form(...), | |
| username: str = Form("API"), | |
| role: str = Form(""), | |
| _token: str = Depends(verify_internal_token) | |
| ): | |
| started_at = time.perf_counter() | |
| api_key_clean = gemini_key.strip() | |
| try: | |
| client = get_gemini_client(api_key_clean) | |
| archivos_subidos = [] | |
| for archivo in archivos_pdf: | |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp: | |
| content = await archivo.read() | |
| tmp.write(content) | |
| tmp_path = tmp.name | |
| uploaded_file = gemini_upload_file(client, tmp_path) | |
| archivos_subidos.append(uploaded_file) | |
| os.remove(tmp_path) | |
| # gemini-2.5-flash para análisis complejo de PDFs | |
| response, used_model = gemini_generate_with_fallback( | |
| client, | |
| [PROMPT_ANALISTA_MULTI, *archivos_subidos], | |
| response_mime_type="application/json", | |
| ) | |
| try: | |
| db.log_ai_usage( | |
| username=username, | |
| role=role, | |
| action="analizar_pliego", | |
| model=used_model, | |
| usage_metadata=response.usage_metadata, | |
| duration_ms=int((time.perf_counter() - started_at) * 1000), | |
| metadata={"pdf_count": len(archivos_subidos)} | |
| ) | |
| except Exception as e: | |
| logger.warning(f"Error logging metric: {e}") | |
| # Limpieza de archivos en la nube de Gemini para evitar llenar la cuota | |
| for f in archivos_subidos: | |
| gemini_delete_file(client, f) | |
| logger.info(f"{len(archivos_subidos)} pliego(s) analizados exitosamente.") | |
| data = json.loads(response.text) | |
| return postprocess_technical_analysis(data) | |
| except Exception as e: | |
| logger.error(f"Error analizando pliego: {str(e)}") | |
| db.log_usage_event( | |
| username=username, | |
| role=role, | |
| module="ai", | |
| action="analizar_pliego", | |
| provider="gemini", | |
| model=GEMINI_MODEL, | |
| status="error", | |
| error_message=str(e)[:500], | |
| duration_ms=int((time.perf_counter() - started_at) * 1000), | |
| metadata={"pdf_count": len(archivos_pdf)} | |
| ) | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| # --- 5. FUNCIONES DE APOYO PARA CORREOS (HILO SECUNDARIO) --- | |
| def get_user_credentials(username): | |
| row = db.get_user_credentials(username) | |
| return (row[0], crypto.decrypt_data(row[1]), row[2]) if row else (None, None, None) | |
| # Prompt compacto: clasifica Y extrae cotizacion en una sola llamada (cero tokens extra) | |
| PROMPT_CLASIFICADOR_CORREOS = """Eres un asistente de procura. Analiza este correo en relacion a la licitacion {licitacion}. | |
| Items de referencia: {contexto_items_resumido} | |
| Correo: | |
| Asunto: {asunto}\nRemitente: {remitente}\nCuerpo: {cuerpo} | |
| Responde SOLO con este JSON (sin texto adicional): | |
| {{"relacionado": true/false, | |
| "resumen": "1 linea de lo que ofrece el proveedor", | |
| "renglones": "numeros separados por coma ej: 1, 3", | |
| "borrador_respuesta": "correo de respuesta profesional firmado como Departamento de Compras", | |
| "cotizaciones": [ | |
| {{"renglon": "1", "precio_unitario": 0.0, "moneda": "USD", "tiempo_entrega": "30 dias", "condiciones": "FOB"}} | |
| ] | |
| }} | |
| Si el correo no contiene precios, devuelve cotizaciones como lista vacia []. | |
| """ | |
| def procesar_correos_background(username: str, servidor_imap: str, licitacion_activa: str, contexto_items: str): | |
| email_user, email_pass, gemini_key = get_user_credentials(username) | |
| if not email_user or not email_pass: | |
| logger.warning(f"Sin credenciales de correo para usuario {username}") | |
| return | |
| try: | |
| client = get_gemini_client(gemini_key) | |
| # gemini-2.5-flash para clasificación simple de correos | |
| # Reducir contexto enviado: solo los 3 campos clave, NO la ficha técnica completa | |
| try: | |
| df_items = pd.read_json(io.StringIO(contexto_items)) | |
| cols_disponibles = [c for c in ['renglon', 'codigo_articulo', 'termino_de_busqueda_corto'] if c in df_items.columns] | |
| contexto_resumido = df_items[cols_disponibles].to_json(orient="records", force_ascii=False) | |
| except Exception: | |
| contexto_resumido = contexto_items[:500] # fallback seguro | |
| mail = imaplib.IMAP4_SSL(servidor_imap) | |
| mail.login(email_user, email_pass) | |
| mail.select("inbox") | |
| status, mensajes = mail.search(None, 'ALL') | |
| if not mensajes[0]: | |
| return | |
| # Evaluamos los últimos 50 correos, pero enviaremos máximo 15 a Gemini | |
| lista_ids = mensajes[0].split()[-50:] | |
| correos_enviados_a_gemini = 0 | |
| for id_correo in lista_ids: | |
| if correos_enviados_a_gemini >= 15: | |
| break | |
| res, data = mail.fetch(id_correo, '(RFC822)') | |
| for part in data: | |
| if isinstance(part, tuple): | |
| msg = email.message_from_bytes(part[1]) | |
| subj_raw = decode_header(msg.get("Subject", ""))[0] | |
| asunto = subj_raw[0].decode(subj_raw[1] or 'utf-8', errors='ignore') if isinstance(subj_raw[0], bytes) else str(subj_raw[0]) | |
| remitente = msg.get("From", "Desconocido") | |
| if db.check_email_exists(licitacion_activa, asunto, remitente): | |
| continue | |
| # Pre-filtro inteligente y ahorrador de tokens: | |
| num_lic_clean = "".join(re.findall(r'\d+', licitacion_activa)) | |
| # Evitar procesar correos automáticos o spam obvio | |
| if "no-reply" in remitente.lower() or "newsletter" in remitente.lower() or "marketing" in remitente.lower(): | |
| continue | |
| # Extraer el cuerpo antes para poder filtrarlo | |
| cuerpo_crudo = "" | |
| if msg.is_multipart(): | |
| for p in msg.walk(): | |
| if p.get_content_type() == "text/plain": | |
| cuerpo_crudo += p.get_payload(decode=True).decode(errors='ignore') | |
| else: | |
| cuerpo_crudo = msg.get_payload(decode=True).decode(errors='ignore') | |
| cuerpo_limpio = " ".join(cuerpo_crudo.split()) | |
| # Chequeo flexible: Si menciona el número de licitación o tiene palabras clave de B2B | |
| # Busca tanto en el Asunto como en los primeros 300 caracteres del correo | |
| texto_busqueda = (asunto + " " + cuerpo_limpio[:300]).upper() | |
| palabras_clave = ["RFQ", "COTIZA", "QUOTE", "PROCURA", "PRECIO", "OFERTA", "SUMINISTRO", "USD", "$", "ATTACH", "ADJUNT", "REQUIREMENT", "TECH", "ESPECIFICACION", "DELIVERY", "ENTREGA"] | |
| es_relevante = (num_lic_clean in texto_busqueda) or (any(p in texto_busqueda for p in palabras_clave)) | |
| if not es_relevante: | |
| continue | |
| # Limitar cuerpo a 1200 chars (antes 2000) - ahorra 40% de tokens de Gemini | |
| cuerpo_ia = cuerpo_limpio[:1200] | |
| prompt = PROMPT_CLASIFICADOR_CORREOS.format( | |
| licitacion=licitacion_activa, | |
| contexto_items_resumido=contexto_resumido, | |
| asunto=asunto, | |
| remitente=remitente, | |
| cuerpo=cuerpo_ia | |
| ) | |
| try: | |
| time.sleep(6) # 6s entre llamadas — respeta 15 RPM de Gemini Free | |
| res_ia, _ = gemini_generate_with_fallback(client, prompt) | |
| correos_enviados_a_gemini += 1 | |
| texto_ia = res_ia.text.strip().replace("```json", "").replace("```", "").strip() | |
| datos_ia = json.loads(texto_ia) | |
| if datos_ia.get("relacionado"): | |
| db.insert_smart_inbox( | |
| licitacion_activa, remitente, asunto, | |
| msg.get("Date"), datos_ia.get('resumen', ''), | |
| datos_ia.get('renglones', ''), cuerpo_limpio, | |
| datos_ia.get('borrador_respuesta', '') | |
| ) | |
| logger.info(f"Correo guardado: '{asunto}' para licitación {licitacion_activa}") | |
| # Guardar cotizaciones extraídas (si las hay) en tabla comparador | |
| for cot in datos_ia.get('cotizaciones', []): | |
| renglon = str(cot.get('renglon', '')).strip() | |
| proveedor = remitente | |
| precio = float(cot.get('precio_unitario', 0) or 0) | |
| if renglon and precio > 0: | |
| if not db.check_cotizacion_exists(licitacion_activa, renglon, proveedor): | |
| db.insert_cotizacion( | |
| licitacion_activa, renglon, proveedor, | |
| precio, | |
| str(cot.get('moneda', 'USD')), | |
| str(cot.get('tiempo_entrega', 'N/A')), | |
| str(cot.get('condiciones', '')), | |
| str(msg.get('Date', '')), | |
| asunto | |
| ) | |
| logger.info(f"Cotizacion guardada: Renglón {renglon} | {proveedor} | ${precio}") | |
| except Exception as parse_error: | |
| logger.warning(f"Error procesando correo '{asunto}': {parse_error}") | |
| continue | |
| mail.logout() | |
| logger.info(f"Escaneo de correos finalizado para usuario {username}.") | |
| except Exception as e: | |
| logger.error(f"Error crítico en hilo de correos: {e}") | |
| # --- 6. ENDPOINT ASÍNCRONO DE CORREOS --- | |
| def organizar_correos( | |
| background_tasks: BackgroundTasks, | |
| username: str = Form(...), | |
| servidor_imap: str = Form("mail.proyelec.com"), | |
| licitacion_activa: str = Form(...), | |
| contexto_items: str = Form(...), | |
| _token: str = Depends(verify_internal_token) | |
| ): | |
| # Validar credenciales de correo sincronamente antes de lanzar la tarea | |
| email_user, email_pass, _ = get_user_credentials(username) | |
| if not email_user or not email_pass: | |
| db.log_usage_event(username=username, module="email", action="organizar_correos", licitacion=licitacion_activa, status="error", error_message="Credenciales de correo faltantes") | |
| return {"status": "error", "mensaje": "No has configurado tu correo y contraseña en el panel lateral."} | |
| try: | |
| import imaplib | |
| mail = imaplib.IMAP4_SSL(servidor_imap, timeout=10) | |
| mail.login(email_user, email_pass) | |
| mail.logout() | |
| except imaplib.IMAP4.error as e: | |
| db.log_usage_event(username=username, module="email", action="organizar_correos", licitacion=licitacion_activa, status="error", error_message=str(e)[:500]) | |
| return {"status": "error", "mensaje": f"Autenticación rechazada. ¿Usas Office365 o Gmail? Necesitas una 'App Password'. Error: {e}"} | |
| except Exception as e: | |
| db.log_usage_event(username=username, module="email", action="organizar_correos", licitacion=licitacion_activa, status="error", error_message=str(e)[:500]) | |
| return {"status": "error", "mensaje": f"No se pudo conectar al servidor IMAP '{servidor_imap}'. Revisa la dirección del servidor. Error: {e}"} | |
| background_tasks.add_task(procesar_correos_background, username, servidor_imap, licitacion_activa, contexto_items) | |
| db.log_usage_event(username=username, module="email", action="organizar_correos", licitacion=licitacion_activa, metadata={"servidor_imap": servidor_imap}) | |
| return {"status": "success", "mensaje": "✅ Conexión exitosa. Gemini está escaneando los correos en segundo plano..."} | |
| # --- 7. GENERADOR DE FICHAS TÉCNICAS (CON CACHE) --- | |
| def generar_ficha( | |
| username: str = Form(...), | |
| licitacion: str = Form(...), | |
| codigo_renglon: str = Form(...), | |
| pliego_context: str = Form(...), | |
| items_context: str = Form(...), | |
| gemini_key: str = Form(...), | |
| _token: str = Depends(verify_internal_token) | |
| ): | |
| # Verificar cache primero — si ya se generó, devolver sin gastar tokens | |
| cached = db.get_ficha_cache(username, licitacion, codigo_renglon) | |
| if cached: | |
| logger.info(f"Ficha para {codigo_renglon} servida desde cache.") | |
| db.log_usage_event( | |
| username=username, | |
| module="ai", | |
| action="generar_ficha_cache", | |
| licitacion=licitacion, | |
| provider="gemini", | |
| model=GEMINI_MODEL, | |
| metadata={"codigo_renglon": codigo_renglon} | |
| ) | |
| return {"status": "success", "datasheet_md": cached, "from_cache": True} | |
| try: | |
| prompt = f"""Eres un Ingeniero de Compras especializado. Genera una ficha técnica en formato Markdown para el artículo: {codigo_renglon}. | |
| Condiciones del Pliego: {pliego_context} | |
| Detalle del Ítem: {items_context} | |
| La ficha debe contener: | |
| - **Título y Descripción breve** | |
| - **Tabla de Especificaciones Técnicas** | |
| - **Requisitos de Calidad / Certificaciones** | |
| - **Condiciones especiales de la licitación** | |
| Formato profesional y estructurado.""" | |
| response = gemini_generate_content(gemini_key, prompt) | |
| datasheet = response.text | |
| # Guardar en cache para futuras consultas | |
| db.save_ficha_cache(username, licitacion, codigo_renglon, datasheet) | |
| db.log_ai_usage( | |
| username=username, | |
| action="generar_ficha", | |
| licitacion=licitacion, | |
| model=GEMINI_MODEL, | |
| usage_metadata=response.usage_metadata, | |
| metadata={"codigo_renglon": codigo_renglon} | |
| ) | |
| logger.info(f"Ficha técnica generada y cacheada para {codigo_renglon}.") | |
| return {"status": "success", "datasheet_md": datasheet, "from_cache": False} | |
| except Exception as e: | |
| logger.error(f"Error generando ficha: {str(e)}") | |
| db.log_usage_event( | |
| username=username, | |
| module="ai", | |
| action="generar_ficha", | |
| licitacion=licitacion, | |
| provider="gemini", | |
| model=GEMINI_MODEL, | |
| status="error", | |
| error_message=str(e)[:500], | |
| metadata={"codigo_renglon": codigo_renglon} | |
| ) | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| # --- 8. ENDPOINTS REST --- | |
| class LoginRequest(BaseModel): | |
| username: str | |
| password: str | |
| def login(req: LoginRequest, _token: str = Depends(verify_internal_token)): | |
| user = db.get_user(req.username, req.password) | |
| if user: | |
| db.log_usage_event(username=user[0], role=user[2], module="auth", action="api_login") | |
| return {"status": "success", "username": user[0], "role": user[2]} | |
| db.log_usage_event(username=req.username, module="auth", action="api_login", status="error", error_message="Credenciales incorrectas") | |
| raise HTTPException(status_code=401, detail="Credenciales incorrectas") | |
| def get_workspace(username: str, _token: str = Depends(verify_internal_token)): | |
| row = db.load_workspace_state(username) | |
| if row and row[0] and row[1]: | |
| df = pd.read_json(io.StringIO(row[0])) | |
| return {"cg": json.loads(row[1]), "items": df.to_dict(orient="records")} | |
| return {"cg": None, "items": []} | |
| def get_history(username: str, skip: int = 0, limit: int = 50, _token: str = Depends(verify_internal_token)): | |
| df = db.get_user_history_df(username) | |
| return df.iloc[skip : skip+limit].to_dict(orient="records") | |
| def get_inbox(licitacion: str, skip: int = 0, limit: int = 50, _token: str = Depends(verify_internal_token)): | |
| df = db.get_correos_licitacion_df(licitacion) | |
| return df.iloc[skip : skip+limit].to_dict(orient="records") | |
| def get_config(username: str, _token: str = Depends(verify_internal_token)): | |
| user_creds = db.get_user_credentials(username) | |
| if user_creds: | |
| return {"status": "success", "email_user": user_creds[0], "gemini_key": user_creds[2]} | |
| return {"status": "error"} | |
| def save_config( | |
| username: str = Form(...), | |
| gemini_key: str = Form(...), | |
| email_user: str = Form(""), | |
| email_pass: str = Form(""), | |
| _token: str = Depends(verify_internal_token) | |
| ): | |
| existing = db.get_user_credentials(username) | |
| if not existing: | |
| raise HTTPException(status_code=404, detail="Usuario no encontrado") | |
| enc_pass = existing[1] | |
| if email_pass: | |
| enc_pass = crypto.encrypt_data(email_pass) | |
| existing_tavily = existing[3] if len(existing) > 3 else "" | |
| db.update_user_profile(username, gemini_key, existing_tavily, email_user, enc_pass) | |
| return {"status": "success"} | |
| # ============================================= | |
| # CONSULTA AUTOMATICA AL SLI DE LA ACP | |
| # ============================================= | |
| def consultar_sli(rfq_id: str, _token: str = Depends(verify_internal_token)): | |
| started_at = time.perf_counter() | |
| rfq_id = "".join(filter(str.isdigit, str(rfq_id or ""))) | |
| if not rfq_id: | |
| db.log_usage_event(module="sli", action="consultar_sli", status="error", error_message="Numero de licitacion invalido") | |
| raise HTTPException( | |
| status_code=400, | |
| detail={ | |
| "message": "Numero de licitacion invalido.", | |
| "hint": "Ingresa solo el numero RFQ de la licitacion ACP." | |
| } | |
| ) | |
| SLI_HOME_URL = "https://apps.pancanal.com/sli/LicitacionesBusqueda/Welcome" | |
| SLI_URL = f"https://apps.pancanal.com/sli/Licitaciones/LicitacionHeader?rfqId={rfq_id}" | |
| def extraer_resumen_acta(texto_acta, acta_url): | |
| texto_acta = re.sub(r"\s+", " ", texto_acta or "").strip() | |
| if not texto_acta: | |
| return { | |
| "disponible": False, | |
| "url": acta_url, | |
| "resumen": "", | |
| "hallazgos": [], | |
| "error": "El acta no contiene texto legible." | |
| } | |
| palabras_clave = [ | |
| "no cumple", "incumple", "fallo", "falla", "deficiencia", | |
| "observacion", "observación", "subsan", "tecnico", "técnico", | |
| "rechaz", "descalific", "no acept", "aclaracion", "aclaración" | |
| ] | |
| partes = re.split(r"(?<=[.!?])\s+|\n+", texto_acta) | |
| hallazgos = [] | |
| for parte in partes: | |
| parte_limpia = parte.strip() | |
| parte_lower = parte_limpia.lower() | |
| if len(parte_limpia) < 35: | |
| continue | |
| if any(palabra in parte_lower for palabra in palabras_clave): | |
| hallazgos.append(parte_limpia[:450]) | |
| if len(hallazgos) >= 8: | |
| break | |
| if hallazgos: | |
| resumen = "Se detectaron posibles observaciones tecnicas o comentarios relevantes en el acta." | |
| elif any(palabra in texto_acta.lower() for palabra in ["cumple", "conforme", "adjudic"]): | |
| resumen = "No se detectaron fallos tecnicos evidentes en una lectura automatica del acta." | |
| else: | |
| resumen = "El acta fue encontrada, pero no se detectaron observaciones tecnicas claras automaticamente." | |
| return { | |
| "disponible": True, | |
| "url": acta_url, | |
| "resumen": resumen, | |
| "hallazgos": hallazgos, | |
| "texto_muestra": texto_acta[:1200], | |
| "error": None | |
| } | |
| try: | |
| from playwright.sync_api import ( | |
| Error as PlaywrightError, | |
| TimeoutError as PlaywrightTimeoutError, | |
| sync_playwright, | |
| ) | |
| except ImportError: | |
| raise HTTPException( | |
| status_code=503, | |
| detail={ | |
| "message": "Playwright no esta instalado.", | |
| "hint": "Ejecuta: pip install playwright && playwright install chromium" | |
| } | |
| ) | |
| browser = None | |
| resumen_acta = { | |
| "disponible": False, | |
| "url": None, | |
| "resumen": "", | |
| "hallazgos": [], | |
| "error": "No se encontro el boton de resumen de propuestas recibidas." | |
| } | |
| try: | |
| with sync_playwright() as p: | |
| try: | |
| browser = p.chromium.launch( | |
| headless=True, | |
| args=[ | |
| "--no-sandbox", | |
| "--disable-dev-shm-usage", | |
| "--disable-blink-features=AutomationControlled" | |
| ] | |
| ) | |
| except PlaywrightError as e: | |
| msg = str(e) | |
| if "Executable doesn't exist" in msg or "playwright install" in msg: | |
| raise HTTPException( | |
| status_code=503, | |
| detail={ | |
| "message": "Chromium de Playwright no esta instalado.", | |
| "hint": "Ejecuta: playwright install chromium" | |
| } | |
| ) | |
| raise | |
| page = browser.new_page() | |
| page.set_default_timeout(15000) | |
| page.set_extra_http_headers({ | |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124" | |
| }) | |
| response = page.goto(SLI_HOME_URL, wait_until="domcontentloaded", timeout=30000) | |
| if response and response.status >= 500: | |
| raise HTTPException( | |
| status_code=502, | |
| detail={ | |
| "message": f"El SLI respondio con HTTP {response.status}.", | |
| "hint": "El portal de ACP puede estar caido o inestable. Intenta de nuevo mas tarde." | |
| } | |
| ) | |
| page.wait_for_selector("#rfqId", timeout=15000) | |
| page.fill("#rfqId", rfq_id) | |
| if page.locator("#hfEstatusSeleccionadoID").count() > 0: | |
| page.evaluate( | |
| 'document.getElementById("hfEstatusSeleccionadoID").value = "TODOS";' | |
| ) | |
| page.click("input[type='submit']") | |
| try: | |
| page.wait_for_function( | |
| "() => document.body.innerText.includes('Detalle de RFQ') || " | |
| "document.body.innerText.includes('EVALUACI') || " | |
| "document.body.innerText.includes('No se encontraron') || " | |
| "document.body.innerText.includes('InternalServer')", | |
| timeout=20000 | |
| ) | |
| except PlaywrightTimeoutError: | |
| logger.warning(f"Timeout esperando resultados del SLI para RFQ {rfq_id}") | |
| content = page.content() | |
| SLI_URL = page.url | |
| resumen_visible = page.locator(".ResPropRec").count() > 0 | |
| po_header_match = re.search(r"po_header\s*[=:]\s*['\"]?(\d+)", content, re.IGNORECASE) | |
| if not po_header_match: | |
| po_header_match = re.search(r"po_header=(\d+)", content, re.IGNORECASE) | |
| if resumen_visible and po_header_match: | |
| po_header = po_header_match.group(1) | |
| acta_url = urljoin( | |
| SLI_URL, | |
| f"../Comunes/ImpresionActaResumen?p_rfq={rfq_id}&po_header={po_header}" | |
| ) | |
| try: | |
| acta_response = page.request.get( | |
| acta_url, | |
| headers={"Referer": SLI_URL}, | |
| timeout=30000 | |
| ) | |
| acta_bytes = acta_response.body() | |
| content_type = (acta_response.headers.get("content-type") or "").lower() | |
| if "pdf" in content_type or acta_bytes[:4] == b"%PDF": | |
| try: | |
| from pypdf import PdfReader | |
| reader = PdfReader(io.BytesIO(acta_bytes)) | |
| texto_acta = "\n".join( | |
| page_pdf.extract_text() or "" | |
| for page_pdf in reader.pages | |
| ) | |
| resumen_acta = extraer_resumen_acta(texto_acta, acta_url) | |
| except ImportError: | |
| resumen_acta = { | |
| "disponible": False, | |
| "url": acta_url, | |
| "resumen": "", | |
| "hallazgos": [], | |
| "error": "pypdf no esta instalado para leer el PDF del resumen." | |
| } | |
| else: | |
| html_acta = acta_bytes.decode("utf-8", errors="ignore") | |
| texto_acta = BeautifulSoup(html_acta, "html.parser").get_text( | |
| separator=" ", | |
| strip=True | |
| ) | |
| resumen_acta = extraer_resumen_acta(texto_acta, acta_url) | |
| except Exception as e: | |
| logger.warning(f"No se pudo leer acta resumen SLI {rfq_id}: {e}") | |
| resumen_acta = { | |
| "disponible": False, | |
| "url": acta_url, | |
| "resumen": "", | |
| "hallazgos": [], | |
| "error": "Se encontro el resumen, pero no se pudo leer automaticamente." | |
| } | |
| except HTTPException: | |
| raise | |
| except PlaywrightTimeoutError as e: | |
| logger.warning(f"Timeout consultando SLI {rfq_id}: {e}") | |
| raise HTTPException( | |
| status_code=504, | |
| detail={ | |
| "message": "El SLI tardo demasiado en responder.", | |
| "hint": "Verifica la conexion o intenta nuevamente en unos minutos." | |
| } | |
| ) | |
| except PlaywrightError as e: | |
| logger.exception(f"Error de Playwright consultando SLI {rfq_id}") | |
| raise HTTPException( | |
| status_code=502, | |
| detail={ | |
| "message": "No se pudo consultar el portal SLI.", | |
| "hint": "El portal pudo cambiar, bloquear la automatizacion o estar temporalmente fuera de servicio.", | |
| "technical": str(e)[:500] | |
| } | |
| ) | |
| except Exception as e: | |
| logger.exception(f"Error inesperado consultando SLI {rfq_id}") | |
| raise HTTPException( | |
| status_code=500, | |
| detail={ | |
| "message": "Error inesperado consultando el SLI.", | |
| "hint": "Revisa backend.log para ver el traceback completo.", | |
| "technical": str(e)[:500] | |
| } | |
| ) | |
| finally: | |
| if browser: | |
| try: | |
| browser.close() | |
| except Exception: | |
| pass | |
| try: | |
| soup_sli = BeautifulSoup(content, "html.parser") | |
| texto_sli = soup_sli.get_text(separator="|", strip=True) | |
| tokens = [t.strip() for t in texto_sli.split("|") if t.strip()] | |
| def buscar_valor(etiquetas): | |
| for i, tok in enumerate(tokens): | |
| for etiq in etiquetas: | |
| if ( | |
| tok.strip().lower() == etiq.lower() | |
| or tok.strip().lower() == f"{etiq.lower()}:" | |
| ): | |
| for j in range(i + 1, min(i + 4, len(tokens))): | |
| cand = tokens[j] | |
| if ( | |
| cand | |
| and not any( | |
| e.lower() == cand.strip().lower() | |
| for e in etiquetas | |
| ) | |
| and len(cand) > 2 | |
| ): | |
| return cand | |
| return None | |
| resultado = { | |
| "rfq_id": rfq_id, | |
| "url": SLI_URL, | |
| "estatus": buscar_valor(["Estatus", "Estado"]), | |
| "descripcion": buscar_valor(["Descripción", "Descripcion"]), | |
| "fecha_cierre": buscar_valor([ | |
| "Fecha y hora de cierre", | |
| "Fecha de cierre", | |
| "Cierre" | |
| ]), | |
| "fecha_publicacion": buscar_valor([ | |
| "Fecha de publicación", | |
| "Publicación", | |
| "Publicacion" | |
| ]), | |
| "ultima_revision": buscar_valor([ | |
| "Última revisión", | |
| "Ultima Revision", | |
| "Última Revisión" | |
| ]), | |
| "agente_compras": buscar_valor([ | |
| "Agente de compras", | |
| "Agente Compras", | |
| "Purchasing Agent" | |
| ]), | |
| "resumen_acta": resumen_acta, | |
| "error": None | |
| } | |
| ESTADOS_SLI = [ | |
| "EVALUACIÓN", | |
| "EVALUACION", | |
| "ADJUDICACIÓN", | |
| "ADJUDICACION", | |
| "CANCELACIÓN", | |
| "CANCELACION", | |
| "ACTO DESIERTO", | |
| "DESIERTA", | |
| "ENMENDADA", | |
| "ANUNCIO VENCIDO", | |
| "ABIERTA", | |
| "PRECALIFICACIÓN" | |
| ] | |
| if not resultado["estatus"]: | |
| texto_upper = texto_sli.upper() | |
| for estado in ESTADOS_SLI: | |
| if estado in texto_upper: | |
| resultado["estatus"] = estado.title() | |
| break | |
| if not resultado["estatus"] and not resultado["descripcion"]: | |
| resultado["error"] = ( | |
| "No se encontró información. " | |
| "Verifica el número de licitación o intenta más tarde." | |
| ) | |
| logger.info( | |
| f"Consulta SLI {rfq_id}: " | |
| f"estatus={resultado['estatus']} | " | |
| f"desc={resultado['descripcion']}" | |
| ) | |
| db.log_usage_event( | |
| module="sli", | |
| action="consultar_sli", | |
| licitacion=rfq_id, | |
| status="error" if resultado.get("error") else "success", | |
| error_message=resultado.get("error") or "", | |
| duration_ms=int((time.perf_counter() - started_at) * 1000), | |
| metadata={ | |
| "estatus": resultado.get("estatus"), | |
| "descripcion_detectada": bool(resultado.get("descripcion")), | |
| "acta_disponible": bool((resultado.get("resumen_acta") or {}).get("disponible")) | |
| } | |
| ) | |
| return resultado | |
| except Exception as e: | |
| logger.exception(f"Error parseando respuesta SLI {rfq_id}") | |
| db.log_usage_event( | |
| module="sli", | |
| action="consultar_sli", | |
| licitacion=rfq_id, | |
| status="error", | |
| error_message=str(e)[:500], | |
| duration_ms=int((time.perf_counter() - started_at) * 1000) | |
| ) | |
| raise HTTPException( | |
| status_code=500, | |
| detail={ | |
| "message": "El SLI respondio, pero no se pudo interpretar la pagina.", | |
| "hint": "Puede haber cambiado el formato del portal ACP.", | |
| "technical": str(e)[:500] | |
| } | |
| ) | |