jcalbornoz commited on
Commit
9598b2d
·
verified ·
1 Parent(s): 6089a8e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +51 -61
app.py CHANGED
@@ -28,7 +28,6 @@ except ImportError:
28
  subprocess.run(["pip", "install", "fake-useragent"], check=True)
29
  from fake_useragent import UserAgent
30
 
31
- # --- URL DEL GOBIERNO ---
32
  URL_LOGO_GOBIERNO = "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c3/Escudo_de_Colombia.svg/250px-Escudo_de_Colombia.svg.png"
33
 
34
  def descargar_recurso(url, nombre_archivo):
@@ -79,17 +78,13 @@ def construir_urls_final(operacion, barrio, ciudad, tipo, hab, ban, park, antigu
79
  if tipo_slug == "local": tipo_fr = "locales"
80
  elif tipo_slug == "edificio": tipo_fr = "edificios"
81
  else: tipo_fr = tipo_slug + "s"
82
-
83
- if tipo_slug in ["lote", "finca"]:
84
- filtros_fr = ""; filtros_mc = ""
85
- else:
86
- filtros_fr = f"/{slug_park}/{slug_ant}"; filtros_mc = ""
87
 
88
  url_fr_base = f"https://www.fincaraiz.com.co/{op_slug}/{tipo_fr}/{b_slug}/{c_slug}{filtros_fr}/m2-desde-{int(m2_min)}/m2-hasta-{int(m2_max)}"
89
  if ascensor and tipo_slug not in ["lote", "finca"]: url_fr_base += "/con-ascensor"
90
  if piscina and tipo_slug in residenciales + ["finca"]: url_fr_base += "/con-piscina"
91
  url_mc = f"https://www.metrocuadrado.com/{tipo_slug}/{op_slug}/{c_slug}/{b_slug}{filtros_mc}/?search=form"
92
-
93
  return url_fr_base, url_mc
94
 
95
  def extraer_precio(texto, operacion):
@@ -158,11 +153,10 @@ class PDF_SAE(FPDF):
158
 
159
 
160
  # ==========================================
161
- # FASE 1: BÚSQUEDA Y EXTRACCIÓN (TIEMPO REAL)
162
  # ==========================================
163
- def fase_1_buscar_testigos(operacion, barrio, ciudad, area, m2_min, m2_max, tipo, hab, ban, park, antiguedad, ascensor, piscina):
164
- yield " Bloqueando interfaz... Calculando rutas...", pd.DataFrame(), gr.update(visible=False), gr.update(interactive=False), [], [], gr.update(interactive=False)
165
-
166
  resultados = []; urls_vistas = set(); precios_inversos = []
167
 
168
  url_fr, url_mc = construir_urls_final(operacion, barrio, ciudad, tipo, hab, ban, park, antiguedad, m2_min, m2_max, ascensor, piscina)
@@ -170,8 +164,6 @@ def fase_1_buscar_testigos(operacion, barrio, ciudad, area, m2_min, m2_max, tipo
170
  url_inversa, _ = construir_urls_final(op_inversa, barrio, ciudad, tipo, hab, ban, park, antiguedad, m2_min, m2_max, ascensor, piscina)
171
 
172
  log_visible = f"✅ RUTAS GENERADAS:\n- FR: {url_fr}\n- MC: {url_mc}\n\n"
173
- yield f"{log_visible}🚀 Levantando robot invisible...", pd.DataFrame(), gr.update(visible=False), gr.update(interactive=False), [], [], gr.update(interactive=False)
174
-
175
  ua = UserAgent()
176
 
177
  with sync_playwright() as p:
@@ -179,7 +171,7 @@ def fase_1_buscar_testigos(operacion, barrio, ciudad, area, m2_min, m2_max, tipo
179
  context = browser.new_context(viewport={'width': 1366, 'height': 768}, user_agent=ua.random)
180
 
181
  # 1. Finca Raíz
182
- yield f"{log_visible}🏢 ESCANEANDO FINCA RAÍZ...\n(Buscando enlaces de inmuebles...)", pd.DataFrame(), gr.update(visible=False), gr.update(interactive=False), [], [], gr.update(interactive=False)
183
  try:
184
  page = context.new_page()
185
  page.goto(url_fr, wait_until="domcontentloaded", timeout=60000)
@@ -199,8 +191,7 @@ def fase_1_buscar_testigos(operacion, barrio, ciudad, area, m2_min, m2_max, tipo
199
  card = el.evaluate_handle("el => el.closest('article') || el.closest('[class*=\"card\"]') || el.parentElement.parentElement")
200
  if not card: continue
201
 
202
- txt = card.inner_text()
203
- precio = extraer_precio(txt, operacion)
204
  if precio > 0:
205
  img_url = ""
206
  img_el = card.query_selector("img")
@@ -208,15 +199,13 @@ def fase_1_buscar_testigos(operacion, barrio, ciudad, area, m2_min, m2_max, tipo
208
  if img_url and img_url.startswith("/"): img_url = "https://www.fincaraiz.com.co" + img_url
209
 
210
  resultados.append({"Portal": "Finca Raiz", "Precio": precio, "Precio_M2": precio / area, "Ubicacion": extraer_ubicacion(txt), "Descripcion": txt.replace('\n', ' | ')[:120] + "...", "URL": full_url, "Imagen": img_url})
211
- urls_vistas.add(full_url)
212
- cont_fr += 1
213
  except: continue
214
- page.close()
215
- log_visible += f" FR: {cont_fr} inmuebles extraídos.\n"
216
- except Exception as e: log_visible += f"⚠️ Error en FR: {e}\n"
217
 
218
  # 2. Metrocuadrado
219
- yield f"{log_visible}\n🏢 ESCANEANDO METROCUADRADO...", pd.DataFrame(), gr.update(visible=False), gr.update(interactive=False), [], [], gr.update(interactive=False)
220
  try:
221
  page = context.new_page()
222
  page.goto(url_mc, wait_until="domcontentloaded", timeout=60000)
@@ -236,8 +225,7 @@ def fase_1_buscar_testigos(operacion, barrio, ciudad, area, m2_min, m2_max, tipo
236
  card = el.evaluate_handle("el => el.closest('li') || el.closest('[class*=\"card\"]') || el.closest('[class*=\"property\"]') || el.parentElement.parentElement.parentElement")
237
  if not card: continue
238
 
239
- txt = card.inner_text()
240
- precio = extraer_precio(txt, operacion)
241
  if precio > 0:
242
  img_url = ""
243
  img_el = card.query_selector("img")
@@ -245,15 +233,13 @@ def fase_1_buscar_testigos(operacion, barrio, ciudad, area, m2_min, m2_max, tipo
245
  if img_url and img_url.startswith("/"): img_url = "https://www.metrocuadrado.com" + img_url
246
 
247
  resultados.append({"Portal": "Metrocuadrado", "Precio": precio, "Precio_M2": precio / area, "Ubicacion": extraer_ubicacion(txt), "Descripcion": txt.replace('\n', ' | ')[:120] + "...", "URL": full_url, "Imagen": img_url})
248
- urls_vistas.add(full_url)
249
- cont_mc += 1
250
  except: continue
251
- page.close()
252
- log_visible += f"✅ MC: {cont_mc} inmuebles extraídos.\n"
253
  except Exception as e: log_visible += f"⚠️ Error en MC.\n"
254
 
255
  # 3. Módulo Financiero
256
- yield f"{log_visible}\n📈 CALCULANDO CAP RATE (Buscando {op_inversa} silenciosamente)...", pd.DataFrame(), gr.update(visible=False), gr.update(interactive=False), [], [], gr.update(interactive=False)
257
  try:
258
  page = context.new_page(); page.goto(url_inversa, wait_until="domcontentloaded", timeout=45000)
259
  for _ in range(3): page.mouse.wheel(0, 1000); page.wait_for_timeout(1000)
@@ -266,54 +252,55 @@ def fase_1_buscar_testigos(operacion, barrio, ciudad, area, m2_min, m2_max, tipo
266
  if not es_inmueble_valido(href, "FR"): continue
267
  card = el.evaluate_handle("el => el.closest('article') || el.closest('[class*=\"card\"]') || el.parentElement.parentElement")
268
  if not card: continue
269
- txt = card.inner_text()
270
- precio = extraer_precio(txt, op_inversa)
271
  if precio > 0: precios_inversos.append(precio)
272
  except: continue
273
- page.close()
274
- log_visible += f"✅ Finanzas: Capturados {len(precios_inversos)} valores de {op_inversa}.\n"
275
  except Exception as ex: pass
276
-
277
  browser.close()
278
 
 
 
279
  if not resultados:
280
- yield f"{log_visible}\n❌ NO HAY DATOS. Intenta ampliar los filtros.", pd.DataFrame(), gr.update(choices=[], value=[], visible=False), gr.update(interactive=False), [], [], gr.update(interactive=True)
281
- return
282
 
283
- # --- PREPARAR LOS CHECKBOXES (AHORA CON LA URL VISIBLE) ---
284
  opciones_check = []
285
  for idx, r in enumerate(resultados):
286
- # Aquí agregamos el link para que sea totalmente visible a la hora de seleccionar
287
- etiqueta = f"{idx+1}. [{r['Portal']}] ${r['Precio']:,.0f} - {r['Ubicacion'][:35]} 👉 {r['URL']}"
288
  r['etiqueta_ui'] = etiqueta
289
  opciones_check.append(etiqueta)
290
 
291
- # --- PREPARACIÓN DE TABLA INTERACTIVA EN FORMATO MARKDOWN ---
292
  df_mostrar = pd.DataFrame(resultados)[['Portal', 'Precio', 'Ubicacion', 'URL']].copy()
 
 
293
  df_mostrar['Precio'] = df_mostrar['Precio'].apply(lambda x: f"${x:,.0f}")
294
- # Convertimos la URL en un enlace clickeable para la tabla visual
295
- df_mostrar['URL'] = df_mostrar['URL'].apply(lambda x: f"[🌐 Clic para abrir el Inmueble]({x})")
296
- df_mostrar.rename(columns={'URL': 'Enlace'}, inplace=True)
297
 
298
- log_visible += "\n🛑 EXTRACCIÓN FINALIZADA.\n👉 Selecciona los testigos válidos abajo y haz clic en 'Generar Dictamen'."
 
 
299
 
300
- # Desbloqueamos botones
301
- yield log_visible, df_mostrar, gr.update(choices=opciones_check, value=opciones_check, visible=True), gr.update(interactive=True), resultados, precios_inversos, gr.update(interactive=True)
302
 
303
  # ==========================================
304
- # FASE 2: CÁLCULO Y GENERACIÓN DE PDF
305
  # ==========================================
306
- def fase_2_generar_dictamen(seleccionados_ui, data_cruda, precios_inversos, operacion, barrio, zona_especifica, ciudad, area, tipo, hab, ban, park):
307
- yield None, "⏳ Procesando curaduría y estadísticas...", "<p>Cargando mapa...</p>", gr.update(interactive=False)
308
-
309
  if not seleccionados_ui:
310
- yield None, "❌ ERROR: Selecciona al menos 1 testigo.", "<p>Error</p>", gr.update(interactive=True)
311
- return
312
 
 
313
  resultados_filtrados = [r for r in data_cruda if r['etiqueta_ui'] in seleccionados_ui]
 
 
 
 
314
  df_final = pd.DataFrame(resultados_filtrados)
315
  df_final['V_Homogeneizado'] = df_final['Precio'] * 0.92
316
 
 
317
  mediana_m2 = df_final['Precio_M2'].median()
318
  minimo_zona = df_final['Precio'].min()
319
  maximo_zona = df_final['Precio'].max()
@@ -335,10 +322,10 @@ def fase_2_generar_dictamen(seleccionados_ui, data_cruda, precios_inversos, oper
335
  cap_rate_txt = f"Canon Arriendo Promedio: ${mediana_inversa:,.0f} COP\nRentabilidad Bruta Anual (Cap Rate): {rentabilidad:.2f}%"
336
  except: pass
337
 
338
- yield None, "🗺️ Dibujando mapa interactivo...", "<p>Dibujando mapa...</p>", gr.update(interactive=False)
339
  mapa_html, lat_mapa, lon_mapa = generar_mapa(barrio, ciudad)
340
 
341
- yield None, "📄 Creando PDF de dictamen pericial...", mapa_html, gr.update(interactive=False)
342
  preparar_entorno_pdf()
343
  pdf_path = f"Dictamen_Pericial_SAE_{int(time.time())}.pdf"
344
  pdf = PDF_SAE()
@@ -412,13 +399,14 @@ def fase_2_generar_dictamen(seleccionados_ui, data_cruda, precios_inversos, oper
412
  pdf.set_x(text_x); pdf.set_font("Arial", '', 8); pdf.set_text_color(*COLOR_NEGRO)
413
  pdf.multi_cell(0, 4, sanear_texto(r['Descripcion']))
414
  pdf.set_x(text_x); pdf.set_font("Arial", '', 8); pdf.set_text_color(0, 102, 204)
415
- pdf.cell(0, 4, f">> Ver testigo en {sanear_texto(r['Portal'])}", link=r['URL'], ln=True)
416
  pdf.set_text_color(*COLOR_NEGRO); y_end = pdf.get_y(); pdf.set_y(max(y_start + 35, y_end + 5))
417
  pdf.set_draw_color(200, 200, 200); pdf.line(10, pdf.get_y(), 200, pdf.get_y()); pdf.ln(4)
418
  if img_path and os.path.exists(img_path):
419
  try: os.remove(img_path)
420
  except: pass
421
 
 
422
  pdf.output(pdf_path)
423
 
424
  resumen = (
@@ -429,7 +417,7 @@ def fase_2_generar_dictamen(seleccionados_ui, data_cruda, precios_inversos, oper
429
  f"📊 **Análisis de Inversión:**\n{cap_rate_txt}"
430
  )
431
 
432
- yield pdf_path, resumen, mapa_html, gr.update(interactive=True)
433
 
434
  def adaptar_interfaz(tipo):
435
  if tipo in ["Lote", "Finca"]: return [gr.update(visible=False)] * 6
@@ -441,7 +429,7 @@ with gr.Blocks(theme=gr.themes.Soft()) as demo:
441
  estado_datos_crudos = gr.State([])
442
  estado_datos_financieros = gr.State([])
443
 
444
- gr.Markdown("## 🏢 TramitIA Pro: Dictamen Pericial (Extracción en Tiempo Real)")
445
 
446
  with gr.Row():
447
  with gr.Column(scale=1):
@@ -467,27 +455,29 @@ with gr.Blocks(theme=gr.themes.Soft()) as demo:
467
 
468
  gr.Markdown("---")
469
  gr.Markdown("### 📋 CURADURÍA (Selecciona los válidos)")
 
470
  selector_testigos = gr.CheckboxGroup(label="Testigos encontrados", choices=[], visible=False)
471
  btn_generar = gr.Button("📄 2. GENERAR DICTAMEN", variant="secondary", interactive=False)
472
 
473
  with gr.Column(scale=2):
474
  res_fin = gr.Markdown("### 💰 Resultado, Progreso y Rentabilidad...")
475
  with gr.Tabs():
476
- with gr.TabItem("Exploración Bruta (Links)"): out_df = gr.Dataframe(datatype=["str", "str", "str", "markdown"])
477
- with gr.TabItem("Log en Tiempo Real"): msg = gr.Textbox(lines=12, label="Progreso del Robot")
 
478
  with gr.TabItem("Descargar Dictamen (PDF)"): out_pdf = gr.File()
479
  with gr.TabItem("Mapa Geoespacial"): mapa_ui = gr.HTML("<p style='text-align:center; color:gray;'>El mapa aparecerá aquí...</p>")
480
 
481
  btn_buscar.click(
482
  fase_1_buscar_testigos,
483
  inputs=[op, b, c, a, m2_min, m2_max, t, h, ban, p, e, ascensor, piscina],
484
- outputs=[msg, out_df, selector_testigos, btn_generar, estado_datos_crudos, estado_datos_financieros, btn_buscar]
485
  )
486
 
487
  btn_generar.click(
488
  fase_2_generar_dictamen,
489
  inputs=[selector_testigos, estado_datos_crudos, estado_datos_financieros, op, b, z, c, a, t, h, ban, p],
490
- outputs=[out_pdf, res_fin, mapa_ui, btn_generar]
491
  )
492
 
493
  demo.launch(theme=gr.themes.Soft())
 
28
  subprocess.run(["pip", "install", "fake-useragent"], check=True)
29
  from fake_useragent import UserAgent
30
 
 
31
  URL_LOGO_GOBIERNO = "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c3/Escudo_de_Colombia.svg/250px-Escudo_de_Colombia.svg.png"
32
 
33
  def descargar_recurso(url, nombre_archivo):
 
78
  if tipo_slug == "local": tipo_fr = "locales"
79
  elif tipo_slug == "edificio": tipo_fr = "edificios"
80
  else: tipo_fr = tipo_slug + "s"
81
+ if tipo_slug in ["lote", "finca"]: filtros_fr = ""; filtros_mc = ""
82
+ else: filtros_fr = f"/{slug_park}/{slug_ant}"; filtros_mc = ""
 
 
 
83
 
84
  url_fr_base = f"https://www.fincaraiz.com.co/{op_slug}/{tipo_fr}/{b_slug}/{c_slug}{filtros_fr}/m2-desde-{int(m2_min)}/m2-hasta-{int(m2_max)}"
85
  if ascensor and tipo_slug not in ["lote", "finca"]: url_fr_base += "/con-ascensor"
86
  if piscina and tipo_slug in residenciales + ["finca"]: url_fr_base += "/con-piscina"
87
  url_mc = f"https://www.metrocuadrado.com/{tipo_slug}/{op_slug}/{c_slug}/{b_slug}{filtros_mc}/?search=form"
 
88
  return url_fr_base, url_mc
89
 
90
  def extraer_precio(texto, operacion):
 
153
 
154
 
155
  # ==========================================
156
+ # FASE 1: BÚSQUEDA (CON PROGRESO NATIVO)
157
  # ==========================================
158
+ def fase_1_buscar_testigos(operacion, barrio, ciudad, area, m2_min, m2_max, tipo, hab, ban, park, antiguedad, ascensor, piscina, progress=gr.Progress()):
159
+ progress(0.1, desc="Generando rutas y conectando al navegador...")
 
160
  resultados = []; urls_vistas = set(); precios_inversos = []
161
 
162
  url_fr, url_mc = construir_urls_final(operacion, barrio, ciudad, tipo, hab, ban, park, antiguedad, m2_min, m2_max, ascensor, piscina)
 
164
  url_inversa, _ = construir_urls_final(op_inversa, barrio, ciudad, tipo, hab, ban, park, antiguedad, m2_min, m2_max, ascensor, piscina)
165
 
166
  log_visible = f"✅ RUTAS GENERADAS:\n- FR: {url_fr}\n- MC: {url_mc}\n\n"
 
 
167
  ua = UserAgent()
168
 
169
  with sync_playwright() as p:
 
171
  context = browser.new_context(viewport={'width': 1366, 'height': 768}, user_agent=ua.random)
172
 
173
  # 1. Finca Raíz
174
+ progress(0.3, desc="🏢 Escaneando Finca Raíz...")
175
  try:
176
  page = context.new_page()
177
  page.goto(url_fr, wait_until="domcontentloaded", timeout=60000)
 
191
  card = el.evaluate_handle("el => el.closest('article') || el.closest('[class*=\"card\"]') || el.parentElement.parentElement")
192
  if not card: continue
193
 
194
+ txt = card.inner_text(); precio = extraer_precio(txt, operacion)
 
195
  if precio > 0:
196
  img_url = ""
197
  img_el = card.query_selector("img")
 
199
  if img_url and img_url.startswith("/"): img_url = "https://www.fincaraiz.com.co" + img_url
200
 
201
  resultados.append({"Portal": "Finca Raiz", "Precio": precio, "Precio_M2": precio / area, "Ubicacion": extraer_ubicacion(txt), "Descripcion": txt.replace('\n', ' | ')[:120] + "...", "URL": full_url, "Imagen": img_url})
202
+ urls_vistas.add(full_url); cont_fr += 1
 
203
  except: continue
204
+ page.close(); log_visible += f"✅ FR: {cont_fr} inmuebles extraídos.\n"
205
+ except Exception as e: log_visible += f"⚠️ Error en FR.\n"
 
206
 
207
  # 2. Metrocuadrado
208
+ progress(0.6, desc="🏢 Escaneando Metrocuadrado...")
209
  try:
210
  page = context.new_page()
211
  page.goto(url_mc, wait_until="domcontentloaded", timeout=60000)
 
225
  card = el.evaluate_handle("el => el.closest('li') || el.closest('[class*=\"card\"]') || el.closest('[class*=\"property\"]') || el.parentElement.parentElement.parentElement")
226
  if not card: continue
227
 
228
+ txt = card.inner_text(); precio = extraer_precio(txt, operacion)
 
229
  if precio > 0:
230
  img_url = ""
231
  img_el = card.query_selector("img")
 
233
  if img_url and img_url.startswith("/"): img_url = "https://www.metrocuadrado.com" + img_url
234
 
235
  resultados.append({"Portal": "Metrocuadrado", "Precio": precio, "Precio_M2": precio / area, "Ubicacion": extraer_ubicacion(txt), "Descripcion": txt.replace('\n', ' | ')[:120] + "...", "URL": full_url, "Imagen": img_url})
236
+ urls_vistas.add(full_url); cont_mc += 1
 
237
  except: continue
238
+ page.close(); log_visible += f"✅ MC: {cont_mc} inmuebles extraídos.\n"
 
239
  except Exception as e: log_visible += f"⚠️ Error en MC.\n"
240
 
241
  # 3. Módulo Financiero
242
+ progress(0.8, desc="📈 Calculando Cap Rate (Búsqueda Inversa)...")
243
  try:
244
  page = context.new_page(); page.goto(url_inversa, wait_until="domcontentloaded", timeout=45000)
245
  for _ in range(3): page.mouse.wheel(0, 1000); page.wait_for_timeout(1000)
 
252
  if not es_inmueble_valido(href, "FR"): continue
253
  card = el.evaluate_handle("el => el.closest('article') || el.closest('[class*=\"card\"]') || el.parentElement.parentElement")
254
  if not card: continue
255
+ txt = card.inner_text(); precio = extraer_precio(txt, op_inversa)
 
256
  if precio > 0: precios_inversos.append(precio)
257
  except: continue
258
+ page.close(); log_visible += f"✅ Finanzas: Capturados {len(precios_inversos)} valores de {op_inversa}.\n"
 
259
  except Exception as ex: pass
 
260
  browser.close()
261
 
262
+ progress(1.0, desc="¡Búsqueda Finalizada!")
263
+
264
  if not resultados:
265
+ return log_visible, pd.DataFrame(), gr.update(choices=[], value=[], visible=False), gr.update(interactive=False), [], []
 
266
 
267
+ # --- TEXTOS LIMPIOS PARA LAS CASILLAS (EVITA BUGS DE GRADIO) ---
268
  opciones_check = []
269
  for idx, r in enumerate(resultados):
270
+ etiqueta = f"ID {idx+1} | {r['Portal'][:2]} | ${r['Precio']:,.0f} | {r['Ubicacion'][:25]}"
 
271
  r['etiqueta_ui'] = etiqueta
272
  opciones_check.append(etiqueta)
273
 
274
+ # --- PREPARACIÓN DE TABLA INTERACTIVA CON LINKS CLICKEABLES ---
275
  df_mostrar = pd.DataFrame(resultados)[['Portal', 'Precio', 'Ubicacion', 'URL']].copy()
276
+ # Insertamos la columna ID para que el usuario sepa cuál desmarcar
277
+ df_mostrar.insert(0, 'ID_Testigo', [f"ID {i+1}" for i in range(len(resultados))])
278
  df_mostrar['Precio'] = df_mostrar['Precio'].apply(lambda x: f"${x:,.0f}")
279
+ df_mostrar['URL'] = df_mostrar['URL'].apply(lambda x: f"[🌐 CLIC PARA VER INMUEBLE]({x})")
280
+ df_mostrar.rename(columns={'URL': 'Enlace Origial'}, inplace=True)
 
281
 
282
+ log_visible += "\n🛑 EXTRACCIÓN FINALIZADA.\n👉 Revisa la tabla de enlaces. Luego desmarca los IDs inválidos en la lista izquierda y haz clic en 'Generar Dictamen'."
283
+
284
+ return log_visible, df_mostrar, gr.update(choices=opciones_check, value=opciones_check, visible=True), gr.update(interactive=True), resultados, precios_inversos
285
 
 
 
286
 
287
  # ==========================================
288
+ # FASE 2: CÁLCULO Y GENERACIÓN (CON PROGRESO NATIVO)
289
  # ==========================================
290
+ def fase_2_generar_dictamen(seleccionados_ui, data_cruda, precios_inversos, operacion, barrio, zona_especifica, ciudad, area, tipo, hab, ban, park, progress=gr.Progress()):
 
 
291
  if not seleccionados_ui:
292
+ return None, "❌ ERROR: No dejaste seleccionado ningún testigo. Selecciona al menos 1.", "<p>Error</p>"
 
293
 
294
+ progress(0.1, desc="Filtrando testigos seleccionados...")
295
  resultados_filtrados = [r for r in data_cruda if r['etiqueta_ui'] in seleccionados_ui]
296
+
297
+ if len(resultados_filtrados) == 0:
298
+ return None, "❌ ERROR: La lista de testigos válidos está vacía.", "<p>Error</p>"
299
+
300
  df_final = pd.DataFrame(resultados_filtrados)
301
  df_final['V_Homogeneizado'] = df_final['Precio'] * 0.92
302
 
303
+ progress(0.4, desc="Ejecutando cálculos estadísticos SAE...")
304
  mediana_m2 = df_final['Precio_M2'].median()
305
  minimo_zona = df_final['Precio'].min()
306
  maximo_zona = df_final['Precio'].max()
 
322
  cap_rate_txt = f"Canon Arriendo Promedio: ${mediana_inversa:,.0f} COP\nRentabilidad Bruta Anual (Cap Rate): {rentabilidad:.2f}%"
323
  except: pass
324
 
325
+ progress(0.6, desc="Dibujando mapa geoespacial...")
326
  mapa_html, lat_mapa, lon_mapa = generar_mapa(barrio, ciudad)
327
 
328
+ progress(0.8, desc="Maquetando documento PDF...")
329
  preparar_entorno_pdf()
330
  pdf_path = f"Dictamen_Pericial_SAE_{int(time.time())}.pdf"
331
  pdf = PDF_SAE()
 
399
  pdf.set_x(text_x); pdf.set_font("Arial", '', 8); pdf.set_text_color(*COLOR_NEGRO)
400
  pdf.multi_cell(0, 4, sanear_texto(r['Descripcion']))
401
  pdf.set_x(text_x); pdf.set_font("Arial", '', 8); pdf.set_text_color(0, 102, 204)
402
+ pdf.cell(0, 4, f">> Ver testigo original online", link=r['URL'], ln=True)
403
  pdf.set_text_color(*COLOR_NEGRO); y_end = pdf.get_y(); pdf.set_y(max(y_start + 35, y_end + 5))
404
  pdf.set_draw_color(200, 200, 200); pdf.line(10, pdf.get_y(), 200, pdf.get_y()); pdf.ln(4)
405
  if img_path and os.path.exists(img_path):
406
  try: os.remove(img_path)
407
  except: pass
408
 
409
+ progress(1.0, desc="¡Dictamen Finalizado!")
410
  pdf.output(pdf_path)
411
 
412
  resumen = (
 
417
  f"📊 **Análisis de Inversión:**\n{cap_rate_txt}"
418
  )
419
 
420
+ return pdf_path, resumen, mapa_html
421
 
422
  def adaptar_interfaz(tipo):
423
  if tipo in ["Lote", "Finca"]: return [gr.update(visible=False)] * 6
 
429
  estado_datos_crudos = gr.State([])
430
  estado_datos_financieros = gr.State([])
431
 
432
+ gr.Markdown("## 🏢 TramitIA Pro: Dictamen Pericial (Extracción Curada)")
433
 
434
  with gr.Row():
435
  with gr.Column(scale=1):
 
455
 
456
  gr.Markdown("---")
457
  gr.Markdown("### 📋 CURADURÍA (Selecciona los válidos)")
458
+ # La caja de Checkboxes usará textos seguros
459
  selector_testigos = gr.CheckboxGroup(label="Testigos encontrados", choices=[], visible=False)
460
  btn_generar = gr.Button("📄 2. GENERAR DICTAMEN", variant="secondary", interactive=False)
461
 
462
  with gr.Column(scale=2):
463
  res_fin = gr.Markdown("### 💰 Resultado, Progreso y Rentabilidad...")
464
  with gr.Tabs():
465
+ # Dataframe optimizado para mostrar Markdown clickeables
466
+ with gr.TabItem("Exploración Bruta (Links)"): out_df = gr.Dataframe(datatype=["str", "str", "str", "str", "markdown"])
467
+ with gr.TabItem("Log del Sistema"): msg = gr.Textbox(lines=12, label="Progreso del Robot")
468
  with gr.TabItem("Descargar Dictamen (PDF)"): out_pdf = gr.File()
469
  with gr.TabItem("Mapa Geoespacial"): mapa_ui = gr.HTML("<p style='text-align:center; color:gray;'>El mapa aparecerá aquí...</p>")
470
 
471
  btn_buscar.click(
472
  fase_1_buscar_testigos,
473
  inputs=[op, b, c, a, m2_min, m2_max, t, h, ban, p, e, ascensor, piscina],
474
+ outputs=[msg, out_df, selector_testigos, btn_generar, estado_datos_crudos, estado_datos_financieros]
475
  )
476
 
477
  btn_generar.click(
478
  fase_2_generar_dictamen,
479
  inputs=[selector_testigos, estado_datos_crudos, estado_datos_financieros, op, b, z, c, a, t, h, ban, p],
480
+ outputs=[out_pdf, res_fin, mapa_ui]
481
  )
482
 
483
  demo.launch(theme=gr.themes.Soft())