tokiospg commited on
Commit
6937b1f
·
verified ·
1 Parent(s): f0adbc1

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +217 -10
app.py CHANGED
@@ -56,6 +56,13 @@ def encrypt_data(text):
56
  def decrypt_data(text):
57
  return crypto.decrypt_data(text)
58
 
 
 
 
 
 
 
 
59
  API_URL_BASE = os.getenv("API_URL_BASE", "http://localhost:8000/api/v1")
60
  API_HEADERS = {"X-Internal-Token": os.getenv("INTERNAL_API_TOKEN", "default-dev-token")}
61
  TIEMPO_BLOQUEO = 15 # minutos — debe coincidir con database.py
@@ -650,6 +657,87 @@ with tab_acp:
650
  </div>
651
  """, unsafe_allow_html=True)
652
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
653
  # Controles — fila 1: manual
654
  resumen_acta_cache = st.session_state.get(f"sli_resumen_acta_{lic_id}")
655
  if resumen_acta_cache:
@@ -707,6 +795,24 @@ with tab_acp:
707
  # Mapear estatus SLI al catálogo interno
708
  estado_mapeado = MAPA_ESTADOS_SLI.get(
709
  estatus_sli.upper(), estatus_sli)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
710
  nota_auto = f"[SLI Auto] Estatus: {estatus_sli}"
711
  if cierre_sli:
712
  nota_auto += f" | Cierre: {cierre_sli}"
@@ -768,6 +874,102 @@ with tab_acp:
768
  st.toast(f"Seguimiento {num_lic} eliminado.", icon="🗑️")
769
  st.rerun()
770
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
771
 
772
  # Historial colapsable
773
  df_hist_seg = db.get_historial_seguimiento(lic_id)
@@ -1457,12 +1659,17 @@ Responde con el siguiente formato estructurado en Markdown:
1457
  </p>
1458
  """, unsafe_allow_html=True)
1459
 
1460
- # Inicializar historial del chat
1461
- if "copilot_messages" not in st.session_state:
1462
- st.session_state.copilot_messages = []
 
 
 
 
 
1463
 
1464
  # Mostrar historial
1465
- for msg in st.session_state.copilot_messages:
1466
  with st.chat_message(msg["role"]):
1467
  st.markdown(msg["content"])
1468
 
@@ -1474,7 +1681,7 @@ Responde con el siguiente formato estructurado en Markdown:
1474
  st.error("🔑 Configura tu API Key de Gemini en el panel lateral.")
1475
  else:
1476
  # Añadir mensaje del usuario
1477
- st.session_state.copilot_messages.append({"role": "user", "content": pregunta})
1478
  with st.chat_message("user"):
1479
  st.markdown(pregunta)
1480
 
@@ -1491,7 +1698,7 @@ Responde con el siguiente formato estructurado en Markdown:
1491
  items_ctx = df_render[['renglon','codigo_articulo','cantidad','unidad_de_medida','termino_de_busqueda_corto']].to_string(index=False) if not df_render.empty else "Sin renglones cargados."
1492
 
1493
  # Historial reciente del chat (últimas 6 interacciones)
1494
- historial_str = "\n".join([f"{m['role'].upper()}: {m['content']}" for m in st.session_state.copilot_messages[-6:]])
1495
 
1496
  prompt_cop = f"""Eres Procura Copilot, un asistente experto en procura y compras industriales de la empresa Proyelec S.A.
1497
  Respondes en español de forma concisa, directa y útil. Si no tienes datos suficientes, lo dices honestamente.
@@ -1518,17 +1725,17 @@ Responde de forma clara y profesional. Si puedes dar un número o dato exacto de
1518
  resp_cop = modelo_cop.generate_content(prompt_cop)
1519
  respuesta = resp_cop.text.strip()
1520
  st.markdown(respuesta)
1521
- st.session_state.copilot_messages.append({"role": "assistant", "content": respuesta})
1522
 
1523
  except Exception as e:
1524
  err_msg = f"❌ Error del Copilot: {e}"
1525
  st.error(err_msg)
1526
- st.session_state.copilot_messages.append({"role": "assistant", "content": err_msg})
1527
 
1528
  # Botón para limpiar chat
1529
- if st.session_state.copilot_messages:
1530
  if st.button("🗑️ Limpiar conversación", key="clear_copilot"):
1531
- st.session_state.copilot_messages = []
1532
  st.rerun()
1533
 
1534
 
 
56
  def decrypt_data(text):
57
  return crypto.decrypt_data(text)
58
 
59
+ def extraer_meta_nota(nota):
60
+ cierre_match = re.search(r"Cierre:\s*([^|]+)", nota)
61
+ rev_match = re.search(r"Última rev:\s*([^|]+)", nota)
62
+ cierre = cierre_match.group(1).strip() if cierre_match else None
63
+ rev = rev_match.group(1).strip() if rev_match else None
64
+ return cierre, rev
65
+
66
  API_URL_BASE = os.getenv("API_URL_BASE", "http://localhost:8000/api/v1")
67
  API_HEADERS = {"X-Internal-Token": os.getenv("INTERNAL_API_TOKEN", "default-dev-token")}
68
  TIEMPO_BLOQUEO = 15 # minutos — debe coincidir con database.py
 
657
  </div>
658
  """, unsafe_allow_html=True)
