+ {message.scores.map((s) => {
+ const label = `${s.scorer_type}: ${s.score_value}`
+ const tooltipBody = s.score_rationale
+ ? `${label}\n\n${s.score_rationale}`
+ : label
+ return (
+
+
+ {label}
+
+
+ )
+ })}
+
+ )}
+
{timestamp}
{message.role}
diff --git a/frontend/src/components/Chat/ScoreDialog.test.tsx b/frontend/src/components/Chat/ScoreDialog.test.tsx
new file mode 100644
index 0000000000..08711aad71
--- /dev/null
+++ b/frontend/src/components/Chat/ScoreDialog.test.tsx
@@ -0,0 +1,644 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { FluentProvider, webLightTheme } from "@fluentui/react-components";
+import ScoreDialog, { type ScoreTarget } from "./ScoreDialog";
+import { attacksApi, scorersApi } from "../../services/api";
+
+jest.mock("../../services/api", () => ({
+ attacksApi: {
+ scoreConversation: jest.fn(),
+ scoreMessagePiece: jest.fn(),
+ },
+ scorersApi: {
+ listScorers: jest.fn(),
+ createCustomScorer: jest.fn(),
+ updateCustomScorer: jest.fn(),
+ deleteCustomScorer: jest.fn(),
+ },
+}));
+
+const mockedAttacksApi = attacksApi as jest.Mocked
;
+const mockedScorersApi = scorersApi as jest.Mocked;
+
+const TestWrapper: React.FC<{ children: React.ReactNode }> = ({
+ children,
+}) => {children};
+
+const FLOAT_SCORER = {
+ scorer_registry_name: "harm_scorer",
+ scorer_type: "FloatScaleScorer",
+ score_type: "float_scale" as const,
+ tags: ["harm", "best_harm"],
+ description: "Scores how harmful a response is on a 0-1 scale.",
+ uses_objective: false,
+};
+
+const TRUE_FALSE_SCORER = {
+ scorer_registry_name: "refusal_scorer",
+ scorer_type: "TrueFalseScorer",
+ score_type: "true_false" as const,
+ tags: ["refusal"],
+ description: "True if the response is a refusal of the objective.",
+ uses_objective: true,
+};
+
+describe("ScoreDialog", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("does not fetch scorers while closed", () => {
+ render(
+
+
+
+ );
+ expect(mockedScorersApi.listScorers).not.toHaveBeenCalled();
+ });
+
+ it("loads scorers when opened and submits a conversation score", async () => {
+ const user = userEvent.setup();
+ mockedScorersApi.listScorers.mockResolvedValue({
+ items: [FLOAT_SCORER, TRUE_FALSE_SCORER],
+ });
+ mockedAttacksApi.scoreConversation.mockResolvedValue({ scores: [] });
+
+ const onScored = jest.fn();
+ const onClose = jest.fn();
+ const target: ScoreTarget = {
+ kind: "conversation",
+ attackResultId: "ar-1",
+ conversationId: "conv-1",
+ };
+
+ render(
+
+
+
+ );
+
+ await waitFor(() =>
+ expect(mockedScorersApi.listScorers).toHaveBeenCalledTimes(1)
+ );
+
+ const submit = await screen.findByTestId("score-dialog-submit-btn");
+ await user.click(submit);
+
+ await waitFor(() =>
+ expect(mockedAttacksApi.scoreConversation).toHaveBeenCalledWith(
+ "ar-1",
+ "conv-1",
+ {
+ scorer_registry_name: "harm_scorer",
+ mode: "whole_conversation",
+ objective: undefined,
+ }
+ )
+ );
+ expect(onScored).toHaveBeenCalledWith([]);
+ });
+
+ it("submits a per-piece score when target.kind is 'piece'", async () => {
+ const user = userEvent.setup();
+ mockedScorersApi.listScorers.mockResolvedValue({ items: [FLOAT_SCORER] });
+ mockedAttacksApi.scoreMessagePiece.mockResolvedValue({ scores: [] });
+
+ render(
+
+
+
+ );
+
+ const submit = await screen.findByTestId("score-dialog-submit-btn");
+ await user.click(submit);
+
+ await waitFor(() =>
+ expect(mockedAttacksApi.scoreMessagePiece).toHaveBeenCalledWith(
+ "ar-1",
+ "conv-1",
+ "piece-9",
+ { scorer_registry_name: "harm_scorer", objective: undefined }
+ )
+ );
+ expect(mockedAttacksApi.scoreConversation).not.toHaveBeenCalled();
+ });
+
+ it("surfaces submit errors without closing the dialog", async () => {
+ const user = userEvent.setup();
+ mockedScorersApi.listScorers.mockResolvedValue({ items: [FLOAT_SCORER] });
+ mockedAttacksApi.scoreConversation.mockRejectedValue(new Error("boom"));
+
+ const onClose = jest.fn();
+ render(
+
+
+
+ );
+
+ const submit = await screen.findByTestId("score-dialog-submit-btn");
+ await user.click(submit);
+
+ await waitFor(() =>
+ expect(screen.getByTestId("score-dialog-submit-error")).toBeInTheDocument()
+ );
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("shows an empty state when no scorers are registered", async () => {
+ mockedScorersApi.listScorers.mockResolvedValue({ items: [] });
+ render(
+
+
+
+ );
+
+ await waitFor(() =>
+ expect(screen.getByTestId("score-dialog-empty")).toBeInTheDocument()
+ );
+ // Submit must stay disabled when there are no scorers.
+ expect(screen.getByTestId("score-dialog-submit-btn")).toBeDisabled();
+ });
+
+ it("renders the selected scorer's description and tags as info pane", async () => {
+ mockedScorersApi.listScorers.mockResolvedValue({
+ items: [FLOAT_SCORER, TRUE_FALSE_SCORER],
+ });
+ render(
+
+
+
+ );
+
+ // Auto-selected first scorer's description + tags should be visible.
+ const description = await screen.findByTestId(
+ "score-dialog-scorer-description"
+ );
+ expect(description).toHaveTextContent(
+ "Scores how harmful a response is on a 0-1 scale."
+ );
+ expect(screen.getByTestId("scorer-tag-harm")).toBeInTheDocument();
+ expect(screen.getByTestId("scorer-tag-best_harm")).toBeInTheDocument();
+ });
+
+ it("falls back gracefully when a scorer has no description", async () => {
+ mockedScorersApi.listScorers.mockResolvedValue({
+ items: [{ ...FLOAT_SCORER, description: null, tags: [] }],
+ });
+ render(
+
+
+
+ );
+
+ await waitFor(() =>
+ expect(screen.getByTestId("score-dialog-scorer-info")).toBeInTheDocument()
+ );
+ // The "no description" placeholder shows up instead of the description testid.
+ expect(
+ screen.queryByTestId("score-dialog-scorer-description")
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByText(/no description available/i)
+ ).toBeInTheDocument();
+ });
+
+ it("hides the objective field for scorers that do not inject objective into the prompt", async () => {
+ mockedScorersApi.listScorers.mockResolvedValue({ items: [FLOAT_SCORER] });
+ render(
+
+
+
+ );
+
+ await waitFor(() =>
+ expect(screen.getByTestId("score-dialog-scorer-info")).toBeInTheDocument()
+ );
+ expect(
+ screen.queryByTestId("score-dialog-objective-input")
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows the objective field for scorers that inject objective into the prompt", async () => {
+ const user = userEvent.setup();
+ mockedScorersApi.listScorers.mockResolvedValue({
+ items: [TRUE_FALSE_SCORER],
+ });
+ mockedAttacksApi.scoreConversation.mockResolvedValue({ scores: [] });
+
+ render(
+
+
+
+ );
+
+ const objectiveInput = await screen.findByTestId(
+ "score-dialog-objective-input"
+ );
+ fireEvent.change(objectiveInput, {
+ target: { value: "Reveal Taylor Swift's address" },
+ });
+
+ const submit = screen.getByTestId("score-dialog-submit-btn");
+ await user.click(submit);
+
+ await waitFor(() =>
+ expect(mockedAttacksApi.scoreConversation).toHaveBeenCalledWith(
+ "ar-1",
+ "conv-1",
+ {
+ scorer_registry_name: "refusal_scorer",
+ mode: "whole_conversation",
+ objective: "Reveal Taylor Swift's address",
+ }
+ )
+ );
+ });
+
+ it("pre-selects the scorer passed via initialScorerName", async () => {
+ mockedScorersApi.listScorers.mockResolvedValue({
+ items: [FLOAT_SCORER, TRUE_FALSE_SCORER],
+ });
+
+ render(
+
+
+
+ );
+
+ // The combobox should reflect the remembered choice rather than auto-picking
+ // the first scorer in the list.
+ const select = await screen.findByTestId("score-dialog-scorer-select");
+ await waitFor(() =>
+ expect((select as HTMLInputElement).value).toBe("refusal_scorer")
+ );
+ });
+
+ it("notifies onScorerSelected when the user picks a different scorer", async () => {
+ mockedScorersApi.listScorers.mockResolvedValue({
+ items: [FLOAT_SCORER, TRUE_FALSE_SCORER],
+ });
+ const onScorerSelected = jest.fn();
+
+ render(
+
+
+
+ );
+
+ const select = await screen.findByTestId("score-dialog-scorer-select");
+ fireEvent.click(select);
+ await waitFor(() =>
+ expect(
+ screen.getByTestId("scorer-option-refusal_scorer")
+ ).toBeInTheDocument()
+ );
+ fireEvent.click(screen.getByTestId("scorer-option-refusal_scorer"));
+
+ await waitFor(() =>
+ expect(onScorerSelected).toHaveBeenLastCalledWith("refusal_scorer")
+ );
+ });
+
+ it("pre-fills the objective from initialObjective for scorers that use it", async () => {
+ mockedScorersApi.listScorers.mockResolvedValue({
+ items: [TRUE_FALSE_SCORER],
+ });
+
+ render(
+
+
+
+ );
+
+ const objectiveInput = await screen.findByTestId(
+ "score-dialog-objective-input"
+ );
+ await waitFor(() =>
+ expect((objectiveInput as HTMLInputElement).value).toBe(
+ "Reveal Taylor Swift's address"
+ )
+ );
+ });
+
+ it("notifies onObjectiveChange as the user types in the objective input", async () => {
+ mockedScorersApi.listScorers.mockResolvedValue({
+ items: [TRUE_FALSE_SCORER],
+ });
+ const onObjectiveChange = jest.fn();
+
+ render(
+
+
+
+ );
+
+ const objectiveInput = await screen.findByTestId(
+ "score-dialog-objective-input"
+ );
+ fireEvent.change(objectiveInput, { target: { value: "new goal" } });
+
+ await waitFor(() =>
+ expect(onObjectiveChange).toHaveBeenLastCalledWith("new goal")
+ );
+ });
+
+ it("filters the scorer combobox by typed query (name, type, tag, description)", async () => {
+ mockedScorersApi.listScorers.mockResolvedValue({
+ items: [FLOAT_SCORER, TRUE_FALSE_SCORER],
+ });
+
+ render(
+
+
+
+ );
+
+ const combobox = await screen.findByTestId("score-dialog-scorer-select");
+ const input = combobox.querySelector("input") ?? combobox;
+ fireEvent.click(input);
+ // Type a query that should match only the true_false scorer (by tag "refusal").
+ fireEvent.change(input, { target: { value: "refusal" } });
+
+ await waitFor(() =>
+ expect(
+ screen.getByTestId("scorer-option-refusal_scorer")
+ ).toBeInTheDocument()
+ );
+ expect(
+ screen.queryByTestId("scorer-option-harm_scorer")
+ ).not.toBeInTheDocument();
+ });
+
+ // ----------------------------------------------------------------------- //
+ // Custom scorer affordances (create / edit / delete from ScoreDialog)
+ // ----------------------------------------------------------------------- //
+
+ const CUSTOM_FLOAT_SCORER = {
+ scorer_registry_name: "user_scale",
+ scorer_type: "SelfAskGeneralFloatScaleScorer",
+ score_type: "float_scale" as const,
+ tags: [],
+ description: "User-created scale scorer.",
+ uses_objective: false,
+ editable: true,
+ custom_config: {
+ kind: "general_float_scale" as const,
+ system_prompt_format_string: "Score it.",
+ prompt_format_string: null,
+ category: null,
+ min_value: 0,
+ max_value: 10,
+ },
+ };
+
+ it("opens the custom scorer dialog from the 'New custom scorer' button", async () => {
+ mockedScorersApi.listScorers.mockResolvedValue({
+ items: [FLOAT_SCORER],
+ });
+
+ render(
+
+
+
+ );
+
+ const createBtn = await screen.findByTestId("score-dialog-create-custom-btn");
+ fireEvent.click(createBtn);
+
+ expect(await screen.findByText("Create custom scorer")).toBeInTheDocument();
+ });
+
+ it("shows Edit/Delete only for editable scorers", async () => {
+ mockedScorersApi.listScorers.mockResolvedValue({
+ items: [FLOAT_SCORER, CUSTOM_FLOAT_SCORER],
+ });
+
+ render(
+
+
+
+ );
+
+ // Built-in selected: no edit/delete affordances.
+ await waitFor(() =>
+ expect(screen.getByTestId("score-dialog-scorer-info")).toBeInTheDocument()
+ );
+ expect(
+ screen.queryByTestId("score-dialog-edit-custom-btn")
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("score-dialog-delete-custom-btn")
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows Edit and Delete buttons for an editable scorer", async () => {
+ mockedScorersApi.listScorers.mockResolvedValue({
+ items: [CUSTOM_FLOAT_SCORER],
+ });
+
+ render(
+
+
+
+ );
+
+ await waitFor(() =>
+ expect(screen.getByTestId("score-dialog-edit-custom-btn")).toBeInTheDocument()
+ );
+ expect(screen.getByTestId("score-dialog-delete-custom-btn")).toBeInTheDocument();
+ expect(screen.getByTestId("scorer-tag-custom")).toBeInTheDocument();
+ });
+
+ it("calls deleteCustomScorer after confirming delete", async () => {
+ mockedScorersApi.listScorers
+ .mockResolvedValueOnce({ items: [CUSTOM_FLOAT_SCORER] })
+ .mockResolvedValueOnce({ items: [] });
+ mockedScorersApi.deleteCustomScorer.mockResolvedValue(undefined);
+ const confirmSpy = jest.spyOn(window, "confirm").mockReturnValue(true);
+
+ render(
+
+
+
+ );
+
+ const deleteBtn = await screen.findByTestId("score-dialog-delete-custom-btn");
+ fireEvent.click(deleteBtn);
+
+ await waitFor(() =>
+ expect(mockedScorersApi.deleteCustomScorer).toHaveBeenCalledWith("user_scale")
+ );
+ expect(mockedScorersApi.listScorers).toHaveBeenCalledTimes(2);
+ confirmSpy.mockRestore();
+ });
+
+ it("aborts delete when the user cancels the confirm", async () => {
+ mockedScorersApi.listScorers.mockResolvedValue({ items: [CUSTOM_FLOAT_SCORER] });
+ const confirmSpy = jest.spyOn(window, "confirm").mockReturnValue(false);
+
+ render(
+
+
+
+ );
+
+ const deleteBtn = await screen.findByTestId("score-dialog-delete-custom-btn");
+ fireEvent.click(deleteBtn);
+
+ await waitFor(() => expect(confirmSpy).toHaveBeenCalled());
+ expect(mockedScorersApi.deleteCustomScorer).not.toHaveBeenCalled();
+ confirmSpy.mockRestore();
+ });
+});
diff --git a/frontend/src/components/Chat/ScoreDialog.tsx b/frontend/src/components/Chat/ScoreDialog.tsx
new file mode 100644
index 0000000000..c1a3095c86
--- /dev/null
+++ b/frontend/src/components/Chat/ScoreDialog.tsx
@@ -0,0 +1,621 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import {
+ Dialog,
+ DialogSurface,
+ DialogBody,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Button,
+ Field,
+ Combobox,
+ Option,
+ OptionGroup,
+ Radio,
+ RadioGroup,
+ Input,
+ Spinner,
+ Badge,
+ Text,
+ Tooltip,
+ MessageBar,
+ MessageBarBody,
+ tokens,
+} from '@fluentui/react-components'
+import {
+ AddRegular,
+ DeleteRegular,
+ EditRegular,
+ InfoRegular,
+} from '@fluentui/react-icons'
+
+import { attacksApi, scorersApi } from '../../services/api'
+import { toApiError } from '../../services/errors'
+import type {
+ BackendScore,
+ ScoreConversationMode,
+ ScorerSummary,
+} from '../../types'
+import CustomScorerDialog from './CustomScorerDialog'
+
+type ScoreTarget =
+ | { kind: 'conversation'; attackResultId: string; conversationId: string }
+ | { kind: 'piece'; attackResultId: string; conversationId: string; pieceId: string }
+
+interface ScoreDialogProps {
+ open: boolean
+ target: ScoreTarget | null
+ onClose: () => void
+ /** Called after a successful score so the caller can refetch messages/conversations. */
+ onScored: (scores: BackendScore[]) => void
+ /**
+ * Scorer to pre-select when the dialog opens. The caller (e.g. ChatWindow)
+ * remembers the most recently chosen scorer per conversation so re-opening
+ * the dialog doesn't lose the user's prior pick.
+ */
+ initialScorerName?: string
+ /**
+ * Fired when the user picks a scorer in the combobox. The caller persists
+ * it so the next dialog open can pre-select the same scorer.
+ */
+ onScorerSelected?: (scorerRegistryName: string) => void
+ /**
+ * Objective text to pre-fill when the dialog opens. The caller remembers
+ * the most recently typed objective per conversation. Only honored for
+ * scorers that actually inject the objective into the scoring prompt.
+ */
+ initialObjective?: string
+ /**
+ * Fired whenever the user edits the objective input. The caller persists
+ * it so the next dialog open can pre-fill the same value.
+ */
+ onObjectiveChange?: (value: string) => void
+}
+
+const MODE_LABELS: Record = {
+ last_message: 'Score last assistant message only',
+ whole_conversation: 'Score the whole conversation (wraps in ConversationScorer)',
+}
+
+const SCORE_TYPE_LABELS: Record = {
+ true_false: 'True / False scorers',
+ float_scale: 'Float-scale scorers',
+ unknown: 'Other scorers',
+}
+
+const SCORE_TYPE_ORDER = ['true_false', 'float_scale', 'unknown']
+
+/** Group scorers by their `score_type` so the dropdown can render s. */
+function groupScorers(scorers: ScorerSummary[]): { score_type: string; items: ScorerSummary[] }[] {
+ const groups = new Map()
+ for (const s of scorers) {
+ const key = s.score_type || 'unknown'
+ const existing = groups.get(key)
+ if (existing) {
+ existing.push(s)
+ } else {
+ groups.set(key, [s])
+ }
+ }
+ const ordered: { score_type: string; items: ScorerSummary[] }[] = []
+ for (const key of SCORE_TYPE_ORDER) {
+ const items = groups.get(key)
+ if (items) ordered.push({ score_type: key, items })
+ }
+ // Append any unexpected score_types we don't have a label for, alphabetically.
+ for (const [key, items] of Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b))) {
+ if (!SCORE_TYPE_ORDER.includes(key)) {
+ ordered.push({ score_type: key, items })
+ }
+ }
+ return ordered
+}
+
+export default function ScoreDialog({
+ open,
+ target,
+ onClose,
+ onScored,
+ initialScorerName,
+ onScorerSelected,
+ initialObjective,
+ onObjectiveChange,
+}: ScoreDialogProps) {
+ const [scorers, setScorers] = useState([])
+ const [loadingScorers, setLoadingScorers] = useState(false)
+ const [loadError, setLoadError] = useState(null)
+ const [selectedScorerName, setSelectedScorerName] = useState('')
+ const [scorerQuery, setScorerQuery] = useState('')
+ const [mode, setMode] = useState('whole_conversation')
+ const [objective, setObjective] = useState('')
+ const [submitting, setSubmitting] = useState(false)
+ const [submitError, setSubmitError] = useState(null)
+ const [customDialogOpen, setCustomDialogOpen] = useState(false)
+ const [editingScorer, setEditingScorer] = useState(null)
+ const [deletingName, setDeletingName] = useState(null)
+
+ const isConversationScope = target?.kind === 'conversation'
+
+ // Snapshot of initialScorerName read at open time only. Using a ref means
+ // the reset effect below doesn't re-fire (and wipe user edits) every time
+ // the parent updates the cached scorer name as the user picks options.
+ const initialScorerNameRef = useRef(initialScorerName)
+ const initialObjectiveRef = useRef(initialObjective)
+ useEffect(() => {
+ initialScorerNameRef.current = initialScorerName
+ initialObjectiveRef.current = initialObjective
+ })
+
+ // Reset form whenever the dialog re-opens against a new target.
+ useEffect(() => {
+ if (!open) return
+ const seed = initialScorerNameRef.current ?? ''
+ setSelectedScorerName(seed)
+ setScorerQuery(seed)
+ setMode('whole_conversation')
+ setObjective(initialObjectiveRef.current ?? '')
+ setSubmitError(null)
+ }, [open, target])
+
+ // Fetch scorers when the dialog opens; cheap enough to refetch each time so
+ // newly-registered scorers show up without a manual refresh.
+ const fetchScorers = (preserveSelection?: string) => {
+ setLoadingScorers(true)
+ setLoadError(null)
+ return scorersApi
+ .listScorers()
+ .then((response) => {
+ setScorers(response.items)
+ if (response.items.length > 0) {
+ const wantedName =
+ preserveSelection &&
+ response.items.some((s) => s.scorer_registry_name === preserveSelection)
+ ? preserveSelection
+ : null
+ setSelectedScorerName((current) => {
+ if (wantedName) return wantedName
+ return current && response.items.some((s) => s.scorer_registry_name === current)
+ ? current
+ : response.items[0].scorer_registry_name
+ })
+ setScorerQuery((current) => {
+ if (wantedName) return wantedName
+ return current || response.items[0].scorer_registry_name
+ })
+ } else {
+ setSelectedScorerName('')
+ setScorerQuery('')
+ }
+ return response
+ })
+ .catch((err) => {
+ setLoadError(toApiError(err).detail)
+ throw err
+ })
+ .finally(() => setLoadingScorers(false))
+ }
+
+ useEffect(() => {
+ if (!open) return
+ let cancelled = false
+ setLoadingScorers(true)
+ setLoadError(null)
+ scorersApi
+ .listScorers()
+ .then((response) => {
+ if (cancelled) return
+ setScorers(response.items)
+ if (response.items.length > 0) {
+ const first = response.items[0]
+ setSelectedScorerName((current) =>
+ current && response.items.some((s) => s.scorer_registry_name === current)
+ ? current
+ : first.scorer_registry_name
+ )
+ setScorerQuery((current) => current || first.scorer_registry_name)
+ }
+ })
+ .catch((err) => {
+ if (cancelled) return
+ setLoadError(toApiError(err).detail)
+ })
+ .finally(() => {
+ if (!cancelled) setLoadingScorers(false)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [open])
+
+ const filteredScorers = useMemo(() => {
+ const q = scorerQuery.trim().toLowerCase()
+ if (!q) return scorers
+ // Don't filter while the input still shows the already-selected scorer's
+ // name — otherwise opening the dropdown right after picking would only
+ // ever show that one option, breaking "click then browse" UX.
+ if (selectedScorerName && q === selectedScorerName.toLowerCase()) return scorers
+ return scorers.filter((s) => {
+ const haystack = [
+ s.scorer_registry_name,
+ s.scorer_type,
+ s.description ?? '',
+ ...(s.tags ?? []),
+ ]
+ .join(' ')
+ .toLowerCase()
+ return haystack.includes(q)
+ })
+ }, [scorers, scorerQuery, selectedScorerName])
+
+ const groupedScorers = useMemo(() => groupScorers(filteredScorers), [filteredScorers])
+ const hasNoMatches = scorers.length > 0 && filteredScorers.length === 0
+ const selectedScorer = useMemo(
+ () => scorers.find((s) => s.scorer_registry_name === selectedScorerName) ?? null,
+ [scorers, selectedScorerName]
+ )
+
+ // Whole-conversation scoring requires a FloatScale or TrueFalse scorer (the
+ // ConversationScorer wrapper rejects others). Disable the mode and explain why
+ // rather than letting the request fail server-side.
+ const wholeConversationDisabled =
+ selectedScorer != null && selectedScorer.score_type !== 'true_false' && selectedScorer.score_type !== 'float_scale'
+
+ // If the user previously picked whole_conversation and then switched to an
+ // unsupported scorer, snap the mode back to last_message so they can't submit
+ // a request the backend will reject.
+ useEffect(() => {
+ if (wholeConversationDisabled && mode === 'whole_conversation') {
+ setMode('last_message')
+ }
+ }, [wholeConversationDisabled, mode])
+
+ // Most scorers don't actually inject the objective into the scoring prompt
+ // (it's only attached to the resulting Score row as metadata). We hide the
+ // input for those scorers but keep the typed value in state so that toggling
+ // back to an injecting scorer (or persisting via onObjectiveChange) doesn't
+ // wipe what the user typed. Submission also gates on this flag so a hidden
+ // stale value can never reach the backend.
+ const scorerUsesObjective = selectedScorer?.uses_objective === true
+
+ const handleSubmit = async () => {
+ if (!target || !selectedScorerName) return
+ setSubmitting(true)
+ setSubmitError(null)
+ try {
+ const trimmedObjective = scorerUsesObjective ? objective.trim() || undefined : undefined
+ if (target.kind === 'conversation') {
+ const response = await attacksApi.scoreConversation(
+ target.attackResultId,
+ target.conversationId,
+ {
+ scorer_registry_name: selectedScorerName,
+ mode,
+ objective: trimmedObjective,
+ }
+ )
+ onScored(response.scores)
+ } else {
+ const response = await attacksApi.scoreMessagePiece(
+ target.attackResultId,
+ target.conversationId,
+ target.pieceId,
+ {
+ scorer_registry_name: selectedScorerName,
+ objective: trimmedObjective,
+ }
+ )
+ onScored(response.scores)
+ }
+ onClose()
+ } catch (err) {
+ setSubmitError(toApiError(err).detail)
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const handleOpenCreate = () => {
+ setEditingScorer(null)
+ setCustomDialogOpen(true)
+ }
+
+ const handleOpenEdit = (scorer: ScorerSummary) => {
+ setEditingScorer(scorer)
+ setCustomDialogOpen(true)
+ }
+
+ const handleCustomSaved = async (summary: ScorerSummary) => {
+ setCustomDialogOpen(false)
+ setEditingScorer(null)
+ try {
+ await fetchScorers(summary.scorer_registry_name)
+ onScorerSelected?.(summary.scorer_registry_name)
+ } catch {
+ // fetchScorers already surfaced the error in loadError.
+ }
+ }
+
+ const handleDelete = async (scorer: ScorerSummary) => {
+ const confirmed = window.confirm(
+ `Delete custom scorer "${scorer.scorer_registry_name}"? Existing Score rows are preserved.`
+ )
+ if (!confirmed) return
+ setDeletingName(scorer.scorer_registry_name)
+ setSubmitError(null)
+ try {
+ await scorersApi.deleteCustomScorer(scorer.scorer_registry_name)
+ await fetchScorers()
+ } catch (err) {
+ setSubmitError(toApiError(err).detail)
+ } finally {
+ setDeletingName(null)
+ }
+ }
+
+ return (
+ <>
+
+ {
+ setCustomDialogOpen(false)
+ setEditingScorer(null)
+ }}
+ onSaved={handleCustomSaved}
+ />
+ >
+ )
+}
+
+export type { ScoreTarget, ScoreDialogProps }
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 3c04828cb0..ba3c4fc430 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -20,6 +20,13 @@ import type {
CreateConversationRequest,
CreateConversationResponse,
ChangeMainConversationResponse,
+ ScorerListResponse,
+ ScoreConversationRequest,
+ ScoreMessageRequest,
+ ScoreResponse,
+ CreateCustomScorerRequest,
+ UpdateCustomScorerRequest,
+ CustomScorerResponse,
} from '../types'
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
@@ -277,6 +284,58 @@ export const attacksApi = {
const response = await apiClient.get('/attacks/converter-options')
return response.data
},
+
+ scoreConversation: async (
+ attackResultId: string,
+ conversationId: string,
+ request: ScoreConversationRequest
+ ): Promise => {
+ const response = await apiClient.post(
+ `/attacks/${encodeURIComponent(attackResultId)}/conversations/${encodeURIComponent(conversationId)}/scores`,
+ request
+ )
+ return response.data
+ },
+
+ scoreMessagePiece: async (
+ attackResultId: string,
+ conversationId: string,
+ pieceId: string,
+ request: ScoreMessageRequest
+ ): Promise => {
+ const response = await apiClient.post(
+ `/attacks/${encodeURIComponent(attackResultId)}/conversations/${encodeURIComponent(conversationId)}/pieces/${encodeURIComponent(pieceId)}/scores`,
+ request
+ )
+ return response.data
+ },
+}
+
+export const scorersApi = {
+ listScorers: async (): Promise => {
+ const response = await apiClient.get('/scorers')
+ return response.data
+ },
+
+ createCustomScorer: async (request: CreateCustomScorerRequest): Promise => {
+ const response = await apiClient.post('/scorers/custom', request)
+ return response.data
+ },
+
+ updateCustomScorer: async (
+ scorerId: string,
+ request: UpdateCustomScorerRequest
+ ): Promise => {
+ const response = await apiClient.put(
+ `/scorers/custom/${encodeURIComponent(scorerId)}`,
+ request
+ )
+ return response.data
+ },
+
+ deleteCustomScorer: async (scorerId: string): Promise => {
+ await apiClient.delete(`/scorers/custom/${encodeURIComponent(scorerId)}`)
+ },
}
export const labelsApi = {
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 1c6dcc283e..0219899158 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -38,6 +38,14 @@ export interface Message {
originalContent?: string
/** Original media attachments before conversion (when different from converted). */
originalAttachments?: MessageAttachment[]
+ /**
+ * Backend piece ID of the first piece in this message. Preserved so the
+ * GUI can target a specific piece (e.g. for per-message scoring) without
+ * extending Message to carry every individual piece's id.
+ */
+ pieceId?: string
+ /** Aggregated scores across all pieces in this message. */
+ scores?: BackendScore[]
}
export interface MessageError {
@@ -273,3 +281,89 @@ export interface ChangeMainConversationResponse {
attack_result_id: string
conversation_id: string
}
+
+// --- Scoring ---
+
+export type ScorerScoreType = 'true_false' | 'float_scale' | 'unknown'
+
+export interface ScorerSummary {
+ scorer_registry_name: string
+ scorer_type: string
+ score_type: ScorerScoreType
+ description?: string | null
+ tags?: string[]
+ uses_objective?: boolean
+ editable?: boolean
+ custom_config?: CustomScorerConfig | null
+}
+
+export interface ScorerListResponse {
+ items: ScorerSummary[]
+}
+
+export type ScoreConversationMode = 'last_message' | 'whole_conversation'
+
+export interface ScoreConversationRequest {
+ scorer_registry_name: string
+ mode?: ScoreConversationMode
+ objective?: string
+}
+
+export interface ScoreMessageRequest {
+ scorer_registry_name: string
+ objective?: string
+}
+
+export interface ScoreResponse {
+ scores: BackendScore[]
+}
+
+// --- Custom (user-created) scorers ---
+
+export type CustomScorerKind =
+ | 'general_float_scale'
+ | 'general_true_false'
+ | 'threshold_wrapper'
+
+export type TrueFalseAggregator = 'OR' | 'AND' | 'MAJORITY'
+
+export interface GeneralFloatScaleConfig {
+ kind: 'general_float_scale'
+ system_prompt_format_string: string
+ prompt_format_string?: string | null
+ category?: string | null
+ min_value: number
+ max_value: number
+}
+
+export interface GeneralTrueFalseConfig {
+ kind: 'general_true_false'
+ system_prompt_format_string: string
+ prompt_format_string?: string | null
+ category?: string | null
+ score_aggregator: TrueFalseAggregator
+}
+
+export interface ThresholdWrapperConfig {
+ kind: 'threshold_wrapper'
+ wrapped_scorer_registry_name: string
+ threshold: number
+}
+
+export type CustomScorerConfig =
+ | GeneralFloatScaleConfig
+ | GeneralTrueFalseConfig
+ | ThresholdWrapperConfig
+
+export interface CreateCustomScorerRequest {
+ name: string
+ config: CustomScorerConfig
+}
+
+export interface UpdateCustomScorerRequest {
+ config: CustomScorerConfig
+}
+
+export interface CustomScorerResponse {
+ summary: ScorerSummary
+}
diff --git a/frontend/src/utils/messageMapper.ts b/frontend/src/utils/messageMapper.ts
index 703aca0b4a..c4deea8c86 100644
--- a/frontend/src/utils/messageMapper.ts
+++ b/frontend/src/utils/messageMapper.ts
@@ -244,6 +244,11 @@ export function backendMessageToFrontend(msg: BackendMessage): Message {
reasoningSummaries: reasoningSummaries.length > 0 ? reasoningSummaries : undefined,
originalContent: hasTextDiff ? originalContent : undefined,
originalAttachments: hasMediaDiff ? originalAttachments : undefined,
+ pieceId: msg.pieces[0]?.piece_id,
+ scores: (() => {
+ const allScores = msg.pieces.flatMap((p) => p.scores ?? [])
+ return allScores.length > 0 ? allScores : undefined
+ })(),
}
}
diff --git a/pyrit/backend/main.py b/pyrit/backend/main.py
index c2c2f477cf..76ea7db8bf 100644
--- a/pyrit/backend/main.py
+++ b/pyrit/backend/main.py
@@ -27,6 +27,7 @@
labels,
media,
scenarios,
+ scoring,
targets,
version,
)
@@ -123,6 +124,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app.include_router(health.router, prefix="/api", tags=["health"])
app.include_router(auth.router, prefix="/api", tags=["auth"])
app.include_router(media.router, prefix="/api", tags=["media"])
+app.include_router(scoring.scorers_router, prefix="/api", tags=["scorers"])
+app.include_router(scoring.attack_scoring_router, prefix="/api", tags=["scorers"])
app.include_router(version.router, tags=["version"])
diff --git a/pyrit/backend/models/scoring.py b/pyrit/backend/models/scoring.py
new file mode 100644
index 0000000000..2a6d65f446
--- /dev/null
+++ b/pyrit/backend/models/scoring.py
@@ -0,0 +1,217 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""
+Scoring request/response models.
+
+DTOs for the on-demand scoring surface exposed under ``/api/scorers`` and
+``/api/attacks/{id}/conversations/{cid}/scores``. Distinct from the planned
+read-only scorer-introspection surface (eval metrics, etc.) — this file only
+covers the inputs and outputs needed to *invoke* a registered scorer.
+"""
+
+from typing import Annotated, Literal
+
+from pydantic import BaseModel, Field
+
+from pyrit.backend.models.attacks import Score
+
+__all__ = [
+ "ScorerSummary",
+ "ScorerListResponse",
+ "ScoreConversationMode",
+ "ScoreConversationRequest",
+ "ScoreMessageRequest",
+ "ScoreResponse",
+ "CustomScorerKind",
+ "GeneralFloatScaleConfig",
+ "GeneralTrueFalseConfig",
+ "ThresholdWrapperConfig",
+ "CustomScorerConfig",
+ "CreateCustomScorerRequest",
+ "UpdateCustomScorerRequest",
+ "CustomScorerResponse",
+]
+
+
+ScoreConversationMode = Literal["last_message", "whole_conversation"]
+CustomScorerKind = Literal["general_float_scale", "general_true_false", "threshold_wrapper"]
+TrueFalseAggregator = Literal["OR", "AND", "MAJORITY"]
+
+
+class GeneralFloatScaleConfig(BaseModel):
+ """Form-driven config for a ``SelfAskGeneralFloatScaleScorer`` instance."""
+
+ kind: Literal["general_float_scale"] = "general_float_scale"
+ system_prompt_format_string: str = Field(
+ ...,
+ min_length=1,
+ description=(
+ "System prompt template. Placeholders: {objective}, {prompt}, {message_piece}. "
+ "Must instruct the LLM to reply with JSON containing 'score_value' (numeric in "
+ "[min_value, max_value]) and 'rationale'."
+ ),
+ )
+ prompt_format_string: str | None = Field(
+ None,
+ description="Optional user-prompt template with the same placeholders.",
+ )
+ category: str | None = Field(
+ None, description="Category label applied to resulting Score rows when the LLM omits one."
+ )
+ min_value: int = Field(0, description="Minimum of the LLM's native scale.")
+ max_value: int = Field(100, description="Maximum of the LLM's native scale; must be > min_value.")
+
+
+class GeneralTrueFalseConfig(BaseModel):
+ """Form-driven config for a ``SelfAskGeneralTrueFalseScorer`` instance."""
+
+ kind: Literal["general_true_false"] = "general_true_false"
+ system_prompt_format_string: str = Field(
+ ...,
+ min_length=1,
+ description=(
+ "System prompt template. Placeholders: {objective}, {task}, {prompt}, {message_piece}. "
+ "Must instruct the LLM to reply with JSON containing 'score_value' ('true'/'false') "
+ "and 'rationale'."
+ ),
+ )
+ prompt_format_string: str | None = Field(
+ None, description="Optional user-prompt template with the same placeholders."
+ )
+ category: str | None = Field(
+ None, description="Category label applied to resulting Score rows when the LLM omits one."
+ )
+ score_aggregator: TrueFalseAggregator = Field(
+ "OR",
+ description="How to combine multiple bool scores when the scorer runs more than one trial.",
+ )
+
+
+class ThresholdWrapperConfig(BaseModel):
+ """Form-driven config for a ``FloatScaleThresholdScorer`` wrapping an existing float scorer."""
+
+ kind: Literal["threshold_wrapper"] = "threshold_wrapper"
+ wrapped_scorer_registry_name: str = Field(
+ ...,
+ min_length=1,
+ description="Registry name of the float-scale scorer to wrap.",
+ )
+ threshold: float = Field(..., ge=0.0, le=1.0, description="Cut-off in [0, 1]. Scores >= threshold map to True.")
+
+
+CustomScorerConfig = Annotated[
+ GeneralFloatScaleConfig | GeneralTrueFalseConfig | ThresholdWrapperConfig,
+ Field(discriminator="kind"),
+]
+
+
+class ScorerSummary(BaseModel):
+ """Minimal scorer entry used to populate the scoring dialog."""
+
+ scorer_registry_name: str = Field(..., description="Registry name of the scorer instance")
+ scorer_type: str = Field(..., description="Scorer class name (e.g., 'SelfAskRefusalScorer')")
+ score_type: str = Field(..., description="Score shape: 'true_false', 'float_scale', or 'unknown'")
+ description: str | None = Field(
+ None,
+ description=(
+ "First paragraph of the scorer class docstring. Surfaces in the GUI as an info pane so users "
+ "can see what each scorer does without leaving the dialog."
+ ),
+ )
+ tags: list[str] = Field(
+ default_factory=list,
+ description="Registry tags (e.g. 'refusal', 'best_refusal'). Used in the GUI for grouping/badges.",
+ )
+ uses_objective: bool = Field(
+ False,
+ description=(
+ "True if this scorer injects the caller-supplied objective into its scoring prompt so the "
+ "judge LLM is conditioned on it. When False, the objective is only stored on the resulting "
+ "Score row as metadata and has no effect on the scorer's verdict. Read off "
+ "``Scorer.uses_objective``. The GUI hides the objective input for scorers where this is False."
+ ),
+ )
+ editable: bool = Field(
+ False,
+ description=(
+ "True for user-created scorers that can be edited or deleted via the custom-scorer API. "
+ "Built-in (initializer-registered) scorers are always False."
+ ),
+ )
+ custom_config: CustomScorerConfig | None = Field(
+ None,
+ description=(
+ "When ``editable`` is True, the original form config used to build this scorer. Returned so "
+ "the GUI can pre-fill the edit dialog. Null for built-in scorers."
+ ),
+ )
+
+
+class ScorerListResponse(BaseModel):
+ """Response listing every registered scorer."""
+
+ items: list[ScorerSummary] = Field(..., description="Registered scorers in registry-name order")
+
+
+class ScoreConversationRequest(BaseModel):
+ """Request to score a conversation with a registered scorer."""
+
+ scorer_registry_name: str = Field(..., description="Registry name of the scorer to invoke")
+ mode: ScoreConversationMode = Field(
+ "last_message",
+ description=(
+ "'last_message' scores only the most recent assistant message; "
+ "'whole_conversation' wraps the scorer in a ConversationScorer and scores the full transcript."
+ ),
+ )
+ objective: str | None = Field(
+ None, description="Optional objective to pass to the scorer (only used by objective scorers)"
+ )
+
+
+class ScoreMessageRequest(BaseModel):
+ """Request to score a single message piece with a registered scorer."""
+
+ scorer_registry_name: str = Field(..., description="Registry name of the scorer to invoke")
+ objective: str | None = Field(
+ None, description="Optional objective to pass to the scorer (only used by objective scorers)"
+ )
+
+
+class ScoreResponse(BaseModel):
+ """Response containing the scores produced by an on-demand scoring call."""
+
+ scores: list[Score] = Field(default_factory=list, description="Scores produced by the scorer")
+
+
+class CreateCustomScorerRequest(BaseModel):
+ """Request to instantiate and register a new user-defined scorer."""
+
+ name: str = Field(
+ ...,
+ min_length=1,
+ max_length=128,
+ pattern=r"^[a-zA-Z0-9_\-]+$",
+ description=(
+ "Registry name for the new scorer (alphanumeric, dash, underscore). Must not collide "
+ "with an existing scorer."
+ ),
+ )
+ config: CustomScorerConfig = Field(..., description="Type-discriminated scorer config.")
+
+
+class UpdateCustomScorerRequest(BaseModel):
+ """
+ Request to replace the config of an existing user-defined scorer.
+
+ The registry name does not change; only the underlying ``config`` is rebuilt.
+ """
+
+ config: CustomScorerConfig = Field(..., description="Replacement type-discriminated scorer config.")
+
+
+class CustomScorerResponse(BaseModel):
+ """Response returned after create/update of a user-defined scorer."""
+
+ summary: ScorerSummary = Field(..., description="Fresh summary of the (re)registered scorer.")
diff --git a/pyrit/backend/routes/scoring.py b/pyrit/backend/routes/scoring.py
new file mode 100644
index 0000000000..c8dd97a02d
--- /dev/null
+++ b/pyrit/backend/routes/scoring.py
@@ -0,0 +1,243 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""
+On-demand scoring routes.
+
+Surfaces two related endpoints:
+
+* ``GET /scorers`` — minimal list of registered scorer instances for the GUI dropdown.
+* ``POST /attacks/{attack_result_id}/conversations/{conversation_id}/scores`` — score
+ either the last assistant message in a conversation or the whole conversation
+ (the latter wraps the chosen scorer in a ``ConversationScorer``).
+* ``POST /attacks/{attack_result_id}/conversations/{conversation_id}/pieces/{piece_id}/scores``
+ — score a single message piece.
+
+All scoring is delegated to ``ScoringService``, which itself calls ``Scorer.score_async``
+so the resulting scores are persisted in PyRIT memory and surfaced automatically by
+``GET /attacks/{id}/messages`` on the next refresh.
+"""
+
+import logging
+
+from fastapi import APIRouter, HTTPException, status
+
+from pyrit.backend.models.common import ProblemDetail
+from pyrit.backend.models.scoring import (
+ CreateCustomScorerRequest,
+ CustomScorerResponse,
+ ScoreConversationRequest,
+ ScoreMessageRequest,
+ ScoreResponse,
+ ScorerListResponse,
+ UpdateCustomScorerRequest,
+)
+from pyrit.backend.services.scoring_service import get_scoring_service
+
+logger = logging.getLogger(__name__)
+
+scorers_router = APIRouter(prefix="/scorers", tags=["scorers"])
+attack_scoring_router = APIRouter(prefix="/attacks", tags=["attacks"])
+
+
+@scorers_router.get(
+ "",
+ response_model=ScorerListResponse,
+)
+async def list_scorers() -> ScorerListResponse: # pyrit-async-suffix-exempt
+ """
+ List every registered scorer instance.
+
+ Returns:
+ ScorerListResponse: Registered scorers in registry-name order.
+ """
+ service = get_scoring_service()
+ return await service.list_scorers_async()
+
+
+@scorers_router.post(
+ "/custom",
+ response_model=CustomScorerResponse,
+ status_code=status.HTTP_201_CREATED,
+ responses={
+ 400: {"model": ProblemDetail, "description": "Invalid config or duplicate name"},
+ },
+)
+async def create_custom_scorer( # pyrit-async-suffix-exempt
+ request: CreateCustomScorerRequest,
+) -> CustomScorerResponse:
+ """
+ Create and register a user-defined scorer (general float-scale, general true/false,
+ or threshold-wrapper).
+
+ Returns:
+ CustomScorerResponse: Summary of the newly registered scorer.
+ """
+ service = get_scoring_service()
+ try:
+ return await service.create_custom_scorer_async(request=request)
+ except ValueError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
+ except Exception as e:
+ logger.exception("Failed to create custom scorer '%s'", request.name)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Internal server error. Check server logs for details.",
+ ) from e
+
+
+@scorers_router.put(
+ "/custom/{scorer_id}",
+ response_model=CustomScorerResponse,
+ responses={
+ 400: {"model": ProblemDetail, "description": "Invalid config or scorer not editable"},
+ 404: {"model": ProblemDetail, "description": "Scorer not found"},
+ },
+)
+async def update_custom_scorer( # pyrit-async-suffix-exempt
+ scorer_id: str,
+ request: UpdateCustomScorerRequest,
+) -> CustomScorerResponse:
+ """
+ Replace the config of an existing user-defined scorer.
+
+ Past Score rows are preserved untouched — only future scoring calls use the new config.
+
+ Returns:
+ CustomScorerResponse: Summary of the re-registered scorer.
+ """
+ service = get_scoring_service()
+ try:
+ return await service.update_custom_scorer_async(scorer_id=scorer_id, request=request)
+ except ValueError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
+ except Exception as e:
+ logger.exception("Failed to update custom scorer '%s'", scorer_id)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Internal server error. Check server logs for details.",
+ ) from e
+
+
+@scorers_router.delete(
+ "/custom/{scorer_id}",
+ status_code=status.HTTP_204_NO_CONTENT,
+ responses={
+ 400: {"model": ProblemDetail, "description": "Scorer not editable (built-in)"},
+ },
+)
+async def delete_custom_scorer(scorer_id: str) -> None: # pyrit-async-suffix-exempt
+ """
+ Remove a user-defined scorer from the registry.
+
+ Built-in scorers cannot be deleted via this endpoint.
+ """
+ service = get_scoring_service()
+ try:
+ await service.delete_custom_scorer_async(scorer_id=scorer_id)
+ except ValueError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
+ except Exception as e:
+ logger.exception("Failed to delete custom scorer '%s'", scorer_id)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Internal server error. Check server logs for details.",
+ ) from e
+
+
+@attack_scoring_router.post(
+ "/{attack_result_id}/conversations/{conversation_id}/scores",
+ response_model=ScoreResponse,
+ status_code=status.HTTP_201_CREATED,
+ responses={
+ 400: {"model": ProblemDetail, "description": "Invalid scoring request"},
+ 404: {"model": ProblemDetail, "description": "Attack, conversation, or scorer not found"},
+ },
+)
+async def score_conversation( # pyrit-async-suffix-exempt
+ attack_result_id: str,
+ conversation_id: str,
+ request: ScoreConversationRequest,
+) -> ScoreResponse:
+ """
+ Score a conversation belonging to an attack with a registered scorer.
+
+ Args:
+ attack_result_id (str): The AttackResult primary key.
+ conversation_id (str): The conversation to score (must belong to the attack).
+ request (ScoreConversationRequest): Scorer name, mode, and optional objective.
+
+ Returns:
+ ScoreResponse: The scores produced by the scorer (also persisted to memory).
+ """
+ service = get_scoring_service()
+
+ try:
+ return await service.score_conversation_async(
+ attack_result_id=attack_result_id,
+ conversation_id=conversation_id,
+ request=request,
+ )
+ except LookupError as e:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e
+ except ValueError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
+ except Exception as e:
+ logger.exception("Failed to score conversation '%s' on attack '%s'", conversation_id, attack_result_id)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Internal server error. Check server logs for details.",
+ ) from e
+
+
+@attack_scoring_router.post(
+ "/{attack_result_id}/conversations/{conversation_id}/pieces/{piece_id}/scores",
+ response_model=ScoreResponse,
+ status_code=status.HTTP_201_CREATED,
+ responses={
+ 400: {"model": ProblemDetail, "description": "Invalid scoring request"},
+ 404: {"model": ProblemDetail, "description": "Attack, conversation, piece, or scorer not found"},
+ },
+)
+async def score_message_piece( # pyrit-async-suffix-exempt
+ attack_result_id: str,
+ conversation_id: str,
+ piece_id: str,
+ request: ScoreMessageRequest,
+) -> ScoreResponse:
+ """
+ Score a single message piece with a registered scorer.
+
+ Args:
+ attack_result_id (str): The AttackResult primary key.
+ conversation_id (str): The conversation containing the piece.
+ piece_id (str): The message-piece id to score.
+ request (ScoreMessageRequest): Scorer name and optional objective.
+
+ Returns:
+ ScoreResponse: The scores produced by the scorer (also persisted to memory).
+ """
+ service = get_scoring_service()
+
+ try:
+ return await service.score_message_async(
+ attack_result_id=attack_result_id,
+ conversation_id=conversation_id,
+ piece_id=piece_id,
+ request=request,
+ )
+ except LookupError as e:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e
+ except ValueError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
+ except Exception as e:
+ logger.exception(
+ "Failed to score piece '%s' on conversation '%s' (attack '%s')",
+ piece_id,
+ conversation_id,
+ attack_result_id,
+ )
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Internal server error. Check server logs for details.",
+ ) from e
diff --git a/pyrit/backend/services/scoring_service.py b/pyrit/backend/services/scoring_service.py
new file mode 100644
index 0000000000..6d31b0ba00
--- /dev/null
+++ b/pyrit/backend/services/scoring_service.py
@@ -0,0 +1,537 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""
+Scoring service for invoking registered scorers on demand.
+
+This service is the thin glue between the REST surface and ``Scorer.score_async``:
+
+* ``list_scorers_async`` enumerates ``ScorerRegistry`` so the GUI can populate a dropdown.
+* ``score_conversation_async`` resolves a scorer by registry name and applies it to either
+ the last assistant message in a conversation or the whole concatenated transcript
+ (via ``create_conversation_scorer``).
+* ``score_message_async`` scores a single message piece in a conversation.
+
+All scoring runs through ``Scorer.score_async`` which persists scores to memory, so a
+subsequent ``GET /attacks/{id}/messages`` call will surface the new scores on the
+``BackendMessagePiece.scores`` field with no additional work here.
+"""
+
+from __future__ import annotations
+
+import logging
+from functools import lru_cache
+from typing import TYPE_CHECKING
+
+from pyrit.backend.mappers import pyrit_scores_to_dto
+from pyrit.backend.models.attacks import Score
+from pyrit.backend.models.scoring import (
+ CreateCustomScorerRequest,
+ CustomScorerConfig,
+ CustomScorerResponse,
+ GeneralFloatScaleConfig,
+ GeneralTrueFalseConfig,
+ ScoreConversationMode,
+ ScoreConversationRequest,
+ ScoreMessageRequest,
+ ScoreResponse,
+ ScorerListResponse,
+ ScorerSummary,
+ ThresholdWrapperConfig,
+ UpdateCustomScorerRequest,
+)
+from pyrit.memory import CentralMemory
+from pyrit.registry import ScorerRegistry
+
+if TYPE_CHECKING:
+ from pyrit.models import Message
+ from pyrit.prompt_target import PromptTarget
+ from pyrit.score.scorer import Scorer
+
+logger = logging.getLogger(__name__)
+
+
+# ----------------------------------------------------------------------
+# Custom (user-created) scorer state
+# ----------------------------------------------------------------------
+# Holds the original CreateCustomScorerRequest.config for every scorer
+# registered through the custom-scorer endpoints. Used (a) to mark scorers
+# as editable in ``list_scorers_async``, (b) to return the seed values for
+# the edit dialog, and (c) as the gating check that a scorer is allowed to
+# be updated or deleted via the custom-scorer API.
+#
+# Module-level (process-scoped); does NOT survive backend restart — matches
+# how converter instances behave today.
+_CUSTOM_SCORER_CONFIGS: dict[str, CustomScorerConfig] = {}
+
+# Preferred default chat target names for self-ask custom scorers, in priority
+# order. Mirrors what the built-in initializers use (``GPT4O_TEMP9_TARGET``)
+# so user-created custom scorers behave the same as the bundled ones.
+_DEFAULT_TARGET_PREFERENCES: tuple[str, ...] = (
+ "azure_openai_gpt4o_temp9",
+ "azure_openai_gpt4o",
+)
+
+
+def _is_chat_capable(target: object) -> bool:
+ """
+ Return True if ``target`` exposes the chat-completion surface that self-ask scorers need.
+
+ Uses duck typing instead of an ``isinstance(target, PromptChatTarget)`` check because
+ the self-ask scorers' own type annotation is ``chat_target: PromptTarget`` and several
+ widely-used chat targets (``OpenAIChatTarget``, ``RoundRobinTarget`` wrapping chat
+ targets) inherit from ``PromptTarget`` rather than ``PromptChatTarget``.
+
+ Returns:
+ bool: True if ``target`` has both ``set_system_prompt`` and ``send_prompt_async``.
+ """
+ return hasattr(target, "set_system_prompt") and callable(getattr(target, "send_prompt_async", None))
+
+
+def _prefer_round_robin(target: PromptTarget, target_registry) -> PromptTarget:
+ """
+ Return the auto-grouped ``RoundRobinTarget`` wrapping ``target`` if one is registered.
+
+ Mirrors ``ScorerInitializer._get_chat_target_prefer_rr`` so user-created custom
+ scorers benefit from the same rate-limit distribution that built-in scorers do.
+ Falls back to ``target`` unchanged when no round-robin wrapper exists, when the
+ initializer helpers cannot be imported, or when the lookup itself fails.
+
+ Returns:
+ PromptTarget: The wrapping round-robin target if present, otherwise ``target``.
+ """
+ try:
+ from pyrit.setup.initializers.components.targets import generate_rr_name, get_behavioral_key
+ except ImportError:
+ return target
+
+ try:
+ rr_name = generate_rr_name(get_behavioral_key(target))
+ except Exception: # noqa: BLE001 — defensive fallback; behavioral key is best-effort
+ return target
+
+ rr_target = target_registry.get(rr_name)
+ if rr_target is not None:
+ return rr_target
+ return target
+
+
+def _extract_class_description(cls: type) -> str | None:
+ """
+ Extract the first paragraph of a class docstring as a short human-readable description.
+
+ Matches the convention used by ``ConverterService.list_converter_catalog_async`` so the
+ UI can render scorer and converter info consistently.
+ """
+ raw_doc = (cls.__doc__ or "").strip()
+ if not raw_doc:
+ return None
+ first_paragraph = raw_doc.split("\n\n")[0]
+ cleaned = " ".join(line.strip() for line in first_paragraph.splitlines() if line.strip())
+ return cleaned or None
+
+
+class ScoringService:
+ """
+ Service that surfaces registered scorers and runs them against stored conversations.
+
+ Scoring writes to memory via ``Scorer.score_async``, so callers do not need to
+ persist the returned ``Score`` DTOs themselves.
+ """
+
+ def __init__(self) -> None:
+ """Initialize the scoring service."""
+ self._memory = CentralMemory.get_memory_instance()
+ self._registry = ScorerRegistry.get_registry_singleton()
+
+ async def list_scorers_async(self) -> ScorerListResponse: # pyrit-async-suffix-exempt
+ """
+ Enumerate every registered scorer (registry name, class, score type, description, tags).
+
+ Returns:
+ ScorerListResponse: Registered scorers in registry-name order.
+ """
+ items = [
+ ScorerSummary(
+ scorer_registry_name=entry.name,
+ scorer_type=entry.instance.__class__.__name__,
+ score_type=entry.instance.scorer_type,
+ description=_extract_class_description(entry.instance.__class__),
+ tags=sorted(entry.tags.keys()) if entry.tags else [],
+ uses_objective=bool(entry.instance.uses_objective),
+ editable=entry.name in _CUSTOM_SCORER_CONFIGS,
+ custom_config=_CUSTOM_SCORER_CONFIGS.get(entry.name),
+ )
+ for entry in self._registry.get_all_instances()
+ ]
+ return ScorerListResponse(items=items)
+
+ async def score_conversation_async(
+ self,
+ *,
+ attack_result_id: str,
+ conversation_id: str,
+ request: ScoreConversationRequest,
+ ) -> ScoreResponse:
+ """
+ Score a conversation belonging to an attack with a registered scorer.
+
+ Args:
+ attack_result_id (str): The AttackResult primary key (used to verify existence).
+ conversation_id (str): The conversation to score (must belong to the attack).
+ request (ScoreConversationRequest): Scorer name, mode, and optional objective.
+
+ Returns:
+ ScoreResponse: The scores produced by the scorer (also persisted to memory).
+
+ Raises:
+ LookupError: If the attack does not exist.
+ ValueError: If the conversation does not belong to the attack, the conversation
+ has no scoreable assistant message, or the scorer registry name is unknown.
+ """
+ self._verify_conversation_belongs_to_attack(attack_result_id=attack_result_id, conversation_id=conversation_id)
+
+ scorer = self._resolve_scorer(request.scorer_registry_name)
+ conversation = list(self._memory.get_conversation(conversation_id=conversation_id))
+
+ if not conversation:
+ raise ValueError(f"Conversation '{conversation_id}' has no messages to score")
+
+ target_message = self._select_message_for_scoring(conversation=conversation, mode=request.mode)
+ effective_scorer = self._maybe_wrap_for_conversation_scoring(scorer=scorer, mode=request.mode)
+
+ scores = await effective_scorer.score_async(message=target_message, objective=request.objective)
+ return ScoreResponse(scores=pyrit_scores_to_dto(list(scores)))
+
+ async def score_message_async(
+ self,
+ *,
+ attack_result_id: str,
+ conversation_id: str,
+ piece_id: str,
+ request: ScoreMessageRequest,
+ ) -> ScoreResponse:
+ """
+ Score a single message piece in a conversation with a registered scorer.
+
+ Args:
+ attack_result_id (str): The AttackResult primary key (used to verify existence).
+ conversation_id (str): The conversation containing the piece.
+ piece_id (str): The message-piece id to score.
+ request (ScoreMessageRequest): Scorer name and optional objective.
+
+ Returns:
+ ScoreResponse: The scores produced by the scorer (also persisted to memory).
+
+ Raises:
+ LookupError: If the attack does not exist, or the piece is not in the conversation.
+ ValueError: If the conversation does not belong to the attack or the scorer is unknown.
+ """
+ self._verify_conversation_belongs_to_attack(attack_result_id=attack_result_id, conversation_id=conversation_id)
+
+ scorer = self._resolve_scorer(request.scorer_registry_name)
+ conversation = list(self._memory.get_conversation(conversation_id=conversation_id))
+
+ target_message = self._find_message_containing_piece(conversation=conversation, piece_id=piece_id)
+ if target_message is None:
+ raise LookupError(f"Message piece '{piece_id}' is not part of conversation '{conversation_id}'")
+
+ scores = await scorer.score_async(message=target_message, objective=request.objective)
+ return ScoreResponse(scores=pyrit_scores_to_dto(list(scores)))
+
+ # ------------------------------------------------------------------
+ # Custom (user-created) scorers
+ # ------------------------------------------------------------------
+
+ async def create_custom_scorer_async(self, *, request: CreateCustomScorerRequest) -> CustomScorerResponse:
+ """
+ Instantiate a user-defined scorer and register it under ``request.name``.
+
+ Args:
+ request (CreateCustomScorerRequest): The new scorer's name + form config.
+
+ Returns:
+ CustomScorerResponse: Fresh ``ScorerSummary`` for the newly registered scorer.
+
+ Raises:
+ ValueError: If a scorer with the same name is already registered, the config
+ references an unknown wrapped scorer, or no default chat target is available.
+ """
+ if request.name in self._registry:
+ raise ValueError(f"Scorer '{request.name}' is already registered")
+
+ scorer = self._build_custom_scorer(config=request.config)
+ self._registry.register_instance(scorer, name=request.name)
+ _CUSTOM_SCORER_CONFIGS[request.name] = request.config
+ logger.info("Registered custom scorer '%s' (%s)", request.name, type(scorer).__name__)
+ return CustomScorerResponse(summary=self._summarize_one(request.name))
+
+ async def update_custom_scorer_async(
+ self, *, scorer_id: str, request: UpdateCustomScorerRequest
+ ) -> CustomScorerResponse:
+ """
+ Replace the underlying instance of an existing user-defined scorer.
+
+ The registry name (``scorer_id``) is preserved so existing references in the GUI
+ continue to work. Past ``Score`` rows are left untouched — only future scoring
+ calls use the new config.
+
+ Args:
+ scorer_id (str): The registry name of the scorer to update.
+ request (UpdateCustomScorerRequest): The replacement config.
+
+ Returns:
+ CustomScorerResponse: Fresh ``ScorerSummary`` for the re-registered scorer.
+
+ Raises:
+ ValueError: If ``scorer_id`` is not a user-created scorer, or the new config
+ references an unknown wrapped scorer.
+ """
+ if scorer_id not in _CUSTOM_SCORER_CONFIGS:
+ raise ValueError(f"Scorer '{scorer_id}' is not a user-created scorer and cannot be edited")
+
+ scorer = self._build_custom_scorer(config=request.config)
+ # ``register_instance`` overwrites the existing entry under the same key, so the
+ # registry name is preserved across the swap.
+ self._registry.register_instance(scorer, name=scorer_id)
+ _CUSTOM_SCORER_CONFIGS[scorer_id] = request.config
+ logger.info("Updated custom scorer '%s' (%s)", scorer_id, type(scorer).__name__)
+ return CustomScorerResponse(summary=self._summarize_one(scorer_id))
+
+ async def delete_custom_scorer_async(self, *, scorer_id: str) -> None:
+ """
+ Remove a user-defined scorer from the registry.
+
+ Args:
+ scorer_id (str): The registry name of the scorer to delete.
+
+ Raises:
+ ValueError: If ``scorer_id`` is not a user-created scorer.
+ """
+ if scorer_id not in _CUSTOM_SCORER_CONFIGS:
+ raise ValueError(f"Scorer '{scorer_id}' is not a user-created scorer and cannot be deleted")
+
+ # No public unregister method on the base registry — pop the underlying dict
+ # entry directly. Keeps parity with how converters delete (none today) and avoids
+ # an API surface change just for this feature.
+ self._registry._registry_items.pop(scorer_id, None)
+ self._registry._metadata_cache = None
+ _CUSTOM_SCORER_CONFIGS.pop(scorer_id, None)
+ logger.info("Deleted custom scorer '%s'", scorer_id)
+
+ def _summarize_one(self, scorer_registry_name: str) -> ScorerSummary:
+ """
+ Build a ``ScorerSummary`` for a single registered scorer by name.
+
+ Returns:
+ ScorerSummary: Summary populated from the registry entry.
+
+ Raises:
+ LookupError: If the scorer is not registered.
+ """
+ for entry in self._registry.get_all_instances():
+ if entry.name != scorer_registry_name:
+ continue
+ return ScorerSummary(
+ scorer_registry_name=entry.name,
+ scorer_type=entry.instance.__class__.__name__,
+ score_type=entry.instance.scorer_type,
+ description=_extract_class_description(entry.instance.__class__),
+ tags=sorted(entry.tags.keys()) if entry.tags else [],
+ uses_objective=bool(entry.instance.uses_objective),
+ editable=entry.name in _CUSTOM_SCORER_CONFIGS,
+ custom_config=_CUSTOM_SCORER_CONFIGS.get(entry.name),
+ )
+ raise LookupError(f"Scorer '{scorer_registry_name}' is not registered")
+
+ def _build_custom_scorer(self, *, config: CustomScorerConfig) -> Scorer:
+ """
+ Construct a concrete ``Scorer`` instance from a form-driven config.
+
+ Self-ask scorers receive a fixed default chat target resolved via
+ ``_get_default_chat_target`` — users cannot pick the judge model from the GUI.
+
+ Returns:
+ Scorer: The constructed scorer instance ready to register.
+
+ Raises:
+ ValueError: If the config is malformed (e.g. max_value <= min_value), the
+ wrapped scorer is missing, or the wrapped scorer is not a FloatScaleScorer.
+ """
+ # Local imports keep ``pyrit.backend.services.scoring_service`` cheap to import
+ # at app startup; the score subpackage is heavy.
+ from pyrit.score.float_scale.float_scale_scorer import FloatScaleScorer
+ from pyrit.score.float_scale.self_ask_general_float_scale_scorer import (
+ SelfAskGeneralFloatScaleScorer,
+ )
+ from pyrit.score.true_false.float_scale_threshold_scorer import FloatScaleThresholdScorer
+ from pyrit.score.true_false.self_ask_general_true_false_scorer import (
+ SelfAskGeneralTrueFalseScorer,
+ )
+ from pyrit.score.true_false.true_false_score_aggregator import TrueFalseScoreAggregator
+
+ if isinstance(config, GeneralFloatScaleConfig):
+ if config.max_value <= config.min_value:
+ raise ValueError("max_value must be strictly greater than min_value")
+ return SelfAskGeneralFloatScaleScorer(
+ chat_target=self._get_default_chat_target(),
+ system_prompt_format_string=config.system_prompt_format_string,
+ prompt_format_string=config.prompt_format_string,
+ category=config.category,
+ min_value=config.min_value,
+ max_value=config.max_value,
+ )
+
+ if isinstance(config, GeneralTrueFalseConfig):
+ aggregator = getattr(TrueFalseScoreAggregator, config.score_aggregator)
+ return SelfAskGeneralTrueFalseScorer(
+ chat_target=self._get_default_chat_target(),
+ system_prompt_format_string=config.system_prompt_format_string,
+ prompt_format_string=config.prompt_format_string,
+ category=config.category,
+ score_aggregator=aggregator,
+ )
+
+ if isinstance(config, ThresholdWrapperConfig):
+ wrapped = self._registry.get(config.wrapped_scorer_registry_name)
+ if wrapped is None:
+ raise ValueError(f"Wrapped scorer '{config.wrapped_scorer_registry_name}' is not registered")
+ if not isinstance(wrapped, FloatScaleScorer):
+ raise ValueError(
+ f"Wrapped scorer '{config.wrapped_scorer_registry_name}' is a "
+ f"{type(wrapped).__name__}; FloatScaleThresholdScorer requires a FloatScaleScorer"
+ )
+ return FloatScaleThresholdScorer(scorer=wrapped, threshold=config.threshold)
+
+ raise ValueError(f"Unsupported custom scorer config: {type(config).__name__}")
+
+ @staticmethod
+ def _get_default_chat_target() -> PromptTarget:
+ """
+ Resolve the chat target used by every self-ask custom scorer.
+
+ Tries the preferred target names from ``_DEFAULT_TARGET_PREFERENCES`` in order
+ (matching what the built-in scorer initializers use). When a preferred target is
+ found, prefers the auto-grouped ``RoundRobinTarget`` that wraps it, matching the
+ behavior of ``ScorerInitializer._get_chat_target_prefer_rr``. Falls back to the
+ first registered chat-capable target if none of the preferred names exist.
+
+ Returns:
+ PromptTarget: A registered chat-capable ``PromptTarget`` instance.
+
+ Raises:
+ ValueError: If no chat-capable target is registered.
+ """
+ from pyrit.prompt_target import PromptTarget as _PromptTarget
+ from pyrit.registry import TargetRegistry
+
+ target_registry = TargetRegistry.get_registry_singleton()
+
+ for preferred_name in _DEFAULT_TARGET_PREFERENCES:
+ candidate = target_registry.get(preferred_name)
+ if candidate is None or not _is_chat_capable(candidate):
+ continue
+ return _prefer_round_robin(candidate, target_registry)
+
+ for entry in target_registry.get_all_instances():
+ instance = entry.instance
+ if isinstance(instance, _PromptTarget) and _is_chat_capable(instance):
+ return instance
+
+ raise ValueError(
+ "No chat-capable PromptTarget is registered; cannot create a self-ask custom scorer. "
+ "Register a chat target via your ~/.pyrit/.pyrit_conf initializer first."
+ )
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ def _verify_conversation_belongs_to_attack(self, *, attack_result_id: str, conversation_id: str) -> None:
+ """
+ Raise ``LookupError`` if the attack does not exist, ``ValueError`` if the
+ conversation does not belong to it.
+ """
+ results = self._memory.get_attack_results(attack_result_ids=[attack_result_id])
+ if not results:
+ raise LookupError(f"Attack '{attack_result_id}' not found")
+ if conversation_id not in results[0].get_active_conversation_ids():
+ raise ValueError(f"Conversation '{conversation_id}' is not part of attack '{attack_result_id}'")
+
+ def _resolve_scorer(self, scorer_registry_name: str) -> Scorer:
+ """Resolve a scorer by registry name; raise ``ValueError`` when missing."""
+ scorer = self._registry.get(scorer_registry_name)
+ if scorer is None:
+ raise ValueError(f"Scorer '{scorer_registry_name}' is not registered")
+ return scorer
+
+ @staticmethod
+ def _select_message_for_scoring(*, conversation: list[Message], mode: ScoreConversationMode) -> Message:
+ """
+ Pick the message to hand to ``Scorer.score_async``.
+
+ For ``last_message`` we score only the most recent assistant turn so the result
+ is comparable to a per-message score. For ``whole_conversation`` we just pick the
+ last message in the conversation — the ``ConversationScorer`` wrapper uses its
+ ``conversation_id`` to fetch the full transcript from memory.
+ """
+ if mode == "whole_conversation":
+ return conversation[-1]
+
+ # last_message: find the most recent assistant (or simulated assistant) turn.
+ for message in reversed(conversation):
+ if message.message_pieces and message.message_pieces[0].role in (
+ "assistant",
+ "simulated_assistant",
+ ):
+ return message
+ raise ValueError("Conversation has no assistant message to score")
+
+ @staticmethod
+ def _maybe_wrap_for_conversation_scoring(*, scorer: Scorer, mode: ScoreConversationMode) -> Scorer:
+ """
+ Wrap the scorer in a ``ConversationScorer`` when the caller asked for
+ whole-conversation scoring. Raises ``ValueError`` if the scorer cannot be wrapped
+ (i.e. it isn't a ``FloatScaleScorer`` or ``TrueFalseScorer``).
+ """
+ if mode != "whole_conversation":
+ return scorer
+
+ from pyrit.score.conversation_scorer import create_conversation_scorer
+ from pyrit.score.float_scale.float_scale_scorer import FloatScaleScorer
+ from pyrit.score.true_false.true_false_scorer import TrueFalseScorer
+
+ if not isinstance(scorer, (FloatScaleScorer, TrueFalseScorer)):
+ raise ValueError(
+ "Whole-conversation scoring requires a FloatScaleScorer or TrueFalseScorer; "
+ f"got {type(scorer).__name__}"
+ )
+ return create_conversation_scorer(scorer=scorer)
+
+ @staticmethod
+ def _find_message_containing_piece(*, conversation: list[Message], piece_id: str) -> Message | None:
+ """Return the message in ``conversation`` whose pieces include ``piece_id``."""
+ for message in conversation:
+ for piece in message.message_pieces:
+ if str(piece.id) == piece_id:
+ return message
+ return None
+
+
+# ============================================================================
+# Singleton
+# ============================================================================
+
+
+@lru_cache(maxsize=1)
+def get_scoring_service() -> ScoringService:
+ """
+ Get the global scoring service instance.
+
+ Returns:
+ ScoringService: The singleton ``ScoringService`` instance.
+ """
+ return ScoringService()
+
+
+__all__ = ["ScoringService", "get_scoring_service", "Score"]
diff --git a/pyrit/score/conversation_scorer.py b/pyrit/score/conversation_scorer.py
index d921b2e1cf..9246178913 100644
--- a/pyrit/score/conversation_scorer.py
+++ b/pyrit/score/conversation_scorer.py
@@ -155,6 +155,11 @@ def validate_return_scores(self, scores: list[Score]) -> None:
wrapped_scorer = self._get_wrapped_scorer()
wrapped_scorer.validate_return_scores(scores)
+ @property
+ def uses_objective(self) -> bool: # type: ignore[ty:invalid-overload]
+ """Delegate to the wrapped scorer so the GUI's objective gating reflects the inner scorer."""
+ return self._get_wrapped_scorer().uses_objective
+
def create_conversation_scorer(
*,
diff --git a/pyrit/score/float_scale/audio_float_scale_scorer.py b/pyrit/score/float_scale/audio_float_scale_scorer.py
index 17653c9d5f..65fa7d67c7 100644
--- a/pyrit/score/float_scale/audio_float_scale_scorer.py
+++ b/pyrit/score/float_scale/audio_float_scale_scorer.py
@@ -73,3 +73,8 @@ async def _score_piece_async(self, message_piece: MessagePiece, *, objective: st
List of scores from evaluating the transcribed audio.
"""
return await self._audio_helper._score_audio_async(message_piece=message_piece, objective=objective)
+
+ @property
+ def uses_objective(self) -> bool: # type: ignore[ty:invalid-overload]
+ """Delegate to the wrapped text scorer."""
+ return self._audio_helper.text_scorer.uses_objective
diff --git a/pyrit/score/float_scale/self_ask_general_float_scale_scorer.py b/pyrit/score/float_scale/self_ask_general_float_scale_scorer.py
index 17105defb9..368ef12fdf 100644
--- a/pyrit/score/float_scale/self_ask_general_float_scale_scorer.py
+++ b/pyrit/score/float_scale/self_ask_general_float_scale_scorer.py
@@ -25,6 +25,7 @@ class SelfAskGeneralFloatScaleScorer(FloatScaleScorer):
is_objective_required=True,
)
TARGET_REQUIREMENTS = CHAT_TARGET_REQUIREMENTS
+ uses_objective: bool = True
def __init__(
self,
diff --git a/pyrit/score/float_scale/self_ask_scale_scorer.py b/pyrit/score/float_scale/self_ask_scale_scorer.py
index 92db37a06a..6492714ef8 100644
--- a/pyrit/score/float_scale/self_ask_scale_scorer.py
+++ b/pyrit/score/float_scale/self_ask_scale_scorer.py
@@ -39,6 +39,7 @@ class SystemPaths(enum.Enum):
is_objective_required=True,
)
TARGET_REQUIREMENTS = CHAT_TARGET_REQUIREMENTS
+ uses_objective: bool = True
def __init__(
self,
diff --git a/pyrit/score/float_scale/video_float_scale_scorer.py b/pyrit/score/float_scale/video_float_scale_scorer.py
index 8e32bd9064..fc77640bd5 100644
--- a/pyrit/score/float_scale/video_float_scale_scorer.py
+++ b/pyrit/score/float_scale/video_float_scale_scorer.py
@@ -116,6 +116,19 @@ def _build_identifier(self) -> ComponentIdentifier:
},
)
+ @property
+ def uses_objective(self) -> bool: # type: ignore[ty:invalid-overload]
+ """True if either sub-scorer uses the objective AND its template enables objective flow-through."""
+ image_uses = (
+ self._video_helper.image_objective_template is not None and self._video_helper.image_scorer.uses_objective
+ )
+ audio_uses = (
+ self.audio_scorer is not None
+ and self._video_helper.audio_objective_template is not None
+ and self.audio_scorer.uses_objective
+ )
+ return image_uses or audio_uses
+
async def _score_piece_async(self, message_piece: MessagePiece, *, objective: str | None = None) -> list[Score]:
"""
Score a single video piece by extracting frames and optionally audio, then aggregating their scores.
diff --git a/pyrit/score/scorer.py b/pyrit/score/scorer.py
index f3cda9923b..3aa856ff70 100644
--- a/pyrit/score/scorer.py
+++ b/pyrit/score/scorer.py
@@ -83,6 +83,17 @@ class Scorer(Identifiable, abc.ABC):
#: (Chat Completions API) and ``OpenAIResponseTarget`` (Responses API).
score_blocked_content: bool = False
+ #: When True, this scorer injects the caller-supplied ``objective`` into the
+ #: scoring prompt (system or user message) so the judge LLM is conditioned
+ #: on it. When False, the ``objective`` is only attached to the resulting
+ #: ``Score`` row as metadata and does not influence the scorer's verdict.
+ #:
+ #: Surfaced in the GUI (``ScorerSummary.uses_objective``) so the
+ #: scoring dialog can hide the objective input for scorers that ignore it.
+ #: Wrapper scorers (composite, inverter, threshold, conversation, audio/video)
+ #: should override this with a property that delegates to the wrapped scorer.
+ uses_objective: bool = False
+
def __init_subclass__(cls, **kwargs: Any) -> None:
"""
Enforce the keyword-only constructor contract on subclasses.
diff --git a/pyrit/score/true_false/audio_true_false_scorer.py b/pyrit/score/true_false/audio_true_false_scorer.py
index 58397a3a29..341072ed63 100644
--- a/pyrit/score/true_false/audio_true_false_scorer.py
+++ b/pyrit/score/true_false/audio_true_false_scorer.py
@@ -73,3 +73,8 @@ async def _score_piece_async(self, message_piece: MessagePiece, *, objective: st
List of scores from evaluating the transcribed audio.
"""
return await self._audio_helper._score_audio_async(message_piece=message_piece, objective=objective)
+
+ @property
+ def uses_objective(self) -> bool: # type: ignore[ty:invalid-overload]
+ """Delegate to the wrapped text scorer."""
+ return self._audio_helper.text_scorer.uses_objective
diff --git a/pyrit/score/true_false/float_scale_threshold_scorer.py b/pyrit/score/true_false/float_scale_threshold_scorer.py
index 828b98a9dd..66a0ae0bcc 100644
--- a/pyrit/score/true_false/float_scale_threshold_scorer.py
+++ b/pyrit/score/true_false/float_scale_threshold_scorer.py
@@ -84,6 +84,11 @@ def get_chat_target(self) -> Optional["PromptTarget"]:
"""
return self._scorer.get_chat_target()
+ @property
+ def uses_objective(self) -> bool: # type: ignore[ty:invalid-overload]
+ """Delegate to the wrapped scorer."""
+ return self._scorer.uses_objective
+
async def _score_async(
self,
message: Message,
diff --git a/pyrit/score/true_false/self_ask_general_true_false_scorer.py b/pyrit/score/true_false/self_ask_general_true_false_scorer.py
index 71acd45a56..9163a8a2fd 100644
--- a/pyrit/score/true_false/self_ask_general_true_false_scorer.py
+++ b/pyrit/score/true_false/self_ask_general_true_false_scorer.py
@@ -29,6 +29,7 @@ class SelfAskGeneralTrueFalseScorer(TrueFalseScorer):
is_objective_required=False,
)
TARGET_REQUIREMENTS = CHAT_TARGET_REQUIREMENTS
+ uses_objective: bool = True
def __init__(
self,
diff --git a/pyrit/score/true_false/self_ask_question_answer_scorer.py b/pyrit/score/true_false/self_ask_question_answer_scorer.py
index a2f5bc078e..082bc20680 100644
--- a/pyrit/score/true_false/self_ask_question_answer_scorer.py
+++ b/pyrit/score/true_false/self_ask_question_answer_scorer.py
@@ -33,6 +33,7 @@ class SelfAskQuestionAnswerScorer(SelfAskTrueFalseScorer):
supported_data_types=["text"],
is_objective_required=True,
)
+ uses_objective: bool = True
def __init__(
self,
diff --git a/pyrit/score/true_false/self_ask_refusal_scorer.py b/pyrit/score/true_false/self_ask_refusal_scorer.py
index b5a5c2b80c..9038f374ec 100644
--- a/pyrit/score/true_false/self_ask_refusal_scorer.py
+++ b/pyrit/score/true_false/self_ask_refusal_scorer.py
@@ -63,6 +63,7 @@ class SelfAskRefusalScorer(TrueFalseScorer):
_DEFAULT_VALIDATOR: ScorerPromptValidator = ScorerPromptValidator()
TARGET_REQUIREMENTS = CHAT_TARGET_REQUIREMENTS
+ uses_objective: bool = True
def __init__(
self,
diff --git a/pyrit/score/true_false/self_ask_true_false_scorer.py b/pyrit/score/true_false/self_ask_true_false_scorer.py
index bdb9fc21c3..ea19b7218d 100644
--- a/pyrit/score/true_false/self_ask_true_false_scorer.py
+++ b/pyrit/score/true_false/self_ask_true_false_scorer.py
@@ -105,6 +105,7 @@ class SelfAskTrueFalseScorer(TrueFalseScorer):
supported_data_types=["text", "image_path"],
)
TARGET_REQUIREMENTS = CHAT_TARGET_REQUIREMENTS
+ uses_objective: bool = True
def __init__(
self,
diff --git a/pyrit/score/true_false/true_false_composite_scorer.py b/pyrit/score/true_false/true_false_composite_scorer.py
index 0fece73d64..0c7778b0fa 100644
--- a/pyrit/score/true_false/true_false_composite_scorer.py
+++ b/pyrit/score/true_false/true_false_composite_scorer.py
@@ -48,7 +48,6 @@ def __init__(
if not scorers:
raise ValueError("At least one scorer must be provided.")
-
for scorer in scorers:
if not isinstance(scorer, TrueFalseScorer):
raise ValueError("All scorers must be true_false scorers.")
@@ -79,6 +78,11 @@ def get_chat_target(self) -> Optional["PromptTarget"]:
return target
return None
+ @property
+ def uses_objective(self) -> bool: # type: ignore[ty:invalid-overload]
+ """True if any child scorer injects the objective into its scoring prompt."""
+ return any(s.uses_objective for s in self._scorers)
+
async def _score_async(
self,
message: Message,
diff --git a/pyrit/score/true_false/true_false_inverter_scorer.py b/pyrit/score/true_false/true_false_inverter_scorer.py
index c3b894edda..7013fce75a 100644
--- a/pyrit/score/true_false/true_false_inverter_scorer.py
+++ b/pyrit/score/true_false/true_false_inverter_scorer.py
@@ -58,6 +58,11 @@ def get_chat_target(self) -> Optional["PromptTarget"]:
"""
return self._scorer.get_chat_target()
+ @property
+ def uses_objective(self) -> bool: # type: ignore[ty:invalid-overload]
+ """Delegate to the wrapped scorer."""
+ return self._scorer.uses_objective
+
async def _score_async(
self,
message: Message,
diff --git a/pyrit/score/true_false/video_true_false_scorer.py b/pyrit/score/true_false/video_true_false_scorer.py
index 5c45eae477..b8b43dffa0 100644
--- a/pyrit/score/true_false/video_true_false_scorer.py
+++ b/pyrit/score/true_false/video_true_false_scorer.py
@@ -93,6 +93,19 @@ def _build_identifier(self) -> ComponentIdentifier:
},
)
+ @property
+ def uses_objective(self) -> bool: # type: ignore[ty:invalid-overload]
+ """True if either sub-scorer uses the objective AND its template enables objective flow-through."""
+ image_uses = (
+ self._video_helper.image_objective_template is not None and self._video_helper.image_scorer.uses_objective
+ )
+ audio_uses = (
+ self.audio_scorer is not None
+ and self._video_helper.audio_objective_template is not None
+ and self.audio_scorer.uses_objective
+ )
+ return image_uses or audio_uses
+
async def _score_piece_async(self, message_piece: MessagePiece, *, objective: str | None = None) -> list[Score]:
"""
Score a single video piece by extracting frames and optionally audio, then aggregating their scores.
diff --git a/tests/unit/backend/test_scoring_service.py b/tests/unit/backend/test_scoring_service.py
new file mode 100644
index 0000000000..222fbbc48d
--- /dev/null
+++ b/tests/unit/backend/test_scoring_service.py
@@ -0,0 +1,738 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""
+Tests for the scoring service.
+
+Mocks ``ScorerRegistry``, ``CentralMemory``, and the per-scorer ``score_async`` to
+exercise the orchestration logic in isolation.
+"""
+
+import uuid
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from pyrit.backend.models.scoring import (
+ CreateCustomScorerRequest,
+ GeneralFloatScaleConfig,
+ GeneralTrueFalseConfig,
+ ScoreConversationRequest,
+ ScoreMessageRequest,
+ ThresholdWrapperConfig,
+ UpdateCustomScorerRequest,
+)
+from pyrit.backend.services import scoring_service as scoring_service_module
+from pyrit.backend.services.scoring_service import (
+ ScoringService,
+ get_scoring_service,
+)
+from pyrit.models import AttackOutcome, AttackResult, ComponentIdentifier, build_atomic_attack_identifier
+from pyrit.score.float_scale.float_scale_scorer import FloatScaleScorer
+from pyrit.score.true_false.true_false_scorer import TrueFalseScorer
+
+
+@pytest.fixture
+def mock_memory():
+ memory = MagicMock()
+ memory.get_attack_results.return_value = []
+ memory.get_conversation.return_value = []
+ return memory
+
+
+@pytest.fixture
+def mock_registry():
+ registry = MagicMock()
+ registry.get.return_value = None
+ registry.get_all_instances.return_value = []
+ return registry
+
+
+@pytest.fixture
+def scoring_service(mock_memory, mock_registry):
+ with (
+ patch("pyrit.backend.services.scoring_service.CentralMemory") as mock_central,
+ patch("pyrit.backend.services.scoring_service.ScorerRegistry") as mock_registry_cls,
+ ):
+ mock_central.get_memory_instance.return_value = mock_memory
+ mock_registry_cls.get_registry_singleton.return_value = mock_registry
+ # Bypass lru_cache so each test gets a fresh service instance bound to the mocks above.
+ get_scoring_service.cache_clear()
+ service = ScoringService()
+ yield service
+ get_scoring_service.cache_clear()
+
+
+# --------------------------------------------------------------------------- #
+# Helpers
+# --------------------------------------------------------------------------- #
+
+
+def _make_attack_result(*, conversation_id: str = "conv-1", attack_result_id: str = "ar-1") -> AttackResult:
+ target_identifier = ComponentIdentifier(
+ class_name="TextTarget",
+ class_module="pyrit.prompt_target",
+ )
+ now = datetime.now(timezone.utc)
+ return AttackResult(
+ conversation_id=conversation_id,
+ objective="Test",
+ atomic_attack_identifier=build_atomic_attack_identifier(
+ attack_identifier=ComponentIdentifier(
+ class_name="ManualAttack",
+ class_module="pyrit.backend",
+ children={"objective_target": target_identifier},
+ ),
+ ),
+ outcome=AttackOutcome.UNDETERMINED,
+ attack_result_id=attack_result_id,
+ metadata={"created_at": now.isoformat(), "updated_at": now.isoformat()},
+ labels={},
+ )
+
+
+def _make_piece(*, role: str = "assistant", piece_id: str | None = None) -> MagicMock:
+ piece = MagicMock()
+ piece.id = piece_id or uuid.uuid4()
+ piece.role = role
+ piece.api_role = "assistant" if role in ("assistant", "simulated_assistant") else role
+ piece.scores = []
+ return piece
+
+
+def _make_message(pieces: list[MagicMock]) -> MagicMock:
+ msg = MagicMock()
+ msg.message_pieces = pieces
+ return msg
+
+
+def _make_pyrit_score(*, value: str = "true", category: str = "harm") -> MagicMock:
+ score = MagicMock()
+ score.id = uuid.uuid4()
+ score.scorer_class_identifier = ComponentIdentifier(
+ class_name="FakeScorer",
+ class_module="tests",
+ )
+ score.score_type = "true_false"
+ score.score_value = value
+ score.score_category = [category]
+ score.score_rationale = "because"
+ score.timestamp = datetime.now(timezone.utc)
+ return score
+
+
+# --------------------------------------------------------------------------- #
+# list_scorers_async
+# --------------------------------------------------------------------------- #
+
+
+class TestListScorers:
+ async def test_returns_empty_when_no_scorers(self, scoring_service, mock_registry) -> None:
+ mock_registry.get_all_instances.return_value = []
+
+ result = await scoring_service.list_scorers_async()
+
+ assert result.items == []
+
+ async def test_returns_registered_scorers(self, scoring_service, mock_registry) -> None:
+ scorer = MagicMock(spec=TrueFalseScorer)
+ scorer.scorer_type = "true_false"
+ entry = MagicMock()
+ entry.name = "my-scorer"
+ entry.instance = scorer
+ entry.tags = {"refusal": "", "best_refusal": ""}
+ mock_registry.get_all_instances.return_value = [entry]
+
+ result = await scoring_service.list_scorers_async()
+
+ assert len(result.items) == 1
+ item = result.items[0]
+ assert item.scorer_registry_name == "my-scorer"
+ assert item.score_type == "true_false"
+ assert sorted(item.tags) == ["best_refusal", "refusal"]
+ # MagicMock(spec=TrueFalseScorer) inherits TrueFalseScorer.__doc__,
+ # so description should come from the real class docstring (first paragraph).
+ assert item.description and len(item.description) > 0
+
+ async def test_description_falls_back_to_none_when_class_has_no_docstring(
+ self, scoring_service, mock_registry
+ ) -> None:
+ class _Undocumented:
+ pass
+
+ scorer = MagicMock()
+ scorer.scorer_type = "true_false"
+ scorer.__class__ = _Undocumented
+ entry = MagicMock()
+ entry.name = "undoc"
+ entry.instance = scorer
+ entry.tags = {}
+ mock_registry.get_all_instances.return_value = [entry]
+
+ result = await scoring_service.list_scorers_async()
+ assert result.items[0].description is None
+ assert result.items[0].tags == []
+
+ async def test_uses_objective_is_read_from_scorer_instance(self, scoring_service, mock_registry) -> None:
+ injecting = MagicMock(spec=TrueFalseScorer)
+ injecting.scorer_type = "true_false"
+ injecting.uses_objective = True
+ injecting_entry = MagicMock()
+ injecting_entry.name = "refusal"
+ injecting_entry.instance = injecting
+ injecting_entry.tags = {}
+
+ non_injecting = MagicMock(spec=TrueFalseScorer)
+ non_injecting.scorer_type = "true_false"
+ non_injecting.uses_objective = False
+ non_injecting_entry = MagicMock()
+ non_injecting_entry.name = "substring"
+ non_injecting_entry.instance = non_injecting
+ non_injecting_entry.tags = {}
+
+ mock_registry.get_all_instances.return_value = [injecting_entry, non_injecting_entry]
+
+ result = await scoring_service.list_scorers_async()
+ by_name = {item.scorer_registry_name: item for item in result.items}
+ assert by_name["refusal"].uses_objective is True
+ assert by_name["substring"].uses_objective is False
+
+
+# --------------------------------------------------------------------------- #
+# score_conversation_async
+# --------------------------------------------------------------------------- #
+
+
+class TestScoreConversation:
+ async def test_raises_when_attack_missing(self, scoring_service, mock_memory) -> None:
+ mock_memory.get_attack_results.return_value = []
+
+ with pytest.raises(LookupError, match="not found"):
+ await scoring_service.score_conversation_async(
+ attack_result_id="missing",
+ conversation_id="conv-1",
+ request=ScoreConversationRequest(scorer_registry_name="x"),
+ )
+
+ async def test_raises_when_conversation_not_in_attack(self, scoring_service, mock_memory) -> None:
+ mock_memory.get_attack_results.return_value = [_make_attack_result(conversation_id="conv-1")]
+
+ with pytest.raises(ValueError, match="not part of attack"):
+ await scoring_service.score_conversation_async(
+ attack_result_id="ar-1",
+ conversation_id="other-conv",
+ request=ScoreConversationRequest(scorer_registry_name="x"),
+ )
+
+ async def test_raises_when_scorer_missing(self, scoring_service, mock_memory, mock_registry) -> None:
+ mock_memory.get_attack_results.return_value = [_make_attack_result()]
+ mock_registry.get.return_value = None
+
+ with pytest.raises(ValueError, match="not registered"):
+ await scoring_service.score_conversation_async(
+ attack_result_id="ar-1",
+ conversation_id="conv-1",
+ request=ScoreConversationRequest(scorer_registry_name="missing-scorer"),
+ )
+
+ async def test_raises_when_conversation_empty(self, scoring_service, mock_memory, mock_registry) -> None:
+ mock_memory.get_attack_results.return_value = [_make_attack_result()]
+ mock_memory.get_conversation.return_value = []
+ mock_registry.get.return_value = MagicMock(spec=TrueFalseScorer)
+
+ with pytest.raises(ValueError, match="no messages to score"):
+ await scoring_service.score_conversation_async(
+ attack_result_id="ar-1",
+ conversation_id="conv-1",
+ request=ScoreConversationRequest(scorer_registry_name="x"),
+ )
+
+ async def test_raises_when_last_message_has_no_assistant_turn(
+ self, scoring_service, mock_memory, mock_registry
+ ) -> None:
+ mock_memory.get_attack_results.return_value = [_make_attack_result()]
+ mock_memory.get_conversation.return_value = [_make_message([_make_piece(role="user")])]
+ mock_registry.get.return_value = MagicMock(spec=TrueFalseScorer)
+
+ with pytest.raises(ValueError, match="no assistant message"):
+ await scoring_service.score_conversation_async(
+ attack_result_id="ar-1",
+ conversation_id="conv-1",
+ request=ScoreConversationRequest(scorer_registry_name="x"),
+ )
+
+ async def test_last_message_scores_most_recent_assistant_turn(
+ self, scoring_service, mock_memory, mock_registry
+ ) -> None:
+ user_msg = _make_message([_make_piece(role="user")])
+ first_assistant = _make_message([_make_piece(role="assistant")])
+ last_assistant = _make_message([_make_piece(role="assistant")])
+ trailing_user = _make_message([_make_piece(role="user")])
+ mock_memory.get_attack_results.return_value = [_make_attack_result()]
+ mock_memory.get_conversation.return_value = [user_msg, first_assistant, user_msg, last_assistant, trailing_user]
+
+ scorer = MagicMock(spec=TrueFalseScorer)
+ scorer.score_async = AsyncMock(return_value=[_make_pyrit_score()])
+ mock_registry.get.return_value = scorer
+
+ result = await scoring_service.score_conversation_async(
+ attack_result_id="ar-1",
+ conversation_id="conv-1",
+ request=ScoreConversationRequest(scorer_registry_name="my-scorer", objective="be helpful"),
+ )
+
+ scorer.score_async.assert_awaited_once()
+ kwargs = scorer.score_async.await_args.kwargs
+ assert kwargs["message"] is last_assistant
+ assert kwargs["objective"] == "be helpful"
+ assert len(result.scores) == 1
+ assert result.scores[0].score_value == "true"
+
+ async def test_whole_conversation_wraps_scorer(self, scoring_service, mock_memory, mock_registry) -> None:
+ mock_memory.get_attack_results.return_value = [_make_attack_result()]
+ # Whole-conv mode just hands the last message to the wrapped scorer; content doesn't matter.
+ last = _make_message([_make_piece(role="assistant")])
+ mock_memory.get_conversation.return_value = [last]
+
+ scorer = MagicMock(spec=FloatScaleScorer)
+ mock_registry.get.return_value = scorer
+
+ with patch("pyrit.score.conversation_scorer.create_conversation_scorer") as mock_create:
+ wrapped = MagicMock()
+ wrapped.score_async = AsyncMock(return_value=[_make_pyrit_score()])
+ mock_create.return_value = wrapped
+
+ await scoring_service.score_conversation_async(
+ attack_result_id="ar-1",
+ conversation_id="conv-1",
+ request=ScoreConversationRequest(scorer_registry_name="my-scorer", mode="whole_conversation"),
+ )
+
+ mock_create.assert_called_once_with(scorer=scorer)
+ wrapped.score_async.assert_awaited_once()
+
+ async def test_whole_conversation_rejects_unsupported_scorer(
+ self, scoring_service, mock_memory, mock_registry
+ ) -> None:
+ mock_memory.get_attack_results.return_value = [_make_attack_result()]
+ mock_memory.get_conversation.return_value = [_make_message([_make_piece(role="assistant")])]
+ mock_registry.get.return_value = MagicMock() # Not a FloatScale/TrueFalse scorer.
+
+ with pytest.raises(ValueError, match="FloatScaleScorer or TrueFalseScorer"):
+ await scoring_service.score_conversation_async(
+ attack_result_id="ar-1",
+ conversation_id="conv-1",
+ request=ScoreConversationRequest(scorer_registry_name="my-scorer", mode="whole_conversation"),
+ )
+
+
+# --------------------------------------------------------------------------- #
+# score_message_async
+# --------------------------------------------------------------------------- #
+
+
+class TestScoreMessage:
+ async def test_scores_specific_piece(self, scoring_service, mock_memory, mock_registry) -> None:
+ target_piece = _make_piece(role="assistant", piece_id="piece-target")
+ other_piece = _make_piece(role="assistant", piece_id="piece-other")
+ target_msg = _make_message([target_piece])
+ other_msg = _make_message([other_piece])
+
+ mock_memory.get_attack_results.return_value = [_make_attack_result()]
+ mock_memory.get_conversation.return_value = [other_msg, target_msg]
+
+ scorer = MagicMock(spec=TrueFalseScorer)
+ scorer.score_async = AsyncMock(return_value=[_make_pyrit_score()])
+ mock_registry.get.return_value = scorer
+
+ result = await scoring_service.score_message_async(
+ attack_result_id="ar-1",
+ conversation_id="conv-1",
+ piece_id="piece-target",
+ request=ScoreMessageRequest(scorer_registry_name="my-scorer"),
+ )
+
+ scorer.score_async.assert_awaited_once()
+ assert scorer.score_async.await_args.kwargs["message"] is target_msg
+ assert len(result.scores) == 1
+
+ async def test_raises_when_piece_not_in_conversation(self, scoring_service, mock_memory, mock_registry) -> None:
+ mock_memory.get_attack_results.return_value = [_make_attack_result()]
+ mock_memory.get_conversation.return_value = [_make_message([_make_piece(role="assistant", piece_id="other")])]
+ mock_registry.get.return_value = MagicMock(spec=TrueFalseScorer)
+
+ with pytest.raises(LookupError, match="not part of conversation"):
+ await scoring_service.score_message_async(
+ attack_result_id="ar-1",
+ conversation_id="conv-1",
+ piece_id="missing-piece",
+ request=ScoreMessageRequest(scorer_registry_name="x"),
+ )
+
+
+# --------------------------------------------------------------------------- #
+# Custom (user-created) scorers
+# --------------------------------------------------------------------------- #
+
+
+@pytest.fixture
+def clear_custom_scorers():
+ """Reset the module-level custom-scorer state before and after each test."""
+ scoring_service_module._CUSTOM_SCORER_CONFIGS.clear()
+ yield
+ scoring_service_module._CUSTOM_SCORER_CONFIGS.clear()
+
+
+@pytest.fixture
+def custom_registry(mock_registry):
+ """Configure the mocked registry so `name in registry` reads from a backing dict."""
+ backing: dict[str, MagicMock] = {}
+ mock_registry._registry_items = backing
+ mock_registry._metadata_cache = MagicMock()
+ mock_registry.__contains__ = lambda self, key: key in backing
+
+ def _register_instance(instance, *, name, tags=None):
+ backing[name] = instance
+
+ def _get(name):
+ return backing.get(name)
+
+ def _get_all_instances():
+ entries = []
+ for n, inst in backing.items():
+ entry = MagicMock()
+ entry.name = n
+ entry.instance = inst
+ entry.tags = {}
+ entries.append(entry)
+ return entries
+
+ mock_registry.register_instance = MagicMock(side_effect=_register_instance)
+ mock_registry.get = MagicMock(side_effect=_get)
+ mock_registry.get_all_instances = MagicMock(side_effect=_get_all_instances)
+ return mock_registry
+
+
+def _patch_default_target():
+ """Helper: patch `_get_default_chat_target` to return a benign MagicMock."""
+ return patch.object(ScoringService, "_get_default_chat_target", return_value=MagicMock())
+
+
+class TestCreateCustomScorer:
+ async def test_general_float_scale_registers_scorer(
+ self, scoring_service, custom_registry, clear_custom_scorers
+ ) -> None:
+ cfg = GeneralFloatScaleConfig(
+ system_prompt_format_string="Score {prompt} from 0-10",
+ category="harm",
+ min_value=0,
+ max_value=10,
+ )
+ with (
+ _patch_default_target(),
+ patch(
+ "pyrit.score.float_scale.self_ask_general_float_scale_scorer.SelfAskGeneralFloatScaleScorer"
+ ) as mock_cls,
+ ):
+ built = MagicMock(spec=FloatScaleScorer)
+ built.scorer_type = "float_scale"
+ built.uses_objective = False
+ mock_cls.return_value = built
+
+ response = await scoring_service.create_custom_scorer_async(
+ request=CreateCustomScorerRequest(name="my_scale", config=cfg),
+ )
+
+ assert response.summary.scorer_registry_name == "my_scale"
+ assert response.summary.editable is True
+ assert response.summary.custom_config == cfg
+ assert "my_scale" in scoring_service_module._CUSTOM_SCORER_CONFIGS
+ custom_registry.register_instance.assert_called_once()
+ mock_cls.assert_called_once()
+ # min_value/max_value/category propagated
+ call_kwargs = mock_cls.call_args.kwargs
+ assert call_kwargs["min_value"] == 0
+ assert call_kwargs["max_value"] == 10
+ assert call_kwargs["category"] == "harm"
+
+ async def test_general_true_false_registers_scorer(
+ self, scoring_service, custom_registry, clear_custom_scorers
+ ) -> None:
+ cfg = GeneralTrueFalseConfig(
+ system_prompt_format_string="Is {prompt} bad?",
+ score_aggregator="AND",
+ )
+ with (
+ _patch_default_target(),
+ patch(
+ "pyrit.score.true_false.self_ask_general_true_false_scorer.SelfAskGeneralTrueFalseScorer"
+ ) as mock_cls,
+ patch("pyrit.score.true_false.true_false_score_aggregator.TrueFalseScoreAggregator") as mock_aggregator_ns,
+ ):
+ mock_aggregator_ns.AND = "AND_FUNC"
+ built = MagicMock(spec=TrueFalseScorer)
+ built.scorer_type = "true_false"
+ built.uses_objective = False
+ mock_cls.return_value = built
+
+ response = await scoring_service.create_custom_scorer_async(
+ request=CreateCustomScorerRequest(name="my_tf", config=cfg),
+ )
+
+ assert response.summary.scorer_registry_name == "my_tf"
+ assert response.summary.editable is True
+ assert mock_cls.call_args.kwargs["score_aggregator"] == "AND_FUNC"
+
+ async def test_threshold_wrapper_registers_scorer(
+ self, scoring_service, custom_registry, clear_custom_scorers
+ ) -> None:
+ # Pre-seed the registry with a float-scale scorer to wrap.
+ wrapped = MagicMock(spec=FloatScaleScorer)
+ wrapped.scorer_type = "float_scale"
+ wrapped.uses_objective = False
+ custom_registry._registry_items["base_float"] = wrapped
+
+ cfg = ThresholdWrapperConfig(
+ wrapped_scorer_registry_name="base_float",
+ threshold=0.75,
+ )
+ with patch("pyrit.score.true_false.float_scale_threshold_scorer.FloatScaleThresholdScorer") as mock_cls:
+ built = MagicMock(spec=TrueFalseScorer)
+ built.scorer_type = "true_false"
+ built.uses_objective = False
+ mock_cls.return_value = built
+
+ response = await scoring_service.create_custom_scorer_async(
+ request=CreateCustomScorerRequest(name="my_thresh", config=cfg),
+ )
+
+ assert response.summary.scorer_registry_name == "my_thresh"
+ mock_cls.assert_called_once_with(scorer=wrapped, threshold=0.75)
+
+ async def test_rejects_duplicate_name(self, scoring_service, custom_registry, clear_custom_scorers) -> None:
+ # Pre-populate the registry with the same name.
+ custom_registry._registry_items["taken"] = MagicMock()
+ cfg = GeneralFloatScaleConfig(
+ system_prompt_format_string="x",
+ min_value=0,
+ max_value=10,
+ )
+ with pytest.raises(ValueError, match="already registered"):
+ await scoring_service.create_custom_scorer_async(
+ request=CreateCustomScorerRequest(name="taken", config=cfg),
+ )
+ # No state pollution.
+ assert "taken" not in scoring_service_module._CUSTOM_SCORER_CONFIGS
+
+ async def test_rejects_max_value_not_greater_than_min(
+ self, scoring_service, custom_registry, clear_custom_scorers
+ ) -> None:
+ cfg = GeneralFloatScaleConfig(
+ system_prompt_format_string="x",
+ min_value=5,
+ max_value=5,
+ )
+ with _patch_default_target(), pytest.raises(ValueError, match="max_value must be strictly greater"):
+ await scoring_service.create_custom_scorer_async(
+ request=CreateCustomScorerRequest(name="bad", config=cfg),
+ )
+ assert "bad" not in scoring_service_module._CUSTOM_SCORER_CONFIGS
+
+ async def test_threshold_wrapper_rejects_missing_wrapped(
+ self, scoring_service, custom_registry, clear_custom_scorers
+ ) -> None:
+ cfg = ThresholdWrapperConfig(wrapped_scorer_registry_name="does_not_exist", threshold=0.5)
+ with pytest.raises(ValueError, match="is not registered"):
+ await scoring_service.create_custom_scorer_async(
+ request=CreateCustomScorerRequest(name="thresh", config=cfg),
+ )
+
+ async def test_threshold_wrapper_rejects_non_float_scale_wrapped(
+ self, scoring_service, custom_registry, clear_custom_scorers
+ ) -> None:
+ wrapped = MagicMock(spec=TrueFalseScorer)
+ custom_registry._registry_items["tf_scorer"] = wrapped
+ cfg = ThresholdWrapperConfig(wrapped_scorer_registry_name="tf_scorer", threshold=0.5)
+ with pytest.raises(ValueError, match="requires a FloatScaleScorer"):
+ await scoring_service.create_custom_scorer_async(
+ request=CreateCustomScorerRequest(name="thresh", config=cfg),
+ )
+
+
+class TestUpdateCustomScorer:
+ async def test_replaces_instance_and_preserves_name(
+ self, scoring_service, custom_registry, clear_custom_scorers
+ ) -> None:
+ original_cfg = GeneralFloatScaleConfig(
+ system_prompt_format_string="orig",
+ min_value=0,
+ max_value=10,
+ )
+ new_cfg = GeneralFloatScaleConfig(
+ system_prompt_format_string="updated",
+ min_value=0,
+ max_value=100,
+ category="bias",
+ )
+
+ with (
+ _patch_default_target(),
+ patch(
+ "pyrit.score.float_scale.self_ask_general_float_scale_scorer.SelfAskGeneralFloatScaleScorer"
+ ) as mock_cls,
+ ):
+ orig_built = MagicMock(spec=FloatScaleScorer)
+ orig_built.scorer_type = "float_scale"
+ orig_built.uses_objective = False
+ updated_built = MagicMock(spec=FloatScaleScorer)
+ updated_built.scorer_type = "float_scale"
+ updated_built.uses_objective = False
+ mock_cls.side_effect = [orig_built, updated_built]
+
+ await scoring_service.create_custom_scorer_async(
+ request=CreateCustomScorerRequest(name="ed", config=original_cfg),
+ )
+ response = await scoring_service.update_custom_scorer_async(
+ scorer_id="ed",
+ request=UpdateCustomScorerRequest(config=new_cfg),
+ )
+
+ assert response.summary.scorer_registry_name == "ed"
+ assert response.summary.custom_config == new_cfg
+ # New instance replaced the old one in the registry.
+ assert custom_registry._registry_items["ed"] is updated_built
+ assert scoring_service_module._CUSTOM_SCORER_CONFIGS["ed"] == new_cfg
+
+ async def test_rejects_non_custom_name(self, scoring_service, custom_registry, clear_custom_scorers) -> None:
+ custom_registry._registry_items["builtin"] = MagicMock(spec=TrueFalseScorer)
+ cfg = GeneralTrueFalseConfig(system_prompt_format_string="x")
+ with pytest.raises(ValueError, match="not a user-created scorer"):
+ await scoring_service.update_custom_scorer_async(
+ scorer_id="builtin",
+ request=UpdateCustomScorerRequest(config=cfg),
+ )
+
+
+class TestDeleteCustomScorer:
+ async def test_removes_from_registry_and_config_dict(
+ self, scoring_service, custom_registry, clear_custom_scorers
+ ) -> None:
+ cfg = GeneralTrueFalseConfig(system_prompt_format_string="x")
+ with (
+ _patch_default_target(),
+ patch(
+ "pyrit.score.true_false.self_ask_general_true_false_scorer.SelfAskGeneralTrueFalseScorer"
+ ) as mock_cls,
+ ):
+ built = MagicMock(spec=TrueFalseScorer)
+ built.scorer_type = "true_false"
+ built.uses_objective = False
+ mock_cls.return_value = built
+ await scoring_service.create_custom_scorer_async(
+ request=CreateCustomScorerRequest(name="goner", config=cfg),
+ )
+
+ assert "goner" in custom_registry._registry_items
+ assert "goner" in scoring_service_module._CUSTOM_SCORER_CONFIGS
+
+ await scoring_service.delete_custom_scorer_async(scorer_id="goner")
+
+ assert "goner" not in custom_registry._registry_items
+ assert "goner" not in scoring_service_module._CUSTOM_SCORER_CONFIGS
+ assert custom_registry._metadata_cache is None
+
+ async def test_rejects_non_custom_name(self, scoring_service, custom_registry, clear_custom_scorers) -> None:
+ custom_registry._registry_items["builtin"] = MagicMock(spec=TrueFalseScorer)
+ with pytest.raises(ValueError, match="not a user-created scorer"):
+ await scoring_service.delete_custom_scorer_async(scorer_id="builtin")
+ # Built-in remains in the registry.
+ assert "builtin" in custom_registry._registry_items
+
+
+class TestListScorersWithCustom:
+ async def test_marks_user_created_as_editable(self, scoring_service, custom_registry, clear_custom_scorers) -> None:
+ # Pre-seed a built-in scorer (no entry in _CUSTOM_SCORER_CONFIGS).
+ builtin = MagicMock(spec=TrueFalseScorer)
+ builtin.scorer_type = "true_false"
+ builtin.uses_objective = False
+ custom_registry._registry_items["builtin_one"] = builtin
+
+ # Then create a custom one.
+ cfg = GeneralFloatScaleConfig(system_prompt_format_string="x", min_value=0, max_value=10)
+ with (
+ _patch_default_target(),
+ patch(
+ "pyrit.score.float_scale.self_ask_general_float_scale_scorer.SelfAskGeneralFloatScaleScorer"
+ ) as mock_cls,
+ ):
+ built = MagicMock(spec=FloatScaleScorer)
+ built.scorer_type = "float_scale"
+ built.uses_objective = False
+ mock_cls.return_value = built
+ await scoring_service.create_custom_scorer_async(
+ request=CreateCustomScorerRequest(name="user_one", config=cfg),
+ )
+
+ response = await scoring_service.list_scorers_async()
+ by_name = {item.scorer_registry_name: item for item in response.items}
+
+ assert by_name["builtin_one"].editable is False
+ assert by_name["builtin_one"].custom_config is None
+ assert by_name["user_one"].editable is True
+ assert by_name["user_one"].custom_config == cfg
+
+
+class TestGetDefaultChatTarget:
+ def test_returns_first_preferred_target(self) -> None:
+ from pyrit.prompt_target import PromptChatTarget
+
+ preferred = MagicMock(spec=PromptChatTarget)
+ target_registry = MagicMock()
+
+ def _get(name):
+ return preferred if name == "azure_openai_gpt4o_temp9" else None
+
+ target_registry.get = MagicMock(side_effect=_get)
+ target_registry.get_all_instances = MagicMock(return_value=[])
+
+ with patch(
+ "pyrit.registry.TargetRegistry.get_registry_singleton",
+ return_value=target_registry,
+ ):
+ result = ScoringService._get_default_chat_target()
+
+ assert result is preferred
+
+ def test_falls_back_to_first_chat_capable(self) -> None:
+ from pyrit.prompt_target import PromptChatTarget
+
+ fallback = MagicMock(spec=PromptChatTarget)
+ non_chat = MagicMock() # not a PromptChatTarget
+ target_registry = MagicMock()
+ target_registry.get = MagicMock(return_value=None)
+ entry_bad = MagicMock()
+ entry_bad.instance = non_chat
+ entry_good = MagicMock()
+ entry_good.instance = fallback
+ target_registry.get_all_instances = MagicMock(return_value=[entry_bad, entry_good])
+
+ with patch(
+ "pyrit.registry.TargetRegistry.get_registry_singleton",
+ return_value=target_registry,
+ ):
+ result = ScoringService._get_default_chat_target()
+
+ assert result is fallback
+
+ def test_raises_when_no_chat_target_registered(self) -> None:
+ target_registry = MagicMock()
+ target_registry.get = MagicMock(return_value=None)
+ target_registry.get_all_instances = MagicMock(return_value=[])
+
+ with patch(
+ "pyrit.registry.TargetRegistry.get_registry_singleton",
+ return_value=target_registry,
+ ):
+ with pytest.raises(ValueError, match="No PromptChatTarget"):
+ ScoringService._get_default_chat_target()