Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ The model is **Claude Haiku 5.5 on Vertex AI** (`claude-haiku-5-5`) by default.
| `evals/sync_cases.py` | Keeps `evals/.cases/` in line with the `agent_feedback` table: imports the promoted cases from the private case bucket, removes the retired ones, and with `--backfill` indexes cases that have no row ([Sync promoted cases from the bucket](#sync-promoted-cases-from-the-bucket)) |
| `evals/eligibility.py` | The model-free sweep of the catalogue's files of every Python library with a code profile (matplotlib, seaborn, plotnine, altair, pygal, lets-plot): eligible, coupled and blocked pairs, and why |
| `evals/scope.py`, `evals/scope.evalset.json` | The judge-only scope eval and its 219 synthetic cases: in-scope, off-topic and adversarial messages and notes pasted with a table, scored for adversarial recall and false refusals |
| `evals/prep.py`, `evals/fixtures/prep/` | The live prep eval and its 55 German and English cases (51 data requests, 10 of them `hard` and 11 data-code cases, and 4 step-1 summaries): each through `/v1` in process with the real model, scored on the `prepare_data` operations and `generate_data` calls the root chose, on the size of the table each `run_data_code` call stored (a `refuse` case passes when nothing was stored), and on the shape of the root's reply ([Run the prep eval](#run-the-prep-eval)) |
| `evals/prep.py`, `evals/fixtures/prep/` | The live prep eval and its 58 German and English cases (54 data requests, 10 of them `hard`, 11 data-code cases and 3 requests for example data, and 4 step-1 summaries): each through `/v1` in process with the real model, scored on the `prepare_data` operations and `generate_data` calls the root chose, on the size of the table each `run_data_code` call stored (a `refuse` case passes when nothing was stored), and on the shape of the root's reply ([Run the prep eval](#run-the-prep-eval)) |
| `evals/fixtures/cases/` | The eval cases: 360 generated (`<spec>-<library>-<perturbation>`), eleven hand-written, among them `scatter-basic-matplotlib`, `bar-grouped-seaborn`, `scatter-basic-matplotlib-german-message` (a German follow-up that sets the session language) and four `*-combined-*` cases whose follow-up asks for two changes at once, each measured by the case's `checks`, and 23 stress cases (`stress-<name>`) ([fixtures README](evals/fixtures/README.md)) |
| `evals/baselines/` | Committed baseline reports, one per model ([baselines README](evals/baselines/README.md)) |

Expand Down Expand Up @@ -842,3 +842,5 @@ Runs on 2026-10-15 with Claude Haiku 5.5 on `eu`, all 50 cases:
The two misses since the fix are model variance on the chain cases, not one cause: `de-hard-kette-gesamtzeile` once left out `drop_columns Notiz`, and `en-hard-messy-export` once added a `drop_rows` for the TOTAL line that the parser had already left out. Both still produce the right table.

Since live test round 5 of 2026-10-15 a 51st case, `de-tagesmittel-pro-sensor` (tags `aggregate`, `mean`, `period`, `day`), asks for a daily mean per sensor of readings taken every 13 minutes, which the `day` period of `aggregate` answers ([#12200](https://github.com/MarkusNeusinger/anyplot/pull/12200)). Its run on 2026-10-15 with Claude Haiku 5.5 on `eu`, all 51 cases on the prompt that names the `hour`, `day` and `week` periods (root prompt hash `be470a15161bedb9`): 51 of 51, 11 of 11 data-code cases, 146 model calls, $0.137.

Since 2026-11-01 three cases (tag `example_data`) ask for example data without a table, each with the size it names, so the stored table's rows and columns are fixed: "Beispieldaten mit 4 Regionen und 3 Jahren" on `bar-grouped` (12 rows, 3 columns), "example data with 50 points" on `scatter-basic` (50 by 2) and 24 monthly values on `line-timeseries` (24 by 2). The root writes the code itself and calls `run_data_code` with `source_language` `example` (see [Two steps: data first, then the plot](../docs/concepts/agent-network.md#two-steps-data-first-then-the-plot)). Their first run, on 2026-11-01 with Claude Haiku 5.5 on `eu` (root prompt hash `1e36782cc5cef769`): 3 of 3, 10 model calls, $0.010, replies of 39 words at the median. The scope eval's seven `example_data` and `example_data_contrast` cases (five made-up requests in German, English and French in scope, two named companies' real figures `needs_data`) passed 7 of 7 the same day for $0.004.
101 changes: 90 additions & 11 deletions agents/anyplot/data/classify.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
"""What text pasted into the data panel is, before any parser reads it: code, a formula, or neither.
"""What text pasted into the data panel is, before any parser reads it: code, a data request, a formula, or neither.

The dataset route (`agents/main.py`, `upload_dataset`) asks `classify_paste` first,
because both kinds would otherwise take a wrong path: a snippet such as
`np.linspace(0, 10, 100)` parses as a one-row table, and the transcriber would compute
a formula's rows itself. Code answers `422 code` and a formula `422 formula`; the page
offers to move either to the chat, where the root runs code (`run_data_code`) or
computes the formula (`generate_data`). Only text the chat takes
(`schemas.MAX_MESSAGE_CHARS`, 2,000 characters) can move there; longer code is refused
all the same (it is never a table), and the page asks to shorten it.
because these kinds would otherwise take a wrong path: a snippet such as
`np.linspace(0, 10, 100)` parses as a one-row table, the transcriber would compute
a formula's rows itself, and a request for data ("Beispieldaten") holds no table for it
to read. Code answers `422 code`, a data request `422 data_request` and a formula
`422 formula`; the page offers to move code or a formula to the chat, and sends a data
request there itself, where the root runs code (`run_data_code`), generates example data
with code it writes (`run_data_code` with `source_language` `example`) or computes the
formula (`generate_data`). Only text the chat takes (`schemas.MAX_MESSAGE_CHARS`, 2,000
characters) can move there; longer code is refused all the same (it is never a table),
and the page asks to shorten it.

**A data request** asks for data instead of holding it: example, sample, test or random
data ("Beispieldaten", "generiere Zufallszahlen mit Kategorien", "example data with 3
categories"). `looks_like_data_request` reads one line of at most `MAX_REQUEST_WORDS`
words for a phrase of `_REQUEST` (German, English, French, Spanish, Italian, Portuguese,
Dutch and Polish) and holding fewer than `generate.MIN_DATA_VALUES` values of its own
(`generate.stated_values`: the counts a request names, "3 Kategorien und 20 Zeilen", stay
below it; "Beispieldaten: Hund 7, Katze 3, Vogel 4" is data for the transcriber). A
request in other words or another language reaches the transcriber, whose answer says so
(`TranscribedTable.asks_for_data`).

**Code** is told from data line by line. A line is code when it has the shape of a
statement no table cell has: an import (`import x`, `from x import y`, `library(x)`,
Expand All @@ -29,10 +42,10 @@
from typing import Literal

from ..schemas import MAX_MESSAGE_CHARS
from .generate import looks_like_formula
from .generate import MIN_DATA_VALUES, looks_like_formula, stated_values


PasteKind = Literal["code", "formula"]
PasteKind = Literal["code", "data_request", "formula"]
SCAN_LINES = 200
"""Non-empty lines read from the top; a pasted table is decided long before."""
MIN_CODE_SHARE = 0.5
Expand Down Expand Up @@ -64,6 +77,40 @@
)
"""A line code has as well, which alone proves nothing: a comment, a brace, `return`, a line ending in `;`, `{`, `:`."""

MAX_REQUEST_WORDS = 30
"""The longest line the data-request guard reads, in words; a longer one is prose for the transcriber, whose answer
says whether it asks for data."""
_REQUEST = re.compile(
r"(?i)\b(?:"
# German compounds: Beispieldaten, Test-Daten, Zufallszahlen, Dummywerte, Beispieldatensatz
r"(?:beispiel|test|dummy|demo|zufalls|fantasie|phantasie|muster|übungs|uebungs|fake|probe)[- ]?"
r"(?:daten(?:satz|sätze|saetze)?|werte|zahlen|tabelle)"
# zufällige Daten, erfundene Werte, beliebige Zahlen
r"|(?:zufällig|zufaellig|erfunden|ausgedacht|künstlich|kuenstlich|synthetisch|beliebig|irgendwelch)\w{0,3}"
r"\s+(?:daten|werte|zahlen)"
# generiere Daten, erstelle mir eine Tabelle, denk dir ein paar Werte aus
r"|(?:generier|erzeug|erstell|mach|gib|denk\s+dir)\w{0,3}"
r"\s+(?:(?:mir|uns|bitte|mal|ein|eine|einen|einige|paar|etwas|irgendwelche)\s+){0,3}(?:daten|werte|zahlen|tabelle)"
# example data, random numbers, sample sales data, made-up values
r"|(?:example|sample|test|dummy|fake|mock|demo|toy|synthetic|random|made[- ]up|invented|placeholder|fictional"
r"|fictitious|arbitrary)\s+(?:\w+\s+)?(?:data(?:set)?|values|numbers|records|table)"
# generate some data, make me a table, give me some numbers
r"|(?:generate|create|make|give|invent|simulate)"
r"\s+(?:(?:me|us|up|some|a|an|few|random|fake|example|sample|test)\s+){0,3}(?:data(?:set)?|values|numbers|table)"
# French, Spanish and Portuguese, Italian
r"|(?:données|donnees|valeurs|nombres|chiffres)\s+(?:d['’]exemple|de\s+test|aléatoires|aleatoires|fictives|factices"
r"|synthétiques|synthetiques)"
r"|(?:exemple|jeu)\s+de\s+(?:données|donnees)"
r"|(?:datos|dados|valores|números|numeros)\s+(?:de\s+(?:ejemplo|exemplo|prueba|teste)|aleatorios|aleatórios"
r"|ficticios|fictícios|sintéticos|sinteticos)"
r"|(?:dati|valori|numeri)\s+(?:di\s+(?:esempio|prova)|casuali|fittizi|sintetici)"
# Dutch, Polish
r"|(?:voorbeeld|test|willekeurige|nep)\s*(?:data|gegevens|getallen|waarden)"
r"|(?:przykładowe|przykladowe|testowe|losowe)\s+(?:dane|liczby|wartości|wartosci)"
r")\b"
)
"""A phrase that asks for data to be made up: every alternative starts at a fixed word, and its gaps are bounded."""


def looks_like_code(text: str) -> bool:
"""Whether pasted text is a program (Python, JavaScript, R, ...) rather than a table or a formula."""
Expand All @@ -77,10 +124,42 @@ def looks_like_code(text: str) -> bool:
return shaped / len(lines) >= MIN_CODE_SHARE


def looks_like_data_request(text: str) -> bool:
"""Whether pasted text asks for data instead of holding it ("Beispieldaten", "random numbers with 3 categories").

One line of at most `MAX_REQUEST_WORDS` words that the chat takes (`MAX_MESSAGE_CHARS`), no
JSON, with a phrase of `_REQUEST` and fewer than `MIN_DATA_VALUES` values of its own. A
table, even one under the header `Testdaten`, has several lines or its values, and
stays the parser's.
"""
stripped = text.strip()
if not stripped or len(stripped) > MAX_MESSAGE_CHARS or len(stripped.splitlines()) > 1:
return False
if stripped.startswith(("{", "[")) or len(stripped.split()) > MAX_REQUEST_WORDS:
return False
return _REQUEST.search(stripped) is not None and stated_values(stripped) < MIN_DATA_VALUES


def request_for_chat(text: str, *, without_table: bool) -> bool:
"""Whether text the transcriber says asks for data (`TranscribedTable.asks_for_data`) goes to the chat as a data
request: the chat takes it (`MAX_MESSAGE_CHARS`), and the transcriber found no table in it (`without_table`, the
rubric's one `text` column) or it holds fewer than `MIN_DATA_VALUES` values of its own. A request that names its
sizes ("3 Kategorien, 4 Regionen und 12 Monate") goes, while a table in words that the model also read as one is
never sent away on a misreading."""
stripped = text.strip()
if not 0 < len(stripped) <= MAX_MESSAGE_CHARS:
return False
return without_table or stated_values(stripped) < MIN_DATA_VALUES


def classify_paste(text: str) -> PasteKind | None:
"""`code`, `formula` (only text the chat takes, `MAX_MESSAGE_CHARS`) or None for anything the parser should read."""
"""`code`, `data_request` or `formula` (only text the chat takes, `MAX_MESSAGE_CHARS`), or None for anything the
parser should read. A data request that names a curve ("Beispieldaten für eine Gaußkurve") is a data request: the
page sends it to the chat by itself, where the root picks the tool."""
if looks_like_code(text):
return "code"
if looks_like_data_request(text):
return "data_request"
if len(text) <= MAX_MESSAGE_CHARS and looks_like_formula(text):
return "formula"
return None
11 changes: 11 additions & 0 deletions agents/anyplot/data/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -806,3 +806,14 @@ def _data_values(text: str) -> int:
def _shape_only(text: str) -> bool:
"""A short request that names a curve and holds no number (`Gaußkurve`, `eine Parabel`)."""
return len(text.split()) <= MAX_SHAPE_WORDS and not _DIGIT.search(text) and _SHAPES.search(text) is not None


def stated_values(text: str) -> int:
"""How many numbers of `text` stand as values, outside its range phrases (`between 10 and 100`, `von 1 bis 5`).

The data-request guard (`classify.looks_like_data_request`) counts them as the formula
guard counts its values: a request that names its sizes ("3 Kategorien und 20 Zeilen")
holds fewer than `MIN_DATA_VALUES`, a sentence with its own data ("Hund 7, Katze 3 und
Vogel 4") holds that many.
"""
return _data_values(_blank(text, [match.span() for match in _RANGE.finditer(text)]))
10 changes: 9 additions & 1 deletion agents/anyplot/data/transcribe.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,21 @@


class TranscribedTable(BaseModel):
"""The model's reading of the text: column names, rows of cells as text, and a short note."""
"""The model's reading of the text: column names, rows of cells as text, a short note, and whether the text asks
for data instead of holding it.

`asks_for_data` is the transcriber's half of the data-request guard (`classify.looks_like_data_request` is the
rule-based half, before any model): text such as "monthly sales for 4 regions" or a request in a language the
rules do not read. The route answers it `422 data_request`, which the page sends to the chat, for text the chat
takes that holds no table by this answer or no values of its own (`classify.request_for_chat`).
"""

model_config = ConfigDict(extra="forbid")

columns: list[Header] = Field(min_length=1, max_length=MAX_TRANSCRIBE_COLUMNS)
rows: list[Row] = Field(min_length=1, max_length=MAX_TRANSCRIBE_ROWS)
note: str = Field(default="", max_length=MAX_TRANSCRIBE_NOTE_CHARS)
asks_for_data: bool = False


MAX_TRANSCRIBE_LINES = MAX_TRANSCRIBE_ROWS
Expand Down
12 changes: 9 additions & 3 deletions agents/anyplot/dataset_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,18 @@ def code_source(code: str, language: str) -> str:
what it does, not its names), so they reach the adapter and the reviewer only fenced as
`<user_notes>` data, at most `MAX_SOURCE_NAMES` per kind and `MAX_SOURCE_CHARS` in all.
A function the root named after the system (`def lorenz(...)`) carries the subject
into the title.
into the title. Example data (`language` `example`) is named as such: the root made it
up for a user who asked for example data, so it is no measurement.
"""
head = (
"generated example data, made up by Python code"
if language == "example"
else f"computed by Python code (translated from {language})"
)
try:
tree = ast.parse(code)
except SyntaxError, ValueError:
return f"computed by Python code (translated from {language})"
return head
defined: list[str] = []
calls: list[str] = []
assigned: list[str] = []
Expand All @@ -80,7 +86,7 @@ def code_source(code: str, language: str) -> str:
calls.append(name)
elif isinstance(item, ast.Assign):
assigned += [target.id for target in item.targets if isinstance(target, ast.Name)]
parts = [f"computed by Python code (translated from {language})"]
parts = [head]
for label, names in (("functions defined", defined), ("calls", calls), ("names", assigned)):
unique = list(dict.fromkeys(names))[:MAX_SOURCE_NAMES]
if unique:
Expand Down
3 changes: 3 additions & 0 deletions agents/anyplot/plugins/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ class RequestLedger:
"""The change lines of every `prepare_data` call of the turn, in order: the `done` event's `dataset.changes`."""
data_code_calls: int = 0
"""`run_data_code` calls of this invocation; ToolSafety allows `MAX_DATA_CODE_CALLS`."""
example_data: bool = False
"""A `run_data_code` call of this invocation stored example data the user asked for (`source_language` `example`):
the `chat_turn` line's `kind` is `example`."""
root_request: Any = None
"""The root's latest `LlmRequest`, kept by the reply-language plugin so a reply in the wrong language can be
asked again once with the same context (`plugins/reply_language.py`)."""
Expand Down
9 changes: 7 additions & 2 deletions agents/anyplot/plugins/tool_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,17 @@ class _PrepareArgs(BaseModel):
operations: list[Operation] = Field(min_length=1, max_length=MAX_OPERATIONS)


DataCodeSource = Literal["python", "javascript", "r", "math", "other", "example"]
"""What the user gave `run_data_code`'s Python from: code or a formula in a language, or `example` for example data the
user asked for without giving any (`tools/session.py` `SOURCE_LANGUAGES`, pinned equal by a test)."""


class _DataCodeArgs(BaseModel):
"""`run_data_code`: Python of at most `MAX_DATA_CODE_CHARS`, and what the user wrote it in (attribution only)."""
"""`run_data_code`: Python of at most `MAX_DATA_CODE_CHARS`, and what the user gave it from (`DataCodeSource`)."""

model_config = ConfigDict(extra="forbid")
code: str = Field(min_length=1, max_length=MAX_DATA_CODE_CHARS)
source_language: Literal["python", "javascript", "r", "math", "other"]
source_language: DataCodeSource


ARGUMENTS: dict[str, type[BaseModel]] = {
Expand Down
Loading
Loading