659
 
660
+ # --- ALERTA DE ENMIENDA RECIENTE ---
661
+ alerta_enmienda = st.session_state.get(f"sli_alerta_enmienda_{lic_id}")
662
+ if alerta_enmienda:
663
+ st.markdown(f"""
664
+ <div style="background: #2B220B; border: 1px solid #E3B341; border-left: 4px solid #E3B341;
665
+ padding: 10px 14px; border-radius: 8px; margin: 8px 0 12px 0;">
666
+ <div style="color: #F6B44B; font-weight: bold; font-size: 12px; margin-bottom: 4px; display: flex; align-items: center; gap: 6px;">
667
+ ⚠️ Nueva Enmienda o Modificación Detectada en ACP
668
+ </div>
669
+ <div style="color: #E6EDF3; font-size: 11px; line-height: 1.45;">
670
+ El portal SLI registra una actualización en los documentos de esta licitación.<br>
671
+ • <b>Última Revisión:</b> {alerta_enmienda['rev_nueva']} (antes: {alerta_enmienda['rev_antigua'] or 'Desconocida'})<br>
672
+ • <b>Nueva Fecha de Cierre:</b> {alerta_enmienda['cierre_nuevo']} (antes: {alerta_enmienda['cierre_antiguo'] or 'Desconocida'})
673
+ </div>
674
+ </div>
675
+ """, unsafe_allow_html=True)
676
+
677
+ # --- LÍNEA DE TIEMPO INTERACTIVA / PROGRESO ---
678
+ estado_idx = 1
679
+ if estado == "Oferta Enviada al SLI":
680
+ estado_idx = 2
681
+ elif estado in ["Cumple Tecnicamente", "No Cumple Tecnicamente", "En Evaluacion Economica"]:
682
+ estado_idx = 3
683
+ elif estado in ["Adjudicada", "No Adjudicada", "Desierta"]:
684
+ estado_idx = 4
685
+
686
+ p1_bg = "#4F9CF9"
687
+ p1_txt = "#080C12"
688
+ p2_bg = "#F6B44B" if estado_idx >= 2 else "#242C38"
689
+ p2_txt = "#080C12" if estado_idx >= 2 else "#8C98A8"
690
+ p3_bg = "#F08A3C" if estado_idx >= 3 else "#242C38"
691
+ p3_txt = "#080C12" if estado_idx >= 3 else "#8C98A8"
692
+
693
+ if estado == "No Cumple Tecnicamente":
694
+ p3_bg = "#EF5B5B"
695
+ p3_txt = "#ffffff"
696
+
697
+ p4_bg = "#31C48D" if estado_idx == 4 else "#242C38"
698
+ p4_txt = "#080C12" if estado_idx == 4 else "#8C98A8"
699
+
700
+ if estado == "No Adjudicada":
701
+ p4_bg = "#748091"
702
+ p4_txt = "#ffffff"
703
+ elif estado == "Desierta":
704
+ p4_bg = "#748091"
705
+ p4_txt = "#ffffff"
706
+
707
+ l1_color = "#F6B44B" if estado_idx >= 2 else "#242C38"
708
+ l2_color = "#F08A3C" if estado_idx >= 3 else "#242C38"
709
+ l3_color = "#31C48D" if estado_idx == 4 else "#242C38"
710
+
711
+ st.markdown(f"""
712
+ <div style="display: flex; align-items: center; justify-content: space-between;
713
+ margin: 10px 0 16px 0; padding: 10px 14px; background: #161B22;
714
+ border-radius: 8px; border: 1px solid #30363D;">
715
+ <!-- Paso 1 -->
716
+ <div style="text-align: center; flex: 1;">
717
+ <div style="width: 22px; height: 22px; border-radius: 50%; background: {p1_bg}; color: {p1_txt}; display: flex; align-items: center; justify-content: center; margin: 0 auto 4px auto; font-size: 10px; font-weight: bold; box-shadow: 0 0 10px rgba(79, 156, 249, 0.2);">{ "✓" if estado_idx > 1 else "1" }</div>
718
+ <div style="font-size: 9px; color: #4F9CF9; font-weight: 700; text-transform: uppercase;">Preparación</div>
719
+ </div>
720
+ <div style="flex: 0.4; height: 2px; background: {l1_color}; margin-bottom: 12px;"></div>
721
+ <!-- Paso 2 -->
722
+ <div style="text-align: center; flex: 1;">
723
+ <div style="width: 22px; height: 22px; border-radius: 50%; background: {p2_bg}; color: {p2_txt}; display: flex; align-items: center; justify-content: center; margin: 0 auto 4px auto; font-size: 10px; font-weight: bold;">{ "✓" if estado_idx > 2 else "2" }</div>
724
+ <div style="font-size: 9px; color: { "#F6B44B" if estado_idx >= 2 else "#8C98A8" }; font-weight: 700; text-transform: uppercase;">Envío SLI</div>
725
+ </div>
726
+ <div style="flex: 0.4; height: 2px; background: {l2_color}; margin-bottom: 12px;"></div>
727
+ <!-- Paso 3 -->
728
+ <div style="text-align: center; flex: 1;">
729
+ <div style="width: 22px; height: 22px; border-radius: 50%; background: {p3_bg}; color: {p3_txt}; display: flex; align-items: center; justify-content: center; margin: 0 auto 4px auto; font-size: 10px; font-weight: bold;">{ "✓" if estado_idx > 3 else "3" }</div>
730
+ <div style="font-size: 9px; color: { "#F08A3C" if estado_idx >= 3 else "#8C98A8" }; font-weight: 700; text-transform: uppercase;">Evaluación</div>
731
+ </div>
732
+ <div style="flex: 0.4; height: 2px; background: {l3_color}; margin-bottom: 12px;"></div>
733
+ <!-- Paso 4 -->
734
+ <div style="text-align: center; flex: 1;">
735
+ <div style="width: 22px; height: 22px; border-radius: 50%; background: {p4_bg}; color: {p4_txt}; display: flex; align-items: center; justify-content: center; margin: 0 auto 4px auto; font-size: 10px; font-weight: bold;">{ "✓" if estado_idx == 4 else "4" }</div>
736
+ <div style="font-size: 9px; color: { "#31C48D" if estado_idx == 4 else "#8C98A8" }; font-weight: 700; text-transform: uppercase;">Resolución</div>
737
+ </div>
738
+ </div>
739
+ """, unsafe_allow_html=True)
740
+
741
  # Controles — fila 1: manual
