tokiospg commited on
Commit
4e9cc0e
·
verified ·
1 Parent(s): 0371007

Upload 12 files

Browse files
Files changed (13) hide show
  1. .gitattributes +1 -0
  2. ACP DATA LIC PASADAS v2_2.xlsx +3 -0
  3. Dockerfile +31 -0
  4. README.md +3 -3
  5. api.py +716 -0
  6. app.py +1156 -0
  7. config.toml +8 -0
  8. crypto.py +52 -0
  9. database.py +465 -0
  10. entrypoint.sh +12 -0
  11. proyelec_logo.png +0 -0
  12. requirements.txt +16 -0
  13. style.css +372 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ ACP[[:space:]]DATA[[:space:]]LIC[[:space:]]PASADAS[[:space:]]v2_2.xlsx filter=lfs diff=lfs merge=lfs -text
ACP DATA LIC PASADAS v2_2.xlsx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f4bb7cc4520ec8508c72dfca9e9875d69b7680aa06dc45a2b8e789aaa4500ce2
3
+ size 595577
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Usamos la imagen oficial de Playwright que ya tiene Chromium preinstalado
2
+ FROM mcr.microsoft.com/playwright/python:v1.44.0-jammy
3
+
4
+ # Establecer la carpeta de trabajo dentro del servidor
5
+ WORKDIR /app
6
+
7
+ # Copiar el archivo de requerimientos primero
8
+ COPY requirements.txt .
9
+
10
+ # Instalar los requerimientos de Python
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+
13
+ # Asegurar que Chromium coincida con la version de Playwright instalada por pip
14
+ RUN playwright install chromium
15
+
16
+ # Copiar todo el resto del código del proyecto al servidor
17
+ COPY . .
18
+
19
+ # Crear configuración de Streamlit para Hugging Face (Evita AxiosError 403 / CORS)
20
+ RUN mkdir -p .streamlit && \
21
+ echo "[server]\nport = 7860\naddress = \"0.0.0.0\"\nenableCORS = false\nenableXsrfProtection = false\n\n[browser]\ngatherUsageStats = false" > .streamlit/config.toml
22
+
23
+ # Dar permisos de ejecución al script de arranque
24
+ RUN chmod +x entrypoint.sh
25
+
26
+ # Exponer los puertos necesarios (7860 es el que exige Hugging Face)
27
+ EXPOSE 7860
28
+ EXPOSE 8000
29
+
30
+ # Comando para iniciar todo
31
+ CMD ["./entrypoint.sh"]
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
- title: PROCURA
3
- emoji: 😻
4
  colorFrom: green
5
- colorTo: gray
6
  sdk: docker
7
  pinned: false
8
  ---
 
1
  ---
2
+ title: Procura Ai Acp
3
+ emoji: 📈
4
  colorFrom: green
5
+ colorTo: blue
6
  sdk: docker
7
  pinned: false
8
  ---
