diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e51513..162ce5ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR - A criação das colunas de saída usa a opção `mode.chained_assignment` só no pandas 2, preservando entradas fatiadas sem emitir aviso de depreciação no pandas 3.1 (#269). +- A documentação, os READMEs e os notebooks de exemplo deixam de descrever o que o código não faz. A referência da API traz `perguntas` na 4ª posição da assinatura, diz que a entrada `pd.DataFrame` é modificada no lugar e que a saída é sempre DataFrame; `basic-usage.md` e os notebooks 04 e 08 deixam de passar a uma segunda chamada o DataFrame que a primeira já processou, e o trace completo da busca web usa um modelo sem configuração por campo. A página de exceções lista `ProviderAbortError` e `ProviderUsageLimitError`, que param a execução; a FAQ mostra a mensagem de dependência que o código emite e diz que só o `codex` recusa `api_key`; o extra `all`, as colunas de tokens da saída e o resumo de throughput, que só sai com `parallel_requests > 1`, aparecem como são (#200). - No provider `codex`, a thread daemon que pede o `thread/unsubscribe` da linha abortada pela morte do app-server deixa de ficar presa para sempre. O processo morto ainda aceita a escrita enquanto termina, e o SDK registra a espera da resposta depois de o leitor do stdout ter acordado os pedidos em voo; agora o fechamento do cliente acorda essa espera (#250). - `read_df` com argumento de leitura que faz o pandas devolver outra coisa que um DataFrame, como `chunksize`, `iterator`, `sheet_name=None` ou lista e o `chunksize` do JSON em linhas, devolve o `TextFileReader`, o `JsonReader` ou o dict de abas como o pandas o devolve, sem normalizar, com ou sem modelo. Antes, sem modelo, ou com modelo que tem campo de lista, dict ou modelo aninhado, e com `normalize=True`, o padrão, a função levantava `AttributeError` ao procurar as colunas no objeto do leitor (#249). - Com entrada polars, a saída deixa de trocar sem aviso o valor que o modelo devolveu quando o pyarrow tipa a coluna pelo primeiro valor e converte os demais. Sai `Object`, com o aviso das colunas que o polars não reúne, o campo cuja anotação junta `timedelta` a outro tipo, `float` a `bool` ou `bytes` a `str`, e a coluna cujos valores trazem essas combinações ou as de data e hora, também em `Any`, onde o escalar do numpy, como o `np.int64`, conta como o número do Python, nos itens de lista e no campo de modelo aninhado, além da coluna cujos dicts do mesmo campo têm chaves diferentes e da que traz `datetime` com e sem fuso, `time` com fuso ou `Decimal` NaN. O NaN de campo `float` sai NaN. Antes, em `Union[timedelta, int]` o 5 virava cinco microssegundos, em `Union[float, bool]` o `True` virava `1.0`, o `datetime` sem fuso ganhava o fuso do primeiro, o `time` perdia o fuso, o `Decimal` NaN e o NaN de campo `float` viravam nulo, `{}` ao lado de `{"a": 1}` virava `{"a": None}`, e em `Union[NewType sobre date, int]` o 5 virava `1970-01-06`. Com `resume=True`, a coluna do campo cuja anotação é `int`, um `Literal` com inteiro ou uma união com um deles é lida com os inteiros do polars, e o inteiro acima de 2**53 deixa de voltar arredondado em `Float64`. O `Decimal` NaN e o NaN de campo `float` de uma linha processada deixam de ser acusados como incompatíveis na retomada, também com entrada pandas, e o reprocessamento campo a campo os repassa às condições como valor; na coluna float64, como a do checkpoint relido, o NaN não se distingue do nulo e segue contando como ausente. O `NewType` e o apelido `type`, inclusive o apelido genérico usado com argumentos, como `Opcional[int]` de `type Opcional[T] = T | None`, passam a contar pelo tipo a que se referem na saída polars, na retomada e em `read_df`: o `NewType` sobre `dict` sai em texto JSON, como o campo `dict`, e é retomado, e o campo `NewType` sobre `str` ou sobre `int` é relido do CSV como texto cru ou como inteiro exato (#241). diff --git a/README.es.md b/README.es.md index b06f03f0..9d0322b5 100644 --- a/README.es.md +++ b/README.es.md @@ -22,7 +22,7 @@ pip install dataframeit[anthropic] # Anthropic Claude pip install dataframeit[groq] # Groq pip install dataframeit[codex] # SDK oficial de Codex (experimental) pip install dataframeit[claude-code] # Claude Code mediante el Claude Agent SDK -pip install dataframeit[all] # todos los proveedores excepto Codex, búsqueda web, Polars y Excel +pip install dataframeit[all] # los proveedores de arriba salvo Codex, más búsqueda web, Polars y Excel ``` Extras opcionales: `search` (Tavily), `search-exa` (Exa), `search-all`, `polars`, `excel`. @@ -70,6 +70,8 @@ print(resultado) | Pésima atención... | negativo | alta | | La entrega bien... | neutro | media | +Con `track_tokens=True`, el valor por defecto, la salida también trae las columnas de uso de tokens `_input_tokens`, `_cached_input_tokens`, `_output_tokens` y `_reasoning_tokens`, omitidas en la tabla de arriba. + Los nombres de clases y campos son arbitrarios: los notebooks de [`example/`](example/) usan nombres en portugués. ## Funcionalidades diff --git a/README.md b/README.md index 810f4dba..1c833e66 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ pip install dataframeit[anthropic] # Anthropic Claude pip install dataframeit[groq] # Groq pip install dataframeit[codex] # Official Codex SDK (experimental) pip install dataframeit[claude-code] # Claude Code via the Claude Agent SDK -pip install dataframeit[all] # all providers except Codex, web search, Polars and Excel +pip install dataframeit[all] # the providers above except Codex, plus web search, Polars and Excel ``` Optional extras: `search` (Tavily), `search-exa` (Exa), `search-all`, `polars`, `excel`. @@ -70,6 +70,8 @@ print(result) | Terrible service... | negative | high | | Delivery was fine... | neutral | medium | +With `track_tokens=True`, the default, the output also has the token usage columns `_input_tokens`, `_cached_input_tokens`, `_output_tokens` and `_reasoning_tokens`, left out of the table above. + Field and class names are arbitrary; the examples in the [`example/`](https://github.com/bdcdo/dataframeit/tree/main/example) notebooks use Portuguese ones. ## Features diff --git a/README.pt-BR.md b/README.pt-BR.md index 1b62aaed..b9ff0ca3 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -22,7 +22,7 @@ pip install dataframeit[anthropic] # Anthropic Claude pip install dataframeit[groq] # Groq pip install dataframeit[codex] # Codex SDK oficial (experimental) pip install dataframeit[claude-code] # Claude Code pelo Claude Agent SDK -pip install dataframeit[all] # todos os providers exceto Codex, busca web, Polars e Excel +pip install dataframeit[all] # os providers acima menos o Codex, mais busca web, Polars e Excel ``` Extras opcionais: `search` (Tavily), `search-exa` (Exa), `search-all`, `polars`, `excel`. @@ -70,6 +70,8 @@ print(resultado) | Péssimo atendimento... | negativo | alta | | Entrega ok... | neutro | media | +Com `track_tokens=True`, o padrão, a saída traz também as colunas de uso de tokens `_input_tokens`, `_cached_input_tokens`, `_output_tokens` e `_reasoning_tokens`, omitidas da tabela acima. + Os nomes de classes e campos são arbitrários: os notebooks de [`example/`](example/) usam nomes em português. ## Funcionalidades diff --git a/docs/en/getting-started/quickstart.md b/docs/en/getting-started/quickstart.md index 420cc09c..3b2dea95 100644 --- a/docs/en/getting-started/quickstart.md +++ b/docs/en/getting-started/quickstart.md @@ -84,6 +84,8 @@ print(result) 2 Delivery ok, average product. neutral medium ``` +With `track_tokens=True`, the default, the output also has the token usage columns `_input_tokens`, `_cached_input_tokens`, `_output_tokens` and `_reasoning_tokens`, left out above; see [Added Columns](../reference/api.md#added-columns). + ## Complete Example ```python diff --git a/docs/en/guides/basic-usage.md b/docs/en/guides/basic-usage.md index 342b6252..ba33465b 100644 --- a/docs/en/guides/basic-usage.md +++ b/docs/en/guides/basic-usage.md @@ -2,6 +2,8 @@ Practical examples for common use cases. +`dataframeit()` writes the results and the status into the pandas DataFrame it receives; see [Input modified in place](../reference/api.md#input-modified-in-place). That is why the examples on this page pass `df.copy()`: without the copy, each example would find the `df` already processed by the previous one. + ## Sentiment Analysis ```python @@ -22,7 +24,7 @@ df = pd.DataFrame({ ] }) -result = dataframeit(df, Sentiment, "Analyze the sentiment of the text.") +result = dataframeit(df.copy(), Sentiment, "Analyze the sentiment of the text.") ``` ## Category Classification @@ -36,7 +38,7 @@ class Category(BaseModel): subcategory: str = Field(description="More specific subcategory") result = dataframeit( - df, + df.copy(), Category, "Classify the text into the most appropriate category." ) @@ -59,7 +61,7 @@ Extract all named entities from the text. If there are no entities of some type, return an empty list. """ -result = dataframeit(df, Entities, PROMPT) +result = dataframeit(df.copy(), Entities, PROMPT) ``` ## Text Summarization @@ -77,7 +79,7 @@ Analyze the text and extract a concise summary. Identify the main points and central topic. """ -result = dataframeit(df, Summary, PROMPT) +result = dataframeit(df.copy(), Summary, PROMPT) ``` ## Using Different Input Types @@ -113,18 +115,18 @@ result = dataframeit(series, Sentiment, PROMPT) ```python # OpenAI with gpt-6-luna (default) -result = dataframeit(df, Model, PROMPT) +result = dataframeit(df.copy(), Sentiment, PROMPT) # Google Gemini result = dataframeit( - df, Model, PROMPT, + df.copy(), Sentiment, PROMPT, provider='google_genai', model='gemini-3.8-flash' ) # Anthropic Claude result = dataframeit( - df, Model, PROMPT, + df.copy(), Sentiment, PROMPT, provider='anthropic', model='claude-sonnet-5' ) diff --git a/docs/en/guides/faq.md b/docs/en/guides/faq.md index 6a2f9a0d..29f505f8 100644 --- a/docs/en/guides/faq.md +++ b/docs/en/guides/faq.md @@ -4,19 +4,21 @@ ### `ImportError` asking you to install a package -The chosen provider needs the corresponding extra. The message says which one to install, for example: +The chosen provider needs its package, and the message says what to install. For LangChain providers, it suggests the provider's package or the `all` extra: ```bash -pip install dataframeit[anthropic] +pip install langchain-anthropic +# or +pip install dataframeit[all] ``` -The extras are listed in [Installation](../getting-started/installation.md). LangChain providers without their own extra, such as Cohere and Mistral, need the `langchain-` package. +The provider's extra, such as `dataframeit[anthropic]`, installs the same package. `all` brings the providers that have an extra, except Codex; LangChain providers without their own extra, such as Cohere and Mistral, need the `langchain-` package, which `all` does not include. For `claude_code` and `codex`, the message asks for the provider's extra (`dataframeit[claude-code]`, `dataframeit[codex]`), and for web search, the search provider's package. The extras are listed in [Installation](../getting-started/installation.md). ### Authentication error (401 or 403) The API key was not found or was rejected. Check the provider's environment variable (`OPENAI_API_KEY`, `GOOGLE_API_KEY`, `ANTHROPIC_API_KEY`, `GROQ_API_KEY`) in the same process that runs Python; in a notebook, set it before calling `dataframeit()`. An authentication error gets no new attempt: every row ends with status `'error'`. -The `codex` and `claude_code` providers use the local authentication of those tools and do not accept `api_key`; see [Providers](providers.md). +The `codex` and `claude_code` providers use the local authentication of those tools. `codex` rejects `api_key` with `ProviderConfigurationError`, and `claude_code` ignores it; see [Providers](providers.md). ## Result diff --git a/docs/en/guides/performance.md b/docs/en/guides/performance.md index 4394db93..e82e995b 100644 --- a/docs/en/guides/performance.md +++ b/docs/en/guides/performance.md @@ -119,7 +119,7 @@ result = dataframeit( ) ``` -When the provider reports a failure that prevents any further row, such as an exhausted usage quota (`ProviderUsageLimitError`) or a terminated `codex` runtime (`ProviderAbortError`), execution stops dispatching rows, saves the checkpoint, and warns how many rows were left without status. The control columns stay in the output, and running again with `resume=True` processes only the pending rows. With `reprocess_columns`, rows the interruption left without reprocessing keep their previous values and are marked in `_error_details`. +When the provider reports a failure that prevents any further row, such as an exhausted usage quota (`ProviderUsageLimitError`) or a terminated `codex` runtime (`ProviderAbortError`), execution stops dispatching rows, saves the checkpoint, and warns how many rows were left without status. The control columns stay in the output. When running again with `resume=True` processes only the pending rows, and what happens with `reprocess_columns`, is covered in [Failures that stop the run](../reference/exceptions.md#failures-that-stop-the-run). ## Token Tracking @@ -155,7 +155,7 @@ Requisicoes: 100 ============================================================ ``` -Cache and reasoning lines appear when the provider reports those tokens. With web search, the summary gains a section on searches and credits; with `claude_code`, the cost reported by the SDK. +The throughput section appears only with `parallel_requests > 1`. Cache and reasoning lines appear when the provider reports those tokens. With web search, the summary gains a section on searches and credits; with `claude_code`, the cost reported by the SDK. To silence the summary and keep the usage columns, raise the logger level: diff --git a/docs/en/guides/web-search.md b/docs/en/guides/web-search.md index dd371e4f..353ad0c8 100644 --- a/docs/en/guides/web-search.md +++ b/docs/en/guides/web-search.md @@ -33,11 +33,13 @@ export EXA_API_KEY="your-exa-key" # https://exa.ai/ | Exa | `"exa"` | ignores `search_depth` | 1 credit (US$ 0.005) per search with up to 25 results, 5 above that | ```python -result = dataframeit(df, Model, PROMPT, use_search=True, search_provider="exa") +result = dataframeit(df.copy(), Model, PROMPT, use_search=True, search_provider="exa") ``` ## Basic Usage +`dataframeit()` writes the results and the status into the pandas DataFrame it receives; see [Input modified in place](../reference/api.md#input-modified-in-place). That is why the examples on this page pass `df.copy()`: without the copy, each example would find the `df` already processed by the previous one. + ```python from pydantic import BaseModel, Field from typing import Literal @@ -61,7 +63,7 @@ extract information about the mentioned company. # Enable web search with use_search=True result = dataframeit( - df, + df.copy(), CompanyInfo, PROMPT, use_search=True, # Enable web search @@ -90,7 +92,7 @@ With search, the output gains the `_search_credits` column, with the credits spe ```python result = dataframeit( - df, Model, PROMPT, + df.copy(), Model, PROMPT, use_search=True ) ``` @@ -101,7 +103,7 @@ When the model has many fields, it can be useful to give each field its own agen ```python result = dataframeit( - df, Model, PROMPT, + df.copy(), Model, PROMPT, use_search=True, search_per_field=True # One search agent per model field ) @@ -112,7 +114,7 @@ result = dataframeit( ```python # More detailed search (slower, more expensive) result = dataframeit( - df, Model, PROMPT, + df.copy(), Model, PROMPT, use_search=True, search_depth='advanced', max_results=10 @@ -162,7 +164,7 @@ class MedicationInfo(BaseModel): ) result = dataframeit( - df, + df.copy(), MedicationInfo, "Analyze the medication: {texto}", use_search=True, @@ -243,12 +245,14 @@ To debug and audit agent reasoning, use the `save_trace` parameter. ### Example: Full Trace +With one agent per row, the trace goes to `_trace`. The example uses `CompanyInfo` from [Basic Usage](#basic-usage), which has no per-field configuration: + ```python import json result = dataframeit( - df, - MedicationInfo, + df.copy(), + CompanyInfo, PROMPT, use_search=True, save_trace=True # or "full" @@ -267,7 +271,7 @@ For audits where only the search queries matter: ```python result = dataframeit( - df, Model, PROMPT, + df.copy(), Model, PROMPT, use_search=True, save_trace="minimal" # Excludes search result content ) @@ -277,7 +281,7 @@ result = dataframeit( ```python result = dataframeit( - df, + df.copy(), MedicationInfo, PROMPT, use_search=True, @@ -329,7 +333,7 @@ class DrugRegulatory(BaseModel): manufacturer: str = Field(description="Manufacturer") result = dataframeit( - df, + df.copy(), DrugRegulatory, "Research the drug: {texto}", use_search=True, @@ -368,7 +372,7 @@ With `save_trace=True`, traces are organized by group: ```python result = dataframeit( - df, Model, PROMPT, + df.copy(), Model, PROMPT, use_search=True, search_per_field=True, search_groups={"regulatory": {"fields": ["fda_status", "ema_approval"]}}, @@ -494,14 +498,14 @@ With `parallel_requests=20` and `search_per_field=True` on a 4-field model, 80 a ```python # Safe settings for Tavily with multiple fields result = dataframeit( - df, Model, PROMPT, + df.copy(), Model, PROMPT, use_search=True, search_per_field=True, parallel_requests=3, rate_limit_delay=0.5, ) # Higher throughput with Exa result = dataframeit( - df, Model, PROMPT, + df.copy(), Model, PROMPT, use_search=True, search_provider="exa", search_per_field=True, parallel_requests=5, rate_limit_delay=0.3, diff --git a/docs/en/index.md b/docs/en/index.md index ef39e4ae..714c7a97 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -71,7 +71,7 @@ Automatic retry with exponential backoff, configurable rate limiting and periodi ### Performance -Parallel processing with auto-adjustment. Real-time throughput metrics. +Parallel processing that reduces the workers on rate limits. With `parallel_requests > 1`, the summary at the end of the run includes throughput metrics.
@@ -87,7 +87,7 @@ Tavily or Exa to enrich data with information from the internet, with per-field ### Multiple Inputs -pandas or Polars DataFrame and Series, list and dictionary. The output comes back in the same format as the input. +pandas or Polars DataFrame and Series, list and dictionary. The output is a DataFrame, a Polars one when the input is Polars.
@@ -98,7 +98,7 @@ pandas or Polars DataFrame and Series, list and dictionary. The output comes bac pip install dataframeit[openai] # OpenAI (default provider) pip install dataframeit[google] # Google Gemini pip install dataframeit[anthropic] # Anthropic -pip install dataframeit[all] # all providers, web search, Polars and Excel +pip install dataframeit[all] # providers with an extra except Codex, plus web search, Polars and Excel ``` ## Next Steps diff --git a/docs/en/reference/api.md b/docs/en/reference/api.md index bc9bcaa7..decfbc56 100644 --- a/docs/en/reference/api.md +++ b/docs/en/reference/api.md @@ -11,6 +11,7 @@ def dataframeit( data, questions, prompt, + perguntas=None, # deprecated name of questions resume=True, reprocess_columns=None, model=None, @@ -102,7 +103,7 @@ With `use_search=True` and `search_per_field=True`, model fields accept their ow ### Return -Returns data in the same format as input with extracted columns added. +Returns a DataFrame with the extracted columns: polars for polars input and pandas for the others. | Input | Output | |-------|--------| @@ -136,6 +137,19 @@ if '_dataframeit_status' in result.columns: Rows with empty or missing text are not sent to the model: they get status `'error'` and the detail `"Texto ausente"`. +### Input modified in place + +With `pd.DataFrame` input, `dataframeit()` writes the model columns and the [added columns](#added-columns) into the DataFrame it receives, including `_dataframeit_status`, which stays there even when the output drops it. That is why an interruption leaves the rows already processed in the DataFrame, and `resume=True` continues from them. + +For the same reason, a second call with the same DataFrame, or with a copy made after the first call, does not send the rows with status `'processed'` to the model. With a model that has the same fields, it returns the results of the first call. With a model that has other fields and `resume=True`, the default, the outcome depends on the fields the first call did not fill. If any of them does not accept `None`, such as `category: str`, the call raises `ValueError` for incompatible fields. If all of them accept `None`, such as `Optional[str]`, with or without a default, the call raises no error and gives no warning about the fields, sends no row to the model, and returns `None` in those fields for every row. The same happens with the first call's model plus an `Optional` field. The error therefore protects you only when the new field does not accept `None`. To apply another model or another prompt to the same texts, pass each call a new DataFrame or a copy made before the first one: + +```python +result_a = dataframeit(df.copy(), ModelA, PROMPT_A) +result_b = dataframeit(df.copy(), ModelB, PROMPT_B) +``` + +Polars input, `pd.Series`, `list` and `dict` are not changed. + ### Examples ```python diff --git a/docs/en/reference/exceptions.md b/docs/en/reference/exceptions.md index 8ecdaaec..cacf38c7 100644 --- a/docs/en/reference/exceptions.md +++ b/docs/en/reference/exceptions.md @@ -8,7 +8,7 @@ The configuration is validated before the first call to the model. A problem her | Exception | When it occurs | |-----------|----------------| -| `ImportError` | The extra for the provider or the search provider is missing (e.g. `pip install dataframeit[anthropic]`). The message says which one to install | +| `ImportError` | The package for the provider or the search provider is missing. The message says what to install: for LangChain providers, the provider's package (e.g. `pip install langchain-anthropic`) or the `all` extra; for `claude_code` and `codex`, the provider's extra; for web search, the search provider's package (e.g. `pip install langchain-tavily`) | | `ValueError` | Invalid parameter: `questions` or `prompt` missing, `max_retries < 1`, `batch_size` without `checkpoint_path`, `search_groups` without `search_per_field=True`, missing search API key, previously processed rows incompatible with the current model | | `ProviderConfigurationError` | Configuration incompatible with the provider's contract, such as a `model_kwargs` that `codex` does not accept or a Pydantic schema it cannot represent. It is a subclass of `ValueError` | @@ -23,7 +23,7 @@ except ProviderConfigurationError as error: ## Errors during processing -A failure on a row does not stop the run. The row gets status `'error'`, the reason goes to `_error_details` and processing moves on to the next row. The text in `_error_details` has the form `[Falhou após N tentativa(s)] Class: message` (or `[Erro não-recuperável] Class: message`), which lets you filter by type: +A failure on a row does not stop the run, except the failures that prevent any further row, described in [Failures that stop the run](#failures-that-stop-the-run). The row gets status `'error'`, the reason goes to `_error_details` and processing moves on to the next row. The text in `_error_details` has the form `[Falhou após N tentativa(s)] Class: message` (or `[Erro não-recuperável] Class: message`), which lets you filter by type: ```python if '_dataframeit_status' in result.columns: @@ -36,9 +36,11 @@ The classes below classify the failure and decide whether it gets a new attempt. ``` RuntimeError └── ProviderError definitive failure reported by the provider - └── ProviderTransientError transient: gets a new attempt - ├── ProviderOverloadedError provider overload or rate limit - └── ProviderRejectedOutputError response rejected by validation (also ValueError) + ├── ProviderTransientError transient: gets a new attempt + │ ├── ProviderOverloadedError provider overload or rate limit + │ └── ProviderRejectedOutputError response rejected by validation (also ValueError) + └── ProviderAbortError prevents any further row: the run stops + └── ProviderUsageLimitError account usage quota exhausted ValueError ├── ProviderConfigurationError local configuration incompatible with the provider @@ -52,5 +54,11 @@ ValueError | `ProviderOverloadedError` | Yes | Provider overload or HTTP 429 | | `ProviderRejectedOutputError` | Yes | The response failed validation against the Pydantic model. With LangChain providers, the new attempt sends the model the rejected response and the error; with `claude_code`, it repeats the request | | `ProviderOutputError` | No | The provider finished without a usable response, such as a `codex` turn with no content or with a response that fails validation against the Pydantic model | +| `ProviderAbortError` | No | A failure after which no row would get through, such as a terminated `codex` runtime; the run stops | +| `ProviderUsageLimitError` | No | Account usage quota exhausted, such as the `codex` one; the run stops | Errors from providers via LangChain do not use these classes. They are classified by the type LangChain itself declares, by the HTTP status and by the message. The details are in [Error Handling](../guides/error-handling.md). + +## Failures that stop the run + +`ProviderAbortError` and its subclass `ProviderUsageLimitError` do not reach the caller of `dataframeit()`. When a row gets one of them, the run stops dispatching rows: the row that got the error and the rows not yet dispatched are left without status, and, in parallel mode, the rows already in flight finish and get status `'processed'`. The checkpoint, if there is one, is saved, and a warning says how many are pending and how to resume. Running again with `resume=True` processes only the pending rows when the call receives the status of the interrupted run. With `pd.DataFrame` input, that holds for the same input, which is [modified in place](api.md#input-modified-in-place), and for the returned output. With polars input, it holds only for the returned output: the input is not changed, and repeating the call on it processes every row again. With input that is not a DataFrame, such as `list`, the output does not carry the text, and only the same `checkpoint_path` allows resuming. With `checkpoint_path`, repeating the call with the same input resumes from the checkpoint, polars and `list` included; see [Checkpoints for Long Runs](../guides/performance.md#checkpoints-for-long-runs). With `reprocess_columns`, the interruption leaves no row without status: the rows not yet reprocessed, including the one that got the error, keep status `'processed'` and their previous values, marked in `_error_details`, and `resume=True` alone does not redo them. To redo them, repeat the call with `reprocess_columns`, which processes every row again. The `codex` cases are in [Providers](../guides/providers.md#openai-codex-experimental). diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 5d8b20b2..3b01e817 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -84,6 +84,8 @@ print(resultado) 2 Entrega ok, produto mediano. neutro media ``` +Com `track_tokens=True`, o padrão, a saída traz também as colunas de uso de tokens `_input_tokens`, `_cached_input_tokens`, `_output_tokens` e `_reasoning_tokens`, omitidas acima; ver [Colunas Adicionadas](../reference/api.md#colunas-adicionadas). + ## Exemplo Completo ```python diff --git a/docs/guides/basic-usage.md b/docs/guides/basic-usage.md index 955da9fd..c514a5eb 100644 --- a/docs/guides/basic-usage.md +++ b/docs/guides/basic-usage.md @@ -2,6 +2,8 @@ Exemplos práticos para casos de uso comuns. +O `dataframeit()` grava os resultados e o status no próprio DataFrame pandas que recebe; ver [Entrada modificada no lugar](../reference/api.md#entrada-modificada-no-lugar). Por isso os exemplos desta página passam `df.copy()`: sem a cópia, cada exemplo encontraria o `df` já processado pelo anterior. + ## Análise de Sentimento ```python @@ -22,7 +24,7 @@ df = pd.DataFrame({ ] }) -resultado = dataframeit(df, Sentimento, "Analise o sentimento do texto.") +resultado = dataframeit(df.copy(), Sentimento, "Analise o sentimento do texto.") ``` ## Classificação de Categorias @@ -36,7 +38,7 @@ class Categoria(BaseModel): subcategoria: str = Field(description="Subcategoria mais específica") resultado = dataframeit( - df, + df.copy(), Categoria, "Classifique o texto na categoria mais apropriada." ) @@ -59,7 +61,7 @@ Extraia todas as entidades nomeadas do texto. Se não houver entidades de algum tipo, retorne lista vazia. """ -resultado = dataframeit(df, Entidades, PROMPT) +resultado = dataframeit(df.copy(), Entidades, PROMPT) ``` ## Resumo de Texto @@ -77,7 +79,7 @@ Analise o texto e extraia um resumo conciso. Identifique os pontos principais e o tema central. """ -resultado = dataframeit(df, Resumo, PROMPT) +resultado = dataframeit(df.copy(), Resumo, PROMPT) ``` ## Usando Diferentes Tipos de Entrada @@ -113,18 +115,18 @@ resultado = dataframeit(series, Sentimento, PROMPT) ```python # OpenAI com gpt-6-luna (padrão) -resultado = dataframeit(df, Model, PROMPT) +resultado = dataframeit(df.copy(), Sentimento, PROMPT) # Google Gemini resultado = dataframeit( - df, Model, PROMPT, + df.copy(), Sentimento, PROMPT, provider='google_genai', model='gemini-3.8-flash' ) # Anthropic Claude resultado = dataframeit( - df, Model, PROMPT, + df.copy(), Sentimento, PROMPT, provider='anthropic', model='claude-sonnet-5' ) diff --git a/docs/guides/faq.md b/docs/guides/faq.md index d3364cfa..e71ec335 100644 --- a/docs/guides/faq.md +++ b/docs/guides/faq.md @@ -4,19 +4,21 @@ ### `ImportError` pedindo para instalar um pacote -O provider escolhido precisa do extra correspondente. A mensagem diz qual instalar, por exemplo: +O provider escolhido precisa do pacote dele, e a mensagem diz o que instalar. Nos providers do LangChain, ela sugere o pacote do provider ou o extra `all`: ```bash -pip install dataframeit[anthropic] +pip install langchain-anthropic +# ou +pip install dataframeit[all] ``` -Os extras estão em [Instalação](../getting-started/installation.md). Providers do LangChain sem extra próprio, como Cohere e Mistral, pedem o pacote `langchain-`. +O extra do provider, como `dataframeit[anthropic]`, instala o mesmo pacote. O `all` traz os providers que têm extra, menos o Codex; os providers do LangChain sem extra próprio, como Cohere e Mistral, precisam do pacote `langchain-`, que o `all` não inclui. No `claude_code` e no `codex`, a mensagem pede o extra do provider (`dataframeit[claude-code]`, `dataframeit[codex]`), e na busca web, o pacote do provedor de busca. Os extras estão em [Instalação](../getting-started/installation.md). ### Erro de autenticação (401 ou 403) A chave de API não foi encontrada ou foi recusada. Confira a variável de ambiente do provider (`OPENAI_API_KEY`, `GOOGLE_API_KEY`, `ANTHROPIC_API_KEY`, `GROQ_API_KEY`) no mesmo processo que roda o Python; num notebook, defina-a antes de chamar `dataframeit()`. Erro de autenticação não ganha nova tentativa: todas as linhas terminam com status `'error'`. -Os providers `codex` e `claude_code` usam a autenticação local das ferramentas e não aceitam `api_key`; ver [Provedores](providers.md). +Os providers `codex` e `claude_code` usam a autenticação local das ferramentas. O `codex` recusa `api_key` com `ProviderConfigurationError`, e o `claude_code` a ignora; ver [Provedores](providers.md). ## Resultado diff --git a/docs/guides/performance.md b/docs/guides/performance.md index bc3a78d2..f60d123c 100644 --- a/docs/guides/performance.md +++ b/docs/guides/performance.md @@ -121,7 +121,7 @@ resultado = dataframeit( ) ``` -Quando o provider informa uma falha que impede qualquer linha seguinte, como a cota de uso esgotada (`ProviderUsageLimitError`) ou o runtime do `codex` encerrado (`ProviderAbortError`), a execução para de despachar linhas, grava o checkpoint e avisa quantas ficaram sem status. As colunas de controle ficam na saída, e rodar de novo com `resume=True` processa só as pendentes. Com `reprocess_columns`, as linhas que a interrupção deixou sem reprocessar mantêm os valores anteriores e ficam marcadas em `_error_details`. +Quando o provider informa uma falha que impede qualquer linha seguinte, como a cota de uso esgotada (`ProviderUsageLimitError`) ou o runtime do `codex` encerrado (`ProviderAbortError`), a execução para de despachar linhas, grava o checkpoint e avisa quantas ficaram sem status. As colunas de controle ficam na saída. Em que casos rodar de novo com `resume=True` processa só as pendentes, e o que acontece com `reprocess_columns`, está em [Falhas que interrompem a execução](../reference/exceptions.md#falhas-que-interrompem-a-execucao). ## Tracking de Tokens @@ -157,7 +157,7 @@ Requisicoes: 100 ============================================================ ``` -Linhas de cache e raciocínio aparecem quando o provider informa esses tokens. Com busca web, o resumo ganha a seção de buscas e créditos; com `claude_code`, o custo informado pelo SDK. +A seção de throughput só aparece com `parallel_requests > 1`. Linhas de cache e raciocínio aparecem quando o provider informa esses tokens. Com busca web, o resumo ganha a seção de buscas e créditos; com `claude_code`, o custo informado pelo SDK. Para silenciar o resumo e manter as colunas de uso, suba o nível do logger: diff --git a/docs/guides/web-search.md b/docs/guides/web-search.md index a82e464f..93574dbe 100644 --- a/docs/guides/web-search.md +++ b/docs/guides/web-search.md @@ -33,11 +33,13 @@ export EXA_API_KEY="sua-chave-exa" # https://exa.ai/ | Exa | `"exa"` | ignora `search_depth` | 1 crédito (US$ 0,005) por busca com até 25 resultados, 5 acima disso | ```python -resultado = dataframeit(df, Model, PROMPT, use_search=True, search_provider="exa") +resultado = dataframeit(df.copy(), Model, PROMPT, use_search=True, search_provider="exa") ``` ## Uso Básico +O `dataframeit()` grava os resultados e o status no próprio DataFrame pandas que recebe; ver [Entrada modificada no lugar](../reference/api.md#entrada-modificada-no-lugar). Por isso os exemplos desta página passam `df.copy()`: sem a cópia, cada exemplo encontraria o `df` já processado pelo anterior. + ```python from pydantic import BaseModel, Field from typing import Literal @@ -61,7 +63,7 @@ extraia informações sobre a empresa mencionada. # Habilita busca web com use_search=True resultado = dataframeit( - df, + df.copy(), EmpresaInfo, PROMPT, use_search=True, # Habilita busca web @@ -90,7 +92,7 @@ Com busca, a saída ganha a coluna `_search_credits`, com os créditos gastos em ```python resultado = dataframeit( - df, Model, PROMPT, + df.copy(), Model, PROMPT, use_search=True ) ``` @@ -101,7 +103,7 @@ Quando o modelo tem muitos campos, pode ser útil dar a cada campo um agente pr ```python resultado = dataframeit( - df, Model, PROMPT, + df.copy(), Model, PROMPT, use_search=True, search_per_field=True # Um agente de busca por campo do modelo ) @@ -112,7 +114,7 @@ resultado = dataframeit( ```python # Busca mais detalhada (mais lenta, mais cara) resultado = dataframeit( - df, Model, PROMPT, + df.copy(), Model, PROMPT, use_search=True, search_depth='advanced', max_results=10 @@ -162,7 +164,7 @@ class MedicamentoInfo(BaseModel): ) resultado = dataframeit( - df, + df.copy(), MedicamentoInfo, "Analise o medicamento: {texto}", use_search=True, @@ -243,12 +245,14 @@ Para debugar e auditar o raciocínio do agente, use o parâmetro `save_trace`. ### Exemplo: Trace Completo +Com um agente por linha, o trace fica em `_trace`. O exemplo usa o `EmpresaInfo` de [Uso Básico](#uso-basico), que não tem configuração por campo: + ```python import json resultado = dataframeit( - df, - MedicamentoInfo, + df.copy(), + EmpresaInfo, PROMPT, use_search=True, save_trace=True # ou "full" @@ -267,7 +271,7 @@ Para auditorias onde só importa saber o que foi buscado: ```python resultado = dataframeit( - df, Model, PROMPT, + df.copy(), Model, PROMPT, use_search=True, save_trace="minimal" # Não inclui conteúdo das buscas ) @@ -277,7 +281,7 @@ resultado = dataframeit( ```python resultado = dataframeit( - df, + df.copy(), MedicamentoInfo, PROMPT, use_search=True, @@ -329,7 +333,7 @@ class MedicamentoRegulatorio(BaseModel): fabricante: str = Field(description="Laboratório fabricante") resultado = dataframeit( - df, + df.copy(), MedicamentoRegulatorio, "Pesquise sobre o medicamento: {texto}", use_search=True, @@ -368,7 +372,7 @@ Com `save_trace=True`, os traces são organizados por grupo: ```python resultado = dataframeit( - df, Model, PROMPT, + df.copy(), Model, PROMPT, use_search=True, search_per_field=True, search_groups={"regulatory": {"fields": ["status_anvisa", "avaliacao_conitec"]}}, @@ -494,14 +498,14 @@ Com `parallel_requests=20` e `search_per_field=True` em um modelo de 4 campos, r ```python # Configuração segura com Tavily e múltiplos campos resultado = dataframeit( - df, Model, PROMPT, + df.copy(), Model, PROMPT, use_search=True, search_per_field=True, parallel_requests=3, rate_limit_delay=0.5, ) # Maior throughput com Exa resultado = dataframeit( - df, Model, PROMPT, + df.copy(), Model, PROMPT, use_search=True, search_provider="exa", search_per_field=True, parallel_requests=5, rate_limit_delay=0.3, diff --git a/docs/index.md b/docs/index.md index 0f58c2a3..2af52086 100644 --- a/docs/index.md +++ b/docs/index.md @@ -71,7 +71,7 @@ Retry automático com backoff exponencial, rate limiting configurável e checkpo ### Performance -Processamento paralelo com auto-ajuste. Métricas de throughput em tempo real. +Processamento paralelo que reduz os workers diante de rate limit. Com `parallel_requests > 1`, o resumo ao fim da execução traz as métricas de throughput.
@@ -87,7 +87,7 @@ Tavily ou Exa para enriquecer dados com informações da internet, com busca por ### Múltiplas Entradas -DataFrame e Series do pandas ou do Polars, lista e dicionário. A saída volta no mesmo formato da entrada. +DataFrame e Series do pandas ou do Polars, lista e dicionário. A saída é um DataFrame, do Polars quando a entrada é do Polars.
@@ -98,7 +98,7 @@ DataFrame e Series do pandas ou do Polars, lista e dicionário. A saída volta n pip install dataframeit[openai] # OpenAI (provider padrão) pip install dataframeit[google] # Google Gemini pip install dataframeit[anthropic] # Anthropic -pip install dataframeit[all] # todos os providers, busca web, Polars e Excel +pip install dataframeit[all] # providers com extra, menos o Codex, mais busca web, Polars e Excel ``` ## Próximos Passos diff --git a/docs/reference/api.md b/docs/reference/api.md index 2f62c744..96ddb705 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -11,6 +11,7 @@ def dataframeit( data, questions, prompt, + perguntas=None, # nome obsoleto de questions resume=True, reprocess_columns=None, model=None, @@ -102,7 +103,7 @@ Com `use_search=True` e `search_per_field=True`, os campos do modelo aceitam con ### Retorno -Retorna dados no mesmo formato da entrada com colunas extraídas adicionadas. +Retorna um DataFrame com as colunas extraídas: polars para entrada polars e pandas para as demais. | Entrada | Saída | |---------|-------| @@ -136,6 +137,19 @@ if '_dataframeit_status' in resultado.columns: Linhas com texto vazio ou ausente não vão ao modelo: recebem status `'error'` e o detalhe `"Texto ausente"`. +### Entrada modificada no lugar + +Com entrada `pd.DataFrame`, o `dataframeit()` grava no próprio DataFrame que recebe as colunas do modelo e as [colunas adicionadas](#colunas-adicionadas), inclusive `_dataframeit_status`, que fica nele mesmo quando a saída a remove. Por isso uma interrupção deixa no DataFrame as linhas já processadas, e `resume=True` continua delas. + +Pela mesma razão, uma segunda chamada com o mesmo DataFrame, ou com uma cópia feita depois da primeira, não manda ao modelo as linhas com status `'processed'`. Com um modelo de mesmos campos, ela devolve os resultados da primeira chamada. Com um modelo de outros campos e `resume=True`, o padrão, o resultado depende dos campos que a primeira chamada não preencheu. Se algum deles não admite `None`, como `categoria: str`, a chamada levanta `ValueError` por campos incompatíveis. Se todos admitem `None`, como `Optional[str]`, com ou sem padrão, a chamada não levanta erro nem avisa da diferença de campos, não manda nenhuma linha ao modelo e devolve `None` nesses campos em todas as linhas. O mesmo acontece com o modelo da primeira chamada acrescido de um campo `Optional`. O erro só protege, portanto, quando o campo novo não admite `None`. Para aplicar outro modelo ou outro prompt aos mesmos textos, passe a cada chamada um DataFrame novo ou uma cópia feita antes da primeira: + +```python +resultado_a = dataframeit(df.copy(), ModeloA, PROMPT_A) +resultado_b = dataframeit(df.copy(), ModeloB, PROMPT_B) +``` + +A entrada polars, `pd.Series`, `list` e `dict` não é alterada. + ### Exemplos ```python diff --git a/docs/reference/exceptions.md b/docs/reference/exceptions.md index 713193eb..44f68055 100644 --- a/docs/reference/exceptions.md +++ b/docs/reference/exceptions.md @@ -8,7 +8,7 @@ A configuração é validada antes da primeira chamada ao modelo. Um problema aq | Exceção | Quando ocorre | |---------|---------------| -| `ImportError` | Falta o extra do provider ou do provedor de busca (ex.: `pip install dataframeit[anthropic]`). A mensagem diz qual instalar | +| `ImportError` | Falta o pacote do provider ou do provedor de busca. A mensagem diz qual instalar: nos providers do LangChain, o pacote do provider (ex.: `pip install langchain-anthropic`) ou o extra `all`; no `claude_code` e no `codex`, o extra do provider; na busca web, o pacote do provedor (ex.: `pip install langchain-tavily`) | | `ValueError` | Parâmetro inválido: `questions` ou `prompt` ausentes, `max_retries < 1`, `batch_size` sem `checkpoint_path`, `search_groups` sem `search_per_field=True`, chave de API de busca ausente, linhas já processadas incompatíveis com o modelo atual | | `ProviderConfigurationError` | Configuração incompatível com o contrato do provider, como um `model_kwargs` que o `codex` não aceita ou um schema Pydantic que ele não consegue representar. É subclasse de `ValueError` | @@ -23,7 +23,7 @@ except ProviderConfigurationError as erro: ## Erros durante o processamento -Uma falha numa linha não interrompe a execução. A linha recebe status `'error'`, o motivo vai para `_error_details` e o processamento segue para a próxima. O texto em `_error_details` tem a forma `[Falhou após N tentativa(s)] Classe: mensagem` (ou `[Erro não-recuperável] Classe: mensagem`), o que permite filtrar por tipo: +Uma falha numa linha não interrompe a execução, exceto as que impedem qualquer linha seguinte, descritas em [Falhas que interrompem a execução](#falhas-que-interrompem-a-execucao). A linha recebe status `'error'`, o motivo vai para `_error_details` e o processamento segue para a próxima. O texto em `_error_details` tem a forma `[Falhou após N tentativa(s)] Classe: mensagem` (ou `[Erro não-recuperável] Classe: mensagem`), o que permite filtrar por tipo: ```python if '_dataframeit_status' in resultado.columns: @@ -36,9 +36,11 @@ As classes abaixo classificam a falha e decidem se ela ganha nova tentativa. Tod ``` RuntimeError └── ProviderError falha definitiva reportada pelo provider - └── ProviderTransientError transitória: ganha nova tentativa - ├── ProviderOverloadedError sobrecarga ou limite de taxa do provider - └── ProviderRejectedOutputError resposta recusada pela validação (também ValueError) + ├── ProviderTransientError transitória: ganha nova tentativa + │ ├── ProviderOverloadedError sobrecarga ou limite de taxa do provider + │ └── ProviderRejectedOutputError resposta recusada pela validação (também ValueError) + └── ProviderAbortError impede qualquer linha seguinte: a execução para + └── ProviderUsageLimitError cota de uso da conta esgotada ValueError ├── ProviderConfigurationError configuração local incompatível com o provider @@ -52,5 +54,11 @@ ValueError | `ProviderOverloadedError` | Sim | Sobrecarga ou HTTP 429 do provider | | `ProviderRejectedOutputError` | Sim | A resposta não passou na validação do modelo Pydantic. Nos providers do LangChain, a nova tentativa leva ao modelo a resposta recusada e o erro; no `claude_code`, repete o pedido | | `ProviderOutputError` | Não | O provider terminou sem resposta utilizável, como um turno do `codex` sem conteúdo ou com resposta que não passa na validação do modelo Pydantic | +| `ProviderAbortError` | Não | Falha depois da qual nenhuma linha passaria, como o runtime do `codex` encerrado; a execução para | +| `ProviderUsageLimitError` | Não | Cota de uso da conta esgotada, como a do `codex`; a execução para | Erros dos providers via LangChain não usam essas classes. Eles são classificados pelo tipo que a própria LangChain declara, pelo status HTTP e pela mensagem. Os detalhes estão em [Tratamento de Erros](../guides/error-handling.md). + +## Falhas que interrompem a execução + +`ProviderAbortError` e a subclasse `ProviderUsageLimitError` não chegam a quem chama o `dataframeit()`. Quando uma linha recebe uma delas, a execução para de despachar linhas: a linha que recebeu o erro e as que ainda não tinham sido despachadas ficam sem status, e, no modo paralelo, as que já estavam em andamento terminam e ficam com status `'processed'`. O checkpoint, quando há um, é gravado, e um aviso diz quantas ficaram pendentes e como retomar. Rodar de novo com `resume=True` processa só as pendentes quando a chamada recebe o status da execução interrompida. Com entrada `pd.DataFrame`, isso vale para a mesma entrada, que é [modificada no lugar](api.md#entrada-modificada-no-lugar), e para a saída devolvida. Com entrada polars, vale só para a saída devolvida: a entrada não é alterada, e repetir a chamada sobre ela processa todas as linhas de novo. Com entrada que não é DataFrame, como `list`, a saída não traz o texto, e só o mesmo `checkpoint_path` permite retomar. Com `checkpoint_path`, repetir a chamada com a mesma entrada retoma do checkpoint, inclusive com polars e `list`; ver [Checkpoints em Execuções Longas](../guides/performance.md#checkpoints-em-execucoes-longas). Com `reprocess_columns`, a interrupção não deixa linha sem status: as linhas que não chegaram a ser reprocessadas, inclusive a que recebeu o erro, ficam com status `'processed'` e os valores anteriores, marcadas em `_error_details`, e `resume=True` sozinho não as refaz. Para refazê-las, repita a chamada com `reprocess_columns`, que processa de novo todas as linhas. Os casos do `codex` estão em [Provedores](../guides/providers.md#openai-codex-experimental). diff --git a/example/04_custom_placeholder.ipynb b/example/04_custom_placeholder.ipynb index eac5172c..c32a09f1 100644 --- a/example/04_custom_placeholder.ipynb +++ b/example/04_custom_placeholder.ipynb @@ -162,8 +162,10 @@ "metadata": {}, "outputs": [], "source": [ - "resultado_com_marcador = dataframeit(df, Avaliacao, PROMPT_COM_MARCADOR)\n", - "resultado_sem_marcador = dataframeit(df, Avaliacao, PROMPT_SEM_MARCADOR)\n", + "# O dataframeit grava os resultados e o status no próprio DataFrame que recebe.\n", + "# Sem a cópia, a segunda chamada acharia as linhas já processadas e não chamaria o modelo.\n", + "resultado_com_marcador = dataframeit(df.copy(), Avaliacao, PROMPT_COM_MARCADOR)\n", + "resultado_sem_marcador = dataframeit(df.copy(), Avaliacao, PROMPT_SEM_MARCADOR)\n", "\n", "print(\"Com o marcador:\")\n", "display(resultado_com_marcador[['id', 'sentimento']])\n", @@ -200,7 +202,7 @@ "\n", "print(PROMPT_COM_JSON.replace('{texto}', df['texto'][1]))\n", "\n", - "resultado_json = dataframeit(df, Avaliacao, PROMPT_COM_JSON)\n", + "resultado_json = dataframeit(df.copy(), Avaliacao, PROMPT_COM_JSON)\n", "resultado_json[['id', 'sentimento']]" ] }, @@ -269,7 +271,7 @@ "})\n", "\n", "resultado_comentarios = dataframeit(\n", - " df_comentarios,\n", + " df_comentarios.copy(), # df_comentarios segue sem os resultados para a próxima célula\n", " Avaliacao,\n", " PROMPT_COM_MARCADOR,\n", " text_column='comentario',\n", diff --git a/example/08_rate_limiting.ipynb b/example/08_rate_limiting.ipynb index 7f66defa..9b67ab28 100644 --- a/example/08_rate_limiting.ipynb +++ b/example/08_rate_limiting.ipynb @@ -322,8 +322,10 @@ "print(\" Rate Limiting: Previne erros de rate limit\")\n", "print(\" Retry: Trata erros inesperados com backoff exponencial\\n\")\n", "\n", + "# A chamada da seção 7 gravou os resultados e o status no próprio df, e sobre ele\n", + "# esta chamada não teria linha pendente. Um DataFrame novo traz os textos sem status.\n", "resultado_protegido = dataframeit(\n", - " df,\n", + " pd.DataFrame({'texto': textos}),\n", " AnaliseTexto,\n", " TEMPLATE,\n", " # Rate limiting proativo\n", diff --git a/tests/test_api_publica.py b/tests/test_api_publica.py index a6e078fd..5e094c35 100644 --- a/tests/test_api_publica.py +++ b/tests/test_api_publica.py @@ -1,6 +1,16 @@ +import ast +import inspect +import re +from pathlib import Path + +import pytest + import dataframeit from dataframeit import errors +PASTA_DOCS = Path(__file__).resolve().parent.parent / "docs" +IDIOMAS = [PASTA_DOCS, PASTA_DOCS / "en"] + def test_all_so_lista_nomes_que_existem(): for nome in dataframeit.__all__: @@ -35,3 +45,30 @@ def test_hierarquia_documentada_das_excecoes(): assert issubclass(dataframeit.ProviderUsageLimitError, dataframeit.ProviderAbortError) assert issubclass(dataframeit.ProviderAbortError, dataframeit.ProviderError) assert not issubclass(dataframeit.ProviderAbortError, dataframeit.ProviderTransientError) + + +@pytest.mark.parametrize("pasta", IDIOMAS, ids=["pt", "en"]) +def test_assinatura_da_referencia_segue_a_ordem_do_codigo(pasta): + # Quem passa um parâmetro por posição conta com a ordem que a referência mostra. + texto = (pasta / "reference" / "api.md").read_text(encoding="utf-8") + bloco = re.search( + r"^```python\n(def dataframeit\(.*?\) -> [^\n]+)$", texto, re.DOTALL | re.MULTILINE + ) + assert bloco, "a referência da API não traz o def de dataframeit()" + funcao = ast.parse(bloco.group(1) + ": ...").body[0] + assert isinstance(funcao, ast.FunctionDef) + documentados = [argumento.arg for argumento in funcao.args.args + funcao.args.kwonlyargs] + assert documentados == list(inspect.signature(dataframeit.dataframeit).parameters) + + +@pytest.mark.parametrize("pasta", IDIOMAS, ids=["pt", "en"]) +def test_pagina_de_excecoes_lista_todas_as_exportadas(pasta): + texto = (pasta / "reference" / "exceptions.md").read_text(encoding="utf-8") + exportadas = [ + nome + for nome in dataframeit.__all__ + if isinstance(getattr(dataframeit, nome), type) + and issubclass(getattr(dataframeit, nome), BaseException) + ] + assert exportadas + assert [nome for nome in exportadas if f"`{nome}`" not in texto] == [] diff --git a/tests/test_exemplos.py b/tests/test_exemplos.py index 1724987b..464562bb 100644 --- a/tests/test_exemplos.py +++ b/tests/test_exemplos.py @@ -53,6 +53,67 @@ def argumentos_desconhecidos(codigo): return desconhecidos +def _argumento_de_dados(chamada): + """Nome do DataFrame passado a dataframeit() e se ele vai como cópia (df.copy()).""" + dados = next( + (argumento.value for argumento in chamada.keywords if argumento.arg == "data"), + chamada.args[0] if chamada.args else None, + ) + if isinstance(dados, ast.Name): + return dados.id, False + if ( + isinstance(dados, ast.Call) + and isinstance(dados.func, ast.Attribute) + and dados.func.attr == "copy" + and isinstance(dados.func.value, ast.Name) + ): + return dados.func.value.id, True + return None, False + + +def dataframes_reusados(codigos): + """Nomes passados a dataframeit() depois de uma chamada que já os processou. + + O dataframeit grava os resultados e o status no próprio DataFrame pandas de + entrada, e a chamada seguinte sobre ele, ou sobre uma cópia dele, não tem + linha pendente: com o mesmo modelo não chama o LLM, e com um modelo de + outros campos levanta ValueError. O nome volta a ser livre quando é + atribuído de novo; gravar numa coluna dele (`df["x"] = ...`) não o libera, + porque o status continua lá. + + `codigos` são as células na ordem de execução. A atribuição conta no fim + do comando, depois da chamada que ela recebe: em `df = dataframeit(df, ...)`, + o `df` seguinte já é a saída. + """ + eventos: list[tuple[tuple[int, int | None, int | None], str, str, bool]] = [] + for celula, codigo in enumerate(codigos): + for no in ast.walk(ast.parse(codigo)): + if isinstance(no, ast.Assign): + eventos += [ + ((celula, no.end_lineno, no.end_col_offset), "atribui", alvo.id, False) + for alvo in no.targets + if isinstance(alvo, ast.Name) + ] + elif ( + isinstance(no, ast.Call) + and isinstance(no.func, ast.Name) + and no.func.id == "dataframeit" + ): + nome, copia = _argumento_de_dados(no) + if nome is not None: + eventos.append(((celula, no.lineno, no.col_offset), "processa", nome, copia)) + processados = set() + reusados = [] + for _, tipo, nome, copia in sorted(eventos): + if tipo == "atribui": + processados.discard(nome) + elif nome in processados: + reusados.append(nome) + elif not copia: + processados.add(nome) + return reusados + + def test_ha_exemplos_para_checar(): assert NOTEBOOKS assert SCRIPTS @@ -78,6 +139,40 @@ def test_notebook_sem_saidas(caminho): assert celula["execution_count"] is None +@pytest.mark.parametrize("caminho", NOTEBOOKS + SCRIPTS, ids=lambda caminho: caminho.name) +def test_exemplo_nao_reusa_dataframe_ja_processado(caminho): + assert dataframes_reusados(_codigos(caminho)) == [] + + def test_checagem_acusa_parametro_inexistente(): codigo = "dataframeit(df, Modelo, PROMPT, placeholder='sentenca', resume=True)" assert argumentos_desconhecidos(codigo) == ["placeholder"] + + +@pytest.mark.parametrize( + ("codigos", "esperado"), + [ + (["a = dataframeit(df, M, P)\nb = dataframeit(df, M, Q)"], ["df"]), + (["a = dataframeit(df, M, P)", "b = dataframeit(df.copy(), M, Q)"], ["df"]), + (["a = dataframeit(df.copy(), M, P)", "b = dataframeit(df.copy(), M, Q)"], []), + (["a = dataframeit(df, M, P)", "df = carrega()", "b = dataframeit(df, M, Q)"], []), + (["a = dataframeit(df, M, P)", "df['x'] = 1", "b = dataframeit(df, M, Q)"], ["df"]), + (["a = dataframeit(data=df, questions=M, prompt=P)", "b = dataframeit(data=df)"], ["df"]), + (["df = dataframeit(df, M, P)\nb = dataframeit(df, M, Q)"], []), + (["a = dataframeit(df, M, P)\nb = dataframeit(outro, M, Q)"], []), + (["a = dataframeit(['texto'], M, P)\nb = dataframeit(df[:2], M, Q)"], []), + ], + ids=[ + "mesmo-df", + "copia-de-df-ja-processado", + "copia-nas-duas", + "reatribuido-entre-as-chamadas", + "coluna-gravada-nao-libera", + "por-nome", + "saida-no-mesmo-nome", + "outro-df", + "entrada-sem-nome", + ], +) +def test_checagem_acusa_dataframe_reusado(codigos, esperado): + assert dataframes_reusados(codigos) == esperado