diff --git a/app/src/global-config.ts b/app/src/global-config.ts index c4614fbe82e..1dd28e0ff7f 100644 --- a/app/src/global-config.ts +++ b/app/src/global-config.ts @@ -12,7 +12,7 @@ interface GlobalConfig { }; isDev: boolean; features: { - /** The admin-only "Use with my data" agent chat (`/debug/agent`). */ + /** The "Use with my data" agent chat (`/agent`, and `/debug/agent` for admins). */ agentChat: boolean; }; } @@ -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/hooks/useAgentLaneOpen.ts b/app/src/hooks/useAgentLaneOpen.ts index b31de5cef2f..ec6eb07b480 100644 --- a/app/src/hooks/useAgentLaneOpen.ts +++ b/app/src/hooks/useAgentLaneOpen.ts @@ -1,7 +1,7 @@ /** * Whether the public lane of the agent chat is open (`GET /agent/lane`), for - * the entry points: the plot page's button, the landing page's line and the - * public chat page itself. + * the entry points: the plot page's `.use_my_data()`, the landing page's line + * and the public chat page itself. * * The API answers `{"open": true}` while the owner has the lane open and 404 * while it is closed, so one switch on the server opens and closes every entry 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..ef11dd2a388 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,22 @@ 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' })).not.toBeInTheDocument(); + + agentEligibility.lane = 'public'; + rerender(); + await userEvent.click(await screen.findByRole('button', { name: 'Use with my data' })); + 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} /> ({ const lane = vi.hoisted(() => ({ open: false as boolean | null })); vi.mock('src/hooks/useAgentLaneOpen', () => ({ useAgentLaneOpen: () => lane.open })); -import { AGENT_LANE_LINE, HeroSection } from 'src/sections/landing/HeroSection'; +import { AGENT_LANE_LABEL, AGENT_LANE_LINE, HeroSection } from 'src/sections/landing/HeroSection'; describe('HeroSection', () => { beforeEach(() => { @@ -31,17 +31,30 @@ describe('HeroSection', () => { lane.open = false; }); - it('points to the public agent chat only while its lane is open', () => { + it('announces .use_my_data() only while the public lane is open, leading to the catalogue', async () => { + const user = userEvent.setup(); const { rerender } = render(); - expect(screen.queryByText(new RegExp(AGENT_LANE_LINE))).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: AGENT_LANE_LABEL })).not.toBeInTheDocument(); lane.open = true; rerender(); - const link = screen.getByRole('link', { name: new RegExp(AGENT_LANE_LINE) }); - expect(link).toHaveAttribute( - 'href', - '/agent?spec=scatter-basic&library=matplotlib&language=python&source=landing' - ); + const link = screen.getByRole('link', { name: AGENT_LANE_LABEL }); + expect(link).toHaveTextContent(`# ${AGENT_LANE_LINE} →`); + expect(link).toHaveTextContent('# new: pick any plot, then .use_my_data() →'); + expect(link).not.toHaveTextContent(/experimental/i); + expect(link).toHaveAttribute('href', '/plots'); + + await user.click(link); + expect(trackEvent).toHaveBeenCalledWith('nav_click', { + source: 'hero_use_my_data', + target: '/plots', + }); + }); + + it('keeps the line hidden while the lane probe is pending', () => { + lane.open = null; + render(); + expect(screen.queryByRole('link', { name: AGENT_LANE_LABEL })).not.toBeInTheDocument(); }); it('tracks the primary browse CTA, mcp link and github link', async () => { diff --git a/app/src/sections/landing/HeroSection.tsx b/app/src/sections/landing/HeroSection.tsx index a10d4b3bee6..444dcf4d235 100644 --- a/app/src/sections/landing/HeroSection.tsx +++ b/app/src/sections/landing/HeroSection.tsx @@ -209,7 +209,11 @@ export function HeroSection({ potd = null }: HeroSectionProps) { /> - + + trackEvent('nav_click', { source: 'hero_use_my_data', target: paths.plots }) + } + /> {/* Right: terminal plot */} @@ -222,19 +226,25 @@ export function HeroSection({ potd = null }: HeroSectionProps) { } /** - * The one line that points to the public agent chat, shown only while its lane is open - * (`useAgentLaneOpen`), so closing the lane on the server removes it too. It opens the chat - * on a plot everyone knows; `source=landing` feeds `agent_open`. + * The one line that announces "use with my data", shown only while the public agent lane + * is open (`useAgentLaneOpen`), so closing the lane on the server removes it too. It leads + * to the catalogue rather than into the chat: the entry is `.use_my_data()` beside a plot + * page's library carousel, so the visitor picks the plot first. Tracked as + * `nav_click{source: hero_use_my_data}`. No "experimental" here: the chat's notice says it. */ -export const AGENT_LANE_LINE = 'experimental: adapt any plot to your own data'; +export const AGENT_LANE_LINE = 'new: pick any plot, then .use_my_data()'; +/** The line's accessible name, in words rather than the method-call style. */ +export const AGENT_LANE_LABEL = 'New: pick any plot, then use it with my data'; -function AgentLaneLine() { +function AgentLaneLine({ onClick }: { onClick: () => void }) { const open = useAgentLaneOpen(); if (open !== true) return null; return ( - # {AGENT_LANE_LINE} → + {/* A no-break space keeps the arrow with `.use_my_data()` when a narrow phone wraps + the line. */} + # {AGENT_LANE_LINE} + {' '}→ ); } diff --git a/app/src/sections/spec-detail/LibraryPills.test.tsx b/app/src/sections/spec-detail/LibraryPills.test.tsx index 6feafca4475..183a6ac0b98 100644 --- a/app/src/sections/spec-detail/LibraryPills.test.tsx +++ b/app/src/sections/spec-detail/LibraryPills.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { LibraryPills } from 'src/sections/spec-detail/LibraryPills'; +import { LibraryPills, USE_MY_DATA_LABEL } from 'src/sections/spec-detail/LibraryPills'; import { render, screen, userEvent } from 'src/test-utils'; const mockImplementations = [ @@ -89,4 +89,52 @@ describe('LibraryPills', () => { 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(); + + rerender(); + const action = screen.getByRole('button', { name: 'Use with my data' }); + // Label and arrow are two spans set apart by the flex gap, not by a space. + expect(action).toHaveTextContent('.use_my_data()→'); + // "experimental" is the chat's notice to make, not the entry's. + expect(screen.queryByText(/experimental/i)).not.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..43c4c5713f1 100644 --- a/app/src/sections/spec-detail/LibraryPills.tsx +++ b/app/src/sections/spec-detail/LibraryPills.tsx @@ -32,6 +32,96 @@ 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() →`. It + * says nothing the label does not: "experimental" is the chat's notice to make, once open. + */ +export const USE_MY_DATA_LABEL = 'Use with my data'; + +/** + * 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 +129,7 @@ export const LibraryPills = memo(function LibraryPills({ selectedLibrary, onSelect, onAll, + onUseWithMyData, }: LibraryPillsProps) { // Sort implementations alphabetically const sortedImpls = useMemo(() => { @@ -87,73 +178,62 @@ 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, 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 && ( + + + + .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..6b2e1b63986 --- /dev/null +++ b/changelog.d/use-my-data-entry.md @@ -0,0 +1,15 @@ +### 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 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`; "experimental" is left to the chat's + notice. 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 (#12539). +- **The landing page's line points to the catalogue.** While the public lane + is open it reads `# new: pick any plot, then .use_my_data() →` and leads to + `/plots` instead of opening the chat on one fixed plot, tracked as + `nav_click{source: hero_use_my_data}` (#12539). diff --git a/docs/concepts/agent-network.md b/docs/concepts/agent-network.md index 0c14d853e40..de53abfe7c5 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=` for an admin. A public visitor goes through the router to `/agent?spec=&library=&language=` instead, whose requests take the same steps on `/agent/*` (see the **Public page** bullet in [Serving and infrastructure](#serving-and-infrastructure)); the steps here name the admin routes. +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 `