api.py ADDED
@@ -0,0 +1,716 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, UploadFile, File, HTTPException, Form, BackgroundTasks, Header, Depends
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
+ Responde ÚNICAMENTE con el siguiente JSON estricto, sin texto adicional:
68
+ {"condiciones_generales": {"numero_licitacion": "", "tiempo_de_entrega_global": "", "garantia_exigida": "", "lugar_de_entrega": "", "validez_de_la_oferta": "", "propuesta_tecnica_requerida": "Si/No"},
69
+ "items": [{"renglon": "", "codigo_articulo": "", "cantidad": 0, "unidad_de_medida": "", "ficha_tecnica_completa": "", "termino_de_busqueda_corto": ""}]}
70
+ """
71
+
72
+ @app.post("/api/v1/analizar-pliego")
73
+ async def analizar_pliego(archivos_pdf: List[UploadFile] = File(...), gemini_key: str = Form(...), _token: str = Depends(verify_internal_token)):
74
+ api_key_clean = gemini_key.strip()
75
+ try:
76
+ genai.configure(api_key=api_key_clean)
77
+ archivos_subidos = []
78
+
79
+ for archivo in archivos_pdf:
80
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
81
+ content = await archivo.read()
82
+ tmp.write(content)
83
+ tmp_path = tmp.name
84
+
85
+ uploaded_file = genai.upload_file(path=tmp_path, mime_type="application/pdf")
86
+ archivos_subidos.append(uploaded_file)
87
+ os.remove(tmp_path)
88
+
89
+ # gemini-2.5-flash para análisis complejo de PDFs
90
+ model = genai.GenerativeModel('gemini-2.5-flash', generation_config={"response_mime_type": "application/json"})
91
+ response = model.generate_content([PROMPT_ANALISTA_MULTI, *archivos_subidos])
92
+
93
+ # Limpieza de archivos en la nube de Gemini para evitar llenar la cuota
94
+ for f in archivos_subidos:
95
+ try:
96
+ genai.delete_file(f.name)
97
+ except Exception as e:
98
+ logger.warning(f"No se pudo borrar archivo temporal de Gemini: {e}")
99
+
100
+ logger.info(f"{len(archivos_subidos)} pliego(s) analizados exitosamente.")
101
+ return json.loads(response.text)
102
+
103
+ except Exception as e:
104
+ logger.error(f"Error analizando pliego: {str(e)}")
105
+ raise HTTPException(status_code=500, detail=str(e))
106
+
107
+ # --- 5. FUNCIONES DE APOYO PARA CORREOS (HILO SECUNDARIO) ---
108
+ def get_user_credentials(username):
109
+ row = db.get_user_credentials(username)
110
+ return (row[0], crypto.decrypt_data(row[1]), row[2]) if row else (None, None, None)
111
+
112
+ # Prompt compacto: clasifica Y extrae cotizacion en una sola llamada (cero tokens extra)
113
+ PROMPT_CLASIFICADOR_CORREOS = """Eres un asistente de procura. Analiza este correo en relacion a la licitacion {licitacion}.
114
+ Items de referencia: {contexto_items_resumido}
115
+
116
+ Correo:
117
+ Asunto: {asunto}\nRemitente: {remitente}\nCuerpo: {cuerpo}
118
+
119
+ Responde SOLO con este JSON (sin texto adicional):
120
+ {{"relacionado": true/false,
121
+ "resumen": "1 linea de lo que ofrece el proveedor",
122
+ "renglones": "numeros separados por coma ej: 1, 3",
123
+ "borrador_respuesta": "correo de respuesta profesional firmado como Departamento de Compras",
124
+ "cotizaciones": [
125
+ {{"renglon": "1", "precio_unitario": 0.0, "moneda": "USD", "tiempo_entrega": "30 dias", "condiciones": "FOB"}}
126
+ ]
127
+ }}
128
+ Si el correo no contiene precios, devuelve cotizaciones como lista vacia [].
129
+ """
130
+
131
+ def procesar_correos_background(username: str, servidor_imap: str, licitacion_activa: str, contexto_items: str):
132
+ email_user, email_pass, gemini_key = get_user_credentials(username)
133
+ if not email_user or not email_pass:
134
+ logger.warning(f"Sin credenciales de correo para usuario {username}")
135
+ return
136
+
137
+ try:
138
+ genai.configure(api_key=gemini_key)
139
+ # gemini-2.5-flash para clasificación simple de correos
140
+ model = genai.GenerativeModel('gemini-2.5-flash')
141
+
142
+ # Reducir contexto enviado: solo los 3 campos clave, NO la ficha técnica completa
143
+ try:
144
+ df_items = pd.read_json(io.StringIO(contexto_items))
145
+ cols_disponibles = [c for c in ['renglon', 'codigo_articulo', 'termino_de_busqueda_corto'] if c in df_items.columns]
146
+ contexto_resumido = df_items[cols_disponibles].to_json(orient="records", force_ascii=False)
147
+ except Exception:
148
+ contexto_resumido = contexto_items[:500] # fallback seguro
149
+
150
+ mail = imaplib.IMAP4_SSL(servidor_imap)
151
+ mail.login(email_user, email_pass)
152
+ mail.select("inbox")
153
+
154
+ status, mensajes = mail.search(None, 'ALL')
155
+ if not mensajes[0]:
156
+ return
157
+
158
+ # Solo últimos 15 correos para respetar el límite de 15 RPM de Gemini Free
159
+ lista_ids = mensajes[0].split()[-15:]
160
+
161
+ for id_correo in lista_ids:
162
+ res, data = mail.fetch(id_correo, '(RFC822)')
163
+ for part in data:
164
+ if isinstance(part, tuple):
165
+ msg = email.message_from_bytes(part[1])
166
+ subj_raw = decode_header(msg.get("Subject", ""))[0]
167
+ asunto = subj_raw[0].decode(subj_raw[1] or 'utf-8', errors='ignore') if isinstance(subj_raw[0], bytes) else str(subj_raw[0])
168
+ remitente = msg.get("From", "Desconocido")
169
+
170
+ if db.check_email_exists(licitacion_activa, asunto, remitente):
171
+ continue
172
+
173
+ # Pre-filtro local: descarta correos claramente irrelevantes antes de llamar a Gemini
174
+ num_lic_clean = "".join(re.findall(r'\d+', licitacion_activa))
175
+ palabras_clave = ["RFQ", "COTIZA", "QUOTE", "PROCURA", "PRECIO", "OFERTA", "SUMINISTRO"]
176
+ es_relevante = num_lic_clean in asunto or any(p in asunto.upper() for p in palabras_clave)
177
+ if not es_relevante:
178
+ continue
179
+
180
+ cuerpo_crudo = ""
181
+ if msg.is_multipart():
182
+ for p in msg.walk():
183
+ if p.get_content_type() == "text/plain":
184
+ cuerpo_crudo += p.get_payload(decode=True).decode(errors='ignore')
185
+ else:
186
+ cuerpo_crudo = msg.get_payload(decode=True).decode(errors='ignore')
187
+
188
+ cuerpo_limpio = " ".join(cuerpo_crudo.split())
189
+ # Limitar cuerpo a 2000 chars (era 3000) — reduce tokens sin perder contexto
190
+ cuerpo_ia = cuerpo_limpio[:2000]
191
+
192
+ prompt = PROMPT_CLASIFICADOR_CORREOS.format(
193
+ licitacion=licitacion_activa,
194
+ contexto_items_resumido=contexto_resumido,
195
+ asunto=asunto,
196
+ remitente=remitente,
197
+ cuerpo=cuerpo_ia
198
+ )
199
+
200
+ try:
201
+ time.sleep(6) # 6s entre llamadas — respeta 15 RPM de Gemini Free
202
+ res_ia = model.generate_content(prompt)
203
+ texto_ia = res_ia.text.strip().replace("```json", "").replace("```", "").strip()
204
+ datos_ia = json.loads(texto_ia)
205
+
206
+ if datos_ia.get("relacionado"):
207
+ db.insert_smart_inbox(
208
+ licitacion_activa, remitente, asunto,
209
+ msg.get("Date"), datos_ia.get('resumen', ''),
210
+ datos_ia.get('renglones', ''), cuerpo_limpio,
211
+ datos_ia.get('borrador_respuesta', '')
212
+ )
213
+ logger.info(f"Correo guardado: '{asunto}' para licitación {licitacion_activa}")
214
+
215
+ # Guardar cotizaciones extraídas (si las hay) en tabla comparador
216
+ for cot in datos_ia.get('cotizaciones', []):
217
+ renglon = str(cot.get('renglon', '')).strip()
218
+ proveedor = remitente
219
+ precio = float(cot.get('precio_unitario', 0) or 0)
220
+ if renglon and precio > 0:
221
+ if not db.check_cotizacion_exists(licitacion_activa, renglon, proveedor):
222
+ db.insert_cotizacion(
223
+ licitacion_activa, renglon, proveedor,
224
+ precio,
225
+ str(cot.get('moneda', 'USD')),
226
+ str(cot.get('tiempo_entrega', 'N/A')),
227
+ str(cot.get('condiciones', '')),
228
+ str(msg.get('Date', '')),
229
+ asunto
230
+ )
231
+ logger.info(f"Cotizacion guardada: Renglón {renglon} | {proveedor} | ${precio}")
232
+ except Exception as parse_error:
233
+ logger.warning(f"Error procesando correo '{asunto}': {parse_error}")
234
+ continue
235
+
236
+ mail.logout()
237
+ logger.info(f"Escaneo de correos finalizado para usuario {username}.")
238
+
239
+ except Exception as e:
240
+ logger.error(f"Error crítico en hilo de correos: {e}")
241
+
242
+
243
+ # --- 6. ENDPOINT ASÍNCRONO DE CORREOS ---
244
+ @app.post("/api/v1/organizar-correos")
245
+ def organizar_correos(
246
+ background_tasks: BackgroundTasks,
247
+ username: str = Form(...),
248
+ servidor_imap: str = Form("mail.proyelec.com"),
249
+ licitacion_activa: str = Form(...),
250
+ contexto_items: str = Form(...),
251
+ _token: str = Depends(verify_internal_token)
252
+ ):
253
+ background_tasks.add_task(procesar_correos_background, username, servidor_imap, licitacion_activa, contexto_items)
254
+ return {"status": "success", "mensaje": "🤖 Gemini está escaneando los correos en segundo plano. Los resultados aparecerán en la bandeja en breve."}
255
+
256
+
257
+ # --- 7. GENERADOR DE FICHAS TÉCNICAS (CON CACHE) ---
258
+ @app.post("/api/v1/generar-ficha")
259
+ def generar_ficha(
260
+ username: str = Form(...),
261
+ licitacion: str = Form(...),
262
+ codigo_renglon: str = Form(...),
263
+ pliego_context: str = Form(...),
264
+ items_context: str = Form(...),
265
+ gemini_key: str = Form(...),
266
+ _token: str = Depends(verify_internal_token)
267
+ ):
268
+ # Verificar cache primero — si ya se generó, devolver sin gastar tokens
269
+ cached = db.get_ficha_cache(username, licitacion, codigo_renglon)
270
+ if cached:
271
+ logger.info(f"Ficha para {codigo_renglon} servida desde cache.")
272
+ return {"status": "success", "datasheet_md": cached, "from_cache": True}
273
+
274
+ try:
275
+ genai.configure(api_key=gemini_key)
276
+ model = genai.GenerativeModel('gemini-2.5-flash')
277
+ prompt = f"""Eres un Ingeniero de Compras especializado. Genera una ficha técnica en formato Markdown para el artículo: {codigo_renglon}.
278
+ Condiciones del Pliego: {pliego_context}
279
+ Detalle del Ítem: {items_context}
280
+
281
+ La ficha debe contener:
282
+ - **Título y Descripción breve**
283
+ - **Tabla de Especificaciones Técnicas**
284
+ - **Requisitos de Calidad / Certificaciones**
285
+ - **Condiciones especiales de la licitación**
286
+ Formato profesional y estructurado."""
287
+
288
+ response = model.generate_content(prompt)
289
+ datasheet = response.text
290
+
291
+ # Guardar en cache para futuras consultas
292
+ db.save_ficha_cache(username, licitacion, codigo_renglon, datasheet)
293
+ logger.info(f"Ficha técnica generada y cacheada para {codigo_renglon}.")
294
+ return {"status": "success", "datasheet_md": datasheet, "from_cache": False}
295
+
296
+ except Exception as e:
297
+ logger.error(f"Error generando ficha: {str(e)}")
298
+ raise HTTPException(status_code=500, detail=str(e))
299
+
300
+
301
+ # --- 8. ENDPOINTS REST ---
302
+ class LoginRequest(BaseModel):
303
+ username: str
304
+ password: str
305
+
306
+ @app.post("/api/v1/login")
307
+ def login(req: LoginRequest, _token: str = Depends(verify_internal_token)):
308
+ user = db.get_user(req.username, req.password)
309
+ if user:
310
+ return {"status": "success", "username": user[0], "role": user[2]}
311
+ raise HTTPException(status_code=401, detail="Credenciales incorrectas")
312
+
313
+ @app.get("/api/v1/workspace/{username}")
314
+ def get_workspace(username: str, _token: str = Depends(verify_internal_token)):
315
+ row = db.load_workspace_state(username)
316
+ if row and row[0] and row[1]:
317
+ df = pd.read_json(io.StringIO(row[0]))
318
+ return {"cg": json.loads(row[1]), "items": df.to_dict(orient="records")}
319
+ return {"cg": None, "items": []}
320
+
321
+ @app.get("/api/v1/history/{username}")
322
+ def get_history(username: str, skip: int = 0, limit: int = 50, _token: str = Depends(verify_internal_token)):
323
+ df = db.get_user_history_df(username)
324
+ return df.iloc[skip : skip+limit].to_dict(orient="records")
325
+
326
+ @app.get("/api/v1/inbox/{licitacion}")
327
+ def get_inbox(licitacion: str, skip: int = 0, limit: int = 50, _token: str = Depends(verify_internal_token)):
328
+ df = db.get_correos_licitacion_df(licitacion)
329
+ return df.iloc[skip : skip+limit].to_dict(orient="records")
330
+
331
+ @app.get("/api/v1/configuracion/{username}")
332
+ def get_config(username: str, _token: str = Depends(verify_internal_token)):
333
+ user_creds = db.get_user_credentials(username)
334
+ if user_creds:
335
+ return {"status": "success", "email_user": user_creds[0], "gemini_key": user_creds[2]}
336
+ return {"status": "error"}
337
+
338
+ @app.post("/api/v1/configuracion")
339
+ def save_config(
340
+ username: str = Form(...),
341
+ gemini_key: str = Form(...),
342
+ email_user: str = Form(""),
343
+ email_pass: str = Form(""),
344
+ _token: str = Depends(verify_internal_token)
345
+ ):
346
+ existing = db.get_user_credentials(username)
347
+ if not existing:
348
+ raise HTTPException(status_code=404, detail="Usuario no encontrado")
349
+
350
+ enc_pass = existing[1]
351
+ if email_pass:
352
+ enc_pass = crypto.encrypt_data(email_pass)
353
+
354
+ db.update_user_profile(username, gemini_key, "", email_user, enc_pass)
355
+ return {"status": "success"}
356
+
357
+ # =============================================
358
+ # CONSULTA AUTOMATICA AL SLI DE LA ACP
359
+ # =============================================
360
+
361
+ @app.get("/api/v1/consultar-sli/{rfq_id}")
362
+ def consultar_sli(rfq_id: str, _token: str = Depends(verify_internal_token)):
363
+ rfq_id = "".join(filter(str.isdigit, str(rfq_id or "")))
364
+ if not rfq_id:
365
+ raise HTTPException(
366
+ status_code=400,
367
+ detail={
368
+ "message": "Numero de licitacion invalido.",
369
+ "hint": "Ingresa solo el numero RFQ de la licitacion ACP."
370
+ }
371
+ )
372
+
373
+ SLI_HOME_URL = "https://apps.pancanal.com/sli/LicitacionesBusqueda/Welcome"
374
+ SLI_URL = f"https://apps.pancanal.com/sli/Licitaciones/LicitacionHeader?rfqId={rfq_id}"
375
+
376
+ def extraer_resumen_acta(texto_acta, acta_url):
377
+ texto_acta = re.sub(r"\s+", " ", texto_acta or "").strip()
378
+ if not texto_acta:
379
+ return {
380
+ "disponible": False,
381
+ "url": acta_url,
382
+ "resumen": "",
383
+ "hallazgos": [],
384
+ "error": "El acta no contiene texto legible."
385
+ }
386
+
387
+ palabras_clave = [
388
+ "no cumple", "incumple", "fallo", "falla", "deficiencia",
389
+ "observacion", "observación", "subsan", "tecnico", "técnico",
390
+ "rechaz", "descalific", "no acept", "aclaracion", "aclaración"
391
+ ]
392
+
393
+ partes = re.split(r"(?<=[.!?])\s+|\n+", texto_acta)
394
+ hallazgos = []
395
+
396
+ for parte in partes:
397
+ parte_limpia = parte.strip()
398
+ parte_lower = parte_limpia.lower()
399
+ if len(parte_limpia) < 35:
400
+ continue
401
+ if any(palabra in parte_lower for palabra in palabras_clave):
402
+ hallazgos.append(parte_limpia[:450])
403
+ if len(hallazgos) >= 8:
404
+ break
405
+
406
+ if hallazgos:
407
+ resumen = "Se detectaron posibles observaciones tecnicas o comentarios relevantes en el acta."
408
+ elif any(palabra in texto_acta.lower() for palabra in ["cumple", "conforme", "adjudic"]):
409
+ resumen = "No se detectaron fallos tecnicos evidentes en una lectura automatica del acta."
410
+ else:
411
+ resumen = "El acta fue encontrada, pero no se detectaron observaciones tecnicas claras automaticamente."
412
+
413
+ return {
414
+ "disponible": True,
415
+ "url": acta_url,
416
+ "resumen": resumen,
417
+ "hallazgos": hallazgos,
418
+ "texto_muestra": texto_acta[:1200],
419
+ "error": None
420
+ }
421
+
422
+ try:
423
+ from playwright.sync_api import (
424
+ Error as PlaywrightError,
425
+ TimeoutError as PlaywrightTimeoutError,
426
+ sync_playwright,
427
+ )
428
+ except ImportError:
429
+ raise HTTPException(
430
+ status_code=503,
431
+ detail={
432
+ "message": "Playwright no esta instalado.",
433
+ "hint": "Ejecuta: pip install playwright && playwright install chromium"
434
+ }
435
+ )
436
+
437
+ browser = None
438
+ resumen_acta = {
439
+ "disponible": False,
440
+ "url": None,
441
+ "resumen": "",
442
+ "hallazgos": [],
443
+ "error": "No se encontro el boton de resumen de propuestas recibidas."
444
+ }
445
+
446
+ try:
447
+ with sync_playwright() as p:
448
+ try:
449
+ browser = p.chromium.launch(
450
+ headless=True,
451
+ args=[
452
+ "--no-sandbox",
453
+ "--disable-dev-shm-usage",
454
+ "--disable-blink-features=AutomationControlled"
455
+ ]
456
+ )
457
+ except PlaywrightError as e:
458
+ msg = str(e)
459
+ if "Executable doesn't exist" in msg or "playwright install" in msg:
460
+ raise HTTPException(
461
+ status_code=503,
462
+ detail={
463
+ "message": "Chromium de Playwright no esta instalado.",
464
+ "hint": "Ejecuta: playwright install chromium"
465
+ }
466
+ )
467
+ raise
468
+
469
+ page = browser.new_page()
470
+ page.set_default_timeout(15000)
471
+ page.set_extra_http_headers({
472
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124"
473
+ })
474
+
475
+ response = page.goto(SLI_HOME_URL, wait_until="domcontentloaded", timeout=30000)
476
+ if response and response.status >= 500:
477
+ raise HTTPException(
478
+ status_code=502,
479
+ detail={
480
+ "message": f"El SLI respondio con HTTP {response.status}.",
481
+ "hint": "El portal de ACP puede estar caido o inestable. Intenta de nuevo mas tarde."
482
+ }
483
+ )
484
+
485
+ page.wait_for_selector("#rfqId", timeout=15000)
486
+ page.fill("#rfqId", rfq_id)
487
+
488
+ if page.locator("#hfEstatusSeleccionadoID").count() > 0:
489
+ page.evaluate(
490
+ 'document.getElementById("hfEstatusSeleccionadoID").value = "TODOS";'
491
+ )
492
+
493
+ page.click("input[type='submit']")
494
+
495
+ try:
496
+ page.wait_for_function(
497
+ "() => document.body.innerText.includes('Detalle de RFQ') || "
498
+ "document.body.innerText.includes('EVALUACI') || "
499
+ "document.body.innerText.includes('No se encontraron') || "
500
+ "document.body.innerText.includes('InternalServer')",
501
+ timeout=20000
502
+ )
503
+ except PlaywrightTimeoutError:
504
+ logger.warning(f"Timeout esperando resultados del SLI para RFQ {rfq_id}")
505
+
506
+ content = page.content()
507
+ SLI_URL = page.url
508
+
509
+ resumen_visible = page.locator(".ResPropRec").count() > 0
510
+ po_header_match = re.search(r"po_header\s*[=:]\s*['\"]?(\d+)", content, re.IGNORECASE)
511
+ if not po_header_match:
512
+ po_header_match = re.search(r"po_header=(\d+)", content, re.IGNORECASE)
513
+
514
+ if resumen_visible and po_header_match:
515
+ po_header = po_header_match.group(1)
516
+ acta_url = urljoin(
517
+ SLI_URL,
518
+ f"../Comunes/ImpresionActaResumen?p_rfq={rfq_id}&po_header={po_header}"
519
+ )
520
+
521
+ try:
522
+ acta_response = page.request.get(
523
+ acta_url,
524
+ headers={"Referer": SLI_URL},
525
+ timeout=30000
526
+ )
527
+ acta_bytes = acta_response.body()
528
+ content_type = (acta_response.headers.get("content-type") or "").lower()
529
+
530
+ if "pdf" in content_type or acta_bytes[:4] == b"%PDF":
531
+ try:
532
+ from pypdf import PdfReader
533
+
534
+ reader = PdfReader(io.BytesIO(acta_bytes))
535
+ texto_acta = "\n".join(
536
+ page_pdf.extract_text() or ""
537
+ for page_pdf in reader.pages
538
+ )
539
+ resumen_acta = extraer_resumen_acta(texto_acta, acta_url)
540
+ except ImportError:
541
+ resumen_acta = {
542
+ "disponible": False,
543
+ "url": acta_url,
544
+ "resumen": "",
545
+ "hallazgos": [],
546
+ "error": "pypdf no esta instalado para leer el PDF del resumen."
547
+ }
548
+ else:
549
+ html_acta = acta_bytes.decode("utf-8", errors="ignore")
550
+ texto_acta = BeautifulSoup(html_acta, "html.parser").get_text(
551
+ separator=" ",
552
+ strip=True
553
+ )
554
+ resumen_acta = extraer_resumen_acta(texto_acta, acta_url)
555
+
556
+ except Exception as e:
557
+ logger.warning(f"No se pudo leer acta resumen SLI {rfq_id}: {e}")
558
+ resumen_acta = {
559
+ "disponible": False,
560
+ "url": acta_url,
561
+ "resumen": "",
562
+ "hallazgos": [],
563
+ "error": "Se encontro el resumen, pero no se pudo leer automaticamente."
564
+ }
565
+
566
+ except HTTPException:
567
+ raise
568
+ except PlaywrightTimeoutError as e:
569
+ logger.warning(f"Timeout consultando SLI {rfq_id}: {e}")
570
+ raise HTTPException(
571
+ status_code=504,
572
+ detail={
573
+ "message": "El SLI tardo demasiado en responder.",
574
+ "hint": "Verifica la conexion o intenta nuevamente en unos minutos."
575
+ }
576
+ )
577
+ except PlaywrightError as e:
578
+ logger.exception(f"Error de Playwright consultando SLI {rfq_id}")
579
+ raise HTTPException(
580
+ status_code=502,
581
+ detail={
582
+ "message": "No se pudo consultar el portal SLI.",
583
+ "hint": "El portal pudo cambiar, bloquear la automatizacion o estar temporalmente fuera de servicio.",
584
+ "technical": str(e)[:500]
585
+ }
586
+ )
587
+ except Exception as e:
588
+ logger.exception(f"Error inesperado consultando SLI {rfq_id}")
589
+ raise HTTPException(
590
+ status_code=500,
591
+ detail={
592
+ "message": "Error inesperado consultando el SLI.",
593
+ "hint": "Revisa backend.log para ver el traceback completo.",
594
+ "technical": str(e)[:500]
595
+ }
596
+ )
597
+ finally:
598
+ if browser:
599
+ try:
600
+ browser.close()
601
+ except Exception:
602
+ pass
603
+
604
+ try:
605
+
606
+ soup_sli = BeautifulSoup(content, "html.parser")
607
+
608
+ texto_sli = soup_sli.get_text(separator="|", strip=True)
609
+
610
+ tokens = [t.strip() for t in texto_sli.split("|") if t.strip()]
611
+
612
+ def buscar_valor(etiquetas):
613
+
614
+ for i, tok in enumerate(tokens):
615
+
616
+ for etiq in etiquetas:
617
+
618
+ if (
619
+ tok.strip().lower() == etiq.lower()
620
+ or tok.strip().lower() == f"{etiq.lower()}:"
621
+ ):
622
+
623
+ for j in range(i + 1, min(i + 4, len(tokens))):
624
+
625
+ cand = tokens[j]
626
+
627
+ if (
628
+ cand
629
+ and not any(
630
+ e.lower() == cand.strip().lower()
631
+ for e in etiquetas
632
+ )
633
+ and len(cand) > 2
634
+ ):
635
+ return cand
636
+
637
+ return None
638
+
639
+ resultado = {
640
+ "rfq_id": rfq_id,
641
+ "url": SLI_URL,
642
+ "estatus": buscar_valor(["Estatus", "Estado"]),
643
+ "descripcion": buscar_valor(["Descripción", "Descripcion"]),
644
+ "fecha_cierre": buscar_valor([
645
+ "Fecha y hora de cierre",
646
+ "Fecha de cierre",
647
+ "Cierre"
648
+ ]),
649
+ "fecha_publicacion": buscar_valor([
650
+ "Fecha de publicación",
651
+ "Publicación",
652
+ "Publicacion"
653
+ ]),
654
+ "ultima_revision": buscar_valor([
655
+ "Última revisión",
656
+ "Ultima Revision",
657
+ "Última Revisión"
658
+ ]),
659
+ "agente_compras": buscar_valor([
660
+ "Agente de compras",
661
+ "Agente Compras",
662
+ "Purchasing Agent"
663
+ ]),
664
+ "resumen_acta": resumen_acta,
665
+ "error": None
666
+ }
667
+
668
+ ESTADOS_SLI = [
669
+ "EVALUACIÓN",
670
+ "EVALUACION",
671
+ "ADJUDICACIÓN",
672
+ "ADJUDICACION",
673
+ "CANCELACIÓN",
674
+ "CANCELACION",
675
+ "ACTO DESIERTO",
676
+ "DESIERTA",
677
+ "ENMENDADA",
678
+ "ANUNCIO VENCIDO",
679
+ "ABIERTA",
680
+ "PRECALIFICACIÓN"
681
+ ]
682
+
683
+ if not resultado["estatus"]:
684
+
685
+ texto_upper = texto_sli.upper()
686
+
687
+ for estado in ESTADOS_SLI:
688
+
689
+ if estado in texto_upper:
690
+ resultado["estatus"] = estado.title()
691
+ break
692
+
693
+ if not resultado["estatus"] and not resultado["descripcion"]:
694
+
695
+ resultado["error"] = (
696
+ "No se encontró información. "
697
+ "Verifica el número de licitación o intenta más tarde."
698
+ )
699
+
700
+ logger.info(
701
+ f"Consulta SLI {rfq_id}: "
702
+ f"estatus={resultado['estatus']} | "
703
+ f"desc={resultado['descripcion']}"
704
+ )
705
+
706
+ return resultado
707
+ except Exception as e:
708
+ logger.exception(f"Error parseando respuesta SLI {rfq_id}")
709
+ raise HTTPException(
710
+ status_code=500,
711
+ detail={
712
+ "message": "El SLI respondio, pero no se pudo interpretar la pagina.",
713
+ "hint": "Puede haber cambiado el formato del portal ACP.",
714
+ "technical": str(e)[:500]
715
+ }
716
+ )
app.py ADDED
@@ -0,0 +1,1156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import pandas as pd
3
+ import json
4
+ import urllib.parse
5
+ import plotly.graph_objects as go
6
+ from tavily import TavilyClient
7
+ from email.message import EmailMessage
8
+
9
+ from datetime import datetime
10
+ import requests
11
+ import os
12
+ import re
13
+ import io
14
+ from html import escape
15
+ import crypto
16
+ from dotenv import load_dotenv
17
+ import database as db
18
+
19
+ load_dotenv()
20
+
21
+ # --- 1. CONFIGURACIÓN INICIAL Y CIBERSEGURIDAD ---
22
+ st.set_page_config(page_title="Proyelec Sourcing Pro", layout="wide", page_icon="💎", initial_sidebar_state="expanded")
23
+
24
+
25
+
26
+ if "logged_in" not in st.session_state:
27
+ st.session_state.logged_in = False
28
+ st.session_state.username = ""
29
+ st.session_state.role = ""
30
+ st.session_state.gemini_key = ""
31
+ st.session_state.tavily_key = ""
32
+ st.session_state.email_user = ""
33
+ st.session_state.email_pass = ""
34
+ if "procesado" not in st.session_state:
35
+ st.session_state.procesado = False
36
+ if "df_exportar" not in st.session_state:
37
+ st.session_state.df_exportar = pd.DataFrame()
38
+ if "cg" not in st.session_state:
39
+ st.session_state.cg = {}
40
+
41
+ def encrypt_data(text):
42
+ return crypto.encrypt_data(text)
43
+
44
+ def decrypt_data(text):
45
+ return crypto.decrypt_data(text)
46
+
47
+ API_URL_BASE = os.getenv("API_URL_BASE", "http://localhost:8000/api/v1")
48
+ API_HEADERS = {"X-Internal-Token": os.getenv("INTERNAL_API_TOKEN", "default-dev-token")}
49
+ MAPA_ESTADOS_SLI = {
50
+ "EVALUACIÓN": "En Evaluacion Economica",
51
+ "EVALUACION": "En Evaluacion Economica",
52
+ "ADJUDICADA": "Adjudicada",
53
+ "CANCELADA": "No Adjudicada",
54
+ "DESIERTA": "Desierta",
55
+ "CERRADA": "Oferta Enviada al SLI",
56
+ "ABIERTA": "En Preparacion",
57
+ }
58
+
59
+
60
+ # --- 2. BASE DE DATOS LOCAL Y PERSISTENCIA ---
61
+ def init_db():
62
+ db.init_db()
63
+
64
+ init_db()
65
+
66
+ # Workspace multi
67
+ def save_workspace_state(username, cg_dict, df):
68
+ lic = cg_dict.get('numero_licitacion', 'Sin_Numero')
69
+ db.save_workspace(username, str(lic), df.to_json(orient="records"), json.dumps(cg_dict))
70
+
71
+ def load_workspace_state(username):
72
+ row = db.load_workspace_state(username)
73
+ if row and row[0] and row[1]:
74
+ return pd.read_json(io.StringIO(row[0])), json.loads(row[1])
75
+ return None, None
76
+
77
+ def load_workspace_by_licitacion(username, licitacion):
78
+ row = db.load_workspace(username, licitacion)
79
+ if row and row[0] and row[1]:
80
+ return pd.read_json(io.StringIO(row[0])), json.loads(row[1])
81
+ return None, None
82
+
83
+ def verify_login(username, password):
84
+ user_data = db.get_user(username, password)
85
+ if user_data:
86
+ u_list = list(user_data)
87
+ role = u_list[2]
88
+
89
+ # Compartir llaves de Gerencia con los Analistas si no tienen las suyas propias
90
+ if role == "Analista":
91
+ conn = db.get_connection()
92
+ c = conn.cursor()
93
+ c.execute("SELECT gemini_key, tavily_key, email_user, email_pass_enc FROM users WHERE role='Gerencia' AND gemini_key != '' LIMIT 1")
94
+ gerencia = c.fetchone()
95
+ conn.close()
96
+
97
+ if gerencia:
98
+ if not u_list[3]: u_list[3] = gerencia[0] # gemini_key
99
+ if not u_list[4]: u_list[4] = gerencia[1] # tavily_key
100
+ if not u_list[5]: u_list[5] = gerencia[2] # email_user
101
+ if not u_list[6]: u_list[6] = gerencia[3] # email_pass_enc
102
+
103
+ return tuple(u_list)
104
+ return None
105
+
106
+ def update_user_profile(username, gemini, tavily, email, raw_email_pass):
107
+ enc_pass = encrypt_data(raw_email_pass) if raw_email_pass else ""
108
+ db.update_user_profile(username, gemini, tavily, email, enc_pass)
109
+
110
+ def save_history(username, licitacion, items_count):
111
+ db.save_history(username, licitacion, items_count)
112
+
113
+ def get_user_history(username):
114
+ return db.get_user_history_df(username)
115
+
116
+ def obtener_correos_licitacion(licitacion):
117
+ return db.get_correos_licitacion_df(licitacion)
118
+
119
+ def activar_organizacion_imap():
120
+ try:
121
+ items_json = st.session_state.df_exportar.to_json(orient="records") if not st.session_state.df_exportar.empty else "[]"
122
+ num_lic = "".join(re.findall(r'\d+', str(st.session_state.cg.get('numero_licitacion', ''))))
123
+ datos = {"username": st.session_state.username, "licitacion_activa": num_lic, "contexto_items": items_json}
124
+ res = requests.post(f"{API_URL_BASE}/organizar-correos", data=datos, headers=API_HEADERS)
125
+ if res.status_code == 200: return True, res.json().get("mensaje", "Organización completada.")
126
+ else: return False, f"Error del servidor: {res.text}"
127
+ except Exception as e: return False, str(e)
128
+
129
+ # --- 3. CSS PREMIUM Y NAVBAR FULL-WIDTH ---
130
+ try:
131
+ with open('style.css', 'r') as f:
132
+ st.markdown(f'<style>{f.read()}</style>', unsafe_allow_html=True)
133
+ except:
134
+ pass
135
+
136
+ # --- 4. LOGIN CON IDENTIDAD PROYELEC ---
137
+ if not st.session_state.logged_in:
138
+ st.markdown("""
139
+ <style>
140
+ [data-testid="stAppViewContainer"] {
141
+ background: radial-gradient(ellipse at 20% 50%, #0d1f3c 0%, #0A0E1A 60%);
142
+ }
143
+ [data-testid="stSidebar"] { display: none; }
144
+ [data-testid="stHeader"] { display: none; }
145
+ .block-container {
146
+ padding-top: 5vh !important;
147
+ max-width: 460px !important;
148
+ }
149
+ .login-title {
150
+ font-size: 24px; font-weight: 800; color: #F0F6FC;
151
+ letter-spacing: -0.5px; margin: 12px 0 2px 0; text-align: center;
152
+ }
153
+ .login-subtitle {
154
+ font-size: 12px; color: #58A6FF; font-weight: 500;
155
+ letter-spacing: 1.5px; text-transform: uppercase; margin-bottom: 24px; text-align: center;
156
+ }
157
+ .login-footer {
158
+ font-size: 11px; color: #484f58; margin-top: 24px; text-align: center;
159
+ }
160
+ </style>
161
+ """, unsafe_allow_html=True)
162
+
163
+ col_logo = st.columns([1,3,1])[1]
164
+ with col_logo:
165
+ try:
166
+ st.image("proyelec_logo.png", use_container_width=True)
167
+ except Exception:
168
+ st.markdown("<div style='text-align:center; font-size:48px;'>⚡</div>", unsafe_allow_html=True)
169
+
170
+ st.markdown("""
171
+ <div class="login-title">PROYELEC</div>
172
+ <div class="login-subtitle">Sourcing Intelligence Pro</div>
173
+ """, unsafe_allow_html=True)
174
+
175
+ user_input = st.text_input("Usuario", placeholder="Usuario corporativo", label_visibility="collapsed")
176
+ pass_input = st.text_input("Contraseña", type="password", placeholder="Contraseña", label_visibility="collapsed")
177
+ st.write("")
178
+ if st.button("Ingresar al Sistema", type="primary", use_container_width=True):
179
+ user_data = verify_login(user_input, pass_input)
180
+ if user_data:
181
+ st.session_state.logged_in = True
182
+ st.session_state.username = user_data[0]
183
+ st.session_state.role = user_data[2] if user_data[2] else "Analista"
184
+ st.session_state.gemini_key = user_data[3] if user_data[3] else ""
185
+ st.session_state.tavily_key = user_data[4] if user_data[4] else ""
186
+ st.session_state.email_user = user_data[5] if user_data[5] else ""
187
+ st.session_state.email_pass = decrypt_data(user_data[6]) if user_data[6] else ""
188
+ df_saved, cg_saved = load_workspace_state(user_data[0])
189
+ if df_saved is not None:
190
+ st.session_state.df_exportar = df_saved
191
+ st.session_state.cg = cg_saved
192
+ st.session_state.procesado = True
193
+ st.rerun()
194
+ else:
195
+ st.error("Credenciales incorrectas. Intenta de nuevo.")
196
+
197
+ st.markdown('<div class="login-footer">Acceso restringido — Solo personal autorizado Proyelec</div>', unsafe_allow_html=True)
198
+ st.stop()
199
+
200
+ # --- NAVBAR RENDER ---
201
+ st.markdown(f"""
202
+ <div class="top-navbar">
203
+ <div style="font-size: 20px; font-weight: 800; color: #58A6FF; letter-spacing: -0.5px;">💎 PROYELEC <span style="color:white; font-weight:300;">SOURCING PRO</span></div>
204
+ <div style="font-size: 13px; color: #8B949E;">Usuario: <b style="color:#F0F6FC;">{st.session_state.username}</b> &nbsp;<span style="background:#1E2A3A;color:#58A6FF;padding:2px 8px;border-radius:10px;font-size:11px;">{st.session_state.role}</span> &nbsp;| {datetime.now().strftime("%d/%m/%Y")}</div>
205
+ </div>
206
+ <div class="main-content-spacer"></div>
207
+ """, unsafe_allow_html=True)
208
+
209
+ # --- PANEL DE ADMINISTRACION GLOBAL ---
210
+ if st.session_state.username == "admin":
211
+ st.markdown("""<style>[data-testid="stSidebar"] { display: none !important; }</style>""", unsafe_allow_html=True)
212
+ st.title("🛡️ Panel de Control Global")
213
+ st.markdown("Gestión de usuarios y monitorización del sistema central.")
214
+
215
+ tab_users, tab_stats = st.tabs(["👥 Gestión de Personal", "📊 Estadísticas del Sistema"])
216
+
217
+ with tab_users:
218
+ col1, col2 = st.columns([0.6, 0.4])
219
+ with col1:
220
+ st.subheader("Personal Registrado")
221
+ df_users = db.get_all_users()
222
+ st.dataframe(df_users, use_container_width=True, hide_index=True)
223
+
224
+ st.markdown("---")
225
+ st.subheader("Acciones Rápidas")
226
+ ac1, ac2 = st.columns(2)
227
+ with ac1:
228
+ del_user = st.text_input("Eliminar Usuario", placeholder="Nombre de usuario")
229
+ if st.button("🗑️ Eliminar Acceso", type="secondary"):
230
+ if del_user == "admin": st.error("No puedes eliminar al administrador maestro.")
231
+ elif del_user:
232
+ if db.delete_user(del_user):
233
+ st.success(f"Usuario {del_user} eliminado.")
234
+ st.rerun()
235
+ else: st.warning("Usuario no encontrado.")
236
+ with ac2:
237
+ reset_user = st.text_input("Resetear Contraseña", placeholder="Nombre de usuario")
238
+ new_pass = st.text_input("Nueva contraseña", type="password")
239
+ if st.button("🔄 Cambiar Contraseña", type="primary"):
240
+ if reset_user and new_pass:
241
+ db.reset_user_password(reset_user, new_pass)
242
+ st.success("Contraseña actualizada.")
243
+ st.rerun()
244
+
245
+ with col2:
246
+ st.markdown("""
247
+ <div style='background:#161B22; border:1px solid #30363D; padding:20px; border-radius:12px;'>
248
+ <h3 style='margin-top:0; color:#58A6FF;'>➕ Nuevo Usuario</h3>
249
+ """, unsafe_allow_html=True)
250
+ new_u = st.text_input("Nombre de Usuario (Login)")
251
+ new_p = st.text_input("Contraseña Temporal", type="password")
252
+ new_r = st.selectbox("Nivel de Acceso", ["Analista", "Gerencia"])
253
+ if st.button("Crear Cuenta", use_container_width=True, type="primary"):
254
+ if new_u and new_p:
255
+ if db.create_user(new_u, new_p, new_r):
256
+ st.success(f"✅ Cuenta de {new_r} creada para '{new_u}'")
257
+ st.rerun()
258
+ else:
259
+ st.error("⚠️ El usuario ya existe.")
260
+ else:
261
+ st.warning("Completa los campos.")
262
+ st.markdown("</div>", unsafe_allow_html=True)
263
+
264
+ st.markdown("<br>", unsafe_allow_html=True)
265
+ with st.expander("🔑 Configurar API Keys por Usuario"):
266
+ target_u = st.selectbox("Seleccionar Usuario", [""] + df_users['Usuario'].tolist())
267
+ if target_u:
268
+ conn = db.get_connection()
269
+ c = conn.cursor()
270
+ c.execute("SELECT gemini_key, tavily_key, email_user, email_pass_enc FROM users WHERE username=?", (target_u,))
271
+ curr = c.fetchone()
272
+ conn.close()
273
+
274
+ if curr:
275
+ new_g = st.text_input("Gemini API Key", value=curr[0], type="password", key="g_key")
276
+ new_t = st.text_input("Tavily API Key", value=curr[1], type="password", key="t_key")
277
+ new_e = st.text_input("Correo (IMAP/SMTP)", value=curr[2], key="e_key")
278
+ new_p = st.text_input("Contraseña de Correo", type="password", key="p_key", help="Solo escribe aquí si deseas cambiarla o configurarla por primera vez.")
279
+
280
+ if st.button("Guardar Llaves", type="primary", use_container_width=True):
281
+ # Si se escribió algo en new_p, lo encriptamos. Si no, mantenemos el actual (curr[3])
282
+ final_p_enc = encrypt_data(new_p) if new_p else curr[3]
283
+ db.update_user_profile(target_u, new_g, new_t, new_e, final_p_enc)
284
+ st.success(f"Configuración guardada para {target_u}")
285
+ st.rerun()
286
+
287
+ with tab_stats:
288
+ st.subheader("Salud del Sistema")
289
+ conn = db.get_connection()
290
+ try:
291
+ total_lic_monitor = pd.read_sql_query("SELECT COUNT(*) as c FROM seguimiento_licitaciones", conn).iloc[0]['c']
292
+ total_historial = pd.read_sql_query("SELECT COUNT(*) as c FROM history", conn).iloc[0]['c']
293
+ total_fichas = pd.read_sql_query("SELECT COUNT(*) as c FROM fichas_cache", conn).iloc[0]['c']
294
+ except Exception:
295
+ total_lic_monitor, total_historial, total_fichas = 0, 0, 0
296
+ conn.close()
297
+
298
+ s1, s2, s3 = st.columns(3)
299
+ s1.metric("Licitaciones en Monitor ACP", total_lic_monitor)
300
+ s2.metric("Pliegos Analizados (Histórico)", total_historial)
301
+ s3.metric("Fichas Técnicas Generadas", total_fichas)
302
+
303
+ st.markdown("---")
304
+ if st.button("Cerrar Sesión Segura", type="primary", use_container_width=True):
305
+ st.session_state.clear()
306
+ st.rerun()
307
+
308
+ st.stop()
309
+
310
+ # --- 5. SIDEBAR ---
311
+ with st.sidebar:
312
+ st.markdown("<br><br>", unsafe_allow_html=True)
313
+
314
+ st.title("📄 1. Cargar Requerimiento")
315
+ st.caption("Sube el pliego principal y los anexos técnicos en formato PDF.")
316
+ archivos_pdf = st.file_uploader("", type=["pdf"], label_visibility="collapsed", accept_multiple_files=True)
317
+
318
+ if st.button("🚀 Procesar con IA", type="primary", use_container_width=True):
319
+ if not st.session_state.gemini_key: st.error("⚠️ Verifica tus API Keys en la configuración.")
320
+ elif not archivos_pdf: st.error("⚠️ Falta subir al menos un documento.")
321
+ else:
322
+ with st.status("Conectando con Motor IA...", expanded=True) as status:
323
+ try:
324
+ archivos = [("archivos_pdf", (f.name, f.getvalue(), "application/pdf")) for f in archivos_pdf]
325
+ datos_formulario = {"gemini_key": st.session_state.gemini_key}
326
+ respuesta_api = requests.post(f"{API_URL_BASE}/analizar-pliego", files=archivos, data=datos_formulario, headers=API_HEADERS)
327
+
328
+ if respuesta_api.status_code == 200:
329
+ datos_crudos = respuesta_api.json()
330
+ cg = datos_crudos.get("condiciones_generales", {})
331
+ df_exportar = pd.DataFrame(datos_crudos.get("items", []))
332
+
333
+ try:
334
+ if not os.path.exists("ACP DATA LIC PASADAS v2_2.xlsx"):
335
+ st.warning("⚠️ No se encontró el histórico 'ACP DATA LIC PASADAS v2_2.xlsx'. Procesando sin precios base.")
336
+ else:
337
+ df_historico = pd.read_excel("ACP DATA LIC PASADAS v2_2.xlsx", skiprows=8).rename(columns=lambda x: str(x).strip())
338
+
339
+ # Limpiar códigos aislando solo el ID puro (Primera palabra, sin basura de la IA) y haciendo alfanumérico
340
+ df_historico['codigo_match'] = df_historico['CODIGO ACP'].astype(str).str.replace(r'[^a-zA-Z0-9]', '', regex=True).str.upper()
341
+ df_exportar['codigo_match'] = df_exportar['codigo_articulo'].astype(str).str.split().str[0].str.replace(r'[^a-zA-Z0-9]', '', regex=True).str.upper()
342
+
343
+ df_cruzado = pd.merge(df_exportar, df_historico.drop_duplicates(subset=['codigo_match'])[['codigo_match', 'PRECIO COMPETENCIA', 'PRECIO PROYELEC']], left_on='codigo_match', right_on='codigo_match', how='left')
344
+ df_cruzado = df_cruzado.drop(columns=['codigo_match']).rename(columns={'PRECIO COMPETENCIA': "precio_comp_hist", 'PRECIO PROYELEC': "precio_proy_hist"})
345
+
346
+ df_cruzado['precio_comp_hist'] = pd.to_numeric(df_cruzado['precio_comp_hist'], errors='coerce')
347
+ df_cruzado['precio_proy_hist'] = pd.to_numeric(df_cruzado['precio_proy_hist'], errors='coerce')
348
+ df_cruzado['margen_$'] = df_cruzado['precio_proy_hist'] - df_cruzado['precio_comp_hist']
349
+ df_exportar = df_cruzado
350
+ except Exception as excel_e:
351
+ st.warning(f"⚠️ Error leyendo Excel histórico: {excel_e}")
352
+
353
+ save_history(st.session_state.username, str(cg.get('numero_licitacion', 'Desconocida')), len(df_exportar))
354
+ save_workspace_state(st.session_state.username, cg, df_exportar)
355
+
356
+ st.session_state.df_exportar, st.session_state.cg, st.session_state.procesado = df_exportar, cg, True
357
+ status.update(label="✅ Análisis Completado", state="complete", expanded=False)
358
+ st.rerun()
359
+ else: status.update(label=f"❌ Error en API: {respuesta_api.text}", state="error")
360
+ except Exception as e: status.update(label=f"❌ Error crítico: {e}", state="error")
361
+
362
+ st.divider()
363
+
364
+ st.caption("Opciones del Sistema")
365
+
366
+ # Indicador de estado de la API
367
+ try:
368
+ _r = requests.get(f"{API_URL_BASE.replace('/api/v1','')}/", timeout=2, headers=API_HEADERS)
369
+ if _r.status_code == 200:
370
+ st.markdown("<div style='background:#1a3a1a;border:1px solid #238636;border-radius:8px;padding:8px 12px;font-size:12px;color:#3fb950;margin-bottom:10px;'>🟢 Motor IA <b>Activo</b></div>", unsafe_allow_html=True)
371
+ else:
372
+ st.markdown("<div style='background:#3a1a1a;border:1px solid #f85149;border-radius:8px;padding:8px 12px;font-size:12px;color:#f85149;margin-bottom:10px;'>🔴 Motor IA <b>Sin respuesta</b></div>", unsafe_allow_html=True)
373
+ except Exception:
374
+ st.markdown("<div style='background:#3a1a1a;border:1px solid #f85149;border-radius:8px;padding:8px 12px;font-size:12px;color:#f85149;margin-bottom:10px;'>🔴 Motor IA <b>Apagado</b> — Ejecuta run.bat</div>", unsafe_allow_html=True)
375
+
376
+ if st.session_state.role == "Gerencia":
377
+ with st.expander("⚙️ Configuración y Llaves", expanded=False):
378
+ nueva_gemini = st.text_input("Gemini API Key", value=st.session_state.gemini_key, type="password")
379
+ nueva_tavily = st.text_input("Tavily API Key", value=st.session_state.tavily_key, type="password")
380
+ nuevo_email = st.text_input("Correo Proyelec", value=st.session_state.email_user)
381
+ nuevo_email_pass = st.text_input("Contraseña", value=st.session_state.email_pass, type="password")
382
+
383
+ if st.button("Guardar Cambios", use_container_width=True):
384
+ update_user_profile(st.session_state.username, nueva_gemini, nueva_tavily, nuevo_email, nuevo_email_pass)
385
+ st.session_state.gemini_key, st.session_state.tavily_key = nueva_gemini, nueva_tavily
386
+ st.session_state.email_user, st.session_state.email_pass = nuevo_email, nuevo_email_pass
387
+ st.toast("✅ Configuración guardada.")
388
+ else:
389
+ st.markdown("""
390
+ <div style='background:#0D1117;border:1px solid #1E2A3A;border-radius:8px;
391
+ padding:10px 14px;font-size:12px;color:#484f58;margin-bottom:8px;'>
392
+ ⚙️ Configuración administrada por Gerencia
393
+ </div>
394
+ """, unsafe_allow_html=True)
395
+
396
+ # --- PANEL DE ADMINISTRACIÓN (solo Gerencia) ---
397
+ if st.session_state.role == "Gerencia":
398
+ with st.expander("👥 Gestión de Usuarios", expanded=False):
399
+ st.caption("Panel exclusivo de Gerencia")
400
+
401
+ df_users = db.get_all_users()
402
+ if not df_users.empty:
403
+ for _, u in df_users.iterrows():
404
+ u_col1, u_col2, u_col3 = st.columns([0.45, 0.3, 0.25])
405
+ with u_col1:
406
+ st.markdown(f"<div style='font-size:13px;color:#F0F6FC;padding-top:6px;'><b>{u['username']}</b></div>", unsafe_allow_html=True)
407
+ with u_col2:
408
+ roles_opt = ["Gerencia", "Analista"]
409
+ idx = roles_opt.index(u['role']) if u['role'] in roles_opt else 1
410
+ nuevo_rol = st.selectbox("", roles_opt, index=idx, key=f"rol_{u['username']}", label_visibility="collapsed")
411
+ if nuevo_rol != u['role']:
412
+ db.update_user_role(u['username'], nuevo_rol)
413
+ st.toast(f"Rol de {u['username']} actualizado.")
414
+ st.rerun()
415
+ with u_col3:
416
+ if u['username'] != st.session_state.username:
417
+ if st.button("🗑️", key=f"del_{u['username']}", help=f"Eliminar {u['username']}"):
418
+ db.delete_user(u['username'])
419
+ st.toast(f"Usuario {u['username']} eliminado.")
420
+ st.rerun()
421
+
422
+ st.divider()
423
+ st.caption("Crear nuevo usuario")
424
+ new_u = st.text_input("Username", key="new_username", placeholder="ej: analista2")
425
+ new_p = st.text_input("Contraseña", key="new_password", type="password", placeholder="mínimo 6 caracteres")
426
+ new_r = st.selectbox("Rol", ["Analista", "Gerencia"], key="new_role")
427
+ if st.button("➕ Crear Usuario", use_container_width=True):
428
+ if new_u and new_p and len(new_p) >= 6:
429
+ ok = db.create_user(new_u, new_p, new_r)
430
+ if ok:
431
+ st.toast(f"✅ Usuario '{new_u}' creado con rol {new_r}.")
432
+ st.rerun()
433
+ else:
434
+ st.error(f"El usuario '{new_u}' ya existe.")
435
+ else:
436
+ st.warning("Username y contraseña (mín. 6 chars) son requeridos.")
437
+
438
+ st.divider()
439
+ st.caption("Resetear contraseña")
440
+ reset_u = st.selectbox("Usuario", df_users['username'].tolist() if not df_users.empty else [], key="reset_user")
441
+ reset_p = st.text_input("Nueva contraseña", key="reset_pass", type="password")
442
+ if st.button("🔑 Resetear", use_container_width=True):
443
+ if reset_u and reset_p and len(reset_p) >= 6:
444
+ db.reset_user_password(reset_u, reset_p)
445
+ st.toast(f"✅ Contraseña de '{reset_u}' actualizada.")
446
+ else:
447
+ st.warning("Selecciona usuario y escribe la nueva contraseña.")
448
+
449
+ if st.button("🚪 Cerrar Sesión", use_container_width=True):
450
+ st.session_state.logged_in = False
451
+ st.session_state.procesado = False
452
+ st.session_state.role = ""
453
+ st.rerun()
454
+
455
+ # --- 7. RENDERIZADO VISUAL PRINCIPAL ---
456
+ # Colores y emojis por estado ACP
457
+ ESTADO_CONFIG = {
458
+ "En Preparacion": ("🔵", "#1E3A5F", "#58A6FF"),
459
+ "Oferta Enviada al SLI": ("🟡", "#3D2E00", "#E3B341"),
460
+ "Cumple Tecnicamente": ("🟢", "#1A3A1A", "#3FB950"),
461
+ "No Cumple Tecnicamente": ("🔴", "#3A1A1A", "#F85149"),
462
+ "En Evaluacion Economica":("🟠", "#3A2A00", "#F0883E"),
463
+ "Adjudicada": ("🏆", "#1A3A2A", "#10B981"),
464
+ "No Adjudicada": ("❌", "#2A1A1A", "#6E7681"),
465
+ "Desierta": ("🚫", "#2A2A2A", "#484F58"),
466
+ }
467
+
468
+ # Redefinicion visual: badges sobrios para el tablero operativo.
469
+ ESTADO_CONFIG.update({
470
+ "En Preparacion": ("", "#101A2A", "#4F9CF9"),
471
+ "Oferta Enviada al SLI": ("", "#261F0B", "#F6B44B"),
472
+ "Cumple Tecnicamente": ("", "#0E241A", "#31C48D"),
473
+ "No Cumple Tecnicamente": ("", "#2A1111", "#EF5B5B"),
474
+ "En Evaluacion Economica":("", "#2A1D0F", "#F08A3C"),
475
+ "Adjudicada": ("", "#0E241A", "#31C48D"),
476
+ "No Adjudicada": ("", "#171C24", "#748091"),
477
+ "Desierta": ("", "#171C24", "#748091"),
478
+ })
479
+
480
+ tab_main, tab_hist, tab_acp = st.tabs([
481
+ "🚀 Tablero de Operaciones",
482
+ "📚 Base de Conocimiento",
483
+ "🏛️ Monitor ACP"
484
+ ])
485
+
486
+ with tab_acp:
487
+ st.markdown("<div class='section-title'>Monitor ACP</div>", unsafe_allow_html=True)
488
+ st.markdown("<div class='section-subtitle'>Seguimiento de licitaciones, estados SLI y alertas de evaluacion.</div>", unsafe_allow_html=True)
489
+ seg_df = db.get_seguimientos()
490
+
491
+ # --- KPIs del monitor ---
492
+ total_seg = len(seg_df)
493
+ adjudicadas = len(seg_df[seg_df['estado'] == 'Adjudicada']) if not seg_df.empty else 0
494
+ en_proceso = len(seg_df[seg_df['estado'].isin(['Oferta Enviada al SLI', 'Cumple Tecnicamente', 'En Evaluacion Economica'])]) if not seg_df.empty else 0
495
+ tasa = f"{int(adjudicadas/total_seg*100)}%" if total_seg > 0 else "—"
496
+
497
+ mk1, mk2, mk3, mk4 = st.columns(4)
498
+ mk1.metric("Total Licitaciones", total_seg)
499
+ mk2.metric("En Evaluación", en_proceso)
500
+ mk3.metric("Adjudicadas", adjudicadas)
501
+ mk4.metric("Tasa de Éxito", tasa)
502
+ st.divider()
503
+
504
+ col_monitor, col_form = st.columns([0.62, 0.38])
505
+
506
+ with col_form:
507
+ with st.expander("➕ Añadir Licitación al Seguimiento", expanded=(total_seg == 0)):
508
+ f_num = st.text_input("Nº Licitación ACP (RFQ)*", placeholder="Ej: 213330", key="seg_num")
509
+ f_nota = st.text_area("Notas internas (opcional)", key="seg_nota", height=60)
510
+
511
+ if st.button("Añadir al Monitor", type="primary", use_container_width=True):
512
+ if f_num:
513
+ sli_rfq = "".join(filter(str.isdigit, f_num))
514
+ with st.status(f"Obteniendo datos del SLI para {sli_rfq}...", expanded=True):
515
+ try:
516
+ # Hacer peticion a la API local para consultar el SLI
517
+ resp_sli = requests.get(f"{API_URL_BASE}/consultar-sli/{sli_rfq}", timeout=45, headers=API_HEADERS)
518
+
519
+ f_obj = f"Licitación {sli_rfq}"
520
+ f_link = f"https://apps.pancanal.com/sli/Licitaciones/LicitacionHeader?rfqId={sli_rfq}"
521
+ f_estatus = ""
522
+
523
+ if resp_sli.status_code == 200:
524
+ datos_sli = resp_sli.json()
525
+ if datos_sli.get("descripcion"):
526
+ f_obj = datos_sli.get("descripcion")
527
+ if datos_sli.get("url"):
528
+ f_link = datos_sli.get("url")
529
+ f_estatus = datos_sli.get("estatus", "")
530
+
531
+ ok = db.crear_seguimiento(
532
+ sli_rfq, f_obj, "", "",
533
+ 0.0, "USD", f_link, f_nota, st.session_state.username)
534
+
535
+ if ok:
536
+ # buscar la licitacion insertada
537
+ seg_df_new = db.get_seguimientos()
538
+ lic_row = seg_df_new[seg_df_new['numero_licitacion'] == sli_rfq]
539
+ if not lic_row.empty and f_estatus:
540
+ lic_id = int(lic_row.iloc[0]['id'])
541
+ estado_mapeado = MAPA_ESTADOS_SLI.get(f_estatus.upper(), f_estatus)
542
+ db.actualizar_estado(lic_id, estado_mapeado, f"Estado inicial desde SLI: {f_estatus}", "Sistema SLI")
543
+ st.toast(f"✅ Licitación {sli_rfq} añadida al monitor.")
544
+ st.rerun()
545
+ else:
546
+ st.error(f"La licitación '{sli_rfq}' ya existe en el sistema.")
547
+ except Exception as e:
548
+ st.error(f"Error conectando con la API: {e}")
549
+ else:
550
+ st.warning("El número de licitación es obligatorio.")
551
+
552
+ st.caption("🔗 Acceso rápido al portal")
553
+ st.link_button("Abrir SLI de la ACP", "https://sli.pancanal.com", use_container_width=True)
554
+
555
+ with col_monitor:
556
+ if seg_df.empty:
557
+ st.markdown("""
558
+ <div style='text-align:center;padding:50px 20px;background:rgba(22,27,34,0.6);
559
+ border-radius:16px;border:1px dashed #30363D;'>
560
+ <div style='font-size:48px;margin-bottom:12px;'>🏛️</div>
561
+ <h3 style='color:#8B949E;font-weight:500;'>Sin licitaciones registradas</h3>
562
+ <p style='color:#484f58;font-size:13px;'>Usa el formulario para registrar la primera licitación ACP en seguimiento.</p>
563
+ </div>
564
+ """, unsafe_allow_html=True)
565
+ else:
566
+ estado_filtro = st.selectbox(
567
+ "Filtrar por estado",
568
+ ["Todos"] + list(db.ESTADOS_ACP),
569
+ label_visibility="collapsed",
570
+ key="monitor_estado_filtro"
571
+ )
572
+ seg_view = seg_df if estado_filtro == "Todos" else seg_df[seg_df["estado"] == estado_filtro]
573
+
574
+ if seg_view.empty:
575
+ st.info("No hay licitaciones en ese estado.")
576
+
577
+ for _, row in seg_view.iterrows():
578
+ estado = row.get('estado', 'En Preparacion')
579
+ emoji, bg_color, txt_color = ESTADO_CONFIG.get(estado, ("🔵", "#1E3A5F", "#58A6FF"))
580
+ lic_id = int(row['id'])
581
+ num_lic = row.get('numero_licitacion', '')
582
+ objeto = row.get('objeto', '')[:80]
583
+ resp = row.get('responsable', '')
584
+ link_sli = row.get('link_sli', '')
585
+ monto = row.get('monto_ofertado', 0) or 0
586
+ moneda = row.get('moneda', 'USD')
587
+ objeto_safe = escape(str(objeto or "Sin descripcion"))
588
+ resp_safe = escape(str(resp or "Sin responsable"))
589
+ estado_safe = escape(str(estado or ""))
590
+
591
+ # Calcular días desde envío oferta
592
+ dias_txt = ""
593
+ try:
594
+ f_env_dt = datetime.strptime(str(row.get('fecha_envio_oferta', ''))[:10], "%Y-%m-%d")
595
+ dias_el = (datetime.now() - f_env_dt).days
596
+ alerta = " ⚠️" if dias_el > 60 and estado in ["Oferta Enviada al SLI", "En Evaluacion Economica"] else ""
597
+ dias_txt = f"{dias_el} días en evaluación{alerta}"
598
+ except Exception:
599
+ dias_txt = ""
600
+
601
+ with st.container():
602
+ st.markdown(f"""
603
+ <div class='monitor-row' style='--status-color:{txt_color};'>
604
+ <div class='monitor-row-head'>
605
+ <div>
606
+ <div class='monitor-owner'>{resp_safe}</div>
607
+ <div class='monitor-id'>{num_lic}</div>
608
+ <div class='monitor-object'>{objeto_safe}</div>
609
+ </div>
610
+ <div>
611
+ <div class='status-badge'><span class='status-dot'></span>{estado_safe}</div>
612
+ <div class='monitor-meta'>
613
+ <span class='mini-pill'>{dias_txt or "Sin fecha base"}</span>
614
+ <span class='mini-pill'>{moneda} {monto:,.2f}</span>
615
+ </div>
616
+ </div>
617
+ </div>
618
+ </div>
619
+ """, unsafe_allow_html=True)
620
+
621
+ # Controles — fila 1: manual
622
+ resumen_acta_cache = st.session_state.get(f"sli_resumen_acta_{lic_id}")
623
+ if resumen_acta_cache:
624
+ resumen_txt = escape(str(resumen_acta_cache.get("resumen", "Resumen de propuestas consultado.")))
625
+ st.markdown(f"""
626
+ <div class='sli-summary'>
627
+ <div class='sli-summary-title'>Resumen SLI</div>
628
+ <div class='sli-summary-body'>{resumen_txt}</div>
629
+ </div>
630
+ """, unsafe_allow_html=True)
631
+ hallazgos_cache = resumen_acta_cache.get("hallazgos", [])
632
+ if hallazgos_cache:
633
+ with st.expander("Ver observaciones detectadas en resumen SLI"):
634
+ for hallazgo in hallazgos_cache:
635
+ st.markdown(f"- {hallazgo}")
636
+ if resumen_acta_cache.get("url"):
637
+ st.link_button("Abrir resumen SLI", resumen_acta_cache.get("url"))
638
+
639
+ ac1, ac2, ac3 = st.columns([0.4, 0.35, 0.25])
640
+ nuevo_estado = ac1.selectbox("Estado manual", db.ESTADOS_ACP,
641
+ index=db.ESTADOS_ACP.index(estado) if estado in db.ESTADOS_ACP else 0,
642
+ key=f"est_{lic_id}", label_visibility="collapsed")
643
+ nota_upd = ac2.text_input("Nota", key=f"nota_{lic_id}",
644
+ placeholder="Observación...", label_visibility="collapsed")
645
+ with ac3:
646
+ if st.button("Guardar", key=f"upd_{lic_id}", use_container_width=True):
647
+ db.actualizar_estado(lic_id, nuevo_estado, nota_upd, st.session_state.username)
648
+ st.toast(f"✅ Estado actualizado a '{nuevo_estado}'")
649
+ st.rerun()
650
+
651
+ # Controles — fila 2: consulta automática SLI
652
+ sli_col1, sli_col2, sli_col3 = st.columns([0.38, 0.34, 0.28])
653
+ with sli_col1:
654
+ sli_rfq = "".join(filter(str.isdigit, num_lic))
655
+ if st.button("Consultar SLI", key=f"sli_{lic_id}", use_container_width=True, type="primary"):
656
+ with st.status(f"Consultando SLI para licitación {num_lic}...", expanded=True):
657
+ try:
658
+ resp_sli = requests.get(
659
+ f"{API_URL_BASE}/consultar-sli/{sli_rfq}",
660
+ timeout=75,
661
+ headers=API_HEADERS
662
+ )
663
+ if resp_sli.status_code == 200:
664
+ datos_sli = resp_sli.json()
665
+ estatus_sli = datos_sli.get("estatus")
666
+ desc_sli = datos_sli.get("descripcion", "")
667
+ cierre_sli = datos_sli.get("fecha_cierre", "")
668
+ rev_sli = datos_sli.get("ultima_revision", "")
669
+ resumen_acta = datos_sli.get("resumen_acta", {})
670
+ err_sli = datos_sli.get("error")
671
+
672
+ if err_sli:
673
+ st.warning(f"⚠️ {err_sli}")
674
+ elif estatus_sli:
675
+ # Mapear estatus SLI al catálogo interno
676
+ estado_mapeado = MAPA_ESTADOS_SLI.get(
677
+ estatus_sli.upper(), estatus_sli)
678
+ nota_auto = f"[SLI Auto] Estatus: {estatus_sli}"
679
+ if cierre_sli:
680
+ nota_auto += f" | Cierre: {cierre_sli}"
681
+ if rev_sli:
682
+ nota_auto += f" | Última rev: {rev_sli}"
683
+ if resumen_acta.get("disponible"):
684
+ st.session_state[f"sli_resumen_acta_{lic_id}"] = resumen_acta
685
+ nota_auto += f" | Resumen: {resumen_acta.get('resumen', '')}"
686
+ if resumen_acta.get("hallazgos"):
687
+ nota_auto += " | Observaciones: " + " / ".join(resumen_acta.get("hallazgos", [])[:3])
688
+ elif resumen_acta.get("error"):
689
+ st.session_state.pop(f"sli_resumen_acta_{lic_id}", None)
690
+ nota_auto += f" | Resumen SLI: {resumen_acta.get('error')}"
691
+ db.actualizar_estado(lic_id, estado_mapeado,
692
+ nota_auto, "Sistema SLI")
693
+ if resumen_acta.get("disponible"):
694
+ st.info(resumen_acta.get("resumen", "Resumen de propuestas consultado."))
695
+ for hallazgo in resumen_acta.get("hallazgos", [])[:3]:
696
+ st.markdown(f"- {hallazgo}")
697
+ st.success(f"✅ Estado SLI: **{estatus_sli}** — Registro actualizado")
698
+ st.rerun()
699
+ else:
700
+ st.warning("El SLI no devolvió un estatus reconocible.")
701
+ elif resp_sli.status_code == 503:
702
+ try:
703
+ err_detail = resp_sli.json().get("detail", {})
704
+ except Exception:
705
+ err_detail = {}
706
+ if isinstance(err_detail, dict):
707
+ st.error(f"⚠️ {err_detail.get('message', 'Servicio SLI no disponible')}")
708
+ if err_detail.get("hint"):
709
+ st.caption(err_detail.get("hint"))
710
+ else:
711
+ st.error(f"⚠️ {err_detail or 'Servicio SLI no disponible'}")
712
+ else:
713
+ try:
714
+ err_detail = resp_sli.json().get("detail", {})
715
+ except Exception:
716
+ err_detail = {}
717
+ if isinstance(err_detail, dict):
718
+ st.error(f"Error SLI {resp_sli.status_code}: {err_detail.get('message', 'No se pudo consultar el SLI')}")
719
+ if err_detail.get("hint"):
720
+ st.caption(err_detail.get("hint"))
721
+ if err_detail.get("technical"):
722
+ with st.expander("Detalle tecnico"):
723
+ st.code(err_detail.get("technical"))
724
+ else:
725
+ st.error(f"Error SLI {resp_sli.status_code}: {err_detail or 'No se pudo consultar el SLI'}")
726
+ except requests.exceptions.Timeout:
727
+ st.error("⏱️ El SLI tardó demasiado. Intenta de nuevo.")
728
+ except Exception as e_sli:
729
+ st.error(f"Error: {e_sli}")
730
+ with sli_col2:
731
+ sli_url_directo = link_sli or f"https://apps.pancanal.com/sli/Licitaciones/LicitacionHeader?rfqId={sli_rfq}"
732
+ st.link_button("Ver en SLI", sli_url_directo, use_container_width=True)
733
+ with sli_col3:
734
+ if st.button("Eliminar", key=f"del_seg_{lic_id}", help="Eliminar del Monitor ACP", use_container_width=True):
735
+ db.eliminar_seguimiento(lic_id)
736
+ st.toast(f"Seguimiento {num_lic} eliminado.", icon="🗑️")
737
+ st.rerun()
738
+
739
+
740
+ # Historial colapsable
741
+ df_hist_seg = db.get_historial_seguimiento(lic_id)
742
+ if not df_hist_seg.empty:
743
+ with st.expander(f"📋 Historial ({len(df_hist_seg)} actualizaciones)"):
744
+ for _, h in df_hist_seg.iterrows():
745
+ h_emoji, _, h_color = ESTADO_CONFIG.get(h['estado_nuevo'], ("🔵","","#58A6FF"))
746
+ st.markdown(f"""
747
+ <div style='border-left:3px solid {h_color};padding:6px 12px;margin-bottom:6px;'>
748
+ <div style='font-size:11px;color:#484f58;'>{h['fecha'][:16]} — {h['registrado_por']}</div>
749
+ <div style='font-size:13px;color:#F0F6FC;'>{h_emoji} <b>{h['estado_nuevo']}</b></div>
750
+ <div style='font-size:12px;color:#8B949E;'>{h['nota'] or ''}</div>
751
+ </div>
752
+ """, unsafe_allow_html=True)
753
+ st.markdown("---")
754
+
755
+ with tab_hist:
756
+ df_history = get_user_history(st.session_state.username)
757
+
758
+ # --- STATS GLOBALES ---
759
+ total_lic_h = len(df_history)
760
+ total_reng_h = int(df_history['Renglones'].sum()) if not df_history.empty and 'Renglones' in df_history.columns else 0
761
+ ultima_h = df_history['Fecha Proceso'].iloc[0][:10] if not df_history.empty else "—"
762
+
763
+ hk1, hk2, hk3 = st.columns(3)
764
+ hk1.metric("Total Licitaciones Procesadas", total_lic_h)
765
+ hk2.metric("Total Renglones Analizados", total_reng_h)
766
+ hk3.metric("Última Actividad", ultima_h)
767
+ st.divider()
768
+
769
+ if df_history.empty:
770
+ st.markdown("""
771
+ <div style='text-align:center; padding:60px 20px; background:rgba(22,27,34,0.6);
772
+ border-radius:16px; border:1px dashed #30363D;'>
773
+ <div style='font-size:48px; margin-bottom:16px;'>📂</div>
774
+ <h3 style='color:#8B949E; font-weight:500;'>Sin registros aun</h3>
775
+ <p style='color:#484f58; font-size:14px;'>Procesa tu primer pliego para comenzar a construir el historial.</p>
776
+ </div>
777
+ """, unsafe_allow_html=True)
778
+ else:
779
+ # --- BUSCADOR ---
780
+ busqueda = st.text_input("🔍 Buscar por número de licitación", placeholder="Ej: ACP-2024-001", label_visibility="collapsed")
781
+ df_filtrado = df_history[df_history['Nº Licitación'].str.contains(busqueda, case=False, na=False)] if busqueda else df_history
782
+
783
+ col_tabla, col_acciones = st.columns([0.65, 0.35])
784
+
785
+ with col_tabla:
786
+ st.caption(f"Mostrando {len(df_filtrado)} de {len(df_history)} licitaciones — Selecciona una fila para ver opciones")
787
+ ev_hist = st.dataframe(
788
+ df_filtrado,
789
+ use_container_width=True,
790
+ hide_index=True,
791
+ on_select="rerun",
792
+ selection_mode="single-row",
793
+ column_config={
794
+ "Nº Licitación": st.column_config.TextColumn("Nº Licitación", width="medium"),
795
+ "Fecha Proceso": st.column_config.TextColumn("Fecha", width="medium"),
796
+ "Renglones": st.column_config.NumberColumn("Renglones", width="small"),
797
+ }
798
+ )
799
+
800
+ with col_acciones:
801
+ st.caption("Acciones")
802
+
803
+ # --- WORKSPACES GUARDADOS ---
804
+ es_gerencia = st.session_state.role == "Gerencia"
805
+ df_ws_list = db.get_all_workspaces(st.session_state.username, all_users=es_gerencia)
806
+
807
+ if df_ws_list.empty:
808
+ st.info("No hay workspaces guardados.")
809
+ else:
810
+ st.caption(f"{'Todos los workspaces del equipo' if es_gerencia else 'Mis workspaces guardados'} ({len(df_ws_list)})")
811
+ for _, ws_row in df_ws_list.iterrows():
812
+ ws_lic = ws_row['licitacion']
813
+ ws_user = ws_row.get('username', st.session_state.username)
814
+ ws_fecha = ws_row.get('fecha_guardado', '')[:10]
815
+ wc1, wc2, wc3 = st.columns([0.5, 0.25, 0.25])
816
+ with wc1:
817
+ st.markdown(f"<div style='font-size:12px;color:#F0F6FC;padding-top:6px;'><b>{ws_lic}</b><br><span style='color:#484f58;font-size:10px;'>{ws_user} · {ws_fecha}</span></div>", unsafe_allow_html=True)
818
+ with wc2:
819
+ if st.button("Cargar", key=f"ws_load_{ws_user}_{ws_lic}", use_container_width=True):
820
+ df_ws_load, cg_ws_load = load_workspace_by_licitacion(ws_user, ws_lic)
821
+ if df_ws_load is not None:
822
+ st.session_state.df_exportar = df_ws_load
823
+ st.session_state.cg = cg_ws_load
824
+ st.session_state.procesado = True
825
+ st.toast(f"✅ Workspace '{ws_lic}' cargado.", icon="🔄")
826
+ st.rerun()
827
+ with wc3:
828
+ if st.button("🗑️", key=f"ws_del_{ws_user}_{ws_lic}", help="Eliminar workspace"):
829
+ db.delete_workspace(ws_user, ws_lic)
830
+ st.toast(f"Workspace '{ws_lic}' eliminado.")
831
+ st.rerun()
832
+
833
+ st.divider()
834
+
835
+ # Si seleccionaron una fila — ver sus correos
836
+ if len(ev_hist.selection.rows) > 0:
837
+ fila_hist = df_filtrado.iloc[ev_hist.selection.rows[0]]
838
+ lic_sel = str(fila_hist.get('Nº Licitación', ''))
839
+ num_sel = "".join(re.findall(r'\d+', lic_sel))
840
+
841
+ st.markdown(f"""
842
+ <div style='background:#161B22; border:1px solid #238636; border-radius:10px; padding:14px; margin-bottom:12px;'>
843
+ <div style='font-size:11px; color:#3fb950; text-transform:uppercase; letter-spacing:1px;'>Seleccionada</div>
844
+ <div style='font-size:14px; font-weight:700; color:#F0F6FC; margin-top:4px;'>{lic_sel}</div>
845
+ <div style='font-size:11px; color:#8B949E;'>{int(fila_hist.get('Renglones', 0))} renglones procesados</div>
846
+ </div>
847
+ """, unsafe_allow_html=True)
848
+
849
+ df_correos_hist = obtener_correos_licitacion(num_sel)
850
+ n_correos = len(df_correos_hist)
851
+ st.metric("Correos en Bandeja", n_correos)
852
+
853
+ if n_correos > 0:
854
+ with st.expander(f"📥 Ver {n_correos} correo(s) de esta licitación"):
855
+ for _, ch in df_correos_hist.iterrows():
856
+ st.markdown(f"""
857
+ <div class='email-card'>
858
+ <div style='display:flex;justify-content:space-between;color:#8B949E;font-size:11px;margin-bottom:6px;'>
859
+ <strong>{ch.get('remitente','')[:40]}</strong>
860
+ <span>{ch.get('fecha','')}</span>
861
+ </div>
862
+ <div style='color:#E0E0E0;font-size:14px;font-weight:600;margin-bottom:6px;'>{ch.get('asunto','')}</div>
863
+ <div style='color:#C9D1D9;font-size:12px;'>💡 {ch.get('resumen','')}</div>
864
+ </div>
865
+ """, unsafe_allow_html=True)
866
+
867
+ st.markdown("<br>", unsafe_allow_html=True)
868
+ if st.button("🗑️ Eliminar este Registro", key=f"del_hist_{lic_sel}", use_container_width=True):
869
+ db.delete_history_entry(st.session_state.username, lic_sel)
870
+ st.toast(f"Registro {lic_sel} eliminado.", icon="🗑️")
871
+ st.rerun()
872
+
873
+ st.divider()
874
+ # Exportar historial completo
875
+ buf_hist = io.BytesIO()
876
+ df_history.to_excel(buf_hist, index=False, engine='openpyxl')
877
+
878
+ col_exp, col_del = st.columns([0.7, 0.3])
879
+ with col_exp:
880
+ st.download_button("📥 Exportar Historial a Excel", data=buf_hist.getvalue(),
881
+ file_name="Historial_PROCURA.xlsx",
882
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
883
+ use_container_width=True)
884
+ with col_del:
885
+ if st.button("⚠️ Borrar Todo", use_container_width=True, type="secondary"):
886
+ db.clear_all_history(st.session_state.username)
887
+ st.toast("Historial borrado completamente.", icon="✅")
888
+ st.rerun()
889
+
890
+ with tab_main:
891
+ if not st.session_state.procesado:
892
+ # --- KPIs REALES DEL SISTEMA ---
893
+ df_hist_kpi = get_user_history(st.session_state.username)
894
+ total_lic = len(df_hist_kpi)
895
+ total_reng = int(df_hist_kpi['Renglones'].sum()) if not df_hist_kpi.empty and 'Renglones' in df_hist_kpi.columns else 0
896
+ ultima_act = df_hist_kpi['Fecha Proceso'].iloc[0][:10] if not df_hist_kpi.empty else "Sin actividad"
897
+
898
+ st.markdown("<br>", unsafe_allow_html=True)
899
+ st.markdown(f"""
900
+ <div style='text-align:center; margin-bottom: 8px;'>
901
+ <span style='font-size:13px; color:#58A6FF; font-weight:600; letter-spacing:2px; text-transform:uppercase;'>Centro de Mando</span>
902
+ </div>
903
+ <h2 style='text-align:center; color:#F0F6FC; margin:0 0 6px 0; font-weight:800;'>Bienvenido, {st.session_state.username}</h2>
904
+ <p style='text-align:center; color:#8B949E; margin-bottom:28px; font-size:14px;'>Sistema de Inteligencia de Sourcing — Proyelec</p>
905
+ """, unsafe_allow_html=True)
906
+
907
+ k1, k2, k3, k4 = st.columns(4)
908
+ k1.metric("Licitaciones Procesadas", total_lic)
909
+ k2.metric("Renglones Analizados", total_reng)
910
+ k3.metric("Ultima Actividad", ultima_act)
911
+ k4.metric("Motor IA", "Gemini 2.5 Flash")
912
+
913
+ st.markdown("<br>", unsafe_allow_html=True)
914
+ st.markdown("<p style='text-align:center;color:#484f58;font-size:13px;margin-bottom:20px;'>Para comenzar, sube el pliego en el panel izquierdo y presiona <b style=\'color:#58A6FF\'>Procesar con IA</b></p>", unsafe_allow_html=True)
915
+
916
+ c1, c2, c3 = st.columns(3)
917
+ with c1:
918
+ st.markdown("""
919
+ <div class='step-card'>
920
+ <div class='step-icon'>📄</div>
921
+ <h4>1. Carga el Pliego</h4>
922
+ <p style='color:#8B949E; font-size:14px;'>Sube el PDF oficial de la licitacion. Soporta pliego principal y anexos tecnicos simultaneamente.</p>
923
+ </div>
924
+ """, unsafe_allow_html=True)
925
+ with c2:
926
+ st.markdown("""
927
+ <div class='step-card'>
928
+ <div class='step-icon'>🧠</div>
929
+ <h4>2. Analisis con IA</h4>
930
+ <p style='color:#8B949E; font-size:14px;'>Gemini extrae renglones, codigos, cantidades y cruza automaticamente con el historico de precios.</p>
931
+ </div>
932
+ """, unsafe_allow_html=True)
933
+ with c3:
934
+ st.markdown("""
935
+ <div class='step-card'>
936
+ <div class='step-icon'>🎯</div>
937
+ <h4>3. Sourcing Global</h4>
938
+ <p style='color:#8B949E; font-size:14px;'>Busca los mejores proveedores, genera RFQs y organiza cotizaciones en tu bandeja inteligente.</p>
939
+ </div>
940
+ """, unsafe_allow_html=True)
941
+
942
+ else:
943
+ df_render = st.session_state.df_exportar
944
+ cg_render = st.session_state.cg
945
+
946
+ t1, t2, t3, t4 = st.tabs(["📋 1. Matriz de Productos", "✉️ 2. Emisión de RFQs", "📥 3. Bandeja Inteligente", "📈 4. Análisis de Costos"])
947
+
948
+ with t1:
949
+ st.markdown(f"""
950
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 10px;">
951
+ <div style="background:#161B22; padding:15px; border-radius:8px; border:1px solid #30363D;">
952
+ <div style="font-size:11px; color:#8B949E; text-transform:uppercase;">Nº Licitación</div>
953
+ <div style="font-size:16px; color:#58A6FF; font-weight:bold; margin-top:5px;">{cg_render.get('numero_licitacion', 'N/A')}</div>
954
+ </div>
955
+ <div style="background:#161B22; padding:15px; border-radius:8px; border:1px solid #30363D;">
956
+ <div style="font-size:11px; color:#8B949E; text-transform:uppercase;">Lugar Entrega</div>
957
+ <div style="font-size:14px; color:#E6EDF3; margin-top:5px; word-wrap:break-word;">{cg_render.get('lugar_de_entrega', 'N/A')}</div>
958
+ </div>
959
+ <div style="background:#161B22; padding:15px; border-radius:8px; border:1px solid #30363D;">
960
+ <div style="font-size:11px; color:#8B949E; text-transform:uppercase;">Tiempo Entrega</div>
961
+ <div style="font-size:14px; color:#E6EDF3; margin-top:5px; word-wrap:break-word;">{cg_render.get('tiempo_de_entrega_global', 'N/A')}</div>
962
+ </div>
963
+ <div style="background:#161B22; padding:15px; border-radius:8px; border:1px solid #30363D;">
964
+ <div style="font-size:11px; color:#8B949E; text-transform:uppercase;">Garantía</div>
965
+ <div style="font-size:14px; color:#E6EDF3; margin-top:5px; word-wrap:break-word;">{cg_render.get('garantia_exigida', 'N/A')}</div>
966
+ </div>
967
+ <div style="background:#161B22; padding:15px; border-radius:8px; border:1px solid #30363D;">
968
+ <div style="font-size:11px; color:#8B949E; text-transform:uppercase;">Req. Prop. Técnica</div>
969
+ <div style="font-size:14px; color:#3FB950; margin-top:5px; font-weight:bold;">{cg_render.get('propuesta_tecnica_requerida', 'N/A')}</div>
970
+ </div>
971
+ <div style="background:#161B22; padding:15px; border-radius:8px; border:1px solid #30363D;">
972
+ <div style="font-size:11px; color:#8B949E; text-transform:uppercase;">Validez de Oferta</div>
973
+ <div style="font-size:14px; color:#F0883E; margin-top:5px; font-weight:bold;">{cg_render.get('validez_de_la_oferta', 'N/A')}</div>
974
+ </div>
975
+ </div>
976
+ """, unsafe_allow_html=True)
977
+ st.divider()
978
+
979
+ col_config = {
980
+ "renglon": st.column_config.TextColumn("Renglón", width="small"),
981
+ "codigo_articulo": st.column_config.TextColumn("Código", width="medium"),
982
+ "cantidad": st.column_config.NumberColumn("Cant.", format="%d"),
983
+ "precio_comp_hist": st.column_config.NumberColumn("Mejor Comp.", format="$ %.2f"),
984
+ "precio_proy_hist": st.column_config.NumberColumn("Proyelec", format="$ %.2f"),
985
+ "margen_$": st.column_config.NumberColumn("Margen", format="$ %.2f"),
986
+ "ficha_tecnica_completa": None, "termino_de_busqueda_corto": None
987
+ }
988
+
989
+ col_exp, col_xls = st.columns([0.7, 0.3])
990
+ with col_exp:
991
+ st.caption("Selecciona una fila para ver el detalle y buscar proveedores.")
992
+ with col_xls:
993
+ _buf = io.BytesIO()
994
+ df_render.to_excel(_buf, index=False, engine='openpyxl')
995
+ st.download_button("Exportar Excel", data=_buf.getvalue(), file_name=f"Licitacion_{cg_render.get('numero_licitacion','')}.xlsx", mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", use_container_width=True)
996
+ event = st.dataframe(df_render, column_config=col_config, use_container_width=True, hide_index=True, on_select="rerun", selection_mode="single-row")
997
+
998
+ if len(event.selection.rows) > 0:
999
+ fila = df_render.iloc[event.selection.rows[0]]
1000
+ termino_google = urllib.parse.quote(str(fila.get('termino_de_busqueda_corto', '')))
1001
+
1002
+ # --- AQUÍ ESTÁ EL FIX DEL MARKDOWN CON LOS BOTONES DEL TAMAÑO CORRECTO ---
1003
+ st.markdown(f"""
1004
+ <div class="detail-card">
1005
+ <h3 style="color:#58A6FF; margin-top:0;">Renglón {fila.get('renglon', '-')} | Cód: {fila.get('codigo_articulo', 'N/A')}</h3>
1006
+ <div style="background-color:#0D1117; padding:15px; border-radius:8px; color:#C9D1D9; font-family:monospace; margin-bottom:15px; border: 1px solid #30363D;">{fila.get('ficha_tecnica_completa', 'Sin descripción')}</div>
1007
+ <div style="display: flex; gap: 15px; flex-wrap: wrap; margin-top: 20px;">
1008
+ <a style="background-color: #4285F4; color: white; padding: 12px 20px; border-radius: 8px; text-decoration: none; font-weight: 700; font-size: 15px; text-align: center; flex-grow: 1; min-width: 180px; box-shadow: 0 4px 6px rgba(0,0,0,0.3);" href="https://www.google.com/search?q={termino_google}+supplier+distributor" target="_blank">🔍 Google B2B</a>
1009
+ <a style="background-color: #0033A0; color: white; padding: 12px 20px; border-radius: 8px; text-decoration: none; font-weight: 700; font-size: 15px; text-align: center; flex-grow: 1; min-width: 180px; box-shadow: 0 4px 6px rgba(0,0,0,0.3);" href="https://www.thomasnet.com/search.html?cov=NA&what={termino_google}" target="_blank">⚙️ ThomasNet</a>
1010
+ <a style="background-color: #FF6A00; color: white; padding: 12px 20px; border-radius: 8px; text-decoration: none; font-weight: 700; font-size: 15px; text-align: center; flex-grow: 1; min-width: 180px; box-shadow: 0 4px 6px rgba(0,0,0,0.3);" href="https://www.alibaba.com/trade/search?SearchText={termino_google}" target="_blank">🛒 Alibaba</a>
1011
+ </div>
1012
+ </div>
1013
+ """, unsafe_allow_html=True)
1014
+
1015
+ st.write("")
1016
+ if st.button(f"Escaneo profundo - Renglon {fila.get('renglon')}", type="primary"):
1017
+ if not st.session_state.tavily_key: st.error("⚠️ Configura la API Key de Tavily en el panel izquierdo.")
1018
+ else:
1019
+ with st.status(f"Analizando bases de datos B2B para {fila.get('termino_de_busqueda_corto')}...", expanded=True):
1020
+ try:
1021
+ t_client = TavilyClient(api_key=st.session_state.tavily_key)
1022
+ res_tavily = t_client.search(query=f"B2B supplier distributor {fila.get('termino_de_busqueda_corto')} industrial parts", search_depth="advanced", max_results=3)
1023
+ st.toast("✅ Búsqueda completada", icon="🔍")
1024
+
1025
+ st.markdown("#### 🌐 Mejores Proveedores Detectados:")
1026
+ cols = st.columns(3)
1027
+ for i, res in enumerate(res_tavily.get('results', [])):
1028
+ with cols[i % 3]:
1029
+ st.markdown(f"""
1030
+ <div class="provider-card">
1031
+ <h4 style="color:#58A6FF; margin-top:0;">{res.get('title', 'Supplier')}</h4>
1032
+ <p style="font-size:12px; color:#8B949E;">{res.get('content', '')[:120]}...</p>
1033
+ <a style="color:#10B981; font-weight:bold; text-decoration:none;" href="{res.get('url', '#')}" target="_blank">Visitar Website ↗</a>
1034
+ </div>
1035
+ """, unsafe_allow_html=True)
1036
+ except Exception as e: st.error(f"Fallo en scraping: {e}")
1037
+
1038
+ st.divider()
1039
+ st.markdown("#### 📄 Generador de Fichas Técnicas Automáticas")
1040
+ st.caption("Usa la IA para redactar una Ficha Técnica estructurada a partir del contexto del pliego.")
1041
+ if st.button(f"Crear ficha tecnica - Renglon {fila.get('renglon')}", type="secondary"):
1042
+ if not st.session_state.gemini_key: st.error("⚠️ Verifica tus API Keys en la configuración.")
1043
+ else:
1044
+ with st.status("Redactando ficha técnica profesional...", expanded=True):
1045
+ try:
1046
+ payload = {
1047
+ "username": st.session_state.username,
1048
+ "licitacion": str(cg_render.get('numero_licitacion', '')),
1049
+ "codigo_renglon": str(fila.get('codigo_articulo', '')),
1050
+ "pliego_context": json.dumps(cg_render),
1051
+ "items_context": fila.to_json(),
1052
+ "gemini_key": st.session_state.gemini_key
1053
+ }
1054
+ res_ficha = requests.post(f"{API_URL_BASE}/generar-ficha", data=payload, headers=API_HEADERS)
1055
+ if res_ficha.status_code == 200:
1056
+ data_ficha = res_ficha.json()
1057
+ from_cache = data_ficha.get('from_cache', False)
1058
+ label = "✅ Ficha cargada desde caché (sin gasto de tokens)" if from_cache else "✅ ¡Ficha Técnica Generada y guardada!"
1059
+ st.success(label)
1060
+ st.markdown("<div style='background-color:#0D1117; padding:20px; border-radius:10px; border:1px solid #30363D;'>", unsafe_allow_html=True)
1061
+ st.markdown(data_ficha.get("datasheet_md", ""))
1062
+ st.markdown("</div>", unsafe_allow_html=True)
1063
+ else:
1064
+ st.error("Error al generar ficha: " + res_ficha.text)
1065
+ except Exception as e:
1066
+ st.error(f"Fallo en generación: {e}")
1067
+
1068
+ with t2:
1069
+ st.markdown("### ✉️ Motor de Generación de Cotizaciones (RFQs)")
1070
+ plantilla = st.selectbox("Modelo de Documento", ["Internacional (Inglés)", "Local (Español)"])
1071
+ txt_base = "Dear Supplier,\n\nPlease quote for Bid {{NUMERO_LICITACION}}:\n{{LISTA_ITEMS}}\nDelivery: {{LUGAR_ENTREGA}}" if "Inglés" in plantilla else "Estimado Proveedor,\n\nFavor cotizar los siguientes renglones para la licitación {{NUMERO_LICITACION}}:\n{{LISTA_ITEMS}}\nLugar de Entrega: {{LUGAR_ENTREGA}}"
1072
+
1073
+ items_str = "".join([f"- {r.get('codigo_articulo','')} | Qty: {r.get('cantidad','')}\n" for _, r in df_render.iterrows()])
1074
+ cuerpo = txt_base.replace("{{NUMERO_LICITACION}}", str(cg_render.get('numero_licitacion'))).replace("{{LISTA_ITEMS}}", items_str).replace("{{LUGAR_ENTREGA}}", str(cg_render.get('lugar_de_entrega')))
1075
+
1076
+ st.text_area("Borrador Master", value=cuerpo, height=200)
1077
+ msg = EmailMessage()
1078
+ msg.set_content(cuerpo)
1079
+ msg['Subject'], msg['From'] = f"[PROY-ACP-{cg_render.get('numero_licitacion')}] RFQ", st.session_state.email_user
1080
+
1081
+ c1, c2 = st.columns([0.3, 0.7])
1082
+ with c1: st.download_button("📦 DESCARGAR MASTER (.eml)", data=msg.as_bytes(), file_name="Master_RFQ.eml", type="primary", use_container_width=True)
1083
+
1084
+ with t3:
1085
+ st.markdown("### 📥 Centro de Mensajería y Respuestas")
1086
+ st.caption("Centraliza los correos de proveedores entrantes y los clasifica por renglón usando Inteligencia Artificial.")
1087
+
1088
+ col_filt, col_btn = st.columns([0.7, 0.3])
1089
+
1090
+ num_clean = "".join(re.findall(r'\d+', str(cg_render.get('numero_licitacion', ''))))
1091
+ with col_filt:
1092
+ filtro = st.selectbox("🎯 Filtrar Bandeja por Renglón:", ["Ver Todos los Correos"] + [f"Renglón {r.get('renglon')} - {r.get('termino_de_busqueda_corto')}" for _, r in df_render.iterrows()])
1093
+
1094
+ with col_btn:
1095
+ st.write("")
1096
+ if st.button("🔄 Escanear Nuevos Correos", use_container_width=True):
1097
+ if not st.session_state.email_user or not st.session_state.email_pass: st.error("⚠️ Configura el correo corporativo en el panel lateral.")
1098
+ else:
1099
+ with st.status("Leyendo bandeja con Gemini AI...", expanded=True):
1100
+ exito, msj = activar_organizacion_imap()
1101
+ if exito:
1102
+ st.toast("✅ Proceso iniciado en backend.", icon="📥")
1103
+ st.success("Bandeja actualizada")
1104
+ st.rerun()
1105
+ else:
1106
+ st.error(msj)
1107
+
1108
+ st.divider()
1109
+
1110
+ df_correos = obtener_correos_licitacion(num_clean)
1111
+ if df_correos.empty:
1112
+ st.info("📭 Bandeja limpia. Haz clic en 'Escanear Nuevos Correos' para revisar si hay cotizaciones de proveedores.")
1113
+ else:
1114
+ for _, c in df_correos.iterrows():
1115
+ tags = str(c.get('renglones_relacionados', '')).split(',')
1116
+ mostrar = True if filtro == "Ver Todos los Correos" else (filtro.split(" ")[1] in [t.strip() for t in tags])
1117
+
1118
+ if mostrar:
1119
+ tags_html = "".join([f"<span class='tag-badge'>Renglón {t.strip()}</span>" for t in tags if t.strip()])
1120
+ st.markdown(f"""
1121
+ <div class="email-card">
1122
+ <div style="display:flex; justify-content:space-between; margin-bottom:8px; color:#8B949E; font-size:12px;">
1123
+ <strong>{c.get('remitente', 'Desconocido')}</strong><span>{c.get('fecha', '')}</span>
1124
+ </div>
1125
+ <div style="color:#E0E0E0; font-size:15px; font-weight:bold; margin-bottom:10px;">{c.get('asunto', '')}</div>
1126
+ <div style="margin-bottom:10px;">{tags_html}</div>
1127
+ <div style="color:#C9D1D9; font-size:13px; background:#0D1117; padding:10px; border-radius:6px; border:1px solid #30363D;">
1128
+ 💡 <b>Resumen IA:</b> {c.get('resumen', '')}
1129
+ </div>
1130
+ </div>
1131
+ """, unsafe_allow_html=True)
1132
+
1133
+ # --- AQUÍ AÑADIMOS EL DESPLEGABLE PARA LEER EL CORREO ORIGINAL ---
1134
+ with st.expander("📄 Leer correo original completo"):
1135
+ st.write(c.get('cuerpo', 'El cuerpo del correo no se pudo cargar.'))
1136
+
1137
+ # --- BORRADOR DE RESPUESTA IA ---
1138
+ if c.get('borrador_respuesta') and c.get('borrador_respuesta').strip():
1139
+ st.markdown("##### 🤖 Borrador de Respuesta Propuesto por IA")
1140
+ st.text_area("Puedes editar y copiar este borrador para enviarlo al proveedor:", value=c.get('borrador_respuesta', ''), height=150, key=f"draft_{c.get('id', c.name)}")
1141
+
1142
+ with t4:
1143
+ st.markdown("### 📈 Visualización de Costos")
1144
+ if 'precio_comp_hist' in df_render.columns and not df_render['precio_comp_hist'].isna().all():
1145
+ fig = go.Figure()
1146
+ fig.add_trace(go.Bar(x=df_render['renglon'], y=df_render['precio_comp_hist'], name='Competencia', marker_color='#30363D'))
1147
+ fig.add_trace(go.Bar(x=df_render['renglon'], y=df_render['precio_proy_hist'], name='Proyelec', marker_color='#58A6FF'))
1148
+ fig.update_layout(template="plotly_dark", title="Análisis de Competitividad Histórica", barmode='group', plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)')
1149
+ st.plotly_chart(fig, use_container_width=True)
1150
+ else:
1151
+ st.info("📊 **Sin Historial de Costos**\n\nNo se encontraron registros de precios anteriores para los códigos de esta licitación en la base de datos histórica. Los artículos parecen ser nuevos o no han sido cotizados previamente.")
1152
+
1153
+ st.markdown("#### Volumen Solicitado por Renglón")
1154
+ fig = go.Figure(data=[go.Bar(x=df_render['renglon'], y=df_render['cantidad'], marker_color='#238636')])
1155
+ fig.update_layout(template="plotly_dark", plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)')
1156
+ st.plotly_chart(fig, use_container_width=True)
config.toml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ [server]
2
+ port = 7860
3
+ address = "0.0.0.0"
4
+ enableCORS = false
5
+ enableXsrfProtection = false
6
+
7
+ [browser]
8
+ gatherUsageStats = false
crypto.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ from cryptography.fernet import Fernet
4
+ from dotenv import load_dotenv
5
+
6
+ load_dotenv()
7
+ logger = logging.getLogger(__name__)
8
+
9
+ # Inicialización segura de la clave Fernet compartida
10
+ _enc_key_str = os.getenv("ENCRYPTION_KEY", "").strip()
11
+
12
+ if not _enc_key_str:
13
+ _enc_key_str = Fernet.generate_key().decode()
14
+ try:
15
+ with open(".env", "a") as f:
16
+ f.write(f"\nENCRYPTION_KEY={_enc_key_str}\n")
17
+ except Exception as e:
18
+ logger.warning(f"No se pudo escribir en .env: {e}")
19
+ os.environ["ENCRYPTION_KEY"] = _enc_key_str
20
+ logger.warning("ENCRYPTION_KEY no encontrada — se generó una nueva.")
21
+
22
+ # Extraer solo la llave si el usuario pegó "ENCRYPTION_KEY=..." por error
23
+ if _enc_key_str.startswith("ENCRYPTION_KEY="):
24
+ _enc_key_str = _enc_key_str.split("=", 1)[1].strip()
25
+
26
+ try:
27
+ ENCRYPTION_KEY = _enc_key_str.encode()
28
+ cipher_suite = Fernet(ENCRYPTION_KEY)
29
+ except Exception as e:
30
+ logger.error(f"Error inicializando Fernet con la clave proporcionada: {e}")
31
+ # Fallback seguro en caso de clave inválida (evita que la app crashee, pero las contraseñas fallarán)
32
+ cipher_suite = Fernet(Fernet.generate_key())
33
+
34
+ def encrypt_data(text: str) -> str:
35
+ """Cifra un texto plano utilizando la clave maestra."""
36
+ if not text:
37
+ return ""
38
+ try:
39
+ return cipher_suite.encrypt(text.encode()).decode()
40
+ except Exception as e:
41
+ logger.error(f"Error al cifrar datos: {e}")
42
+ return ""
43
+
44
+ def decrypt_data(text: str) -> str:
45
+ """Descifra un texto cifrado utilizando la clave maestra."""
46
+ if not text:
47
+ return ""
48
+ try:
49
+ return cipher_suite.decrypt(text.encode()).decode()
50
+ except Exception as e:
51
+ logger.error(f"Error al descifrar datos: {e}")
52
+ return ""
database.py ADDED
@@ -0,0 +1,465 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import pandas as pd
3
+ from datetime import datetime
4
+ import os
5
+ import bcrypt
6
+
7
+ DB_PATH = os.getenv("DB_PATH", "proyelec_crm.db")
8
+
9
+ def get_connection(db_path=DB_PATH):
10
+ conn = sqlite3.connect(db_path, check_same_thread=False)
11
+ if db_path != ":memory:":
12
+ conn.execute('PRAGMA journal_mode=WAL;')
13
+ return conn
14
+
15
+ def init_db():
16
+ conn = get_connection()
17
+ c = conn.cursor()
18
+
19
+ # Usuarios
20
+ c.execute('''CREATE TABLE IF NOT EXISTS users (
21
+ username TEXT PRIMARY KEY,
22
+ password TEXT,
23
+ role TEXT,
24
+ gemini_key TEXT,
25
+ tavily_key TEXT,
26
+ email_user TEXT,
27
+ email_pass_enc TEXT
28
+ )''')
29
+
30
+ # Historial de análisis
31
+ c.execute('''CREATE TABLE IF NOT EXISTS history (
32
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
33
+ username TEXT,
34
+ licitacion TEXT,
35
+ fecha TEXT,
36
+ items INTEGER
37
+ )''')
38
+
39
+ # Bandeja inteligente de correos
40
+ c.execute('''CREATE TABLE IF NOT EXISTS smart_inbox (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ licitacion TEXT,
43
+ remitente TEXT,
44
+ asunto TEXT,
45
+ fecha TEXT,
46
+ resumen TEXT,
47
+ renglones_relacionados TEXT,
48
+ cuerpo TEXT,
49
+ borrador_respuesta TEXT
50
+ )''')
51
+
52
+ # Cache de fichas técnicas (ahorra tokens Gemini)
53
+ c.execute('''CREATE TABLE IF NOT EXISTS fichas_cache (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ username TEXT,
56
+ licitacion TEXT,
57
+ codigo_renglon TEXT,
58
+ datasheet_md TEXT,
59
+ fecha TEXT,
60
+ UNIQUE(username, licitacion, codigo_renglon)
61
+ )''')
62
+
63
+ # Tabla de cotizaciones extraídas de correos (para uso futuro)
64
+ c.execute('''CREATE TABLE IF NOT EXISTS cotizaciones (
65
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
66
+ licitacion TEXT,
67
+ renglon TEXT,
68
+ proveedor TEXT,
69
+ precio_unitario REAL,
70
+ moneda TEXT,
71
+ tiempo_entrega TEXT,
72
+ condiciones TEXT,
73
+ fecha TEXT,
74
+ email_asunto TEXT
75
+ )''')
76
+
77
+ # === MÚLTIPLES WORKSPACES POR USUARIO ===
78
+ c.execute('''CREATE TABLE IF NOT EXISTS workspaces (
79
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
80
+ username TEXT NOT NULL,
81
+ licitacion TEXT NOT NULL,
82
+ data_json TEXT,
83
+ cg_json TEXT,
84
+ fecha_guardado TEXT,
85
+ UNIQUE(username, licitacion)
86
+ )''')
87
+
88
+ # app_state legacy (se mantiene para compatibilidad)
89
+ c.execute('''CREATE TABLE IF NOT EXISTS app_state (
90
+ username TEXT PRIMARY KEY,
91
+ last_licitacion TEXT,
92
+ last_data TEXT,
93
+ last_cg TEXT
94
+ )''')
95
+
96
+ # === MONITOR DE LICITACIONES ACP ===
97
+ c.execute('''CREATE TABLE IF NOT EXISTS seguimiento_licitaciones (
98
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
99
+ numero_licitacion TEXT UNIQUE NOT NULL,
100
+ objeto TEXT,
101
+ fecha_asignacion TEXT,
102
+ fecha_envio_oferta TEXT,
103
+ monto_ofertado REAL,
104
+ moneda TEXT DEFAULT "USD",
105
+ estado TEXT DEFAULT "En Preparacion",
106
+ link_sli TEXT,
107
+ notas TEXT,
108
+ responsable TEXT,
109
+ fecha_registro TEXT
110
+ )''')
111
+ c.execute('''CREATE TABLE IF NOT EXISTS seguimiento_historial (
112
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
113
+ licitacion_id INTEGER,
114
+ fecha TEXT,
115
+ estado_nuevo TEXT,
116
+ nota TEXT,
117
+ registrado_por TEXT,
118
+ FOREIGN KEY(licitacion_id) REFERENCES seguimiento_licitaciones(id)
119
+ )''')
120
+
121
+ # --- Migrar workspace legacy a nueva tabla si existe ---
122
+ c.execute("SELECT username, last_licitacion, last_data, last_cg FROM app_state")
123
+ legacy_rows = c.fetchall()
124
+ for row in legacy_rows:
125
+ uname, lic, data, cg = row
126
+ if lic and data and cg:
127
+ c.execute("""INSERT OR IGNORE INTO workspaces (username, licitacion, data_json, cg_json, fecha_guardado)
128
+ VALUES (?, ?, ?, ?, ?)""",
129
+ (uname, lic, data, cg, datetime.now().strftime("%Y-%m-%d %H:%M:%S")))
130
+
131
+ # Usuario admin por defecto (contraseña: admin)
132
+ c.execute("SELECT * FROM users WHERE username='admin'")
133
+ if not c.fetchone():
134
+ salt = bcrypt.gensalt()
135
+ hashed_pw = bcrypt.hashpw(b"admin", salt).decode('utf-8')
136
+ c.execute("INSERT INTO users VALUES ('admin', ?, 'Gerencia', '', '', '', '')", (hashed_pw,))
137
+
138
+ conn.commit()
139
+ conn.close()
140
+
141
+ # =============================================
142
+ # WORKSPACES (MÚLTIPLES POR USUARIO)
143
+ # =============================================
144
+
145
+ def save_workspace(username, licitacion, data_json, cg_json):
146
+ """Guarda o actualiza el workspace de una licitación específica."""
147
+ conn = get_connection()
148
+ c = conn.cursor()
149
+ fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
150
+ c.execute("""INSERT OR REPLACE INTO workspaces (username, licitacion, data_json, cg_json, fecha_guardado)
151
+ VALUES (?, ?, ?, ?, ?)""",
152
+ (username, licitacion, data_json, cg_json, fecha))
153
+ conn.commit()
154
+ conn.close()
155
+
156
+ def get_all_workspaces(username, all_users=False):
157
+ """Retorna todos los workspaces. Si all_users=True, retorna de todos los usuarios (para Gerencia)."""
158
+ conn = get_connection()
159
+ if all_users:
160
+ df = pd.read_sql_query(
161
+ """SELECT username, licitacion, fecha_guardado FROM workspaces
162
+ ORDER BY fecha_guardado DESC""", conn)
163
+ else:
164
+ df = pd.read_sql_query(
165
+ """SELECT username, licitacion, fecha_guardado FROM workspaces
166
+ WHERE username=? ORDER BY fecha_guardado DESC""",
167
+ conn, params=(username,))
168
+ conn.close()
169
+ return df
170
+
171
+ def load_workspace(username, licitacion):
172
+ """Carga un workspace específico por licitación."""
173
+ conn = get_connection()
174
+ c = conn.cursor()
175
+ c.execute("SELECT data_json, cg_json FROM workspaces WHERE username=? AND licitacion=?",
176
+ (username, licitacion))
177
+ row = c.fetchone()
178
+ conn.close()
179
+ return row # (data_json, cg_json) o None
180
+
181
+ def delete_workspace(username, licitacion):
182
+ """Elimina un workspace guardado."""
183
+ conn = get_connection()
184
+ c = conn.cursor()
185
+ c.execute("DELETE FROM workspaces WHERE username=? AND licitacion=?", (username, licitacion))
186
+ conn.commit()
187
+ conn.close()
188
+
189
+ # Legacy — mantener compatibilidad con código existente
190
+ def save_workspace_state(username, licitacion, data_json, cg_json):
191
+ save_workspace(username, licitacion, data_json, cg_json)
192
+
193
+ def load_workspace_state(username):
194
+ """Carga el workspace más reciente del usuario (compatibilidad legacy)."""
195
+ conn = get_connection()
196
+ c = conn.cursor()
197
+ c.execute("SELECT data_json, cg_json FROM workspaces WHERE username=? ORDER BY fecha_guardado DESC LIMIT 1",
198
+ (username,))
199
+ row = c.fetchone()
200
+ conn.close()
201
+ return row
202
+
203
+ # =============================================
204
+ # GESTIÓN DE USUARIOS (ADMIN)
205
+ # =============================================
206
+
207
+ def get_all_users():
208
+ """Retorna todos los usuarios del sistema para el panel de administración."""
209
+ conn = get_connection()
210
+ df = pd.read_sql_query(
211
+ "SELECT username as Usuario, role as Nivel, email_user as Correo, gemini_key as Clave_Gemini, tavily_key as Clave_Tavily FROM users ORDER BY role, Usuario", conn)
212
+ conn.close()
213
+
214
+ # Enmascarar las llaves visualmente para seguridad (opcional, pero recomendado)
215
+ df['Clave_Gemini'] = df['Clave_Gemini'].apply(lambda x: f"{x[:12]}...{x[-4:]}" if x and len(x) > 15 else ("Sin configurar" if not x else x))
216
+ df['Clave_Tavily'] = df['Clave_Tavily'].apply(lambda x: f"{x[:12]}...{x[-4:]}" if x and len(x) > 15 else ("Sin configurar" if not x else x))
217
+ df['Correo'] = df['Correo'].apply(lambda x: x if x else "Sin configurar")
218
+
219
+ return df
220
+
221
+ def create_user(username, password_plain, role):
222
+ """Crea un nuevo usuario. Retorna True si exitoso, False si el username ya existe."""
223
+ conn = get_connection()
224
+ c = conn.cursor()
225
+ salt = bcrypt.gensalt()
226
+ hashed = bcrypt.hashpw(password_plain.encode(), salt).decode('utf-8')
227
+ try:
228
+ c.execute("INSERT INTO users (username, password, role, gemini_key, tavily_key, email_user, email_pass_enc) VALUES (?, ?, ?, '', '', '', '')",
229
+ (username, hashed, role))
230
+ conn.commit()
231
+ conn.close()
232
+ return True
233
+ except sqlite3.IntegrityError:
234
+ conn.close()
235
+ return False
236
+
237
+ def delete_user(username):
238
+ """Elimina un usuario. No permite eliminar al admin principal."""
239
+ if username == 'admin':
240
+ return False
241
+ conn = get_connection()
242
+ c = conn.cursor()
243
+ c.execute("DELETE FROM users WHERE username=?", (username,))
244
+ conn.commit()
245
+ conn.close()
246
+ return True
247
+
248
+ def update_user_role(username, new_role):
249
+ """Cambia el rol de un usuario."""
250
+ conn = get_connection()
251
+ c = conn.cursor()
252
+ c.execute("UPDATE users SET role=? WHERE username=?", (new_role, username))
253
+ conn.commit()
254
+ conn.close()
255
+
256
+ def reset_user_password(username, new_password_plain):
257
+ """Resetea la contraseña de un usuario."""
258
+ conn = get_connection()
259
+ c = conn.cursor()
260
+ salt = bcrypt.gensalt()
261
+ hashed = bcrypt.hashpw(new_password_plain.encode(), salt).decode('utf-8')
262
+ c.execute("UPDATE users SET password=? WHERE username=?", (hashed, username))
263
+ conn.commit()
264
+ conn.close()
265
+
266
+ # =============================================
267
+ # FUNCIONES EXISTENTES (sin cambios)
268
+ # =============================================
269
+
270
+ def get_user(username, password_plain):
271
+ conn = get_connection()
272
+ c = conn.cursor()
273
+ c.execute("SELECT * FROM users WHERE LOWER(username)=LOWER(?)", (username,))
274
+ user = c.fetchone()
275
+ conn.close()
276
+ if user:
277
+ stored_hash = user[1]
278
+ try:
279
+ if bcrypt.checkpw(password_plain.encode(), stored_hash.encode('utf-8')):
280
+ return user
281
+ except ValueError:
282
+ # En caso de hashes antiguos SHA-256 sin sal
283
+ pass
284
+ return None
285
+
286
+ def update_user_profile(username, gemini, tavily, email, enc_pass):
287
+ conn = get_connection()
288
+ c = conn.cursor()
289
+ c.execute("UPDATE users SET gemini_key=?, tavily_key=?, email_user=?, email_pass_enc=? WHERE username=?",
290
+ (gemini, tavily, email, enc_pass, username))
291
+ conn.commit()
292
+ conn.close()
293
+
294
+ def save_history(username, licitacion, items_count):
295
+ conn = get_connection()
296
+ c = conn.cursor()
297
+ fecha_actual = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
298
+ c.execute("INSERT INTO history (username, licitacion, fecha, items) VALUES (?, ?, ?, ?)",
299
+ (username, licitacion, fecha_actual, items_count))
300
+ conn.commit()
301
+ conn.close()
302
+
303
+ def get_user_history_df(username):
304
+ conn = get_connection()
305
+ df = pd.read_sql_query(
306
+ "SELECT licitacion as 'Nº Licitación', fecha as 'Fecha Proceso', items as 'Renglones' FROM history WHERE username=? ORDER BY id DESC",
307
+ conn, params=(username,))
308
+ conn.close()
309
+ return df
310
+
311
+ def delete_history_entry(username, licitacion):
312
+ conn = get_connection()
313
+ c = conn.cursor()
314
+ c.execute("DELETE FROM history WHERE username=? AND licitacion=?", (username, licitacion))
315
+ conn.commit()
316
+ conn.close()
317
+
318
+ def clear_all_history(username):
319
+ conn = get_connection()
320
+ c = conn.cursor()
321
+ c.execute("DELETE FROM history WHERE username=?", (username,))
322
+ conn.commit()
323
+ conn.close()
324
+
325
+ def get_correos_licitacion_df(licitacion):
326
+ conn = get_connection()
327
+ try:
328
+ df = pd.read_sql_query(
329
+ "SELECT id, remitente, asunto, fecha, resumen, renglones_relacionados, cuerpo, borrador_respuesta FROM smart_inbox WHERE licitacion=? ORDER BY id DESC",
330
+ conn, params=(licitacion,))
331
+ except Exception:
332
+ df = pd.DataFrame()
333
+ conn.close()
334
+ return df
335
+
336
+ def get_user_credentials(username):
337
+ conn = get_connection()
338
+ c = conn.cursor()
339
+ c.execute("SELECT email_user, email_pass_enc, gemini_key FROM users WHERE username=?", (username,))
340
+ row = c.fetchone()
341
+ conn.close()
342
+ return row
343
+
344
+ def check_email_exists(licitacion, asunto, remitente):
345
+ conn = get_connection()
346
+ c = conn.cursor()
347
+ c.execute("SELECT id FROM smart_inbox WHERE licitacion=? AND asunto=? AND remitente=?",
348
+ (licitacion, asunto, remitente))
349
+ exists = c.fetchone() is not None
350
+ conn.close()
351
+ return exists
352
+
353
+ def insert_smart_inbox(licitacion, remitente, asunto, fecha, resumen, renglones, cuerpo, borrador=""):
354
+ conn = get_connection()
355
+ c = conn.cursor()
356
+ c.execute(
357
+ "INSERT INTO smart_inbox (licitacion, remitente, asunto, fecha, resumen, renglones_relacionados, cuerpo, borrador_respuesta) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
358
+ (licitacion, remitente, asunto, fecha, resumen, renglones, cuerpo, borrador))
359
+ conn.commit()
360
+ conn.close()
361
+
362
+ def insert_cotizacion(licitacion, renglon, proveedor, precio_unitario, moneda, tiempo_entrega, condiciones, fecha, email_asunto):
363
+ conn = get_connection()
364
+ c = conn.cursor()
365
+ c.execute(
366
+ "INSERT INTO cotizaciones (licitacion, renglon, proveedor, precio_unitario, moneda, tiempo_entrega, condiciones, fecha, email_asunto) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
367
+ (licitacion, renglon, proveedor, precio_unitario, moneda, tiempo_entrega, condiciones, fecha, email_asunto))
368
+ conn.commit()
369
+ conn.close()
370
+
371
+ def check_cotizacion_exists(licitacion, renglon, proveedor):
372
+ conn = get_connection()
373
+ c = conn.cursor()
374
+ c.execute("SELECT id FROM cotizaciones WHERE licitacion=? AND renglon=? AND proveedor=?",
375
+ (licitacion, renglon, proveedor))
376
+ exists = c.fetchone() is not None
377
+ conn.close()
378
+ return exists
379
+
380
+ def get_ficha_cache(username, licitacion, codigo_renglon):
381
+ conn = get_connection()
382
+ c = conn.cursor()
383
+ c.execute("SELECT datasheet_md FROM fichas_cache WHERE username=? AND licitacion=? AND codigo_renglon=?",
384
+ (username, licitacion, codigo_renglon))
385
+ row = c.fetchone()
386
+ conn.close()
387
+ return row[0] if row else None
388
+
389
+ def save_ficha_cache(username, licitacion, codigo_renglon, datasheet_md):
390
+ conn = get_connection()
391
+ c = conn.cursor()
392
+ fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
393
+ c.execute(
394
+ "INSERT OR REPLACE INTO fichas_cache (username, licitacion, codigo_renglon, datasheet_md, fecha) VALUES (?, ?, ?, ?, ?)",
395
+ (username, licitacion, codigo_renglon, datasheet_md, fecha))
396
+ conn.commit()
397
+ conn.close()
398
+
399
+ # =============================================
400
+ # MONITOR DE LICITACIONES ACP
401
+ # =============================================
402
+
403
+ ESTADOS_ACP = [
404
+ "En Preparacion",
405
+ "Oferta Enviada al SLI",
406
+ "Cumple Tecnicamente",
407
+ "No Cumple Tecnicamente",
408
+ "En Evaluacion Economica",
409
+ "Adjudicada",
410
+ "No Adjudicada",
411
+ "Desierta",
412
+ ]
413
+
414
+ def crear_seguimiento(numero_licitacion, objeto, fecha_asignacion, fecha_envio_oferta,
415
+ monto_ofertado, moneda, link_sli, notas, responsable):
416
+ conn = get_connection()
417
+ c = conn.cursor()
418
+ fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
419
+ try:
420
+ c.execute("""INSERT INTO seguimiento_licitaciones
421
+ (numero_licitacion, objeto, fecha_asignacion, fecha_envio_oferta,
422
+ monto_ofertado, moneda, estado, link_sli, notas, responsable, fecha_registro)
423
+ VALUES (?, ?, ?, ?, ?, ?, 'En Preparacion', ?, ?, ?, ?)""",
424
+ (numero_licitacion, objeto, fecha_asignacion, fecha_envio_oferta,
425
+ monto_ofertado, moneda, link_sli, notas, responsable, fecha))
426
+ conn.commit()
427
+ conn.close()
428
+ return True
429
+ except Exception:
430
+ conn.close()
431
+ return False
432
+
433
+ def get_seguimientos():
434
+ conn = get_connection()
435
+ df = pd.read_sql_query(
436
+ "SELECT * FROM seguimiento_licitaciones ORDER BY fecha_registro DESC", conn)
437
+ conn.close()
438
+ return df
439
+
440
+ def actualizar_estado(licitacion_id, nuevo_estado, nota, registrado_por):
441
+ conn = get_connection()
442
+ c = conn.cursor()
443
+ fecha = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
444
+ c.execute("UPDATE seguimiento_licitaciones SET estado=? WHERE id=?", (nuevo_estado, licitacion_id))
445
+ c.execute("""INSERT INTO seguimiento_historial (licitacion_id, fecha, estado_nuevo, nota, registrado_por)
446
+ VALUES (?, ?, ?, ?, ?)""", (licitacion_id, fecha, nuevo_estado, nota, registrado_por))
447
+ conn.commit()
448
+ conn.close()
449
+
450
+ def get_historial_seguimiento(licitacion_id):
451
+ conn = get_connection()
452
+ df = pd.read_sql_query(
453
+ "SELECT fecha, estado_nuevo, nota, registrado_por FROM seguimiento_historial WHERE licitacion_id=? ORDER BY id DESC",
454
+ conn, params=(licitacion_id,))
455
+ conn.close()
456
+ return df
457
+
458
+ def eliminar_seguimiento(licitacion_id):
459
+ conn = get_connection()
460
+ c = conn.cursor()
461
+ c.execute("DELETE FROM seguimiento_historial WHERE licitacion_id=?", (licitacion_id,))
462
+ c.execute("DELETE FROM seguimiento_licitaciones WHERE id=?", (licitacion_id,))
463
+ conn.commit()
464
+ conn.close()
465
+
entrypoint.sh ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Iniciar la API en segundo plano
4
+ echo "Iniciando Motor IA (FastAPI)..."
5
+ uvicorn api:app --host 0.0.0.0 --port 8000 &
6
+
7
+ # Esperar unos segundos para que la API levante bien
8
+ sleep 3
9
+
10
+ # Iniciar Streamlit en el puerto principal
11
+ echo "Iniciando Centro de Mando (Streamlit)..."
12
+ streamlit run app.py --server.port 7860 --server.address 0.0.0.0
proyelec_logo.png ADDED
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
style.css ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
2
+
3
+ /* ===== BASE ===== */
4
+ html, body, [data-testid="stAppViewContainer"] {
5
+ background-color: #0A0E1A;
6
+ color: #E0E6F0;
7
+ font-family: 'Inter', sans-serif;
8
+ }
9
+ [data-testid="stSidebar"] {
10
+ background: linear-gradient(180deg, #0D1117 0%, #0A0E1A 100%);
11
+ border-right: 1px solid #1E2A3A;
12
+ }
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"] {
36
+ background: linear-gradient(135deg, #161B22 0%, #0D1117 100%);
37
+ border: 1px solid #1E2A3A;
38
+ padding: 18px;
39
+ border-radius: 14px;
40
+ transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
41
+ }
42
+ div[data-testid="metric-container"]:hover {
43
+ transform: translateY(-4px);
44
+ border-color: #58A6FF;
45
+ box-shadow: 0 8px 25px rgba(88, 166, 255, 0.15);
46
+ }
47
+
48
+ /* ===== CARDS ===== */
49
+ .detail-card {
50
+ background: linear-gradient(135deg, #161B22 0%, #0D1117 100%);
51
+ padding: 25px;
52
+ border-radius: 16px;
53
+ border-top: 3px solid #58A6FF;
54
+ border-left: 1px solid #1E2A3A;
55
+ border-right: 1px solid #1E2A3A;
56
+ border-bottom: 1px solid #1E2A3A;
57
+ margin-top: 20px;
58
+ animation: fadeInUp 0.3s ease;
59
+ }
60
+ .provider-card {
61
+ border: 1px solid #1E2A3A;
62
+ border-radius: 14px;
63
+ padding: 20px;
64
+ background: linear-gradient(135deg, #0D1117 0%, #0A0E1A 100%);
65
+ border-left: 3px solid #58A6FF;
66
+ height: 100%;
67
+ transition: all 0.25s ease;
68
+ margin-top: 15px;
69
+ }
70
+ .provider-card:hover {
71
+ border-left-color: #10B981;
72
+ transform: translateY(-4px);
73
+ box-shadow: 0 10px 30px rgba(16, 185, 129, 0.15);
74
+ }
75
+ .email-card {
76
+ background: linear-gradient(135deg, #161B22 0%, #0D1117 100%);
77
+ border-left: 3px solid #10B981;
78
+ padding: 18px;
79
+ border-radius: 12px;
80
+ margin-bottom: 12px;
81
+ border-top: 1px solid #1E2A3A;
82
+ border-right: 1px solid #1E2A3A;
83
+ border-bottom: 1px solid #1E2A3A;
84
+ transition: all 0.2s ease;
85
+ animation: fadeInUp 0.3s ease;
86
+ }
87
+ .email-card:hover {
88
+ border-left-color: #58A6FF;
89
+ box-shadow: 0 4px 20px rgba(88, 166, 255, 0.1);
90
+ }
91
+ .step-card {
92
+ background: linear-gradient(135deg, #161B22 0%, #0D1117 100%);
93
+ border: 1px solid #1E2A3A;
94
+ padding: 30px 20px;
95
+ border-radius: 18px;
96
+ text-align: center;
97
+ height: 100%;
98
+ transition: all 0.3s ease;
99
+ animation: fadeInUp 0.4s ease;
100
+ }
101
+ .step-card:hover {
102
+ border-color: #58A6FF;
103
+ transform: translateY(-5px);
104
+ box-shadow: 0 15px 40px rgba(88, 166, 255, 0.12);
105
+ }
106
+ .step-icon { font-size: 44px; margin-bottom: 15px; display: block; }
107
+
108
+ /* ===== BADGES ===== */
109
+ .tag-badge {
110
+ background: linear-gradient(90deg, #1a3a1a, #238636);
111
+ color: #3fb950;
112
+ border: 1px solid #238636;
113
+ padding: 3px 10px;
114
+ border-radius: 20px;
115
+ font-size: 11px;
116
+ font-weight: 600;
117
+ letter-spacing: 0.5px;
118
+ margin-right: 4px;
119
+ }
120
+
121
+ /* ===== BOTONES LEGACY ===== */
122
+ .action-btn {
123
+ display: inline-block;
124
+ margin-top: 10px; margin-right: 10px;
125
+ background: linear-gradient(90deg, #238636, #2ea043);
126
+ color: white !important;
127
+ padding: 10px 18px;
128
+ border-radius: 8px;
129
+ text-decoration: none;
130
+ font-weight: 700;
131
+ font-size: 13px;
132
+ transition: all 0.25s ease;
133
+ box-shadow: 0 2px 8px rgba(35, 134, 54, 0.3);
134
+ }
135
+ .action-btn:hover {
136
+ transform: translateY(-2px);
137
+ box-shadow: 0 8px 20px rgba(35, 134, 54, 0.4);
138
+ }
139
+
140
+ /* ===== ANIMACIONES ===== */
141
+ @keyframes fadeInUp {
142
+ from { opacity: 0; transform: translateY(12px); }
143
+ to { opacity: 1; transform: translateY(0); }
144
+ }
145
+ @keyframes pulse-blue {
146
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(88, 166, 255, 0.3); }
147
+ 50% { box-shadow: 0 0 0 8px rgba(88, 166, 255, 0); }
148
+ }
149
+
150
+ /* ===== SCROLLBAR ===== */
151
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
152
+ ::-webkit-scrollbar-track { background: #0A0E1A; }
153
+ ::-webkit-scrollbar-thumb { background: #1E2A3A; border-radius: 4px; }
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 {
161
+ --bg-main: #080C12;
162
+ --surface-1: #0F141C;
163
+ --surface-2: #151B24;
164
+ --line-soft: #242C38;
165
+ --text-main: #E8EDF5;
166
+ --text-muted: #8C98A8;
167
+ --blue: #4F9CF9;
168
+ --green: #31C48D;
169
+ --amber: #F6B44B;
170
+ --orange: #F08A3C;
171
+ --red: #EF5B5B;
172
+ --gray: #748091;
173
+ }
174
+
175
+ div[data-testid="metric-container"] {
176
+ background: var(--surface-1);
177
+ border: 1px solid var(--line-soft);
178
+ border-radius: 8px;
179
+ box-shadow: none;
180
+ }
181
+
182
+ div[data-testid="metric-container"]:hover {
183
+ transform: none;
184
+ border-color: #344255;
185
+ box-shadow: none;
186
+ }
187
+
188
+ .section-title {
189
+ margin: 2px 0 12px 0;
190
+ color: var(--text-main);
191
+ font-size: 18px;
192
+ font-weight: 750;
193
+ }
194
+
195
+ .section-subtitle {
196
+ margin: -8px 0 16px 0;
197
+ color: var(--text-muted);
198
+ font-size: 13px;
199
+ }
200
+
201
+ .monitor-list {
202
+ display: flex;
203
+ flex-direction: column;
204
+ gap: 10px;
205
+ }
206
+
207
+ .monitor-row {
208
+ background: var(--surface-1);
209
+ border: 1px solid var(--line-soft);
210
+ border-left: 4px solid var(--status-color, var(--blue));
211
+ border-radius: 8px;
212
+ padding: 12px 14px;
213
+ }
214
+
215
+ .monitor-row-head {
216
+ display: flex;
217
+ align-items: flex-start;
218
+ justify-content: space-between;
219
+ gap: 14px;
220
+ }
221
+
222
+ .monitor-id {
223
+ color: var(--text-main);
224
+ font-size: 16px;
225
+ font-weight: 800;
226
+ line-height: 1.2;
227
+ }
228
+
229
+ .monitor-owner {
230
+ color: var(--text-muted);
231
+ font-size: 11px;
232
+ text-transform: uppercase;
233
+ letter-spacing: 0.04em;
234
+ margin-bottom: 3px;
235
+ }
236
+
237
+ .monitor-object {
238
+ color: #B8C2D0;
239
+ font-size: 12px;
240
+ line-height: 1.35;
241
+ margin-top: 5px;
242
+ }
243
+
244
+ .monitor-meta {
245
+ display: flex;
246
+ justify-content: flex-end;
247
+ flex-wrap: wrap;
248
+ gap: 6px;
249
+ margin-top: 8px;
250
+ }
251
+
252
+ .status-badge {
253
+ display: inline-flex;
254
+ align-items: center;
255
+ gap: 6px;
256
+ background: color-mix(in srgb, var(--status-color, var(--blue)) 14%, transparent);
257
+ color: var(--status-color, var(--blue));
258
+ border: 1px solid color-mix(in srgb, var(--status-color, var(--blue)) 42%, transparent);
259
+ border-radius: 999px;
260
+ padding: 3px 9px;
261
+ font-size: 11px;
262
+ font-weight: 700;
263
+ white-space: nowrap;
264
+ }
265
+
266
+ .status-dot {
267
+ width: 7px;
268
+ height: 7px;
269
+ border-radius: 50%;
270
+ background: var(--status-color, var(--blue));
271
+ }
272
+
273
+ .mini-pill {
274
+ background: #0B1017;
275
+ border: 1px solid var(--line-soft);
276
+ border-radius: 999px;
277
+ color: var(--text-muted);
278
+ padding: 3px 9px;
279
+ font-size: 11px;
280
+ white-space: nowrap;
281
+ }
282
+
283
+ .sli-summary {
284
+ margin: 8px 0 10px 0;
285
+ background: #0B1017;
286
+ border: 1px solid var(--line-soft);
287
+ border-left: 3px solid var(--amber);
288
+ border-radius: 8px;
289
+ padding: 10px 12px;
290
+ }
291
+
292
+ .sli-summary-title {
293
+ color: var(--amber);
294
+ font-size: 11px;
295
+ font-weight: 800;
296
+ letter-spacing: 0.04em;
297
+ text-transform: uppercase;
298
+ margin-bottom: 4px;
299
+ }
300
+
301
+ .sli-summary-body {
302
+ color: #D9E2EF;
303
+ font-size: 12px;
304
+ line-height: 1.35;
305
+ }
306
+
307
+ .work-panel {
308
+ background: var(--surface-1);
309
+ border: 1px solid var(--line-soft);
310
+ border-radius: 8px;
311
+ padding: 14px;
312
+ }
313
+
314
+ .info-grid {
315
+ display: grid;
316
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
317
+ gap: 8px;
318
+ margin-bottom: 14px;
319
+ }
320
+
321
+ .info-tile {
322
+ background: var(--surface-1);
323
+ border: 1px solid var(--line-soft);
324
+ border-radius: 8px;
325
+ padding: 12px;
326
+ }
327
+
328
+ .info-label {
329
+ color: var(--text-muted);
330
+ font-size: 10px;
331
+ text-transform: uppercase;
332
+ letter-spacing: 0.05em;
333
+ margin-bottom: 5px;
334
+ }
335
+
336
+ .info-value {
337
+ color: var(--text-main);
338
+ font-size: 13px;
339
+ font-weight: 650;
340
+ line-height: 1.3;
341
+ }
342
+
343
+ .detail-card {
344
+ background: var(--surface-1);
345
+ border: 1px solid var(--line-soft);
346
+ border-left: 4px solid var(--blue);
347
+ border-radius: 8px;
348
+ padding: 18px;
349
+ box-shadow: none;
350
+ }
351
+
352
+ .provider-card, .email-card, .step-card {
353
+ border-radius: 8px;
354
+ background: var(--surface-1);
355
+ border-color: var(--line-soft);
356
+ box-shadow: none;
357
+ }
358
+
359
+ .stButton > button,
360
+ .stDownloadButton > button,
361
+ [data-testid="stBaseButton-secondary"],
362
+ [data-testid="stBaseButton-primary"] {
363
+ border-radius: 7px !important;
364
+ min-height: 36px;
365
+ font-weight: 650;
366
+ }
367
+
368
+ .stButton > button[kind="primary"],
369
+ [data-testid="stBaseButton-primary"] {
370
+ background: #2F7FD8 !important;
371
+ border-color: #2F7FD8 !important;
372
+ }