From 374c7f6d1b7ea0f9a5e10c78685bca125693e819 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:22:05 +0100 Subject: [PATCH 1/4] feat(spec-detail): .use_my_data() beside the library carousel replaces the image icon The agent chat entry on plot pages was a data-table icon in the image's top-right corner. It becomes a text action to the right of the library carousel, the mirror of `<- .compare()` on its left, with a small muted `experimental` tag hanging under the label. Same gating, same chat URL, same `agent_open{source: plot_page}`. The carousel row becomes a grid with equal side columns, so the carousel stays centred over the image and the action fades in after the eligibility probe without moving anything. Below md the two text actions share the line above the carousel, and the gap under it keeps room for the tag. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8 --- app/src/global-config.ts | 2 +- app/src/pages/DebugPage.test.tsx | 2 +- app/src/pages/DebugPage.tsx | 2 +- app/src/pages/SpecPage.test.tsx | 27 ++ app/src/pages/SpecPage.tsx | 12 +- .../spec-detail/LibraryPills.test.tsx | 49 +++- app/src/sections/spec-detail/LibraryPills.tsx | 249 +++++++++++++----- .../spec-detail/SpecDetailView.test.tsx | 14 +- .../sections/spec-detail/SpecDetailView.tsx | 24 -- app/src/utils/adminAuth.ts | 2 +- app/src/vite-env.d.ts | 2 +- changelog.d/use-my-data-entry.md | 12 + docs/concepts/agent-network.md | 8 +- docs/reference/plausible.md | 2 +- 14 files changed, 296 insertions(+), 111 deletions(-) create mode 100644 changelog.d/use-my-data-entry.md diff --git a/app/src/global-config.ts b/app/src/global-config.ts index c4614fbe82e..61b4bf15c9f 100644 --- a/app/src/global-config.ts +++ b/app/src/global-config.ts @@ -22,7 +22,7 @@ const apiBaseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'; /** * Build-time switch for the agent chat (`docs/concepts/agent-network.md`). * Vite inlines the variable, so with the flag unset this is the literal - * `false` and the bundler drops every branch behind it, the `.adapt()` probe + * `false` and the bundler drops every branch behind it, the `.use_my_data()` probe * included. The route in `src/routes/index.tsx` reads the variable inline for * the same reason, so the chat page's chunk is not even built. */ diff --git a/app/src/pages/DebugPage.test.tsx b/app/src/pages/DebugPage.test.tsx index a85344f3e61..5c58e42d458 100644 --- a/app/src/pages/DebugPage.test.tsx +++ b/app/src/pages/DebugPage.test.tsx @@ -101,7 +101,7 @@ describe('DebugPage', () => { expect(screen.getAllByText('scatter-basic').length).toBeGreaterThan(0); }); // A 200 from /debug/status marks this browser as an admin's, which lets - // the plot page show the agent chat's `.adapt()` button. + // the plot page show the agent chat's `.use_my_data()` entry. expect(localStorage.getItem('anyplot.adminHint')).toBe('1'); }); diff --git a/app/src/pages/DebugPage.tsx b/app/src/pages/DebugPage.tsx index cb5b874521c..31687089311 100644 --- a/app/src/pages/DebugPage.tsx +++ b/app/src/pages/DebugPage.tsx @@ -106,7 +106,7 @@ const PING_HISTORY = 30; // in sessionStorage so it survives reloads of the same tab without // persisting across browser sessions (src/utils/adminAuth.ts). // A 200 from /debug/status also sets the admin hint that lets the plot page -// show the agent chat's `.adapt()` button; a refusal clears it. +// show the agent chat's `.use_my_data()` entry; a refusal clears it. export function DebugPage() { const { trackPageview } = useAnalytics(); diff --git a/app/src/pages/SpecPage.test.tsx b/app/src/pages/SpecPage.test.tsx index 80df966c177..b27805edfec 100644 --- a/app/src/pages/SpecPage.test.tsx +++ b/app/src/pages/SpecPage.test.tsx @@ -57,6 +57,12 @@ vi.mock('src/sections/spec-detail/SpecDetailView', () => ({ SpecDetailView: () =>
SpecDetailView
, })); +// The lane `useAgentEligibility` names for the pair; null is no entry (also while it probes). +const agentEligibility: { lane: 'admin' | 'public' | null } = { lane: null }; +vi.mock('src/hooks/useAgentEligibility', () => ({ + useAgentEligibility: () => agentEligibility.lane, +})); + const mockSpecData = { id: 'scatter-basic', title: 'Basic Scatter Plot', @@ -85,6 +91,7 @@ beforeEach(() => { vi.restoreAllMocks(); mockParams = { specId: 'scatter-basic' }; mockNavigate.mockReset(); + agentEligibility.lane = null; }); function mockFetchSuccess(data = mockSpecData) { @@ -235,6 +242,26 @@ describe('SpecPage', () => { }); }); + it('puts .use_my_data() beside the carousel only for an eligible pair', async () => { + mockParams = { specId: 'scatter-basic', language: 'python', library: 'matplotlib' }; + mockFetchSuccess(); + const { rerender } = render(); + + await screen.findByRole('button', { name: 'Back to library comparison' }); + expect( + screen.queryByRole('button', { name: 'Use with my data (experimental)' }) + ).not.toBeInTheDocument(); + + agentEligibility.lane = 'public'; + rerender(); + await userEvent.click( + await screen.findByRole('button', { name: 'Use with my data (experimental)' }) + ); + expect(mockNavigate).toHaveBeenCalledWith( + '/agent?spec=scatter-basic&library=matplotlib&language=python&source=plot_page' + ); + }); + it('does not report a miss when the implementation exists', async () => { mockParams = { specId: 'scatter-basic', language: 'python', library: 'matplotlib' }; mockFetchSuccess(); diff --git a/app/src/pages/SpecPage.tsx b/app/src/pages/SpecPage.tsx index 28528c48b84..1f3f0c0a6f3 100644 --- a/app/src/pages/SpecPage.tsx +++ b/app/src/pages/SpecPage.tsx @@ -309,11 +309,11 @@ export function SpecPage() { [specId, trackEvent, mode, fetchCode] ); - // "Use with my data": for an eligible pair, while the agent chat is built. - // An admin gets the admin chat, through a full navigation so Cloudflare - // Access can intercept the /debug path; everyone else gets the public chat - // while its lane is open, through the router. The chat page records - // `agent_open{source: plot_page}`. + // "Use with my data" (`.use_my_data()` beside the library carousel): for an + // eligible pair, while the agent chat is built. An admin gets the admin chat, + // through a full navigation so Cloudflare Access can intercept the /debug + // path; everyone else gets the public chat while its lane is open, through + // the router. The chat page records `agent_open{source: plot_page}`. const agentLane = useAgentEligibility(mode === 'detail' ? specId : null, currentImpl?.library_id); const handleUseWithMyData = useCallback(() => { if (!specId || !currentImpl) return; @@ -624,6 +624,7 @@ export function SpecPage() { selectedLibrary={selectedLibrary || ''} onSelect={handleLibrarySelect} onAll={() => navigate(specPath(specId!))} + onUseWithMyData={agentLane ? handleUseWithMyData : undefined} /> { expect(screen.getAllByText('matplotlib').length).toBeGreaterThan(0); }); + + it('shows .use_my_data() only when SpecPage passes onUseWithMyData', async () => { + const onUseWithMyData = vi.fn(); + const user = userEvent.setup(); + const props = { + implementations: mockImplementations, + selectedLibrary: 'matplotlib', + onSelect: vi.fn(), + onAll: vi.fn(), + }; + const { rerender } = render(); + expect(screen.queryByRole('button', { name: USE_MY_DATA_LABEL })).not.toBeInTheDocument(); + expect(screen.queryByText('experimental')).not.toBeInTheDocument(); + + rerender(); + const action = screen.getByRole('button', { name: 'Use with my data (experimental)' }); + expect(action).toHaveTextContent('.use_my_data()'); + expect(screen.getByText('experimental')).toBeInTheDocument(); + await user.click(action); + expect(onUseWithMyData).toHaveBeenCalledTimes(1); + expect(props.onAll).not.toHaveBeenCalled(); + }); + + it('opens .use_my_data() from the keyboard, after the carousel in the tab order', async () => { + const onUseWithMyData = vi.fn(); + const user = userEvent.setup(); + render( + + ); + + await user.tab(); + expect(screen.getByRole('button', { name: 'Back to library comparison' })).toHaveFocus(); + await user.tab(); + await user.tab(); + await user.tab(); + const action = screen.getByRole('button', { name: USE_MY_DATA_LABEL }); + expect(action).toHaveFocus(); + await user.keyboard('{Enter}'); + await user.keyboard(' '); + expect(onUseWithMyData).toHaveBeenCalledTimes(2); + }); }); diff --git a/app/src/sections/spec-detail/LibraryPills.tsx b/app/src/sections/spec-detail/LibraryPills.tsx index e28a411e6e0..b386332e4a9 100644 --- a/app/src/sections/spec-detail/LibraryPills.tsx +++ b/app/src/sections/spec-detail/LibraryPills.tsx @@ -32,6 +32,93 @@ interface LibraryPillsProps { selectedLibrary: string; onSelect: (libraryId: string) => void; onAll?: () => void; + /** + * "Use with my data": present only when SpecPage's `useAgentEligibility` names a lane + * for this pair. Renders `.use_my_data() →` to the right of the carousel, the mirror of + * `← .compare()` on its left. + */ + onUseWithMyData?: () => void; +} + +/** Accessible name of the agent chat entry; the visible label is `.use_my_data() →`. */ +export const USE_MY_DATA_LABEL = 'Use with my data (experimental)'; + +/** + * One of the two text actions that flank the carousel: mono, `--ink-soft`, green on + * hover, the arrow nudging outwards. `arrow` says on which side the arrow sits. + */ +function TextAction({ + arrow, + ariaLabel, + onActivate, + children, +}: { + arrow: 'left' | 'right'; + ariaLabel: string; + onActivate: () => void; + children: React.ReactNode; +}) { + const arrowGlyph = ( + + {arrow === 'left' ? '←' : '→'} + + ); + return ( + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onActivate(); + } + }} + aria-label={ariaLabel} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.75, + fontFamily: typography.mono, + fontSize: fontSize.sm, + color: 'var(--ink-soft)', + cursor: 'pointer', + whiteSpace: 'nowrap', + transition: 'color 0.2s', + '& .action-arrow': { transition: 'transform 0.2s' }, + '&:hover': { color: colors.primary }, + '&:hover .action-arrow': { transform: `translateX(${arrow === 'left' ? -3 : 3}px)` }, + '&:focus-visible': { + outline: `2px solid ${colors.primary}`, + outlineOffset: 2, + borderRadius: '2px', + }, + }} + > + {arrow === 'left' && arrowGlyph} + {children} + {arrow === 'right' && arrowGlyph} + + ); +} + +/** + * Vertical rule between a text action and the carousel, desktop only. The margin next to + * the carousel is the smaller one: it compensates for the arrow IconButton's ~5 px internal + * padding so the visible whitespace on either side of the rule is equal. + */ +function ActionRule({ side }: { side: 'left' | 'right' }) { + return ( + + ); } export const LibraryPills = memo(function LibraryPills({ @@ -39,6 +126,7 @@ export const LibraryPills = memo(function LibraryPills({ selectedLibrary, onSelect, onAll, + onUseWithMyData, }: LibraryPillsProps) { // Sort implementations alphabetically const sortedImpls = useMemo(() => { @@ -87,73 +175,63 @@ export const LibraryPills = memo(function LibraryPills({ if (sortedImpls.length === 0) return null; + // Layout. From md up, one row on a three-column grid whose side columns are equal + // (`minmax(0, 1fr)`): the carousel is the centre column, so it stays centred over the + // image whether or not the right-hand action is there, and the action can fade in after + // the eligibility probe without moving anything. Below md the carousel's own width + // leaves no room beside it, so the two text actions share a line above it, `.compare()` + // at the left edge and `.use_my_data()` at the right, and the carousel takes the line + // below. That first line is there for `.compare()` anyway, and the gap under it always + // holds the room for the `experimental` tag, so the late action does not move the page + // either. return ( - {onAll && ( - <> - { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onAll(); - } - }} - aria-label="Back to library comparison" - sx={{ - display: 'inline-flex', - alignItems: 'center', - gap: 0.75, - fontFamily: typography.mono, - fontSize: fontSize.sm, - color: 'var(--ink-soft)', - cursor: 'pointer', - whiteSpace: 'nowrap', - transition: 'color 0.2s', - '& .all-arrow': { transition: 'transform 0.2s' }, - '&:hover': { color: colors.primary }, - '&:hover .all-arrow': { transform: 'translateX(-3px)' }, - '&:focus-visible': { - outline: `2px solid ${colors.primary}`, - outlineOffset: 2, - borderRadius: '2px', - }, - }} - > - - ← - - .compare() - - {/* Vertical divider. Asymmetric mx compensates for the prev IconButton's - ~5 px internal left padding so the visible whitespace on either side - of the rule is equal. */} - - - )} + + {onAll && ( + <> + + .compare() + + + + )} + - {/* Carousel row — stays horizontal even when the wrapper stacks on xs */} - + {/* Carousel row; in the DOM between the two actions, so the tab order follows the + desktop row from left to right */} + {/* Left Arrow */} + + + {onUseWithMyData && ( + + + {/* The tag hangs under the label, out of the flow: the label line stays an exact + mirror of `← .compare()`, arrow on the outer edge, and the tag takes neither + width beside the carousel nor height from the row (on desktop it sits in + the row's bottom padding, on a phone in the reserved gap above the + carousel). Outside the button and hidden from assistive tech, which hears + "experimental" in the accessible name. */} + + + .use_my_data() + + + + + )} + ); }); diff --git a/app/src/sections/spec-detail/SpecDetailView.test.tsx b/app/src/sections/spec-detail/SpecDetailView.test.tsx index 3a666413935..20f105ea681 100644 --- a/app/src/sections/spec-detail/SpecDetailView.test.tsx +++ b/app/src/sections/spec-detail/SpecDetailView.test.tsx @@ -122,17 +122,9 @@ describe('SpecDetailView', () => { expect(screen.getByRole('button', { name: /show interactive/i })).toBeInTheDocument(); }); - it('shows the .adapt() button only when SpecPage passes onUseWithMyData', async () => { - const onUseWithMyData = vi.fn(); - const user = userEvent.setup(); - const { rerender } = render(); - expect( - screen.queryByRole('button', { name: 'Use with my data (experimental)' }) - ).not.toBeInTheDocument(); - - rerender(); - await user.click(screen.getByRole('button', { name: 'Use with my data (experimental)' })); - expect(onUseWithMyData).toHaveBeenCalledTimes(1); + it('keeps the "use with my data" entry off the image (it sits beside the carousel)', () => { + render(); + expect(screen.queryByRole('button', { name: /use with my data/i })).not.toBeInTheDocument(); }); it('shows implementation counter with current/total', () => { diff --git a/app/src/sections/spec-detail/SpecDetailView.tsx b/app/src/sections/spec-detail/SpecDetailView.tsx index 9a1e73d5301..bf1761ea3f9 100644 --- a/app/src/sections/spec-detail/SpecDetailView.tsx +++ b/app/src/sections/spec-detail/SpecDetailView.tsx @@ -12,7 +12,6 @@ import DownloadIcon from '@mui/icons-material/Download'; import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; -import TableChartOutlinedIcon from '@mui/icons-material/TableChartOutlined'; import ThumbDownIcon from '@mui/icons-material/ThumbDown'; import ThumbDownOutlinedIcon from '@mui/icons-material/ThumbDownOutlined'; import ThumbUpIcon from '@mui/icons-material/ThumbUp'; @@ -51,13 +50,6 @@ interface SpecDetailViewProps { onCopyCode: (impl: Implementation) => void; onDownload: (impl: Implementation) => void; onTrackEvent: (event: string, props?: Record) => void; - /** - * "Use with my data": present only when the agent chat is built, the pair is - * eligible, and this browser is an admin's (or local development) or the - * public lane is open — SpecPage decides (`useAgentEligibility`). Renders the - * fourth overlay button. - */ - onUseWithMyData?: () => void; } export function SpecDetailView({ @@ -75,7 +67,6 @@ export function SpecDetailView({ onCopyCode, onDownload, onTrackEvent, - onUseWithMyData, }: SpecDetailViewProps) { const sortedImpls = [...implementations].sort((a, b) => a.library_id.localeCompare(b.library_id)); const currentIndex = sortedImpls.findIndex(impl => impl.library_id === selectedLibrary); @@ -611,21 +602,6 @@ export function SpecDetailView({ )} - {currentImpl && onUseWithMyData && ( - - { - (e.currentTarget as HTMLElement).blur(); - onUseWithMyData(); - }} - aria-label="Use with my data (experimental)" - sx={overlayBtnSx} - size="medium" - > - - - - )} {implementations.length > 1 && !zoomed && ( diff --git a/app/src/utils/adminAuth.ts b/app/src/utils/adminAuth.ts index 586d1f72fc3..2add500cf6d 100644 --- a/app/src/utils/adminAuth.ts +++ b/app/src/utils/adminAuth.ts @@ -9,7 +9,7 @@ * - The admin hint is a localStorage flag that `DebugPage` sets once * `/debug/status` answered 200 and clears when it is refused. It is a UI * hint, never an authorization: it only decides whether a public page may - * probe `/debug/agent/*` at all (the `.adapt()` button), so public visitors + * probe `/debug/agent/*` at all (the `.use_my_data()` entry), so public visitors * never send a request to the debug API. The server's admin gate still * decides every call. * diff --git a/app/src/vite-env.d.ts b/app/src/vite-env.d.ts index 6521f487214..6f7f9207ee7 100644 --- a/app/src/vite-env.d.ts +++ b/app/src/vite-env.d.ts @@ -3,7 +3,7 @@ interface ImportMetaEnv { readonly VITE_API_URL?: string; readonly VITE_DEBUG_API_URL?: string; - /** `true` builds the admin-only agent chat page and the `.adapt()` button. */ + /** `true` builds the agent chat page and the plot page's `.use_my_data()` entry. */ readonly VITE_ENABLE_AGENT_CHAT?: string; } diff --git a/changelog.d/use-my-data-entry.md b/changelog.d/use-my-data-entry.md new file mode 100644 index 00000000000..66524fb58d9 --- /dev/null +++ b/changelog.d/use-my-data-entry.md @@ -0,0 +1,12 @@ +### Changed + +- **The plot page's way into "use with my data" is a text action beside the + library carousel.** `.use_my_data() →` sits to the right of the carousel as + the mirror of `← .compare()` on its left, with a small muted `experimental` + tag under it, and replaces the data-table icon on the image, which said + little and crowded the overlay buttons. It keeps the icon's gating (open + lane or an admin, eligible pair), its chat link and `agent_open`'s + `source=plot_page`. The carousel now sits centred over the image, so the + action fades in after the eligibility check without moving anything; on a + phone or tablet the two actions share the line above the carousel, with + room for the tag kept in place. diff --git a/docs/concepts/agent-network.md b/docs/concepts/agent-network.md index 0c14d853e40..3993be5b2ab 100644 --- a/docs/concepts/agent-network.md +++ b/docs/concepts/agent-network.md @@ -83,8 +83,8 @@ Deviations from the first sketch, each evidence-backed: there is no data-intake ### Request flow for "Use with my data" -1. The `.adapt()` overlay button in `SpecDetailView` does a full navigation to `/debug/agent?spec=&library=&language=`. -2. `POST /debug/agent/sessions {spec_id, library, locale}`. The BFF validates the spec and library against `core/constants.py` and the enabled libraries from anyplot-agents `GET /v1/status` (cached), loads a catalogue snapshot through `core/catalogue/queries.py` (title, description, data roles, notes, noqa-stripped code, library version), derives the user id, and forwards it. anyplot-agents normalises the code, runs the readiness scan and the SECURITY validator, and answers `422 not_eligible` for blocked pairs (the 13 map specs, validator failures, files without a `THEME` block or with a non-literal save target). anyplot-agents has no database access in phase 1. `GET /debug/agent/eligibility?spec=&library=` gives the button the same answer. `locale` is the browser's locale, recorded with a feedback case; it sets no language, and every session starts in English (see [Session language](#session-language)). +1. The `.use_my_data()` action beside the plot page's library carousel (`LibraryPills`; an `.adapt()` button on the image until 2026-11-02) does a full navigation to `/debug/agent?spec=&library=&language=`. +2. `POST /debug/agent/sessions {spec_id, library, locale}`. The BFF validates the spec and library against `core/constants.py` and the enabled libraries from anyplot-agents `GET /v1/status` (cached), loads a catalogue snapshot through `core/catalogue/queries.py` (title, description, data roles, notes, noqa-stripped code, library version), derives the user id, and forwards it. anyplot-agents normalises the code, runs the readiness scan and the SECURITY validator, and answers `422 not_eligible` for blocked pairs (the 13 map specs, validator failures, files without a `THEME` block or with a non-literal save target). anyplot-agents has no database access in phase 1. `GET /debug/agent/eligibility?spec=&library=` gives the plot page's entry the same answer. `locale` is the browser's locale, recorded with a feedback case; it sets no language, and every session starts in English (see [Session language](#session-language)). 3. `POST .../dataset {text}` runs the deterministic parse and returns a 20-row preview, the `DatasetProfile`, default bindings and warnings. A deterministic pre-filter over every header and cell for text addressed to an AI, then the dataset judge with a data rubric, refuse instruction-like datasets. Both look for injection only: names, postal and e-mail addresses, phone numbers, web addresses and amounts are the user's data and plot like any other value. The judge sees every header and the same five sample rows and top values the root and the adapter get (every value of a column with at most 12 distinct ones, else the five most frequent), with cells in full, in parts of about 4,000 characters, one judge call per part (one part for every eval fixture). When the paste carried notes around the table (`context_lines`, see [Parse](#parse)), they go through the same pre-filter and then, after the dataset judge passed the table, one scope-judge call with the scope rubric, the lines fenced as ``. A pre-filter hit, a verdict other than `in_scope` or no verdict drops every note with a warning and stores the table all the same; no verdict on notes is a strike, because a hidden injection in a copied web table is not the user's doing, and the judge's tokens are booked to the user like the dataset judge's (the `notes_judge` attribution line carries the verdict and the counts, never the text). The route answers the kept notes as `context_lines`, the stored dataset keeps them, and every version that `prepare_data`, `generate_data` or `.to_long()` makes from it carries them on; a new paste replaces them. Judging the notes at the paste rather than in the turn that delivers them keeps every place the root can read them (the summary turn, `get_dataset_profile`) behind one verdict, and lets the data panel show exactly what the assistant got (`# notes sent to the assistant`). No other LLM call happens for a table. Text that is no table the parser can read (prose such as "a bar chart with dog 7, cat 3 and bird 4", `label value` lines without a separator, a list of names typed one per line; at most 4,000 characters and 200 non-blank lines, so that a list without a header fits the schema's 200 rows, never a JSON array or object, which the parser reads or refuses itself) takes the transcriber path instead; free text past those limits is `422 unparseable` with the limit as its cause, never stored as it parsed. One column of typed values (integers, numbers, dates, booleans: the input of a histogram, a density or a box plot), of digit codes kept as text (`01067`) or of categories under a header, at least one repeating, is a table and never reaches the transcriber (live round 7 of 2026-10-16: 800 commute times timed out the model every time); a category list whose header has the categories' form (no `_`, brackets, `:`, `/`, `%`, `#` or digit, and no more words than a category) is stored with the short note `read '
' as the column name`, since no cheap rule tells `Tier` above `Hund, Katze` from `Apfel` above `Birne, Kirsche`; a header with a column name's form gets no note. A list of values without a header line keeps its first line as the first value, in a column named `value`, with a warning (`parse._header_as_row`); one of 20,001 values is refused as too many rows. A transcription whose answer stops at its output cap or breaks the schema's bounds (`models.AnswerCutOff`, not retried), or that runs out of time on a text of at least half the limits (`transcribe.near_transcribe_cap`), is `422 unparseable` with a cause that names the text's line and character count and asks for a separator; a short text that times out (a stalled endpoint: the budget is the only clock, no request has a timeout of its own), an error, a quota or an unparseable answer is `503 guard_unavailable` (owner rule of 2026-10-11): the pre-filter runs on the raw text, the judge model reads it under the `TranscribedTable` schema (`agents/anyplot/data/transcribe.py`, rubric `prompts/transcriber.md`: keep every value as written, invent and compute nothing, a note on anything ambiguous), the table's CSV is parsed like a paste, and the route answers `transcribed: true` with that CSV, the preview and the note, storing nothing. The page shows the table for the user to confirm or discard, because a wrong reading makes everything after it wrong; confirming posts the CSV through this same route with `confirmed: true`, which skips the free-text fallback (a one-column table would otherwise be read again) and the narrowing to the table (the user has seen every row, so no line is left out as a note or a total), while the deterministic parse, the pre-filter, the judge and the store run as for any paste. A binary data file reaches it the same way, through one pre-process (owner decision of 2026-10-15, part 2 of 2): Excel (`.xlsx`, `.xlsm`, `.xlsb`, `.xls`), OpenDocument (`.ods`), Parquet, Feather (v1 and v2), Arrow IPC (file and stream) and NumPy (`.npy`, `.npz`) go as base64 in JSON (the BFF's CSRF guard takes JSON only; at most 5 MB raw, the BFF refuses a larger body by its `Content-Length` before reading it) to `POST /v1/sessions/{sid}/dataset/convert`, which converts the file in the renderer's sandbox and answers the text the paste box takes; nothing is stored and no model reads anything, so the text then goes through the ordinary paste route above (its limits, pre-filter and judges) when the user presses `.parse()`, with the file's name and sheet as `source` (`Bericht.xlsx · sheet ‹2024›`; the sheet in single angle quotes since live test round 4 of 2026-10-15, so the data line's `from "…"` never nests quotes), screened as above. The renderer runs the harness in convert mode (`harness.py --convert input.bin`, `POST /convert`): like data mode it reads the bytes, loads the converter (`agents/anyplot/render/convert.py`, copied next to the harness in the image) and imports every reader it needs before it arms its guard (`ConvertGuard`: `DataGuard` with no code to run at all, but the one `compile` NumPy's `.npy` header reader makes to parse its dict literal), then parses from memory with the guard armed and writes `out.csv`, `meta.json` and `status.json`; a refusal is an exit code (`wire.CONVERT_REASONS`) with the measured value and the cap. The format comes from the first bytes, never from the name: every zip is checked from its central directory first (at most 2,000 entries, 100 MB declared uncompressed, 250 times the archive's size, no encrypted member) and every member is inflated, bounded by its declared size, to prove that size, because calamine's reader trusts none (security review of #12194); every pattern over file content is linear (the review's 30 MB run of `