diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a4b8f2..e9fef23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- **Re-identify output format control** — The `re-identify` tool now accepts an optional `format` object (`{ redacted?, masked?, plaintext? }`, each a list of entity type strings) that lets the caller specify, per entity type, how tokens are rendered on the way out — fully restored (plaintext), partially masked, or fully redacted. Omitting `format` preserves existing behavior exactly; entity types not listed in a provided `format` fall back to the Detect API's default and are restored as full plaintext. Maps to the `skyflow-node` SDK's `ReidentifyTextOptions` per the Detect API spec. The requested format is echoed back (normalized) in the response and summarized in the re-identify UI. + - **MCP Apps UI for all three tools** — Each tool (`dehydrate`, `rehydrate`, `dehydrate_file`) now has an interactive vanilla TypeScript UI that renders inline in MCP Apps-capable hosts. Text-only hosts continue to receive JSON responses as before. - **Dehydrate UI**: Side-by-side before/after text panels with color-coded entity highlights, confidence scores, and an entity breakdown table. Shows anonymous mode banner when applicable. - **Rehydrate UI**: Token-to-original mapping display with color-matched highlights across before/after panels. diff --git a/CLAUDE.md b/CLAUDE.md index 7278309..42d38f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,8 @@ curl -X POST "http://localhost:3000/mcp?vaultId={vault_id}&vaultUrl={vault_url}" **re-identify tool** (`src/lib/tools/reIdentify.ts`) - Reverses de-identification by replacing tokens with original sensitive data -- Returns `inputText` and `processedText` +- Accepts optional `format` object (`{ redacted?, masked?, plaintext? }`, each a list of entity type strings from `ENTITY_MAP`) to control how each entity type is rendered on the way out, per the Detect API spec. Maps to the SDK's `ReidentifyTextOptions` (`setRedactedEntities` / `setMaskedEntities` / `setPlainTextEntities`). Entity types not listed fall back to the Detect API's default and are restored as full plaintext (the same as re-identifying without a format). Rejects with an error if the same entity type appears in more than one bucket +- Returns `inputText` and `processedText`, and echoes back the requested `format` (normalized — empty buckets omitted) when one was provided - Returns error with `anonymousModeRestricted: true` in anonymous mode **de-identify_file tool** (`src/lib/tools/deIdentifyFile.ts`) diff --git a/README.md b/README.md index 924fa57..0388ba5 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,24 @@ curl -X POST "http://localhost:3000/mcp?vaultId={vault_id}&vaultUrl={vault_url}" -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"re-identify","arguments":{"inputString":"[REDACTED_TEXT_WITH_TOKENS]"}},"id":3}' ``` +By default every token is fully restored to its original value. To control how +individual entity types are rendered, pass an optional `format` object with any +of `redacted`, `masked`, or `plaintext` — each a list of entity types (the same +lowercase names as the `de-identify` tool, e.g. `ssn`, `email_address`). Entity +types not listed fall back to the Detect API's default and are restored as full plaintext: + +```bash +curl -X POST "http://localhost:3000/mcp?vaultId={vault_id}&vaultUrl={vault_url}" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Authorization: Bearer {your_bearer_token}" \ + -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"re-identify","arguments":{"inputString":"[REDACTED_TEXT_WITH_TOKENS]","format":{"masked":["ssn"],"redacted":["email_address"],"plaintext":["name"]}}},"id":3}' +``` + +In this example, SSNs come back partially masked, email addresses are fully +redacted, names are restored in full, and any other detected entity type is +restored to plaintext. + ## Integration with Claude Desktop Claude Desktop is one concrete client for the [Connection Contract](#connection-contract). It uses `mcp-remote` as a bridge to the Streamable HTTP endpoint. Add the following to your `claude_desktop_config.json`: diff --git a/docs/wrapping-mcp-tools-with-skyflow.md b/docs/wrapping-mcp-tools-with-skyflow.md index c08b853..19f83ef 100644 --- a/docs/wrapping-mcp-tools-with-skyflow.md +++ b/docs/wrapping-mcp-tools-with-skyflow.md @@ -220,6 +220,29 @@ lowercase string → enum map in `src/lib/mappings/entityMaps.ts` (`ENTITY_MAP`) reference list. Common values include `email_address`, `ssn`, `credit_card`, `name`, `phone_number`, `ip_address`, `location`, and `bank_account`. +### Optional: control re-identify output format + +Re-identify fully restores every token by default. To render specific entity types differently on +the way out — reveal some, partially mask others, fully redact the rest — pass a +`ReidentifyTextOptions` as the second argument. Entity types you don't list fall back to the Detect +API's default and are restored as full plaintext: + +```ts +import { ReidentifyTextOptions, DetectEntities } from "skyflow-node"; + +const options = new ReidentifyTextOptions(); +options.setPlainTextEntities([DetectEntities.NAME]); // restore in full +options.setMaskedEntities([DetectEntities.SSN]); // partially masked; exact form depends on vault config +options.setRedactedEntities([DetectEntities.EMAIL_ADDRESS]); // fully hidden + +const res = await skyflow + .detect() + .reidentifyText(new ReidentifyTextRequest(text), options); +``` + +This is the same capability the `re-identify` MCP tool exposes via its optional `format` argument +(`{ redacted, masked, plaintext }`); see `src/lib/tools/reIdentify.ts`. + ## Approach B — Detect REST API (any language) The SDK just wraps Skyflow's **Detect REST API**, so any language can run the same round-trip over @@ -279,6 +302,18 @@ curl -X POST "https://$CLUSTER_ID.vault.skyflowapis.com/v1/detect/reidentify/str { "text": "email john.doe@example.com about order 12345" } ``` +`text` and `vault_id` are required. Add an optional `format` object to control how each entity type +is rendered — `{ "redacted": [...], "masked": [...], "plaintext": [...] }`, each a list of the same +lowercase entity names. Entity types you don't list fall back to the Detect API's default and are restored as full plaintext: + +```json +{ + "vault_id": "...", + "text": "email [EMAIL_ADDRESS_a1b2], ssn [SSN_c3d4]", + "format": { "redacted": ["email_address"], "masked": ["ssn"] } +} +``` + Note the re-identify response field is `text`, while de-identify returns `processed_text` — an asymmetry in the raw v1 wire format. The `skyflow-node` SDK (Approach A) smooths this over: it surfaces both as `processedText`, and exposes each detected entity with `entity` / `scores` / diff --git a/src/lib/tools/reIdentify.ts b/src/lib/tools/reIdentify.ts index 349d324..ba3d94a 100644 --- a/src/lib/tools/reIdentify.ts +++ b/src/lib/tools/reIdentify.ts @@ -1,15 +1,89 @@ -import { ReidentifyTextRequest, SkyflowError } from "skyflow-node"; +import { ReidentifyTextOptions, ReidentifyTextRequest, SkyflowError } from "skyflow-node"; import type { Skyflow } from "skyflow-node"; -import type { ReIdentifyOutput, ReIdentifyErrorOutput, AnonymousModeError, ToolResult } from "./types.js"; +import { getEntityEnum, isValidEntity } from "../mappings/entityMaps.js"; +import type { ReIdentifyFormat, ReIdentifyOutput, ReIdentifyErrorOutput, AnonymousModeError, ToolResult } from "./types.js"; + +/** The three re-identification treatment buckets, in output order. */ +const FORMAT_BUCKETS = ["redacted", "masked", "plaintext"] as const; + +/** + * Reduce a format to only the buckets that carry entity types, de-duplicating + * within each bucket and dropping empty/absent buckets. The result is what gets + * forwarded to the SDK and echoed back, so both stay in lockstep and neither + * carries empty arrays or redundant entries. + */ +function normalizeFormat(format: ReIdentifyFormat): ReIdentifyFormat { + const normalized: ReIdentifyFormat = {}; + for (const bucket of FORMAT_BUCKETS) { + const entities = format[bucket]; + if (entities && entities.length > 0) { + normalized[bucket] = [...new Set(entities)]; + } + } + return normalized; +} + +/** + * Collect any entity types in the format that are not recognized. Returned so + * an invalid name can be reported as a client-side validation error (before any + * Skyflow call), distinct in shape from a Skyflow API error. + */ +function findInvalidEntities(format: ReIdentifyFormat): string[] { + const invalid = new Set(); + for (const bucket of FORMAT_BUCKETS) { + for (const entity of format[bucket] ?? []) { + if (!isValidEntity(entity)) invalid.add(entity); + } + } + return [...invalid]; +} + +/** + * Find entity types that appear in more than one format bucket. Each entity type + * may only be rendered one way, so an entity listed under, say, both `redacted` + * and `masked` is ambiguous (the SDK would forward it to both setters with + * last-wins/undefined behavior). Duplicates within a single bucket are ignored. + */ +function findFormatOverlaps(format: ReIdentifyFormat): string[] { + const bucketCount = new Map(); + for (const bucket of FORMAT_BUCKETS) { + for (const entity of new Set(format[bucket] ?? [])) { + bucketCount.set(entity, (bucketCount.get(entity) ?? 0) + 1); + } + } + return [...bucketCount.entries()].filter(([, count]) => count > 1).map(([entity]) => entity); +} + +/** + * Build Skyflow re-identify options from an already-normalized format. + * Returns `undefined` when no entity types are specified, so the SDK is called + * without options and every token is fully re-identified (the default behavior). + */ +function buildReidentifyOptions( + normalized: ReIdentifyFormat +): ReidentifyTextOptions | undefined { + if (Object.keys(normalized).length === 0) return undefined; + + const options = new ReidentifyTextOptions(); + if (normalized.redacted) options.setRedactedEntities(normalized.redacted.map(getEntityEnum)); + if (normalized.masked) options.setMaskedEntities(normalized.masked.map(getEntityEnum)); + if (normalized.plaintext) options.setPlainTextEntities(normalized.plaintext.map(getEntityEnum)); + return options; +} /** * Handle the re-identify tool logic. * Restores original sensitive data from de-identified placeholders. + * + * An optional `format` controls how each entity type is rendered on the way out + * (redacted / masked / plaintext), per the Skyflow Detect API spec. When omitted, + * every token is fully re-identified to its original plaintext value. */ export async function handleReIdentify( inputString: string, skyflow: Skyflow, - anonymousMode: boolean + anonymousMode: boolean, + format?: ReIdentifyFormat ): Promise> { if (anonymousMode) { return { @@ -28,15 +102,55 @@ export async function handleReIdentify( }; } + if (format) { + const overlaps = findFormatOverlaps(format); + if (overlaps.length > 0) { + return { + output: { + error: true, + message: + "Each entity type may appear in only one format bucket (redacted, masked, or plaintext). " + + `The following appear in more than one: ${overlaps.join(", ")}.`, + }, + isError: true, + }; + } + + // The server inputSchema already enum-constrains entity names, but this + // handler is a standalone, independently-testable function and must not + // assume the caller pre-validated — re-validate so a direct call (or a + // future caller that skips the schema) still gets a clean client-side error. + const invalidEntities = findInvalidEntities(format); + if (invalidEntities.length > 0) { + return { + output: { + error: true, + message: + `Invalid entity type(s) in format: ${invalidEntities.join(", ")}. ` + + "Use the same lowercase entity names as the de-identify tool.", + }, + isError: true, + }; + } + } + + // Normalize once: drops empty buckets and intra-bucket duplicates. The same + // value feeds the SDK options and the echoed-back format. + const normalizedFormat = format ? normalizeFormat(format) : undefined; + const hasFormat = normalizedFormat !== undefined && Object.keys(normalizedFormat).length > 0; + try { + const options = normalizedFormat ? buildReidentifyOptions(normalizedFormat) : undefined; + const response = await skyflow .detect() - .reidentifyText(new ReidentifyTextRequest(inputString)); + .reidentifyText(new ReidentifyTextRequest(inputString), options); return { output: { inputText: inputString, processedText: response.processedText, + ...(hasFormat && { format: normalizedFormat }), }, }; } catch (error) { diff --git a/src/lib/tools/types.ts b/src/lib/tools/types.ts index 1d5c320..84a9d4e 100644 --- a/src/lib/tools/types.ts +++ b/src/lib/tools/types.ts @@ -19,10 +19,27 @@ export interface DeIdentifyOutput { note?: string; } +/** + * Per-entity-type formatting for re-identification. + * Mirrors the Skyflow Detect API `format` object: each entity type listed is + * rendered according to its bucket. Entity types not listed in any bucket + * default to `plaintext` (full re-identification). + */ +export interface ReIdentifyFormat { + /** Entity types to fully redact (original value completely hidden). */ + redacted?: string[]; + /** Entity types to partially mask (only part of the original value revealed). */ + masked?: string[]; + /** Entity types to fully restore to their original plaintext value. */ + plaintext?: string[]; +} + /** Output from the re-identify tool handler */ export interface ReIdentifyOutput { inputText: string; processedText: string; + /** The re-identification format the caller requested (normalized), echoed back when provided. */ + format?: ReIdentifyFormat; } /** Error output for tools that don't support anonymous mode */ diff --git a/src/server.ts b/src/server.ts index 81923ed..b604c21 100644 --- a/src/server.ts +++ b/src/server.ts @@ -144,11 +144,40 @@ registerAppTool( { title: "Skyflow Re-identify Tool", description: - "Re-identify previously de-identified sensitive information in strings using Skyflow. This tool accepts a string with redacted placeholders (like [CREDIT_CARD_abc123]) and returns the original sensitive data.", - inputSchema: { inputString: z.string().min(1).describe("Original Text — paste the tokenized text you want to restore") }, + "Re-identify previously de-identified sensitive information in strings using Skyflow. This tool accepts a string with redacted placeholders (like [CREDIT_CARD_abc123]) and returns the original sensitive data. Optionally pass a `format` object to control how each entity type is rendered on the way out — fully restore (plaintext), partially mask, or fully redact specific entity types. Entity types not listed fall back to the Detect API's default and are restored as full plaintext (the same as re-identifying without a format).", + inputSchema: { + inputString: z.string().min(1).describe("Original Text — paste the tokenized text you want to restore"), + format: z + .object({ + redacted: z + .array(z.enum(ENTITY_KEYS)) + .optional() + .describe("Entity types to fully redact — the original value is completely hidden (e.g. '[REDACTED]')."), + masked: z + .array(z.enum(ENTITY_KEYS)) + .optional() + .describe("Entity types to partially mask — only part of the original value is revealed. The exact masked form depends on the vault's masking configuration."), + plaintext: z + .array(z.enum(ENTITY_KEYS)) + .optional() + .describe("Entity types to fully restore to their original plaintext value. This is already the default for any unlisted entity type, so listing here is only for explicitness."), + }) + .optional() + .describe( + "Optional per-entity-type re-identification format. List entity types under 'redacted', 'masked', or 'plaintext' to control how each is rendered. Any entity type not listed falls back to the Detect API's default and is restored as full plaintext." + ), + }, outputSchema: { inputText: z.string().optional().describe("The original tokenized input text"), processedText: z.string().optional(), + format: z + .object({ + redacted: z.array(z.string()).optional(), + masked: z.array(z.string()).optional(), + plaintext: z.array(z.string()).optional(), + }) + .optional() + .describe("The re-identification format the caller requested, normalized (empty buckets dropped) and echoed back when provided"), error: z.union([z.boolean(), z.string()]).optional().describe("Error indicator or message"), anonymousModeRestricted: z.boolean().optional().describe("True when blocked due to anonymous mode"), message: z.string().optional().describe("Detailed error or setup instructions"), @@ -158,8 +187,8 @@ registerAppTool( }, _meta: { ui: { resourceUri: RE_IDENTIFY_RESOURCE_URI } }, }, - async ({ inputString }) => { - const result = await handleReIdentify(inputString, getCurrentSkyflow(), isAnonymousMode()); + async ({ inputString, format }) => { + const result = await handleReIdentify(inputString, getCurrentSkyflow(), isAnonymousMode(), format); return { content: [{ type: "text", text: JSON.stringify(result.output) }], structuredContent: toStructuredContent(result.output), diff --git a/tests/unit/tools/reIdentify.test.ts b/tests/unit/tools/reIdentify.test.ts index 0923be9..384afa7 100644 --- a/tests/unit/tools/reIdentify.test.ts +++ b/tests/unit/tools/reIdentify.test.ts @@ -4,6 +4,9 @@ import type { ReIdentifyOutput, ReIdentifyErrorOutput, AnonymousModeError } from // Mock the skyflow-node SDK const mockReidentifyText = vi.fn(); +const mockSetRedactedEntities = vi.fn(); +const mockSetMaskedEntities = vi.fn(); +const mockSetPlainTextEntities = vi.fn(); vi.mock("skyflow-node", () => { class MockSkyflowError extends Error { @@ -16,6 +19,16 @@ vi.mock("skyflow-node", () => { } return { ReidentifyTextRequest: vi.fn(function (this: any, input: string) { this.input = input; }), + ReidentifyTextOptions: vi.fn(function (this: any) { + this.setRedactedEntities = mockSetRedactedEntities; + this.setMaskedEntities = mockSetMaskedEntities; + this.setPlainTextEntities = mockSetPlainTextEntities; + }), + // Proxy returns lowercase prop names to mirror the real DetectEntities enum + // values (e.g. DetectEntities.SSN === "ssn"), so getEntityEnum round-trips. + DetectEntities: new Proxy({}, { get: (_t, prop) => String(prop).toLowerCase() }), + MaskingMethod: new Proxy({}, { get: (_t, prop) => prop }), + DetectOutputTranscription: new Proxy({}, { get: (_t, prop) => prop }), SkyflowError: MockSkyflowError, }; }); @@ -62,6 +75,158 @@ describe("handleReIdentify", () => { }); }); + describe("format handling", () => { + it("should call reidentifyText without options when no format is provided", async () => { + const skyflow = createMockSkyflow({ processedText: "restored" }); + await handleReIdentify("[SSN_abc123]", skyflow as any, false); + + expect(mockReidentifyText).toHaveBeenCalledTimes(1); + // Second argument (options) should be undefined for backward compatibility + expect(mockReidentifyText.mock.calls[0][1]).toBeUndefined(); + expect(mockSetRedactedEntities).not.toHaveBeenCalled(); + expect(mockSetMaskedEntities).not.toHaveBeenCalled(); + expect(mockSetPlainTextEntities).not.toHaveBeenCalled(); + }); + + it("should not echo a format field when none is provided", async () => { + const skyflow = createMockSkyflow({ processedText: "restored" }); + const result = await handleReIdentify("[SSN_abc123]", skyflow as any, false); + const output = result.output as ReIdentifyOutput; + + expect(output).not.toHaveProperty("format"); + }); + + it("should route entity types to redacted / masked / plaintext setters", async () => { + const skyflow = createMockSkyflow({ processedText: "restored" }); + await handleReIdentify("input", skyflow as any, false, { + redacted: ["ssn"], + masked: ["credit_card"], + plaintext: ["email_address", "name"], + }); + + expect(mockSetRedactedEntities).toHaveBeenCalledWith(["ssn"]); + expect(mockSetMaskedEntities).toHaveBeenCalledWith(["credit_card"]); + expect(mockSetPlainTextEntities).toHaveBeenCalledWith(["email_address", "name"]); + // Options object should be forwarded to the SDK call + expect(mockReidentifyText.mock.calls[0][1]).toBeDefined(); + }); + + it("should pass options even when only one bucket is provided", async () => { + const skyflow = createMockSkyflow({ processedText: "restored" }); + await handleReIdentify("input", skyflow as any, false, { masked: ["ssn"] }); + + expect(mockSetMaskedEntities).toHaveBeenCalledWith(["ssn"]); + expect(mockSetRedactedEntities).not.toHaveBeenCalled(); + expect(mockSetPlainTextEntities).not.toHaveBeenCalled(); + expect(mockReidentifyText.mock.calls[0][1]).toBeDefined(); + }); + + it("should ignore empty buckets and skip options when format has no entities", async () => { + const skyflow = createMockSkyflow({ processedText: "restored" }); + await handleReIdentify("input", skyflow as any, false, { + redacted: [], + masked: [], + plaintext: [], + }); + + expect(mockSetRedactedEntities).not.toHaveBeenCalled(); + expect(mockSetMaskedEntities).not.toHaveBeenCalled(); + expect(mockSetPlainTextEntities).not.toHaveBeenCalled(); + // No entities means no options object is forwarded + expect(mockReidentifyText.mock.calls[0][1]).toBeUndefined(); + }); + + it("should echo the applied format back in the output", async () => { + const skyflow = createMockSkyflow({ processedText: "restored" }); + const format = { masked: ["ssn"], plaintext: ["name"] }; + const result = await handleReIdentify("input", skyflow as any, false, format); + const output = result.output as ReIdentifyOutput; + + expect(output.format).toEqual(format); + }); + + it("should omit format from the output when provided with no entities", async () => { + const skyflow = createMockSkyflow({ processedText: "restored" }); + const result = await handleReIdentify("input", skyflow as any, false, {}); + const output = result.output as ReIdentifyOutput; + + expect(output).not.toHaveProperty("format"); + }); + + it("should drop empty buckets from the echoed format", async () => { + const skyflow = createMockSkyflow({ processedText: "restored" }); + const result = await handleReIdentify("input", skyflow as any, false, { + masked: ["ssn"], + redacted: [], + plaintext: [], + }); + const output = result.output as ReIdentifyOutput; + + expect(output.format).toEqual({ masked: ["ssn"] }); + }); + + it("should reject an entity type that appears in more than one bucket", async () => { + const skyflow = createMockSkyflow({ processedText: "restored" }); + const result = await handleReIdentify("input", skyflow as any, false, { + redacted: ["ssn"], + masked: ["ssn"], + }); + const output = result.output as ReIdentifyErrorOutput; + + expect(result.isError).toBe(true); + expect(output.error).toBe(true); + expect(output.message).toContain("ssn"); + expect(output.message).toContain("only one format bucket"); + expect(mockReidentifyText).not.toHaveBeenCalled(); + }); + + it("should de-duplicate an entity listed twice within a single bucket", async () => { + const skyflow = createMockSkyflow({ processedText: "restored" }); + const result = await handleReIdentify("input", skyflow as any, false, { + masked: ["ssn", "ssn"], + }); + const output = result.output as ReIdentifyOutput; + + // A duplicate within one bucket is not a cross-bucket conflict, and it is + // collapsed before reaching the SDK and the echoed format. + expect(result.isError).toBeUndefined(); + expect(mockSetMaskedEntities).toHaveBeenCalledWith(["ssn"]); + expect(output.format).toEqual({ masked: ["ssn"] }); + }); + + it("should reject an overlap before validating entity names (overlap guard runs first)", async () => { + const skyflow = createMockSkyflow({ processedText: "restored" }); + const result = await handleReIdentify("input", skyflow as any, false, { + redacted: ["not_a_real_entity"], + masked: ["not_a_real_entity"], + }); + const output = result.output as ReIdentifyErrorOutput; + + // The overlap guard runs before getEntityEnum, so the error is about the + // bucket conflict, not the invalid entity name. + expect(result.isError).toBe(true); + expect(output.message).toContain("only one format bucket"); + expect(output.message).not.toContain("Invalid entity type"); + expect(mockReidentifyText).not.toHaveBeenCalled(); + }); + + it("should return an error for an invalid entity type in the format", async () => { + const skyflow = createMockSkyflow({ processedText: "restored" }); + const result = await handleReIdentify("input", skyflow as any, false, { + masked: ["not_a_real_entity"], + }); + const output = result.output as ReIdentifyErrorOutput; + + expect(result.isError).toBe(true); + expect(output.error).toBe(true); + expect(output.message).toContain("Invalid entity type"); + // Client-side validation error: no HTTP code, so it is distinguishable + // from a Skyflow API error (which carries `code`). + expect(output.code).toBeUndefined(); + expect(mockReidentifyText).not.toHaveBeenCalled(); + }); + }); + describe("anonymous mode", () => { it("should return error with anonymousModeRestricted flag", async () => { const skyflow = createMockSkyflow({ processedText: "" }); diff --git a/ui/re-identify/main.ts b/ui/re-identify/main.ts index ec00b6b..5560aab 100644 --- a/ui/re-identify/main.ts +++ b/ui/re-identify/main.ts @@ -160,6 +160,41 @@ function highlightValuesInOutput(text: string, mappings: TokenMapping[]): string return html; } +function renderFormatSummary(format: NonNullable): string { + // Ordered least- to most-restrictive (revealed -> hidden) for readability; + // this intentionally differs from the handler's FORMAT_BUCKETS order. + const groups: { label: string; entities: string[] }[] = [ + { label: "Plaintext", entities: format.plaintext || [] }, + { label: "Masked", entities: format.masked || [] }, + { label: "Redacted", entities: format.redacted || [] }, + ].filter((g) => g.entities.length > 0); + + if (groups.length === 0) return ""; + + const groupsHtml = groups + .map((g) => { + const chips = g.entities + .map((e) => { + const cls = getEntityClass(e); + const label = e.replace(/_/g, " "); + return `${escapeHtml(label)}`; + }) + .join(" "); + return ` +
+ ${escapeHtml(g.label)} + ${chips} +
+ `; + }) + .join(""); + + return ` +
Requested Format
+
${groupsHtml}
+ `; +} + function renderResult(data: ReIdentifyResult): void { if (data.error || data.anonymousModeRestricted) { const isAnonymous = data.anonymousModeRestricted; @@ -211,6 +246,8 @@ function renderResult(data: ReIdentifyResult): void { + ${data.format ? renderFormatSummary(data.format) : ""} +
Tokenized Input
diff --git a/ui/shared/styles.css b/ui/shared/styles.css index bd940af..2897870 100644 --- a/ui/shared/styles.css +++ b/ui/shared/styles.css @@ -247,6 +247,36 @@ body { flex-shrink: 0; } +/* Re-identify format summary */ +.format-summary { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} + +.format-group { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 6px 10px; +} + +.format-group-label { + font-size: 11px; + font-weight: var(--font-weight-semibold, 600); + color: var(--color-text-secondary, #6b6b80); + text-transform: uppercase; + letter-spacing: 0.04em; + min-width: 68px; +} + +.format-group-chips { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; +} + /* Info banner */ .banner { padding: 10px 14px; diff --git a/ui/shared/types.ts b/ui/shared/types.ts index cbe6e3a..1cf1578 100644 --- a/ui/shared/types.ts +++ b/ui/shared/types.ts @@ -19,10 +19,18 @@ export interface DeIdentifyResult { note?: string; } +/** Per-entity-type formatting requested during re-identification */ +export interface ReIdentifyFormat { + redacted?: string[]; + masked?: string[]; + plaintext?: string[]; +} + /** Result from the re-identify tool */ export interface ReIdentifyResult { inputText?: string; processedText?: string; + format?: ReIdentifyFormat; error?: string; message?: string; anonymousModeRestricted?: boolean;