742
  resumen_acta_cache = st.session_state.get(f"sli_resumen_acta_{lic_id}")
743
  if resumen_acta_cache:
 
795
  # Mapear estatus SLI al catálogo interno
796
  estado_mapeado = MAPA_ESTADOS_SLI.get(
797
  estatus_sli.upper(), estatus_sli)
798
+
799
+ # --- WATCHDOG DE ENMIENDAS / CAMBIOS ---
800
+ df_hist_prev = db.get_historial_seguimiento(lic_id)
801
+ if not df_hist_prev.empty:
802
+ nota_antigua = str(df_hist_prev.iloc[0].get('nota', ''))
803
+ cierre_antiguo, rev_antigua = extraer_meta_nota(nota_antigua)
804
+
805
+ cambio_cierre = cierre_sli and cierre_antiguo and (cierre_sli.strip() != cierre_antiguo.strip())
806
+ cambio_rev = rev_sli and rev_antigua and (rev_sli.strip() != rev_antigua.strip())
807
+
808
+ if cambio_cierre or cambio_rev:
809
+ st.session_state[f"sli_alerta_enmienda_{lic_id}"] = {
810
+ "cierre_antiguo": cierre_antiguo,
811
+ "cierre_nuevo": cierre_sli,
812
+ "rev_antigua": rev_antigua,
813
+ "rev_nueva": rev_sli
814
+ }
815
+
816
  nota_auto = f"[SLI Auto] Estatus: {estatus_sli}"
