tokiospg commited on
Commit
3313fb0
·
verified ·
1 Parent(s): 881e520

Upload 7 files

Browse files
Files changed (7) hide show
  1. .gitignore +12 -0
  2. api.py +993 -691
  3. app.py +0 -0
  4. database.py +832 -12
  5. requirements.txt +18 -16
  6. sli_scraper.py +360 -0
  7. style.css +1598 -19
.gitignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ *.log
5
+ api_test.*.log
6
+ streamlit_test.*.log
7
+ backend.log
8
+ ACP DATA LIC PASADAS*.xlsx
9
+ PRY-FRPCL-003 Evaluación de Cumplimiento del Proveedor.xlsx
10
+ app_recovered_*.py
11
+ fix_*.py
12
+ update_*.py
api.py CHANGED
@@ -2,750 +2,1052 @@ from fastapi import FastAPI, UploadFile, File, HTTPException, Form, BackgroundTa
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from typing import List
4
  from pydantic import BaseModel
5
- import google.generativeai as genai
6
  import tempfile
7
  import os
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  import json
9
- import imaplib
10
- import email
11
- from email.header import decode_header
12
- import re
13
- import hashlib
14
- import time
15
- import pandas as pd
16
- import io
17
- from urllib.parse import urljoin
18
- from dotenv import load_dotenv
19
- import database as db
20
- import logging
21
- from logging.handlers import RotatingFileHandler
22
- from bs4 import BeautifulSoup
23
- import sys
24
- import asyncio
25
- import crypto
26
-
27
- if sys.platform == "win32":
28
- asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
29
- load_dotenv()
30
-
31
- # --- 1. LOGGING CON ROTACIÓN (max 2MB, 3 backups) ---
32
- log_handler = RotatingFileHandler('backend.log', maxBytes=2*1024*1024, backupCount=3, encoding='utf-8')
33
- log_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
34
- logger = logging.getLogger(__name__)
35
- logger.setLevel(logging.INFO)
36
- logger.addHandler(log_handler)
37
-
38
- # --- 2. SEGURIDAD Y CIFRADO ---
39
- # Se utiliza el módulo centralizado `crypto.py`
40
- INTERNAL_API_TOKEN = os.getenv("INTERNAL_API_TOKEN", "default-dev-token")
41
-
42
- def verify_internal_token(x_internal_token: str = Header(None)):
43
- if x_internal_token != INTERNAL_API_TOKEN:
44
- raise HTTPException(status_code=403, detail="Acceso denegado: Token interno inválido.")
45
- return x_internal_token
46
-
47
-
48
- # --- 3. APP FASTAPI ---
49
- app = FastAPI(title="Proyelec Core API v6.1")
50
-
51
- app.add_middleware(
52
- CORSMiddleware,
53
- allow_origins=["http://localhost:8501", "http://127.0.0.1:8501"],
54
- allow_credentials=True,
55
- allow_methods=["*"],
56
- allow_headers=["*"],
57
- )
58
-
59
- @app.get("/")
60
- def estado():
61
- return {"status": "Online", "engine": "Proyelec Core v6.1 — Token-Optimized"}
62
-
63
- # --- 4. PROMPT ANALISTA DE PLIEGOS (Multi-documento) ---
64
  PROMPT_ANALISTA_MULTI = """
65
  Eres un Analista Senior de Procura. Analiza TODO el conjunto de documentos proporcionados (Pliego Principal y Anexos Técnicos).
66
  Cruza la información de todos los documentos para obtener descripciones técnicas exactas.
 
 
 
 
 
 
67
  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'.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
  Responde ÚNICAMENTE con el siguiente JSON estricto, sin texto adicional:
70
- {"condiciones_generales": {"numero_licitacion": "", "tiempo_de_entrega_global": "", "garantia_exigida": "", "lugar_de_entrega": "", "validez_de_la_oferta": "", "propuesta_tecnica_requerida": "Si/No"},
71
- "items": [{"renglon": "", "codigo_articulo": "", "cantidad": 0, "unidad_de_medida": "", "ficha_tecnica_completa": "", "termino_de_busqueda_corto": ""}]}
72
  """
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  @app.post("/api/v1/analizar-pliego")
75
- async def analizar_pliego(archivos_pdf: List[UploadFile] = File(...), gemini_key: str = Form(...), _token: str = Depends(verify_internal_token)):
 
 
 
 
 
 
 
76
  api_key_clean = gemini_key.strip()
77
  try:
78
- genai.configure(api_key=api_key_clean)
79
- archivos_subidos = []
80
-
81
- for archivo in archivos_pdf:
82
- with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
83
- content = await archivo.read()
84
- tmp.write(content)
85
- tmp_path = tmp.name
86
-
87
- uploaded_file = genai.upload_file(path=tmp_path, mime_type="application/pdf")
88
- archivos_subidos.append(uploaded_file)
89
- os.remove(tmp_path)
90
-
91
- # gemini-2.5-flash para análisis complejo de PDFs
92
  model = genai.GenerativeModel('gemini-2.5-flash', generation_config={"response_mime_type": "application/json"})
93
  response = model.generate_content([PROMPT_ANALISTA_MULTI, *archivos_subidos])
