Skip to content

feat(studio): anonymizer builder live preview panel [ASTD-332] - #1005

Draft
marcusds wants to merge 9 commits into
mainfrom
astd-332-anonymizer-live-preview-panel/mschwab
Draft

feat(studio): anonymizer builder live preview panel [ASTD-332]#1005
marcusds wants to merge 9 commits into
mainfrom
astd-332-anonymizer-live-preview-panel/mschwab

Conversation

@marcusds

@marcusds marcusds commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Builds the right-hand Preview panel of the anonymizer builder (/anonymizer/new), replacing the "Your records preview will appear here" placeholder.

Closes ASTD-332.

What it does

Preview (beside the Source | Model Settings tabs) streams the current form config through the anonymizer /preview endpoint and renders:

  • Original vs Replaced columns with inline PII tags on detected/replaced entities
  • Replacement Map table (Label | Original | → | Replacement)
  • Record pager (‹ Record 1 of N ›), loading skeletons, empty state, error banner
  • Collapsible Logs panel fed by the stream's log frames

While a run is in flight the button becomes Stop. Full Run moves into the panel header per the Figma and still submits the create-job form — the panel is inside the same <form>. The left panel footer is dropped: Figma marks that frame hidden and moves its only visible button into the preview header, which left Cancel stranded. Exit is via the breadcrumb.

Why previewApi.ts is hand-written rather than generated

The request half is generated — PreviewRequest comes from the SDK. Only the transport and the frame types are hand-rolled, for two reasons:

The generated client can't stream. anonymizerPreviewfunctionRoute exists, but it routes through the axios mutator in generated/fetchers/anonymizer.ts, which buffers the whole body. A preview streams log frames for minutes; through that client you'd see nothing until it finished, which defeats the panel.

The frames aren't in the spec to generate from. plugins/nemo-anonymizer/openapi/openapi.yaml declares the 200 as:

content:
  application/json:   # the route actually returns application/x-ndjson
    schema: {}

so orval emits customFetch<unknown> and no frame schemas. That traces to functions/routes.py:203, where the shared NemoFunction route factory registers streaming functions with response_model=None.

Worth fixing upstream, not here. If the factory declared the frame union and application/x-ndjson in responses=, orval would generate LogFrame | PreviewDatasetFrame | TraceDatasetFrame | FailedRecordsFrame | Heartbeat | Done | Error and roughly 60 lines of hand-written types and guards would come out of previewApi.ts. That's a platform-wide change affecting every streaming function plus a spec/SDK regen, so it wants its own ticket. The streaming transport itself would stay hand-written either way — orval doesn't generate incremental readers.

Notes for review

Replaced-side spans are searched, not replayed. Each entity is located in the replaced text by scanning forward for its synthetic value. Replaying the original offsets drifts the moment a replacement changes length — a bug the upstream library already hit and worked around, so parse.ts mirrors its display.py logic: the exact → value-only → case-insensitive lookup ladder, plus the build-from-replacement-map fallbacks used when detection returns no spans but the map is populated.

PII tags use Badge, not Tag. Code Connect on the Figma PII Tag component maps to Badge (solid for the value, outline for the label); Tag is the interactive pill and rendered noticeably heavier than the mock.

Stop is real, not cosmetic — but not instant. Aborting makes Starlette close the response generator, cancelling the drain task and unwinding the function's task group until the worker's next frame send raises PreviewMessageDeliveryError. Because the anonymizer runs under abandon_on_cancel=True, the worker thread is abandoned rather than joined, so an in-flight model call still finishes.

The shared-URL-param hazard. useStudioDataViewState syncs to unprefixed params (page, page_size, sort, …). That bit inside this PR: with a map that overflows a page, going to ?page=2 then advancing the record pager to a shorter map sliced past the end and rendered an empty table — with the map's pager hidden below page size, nothing was left to recover with. Clamped, with a regression test confirmed to fail without the fix. The broader collision remains for when ASTD-330 reuses this table alongside ResultsPreviewTable; that wants a paramPrefix option on the shared hook.

