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.
- dataframeit/__init__.py +17 -0
- dataframeit/core.py +638 -0
- dataframeit/errors.py +408 -0
- dataframeit/llm.py +114 -0
- dataframeit/py.typed +0 -0
- dataframeit/utils.py +464 -0
- dataframeit-0.5.0.dist-info/METADATA +653 -0
- dataframeit-0.5.0.dist-info/RECORD +10 -0
- dataframeit-0.5.0.dist-info/WHEEL +4 -0
- dataframeit-0.5.0.dist-info/licenses/LICENSE +21 -0
dataframeit/__init__.py
ADDED
|
@@ -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
|