94
-
95
- # Limpieza de archivos en la nube de Gemini para evitar llenar la cuota
96
- for f in archivos_subidos:
97
- try:
98
- genai.delete_file(f.name)
99
- except Exception as e:
100
- logger.warning(f"No se pudo borrar archivo temporal de Gemini: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
  logger.info(f"{len(archivos_subidos)} pliego(s) analizados exitosamente.")
103
- return json.loads(response.text)
 
104
 
105
  except Exception as e:
106
  logger.error(f"Error analizando pliego: {str(e)}")
 
 
 
 
 
 
 
 
 
 
 
 
107
  raise HTTPException(status_code=500, detail=str(e))
108
-
109
- # --- 5. FUNCIONES DE APOYO PARA CORREOS (HILO SECUNDARIO) ---
110
- def get_user_credentials(username):
111
- row = db.get_user_credentials(username)
112
- return (row[0], crypto.decrypt_data(row[1]), row[2]) if row else (None, None, None)
113
-
114
- # Prompt compacto: clasifica Y extrae cotizacion en una sola llamada (cero tokens extra)
115
- PROMPT_CLASIFICADOR_CORREOS = """Eres un asistente de procura. Analiza este correo en relacion a la licitacion {licitacion}.
116
- Items de referencia: {contexto_items_resumido}
117
-
118
- Correo:
119
- Asunto: {asunto}\nRemitente: {remitente}\nCuerpo: {cuerpo}
120
-
121
- Responde SOLO con este JSON (sin texto adicional):
122
- {{"relacionado": true/false,
123
- "resumen": "1 linea de lo que ofrece el proveedor",
124
- "renglones": "numeros separados por coma ej: 1, 3",
125
- "borrador_respuesta": "correo de respuesta profesional firmado como Departamento de Compras",
126
- "cotizaciones": [
127
- {{"renglon": "1", "precio_unitario": 0.0, "moneda": "USD", "tiempo_entrega": "30 dias", "condiciones": "FOB"}}
128
- ]
129
- }}
130
- Si el correo no contiene precios, devuelve cotizaciones como lista vacia [].
131
- """
132
-
133
- def procesar_correos_background(username: str, servidor_imap: str, licitacion_activa: str, contexto_items: str):
134
- email_user, email_pass, gemini_key = get_user_credentials(username)
135
- if not email_user or not email_pass:
136
- logger.warning(f"Sin credenciales de correo para usuario {username}")
137
- return
138
-
139
- try:
140
- genai.configure(api_key=gemini_key)
141
- # gemini-2.5-flash para clasificación simple de correos
142
- model = genai.GenerativeModel('gemini-2.5-flash')
143
-
144
- # Reducir contexto enviado: solo los 3 campos clave, NO la ficha técnica completa
145
- try:
146
- df_items = pd.read_json(io.StringIO(contexto_items))
147
- cols_disponibles = [c for c in ['renglon', 'codigo_articulo', 'termino_de_busqueda_corto'] if c in df_items.columns]
148
- contexto_resumido = df_items[cols_disponibles].to_json(orient="records", force_ascii=False)
149
- except Exception:
150
- contexto_resumido = contexto_items[:500] # fallback seguro
151
-
152
- mail = imaplib.IMAP4_SSL(servidor_imap)
153
- mail.login(email_user, email_pass)
154
- mail.select("inbox")
155
-
156
- status, mensajes = mail.search(None, 'ALL')
157
- if not mensajes[0]:
158
- return
159
-
160
- # Evaluamos los últimos 50 correos, pero enviaremos máximo 15 a Gemini
161
- lista_ids = mensajes[0].split()[-50:]
162
- correos_enviados_a_gemini = 0
163
-
164
- for id_correo in lista_ids:
165
- if correos_enviados_a_gemini >= 15:
166
- break
167
- res, data = mail.fetch(id_correo, '(RFC822)')
168
- for part in data:
169
- if isinstance(part, tuple):
170
- msg = email.message_from_bytes(part[1])
171
- subj_raw = decode_header(msg.get("Subject", ""))[0]
172
- asunto = subj_raw[0].decode(subj_raw[1] or 'utf-8', errors='ignore') if isinstance(subj_raw[0], bytes) else str(subj_raw[0])
173
- remitente = msg.get("From", "Desconocido")
174
-
175
- if db.check_email_exists(licitacion_activa, asunto, remitente):
176
- continue
177
-
178
- # Pre-filtro inteligente y ahorrador de tokens:
179
- num_lic_clean = "".join(re.findall(r'\d+', licitacion_activa))
180
-
181
- # Evitar procesar correos automáticos o spam obvio
182
- if "no-reply" in remitente.lower() or "newsletter" in remitente.lower() or "marketing" in remitente.lower():
183
- continue
184
-
185
- # Extraer el cuerpo antes para poder filtrarlo
186
- cuerpo_crudo = ""
187
- if msg.is_multipart():
188
- for p in msg.walk():
189
- if p.get_content_type() == "text/plain":
190
- cuerpo_crudo += p.get_payload(decode=True).decode(errors='ignore')
191
- else:
192
- cuerpo_crudo = msg.get_payload(decode=True).decode(errors='ignore')
193
-
194
- cuerpo_limpio = " ".join(cuerpo_crudo.split())
195
-
196
- # Chequeo flexible: Si menciona el número de licitación o tiene palabras clave de B2B
197
- # Busca tanto en el Asunto como en los primeros 300 caracteres del correo
198
- texto_busqueda = (asunto + " " + cuerpo_limpio[:300]).upper()
199
- palabras_clave = ["RFQ", "COTIZA", "QUOTE", "PROCURA", "PRECIO", "OFERTA", "SUMINISTRO", "USD", "$", "ATTACH", "ADJUNT", "REQUIREMENT", "TECH", "ESPECIFICACION", "DELIVERY", "ENTREGA"]
200
-
201
- es_relevante = (num_lic_clean in texto_busqueda) or (any(p in texto_busqueda for p in palabras_clave))
202
-
203
- if not es_relevante:
204
- continue
205
-
206
- # Limitar cuerpo a 1200 chars (antes 2000) - ahorra 40% de tokens de Gemini
207
- cuerpo_ia = cuerpo_limpio[:1200]
208
-
209
- prompt = PROMPT_CLASIFICADOR_CORREOS.format(
210
- licitacion=licitacion_activa,
211
- contexto_items_resumido=contexto_resumido,
212
- asunto=asunto,
213
- remitente=remitente,
214
- cuerpo=cuerpo_ia
215
- )
216
-
217
- try:
218
- time.sleep(6) # 6s entre llamadas — respeta 15 RPM de Gemini Free
219
- res_ia = model.generate_content(prompt)
220
- correos_enviados_a_gemini += 1
221
- texto_ia = res_ia.text.strip().replace("```json", "").replace("```", "").strip()
222
- datos_ia = json.loads(texto_ia)
223
-
224
- if datos_ia.get("relacionado"):
225
- db.insert_smart_inbox(
226
- licitacion_activa, remitente, asunto,
227
- msg.get("Date"), datos_ia.get('resumen', ''),
228
- datos_ia.get('renglones', ''), cuerpo_limpio,
229
- datos_ia.get('borrador_respuesta', '')
230
- )
231
- logger.info(f"Correo guardado: '{asunto}' para licitación {licitacion_activa}")
232
-
233
- # Guardar cotizaciones extraídas (si las hay) en tabla comparador
234
- for cot in datos_ia.get('cotizaciones', []):
235
- renglon = str(cot.get('renglon', '')).strip()
236
- proveedor = remitente
237
- precio = float(cot.get('precio_unitario', 0) or 0)
238
- if renglon and precio > 0:
239
- if not db.check_cotizacion_exists(licitacion_activa, renglon, proveedor):
240
- db.insert_cotizacion(
241
- licitacion_activa, renglon, proveedor,
242
- precio,
243
- str(cot.get('moneda', 'USD')),
244
- str(cot.get('tiempo_entrega', 'N/A')),
245
- str(cot.get('condiciones', '')),
246
- str(msg.get('Date', '')),
247
- asunto
248
- )
249
- logger.info(f"Cotizacion guardada: Renglón {renglon} | {proveedor} | ${precio}")
250
- except Exception as parse_error:
251
- logger.warning(f"Error procesando correo '{asunto}': {parse_error}")
252
- continue
253
-
254
- mail.logout()
255
- logger.info(f"Escaneo de correos finalizado para usuario {username}.")
256
-
257
- except Exception as e:
258
- logger.error(f"Error crítico en hilo de correos: {e}")
259
-
260
-
261
- # --- 6. ENDPOINT ASÍNCRONO DE CORREOS ---
262
- @app.post("/api/v1/organizar-correos")
263
- def organizar_correos(
264
- background_tasks: BackgroundTasks,
265
- username: str = Form(...),
266
- servidor_imap: str = Form("mail.proyelec.com"),
267
- licitacion_activa: str = Form(...),
268
- contexto_items: str = Form(...),
269
- _token: str = Depends(verify_internal_token)
270
- ):
271
- # Validar credenciales de correo sincronamente antes de lanzar la tarea
272
  email_user, email_pass, _ = get_user_credentials(username)
273
  if not email_user or not email_pass:
 
274
  return {"status": "error", "mensaje": "No has configurado tu correo y contraseña en el panel lateral."}
275
-
276
- try:
277
- import imaplib
278
- mail = imaplib.IMAP4_SSL(servidor_imap, timeout=10)
279
- mail.login(email_user, email_pass)
280
- mail.logout()
281
  except imaplib.IMAP4.error as e:
 
282
  return {"status": "error", "mensaje": f"Autenticación rechazada. ¿Usas Office365 o Gmail? Necesitas una 'App Password'. Error: {e}"}
283
  except Exception as e:
 
284
  return {"status": "error", "mensaje": f"No se pudo conectar al servidor IMAP '{servidor_imap}'. Revisa la dirección del servidor. Error: {e}"}
285
 
286
  background_tasks.add_task(procesar_correos_background, username, servidor_imap, licitacion_activa, contexto_items)
 
287
  return {"status": "success", "mensaje": "✅ Conexión exitosa. Gemini está escaneando los correos en segundo plano..."}
288
-
289
-
290
- # --- 7. GENERADOR DE FICHAS TÉCNICAS (CON CACHE) ---
291
- @app.post("/api/v1/generar-ficha")
292
- def generar_ficha(
293
- username: str = Form(...),
294
- licitacion: str = Form(...),
295
- codigo_renglon: str = Form(...),
296
- pliego_context: str = Form(...),
297
- items_context: str = Form(...),
298
- gemini_key: str = Form(...),
299
- _token: str = Depends(verify_internal_token)
300
- ):
301
- # Verificar cache primero — si ya se generó, devolver sin gastar tokens
302
- cached = db.get_ficha_cache(username, licitacion, codigo_renglon)
303
- if cached:
304
- logger.info(f"Ficha para {codigo_renglon} servida desde cache.")
 
 
 
 
 
 
 
 
 
305
  return {"status": "success", "datasheet_md": cached, "from_cache": True}
306
-
307
- try:
308
- genai.configure(api_key=gemini_key)
309
- model = genai.GenerativeModel('gemini-2.5-flash')
310
- prompt = f"""Eres un Ingeniero de Compras especializado. Genera una ficha técnica en formato Markdown para el artículo: {codigo_renglon}.
311
- Condiciones del Pliego: {pliego_context}
312
- Detalle del Ítem: {items_context}
313
-
314
- La ficha debe contener:
315
- - **Título y Descripción breve**
316
- - **Tabla de Especificaciones Técnicas**
317
- - **Requisitos de Calidad / Certificaciones**
318
- - **Condiciones especiales de la licitación**
319
- Formato profesional y estructurado."""
320
-
321
- response = model.generate_content(prompt)
322
- datasheet = response.text
323
-
324
- # Guardar en cache para futuras consultas
325
- db.save_ficha_cache(username, licitacion, codigo_renglon, datasheet)
 
 
 
 
 
 
 
 
326
  logger.info(f"Ficha técnica generada y cacheada para {codigo_renglon}.")
327
- return {"status": "success", "datasheet_md": datasheet, "from_cache": False}
328
-
329
- except Exception as e:
330
- logger.error(f"Error generando ficha: {str(e)}")
 
 
 
 
 
 
 
 
 
 
 
331
  raise HTTPException(status_code=500, detail=str(e))
332
-
333
-
334
- # --- 8. ENDPOINTS REST ---
335
- class LoginRequest(BaseModel):
336
- username: str
337
- password: str
338
-
339
- @app.post("/api/v1/login")
340
  def login(req: LoginRequest, _token: str = Depends(verify_internal_token)):
341
  user = db.get_user(req.username, req.password)
342
  if user:
 
343
  return {"status": "success", "username": user[0], "role": user[2]}
 
344
  raise HTTPException(status_code=401, detail="Credenciales incorrectas")
345
-
346
- @app.get("/api/v1/workspace/{username}")
347
- def get_workspace(username: str, _token: str = Depends(verify_internal_token)):
348
- row = db.load_workspace_state(username)
349
- if row and row[0] and row[1]:
350
- df = pd.read_json(io.StringIO(row[0]))
351
- return {"cg": json.loads(row[1]), "items": df.to_dict(orient="records")}
352
- return {"cg": None, "items": []}
353
-
354
- @app.get("/api/v1/history/{username}")
355
- def get_history(username: str, skip: int = 0, limit: int = 50, _token: str = Depends(verify_internal_token)):
356
- df = db.get_user_history_df(username)
357
- return df.iloc[skip : skip+limit].to_dict(orient="records")
358
-
359
- @app.get("/api/v1/inbox/{licitacion}")
360
- def get_inbox(licitacion: str, skip: int = 0, limit: int = 50, _token: str = Depends(verify_internal_token)):
361
- df = db.get_correos_licitacion_df(licitacion)
362
- return df.iloc[skip : skip+limit].to_dict(orient="records")
363
-
364
- @app.get("/api/v1/configuracion/{username}")
365
- def get_config(username: str, _token: str = Depends(verify_internal_token)):
366
- user_creds = db.get_user_credentials(username)
367
- if user_creds:
368
- return {"status": "success", "email_user": user_creds[0], "gemini_key": user_creds[2]}
369
- return {"status": "error"}
370
-
371
- @app.post("/api/v1/configuracion")
372
- def save_config(
373
- username: str = Form(...),
374
- gemini_key: str = Form(...),
375
- email_user: str = Form(""),
376
- email_pass: str = Form(""),
377
- _token: str = Depends(verify_internal_token)
378
- ):
379
- existing = db.get_user_credentials(username)
380
- if not existing:
381
- raise HTTPException(status_code=404, detail="Usuario no encontrado")
382
-
383
- enc_pass = existing[1]
384
- if email_pass:
385
- enc_pass = crypto.encrypt_data(email_pass)
386
-
387
- existing_tavily = existing[3] if len(existing) > 3 else ""
388
-
389
- db.update_user_profile(username, gemini_key, existing_tavily, email_user, enc_pass)
390
- return {"status": "success"}
391
-
392
- # =============================================
393
- # CONSULTA AUTOMATICA AL SLI DE LA ACP
394
- # =============================================
395
-
396
  @app.get("/api/v1/consultar-sli/{rfq_id}")
397
  def consultar_sli(rfq_id: str, _token: str = Depends(verify_internal_token)):
 
398
  rfq_id = "".join(filter(str.isdigit, str(rfq_id or "")))
399
  if not rfq_id:
 
400
  raise HTTPException(
401
  status_code=400,
402
- detail={
403
- "message": "Numero de licitacion invalido.",
404
- "hint": "Ingresa solo el numero RFQ de la licitacion ACP."
405
- }
406
- )
407
-
408
- SLI_HOME_URL = "https://apps.pancanal.com/sli/LicitacionesBusqueda/Welcome"
409
- SLI_URL = f"https://apps.pancanal.com/sli/Licitaciones/LicitacionHeader?rfqId={rfq_id}"
410
-
411
- def extraer_resumen_acta(texto_acta, acta_url):
412
- texto_acta = re.sub(r"\s+", " ", texto_acta or "").strip()
413
- if not texto_acta:
414
- return {
415
- "disponible": False,
416
- "url": acta_url,
417
- "resumen": "",
418
- "hallazgos": [],
419
- "error": "El acta no contiene texto legible."
420
- }
421
-
422
- palabras_clave = [
423
- "no cumple", "incumple", "fallo", "falla", "deficiencia",
424
- "observacion", "observación", "subsan", "tecnico", "técnico",
425
- "rechaz", "descalific", "no acept", "aclaracion", "aclaración"
426
- ]
427
-
428
- partes = re.split(r"(?<=[.!?])\s+|\n+", texto_acta)
429
- hallazgos = []
430
-
431
- for parte in partes:
432
- parte_limpia = parte.strip()
433
- parte_lower = parte_limpia.lower()
434
- if len(parte_limpia) < 35:
435
- continue
436
- if any(palabra in parte_lower for palabra in palabras_clave):
437
- hallazgos.append(parte_limpia[:450])
438
- if len(hallazgos) >= 8:
439
- break
440
-
441
- if hallazgos:
442
- resumen = "Se detectaron posibles observaciones tecnicas o comentarios relevantes en el acta."
443
- elif any(palabra in texto_acta.lower() for palabra in ["cumple", "conforme", "adjudic"]):
444
- resumen = "No se detectaron fallos tecnicos evidentes en una lectura automatica del acta."
445
- else:
446
- resumen = "El acta fue encontrada, pero no se detectaron observaciones tecnicas claras automaticamente."
447
-
448
- return {
449
- "disponible": True,
450
- "url": acta_url,
451
- "resumen": resumen,
452
- "hallazgos": hallazgos,
453
- "texto_muestra": texto_acta[:1200],
454
- "error": None
455
- }
456
-
457
- try:
458
- from playwright.sync_api import (
459
- Error as PlaywrightError,
460
- TimeoutError as PlaywrightTimeoutError,
461
- sync_playwright,
462
- )
463
- except ImportError:
464
- raise HTTPException(
465
- status_code=503,
466
- detail={
467
- "message": "Playwright no esta instalado.",
468
- "hint": "Ejecuta: pip install playwright && playwright install chromium"
469
- }
470
- )
471
-
472
- browser = None
473
- resumen_acta = {
474
- "disponible": False,
475
- "url": None,
476
- "resumen": "",
477
- "hallazgos": [],
478
- "error": "No se encontro el boton de resumen de propuestas recibidas."
479
- }
480
-
481
- try:
482
- with sync_playwright() as p:
483
- try:
484
- browser = p.chromium.launch(
485
- headless=True,
486
- args=[
487
- "--no-sandbox",
488
- "--disable-dev-shm-usage",
489
- "--disable-blink-features=AutomationControlled"
490
- ]
491
- )
492
- except PlaywrightError as e:
493
- msg = str(e)
494
- if "Executable doesn't exist" in msg or "playwright install" in msg:
495
- raise HTTPException(
496
- status_code=503,
497
- detail={
498
- "message": "Chromium de Playwright no esta instalado.",
499
- "hint": "Ejecuta: playwright install chromium"
500
- }
501
- )
502
- raise
503
-
504
- page = browser.new_page()
505
- page.set_default_timeout(15000)
506
- page.set_extra_http_headers({
507
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124"
508
- })
509
-
510
- response = page.goto(SLI_HOME_URL, wait_until="domcontentloaded", timeout=30000)
511
- if response and response.status >= 500:
512
- raise HTTPException(
513
- status_code=502,
514
- detail={
515
- "message": f"El SLI respondio con HTTP {response.status}.",
516
- "hint": "El portal de ACP puede estar caido o inestable. Intenta de nuevo mas tarde."
517
- }
518
- )
519
-
520
- page.wait_for_selector("#rfqId", timeout=15000)
521
- page.fill("#rfqId", rfq_id)
522
-
523
- if page.locator("#hfEstatusSeleccionadoID").count() > 0:
524
- page.evaluate(
525
- 'document.getElementById("hfEstatusSeleccionadoID").value = "TODOS";'
526
- )
527
-
528
- page.click("input[type='submit']")
529
-
530
- try:
531
- page.wait_for_function(
532
- "() => document.body.innerText.includes('Detalle de RFQ') || "
533
- "document.body.innerText.includes('EVALUACI') || "
534
- "document.body.innerText.includes('No se encontraron') || "
535
- "document.body.innerText.includes('InternalServer')",
536
- timeout=20000
537
- )
538
- except PlaywrightTimeoutError:
539
- logger.warning(f"Timeout esperando resultados del SLI para RFQ {rfq_id}")
540
-
541
- content = page.content()
542
- SLI_URL = page.url
543
-
544
- resumen_visible = page.locator(".ResPropRec").count() > 0
545
- po_header_match = re.search(r"po_header\s*[=:]\s*['\"]?(\d+)", content, re.IGNORECASE)
546
- if not po_header_match:
547
- po_header_match = re.search(r"po_header=(\d+)", content, re.IGNORECASE)
548
-
549
- if resumen_visible and po_header_match:
550
- po_header = po_header_match.group(1)
551
- acta_url = urljoin(
552
- SLI_URL,
553
- f"../Comunes/ImpresionActaResumen?p_rfq={rfq_id}&po_header={po_header}"
554
- )
555
-
556
- try:
557
- acta_response = page.request.get(
558
- acta_url,
559
- headers={"Referer": SLI_URL},
560
- timeout=30000
561
- )
562
- acta_bytes = acta_response.body()
563
- content_type = (acta_response.headers.get("content-type") or "").lower()
564
-
565
- if "pdf" in content_type or acta_bytes[:4] == b"%PDF":
566
- try:
567
- from pypdf import PdfReader
568
-
569
- reader = PdfReader(io.BytesIO(acta_bytes))
570
- texto_acta = "\n".join(
571
- page_pdf.extract_text() or ""
572
- for page_pdf in reader.pages
573
- )
574
- resumen_acta = extraer_resumen_acta(texto_acta, acta_url)
575
- except ImportError:
576
- resumen_acta = {
577
- "disponible": False,
578
- "url": acta_url,
579
- "resumen": "",
580
- "hallazgos": [],
581
- "error": "pypdf no esta instalado para leer el PDF del resumen."
582
- }
583
- else:
584
- html_acta = acta_bytes.decode("utf-8", errors="ignore")
585
- texto_acta = BeautifulSoup(html_acta, "html.parser").get_text(
586
- separator=" ",
587
- strip=True
588
- )
589
- resumen_acta = extraer_resumen_acta(texto_acta, acta_url)
590
-
591
- except Exception as e:
592
- logger.warning(f"No se pudo leer acta resumen SLI {rfq_id}: {e}")
593
- resumen_acta = {
594
- "disponible": False,
595
- "url": acta_url,
596
- "resumen": "",
597
- "hallazgos": [],
598
- "error": "Se encontro el resumen, pero no se pudo leer automaticamente."
599
- }
600
-
601
- except HTTPException:
602
- raise
603
- except PlaywrightTimeoutError as e:
604
- logger.warning(f"Timeout consultando SLI {rfq_id}: {e}")
605
- raise HTTPException(
606
- status_code=504,
607
- detail={
608
- "message": "El SLI tardo demasiado en responder.",
609
- "hint": "Verifica la conexion o intenta nuevamente en unos minutos."
610
- }
611
- )
612
- except PlaywrightError as e:
613
- logger.exception(f"Error de Playwright consultando SLI {rfq_id}")
614
- raise HTTPException(
615
- status_code=502,
616
- detail={
617
- "message": "No se pudo consultar el portal SLI.",
618
- "hint": "El portal pudo cambiar, bloquear la automatizacion o estar temporalmente fuera de servicio.",
619
- "technical": str(e)[:500]
620
- }
621
- )
622
- except Exception as e:
623
- logger.exception(f"Error inesperado consultando SLI {rfq_id}")
624
- raise HTTPException(
625
- status_code=500,
626
- detail={
627
- "message": "Error inesperado consultando el SLI.",
628
- "hint": "Revisa backend.log para ver el traceback completo.",
629
- "technical": str(e)[:500]
630
- }
631
- )
632
- finally:
633
- if browser:
634
- try:
635
- browser.close()
636
- except Exception:
637
- pass
638
-
639
- try:
640
-
641
- soup_sli = BeautifulSoup(content, "html.parser")
642
-
643
- texto_sli = soup_sli.get_text(separator="|", strip=True)
644
-
645
- tokens = [t.strip() for t in texto_sli.split("|") if t.strip()]
646
-
647
- def buscar_valor(etiquetas):
648
-
649
- for i, tok in enumerate(tokens):
650
-
651
- for etiq in etiquetas:
652
-
653
- if (
654
- tok.strip().lower() == etiq.lower()
655
- or tok.strip().lower() == f"{etiq.lower()}:"
656
- ):
657
-
658
- for j in range(i + 1, min(i + 4, len(tokens))):
659
-
660
- cand = tokens[j]
661
-
662
- if (
663
- cand
664
- and not any(
665
- e.lower() == cand.strip().lower()
666
- for e in etiquetas
667
- )
668
- and len(cand) > 2
669
- ):
670
- return cand
671
-
672
- return None
673
-
674
- resultado = {
675
- "rfq_id": rfq_id,
676
- "url": SLI_URL,
677
- "estatus": buscar_valor(["Estatus", "Estado"]),
678
- "descripcion": buscar_valor(["Descripción", "Descripcion"]),
679
- "fecha_cierre": buscar_valor([
680
- "Fecha y hora de cierre",
681
- "Fecha de cierre",
682
- "Cierre"
683
- ]),
684
- "fecha_publicacion": buscar_valor([
685
- "Fecha de publicación",
686
- "Publicación",
687
- "Publicacion"
688
- ]),
689
- "ultima_revision": buscar_valor([
690
- "Última revisión",
691
- "Ultima Revision",
692
- "Última Revisión"
693
- ]),
694
- "agente_compras": buscar_valor([
695
- "Agente de compras",
696
- "Agente Compras",
697
- "Purchasing Agent"
698
- ]),
699
- "resumen_acta": resumen_acta,
700
- "error": None
701
- }
702
-
703
- ESTADOS_SLI = [
704
- "EVALUACIÓN",
705
- "EVALUACION",
706
- "ADJUDICACIÓN",
707
- "ADJUDICACION",
708
- "CANCELACIÓN",
709
- "CANCELACION",
710
- "ACTO DESIERTO",
711
- "DESIERTA",
712
- "ENMENDADA",
713
- "ANUNCIO VENCIDO",
714
- "ABIERTA",
715
- "PRECALIFICACIÓN"
716
- ]
717
-
718
- if not resultado["estatus"]:
719
-
720
- texto_upper = texto_sli.upper()
721
-
722
- for estado in ESTADOS_SLI:
723
-
724
- if estado in texto_upper:
725
- resultado["estatus"] = estado.title()
726
- break
727
-
728
- if not resultado["estatus"] and not resultado["descripcion"]:
729
-
730
- resultado["error"] = (
731
- "No se encontró información. "
732
- "Verifica el número de licitación o intenta más tarde."
733
- )
734
-
735
  logger.info(
736
  f"Consulta SLI {rfq_id}: "
737
  f"estatus={resultado['estatus']} | "
738
  f"desc={resultado['descripcion']}"
739
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
740
 
741
  return resultado
742
  except Exception as e:
743
  logger.exception(f"Error parseando respuesta SLI {rfq_id}")
744
- raise HTTPException(
745
- status_code=500,
746
- detail={
747
- "message": "El SLI respondio, pero no se pudo interpretar la pagina.",
748
- "hint": "Puede haber cambiado el formato del portal ACP.",
749
- "technical": str(e)[:500]
750
- }
751
  )
 
 
 
 
 
 
 
 
 
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from typing import List
4
  from pydantic import BaseModel
 
5
  import tempfile
6
  import os
7
+ try:
8
+ import truststore
9
+ truststore.inject_into_ssl()
10
+ except Exception:
11
+ pass
12
+ try:
13
+ import certifi
14
+ CERTIFI_CA_BUNDLE = certifi.where()
15
+ os.environ.setdefault("SSL_CERT_FILE", CERTIFI_CA_BUNDLE)
16
+ os.environ.setdefault("REQUESTS_CA_BUNDLE", CERTIFI_CA_BUNDLE)
17
+ os.environ.setdefault("GRPC_DEFAULT_SSL_ROOTS_FILE_PATH", CERTIFI_CA_BUNDLE)
18
+ except Exception:
19
+ pass
20
+ import google.generativeai as genai
21
  import json
22
+ import imaplib
23
+ import email
24
+ from email.header import decode_header
25
+ import re
26
+ import hashlib
27
+ import time
28
+ import pandas as pd
29
+ import io
30
+ from urllib.parse import urljoin
31
+ from dotenv import load_dotenv
32
+ import database as db
33
+ import logging
34
+ from logging.handlers import RotatingFileHandler
35
+ from bs4 import BeautifulSoup
36
+ import sys
37
+ import asyncio
38
+ import crypto
39
+
40
+ if sys.platform == "win32":
41
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
42
+ load_dotenv()
43
+
44
+ # --- 1. LOGGING CON ROTACIÓN (max 2MB, 3 backups) ---
45
+ log_handler = RotatingFileHandler('backend.log', maxBytes=2*1024*1024, backupCount=3, encoding='utf-8')
46
+ log_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
47
+ logger = logging.getLogger(__name__)
48
+ logger.setLevel(logging.INFO)
49
+ logger.addHandler(log_handler)
50
+
51
+ # --- 2. SEGURIDAD Y CIFRADO ---
52
+ # Se utiliza el módulo centralizado `crypto.py`
53
+ INTERNAL_API_TOKEN = os.getenv("INTERNAL_API_TOKEN", "default-dev-token")
54
+
55
+ def verify_internal_token(x_internal_token: str = Header(None)):
56
+ if x_internal_token != INTERNAL_API_TOKEN:
57
+ raise HTTPException(status_code=403, detail="Acceso denegado: Token interno inválido.")
58
+ return x_internal_token
59
+
60
+
61
+ # --- 3. APP FASTAPI ---
62
+ app = FastAPI(title="Proyelec Core API v6.1")
63
+
64
+ app.add_middleware(
65
+ CORSMiddleware,
66
+ allow_origins=["http://localhost:8501", "http://127.0.0.1:8501", "http://localhost:3000"],
67
+ allow_credentials=True,
68
+ allow_methods=["*"],
69
+ allow_headers=["*"],
70
+ )
71
+
72
+ @app.get("/")
73
+ def estado():
74
+ return {"status": "Online", "engine": "Proyelec Core v6.1 — Token-Optimized"}
75
+
76
+ # --- 4. PROMPT ANALISTA DE PLIEGOS (Multi-documento) ---
77
  PROMPT_ANALISTA_MULTI = """
78
  Eres un Analista Senior de Procura. Analiza TODO el conjunto de documentos proporcionados (Pliego Principal y Anexos Técnicos).
79
  Cruza la información de todos los documentos para obtener descripciones técnicas exactas.
80
+
81
+ REGLA DE ORO ESTRICTA:
82
+ Tu análisis debe basarse ÚNICA Y EXCLUSIVAMENTE en el texto, tablas y datos contenidos en los documentos adjuntos.
83
+ No busques información en internet, no deduzcas, no asumas y no uses conocimiento externo sobre leyes, fabricantes, estándares o prácticas comerciales.
84
+ Si un dato no aparece explícitamente en los documentos, devuelve exactamente: "No especificado en los documentos adjuntos".
85
+
86
  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'.
87
+ IMPORTANTE: No confundas 'ficha_tecnica_completa' con 'requiere_ficha_tecnica'.
88
+ - 'ficha_tecnica_completa' resume las especificaciones técnicas del producto/renglón.
89
+ - 'requiere_ficha_tecnica' solo indica si el oferente debe ENTREGAR/ADJUNTAR un documento técnico en la oferta.
90
+
91
+ Extrae también controles técnicos críticos para decidir participación:
92
+ - 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.
93
+ - 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.
94
+ - 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.
95
+ - 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 "".
96
+ - riesgo_tecnico_global: "Bajo", "Medio" o "Alto" según restricciones de marca/proveedor, falta de equivalentes, fichas técnicas obligatorias y riesgo de obsolescencia.
97
+ - 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)".
98
+ - propuesta_tecnica_aplica_renglones: lista de renglones/líneas donde aplica la propuesta técnica. Si aplica globalmente, usa ["Todos"]. Si no se especifica, [].
99
+ - evidencia_propuesta_tecnica: cita corta exacta donde se pide la propuesta técnica y se indica a qué líneas aplica.
100
+ - 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".
101
+ - correo_encargado_licitacion: correo electrónico del contacto de la licitación si aparece. Si no aparece, devuelve "No especificado en los documentos adjuntos".
102
+ - telefono_encargado_licitacion: teléfono del contacto de la licitación si aparece. Si no aparece, devuelve "No especificado en los documentos adjuntos".
103
+ - 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.
104
+ - 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 "".
105
+ - empresa_recomendada_participacion: "EP" si requiere_presencia_local es true; "Proyelec" si requiere_presencia_local es false; "Validar" si requiere_presencia_local es null.
106
+
107
+ Para cada renglón extrae:
108
+ - 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 "".
109
+ - requiere_propuesta_tecnica: true si la propuesta técnica aplica a ese renglón/línea; false si no aplica.
110
+ - 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.
111
+ - marca_modelo_requerido: marca, fabricante, modelo o número de parte exigido para ese renglón. Si no hay, null.
112
+ - acepta_equivalente: true si ese renglón acepta equivalente; false si no acepta; null si no se puede determinar.
113
+ - 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.
114
+ - evidencia_tecnica: cita corta o inciso relevante para ficha técnica, marca/modelo, equivalentes u obsolescencia de ese renglón.
115
+
116
+ Regla especial para 'requiere_ficha_tecnica':
117
+ - NO marques true solo porque exista una marca restringida.
118
+ - NO marques true solo porque exista número de parte, modelo, material, dimensión, norma o especificación técnica.
119
+ - 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.
120
+ - Si la evidencia no contiene una exigencia documental clara, requiere_ficha_tecnica debe ser false.
121
+
122
+ Regla especial para 'requiere_propuesta_tecnica':
123
+ - 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.
124
+ - 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.
125
+ - 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.
126
 
127
  Responde ÚNICAMENTE con el siguiente JSON estricto, sin texto adicional:
128
+ {"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"},
129
+ "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": ""}]}
130
  """
131
 
132
+ DOCUMENTAL_KEYWORDS = [
133
+ "presentar ficha", "adjuntar ficha", "entregar ficha", "ficha tecnica", "ficha técnica",
134
+ "catalogo", "catálogo", "datasheet", "data sheet", "certificado", "certificacion",
135
+ "certificación", "manual", "carta del fabricante", "carta de fabricante", "plano",
136
+ "muestra", "submittal", "hoja de seguridad", "ficha de seguridad", "msds",
137
+ ]
138
+
139
+ ACP_CODE_RE = re.compile(r"\b([A-Z]{3})-([A-Z]{3})-(\d{5})\b", re.IGNORECASE)
140
+
141
+ def _normalize_acp_code(value):
142
+ text = str(value or "").strip().upper()
143
+ match = ACP_CODE_RE.search(text)
144
+ if not match:
145
+ return ""
146
+ return f"{match.group(1).upper()}-{match.group(2).upper()}-{match.group(3)}"
147
+
148
+ def _parse_rows_from_scope_text(value):
149
+ rows = set()
150
+ if value is None:
151
+ return rows, False
152
+ if isinstance(value, (list, tuple, set)):
153
+ all_rows = False
154
+ for item in value:
155
+ item_text = str(item or "").strip().lower()
156
+ if item_text in ["todos", "todas", "all"]:
157
+ all_rows = True
158
+ rows.update(re.findall(r"\d+", item_text))
159
+ return rows, all_rows
160
+
161
+ text = str(value or "").strip()
162
+ if not text:
163
+ return rows, False
164
+ if re.search(r"\b(todos|todas|global|all)\b", text, re.IGNORECASE):
165
+ return rows, True
166
+
167
+ scoped_matches = re.findall(
168
+ r"(?:l[ií]neas?|renglones?)\s+([0-9][0-9,\s\-yY]*)",
169
+ text,
170
+ flags=re.IGNORECASE,
171
+ )
172
+ for match in scoped_matches:
173
+ rows.update(re.findall(r"\d+", match))
174
+ return rows, False
175
+
176
+ def _to_bool(value, default=False):
177
+ if isinstance(value, bool):
178
+ return value
179
+ if value is None:
180
+ return default
181
+ text = str(value).strip().lower()
182
+ if text in ["true", "si", "sí", "yes", "1"]:
183
+ return True
184
+ if text in ["false", "no", "0", "none", "null", "n/a", ""]:
185
+ return False
186
+ return default
187
+
188
+ def _to_optional_bool(value):
189
+ if isinstance(value, bool):
190
+ return value
191
+ if value is None:
192
+ return None
193
+ text = str(value).strip().lower()
194
+ if text in ["true", "si", "sí", "yes", "1"]:
195
+ return True
196
+ if text in ["false", "no", "0"]:
197
+ return False
198
+ return None
199
+
200
+ def postprocess_technical_analysis(data: dict) -> dict:
201
+ """Reduce falsos positivos entre especificaciones técnicas y entregables documentales."""
202
+ items = data.get("items", []) if isinstance(data, dict) else []
203
+ proposal_rows = []
204
+ for item in items:
205
+ if not isinstance(item, dict):
206
+ continue
207
+ item["codigo_articulo"] = _normalize_acp_code(item.get("codigo_articulo"))
208
+ requiere_propuesta = _to_bool(item.get("requiere_propuesta_tecnica"), default=False)
209
+ item["requiere_propuesta_tecnica"] = requiere_propuesta
210
+ if requiere_propuesta:
211
+ renglon = str(item.get("renglon") or "").strip()
212
+ if renglon:
213
+ proposal_rows.append(renglon)
214
+
215
+ requiere = _to_bool(item.get("requiere_ficha_tecnica"), default=False)
216
+ evidencia = str(item.get("evidencia_tecnica") or "").lower()
217
+ combined = evidencia
218
+
219
+ has_documental_evidence = any(keyword in combined for keyword in DOCUMENTAL_KEYWORDS)
220
+ if requiere and not has_documental_evidence:
221
+ item["requiere_ficha_tecnica"] = False
222
+ if evidencia:
223
+ item["evidencia_tecnica"] = f"{item.get('evidencia_tecnica')} | Nota sistema: no se detectó entregable documental explícito."
224
+ else:
225
+ item["evidencia_tecnica"] = "No especificado en los documentos adjuntos"
226
+ else:
227
+ item["requiere_ficha_tecnica"] = requiere
228
+
229
+ item["posible_obsolescencia"] = _to_bool(item.get("posible_obsolescencia"), default=False)
230
+
231
+ cg = data.get("condiciones_generales", {}) if isinstance(data, dict) else {}
232
+ if isinstance(cg, dict):
233
+ scope_rows, scope_all = _parse_rows_from_scope_text(cg.get("propuesta_tecnica_aplica_renglones"))
234
+ text_scope_rows, text_scope_all = _parse_rows_from_scope_text(
235
+ f"{cg.get('propuesta_tecnica_requerida', '')} {cg.get('evidencia_propuesta_tecnica', '')}"
236
+ )
237
+ scope_rows.update(text_scope_rows)
238
+ scope_all = scope_all or text_scope_all
239
+ proposal_required = str(cg.get("propuesta_tecnica_requerida", "") or "").strip().lower().startswith(("si", "sí")) or bool(scope_rows) or scope_all
240
+
241
+ if proposal_required and (scope_all or scope_rows):
242
+ proposal_rows = []
243
+ for item in items:
244
+ if not isinstance(item, dict):
245
+ continue
246
+ renglon = str(item.get("renglon") or "").strip()
247
+ row_number_match = re.search(r"\d+", renglon)
248
+ row_number = row_number_match.group(0) if row_number_match else ""
249
+ applies = scope_all or row_number in scope_rows
250
+ item["requiere_propuesta_tecnica"] = bool(applies)
251
+ if applies and row_number:
252
+ proposal_rows.append(row_number)
253
+ if scope_rows:
254
+ proposal_rows = sorted(set(scope_rows), key=lambda x: int(x) if x.isdigit() else x)
255
+ elif scope_all:
256
+ cg["propuesta_tecnica_requerida"] = "Si (aplica a todos los renglones)"
257
+ cg["propuesta_tecnica_aplica_renglones"] = ["Todos"]
258
+ if proposal_rows:
259
+ unique_rows = sorted(set(proposal_rows), key=lambda x: int(x) if x.isdigit() else x)
260
+ cg["propuesta_tecnica_requerida"] = f"Si (aplica líneas {', '.join(unique_rows)})"
261
+ cg["propuesta_tecnica_aplica_renglones"] = unique_rows
262
+ elif str(cg.get("propuesta_tecnica_requerida", "")).strip().lower() in ["", "n/a", "none", "null"]:
263
+ cg["propuesta_tecnica_requerida"] = "No especificado en los documentos adjuntos"
264
+
265
+ for key in [
266
+ "persona_encargada_licitacion",
267
+ "correo_encargado_licitacion",
268
+ "telefono_encargado_licitacion",
269
+ ]:
270
+ if str(cg.get(key, "") or "").strip().lower() in ["", "n/a", "none", "null"]:
271
+ cg[key] = "No especificado en los documentos adjuntos"
272
+
273
+ presencia_local = _to_optional_bool(cg.get("requiere_presencia_local"))
274
+ cg["requiere_presencia_local"] = presencia_local
275
+ if presencia_local is True:
276
+ cg["empresa_recomendada_participacion"] = "EP"
277
+ elif presencia_local is False:
278
+ cg["empresa_recomendada_participacion"] = "Proyelec"
279
+ else:
280
+ cg["empresa_recomendada_participacion"] = "Validar"
281
+ if str(cg.get("evidencia_presencia_local", "") or "").strip().lower() in ["n/a", "none", "null"]:
282
+ cg["evidencia_presencia_local"] = ""
283
+
284
+ return data
285
+
286
  @app.post("/api/v1/analizar-pliego")
287
+ async def analizar_pliego(
288
+ archivos_pdf: List[UploadFile] = File(...),
289
+ gemini_key: str = Form(...),
290
+ username: str = Form("API"),
291
+ role: str = Form(""),
292
+ _token: str = Depends(verify_internal_token)
293
+ ):
294
+ started_at = time.perf_counter()
295
  api_key_clean = gemini_key.strip()
296
  try:
297
+ genai.configure(api_key=api_key_clean, transport="rest")
298
+ archivos_subidos = []
299
+
300
+ for archivo in archivos_pdf:
301
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
302
+ content = await archivo.read()
303
+ tmp.write(content)
304
+ tmp_path = tmp.name
305
+
306
+ uploaded_file = genai.upload_file(path=tmp_path, mime_type="application/pdf")
307
+ archivos_subidos.append(uploaded_file)
308
+ os.remove(tmp_path)
309
+
310
+ # gemini-2.5-flash para análisis complejo de PDFs
311
  model = genai.GenerativeModel('gemini-2.5-flash', generation_config={"response_mime_type": "application/json"})
312
  response = model.generate_content([PROMPT_ANALISTA_MULTI, *archivos_subidos])
313
+ try:
314
+ db.log_ai_usage(
315
+ username=username,
316
+ role=role,
317
+ action="analizar_pliego",
318
+ model="gemini-2.5-flash",
319
+ usage_metadata=response.usage_metadata,
320
+ duration_ms=int((time.perf_counter() - started_at) * 1000),
321
+ metadata={"pdf_count": len(archivos_subidos)}
322
+ )
323
+ except Exception as e:
324
+ logger.warning(f"Error logging metric: {e}")
325
+
326
+
327
+ # Limpieza de archivos en la nube de Gemini para evitar llenar la cuota
328
+ for f in archivos_subidos:
329
+ try:
330
+ genai.delete_file(f.name)
331
+ except Exception as e:
332
+ logger.warning(f"No se pudo borrar archivo temporal de Gemini: {e}")
333
 
334
  logger.info(f"{len(archivos_subidos)} pliego(s) analizados exitosamente.")
335
+ data = json.loads(response.text)
336
+ return postprocess_technical_analysis(data)
337
 
338
  except Exception as e:
339
  logger.error(f"Error analizando pliego: {str(e)}")
340
+ db.log_usage_event(
341
+ username=username,
342
+ role=role,
343
+ module="ai",
344
+ action="analizar_pliego",
345
+ provider="gemini",
346
+ model="gemini-2.5-flash",
347
+ status="error",
348
+ error_message=str(e)[:500],
349
+ duration_ms=int((time.perf_counter() - started_at) * 1000),
350
+ metadata={"pdf_count": len(archivos_pdf)}
351
+ )
352
  raise HTTPException(status_code=500, detail=str(e))
353
+
354
+ # --- 5. FUNCIONES DE APOYO PARA CORREOS (HILO SECUNDARIO) ---
355
+ def get_user_credentials(username):
356
+ row = db.get_user_credentials(username)
357
+ return (row[0], crypto.decrypt_data(row[1]), row[2]) if row else (None, None, None)
358
+
359
+ # Prompt compacto: clasifica Y extrae cotizacion en una sola llamada (cero tokens extra)
360
+ PROMPT_CLASIFICADOR_CORREOS = """Eres un asistente de procura. Analiza este correo en relacion a la licitacion {licitacion}.
361
+ Items de referencia: {contexto_items_resumido}
362
+
363
+ Correo:
364
+ Asunto: {asunto}\nRemitente: {remitente}\nCuerpo: {cuerpo}
365
+
366
+ Responde SOLO con este JSON (sin texto adicional):
367
+ {{"relacionado": true/false,
368
+ "resumen": "1 linea de lo que ofrece el proveedor",
369
+ "renglones": "numeros separados por coma ej: 1, 3",
370
+ "borrador_respuesta": "correo de respuesta profesional firmado como Departamento de Compras",
371
+ "cotizaciones": [
372
+ {{"renglon": "1", "precio_unitario": 0.0, "moneda": "USD", "tiempo_entrega": "30 dias", "condiciones": "FOB"}}
373
+ ]
374
+ }}
375
+ Si el correo no contiene precios, devuelve cotizaciones como lista vacia [].
376
+ """
377
+
378
+ def procesar_correos_background(username: str, servidor_imap: str, licitacion_activa: str, contexto_items: str):
379
+ email_user, email_pass, gemini_key = get_user_credentials(username)
380
+ if not email_user or not email_pass:
381
+ logger.warning(f"Sin credenciales de correo para usuario {username}")
382
+ return
383
+
384
+ try:
385
+ genai.configure(api_key=gemini_key, transport="rest")
386
+ # gemini-2.5-flash para clasificación simple de correos
387
+ model = genai.GenerativeModel('gemini-2.5-flash')
388
+
389
+ # Reducir contexto enviado: solo los 3 campos clave, NO la ficha técnica completa
390
+ try:
391
+ df_items = pd.read_json(io.StringIO(contexto_items))
392
+ cols_disponibles = [c for c in ['renglon', 'codigo_articulo', 'termino_de_busqueda_corto'] if c in df_items.columns]
393
+ contexto_resumido = df_items[cols_disponibles].to_json(orient="records", force_ascii=False)
394
+ except Exception:
395
+ contexto_resumido = contexto_items[:500] # fallback seguro
396
+
397
+ mail = imaplib.IMAP4_SSL(servidor_imap)
398
+ mail.login(email_user, email_pass)
399
+ mail.select("inbox")
400
+
401
+ status, mensajes = mail.search(None, 'ALL')
402
+ if not mensajes[0]:
403
+ return
404
+
405
+ # Evaluamos los últimos 50 correos, pero enviaremos máximo 15 a Gemini
406
+ lista_ids = mensajes[0].split()[-50:]
407
+ correos_enviados_a_gemini = 0
408
+
409
+ for id_correo in lista_ids:
410
+ if correos_enviados_a_gemini >= 15:
411
+ break
412
+ res, data = mail.fetch(id_correo, '(RFC822)')
413
+ for part in data:
414
+ if isinstance(part, tuple):
415
+ msg = email.message_from_bytes(part[1])
416
+ subj_raw = decode_header(msg.get("Subject", ""))[0]
417
+ asunto = subj_raw[0].decode(subj_raw[1] or 'utf-8', errors='ignore') if isinstance(subj_raw[0], bytes) else str(subj_raw[0])
418
+ remitente = msg.get("From", "Desconocido")
419
+
420
+ if db.check_email_exists(licitacion_activa, asunto, remitente):
421
+ continue
422
+
423
+ # Pre-filtro inteligente y ahorrador de tokens:
424
+ num_lic_clean = "".join(re.findall(r'\d+', licitacion_activa))
425
+
426
+ # Evitar procesar correos automáticos o spam obvio
427
+ if "no-reply" in remitente.lower() or "newsletter" in remitente.lower() or "marketing" in remitente.lower():
428
+ continue
429
+
430
+ # Extraer el cuerpo antes para poder filtrarlo
431
+ cuerpo_crudo = ""
432
+ if msg.is_multipart():
433
+ for p in msg.walk():
434
+ if p.get_content_type() == "text/plain":
435
+ cuerpo_crudo += p.get_payload(decode=True).decode(errors='ignore')
436
+ else:
437
+ cuerpo_crudo = msg.get_payload(decode=True).decode(errors='ignore')
438
+
439
+ cuerpo_limpio = " ".join(cuerpo_crudo.split())
440
+
441
+ # Chequeo flexible: Si menciona el número de licitación o tiene palabras clave de B2B
442
+ # Busca tanto en el Asunto como en los primeros 300 caracteres del correo
443
+ texto_busqueda = (asunto + " " + cuerpo_limpio[:300]).upper()
444
+ palabras_clave = ["RFQ", "COTIZA", "QUOTE", "PROCURA", "PRECIO", "OFERTA", "SUMINISTRO", "USD", "$", "ATTACH", "ADJUNT", "REQUIREMENT", "TECH", "ESPECIFICACION", "DELIVERY", "ENTREGA"]
445
+
446
+ es_relevante = (num_lic_clean in texto_busqueda) or (any(p in texto_busqueda for p in palabras_clave))
447
+
448
+ if not es_relevante:
449
+ continue
450
+
451
+ # Limitar cuerpo a 1200 chars (antes 2000) - ahorra 40% de tokens de Gemini
452
+ cuerpo_ia = cuerpo_limpio[:1200]
453
+
454
+ prompt = PROMPT_CLASIFICADOR_CORREOS.format(
455
+ licitacion=licitacion_activa,
456
+ contexto_items_resumido=contexto_resumido,
457
+ asunto=asunto,
458
+ remitente=remitente,
459
+ cuerpo=cuerpo_ia
460
+ )
461
+
462
+ try:
463
+ time.sleep(6) # 6s entre llamadas — respeta 15 RPM de Gemini Free
464
+ res_ia = model.generate_content(prompt)
465
+ correos_enviados_a_gemini += 1
466
+ texto_ia = res_ia.text.strip().replace("```json", "").replace("```", "").strip()
467
+ datos_ia = json.loads(texto_ia)
468
+
469
+ if datos_ia.get("relacionado"):
470
+ db.insert_smart_inbox(
471
+ licitacion_activa, remitente, asunto,
472
+ msg.get("Date"), datos_ia.get('resumen', ''),
473
+ datos_ia.get('renglones', ''), cuerpo_limpio,
474
+ datos_ia.get('borrador_respuesta', '')
475
+ )
476
+ logger.info(f"Correo guardado: '{asunto}' para licitación {licitacion_activa}")
477
+
478
+ # Guardar cotizaciones extraídas (si las hay) en tabla comparador
479
+ for cot in datos_ia.get('cotizaciones', []):
480
+ renglon = str(cot.get('renglon', '')).strip()
481
+ proveedor = remitente
482
+ precio = float(cot.get('precio_unitario', 0) or 0)
483
+ if renglon and precio > 0:
484
+ if not db.check_cotizacion_exists(licitacion_activa, renglon, proveedor):
485
+ db.insert_cotizacion(
486
+ licitacion_activa, renglon, proveedor,
487
+ precio,
488
+ str(cot.get('moneda', 'USD')),
489
+ str(cot.get('tiempo_entrega', 'N/A')),
490
+ str(cot.get('condiciones', '')),
491
+ str(msg.get('Date', '')),
492
+ asunto
493
+ )
494
+ logger.info(f"Cotizacion guardada: Renglón {renglon} | {proveedor} | ${precio}")
495
+ except Exception as parse_error:
496
+ logger.warning(f"Error procesando correo '{asunto}': {parse_error}")
497
+ continue
498
+
499
+ mail.logout()
500
+ logger.info(f"Escaneo de correos finalizado para usuario {username}.")
501
+
502
+ except Exception as e:
503
+ logger.error(f"Error crítico en hilo de correos: {e}")
504
+
505
+
506
+ # --- 6. ENDPOINT ASÍNCRONO DE CORREOS ---
507
+ @app.post("/api/v1/organizar-correos")
508
+ def organizar_correos(
509
+ background_tasks: BackgroundTasks,
510
+ username: str = Form(...),
511
+ servidor_imap: str = Form("mail.proyelec.com"),
512
+ licitacion_activa: str = Form(...),
513
+ contexto_items: str = Form(...),
514
+ _token: str = Depends(verify_internal_token)
515
+ ):
516
+ # Validar credenciales de correo sincronamente antes de lanzar la tarea
517
  email_user, email_pass, _ = get_user_credentials(username)
518
  if not email_user or not email_pass:
519
+ db.log_usage_event(username=username, module="email", action="organizar_correos", licitacion=licitacion_activa, status="error", error_message="Credenciales de correo faltantes")
520
  return {"status": "error", "mensaje": "No has configurado tu correo y contraseña en el panel lateral."}
521
+
522
+ try:
523
+ import imaplib
524
+ mail = imaplib.IMAP4_SSL(servidor_imap, timeout=10)
525
+ mail.login(email_user, email_pass)
526
+ mail.logout()
527
  except imaplib.IMAP4.error as e:
528
+ db.log_usage_event(username=username, module="email", action="organizar_correos", licitacion=licitacion_activa, status="error", error_message=str(e)[:500])
529
  return {"status": "error", "mensaje": f"Autenticación rechazada. ¿Usas Office365 o Gmail? Necesitas una 'App Password'. Error: {e}"}
530
  except Exception as e:
531
+ db.log_usage_event(username=username, module="email", action="organizar_correos", licitacion=licitacion_activa, status="error", error_message=str(e)[:500])
532
  return {"status": "error", "mensaje": f"No se pudo conectar al servidor IMAP '{servidor_imap}'. Revisa la dirección del servidor. Error: {e}"}
533
 
534
  background_tasks.add_task(procesar_correos_background, username, servidor_imap, licitacion_activa, contexto_items)
535
+ db.log_usage_event(username=username, module="email", action="organizar_correos", licitacion=licitacion_activa, metadata={"servidor_imap": servidor_imap})
536
  return {"status": "success", "mensaje": "✅ Conexión exitosa. Gemini está escaneando los correos en segundo plano..."}
537
+
538
+
539
+ # --- 7. GENERADOR DE FICHAS TÉCNICAS (CON CACHE) ---
540
+ @app.post("/api/v1/generar-ficha")
541
+ def generar_ficha(
542
+ username: str = Form(...),
543
+ licitacion: str = Form(...),
544
+ codigo_renglon: str = Form(...),
545
+ pliego_context: str = Form(...),
546
+ items_context: str = Form(...),
547
+ gemini_key: str = Form(...),
548
+ _token: str = Depends(verify_internal_token)
549
+ ):
550
+ # Verificar cache primero — si ya se generó, devolver sin gastar tokens
551
+ cached = db.get_ficha_cache(username, licitacion, codigo_renglon)
552
+ if cached:
553
+ logger.info(f"Ficha para {codigo_renglon} servida desde cache.")
554
+ db.log_usage_event(
555
+ username=username,
556
+ module="ai",
557
+ action="generar_ficha_cache",
558
+ licitacion=licitacion,
559
+ provider="gemini",
560
+ model="gemini-2.5-flash",
561
+ metadata={"codigo_renglon": codigo_renglon}
562
+ )
563
  return {"status": "success", "datasheet_md": cached, "from_cache": True}
564
+
565
+ try:
566
+ genai.configure(api_key=gemini_key, transport="rest")
567
+ model = genai.GenerativeModel('gemini-2.5-flash')
568
+ prompt = f"""Eres un Ingeniero de Compras especializado. Genera una ficha técnica en formato Markdown para el artículo: {codigo_renglon}.
569
+ Condiciones del Pliego: {pliego_context}
570
+ Detalle del Ítem: {items_context}
571
+
572
+ La ficha debe contener:
573
+ - **Título y Descripción breve**
574
+ - **Tabla de Especificaciones Técnicas**
575
+ - **Requisitos de Calidad / Certificaciones**
576
+ - **Condiciones especiales de la licitación**
577
+ Formato profesional y estructurado."""
578
+
579
+ response = model.generate_content(prompt)
580
+ datasheet = response.text
581
+
582
+ # Guardar en cache para futuras consultas
583
+ db.save_ficha_cache(username, licitacion, codigo_renglon, datasheet)
584
+ db.log_ai_usage(
585
+ username=username,
586
+ action="generar_ficha",
587
+ licitacion=licitacion,
588
+ model="gemini-2.5-flash",
589
+ usage_metadata=response.usage_metadata,
590
+ metadata={"codigo_renglon": codigo_renglon}
591
+ )
592
  logger.info(f"Ficha técnica generada y cacheada para {codigo_renglon}.")
593
+ return {"status": "success", "datasheet_md": datasheet, "from_cache": False}
594
+
595
+ except Exception as e:
596
+ logger.error(f"Error generando ficha: {str(e)}")
597
+ db.log_usage_event(
598
+ username=username,
599
+ module="ai",
600
+ action="generar_ficha",
601
+ licitacion=licitacion,
602
+ provider="gemini",
603
+ model="gemini-2.5-flash",
604
+ status="error",
605
+ error_message=str(e)[:500],
606
+ metadata={"codigo_renglon": codigo_renglon}
607
+ )
608
  raise HTTPException(status_code=500, detail=str(e))
609
+
610
+
611
+ # --- 8. ENDPOINTS REST ---
612
+ class LoginRequest(BaseModel):
613
+ username: str
614
+ password: str
615
+
616
+ @app.post("/api/v1/login")
617
  def login(req: LoginRequest, _token: str = Depends(verify_internal_token)):
618
  user = db.get_user(req.username, req.password)
619
  if user:
620
+ db.log_usage_event(username=user[0], role=user[2], module="auth", action="api_login")
621
  return {"status": "success", "username": user[0], "role": user[2]}
622
+ db.log_usage_event(username=req.username, module="auth", action="api_login", status="error", error_message="Credenciales incorrectas")
623
  raise HTTPException(status_code=401, detail="Credenciales incorrectas")
624
+
625
+ @app.get("/api/v1/workspace/{username}")
626
+ def get_workspace(username: str, _token: str = Depends(verify_internal_token)):
627
+ row = db.load_workspace_state(username)
628
+ if row and row[0] and row[1]:
629
+ df = pd.read_json(io.StringIO(row[0]))
630
+ return {"cg": json.loads(row[1]), "items": df.to_dict(orient="records")}
631
+ return {"cg": None, "items": []}
632
+
633
+ @app.get("/api/v1/history/{username}")
634
+ def get_history(username: str, skip: int = 0, limit: int = 50, _token: str = Depends(verify_internal_token)):
635
+ df = db.get_user_history_df(username)
636
+ return df.iloc[skip : skip+limit].to_dict(orient="records")
637
+
638
+ @app.get("/api/v1/inbox/{licitacion}")
639
+ def get_inbox(licitacion: str, skip: int = 0, limit: int = 50, _token: str = Depends(verify_internal_token)):
640
+ df = db.get_correos_licitacion_df(licitacion)
641
+ return df.iloc[skip : skip+limit].to_dict(orient="records")
642
+
643
+ @app.get("/api/v1/configuracion/{username}")
644
+ def get_config(username: str, _token: str = Depends(verify_internal_token)):
645
+ user_creds = db.get_user_credentials(username)
646
+ if user_creds:
647
+ return {"status": "success", "email_user": user_creds[0], "gemini_key": user_creds[2]}
648
+ return {"status": "error"}
649
+
650
+ @app.post("/api/v1/configuracion")
651
+ def save_config(
652
+ username: str = Form(...),
653
+ gemini_key: str = Form(...),
654
+ email_user: str = Form(""),
655
+ email_pass: str = Form(""),
656
+ _token: str = Depends(verify_internal_token)
657
+ ):
658
+ existing = db.get_user_credentials(username)
659
+ if not existing:
660
+ raise HTTPException(status_code=404, detail="Usuario no encontrado")
661
+
662
+ enc_pass = existing[1]
663
+ if email_pass:
664
+ enc_pass = crypto.encrypt_data(email_pass)
665
+
666
+ existing_tavily = existing[3] if len(existing) > 3 else ""
667
+
668
+ db.update_user_profile(username, gemini_key, existing_tavily, email_user, enc_pass)
669
+ return {"status": "success"}
670
+
671
+ # =============================================
672
+ # CONSULTA AUTOMATICA AL SLI DE LA ACP
673
+ # =============================================
674
+
675
  @app.get("/api/v1/consultar-sli/{rfq_id}")
676
  def consultar_sli(rfq_id: str, _token: str = Depends(verify_internal_token)):
677
+ started_at = time.perf_counter()
678
  rfq_id = "".join(filter(str.isdigit, str(rfq_id or "")))
679
  if not rfq_id:
680
+ db.log_usage_event(module="sli", action="consultar_sli", status="error", error_message="Numero de licitacion invalido")
681
  raise HTTPException(
682
  status_code=400,
683
+ detail={
684
+ "message": "Numero de licitacion invalido.",
685
+ "hint": "Ingresa solo el numero RFQ de la licitacion ACP."
686
+ }
687
+ )
688
+
689
+ SLI_HOME_URL = "https://apps.pancanal.com/sli/LicitacionesBusqueda/Welcome"
690
+ SLI_URL = f"https://apps.pancanal.com/sli/Licitaciones/LicitacionHeader?rfqId={rfq_id}"
691
+
692
+ def extraer_resumen_acta(texto_acta, acta_url):
693
+ texto_acta = re.sub(r"\s+", " ", texto_acta or "").strip()
694
+ if not texto_acta:
695
+ return {
696
+ "disponible": False,
697
+ "url": acta_url,
698
+ "resumen": "",
699
+ "hallazgos": [],
700
+ "error": "El acta no contiene texto legible."
701
+ }
702
+
703
+ palabras_clave = [
704
+ "no cumple", "incumple", "fallo", "falla", "deficiencia",
705
+ "observacion", "observación", "subsan", "tecnico", "técnico",
706
+ "rechaz", "descalific", "no acept", "aclaracion", "aclaración"
707
+ ]
708
+
709
+ partes = re.split(r"(?<=[.!?])\s+|\n+", texto_acta)
710
+ hallazgos = []
711
+
712
+ for parte in partes:
713
+ parte_limpia = parte.strip()
714
+ parte_lower = parte_limpia.lower()
715
+ if len(parte_limpia) < 35:
716
+ continue
717
+ if any(palabra in parte_lower for palabra in palabras_clave):
718
+ hallazgos.append(parte_limpia[:450])
719
+ if len(hallazgos) >= 8:
720
+ break
721
+
722
+ if hallazgos:
723
+ resumen = "Se detectaron posibles observaciones tecnicas o comentarios relevantes en el acta."
724
+ elif any(palabra in texto_acta.lower() for palabra in ["cumple", "conforme", "adjudic"]):
725
+ resumen = "No se detectaron fallos tecnicos evidentes en una lectura automatica del acta."
726
+ else:
727
+ resumen = "El acta fue encontrada, pero no se detectaron observaciones tecnicas claras automaticamente."
728
+
729
+ return {
730
+ "disponible": True,
731
+ "url": acta_url,
732
+ "resumen": resumen,
733
+ "hallazgos": hallazgos,
734
+ "texto_muestra": texto_acta[:1200],
735
+ "error": None
736
+ }
737
+
738
+ try:
739
+ from playwright.sync_api import (
740
+ Error as PlaywrightError,
741
+ TimeoutError as PlaywrightTimeoutError,
742
+ sync_playwright,
743
+ )
744
+ except ImportError:
745
+ raise HTTPException(
746
+ status_code=503,
747
+ detail={
748
+ "message": "Playwright no esta instalado.",
749
+ "hint": "Ejecuta: pip install playwright && playwright install chromium"
750
+ }
751
+ )
752
+
753
+ browser = None
754
+ resumen_acta = {
755
+ "disponible": False,
756
+ "url": None,
757
+ "resumen": "",
758
+ "hallazgos": [],
759
+ "error": "No se encontro el boton de resumen de propuestas recibidas."
760
+ }
761
+
762
+ try:
763
+ with sync_playwright() as p:
764
+ try:
765
+ browser = p.chromium.launch(
766
+ headless=True,
767
+ args=[
768
+ "--no-sandbox",
769
+ "--disable-dev-shm-usage",
770
+ "--disable-blink-features=AutomationControlled"
771
+ ]
772
+ )
773
+ except PlaywrightError as e:
774
+ msg = str(e)
775
+ if "Executable doesn't exist" in msg or "playwright install" in msg:
776
+ raise HTTPException(
777
+ status_code=503,
778
+ detail={
779
+ "message": "Chromium de Playwright no esta instalado.",
780
+ "hint": "Ejecuta: playwright install chromium"
781
+ }
782
+ )
783
+ raise
784
+
785
+ page = browser.new_page()
786
+ page.set_default_timeout(15000)
787
+ page.set_extra_http_headers({
788
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124"
789
+ })
790
+
791
+ response = page.goto(SLI_HOME_URL, wait_until="domcontentloaded", timeout=30000)
792
+ if response and response.status >= 500:
793
+ raise HTTPException(
794
+ status_code=502,
795
+ detail={
796
+ "message": f"El SLI respondio con HTTP {response.status}.",
797
+ "hint": "El portal de ACP puede estar caido o inestable. Intenta de nuevo mas tarde."
798
+ }
799
+ )
800
+
801
+ page.wait_for_selector("#rfqId", timeout=15000)
802
+ page.fill("#rfqId", rfq_id)
803
+
804
+ if page.locator("#hfEstatusSeleccionadoID").count() > 0:
805
+ page.evaluate(
806
+ 'document.getElementById("hfEstatusSeleccionadoID").value = "TODOS";'
807
+ )
808
+
809
+ page.click("input[type='submit']")
810
+
811
+ try:
812
+ page.wait_for_function(
813
+ "() => document.body.innerText.includes('Detalle de RFQ') || "
814
+ "document.body.innerText.includes('EVALUACI') || "
815
+ "document.body.innerText.includes('No se encontraron') || "
816
+ "document.body.innerText.includes('InternalServer')",
817
+ timeout=20000
818
+ )
819
+ except PlaywrightTimeoutError:
820
+ logger.warning(f"Timeout esperando resultados del SLI para RFQ {rfq_id}")
821
+
822
+ content = page.content()
823
+ SLI_URL = page.url
824
+
825
+ resumen_visible = page.locator(".ResPropRec").count() > 0
826
+ po_header_match = re.search(r"po_header\s*[=:]\s*['\"]?(\d+)", content, re.IGNORECASE)
827
+ if not po_header_match:
828
+ po_header_match = re.search(r"po_header=(\d+)", content, re.IGNORECASE)
829
+
830
+ if resumen_visible and po_header_match:
831
+ po_header = po_header_match.group(1)
832
+ acta_url = urljoin(
833
+ SLI_URL,
834
+ f"../Comunes/ImpresionActaResumen?p_rfq={rfq_id}&po_header={po_header}"
835
+ )
836
+
837
+ try:
838
+ acta_response = page.request.get(
839
+ acta_url,
840
+ headers={"Referer": SLI_URL},
841
+ timeout=30000
842
+ )
843
+ acta_bytes = acta_response.body()
844
+ content_type = (acta_response.headers.get("content-type") or "").lower()
845
+
846
+ if "pdf" in content_type or acta_bytes[:4] == b"%PDF":
847
+ try:
848
+ from pypdf import PdfReader
849
+
850
+ reader = PdfReader(io.BytesIO(acta_bytes))
851
+ texto_acta = "\n".join(
852
+ page_pdf.extract_text() or ""
853
+ for page_pdf in reader.pages
854
+ )
855
+ resumen_acta = extraer_resumen_acta(texto_acta, acta_url)
856
+ except ImportError:
857
+ resumen_acta = {
858
+ "disponible": False,
859
+ "url": acta_url,
860
+ "resumen": "",
861
+ "hallazgos": [],
862
+ "error": "pypdf no esta instalado para leer el PDF del resumen."
863
+ }
864
+ else:
865
+ html_acta = acta_bytes.decode("utf-8", errors="ignore")
866
+ texto_acta = BeautifulSoup(html_acta, "html.parser").get_text(
867
+ separator=" ",
868
+ strip=True
869
+ )
870
+ resumen_acta = extraer_resumen_acta(texto_acta, acta_url)
871
+
872
+ except Exception as e:
873
+ logger.warning(f"No se pudo leer acta resumen SLI {rfq_id}: {e}")
874
+ resumen_acta = {
875
+ "disponible": False,
876
+ "url": acta_url,
877
+ "resumen": "",
878
+ "hallazgos": [],
879
+ "error": "Se encontro el resumen, pero no se pudo leer automaticamente."
880
+ }
881
+
882
+ except HTTPException:
883
+ raise
884
+ except PlaywrightTimeoutError as e:
885
+ logger.warning(f"Timeout consultando SLI {rfq_id}: {e}")
886
+ raise HTTPException(
887
+ status_code=504,
888
+ detail={
889
+ "message": "El SLI tardo demasiado en responder.",
890
+ "hint": "Verifica la conexion o intenta nuevamente en unos minutos."
891
+ }
892
+ )
893
+ except PlaywrightError as e:
894
+ logger.exception(f"Error de Playwright consultando SLI {rfq_id}")
895
+ raise HTTPException(
896
+ status_code=502,
897
+ detail={
898
+ "message": "No se pudo consultar el portal SLI.",
899
+ "hint": "El portal pudo cambiar, bloquear la automatizacion o estar temporalmente fuera de servicio.",
900
+ "technical": str(e)[:500]
901
+ }
902
+ )
903
+ except Exception as e:
904
+ logger.exception(f"Error inesperado consultando SLI {rfq_id}")
905
+ raise HTTPException(
906
+ status_code=500,
907
+ detail={
908
+ "message": "Error inesperado consultando el SLI.",
909
+ "hint": "Revisa backend.log para ver el traceback completo.",
910
+ "technical": str(e)[:500]
911
+ }
912
+ )
913
+ finally:
914
+ if browser:
915
+ try:
916
+ browser.close()
917
+ except Exception:
918
+ pass
919
+
920
+ try:
921
+
922
+ soup_sli = BeautifulSoup(content, "html.parser")
923
+
924
+ texto_sli = soup_sli.get_text(separator="|", strip=True)
925
+
926
+ tokens = [t.strip() for t in texto_sli.split("|") if t.strip()]
927
+
928
+ def buscar_valor(etiquetas):
929
+
930
+ for i, tok in enumerate(tokens):
931
+
932
+ for etiq in etiquetas:
933
+
934
+ if (
935
+ tok.strip().lower() == etiq.lower()
936
+ or tok.strip().lower() == f"{etiq.lower()}:"
937
+ ):
938
+
939
+ for j in range(i + 1, min(i + 4, len(tokens))):
940
+
941
+ cand = tokens[j]
942
+
943
+ if (
944
+ cand
945
+ and not any(
946
+ e.lower() == cand.strip().lower()
947
+ for e in etiquetas
948
+ )
949
+ and len(cand) > 2
950
+ ):
951
+ return cand
952
+
953
+ return None
954
+
955
+ resultado = {
956
+ "rfq_id": rfq_id,
957
+ "url": SLI_URL,
958
+ "estatus": buscar_valor(["Estatus", "Estado"]),
959
+ "descripcion": buscar_valor(["Descripción", "Descripcion"]),
960
+ "fecha_cierre": buscar_valor([
961
+ "Fecha y hora de cierre",
962
+ "Fecha de cierre",
963
+ "Cierre"
964
+ ]),
965
+ "fecha_publicacion": buscar_valor([
966
+ "Fecha de publicación",
967
+ "Publicación",
968
+ "Publicacion"
969
+ ]),
970
+ "ultima_revision": buscar_valor([
971
+ "Última revisión",
972
+ "Ultima Revision",
973
+ "Última Revisión"
974
+ ]),
975
+ "agente_compras": buscar_valor([
976
+ "Agente de compras",
977
+ "Agente Compras",
978
+ "Purchasing Agent"
979
+ ]),
980
+ "resumen_acta": resumen_acta,
981
+ "error": None
982
+ }
983
+
984
+ ESTADOS_SLI = [
985
+ "EVALUACIÓN",
986
+ "EVALUACION",
987
+ "ADJUDICACIÓN",
988
+ "ADJUDICACION",
989
+ "CANCELACIÓN",
990
+ "CANCELACION",
991
+ "ACTO DESIERTO",
992
+ "DESIERTA",
993
+ "ENMENDADA",
994
+ "ANUNCIO VENCIDO",
995
+ "ABIERTA",
996
+ "PRECALIFICACIÓN"
997
+ ]
998
+
999
+ if not resultado["estatus"]:
1000
+
1001
+ texto_upper = texto_sli.upper()
1002
+
1003
+ for estado in ESTADOS_SLI:
1004
+
1005
+ if estado in texto_upper:
1006
+ resultado["estatus"] = estado.title()
1007
+ break
1008
+
1009
+ if not resultado["estatus"] and not resultado["descripcion"]:
1010
+
1011
+ resultado["error"] = (
1012
+ "No se encontró información. "
1013
+ "Verifica el número de licitación o intenta más tarde."
1014
+ )
1015
+
1016
  logger.info(
1017
  f"Consulta SLI {rfq_id}: "
1018
  f"estatus={resultado['estatus']} | "
1019
  f"desc={resultado['descripcion']}"
1020
  )
1021
+ db.log_usage_event(
1022
+ module="sli",
1023
+ action="consultar_sli",
1024
+ licitacion=rfq_id,
1025
+ status="error" if resultado.get("error") else "success",
1026
+ error_message=resultado.get("error") or "",
1027
+ duration_ms=int((time.perf_counter() - started_at) * 1000),
1028
+ metadata={
1029
+ "estatus": resultado.get("estatus"),
1030
+ "descripcion_detectada": bool(resultado.get("descripcion")),
1031
+ "acta_disponible": bool((resultado.get("resumen_acta") or {}).get("disponible"))
1032
+ }
1033
+ )
1034
 
1035
  return resultado
1036
  except Exception as e:
1037
  logger.exception(f"Error parseando respuesta SLI {rfq_id}")
1038
+ db.log_usage_event(
1039
+ module="sli",
1040
+ action="consultar_sli",
1041
+ licitacion=rfq_id,
1042
+ status="error",
1043
+ error_message=str(e)[:500],
1044
+ duration_ms=int((time.perf_counter() - started_at) * 1000)
1045
  )
1046
+ raise HTTPException(
1047
+ status_code=500,
1048
+ detail={
1049
+ "message": "El SLI respondio, pero no se pudo interpretar la pagina.",
1050
+ "hint": "Puede haber cambiado el formato del portal ACP.",
1051
+ "technical": str(e)[:500]
1052
+ }
1053
+ )
app.py CHANGED
The diff for this file is too large to render. See raw diff
 
database.py CHANGED
@@ -1,13 +1,16 @@
1
- import psycopg2
2
- import pandas as pd
3
- from datetime import datetime, timedelta
4
- import os
5
- import bcrypt
6
- import crypto
7
- import warnings
 
 
8
 
9
- # Suprimir advertencias de Pandas al usar psycopg2 directo
10
- warnings.filterwarnings("ignore", category=UserWarning, module="pandas")
 
11
 
12
  DATABASE_URL = os.getenv("DATABASE_URL")
13
 
@@ -15,9 +18,10 @@ MAX_INTENTOS = 5 # Intentos fallidos antes de bloquear
15
  TIEMPO_BLOQUEO = 15 # Minutos de bloqueo
16
 
17
  def get_connection():
18
- if not DATABASE_URL:
 
19
  raise ValueError("Falta DATABASE_URL en las variables de entorno")
20
- conn = psycopg2.connect(DATABASE_URL)
21
  return conn
22
 
23
  def init_db():
@@ -150,6 +154,125 @@ def init_db():
150
  hashed_pw = bcrypt.hashpw(b"admin", salt).decode('utf-8')
151
  c.execute("INSERT INTO users VALUES ('admin', %s, 'Gerencia', '', '', '', '')", (hashed_pw,))
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  conn.commit()
154
  conn.close()
155
 
@@ -157,7 +280,194 @@ def init_db():
157
  # WORKSPACES (MÚLTIPLES POR USUARIO)
158
  # =============================================
159
 
160
- def save_workspace(username, licitacion, data_json, cg_json):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  """Guarda o actualiza el workspace de una licitación específica."""
162
  conn = get_connection()
163
  c = conn.cursor()
@@ -529,3 +839,513 @@ def eliminar_seguimiento(licitacion_id):
529
  conn.commit()
530
  conn.close()
531
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import psycopg2
2
+ from psycopg2.extras import execute_values
3
+ import pandas as pd
4
+ from datetime import datetime, timedelta
5
+ import os
6
+ import json
7
+ import bcrypt
8
+ import crypto
9
+ import warnings
10
 
11
+ # Suprimir advertencias de Pandas al usar psycopg2 directo
12
+ warnings.filterwarnings("ignore", category=UserWarning, module="pandas")
13
+ warnings.filterwarnings("ignore", message="pandas only supports SQLAlchemy.*")
14
 
15
  DATABASE_URL = os.getenv("DATABASE_URL")
16
 
 
18
  TIEMPO_BLOQUEO = 15 # Minutos de bloqueo
19
 
20
  def get_connection():
21
+ db_url = os.getenv("DATABASE_URL") or DATABASE_URL
22
+ if not db_url:
23
  raise ValueError("Falta DATABASE_URL en las variables de entorno")
24
+ conn = psycopg2.connect(db_url)
25
  return conn
26
 
27
  def init_db():
 
154
  hashed_pw = bcrypt.hashpw(b"admin", salt).decode('utf-8')
155
  c.execute("INSERT INTO users VALUES ('admin', %s, 'Gerencia', '', '', '', '')", (hashed_pw,))
156
 
157
+
158
+ # Metrics de consumo
159
+ c.execute('''CREATE TABLE IF NOT EXISTS metrics_log (
160
+ id SERIAL PRIMARY KEY,
161
+ timestamp TEXT,
162
+ username TEXT,
163
+ action TEXT,
164
+ tokens INTEGER
165
+ )''')
166
+
167
+ # Metricas operativas enriquecidas para Supabase/Postgres.
168
+ # Mantiene detalle por usuario, modulo, funcion, licitacion y estado.
169
+ c.execute('''CREATE TABLE IF NOT EXISTS usage_metrics (
170
+ id SERIAL PRIMARY KEY,
171
+ created_at TIMESTAMPTZ DEFAULT NOW(),
172
+ username TEXT,
173
+ role TEXT,
174
+ module TEXT NOT NULL,
175
+ action TEXT NOT NULL,
176
+ licitacion TEXT,
177
+ provider TEXT,
178
+ model TEXT,
179
+ tokens_input INTEGER DEFAULT 0,
180
+ tokens_output INTEGER DEFAULT 0,
181
+ tokens_total INTEGER DEFAULT 0,
182
+ estimated_cost_usd NUMERIC(12, 6) DEFAULT 0,
183
+ status TEXT DEFAULT 'success',
184
+ error_message TEXT,
185
+ duration_ms INTEGER,
186
+ metadata JSONB DEFAULT '{}'::jsonb
187
+ )''')
188
+ c.execute("CREATE INDEX IF NOT EXISTS idx_usage_metrics_created_at ON usage_metrics(created_at DESC)")
189
+ c.execute("CREATE INDEX IF NOT EXISTS idx_usage_metrics_user ON usage_metrics(username)")
190
+ c.execute("CREATE INDEX IF NOT EXISTS idx_usage_metrics_module_action ON usage_metrics(module, action)")
191
+ c.execute("CREATE INDEX IF NOT EXISTS idx_usage_metrics_licitacion ON usage_metrics(licitacion)")
192
+
193
+ # Historico de licitaciones importado desde el Excel corporativo.
194
+ # Permite retirar el archivo fisico del repo y consultar precios/records desde Supabase.
195
+ c.execute('''CREATE TABLE IF NOT EXISTS historico_licitaciones (
196
+ id SERIAL PRIMARY KEY,
197
+ numero_licitacion TEXT,
198
+ mes TEXT,
199
+ anio INTEGER,
200
+ codigo_acp TEXT,
201
+ codigo_match TEXT,
202
+ cantidad NUMERIC(14, 2),
203
+ precio_proyelec NUMERIC(14, 4),
204
+ precio_competencia NUMERIC(14, 4),
205
+ adjudicada_a_proyelec TEXT,
206
+ analista_procura TEXT,
207
+ observaciones TEXT,
208
+ fuente TEXT DEFAULT 'excel_historico',
209
+ imported_at TIMESTAMPTZ DEFAULT NOW(),
210
+ UNIQUE(numero_licitacion, anio, codigo_acp, cantidad, precio_proyelec)
211
+ )''')
212
+ c.execute("CREATE INDEX IF NOT EXISTS idx_historico_codigo_match ON historico_licitaciones(codigo_match)")
213
+ c.execute("CREATE INDEX IF NOT EXISTS idx_historico_numero_anio ON historico_licitaciones(numero_licitacion, anio)")
214
+ c.execute("CREATE INDEX IF NOT EXISTS idx_historico_anio ON historico_licitaciones(anio)")
215
+
216
+ # Tarifas por modelo/API. Mantener aqui permite ajustar costos sin tocar codigo.
217
+ c.execute('''CREATE TABLE IF NOT EXISTS api_pricing (
218
+ model TEXT PRIMARY KEY,
219
+ provider TEXT NOT NULL,
220
+ plan TEXT DEFAULT 'standard',
221
+ currency TEXT DEFAULT 'USD',
222
+ input_price_per_million NUMERIC(12, 6) DEFAULT 0,
223
+ output_price_per_million NUMERIC(12, 6) DEFAULT 0,
224
+ cache_price_per_million NUMERIC(12, 6) DEFAULT 0,
225
+ search_price_per_1000 NUMERIC(12, 6) DEFAULT 0,
226
+ source_url TEXT,
227
+ updated_at TIMESTAMPTZ DEFAULT NOW()
228
+ )''')
229
+ c.execute("""INSERT INTO api_pricing
230
+ (model, provider, plan, currency, input_price_per_million, output_price_per_million,
231
+ cache_price_per_million, search_price_per_1000, source_url)
232
+ VALUES
233
+ ('gemini-2.5-flash', 'gemini', 'standard', 'USD', 0.30, 2.50, 0.03, 35.00, 'https://ai.google.dev/gemini-api/docs/pricing'),
234
+ ('gemini-2.5-flash-lite', 'gemini', 'standard', 'USD', 0.10, 0.40, 0.01, 35.00, 'https://ai.google.dev/gemini-api/docs/pricing')
235
+ ON CONFLICT (model) DO UPDATE SET
236
+ provider = EXCLUDED.provider,
237
+ plan = EXCLUDED.plan,
238
+ currency = EXCLUDED.currency,
239
+ input_price_per_million = EXCLUDED.input_price_per_million,
240
+ output_price_per_million = EXCLUDED.output_price_per_million,
241
+ cache_price_per_million = EXCLUDED.cache_price_per_million,
242
+ search_price_per_1000 = EXCLUDED.search_price_per_1000,
243
+ source_url = EXCLUDED.source_url,
244
+ updated_at = NOW()
245
+ """)
246
+
247
+ # === RADAR DE LICITACIONES SLI ===
248
+ c.execute('''CREATE TABLE IF NOT EXISTS radar_licitaciones (
249
+ id SERIAL PRIMARY KEY,
250
+ numero_licitacion TEXT UNIQUE NOT NULL,
251
+ objeto TEXT,
252
+ categoria TEXT,
253
+ monto_estimado REAL DEFAULT 0,
254
+ moneda TEXT DEFAULT 'USD',
255
+ fecha_apertura TEXT,
256
+ fecha_cierre TEXT,
257
+ link_sli TEXT,
258
+ es_prioritaria BOOLEAN DEFAULT FALSE,
259
+ fecha_descubierta TEXT,
260
+ fecha_ultimo_escaneo TEXT,
261
+ score_interes INTEGER DEFAULT 0,
262
+ estado_radar TEXT DEFAULT 'nueva',
263
+ revisada_por TEXT,
264
+ notas TEXT
265
+ )''')
266
+
267
+ # Log de escaneos del radar
268
+ c.execute('''CREATE TABLE IF NOT EXISTS radar_escaneos (
269
+ id SERIAL PRIMARY KEY,
270
+ fecha TEXT,
271
+ total_encontradas INTEGER DEFAULT 0,
272
+ nuevas INTEGER DEFAULT 0,
273
+ errores TEXT
274
+ )''')
275
+
276
  conn.commit()
277
  conn.close()
278
 
 
280
  # WORKSPACES (MÚLTIPLES POR USUARIO)
281
  # =============================================
282
 
283
+ # =============================================
284
+ # HISTORICO CORPORATIVO DE LICITACIONES
285
+ # =============================================
286
+
287
+ def _clean_codigo_match(value):
288
+ return "".join(ch for ch in str(value or "").upper() if ch.isascii() and ch.isalnum())
289
+
290
+ def _clean_text(value):
291
+ if pd.isna(value):
292
+ return ""
293
+ return str(value).strip()
294
+
295
+ def _to_float_or_none(value):
296
+ if pd.isna(value) or value == "":
297
+ return None
298
+ try:
299
+ return float(value)
300
+ except Exception:
301
+ cleaned = str(value).replace(",", "").replace("$", "").strip()
302
+ try:
303
+ return float(cleaned)
304
+ except Exception:
305
+ return None
306
+
307
+ def _to_int_or_none(value):
308
+ if pd.isna(value) or value == "":
309
+ return None
310
+ try:
311
+ return int(float(value))
312
+ except Exception:
313
+ return None
314
+
315
+ def normalize_historico_excel_df(df):
316
+ df = df.rename(columns=lambda x: str(x).strip())
317
+ rename_map = {
318
+ "N° DE LIC": "numero_licitacion",
319
+ "N° DE LIC": "numero_licitacion",
320
+ "Nº DE LIC": "numero_licitacion",
321
+ "CODIGO ACP": "codigo_acp",
322
+ "MES": "mes",
323
+ "AÑO": "anio",
324
+ "AÑO": "anio",
325
+ "CANT": "cantidad",
326
+ "PRECIO PROYELEC": "precio_proyelec",
327
+ "PRECIO COMPETENCIA": "precio_competencia",
328
+ "ADJUDICADA A PROYELEC": "adjudicada_a_proyelec",
329
+ "ANALISTA DE PROCURA": "analista_procura",
330
+ "OBSERVACIONES": "observaciones",
331
+ }
332
+ df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
333
+ expected = [
334
+ "numero_licitacion", "mes", "anio", "codigo_acp", "cantidad",
335
+ "precio_proyelec", "precio_competencia", "adjudicada_a_proyelec",
336
+ "analista_procura", "observaciones",
337
+ ]
338
+ for col in expected:
339
+ if col not in df.columns:
340
+ df[col] = None
341
+
342
+ out = df[expected].copy()
343
+ out = out.dropna(how="all", subset=["numero_licitacion", "codigo_acp", "precio_proyelec", "precio_competencia"])
344
+ out["numero_licitacion"] = out["numero_licitacion"].apply(lambda v: _clean_text(v).replace(".0", ""))
345
+ out["mes"] = out["mes"].apply(_clean_text)
346
+ out["codigo_acp"] = out["codigo_acp"].apply(_clean_text)
347
+ out["codigo_match"] = out["codigo_acp"].apply(_clean_codigo_match)
348
+ out["anio"] = out["anio"].apply(_to_int_or_none)
349
+ out["cantidad"] = out["cantidad"].apply(_to_float_or_none)
350
+ out["precio_proyelec"] = out["precio_proyelec"].apply(_to_float_or_none)
351
+ out["precio_competencia"] = out["precio_competencia"].apply(_to_float_or_none)
352
+ out["adjudicada_a_proyelec"] = out["adjudicada_a_proyelec"].apply(_clean_text)
353
+ out["analista_procura"] = out["analista_procura"].apply(_clean_text)
354
+ out["observaciones"] = out["observaciones"].apply(_clean_text)
355
+ out = out[out["codigo_match"] != ""]
356
+ out = out.drop_duplicates(
357
+ subset=["numero_licitacion", "anio", "codigo_acp", "cantidad", "precio_proyelec"],
358
+ keep="last",
359
+ )
360
+ return out
361
+
362
+ def import_historico_excel_to_db(excel_path="ACP DATA LIC PASADAS v2_2.xlsx", replace=False):
363
+ df = pd.read_excel(excel_path, skiprows=8)
364
+ df = normalize_historico_excel_df(df)
365
+ conn = get_connection()
366
+ c = conn.cursor()
367
+ if replace:
368
+ c.execute("DELETE FROM historico_licitaciones")
369
+ values = [
370
+ (
371
+ row.get("numero_licitacion"), row.get("mes"), row.get("anio"),
372
+ row.get("codigo_acp"), row.get("codigo_match"), row.get("cantidad"),
373
+ row.get("precio_proyelec"), row.get("precio_competencia"),
374
+ row.get("adjudicada_a_proyelec"), row.get("analista_procura"),
375
+ row.get("observaciones"),
376
+ )
377
+ for row in df.to_dict("records")
378
+ ]
379
+ execute_values(c, """
380
+ INSERT INTO historico_licitaciones
381
+ (numero_licitacion, mes, anio, codigo_acp, codigo_match, cantidad,
382
+ precio_proyelec, precio_competencia, adjudicada_a_proyelec,
383
+ analista_procura, observaciones)
384
+ VALUES %s
385
+ ON CONFLICT (numero_licitacion, anio, codigo_acp, cantidad, precio_proyelec)
386
+ DO UPDATE SET
387
+ mes = EXCLUDED.mes,
388
+ codigo_match = EXCLUDED.codigo_match,
389
+ precio_competencia = EXCLUDED.precio_competencia,
390
+ adjudicada_a_proyelec = EXCLUDED.adjudicada_a_proyelec,
391
+ analista_procura = EXCLUDED.analista_procura,
392
+ observaciones = EXCLUDED.observaciones,
393
+ imported_at = NOW()
394
+ """, values, page_size=1000)
395
+ conn.commit()
396
+ conn.close()
397
+ return {"rows_source": int(len(df)), "rows_processed": int(len(df)), "replace": bool(replace)}
398
+
399
+ def get_historico_licitaciones_df(limit=5000, search=None, anio=None):
400
+ conn = get_connection()
401
+ params = []
402
+ filters = []
403
+ if search:
404
+ q = f"%{search}%"
405
+ filters.append("(numero_licitacion ILIKE %s OR codigo_acp ILIKE %s OR observaciones ILIKE %s)")
406
+ params.extend([q, q, q])
407
+ if anio and str(anio) != "Todos":
408
+ filters.append("anio = %s")
409
+ params.append(int(anio))
410
+ where_clause = ("WHERE " + " AND ".join(filters)) if filters else ""
411
+ params.append(int(limit))
412
+ df = pd.read_sql_query(f"""
413
+ SELECT numero_licitacion AS "N° Licitación",
414
+ anio AS "Año",
415
+ mes AS "Mes",
416
+ codigo_acp AS "Código ACP",
417
+ cantidad AS "Cantidad",
418
+ precio_proyelec AS "Precio Proyelec",
419
+ precio_competencia AS "Precio Competencia",
420
+ adjudicada_a_proyelec AS "Adjudicada a Proyelec",
421
+ analista_procura AS "Analista",
422
+ observaciones AS "Observaciones"
423
+ FROM historico_licitaciones
424
+ {where_clause}
425
+ ORDER BY anio DESC NULLS LAST, numero_licitacion DESC NULLS LAST
426
+ LIMIT %s
427
+ """, conn, params=tuple(params))
428
+ conn.close()
429
+ return df
430
+
431
+ def get_historico_anios():
432
+ conn = get_connection()
433
+ df = pd.read_sql_query("""
434
+ SELECT DISTINCT anio
435
+ FROM historico_licitaciones
436
+ WHERE anio IS NOT NULL
437
+ ORDER BY anio DESC
438
+ """, conn)
439
+ conn.close()
440
+ return df["anio"].dropna().astype(int).tolist() if not df.empty else []
441
+
442
+ def get_historical_prices_df():
443
+ conn = get_connection()
444
+ df = pd.read_sql_query("""
445
+ SELECT DISTINCT ON (codigo_match)
446
+ codigo_match,
447
+ precio_competencia AS "PRECIO COMPETENCIA",
448
+ precio_proyelec AS "PRECIO PROYELEC",
449
+ numero_licitacion AS licitacion_hist,
450
+ anio AS anio_hist
451
+ FROM historico_licitaciones
452
+ WHERE codigo_match IS NOT NULL
453
+ AND codigo_match <> ''
454
+ AND (precio_competencia IS NOT NULL OR precio_proyelec IS NOT NULL)
455
+ ORDER BY codigo_match,
456
+ anio DESC NULLS LAST,
457
+ imported_at DESC
458
+ """, conn)
459
+ conn.close()
460
+ return df
461
+
462
+ def get_historico_count():
463
+ conn = get_connection()
464
+ c = conn.cursor()
465
+ c.execute("SELECT COUNT(*) FROM historico_licitaciones")
466
+ count = c.fetchone()[0]
467
+ conn.close()
468
+ return int(count or 0)
469
+
470
+ def save_workspace(username, licitacion, data_json, cg_json):
471
  """Guarda o actualiza el workspace de una licitación específica."""
472
  conn = get_connection()
473
  c = conn.cursor()
 
839
  conn.commit()
840
  conn.close()
841
 
842
+
843
+
844
+ def get_api_pricing_df():
845
+ conn = get_connection()
846
+ df = pd.read_sql_query(
847
+ """SELECT model, provider, plan, currency, input_price_per_million,
848
+ output_price_per_million, cache_price_per_million,
849
+ search_price_per_1000, source_url, updated_at
850
+ FROM api_pricing ORDER BY provider, model""",
851
+ conn
852
+ )
853
+ conn.close()
854
+ return df
855
+
856
+ def get_api_pricing(model):
857
+ if not model:
858
+ return None
859
+ conn = get_connection()
860
+ c = conn.cursor()
861
+ c.execute("""SELECT input_price_per_million, output_price_per_million,
862
+ cache_price_per_million, search_price_per_1000, currency, plan
863
+ FROM api_pricing WHERE model=%s""", (model,))
864
+ row = c.fetchone()
865
+ conn.close()
866
+ return row
867
+
868
+ def calculate_api_cost(model, tokens_input=0, tokens_output=0, cache_tokens=0, search_requests=0):
869
+ """Calcula costo billable estimado con tarifas guardadas en api_pricing."""
870
+ pricing = get_api_pricing(model)
871
+ if not pricing:
872
+ return 0.0
873
+
874
+ input_price, output_price, cache_price, search_price, _, _ = pricing
875
+ tokens_input = int(tokens_input or 0)
876
+ tokens_output = int(tokens_output or 0)
877
+ cache_tokens = int(cache_tokens or 0)
878
+ search_requests = int(search_requests or 0)
879
+
880
+ cost = 0.0
881
+ cost += (tokens_input / 1_000_000) * float(input_price or 0)
882
+ cost += (tokens_output / 1_000_000) * float(output_price or 0)
883
+ cost += (cache_tokens / 1_000_000) * float(cache_price or 0)
884
+ cost += (search_requests / 1_000) * float(search_price or 0)
885
+ return round(cost, 6)
886
+
887
+ def extract_usage_counts(usage_metadata):
888
+ """Normaliza usage_metadata de Gemini a input/output/total tokens."""
889
+ if not usage_metadata:
890
+ return 0, 0, 0
891
+
892
+ def read_attr(*names):
893
+ for name in names:
894
+ if isinstance(usage_metadata, dict) and name in usage_metadata:
895
+ return usage_metadata.get(name) or 0
896
+ if hasattr(usage_metadata, name):
897
+ return getattr(usage_metadata, name) or 0
898
+ return 0
899
+
900
+ input_tokens = read_attr("prompt_token_count", "input_token_count")
901
+ output_tokens = read_attr("candidates_token_count", "output_token_count")
902
+ total_tokens = read_attr("total_token_count")
903
+ if not total_tokens:
904
+ total_tokens = int(input_tokens or 0) + int(output_tokens or 0)
905
+ if not output_tokens and total_tokens and input_tokens:
906
+ output_tokens = max(int(total_tokens) - int(input_tokens), 0)
907
+ return int(input_tokens or 0), int(output_tokens or 0), int(total_tokens or 0)
908
+
909
+ def log_usage_event(username="", role="", module="general", action="", licitacion="",
910
+ provider="", model="", tokens_input=0, tokens_output=0,
911
+ tokens_total=0, estimated_cost_usd=None, status="success",
912
+ error_message="", duration_ms=None, metadata=None):
913
+ """Registra un evento operativo. Nunca debe romper el flujo principal."""
914
+ if metadata is None:
915
+ metadata = {}
916
+ tokens_input = int(tokens_input or 0)
917
+ tokens_output = int(tokens_output or 0)
918
+ tokens_total = int(tokens_total or 0)
919
+ if not tokens_total:
920
+ tokens_total = tokens_input + tokens_output
921
+ if estimated_cost_usd is None:
922
+ estimated_cost_usd = calculate_api_cost(
923
+ model=model,
924
+ tokens_input=tokens_input,
925
+ tokens_output=tokens_output,
926
+ cache_tokens=int(metadata.get("cache_tokens", 0) or 0) if isinstance(metadata, dict) else 0,
927
+ search_requests=int(metadata.get("search_requests", 0) or 0) if isinstance(metadata, dict) else 0,
928
+ )
929
+
930
+ conn = None
931
+ try:
932
+ conn = get_connection()
933
+ c = conn.cursor()
934
+ c.execute("""INSERT INTO usage_metrics
935
+ (username, role, module, action, licitacion, provider, model,
936
+ tokens_input, tokens_output, tokens_total, estimated_cost_usd,
937
+ status, error_message, duration_ms, metadata)
938
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)""",
939
+ (username or "", role or "", module or "general", action or "",
940
+ licitacion or "", provider or "", model or "",
941
+ tokens_input, tokens_output, tokens_total,
942
+ estimated_cost_usd, status or "success", error_message or "",
943
+ duration_ms, json.dumps(metadata, ensure_ascii=False)))
944
+ conn.commit()
945
+ except Exception as e:
946
+ print(f"[METRICS] No se pudo registrar evento: {e}")
947
+ finally:
948
+ if conn:
949
+ conn.close()
950
+
951
+ def log_metric(username, action, tokens):
952
+ """Compatibilidad con llamadas existentes; tambien alimenta usage_metrics."""
953
+ conn = None
954
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
955
+ try:
956
+ conn = get_connection()
957
+ c = conn.cursor()
958
+ c.execute("INSERT INTO metrics_log (timestamp, username, action, tokens) VALUES (%s, %s, %s, %s)",
959
+ (now, username, action, int(tokens or 0)))
960
+ conn.commit()
961
+ except Exception as e:
962
+ print(f"[METRICS_LOG] No se pudo registrar legacy metric: {e}")
963
+ finally:
964
+ if conn:
965
+ conn.close()
966
+
967
+ log_usage_event(
968
+ username=username,
969
+ module="ai",
970
+ action=action,
971
+ provider="gemini",
972
+ model="gemini-2.5-flash",
973
+ tokens_total=int(tokens or 0),
974
+ metadata={"legacy_action": action, "cost_accuracy": "legacy_total_only"}
975
+ )
976
+
977
+ def log_ai_usage(username="", role="", action="", licitacion="", model="gemini-2.5-flash",
978
+ usage_metadata=None, status="success", error_message="", duration_ms=None,
979
+ metadata=None):
980
+ tokens_input, tokens_output, tokens_total = extract_usage_counts(usage_metadata)
981
+ metadata = metadata or {}
982
+ metadata.setdefault("cost_accuracy", "input_output_tokens")
983
+ log_usage_event(
984
+ username=username,
985
+ role=role,
986
+ module="ai",
987
+ action=action,
988
+ licitacion=licitacion,
989
+ provider="gemini",
990
+ model=model,
991
+ tokens_input=tokens_input,
992
+ tokens_output=tokens_output,
993
+ tokens_total=tokens_total,
994
+ status=status,
995
+ error_message=error_message,
996
+ duration_ms=duration_ms,
997
+ metadata=metadata,
998
+ )
999
+
1000
+ def get_metrics_data():
1001
+ conn = get_connection()
1002
+ c = conn.cursor()
1003
+ c.execute("SELECT timestamp, username, action, tokens FROM metrics_log ORDER BY id DESC")
1004
+ rows = c.fetchall()
1005
+ conn.close()
1006
+ return rows
1007
+
1008
+ def get_usage_metrics_df(days=90, username=None, module=None):
1009
+ conn = get_connection()
1010
+ params = [int(days)]
1011
+ filters = ["created_at >= NOW() - (%s * INTERVAL '1 day')"]
1012
+ if username and username != "Todos":
1013
+ filters.append("username = %s")
1014
+ params.append(username)
1015
+ if module and module != "Todos":
1016
+ filters.append("module = %s")
1017
+ params.append(module)
1018
+
1019
+ where_clause = " AND ".join(filters)
1020
+ df = pd.read_sql_query(f"""
1021
+ SELECT id, created_at, username, role, module, action, licitacion,
1022
+ provider, model, tokens_input, tokens_output, tokens_total,
1023
+ estimated_cost_usd, status, error_message, duration_ms
1024
+ FROM usage_metrics
1025
+ WHERE {where_clause}
1026
+ ORDER BY created_at DESC
1027
+ """, conn, params=tuple(params))
1028
+ conn.close()
1029
+ return df
1030
+
1031
+ def get_usage_filter_options(days=180):
1032
+ df = get_usage_metrics_df(days)
1033
+ users = sorted([u for u in df["username"].dropna().unique().tolist() if str(u).strip()]) if not df.empty else []
1034
+ modules = sorted([m for m in df["module"].dropna().unique().tolist() if str(m).strip()]) if not df.empty else []
1035
+ return {
1036
+ "users": ["Todos"] + users,
1037
+ "modules": ["Todos"] + modules,
1038
+ }
1039
+
1040
+ def get_usage_summary(days=30, username=None, module=None):
1041
+ df = get_usage_metrics_df(days, username=username, module=module)
1042
+ if df.empty:
1043
+ return {
1044
+ "total_events": 0,
1045
+ "active_users": 0,
1046
+ "active_licitaciones": 0,
1047
+ "tokens_total": 0,
1048
+ "estimated_cost_usd": 0.0,
1049
+ "errors": 0,
1050
+ "peak_users_hour": 0,
1051
+ "uncosted_events": 0,
1052
+ "df": df,
1053
+ "by_module": pd.DataFrame(),
1054
+ "by_user": pd.DataFrame(),
1055
+ "by_day": pd.DataFrame(),
1056
+ "by_month": pd.DataFrame(),
1057
+ "by_hour": pd.DataFrame(),
1058
+ "by_status": pd.DataFrame(),
1059
+ }
1060
+
1061
+ df["created_at"] = pd.to_datetime(df["created_at"], errors="coerce", utc=True).dt.tz_convert(None)
1062
+ df["estimated_cost_usd"] = pd.to_numeric(df["estimated_cost_usd"], errors="coerce").fillna(0)
1063
+ df["tokens_input"] = pd.to_numeric(df["tokens_input"], errors="coerce").fillna(0).astype(int)
1064
+ df["tokens_output"] = pd.to_numeric(df["tokens_output"], errors="coerce").fillna(0).astype(int)
1065
+ df["tokens_total"] = pd.to_numeric(df["tokens_total"], errors="coerce").fillna(0).astype(int)
1066
+ legacy_total_only = (
1067
+ (df["module"] == "ai")
1068
+ & (df["tokens_total"] > 0)
1069
+ & ((df["tokens_input"] + df["tokens_output"]) == 0)
1070
+ )
1071
+ df["cost_is_real"] = ~legacy_total_only
1072
+ df.loc[legacy_total_only, "estimated_cost_usd"] = 0
1073
+ by_module = df.groupby(["module", "action"], dropna=False).agg(
1074
+ eventos=("id", "count"),
1075
+ tokens_entrada=("tokens_input", "sum"),
1076
+ tokens_salida=("tokens_output", "sum"),
1077
+ tokens=("tokens_total", "sum"),
1078
+ costo_estimado=("estimated_cost_usd", "sum"),
1079
+ errores=("status", lambda s: (s != "success").sum())
1080
+ ).reset_index().sort_values(["tokens", "eventos"], ascending=False)
1081
+ by_user = df.groupby("username", dropna=False).agg(
1082
+ eventos=("id", "count"),
1083
+ tokens_entrada=("tokens_input", "sum"),
1084
+ tokens_salida=("tokens_output", "sum"),
1085
+ tokens=("tokens_total", "sum"),
1086
+ costo_estimado=("estimated_cost_usd", "sum")
1087
+ ).reset_index().sort_values(["tokens", "eventos"], ascending=False)
1088
+ by_day = df.assign(day=df["created_at"].dt.date).groupby("day").agg(
1089
+ eventos=("id", "count"),
1090
+ tokens_entrada=("tokens_input", "sum"),
1091
+ tokens_salida=("tokens_output", "sum"),
1092
+ tokens=("tokens_total", "sum"),
1093
+ costo_estimado=("estimated_cost_usd", "sum")
1094
+ ).reset_index()
1095
+ by_month = df.assign(month=df["created_at"].dt.to_period("M").astype(str)).groupby("month").agg(
1096
+ eventos=("id", "count"),
1097
+ usuarios=("username", lambda s: s.replace("", pd.NA).dropna().nunique()),
1098
+ tokens_entrada=("tokens_input", "sum"),
1099
+ tokens_salida=("tokens_output", "sum"),
1100
+ tokens=("tokens_total", "sum"),
1101
+ costo_estimado=("estimated_cost_usd", "sum"),
1102
+ errores=("status", lambda s: (s != "success").sum())
1103
+ ).reset_index()
1104
+ by_hour = df.assign(hour=df["created_at"].dt.floor("h")).groupby("hour").agg(
1105
+ eventos=("id", "count"),
1106
+ usuarios=("username", lambda s: s.replace("", pd.NA).dropna().nunique())
1107
+ ).reset_index()
1108
+ by_status = df.groupby("status", dropna=False).agg(eventos=("id", "count")).reset_index()
1109
+
1110
+ return {
1111
+ "total_events": int(len(df)),
1112
+ "active_users": int(df["username"].replace("", pd.NA).dropna().nunique()),
1113
+ "active_licitaciones": int(df["licitacion"].replace("", pd.NA).dropna().nunique()),
1114
+ "tokens_total": int(df["tokens_total"].fillna(0).sum()),
1115
+ "estimated_cost_usd": float(df["estimated_cost_usd"].fillna(0).sum()),
1116
+ "errors": int((df["status"] != "success").sum()),
1117
+ "peak_users_hour": int(by_hour["usuarios"].max()) if not by_hour.empty else 0,
1118
+ "uncosted_events": int(legacy_total_only.sum()),
1119
+ "df": df,
1120
+ "by_module": by_module,
1121
+ "by_user": by_user,
1122
+ "by_day": by_day,
1123
+ "by_month": by_month,
1124
+ "by_hour": by_hour,
1125
+ "by_status": by_status,
1126
+ }
1127
+
1128
+ def log_login_attempt(username, is_success):
1129
+ log_usage_event(
1130
+ username=username,
1131
+ module="auth",
1132
+ action="login",
1133
+ status="success" if is_success else "error",
1134
+ error_message="" if is_success else "Credenciales incorrectas"
1135
+ )
1136
+
1137
+ # =============================================
1138
+ # RADAR DE LICITACIONES SLI
1139
+ # =============================================
1140
+
1141
+ def _calcular_score_radar(monto_estimado, es_prioritaria):
1142
+ score = 1
1143
+ monto_estimado = float(monto_estimado or 0)
1144
+ if monto_estimado > 1000000:
1145
+ score = 10
1146
+ elif monto_estimado > 500000:
1147
+ score = 8
1148
+ elif monto_estimado > 100000:
1149
+ score = 6
1150
+ elif monto_estimado > 50000:
1151
+ score = 4
1152
+ elif monto_estimado > 10000:
1153
+ score = 2
1154
+ if es_prioritaria:
1155
+ score = min(score + 2, 10)
1156
+ return score
1157
+
1158
+ def guardar_licitacion_radar(numero_licitacion, objeto, categoria, monto_estimado,
1159
+ moneda, fecha_apertura, fecha_cierre, link_sli, es_prioritaria):
1160
+ """Guarda una licitación descubierta por el radar. Retorna True si es nueva, False si ya existía."""
1161
+ conn = get_connection()
1162
+ c = conn.cursor()
1163
+ fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1164
+
1165
+ score = _calcular_score_radar(monto_estimado, es_prioritaria)
1166
+
1167
+ try:
1168
+ c.execute("""INSERT INTO radar_licitaciones
1169
+ (numero_licitacion, objeto, categoria, monto_estimado, moneda,
1170
+ fecha_apertura, fecha_cierre, link_sli, es_prioritaria,
1171
+ fecha_descubierta, fecha_ultimo_escaneo, score_interes, estado_radar)
1172
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'nueva')
1173
+ ON CONFLICT (numero_licitacion) DO UPDATE SET
1174
+ objeto = EXCLUDED.objeto,
1175
+ categoria = EXCLUDED.categoria,
1176
+ moneda = EXCLUDED.moneda,
1177
+ fecha_apertura = EXCLUDED.fecha_apertura,
1178
+ fecha_cierre = EXCLUDED.fecha_cierre,
1179
+ link_sli = EXCLUDED.link_sli,
1180
+ es_prioritaria = EXCLUDED.es_prioritaria,
1181
+ fecha_ultimo_escaneo = EXCLUDED.fecha_ultimo_escaneo,
1182
+ score_interes = EXCLUDED.score_interes,
1183
+ monto_estimado = CASE WHEN EXCLUDED.monto_estimado > 0 THEN EXCLUDED.monto_estimado ELSE radar_licitaciones.monto_estimado END
1184
+ """, (numero_licitacion, objeto, categoria, monto_estimado, moneda,
1185
+ fecha_apertura, fecha_cierre, link_sli, es_prioritaria, fecha, fecha, score))
1186
+
1187
+ # Verificar si fue INSERT o UPDATE
1188
+ fue_nueva = c.rowcount > 0
1189
+ conn.commit()
1190
+ conn.close()
1191
+
1192
+ # Verificar si realmente es nueva (no existía antes)
1193
+ conn2 = get_connection()
1194
+ c2 = conn2.cursor()
1195
+ c2.execute("SELECT fecha_descubierta FROM radar_licitaciones WHERE numero_licitacion=%s", (numero_licitacion,))
1196
+ row = c2.fetchone()
1197
+ conn2.close()
1198
+ if row and row[0] == fecha:
1199
+ return True # Es nueva
1200
+ return False # Ya existía
1201
+ except Exception as e:
1202
+ print(f"[RADAR DB] Error guardando {numero_licitacion}: {e}")
1203
+ conn.close()
1204
+ return False
1205
+
1206
+
1207
+ def guardar_licitaciones_radar_bulk(licitaciones):
1208
+ """Guarda muchas licitaciones del radar con una sola conexion. Retorna (nuevas, total)."""
1209
+ if not licitaciones:
1210
+ return 0, 0
1211
+
1212
+ fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1213
+ numeros = [str(lic.get("numero_licitacion", "")).strip() for lic in licitaciones if lic.get("numero_licitacion")]
1214
+ if not numeros:
1215
+ return 0, 0
1216
+
1217
+ conn = get_connection()
1218
+ c = conn.cursor()
1219
+ placeholders = ",".join(["%s"] * len(numeros))
1220
+ c.execute(f"SELECT numero_licitacion FROM radar_licitaciones WHERE numero_licitacion IN ({placeholders})", tuple(numeros))
1221
+ existentes = {str(row[0]) for row in c.fetchall()}
1222
+
1223
+ values = []
1224
+ for lic in licitaciones:
1225
+ numero = str(lic.get("numero_licitacion", "")).strip()
1226
+ if not numero:
1227
+ continue
1228
+ monto = float(lic.get("monto_estimado", 0) or 0)
1229
+ es_prioritaria = bool(lic.get("es_prioritaria", False))
1230
+ score = _calcular_score_radar(monto, es_prioritaria)
1231
+ values.append((
1232
+ numero,
1233
+ lic.get("objeto", ""),
1234
+ lic.get("categoria", "General (Autodetectado)"),
1235
+ monto,
1236
+ lic.get("moneda", "USD"),
1237
+ lic.get("fecha_apertura", ""),
1238
+ lic.get("fecha_cierre", ""),
1239
+ lic.get("link_sli", ""),
1240
+ es_prioritaria,
1241
+ fecha,
1242
+ fecha,
1243
+ score,
1244
+ "nueva",
1245
+ ))
1246
+
1247
+ if not values:
1248
+ conn.close()
1249
+ return 0, 0
1250
+
1251
+ execute_values(c, """
1252
+ INSERT INTO radar_licitaciones
1253
+ (numero_licitacion, objeto, categoria, monto_estimado, moneda,
1254
+ fecha_apertura, fecha_cierre, link_sli, es_prioritaria,
1255
+ fecha_descubierta, fecha_ultimo_escaneo, score_interes, estado_radar)
1256
+ VALUES %s
1257
+ ON CONFLICT (numero_licitacion) DO UPDATE SET
1258
+ objeto = EXCLUDED.objeto,
1259
+ categoria = EXCLUDED.categoria,
1260
+ moneda = EXCLUDED.moneda,
1261
+ fecha_apertura = EXCLUDED.fecha_apertura,
1262
+ fecha_cierre = EXCLUDED.fecha_cierre,
1263
+ link_sli = EXCLUDED.link_sli,
1264
+ es_prioritaria = EXCLUDED.es_prioritaria,
1265
+ fecha_ultimo_escaneo = EXCLUDED.fecha_ultimo_escaneo,
1266
+ score_interes = EXCLUDED.score_interes,
1267
+ monto_estimado = CASE WHEN EXCLUDED.monto_estimado > 0 THEN EXCLUDED.monto_estimado ELSE radar_licitaciones.monto_estimado END
1268
+ """, values)
1269
+ conn.commit()
1270
+ conn.close()
1271
+
1272
+ nuevas = len([n for n in numeros if n not in existentes])
1273
+ return nuevas, len(values)
1274
+
1275
+
1276
+ def get_licitaciones_radar(solo_nuevas=False, solo_hoy=False):
1277
+ """Retorna las licitaciones del radar."""
1278
+ conn = get_connection()
1279
+ query = "SELECT * FROM radar_licitaciones"
1280
+ conditions = []
1281
+ if solo_nuevas:
1282
+ conditions.append("estado_radar = 'nueva'")
1283
+ if solo_hoy:
1284
+ hoy = datetime.now().strftime("%Y-%m-%d")
1285
+ conditions.append(f"fecha_descubierta LIKE '{hoy}%'")
1286
+ if conditions:
1287
+ query += " WHERE " + " AND ".join(conditions)
1288
+ query += " ORDER BY score_interes DESC, monto_estimado DESC"
1289
+ df = pd.read_sql_query(query, conn)
1290
+ conn.close()
1291
+ return df
1292
+
1293
+
1294
+ def marcar_licitacion_radar(licitacion_id, estado, usuario, notas=""):
1295
+ """Marca una licitación del radar como revisada/descartada/en_seguimiento."""
1296
+ conn = get_connection()
1297
+ c = conn.cursor()
1298
+ c.execute("UPDATE radar_licitaciones SET estado_radar=%s, revisada_por=%s, notas=%s WHERE id=%s",
1299
+ (estado, usuario, notas, licitacion_id))
1300
+ conn.commit()
1301
+ conn.close()
1302
+
1303
+
1304
+ def eliminar_licitaciones_radar(ids):
1305
+ """Elimina licitaciones del radar por ID. Retorna cuantas filas fueron borradas."""
1306
+ ids = [int(i) for i in ids if str(i).isdigit()]
1307
+ if not ids:
1308
+ return 0
1309
+ conn = get_connection()
1310
+ c = conn.cursor()
1311
+ placeholders = ",".join(["%s"] * len(ids))
1312
+ c.execute(f"DELETE FROM radar_licitaciones WHERE id IN ({placeholders})", tuple(ids))
1313
+ deleted = c.rowcount
1314
+ conn.commit()
1315
+ conn.close()
1316
+ return deleted
1317
+
1318
+
1319
+ def eliminar_radar_fuera_de_numeros(numeros):
1320
+ """Elimina del radar licitaciones que ya no aparecen en el escaneo completo de abiertas."""
1321
+ numeros = [str(n).strip() for n in numeros if str(n).strip()]
1322
+ if not numeros:
1323
+ return 0
1324
+ conn = get_connection()
1325
+ c = conn.cursor()
1326
+ placeholders = ",".join(["%s"] * len(numeros))
1327
+ c.execute(f"DELETE FROM radar_licitaciones WHERE numero_licitacion NOT IN ({placeholders})", tuple(numeros))
1328
+ deleted = c.rowcount
1329
+ conn.commit()
1330
+ conn.close()
1331
+ return deleted
1332
+
1333
+
1334
+ def registrar_escaneo_radar(total, nuevas, errores=""):
1335
+ """Registra un escaneo del radar en el log."""
1336
+ conn = get_connection()
1337
+ c = conn.cursor()
1338
+ fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1339
+ c.execute("INSERT INTO radar_escaneos (fecha, total_encontradas, nuevas, errores) VALUES (%s, %s, %s, %s)",
1340
+ (fecha, total, nuevas, errores))
1341
+ conn.commit()
1342
+ conn.close()
1343
+
1344
+
1345
+ def get_ultimos_escaneos(limite=10):
1346
+ """Retorna los últimos escaneos del radar."""
1347
+ conn = get_connection()
1348
+ df = pd.read_sql_query(
1349
+ f"SELECT * FROM radar_escaneos ORDER BY id DESC LIMIT {limite}", conn)
1350
+ conn.close()
1351
+ return df
requirements.txt CHANGED
@@ -1,17 +1,19 @@
1
- streamlit
2
- pandas
3
- plotly
4
- tavily-python
5
- google-generativeai
6
- cryptography
7
- requests
8
- fastapi
9
- uvicorn
10
- python-multipart
11
- python-dotenv
12
- openpyxl
13
- playwright
14
- beautifulsoup4
15
- pypdf
16
- bcrypt
17
  psycopg2-binary
 
 
 
1
+ streamlit
2
+ pandas
3
+ plotly
4
+ tavily-python
5
+ google-generativeai
6
+ cryptography
7
+ requests
8
+ fastapi
9
+ uvicorn
10
+ python-multipart
11
+ python-dotenv
12
+ openpyxl
13
+ playwright
14
+ beautifulsoup4
15
+ pypdf
16
+ bcrypt
17
  psycopg2-binary
18
+ certifi
19
+ truststore
sli_scraper.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SLI Scraper - Radar de licitaciones abiertas de la ACP.
3
+
4
+ Consulta el portal publico del SLI, recorre todas las paginas de resultados
5
+ y guarda las licitaciones abiertas en la tabla radar_licitaciones.
6
+ """
7
+
8
+ import re
9
+ from datetime import datetime
10
+ from urllib.parse import parse_qs, urljoin, urlparse
11
+
12
+ import requests
13
+ import urllib3
14
+ from bs4 import BeautifulSoup
15
+
16
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
17
+
18
+ SLI_BASE = "https://apps.pancanal.com/sli"
19
+ SLI_SEARCH_URL = f"{SLI_BASE}/LicitacionesBusqueda/LicitacionesBusquedaParametros"
20
+
21
+ CATEGORIAS_PRIORITARIAS = [
22
+ "Electrical & Electronics",
23
+ "Fabricated (ACP)",
24
+ "Communications Equipment",
25
+ "Pumps/Compressors",
26
+ "Hydraulic",
27
+ "Mechanical Power Transmission",
28
+ "Engines & related component(non-vehicle)",
29
+ "Construction/Mining",
30
+ "Construction Material",
31
+ "Alarm, Signal & Detection",
32
+ "Instruments/Lab Equip",
33
+ "Metalworking",
34
+ ]
35
+
36
+ MESES_SLI = {
37
+ "ene": 1,
38
+ "feb": 2,
39
+ "mar": 3,
40
+ "abr": 4,
41
+ "may": 5,
42
+ "jun": 6,
43
+ "jul": 7,
44
+ "ago": 8,
45
+ "sep": 9,
46
+ "oct": 10,
47
+ "nov": 11,
48
+ "dic": 12,
49
+ }
50
+
51
+
52
+ def parse_sli_datetime(value):
53
+ if not value:
54
+ return None
55
+ text = re.sub(r"\s+", " ", str(value).replace("\xa0", " ")).strip().lower()
56
+ match = re.search(
57
+ r"(\d{1,2})-([a-záéíóúñ]{3,})-(\d{4})\s+(\d{1,2}):(\d{2})\s*([ap])\.?m\.?",
58
+ text,
59
+ re.IGNORECASE,
60
+ )
61
+ if not match:
62
+ return None
63
+
64
+ dia, mes_txt, anio, hora, minuto, ampm = match.groups()
65
+ mes = MESES_SLI.get(mes_txt[:3])
66
+ if not mes:
67
+ return None
68
+
69
+ hora = int(hora)
70
+ if ampm.lower() == "p" and hora != 12:
71
+ hora += 12
72
+ if ampm.lower() == "a" and hora == 12:
73
+ hora = 0
74
+
75
+ try:
76
+ return datetime(int(anio), mes, int(dia), hora, int(minuto))
77
+ except ValueError:
78
+ return None
79
+
80
+
81
+ def _extraer_monto_texto(texto):
82
+ if not texto:
83
+ return 0.0
84
+ patterns = [
85
+ r"[\$B/\.]+\s*([\d,]+(?:\.\d{2})?)",
86
+ r"([\d,]+(?:\.\d{2})?)\s*(?:USD|PAB|B/\.)",
87
+ ]
88
+ for pattern in patterns:
89
+ match = re.search(pattern, texto)
90
+ if match:
91
+ try:
92
+ return float(match.group(1).replace(",", ""))
93
+ except ValueError:
94
+ continue
95
+ return 0.0
96
+
97
+
98
+ def _extraer_max_pagina(soup):
99
+ paginas = {1}
100
+ for link in soup.find_all("a", href=True):
101
+ href = link.get("href") or ""
102
+ if "BusquedaLicitacionesResultados" not in href or "pagina=" not in href:
103
+ continue
104
+ qs = parse_qs(urlparse(href).query)
105
+ for value in qs.get("pagina", []):
106
+ if str(value).isdigit():
107
+ paginas.add(int(value))
108
+ return max(paginas) if paginas else 1
109
+
110
+
111
+ def _parsear_resultados_sli(soup):
112
+ resultados = []
113
+ items = soup.find_all("a", id="link_BiddingNumber")
114
+
115
+ for item in items:
116
+ numero = item.text.strip()
117
+ link = item.get("href", "")
118
+ if link and not link.startswith("http"):
119
+ link = urljoin("https://apps.pancanal.com", link)
120
+
121
+ container = item.find_parent("div", class_="col-lg-9") or item.find_parent("div")
122
+ if not container:
123
+ continue
124
+
125
+ objeto_tag = container.find("p", class_="title")
126
+ objeto = objeto_tag.text.strip() if objeto_tag else ""
127
+ if not objeto:
128
+ textos = [t.strip() for t in container.stripped_strings if t.strip()]
129
+ try:
130
+ idx = textos.index(numero)
131
+ objeto = textos[idx + 1] if idx + 1 < len(textos) else ""
132
+ except ValueError:
133
+ objeto = ""
134
+
135
+ texto_completo = container.text
136
+ apertura = ""
137
+ apertura_match = re.search(
138
+ r"Fecha\s+de\s+publicaci\S+n\s*([\d\-A-Za-z]+\s+[\d:]+\s+[APM]+)",
139
+ texto_completo,
140
+ re.IGNORECASE,
141
+ )
142
+ if apertura_match:
143
+ apertura = apertura_match.group(1).strip()
144
+
145
+ cierre = ""
146
+ cierre_match = re.search(
147
+ r"Fecha\s+y\s+hora\s+de\s+cierre\s*([\d\-A-Za-z]+\s+[\d:]+\s+[APM]+)",
148
+ texto_completo,
149
+ re.IGNORECASE,
150
+ )
151
+ if cierre_match:
152
+ cierre = re.sub(r"\s+", " ", cierre_match.group(1).replace("\xa0", " ")).strip()
153
+
154
+ objeto_lower = objeto.lower()
155
+ es_prioritaria = any(
156
+ key in objeto_lower
157
+ for key in [
158
+ "elect",
159
+ "cable",
160
+ "motor",
161
+ "bomba",
162
+ "panel",
163
+ "transformador",
164
+ "breaker",
165
+ "sensor",
166
+ "hidraul",
167
+ "valvula",
168
+ "repuesto",
169
+ ]
170
+ )
171
+
172
+ resultados.append(
173
+ {
174
+ "numero_licitacion": numero,
175
+ "objeto": objeto,
176
+ "categoria": "General (Autodetectado)",
177
+ "monto_estimado": _extraer_monto_texto(objeto),
178
+ "moneda": "USD",
179
+ "fecha_apertura": apertura,
180
+ "fecha_cierre": cierre,
181
+ "link_sli": link,
182
+ "es_prioritaria": es_prioritaria,
183
+ "fecha_descubierta": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
184
+ }
185
+ )
186
+
187
+ return resultados
188
+
189
+
190
+ def escanear_licitaciones_abiertas(
191
+ palabra_clave="",
192
+ numero_licitacion="",
193
+ categoria="TODOS",
194
+ max_paginas=30,
195
+ ):
196
+ session = requests.Session()
197
+ session.verify = False
198
+ palabra_clave = (palabra_clave or "").strip()
199
+ numero_licitacion = re.sub(r"\D", "", str(numero_licitacion or ""))
200
+ categoria = (categoria or "TODOS").strip() or "TODOS"
201
+
202
+ print("[RADAR] Obteniendo token de sesion del SLI...")
203
+ try:
204
+ resp_home = session.get(SLI_BASE, timeout=30)
205
+ resp_home.raise_for_status()
206
+ except Exception as exc:
207
+ print(f"[RADAR] Error accediendo al SLI: {exc}")
208
+ return []
209
+
210
+ soup_home = BeautifulSoup(resp_home.text, "html.parser")
211
+ token_input = soup_home.find("input", {"name": "__RequestVerificationToken"})
212
+ if not token_input:
213
+ print("[RADAR] No se encontro __RequestVerificationToken en la pagina principal.")
214
+ return []
215
+
216
+ payload = {
217
+ "Descripcion": [palabra_clave, palabra_clave],
218
+ "NumeroLicitacion": numero_licitacion,
219
+ "status": "AN",
220
+ "EstatusSeleccionadoID": "AN",
221
+ "categorias": categoria,
222
+ "CategoriaSeleccionadaID": categoria,
223
+ "Text": "100",
224
+ "__RequestVerificationToken": token_input["value"],
225
+ "submitBusqueda3": "Buscar",
226
+ }
227
+
228
+ print("[RADAR] Buscando licitaciones abiertas...")
229
+ try:
230
+ resp = session.post(SLI_SEARCH_URL, data=payload, timeout=60)
231
+ resp.raise_for_status()
232
+ except Exception as exc:
233
+ print(f"[RADAR] Error enviando formulario de busqueda: {exc}")
234
+ return []
235
+
236
+ soups = [BeautifulSoup(resp.text, "html.parser")]
237
+ max_pagina = min(_extraer_max_pagina(soups[0]), int(max_paginas or 30))
238
+ print(f"[RADAR] Paginas detectadas: {max_pagina}")
239
+
240
+ for pagina in range(2, max_pagina + 1):
241
+ page_url = f"{SLI_BASE}/LicitacionesBusqueda/BusquedaLicitacionesResultados?pagina={pagina}"
242
+ try:
243
+ page_resp = session.get(page_url, timeout=45)
244
+ page_resp.raise_for_status()
245
+ soups.append(BeautifulSoup(page_resp.text, "html.parser"))
246
+ except Exception as exc:
247
+ print(f"[RADAR] Error obteniendo pagina {pagina}: {exc}")
248
+
249
+ unicos = {}
250
+ now = datetime.now()
251
+ for soup in soups:
252
+ for lic in _parsear_resultados_sli(soup):
253
+ cierre_dt = parse_sli_datetime(lic.get("fecha_cierre"))
254
+ if cierre_dt and cierre_dt < now:
255
+ continue
256
+ numero = lic["numero_licitacion"]
257
+ if numero not in unicos:
258
+ unicos[numero] = lic
259
+
260
+ resultados = list(unicos.values())
261
+
262
+ def sort_key(lic):
263
+ cierre_dt = parse_sli_datetime(lic.get("fecha_cierre"))
264
+ apertura_dt = parse_sli_datetime(lic.get("fecha_apertura"))
265
+ return (
266
+ cierre_dt or datetime.max,
267
+ apertura_dt or datetime.max,
268
+ str(lic.get("numero_licitacion", "")),
269
+ )
270
+
271
+ resultados.sort(key=sort_key)
272
+ print(f"[RADAR] Parseo completado: {len(resultados)} licitaciones abiertas unicas listas.")
273
+ return resultados
274
+
275
+
276
+ def ejecutar_radar_detallado(db_module=None, palabra_clave="", numero_licitacion="", categoria="TODOS"):
277
+ if db_module is None:
278
+ import database as db_module
279
+
280
+ errores = ""
281
+ try:
282
+ licitaciones = escanear_licitaciones_abiertas(
283
+ palabra_clave=palabra_clave,
284
+ numero_licitacion=numero_licitacion,
285
+ categoria=categoria,
286
+ )
287
+ except Exception as exc:
288
+ licitaciones = []
289
+ errores = str(exc)
290
+
291
+ nuevas = 0
292
+ total = len(licitaciones)
293
+
294
+ if hasattr(db_module, "guardar_licitaciones_radar_bulk"):
295
+ nuevas, total = db_module.guardar_licitaciones_radar_bulk(licitaciones)
296
+ else:
297
+ for lic in licitaciones:
298
+ fue_nueva = db_module.guardar_licitacion_radar(
299
+ numero_licitacion=lic["numero_licitacion"],
300
+ objeto=lic["objeto"],
301
+ categoria=lic["categoria"],
302
+ monto_estimado=lic["monto_estimado"],
303
+ moneda=lic["moneda"],
304
+ fecha_apertura=lic["fecha_apertura"],
305
+ fecha_cierre=lic["fecha_cierre"],
306
+ link_sli=lic["link_sli"],
307
+ es_prioritaria=lic["es_prioritaria"],
308
+ )
309
+ if fue_nueva:
310
+ nuevas += 1
311
+
312
+ obsoletas_eliminadas = 0
313
+ es_escaneo_completo = not palabra_clave and not numero_licitacion and categoria == "TODOS"
314
+ if es_escaneo_completo and licitaciones and hasattr(db_module, "eliminar_radar_fuera_de_numeros"):
315
+ numeros_abiertos = [lic["numero_licitacion"] for lic in licitaciones]
316
+ obsoletas_eliminadas = db_module.eliminar_radar_fuera_de_numeros(numeros_abiertos)
317
+
318
+ if hasattr(db_module, "registrar_escaneo_radar"):
319
+ try:
320
+ db_module.registrar_escaneo_radar(total, nuevas, errores)
321
+ except Exception as exc:
322
+ errores = f"{errores} | Error registrando escaneo: {exc}".strip(" |")
323
+
324
+ print(f"[RADAR] Resultados: {nuevas} nuevas de {total} totales")
325
+ return {
326
+ "total": total,
327
+ "nuevas": nuevas,
328
+ "actualizadas": max(total - nuevas, 0),
329
+ "obsoletas_eliminadas": obsoletas_eliminadas,
330
+ "errores": errores,
331
+ "licitaciones": licitaciones,
332
+ }
333
+
334
+
335
+ def ejecutar_radar(db_module=None):
336
+ resultado = ejecutar_radar_detallado(db_module=db_module)
337
+ return resultado["nuevas"], resultado["total"]
338
+
339
+
340
+ if __name__ == "__main__":
341
+ import io
342
+ import sys
343
+
344
+ from dotenv import load_dotenv
345
+
346
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
347
+ load_dotenv()
348
+
349
+ print("[RADAR] Ejecutando Radar SLI manualmente...")
350
+ lics = escanear_licitaciones_abiertas()
351
+ if lics:
352
+ print(f"\n[RADAR] {len(lics)} licitaciones encontradas:\n")
353
+ for i, lic in enumerate(lics[:30], 1):
354
+ prio = "[PRIO]" if lic["es_prioritaria"] else " "
355
+ monto_str = f"${lic['monto_estimado']:,.2f}" if lic["monto_estimado"] > 0 else "Sin monto"
356
+ print(f"{prio} {i}. [{lic['numero_licitacion']}] {lic['objeto'][:90]}")
357
+ print(f" Monto: {monto_str} | Apertura: {lic['fecha_apertura']} | Cierre: {lic['fecha_cierre']}")
358
+ print()
359
+ else:
360
+ print("[RADAR] No se encontraron licitaciones. Verificar la conexion al SLI.")
style.css CHANGED
@@ -13,23 +13,8 @@ html, body, [data-testid="stAppViewContainer"] {
13
  [data-testid="stHeader"] { background-color: transparent !important; }
14
  [data-testid="stAppViewBlockContainer"] { padding-top: 10px; }
15
 
16
- /* ===== NAVBAR ===== */
17
- .top-navbar {
18
- position: fixed;
19
- top: 0; left: 0;
20
- width: 100%;
21
- background: rgba(13, 17, 23, 0.85);
22
- backdrop-filter: blur(12px);
23
- -webkit-backdrop-filter: blur(12px);
24
- padding: 14px 60px;
25
- border-bottom: 1px solid rgba(88, 166, 255, 0.3);
26
- display: flex;
27
- justify-content: space-between;
28
- align-items: center;
29
- z-index: 9999;
30
- box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5);
31
- }
32
- .main-content-spacer { margin-top: 65px; }
33
 
34
  /* ===== MÉTRICAS ===== */
35
  div[data-testid="metric-container"] {
@@ -154,7 +139,50 @@ div[data-testid="metric-container"]:hover {
154
  ::-webkit-scrollbar-thumb:hover { background: #58A6FF; }
155
 
156
  /* ===== DATAFRAME ===== */
157
- [data-testid="stDataFrame"] { border-radius: 12px; overflow: hidden; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
159
  /* ===== OPERATIONS UI REFRESH ===== */
160
  :root {
@@ -420,4 +448,1555 @@ div[data-testid="metric-container"]:hover {
420
  }
421
  .provider-card a:hover {
422
  text-decoration: underline;
423
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  [data-testid="stHeader"] { background-color: transparent !important; }
14
  [data-testid="stAppViewBlockContainer"] { padding-top: 10px; }
15
 
16
+ /* ===== NAVBAR LATERAL ===== */
17
+ .main-content-spacer { margin-top: 0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  /* ===== MÉTRICAS ===== */
20
  div[data-testid="metric-container"] {
 
139
  ::-webkit-scrollbar-thumb:hover { background: #58A6FF; }
140
 
141
  /* ===== DATAFRAME ===== */
142
+ [data-testid="stDataFrame"] { border-radius: 12px; overflow: hidden; }
143
+
144
+ /* ===== LOADING / RERUN SIN OSCURECER LA APP ===== */
145
+ .stApp,
146
+ main,
147
+ html,
148
+ body,
149
+ [data-testid="stAppViewContainer"],
150
+ [data-testid="stAppViewBlockContainer"],
151
+ [data-testid="stVerticalBlock"] {
152
+ opacity: 1 !important;
153
+ filter: none !important;
154
+ }
155
+
156
+ [data-testid="stAppViewContainer"]::before,
157
+ [data-testid="stAppViewContainer"]::after,
158
+ [data-testid="stAppViewBlockContainer"]::before,
159
+ [data-testid="stAppViewBlockContainer"]::after {
160
+ background: transparent !important;
161
+ opacity: 0 !important;
162
+ pointer-events: none !important;
163
+ }
164
+
165
+ [data-testid="stStatusWidget"] {
166
+ position: fixed !important;
167
+ top: auto !important;
168
+ right: 18px !important;
169
+ bottom: 18px !important;
170
+ background: rgba(15, 20, 28, 0.96) !important;
171
+ border: 1px solid #344255 !important;
172
+ border-radius: 8px !important;
173
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35) !important;
174
+ color: #E8EDF5 !important;
175
+ z-index: 10000 !important;
176
+ }
177
+
178
+ [data-testid="stSpinner"],
179
+ .stSpinner {
180
+ background: rgba(15, 20, 28, 0.92) !important;
181
+ border: 1px solid #2F7FD8 !important;
182
+ border-radius: 8px !important;
183
+ padding: 10px 12px !important;
184
+ box-shadow: none !important;
185
+ }
186
 
187
  /* ===== OPERATIONS UI REFRESH ===== */
188
  :root {
 
448
  }
449
  .provider-card a:hover {
450
  text-decoration: underline;
451
+ }
452
+
453
+ .provider-source-link {
454
+ display: flex;
455
+ align-items: center;
456
+ justify-content: space-between;
457
+ gap: 10px;
458
+ background: #101720;
459
+ border: 1px solid var(--procura-border);
460
+ border-radius: 8px;
461
+ padding: 10px 12px;
462
+ margin-bottom: 8px;
463
+ color: var(--procura-text) !important;
464
+ text-decoration: none !important;
465
+ font-size: 13px;
466
+ font-weight: 750;
467
+ }
468
+
469
+ .provider-source-link:hover {
470
+ border-color: #3F8FE5;
471
+ background: #121C28;
472
+ }
473
+
474
+ .provider-source-link b {
475
+ color: #8EC8FF;
476
+ font-size: 11px;
477
+ }
478
+
479
+ .provider-source-card {
480
+ background: #101720;
481
+ border: 1px solid var(--procura-border);
482
+ border-left: 3px solid var(--procura-blue);
483
+ border-radius: 8px;
484
+ padding: 11px 12px;
485
+ margin-bottom: 9px;
486
+ }
487
+
488
+ .provider-rfq-card {
489
+ background: #101720;
490
+ border: 1px solid var(--procura-border);
491
+ border-left: 3px solid var(--procura-green);
492
+ border-radius: 8px;
493
+ padding: 12px 14px;
494
+ margin: 9px 0 14px 0;
495
+ }
496
+
497
+ .provider-rfq-eyebrow {
498
+ color: #7CE0B7;
499
+ font-size: 10px;
500
+ font-weight: 840;
501
+ text-transform: uppercase;
502
+ margin-bottom: 5px;
503
+ }
504
+
505
+ .provider-rfq-title {
506
+ color: var(--procura-text);
507
+ font-size: 15px;
508
+ line-height: 1.3;
509
+ font-weight: 850;
510
+ margin-bottom: 9px;
511
+ }
512
+
513
+ .provider-rfq-meta {
514
+ display: flex;
515
+ flex-wrap: wrap;
516
+ gap: 6px;
517
+ }
518
+
519
+ .provider-rfq-meta span {
520
+ display: inline-flex;
521
+ align-items: center;
522
+ gap: 5px;
523
+ min-height: 24px;
524
+ padding: 3px 8px;
525
+ border-radius: 999px;
526
+ background: #0B1017;
527
+ border: 1px solid #26384E;
528
+ color: var(--procura-muted);
529
+ font-size: 10.5px;
530
+ font-weight: 760;
531
+ }
532
+
533
+ .provider-rfq-meta b {
534
+ color: #DCE7F4;
535
+ font-size: 10.5px;
536
+ }
537
+
538
+ .source-readiness-grid {
539
+ display: grid;
540
+ grid-template-columns: repeat(auto-fit, minmax(128px, 1fr));
541
+ gap: 8px;
542
+ margin: 10px 0 8px 0;
543
+ }
544
+
545
+ .source-pill {
546
+ background: #0D141D;
547
+ border: 1px solid var(--procura-border);
548
+ border-left: 3px solid var(--procura-blue);
549
+ border-radius: 8px;
550
+ padding: 9px 10px;
551
+ min-height: 62px;
552
+ transition: border-color 0.15s ease, background 0.15s ease, opacity 0.15s ease;
553
+ }
554
+
555
+ .source-pill.is-muted {
556
+ opacity: 0.52;
557
+ }
558
+
559
+ .source-pill.is-selected {
560
+ background: #111B27;
561
+ border-color: #3F8FE5;
562
+ }
563
+
564
+ .source-pill.tone-green { border-left-color: var(--procura-green); }
565
+ .source-pill.tone-amber { border-left-color: var(--procura-amber); }
566
+ .source-pill.tone-blue { border-left-color: var(--procura-blue); }
567
+
568
+ .source-pill-top {
569
+ display: flex;
570
+ align-items: center;
571
+ justify-content: space-between;
572
+ gap: 7px;
573
+ margin-bottom: 4px;
574
+ }
575
+
576
+ .source-pill-top b {
577
+ color: var(--procura-text);
578
+ font-size: 12px;
579
+ line-height: 1.15;
580
+ }
581
+
582
+ .source-pill-top span {
583
+ color: #CFE6FF;
584
+ background: #102033;
585
+ border: 1px solid #284C73;
586
+ border-radius: 999px;
587
+ padding: 2px 6px;
588
+ font-size: 9.5px;
589
+ font-weight: 820;
590
+ white-space: nowrap;
591
+ }
592
+
593
+ .source-pill.tone-green .source-pill-top span {
594
+ color: #7CE0B7;
595
+ background: #0E241A;
596
+ border-color: #1F805E;
597
+ }
598
+
599
+ .source-pill.tone-amber .source-pill-top span {
600
+ color: #FFD48A;
601
+ background: #2A1E0F;
602
+ border-color: #6A4A15;
603
+ }
604
+
605
+ .source-pill small {
606
+ display: block;
607
+ color: var(--procura-muted);
608
+ font-size: 10.5px;
609
+ line-height: 1.2;
610
+ font-weight: 720;
611
+ }
612
+
613
+ .free-source-grid {
614
+ display: grid;
615
+ grid-template-columns: repeat(2, minmax(0, 1fr));
616
+ gap: 8px;
617
+ margin: 8px 0 12px 0;
618
+ }
619
+
620
+ .provider-source-card.compact {
621
+ margin-bottom: 0;
622
+ padding: 10px;
623
+ min-height: 132px;
624
+ display: flex;
625
+ flex-direction: column;
626
+ }
627
+
628
+ .provider-source-card.tone-green { border-left-color: var(--procura-green); }
629
+ .provider-source-card.tone-amber { border-left-color: var(--procura-amber); }
630
+ .provider-source-card.tone-blue { border-left-color: var(--procura-blue); }
631
+
632
+ .source-card-top {
633
+ display: flex;
634
+ justify-content: space-between;
635
+ align-items: flex-start;
636
+ gap: 12px;
637
+ margin-bottom: 7px;
638
+ }
639
+
640
+ .provider-source-card.compact .source-card-top {
641
+ justify-content: flex-start;
642
+ align-items: center;
643
+ gap: 8px;
644
+ margin-bottom: 8px;
645
+ }
646
+
647
+ .source-mark {
648
+ width: 30px;
649
+ height: 30px;
650
+ border-radius: 8px;
651
+ display: flex;
652
+ align-items: center;
653
+ justify-content: center;
654
+ background: #0B1017;
655
+ border: 1px solid #2B3B4F;
656
+ color: #E9EEF5;
657
+ font-size: 11px;
658
+ font-weight: 850;
659
+ flex: 0 0 auto;
660
+ }
661
+
662
+ .source-name {
663
+ color: var(--procura-text);
664
+ font-size: 13px;
665
+ font-weight: 850;
666
+ line-height: 1.15;
667
+ }
668
+
669
+ .source-kind {
670
+ color: var(--procura-muted);
671
+ font-size: 10px;
672
+ font-weight: 750;
673
+ text-transform: uppercase;
674
+ margin-top: 3px;
675
+ }
676
+
677
+ .source-status {
678
+ color: #CFE6FF;
679
+ background: #112033;
680
+ border: 1px solid #284C73;
681
+ border-radius: 999px;
682
+ padding: 2px 7px;
683
+ font-size: 10px;
684
+ font-weight: 800;
685
+ white-space: nowrap;
686
+ }
687
+
688
+ .provider-source-card.compact .source-status {
689
+ align-self: flex-start;
690
+ margin-bottom: 7px;
691
+ }
692
+
693
+ .source-detail {
694
+ color: var(--procura-muted);
695
+ font-size: 12px;
696
+ line-height: 1.4;
697
+ min-height: 34px;
698
+ }
699
+
700
+ .provider-source-card.compact .source-detail {
701
+ min-height: auto;
702
+ flex: 1;
703
+ }
704
+
705
+ .source-action {
706
+ border-top: 1px solid var(--procura-border);
707
+ margin-top: 9px;
708
+ padding-top: 8px;
709
+ font-size: 12px;
710
+ font-weight: 800;
711
+ }
712
+
713
+ .source-action a {
714
+ display: inline-flex;
715
+ justify-content: center;
716
+ width: 100%;
717
+ border: 1px solid #2D5F91;
718
+ border-radius: 7px;
719
+ background: #112033;
720
+ color: #CFE6FF !important;
721
+ text-decoration: none !important;
722
+ padding: 6px 8px;
723
+ }
724
+
725
+ .source-action span {
726
+ color: var(--procura-muted);
727
+ }
728
+
729
+ .provider-query-chips {
730
+ display: flex;
731
+ flex-wrap: wrap;
732
+ gap: 7px;
733
+ margin: 8px 0 12px 0;
734
+ }
735
+
736
+ .provider-query-chips span {
737
+ display: inline-flex;
738
+ align-items: center;
739
+ min-height: 24px;
740
+ padding: 3px 9px;
741
+ border-radius: 999px;
742
+ background: #0B1017;
743
+ border: 1px solid var(--procura-border);
744
+ color: #B8C4D2;
745
+ font-size: 11px;
746
+ font-weight: 750;
747
+ }
748
+
749
+ .item-detail-panel {
750
+ background: #101720;
751
+ border: 1px solid var(--procura-border);
752
+ border-left: 3px solid var(--procura-blue);
753
+ border-radius: 8px;
754
+ padding: 15px;
755
+ margin: 14px 0 10px 0;
756
+ }
757
+
758
+ .item-detail-head {
759
+ display: flex;
760
+ align-items: flex-start;
761
+ justify-content: space-between;
762
+ gap: 16px;
763
+ margin-bottom: 12px;
764
+ }
765
+
766
+ .item-detail-grid {
767
+ display: grid;
768
+ grid-template-columns: repeat(auto-fit, minmax(145px, 1fr));
769
+ gap: 8px;
770
+ margin-bottom: 10px;
771
+ }
772
+
773
+ .item-detail-grid div {
774
+ background: #0B1017;
775
+ border: 1px solid var(--procura-border);
776
+ border-radius: 8px;
777
+ padding: 9px 10px;
778
+ }
779
+
780
+ .item-detail-grid span {
781
+ display: block;
782
+ color: var(--procura-muted);
783
+ font-size: 10px;
784
+ font-weight: 800;
785
+ text-transform: uppercase;
786
+ margin-bottom: 4px;
787
+ }
788
+
789
+ .item-detail-grid b {
790
+ color: var(--procura-text);
791
+ font-size: 13px;
792
+ }
793
+
794
+ .item-warning-line {
795
+ background: #141A22;
796
+ border: 1px solid #30363D;
797
+ border-left: 3px solid var(--procura-amber);
798
+ border-radius: 8px;
799
+ color: #E6EDF3;
800
+ font-size: 13px;
801
+ line-height: 1.45;
802
+ padding: 10px 12px;
803
+ margin-bottom: 10px;
804
+ }
805
+
806
+ .item-description-box {
807
+ background: #0B1017;
808
+ border: 1px solid var(--procura-border);
809
+ border-radius: 8px;
810
+ color: #DCE6F2;
811
+ font-size: 13px;
812
+ line-height: 1.55;
813
+ max-height: 260px;
814
+ overflow: auto;
815
+ padding: 12px;
816
+ white-space: pre-wrap;
817
+ }
818
+
819
+ .technical-spec-box {
820
+ background-color: #0D1117;
821
+ border: 1px solid #30363D;
822
+ border-radius: 8px;
823
+ color: #E6EDF3;
824
+ font-size: 14.5px;
825
+ line-height: 1.7;
826
+ margin-bottom: 10px;
827
+ padding: 20px;
828
+ white-space: pre-wrap;
829
+ box-shadow: inset 0 2px 4px rgba(0,0,0,0.2);
830
+ }
831
+
832
+ .technical-spec-box.checklist-mode {
833
+ padding: 12px;
834
+ }
835
+
836
+ .checklist-title {
837
+ color: var(--procura-text);
838
+ font-size: 13px;
839
+ font-weight: 850;
840
+ margin: 10px 0 8px 0;
841
+ }
842
+
843
+ .checklist-block {
844
+ display: grid;
845
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
846
+ gap: 8px;
847
+ margin-bottom: 10px;
848
+ }
849
+
850
+ .checklist-row {
851
+ display: flex;
852
+ align-items: flex-start;
853
+ gap: 9px;
854
+ background: #0B1017;
855
+ border: 1px solid var(--procura-border);
856
+ border-radius: 8px;
857
+ padding: 10px 11px;
858
+ }
859
+
860
+ .check-dot {
861
+ width: 16px;
862
+ height: 16px;
863
+ border-radius: 999px;
864
+ border: 1px solid #3F8FE5;
865
+ background: #112033;
866
+ flex: 0 0 auto;
867
+ margin-top: 2px;
868
+ }
869
+
870
+ .checklist-row.state-ok .check-dot {
871
+ border-color: #1F805E;
872
+ background: #0E241A;
873
+ }
874
+
875
+ .checklist-row.state-warn .check-dot {
876
+ border-color: #9B6A1E;
877
+ background: #261F0B;
878
+ }
879
+
880
+ .checklist-row b {
881
+ display: block;
882
+ color: var(--procura-text);
883
+ font-size: 12.5px;
884
+ line-height: 1.25;
885
+ margin-bottom: 3px;
886
+ }
887
+
888
+ .checklist-row em {
889
+ display: block;
890
+ color: var(--procura-muted);
891
+ font-size: 12px;
892
+ font-style: normal;
893
+ line-height: 1.35;
894
+ }
895
+
896
+ .analysis-state-panel {
897
+ background: #0D1520;
898
+ border: 1px solid rgba(126, 148, 173, 0.28);
899
+ border-radius: 8px;
900
+ padding: 12px;
901
+ margin: 8px 0 16px 0;
902
+ }
903
+
904
+ .analysis-state-panel .checklist-block {
905
+ margin-bottom: 0;
906
+ }
907
+
908
+ .analysis-state-panel .checklist-row {
909
+ min-height: 68px;
910
+ }
911
+
912
+ .analysis-state-panel .checklist-row.state-ok {
913
+ border-left: 3px solid var(--procura-green);
914
+ }
915
+
916
+ .analysis-state-panel .checklist-row.state-warn {
917
+ border-left: 3px solid var(--procura-amber);
918
+ }
919
+
920
+ .future-note {
921
+ margin-top: 10px;
922
+ color: var(--procura-muted);
923
+ background: #0B1017;
924
+ border: 1px dashed var(--procura-border-strong);
925
+ border-radius: 8px;
926
+ font-size: 12px;
927
+ line-height: 1.45;
928
+ padding: 9px 10px;
929
+ }
930
+
931
+ /* ===== FRONTEND PHASE 1: NAV + DASHBOARD ===== */
932
+ :root {
933
+ --procura-bg: #090D13;
934
+ --procura-sidebar: #0C1118;
935
+ --procura-surface: #111821;
936
+ --procura-surface-2: #151D28;
937
+ --procura-border: #263241;
938
+ --procura-border-strong: #344357;
939
+ --procura-text: #E9EEF5;
940
+ --procura-muted: #93A0B2;
941
+ --procura-blue: #3F8FE5;
942
+ --procura-green: #2FBF8F;
943
+ --procura-amber: #E6A94A;
944
+ }
945
+
946
+ html, body, [data-testid="stAppViewContainer"] {
947
+ background: var(--procura-bg) !important;
948
+ }
949
+
950
+ [data-testid="stSidebar"] {
951
+ background: var(--procura-sidebar) !important;
952
+ border-right: 1px solid var(--procura-border) !important;
953
+ min-width: 285px;
954
+ }
955
+
956
+ [data-testid="stAppViewBlockContainer"] {
957
+ padding-top: 8px;
958
+ max-width: 1500px;
959
+ }
960
+
961
+ .role-pill {
962
+ background: #102033;
963
+ border: 1px solid #214A73;
964
+ color: #8EC8FF;
965
+ border-radius: 999px;
966
+ padding: 3px 9px;
967
+ font-size: 11px;
968
+ font-weight: 700;
969
+ }
970
+
971
+ .main-content-spacer {
972
+ margin-top: 0 !important;
973
+ }
974
+
975
+ .sidebar-brand {
976
+ display: flex;
977
+ align-items: center;
978
+ gap: 10px;
979
+ padding: 12px 6px 14px 6px;
980
+ margin-bottom: 8px;
981
+ border-bottom: 1px solid #1C2633;
982
+ }
983
+
984
+ .brand-mark {
985
+ width: 38px;
986
+ height: 38px;
987
+ border-radius: 8px;
988
+ background: #163B5F;
989
+ border: 1px solid #2B7EC6;
990
+ color: #F7FBFF;
991
+ display: flex;
992
+ align-items: center;
993
+ justify-content: center;
994
+ font-weight: 850;
995
+ box-shadow: 0 8px 18px rgba(15, 95, 160, 0.22);
996
+ }
997
+
998
+ .brand-title {
999
+ color: var(--procura-text);
1000
+ font-size: 13px;
1001
+ font-weight: 850;
1002
+ line-height: 1.15;
1003
+ }
1004
+
1005
+ .brand-subtitle {
1006
+ color: var(--procura-muted);
1007
+ font-size: 11px;
1008
+ line-height: 1.2;
1009
+ }
1010
+
1011
+ .sidebar-user-card {
1012
+ background: linear-gradient(180deg, #121A24 0%, #0C1118 100%);
1013
+ border: 1px solid #2A384A;
1014
+ border-radius: 8px;
1015
+ padding: 12px;
1016
+ margin: 0 0 14px 0;
1017
+ }
1018
+
1019
+ .sidebar-user-top {
1020
+ display: flex;
1021
+ align-items: center;
1022
+ gap: 9px;
1023
+ margin-bottom: 10px;
1024
+ }
1025
+
1026
+ .sidebar-avatar {
1027
+ width: 30px;
1028
+ height: 30px;
1029
+ border-radius: 8px;
1030
+ background: #173150;
1031
+ border: 1px solid #2B679E;
1032
+ color: #F4FAFF;
1033
+ display: flex;
1034
+ align-items: center;
1035
+ justify-content: center;
1036
+ font-size: 13px;
1037
+ font-weight: 850;
1038
+ }
1039
+
1040
+ .sidebar-user-name {
1041
+ color: var(--procura-text);
1042
+ font-size: 13px;
1043
+ font-weight: 800;
1044
+ line-height: 1.15;
1045
+ }
1046
+
1047
+ .sidebar-user-role {
1048
+ color: var(--procura-muted);
1049
+ font-size: 11px;
1050
+ line-height: 1.2;
1051
+ }
1052
+
1053
+ .sidebar-system-row {
1054
+ display: flex;
1055
+ justify-content: space-between;
1056
+ align-items: center;
1057
+ border-top: 1px solid var(--procura-border);
1058
+ padding-top: 9px;
1059
+ color: var(--procura-muted);
1060
+ font-size: 11px;
1061
+ }
1062
+
1063
+ .system-pill {
1064
+ border-radius: 999px;
1065
+ padding: 2px 8px;
1066
+ font-size: 10px;
1067
+ border: 1px solid var(--procura-border);
1068
+ color: var(--procura-muted);
1069
+ }
1070
+
1071
+ .system-pill.online {
1072
+ background: #0E241A;
1073
+ border-color: #1F805E;
1074
+ color: #7CE0B7;
1075
+ }
1076
+
1077
+ .system-pill.offline {
1078
+ background: #2A1111;
1079
+ border-color: #7A2929;
1080
+ color: #FF9D9D;
1081
+ }
1082
+
1083
+ .sidebar-status-card {
1084
+ background: #0B1017;
1085
+ border: 1px solid var(--procura-border);
1086
+ border-radius: 8px;
1087
+ padding: 9px 11px;
1088
+ margin-bottom: 10px;
1089
+ }
1090
+
1091
+ .sidebar-status-card div {
1092
+ display: flex;
1093
+ align-items: center;
1094
+ justify-content: space-between;
1095
+ gap: 10px;
1096
+ padding: 5px 0;
1097
+ color: var(--procura-muted);
1098
+ font-size: 11px;
1099
+ }
1100
+
1101
+ .sidebar-status-card div + div {
1102
+ border-top: 1px solid #1C2633;
1103
+ }
1104
+
1105
+ .sidebar-status-card b {
1106
+ color: var(--procura-text);
1107
+ font-size: 11px;
1108
+ }
1109
+
1110
+ .sidebar-section-label {
1111
+ color: var(--procura-muted);
1112
+ font-size: 10px;
1113
+ font-weight: 800;
1114
+ text-transform: uppercase;
1115
+ letter-spacing: 0;
1116
+ margin: 12px 0 8px 0;
1117
+ }
1118
+
1119
+ .nav-group-label {
1120
+ color: #6F7D8F;
1121
+ font-size: 10px;
1122
+ font-weight: 850;
1123
+ text-transform: uppercase;
1124
+ margin: 12px 0 5px 4px;
1125
+ }
1126
+
1127
+ [data-testid="stSidebar"] [data-testid="stButton"] > button {
1128
+ justify-content: flex-start;
1129
+ border-radius: 8px !important;
1130
+ min-height: 40px;
1131
+ margin-bottom: 3px;
1132
+ font-size: 13px;
1133
+ font-weight: 760;
1134
+ padding-left: 12px !important;
1135
+ }
1136
+
1137
+ [data-testid="stSidebar"] [data-testid="stBaseButton-secondary"] {
1138
+ background: transparent !important;
1139
+ border-color: transparent !important;
1140
+ color: #B8C4D2 !important;
1141
+ }
1142
+
1143
+ [data-testid="stSidebar"] [data-testid="stBaseButton-secondary"]:hover {
1144
+ background: #121A24 !important;
1145
+ border-color: var(--procura-border) !important;
1146
+ color: var(--procura-text) !important;
1147
+ }
1148
+
1149
+ [data-testid="stSidebar"] [data-testid="stBaseButton-primary"] {
1150
+ background: #14263A !important;
1151
+ border-color: #2F6EA8 !important;
1152
+ color: #F4FAFF !important;
1153
+ box-shadow: inset 4px 0 0 #2FBF8F !important;
1154
+ }
1155
+
1156
+ .sidebar-analysis-card {
1157
+ background: #0F151D;
1158
+ border: 1px dashed #344357;
1159
+ border-radius: 8px;
1160
+ padding: 11px 12px;
1161
+ margin-bottom: 9px;
1162
+ }
1163
+
1164
+ .analysis-card-title {
1165
+ color: var(--procura-text);
1166
+ font-size: 13px;
1167
+ font-weight: 850;
1168
+ margin-bottom: 4px;
1169
+ }
1170
+
1171
+ .analysis-card-copy {
1172
+ color: var(--procura-muted);
1173
+ font-size: 11px;
1174
+ line-height: 1.4;
1175
+ }
1176
+
1177
+ .page-hero {
1178
+ margin: 4px 0 18px 0;
1179
+ }
1180
+
1181
+ .page-title-row {
1182
+ display: flex;
1183
+ align-items: flex-start;
1184
+ justify-content: space-between;
1185
+ gap: 16px;
1186
+ padding: 2px 0 6px 0;
1187
+ }
1188
+
1189
+ .page-eyebrow {
1190
+ color: var(--procura-blue);
1191
+ font-size: 11px;
1192
+ font-weight: 800;
1193
+ text-transform: uppercase;
1194
+ letter-spacing: 0;
1195
+ margin-bottom: 4px;
1196
+ }
1197
+
1198
+ .page-title {
1199
+ color: var(--procura-text);
1200
+ font-size: 28px;
1201
+ line-height: 1.1;
1202
+ margin: 0;
1203
+ font-weight: 800;
1204
+ }
1205
+
1206
+ .page-subtitle {
1207
+ color: var(--procura-muted);
1208
+ font-size: 13px;
1209
+ margin-top: 6px;
1210
+ }
1211
+
1212
+ .system-status {
1213
+ display: inline-flex;
1214
+ align-items: center;
1215
+ gap: 8px;
1216
+ background: var(--procura-surface);
1217
+ border: 1px solid var(--procura-border);
1218
+ border-radius: 999px;
1219
+ padding: 7px 11px;
1220
+ color: var(--procura-muted);
1221
+ font-size: 12px;
1222
+ white-space: nowrap;
1223
+ }
1224
+
1225
+ .system-status .status-dot {
1226
+ background: var(--procura-amber);
1227
+ }
1228
+
1229
+ .system-status.is-online .status-dot {
1230
+ background: var(--procura-green);
1231
+ }
1232
+
1233
+ .system-status.is-offline .status-dot {
1234
+ background: #EF5B5B;
1235
+ }
1236
+
1237
+ .dashboard-grid {
1238
+ margin-top: 14px;
1239
+ }
1240
+
1241
+ .ops-hero {
1242
+ display: grid;
1243
+ grid-template-columns: minmax(0, 1fr) 280px;
1244
+ gap: 14px;
1245
+ align-items: stretch;
1246
+ background: #0F151D;
1247
+ border: 1px solid var(--procura-border);
1248
+ border-radius: 8px;
1249
+ padding: 18px;
1250
+ margin: 4px 0 16px 0;
1251
+ }
1252
+
1253
+ .ops-hero-main {
1254
+ min-width: 0;
1255
+ }
1256
+
1257
+ .ops-hero-pills {
1258
+ display: flex;
1259
+ flex-wrap: wrap;
1260
+ gap: 7px;
1261
+ margin-top: 12px;
1262
+ }
1263
+
1264
+ .ops-hero-pills span {
1265
+ background: #0B1017;
1266
+ border: 1px solid var(--procura-border);
1267
+ border-radius: 999px;
1268
+ color: #B8C4D2;
1269
+ font-size: 11px;
1270
+ font-weight: 750;
1271
+ padding: 4px 9px;
1272
+ }
1273
+
1274
+ .ops-hero-pills .pill-ok {
1275
+ border-color: #1F805E;
1276
+ color: #7CE0B7;
1277
+ }
1278
+
1279
+ .ops-hero-pills .pill-alert {
1280
+ border-color: #7A2929;
1281
+ color: #FF9D9D;
1282
+ }
1283
+
1284
+ .ops-hero-side {
1285
+ background: #0B1017;
1286
+ border: 1px solid var(--procura-border);
1287
+ border-radius: 8px;
1288
+ padding: 11px 13px;
1289
+ }
1290
+
1291
+ .ops-hero-side div {
1292
+ display: flex;
1293
+ align-items: center;
1294
+ justify-content: space-between;
1295
+ gap: 14px;
1296
+ padding: 9px 0;
1297
+ border-bottom: 1px solid #1C2633;
1298
+ }
1299
+
1300
+ .ops-hero-side div:last-child {
1301
+ border-bottom: 0;
1302
+ }
1303
+
1304
+ .ops-hero-side span {
1305
+ color: var(--procura-muted);
1306
+ font-size: 12px;
1307
+ }
1308
+
1309
+ .ops-hero-side b {
1310
+ color: var(--procura-text);
1311
+ font-size: 18px;
1312
+ }
1313
+
1314
+ .ops-route-card {
1315
+ min-height: 126px;
1316
+ background: #101720;
1317
+ border: 1px solid var(--procura-border);
1318
+ border-top: 3px solid var(--procura-blue);
1319
+ border-radius: 8px;
1320
+ padding: 13px;
1321
+ margin-bottom: 8px;
1322
+ }
1323
+
1324
+ .route-title {
1325
+ color: var(--procura-text);
1326
+ font-size: 15px;
1327
+ font-weight: 850;
1328
+ margin-bottom: 7px;
1329
+ }
1330
+
1331
+ .route-body {
1332
+ color: var(--procura-muted);
1333
+ font-size: 12.5px;
1334
+ line-height: 1.45;
1335
+ }
1336
+
1337
+ .dashboard-panel {
1338
+ min-height: 132px;
1339
+ margin-bottom: 10px;
1340
+ }
1341
+
1342
+ .panel-label {
1343
+ color: var(--procura-muted);
1344
+ font-size: 10px;
1345
+ font-weight: 800;
1346
+ text-transform: uppercase;
1347
+ letter-spacing: 0;
1348
+ margin-bottom: 8px;
1349
+ }
1350
+
1351
+ .panel-title {
1352
+ color: var(--procura-text);
1353
+ font-size: 17px;
1354
+ font-weight: 800;
1355
+ margin-bottom: 6px;
1356
+ }
1357
+
1358
+ .panel-copy {
1359
+ color: var(--procura-muted);
1360
+ font-size: 13px;
1361
+ line-height: 1.45;
1362
+ }
1363
+
1364
+ .dashboard-stat-row {
1365
+ display: flex;
1366
+ justify-content: space-between;
1367
+ align-items: center;
1368
+ border-top: 1px solid var(--procura-border);
1369
+ padding: 9px 0;
1370
+ color: var(--procura-muted);
1371
+ font-size: 13px;
1372
+ }
1373
+
1374
+ .dashboard-stat-row:first-of-type {
1375
+ border-top: 0;
1376
+ padding-top: 0;
1377
+ }
1378
+
1379
+ .dashboard-stat-row b {
1380
+ color: var(--procura-text);
1381
+ font-size: 15px;
1382
+ }
1383
+
1384
+ div[data-testid="metric-container"] {
1385
+ background: var(--procura-surface) !important;
1386
+ border: 1px solid var(--procura-border) !important;
1387
+ border-radius: 8px !important;
1388
+ padding: 14px 16px !important;
1389
+ }
1390
+
1391
+ div[data-testid="metric-container"] [data-testid="stMetricLabel"] {
1392
+ color: var(--procura-muted) !important;
1393
+ }
1394
+
1395
+ .work-panel,
1396
+ .info-tile,
1397
+ .monitor-row,
1398
+ .detail-card,
1399
+ .provider-card,
1400
+ .email-card,
1401
+ .step-card,
1402
+ .sli-summary {
1403
+ border-radius: 8px !important;
1404
+ }
1405
+
1406
+ /* ===== BETA PRESENTATION SYSTEM ===== */
1407
+ .module-header {
1408
+ display: flex;
1409
+ align-items: flex-start;
1410
+ justify-content: space-between;
1411
+ gap: 18px;
1412
+ padding: 16px 18px;
1413
+ margin: 0 0 16px 0;
1414
+ background: linear-gradient(180deg, #111821 0%, #0D131B 100%);
1415
+ border: 1px solid var(--procura-border);
1416
+ border-radius: 8px;
1417
+ }
1418
+
1419
+ .page-meta {
1420
+ display: flex;
1421
+ flex-wrap: wrap;
1422
+ justify-content: flex-end;
1423
+ gap: 6px;
1424
+ max-width: 420px;
1425
+ }
1426
+
1427
+ .page-meta span {
1428
+ display: inline-flex;
1429
+ align-items: center;
1430
+ min-height: 24px;
1431
+ padding: 3px 9px;
1432
+ border-radius: 999px;
1433
+ background: #0B1017;
1434
+ border: 1px solid var(--procura-border);
1435
+ color: var(--procura-muted);
1436
+ font-size: 11px;
1437
+ font-weight: 700;
1438
+ }
1439
+
1440
+ .summary-strip {
1441
+ display: grid;
1442
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
1443
+ gap: 10px;
1444
+ margin: 12px 0 16px 0;
1445
+ }
1446
+
1447
+ .summary-card {
1448
+ background: var(--procura-surface);
1449
+ border: 1px solid var(--procura-border);
1450
+ border-left: 3px solid var(--procura-blue);
1451
+ border-radius: 8px;
1452
+ padding: 12px 13px;
1453
+ }
1454
+
1455
+ .summary-card.tone-green { border-left-color: var(--procura-green); }
1456
+ .summary-card.tone-amber { border-left-color: var(--procura-amber); }
1457
+ .summary-card.tone-orange { border-left-color: #F08A3C; }
1458
+ .summary-card.tone-red { border-left-color: #EF5B5B; }
1459
+ .summary-card.tone-blue { border-left-color: var(--procura-blue); }
1460
+
1461
+ .summary-label {
1462
+ color: var(--procura-muted);
1463
+ font-size: 10px;
1464
+ font-weight: 800;
1465
+ text-transform: uppercase;
1466
+ letter-spacing: 0;
1467
+ margin-bottom: 5px;
1468
+ }
1469
+
1470
+ .summary-value {
1471
+ color: var(--procura-text);
1472
+ font-size: 18px;
1473
+ line-height: 1.15;
1474
+ font-weight: 820;
1475
+ }
1476
+
1477
+ .selected-record {
1478
+ background: #101720;
1479
+ border: 1px solid var(--procura-border);
1480
+ border-left: 3px solid var(--procura-blue);
1481
+ border-radius: 8px;
1482
+ padding: 13px 15px;
1483
+ margin: 14px 0 10px 0;
1484
+ }
1485
+
1486
+ .selected-kicker {
1487
+ color: var(--procura-muted);
1488
+ font-size: 10px;
1489
+ font-weight: 800;
1490
+ text-transform: uppercase;
1491
+ letter-spacing: 0;
1492
+ margin-bottom: 4px;
1493
+ }
1494
+
1495
+ .selected-title {
1496
+ color: var(--procura-text);
1497
+ font-size: 20px;
1498
+ font-weight: 850;
1499
+ line-height: 1.2;
1500
+ }
1501
+
1502
+ .selected-subtitle {
1503
+ color: #B8C4D2;
1504
+ font-size: 13px;
1505
+ line-height: 1.4;
1506
+ margin-top: 4px;
1507
+ }
1508
+
1509
+ .empty-state {
1510
+ background: #0F151D;
1511
+ border: 1px dashed var(--procura-border-strong);
1512
+ border-radius: 8px;
1513
+ padding: 34px 22px;
1514
+ text-align: center;
1515
+ margin: 16px 0;
1516
+ }
1517
+
1518
+ .empty-title {
1519
+ color: var(--procura-text);
1520
+ font-size: 18px;
1521
+ font-weight: 800;
1522
+ margin-bottom: 6px;
1523
+ }
1524
+
1525
+ .empty-body {
1526
+ color: var(--procura-muted);
1527
+ font-size: 13px;
1528
+ line-height: 1.45;
1529
+ }
1530
+
1531
+ [data-testid="stDataFrame"] {
1532
+ border: 1px solid var(--procura-border);
1533
+ border-radius: 8px !important;
1534
+ background: var(--procura-surface);
1535
+ box-shadow: 0 1px 0 rgba(255,255,255,0.02) inset;
1536
+ }
1537
+
1538
+ [data-testid="stDataFrame"] [role="grid"] {
1539
+ font-size: 12px;
1540
+ }
1541
+
1542
+ [data-testid="stExpander"] {
1543
+ background: #0F151D;
1544
+ border: 1px solid var(--procura-border) !important;
1545
+ border-radius: 8px !important;
1546
+ }
1547
+
1548
+ [data-testid="stTextInput"] input,
1549
+ [data-testid="stTextArea"] textarea,
1550
+ [data-testid="stSelectbox"] {
1551
+ border-radius: 8px !important;
1552
+ }
1553
+
1554
+ /* ===== Streamlit beta polish: roles, sidebar and operational dashboard ===== */
1555
+ .sidebar-brand {
1556
+ padding: 14px 8px 16px 8px;
1557
+ border-bottom: 1px solid #233143;
1558
+ }
1559
+
1560
+ .brand-mark {
1561
+ background: linear-gradient(135deg, #17466D 0%, #1F6E5C 100%);
1562
+ border-color: #3F8FE5;
1563
+ }
1564
+
1565
+ .sidebar-user-card,
1566
+ .sidebar-role-card,
1567
+ .sidebar-analysis-card,
1568
+ .sidebar-status-card {
1569
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
1570
+ }
1571
+
1572
+ .sidebar-role-card {
1573
+ background: #0F151D;
1574
+ border: 1px solid #2B3B4F;
1575
+ border-left: 3px solid var(--procura-green);
1576
+ border-radius: 8px;
1577
+ padding: 11px 12px;
1578
+ margin: 0 0 14px 0;
1579
+ }
1580
+
1581
+ .role-card-top {
1582
+ display: flex;
1583
+ align-items: center;
1584
+ justify-content: space-between;
1585
+ gap: 10px;
1586
+ margin-bottom: 7px;
1587
+ }
1588
+
1589
+ .role-card-top span {
1590
+ color: #7CE0B7;
1591
+ background: #0E241A;
1592
+ border: 1px solid #1F805E;
1593
+ border-radius: 999px;
1594
+ padding: 3px 8px;
1595
+ font-size: 10px;
1596
+ font-weight: 800;
1597
+ }
1598
+
1599
+ .role-card-top b {
1600
+ color: var(--procura-text);
1601
+ font-size: 12px;
1602
+ text-align: right;
1603
+ }
1604
+
1605
+ .role-card-copy {
1606
+ color: var(--procura-muted);
1607
+ font-size: 11px;
1608
+ line-height: 1.38;
1609
+ }
1610
+
1611
+ .nav-group-label {
1612
+ color: #9BA8B8;
1613
+ padding-left: 2px;
1614
+ }
1615
+
1616
+ [data-testid="stSidebar"] [data-testid="stButton"] > button {
1617
+ min-height: 38px;
1618
+ border-radius: 7px !important;
1619
+ }
1620
+
1621
+ [data-testid="stSidebar"] [data-testid="stBaseButton-primary"] {
1622
+ background: #172334 !important;
1623
+ border-color: #3F8FE5 !important;
1624
+ box-shadow: inset 4px 0 0 var(--procura-green) !important;
1625
+ }
1626
+
1627
+ .sidebar-flow {
1628
+ display: grid;
1629
+ grid-template-columns: 1fr 1fr;
1630
+ gap: 6px;
1631
+ margin-top: 10px;
1632
+ }
1633
+
1634
+ .sidebar-flow span {
1635
+ background: #0A1018;
1636
+ border: 1px solid #243246;
1637
+ border-radius: 6px;
1638
+ color: #B8C4D2;
1639
+ font-size: 10px;
1640
+ font-weight: 760;
1641
+ padding: 5px 6px;
1642
+ }
1643
+
1644
+ .ops-hero {
1645
+ background:
1646
+ linear-gradient(135deg, rgba(63, 143, 229, 0.10) 0%, rgba(47, 191, 143, 0.06) 52%, rgba(230, 169, 74, 0.05) 100%),
1647
+ #0F151D;
1648
+ border-color: #2B3B4F;
1649
+ }
1650
+
1651
+ .ops-route-card {
1652
+ min-height: 118px;
1653
+ border-top: 0;
1654
+ border-left: 3px solid var(--procura-blue);
1655
+ box-shadow: 0 10px 22px rgba(0, 0, 0, 0.14);
1656
+ }
1657
+
1658
+ .ops-route-card:hover {
1659
+ border-left-color: var(--procura-green);
1660
+ background: #121B26;
1661
+ }
1662
+
1663
+ .summary-card {
1664
+ min-height: 78px;
1665
+ }
1666
+
1667
+ .admin-hero {
1668
+ display: flex;
1669
+ align-items: flex-start;
1670
+ justify-content: space-between;
1671
+ gap: 16px;
1672
+ background:
1673
+ linear-gradient(135deg, rgba(63, 143, 229, 0.12) 0%, rgba(47, 191, 143, 0.06) 100%),
1674
+ #0F151D;
1675
+ border: 1px solid #2B3B4F;
1676
+ border-radius: 8px;
1677
+ padding: 18px;
1678
+ margin: 4px 0 16px 0;
1679
+ }
1680
+
1681
+ .admin-hero-badge {
1682
+ white-space: nowrap;
1683
+ background: #0E241A;
1684
+ border: 1px solid #1F805E;
1685
+ color: #7CE0B7;
1686
+ border-radius: 999px;
1687
+ padding: 6px 11px;
1688
+ font-size: 11px;
1689
+ font-weight: 820;
1690
+ }
1691
+
1692
+ .notice-panel {
1693
+ background: #0F151D;
1694
+ border: 1px solid var(--procura-border);
1695
+ border-left: 3px solid var(--procura-blue);
1696
+ border-radius: 8px;
1697
+ padding: 13px 15px;
1698
+ margin: 12px 0;
1699
+ }
1700
+
1701
+ .notice-panel.tone-green { border-left-color: var(--procura-green); }
1702
+ .notice-panel.tone-amber { border-left-color: var(--procura-amber); }
1703
+ .notice-panel.tone-red { border-left-color: #EF5B5B; }
1704
+
1705
+ .notice-title {
1706
+ color: var(--procura-text);
1707
+ font-size: 14px;
1708
+ font-weight: 850;
1709
+ margin-bottom: 4px;
1710
+ }
1711
+
1712
+ .notice-body {
1713
+ color: var(--procura-muted);
1714
+ font-size: 12.5px;
1715
+ line-height: 1.45;
1716
+ }
1717
+
1718
+ .table-toolbar {
1719
+ display: flex;
1720
+ align-items: flex-start;
1721
+ justify-content: space-between;
1722
+ gap: 14px;
1723
+ background: #0F151D;
1724
+ border: 1px solid var(--procura-border);
1725
+ border-radius: 8px;
1726
+ padding: 12px 14px;
1727
+ margin: 14px 0 8px 0;
1728
+ }
1729
+
1730
+ .table-toolbar-title {
1731
+ color: var(--procura-text);
1732
+ font-size: 15px;
1733
+ font-weight: 850;
1734
+ margin-bottom: 3px;
1735
+ }
1736
+
1737
+ .table-toolbar-subtitle {
1738
+ color: var(--procura-muted);
1739
+ font-size: 12px;
1740
+ line-height: 1.4;
1741
+ }
1742
+
1743
+ .table-toolbar-meta {
1744
+ display: flex;
1745
+ flex-wrap: wrap;
1746
+ justify-content: flex-end;
1747
+ gap: 6px;
1748
+ }
1749
+
1750
+ .table-toolbar-meta span {
1751
+ color: #B8C4D2;
1752
+ background: #0A1018;
1753
+ border: 1px solid #26384E;
1754
+ border-radius: 999px;
1755
+ padding: 4px 8px;
1756
+ font-size: 10.5px;
1757
+ font-weight: 760;
1758
+ }
1759
+
1760
+ .access-grid {
1761
+ display: grid;
1762
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
1763
+ gap: 8px;
1764
+ margin: 8px 0 16px 0;
1765
+ }
1766
+
1767
+ .access-item {
1768
+ background: #0F151D;
1769
+ border: 1px solid var(--procura-border);
1770
+ border-radius: 8px;
1771
+ padding: 10px 12px;
1772
+ }
1773
+
1774
+ .access-item span {
1775
+ display: block;
1776
+ color: var(--procura-muted);
1777
+ font-size: 11px;
1778
+ font-weight: 780;
1779
+ margin-bottom: 4px;
1780
+ }
1781
+
1782
+ .access-item b {
1783
+ color: #748091;
1784
+ font-size: 13px;
1785
+ }
1786
+
1787
+ .access-item.is-on {
1788
+ border-left: 3px solid var(--procura-green);
1789
+ }
1790
+
1791
+ .access-item.is-on b {
1792
+ color: #7CE0B7;
1793
+ }
1794
+
1795
+ .access-item.is-off {
1796
+ opacity: 0.72;
1797
+ }
1798
+
1799
+ .bid-info-band {
1800
+ background: #0D1520;
1801
+ background-image: linear-gradient(180deg, rgba(24, 36, 51, 0.98), rgba(11, 18, 27, 0.98));
1802
+ border: 1px solid rgba(126, 148, 173, 0.34);
1803
+ border-radius: 8px;
1804
+ padding: 16px;
1805
+ margin: 10px 0 18px 0;
1806
+ box-shadow: 0 18px 42px rgba(0, 0, 0, 0.22);
1807
+ }
1808
+
1809
+ .rfq-summary-grid {
1810
+ display: grid;
1811
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
1812
+ gap: 11px;
1813
+ margin: 0;
1814
+ }
1815
+
1816
+ .rfq-summary-grid + .rfq-summary-grid {
1817
+ margin-top: 11px;
1818
+ }
1819
+
1820
+ .bid-contact-grid {
1821
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
1822
+ }
1823
+
1824
+ .rfq-summary-tile {
1825
+ background: #101A27;
1826
+ border: 1px solid rgba(126, 148, 173, 0.32);
1827
+ border-left: 4px solid var(--procura-blue);
1828
+ border-radius: 8px;
1829
+ padding: 14px 15px;
1830
+ min-height: 92px;
1831
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055), 0 10px 24px rgba(0, 0, 0, 0.12);
1832
+ }
1833
+
1834
+ .rfq-summary-tile span {
1835
+ display: block;
1836
+ color: var(--procura-muted);
1837
+ font-size: 10px;
1838
+ font-weight: 820;
1839
+ text-transform: uppercase;
1840
+ margin-bottom: 6px;
1841
+ }
1842
+
1843
+ .rfq-summary-tile b {
1844
+ display: block;
1845
+ color: var(--procura-text);
1846
+ font-size: 13.5px;
1847
+ line-height: 1.35;
1848
+ word-break: break-word;
1849
+ }
1850
+
1851
+ .rfq-summary-tile small {
1852
+ display: block;
1853
+ color: var(--procura-muted);
1854
+ font-size: 11px;
1855
+ line-height: 1.35;
1856
+ margin-top: 7px;
1857
+ }
1858
+
1859
+ .rfq-summary-tile.tone-green {
1860
+ border-left-color: var(--procura-green);
1861
+ background: linear-gradient(180deg, rgba(16, 72, 55, 0.34), rgba(16, 72, 55, 0.16));
1862
+ }
1863
+
1864
+ .rfq-summary-tile.tone-blue {
1865
+ border-left-color: var(--procura-blue);
1866
+ background: linear-gradient(180deg, rgba(37, 99, 235, 0.28), rgba(37, 99, 235, 0.13));
1867
+ }
1868
+
1869
+ .rfq-summary-tile.tone-amber {
1870
+ border-left-color: var(--procura-amber);
1871
+ background: linear-gradient(180deg, rgba(245, 158, 11, 0.24), rgba(245, 158, 11, 0.11));
1872
+ }
1873
+
1874
+ .rfq-summary-tile.decision-card b {
1875
+ font-size: 15.5px;
1876
+ color: #FFFFFF;
1877
+ }
1878
+
1879
+ .rfq-email-preview {
1880
+ background: #F7FAFC;
1881
+ border: 1px solid #D8DEE8;
1882
+ border-radius: 10px;
1883
+ overflow: hidden;
1884
+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.18);
1885
+ margin: 14px 0;
1886
+ }
1887
+
1888
+ .rfq-email-header {
1889
+ display: flex;
1890
+ align-items: flex-start;
1891
+ justify-content: space-between;
1892
+ gap: 16px;
1893
+ background: #0B1320;
1894
+ padding: 22px 24px;
1895
+ }
1896
+
1897
+ .rfq-email-kicker {
1898
+ color: #7CE0B7;
1899
+ font-size: 11px;
1900
+ font-weight: 820;
1901
+ text-transform: uppercase;
1902
+ margin-bottom: 6px;
1903
+ }
1904
+
1905
+ .rfq-email-title {
1906
+ color: #FFFFFF;
1907
+ font-size: 23px;
1908
+ font-weight: 850;
1909
+ line-height: 1.15;
1910
+ }
1911
+
1912
+ .rfq-email-subject {
1913
+ color: #A8B3C5;
1914
+ font-size: 13px;
1915
+ line-height: 1.4;
1916
+ margin-top: 6px;
1917
+ }
1918
+
1919
+ .rfq-email-badge {
1920
+ color: #0B1320;
1921
+ background: #7CE0B7;
1922
+ border-radius: 999px;
1923
+ padding: 5px 10px;
1924
+ font-size: 10px;
1925
+ font-weight: 850;
1926
+ text-transform: uppercase;
1927
+ }
1928
+
1929
+ .rfq-email-meta {
1930
+ display: grid;
1931
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1932
+ gap: 8px;
1933
+ padding: 16px 18px 8px 18px;
1934
+ background: #FFFFFF;
1935
+ }
1936
+
1937
+ .rfq-email-meta div {
1938
+ border: 1px solid #D8DEE8;
1939
+ border-radius: 8px;
1940
+ padding: 9px 10px;
1941
+ background: #F8FAFC;
1942
+ }
1943
+
1944
+ .rfq-email-meta span {
1945
+ display: block;
1946
+ color: #64748B;
1947
+ font-size: 10px;
1948
+ font-weight: 850;
1949
+ text-transform: uppercase;
1950
+ margin-bottom: 4px;
1951
+ }
1952
+
1953
+ .rfq-email-meta b {
1954
+ color: #0F172A;
1955
+ font-size: 12px;
1956
+ line-height: 1.35;
1957
+ }
1958
+
1959
+ .rfq-email-body {
1960
+ background: #FFFFFF;
1961
+ color: #172033;
1962
+ font-size: 13.5px;
1963
+ line-height: 1.58;
1964
+ padding: 18px 24px 24px 24px;
1965
+ white-space: normal;
1966
+ max-height: 620px;
1967
+ overflow: auto;
1968
+ }
1969
+
1970
+ [data-testid="stDataFrame"] {
1971
+ overflow: hidden;
1972
+ }
1973
+
1974
+ [data-testid="stDataFrame"] [role="columnheader"] {
1975
+ font-weight: 800 !important;
1976
+ }
1977
+
1978
+ @media (max-width: 900px) {
1979
+ .module-header,
1980
+ .page-title-row,
1981
+ .ops-hero {
1982
+ flex-direction: column;
1983
+ grid-template-columns: 1fr;
1984
+ }
1985
+ .admin-hero,
1986
+ .table-toolbar {
1987
+ flex-direction: column;
1988
+ }
1989
+ .free-source-grid,
1990
+ .checklist-block {
1991
+ grid-template-columns: 1fr;
1992
+ }
1993
+ .rfq-email-header {
1994
+ flex-direction: column;
1995
+ }
1996
+ .table-toolbar-meta {
1997
+ justify-content: flex-start;
1998
+ }
1999
+ .page-meta {
2000
+ justify-content: flex-start;
2001
+ }
2002
+ }