Reuse audit. Four things this branch had reimplemented now use what Studio already ships: isAbortError (common's version also handles APIUserAbortError, mine only caught DOMException), the NDJSON read loop (extracted as readLineDelimitedStream, now shared with the data designer preview and given the chunk-boundary test neither call site had), OUTPUT_SUFFIXES (was declared here and in AnonymizerJobDetailRoute), and StackedSkeleton.

The record view lives in components/AnonymizerRecordView/ so the job detail page (ASTD-330) and evaluation results (ASTD-322) can reuse it.

No progress indicator is available

The stream carries no progress frame — frames.py reserves the kind but the plugin never emits one — and records arrive only after the run completes, so "3 of 10" isn't derivable. The library does compute an ETA, but it's gated on total >= 50 while preview caps at 10, so it can never fire. Only phase log lines and a 5s heartbeat are available today.

Testing

  • 78 unit tests across parse, previewApi, lineStream, the record view and the tab/heading helpers
  • Verified in a real browser against a mocked NDJSON stream: empty, loading, populated, stop-mid-run, validation routing, and the rewrite-mode skeleton heading
  • Full studio suite green (302 files, 2706 tests), typecheck and lint clean

Builds the right-hand Preview panel of the anonymizer builder, replacing the
"Your records preview will appear here" placeholder.

A "Preview" button beside the Source / Model Settings tabs streams the current
form config through the anonymizer `/preview` endpoint and renders the result:
Original vs Replaced text with inline PII tags, a Replacement Map table, a
record pager, loading skeletons, and a collapsible Logs panel. "Full Run" moves
into the panel header and still submits the create-job form.

The endpoint returns `application/x-ndjson`, not SSE, so this follows the data
designer's `streamPreview` shape rather than the SSE reader in `util/sseStream`
(which is GET-only). Frames are shape-guarded and unknown `kind` values are
dropped rather than surfaced.

Replaced-side entity spans are located by searching the replaced text for each
synthetic value rather than replaying the original offsets, which drift as soon
as a replacement changes length. This mirrors the upstream library's own
display path, including its case-insensitive lookup fallbacks and the
build-from-replacement-map path used when detection returns no spans.

The record view lives under components/AnonymizerRecordView so the job detail
page (ASTD-330) and evaluation results (ASTD-322) can reuse it.

Signed-off-by: mschwab <mschwab@nvidia.com>
@github-actions github-actions Bot added the feat label Jul 30, 2026
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor
Suite Lines Covered Line Rate Branch Rate
Unit Tests 29340/37347 78.6% 63.2%
Integration Tests 17284/36065 47.9% 20.6%

marcusds added 8 commits July 31, 2026 08:27
…er [ASTD-332]

`useStudioDataViewState` reads `page` from a shared, unprefixed URL param, so
the replacement map's page index survives moving the record pager. Paging to a
record with a shorter map then sliced past the end and rendered an empty table
— with the pager hidden below page size, nothing was left to recover with.
Clamp the requested page to the row count.

Also drops the left panel footer. The Figma marks that frame hidden and moves
its only visible button, Full Run, into the preview header, which left Cancel
stranded there on its own. Exit is via the breadcrumb.

Signed-off-by: mschwab <mschwab@nvidia.com>
Clicking Preview on an incomplete form jumped to Model Settings even when the
failing field was on Source, hiding the error that explained the refusal.

Two causes. `getPreviewRequest` read `form.formState.errors` after `trigger()`,
but RHF's `formState` is a proxy that only maintains the fields the render body
subscribes to, and this component reads none of it — the lookup returned `{}`.
`[].every()` is then vacuously true, so an empty error set satisfied the
"models are the only failure" test and routed to the wrong tab.

Take the errors from `handleSubmit`'s invalid callback instead, which is what
the Full Run path already does, and require a non-empty field list before
treating models as the sole failure. The tab choice moves into
`tabForValidationErrors` so the empty case is pinned by a test.

Signed-off-by: mschwab <mschwab@nvidia.com>
The Preview button was disabled for the duration of a run, so a preview that
turned out to be misconfigured had to be waited out or escaped by navigating
away. Swap it for Stop, which aborts the request.

The abort is honest rather than cosmetic: closing the connection makes
Starlette close the response generator, which cancels the drain task and
unwinds the function's task group until the worker's next frame send raises
`PreviewMessageDeliveryError`. It is not instant — the anonymizer runs under
`abandon_on_cancel=True`, so the worker thread is abandoned rather than joined
and an in-flight model call still finishes.

A stopped run reports itself as stopped instead of claiming the run returned
no records. Partial records already streamed are kept.

This departs from the Figma, which greys the button out for the duration.

Signed-off-by: mschwab <mschwab@nvidia.com>
…D-332]

