From 04772601a060b172a8e7f55707f191fa34982847 Mon Sep 17 00:00:00 2001 From: jbolor21 <86250273+jbolor21@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:09:12 -0700 Subject: [PATCH] add initial files with scores from DB reflected in GUI --- frontend/src/App.tsx | 20 ++++- .../Chat/AttackVerdictChip.styles.ts | 42 +++++++++ .../Chat/AttackVerdictChip.test.tsx | 62 +++++++++++++ .../src/components/Chat/AttackVerdictChip.tsx | 86 +++++++++++++++++++ frontend/src/components/Chat/ChatWindow.tsx | 15 +++- .../components/Chat/ObjectiveHeader.styles.ts | 41 +++++++++ .../components/Chat/ObjectiveHeader.test.tsx | 82 ++++++++++++++++++ .../src/components/Chat/ObjectiveHeader.tsx | 57 ++++++++++++ .../components/History/AttackHistory.test.tsx | 2 + .../components/History/AttackTable.test.tsx | 3 + .../src/components/History/AttackTable.tsx | 25 +----- frontend/src/components/Home/Home.test.tsx | 1 + frontend/src/types/index.ts | 18 +++- frontend/src/utils/attackOutcome.tsx | 29 +++++++ frontend/src/utils/scoreColor.test.ts | 64 ++++++++++++++ frontend/src/utils/scoreColor.ts | 70 +++++++++++++++ 16 files changed, 593 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/Chat/AttackVerdictChip.styles.ts create mode 100644 frontend/src/components/Chat/AttackVerdictChip.test.tsx create mode 100644 frontend/src/components/Chat/AttackVerdictChip.tsx create mode 100644 frontend/src/components/Chat/ObjectiveHeader.styles.ts create mode 100644 frontend/src/components/Chat/ObjectiveHeader.test.tsx create mode 100644 frontend/src/components/Chat/ObjectiveHeader.tsx create mode 100644 frontend/src/utils/attackOutcome.tsx create mode 100644 frontend/src/utils/scoreColor.test.ts create mode 100644 frontend/src/utils/scoreColor.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c617828545..e5986649d5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,7 +17,7 @@ import { ConnectionHealthProvider, useConnectionHealth } from './hooks/useConnec import { DEFAULT_GLOBAL_LABELS } from './components/Labels/labelDefaults' import { filtersFromSearchParams, filtersToSearchParams } from './components/History/historyFilters' import type { ViewName } from './components/Sidebar/Navigation' -import type { TargetInstance, TargetInfo } from './types' +import type { TargetInstance, TargetInfo, AttackOutcome, ScoreView } from './types' import { targetEndpoint, targetModelName, targetType } from './utils/targetIdentity' import { attacksApi, versionApi } from './services/api' import { toApiError } from './services/errors' @@ -51,6 +51,9 @@ interface LoadedAttack { labels: Record | null target: TargetInfo | null relatedConversationIds: string[] + objective: string + outcome: AttackOutcome | null + lastScore: ScoreView | null status: AttackLoadStatus } @@ -201,6 +204,9 @@ function App() { labels: null, target: null, relatedConversationIds: [], + objective: '', + outcome: null, + lastScore: null, }) attacksApi .getAttack(routeAttackId) @@ -212,6 +218,9 @@ function App() { labels: attack.labels ?? {}, target: attack.target ?? null, relatedConversationIds: attack.related_conversation_ids ?? [], + objective: attack.objective, + outcome: attack.outcome ?? null, + lastScore: attack.last_score ?? null, status: 'success', }) }) @@ -228,6 +237,9 @@ function App() { labels: null, target: null, relatedConversationIds: [], + objective: '', + outcome: null, + lastScore: null, }) }) // Drop a stale response once the route has moved on to another attack. @@ -294,6 +306,9 @@ function App() { labels: null, target, relatedConversationIds: [], + objective: '', + outcome: null, + lastScore: null, status: 'success', }) // Replace when promoting an empty /chat to its attack url (first message); @@ -333,6 +348,9 @@ function App() { attackTarget={readyAttack ? readyAttack.target : null} isLoadingAttack={isLoadingAttack} relatedConversationCount={readyAttack ? readyAttack.relatedConversationIds.length : 0} + objective={readyAttack ? readyAttack.objective : ''} + outcome={readyAttack ? readyAttack.outcome : null} + lastScore={readyAttack ? readyAttack.lastScore : null} /> ) diff --git a/frontend/src/components/Chat/AttackVerdictChip.styles.ts b/frontend/src/components/Chat/AttackVerdictChip.styles.ts new file mode 100644 index 0000000000..6baed2190c --- /dev/null +++ b/frontend/src/components/Chat/AttackVerdictChip.styles.ts @@ -0,0 +1,42 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +export const useAttackVerdictChipStyles = makeStyles({ + chip: { + display: 'inline-flex', + alignItems: 'center', + columnGap: tokens.spacingHorizontalXS, + }, + chipLabel: { + textTransform: 'capitalize', + }, + surface: { + display: 'flex', + flexDirection: 'column', + rowGap: tokens.spacingVerticalXS, + minWidth: '240px', + maxWidth: '360px', + }, + row: { + display: 'flex', + columnGap: tokens.spacingHorizontalS, + }, + rowLabel: { + minWidth: '72px', + color: tokens.colorNeutralForeground2, + }, + rationaleBlock: { + display: 'flex', + flexDirection: 'column', + rowGap: tokens.spacingVerticalXXS, + marginTop: tokens.spacingVerticalXS, + paddingTop: tokens.spacingVerticalXS, + borderTop: `1px solid ${tokens.colorNeutralStroke2}`, + }, + rationaleText: { + color: tokens.colorNeutralForeground2, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + maxHeight: '30vh', + overflowY: 'auto', + }, +}) diff --git a/frontend/src/components/Chat/AttackVerdictChip.test.tsx b/frontend/src/components/Chat/AttackVerdictChip.test.tsx new file mode 100644 index 0000000000..c34b43f9b2 --- /dev/null +++ b/frontend/src/components/Chat/AttackVerdictChip.test.tsx @@ -0,0 +1,62 @@ +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import AttackVerdictChip from './AttackVerdictChip' +import type { ScoreView } from '../../types' + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +const sampleScore: ScoreView = { + id: 'score-1', + scorer_type: 'SelfAskRefusalScorer', + score_type: 'true_false', + score_value: 'true', + score_category: ['refusal'], + score_rationale: 'The model refused the request.', + timestamp: '2026-01-15T11:00:00Z', +} + +describe('AttackVerdictChip', () => { + it('renders nothing when there is no score', () => { + render( + + + + ) + + expect(screen.queryByTestId('attack-score-chip')).not.toBeInTheDocument() + }) + + it('shows only the score value on the chip, labeled "Score"', () => { + render( + + + + ) + + const chip = screen.getByRole('button', { name: /score true/i }) + expect(within(chip).getByText('Score')).toBeInTheDocument() + expect(within(chip).getByText('true')).toBeInTheDocument() + // The outcome word must never appear on the chip itself. + expect(within(chip).queryByText(/failure/i)).not.toBeInTheDocument() + }) + + it('opens a popover with full score details when clicked', async () => { + const user = userEvent.setup() + render( + + + + ) + + await user.click(screen.getByRole('button', { name: /score true/i })) + + const details = await screen.findByTestId('attack-score-details') + expect(within(details).getByText('true_false')).toBeInTheDocument() + expect(within(details).getByText('SelfAskRefusalScorer')).toBeInTheDocument() + expect(within(details).getByText('refusal')).toBeInTheDocument() + expect(within(details).getByText('The model refused the request.')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Chat/AttackVerdictChip.tsx b/frontend/src/components/Chat/AttackVerdictChip.tsx new file mode 100644 index 0000000000..2cd927ebdf --- /dev/null +++ b/frontend/src/components/Chat/AttackVerdictChip.tsx @@ -0,0 +1,86 @@ +import { + Button, + Text, + Badge, + Popover, + PopoverTrigger, + PopoverSurface, +} from '@fluentui/react-components' +import type { AttackOutcome, ScoreView } from '../../types' +import { resolveOutcome } from '../../utils/attackOutcome' +import { getScoreColor } from '../../utils/scoreColor' +import { useAttackVerdictChipStyles } from './AttackVerdictChip.styles' + +interface AttackVerdictChipProps { + outcome?: AttackOutcome | null + score?: ScoreView | null +} + +export default function AttackVerdictChip({ outcome, score }: AttackVerdictChipProps) { + const styles = useAttackVerdictChipStyles() + + // Only the score value is surfaced in the ribbon, so there's nothing to show without a score. + if (!score) return null + + const resolvedOutcome = resolveOutcome(outcome) + const categories = score.score_category?.filter(Boolean) ?? [] + const scoreColor = getScoreColor(score.score_type, score.score_value) + const scoreBadgeStyle = { + backgroundColor: scoreColor.background, + borderColor: scoreColor.background, + color: scoreColor.foreground, + } + + return ( + + + + + +
+ Score +
+ Value + + {score.score_value} + +
+
+ Type + {score.score_type} +
+
+ Scorer + {score.scorer_type} +
+ {categories.length > 0 && ( +
+ Category + {categories.join(', ')} +
+ )} +
+ Outcome + {resolvedOutcome} +
+ {score.score_rationale && ( +
+ Rationale + {score.score_rationale} +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 73d257d8e2..0eed1420d5 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -11,6 +11,8 @@ import ChatInputArea from './ChatInputArea' import ConversationPanel from './ConversationPanel' import ConverterPanel from './ConverterPanel' import TargetBadge from './TargetBadge' +import ObjectiveHeader from './ObjectiveHeader' +import AttackVerdictChip from './AttackVerdictChip' import type { PieceConversion } from './converterTypes' import { PIECE_TYPE_TO_DATA_TYPE, basenameFromValue, buildMediaUrl, dataTypeToAttachmentKind, isPathDataType } from './converterTypes' import LabelsBar from '../Labels/LabelsBar' @@ -18,7 +20,7 @@ import type { ChatInputAreaHandle } from './ChatInputArea' import { attacksApi } from '../../services/api' import { toApiError } from '../../services/errors' import { buildMessagePieces, backendMessagesToFrontend } from '../../utils/messageMapper' -import type { Message, MessageAttachment, TargetInstance, TargetInfo } from '../../types' +import type { Message, MessageAttachment, TargetInstance, TargetInfo, AttackOutcome, ScoreView } from '../../types' import { targetEndpoint, targetModelName, targetType } from '../../utils/targetIdentity' import type { ViewName } from '../Sidebar/Navigation' import { useChatWindowStyles } from './ChatWindow.styles' @@ -42,6 +44,12 @@ interface ChatWindowProps { isLoadingAttack?: boolean /** Number of related (non-main) conversations in the loaded attack. */ relatedConversationCount?: number + /** The loaded attack's objective (empty for new/manual attacks). */ + objective?: string + /** The loaded attack's outcome verdict. */ + outcome?: AttackOutcome | null + /** The loaded attack's final score, if any. */ + lastScore?: ScoreView | null } export default function ChatWindow({ @@ -59,6 +67,9 @@ export default function ChatWindow({ attackTarget, isLoadingAttack, relatedConversationCount, + objective = '', + outcome, + lastScore, }: ChatWindowProps) { const styles = useChatWindowStyles() const [messages, setMessages] = useState([]) @@ -610,6 +621,7 @@ export default function ChatWindow({ )}
+
+ {systemMessage && } = ({ children }) => ( + {children} +) + +// jsdom has no layout engine, so scrollWidth/clientWidth are 0 by default (no overflow). +// Force overflow by overriding the prototype getters for the duration of a test. +function mockOverflow(scrollWidth: number, clientWidth: number) { + Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { configurable: true, get: () => scrollWidth }) + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => clientWidth }) +} + +describe('ObjectiveHeader', () => { + afterEach(() => { + delete (HTMLElement.prototype as { scrollWidth?: number }).scrollWidth + delete (HTMLElement.prototype as { clientWidth?: number }).clientWidth + }) + + it('renders nothing when the objective is empty', () => { + render( + + + + ) + + expect(screen.queryByTestId('objective-header')).not.toBeInTheDocument() + }) + + it('renders the label and the objective text', () => { + render( + + + + ) + + expect(screen.getByText('Objective')).toBeInTheDocument() + expect(screen.getByText('Extract the hidden system prompt')).toBeInTheDocument() + }) + + it('does not render an expand toggle when the objective fits on one line', () => { + render( + + + + ) + + expect(screen.queryByTestId('toggle-objective-header-btn')).not.toBeInTheDocument() + }) + + it('renders a collapsed "Show more" toggle when the objective overflows', () => { + mockOverflow(1000, 200) + render( + + + + ) + + const toggle = screen.getByRole('button', { name: /show more of the objective/i }) + expect(toggle).toHaveTextContent('Show more') + expect(toggle).toHaveAttribute('aria-expanded', 'false') + }) + + it('expands to "Show less" when the overflowing toggle is clicked', async () => { + const user = userEvent.setup() + mockOverflow(1000, 200) + render( + + + + ) + + await user.click(screen.getByRole('button', { name: /show more of the objective/i })) + + const toggle = screen.getByRole('button', { name: /show less of the objective/i }) + expect(toggle).toHaveTextContent('Show less') + expect(toggle).toHaveAttribute('aria-expanded', 'true') + }) +}) diff --git a/frontend/src/components/Chat/ObjectiveHeader.tsx b/frontend/src/components/Chat/ObjectiveHeader.tsx new file mode 100644 index 0000000000..faf031fc0b --- /dev/null +++ b/frontend/src/components/Chat/ObjectiveHeader.tsx @@ -0,0 +1,57 @@ +import { useLayoutEffect, useRef, useState } from 'react' +import { Badge, Button, Text, mergeClasses } from '@fluentui/react-components' +import { ChevronDownRegular, ChevronUpRegular } from '@fluentui/react-icons' +import { useObjectiveHeaderStyles } from './ObjectiveHeader.styles' + +interface ObjectiveHeaderProps { + objective: string +} + +export default function ObjectiveHeader({ objective }: ObjectiveHeaderProps) { + const styles = useObjectiveHeaderStyles() + const [expanded, setExpanded] = useState(false) + const [overflowing, setOverflowing] = useState(false) + const contentRef = useRef(null) + + useLayoutEffect(() => { + const el = contentRef.current + if (!el) return + const measure = () => setOverflowing(el.scrollWidth > el.clientWidth) + measure() + const observer = new ResizeObserver(measure) + observer.observe(el) + return () => observer.disconnect() + }, [objective]) + + if (!objective) return null + + const showToggle = overflowing || expanded + + return ( +
+ Objective + + {objective} + + {showToggle && ( + + )} +
+ ) +} diff --git a/frontend/src/components/History/AttackHistory.test.tsx b/frontend/src/components/History/AttackHistory.test.tsx index 641431ea9d..3c4be0eb2d 100644 --- a/frontend/src/components/History/AttackHistory.test.tsx +++ b/frontend/src/components/History/AttackHistory.test.tsx @@ -28,6 +28,7 @@ const sampleAttacks = [ conversation_id: 'conv-1', attack_type: 'CrescendoAttack', attack_specific_params: null, + objective: 'Extract the hidden system prompt', target: { target_type: 'OpenAIChatTarget', endpoint: 'https://api.openai.com', model_name: 'gpt-4' }, converters: ['Base64Converter'], outcome: 'success' as const, @@ -43,6 +44,7 @@ const sampleAttacks = [ conversation_id: 'conv-2', attack_type: 'ManualAttack', attack_specific_params: null, + objective: 'Bypass the safety filter', target: { target_type: 'OpenAIImageTarget', endpoint: 'https://api.openai.com', model_name: 'dall-e-3' }, converters: [], outcome: 'failure' as const, diff --git a/frontend/src/components/History/AttackTable.test.tsx b/frontend/src/components/History/AttackTable.test.tsx index 03f7ec6063..62dc4e366b 100644 --- a/frontend/src/components/History/AttackTable.test.tsx +++ b/frontend/src/components/History/AttackTable.test.tsx @@ -17,6 +17,7 @@ const sampleAttacks: AttackSummary[] = [ attack_result_id: 'ar-1', conversation_id: 'conv-1', attack_type: 'CrescendoAttack', + objective: 'Extract the hidden system prompt', target: { target_type: 'OpenAIChatTarget', endpoint: 'https://api.openai.com', model_name: 'gpt-4' }, converters: ['Base64Converter', 'ROT13Converter', 'UnicodeConverter'], outcome: 'success', @@ -31,6 +32,7 @@ const sampleAttacks: AttackSummary[] = [ attack_result_id: 'ar-2', conversation_id: 'conv-2', attack_type: 'ManualAttack', + objective: 'Bypass the safety filter', target: null, converters: [], outcome: 'failure', @@ -45,6 +47,7 @@ const sampleAttacks: AttackSummary[] = [ attack_result_id: 'ar-3', conversation_id: 'conv-3', attack_type: 'ManualAttack', + objective: 'Elicit disallowed content', target: { target_type: 'TextTarget', endpoint: null, model_name: null }, converters: [], outcome: undefined, diff --git a/frontend/src/components/History/AttackTable.tsx b/frontend/src/components/History/AttackTable.tsx index 906be3538b..63980c370b 100644 --- a/frontend/src/components/History/AttackTable.tsx +++ b/frontend/src/components/History/AttackTable.tsx @@ -13,28 +13,11 @@ import { } from '@fluentui/react-components' import { OpenRegular, - CheckmarkCircleRegular, - DismissCircleRegular, - QuestionCircleRegular, - ErrorCircleRegular, } from '@fluentui/react-icons' import type { AttackSummary } from '../../types' +import { OUTCOME_ICONS, OUTCOME_COLORS, resolveOutcome } from '../../utils/attackOutcome' import { useAttackHistoryStyles } from './AttackHistory.styles' -const OUTCOME_ICONS: Record = { - success: , - failure: , - error: , - undetermined: , -} - -const OUTCOME_COLORS: Record = { - success: 'success', - failure: 'danger', - error: 'warning', - undetermined: 'informative', -} - interface AttackTableProps { attacks: AttackSummary[] onOpenAttack: (attackResultId: string) => void @@ -82,11 +65,11 @@ export default function AttackTable({ attacks, onOpenAttack, formatDate }: Attac - {attack.outcome ?? 'undetermined'} + {resolveOutcome(attack.outcome)} diff --git a/frontend/src/components/Home/Home.test.tsx b/frontend/src/components/Home/Home.test.tsx index e4776d9421..fe3185cea9 100644 --- a/frontend/src/components/Home/Home.test.tsx +++ b/frontend/src/components/Home/Home.test.tsx @@ -31,6 +31,7 @@ function makeAttack(overrides: Partial = {}): AttackSummary { attack_result_id: "ar-1", conversation_id: "conv-1", attack_type: "TestAttack", + objective: "Test objective", converters: [], outcome: "success", last_message_preview: "preview", diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index eab139d013..98da037f9a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -172,14 +172,30 @@ export interface TargetInfo { model_name?: string | null } +export type AttackOutcome = 'undetermined' | 'success' | 'failure' | 'error' + +// Attack-level verdict score (backend `ScoreView`). Mirrors the display fields +// the API exposes on `AttackSummary.last_score`. +export interface ScoreView { + id: string + scorer_type: string + score_type: string + score_value: string + score_category?: string[] | null + score_rationale?: string | null + timestamp: string +} + export interface AttackSummary { attack_result_id: string conversation_id: string attack_type: string attack_specific_params?: Record | null + objective: string target?: TargetInfo | null converters: string[] - outcome?: 'undetermined' | 'success' | 'failure' | 'error' | null + outcome?: AttackOutcome | null + last_score?: ScoreView | null last_message_preview?: string | null message_count: number related_conversation_ids: string[] diff --git a/frontend/src/utils/attackOutcome.tsx b/frontend/src/utils/attackOutcome.tsx new file mode 100644 index 0000000000..b6a7686f49 --- /dev/null +++ b/frontend/src/utils/attackOutcome.tsx @@ -0,0 +1,29 @@ +import { tokens } from '@fluentui/react-components' +import { + CheckmarkCircleRegular, + DismissCircleRegular, + QuestionCircleRegular, + ErrorCircleRegular, +} from '@fluentui/react-icons' +import type { AttackOutcome } from '../types' + +// Shared presentation for an attack's outcome, used by both the history table +// and the chat verdict chip so the two views stay visually consistent. + +export const OUTCOME_ICONS: Record = { + success: , + failure: , + error: , + undetermined: , +} + +export const OUTCOME_COLORS: Record = { + success: 'success', + failure: 'danger', + error: 'warning', + undetermined: 'informative', +} + +export function resolveOutcome(outcome?: AttackOutcome | null): AttackOutcome { + return outcome ?? 'undetermined' +} diff --git a/frontend/src/utils/scoreColor.test.ts b/frontend/src/utils/scoreColor.test.ts new file mode 100644 index 0000000000..e0e8c4ad64 --- /dev/null +++ b/frontend/src/utils/scoreColor.test.ts @@ -0,0 +1,64 @@ +import { getScoreColor, normalizeScoreValue } from './scoreColor' + +const WHITE = '#ffffff' +const GREY = 'rgb(97, 97, 97)' +const RED = 'rgb(197, 15, 31)' +const GREEN = 'rgb(16, 124, 16)' + +describe('normalizeScoreValue', () => { + it('maps true/false verdicts to the [0, 1] extremes', () => { + expect(normalizeScoreValue('true_false', 'true')).toBe(1) + expect(normalizeScoreValue('true_false', 'false')).toBe(0) + }) + + it('is case- and whitespace-insensitive for true/false', () => { + expect(normalizeScoreValue('true_false', ' TRUE ')).toBe(1) + expect(normalizeScoreValue('true_false', 'False')).toBe(0) + }) + + it('returns null for an uninterpretable true/false value', () => { + expect(normalizeScoreValue('true_false', 'maybe')).toBeNull() + }) + + it('parses float_scale values and clamps them to [0, 1]', () => { + expect(normalizeScoreValue('float_scale', '0')).toBe(0) + expect(normalizeScoreValue('float_scale', '0.75')).toBe(0.75) + expect(normalizeScoreValue('float_scale', '1')).toBe(1) + expect(normalizeScoreValue('float_scale', '1.5')).toBe(1) + expect(normalizeScoreValue('float_scale', '-0.5')).toBe(0) + }) + + it('returns null for non-numeric float_scale values and unknown types', () => { + expect(normalizeScoreValue('float_scale', 'abc')).toBeNull() + expect(normalizeScoreValue('unknown', '0.5')).toBeNull() + }) +}) + +describe('getScoreColor', () => { + it('renders a true verdict as the most saturated green', () => { + expect(getScoreColor('true_false', 'true')).toEqual({ background: GREEN, foreground: WHITE }) + }) + + it('renders a false verdict as the most saturated red', () => { + expect(getScoreColor('true_false', 'false')).toEqual({ background: RED, foreground: WHITE }) + }) + + it('renders the float midpoint (0.5) as neutral grey', () => { + expect(getScoreColor('float_scale', '0.5')).toEqual({ background: GREY, foreground: WHITE }) + }) + + it('renders a float above the midpoint as a muted green', () => { + expect(getScoreColor('float_scale', '0.75')).toEqual({ background: 'rgb(57, 111, 57)', foreground: WHITE }) + }) + + it('renders a float below the midpoint as a muted red', () => { + expect(getScoreColor('float_scale', '0.25')).toEqual({ background: 'rgb(147, 56, 64)', foreground: WHITE }) + }) + + it('falls back to grey for missing or uninterpretable scores', () => { + expect(getScoreColor(null, null)).toEqual({ background: GREY, foreground: WHITE }) + expect(getScoreColor('true_false', null)).toEqual({ background: GREY, foreground: WHITE }) + expect(getScoreColor('unknown', '0.5')).toEqual({ background: GREY, foreground: WHITE }) + expect(getScoreColor('float_scale', 'not-a-number')).toEqual({ background: GREY, foreground: WHITE }) + }) +}) diff --git a/frontend/src/utils/scoreColor.ts b/frontend/src/utils/scoreColor.ts new file mode 100644 index 0000000000..cb152c5880 --- /dev/null +++ b/frontend/src/utils/scoreColor.ts @@ -0,0 +1,70 @@ +// Maps an attack score onto a red -> grey -> green spectrum so its harm level is +// readable at a glance. The score sits on a bipolar harm axis: a "false"/harmless +// verdict is red, a "true"/harmful verdict is green, and the neutral midpoint -- +// or a missing/unparseable score -- is grey. Normalized [0, 1] score values are +// remapped to [-1, 1] so the 0.5 threshold lands exactly on grey and the color +// grows more vivid as the score moves toward either extreme. +// +// A continuous spectrum can't be expressed with the discrete Fluent token +// palette, so the endpoints are fixed RGB values chosen to match Fluent's +// neutral/red/green foreground colors, interpolated at render time. + +export interface ScoreColor { + background: string + foreground: string +} + +type Rgb = readonly [number, number, number] + +const GREY: Rgb = [97, 97, 97] +const RED: Rgb = [197, 15, 31] +const GREEN: Rgb = [16, 124, 16] +const FOREGROUND = '#ffffff' + +function rgb(r: number, g: number, b: number): string { + return `rgb(${r}, ${g}, ${b})` +} + +function lerp(from: number, to: number, t: number): number { + return Math.round(from + (to - from) * t) +} + +const NEUTRAL: ScoreColor = { background: rgb(GREY[0], GREY[1], GREY[2]), foreground: FOREGROUND } + +// Resolves a score's position on a normalized [0, 1] scale, or null when the +// value can't be interpreted (unknown scorer type or a non-numeric value). +export function normalizeScoreValue(scoreType: string, scoreValue: string): number | null { + if (scoreType === 'true_false') { + const normalized = scoreValue.trim().toLowerCase() + if (normalized === 'true') return 1 + if (normalized === 'false') return 0 + return null + } + if (scoreType === 'float_scale') { + const parsed = Number.parseFloat(scoreValue) + if (Number.isNaN(parsed)) return null + return Math.min(1, Math.max(0, parsed)) + } + return null +} + +// Resolves the spectrum color for a score. Absent or unparseable scores render grey. +export function getScoreColor(scoreType?: string | null, scoreValue?: string | null): ScoreColor { + if (!scoreType || scoreValue == null) return NEUTRAL + + const normalized = normalizeScoreValue(scoreType, scoreValue) + if (normalized === null) return NEUTRAL + + const bipolar = normalized * 2 - 1 + const magnitude = Math.abs(bipolar) + const target = bipolar < 0 ? RED : GREEN + + return { + background: rgb( + lerp(GREY[0], target[0], magnitude), + lerp(GREY[1], target[1], magnitude), + lerp(GREY[2], target[2], magnitude) + ), + foreground: FOREGROUND, + } +}