dataframeit 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ from .core import dataframeit
2
+ from .utils import (
3
+ normalize_value,
4
+ normalize_complex_columns,
5
+ get_complex_fields,
6
+ read_df,
7
+ )
8
+
9
+ __version__ = "0.5.0"
10
+
11
+ __all__ = [
12
+ 'dataframeit',
13
+ 'read_df',
14
+ 'normalize_value',
15
+ 'normalize_complex_columns',
16
+ 'get_complex_fields',
17
+ ]
dataframeit/core.py ADDED
@@ -0,0 +1,638 @@
1
+ import warnings
2
+ import time
3
+ import logging
4
+ import threading
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+ from typing import Union, Any, Optional
7
+ import pandas as pd
8
+ from tqdm import tqdm
9
+
10
+ from .llm import LLMConfig, call_langchain
11
+ from .utils import (
12
+ to_pandas,
13
+ from_pandas,
14
+ get_complex_fields,
15
+ normalize_complex_columns,
16
+ DEFAULT_TEXT_COLUMN,
17
+ ORIGINAL_TYPE_PANDAS_DF,
18
+ ORIGINAL_TYPE_POLARS_DF,
19
+ )
20
+ from .errors import validate_provider_dependencies, get_friendly_error_message, is_recoverable_error, is_rate_limit_error
21
+
22
+
23
+ # Suprimir mensagens de retry do LangChain (elas são redundantes com nossos warnings)
24
+ logging.getLogger('langchain_google_genai').setLevel(logging.ERROR)
25
+ logging.getLogger('langchain_core').setLevel(logging.WARNING)
26
+ logging.getLogger('httpx').setLevel(logging.WARNING)
27
+
28
+
29
+ def dataframeit(
30
+ data,
31
+ questions=None,
32
+ prompt=None,
33
+ perguntas=None, # Deprecated: use 'questions'
34
+ resume=True,
35
+ reprocess_columns=None,
36
+ model='gemini-3.0-flash',
37
+ provider='google_genai',
38
+ status_column=None,
39
+ text_column: Optional[str] = None,
40
+ api_key=None,
41
+ max_retries=3,
42
+ base_delay=1.0,
43
+ max_delay=30.0,
44
+ rate_limit_delay=0.0,
45
+ track_tokens=True,
46
+ model_kwargs=None,
47
+ parallel_requests=1,
48
+ ) -> Any:
49
+ """Processa textos usando LLMs para extrair informações estruturadas.
50
+
51
+ Suporta múltiplos tipos de entrada:
52
+ - pandas.DataFrame: Retorna DataFrame com colunas extraídas
53
+ - polars.DataFrame: Retorna DataFrame polars com colunas extraídas
54
+ - pandas.Series: Retorna DataFrame com resultados indexados
55
+ - polars.Series: Retorna DataFrame polars com resultados
56
+ - list: Retorna lista de dicionários com os resultados
57
+ - dict: Retorna dicionário {chave: {campos extraídos}}
58
+
59
+ Args:
60
+ data: Dados contendo textos (DataFrame, Series, list ou dict).
61
+ questions: Modelo Pydantic definindo estrutura a extrair.
62
+ prompt: Template do prompt (use {texto} para indicar onde inserir o texto).
63
+ perguntas: (Deprecated) Use 'questions'.
64
+ resume: Se True, continua de onde parou.
65
+ reprocess_columns: Lista de colunas para forçar reprocessamento. Útil para
66
+ atualizar colunas específicas com novas instruções sem perder outras.
67
+ model: Nome do modelo LLM.
68
+ provider: Provider do LangChain ('google_genai', 'openai', 'anthropic', etc).
69
+ status_column: Coluna para rastrear progresso.
70
+ text_column: Nome da coluna com textos (obrigatório para DataFrames,
71
+ automático para Series/list/dict).
72
+ api_key: Chave API específica.
73
+ max_retries: Número máximo de tentativas.
74
+ base_delay: Delay base para retry.
75
+ max_delay: Delay máximo para retry.
76
+ rate_limit_delay: Delay em segundos entre requisições para evitar rate limits (padrão: 0.0).
77
+ track_tokens: Se True, rastreia uso de tokens e exibe estatísticas (padrão: True).
78
+ model_kwargs: Parâmetros extras para o modelo LangChain (ex: temperature, reasoning_effort).
79
+ parallel_requests: Número de requisições paralelas (padrão: 1 = sequencial).
80
+ Se > 1, processa múltiplas linhas simultaneamente.
81
+ Ao detectar erro de rate limit (429), o número de workers é reduzido automaticamente.
82
+ Dica: use track_tokens=True para ver métricas de throughput (RPM, TPM) e calibrar.
83
+
84
+ Returns:
85
+ Dados com informações extraídas no mesmo formato da entrada.
86
+
87
+ Raises:
88
+ ValueError: Se parâmetros obrigatórios faltarem.
89
+ TypeError: Se tipo de dados não for suportado.
90
+ """
91
+ # Compatibilidade com API antiga
92
+ if questions is None and perguntas is not None:
93
+ questions = perguntas
94
+ elif questions is None:
95
+ raise ValueError("Parâmetro 'questions' é obrigatório")
96
+
97
+ if prompt is None:
98
+ raise ValueError("Parâmetro 'prompt' é obrigatório")
99
+
100
+ # Se {texto} não estiver no template, adiciona automaticamente ao final
101
+ if '{texto}' not in prompt:
102
+ prompt = prompt.rstrip() + "\n\nTexto a analisar:\n{texto}"
103
+
104
+ # Validar dependências ANTES de iniciar (falha rápido com mensagem clara)
105
+ validate_provider_dependencies(provider)
106
+
107
+ # Converter para pandas se necessário
108
+ df_pandas, conversion_info = to_pandas(data)
109
+
110
+ # Determinar coluna de texto
111
+ is_dataframe_type = conversion_info.original_type in (
112
+ ORIGINAL_TYPE_PANDAS_DF,
113
+ ORIGINAL_TYPE_POLARS_DF,
114
+ )
115
+
116
+ if is_dataframe_type:
117
+ # Para DataFrames, usa 'texto' como padrão se não especificado
118
+ if text_column is None:
119
+ text_column = 'texto'
120
+ if text_column not in df_pandas.columns:
121
+ raise ValueError(f"Coluna '{text_column}' não encontrada no DataFrame")
122
+ else:
123
+ # Para Series/list/dict, usa coluna interna
124
+ text_column = DEFAULT_TEXT_COLUMN
125
+
126
+ # Extrair campos do modelo Pydantic
127
+ expected_columns = list(questions.model_fields.keys())
128
+ if not expected_columns:
129
+ raise ValueError("Modelo Pydantic não pode estar vazio")
130
+
131
+ # Validar reprocess_columns
132
+ if reprocess_columns is not None:
133
+ if not isinstance(reprocess_columns, (list, tuple)):
134
+ reprocess_columns = [reprocess_columns]
135
+ # Verificar que todas as colunas a reprocessar estão no modelo
136
+ invalid_cols = [col for col in reprocess_columns if col not in expected_columns]
137
+ if invalid_cols:
138
+ raise ValueError(
139
+ f"Colunas {invalid_cols} não estão no modelo Pydantic. "
140
+ f"Colunas disponíveis: {expected_columns}"
141
+ )
142
+
143
+ # Verificar conflitos de colunas
144
+ existing_cols = [col for col in expected_columns if col in df_pandas.columns]
145
+ if existing_cols and not resume and not reprocess_columns:
146
+ warnings.warn(
147
+ f"Colunas {existing_cols} já existem. Use resume=True para continuar ou renomeie-as."
148
+ )
149
+ return from_pandas(df_pandas, conversion_info)
150
+
151
+ # Configurar colunas
152
+ _setup_columns(df_pandas, expected_columns, status_column, resume, track_tokens)
153
+
154
+ # Normalizar colunas complexas (listas, dicts, tuples) que podem ter sido
155
+ # serializadas como strings JSON ao salvar/carregar de arquivos
156
+ complex_fields = get_complex_fields(questions)
157
+ if complex_fields and resume:
158
+ normalize_complex_columns(df_pandas, complex_fields)
159
+
160
+ # Determinar coluna de status
161
+ status_col = status_column or '_dataframeit_status'
162
+
163
+ # Determinar onde começar
164
+ start_pos, processed_count = _get_processing_indices(df_pandas, status_col, resume, reprocess_columns)
165
+
166
+ # Criar config do LLM
167
+ config = LLMConfig(
168
+ model=model,
169
+ provider=provider,
170
+ api_key=api_key,
171
+ max_retries=max_retries,
172
+ base_delay=base_delay,
173
+ max_delay=max_delay,
174
+ rate_limit_delay=rate_limit_delay,
175
+ model_kwargs=model_kwargs or {},
176
+ )
177
+
178
+ # Processar linhas (escolher entre sequencial e paralelo)
179
+ if parallel_requests > 1:
180
+ token_stats = _process_rows_parallel(
181
+ df_pandas,
182
+ questions,
183
+ prompt,
184
+ text_column,
185
+ status_col,
186
+ expected_columns,
187
+ config,
188
+ start_pos,
189
+ processed_count,
190
+ conversion_info,
191
+ track_tokens,
192
+ reprocess_columns,
193
+ parallel_requests,
194
+ )
195
+ else:
196
+ token_stats = _process_rows(
197
+ df_pandas,
198
+ questions,
199
+ prompt,
200
+ text_column,
201
+ status_col,
202
+ expected_columns,
203
+ config,
204
+ start_pos,
205
+ processed_count,
206
+ conversion_info,
207
+ track_tokens,
208
+ reprocess_columns,
209
+ )
210
+
211
+ # Exibir estatísticas de tokens e throughput
212
+ if track_tokens and token_stats and any(token_stats.values()):
213
+ _print_token_stats(token_stats, model, parallel_requests)
214
+
215
+ # Aviso de workers reduzidos (aparece SEMPRE, independente de track_tokens)
216
+ if token_stats.get('workers_reduced'):
217
+ print("\n" + "=" * 60)
218
+ print("AVISO: WORKERS REDUZIDOS POR RATE LIMIT")
219
+ print("=" * 60)
220
+ print(f"Workers iniciais: {token_stats['initial_workers']}")
221
+ print(f"Workers finais: {token_stats['final_workers']}")
222
+ print(f"\nDica: Considere usar parallel_requests={token_stats['final_workers']} "
223
+ f"para evitar rate limits.")
224
+ print("=" * 60 + "\n")
225
+
226
+ # Retornar no formato original (remove colunas de status/erro se não houver erros)
227
+ return from_pandas(df_pandas, conversion_info)
228
+
229
+
230
+ def _setup_columns(df: pd.DataFrame, expected_columns: list, status_column: Optional[str], resume: bool, track_tokens: bool):
231
+ """Configura colunas necessárias no DataFrame (in-place)."""
232
+ status_col = status_column or '_dataframeit_status'
233
+ error_col = '_error_details'
234
+ token_cols = ['_input_tokens', '_output_tokens', '_total_tokens'] if track_tokens else []
235
+
236
+ # Identificar colunas que precisam ser criadas
237
+ new_cols = [col for col in expected_columns if col not in df.columns]
238
+ needs_status = status_col not in df.columns
239
+ needs_error = error_col not in df.columns
240
+ needs_tokens = [col for col in token_cols if col not in df.columns] if track_tokens else []
241
+
242
+ if not new_cols and not needs_status and not needs_error and not needs_tokens:
243
+ return
244
+
245
+ # Criar colunas
246
+ with pd.option_context('mode.chained_assignment', None):
247
+ for col in new_cols:
248
+ df[col] = None
249
+ if needs_status:
250
+ df[status_col] = None
251
+ if needs_error:
252
+ df[error_col] = None
253
+ if track_tokens:
254
+ for col in needs_tokens:
255
+ df[col] = None
256
+
257
+
258
+ def _get_processing_indices(df: pd.DataFrame, status_col: str, resume: bool, reprocess_columns=None) -> tuple[int, int]:
259
+ """Retorna (posição inicial, contagem de processados).
260
+
261
+ Nota: quando reprocess_columns está definido, start_pos é ignorado em _process_rows
262
+ pois todas as linhas são processadas (mas só atualiza colunas específicas nas já processadas).
263
+ """
264
+ if not resume:
265
+ return 0, 0
266
+
267
+ # Encontrar primeira linha não processada
268
+ null_mask = df[status_col].isnull()
269
+ unprocessed_indices = df.index[null_mask]
270
+
271
+ if not unprocessed_indices.empty:
272
+ first_unprocessed = unprocessed_indices.min()
273
+ start_pos = df.index.get_loc(first_unprocessed)
274
+ else:
275
+ start_pos = len(df)
276
+
277
+ processed_count = len(df) - len(unprocessed_indices)
278
+ return start_pos, processed_count
279
+
280
+
281
+ def _print_token_stats(token_stats: dict, model: str, parallel_requests: int = 1):
282
+ """Exibe estatísticas de uso de tokens e throughput.
283
+
284
+ Args:
285
+ token_stats: Dict com contadores de tokens e métricas de tempo.
286
+ model: Nome do modelo usado.
287
+ parallel_requests: Número de workers paralelos usados.
288
+ """
289
+ if not token_stats or token_stats.get('total_tokens', 0) == 0:
290
+ return
291
+
292
+ print("\n" + "=" * 60)
293
+ print("ESTATISTICAS DE USO")
294
+ print("=" * 60)
295
+ print(f"Modelo: {model}")
296
+ print(f"Total de tokens: {token_stats['total_tokens']:,}")
297
+ print(f" - Input: {token_stats['input_tokens']:,} tokens")
298
+ print(f" - Output: {token_stats['output_tokens']:,} tokens")
299
+
300
+ # Métricas de throughput (se disponíveis)
301
+ if 'elapsed_seconds' in token_stats and token_stats['elapsed_seconds'] > 0:
302
+ elapsed = token_stats['elapsed_seconds']
303
+ requests = token_stats.get('requests_completed', 0)
304
+
305
+ print("-" * 60)
306
+ print("METRICAS DE THROUGHPUT")
307
+ print("-" * 60)
308
+ print(f"Tempo total: {elapsed:.1f}s")
309
+ print(f"Workers paralelos: {parallel_requests}")
310
+
311
+ if requests > 0:
312
+ rpm = (requests / elapsed) * 60
313
+ print(f"Requisicoes: {requests}")
314
+ print(f" - RPM (req/min): {rpm:.1f}")
315
+
316
+ tpm = (token_stats['total_tokens'] / elapsed) * 60
317
+ print(f" - TPM (tokens/min): {tpm:,.0f}")
318
+
319
+ print("=" * 60 + "\n")
320
+
321
+
322
+ def _process_rows(
323
+ df: pd.DataFrame,
324
+ pydantic_model,
325
+ user_prompt: str,
326
+ text_column: str,
327
+ status_col: str,
328
+ expected_columns: list,
329
+ config: LLMConfig,
330
+ start_pos: int,
331
+ processed_count: int,
332
+ conversion_info,
333
+ track_tokens: bool,
334
+ reprocess_columns=None,
335
+ ) -> dict:
336
+ """Processa cada linha do DataFrame.
337
+
338
+ Args:
339
+ reprocess_columns: Lista de colunas para forçar reprocessamento.
340
+ Se especificado, não pula linhas já processadas.
341
+
342
+ Returns:
343
+ Dict com estatísticas de tokens: {'input_tokens', 'output_tokens', 'total_tokens'}
344
+ """
345
+ # Criar descrição para progresso
346
+ type_labels = {
347
+ ORIGINAL_TYPE_POLARS_DF: 'polars→pandas',
348
+ ORIGINAL_TYPE_PANDAS_DF: 'pandas',
349
+ }
350
+ engine = type_labels.get(conversion_info.original_type, conversion_info.original_type)
351
+ desc = f"Processando [{engine}+langchain]"
352
+
353
+ # Adicionar info de rate limiting (se ativo)
354
+ if config.rate_limit_delay > 0:
355
+ req_per_min = int(60 / config.rate_limit_delay)
356
+ desc += f" [~{req_per_min} req/min]"
357
+
358
+ if reprocess_columns:
359
+ desc += f" (reprocessando: {', '.join(reprocess_columns)})"
360
+ elif processed_count > 0:
361
+ desc += f" (resumindo de {processed_count}/{len(df)})"
362
+
363
+ # Inicializar contadores de tokens
364
+ token_stats = {'input_tokens': 0, 'output_tokens': 0, 'total_tokens': 0}
365
+
366
+ # Processar cada linha
367
+ for i, (idx, row) in enumerate(tqdm(df.iterrows(), total=len(df), desc=desc)):
368
+ # Verificar se linha já foi processada
369
+ row_already_processed = pd.notna(row[status_col]) and row[status_col] == 'processed'
370
+
371
+ # Decidir se deve processar esta linha
372
+ if reprocess_columns:
373
+ # Com reprocess_columns: processa todas as linhas
374
+ pass
375
+ else:
376
+ # Sem reprocess_columns: pula linhas já processadas (comportamento normal)
377
+ if i < start_pos or row_already_processed:
378
+ continue
379
+
380
+ text = str(row[text_column])
381
+
382
+ try:
383
+ # Chamar LLM via LangChain
384
+ result = call_langchain(text, pydantic_model, user_prompt, config)
385
+
386
+ # Extrair dados e usage metadata
387
+ extracted = result.get('data', result) # Retrocompatibilidade
388
+ usage = result.get('usage')
389
+ retry_info = result.get('_retry_info', {})
390
+
391
+ # Atualizar DataFrame com dados extraídos
392
+ # Se linha já processada e reprocess_columns definido: só atualiza colunas especificadas
393
+ # Caso contrário: atualiza todas as colunas do modelo
394
+ for col in expected_columns:
395
+ if col in extracted:
396
+ if row_already_processed and reprocess_columns:
397
+ # Linha já processada: só atualiza se col está em reprocess_columns
398
+ if col in reprocess_columns:
399
+ df.at[idx, col] = extracted[col]
400
+ else:
401
+ # Linha nova: atualiza tudo
402
+ df.at[idx, col] = extracted[col]
403
+
404
+ # Armazenar tokens no DataFrame (se habilitado)
405
+ if track_tokens and usage:
406
+ df.at[idx, '_input_tokens'] = usage.get('input_tokens', 0)
407
+ df.at[idx, '_output_tokens'] = usage.get('output_tokens', 0)
408
+ df.at[idx, '_total_tokens'] = usage.get('total_tokens', 0)
409
+
410
+ # Acumular estatísticas
411
+ token_stats['input_tokens'] += usage.get('input_tokens', 0)
412
+ token_stats['output_tokens'] += usage.get('output_tokens', 0)
413
+ token_stats['total_tokens'] += usage.get('total_tokens', 0)
414
+
415
+ df.at[idx, status_col] = 'processed'
416
+
417
+ # Registrar se houve retries (mesmo em caso de sucesso)
418
+ if retry_info.get('retries', 0) > 0:
419
+ df.at[idx, '_error_details'] = f"Sucesso após {retry_info['retries']} retry(s)"
420
+
421
+ # Rate limiting: aguardar antes da próxima requisição
422
+ if config.rate_limit_delay > 0:
423
+ time.sleep(config.rate_limit_delay)
424
+
425
+ except Exception as e:
426
+ error_msg = f"{type(e).__name__}: {e}"
427
+
428
+ # Determinar se foi erro recuperável ou não para mensagem correta
429
+ if is_recoverable_error(e):
430
+ # Erro recuperável que esgotou tentativas
431
+ error_details = f"[Falhou após {config.max_retries} tentativa(s)] {error_msg}"
432
+ else:
433
+ # Erro não-recuperável (não fez retry)
434
+ error_details = f"[Erro não-recuperável] {error_msg}"
435
+
436
+ # Exibir mensagem amigável para o usuário
437
+ friendly_msg = get_friendly_error_message(e, config.provider)
438
+ print(f"\n{friendly_msg}\n")
439
+
440
+ warnings.warn(f"Falha ao processar linha {idx}.")
441
+ df.at[idx, status_col] = 'error'
442
+ df.at[idx, '_error_details'] = error_details
443
+
444
+ return token_stats
445
+
446
+
447
+ def _process_rows_parallel(
448
+ df: pd.DataFrame,
449
+ pydantic_model,
450
+ user_prompt: str,
451
+ text_column: str,
452
+ status_col: str,
453
+ expected_columns: list,
454
+ config: LLMConfig,
455
+ start_pos: int,
456
+ processed_count: int,
457
+ conversion_info,
458
+ track_tokens: bool,
459
+ reprocess_columns,
460
+ parallel_requests: int,
461
+ ) -> dict:
462
+ """Processa linhas do DataFrame em paralelo com auto-redução de workers.
463
+
464
+ Args:
465
+ parallel_requests: Número inicial de workers paralelos.
466
+ Será reduzido automaticamente se detectar erros de rate limit (429).
467
+
468
+ Returns:
469
+ Dict com estatísticas de tokens e métricas de throughput.
470
+ """
471
+ start_time = time.time()
472
+
473
+ # Estado compartilhado (thread-safe)
474
+ lock = threading.Lock()
475
+ current_workers = parallel_requests
476
+ initial_workers = parallel_requests
477
+ workers_reduced = False
478
+ rate_limit_event = threading.Event()
479
+
480
+ # Contadores
481
+ token_stats = {
482
+ 'input_tokens': 0,
483
+ 'output_tokens': 0,
484
+ 'total_tokens': 0,
485
+ 'requests_completed': 0,
486
+ }
487
+
488
+ # Criar descrição para progresso
489
+ type_labels = {
490
+ ORIGINAL_TYPE_POLARS_DF: 'polars→pandas',
491
+ ORIGINAL_TYPE_PANDAS_DF: 'pandas',
492
+ }
493
+ engine = type_labels.get(conversion_info.original_type, conversion_info.original_type)
494
+ llm_engine = 'openai' if config.use_openai else 'langchain'
495
+ desc = f"Processando [{engine}+{llm_engine}] [{parallel_requests} workers]"
496
+
497
+ if reprocess_columns:
498
+ desc += f" (reprocessando: {', '.join(reprocess_columns)})"
499
+ elif processed_count > 0:
500
+ desc += f" (resumindo de {processed_count}/{len(df)})"
501
+
502
+ # Identificar linhas a processar
503
+ rows_to_process = []
504
+ for i, (idx, row) in enumerate(df.iterrows()):
505
+ row_already_processed = pd.notna(row[status_col]) and row[status_col] == 'processed'
506
+
507
+ if reprocess_columns:
508
+ rows_to_process.append((i, idx, row))
509
+ else:
510
+ if i >= start_pos and not row_already_processed:
511
+ rows_to_process.append((i, idx, row))
512
+
513
+ if not rows_to_process:
514
+ return token_stats
515
+
516
+ def process_single_row(row_data):
517
+ """Processa uma única linha (executada em thread separada)."""
518
+ nonlocal current_workers, workers_reduced
519
+
520
+ i, idx, row = row_data
521
+ text = str(row[text_column])
522
+ row_already_processed = pd.notna(row[status_col]) and row[status_col] == 'processed'
523
+
524
+ # Verificar se devemos pausar devido a rate limit
525
+ if rate_limit_event.is_set():
526
+ time.sleep(2.0) # Pausa breve quando rate limit detectado
527
+
528
+ try:
529
+ # Chamar LLM apropriado
530
+ if config.use_openai:
531
+ result = call_openai(text, pydantic_model, user_prompt, config)
532
+ else:
533
+ result = call_langchain(text, pydantic_model, user_prompt, config)
534
+
535
+ # Extrair dados
536
+ extracted = result.get('data', result)
537
+ usage = result.get('usage')
538
+ retry_info = result.get('_retry_info', {})
539
+
540
+ # Atualizar DataFrame (com lock para thread-safety)
541
+ with lock:
542
+ for col in expected_columns:
543
+ if col in extracted:
544
+ if row_already_processed and reprocess_columns:
545
+ if col in reprocess_columns:
546
+ df.at[idx, col] = extracted[col]
547
+ else:
548
+ df.at[idx, col] = extracted[col]
549
+
550
+ if track_tokens and usage:
551
+ df.at[idx, '_input_tokens'] = usage.get('input_tokens', 0)
552
+ df.at[idx, '_output_tokens'] = usage.get('output_tokens', 0)
553
+ df.at[idx, '_total_tokens'] = usage.get('total_tokens', 0)
554
+
555
+ token_stats['input_tokens'] += usage.get('input_tokens', 0)
556
+ token_stats['output_tokens'] += usage.get('output_tokens', 0)
557
+ token_stats['total_tokens'] += usage.get('total_tokens', 0)
558
+
559
+ token_stats['requests_completed'] += 1
560
+ df.at[idx, status_col] = 'processed'
561
+
562
+ if retry_info.get('retries', 0) > 0:
563
+ df.at[idx, '_error_details'] = f"Sucesso após {retry_info['retries']} retry(s)"
564
+
565
+ # Rate limiting entre requisições (se configurado)
566
+ if config.rate_limit_delay > 0:
567
+ time.sleep(config.rate_limit_delay)
568
+
569
+ return {'success': True, 'idx': idx}
570
+
571
+ except Exception as e:
572
+ error_msg = f"{type(e).__name__}: {e}"
573
+
574
+ # Verificar se é erro de rate limit
575
+ if is_rate_limit_error(e):
576
+ with lock:
577
+ if current_workers > 1:
578
+ old_workers = current_workers
579
+ current_workers = max(1, current_workers // 2)
580
+ workers_reduced = True
581
+ warnings.warn(
582
+ f"Rate limit detectado! Reduzindo workers de {old_workers} para {current_workers}.",
583
+ stacklevel=2
584
+ )
585
+ rate_limit_event.set()
586
+ # Limpar evento após um tempo
587
+ threading.Timer(5.0, rate_limit_event.clear).start()
588
+
589
+ # Registrar erro
590
+ with lock:
591
+ if is_recoverable_error(e):
592
+ error_details = f"[Falhou após {config.max_retries} tentativa(s)] {error_msg}"
593
+ else:
594
+ error_details = f"[Erro não-recuperável] {error_msg}"
595
+
596
+ friendly_msg = get_friendly_error_message(e, config.provider)
597
+ print(f"\n{friendly_msg}\n")
598
+
599
+ warnings.warn(f"Falha ao processar linha {idx}.")
600
+ df.at[idx, status_col] = 'error'
601
+ df.at[idx, '_error_details'] = error_details
602
+
603
+ return {'success': False, 'idx': idx, 'error': error_msg}
604
+
605
+ # Processar com ThreadPoolExecutor
606
+ with tqdm(total=len(rows_to_process), desc=desc) as pbar:
607
+ # Usar abordagem iterativa para permitir ajuste dinâmico de workers
608
+ pending_rows = list(rows_to_process)
609
+ completed = 0
610
+
611
+ while pending_rows:
612
+ # Pegar batch com número atual de workers
613
+ with lock:
614
+ batch_size = min(current_workers, len(pending_rows))
615
+ batch = pending_rows[:batch_size]
616
+ pending_rows = pending_rows[batch_size:]
617
+
618
+ with ThreadPoolExecutor(max_workers=batch_size) as executor:
619
+ futures = {executor.submit(process_single_row, row): row for row in batch}
620
+
621
+ for future in as_completed(futures):
622
+ try:
623
+ result = future.result()
624
+ pbar.update(1)
625
+ completed += 1
626
+ except Exception as e:
627
+ pbar.update(1)
628
+ completed += 1
629
+ warnings.warn(f"Erro inesperado no executor: {e}")
630
+
631
+ # Calcular métricas finais
632
+ elapsed = time.time() - start_time
633
+ token_stats['elapsed_seconds'] = elapsed
634
+ token_stats['initial_workers'] = initial_workers
635
+ token_stats['final_workers'] = current_workers
636
+ token_stats['workers_reduced'] = workers_reduced
637
+
638
+ return token_stats