From 5023eb6e2971e1f23567b880cd317169bb55940e Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 21 Oct 2026 22:07:45 -0300 Subject: [PATCH 1/4] =?UTF-8?q?Coluna=20do=20usu=C3=A1rio=20com=20nome=20d?= =?UTF-8?q?e=20campo=20ou=20de=20status=20levanta=20ValueError=20antes=20d?= =?UTF-8?q?o=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sem a coluna de status, a coluna da entrada com algum valor e com o nome de um campo do modelo, fora de reprocess_columns, levanta ValueError com resume=True e resume=False. status_column igual à coluna de texto, a um campo, a outra coluna de controle ou a uma coluna com valores que não são de status também levanta. O aviso de reexecução sobre a própria saída sai, porque o caso agora levanta. --- src/dataframeit/core.py | 121 +++++++++++++++++++------ tests/test_entrada_e_saida.py | 162 +++++++++++++++++++++++++++++++++- 2 files changed, 257 insertions(+), 26 deletions(-) diff --git a/src/dataframeit/core.py b/src/dataframeit/core.py index 09019e12..931967bb 100644 --- a/src/dataframeit/core.py +++ b/src/dataframeit/core.py @@ -56,11 +56,13 @@ ) from .search import get_available_providers, get_provider from .utils import ( + _USER_COLUMN, DEFAULT_TEXT_COLUMN, ORIGINAL_TYPE_PANDAS_DF, ORIGINAL_TYPE_POLARS_DF, TOKEN_COLUMNS, ConversionInfo, + _column_rank, _json_default, _keeps_nan, from_pandas, @@ -966,6 +968,64 @@ def _has_field_config(pydantic_model: type[BaseModel]) -> bool: return bool(_collect_configured_fields(pydantic_model)) +# O que a execução grava na coluna de status; a célula vazia marca a linha pendente. +_STATUS_VALUES = ("processed", "error") + + +def _validate_status_column( + df: pd.DataFrame, status_col: str, *, text_column: str, expected_columns: list[str] +) -> None: + """Recusa a coluna de status que ocuparia uma coluna de entrada, de resposta ou de controle. + + A execução grava o status de cada linha por cima do que houver na coluna e, + na saída sem erro, a remove. Uma coluna existente só serve de status quando + traz apenas o que a própria execução grava nela, e aí é a de uma execução + anterior, que se retoma. + """ + if status_col == text_column: + msg = ( + f"status_column='{status_col}' é a coluna de texto, e a coluna de status " + "ocuparia o texto de entrada. Escolha outro nome." + ) + raise ValueError(msg) + if status_col in expected_columns: + msg = ( + f"status_column='{status_col}' é um campo do modelo, e a coluna de status " + "ocuparia a resposta dele. Escolha outro nome." + ) + raise ValueError(msg) + # _column_rank diz quais colunas a execução acrescenta; o status padrão é a + # única delas que pode ser a coluna de status, e com o nome de outra cada + # linha teria dois valores numa célula. O nome fica recusado mesmo com a + # opção que cria a coluna desligada, porque uma retomada com ela ligada + # (track_tokens é o padrão) gravaria por cima do status. + if ( + status_col != "_dataframeit_status" + and _column_rank(status_col, "_dataframeit_status") != _USER_COLUMN + ): + msg = ( + f"status_column='{status_col}' é o nome de uma coluna de controle do dataframeit, " + "gravada na mesma linha que o status, e um dos dois valores se perderia. " + "Escolha outro nome." + ) + raise ValueError(msg) + if status_col in df.columns: + status = df[status_col] + foreign = status[status.notna() & ~status.isin(_STATUS_VALUES)] + if not foreign.empty: + # tolist() devolve escalares Python, cujo repr não traz o tipo do numpy. + value, label = foreign.tolist()[0], foreign.index.tolist()[0] + allowed = ", ".join(repr(status_value) for status_value in _STATUS_VALUES) + msg = ( + f"A coluna de status '{status_col}' já existe no DataFrame com valores que não " + f"são de status do dataframeit ({value!r} na linha {label!r}), e a execução os " + f"leria como status e sobrescreveria a coluna. A coluna de status só guarda " + f"{allowed} ou vazio: passe em status_column outro nome, ou renomeie a coluna " + "existente." + ) + raise ValueError(msg) + + # Os overloads ligam o tipo da entrada ao da saída. O de pandas vem primeiro: # sem polars instalado, PolarsDataFrame não resolve e casaria com qualquer # entrada se fosse o primeiro. @@ -1085,9 +1145,13 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública perguntas: (Deprecated) Use 'questions'. resume: Se True (padrão), continua de onde parou: só as linhas sem status são processadas, e as marcadas 'processed' ou 'error' ficam como estão. - Se False, processa toda linha que não esteja 'processed'. Com False, se - alguma coluna do modelo já existe no DataFrame e reprocess_columns não - foi passado, emite um aviso e devolve a entrada sem processar. + Se False, processa toda linha que não esteja 'processed'. Com False e a + coluna de status presente, se alguma coluna do modelo já existe no + DataFrame e reprocess_columns não foi passado, emite um aviso e devolve + a entrada sem processar. Sem a coluna de status, com True ou False, toda + linha conta como nova, e uma coluna com algum valor e com o nome de um + campo do modelo levanta ValueError antes de qualquer chamada, a menos + que esteja em reprocess_columns. reprocess_columns: Lista de colunas para forçar reprocessamento. Útil para atualizar colunas específicas com novas instruções sem perder outras. model: Nome do modelo LLM. Se None, usa o default do provider em @@ -1095,7 +1159,10 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública provider: Provider do LangChain ('openai', 'google_genai', 'anthropic', etc), 'claude_code' ou 'codex'. Codex usa o SDK Python oficial. status_column: Coluna para rastrear progresso. Se None, usa - "_dataframeit_status". + "_dataframeit_status". Não pode ser a coluna de texto, um campo do + modelo nem outra coluna de controle do dataframeit, e a coluna + existente com esse nome só serve se trouxer apenas 'processed', + 'error' ou vazio; fora disso, levanta ValueError. text_column: Nome da coluna com textos. Se None em um DataFrame, a lib infere dentre TEXT_COLUMN_CANDIDATES ('texto', 'text', 'decisao', 'content', 'content_text'); DataFrames com uma @@ -1361,6 +1428,9 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública raise ValueError(msg) status_col = status_column or "_dataframeit_status" + _validate_status_column( + df_pandas, status_col, text_column=text_column, expected_columns=expected_columns + ) complex_fields = get_complex_fields(questions) # Campos cujo NaN é valor, separado do nulo (`_keeps_nan`). nan_columns = frozenset( @@ -1412,8 +1482,29 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública ) return from_pandas(df_pandas, conversion_info, status_col) - # Verificar conflitos de colunas + # Sem coluna de status, toda linha conta como nova e recebe a resposta em + # todos os campos, e a coluna de entrada com o nome de um deles seria + # sobrescrita; só a de reprocess_columns foi pedida, e a coluna sem valor + # nenhum não tem o que perder. Vem depois da releitura do checkpoint, que + # traz a coluna de status da execução que se retoma, e da entrada vazia, + # que não tem linha a sobrescrever. + reprocessed_columns = set(reprocess_columns or []) existing_cols = [col for col in expected_columns if col in df_pandas.columns] + overwritten = [ + col + for col in existing_cols + if col not in reprocessed_columns and df_pandas[col].notna().to_numpy().any() + ] + if overwritten and status_col not in df_pandas.columns: + msg = ( + f"As colunas {overwritten} já existem no DataFrame com o nome de campos do " + f"modelo, e sem a coluna de status '{status_col}' a resposta do LLM as " + "sobrescreveria. Renomeie ou remova essas colunas. Se o DataFrame é uma saída " + f"do dataframeit, devolva a ele a coluna de status com df['{status_col}'] = " + "'processed': a execução o retoma, e reprocess_columns refaz só as colunas " + "indicadas." + ) + raise ValueError(msg) if existing_cols and not resume and not reprocess_columns: warnings.warn( f"Colunas {existing_cols} já existem. Use resume=True para continuar ou renomeie-as.", @@ -1421,26 +1512,6 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública ) return from_pandas(df_pandas, conversion_info, status_col) - # Sem coluna de status, resume=True trata toda linha como pendente. Numa - # saída anterior sem erros a coluna foi removida, e rodar de novo refaria - # todas as chamadas. - if ( - resume - and not reprocess_columns - and status_col not in df_pandas.columns - and existing_cols == expected_columns - and df_pandas[expected_columns].notna().to_numpy().any() - ): - warnings.warn( - f"As colunas {expected_columns} já estão preenchidas e não há coluna " - f"'{status_col}': todas as linhas serão processadas de novo. Se o " - "DataFrame já é uma saída do dataframeit, não é preciso rodar outra vez; " - "para refazer só algumas colunas, use reprocess_columns.", - UserWarning, - stacklevel=2, - ) - - reprocessed_columns = set(reprocess_columns or []) incompatible_columns = [] processed_values = {} if resume or reprocess_columns: diff --git a/tests/test_entrada_e_saida.py b/tests/test_entrada_e_saida.py index f1fd993f..c714e951 100644 --- a/tests/test_entrada_e_saida.py +++ b/tests/test_entrada_e_saida.py @@ -305,6 +305,161 @@ def test_reexecucao_sobre_a_propria_saida_avisa(): assert any("já estão preenchidas" in str(a.message) for a in avisos) +# ============================================================================= +# Coluna do usuário com o nome de um campo do modelo ou da coluna de status +# ============================================================================= + + +class ModeloCategoria(BaseModel): + categoria: str + nota: int + + +def _rotulos_humanos(): + return pd.DataFrame( + {"id": [10, 20], "texto": ["a", "b"], "categoria": ["rótulo humano 1", "rótulo humano 2"]} + ) + + +def _recusa(entrada, match, questions=ModeloCategoria, **opcoes): + """Roda esperando o ValueError de `match`, sem chamar o LLM nem alterar a entrada.""" + antes = entrada.copy() + opcoes.setdefault("track_tokens", False) + with ( + patch( + "dataframeit.core.call_langchain", side_effect=_responde(categoria="llm", nota=1) + ) as llm, + patch("dataframeit.core.validate_provider_dependencies"), + pytest.raises(ValueError, match=match), + ): + dataframeit(entrada, questions=questions, prompt="{texto}", **opcoes) + llm.assert_not_called() + assert entrada.equals(antes) + + +@pytest.mark.parametrize("resume", [True, False]) +def test_coluna_com_nome_de_campo_sem_status_levanta(resume): + _recusa( + _rotulos_humanos(), + r"As colunas \['categoria'\] já existem .* Renomeie ou remova", + resume=resume, + ) + + +def test_coluna_com_nome_de_campo_e_checkpoint_levanta_sem_gravar(tmp_path): + checkpoint = tmp_path / "c.csv" + _recusa( + _rotulos_humanos(), + r"As colunas \['categoria'\] já existem", + batch_size=1, + checkpoint_path=checkpoint, + ) + assert not checkpoint.exists() + + +def test_coluna_de_campo_com_valor_em_uma_linha_levanta(): + df = pd.DataFrame({"texto": ["a", "b"], "categoria": [None, "rótulo humano"]}) + _recusa(df, r"As colunas \['categoria'\] já existem") + + +def test_coluna_de_campo_sem_valor_nenhum_recebe_a_resposta(): + df = pd.DataFrame({"texto": ["a", "b"], "categoria": [None, np.nan]}) + + resultado, llm = _rodar( + df, questions=ModeloCategoria, llm=_responde(categoria=lambda t: f"llm-{t}", nota=1) + ) + + assert llm.call_count == 2 + assert resultado["categoria"].tolist() == ["llm-a", "llm-b"] + + +@pytest.mark.parametrize("status_column", [None, "st"]) +def test_saida_completa_sem_status_levanta_e_diz_como_retomar(status_column): + primeira, _ = _rodar(pd.DataFrame({"texto": ["a", "b"]}), status_column=status_column) + nome = status_column or "_dataframeit_status" + assert nome not in primeira.columns + + _recusa( + primeira, + rf"\['x'\] já existem .* df\['{nome}'\] = 'processed'", + questions=Modelo, + status_column=status_column, + ) + + +def test_reprocess_columns_sem_status_refaz_a_coluna_pedida(): + df = pd.DataFrame({"texto": ["a", "b"], "categoria": ["humano a", "humano b"]}) + + resultado, llm = _rodar( + df, + questions=ModeloCategoria, + llm=_responde(categoria=lambda t: f"llm-{t}", nota=1), + reprocess_columns=["categoria"], + ) + + assert llm.call_count == 2 + assert resultado["categoria"].tolist() == ["llm-a", "llm-b"] + + +def test_reprocess_columns_sem_status_nao_cobre_outra_coluna_existente(): + df = _rotulos_humanos().assign(nota=[7, 8]) + _recusa(df, r"As colunas \['categoria'\] já existem", reprocess_columns=["nota"]) + + +def test_dataframe_vazio_com_coluna_de_campo_sem_status_nao_levanta(): + vazio = pd.Series([], dtype=object) + resultado, llm = _rodar(pd.DataFrame({"texto": vazio, "x": vazio})) + + llm.assert_not_called() + assert list(resultado.columns) == ["texto", "x"] + + +@pytest.mark.parametrize( + ("status_column", "match"), + [ + ("categoria", "status_column='categoria' é um campo do modelo"), + ("texto", "status_column='texto' é a coluna de texto"), + ("id", r"A coluna de status 'id' já existe .*\(10 na linha 0\)"), + ("_error_details", "status_column='_error_details' é o nome de uma coluna de controle"), + ("_input_tokens", "status_column='_input_tokens' é o nome de uma coluna de controle"), + ("_search_credits", "status_column='_search_credits' é o nome de uma coluna de controle"), + ("_trace", "status_column='_trace' é o nome de uma coluna de controle"), + ("_trace_categoria", "status_column='_trace_categoria' é o nome de uma coluna de controle"), + ], +) +def test_status_column_que_ocuparia_outra_coluna_levanta(status_column, match): + _recusa(_rotulos_humanos().drop(columns="categoria"), match, status_column=status_column) + + +def test_status_column_com_o_nome_da_coluna_interna_de_texto_levanta(): + _recusa( + pd.Series(["a", "b"]), + "status_column='_texto' é a coluna de texto", + status_column="_texto", + ) + + +def test_coluna_de_status_padrao_com_outros_valores_levanta(): + df = pd.DataFrame({"texto": ["a"], "_dataframeit_status": ["concluído"]}) + _recusa(df, r"A coluna de status '_dataframeit_status' já existe .*\('concluído' na linha 0\)") + + +def test_status_column_existente_com_valores_de_status_retoma(): + df = pd.DataFrame( + { + "texto": ["a", "b", "c"], + "x": ["feito", "anterior", None], + "st": ["processed", "error", np.nan], + } + ) + + resultado, llm = _rodar(df, status_column="st") + + assert llm.call_count == 1 + assert resultado["x"].tolist() == ["feito", "anterior", "x-c"] + assert resultado["st"].tolist() == ["processed", "error", "processed"] + + # ============================================================================= # Parâmetros # ============================================================================= @@ -865,7 +1020,12 @@ def test_saida_polars_ja_concluida_devolve_o_campo_de_lista_normalizado(): def test_entrada_polars_com_colunas_existentes_volta_igual(): pl = pytest.importorskip("polars") entrada = pl.DataFrame( - {"texto": ["a"], "x": [date(2024, 1, 2)], "id": pl.Series([None], dtype=pl.Int64)} + { + "texto": ["a"], + "x": [date(2024, 1, 2)], + "id": pl.Series([None], dtype=pl.Int64), + "_dataframeit_status": ["error"], + } ) with pytest.warns(UserWarning, match="já existem"): From f22d8ae01c424bb37352cd02d33287e098d7aa40 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 21 Oct 2026 22:07:49 -0300 Subject: [PATCH 2/4] =?UTF-8?q?Documenta=C3=A7=C3=A3o=20de=20resume=20e=20?= =?UTF-8?q?status=5Fcolumn=20com=20a=20recusa=20da=20coluna=20do=20usu?= =?UTF-8?q?=C3=A1rio=20(#197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + docs/en/guides/faq.md | 6 +++++- docs/en/guides/performance.md | 2 +- docs/en/reference/api.md | 4 ++-- docs/guides/faq.md | 6 +++++- docs/guides/performance.md | 2 +- docs/reference/api.md | 4 ++-- 7 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa4c28b0..c667e2a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ### Corrigido +- Sem a coluna de status, uma coluna da entrada com algum valor e com o nome de um campo do modelo levanta `ValueError` antes de qualquer chamada, com `resume=True` e com `resume=False`, a menos que esteja em `reprocess_columns`. Antes, com `resume=True`, a resposta do LLM a sobrescrevia sem aviso quando só parte das colunas do modelo existia, e só avisava quando existiam todas; com `resume=False`, avisava e devolvia a entrada. `status_column` igual à coluna de texto, a um campo do modelo, ao nome de outra coluna de controle do dataframeit ou a uma coluna existente com valores que não são de status também levanta `ValueError`. Antes, a coluna sumia da saída com a resposta que ocupava, ou a execução terminava sem chamar o LLM e a coluna de texto ou a do usuário sumia (#197). - 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/docs/en/guides/faq.md b/docs/en/guides/faq.md index 6a2f9a0d..c5e69725 100644 --- a/docs/en/guides/faq.md +++ b/docs/en/guides/faq.md @@ -47,7 +47,11 @@ With `resume=True` (default), only rows without status are processed. To redo th ### The model columns already exist and nothing was processed -With `resume=False`, if the model columns are already in the DataFrame and `reprocess_columns` was not passed, dataframeit emits a warning and returns the data unprocessed, so as not to overwrite results. Use `resume=True` to continue, or `reprocess_columns=[...]` to redo specific columns. +With `resume=False` and the status column in the DataFrame, if the model columns are already in it and `reprocess_columns` was not passed, dataframeit emits a warning and returns the data unprocessed, so as not to overwrite results. Use `resume=True` to continue, or `reprocess_columns=[...]` to redo specific columns. + +### `ValueError` because a column already exists with the name of a model field + +Without the status column, every row counts as new, and the model's answer would overwrite the input column that has the name of a field, such as a classification made by hand. dataframeit refuses the run before any call, with `resume=True` or `resume=False`; a column without any value and a column in `reprocess_columns` do not count. Rename or drop the column. If the DataFrame is a dataframeit output, which comes without the status column when no row had an error, put the column back with `df["_dataframeit_status"] = "processed"` (or the name passed in `status_column`): the run resumes from it, and `reprocess_columns` redoes only the columns listed. ## Limits and costs diff --git a/docs/en/guides/performance.md b/docs/en/guides/performance.md index 4394db93..809126aa 100644 --- a/docs/en/guides/performance.md +++ b/docs/en/guides/performance.md @@ -105,7 +105,7 @@ result = dataframeit( ) ``` -The format is inferred from the file extension (`.csv`, `.xlsx`, `.parquet`). In every format, the model's list, dict and tuple fields are saved as JSON text, and `Enum` values by their value; `read_df` turns the structures back, and resuming returns structures and date, time, `datetime`, `Enum`, `int` and `bool` fields with their declared type, also with `strict=True`: a date saved as text, an integer read back as a float and a tuple read back as a list come back as `date`, `int` and `tuple`. XLSX stores numbers as doubles, and an integer above 2**53 comes back rounded; for ids that large, use CSV or parquet. None of the three formats keeps the NaN of a `float` field apart from null: CSV and XLSX write both as an empty cell, and parquet as null. When resuming, the NaN comes back missing, and a required field that received it is flagged for reprocessing. In parquet, an integer above 2**63 - 1 next to a pending row and an integer above 2**53 next to a float in the same column are not saved, and the checkpoint save fails with a warning. If execution is interrupted, run the same call again with the same input: with `resume=True`, the default, and the file present, execution continues from the checkpoint. On every save, `.dataframeit.json` is written next to it with the run's signature (prompt, provider, model, `model_kwargs` except `timeout`, schema, search, and the text and status columns), a hash of each row's text, and a hash of the checkpoint itself. The checkpoint is resumed only when all three match; with another configuration, another input, or a file that is not the one the signature describes, the run warns, starts over, and overwrites it. In a row already concluded, successfully or with an error, the model fields come from the checkpoint, over whatever the input carried in them; in a successful row, that is what an uninterrupted run would give, because it writes the answer over them. To keep an output corrected by hand as it is, pass it with the status column, and the checkpoint is not read again. A complete output is returned without it, and the column comes back with `output["_dataframeit_status"] = "processed"` (or the name passed in `status_column`). With `resume=False`, the file is ignored and overwritten. +The format is inferred from the file extension (`.csv`, `.xlsx`, `.parquet`). In every format, the model's list, dict and tuple fields are saved as JSON text, and `Enum` values by their value; `read_df` turns the structures back, and resuming returns structures and date, time, `datetime`, `Enum`, `int` and `bool` fields with their declared type, also with `strict=True`: a date saved as text, an integer read back as a float and a tuple read back as a list come back as `date`, `int` and `tuple`. XLSX stores numbers as doubles, and an integer above 2**53 comes back rounded; for ids that large, use CSV or parquet. None of the three formats keeps the NaN of a `float` field apart from null: CSV and XLSX write both as an empty cell, and parquet as null. When resuming, the NaN comes back missing, and a required field that received it is flagged for reprocessing. In parquet, an integer above 2**63 - 1 next to a pending row and an integer above 2**53 next to a float in the same column are not saved, and the checkpoint save fails with a warning. If execution is interrupted, run the same call again with the same input: with `resume=True`, the default, and the file present, execution continues from the checkpoint. On every save, `.dataframeit.json` is written next to it with the run's signature (prompt, provider, model, `model_kwargs` except `timeout`, schema, search, and the text and status columns), a hash of each row's text, and a hash of the checkpoint itself. The checkpoint is resumed only when all three match; with another configuration, another input, or a file that is not the one the signature describes, the run warns, starts over, and overwrites it. In a row already concluded, successfully or with an error, the model fields come from the checkpoint, over whatever the input carried in them. To keep an output corrected by hand as it is, pass it with the status column, and the checkpoint is not read again. A complete output is returned without it, and the column comes back with `output["_dataframeit_status"] = "processed"` (or the name passed in `status_column`). With `resume=False`, the file is ignored and overwritten. To inspect what has already been processed, `read_df` loads the file with lists, dicts and text in the model's types. That DataFrame also works as input for resuming, and then the file is not read again: diff --git a/docs/en/reference/api.md b/docs/en/reference/api.md index bc9bcaa7..bfd105cd 100644 --- a/docs/en/reference/api.md +++ b/docs/en/reference/api.md @@ -54,9 +54,9 @@ def dataframeit( | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `resume` | bool | `True` | With `True`, continues from where it stopped: processes only rows without status and keeps those with `'processed'` or `'error'`. With `False`, processes every row that is not `'processed'`; if the model columns already exist and `reprocess_columns` is not passed, it emits a warning and returns the data unprocessed | +| `resume` | bool | `True` | With `True`, continues from where it stopped: processes only rows without status and keeps those with `'processed'` or `'error'`. With `False`, processes every row that is not `'processed'`; with the status column present, if the model columns already exist and `reprocess_columns` is not passed, it emits a warning and returns the data unprocessed. Without the status column, with `True` or `False`, every row counts as new, and an input column that holds any value and has the name of a model field raises `ValueError` before any call, unless it is in `reprocess_columns` | | `reprocess_columns` | list | `None` | Fields to force reprocessing; when resuming with a changed model, it must cover fields incompatible with previously processed rows | -| `status_column` | str | `None` | Name of the status column; `None` uses `_dataframeit_status` | +| `status_column` | str | `None` | Name of the status column; `None` uses `_dataframeit_status`. It cannot be the text column, a model field or another of the [added columns](#added-columns), and an existing column with that name is accepted only if it holds nothing but `'processed'`, `'error'` or empty cells; otherwise, raises `ValueError` | #### Model diff --git a/docs/guides/faq.md b/docs/guides/faq.md index d3364cfa..6fc6b968 100644 --- a/docs/guides/faq.md +++ b/docs/guides/faq.md @@ -47,7 +47,11 @@ Com `resume=True` (padrão), só as linhas sem status são processadas. Para ref ### As colunas do modelo já existem e nada foi processado -Com `resume=False`, se as colunas do modelo já estão no DataFrame e `reprocess_columns` não foi passado, o dataframeit emite um aviso e devolve os dados sem processar, para não sobrescrever resultados. Use `resume=True` para continuar, ou `reprocess_columns=[...]` para refazer colunas específicas. +Com `resume=False` e a coluna de status no DataFrame, se as colunas do modelo já estão nele e `reprocess_columns` não foi passado, o dataframeit emite um aviso e devolve os dados sem processar, para não sobrescrever resultados. Use `resume=True` para continuar, ou `reprocess_columns=[...]` para refazer colunas específicas. + +### `ValueError` porque uma coluna já existe com o nome de um campo do modelo + +Sem a coluna de status, toda linha conta como nova, e a resposta do modelo sobrescreveria a coluna da entrada que tem o nome de um campo, como uma classificação feita à mão. O dataframeit recusa a execução antes de qualquer chamada, com `resume=True` ou `resume=False`; a coluna sem nenhum valor e a que está em `reprocess_columns` não contam. Renomeie ou remova a coluna. Se o DataFrame é uma saída do dataframeit, que sai sem a coluna de status quando nenhuma linha teve erro, devolva a coluna com `df["_dataframeit_status"] = "processed"` (ou o nome passado em `status_column`): a execução retoma dela, e `reprocess_columns` refaz só as colunas indicadas. ## Limites e custos diff --git a/docs/guides/performance.md b/docs/guides/performance.md index bc3a78d2..d6bbd47c 100644 --- a/docs/guides/performance.md +++ b/docs/guides/performance.md @@ -107,7 +107,7 @@ resultado = dataframeit( ) ``` -Formato inferido pela extensão do arquivo (`.csv`, `.xlsx`, `.parquet`). Em qualquer formato, os campos de lista, dict e tupla do modelo são gravados como texto JSON, e o `Enum`, pelo valor; `read_df` devolve as estruturas, e a retomada devolve ao tipo declarado as estruturas e os campos de data, hora, `datetime`, `Enum`, `int` e `bool`, inclusive com `strict=True`: a data gravada como texto, o inteiro relido como float e a tupla relida como lista voltam a `date`, `int` e `tuple`. O XLSX guarda número como double, e um inteiro acima de 2**53 volta arredondado dele; para ids desse tamanho, use CSV ou parquet. Nenhum dos três formatos guarda o NaN de um campo `float` à parte do nulo: o CSV e o XLSX gravam os dois como célula vazia, e o parquet, como nulo. Na retomada, o NaN volta ausente, e o campo obrigatório que o recebeu é acusado para reprocessar. No parquet, o inteiro acima de 2**63 - 1 ao lado de uma linha pendente e o inteiro acima de 2**53 ao lado de um float na mesma coluna não são gravados, e a gravação do checkpoint falha com aviso. Em caso de interrupção, rode a mesma chamada de novo, com a mesma entrada: com `resume=True`, o padrão, e o arquivo presente, a execução continua do checkpoint. A cada gravação, `.dataframeit.json` é gravado ao lado dele com a assinatura da execução (prompt, provider, modelo, `model_kwargs` menos `timeout`, schema, busca e as colunas de texto e de status), o hash do texto de cada linha e o hash do próprio checkpoint. O checkpoint só é retomado quando os três batem; com outra configuração, outra entrada ou um arquivo que não é o que a assinatura descreve, a execução avisa, começa do zero e o sobrescreve. Numa linha já concluída, com sucesso ou com erro, os campos do modelo vêm do checkpoint, sobre o que a entrada trazia neles; na linha com sucesso, é o que uma execução sem interrupção daria, porque ela grava a resposta por cima. Para manter uma saída corrigida à mão como está, passe-a com a coluna de status, e o checkpoint não é relido. A saída completa sai sem ela, e a coluna volta com `saida["_dataframeit_status"] = "processed"` (ou o nome passado em `status_column`). Com `resume=False`, o arquivo é ignorado e sobrescrito. +Formato inferido pela extensão do arquivo (`.csv`, `.xlsx`, `.parquet`). Em qualquer formato, os campos de lista, dict e tupla do modelo são gravados como texto JSON, e o `Enum`, pelo valor; `read_df` devolve as estruturas, e a retomada devolve ao tipo declarado as estruturas e os campos de data, hora, `datetime`, `Enum`, `int` e `bool`, inclusive com `strict=True`: a data gravada como texto, o inteiro relido como float e a tupla relida como lista voltam a `date`, `int` e `tuple`. O XLSX guarda número como double, e um inteiro acima de 2**53 volta arredondado dele; para ids desse tamanho, use CSV ou parquet. Nenhum dos três formatos guarda o NaN de um campo `float` à parte do nulo: o CSV e o XLSX gravam os dois como célula vazia, e o parquet, como nulo. Na retomada, o NaN volta ausente, e o campo obrigatório que o recebeu é acusado para reprocessar. No parquet, o inteiro acima de 2**63 - 1 ao lado de uma linha pendente e o inteiro acima de 2**53 ao lado de um float na mesma coluna não são gravados, e a gravação do checkpoint falha com aviso. Em caso de interrupção, rode a mesma chamada de novo, com a mesma entrada: com `resume=True`, o padrão, e o arquivo presente, a execução continua do checkpoint. A cada gravação, `.dataframeit.json` é gravado ao lado dele com a assinatura da execução (prompt, provider, modelo, `model_kwargs` menos `timeout`, schema, busca e as colunas de texto e de status), o hash do texto de cada linha e o hash do próprio checkpoint. O checkpoint só é retomado quando os três batem; com outra configuração, outra entrada ou um arquivo que não é o que a assinatura descreve, a execução avisa, começa do zero e o sobrescreve. Numa linha já concluída, com sucesso ou com erro, os campos do modelo vêm do checkpoint, sobre o que a entrada trazia neles. Para manter uma saída corrigida à mão como está, passe-a com a coluna de status, e o checkpoint não é relido. A saída completa sai sem ela, e a coluna volta com `saida["_dataframeit_status"] = "processed"` (ou o nome passado em `status_column`). Com `resume=False`, o arquivo é ignorado e sobrescrito. Para inspecionar o que já foi processado, `read_df` carrega o arquivo com listas, dicts e textos nos tipos do modelo. Esse DataFrame também serve de entrada para a retomada, e aí o arquivo não é lido de novo: diff --git a/docs/reference/api.md b/docs/reference/api.md index 2f62c744..ee6bc4c7 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -54,9 +54,9 @@ def dataframeit( | Parâmetro | Tipo | Padrão | Descrição | |-----------|------|--------|-----------| -| `resume` | bool | `True` | Com `True`, continua de onde parou: processa só as linhas sem status e preserva as que têm `'processed'` ou `'error'`. Com `False`, processa toda linha que não esteja `'processed'`; se as colunas do modelo já existirem e `reprocess_columns` não for passado, emite um aviso e devolve os dados sem processar | +| `resume` | bool | `True` | Com `True`, continua de onde parou: processa só as linhas sem status e preserva as que têm `'processed'` ou `'error'`. Com `False`, processa toda linha que não esteja `'processed'`; com a coluna de status presente, se as colunas do modelo já existirem e `reprocess_columns` não for passado, emite um aviso e devolve os dados sem processar. Sem a coluna de status, com `True` ou `False`, toda linha conta como nova, e uma coluna da entrada com algum valor e com o nome de um campo do modelo levanta `ValueError` antes de qualquer chamada, a menos que esteja em `reprocess_columns` | | `reprocess_columns` | list | `None` | Lista de colunas para forçar reprocessamento; ao retomar com modelo alterado, deve cobrir os campos incompatíveis das linhas já processadas | -| `status_column` | str | `None` | Nome da coluna de status; `None` usa `_dataframeit_status` | +| `status_column` | str | `None` | Nome da coluna de status; `None` usa `_dataframeit_status`. Não pode ser a coluna de texto, um campo do modelo nem outra das [colunas adicionadas](#colunas-adicionadas), e a coluna existente com esse nome só serve se trouxer apenas `'processed'`, `'error'` ou vazio; fora disso, levanta `ValueError` | #### Modelo From 1dae1c3c995ba06a641dd32c95394db9cfb24f2b Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 21 Oct 2026 22:24:38 -0300 Subject: [PATCH 3/4] Testes que afirmavam a sobrescrita passam a esperar o ValueError, e a mensagem indica reprocess_columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mensagem sugere reprocess_columns com as colunas que seriam sobrescritas, somadas às já pedidas. A docstring de _resume_from_checkpoint deixa de dizer que a execução sem interrupção grava a resposta por cima da coluna da entrada. --- src/dataframeit/core.py | 17 +++++++---- tests/test_entrada_e_saida.py | 51 ++++++++++++++------------------- tests/test_reprocess_columns.py | 5 +++- tests/test_retomada_assinada.py | 14 +++++++-- 4 files changed, 47 insertions(+), 40 deletions(-) diff --git a/src/dataframeit/core.py b/src/dataframeit/core.py index 931967bb..89faa517 100644 --- a/src/dataframeit/core.py +++ b/src/dataframeit/core.py @@ -1496,13 +1496,18 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública if col not in reprocessed_columns and df_pandas[col].notna().to_numpy().any() ] if overwritten and status_col not in df_pandas.columns: + # A sugestão soma as colunas já pedidas, para não tirá-las do reprocessamento. + suggested = [ + col for col in expected_columns if col in reprocessed_columns or col in overwritten + ] msg = ( f"As colunas {overwritten} já existem no DataFrame com o nome de campos do " f"modelo, e sem a coluna de status '{status_col}' a resposta do LLM as " - "sobrescreveria. Renomeie ou remova essas colunas. Se o DataFrame é uma saída " - f"do dataframeit, devolva a ele a coluna de status com df['{status_col}'] = " - "'processed': a execução o retoma, e reprocess_columns refaz só as colunas " - "indicadas." + "sobrescreveria. Renomeie ou remova essas colunas, ou passe " + f"reprocess_columns={suggested!r} para sobrescrevê-las de propósito. Se o " + "DataFrame é uma saída do dataframeit, devolva a ele a coluna de status com " + f"df['{status_col}'] = 'processed': a execução o retoma, e reprocess_columns " + "refaz só as colunas indicadas." ) raise ValueError(msg) if existing_cols and not resume and not reprocess_columns: @@ -2082,8 +2087,8 @@ def _resume_from_checkpoint( Das linhas já concluídas, com sucesso ou com erro, vêm as colunas que a execução acrescenta e os campos do modelo, sobre o que a entrada trazia - neles. Na linha com sucesso é o que a execução sem interrupção daria, porque - ela grava a resposta por cima; na linha com erro, são as respostas anteriores + neles. Na linha com sucesso, é a resposta paga que o checkpoint gravou; na + linha com erro, são as respostas anteriores que um reprocessamento guardou, ou o que a entrada daquela execução trazia. A entrada não tem como dizer se um valor ali é correção à mão ou resposta de outra execução, diff --git a/tests/test_entrada_e_saida.py b/tests/test_entrada_e_saida.py index c714e951..9a14200a 100644 --- a/tests/test_entrada_e_saida.py +++ b/tests/test_entrada_e_saida.py @@ -294,15 +294,18 @@ def test_lista_em_coluna_existente_float(): # ============================================================================= -def test_reexecucao_sobre_a_propria_saida_avisa(): - primeira, _ = _rodar(pd.DataFrame({"texto": ["a", "b"]})) - assert "_dataframeit_status" not in primeira.columns - - with warnings.catch_warnings(record=True) as avisos: - warnings.simplefilter("always") - _rodar(primeira, resume=True) +@pytest.mark.parametrize("status_column", [None, "st"]) +def test_reexecucao_sobre_a_propria_saida_levanta_e_diz_como_retomar(status_column): + primeira, _ = _rodar(pd.DataFrame({"texto": ["a", "b"]}), status_column=status_column) + nome = status_column or "_dataframeit_status" + assert nome not in primeira.columns - assert any("já estão preenchidas" in str(a.message) for a in avisos) + _recusa( + primeira, + rf"\['x'\] já existem .* df\['{nome}'\] = 'processed'", + questions=Modelo, + status_column=status_column, + ) # ============================================================================= @@ -341,7 +344,8 @@ def _recusa(entrada, match, questions=ModeloCategoria, **opcoes): def test_coluna_com_nome_de_campo_sem_status_levanta(resume): _recusa( _rotulos_humanos(), - r"As colunas \['categoria'\] já existem .* Renomeie ou remova", + r"As colunas \['categoria'\] já existem .* Renomeie ou remova essas colunas, ou passe " + r"reprocess_columns=\['categoria'\] para sobrescrevê-las de propósito\.", resume=resume, ) @@ -373,20 +377,6 @@ def test_coluna_de_campo_sem_valor_nenhum_recebe_a_resposta(): assert resultado["categoria"].tolist() == ["llm-a", "llm-b"] -@pytest.mark.parametrize("status_column", [None, "st"]) -def test_saida_completa_sem_status_levanta_e_diz_como_retomar(status_column): - primeira, _ = _rodar(pd.DataFrame({"texto": ["a", "b"]}), status_column=status_column) - nome = status_column or "_dataframeit_status" - assert nome not in primeira.columns - - _recusa( - primeira, - rf"\['x'\] já existem .* df\['{nome}'\] = 'processed'", - questions=Modelo, - status_column=status_column, - ) - - def test_reprocess_columns_sem_status_refaz_a_coluna_pedida(): df = pd.DataFrame({"texto": ["a", "b"], "categoria": ["humano a", "humano b"]}) @@ -403,7 +393,12 @@ def test_reprocess_columns_sem_status_refaz_a_coluna_pedida(): def test_reprocess_columns_sem_status_nao_cobre_outra_coluna_existente(): df = _rotulos_humanos().assign(nota=[7, 8]) - _recusa(df, r"As colunas \['categoria'\] já existem", reprocess_columns=["nota"]) + _recusa( + df, + r"As colunas \['categoria'\] já existem .* reprocess_columns=\['categoria', 'nota'\] " + "para sobrescrevê-las", + reprocess_columns=["nota"], + ) def test_dataframe_vazio_com_coluna_de_campo_sem_status_nao_levanta(): @@ -559,7 +554,7 @@ def test_padroes_do_modelo_em_coluna_float_na_retomada(): assert resultado["tags"].tolist() == [[], ["t"]] -def test_reexecucao_avisa_mesmo_com_linha_toda_nula(): +def test_reexecucao_levanta_mesmo_com_linha_toda_nula(): primeira, _ = _rodar( pd.DataFrame({"texto": ["a", "b"]}), questions=ModeloOpcional, @@ -567,11 +562,7 @@ def test_reexecucao_avisa_mesmo_com_linha_toda_nula(): ) assert primeira["x"].tolist() == ["x", None] - with warnings.catch_warnings(record=True) as avisos: - warnings.simplefilter("always") - _rodar(primeira, questions=ModeloOpcional, resume=True) - - assert any("já estão preenchidas" in str(a.message) for a in avisos) + _recusa(primeira, r"As colunas \['x'\] já existem", questions=ModeloOpcional) @pytest.mark.parametrize( diff --git a/tests/test_reprocess_columns.py b/tests/test_reprocess_columns.py index 0d19fe1f..def1a5a4 100644 --- a/tests/test_reprocess_columns.py +++ b/tests/test_reprocess_columns.py @@ -263,6 +263,7 @@ def test_reprocess_columns_with_existing_columns_no_warning(): "texto": ["a"], "campo1": ["valor_existente"], "campo2": ["outro_existente"], + "_dataframeit_status": ["processed"], } ) @@ -278,7 +279,7 @@ def test_reprocess_columns_with_existing_columns_no_warning(): ): warnings.simplefilter("always") - dataframeit( + result = dataframeit( df, questions=SimpleModel, prompt="Teste {texto}", @@ -289,6 +290,8 @@ def test_reprocess_columns_with_existing_columns_no_warning(): # Não deve ter warning sobre conflito de colunas conflict_warnings = [warning for warning in w if "já existem" in str(warning.message)] assert len(conflict_warnings) == 0 + assert result["campo1"].tolist() == ["novo"] + assert result["campo2"].tolist() == ["outro_existente"] def test_reprocess_columns_resume_after_interrupt(): diff --git a/tests/test_retomada_assinada.py b/tests/test_retomada_assinada.py index 01bbe546..599b7a0b 100644 --- a/tests/test_retomada_assinada.py +++ b/tests/test_retomada_assinada.py @@ -217,9 +217,9 @@ def llm(*args, **kwargs): return chamadas, llm -@pytest.mark.parametrize("reprocessar", [None, ["campo1", "campo2"]]) -def test_retomada_da_o_mesmo_resultado_que_a_execucao_sem_interrupcao(tmp_path, reprocessar): +def test_retomada_da_o_mesmo_resultado_que_a_execucao_sem_interrupcao(tmp_path): """Valor que a entrada já trazia antes da execução não vence a resposta paga.""" + reprocessar = ["campo1", "campo2"] ckpt = tmp_path / "ckpt.csv" df = pd.DataFrame({"texto": ["a", "b"], "campo1": ["humano", None]}) with warnings.catch_warnings(): @@ -478,7 +478,15 @@ def falha_em_b(texto, *args, **kwargs): with warnings.catch_warnings(): warnings.simplefilter("ignore") - _roda(df, falha_em_b, batch_size=1, checkpoint_path=ckpt, max_retries=1, base_delay=0) + _roda( + df, + falha_em_b, + batch_size=1, + checkpoint_path=ckpt, + max_retries=1, + base_delay=0, + reprocess_columns=["campo1"], + ) corrigida = df.copy() corrigida.loc[1, "campo1"] = "à mão" From f11abd913365ec8bfa49764307dfba736a9ec733 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 21 Oct 2026 22:24:39 -0300 Subject: [PATCH 4/4] =?UTF-8?q?FAQ=20indica=20reprocess=5Fcolumns=20para?= =?UTF-8?q?=20sobrescrever=20a=20coluna=20de=20prop=C3=B3sito=20(#197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/guides/faq.md | 2 +- docs/guides/faq.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/guides/faq.md b/docs/en/guides/faq.md index c5e69725..c20d0a0c 100644 --- a/docs/en/guides/faq.md +++ b/docs/en/guides/faq.md @@ -51,7 +51,7 @@ With `resume=False` and the status column in the DataFrame, if the model columns ### `ValueError` because a column already exists with the name of a model field -Without the status column, every row counts as new, and the model's answer would overwrite the input column that has the name of a field, such as a classification made by hand. dataframeit refuses the run before any call, with `resume=True` or `resume=False`; a column without any value and a column in `reprocess_columns` do not count. Rename or drop the column. If the DataFrame is a dataframeit output, which comes without the status column when no row had an error, put the column back with `df["_dataframeit_status"] = "processed"` (or the name passed in `status_column`): the run resumes from it, and `reprocess_columns` redoes only the columns listed. +Without the status column, every row counts as new, and the model's answer would overwrite the input column that has the name of a field, such as a classification made by hand. dataframeit refuses the run before any call, with `resume=True` or `resume=False`; a column without any value and a column in `reprocess_columns` do not count. Rename or drop the column, or pass it in `reprocess_columns` to overwrite it on purpose. If the DataFrame is a dataframeit output, which comes without the status column when no row had an error, put the column back with `df["_dataframeit_status"] = "processed"` (or the name passed in `status_column`): the run resumes from it, and `reprocess_columns` redoes only the columns listed. ## Limits and costs diff --git a/docs/guides/faq.md b/docs/guides/faq.md index 6fc6b968..07965f86 100644 --- a/docs/guides/faq.md +++ b/docs/guides/faq.md @@ -51,7 +51,7 @@ Com `resume=False` e a coluna de status no DataFrame, se as colunas do modelo j ### `ValueError` porque uma coluna já existe com o nome de um campo do modelo -Sem a coluna de status, toda linha conta como nova, e a resposta do modelo sobrescreveria a coluna da entrada que tem o nome de um campo, como uma classificação feita à mão. O dataframeit recusa a execução antes de qualquer chamada, com `resume=True` ou `resume=False`; a coluna sem nenhum valor e a que está em `reprocess_columns` não contam. Renomeie ou remova a coluna. Se o DataFrame é uma saída do dataframeit, que sai sem a coluna de status quando nenhuma linha teve erro, devolva a coluna com `df["_dataframeit_status"] = "processed"` (ou o nome passado em `status_column`): a execução retoma dela, e `reprocess_columns` refaz só as colunas indicadas. +Sem a coluna de status, toda linha conta como nova, e a resposta do modelo sobrescreveria a coluna da entrada que tem o nome de um campo, como uma classificação feita à mão. O dataframeit recusa a execução antes de qualquer chamada, com `resume=True` ou `resume=False`; a coluna sem nenhum valor e a que está em `reprocess_columns` não contam. Renomeie ou remova a coluna, ou passe-a em `reprocess_columns` para sobrescrevê-la de propósito. Se o DataFrame é uma saída do dataframeit, que sai sem a coluna de status quando nenhuma linha teve erro, devolva a coluna com `df["_dataframeit_status"] = "processed"` (ou o nome passado em `status_column`): a execução retoma dela, e `reprocess_columns` refaz só as colunas indicadas. ## Limites e custos