ConectaODSco commited on
Commit
46a897e
·
verified ·
1 Parent(s): 51e6ddb

Upload llm_clasificador_HF.py

Browse files
Files changed (1) hide show
  1. src/embeddings/llm_clasificador_HF.py +548 -0
src/embeddings/llm_clasificador_HF.py ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # CELDA 1: Instalar dependencias
3
+ # =============================================================================
4
+ # !pip install huggingface_hub pandas openpyxl
5
+
6
+ # =============================================================================
7
+ # CELDA 2: Login y configuración
8
+ # =============================================================================
9
+ from huggingface_hub import InferenceClient, login
10
+ import pandas as pd
11
+ import re
12
+ import time
13
+
14
+ # Login interactivo (ejecutar una vez, te pedirá el token)
15
+ login()
16
+
17
+ # Crear cliente
18
+ client = InferenceClient()
19
+
20
+ # Modelos en orden de preferencia (fallback automático)
21
+ MODELOS = [
22
+ "Qwen/Qwen3-8B",
23
+ "meta-llama/Llama-3.1-8B-Instruct",
24
+ "Qwen/Qwen2.5-7B-Instruct",
25
+ "mistralai/Mistral-7B-Instruct-v0.2",
26
+ ]
27
+
28
+ # =============================================================================
29
+ # CELDA 3: Verificar modelos disponibles
30
+ # =============================================================================
31
+ def verificar_modelos():
32
+ """Verifica qué modelos están disponibles y retorna el primero funcional."""
33
+ for modelo in MODELOS:
34
+ try:
35
+ respuesta = client.chat_completion(
36
+ model=modelo,
37
+ messages=[{"role": "user", "content": "Responde únicamente: OK"}],
38
+ max_tokens=5
39
+ )
40
+ print(f"✅ {modelo}: Disponible")
41
+ return modelo
42
+ except Exception as e:
43
+ print(f"❌ {modelo}: {str(e)[:60]}")
44
+ return None
45
+
46
+ print("Verificando modelos disponibles...\n")
47
+ MODELO_ACTIVO = verificar_modelos()
48
+
49
+ if MODELO_ACTIVO:
50
+ print(f"\n🎯 Modelo seleccionado: {MODELO_ACTIVO}")
51
+ else:
52
+ print("\n⚠️ Ningún modelo disponible. Verifica tu token.")
53
+
54
+ # =============================================================================
55
+ # CELDA 4: Prompts por nivel
56
+ # =============================================================================
57
+ PROMPT_ODS = """Eres un experto en Objetivos de Desarrollo Sostenible (ODS) de las Naciones Unidas.
58
+
59
+ Vincula entre 3 y 5 ODS que tengan relación directa con la siguiente iniciativa:
60
+
61
+ "{iniciativa}"
62
+
63
+ Para cada ODS incluye:
64
+ - Número y nombre del ODS
65
+ - Justificación breve del vínculo (impacto social, territorial, de paz, infraestructura o inclusión)
66
+
67
+ FORMATO DE RESPUESTA (una línea por ODS, ordenados por relevancia):
68
+ ODS [número]: [nombre] – [justificación]
69
+
70
+ Responde ÚNICAMENTE con el listado, sin texto introductorio ni conclusiones."""
71
+
72
+
73
+ PROMPT_META = """Eres un experto en Objetivos de Desarrollo Sostenible (ODS) de las Naciones Unidas.
74
+
75
+ Identifica entre 3 y 5 Metas específicas de los ODS que tengan relación directa con la siguiente iniciativa:
76
+
77
+ "{iniciativa}"
78
+
79
+ Para cada meta incluye:
80
+ - Código de la meta (ej: 4.2, 1.3, 16.10)
81
+ - Descripción de la meta
82
+ - Justificación breve del vínculo con la iniciativa
83
+
84
+ FORMATO DE RESPUESTA (una línea por meta, ordenadas por relevancia):
85
+ Meta [código]: [descripción] – [justificación]
86
+
87
+ Responde ÚNICAMENTE con el listado, sin texto introductorio ni conclusiones."""
88
+
89
+
90
+ PROMPT_INDICADOR = """Eres un experto en Objetivos de Desarrollo Sostenible (ODS) de las Naciones Unidas.
91
+
92
+ Identifica entre 3 y 5 Indicadores de los ODS que permitan medir el impacto de la siguiente iniciativa:
93
+
94
+ "{iniciativa}"
95
+
96
+ Para cada indicador incluye:
97
+ - Código del indicador (ej: 4.2.1, 1.3.1, 16.10.1)
98
+ - Descripción del indicador
99
+ - Justificación de cómo se relaciona con la medición de la iniciativa
100
+
101
+ FORMATO DE RESPUESTA (una línea por indicador, ordenados por relevancia):
102
+ Indicador [código]: [descripción] – [justificación]
103
+
104
+ Responde ÚNICAMENTE con el listado, sin texto introductorio ni conclusiones."""
105
+
106
+ PROMPTS = {
107
+ "ods": PROMPT_ODS,
108
+ "meta": PROMPT_META,
109
+ "indicador": PROMPT_INDICADOR
110
+ }
111
+
112
+ # =============================================================================
113
+ # CELDA 5: Funciones de parsing por nivel
114
+ # =============================================================================
115
+ def parsear_ods(texto: str) -> list:
116
+ """Extrae ODS de la respuesta."""
117
+ items = []
118
+ for linea in texto.strip().split("\n"):
119
+ linea = linea.strip()
120
+ if not linea:
121
+ continue
122
+ match = re.match(r"(?:\d+[\.\)\-]?\s*)?ODS\s*(\d+):?\s*(.+)", linea, re.IGNORECASE)
123
+ if match:
124
+ ods_num = match.group(1)
125
+ resto = match.group(2).strip()
126
+ items.append({
127
+ "ods_id": f"ODS {ods_num}",
128
+ "objetivo": resto
129
+ })
130
+ return items
131
+
132
+
133
+ def parsear_metas(texto: str) -> list:
134
+ """Extrae metas 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*)?Meta\s*([\d\.]+):?\s*(.+)", linea, re.IGNORECASE)
141
+ if match:
142
+ meta_codigo = match.group(1)
143
+ resto = match.group(2).strip()
144
+ ods_num = meta_codigo.split(".")[0]
145
+ items.append({
146
+ "ods_id": f"ODS {ods_num}",
147
+ "meta_id": f"Meta {meta_codigo}",
148
+ "objetivo": resto
149
+ })
150
+ return items
151
+
152
+
153
+ def parsear_indicadores(texto: str) -> list:
154
+ """Extrae indicadores de la respuesta."""
155
+ items = []
156
+ for linea in texto.strip().split("\n"):
157
+ linea = linea.strip()
158
+ if not linea:
159
+ continue
160
+ match = re.match(r"(?:\d+[\.\)\-]?\s*)?Indicador\s*([\d\.]+):?\s*(.+)", linea, re.IGNORECASE)
161
+ if match:
162
+ ind_codigo = match.group(1)
163
+ resto = match.group(2).strip()
164
+ ods_num = ind_codigo.split(".")[0]
165
+ items.append({
166
+ "ods_id": f"ODS {ods_num}",
167
+ "indicador_id": f"Indicador {ind_codigo}",
168
+ "objetivo": resto
169
+ })
170
+ return items
171
+
172
+
173
+ PARSERS = {
174
+ "ods": parsear_ods,
175
+ "meta": parsear_metas,
176
+ "indicador": parsear_indicadores
177
+ }
178
+
179
+ # =============================================================================
180
+ # CELDA 6: Función de clasificación con fallback
181
+ # =============================================================================
182
+ def llamar_modelo(prompt: str, intentos_maximos: int = 3) -> str:
183
+ """Llama al modelo con reintentos y fallback."""
184
+ for modelo in MODELOS:
185
+ for intento in range(intentos_maximos):
186
+ try:
187
+ respuesta = client.chat_completion(
188
+ model=modelo,
189
+ messages=[{"role": "user", "content": prompt}],
190
+ max_tokens=1200,
191
+ temperature=0.3
192
+ )
193
+ return respuesta.choices[0].message.content
194
+ except Exception as e:
195
+ error_msg = str(e).lower()
196
+ # Si es rate limit, esperar y reintentar
197
+ if "rate" in error_msg or "limit" in error_msg or "429" in error_msg:
198
+ wait_time = (intento + 1) * 5
199
+ print(f" ⏳ Rate limit, esperando {wait_time}s...")
200
+ time.sleep(wait_time)
201
+ continue
202
+ # Si es error de modelo, pasar al siguiente
203
+ elif "not supported" in error_msg or "not available" in error_msg:
204
+ break
205
+ # Otros errores, reintentar
206
+ else:
207
+ time.sleep(2)
208
+ continue
209
+
210
+ raise Exception("Todos los modelos fallaron")
211
+
212
+
213
+ def clasificar_nivel(iniciativa: str, nivel: str) -> dict:
214
+ """Clasifica una iniciativa en un nivel específico (ods, meta, indicador)."""
215
+ prompt = PROMPTS[nivel].format(iniciativa=iniciativa)
216
+ parser = PARSERS[nivel]
217
+
218
+ try:
219
+ texto = llamar_modelo(prompt)
220
+ items = parser(texto)
221
+
222
+ return {
223
+ "iniciativa": iniciativa,
224
+ "items": items,
225
+ "respuesta_raw": texto,
226
+ "error": None
227
+ }
228
+ except Exception as e:
229
+ return {
230
+ "iniciativa": iniciativa,
231
+ "items": [],
232
+ "respuesta_raw": "",
233
+ "error": str(e)
234
+ }
235
+
236
+ from sklearn.preprocessing import MinMaxScaler
237
+
238
+
239
+ def normalizar_rank_df(dfs: dict) -> dict:
240
+ """
241
+ Normaliza las columnas de ranking ('ods_rank', 'meta_rank', 'indicador_rank')
242
+ en los DataFrames proporcionados en un diccionario usando Min-Max scaling.
243
+ Añade una nueva columna '_norm' para el rank normalizado.
244
+ """
245
+ # Ajustar el rango de normalización para evitar el 0
246
+ scaler = MinMaxScaler(feature_range=(0.0, 0.99))
247
+ df_actualizados = {}
248
+
249
+ for df_name, df in dfs.items():
250
+ if not df.empty:
251
+ df_temp = df.copy()
252
+ rank_column = None
253
+ if 'ods_rank' in df_temp.columns:
254
+ rank_column = 'ods_rank'
255
+ elif 'meta_rank' in df_temp.columns:
256
+ rank_column = 'meta_rank'
257
+ elif 'indicador_rank' in df_temp.columns:
258
+ rank_column = 'indicador_rank'
259
+
260
+ if rank_column:
261
+ print(rank_column)
262
+ # Asegurarse de que el rank_column es numérico y no tiene NaN que puedan causar problemas
263
+ df_temp[rank_column] = pd.to_numeric(df_temp[rank_column], errors='coerce')
264
+ df_temp.dropna(subset=[rank_column], inplace=True)
265
+
266
+ if not df_temp.empty and df_temp[rank_column].nunique() > 1:
267
+ df_temp[f'{rank_column.replace('_rank','')}_similaridad_cos_normalized'] = scaler.fit_transform(df_temp[[rank_column]])
268
+ df_temp[f'{rank_column.replace('_rank','')}_similaridad_cos_normalized'] = 1 - df_temp[f'{rank_column.replace('_rank','')}_similaridad_cos_normalized']
269
+ elif not df_temp.empty and df_temp[rank_column].nunique() == 1:
270
+ df_temp[f'{rank_column.replace('_rank','')}_similaridad_cos_normalized'] = 0.5 # Asigna un valor medio si solo hay un valor único
271
+ df_temp[f'{rank_column.replace('_rank','')}_similaridad_cos_normalized'] = 1 - df_temp[f'{rank_column.replace('_rank','')}_similaridad_cos_normalized']
272
+ else:
273
+ df_temp[f'{rank_column.replace('_rank','')}_similaridad_cos_normalized'] = None # No se puede normalizar si está vacío o no numérico
274
+ df_temp[f'{rank_column.replace('_rank','')}_similaridad_cos_normalized'] = 1 - df_temp[f'{rank_column.replace('_rank','')}_similaridad_cos_normalized']
275
+
276
+ df_actualizados[df_name] = df_temp
277
+ else:
278
+ df_actualizados[df_name] = df # Devolver el DataFrame vacío sin cambios
279
+
280
+ return df_actualizados
281
+
282
+
283
+ def score_creator(df_pareto, nivel, sim_prop = 0, rank_prop = 0.6):
284
+ stats = df_pareto.groupby(f'{nivel.upper()}_ID').agg({
285
+ f'{nivel}_similaridad_cos_normalized': 'mean',
286
+ f'{nivel}_rank': 'mean',
287
+ 'INICIATIVA_ID': 'count'
288
+ })
289
+
290
+ # Normalizar componentes
291
+ stats['sim_norm'] = stats[f'{nivel}_similaridad_cos_normalized']
292
+ stats['rank_norm'] = 1 - (stats[f'{nivel}_rank'] - stats[f'{nivel}_rank'].min()) / (stats[f'{nivel}_rank'].max() - stats[f'{nivel}_rank'].min())
293
+ stats['freq_norm'] = stats['INICIATIVA_ID'] / stats['INICIATIVA_ID'].max()
294
+
295
+ # Score mixto
296
+ stats['score'] = (
297
+ sim_prop * stats['sim_norm'] +
298
+ rank_prop * stats['rank_norm'] +
299
+ (1 - sim_prop - rank_prop) * stats['freq_norm']
300
+ )
301
+
302
+ resultado = stats[['score', 'INICIATIVA_ID']].reset_index()
303
+ resultado = resultado.sort_values('score', ascending=False).reset_index().rename(columns={'INICIATIVA_ID': 'frecuencia',
304
+ 'index': 'rank'})
305
+ resultado['rank'] = resultado.index + 1
306
+
307
+ # Crear columna ODS_ID de forma robusta para todos los niveles
308
+ if nivel == 'ods':
309
+ # Para ODS, el ID ya es el ODS_ID
310
+ resultado['ODS_ID'] = resultado['ODS_ID'].astype(str)
311
+ elif nivel == 'meta':
312
+ # Extraer ODS del META_ID (formato: "1.1", tomar el primer dígito)
313
+ def extraer_ods_de_meta(meta_id):
314
+ try:
315
+ if pd.isna(meta_id):
316
+ return None
317
+ ods_num = str(meta_id).split('.')[0].strip()
318
+ return ods_num if ods_num else None
319
+ except:
320
+ return None
321
+ resultado['ODS_ID'] = resultado['META_ID'].apply(extraer_ods_de_meta)
322
+ elif nivel == 'indicador':
323
+ # Extraer ODS del INDICADOR_ID (formato: "1.1.1", tomar el primer dígito)
324
+ def extraer_ods_de_indicador(ind_id):
325
+ try:
326
+ if pd.isna(ind_id):
327
+ return None
328
+ ods_num = str(ind_id).split('.')[0].strip()
329
+ return ods_num if ods_num else None
330
+ except:
331
+ return None
332
+ resultado['ODS_ID'] = resultado['INDICADOR_ID'].apply(extraer_ods_de_indicador)
333
+
334
+
335
+ return resultado
336
+ # =============================================================================
337
+ # CELDA 7: Función principal de procesamiento por lotes
338
+ # =============================================================================
339
+
340
+
341
+ def procesar_lote(iniciativas: list, mostrar_progreso: bool = True) -> dict:
342
+ """
343
+ Procesa un listado de iniciativas y genera 3 DataFrames:
344
+ - df_ods: Clasificación a nivel ODS
345
+ - df_metas: Clasificación a nivel Meta
346
+ - df_indicadores: Clasificación a nivel Indicador
347
+
348
+ Columnas: INICIATIVA_ID, ODS_ID, OBJETIVO, ods_rank, iniciativa
349
+ """
350
+ niveles = ["ods", "meta", "indicador"]
351
+ resultados = {nivel: [] for nivel in niveles}
352
+
353
+ total = len(iniciativas) * len(niveles)
354
+ contador = 0
355
+
356
+ for idx, iniciativa in enumerate(iniciativas):
357
+ iniciativa_id = f"INI_{idx + 1:04d}"
358
+
359
+ for nivel in niveles:
360
+ contador += 1
361
+ if mostrar_progreso:
362
+ print(f"[{contador}/{total}] {iniciativa_id} - {nivel.upper()}: {iniciativa[:50]}...")
363
+
364
+ resultado = clasificar_nivel(iniciativa, nivel)
365
+
366
+ if resultado["error"]:
367
+ resultados[nivel].append({
368
+ "INICIATIVA_ID": iniciativa_id,
369
+ "ODS_ID": "ERROR",
370
+ "OBJETIVO": resultado["error"],
371
+ "ods_rank": 0,
372
+ "iniciativa": iniciativa
373
+ })
374
+ else:
375
+ for rank, item in enumerate(resultado["items"], start=1):
376
+ if nivel == "ods":
377
+ fila = {
378
+ "INICIATIVA_ID": iniciativa_id,
379
+ "ODS_ID": item.get("ods_id", ""),
380
+ "OBJETIVO": item.get("objetivo", ""),
381
+ "ods_rank": rank,
382
+ "iniciativa": iniciativa
383
+ }
384
+ resultados[nivel].append(fila)
385
+ elif nivel == "meta":
386
+ fila = {
387
+ "INICIATIVA_ID": iniciativa_id,
388
+ "ODS_ID": item.get("ods_id", ""),
389
+ "META_ID": item.get("meta_id", ""),
390
+ "OBJETIVO": item.get("objetivo", ""),
391
+ "meta_rank": rank,
392
+ "iniciativa": iniciativa
393
+ }
394
+ resultados[nivel].append(fila)
395
+ elif nivel == "indicador":
396
+ fila = {
397
+ "INICIATIVA_ID": iniciativa_id,
398
+ "ODS_ID": item.get("ods_id", ""),
399
+ "INDICADOR_ID": item.get("indicador_id", ""),
400
+ "OBJETIVO": item.get("objetivo", ""),
401
+ "indicador_rank": rank,
402
+ "iniciativa": iniciativa
403
+ }
404
+ resultados[nivel].append(fila)
405
+ # Pausa para evitar rate limits
406
+ time.sleep(1)
407
+
408
+ # Crear DataFrames
409
+ df_ods = pd.DataFrame(resultados["ods"])
410
+ if not df_ods.empty:
411
+ df_ods = df_ods[["INICIATIVA_ID", "ODS_ID", "OBJETIVO", "ods_rank", "iniciativa"]]
412
+
413
+ df_metas = pd.DataFrame(resultados["meta"])
414
+ if not df_metas.empty:
415
+ df_metas = df_metas[["INICIATIVA_ID", "ODS_ID", "META_ID","OBJETIVO", "meta_rank", "iniciativa"]]
416
+
417
+ df_indicadores = pd.DataFrame(resultados["indicador"])
418
+ if not df_indicadores.empty:
419
+ df_indicadores = df_indicadores[["INICIATIVA_ID", "ODS_ID", "INDICADOR_ID", "OBJETIVO", "indicador_rank", "iniciativa"]]
420
+
421
+ dfs = {
422
+ "df_ods": df_ods,
423
+ "df_metas": df_metas,
424
+ "df_indicadores": df_indicadores
425
+ }
426
+ dfs = normalizar_rank_df(dfs)
427
+ dfs['df_ods'] = score_creator(dfs['df_ods'], 'ods')
428
+ dfs['df_metas'] = score_creator(dfs['df_metas'], 'meta')
429
+ dfs['df_indicadores'] = score_creator(dfs['df_indicadores'], 'indicador')
430
+ return dfs['df_ods'], dfs['df_metas'], dfs['df_indicadores']
431
+
432
+ # =============================================================================
433
+ # CELDA 8: Prueba con listado de ejemplo
434
+ # =============================================================================
435
+ # iniciativas_prueba = [
436
+ # "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",
437
+ # "Instalación de paneles solares en comunidades sin acceso a electricidad para garantizar energía limpia y sostenible",
438
+ # "Capacitación digital para mujeres emprendedoras rurales orientada a fortalecer sus habilidades tecnológicas y comerciales",
439
+ # "Construcción de viviendas dignas para familias víctimas del conflicto armado en proceso de reincorporación"
440
+ # ]
441
+
442
+ # print(f"Procesando {len(iniciativas_prueba)} iniciativas...\n")
443
+ # resultado = procesar_lote(iniciativas_prueba)
444
+
445
+ # =============================================================================
446
+ # CELDA 9: Visualizar resultados
447
+ # =============================================================================
448
+ # print("\n" + "="*80)
449
+ # print("📊 NIVEL ODS")
450
+ # print("="*80)
451
+ # display(resultado["df_ods"])
452
+
453
+ # print("\n" + "="*80)
454
+ # print("📊 NIVEL METAS")
455
+ # print("="*80)
456
+ # display(resultado["df_metas"])
457
+
458
+ # print("\n" + "="*80)
459
+ # print("📊 NIVEL INDICADORES")
460
+ # print("="*80)
461
+ # display(resultado["df_indicadores"])
462
+
463
+ # =============================================================================
464
+ # CELDA 10: Exportar a Excel
465
+ # =============================================================================
466
+ # output_file = "clasificacion_ods_por_niveles.xlsx"
467
+
468
+ # with pd.ExcelWriter(output_file, engine="openpyxl") as writer:
469
+ # resultado["df_ods"].to_excel(writer, sheet_name="ODS", index=False)
470
+ # resultado["df_metas"].to_excel(writer, sheet_name="Metas", index=False)
471
+ # resultado["df_indicadores"].to_excel(writer, sheet_name="Indicadores", index=False)
472
+
473
+ # print(f"\n✅ Archivo guardado: {output_file}")
474
+
475
+ # Descargar en Colab
476
+ # from google.colab import files
477
+ # files.download(output_file)
478
+
479
+ # =============================================================================
480
+ # CELDA 11: Función para cargar desde archivo externo
481
+ # =============================================================================
482
+ # def procesar_desde_archivo(columna_iniciativa: str = None):
483
+ # """
484
+ # Carga iniciativas desde un archivo CSV/Excel subido y las procesa.
485
+
486
+ # Uso:
487
+ # resultado = procesar_desde_archivo()
488
+ # # o especificando la columna:
489
+ # resultado = procesar_desde_archivo(columna_iniciativa="descripcion")
490
+ # """
491
+ # # from google.colab import files
492
+
493
+ # print("📁 Sube tu archivo CSV o Excel...")
494
+ # uploaded = files.upload()
495
+
496
+ # if not uploaded:
497
+ # print("No se subió ningún archivo.")
498
+ # return None
499
+
500
+ # archivo = list(uploaded.keys())[0]
501
+ # print(f"Archivo cargado: {archivo}")
502
+
503
+ # # Leer archivo
504
+ # if archivo.endswith(".csv"):
505
+ # df = pd.read_csv(archivo)
506
+ # else:
507
+ # df = pd.read_excel(archivo)
508
+
509
+ # print(f"Columnas disponibles: {list(df.columns)}")
510
+
511
+ # # Detectar columna si no se especifica
512
+ # if columna_iniciativa is None:
513
+ # for col in df.columns:
514
+ # if any(x in col.lower() for x in ["iniciativa", "descripcion", "nombre", "proyecto", "programa"]):
515
+ # columna_iniciativa = col
516
+ # break
517
+ # if columna_iniciativa is None:
518
+ # columna_iniciativa = df.columns[0]
519
+
520
+ # print(f"Usando columna: '{columna_iniciativa}'")
521
+ # print(f"Total iniciativas: {len(df)}")
522
+
523
+ # # Procesar
524
+ # iniciativas = df[columna_iniciativa].astype(str).tolist()
525
+ # return procesar_lote(iniciativas)
526
+
527
+
528
+ # # Ejemplo de uso:
529
+ # # resultado = procesar_desde_archivo()
530
+ # ```
531
+
532
+ # ## Características del código actualizado
533
+
534
+ # | Característica | Descripción |
535
+ # |----------------|-------------|
536
+ # | **Fallback automático** | Si un modelo falla, prueba el siguiente |
537
+ # | **Reintentos** | 3 intentos por modelo antes de cambiar |
538
+ # | **Rate limit handling** | Espera progresiva si hay límites |
539
+ # | **Verificación inicial** | Prueba qué modelos están disponibles |
540
+ # | **Modelos ordenados** | De más potente a más ligero |
541
+
542
+ # ## Estructura de salida
543
+
544
+ # Los 3 DataFrames tendrán estas columnas:
545
+ # ```
546
+ # df_ods: INICIATIVA_ID | ODS_ID | OBJETIVO | ods_rank | iniciativa
547
+ # df_metas: INICIATIVA_ID | ODS_ID | OBJETIVO | ods_rank | iniciativa
548
+ # df_indicadores: INICIATIVA_ID | ODS_ID | OBJETIVO | ods_rank | iniciativa