diff --git a/packages/apollo-react/src/canvas/components/Guardrails/README.md b/packages/apollo-react/src/canvas/components/Guardrails/README.md index 5548e02b4..3c441f70b 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/README.md +++ b/packages/apollo-react/src/canvas/components/Guardrails/README.md @@ -4,10 +4,150 @@ Shared UI for the UiPath Guardrails experience, consumed by Flow (flow-workbench later stage, Agents (`frontend-sw`). Lives in apollo-react next to canvas — MUI-free, built entirely on `@uipath/apollo-wind` primitives and its `forms/` engine, strings on lingui — and is exported through the narrow `@uipath/apollo-react/canvas/guardrails` subpath (also -re-exported from `./canvas`). Members: `GuardrailBuilder` (the whole Add/Edit screen), +re-exported from `./canvas`). Members: the definitions layer (wire types, parser, canonical +copy and `useGuardrailDefinitions`), `GuardrailBuilder` (the whole Add/Edit screen), `GuardrailFormLayout` (the screen shell), and `GuardrailValidatorForm` (the validator parameter section, also rendered inside the builder). +## Definitions layer + +Turns the `GET /api/execution/guardrails/definitions` payload into the +`GuardrailDefinition`s the builder renders. Three pure steps and one hook over them: + +``` +unknown payload → parseGuardrailDefinitions → enrichGuardrailDefinitions → GuardrailBuilder + (zod, private) (canonical copy on lingui) + useGuardrailDefinitions composes all three +``` + +```tsx +import { useGuardrailDefinitions } from '@uipath/apollo-react/canvas/guardrails'; + +const { definitions, invalid, loading, error, refetch } = useGuardrailDefinitions({ + baseUrl: `/${orgName}/${tenantName}/agents_`, // omit for same-origin + tenantId, +}); +``` + +Three host shapes, all supported: + +| Host | Call | +| --- | --- | +| Owns no transport | `useGuardrailDefinitions({ baseUrl, tenantId })` | +| Already has SWR or React Query | `useGuardrailDefinitions(null, { definitions: data })` | +| Never fetches (Flow's vsix, over postMessage) | `useGuardrailDefinitions(null, { definitions: fromMessage })` | + +`options.definitions` wins over the context: when it is present no request is made at all, and +the value is parsed and enriched instead. That is the seam that lets a product keep its own +cache rather than adopting a second one, and it is why the hook stays a `useState` plus +`fetch` plus `AbortController` (the `useDiscoveryModels` idiom) instead of a query library. + +### Contract + +- **The parser never throws.** `parseGuardrailDefinitions(unknown)` returns + `{ definitions, invalid, inputError? }`. A payload that is not an array sets `inputError`; + an individual definition that fails validation is dropped whole and listed in `invalid`, + which is what both products already do entry by entry. Unknown keys are stripped. Surface + `invalid` as a status banner, never as an error page: the other definitions are fine. +- **Transport errors and data errors are different channels.** `error` is a failed request. + A malformed payload arrives through `invalid` / `inputError` with `error` still `null`. +- **zod does not cross the boundary.** The schema is private to `definitions-parse.ts`; + `GuardrailDefinitionWire` is hand-written, and the two are pinned to each other by a + compile-time assignability check in `toWireDefinition` plus two tests (a key-set assertion + and a source-level import guard), so the emitted `.d.ts` for this folder carries no schema + types and consumers take no zod dependency. +- **Enrichment is pure and exported.** `enrichGuardrailDefinitions(wire, { copy, hiddenValidators })` + is React-free, so non-React and bridge callers use it directly. + `EnrichedGuardrailDefinition extends GuardrailDefinition`, so its output feeds + `GuardrailBuilder` with no mapping. +- **Context and `hiddenValidators` are compared by content, not identity**, so a host can + build them inline. (`useDiscoveryModels` compares the context by identity; an inline object + there refetches on every render and never settles.) `options.definitions` is the exception, + compared by identity because hashing a whole payload every render would cost more than it + saves: pass a stable reference (an SWR or react-query result already is). +- **`loading` starts `true` when the hook is about to fetch**, so a host rendering + `loading ? : ` does not flash the empty state on first paint. It starts + `false` when the hook is disabled (`null` context, or `options.definitions` supplied), and + `refetch()` is a no-op in that state. +- **A failed request keeps the previous results.** `error` is set and `definitions` still hold + the last good payload, so a transient 503 on a `refetch` does not empty a list the user is + looking at. Render on `error` first if you want it to replace the data. Disabling the hook + does clear the fetched state. +- **`hiddenValidators` hides nothing by default** and never hides a BYO definition. Which + validators a product exposes is an entitlement decision, so it stays with the caller: Flow + passes `['prompt_injection']`, Agents passes nothing. +- **BYO folder placement stays host-side.** Resolving it needs each product's connections API + (Agents pages `fetchResources`, Flow calls `getConnectionById`), so the hook does not reach + for it. Stamp the result on afterwards: + + ```ts + const withFolders = withGuardrailFolderMetadata(definitions, (id) => connections.get(id)); + ``` + +### Canonical copy + +The display copy for the six built-in validators lives here, as lingui messages in the shared +canvas catalog, rather than in each product's own table. Both products get the same wording, +and the strings enter the real localization pipeline instead of a host-side constant. + +**English only, like every other string in this package.** The other thirteen catalogs get +these ids from `chore(l10n): sync from Localization`, which appends new keys every week or +two. Until it runs, `useSafeLingui` renders the English default, so nothing is missing on +screen. Do not hand-write translations here. + +This narrows, deliberately, the rule the family shipped with in #1138: that domain copy never +ships in this package. The rule still holds for copy this package cannot know, which is why +wire copy wins at parameter level and a BYO definition takes no curated copy at all. What +moved is the six validators both products had already transcribed by hand, where keeping two +copies in sync is what produced `finNationalId` in one product and `fiNationalId` in the +other. The components are unchanged: they still resolve nothing and render what they are +handed, so a host that would rather keep its own table simply does not call +`enrichGuardrailDefinitions`. + +Message ids use the raw wire values, never a transcribed slug: + +``` +guardrails.definitions..display-name | .description | .usage-note +guardrails.definitions..param..label | .tooltip +guardrails.definitions..option.. +``` + +Transcribing is exactly how the two products ended up keying the same Finland entity as +`finNationalId` and `fiNationalId`; `USSocialSecurityNumber` is the value we persist, so it is +also the id. + +Copy precedence, unchanged from what both products already do: + +| level | non-BYO | BYO | +| --- | --- | --- | +| display name | curated, wire, `validator` | wire, `validator` | +| description | curated, wire, `''` | wire, `''` | +| usage note | curated only | none | +| parameter label | **wire**, curated, humanized id | wire, humanized id | +| parameter tooltip | **wire**, curated | wire | +| option labels | curated merged under wire | wire only | + +Curated wins at definition level because that table is what product and localization review; +wire wins at parameter level because a BYO manifest and a newly shipped backend parameter +describe themselves. A BYO definition takes no curated copy at any level, even when its +validator id collides with a UiPath one. + +Where the two products' English differed, the choice is declared with a reason in +`definitions-parity.test.ts` (17 entries) and asserted against both products' transcribed +copy in `__fixtures__/host-copy-baselines.ts`. That suite fails on an undeclared difference, +a stale declaration, or a third wording we invented, so the table cannot quietly drift from +the products it is meant to replace. + +`GUARDRAIL_COPY_EN` is the English table the pure layer defaults to; +`GUARDRAIL_COPY_EN_MESSAGES` is the same copy flattened to id-to-English, exported so hosts +can diff their remaining local tables against it in CI while they migrate off them. + +> `src/canvas` uses no lingui macros, so `lingui extract` does not feed this catalog: its +> English entries are hand-authored. Two tests do what extraction would: every message reaches +> `src/canvas/locales/en.json` with the same English, and no catalog keeps a +> `guardrails.definitions.*` id the source has dropped. The second scans all fourteen files, +> so a rename cannot leave the sync's translations behind as dead entries. + ## GuardrailBuilder The complete Add/Edit screen for an OOTB guardrail validator: status banners, usage note, @@ -21,7 +161,7 @@ import { GuardrailBuilder } from '@uipath/apollo-react/canvas/guardrails'; => + JSON.parse(readFileSync(join(localesDir, `${locale}.json`), 'utf8')); + +/** + * Ids the source declares that English does not carry, and ids whose English has drifted away + * from the source default. A drifted entry is the worse of the two: it translates, but into + * something the component never says. + */ +export function findCatalogDrift(messages: Readonly>): { + missing: string[]; + drifted: string[]; +} { + const catalog = readCanvasCatalog('en'); + const missing: string[] = []; + const drifted: string[] = []; + for (const [id, message] of Object.entries(messages)) { + if (!(id in catalog)) missing.push(id); + else if (catalog[id] !== message) + drifted.push(`${id}\n src: ${message}\n en: ${catalog[id]}`); + } + + return { missing, drifted }; +} + +/** + * `locale: id` for every id under `prefix` that the source no longer declares, across all + * fourteen catalogs. A renamed id otherwise leaves the sync's translations behind as dead + * entries nothing will ever clean up. + */ +export function findCatalogOrphans( + messages: Readonly>, + prefix: string +): string[] { + return CANVAS_LOCALES.flatMap((locale) => + Object.keys(readCanvasCatalog(locale)) + .filter((id) => id.startsWith(prefix)) + .filter((id) => !(id in messages)) + .map((id) => `${locale}: ${id}`) + ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/definitions-wire.fixtures.ts b/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/definitions-wire.fixtures.ts new file mode 100644 index 000000000..2d41fba70 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/definitions-wire.fixtures.ts @@ -0,0 +1,246 @@ +import type { GuardrailDefinitionWire } from '../definitions-wire'; + +/** + * Wire payloads shaped like what `GET /api/execution/guardrails/definitions` actually + * returns, trimmed to the fields the definitions layer reads. Kept close to the real + * payload (option lists, threshold bounds, BYO fields) so the enrichment tests exercise the + * same shapes the products see. + */ + +/** The 25 PII entities the backend offers today. */ +export const PII_ENTITY_OPTIONS: string[] = [ + 'Person', + 'Address', + 'Date', + 'PhoneNumber', + 'EugpsCoordinates', + 'Email', + 'CreditCardNumber', + 'InternationalBankingAccountNumber', + 'SwiftCode', + 'ABARoutingNumber', + 'USDriversLicenseNumber', + 'UKDriversLicenseNumber', + 'USIndividualTaxpayerIdentification', + 'UKUniqueTaxpayerNumber', + 'USBankAccountNumber', + 'USSocialSecurityNumber', + 'UsukPassportNumber', + 'NOIdentityNumber', + 'FINationalID', + 'FIPassportNumber', + 'SENationalID', + 'DKPersonalIdentificationNumber', + 'NLCitizensServiceNumber', + 'URL', + 'IPAddress', +]; + +export const PII_DETECTION_WIRE: GuardrailDefinitionWire = { + validator: 'pii_detection', + allowedScopes: ['Agent', 'Llm', 'Tool'], + status: 'Available', + parameters: [ + { + id: 'entities', + type: 'enum-list', + required: true, + defaultValue: ['Email', 'CreditCardNumber'], + options: PII_ENTITY_OPTIONS, + }, + { + // The backend omits the bounds here, and enrichment leaves them off: only `step` is + // defaulted. See the map-enum branch in `definitions-enrich.ts`. + id: 'entityThresholds', + type: 'map-enum', + required: false, + defaultValue: { Email: 0.8, CreditCardNumber: 0.8 }, + keySource: 'entities', + }, + ], +}; + +export const HARMFUL_CONTENT_WIRE: GuardrailDefinitionWire = { + validator: 'harmful_content', + allowedScopes: ['Llm', 'Tool'], + status: 'Available', + parameters: [ + { + id: 'harmfulContentEntities', + type: 'enum-list', + required: true, + defaultValue: ['Hate', 'Violence'], + options: ['Hate', 'SelfHarm', 'Sexual', 'Violence'], + }, + { + id: 'harmfulContentEntityThresholds', + type: 'map-enum', + required: false, + defaultValue: { Hate: 2, Violence: 2 }, + keySource: 'harmfulContentEntities', + min: 0, + max: 6, + step: 2, + }, + ], +}; + +export const PROMPT_INJECTION_WIRE: GuardrailDefinitionWire = { + validator: 'prompt_injection', + allowedScopes: ['Llm'], + status: 'Available', + parameters: [ + { + id: 'threshold', + type: 'number', + required: true, + defaultValue: 0.7, + min: 0, + max: 1, + step: 0.1, + }, + ], +}; + +export const USER_PROMPT_ATTACKS_WIRE: GuardrailDefinitionWire = { + validator: 'user_prompt_attacks', + allowedScopes: ['Llm'], + status: 'Available', + parameters: [], +}; + +export const INTELLECTUAL_PROPERTY_WIRE: GuardrailDefinitionWire = { + validator: 'intellectual_property', + allowedScopes: ['Llm', 'Tool'], + status: 'FeatureDisabled', + parameters: [ + { + id: 'ipEntities', + type: 'enum-list', + required: true, + defaultValue: ['Text'], + options: ['Text', 'Code'], + }, + ], +}; + +export const LLM_AS_JUDGE_WIRE: GuardrailDefinitionWire = { + validator: 'llm_as_judge', + allowedScopes: ['Agent', 'Llm', 'Tool'], + status: 'Available', + parameters: [ + { + id: 'guardrailText', + type: 'text', + required: true, + defaultValue: null, + maxLength: 4000, + }, + { + id: 'model', + type: 'enum', + required: true, + defaultValue: null, + options: ['gpt-4o-mini-2024-07-18', 'gpt-4o-2024-11-20'], + }, + { + // Agents' schema has no `defaultValue` for text-list at all; this mirrors that. + id: 'positiveExamples', + type: 'text-list', + required: false, + maxItems: 5, + maxLength: 1000, + }, + { + id: 'negativeExamples', + type: 'text-list', + required: false, + defaultValue: null, + maxItems: 5, + maxLength: 1000, + }, + { + id: 'threshold', + type: 'number', + required: true, + defaultValue: 4, + min: 0, + max: 6, + step: 2, + }, + ], +}; + +/** A bring-your-own guardrail: manifest copy only, no curated table entry applies. */ +export const BYO_WIRE: GuardrailDefinitionWire = { + validator: 'pii_detection', + allowedScopes: ['Llm'], + status: 'Available', + displayName: 'Acme PII scan', + description: 'Runs the Acme detector over prompts and completions.', + byoValidatorName: 'acme-pii', + byoConnectorName: 'Acme AI Guardrails', + byoConnectorKey: 'acme', + byoGuardrailConnectionId: 'conn-1', + byoConfigurationId: 'cfg-1', + parameters: [ + { + id: 'sensitivity', + type: 'enum', + required: true, + defaultValue: 'medium', + displayName: 'Sensitivity', + description: 'How aggressively to flag.', + options: ['low', 'medium', 'high'], + optionLabels: { low: 'Low', high: 'High' }, + }, + ], +}; + +/** A UiPath validator the curated table has never heard of. */ +export const UNCURATED_WIRE: GuardrailDefinitionWire = { + validator: 'topic_drift', + allowedScopes: ['Llm'], + status: 'Available', + parameters: [ + { id: 'maxDriftScore', type: 'number', required: true, defaultValue: 0.5 }, + { + id: 'allowedTopics', + type: 'enum-list', + required: false, + defaultValue: [], + options: ['finance', 'legal'], + }, + ], +}; + +export const ALL_BUILT_IN_WIRE: GuardrailDefinitionWire[] = [ + PII_DETECTION_WIRE, + PROMPT_INJECTION_WIRE, + HARMFUL_CONTENT_WIRE, + USER_PROMPT_ATTACKS_WIRE, + INTELLECTUAL_PROPERTY_WIRE, + LLM_AS_JUDGE_WIRE, +]; + +/** + * A raw payload as it arrives over the wire: unknown keys the backend added, one entry that + * fails validation, and the empty display strings Flow's schema normalizes away. + */ +export const RAW_PAYLOAD_WITH_NOISE: unknown = [ + { + ...PROMPT_INJECTION_WIRE, + // Fields a newer backend added that this package does not model. + executionStage: 'PreLlm', + internalRanking: 3, + displayName: '', + description: '', + }, + { + // Missing `status`, so the whole definition is dropped. + validator: 'broken_validator', + allowedScopes: ['Llm'], + parameters: [], + }, + USER_PROMPT_ATTACKS_WIRE, +]; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/host-copy-baselines.ts b/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/host-copy-baselines.ts new file mode 100644 index 000000000..4c50c3fa0 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/host-copy-baselines.ts @@ -0,0 +1,208 @@ +import type { GuardrailCopyTable } from '../definitions-copy'; + +/** + * The English validator copy each product ships today, transcribed into this package's copy + * shape so `definitions-parity.test.ts` can diff all three tables mechanically. + * + * Sources, both read at the time this layer was written: + * + * - Agents `origin/main`: + * `frontend-sw/src/components/definition/AddGuardrailPalette/AddGuardrailPalette.utils.tsx` + * (`OOB_GUARDRAILS_I8N`; its `name` / `params[].infoTooltip` / `params[].options` map onto + * `displayName` / `paramTooltips` / `optionLabels` here) + * - Flow `origin/develop`: + * `packages/canvas/src/components/properties-panel/guardrails/ootb-guardrail-definitions.ts` + * (`buildValidatorDisplayInfo`) + * + * Only the English defaults are transcribed, never the products' message keys: the whole + * point of the shared table is that the keys stop mattering. When a product changes its copy, + * update the baseline here and the parity test will say whether our choice still holds. + */ + +/** + * The 24 PII entity labels the two products spell identically. Shared rather than duplicated + * because they genuinely are the same strings; the one entity they disagree about + * (`FIPassportNumber`, Agents only) is added below. + */ +const SHARED_PII_ENTITY_LABELS: Record = { + Person: 'Person', + Address: 'Address', + Date: 'Date', + PhoneNumber: 'Phone Number', + EugpsCoordinates: 'EU GPS Coordinates', + Email: 'Email', + CreditCardNumber: 'Credit Card Number', + InternationalBankingAccountNumber: 'International Banking Account Number (IBAN)', + SwiftCode: 'SWIFT Code', + ABARoutingNumber: 'ABA Routing Number', + USDriversLicenseNumber: "US Driver's License Number", + UKDriversLicenseNumber: "UK Driver's License Number", + USIndividualTaxpayerIdentification: 'US Individual Taxpayer Identification Number (ITIN)', + UKUniqueTaxpayerNumber: 'UK Unique Taxpayer Number (UTR)', + USBankAccountNumber: 'US Bank Account Number', + USSocialSecurityNumber: 'US Social Security Number (SSN)', + UsukPassportNumber: 'US/UK Passport Number', + NOIdentityNumber: 'Norway Identity Number', + FINationalID: 'Finland National ID', + SENationalID: 'Sweden National ID', + DKPersonalIdentificationNumber: 'Danish Personal Identification Number', + NLCitizensServiceNumber: 'Netherlands Citizens Service Number', + URL: 'URL', + IPAddress: 'IP Address', +}; + +const SHARED_LLM_AS_JUDGE_TOOLTIPS: Record = { + guardrailText: + 'Describe the rule the judge will enforce. Be specific about what should pass and what should fail.', + model: 'The model used to evaluate the policy against each payload.', + positiveExamples: + 'Optional payloads that should pass the policy. Used by the judge as calibration anchors.', + negativeExamples: + 'Optional payloads that should fail the policy. Used by the judge as calibration anchors.', +}; + +/** Agents `frontend-sw`, `OOB_GUARDRAILS_I8N`. */ +export const AGENTS_COPY_EN: GuardrailCopyTable = { + pii_detection: { + displayName: 'PII detection', + description: + 'This validator is designed to detect personally identifiable information using Azure Cognitive Services', + paramLabels: { + entities: 'Entities to detect', + entityThresholds: 'Detection threshold', + }, + paramTooltips: { + entityThresholds: + 'Value between 0 and 1. The sensitivity level for PII detection. Higher thresholds detect more potential PII but may result in more false positives.', + }, + optionLabels: { + entities: { ...SHARED_PII_ENTITY_LABELS, FIPassportNumber: 'Finland Passport Number' }, + }, + }, + prompt_injection: { + displayName: 'Prompt injection', + description: + 'This validator is provided by Noma Security and is built to detect malicious attack attempts (e.g. prompt injection, jailbreak) in LLM calls.', + paramLabels: { threshold: 'Detection threshold' }, + paramTooltips: { + threshold: + 'Value between 0 and 1. The sensitivity level for Prompt Injection detection. Higher thresholds detect more potential Prompt Injection but may result in more false positives.', + }, + }, + harmful_content: { + displayName: 'Harmful content', + description: + 'This validator is provided by Microsoft Azure AI Content Safety and is built to detect harmful content (e.g. hate, violence, etc.) in LLM calls.', + paramLabels: { + harmfulContentEntities: 'Entities to detect', + harmfulContentEntityThresholds: 'Detection threshold', + }, + paramTooltips: { + harmfulContentEntityThresholds: + 'Integer value between 0 and 6 (step 2). The severity threshold for harmful content detection. Higher values require more severe content before triggering.', + }, + optionLabels: { + harmfulContentEntities: { + Hate: 'Hate', + SelfHarm: 'SelfHarm', + Sexual: 'Sexual', + Violence: 'Violence', + }, + }, + }, + user_prompt_attacks: { + displayName: 'User prompt attacks', + description: + 'This validator is provided by Microsoft Azure AI Content Safety and is built to detect user prompt attacks (e.g. jailbreak, prompt injection) that attempt to bypass system instructions.', + paramLabels: {}, + }, + intellectual_property: { + displayName: 'Intellectual property', + description: + 'This validator is provided by Microsoft Azure AI Content Safety and is built to detect potential intellectual property violations in text and code.', + paramLabels: { ipEntities: 'Entities to detect' }, + optionLabels: { ipEntities: { Text: 'Text', Code: 'Code' } }, + }, + llm_as_judge: { + displayName: 'LLM as Judge', + description: 'Detect violations of a rule you define, using an LLM as the judge.', + paramLabels: { + guardrailText: 'Rule prompt', + model: 'Judge model', + positiveExamples: 'Positive examples', + negativeExamples: 'Negative examples', + threshold: 'Threshold', + }, + paramTooltips: { + ...SHARED_LLM_AS_JUDGE_TOOLTIPS, + threshold: + 'Integer value between 0 and 6 (step 2). Lower values are stricter — borderline payloads will fail. Higher values are more lenient — only clear violations are flagged.', + }, + }, +}; + +/** Flow `packages/canvas`, `buildValidatorDisplayInfo()`. */ +export const FLOW_COPY_EN: GuardrailCopyTable = { + pii_detection: { + displayName: 'PII detection', + description: 'Detect personally identifiable information using Azure Cognitive Services.', + paramLabels: { + entities: 'Entities to detect', + entityThresholds: 'Detection thresholds', + }, + optionLabels: { entities: SHARED_PII_ENTITY_LABELS }, + }, + prompt_injection: { + displayName: 'Prompt injection', + description: + 'Detect malicious attack attempts (e.g. prompt injection, jailbreak) in LLM calls.', + paramLabels: { threshold: 'Detection threshold' }, + }, + harmful_content: { + displayName: 'Harmful content', + description: 'Detect harmful content (e.g. hate, violence) using Azure AI Content Safety.', + paramLabels: { + harmfulContentEntities: 'Content categories', + harmfulContentEntityThresholds: 'Severity thresholds', + }, + optionLabels: { + harmfulContentEntities: { + Hate: 'Hate', + SelfHarm: 'Self-harm', + Sexual: 'Sexual', + Violence: 'Violence', + }, + }, + }, + user_prompt_attacks: { + displayName: 'User prompt attacks', + description: + 'Detect user prompt attacks that attempt to bypass system instructions using Azure AI Content Safety.', + paramLabels: {}, + }, + intellectual_property: { + displayName: 'Intellectual property', + description: + 'Detect potential intellectual property violations in text and code using Azure AI Content Safety.', + paramLabels: { ipEntities: 'Content types' }, + optionLabels: { ipEntities: { Text: 'Text', Code: 'Code' } }, + }, + llm_as_judge: { + displayName: 'LLM as Judge', + description: 'Detect violations of a rule you define, using an LLM as the judge.', + usageNote: + "Judge model calls consume Agent units the same way the agent's own LLM calls do. Apply selectively to keep cost predictable, and choose the judge model with cost in mind — rates vary by model.", + paramLabels: { + guardrailText: 'Rule prompt', + model: 'Judge model', + positiveExamples: 'Positive examples', + negativeExamples: 'Negative examples', + threshold: 'Strictness', + }, + paramTooltips: { + ...SHARED_LLM_AS_JUDGE_TOOLTIPS, + threshold: + 'Strictness on a 0–6 scale. Lower values are stricter — the judge flags anything that hints at a violation. Higher values are more lenient — only clear, unambiguous violations are flagged.', + }, + }, +}; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/builder-types.ts b/packages/apollo-react/src/canvas/components/Guardrails/builder-types.ts index c233c0854..3ab02181b 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/builder-types.ts +++ b/packages/apollo-react/src/canvas/components/Guardrails/builder-types.ts @@ -66,8 +66,10 @@ export type GuardrailDefinitionStatus = /** * The display-ready definition of an OOTB guardrail validator. `displayName` and `usageNote` - * arrive pre-resolved (host-localized); `parameters` reuses the validator-form definition - * type. + * arrive pre-resolved - either from the host's own table or from + * `enrichGuardrailDefinitions`, which resolves the built-in validators from the shared canvas + * catalog; the builder renders what it is handed either way. `parameters` reuses the + * validator-form definition type. */ export interface GuardrailDefinition { validator: string; @@ -75,7 +77,7 @@ export interface GuardrailDefinition { allowedScopes: GuardrailScope[]; parameters: GuardrailParameterDefinition[]; status: GuardrailDefinitionStatus; - /** Pre-localized informational note rendered above the form. */ + /** Pre-resolved informational note rendered above the form. */ usageNote?: React.ReactNode; /** Present for bring-your-own guardrail definitions; stamped onto saved values. */ byoValidatorName?: string; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-chip.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-chip.tsx index 1ae27b88a..fd51a5574 100644 --- a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-chip.tsx +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-chip.tsx @@ -2,15 +2,22 @@ import { cn, Toggle } from '@uipath/apollo-wind'; import { cva, type VariantProps } from 'class-variance-authority'; import * as React from 'react'; +/** + * Pill geometry, shared with the read-only `GuardrailStatusChip` so the interactive and + * label chips stay one system. h-6/px-2.5/text-xs is the compact chip scale. + */ +export const GUARDRAIL_CHIP_GEOMETRY = + 'min-w-0 gap-1 rounded-full border px-2.5 text-xs font-medium [&_svg]:size-3'; + // `cn` is twMerge(clsx(...)), and a `future:`-prefixed class sits in a different merge group from // its unprefixed counterpart — so Toggle's own `future:text-muted-foreground` and // `future:data-[state=on]:text-foreground` survive alongside anything set here without a prefix, // and win under a `.future-*` root. Every colour the chip overrides therefore needs a `future:` // twin, or the pressed chip renders a brand fill with plain foreground text. const guardrailChipVariants = cva( - // Pill geometry over Toggle's base (which contributes the focus ring, disabled handling, - // and data-[state] hooks). h-6/px-2.5/text-xs matches the compact chip scale. - 'h-6 min-w-0 gap-1 rounded-full border px-2.5 text-xs font-medium [&_svg]:size-3', + // Geometry over Toggle's base, which contributes the focus ring, disabled handling and the + // data-[state] hooks. + `h-6 ${GUARDRAIL_CHIP_GEOMETRY}`, { variants: { appearance: { diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-chip.stories.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-chip.stories.tsx new file mode 100644 index 000000000..584e8a7cc --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-chip.stories.tsx @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GuardrailStatusChip } from './guardrail-status-chip'; + +const meta = { + title: 'Components/UiPath/Guardrail Status Chip', + component: GuardrailStatusChip, + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +Read-only status label for a guardrail row: definition status in the palette, governance +origin in the centralized section. It is a span carrying the wind badge classes, not a +control, so it is safe inside a button and stays out of the tab order. Use +\`GuardrailChip\` instead when the pill is meant to be toggled. + `, + }, + }, + }, + tags: ['autodocs'], + args: { children: 'Governance managed' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** + * One tone per definition status, plus the two both products already ship: green for a BYO + * origin or connector, blue for a "Preview" lifecycle label. + */ +export const Tones: Story = { + render: () => ( +
+ Governance managed + Preview + Bring your own + Feature disabled + Unauthorized +
+ ), +}; + +/** + * Host text of any length: the label truncates rather than wrapping out of the pill, and the + * full text is on the chip's `title`. + */ +export const LongLabel: Story = { + render: () => ( +
+ + Contoso Content Safety (EU West production) + +
+ ), +}; + +/** Valid inside a button, which is what the palette entry needs. */ +export const InsideAButton: Story = { + render: () => ( + + ), +}; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-chip.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-chip.test.tsx new file mode 100644 index 000000000..82c93c836 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-chip.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { createRef } from 'react'; +import { describe, expect, it } from 'vitest'; +import { GuardrailStatusChip } from './guardrail-status-chip'; + +// The label lives in an inner span so it can truncate, so `getByText` returns that span rather +// than the chip. Everything asserted here is on the chip itself. +const chip = () => document.querySelector('[data-slot="guardrail-status-chip"]'); + +describe('GuardrailStatusChip', () => { + it('renders a label, not a control', () => { + render(Governance managed); + + expect(screen.getByText('Governance managed')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('carries the chip family geometry', () => { + render(Disabled); + + expect(chip()).toHaveClass('rounded-full'); + }); + + it('renders a span carrying the badge classes, not a div', () => { + // wind's `Badge` renders a `
`, and the palette entry puts these chips inside its + // `