ConectaODSco commited on
Commit
ce27bb3
·
verified ·
1 Parent(s): f108c6a

Update src/embeddings/llm_clasificador_HF_gem.py

Browse files
src/embeddings/llm_clasificador_HF_gem.py CHANGED
@@ -1,869 +1,870 @@
1
- # =============================================================================
2
- # CELDA 1: Instalar dependencias
3
- # =============================================================================
4
- # !pip install huggingface_hub pandas openpyxl
5
-
6
- # =============================================================================
7
- # CELDA 2: Configuración Gemini
8
- # =============================================================================
9
- from google import genai
10
- from google.genai import types
11
- import pandas as pd
12
- import re
13
- import time
14
-
15
- # Obtén tu API key en: https://aistudio.google.com/app/apikey
16
-
17
-
18
- client = genai.Client(api_key=GOOGLE_API_KEY)
19
-
20
- # Modelos gratuitos disponibles (de más potente a más ligero)
21
- MODELOS = [
22
- "gemini-2.5-flash-lite", # Más económico, buena opción para pruebas rápidas
23
- "gemini-2.5-flash", # Más reciente, muy bueno
24
- "gemini-2.0-flash", # Estable
25
- "gemini-2.0-flash-lite", # Más económico
26
- ]
27
-
28
- # =============================================================================
29
- # CELDA 3: Verificar modelos disponibles
30
- # =============================================================================
31
- def verificar_modelos():
32
- """Lista modelos disponibles y verifica cuál funciona."""
33
- print("Verificando modelos disponibles...\n")
34
-
35
- for modelo in MODELOS:
36
- try:
37
- response = client.models.generate_content(
38
- model=modelo,
39
- contents="Responde únicamente: OK"
40
- )
41
- print(f"✅ {modelo}: Disponible")
42
- return modelo
43
- except Exception as e:
44
- print(f"❌ {modelo}: {str(e)[:60]}")
45
-
46
- return None
47
-
48
- MODELO_ACTIVO = verificar_modelos()
49
-
50
- if MODELO_ACTIVO:
51
- print(f"\n🎯 Modelo seleccionado: {MODELO_ACTIVO}")
52
- else:
53
- print("\n⚠️ Ningún modelo disponible. Verifica tu API key.")
54
-
55
- # =============================================================================
56
- # CELDA 4: Prompts por nivel
57
- # =============================================================================
58
- PROMPT_ODS = """Eres un experto en Objetivos de Desarrollo Sostenible (ODS) de las Naciones Unidas.
59
-
60
- Vincula entre 4 y 5 ODS que tengan relación directa con la siguiente iniciativa:
61
-
62
- "{iniciativa}"
63
-
64
- Para cada ODS incluye:
65
- - Número y nombre del ODS
66
- - Justificación breve del vínculo (impacto social, territorial, de paz, infraestructura o inclusión)
67
-
68
- FORMATO DE RESPUESTA (una línea por ODS, ordenados por relevancia):
69
- ODS [número]: [nombre] [justificación]
70
-
71
- Responde ÚNICAMENTE con el listado, sin texto introductorio ni conclusiones."""
72
-
73
-
74
- PROMPT_META = """Eres un experto en Objetivos de Desarrollo Sostenible (ODS) de las Naciones Unidas.
75
-
76
- Identifica entre 4 y 6 Metas específicas de los ODS que tengan relación directa con la siguiente iniciativa:
77
-
78
- "{iniciativa}"
79
-
80
- Para cada meta incluye:
81
- - Código de la meta (dos niveles estrictos ej: 4.2, 1.3, 16.10)
82
- - Descripción de la meta
83
- - Justificación breve del vínculo con la iniciativa
84
-
85
- FORMATO DE RESPUESTA (una línea por meta, ordenadas por relevancia):
86
- Meta [código]: [descripción] [justificación]
87
-
88
- Responde ÚNICAMENTE con el listado, sin texto introductorio ni conclusiones."""
89
-
90
- PROMPT_META_TOP = """Eres un experto en Objetivos de Desarrollo Sostenible (ODS) de las Naciones Unidas.
91
-
92
- Identifica 2 Metas específicas del ODS {ods} que tengan relación directa con la siguiente iniciativa:
93
-
94
- "{iniciativa}"
95
-
96
- Para cada meta incluye:
97
- - Código de la meta (estructura de dos niveles estrictos ej: 4.2, 1.3, 16.10)
98
- - Descripción de la meta
99
- - Justificación breve del vínculo con la iniciativa
100
-
101
- FORMATO DE RESPUESTA (una línea por meta, ordenadas por relevancia):
102
- Meta [código]: [descripción] [justificación]
103
-
104
- Responde ÚNICAMENTE con el listado, sin texto introductorio ni conclusiones."""
105
-
106
-
107
- PROMPT_INDICADOR = """Eres un experto en Objetivos de Desarrollo Sostenible (ODS) de las Naciones Unidas.
108
-
109
- Identifica entre 3 y 5 Indicadores de los ODS que permitan medir el impacto de la siguiente iniciativa:
110
-
111
- "{iniciativa}"
112
-
113
- Para cada indicador incluye:
114
- - Código del indicador (ej: 4.2.1, 1.3.1, 16.10.1)
115
- - Descripción del indicador
116
- - Justificación de cómo se relaciona con la medición de la iniciativa
117
-
118
- FORMATO DE RESPUESTA (una línea por indicador, ordenados por relevancia):
119
- Indicador [código]: [descripción] [justificación]
120
-
121
- Responde ÚNICAMENTE con el listado, sin texto introductorio ni conclusiones."""
122
-
123
- PROMPTS = {
124
- "ods": PROMPT_ODS,
125
- "meta": PROMPT_META,
126
- "meta_top": PROMPT_META_TOP,
127
- "indicador": PROMPT_INDICADOR
128
- }
129
-
130
- # =============================================================================
131
- # CELDA 5: Funciones de parsing por nivel
132
- # =============================================================================
133
- def parsear_ods(texto: str) -> list:
134
- """Extrae ODS de la respuesta."""
135
- items = []
136
- for linea in texto.strip().split("\n"):
137
- linea = linea.strip()
138
- if not linea:
139
- continue
140
- match = re.match(r"(?:\d+[\.\)\-]?\s*)?ODS\s*(\d+):?\s*(.+)", linea, re.IGNORECASE)
141
- if match:
142
- ods_num = match.group(1)
143
- resto = match.group(2).strip()
144
- items.append({
145
- "ods_id": f"{ods_num}",
146
- "objetivo": resto
147
- })
148
- return items
149
-
150
-
151
- def parsear_metas(texto: str) -> list:
152
- """Extrae metas de la respuesta."""
153
- items = []
154
- for linea in texto.strip().split("\n"):
155
- linea = linea.strip()
156
- if not linea:
157
- continue
158
- match = re.match(r"(?:\d+[\.\)\-]?\s*)?Meta\s*([\d\.]+):?\s*(.+)", linea, re.IGNORECASE)
159
- if match:
160
- meta_codigo = match.group(1)
161
- meta_codigo = f'{meta_codigo.split(".")[0]}.{meta_codigo.split(".")[1]}'
162
- resto = match.group(2).strip()
163
- ods_num = meta_codigo.split(".")[0]
164
- items.append({
165
- "ods_id": f"{ods_num}",
166
- "meta_id": f"{meta_codigo}",
167
- "objetivo": resto
168
- })
169
- return items
170
-
171
-
172
- def parsear_indicadores(texto: str) -> list:
173
- """Extrae indicadores de la respuesta."""
174
- items = []
175
- for linea in texto.strip().split("\n"):
176
- linea = linea.strip()
177
- if not linea:
178
- continue
179
- match = re.match(r"(?:\d+[\.\)\-]?\s*)?Indicador\s*([\d\.]+):?\s*(.+)", linea, re.IGNORECASE)
180
- if match:
181
- ind_codigo = match.group(1)
182
- resto = match.group(2).strip()
183
- ods_num = ind_codigo.split(".")[0]
184
- items.append({
185
- "ods_id": f"{ods_num}",
186
- "indicador_id": f"{ind_codigo}",
187
- "objetivo": resto
188
- })
189
- return items
190
-
191
-
192
- PARSERS = {
193
- "ods": parsear_ods,
194
- "meta": parsear_metas,
195
- "indicador": parsear_indicadores
196
- }
197
-
198
- # =============================================================================
199
- # CELDA 6: Función de clasificación con fallback
200
- # =============================================================================
201
- # def llamar_modelo(prompt: str, intentos_maximos: int = 3) -> str:
202
- # """Llama al modelo con reintentos y fallback."""
203
- # for modelo in MODELOS:
204
- # for intento in range(intentos_maximos):
205
- # try:
206
- # respuesta = client.chat_completion(
207
- # model=modelo,
208
- # messages=[{"role": "user", "content": prompt}],
209
- # max_tokens=1200,
210
- # temperature=0.3
211
- # )
212
- # return respuesta.choices[0].message.content
213
- # except Exception as e:
214
- # error_msg = str(e).lower()
215
- # # Si es rate limit, esperar y reintentar
216
- # if "rate" in error_msg or "limit" in error_msg or "429" in error_msg:
217
- # wait_time = (intento + 1) * 5
218
- # print(f" ⏳ Rate limit, esperando {wait_time}s...")
219
- # time.sleep(wait_time)
220
- # continue
221
- # # Si es error de modelo, pasar al siguiente
222
- # elif "not supported" in error_msg or "not available" in error_msg:
223
- # break
224
- # # Otros errores, reintentar
225
- # else:
226
- # time.sleep(2)
227
- # continue
228
-
229
- # raise Exception("Todos los modelos fallaron")
230
-
231
- def llamar_modelo(prompt: str, intentos_maximos: int = 3) -> str:
232
- """Llama a Gemini con reintentos."""
233
- for intento in range(intentos_maximos):
234
- try:
235
- response = client.models.generate_content(
236
- model=MODELO_ACTIVO,
237
- contents=prompt,
238
- config=types.GenerateContentConfig(
239
- max_output_tokens=1200,
240
- temperature=0.3
241
- )
242
- )
243
- return response.text
244
- except Exception as e:
245
- error_msg = str(e).lower()
246
- if "quota" in error_msg or "rate" in error_msg or "429" in error_msg:
247
- wait_time = (intento + 1) * 10
248
- print(f" ⏳ Rate limit, esperando {wait_time}s...")
249
- time.sleep(wait_time)
250
- else:
251
- raise e
252
-
253
- raise Exception("Máximo de intentos alcanzado")
254
-
255
-
256
- def clasificar_nivel(iniciativa: str, nivel: str) -> dict:
257
- """Clasifica una iniciativa en un nivel específico (ods, meta, indicador)."""
258
- prompt = PROMPTS[nivel].format(iniciativa=iniciativa)
259
- parser = PARSERS[nivel]
260
-
261
- try:
262
- texto = llamar_modelo(prompt)
263
- items = parser(texto)
264
-
265
- return {
266
- "iniciativa": iniciativa,
267
- "items": items,
268
- "respuesta_raw": texto,
269
- "error": None
270
- }
271
- except Exception as e:
272
- return {
273
- "iniciativa": iniciativa,
274
- "items": [],
275
- "respuesta_raw": "",
276
- "error": str(e)
277
- }
278
-
279
- def clasificar_nivel_meta_top(iniciativa: str, ods: int, nivel: str) -> dict:
280
- """Clasifica una iniciativa en metas específicas del ODS proporcionado."""
281
- # Inicializar HF la primera vez que se usa
282
- inicializar_hf()
283
-
284
- prompt = PROMPTS[nivel].format(ods=ods, iniciativa=iniciativa)
285
- parser = PARSERS[nivel]
286
-
287
- try:
288
- texto = llamar_modelo(prompt)
289
- items = parser(texto)
290
-
291
- return {
292
- "iniciativa": iniciativa,
293
- "ods": ods,
294
- "items": items,
295
- "respuesta_raw": texto,
296
- "error": None
297
- }
298
- except Exception as e:
299
- return {
300
- "iniciativa": iniciativa,
301
- "ods": ods,
302
- "items": [],
303
- "respuesta_raw": "",
304
- "error": str(e)
305
- }
306
-
307
- from sklearn.preprocessing import MinMaxScaler
308
-
309
-
310
- def normalizar_rank_df(dfs: dict) -> dict:
311
- """
312
- Normaliza las columnas de ranking ('ods_rank', 'meta_rank', 'indicador_rank')
313
- en los DataFrames proporcionados en un diccionario usando Min-Max scaling.
314
- Añade una nueva columna '_normalized' para el rank normalizado.
315
-
316
- Maneja casos especiales:
317
- - DataFrames vacíos
318
- - Valores faltantes
319
- - Un único valor único
320
- """
321
- # Ajustar el rango de normalización para evitar el 0
322
- scaler = MinMaxScaler(feature_range=(0.0, 0.99))
323
- df_actualizados = {}
324
-
325
- for df_name, df in dfs.items():
326
- if df.empty:
327
- print(f"⚠️ {df_name} está vacío")
328
- df_actualizados[df_name] = df
329
- continue
330
-
331
- df_temp = df.copy()
332
- rank_column = None
333
-
334
- # Identificar la columna de rank
335
- if 'ods_rank' in df_temp.columns:
336
- rank_column = 'ods_rank'
337
- elif 'meta_rank' in df_temp.columns:
338
- rank_column = 'meta_rank'
339
- elif 'indicador_rank' in df_temp.columns:
340
- rank_column = 'indicador_rank'
341
-
342
- if rank_column:
343
- print(f"✅ Normalizando {df_name} usando {rank_column}")
344
-
345
- try:
346
- # Asegurarse de que el rank_column es numérico
347
- df_temp[rank_column] = pd.to_numeric(df_temp[rank_column], errors='coerce')
348
-
349
- # Contar valores no nulos antes de dropna
350
- valores_validos = df_temp[rank_column].notna().sum()
351
-
352
- if valores_validos == 0:
353
- print(f"⚠️ {df_name}: Todos los valores de {rank_column} son NaN")
354
- # Crear columna de similaridad con valores por defecto
355
- nivel_name = rank_column.replace('_rank', '')
356
- df_temp[f"{nivel_name}_similaridad_cos_normalized"] = 0.5
357
- else:
358
- # Eliminar solo los NaN, no resetear el índice aún
359
- df_temp = df_temp.dropna(subset=[rank_column])
360
-
361
- if df_temp.empty:
362
- print(f"⚠️ {df_name}: Vacío después de eliminar NaN")
363
- continue
364
-
365
- nivel_name = rank_column.replace('_rank', '')
366
-
367
- # Normalizar según el número de valores únicos
368
- if df_temp[rank_column].nunique() > 1:
369
- df_temp[f"{nivel_name}_similaridad_cos_normalized"] = scaler.fit_transform(df_temp[[rank_column]])
370
- df_temp[f"{nivel_name}_similaridad_cos_normalized"] = 1 - df_temp[f"{nivel_name}_similaridad_cos_normalized"]
371
- elif df_temp[rank_column].nunique() == 1:
372
- # Un único valor: asignar 0.5
373
- df_temp[f"{nivel_name}_similaridad_cos_normalized"] = 0.5
374
- else:
375
- # Sin valores válidos
376
- df_temp[f"{nivel_name}_similaridad_cos_normalized"] = 0.5
377
-
378
- except Exception as e:
379
- print(f"❌ Error normalizando {df_name}: {str(e)}")
380
- # Crear columna con valor por defecto
381
- nivel_name = rank_column.replace('_rank', '')
382
- df_temp[f"{nivel_name}_similaridad_cos_normalized"] = 0.5
383
-
384
- df_actualizados[df_name] = df_temp
385
-
386
- return df_actualizados
387
-
388
-
389
- def score_creator(df_pareto, nivel, sim_prop = 0, rank_prop = 0.6):
390
- """
391
- Crea scores para ranking de ODS/Metas/Indicadores.
392
- Maneja DataFrames vacíos y columnas faltantes de forma robusta.
393
- """
394
- # Verificar si el DataFrame está vacío
395
- if df_pareto.empty:
396
- print(f"⚠️ DataFrame vacío para nivel: {nivel}")
397
- return df_pareto
398
-
399
- # Construir nombres de columnas esperadas
400
- id_column = f'{nivel.upper()}_ID'
401
- sim_column = f'{nivel}_similaridad_cos_normalized'
402
- rank_column = f'{nivel}_rank'
403
-
404
- # Verificar que las columnas necesarias existan
405
- columnas_faltantes = []
406
- for col in [id_column, sim_column, rank_column, 'INICIATIVA_ID']:
407
- if col not in df_pareto.columns:
408
- columnas_faltantes.append(col)
409
-
410
- if columnas_faltantes:
411
- print(f"⚠️ Columnas faltantes en {nivel}: {columnas_faltantes}")
412
- print(f" Columnas disponibles: {list(df_pareto.columns)}")
413
- return df_pareto
414
-
415
- try:
416
- stats = df_pareto.groupby(id_column).agg({
417
- sim_column: 'mean',
418
- rank_column: 'mean',
419
- 'INICIATIVA_ID': 'count'
420
- })
421
-
422
- # Normalizar componentes
423
- stats['sim_norm'] = stats[sim_column]
424
- stats['rank_norm'] = 1 - (stats[rank_column] - stats[rank_column].min()) / (stats[rank_column].max() - stats[rank_column].min())
425
- stats['freq_norm'] = stats['INICIATIVA_ID'] / stats['INICIATIVA_ID'].max()
426
-
427
- # Score mixto
428
- stats['score'] = (
429
- sim_prop * stats['sim_norm'] +
430
- rank_prop * stats['rank_norm'] +
431
- (1 - sim_prop - rank_prop) * stats['freq_norm']
432
- )
433
-
434
- resultado = stats[['score', 'INICIATIVA_ID']].reset_index()
435
- resultado = resultado.sort_values('score', ascending=False).reset_index().rename(columns={'INICIATIVA_ID': 'frecuencia',
436
- 'index': 'rank'})
437
- resultado['rank'] = resultado.index + 1
438
-
439
- # Crear columna ODS_ID de forma robusta para todos los niveles
440
- if nivel == 'ods':
441
- # Para ODS, el ID ya es el ODS_ID
442
- resultado['ODS_ID'] = resultado['ODS_ID'].astype(str)
443
- elif nivel == 'meta':
444
- # Extraer ODS del META_ID (formato: "1.1", tomar el primer dígito)
445
- def extraer_ods_de_meta(meta_id):
446
- try:
447
- if pd.isna(meta_id):
448
- return None
449
- ods_num = str(meta_id).split('.')[0].strip()
450
- return ods_num if ods_num else None
451
- except:
452
- return None
453
- resultado['ODS_ID'] = resultado['META_ID'].apply(extraer_ods_de_meta)
454
- elif nivel == 'indicador':
455
- # Extraer ODS del INDICADOR_ID (formato: "1.1.1", tomar el primer dígito)
456
- def extraer_ods_de_indicador(ind_id):
457
- try:
458
- if pd.isna(ind_id):
459
- return None
460
- ods_num = str(ind_id).split('.')[0].strip()
461
- return ods_num if ods_num else None
462
- except:
463
- return None
464
- resultado['ODS_ID'] = resultado['INDICADOR_ID'].apply(extraer_ods_de_indicador)
465
-
466
- return resultado
467
-
468
- except Exception as e:
469
- print(f"❌ Error en score_creator para {nivel}: {str(e)}")
470
- print(f" DataFrame shape: {df_pareto.shape}")
471
- print(f" Columnas: {list(df_pareto.columns)}")
472
- return df_pareto
473
- # =============================================================================
474
- # CELDA 7: Función principal de procesamiento por lotes
475
- # =============================================================================
476
-
477
-
478
- def procesar_lote_llm(iniciativas: list, mostrar_progreso: bool = True) -> dict:
479
- """
480
- Procesa un listado de iniciativas y genera 3 DataFrames:
481
- - df_ods: Clasificación a nivel ODS
482
- - df_metas: Clasificación a nivel Meta
483
- - df_indicadores: Clasificación a nivel Indicador
484
-
485
- Columnas: INICIATIVA_ID, ODS_ID, OBJETIVO, ods_rank, iniciativa
486
- """
487
- print("\n" + "="*70)
488
- print("🚀 INICIANDO PROCESAMIENTO POR LOTES")
489
- print("="*70)
490
- print(f"Total iniciativas: {len(iniciativas)}")
491
- print()
492
-
493
- niveles = ["ods", "meta"]
494
- resultados = {nivel: [] for nivel in niveles}
495
-
496
- total = len(iniciativas) * len(niveles)
497
- contador = 0
498
- errores_totales = 0
499
-
500
- for idx, iniciativa in enumerate(iniciativas):
501
- iniciativa_id = f"INI_{idx + 1:04d}"
502
-
503
- for nivel in niveles:
504
- contador += 1
505
- if mostrar_progreso:
506
- print(f"[{contador}/{total}] {iniciativa_id} - {nivel.upper()}: {iniciativa[:50]}...")
507
-
508
- # Sistema de reintentos
509
- max_reintentos = 3
510
- intento = 0
511
- resultado = None
512
-
513
- while intento < max_reintentos:
514
- intento += 1
515
- resultado = clasificar_nivel(iniciativa, nivel)
516
-
517
- # Éxito: tenemos respuesta sin error y con items
518
- if not resultado["error"] and len(resultado["items"]) > 0:
519
- print(f" {len(resultado['items'])} items encontrados (intento {intento})")
520
- break
521
-
522
- # Si hay error o sin items, reintenta (excepto en el último intento)
523
- if intento < max_reintentos:
524
- if resultado["error"]:
525
- print(f" ⚠️ Reintentando... (error: {resultado['error'][:40]}...)")
526
- else:
527
- print(f" ⚠️ Reintentando... (sin items encontrados)")
528
- time.sleep(2) # Espera antes de reintentar
529
-
530
- # Procesar resultado final después de reintentos
531
- if resultado["error"]:
532
- errores_totales += 1
533
- print(f" ❌ Error persistente en {nivel.upper()} tras {max_reintentos} intentos: {resultado['error'][:60]}")
534
- resultados[nivel].append({
535
- "INICIATIVA_ID": iniciativa_id,
536
- "ODS_ID": "ERROR",
537
- "OBJETIVO": resultado["error"],
538
- "ods_rank": 0,
539
- "iniciativa": iniciativa
540
- })
541
- else:
542
- items_count = len(resultado["items"])
543
- if items_count == 0:
544
- print(f" ⚠️ Sin items encontrados en {nivel.upper()} tras {max_reintentos} intentos")
545
- print(f" Raw response: {resultado['respuesta_raw'][:100]}")
546
-
547
- for rank, item in enumerate(resultado["items"], start=1):
548
- if nivel == "ods":
549
- fila = {
550
- "INICIATIVA_ID": iniciativa_id,
551
- "ODS_ID": item.get("ods_id", ""),
552
- "OBJETIVO": item.get("objetivo", ""),
553
- "ods_rank": rank,
554
- "iniciativa": iniciativa
555
- }
556
- resultados[nivel].append(fila)
557
- elif nivel == "meta":
558
- fila = {
559
- "INICIATIVA_ID": iniciativa_id,
560
- "ODS_ID": item.get("ods_id", ""),
561
- "META_ID": item.get("meta_id", ""),
562
- "OBJETIVO": item.get("objetivo", ""),
563
- "meta_rank": rank,
564
- "iniciativa": iniciativa
565
- }
566
- resultados[nivel].append(fila)
567
- elif nivel == "indicador":
568
- fila = {
569
- "INICIATIVA_ID": iniciativa_id,
570
- "ODS_ID": item.get("ods_id", ""),
571
- "INDICADOR_ID": item.get("indicador_id", ""),
572
- "OBJETIVO": item.get("objetivo", ""),
573
- "indicador_rank": rank,
574
- "iniciativa": iniciativa
575
- }
576
- resultados[nivel].append(fila)
577
- # Pausa para evitar rate limits
578
- time.sleep(1)
579
-
580
- # Mostrar resumen
581
- print()
582
- print("="*70)
583
- print("📊 RESUMEN DE RESULTADOS")
584
- print("="*70)
585
- print(f"Total errores: {errores_totales}")
586
- for nivel in niveles:
587
- print(f" - {nivel.upper()}: {len(resultados[nivel])} registros")
588
- print()
589
-
590
- # Crear DataFrames
591
- df_ods = pd.DataFrame(resultados["ods"])
592
- if not df_ods.empty:
593
- df_ods = df_ods[["INICIATIVA_ID", "ODS_ID", "OBJETIVO", "ods_rank", "iniciativa"]]
594
- print(f"✅ df_ods: {len(df_ods)} registros")
595
- else:
596
- print(f"⚠️ df_ods está vacío - revisar respuestas del modelo")
597
-
598
- df_metas = pd.DataFrame(resultados["meta"])
599
- if not df_metas.empty:
600
- df_metas = df_metas[["INICIATIVA_ID", "ODS_ID", "META_ID","OBJETIVO", "meta_rank", "iniciativa"]]
601
- print(f"✅ df_metas: {len(df_metas)} registros")
602
- else:
603
- print(f"⚠️ df_metas está vacío - revisar respuestas del modelo")
604
-
605
- df_indicadores = pd.DataFrame(columns=["INICIATIVA_ID", "ODS_ID", "INDICADOR_ID", "OBJETIVO", "indicador_rank", "iniciativa"])
606
-
607
- dfs = {
608
- "df_ods": df_ods,
609
- "df_metas": df_metas,
610
- "df_indicadores": df_indicadores
611
- }
612
- dfs = normalizar_rank_df(dfs)
613
- dfs['df_ods'] = score_creator(dfs['df_ods'], 'ods')
614
- dfs['df_metas'] = score_creator(dfs['df_metas'], 'meta')
615
-
616
- print()
617
- print("="*70)
618
- print("✅ PROCESAMIENTO COMPLETADO")
619
- print("="*70)
620
- print()
621
-
622
- return dfs['df_ods'], dfs['df_metas'], dfs['df_indicadores'], 'Ejecución completada'
623
-
624
- def procesar_lote_llm_top(iniciativas: list, mostrar_progreso: bool = True) -> dict:
625
- """
626
- Procesa un listado de iniciativas y genera 3 DataFrames:
627
- - df_ods: Clasificación a nivel ODS
628
- - df_metas: Clasificación a nivel Meta
629
- - df_indicadores: Clasificación a nivel Indicador
630
-
631
- Columnas: INICIATIVA_ID, ODS_ID, OBJETIVO, ods_rank, iniciativa
632
- """
633
- niveles = ["ods", "meta", "indicador"]
634
- # niveles = ["ods", "meta"]
635
- resultados = {nivel: [] for nivel in niveles}
636
-
637
- total = len(iniciativas) * len(niveles)
638
-
639
-
640
- for nivel in niveles:
641
- contador = 0
642
- if nivel == "ods":
643
- for idx, iniciativa in enumerate(iniciativas):
644
- iniciativa_id = f"INI_{idx + 1:04d}"
645
-
646
- contador += 1
647
- if mostrar_progreso:
648
- print(f"[{contador}/{total}] {iniciativa_id} - {nivel.upper()}: {iniciativa[:50]}...")
649
-
650
-
651
- resultado = clasificar_nivel(iniciativa, nivel)
652
-
653
- if resultado["error"]:
654
- resultados[nivel].append({
655
- "INICIATIVA_ID": iniciativa_id,
656
- "ODS_ID": "ERROR",
657
- "OBJETIVO": resultado["error"],
658
- "ods_rank": 0,
659
- "iniciativa": iniciativa
660
- })
661
- else:
662
- for rank, item in enumerate(resultado["items"], start=1):
663
- if nivel == "ods":
664
- fila = {
665
- "INICIATIVA_ID": iniciativa_id,
666
- "ODS_ID": item.get("ods_id", ""),
667
- "OBJETIVO": item.get("objetivo", ""),
668
- "ods_rank": rank,
669
- "iniciativa": iniciativa
670
- }
671
- resultados[nivel].append(fila)
672
- # Pausa para evitar rate limits
673
- time.sleep(1)
674
- # Crear DataFrames
675
- df_ods = pd.DataFrame(resultados["ods"])
676
- if not df_ods.empty:
677
- df_ods = df_ods[["INICIATIVA_ID", "ODS_ID", "OBJETIVO", "ods_rank", "iniciativa"]]
678
-
679
- elif nivel == "meta":
680
- for idx, iniciativa in enumerate(iniciativas):
681
- iniciativa_id = f"INI_{idx + 1:04d}"
682
-
683
- contador += 1
684
- if mostrar_progreso:
685
- print(f"[{contador}/{total}] {iniciativa_id} - {nivel.upper()}: {iniciativa[:50]}...")
686
-
687
-
688
- for ods in df_ods['ODS_ID'].unique().tolist():
689
-
690
- resultado = clasificar_nivel_meta_top(iniciativa, ods, nivel)
691
-
692
- if resultado["error"]:
693
- resultados[nivel].append({
694
- "INICIATIVA_ID": iniciativa_id,
695
- "ODS_ID": "ERROR",
696
- "OBJETIVO": resultado["error"],
697
- "ods_rank": 0,
698
- "iniciativa": iniciativa
699
- })
700
- else:
701
- for rank, item in enumerate(resultado["items"], start=1):
702
- fila = {
703
- "INICIATIVA_ID": iniciativa_id,
704
- "ODS_ID": item.get("ods_id", ""),
705
- "META_ID": item.get("meta_id", ""),
706
- "OBJETIVO": item.get("objetivo", ""),
707
- "meta_rank": rank,
708
- "iniciativa": iniciativa
709
- }
710
- resultados[nivel].append(fila)
711
- # Pausa para evitar rate limits
712
- time.sleep(1)
713
- # Crear DataFrames
714
- df_metas = pd.DataFrame(resultados["meta"])
715
- if not df_metas.empty:
716
- df_metas = df_metas[["INICIATIVA_ID", "ODS_ID", "META_ID","OBJETIVO", "meta_rank", "iniciativa"]]
717
-
718
-
719
- # elif nivel == "indicador":
720
- # fila = {
721
- # "INICIATIVA_ID": iniciativa_id,
722
- # "ODS_ID": item.get("ods_id", ""),
723
- # "INDICADOR_ID": item.get("indicador_id", ""),
724
- # "OBJETIVO": item.get("objetivo", ""),
725
- # "indicador_rank": rank,
726
- # "iniciativa": iniciativa
727
- # }
728
- # resultados[nivel].append(fila)
729
- # # Pausa para evitar rate limits
730
- # time.sleep(1)
731
-
732
-
733
-
734
-
735
-
736
- df_indicadores = pd.DataFrame(columns=["INICIATIVA_ID", "ODS_ID", "INDICADOR_ID", "OBJETIVO", "indicador_rank", "iniciativa"])
737
- # if not df_indicadores.empty:
738
- # df_indicadores = df_indicadores[["INICIATIVA_ID", "ODS_ID", "INDICADOR_ID", "OBJETIVO", "indicador_rank", "iniciativa"]]
739
- # print(df_indicadores.columns)
740
-
741
- dfs = {
742
- "df_ods": df_ods,
743
- "df_metas": df_metas,
744
- "df_indicadores": df_indicadores
745
- }
746
- dfs = normalizar_rank_df(dfs)
747
- dfs['df_ods'] = score_creator(dfs['df_ods'], 'ods')
748
- dfs['df_metas'] = score_creator(dfs['df_metas'], 'meta')
749
- print(dfs['df_indicadores'].columns)
750
- dfs['df_indicadores'] = score_creator(dfs['df_indicadores'], 'indicador')
751
- return dfs['df_ods'], dfs['df_metas'], dfs['df_indicadores'], 'Ejecución completada'
752
-
753
- # =============================================================================
754
- # CELDA 8: Prueba con listado de ejemplo
755
- # =============================================================================
756
- # iniciativas_prueba = [
757
- # "Implementación de programas de atención integral a la primera infancia de manera itinerante en zona rural del municipio de Pradera, en función del cumplimiento de los derechos de la primera infancia",
758
- # "Instalación de paneles solares en comunidades sin acceso a electricidad para garantizar energía limpia y sostenible",
759
- # "Capacitación digital para mujeres emprendedoras rurales orientada a fortalecer sus habilidades tecnológicas y comerciales",
760
- # "Construcción de viviendas dignas para familias víctimas del conflicto armado en proceso de reincorporación"
761
- # ]
762
-
763
- # print(f"Procesando {len(iniciativas_prueba)} iniciativas...\n")
764
- # resultado = procesar_lote(iniciativas_prueba)
765
-
766
- # =============================================================================
767
- # CELDA 9: Visualizar resultados
768
- # =============================================================================
769
- # print("\n" + "="*80)
770
- # print("📊 NIVEL ODS")
771
- # print("="*80)
772
- # display(resultado["df_ods"])
773
-
774
- # print("\n" + "="*80)
775
- # print("📊 NIVEL METAS")
776
- # print("="*80)
777
- # display(resultado["df_metas"])
778
-
779
- # print("\n" + "="*80)
780
- # print("📊 NIVEL INDICADORES")
781
- # print("="*80)
782
- # display(resultado["df_indicadores"])
783
-
784
- # =============================================================================
785
- # CELDA 10: Exportar a Excel
786
- # =============================================================================
787
- # output_file = "clasificacion_ods_por_niveles.xlsx"
788
-
789
- # with pd.ExcelWriter(output_file, engine="openpyxl") as writer:
790
- # resultado["df_ods"].to_excel(writer, sheet_name="ODS", index=False)
791
- # resultado["df_metas"].to_excel(writer, sheet_name="Metas", index=False)
792
- # resultado["df_indicadores"].to_excel(writer, sheet_name="Indicadores", index=False)
793
-
794
- # print(f"\n✅ Archivo guardado: {output_file}")
795
-
796
- # Descargar en Colab
797
- # from google.colab import files
798
- # files.download(output_file)
799
-
800
- # =============================================================================
801
- # CELDA 11: Función para cargar desde archivo externo
802
- # =============================================================================
803
- # def procesar_desde_archivo(columna_iniciativa: str = None):
804
- # """
805
- # Carga iniciativas desde un archivo CSV/Excel subido y las procesa.
806
-
807
- # Uso:
808
- # resultado = procesar_desde_archivo()
809
- # # o especificando la columna:
810
- # resultado = procesar_desde_archivo(columna_iniciativa="descripcion")
811
- # """
812
- # # from google.colab import files
813
-
814
- # print("📁 Sube tu archivo CSV o Excel...")
815
- # uploaded = files.upload()
816
-
817
- # if not uploaded:
818
- # print("No se subió ningún archivo.")
819
- # return None
820
-
821
- # archivo = list(uploaded.keys())[0]
822
- # print(f"Archivo cargado: {archivo}")
823
-
824
- # # Leer archivo
825
- # if archivo.endswith(".csv"):
826
- # df = pd.read_csv(archivo)
827
- # else:
828
- # df = pd.read_excel(archivo)
829
-
830
- # print(f"Columnas disponibles: {list(df.columns)}")
831
-
832
- # # Detectar columna si no se especifica
833
- # if columna_iniciativa is None:
834
- # for col in df.columns:
835
- # if any(x in col.lower() for x in ["iniciativa", "descripcion", "nombre", "proyecto", "programa"]):
836
- # columna_iniciativa = col
837
- # break
838
- # if columna_iniciativa is None:
839
- # columna_iniciativa = df.columns[0]
840
-
841
- # print(f"Usando columna: '{columna_iniciativa}'")
842
- # print(f"Total iniciativas: {len(df)}")
843
-
844
- # # Procesar
845
- # iniciativas = df[columna_iniciativa].astype(str).tolist()
846
- # return procesar_lote(iniciativas)
847
-
848
-
849
- # # Ejemplo de uso:
850
- # # resultado = procesar_desde_archivo()
851
- # ```
852
-
853
- # ## Características del código actualizado
854
-
855
- # | Característica | Descripción |
856
- # |----------------|-------------|
857
- # | **Fallback automático** | Si un modelo falla, prueba el siguiente |
858
- # | **Reintentos** | 3 intentos por modelo antes de cambiar |
859
- # | **Rate limit handling** | Espera progresiva si hay límites |
860
- # | **Verificación inicial** | Prueba qué modelos están disponibles |
861
- # | **Modelos ordenados** | De más potente a más ligero |
862
-
863
- # ## Estructura de salida
864
-
865
- # Los 3 DataFrames tendrán estas columnas:
866
- # ```
867
- # df_ods: INICIATIVA_ID | ODS_ID | OBJETIVO | ods_rank | iniciativa
868
- # df_metas: INICIATIVA_ID | ODS_ID | OBJETIVO | ods_rank | iniciativa
 
869
  # df_indicadores: INICIATIVA_ID | ODS_ID | OBJETIVO | ods_rank | iniciativa
 
1
+ # =============================================================================
2
+ # CELDA 1: Instalar dependencias
3
+ # =============================================================================
4
+ # !pip install huggingface_hub pandas openpyxl
5
+
6
+ # =============================================================================
7
+ # CELDA 2: Configuración Gemini
8
+ # =============================================================================
9
+ from google import genai
10
+ from google.genai import types
11
+ import pandas as pd
12
+ import re
13
+ import time
14
+
15
+ # Obtén tu API key en: https://aistudio.google.com/app/apikey
16
+
17
+ api_key = os.getenv("GOOGLE_API_KEY")
18
+ client = genai.Client(api_key=api_key)
19
+ # api_key = os.getenv("GOOGLE_API_KEY")
20
+
21
+ # Modelos gratuitos disponibles (de más potente a más ligero)
22
+ MODELOS = [
23
+ "gemini-2.5-flash-lite", # Más económico, buena opción para pruebas rápidas
24
+ "gemini-2.5-flash", # Más reciente, muy bueno
25
+ "gemini-2.0-flash", # Estable
26
+ "gemini-2.0-flash-lite", # Más económico
27
+ ]
28
+
29
+ # =============================================================================
30
+ # CELDA 3: Verificar modelos disponibles
31
+ # =============================================================================
32
+ def verificar_modelos():
33
+ """Lista modelos disponibles y verifica cuál funciona."""
34
+ print("Verificando modelos disponibles...\n")
35
+
36
+ for modelo in MODELOS:
37
+ try:
38
+ response = client.models.generate_content(
39
+ model=modelo,
40
+ contents="Responde únicamente: OK"
41
+ )
42
+ print(f"✅ {modelo}: Disponible")
43
+ return modelo
44
+ except Exception as e:
45
+ print(f"❌ {modelo}: {str(e)[:60]}")
46
+
47
+ return None
48
+
49
+ MODELO_ACTIVO = verificar_modelos()
50
+
51
+ if MODELO_ACTIVO:
52
+ print(f"\n🎯 Modelo seleccionado: {MODELO_ACTIVO}")
53
+ else:
54
+ print("\n⚠️ Ningún modelo disponible. Verifica tu API key.")
55
+
56
+ # =============================================================================
57
+ # CELDA 4: Prompts por nivel
58
+ # =============================================================================
59
+ PROMPT_ODS = """Eres un experto en Objetivos de Desarrollo Sostenible (ODS) de las Naciones Unidas.
60
+
61
+ Vincula entre 4 y 5 ODS que tengan relación directa con la siguiente iniciativa:
62
+
63
+ "{iniciativa}"
64
+
65
+ Para cada ODS incluye:
66
+ - Número y nombre del ODS
67
+ - Justificación breve del vínculo (impacto social, territorial, de paz, infraestructura o inclusión)
68
+
69
+ FORMATO DE RESPUESTA (una línea por ODS, ordenados por relevancia):
70
+ ODS [número]: [nombre] – [justificación]
71
+
72
+ Responde ÚNICAMENTE con el listado, sin texto introductorio ni conclusiones."""
73
+
74
+
75
+ PROMPT_META = """Eres un experto en Objetivos de Desarrollo Sostenible (ODS) de las Naciones Unidas.
76
+
77
+ Identifica entre 4 y 6 Metas específicas de los ODS que tengan relación directa con la siguiente iniciativa:
78
+
79
+ "{iniciativa}"
80
+
81
+ Para cada meta incluye:
82
+ - Código de la meta (dos niveles estrictos ej: 4.2, 1.3, 16.10)
83
+ - Descripción de la meta
84
+ - Justificación breve del vínculo con la iniciativa
85
+
86
+ FORMATO DE RESPUESTA (una línea por meta, ordenadas por relevancia):
87
+ Meta [código]: [descripción] – [justificación]
88
+
89
+ Responde ÚNICAMENTE con el listado, sin texto introductorio ni conclusiones."""
90
+
91
+ PROMPT_META_TOP = """Eres un experto en Objetivos de Desarrollo Sostenible (ODS) de las Naciones Unidas.
92
+
93
+ Identifica 2 Metas específicas del ODS {ods} que tengan relación directa con la siguiente iniciativa:
94
+
95
+ "{iniciativa}"
96
+
97
+ Para cada meta incluye:
98
+ - Código de la meta (estructura de dos niveles estrictos ej: 4.2, 1.3, 16.10)
99
+ - Descripción de la meta
100
+ - Justificación breve del vínculo con la iniciativa
101
+
102
+ FORMATO DE RESPUESTA (una línea por meta, ordenadas por relevancia):
103
+ Meta [código]: [descripción] – [justificación]
104
+
105
+ Responde ÚNICAMENTE con el listado, sin texto introductorio ni conclusiones."""
106
+
107
+
108
+ PROMPT_INDICADOR = """Eres un experto en Objetivos de Desarrollo Sostenible (ODS) de las Naciones Unidas.
109
+
110
+ Identifica entre 3 y 5 Indicadores de los ODS que permitan medir el impacto de la siguiente iniciativa:
111
+
112
+ "{iniciativa}"
113
+
114
+ Para cada indicador incluye:
115
+ - Código del indicador (ej: 4.2.1, 1.3.1, 16.10.1)
116
+ - Descripción del indicador
117
+ - Justificación de cómo se relaciona con la medición de la iniciativa
118
+
119
+ FORMATO DE RESPUESTA (una línea por indicador, ordenados por relevancia):
120
+ Indicador [código]: [descripción] – [justificación]
121
+
122
+ Responde ÚNICAMENTE con el listado, sin texto introductorio ni conclusiones."""
123
+
124
+ PROMPTS = {
125
+ "ods": PROMPT_ODS,
126
+ "meta": PROMPT_META,
127
+ "meta_top": PROMPT_META_TOP,
128
+ "indicador": PROMPT_INDICADOR
129
+ }
130
+
131
+ # =============================================================================
132
+ # CELDA 5: Funciones de parsing por nivel
133
+ # =============================================================================
134
+ def parsear_ods(texto: str) -> list:
135
+ """Extrae ODS de la respuesta."""
136
+ items = []
137
+ for linea in texto.strip().split("\n"):
138
+ linea = linea.strip()
139
+ if not linea:
140
+ continue
141
+ match = re.match(r"(?:\d+[\.\)\-]?\s*)?ODS\s*(\d+):?\s*(.+)", linea, re.IGNORECASE)
142
+ if match:
143
+ ods_num = match.group(1)
144
+ resto = match.group(2).strip()
145
+ items.append({
146
+ "ods_id": f"{ods_num}",
147
+ "objetivo": resto
148
+ })
149
+ return items
150
+
151
+
152
+ def parsear_metas(texto: str) -> list:
153
+ """Extrae metas de la respuesta."""
154
+ items = []
155
+ for linea in texto.strip().split("\n"):
156
+ linea = linea.strip()
157
+ if not linea:
158
+ continue
159
+ match = re.match(r"(?:\d+[\.\)\-]?\s*)?Meta\s*([\d\.]+):?\s*(.+)", linea, re.IGNORECASE)
160
+ if match:
161
+ meta_codigo = match.group(1)
162
+ meta_codigo = f'{meta_codigo.split(".")[0]}.{meta_codigo.split(".")[1]}'
163
+ resto = match.group(2).strip()
164
+ ods_num = meta_codigo.split(".")[0]
165
+ items.append({
166
+ "ods_id": f"{ods_num}",
167
+ "meta_id": f"{meta_codigo}",
168
+ "objetivo": resto
169
+ })
170
+ return items
171
+
172
+
173
+ def parsear_indicadores(texto: str) -> list:
174
+ """Extrae indicadores de la respuesta."""
175
+ items = []
176
+ for linea in texto.strip().split("\n"):
177
+ linea = linea.strip()
178
+ if not linea:
179
+ continue
180
+ match = re.match(r"(?:\d+[\.\)\-]?\s*)?Indicador\s*([\d\.]+):?\s*(.+)", linea, re.IGNORECASE)
181
+ if match:
182
+ ind_codigo = match.group(1)
183
+ resto = match.group(2).strip()
184
+ ods_num = ind_codigo.split(".")[0]
185
+ items.append({
186
+ "ods_id": f"{ods_num}",
187
+ "indicador_id": f"{ind_codigo}",
188
+ "objetivo": resto
189
+ })
190
+ return items
191
+
192
+
193
+ PARSERS = {
194
+ "ods": parsear_ods,
195
+ "meta": parsear_metas,
196
+ "indicador": parsear_indicadores
197
+ }
198
+
199
+ # =============================================================================
200
+ # CELDA 6: Función de clasificación con fallback
201
+ # =============================================================================
202
+ # def llamar_modelo(prompt: str, intentos_maximos: int = 3) -> str:
203
+ # """Llama al modelo con reintentos y fallback."""
204
+ # for modelo in MODELOS:
205
+ # for intento in range(intentos_maximos):
206
+ # try:
207
+ # respuesta = client.chat_completion(
208
+ # model=modelo,
209
+ # messages=[{"role": "user", "content": prompt}],
210
+ # max_tokens=1200,
211
+ # temperature=0.3
212
+ # )
213
+ # return respuesta.choices[0].message.content
214
+ # except Exception as e:
215
+ # error_msg = str(e).lower()
216
+ # # Si es rate limit, esperar y reintentar
217
+ # if "rate" in error_msg or "limit" in error_msg or "429" in error_msg:
218
+ # wait_time = (intento + 1) * 5
219
+ # print(f" ⏳ Rate limit, esperando {wait_time}s...")
220
+ # time.sleep(wait_time)
221
+ # continue
222
+ # # Si es error de modelo, pasar al siguiente
223
+ # elif "not supported" in error_msg or "not available" in error_msg:
224
+ # break
225
+ # # Otros errores, reintentar
226
+ # else:
227
+ # time.sleep(2)
228
+ # continue
229
+
230
+ # raise Exception("Todos los modelos fallaron")
231
+
232
+ def llamar_modelo(prompt: str, intentos_maximos: int = 3) -> str:
233
+ """Llama a Gemini con reintentos."""
234
+ for intento in range(intentos_maximos):
235
+ try:
236
+ response = client.models.generate_content(
237
+ model=MODELO_ACTIVO,
238
+ contents=prompt,
239
+ config=types.GenerateContentConfig(
240
+ max_output_tokens=1200,
241
+ temperature=0.3
242
+ )
243
+ )
244
+ return response.text
245
+ except Exception as e:
246
+ error_msg = str(e).lower()
247
+ if "quota" in error_msg or "rate" in error_msg or "429" in error_msg:
248
+ wait_time = (intento + 1) * 10
249
+ print(f" ⏳ Rate limit, esperando {wait_time}s...")
250
+ time.sleep(wait_time)
251
+ else:
252
+ raise e
253
+
254
+ raise Exception("Máximo de intentos alcanzado")
255
+
256
+
257
+ def clasificar_nivel(iniciativa: str, nivel: str) -> dict:
258
+ """Clasifica una iniciativa en un nivel específico (ods, meta, indicador)."""
259
+ prompt = PROMPTS[nivel].format(iniciativa=iniciativa)
260
+ parser = PARSERS[nivel]
261
+
262
+ try:
263
+ texto = llamar_modelo(prompt)
264
+ items = parser(texto)
265
+
266
+ return {
267
+ "iniciativa": iniciativa,
268
+ "items": items,
269
+ "respuesta_raw": texto,
270
+ "error": None
271
+ }
272
+ except Exception as e:
273
+ return {
274
+ "iniciativa": iniciativa,
275
+ "items": [],
276
+ "respuesta_raw": "",
277
+ "error": str(e)
278
+ }
279
+
280
+ def clasificar_nivel_meta_top(iniciativa: str, ods: int, nivel: str) -> dict:
281
+ """Clasifica una iniciativa en metas específicas del ODS proporcionado."""
282
+ # Inicializar HF la primera vez que se usa
283
+ inicializar_hf()
284
+
285
+ prompt = PROMPTS[nivel].format(ods=ods, iniciativa=iniciativa)
286
+ parser = PARSERS[nivel]
287
+
288
+ try:
289
+ texto = llamar_modelo(prompt)
290
+ items = parser(texto)
291
+
292
+ return {
293
+ "iniciativa": iniciativa,
294
+ "ods": ods,
295
+ "items": items,
296
+ "respuesta_raw": texto,
297
+ "error": None
298
+ }
299
+ except Exception as e:
300
+ return {
301
+ "iniciativa": iniciativa,
302
+ "ods": ods,
303
+ "items": [],
304
+ "respuesta_raw": "",
305
+ "error": str(e)
306
+ }
307
+
308
+ from sklearn.preprocessing import MinMaxScaler
309
+
310
+
311
+ def normalizar_rank_df(dfs: dict) -> dict:
312
+ """
313
+ Normaliza las columnas de ranking ('ods_rank', 'meta_rank', 'indicador_rank')
314
+ en los DataFrames proporcionados en un diccionario usando Min-Max scaling.
315
+ Añade una nueva columna '_normalized' para el rank normalizado.
316
+
317
+ Maneja casos especiales:
318
+ - DataFrames vacíos
319
+ - Valores faltantes
320
+ - Un único valor único
321
+ """
322
+ # Ajustar el rango de normalización para evitar el 0
323
+ scaler = MinMaxScaler(feature_range=(0.0, 0.99))
324
+ df_actualizados = {}
325
+
326
+ for df_name, df in dfs.items():
327
+ if df.empty:
328
+ print(f"⚠️ {df_name} está vacío")
329
+ df_actualizados[df_name] = df
330
+ continue
331
+
332
+ df_temp = df.copy()
333
+ rank_column = None
334
+
335
+ # Identificar la columna de rank
336
+ if 'ods_rank' in df_temp.columns:
337
+ rank_column = 'ods_rank'
338
+ elif 'meta_rank' in df_temp.columns:
339
+ rank_column = 'meta_rank'
340
+ elif 'indicador_rank' in df_temp.columns:
341
+ rank_column = 'indicador_rank'
342
+
343
+ if rank_column:
344
+ print(f"✅ Normalizando {df_name} usando {rank_column}")
345
+
346
+ try:
347
+ # Asegurarse de que el rank_column es numérico
348
+ df_temp[rank_column] = pd.to_numeric(df_temp[rank_column], errors='coerce')
349
+
350
+ # Contar valores no nulos antes de dropna
351
+ valores_validos = df_temp[rank_column].notna().sum()
352
+
353
+ if valores_validos == 0:
354
+ print(f"⚠️ {df_name}: Todos los valores de {rank_column} son NaN")
355
+ # Crear columna de similaridad con valores por defecto
356
+ nivel_name = rank_column.replace('_rank', '')
357
+ df_temp[f"{nivel_name}_similaridad_cos_normalized"] = 0.5
358
+ else:
359
+ # Eliminar solo los NaN, no resetear el índice aún
360
+ df_temp = df_temp.dropna(subset=[rank_column])
361
+
362
+ if df_temp.empty:
363
+ print(f"⚠️ {df_name}: Vacío después de eliminar NaN")
364
+ continue
365
+
366
+ nivel_name = rank_column.replace('_rank', '')
367
+
368
+ # Normalizar según el número de valores únicos
369
+ if df_temp[rank_column].nunique() > 1:
370
+ df_temp[f"{nivel_name}_similaridad_cos_normalized"] = scaler.fit_transform(df_temp[[rank_column]])
371
+ df_temp[f"{nivel_name}_similaridad_cos_normalized"] = 1 - df_temp[f"{nivel_name}_similaridad_cos_normalized"]
372
+ elif df_temp[rank_column].nunique() == 1:
373
+ # Un único valor: asignar 0.5
374
+ df_temp[f"{nivel_name}_similaridad_cos_normalized"] = 0.5
375
+ else:
376
+ # Sin valores válidos
377
+ df_temp[f"{nivel_name}_similaridad_cos_normalized"] = 0.5
378
+
379
+ except Exception as e:
380
+ print(f"❌ Error normalizando {df_name}: {str(e)}")
381
+ # Crear columna con valor por defecto
382
+ nivel_name = rank_column.replace('_rank', '')
383
+ df_temp[f"{nivel_name}_similaridad_cos_normalized"] = 0.5
384
+
385
+ df_actualizados[df_name] = df_temp
386
+
387
+ return df_actualizados
388
+
389
+
390
+ def score_creator(df_pareto, nivel, sim_prop = 0, rank_prop = 0.6):
391
+ """
392
+ Crea scores para ranking de ODS/Metas/Indicadores.
393
+ Maneja DataFrames vacíos y columnas faltantes de forma robusta.
394
+ """
395
+ # Verificar si el DataFrame está vacío
396
+ if df_pareto.empty:
397
+ print(f"⚠️ DataFrame vacío para nivel: {nivel}")
398
+ return df_pareto
399
+
400
+ # Construir nombres de columnas esperadas
401
+ id_column = f'{nivel.upper()}_ID'
402
+ sim_column = f'{nivel}_similaridad_cos_normalized'
403
+ rank_column = f'{nivel}_rank'
404
+
405
+ # Verificar que las columnas necesarias existan
406
+ columnas_faltantes = []
407
+ for col in [id_column, sim_column, rank_column, 'INICIATIVA_ID']:
408
+ if col not in df_pareto.columns:
409
+ columnas_faltantes.append(col)
410
+
411
+ if columnas_faltantes:
412
+ print(f"⚠️ Columnas faltantes en {nivel}: {columnas_faltantes}")
413
+ print(f" Columnas disponibles: {list(df_pareto.columns)}")
414
+ return df_pareto
415
+
416
+ try:
417
+ stats = df_pareto.groupby(id_column).agg({
418
+ sim_column: 'mean',
419
+ rank_column: 'mean',
420
+ 'INICIATIVA_ID': 'count'
421
+ })
422
+
423
+ # Normalizar componentes
424
+ stats['sim_norm'] = stats[sim_column]
425
+ stats['rank_norm'] = 1 - (stats[rank_column] - stats[rank_column].min()) / (stats[rank_column].max() - stats[rank_column].min())
426
+ stats['freq_norm'] = stats['INICIATIVA_ID'] / stats['INICIATIVA_ID'].max()
427
+
428
+ # Score mixto
429
+ stats['score'] = (
430
+ sim_prop * stats['sim_norm'] +
431
+ rank_prop * stats['rank_norm'] +
432
+ (1 - sim_prop - rank_prop) * stats['freq_norm']
433
+ )
434
+
435
+ resultado = stats[['score', 'INICIATIVA_ID']].reset_index()
436
+ resultado = resultado.sort_values('score', ascending=False).reset_index().rename(columns={'INICIATIVA_ID': 'frecuencia',
437
+ 'index': 'rank'})
438
+ resultado['rank'] = resultado.index + 1
439
+
440
+ # Crear columna ODS_ID de forma robusta para todos los niveles
441
+ if nivel == 'ods':
442
+ # Para ODS, el ID ya es el ODS_ID
443
+ resultado['ODS_ID'] = resultado['ODS_ID'].astype(str)
444
+ elif nivel == 'meta':
445
+ # Extraer ODS del META_ID (formato: "1.1", tomar el primer dígito)
446
+ def extraer_ods_de_meta(meta_id):
447
+ try:
448
+ if pd.isna(meta_id):
449
+ return None
450
+ ods_num = str(meta_id).split('.')[0].strip()
451
+ return ods_num if ods_num else None
452
+ except:
453
+ return None
454
+ resultado['ODS_ID'] = resultado['META_ID'].apply(extraer_ods_de_meta)
455
+ elif nivel == 'indicador':
456
+ # Extraer ODS del INDICADOR_ID (formato: "1.1.1", tomar el primer dígito)
457
+ def extraer_ods_de_indicador(ind_id):
458
+ try:
459
+ if pd.isna(ind_id):
460
+ return None
461
+ ods_num = str(ind_id).split('.')[0].strip()
462
+ return ods_num if ods_num else None
463
+ except:
464
+ return None
465
+ resultado['ODS_ID'] = resultado['INDICADOR_ID'].apply(extraer_ods_de_indicador)
466
+
467
+ return resultado
468
+
469
+ except Exception as e:
470
+ print(f" Error en score_creator para {nivel}: {str(e)}")
471
+ print(f" DataFrame shape: {df_pareto.shape}")
472
+ print(f" Columnas: {list(df_pareto.columns)}")
473
+ return df_pareto
474
+ # =============================================================================
475
+ # CELDA 7: Función principal de procesamiento por lotes
476
+ # =============================================================================
477
+
478
+
479
+ def procesar_lote_llm(iniciativas: list, mostrar_progreso: bool = True) -> dict:
480
+ """
481
+ Procesa un listado de iniciativas y genera 3 DataFrames:
482
+ - df_ods: Clasificación a nivel ODS
483
+ - df_metas: Clasificación a nivel Meta
484
+ - df_indicadores: Clasificación a nivel Indicador
485
+
486
+ Columnas: INICIATIVA_ID, ODS_ID, OBJETIVO, ods_rank, iniciativa
487
+ """
488
+ print("\n" + "="*70)
489
+ print("🚀 INICIANDO PROCESAMIENTO POR LOTES")
490
+ print("="*70)
491
+ print(f"Total iniciativas: {len(iniciativas)}")
492
+ print()
493
+
494
+ niveles = ["ods", "meta"]
495
+ resultados = {nivel: [] for nivel in niveles}
496
+
497
+ total = len(iniciativas) * len(niveles)
498
+ contador = 0
499
+ errores_totales = 0
500
+
501
+ for idx, iniciativa in enumerate(iniciativas):
502
+ iniciativa_id = f"INI_{idx + 1:04d}"
503
+
504
+ for nivel in niveles:
505
+ contador += 1
506
+ if mostrar_progreso:
507
+ print(f"[{contador}/{total}] {iniciativa_id} - {nivel.upper()}: {iniciativa[:50]}...")
508
+
509
+ # Sistema de reintentos
510
+ max_reintentos = 3
511
+ intento = 0
512
+ resultado = None
513
+
514
+ while intento < max_reintentos:
515
+ intento += 1
516
+ resultado = clasificar_nivel(iniciativa, nivel)
517
+
518
+ # Éxito: tenemos respuesta sin error y con items
519
+ if not resultado["error"] and len(resultado["items"]) > 0:
520
+ print(f" ✅ {len(resultado['items'])} items encontrados (intento {intento})")
521
+ break
522
+
523
+ # Si hay error o sin items, reintenta (excepto en el último intento)
524
+ if intento < max_reintentos:
525
+ if resultado["error"]:
526
+ print(f" ⚠️ Reintentando... (error: {resultado['error'][:40]}...)")
527
+ else:
528
+ print(f" ⚠️ Reintentando... (sin items encontrados)")
529
+ time.sleep(2) # Espera antes de reintentar
530
+
531
+ # Procesar resultado final después de reintentos
532
+ if resultado["error"]:
533
+ errores_totales += 1
534
+ print(f" ❌ Error persistente en {nivel.upper()} tras {max_reintentos} intentos: {resultado['error'][:60]}")
535
+ resultados[nivel].append({
536
+ "INICIATIVA_ID": iniciativa_id,
537
+ "ODS_ID": "ERROR",
538
+ "OBJETIVO": resultado["error"],
539
+ "ods_rank": 0,
540
+ "iniciativa": iniciativa
541
+ })
542
+ else:
543
+ items_count = len(resultado["items"])
544
+ if items_count == 0:
545
+ print(f" ⚠️ Sin items encontrados en {nivel.upper()} tras {max_reintentos} intentos")
546
+ print(f" Raw response: {resultado['respuesta_raw'][:100]}")
547
+
548
+ for rank, item in enumerate(resultado["items"], start=1):
549
+ if nivel == "ods":
550
+ fila = {
551
+ "INICIATIVA_ID": iniciativa_id,
552
+ "ODS_ID": item.get("ods_id", ""),
553
+ "OBJETIVO": item.get("objetivo", ""),
554
+ "ods_rank": rank,
555
+ "iniciativa": iniciativa
556
+ }
557
+ resultados[nivel].append(fila)
558
+ elif nivel == "meta":
559
+ fila = {
560
+ "INICIATIVA_ID": iniciativa_id,
561
+ "ODS_ID": item.get("ods_id", ""),
562
+ "META_ID": item.get("meta_id", ""),
563
+ "OBJETIVO": item.get("objetivo", ""),
564
+ "meta_rank": rank,
565
+ "iniciativa": iniciativa
566
+ }
567
+ resultados[nivel].append(fila)
568
+ elif nivel == "indicador":
569
+ fila = {
570
+ "INICIATIVA_ID": iniciativa_id,
571
+ "ODS_ID": item.get("ods_id", ""),
572
+ "INDICADOR_ID": item.get("indicador_id", ""),
573
+ "OBJETIVO": item.get("objetivo", ""),
574
+ "indicador_rank": rank,
575
+ "iniciativa": iniciativa
576
+ }
577
+ resultados[nivel].append(fila)
578
+ # Pausa para evitar rate limits
579
+ time.sleep(1)
580
+
581
+ # Mostrar resumen
582
+ print()
583
+ print("="*70)
584
+ print("📊 RESUMEN DE RESULTADOS")
585
+ print("="*70)
586
+ print(f"Total errores: {errores_totales}")
587
+ for nivel in niveles:
588
+ print(f" - {nivel.upper()}: {len(resultados[nivel])} registros")
589
+ print()
590
+
591
+ # Crear DataFrames
592
+ df_ods = pd.DataFrame(resultados["ods"])
593
+ if not df_ods.empty:
594
+ df_ods = df_ods[["INICIATIVA_ID", "ODS_ID", "OBJETIVO", "ods_rank", "iniciativa"]]
595
+ print(f"✅ df_ods: {len(df_ods)} registros")
596
+ else:
597
+ print(f"⚠️ df_ods está vacío - revisar respuestas del modelo")
598
+
599
+ df_metas = pd.DataFrame(resultados["meta"])
600
+ if not df_metas.empty:
601
+ df_metas = df_metas[["INICIATIVA_ID", "ODS_ID", "META_ID","OBJETIVO", "meta_rank", "iniciativa"]]
602
+ print(f"✅ df_metas: {len(df_metas)} registros")
603
+ else:
604
+ print(f"⚠️ df_metas está vacío - revisar respuestas del modelo")
605
+
606
+ df_indicadores = pd.DataFrame(columns=["INICIATIVA_ID", "ODS_ID", "INDICADOR_ID", "OBJETIVO", "indicador_rank", "iniciativa"])
607
+
608
+ dfs = {
609
+ "df_ods": df_ods,
610
+ "df_metas": df_metas,
611
+ "df_indicadores": df_indicadores
612
+ }
613
+ dfs = normalizar_rank_df(dfs)
614
+ dfs['df_ods'] = score_creator(dfs['df_ods'], 'ods')
615
+ dfs['df_metas'] = score_creator(dfs['df_metas'], 'meta')
616
+
617
+ print()
618
+ print("="*70)
619
+ print("✅ PROCESAMIENTO COMPLETADO")
620
+ print("="*70)
621
+ print()
622
+
623
+ return dfs['df_ods'], dfs['df_metas'], dfs['df_indicadores'], 'Ejecución completada'
624
+
625
+ def procesar_lote_llm_top(iniciativas: list, mostrar_progreso: bool = True) -> dict:
626
+ """
627
+ Procesa un listado de iniciativas y genera 3 DataFrames:
628
+ - df_ods: Clasificación a nivel ODS
629
+ - df_metas: Clasificación a nivel Meta
630
+ - df_indicadores: Clasificación a nivel Indicador
631
+
632
+ Columnas: INICIATIVA_ID, ODS_ID, OBJETIVO, ods_rank, iniciativa
633
+ """
634
+ niveles = ["ods", "meta", "indicador"]
635
+ # niveles = ["ods", "meta"]
636
+ resultados = {nivel: [] for nivel in niveles}
637
+
638
+ total = len(iniciativas) * len(niveles)
639
+
640
+
641
+ for nivel in niveles:
642
+ contador = 0
643
+ if nivel == "ods":
644
+ for idx, iniciativa in enumerate(iniciativas):
645
+ iniciativa_id = f"INI_{idx + 1:04d}"
646
+
647
+ contador += 1
648
+ if mostrar_progreso:
649
+ print(f"[{contador}/{total}] {iniciativa_id} - {nivel.upper()}: {iniciativa[:50]}...")
650
+
651
+
652
+ resultado = clasificar_nivel(iniciativa, nivel)
653
+
654
+ if resultado["error"]:
655
+ resultados[nivel].append({
656
+ "INICIATIVA_ID": iniciativa_id,
657
+ "ODS_ID": "ERROR",
658
+ "OBJETIVO": resultado["error"],
659
+ "ods_rank": 0,
660
+ "iniciativa": iniciativa
661
+ })
662
+ else:
663
+ for rank, item in enumerate(resultado["items"], start=1):
664
+ if nivel == "ods":
665
+ fila = {
666
+ "INICIATIVA_ID": iniciativa_id,
667
+ "ODS_ID": item.get("ods_id", ""),
668
+ "OBJETIVO": item.get("objetivo", ""),
669
+ "ods_rank": rank,
670
+ "iniciativa": iniciativa
671
+ }
672
+ resultados[nivel].append(fila)
673
+ # Pausa para evitar rate limits
674
+ time.sleep(1)
675
+ # Crear DataFrames
676
+ df_ods = pd.DataFrame(resultados["ods"])
677
+ if not df_ods.empty:
678
+ df_ods = df_ods[["INICIATIVA_ID", "ODS_ID", "OBJETIVO", "ods_rank", "iniciativa"]]
679
+
680
+ elif nivel == "meta":
681
+ for idx, iniciativa in enumerate(iniciativas):
682
+ iniciativa_id = f"INI_{idx + 1:04d}"
683
+
684
+ contador += 1
685
+ if mostrar_progreso:
686
+ print(f"[{contador}/{total}] {iniciativa_id} - {nivel.upper()}: {iniciativa[:50]}...")
687
+
688
+
689
+ for ods in df_ods['ODS_ID'].unique().tolist():
690
+
691
+ resultado = clasificar_nivel_meta_top(iniciativa, ods, nivel)
692
+
693
+ if resultado["error"]:
694
+ resultados[nivel].append({
695
+ "INICIATIVA_ID": iniciativa_id,
696
+ "ODS_ID": "ERROR",
697
+ "OBJETIVO": resultado["error"],
698
+ "ods_rank": 0,
699
+ "iniciativa": iniciativa
700
+ })
701
+ else:
702
+ for rank, item in enumerate(resultado["items"], start=1):
703
+ fila = {
704
+ "INICIATIVA_ID": iniciativa_id,
705
+ "ODS_ID": item.get("ods_id", ""),
706
+ "META_ID": item.get("meta_id", ""),
707
+ "OBJETIVO": item.get("objetivo", ""),
708
+ "meta_rank": rank,
709
+ "iniciativa": iniciativa
710
+ }
711
+ resultados[nivel].append(fila)
712
+ # Pausa para evitar rate limits
713
+ time.sleep(1)
714
+ # Crear DataFrames
715
+ df_metas = pd.DataFrame(resultados["meta"])
716
+ if not df_metas.empty:
717
+ df_metas = df_metas[["INICIATIVA_ID", "ODS_ID", "META_ID","OBJETIVO", "meta_rank", "iniciativa"]]
718
+
719
+
720
+ # elif nivel == "indicador":
721
+ # fila = {
722
+ # "INICIATIVA_ID": iniciativa_id,
723
+ # "ODS_ID": item.get("ods_id", ""),
724
+ # "INDICADOR_ID": item.get("indicador_id", ""),
725
+ # "OBJETIVO": item.get("objetivo", ""),
726
+ # "indicador_rank": rank,
727
+ # "iniciativa": iniciativa
728
+ # }
729
+ # resultados[nivel].append(fila)
730
+ # # Pausa para evitar rate limits
731
+ # time.sleep(1)
732
+
733
+
734
+
735
+
736
+
737
+ df_indicadores = pd.DataFrame(columns=["INICIATIVA_ID", "ODS_ID", "INDICADOR_ID", "OBJETIVO", "indicador_rank", "iniciativa"])
738
+ # if not df_indicadores.empty:
739
+ # df_indicadores = df_indicadores[["INICIATIVA_ID", "ODS_ID", "INDICADOR_ID", "OBJETIVO", "indicador_rank", "iniciativa"]]
740
+ # print(df_indicadores.columns)
741
+
742
+ dfs = {
743
+ "df_ods": df_ods,
744
+ "df_metas": df_metas,
745
+ "df_indicadores": df_indicadores
746
+ }
747
+ dfs = normalizar_rank_df(dfs)
748
+ dfs['df_ods'] = score_creator(dfs['df_ods'], 'ods')
749
+ dfs['df_metas'] = score_creator(dfs['df_metas'], 'meta')
750
+ print(dfs['df_indicadores'].columns)
751
+ dfs['df_indicadores'] = score_creator(dfs['df_indicadores'], 'indicador')
752
+ return dfs['df_ods'], dfs['df_metas'], dfs['df_indicadores'], 'Ejecución completada'
753
+
754
+ # =============================================================================
755
+ # CELDA 8: Prueba con listado de ejemplo
756
+ # =============================================================================
757
+ # iniciativas_prueba = [
758
+ # "Implementación de programas de atención integral a la primera infancia de manera itinerante en zona rural del municipio de Pradera, en función del cumplimiento de los derechos de la primera infancia",
759
+ # "Instalación de paneles solares en comunidades sin acceso a electricidad para garantizar energía limpia y sostenible",
760
+ # "Capacitación digital para mujeres emprendedoras rurales orientada a fortalecer sus habilidades tecnológicas y comerciales",
761
+ # "Construcción de viviendas dignas para familias víctimas del conflicto armado en proceso de reincorporación"
762
+ # ]
763
+
764
+ # print(f"Procesando {len(iniciativas_prueba)} iniciativas...\n")
765
+ # resultado = procesar_lote(iniciativas_prueba)
766
+
767
+ # =============================================================================
768
+ # CELDA 9: Visualizar resultados
769
+ # =============================================================================
770
+ # print("\n" + "="*80)
771
+ # print("📊 NIVEL ODS")
772
+ # print("="*80)
773
+ # display(resultado["df_ods"])
774
+
775
+ # print("\n" + "="*80)
776
+ # print("📊 NIVEL METAS")
777
+ # print("="*80)
778
+ # display(resultado["df_metas"])
779
+
780
+ # print("\n" + "="*80)
781
+ # print("📊 NIVEL INDICADORES")
782
+ # print("="*80)
783
+ # display(resultado["df_indicadores"])
784
+
785
+ # =============================================================================
786
+ # CELDA 10: Exportar a Excel
787
+ # =============================================================================
788
+ # output_file = "clasificacion_ods_por_niveles.xlsx"
789
+
790
+ # with pd.ExcelWriter(output_file, engine="openpyxl") as writer:
791
+ # resultado["df_ods"].to_excel(writer, sheet_name="ODS", index=False)
792
+ # resultado["df_metas"].to_excel(writer, sheet_name="Metas", index=False)
793
+ # resultado["df_indicadores"].to_excel(writer, sheet_name="Indicadores", index=False)
794
+
795
+ # print(f"\n✅ Archivo guardado: {output_file}")
796
+
797
+ # Descargar en Colab
798
+ # from google.colab import files
799
+ # files.download(output_file)
800
+
801
+ # =============================================================================
802
+ # CELDA 11: Función para cargar desde archivo externo
803
+ # =============================================================================
804
+ # def procesar_desde_archivo(columna_iniciativa: str = None):
805
+ # """
806
+ # Carga iniciativas desde un archivo CSV/Excel subido y las procesa.
807
+
808
+ # Uso:
809
+ # resultado = procesar_desde_archivo()
810
+ # # o especificando la columna:
811
+ # resultado = procesar_desde_archivo(columna_iniciativa="descripcion")
812
+ # """
813
+ # # from google.colab import files
814
+
815
+ # print("📁 Sube tu archivo CSV o Excel...")
816
+ # uploaded = files.upload()
817
+
818
+ # if not uploaded:
819
+ # print("No se subió ningún archivo.")
820
+ # return None
821
+
822
+ # archivo = list(uploaded.keys())[0]
823
+ # print(f"Archivo cargado: {archivo}")
824
+
825
+ # # Leer archivo
826
+ # if archivo.endswith(".csv"):
827
+ # df = pd.read_csv(archivo)
828
+ # else:
829
+ # df = pd.read_excel(archivo)
830
+
831
+ # print(f"Columnas disponibles: {list(df.columns)}")
832
+
833
+ # # Detectar columna si no se especifica
834
+ # if columna_iniciativa is None:
835
+ # for col in df.columns:
836
+ # if any(x in col.lower() for x in ["iniciativa", "descripcion", "nombre", "proyecto", "programa"]):
837
+ # columna_iniciativa = col
838
+ # break
839
+ # if columna_iniciativa is None:
840
+ # columna_iniciativa = df.columns[0]
841
+
842
+ # print(f"Usando columna: '{columna_iniciativa}'")
843
+ # print(f"Total iniciativas: {len(df)}")
844
+
845
+ # # Procesar
846
+ # iniciativas = df[columna_iniciativa].astype(str).tolist()
847
+ # return procesar_lote(iniciativas)
848
+
849
+
850
+ # # Ejemplo de uso:
851
+ # # resultado = procesar_desde_archivo()
852
+ # ```
853
+
854
+ # ## Características del código actualizado
855
+
856
+ # | Característica | Descripción |
857
+ # |----------------|-------------|
858
+ # | **Fallback automático** | Si un modelo falla, prueba el siguiente |
859
+ # | **Reintentos** | 3 intentos por modelo antes de cambiar |
860
+ # | **Rate limit handling** | Espera progresiva si hay límites |
861
+ # | **Verificación inicial** | Prueba qué modelos están disponibles |
862
+ # | **Modelos ordenados** | De más potente a más ligero |
863
+
864
+ # ## Estructura de salida
865
+
866
+ # Los 3 DataFrames tendrán estas columnas:
867
+ # ```
868
+ # df_ods: INICIATIVA_ID | ODS_ID | OBJETIVO | ods_rank | iniciativa
869
+ # df_metas: INICIATIVA_ID | ODS_ID | OBJETIVO | ods_rank | iniciativa
870
  # df_indicadores: INICIATIVA_ID | ODS_ID | OBJETIVO | ods_rank | iniciativa