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
64 changes: 60 additions & 4 deletions agents/anyplot/grounding.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@
label or ticks draw it) must use its decimal mark, dates and three-digit groups aside.
Each miss becomes a residual line, so the result says it honestly, and with
`AGENT_EXPECT_CHECK=repair` a measured finding the repair round gets.

A render without drawn texts (R, altair, pygal and lets-plot, which write no probe) is checked by the
reviewer instead where its runtime says so (`RuntimeAdapter.review_texts`, owner decision of
2026-10-29): the review request carries the requested texts, the reviewer reports each one it
cannot find in the image as a defect line under `EXPECT_LINE_ID` that quotes it, and
`reviewed_misses` reads those lines back. Only a render no readable review answered for keeps
`UNVERIFIED_LINE`.
"""

import re
Expand All @@ -30,6 +37,8 @@
from dataclasses import dataclass
from typing import Any

from core.defects import DEFECT_RE

from .schemas import MAX_SHOWN_TEXT_CHARS, ShownGroup


Expand All @@ -56,9 +65,12 @@
"SC-04 ({theme}): the requested texts could not be checked, since the renderer reported no drawn texts "
"→ compare them with the image. Likely cause: a renderer without the probe's text report."
)
"""The residual line of a render with requested texts but no `labels` in its probe: never a silent pass."""
"""The residual line of a render with requested texts but no `labels` in its probe, unless a readable review
answered for them (`reviewed_misses`): never a silent pass."""
EXPECT_LINE_ID = {"text": "SC-04", "format": "SC-02"}
"""The rubric id a missing requested text (title, legend, label) or number format is reported under."""
_QUOTED = re.compile(r'["“”„«»]([^"“”„«»\n]+)["“”„«»]')
"""A passage of a reviewer's line in straight or typographic double quotes or guillemets."""
_MATH_SPACE = re.compile(r"\\[,;:! ]|\\q?quad\b")
"""Mathtext that draws a space: read as a space."""
MATH_SYMBOLS: dict[str, str] = {
Expand All @@ -85,6 +97,11 @@
"""Mathtext markup characters left after the commands: dropped, so `CO$_2$` reads `CO2`."""
_DOLLAR = re.compile(r"(?<!\\)\$")
_QUOTES = str.maketrans(dict.fromkeys("\"'„“”«»‚‘’`", ""))
_QUOTE_MARKS = str.maketrans(dict.fromkeys("\"'„“”«»‚‘’`", '"'))
"""Every quotation mark as `"`, for the comparison that keeps them (`_norm(quotes=True)`)."""
REQUESTED_MARK = 'requested text "'
"""How a review line cites a requested text it cannot find in the image, as the reviewer's heading prescribes
(`pipeline.REVIEW_EXPECTED_HEADING`), read after `_norm(quotes=True)`."""
_DASHES = str.maketrans({"−": "-", "–": "-", "—": "-", " ": " ", " ": " ", "\xa0": " "})
_NUMBER = r"[+\-−]?\d(?:[\d.,'’ ]*\d)?"
_FORMAT = re.compile(
Expand Down Expand Up @@ -187,15 +204,16 @@ def plot_texts(drawn: DrawnTexts | None) -> dict[ShownGroup, list[str]] | None:
}


def _norm(text: str) -> str:
def _norm(text: str, *, quotes: bool = False) -> str:
"""A text as the comparison reads it: NFKC, case-folded, without quotes and mathtext markup, one space.

Mathtext is read as matplotlib draws it: a text with an even number of unescaped `$`
is math, whose delimiters draw nothing, a command that draws one character
(`MATH_SYMBOLS`) is that character, a spacing command is a space, and an escaped `\\$`
is a dollar sign; a lone `$` is a dollar sign too.
is a dollar sign; a lone `$` is a dollar sign too. With `quotes`, every quotation mark
stays, as `"`, so a quoted passage keeps its ends (`reviewed_misses`).
"""
text = unicodedata.normalize("NFKC", text).translate(_DASHES).translate(_QUOTES)
text = unicodedata.normalize("NFKC", text).translate(_DASHES).translate(_QUOTE_MARKS if quotes else _QUOTES)
delimiters = len(_DOLLAR.findall(text))
if delimiters and delimiters % 2 == 0:
text = _DOLLAR.sub("", text)
Expand Down Expand Up @@ -348,3 +366,41 @@ def expected_misses(expected: Sequence[str], drawn: DrawnTexts | None) -> list[E
if (not applies and drawn.complete) or (wrong is not None and any(_wrong_mark(t, wrong) for t in applies)):
misses.append(ExpectMiss(token, "format", _observed(shown, "format")))
return misses


def reviewed_misses(expected: Sequence[str], lines: Sequence[str]) -> list[tuple[str, str]]:
"""The requested texts a review reports missing from the image, each with the first defect line that does.

For a render without drawn texts the reviewer gets the requested texts and reports each one
it cannot find as a defect line under `EXPECT_LINE_ID` (SC-04, or SC-02 for a number format)
in the form its heading prescribes, `the requested text "<text>"`. A line in that form names
exactly the texts it cites so, compared with every quotation mark kept (`_norm(quotes=True)`:
case, spacing and dashes aside), so a cited `Temperatur (°C)` never names a requested
`Temperatur`, a text with quotes of its own (`Zone "A"`) is still named whole, and a passage
the line quotes for what the image shows instead names nothing. A line in another form names
a text when one of its quoted passages reads as that text; when it quotes none, or the text
has quotes of its own or is a number format (a line may quote only the number it saw), when it
holds the text whole (`_contains`). Any other line, and a text no line names, is no miss: the
reviewer found it.
"""
misses: list[tuple[str, str]] = []
for token in dict.fromkeys(" ".join(item.split()) for item in expected):
wanted = _norm(token)
if not wanted:
continue
cited = f'{REQUESTED_MARK}{_norm(token, quotes=True)}"'
loose = token.translate(_QUOTES) != token or format_unit(token) is not None
for line in lines:
match = DEFECT_RE.match(line)
if match is None or not set(EXPECT_LINE_ID.values()) & set(match["ids"].split(", ")):
continue
marked = _norm(line, quotes=True)
if REQUESTED_MARK in marked:
named = cited in marked
else:
quoted = [_norm(passage) for passage in _QUOTED.findall(line)]
named = wanted in quoted or ((loose or not quoted) and _contains(_norm(line), wanted))
if named:
misses.append((token, line))
break
return misses
75 changes: 67 additions & 8 deletions agents/anyplot/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@
allowed attempt), and once more on the repaired render after a rejection
(`MAX_REVIEWS` reviewer calls in all, every round with `AGENT_SHIP_BEST`); the
reviewer agent sees the rendered theme's PNG, so each of its image defects is
filed under that theme, whatever theme the reviewer named;
filed under that theme, whatever theme the reviewer named. A render without drawn
texts on a runtime whose reviewer checks them (`RuntimeAdapter.review_texts`: R,
altair, pygal and lets-plot) hands the review the requested texts, and each one it
cannot find in the image is a miss like the probe's (`grounding.reviewed_misses`), in the same call;
5. **repair**: when an attempt left feedback (an answer that missed the schema or was
cut off at the output limit, each with its own line, failed edits, validator
findings, a failed render, a missed canvas, reviewer defects, and measured
Expand Down Expand Up @@ -130,7 +133,7 @@
from .data.kinds import ColumnKinds, read_kinds
from .data.store import StoredDataset
from .dataset_context import dataset_context, file_name_for_models
from .grounding import UNVERIFIED_LINE, drawn_texts, expected_misses, plot_texts
from .grounding import EXPECT_LINE_ID, UNVERIFIED_LINE, drawn_texts, expected_misses, plot_texts, reviewed_misses
from .language import DEFAULT_LANGUAGE, same_language
from .plugins.ledger import RequestLedger, attribution, budget_allows, ledger_for
from .policy import DATA_PREAMBLE, fence
Expand Down Expand Up @@ -392,7 +395,12 @@ class Candidate:
expect_misses: list[str] = field(default_factory=list)
"""The same misses as the requested texts themselves, for `PlotShows.requested_not_found`."""
expect_note: str | None = None
"""`grounding.UNVERIFIED_LINE` when the run had requested texts but the probe reported no drawn texts."""
"""`grounding.UNVERIFIED_LINE` when the run had requested texts but the probe reported no drawn texts, and no
readable review of this render answered for them (`expect_review`)."""
expect_review: bool = False
"""The requested texts go to this render's review, since it reports no drawn texts and its runtime's reviewer
checks them (`RuntimeAdapter.review_texts`): a readable review replaces `expect_note` with the misses it
reports (`grounding.reviewed_misses`), as `expect_lines` and `expect_misses`."""


@dataclass
Expand Down Expand Up @@ -804,13 +812,38 @@ def render_review_request(request: ReviewRequest) -> str:
]
lines += _context_lines(request.dataset_context, request.file_name)
lines += _requests_lines(request.user_requests, request.change_request)
lines += _review_expected_lines(request.expected_texts, catalogue=False)
if request.gate_notes:
lines += ["Gate notes:", fence("tool_notes", json.dumps(request.gate_notes, ensure_ascii=False))]
return "\n".join(lines)


EARLIER_REQUESTS_HEADING = "Earlier requests (the user's standing decisions, oldest first):"
EXPECTED_HEADING = "Texts and number formats the user asked to see in the plot, verbatim (one per line):"
REVIEW_EXPECTED_HEADING = (
"Texts and number formats the user asked to see in the plot, verbatim (one per line). The server cannot read "
"the texts this render draws, so look for each one in the attached render yourself: a text must stand there as "
"written (case, spacing and quotes aside); a number format (a number with one unit, such as 6,5 Mio. or 12 %) "
"must draw its unit, and the numbers it applies to must use its decimal mark. Report each one you cannot find "
"as one {ids} finding whose observed part quotes it as listed here: the plot does not show the requested "
'text "<text>", then what it shows instead. One you find needs no finding:'
)
"""The reviewer's heading over the requested texts (`ReviewRequest.expected_texts`): only a render without drawn
texts gets it, so a review request of a render with the probe is the same as before 2026-10-29."""


def _review_expected_lines(texts: Sequence[str], *, catalogue: bool) -> list[str]:
"""The requested texts for the reviewer to find in the image, fenced as user text under their heading.

The catalogue reviewer reports a missing number format under SC-02 as the probe's check does
(`grounding.EXPECT_LINE_ID`); the checklist has no SC-02, so it reports every miss under SC-04.
"""
if not texts:
return []
ids = EXPECT_LINE_ID["text"] + (f" ({EXPECT_LINE_ID['format']} for a number format)" if catalogue else "")
return [REVIEW_EXPECTED_HEADING.format(ids=ids), fence("user_message", "\n".join(texts))]


CONTEXT_HEADING = (
"Dataset context (what the session knows about the data: the notes the user pasted with it and where a "
"computed table comes from; use it for the title and the labels):"
Expand Down Expand Up @@ -1221,6 +1254,9 @@ class ReviewOutcome:
"""Good enough to show without notes (`AGENT_STATUS_BAR`, the owner's ship bar); None means `ok`."""
visible: list[str] = field(default_factory=list)
"""The defect lines under `VISIBLE_DEFECT_IDS`: the render is not clean (`Candidate.visible_lines`)."""
requested: list[tuple[str, str]] = field(default_factory=list)
"""The requested texts the review could not find in the image, each with the line that says so
(`grounding.reviewed_misses`); empty when the request carried none (`ReviewRequest.expected_texts`)."""


async def _review(ctx: Context, scope: str, request: ReviewRequest, theme: Theme) -> ReviewOutcome | None:
Expand All @@ -1232,10 +1268,13 @@ async def _review(ctx: Context, scope: str, request: ReviewRequest, theme: Theme
raw = await ctx.run_node(agent, node_input=text, override_isolation_scope=scope)
if not catalogue:
verdict = Verdict.model_validate(raw)
lines = [_on_theme(defect, theme).as_line() for defect in verdict.defects]
requested = reviewed_misses(request.expected_texts, lines)
return ReviewOutcome(
verdict.ok,
[_on_theme(defect, theme).as_line() for defect in verdict.defects],
verdict.ok and not _repairs_miss(requested),
lines,
[defect.id for defect in verdict.defects],
requested=requested,
Comment thread
MarkusNeusinger marked this conversation as resolved.
Resolved
)
result = parse_answer(mend_answer(ScoreAnswer.model_validate(raw).model_dump()))
except (ValidationError, DynamicNodeFailError) as exc:
Expand All @@ -1259,7 +1298,10 @@ async def _review(ctx: Context, scope: str, request: ReviewRequest, theme: Theme
for line in result.defects
if (m := DEFECT_RE.match(line)) and VISIBLE_DEFECT_IDS & set(m["ids"].split(", "))
]
if visible:
# A requested text the review could not find in the image (a render without drawn texts), read from every
# line whatever the points: a repair finding as the probe's miss is under `AGENT_EXPECT_CHECK=repair`.
requested = reviewed_misses(request.expected_texts, result.defects)
if visible or _repairs_miss(requested):
# A visible defect gets the repair round whatever the points (`VISIBLE_DEFECT_IDS`); the ship bar stays.
ok = False
return ReviewOutcome(
Expand All @@ -1270,9 +1312,15 @@ async def _review(ctx: Context, scope: str, request: ReviewRequest, theme: Theme
round(result.technical_share, 3),
status_ok=status_ok,
visible=visible,
requested=requested,
)


def _repairs_miss(requested: Sequence[tuple[str, str]]) -> bool:
"""A requested text the review reports missing sends the round to the repair (`AGENT_EXPECT_CHECK=repair`)."""
return bool(requested) and get_settings().expect_check == "repair"


def render_score_request(request: ReviewRequest) -> str:
"""The catalogue reviewer's node input: the scorer's request (`scoring.user_text`) with the
gate notes and the change request added, every value fenced; the image follows from the callback."""
Expand All @@ -1287,6 +1335,7 @@ def render_score_request(request: ReviewRequest) -> str:
if request.dataset_context or request.file_name:
lines.append("\n".join(_context_lines(request.dataset_context, request.file_name)))
lines += _requests_lines(request.user_requests, request.change_request)
lines += _review_expected_lines(request.expected_texts, catalogue=True)
if request.gate_notes:
lines += ["Gate notes:", fence("tool_notes", json.dumps(request.gate_notes, ensure_ascii=False))]
return "\n\n".join(lines)
Expand Down Expand Up @@ -1727,8 +1776,10 @@ async def _attempts(
checking = settings.expect_check != "off" and bool(run.expected_texts)
misses = expected_misses(run.expected_texts, drawn) if checking else []
expect_lines = [miss.line(run.theme) for miss in misses]
# No drawn texts to check against (a renderer without `labels`): a note, never a pass and never a repair.
expect_note = UNVERIFIED_LINE.format(theme=run.theme) if checking and drawn is None else None
# No drawn texts to check against (a renderer without `labels`): a note, never a pass. Where the runtime says
# so (R), the review finds the texts in the image instead, and only its answer lifts the note.
unverified = checking and drawn is None
expect_note = UNVERIFIED_LINE.format(theme=run.theme) if unverified else None
candidate = Candidate(
working=working,
run_form=run_form,
Expand All @@ -1746,6 +1797,7 @@ async def _attempts(
expect_lines=expect_lines,
expect_misses=[miss.expected for miss in misses],
expect_note=expect_note,
expect_review=unverified and runtime.review_texts,
)
if report.canvas_ok:
run.best = candidate
Expand Down Expand Up @@ -1818,6 +1870,7 @@ async def _attempts(
plot_language=run.language,
dataset_context=context,
file_name=file_name,
expected_texts=run.expected_texts if candidate.expect_review else [],
)
started = time.monotonic()
verdict = await _review(ctx, scope, review_request, run.theme)
Expand Down Expand Up @@ -1860,6 +1913,12 @@ async def _attempts(
candidate.review_score = verdict.score
candidate.review_lines = [] if verdict.ok and candidate.status_ok else list(verdict.lines)
candidate.visible_lines = list(verdict.visible)
if candidate.expect_review:
# The review answered for the requested texts: the ones it could not find stay, with its own lines, as a
# measured miss does (residual, not clean), and no unverified note is left.
candidate.expect_lines = list(dict.fromkeys(line for _, line in verdict.requested))
candidate.expect_misses = [text for text, _ in verdict.requested]
candidate.expect_note = None
if verdict.score is not None and (run.best_scored is None or ship_compare(candidate, run.best_scored) >= 0):
run.best_scored = candidate
# The latest review's lines only: a passing review clears an earlier round's, so a later
Expand Down
2 changes: 1 addition & 1 deletion agents/anyplot/prompts/adapter_r.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ the blank lines around it are not part of it. This comment never reaches a model
a scales labeller such as `scales::label_number(scale = 1e-6, accuracy = 0.1, decimal.mark = ",", big.mark = ".", suffix = " Mio.")` that writes the decimal comma and the unit

<!-- slot: requested_check -->
An R render has no probe, so the server cannot read its drawn texts back: the result marks the requested texts as not checked, and only your code makes them right.
An R render has no probe, so the server cannot read its drawn texts back: the reviewer looks for each requested text in the image instead, and reports one it cannot find, which then comes back to you as a finding.

<!-- slot: measured_hints -->
An R render has no probe, so no hint is ever a target measured on a render: every hint quotes the code.
Expand Down
7 changes: 7 additions & 0 deletions agents/anyplot/render/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,13 @@ class RuntimeAdapter(Protocol):
"""The target of each soft ADAPTATION finding's defect line, in the language's own code; a rule without one gets
the pipeline's default."""

@property
def review_texts(self) -> bool:
"""Whether the reviewer checks the user's requested texts in the image when a render reports no drawn texts
(owner decision of 2026-10-29, for a library without the text probe): False leaves such a render with the
`grounding.UNVERIFIED_LINE` note, True hands the texts to its review (`grounding.reviewed_misses`)."""
...

def command(self, theme: Theme) -> list[str]:
"""The argv that renders the code file in the work directory (never a shell string)."""
...
Expand Down
12 changes: 12 additions & 0 deletions agents/anyplot/render/runtimes/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@
Imprint palette") suggested."""


TEXTLESS_LIBRARIES = frozenset({"altair", "pygal", "letsplot"})
"""The Python libraries that render without matplotlib and so write no probe: no drawn texts to check the user's
requested texts against (`PythonRuntime.review_texts`)."""


class PythonRuntime:
"""Python plot code (matplotlib, seaborn, plotnine, altair, pygal, lets-plot), run as `plot.py` under the probe
harness."""
Expand All @@ -98,6 +103,13 @@ class PythonRuntime:
blocking_rules: frozenset[str] = BLOCKING_RULES
adaptation_targets: Mapping[str, str] = ADAPTATION_TARGETS

@property
def review_texts(self) -> bool:
"""matplotlib, seaborn and plotnine draw through matplotlib's probe, which reports the drawn texts the
requested ones are checked against, as before 2026-10-29. altair, pygal and lets-plot write no probe, as R
does not, so their reviewer finds the requested texts in the image (`TEXTLESS_LIBRARIES`)."""
return self.library in TEXTLESS_LIBRARIES

def __init__(
self,
*,
Expand Down
Loading
Loading