817
  if cierre_sli:
818
  nota_auto += f" | Cierre: {cierre_sli}"
 
874
  st.toast(f"Seguimiento {num_lic} eliminado.", icon="🗑️")
875
  st.rerun()
876
 
877
+ # --- ASESORÍA ESTRATÉGICA IA ---
878
+ st.markdown("<div style='margin-top: 10px;'></div>", unsafe_allow_html=True)
879
+ btn_col, clear_btn_col = st.columns([0.7, 0.3])
880
+ with btn_col:
881
+ btn_consultar_ia = st.button(
882
+ "🎓 Carlos Méndez: Consultar Estrategia",
883
+ key=f"ai_carlos_{lic_id}",
884
+ use_container_width=True
885
+ )
886
+ with clear_btn_col:
887
+ btn_clear_ia = st.button(
888
+ "🗑️ Limpiar Asesoría",
889
+ key=f"clear_ai_carlos_{lic_id}",
890
+ use_container_width=True
891
+ )
892
+
893
+ # Si el usuario hace clic en Consultar
894
+ if btn_consultar_ia:
895
+ if not st.session_state.gemini_key:
896
+ st.error("🔑 Configura tu API Key de Gemini en el panel lateral.")
897
+ else:
898
+ with st.spinner("🎓 El Asesor Carlos Méndez está evaluando el estatus..."):
899
+ try:
900
+ import google.generativeai as genai
901
+ genai.configure(api_key=st.session_state.gemini_key)
902
+ modelo_seg = genai.GenerativeModel('gemini-2.0-flash')
903
+
904
+ hist_seg_str = ""
905
+ df_hist_seg = db.get_historial_seguimiento(lic_id)
906
+ if not df_hist_seg.empty:
907
+ hist_seg_str = "\n".join([f"- {h['fecha'][:10]}: {h['estado_nuevo']} ({h['nota'] or 'Sin nota'})" for _, h in df_hist_seg.head(5).iterrows()])
908
+ else:
909
+ hist_seg_str = "Sin historial de actualizaciones."
910
+
911
+ alerta_ctx = ""
912
+ alerta_enmienda = st.session_state.get(f"sli_alerta_enmienda_{lic_id}")
913
+ if alerta_enmienda:
914
+ alerta_ctx = f"""
915
+ *** ALERTA DE ENMIENDA RECIENTE EN LA ACP ***
916
+ - Se detectó un cambio en el pliego o documentos adjuntos en la última consulta.
917
+ - Fecha de revisión anterior: {alerta_enmienda.get('rev_antigua','N/A')}
918
+ - Nueva fecha de revisión: {alerta_enmienda.get('rev_nueva','N/A')}
919
+ - Fecha de cierre anterior: {alerta_enmienda.get('cierre_antiguo','N/A')}
920
+ - Nueva fecha de cierre: {alerta_enmienda.get('cierre_nuevo','N/A')}
921
+ Debes comentar esta alerta en tu recomendación y dar instrucciones claras de cómo proceder."""
922
+
923
+ prompt_seg_ai = f"""Eres Carlos Méndez, Asesor Senior de Procura con 25 años de experiencia en licitaciones del Canal de Panamá (ACP) y Procura B2B.
924
+
925
+ El analista te pide tu opinión experta sobre esta licitación activa en el Monitor ACP:
926
+
927
+ INFORMACIÓN DE LA LICITACIÓN:
928
+ - RFQ / Número: {num_lic}
929
+ - Objeto: {objeto_safe}
930
+ - Responsable: {resp_safe}
931
+ - Estado Actual: {estado}
932
+ - Días desde que se envió la oferta: {dias_txt or "No definido"}
933
+ - Monto Ofertado: {moneda} {monto:,.2f}
934
+
935
+ HISTORIAL DE ACTUALIZACIONES RECIENTES:
936
+ {hist_seg_str}
937
+ {alerta_ctx}
938
+
939
+ Tu misión es dar un análisis estratégico súper corto, ultra-accionable y con lenguaje de mentor (en español).
940
+
941
+ Estructura tu respuesta en este formato exacto:
942
+ 🎓 **Estrategia para Licitación {num_lic}**
943
+ - **Diagnóstico del Estatus**: [1 frase sobre qué significa esta fase o el retraso actual en la ACP, comentando brevemente la enmienda si existe]
944
+ - **Acción Inmediata**: [1 paso concreto que el analista debe hacer HOY. Ej: Extender fianza de propuesta, preparar subsanación técnica o monitorear acta de apertura]
945
+ - **Alerta de Riesgo**: [Qué podría salir mal si no se actúa y cómo mitigarlo]
946
+
947
+ Sé directo y profesional, escribe máximo 100 palabras en total. ¡Usa tu amplia experiencia con la ACP!"""
948
+
949
+ resp_seg_ai = modelo_seg.generate_content(prompt_seg_ai)
950
+ st.session_state[f"sli_analisis_ia_{lic_id}"] = resp_seg_ai.text.strip()
951
+ except Exception as e:
952
+ st.error(f"❌ Error al consultar al Asesor: {e}")
953
+
954
+ # Si el usuario hace clic en Limpiar
955
+ if btn_clear_ia:
956
+ st.session_state.pop(f"sli_analisis_ia_{lic_id}", None)
957
+ st.rerun()
958
+
959
+ # Mostrar análisis si existe
960
+ analisis_ia = st.session_state.get(f"sli_analisis_ia_{lic_id}")
961
+ if analisis_ia:
962
+ st.markdown(f"""
963
+ <div style="background: #0E1624; border: 1px solid #1E2A3A; border-left: 3px solid #10B981;
964
+ padding: 12px; border-radius: 8px; margin-top: 10px; margin-bottom: 10px;">
965
+ <div style="color: #31C48D; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 5px;">
966
+ 🎓 Consejo Estratégico de Carlos Méndez, CPSM
967
+ </div>
968
+ <div style="color: #E6EDF3; font-size: 12.5px; line-height: 1.45;">
969
+ {analisis_ia}
970
+ </div>
971
+ </div>
972
+ """, unsafe_allow_html=True)
973
 
974
  # Historial colapsable
975
  df_hist_seg = db.get_historial_seguimiento(lic_id)
 
1659
  </p>
1660
  """, unsafe_allow_html=True)
1661
 
1662
+ # Inicializar historial de chat por licitación
1663
+ licitacion_actual = str(cg_render.get('numero_licitacion', 'default'))
1664
+ if "copilot_messages_by_lic" not in st.session_state:
1665
+ st.session_state.copilot_messages_by_lic = {}
1666
+ if licitacion_actual not in st.session_state.copilot_messages_by_lic:
1667
+ st.session_state.copilot_messages_by_lic[licitacion_actual] = []
1668
+
1669
+ copilot_messages = st.session_state.copilot_messages_by_lic[licitacion_actual]
1670
 
1671
  # Mostrar historial
1672
+ for msg in copilot_messages:
1673
  with st.chat_message(msg["role"]):
1674
  st.markdown(msg["content"])
1675
 
 
1681
  st.error("🔑 Configura tu API Key de Gemini en el panel lateral.")
1682
  else:
1683
  # Añadir mensaje del usuario
1684
+ copilot_messages.append({"role": "user", "content": pregunta})
1685
  with st.chat_message("user"):
1686
  st.markdown(pregunta)
1687
 
 
1698
  items_ctx = df_render[['renglon','codigo_articulo','cantidad','unidad_de_medida','termino_de_busqueda_corto']].to_string(index=False) if not df_render.empty else "Sin renglones cargados."
1699
 
1700
  # Historial reciente del chat (últimas 6 interacciones)
1701
+ historial_str = "\n".join([f"{m['role'].upper()}: {m['content']}" for m in copilot_messages[-6:]])
1702
 
1703
  prompt_cop = f"""Eres Procura Copilot, un asistente experto en procura y compras industriales de la empresa Proyelec S.A.
1704
  Respondes en español de forma concisa, directa y útil. Si no tienes datos suficientes, lo dices honestamente.
 
1725
  resp_cop = modelo_cop.generate_content(prompt_cop)
1726
  respuesta = resp_cop.text.strip()
1727
  st.markdown(respuesta)
1728
+ copilot_messages.append({"role": "assistant", "content": respuesta})
1729
 
1730
  except Exception as e:
1731
  err_msg = f"❌ Error del Copilot: {e}"
1732
  st.error(err_msg)
1733
+ copilot_messages.append({"role": "assistant", "content": err_msg})
1734
 
1735
  # Botón para limpiar chat
1736
+ if copilot_messages:
1737
  if st.button("🗑️ Limpiar conversación", key="clear_copilot"):
1738
+ st.session_state.copilot_messages_by_lic[licitacion_actual] = []
1739
  st.rerun()
1740
 
1741