The entity chips used `Tag`, which is the interactive pill — button-based,
large radius, generous padding — so inline text read as chunky and did not
match the mock. Code Connect on the Figma `PII Tag` component maps both chips
to `Badge` (`kind="solid"` for the value, `kind="outline"` for the label),
which is the non-interactive `<span>` label at Label/Bold/sm. The design's
wrapper carries no gap, so the pair now sits flush.

Same swap for the Replacement Map's Label column, which Code Connect also maps
to an outline `Badge`.

`entityTagColor` is unchanged — Tag and Badge expose the same seven colours, so
the existing category mapping carries over. `EntitiesSection` keeps `Tag`,
which is correct there: those chips are removable and interactive.

Signed-off-by: mschwab <mschwab@nvidia.com>
…ASTD-332]

Log frames arrive one per stream read, too far apart for React to batch, so
each one synchronously re-rendered the builder form and the preview panel.
That put the new Stop button behind a queue of log renders exactly when a
chatty run made it most useful. They now update in a transition.

`AnonymizerRecordView` and `HighlightedText` are memoized. Their props are
already stable references — the record is built inside a memo — so this
actually prevents work rather than adding a comparison that never hits.

Also hoists the static skeleton rows out of the render path and folds the
output heading into the record memo, since both derive from the same inputs.

Trims the branch's comments back to the ones carrying a non-obvious why.

Signed-off-by: mschwab <mschwab@nvidia.com>
`PreviewPanel` had grown a skeleton, a record pager and the panel chrome in one
file. Splits out `RecordPager`, and moves the skeleton next to the view it
mirrors as `AnonymizerRecordSkeleton` — the two render the same three sections
and had drifted apart in the same file already.

`RecordSection` replaces six hand-written copies of the heading-plus-content
block across the view and the skeleton, so they can no longer diverge.

The `asObject` guard was written twice on this branch, in `parse.ts` and
`previewApi.ts`. Promotes it to `@studio/util/guards` as `asRecord`, matching
the name the two pre-existing hand-rolled copies elsewhere in Studio already
use; those call sites are left alone as out of scope here.

Signed-off-by: mschwab <mschwab@nvidia.com>
…STD-332]

The output heading is derived from whichever output column a record carries,
which does not exist until results arrive — so a rewrite preview showed
"Replaced" over the skeleton for the whole run, then flipped to "Rewritten".

The form knows the strategy before the run starts, so it now passes the
heading down for the loading state. A loaded record still derives its own,
which stays correct if the strategy is changed after a run.

Signed-off-by: mschwab <mschwab@nvidia.com>
…es [ASTD-332]

An audit of this branch against what Studio already ships turned up four
things it had reimplemented.

`isAbortError` already exists in `common/AssistantChat/completionUtils`, and
its version is the better one — it matches any `Error` whose name is
`AbortError` or `APIUserAbortError`, where the copy here only recognised a
`DOMException`. Drops the copy and its tests.

The NDJSON read loop was duplicated between this preview and the data designer
preview, down to the trailing-partial handling. Extracts
`readLineDelimitedStream` and points both at it, with a test covering the
chunk-boundary cases neither call site exercised.

`OUTPUT_SUFFIXES` was declared here and again in `AnonymizerJobDetailRoute`.
The record view is the reusable home the ticket asked for, so the job detail
route now imports it, and `PreviewPanel` drops its third copy of the literal.

`StackedSkeleton` already renders N stacked skeleton lines, so the local
`SkeletonBlock` uses it rather than mapping its own.

Signed-off-by: mschwab <mschwab@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant