Skip to content

feat(agent): text operations in the data step, bound-pair rule - #12556

Merged
MarkusNeusinger merged 5 commits into
mainfrom
feat/agent-text-operations
Nov 4, 2026
Merged

MarkusNeusinger merged 5 commits into
mainfrom
feat/agent-text-operations

Conversation

@MarkusNeusinger

@MarkusNeusinger MarkusNeusinger commented Nov 4, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Text operations in the data step. prepare_data (and query_data) take four more operations, literal texts only, never a pattern: split (column, separator, names: 2 to 10), extract (keep: number, digits or letters), strip (prefix, suffix) and replace (old, new). Each reads the source's canonical cell texts and writes new columns beside it; the source stays, so .undo() and the user's check work as for every other operation. A new column is typed as set_type reads one (prepare._typed): numbers with the dataset's decimal mark, its thousands rules and at most a tenth of failing cells, which become missing and are counted; else dates; else text, kept text through the re-parse. A number counts only in a cell the parser reads as one (10 kg, never Q1), leading zeros keep a column text, and a column with a cell of digits alone is never read as dates. The steps chain in one call.
  • Bound-pair rule in the binder (agents/anyplot/data/bounds.py). Lower and upper bound pairs come from the specs' role names (lower/upper, low/high, min/max, lcl/ucl: min_value/max_value, ci_lower/ci_upper, low/high, lower_80/upper_80, ...; error_lower/error_upper and the tornado chart's low_value/high_value are excluded, since their values are no bounds). The default bindings never bind a pair whose lower column starts above the upper column's maximum: both roles stay open. A pair bound so by hand is valid and carries a binding warning (telemetry code bound_pair_inverted).
  • A certain range split for an open pair (conversions.RangeSplit, prepare.range_run): when both roles of a pair are open and every cell of a free text column is two plain numbers around one dash, nothing guessed, the first never above the second, the session block lists the split as certain; its two new columns (<column> (min) and <column> (max) in the session language) are bound by carry_bindings, the first to the lower role whatever order the spec lists the roles in. Two parts of one split fill a pair only when the first neither starts nor ends above the second (bounds.ordered), so 22-18 cells leave the pair open, never swapped.

This closes the failure classes range-text-split and binding-min-max-inverted of the agent feedback triage (a range-interval table with its ranges in one text column, and a count and an average bound as the minimum and the maximum).

Owner input on this PR: a hand-bound inverted pair is never blocked

A person may bind high and low "the wrong way round" on purpose. Checked and changed in d09a036:

  1. check_bindings: a hand binding of an inverted pair stays valid and complete (no error, no missing role, so .confirm() is never disabled), with one short, neutral warning line that asks for nothing. The line lost its instruction ("bind the two columns that hold each range's ends").
  2. carry_bindings: a binding that stays valid is never dropped, so a pair the user bound so survives every prepare_data change and every undo; only an inverted binding carry_bindings would add itself is left out. New tests: test_a_pair_bound_high_to_low_by_hand_survives_a_change_of_the_data (a rename that sends the defaults in, a sort, a split whose parts would fit the pair) and test_a_pair_the_user_bound_high_to_low_survives_prepare_data_and_undo (through push_version and undo_version).
  3. root.md: the warning is mentioned once in the session, in one neutral sentence, with what would fit instead; after that the root follows the user, never changes the binding unasked and never mentions it again.

Copilot's round

  • Applied: two columns of one source (two extracts) could fill a pair as if they were a split. Prepared.splits now records each split's parts, push_version and carry_bindings pass them on, and only two neighbouring parts of one split fill a pair.
  • Kept, with the reason in the thread: extract number of Q1 gives 1 (an explicit request, as A-12 gives 12; the Q1 rule is for the automatic typing), and of #00123 gives 123 (number is for amounts; digits keeps the zeros as text).

Decided along the way

  • A cell without the separator stays whole in the first new column, and the last column keeps the rest of a cell with more separators (the convention of a spreadsheet's text to columns); the change line counts both.
  • A separator of spaces cuts at every run of whitespace. A - or − that starts a part right before a number is that part's sign: -5-10 is -5 and 10.
  • Every text operation needs a new column name (name, names): the source is never changed in place.
  • ToolSafety exempts the literals (separator, prefix, suffix, old, new) and names from the link rule, as it exempts a condition's value and a label: they are compared with the cells or written into them. A column reference stays checked.
  • Thousands that read both ways (1.200) make no certain range split, as for any number reading that guesses its mark; asked for, the split reads them the file's own way (1200 in a decimal-comma file).
  • A variadic family (y_lower1, y_upper1) is no pair: its members bind in column order.
  • The binder test data of TestBollinger had every lower band above every upper band (the helper fills each column above the one before it); the columns now stand lower band first. The rule caught it rightly; no production code was changed to make a test pass.

Plan

Agent case triage round 1, general fixes D and E (owner decisions 2026-11-04).

Test plan

  • uv run ruff check ., uv run ruff format --check .
  • uv run --extra typecheck --extra agents mypy api core agents --pretty
  • uv run pytest tests/unit tests/integration -n 8: 26,603 passed before the Flash fixes; tests/unit/agents 20,023 passed on the head. New: test_bounds.py, and TestSplit, TestExtract, TestStripAndReplace, TestTextChains (prepare), TestBoundPairs (bindings), TestRanges (conversions), a push-and-undo test (dataset versions), the link-rule literals (plugins), the warning code, the eval fixtures and describe.
  • The existing rules win, each with a test: ISO dates stay dates (also split on request), -5 stays a number, a decimal comma 1,5 and thousands 1.200 keep their reading, ids and codes (A-12, a column named id) and spans of years stay text unless the user asks, Q1 stays a label, 007 keeps its zeros.
  • uv run python -m tools.changelog check --base origin/main
  • Prep eval, run because root.md's "Step 1: the data" changed (the README asks for it; cost under $1, so no sign-off needed): all 71 cases with Claude Haiku 5.5 on eu passed the gates, 65 of 71 exact (91.5 %), 0 unavailable, 200 model calls, $0.18; the certain conversions were made 5 of 5, the range split among them; replies median 52 words, 1 reply miss. The six new text cases: 5 of 6, the miss chose digits for "the number" of a ticket id (#4711, the same table either way); the prompt now tells number (an amount) from digits (an id or a code), and the case asks for an amount. The other misses: en-percent-unit-mixed also misses on main (a baseline run of its tags), de-klasse-aus-vorzeichen and en-code-strip-plot-calls are model variance, and de-datum-tag-monat-jahr and de-hard-kette-gesamtzeile added a row_number from the session block's row-number line in a message turn (both matched on main in one baseline run each). A rerun after the Flash fixes (tags text, date_format, summary-row, 9 cases, $0.035): 8 of 9, the six text cases 6 of 6, de-datum-tag-monat-jahr matched without a row_number, and de-hard-kette-gesamtzeile missed only by leaving out drop_columns Notiz, the variance the README already names for that case. Total eval spend: $0.24.
  • Gemini 3.8 Flash check before the PR: "sound with changes", 5 findings, all real, all fixed in the second commit (high: extract dropped the minus of $-100 and -$100; medium: -.5-10 was cut at its sign, and a manual high-to-low split could fill a pair the wrong way; low: two prompt wordings and an internal count).

Texts to read

agents/anyplot/prompts/root.md

  1. Binding warnings bullet, sentences added at its end:
    • Before: (none)
    • After: "A binding warning that a row's lower bound is above its upper bound names a pair of roles such as min_value and max_value bound to two columns whose values do not overlap, the lower above the upper. The binding is valid and may be meant: mention it once in the session, in one neutral sentence, with what would fit instead (other columns, or a split of a column that holds the ranges as text, such as 10–25). After that it is the user's choice: never change it unasked, never mention it again, and draw the plot as bound."
  2. "When the user asks in words for a change to the data (...)": the list of examples
    • Before: "... build a date column from year and month columns), or says how a column is meant to be read"
    • After: "... build a date column from year and month columns, split a column of ranges such as 10–20 or of names into two or more columns, keep only the number, the digits or the letters of each cell, take a prefix or a suffix such as # or CHF off the cells, replace a text in the cells), or says how a column is meant to be read"
  3. The operations list, four new entries after row_number:
    • {"op": "split", "column": ..., "separator": ..., "names": [2 to 10 new names]} (cuts each cell at the text separator, such as "-", "–", ",", ";", "/", " to " or a space " ", into new columns named names, in order: 10–25 gives 10 and 25; a space cuts at every run of spaces; a - that starts a part right before a digit is its minus sign, so -5-10 gives -5 and 10; a cell without the separator stays whole in the first new column, and the last one keeps the rest of a cell with more separators, which changes counts)
    • {"op": "extract", "column": ..., "keep": "number" | "digits" | "letters", "name": ...} (adds a column with number, the one number of each cell, for an amount or a measure, read with the dataset's decimal mark: CHF 1'200 gives 1200, ca. -3.5 °C gives -3.5; a cell with no number or with two, such as 10–20, is left empty, and more than a tenth of such cells refuse it; with digits, every digit in order, for an id or a code: +41 79 gives 4179, #00123 keeps its zeros; with letters, only the letters: 12 kg gives kg)
    • {"op": "strip", "column": ..., "prefix": ..., "suffix": ..., "name": ...} (adds a column with the text prefix taken off the start and suffix off the end of each cell that has them; one of the two is enough)
    • {"op": "replace", "column": ..., "old": ..., "new": ..., "name": ...} (adds a column with every old in each cell replaced by new; leave new out to remove old)
  4. New bullet after the operations list:
    • "The four text operations (split, extract, strip, replace) take their texts literally, never as a pattern, add their new columns last and keep column as it is. Each new column is read as set_type reads one: numbers with the dataset's decimal mark, else dates, else text, and changes says which, with the cells left empty. They combine in one call, each step naming the column an earlier step made: #A-12 takes a strip of the prefix #, then a split of the new column at -, whose part 12 is already a number; vol. 3; ed. 2 takes a split at ;, then an extract of the number of each part. Ids and codes such as A-12 stay as they are unless the user asks for them to be split."
  5. "Conversions for open roles", sentence added after the row-number sentence:
    • After: "A line for a lower and an upper bound (such as min_value and max_value) names a text column of ranges such as 10–25 and its split into two new columns, which the server binds to the two roles: say which column you split, with the count and the two new columns' names."
  6. "Data work that an operation can do is yours":
    • Before: "never ask the user to add, compute or reformat a column in their file when prepare_data can do it, and never use run_data_code for it, ... When no operation fits, say which change is needed and that it has to come from the file."
    • After: "never ask the user to add, compute or reformat a column in their file or in a spreadsheet when prepare_data can do it, also when it takes several operations in one call, and never use run_data_code for it, ... When no operation and no chain of them fits, say which change is needed and that it has to come from the file."

The prepare_data tool description (agents/anyplot/tools/session.py, the docstring the model reads) names the four operations the same way.

User-visible and model-visible strings (all new)

  • Binding warning (one neutral line, never an error): "min_value and max_value: every value of 'games' (from 140) lies above every value of 'mean' (up to 67.2), so each row's lower bound is above its upper bound"
  • Session-block line of the range split: "min_value and max_value: column 'min–max' (text): 3 of 3 cells would split at '–' into two numbers, the first never above the second, and be read as numbers, as the new columns 'min–max (min)' (min_value) and 'min–max (max)' (max_value); certain: every cell reads and nothing is guessed; operations: [...]"
  • Change lines (the # changed block of the data panel):
    • "split min–max at '–' into min and max: 3 of 3 cells split; min and max read as numbers; min–max stays as it is"
    • "split r at '-' into lo and hi: 2 of 3 cells split, 1 cell without it kept whole in lo, 1 with more keep the rest in hi; lo read as numbers; hi kept as text; r stays as it is"
    • "new column n: the number in 10 of 11 cells of t, read as numbers, 1 cell without exactly one number is missing; t stays as it is"
    • "new column l: the letters of 2 of 3 cells of t, 1 cell without a letter is empty; l kept as text; t stays as it is"
    • "new column n: '#' off the start of 2 of 3 cells of t; n read as numbers; t stays as it is"
    • "new column r: ' / ' replaced with '–' in 2 of 2 cells of t; r kept as text; t stays as it is" (and "'Fr.' removed in ..." for an empty new)
    • "1 cell of lo could not be read and is now missing" (a part with up to a tenth of failing cells)
  • Refusals (to the model): "no cell of column 'x' holds '/'", "no cell has a part for column 'c'; split into fewer columns", "the new columns need different names", "column 't': 2 of 3 cells hold no number, or more than one", "no cell of column 't' holds a digit", "strip needs a prefix, a suffix or both", "no cell of column 't' starts with '#' or ends with 'x'", "a cell would be longer than the 200 characters a cell may hold", and the too_wide "the data has N columns; K more would exceed the 50 that fit".

Not done here (for a follow-up)

  • The data panel shows its own hints under the role dropdowns (app/src/sections/agent-chat/bindings.ts, signHint, siblingHint); it has no hint for an inverted pair yet. The server warning reaches the root through the session block and the PUT /bindings answer.

https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8

MarkusNeusinger and others added 2 commits November 4, 2026 12:44
…n the binder

prepare_data gains split, extract, strip and replace, a closed set with
literal texts only. Each writes new columns beside its source, typed as
set_type reads them, and the steps chain in one call. The binder never
binds a lower and upper bound pair whose lower column starts above the
upper one's maximum; the pair stays open, and a text column of ranges
becomes a certain split conversion that fills it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
…plits

Fixes from the Gemini Flash check before the PR: extract keeps the
minus of `$-100`, `-$100`, `USD-20` and `ca.-15`; split keeps the
sign of `-.5`; the two parts of one split fill a bound pair only when
the first neither starts nor ends above the second, so `22-18` cells
leave the pair open. The root prompt quotes the separators, tells
`number` from `digits`, and its chain example no longer extracts a
part that already reads as a number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Copilot AI lite review requested due to automatic review settings November 4, 2026 13:32
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Three moderate active findings remain: range pairing can mix unrelated operations, number extraction misclassifies labels such as Q1, and extracted leading-zero values bypass the documented typing guard.

3 open issues
What changed in this PR

This pull request adds literal text operations to the agent data-preparation workflow and improves lower/upper bound-pair handling, including automatic range-text splitting.

Changes:

  • Added chainable split, extract, strip, and replace operations with validation, typing, undo support, and query support.
  • Added bound-pair discovery, inverted-pair warnings, and certain range-column conversions.
  • Expanded prompts, telemetry, documentation, changelog data, evaluations, and regression tests.
File Description
tests/​unit/​agents/​runtime/​test_plugins.py Tests URL/path safety exemptions for text-operation literals and names.
tests/​unit/​agents/​runtime/​test_dataset_versions.py Tests range splitting, binding carry-over, and undo behavior.
tests/​unit/​agents/​evals/​test_prep.py Updates operation and fixture-count assertions and tests operation descriptions.
tests/​unit/​agents/​data/​test_warning_codes.py Adds warning-code coverage for inverted bound pairs.
tests/​unit/​agents/​data/​test_prepare.py Adds comprehensive tests for text operations, typing, refusals, and operation chains.
tests/​unit/​agents/​data/​test_conversions.py Tests automatic range conversion candidates and language-specific names.
tests/​unit/​agents/​data/​test_computed.py Corrects Bollinger test data ordering for lower and upper bands.
tests/​unit/​agents/​data/​test_bounds.py Adds bound-pair discovery, inversion, ordering, and warning tests.
tests/​unit/​agents/​data/​test_bindings.py Tests inverted-pair defaults, carried bindings, and split-pair assignment.
docs/​concepts/​agent-network.md Documents text operations and bound-pair conversion behavior.
changelog.d/​agent-text-operations.md Records the new data operations and bound-pair fixes.
agents/​README.md Updates prep-evaluation counts and documents new fixtures.
agents/​evals/​prep.py Adds canonical descriptions for the four new operations.
agents/​evals/​fixtures/​prep/​en-summary-range-column.json Adds an English range-column summary case.
agents/​evals/​fixtures/​prep/​en-strip-a-prefix.json Adds an English prefix-stripping case.
agents/​evals/​fixtures/​prep/​en-keep-the-number.json Adds an English number-extraction case.
agents/​evals/​fixtures/​prep/​en-hard-text-chain.json Adds a chained strip-and-split case.
agents/​evals/​fixtures/​prep/​de-text-ersetzen.json Adds a German replacement case.
agents/​evals/​fixtures/​prep/​de-spalte-am-bindestrich-teilen.json Adds a German range-splitting case.
agents/​anyplot/​tools/​session.py Exposes the new operations in the model-facing tool documentation.
agents/​anyplot/​telemetry.py Adds the new operations to telemetry’s preparation-operation set.
agents/​anyplot/​prompts/​root.md Teaches the root agent when and how to use text operations and range conversions.
agents/​anyplot/​plugins/​tool_safety.py Exempts literal text-operation arguments from URL/path validation.
agents/​anyplot/​data/​warning_codes.py Adds the bound_pair_inverted warning code.
agents/​anyplot/​data/​prepare.py Implements text operations, typed output columns, range detection, and parsing helpers.
agents/​anyplot/​data/​conversions.py Adds automatic RangeSplit conversion candidates and localized range names.
agents/​anyplot/​data/​bounds.py Defines bound-pair discovery, inversion checks, ordering checks, and warning text.
agents/​anyplot/​data/​bindings.py Prevents inverted defaults and carries split columns into lower/upper roles.

Review effort: Lite

Comment thread agents/anyplot/data/bindings.py Resolved
Comment thread agents/anyplot/data/prepare.py Resolved
Comment thread agents/anyplot/data/prepare.py Resolved
@codecov

codecov Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.07830% with 22 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
agents/anyplot/data/prepare.py 93.77% 19 Missing ⚠️
agents/anyplot/data/bounds.py 95.23% 2 Missing ⚠️
agents/anyplot/data/conversions.py 98.03% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

MarkusNeusinger and others added 2 commits November 4, 2026 14:58
A hand binding of an inverted lower and upper bound pair stays valid,
blocks nothing, survives every change of the data and every undo, and
carries one neutral warning line that asks for nothing; the root
mentions it once and then follows the user. Only two neighbouring parts
of one split fill an open pair (Prepared.splits), never two extracts of
one column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Resolves the agents/README.md conflict: the prep eval's 71 cases from
this branch beside main's 900 generated eval cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
@MarkusNeusinger
MarkusNeusinger merged commit dffe048 into main Nov 4, 2026
17 checks passed
@MarkusNeusinger
MarkusNeusinger deleted the feat/agent-text-operations branch November 4, 2026 16:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants