diff --git a/doc/code/memory/5_advanced_memory.ipynb b/doc/code/memory/5_advanced_memory.ipynb index 9a8f6b257b..7cefe0b502 100644 --- a/doc/code/memory/5_advanced_memory.ipynb +++ b/doc/code/memory/5_advanced_memory.ipynb @@ -602,7 +602,7 @@ } ], "source": [ - "from pyrit.models import Message\n", + "from pyrit.models import Message, MessageScorable\n", "from pyrit.score import SubStringScorer\n", "\n", "# Create three scorers with different substrings\n", @@ -623,9 +623,10 @@ "\n", "# Score every response with both scorers — scores are automatically persisted in memory\n", "for msg in assistant_messages:\n", - " await scorer_molotov.score_async(msg) # type: ignore\n", - " await scorer_launder.score_async(msg) # type: ignore\n", - " await scorer_assist.score_async(msg) # type: ignore\n", + " scorable = MessageScorable.from_message(msg)\n", + " await scorer_molotov.score_async(scorable=scorable) # type: ignore\n", + " await scorer_launder.score_async(scorable=scorable) # type: ignore\n", + " await scorer_assist.score_async(scorable=scorable) # type: ignore\n", "\n", "print(f\"Scored {len(assistant_messages)} messages with all three scorers.\")" ] diff --git a/doc/code/memory/5_advanced_memory.py b/doc/code/memory/5_advanced_memory.py index 366bf80bf5..a8e882100a 100644 --- a/doc/code/memory/5_advanced_memory.py +++ b/doc/code/memory/5_advanced_memory.py @@ -246,7 +246,7 @@ # retrieve only the scores produced by a specific scorer. # %% -from pyrit.models import Message +from pyrit.models import Message, MessageScorable from pyrit.score import SubStringScorer # Create three scorers with different substrings @@ -267,9 +267,10 @@ # Score every response with both scorers — scores are automatically persisted in memory for msg in assistant_messages: - await scorer_molotov.score_async(msg) # type: ignore - await scorer_launder.score_async(msg) # type: ignore - await scorer_assist.score_async(msg) # type: ignore + scorable = MessageScorable.from_message(msg) + await scorer_molotov.score_async(scorable=scorable) # type: ignore + await scorer_launder.score_async(scorable=scorable) # type: ignore + await scorer_assist.score_async(scorable=scorable) # type: ignore print(f"Scored {len(assistant_messages)} messages with all three scorers.") diff --git a/doc/code/scoring/1_true_false_scorers.ipynb b/doc/code/scoring/1_true_false_scorers.ipynb index 0f3849b51d..c487ba8210 100644 --- a/doc/code/scoring/1_true_false_scorers.ipynb +++ b/doc/code/scoring/1_true_false_scorers.ipynb @@ -365,7 +365,7 @@ } ], "source": [ - "from pyrit.models import MessagePiece\n", + "from pyrit.models import MessagePiece, ScoringExpectation\n", "from pyrit.score import SelfAskRefusalScorer\n", "\n", "refusal_scorer = SelfAskRefusalScorer(chat_target=OpenAIChatTarget())\n", @@ -382,8 +382,12 @@ "refusal = MessagePiece(role=\"assistant\", original_value=\"Sorry, but I can't help with that.\").to_message()\n", "refusal.set_response_not_in_memory()\n", "\n", - "blocked_score = (await refusal_scorer.score_async(message=blocked))[0] # type: ignore\n", - "refusal_score = (await refusal_scorer.score_async(message=refusal, objective=\"Explain how to pick a lock\"))[0] # type: ignore\n", + "blocked_score = (await refusal_scorer.score_message_async(message=blocked))[0] # type: ignore\n", + "refusal_score = (\n", + " await refusal_scorer.score_message_async(\n", + " message=refusal, expectation=ScoringExpectation(objective=\"Explain how to pick a lock\")\n", + " )\n", + ")[0] # type: ignore\n", "\n", "print(f\"[refusal] blocked response -> {blocked_score.get_value()}\")\n", "print(f\"[refusal] explicit refusal -> {refusal_score.get_value()}\")" diff --git a/doc/code/scoring/1_true_false_scorers.py b/doc/code/scoring/1_true_false_scorers.py index 4ef91acedc..afa7560a90 100644 --- a/doc/code/scoring/1_true_false_scorers.py +++ b/doc/code/scoring/1_true_false_scorers.py @@ -176,7 +176,7 @@ # which is common with content filters. A partially blocked response still carries content, so # it is scored normally by the LLM rather than short-circuited. # %% -from pyrit.models import MessagePiece +from pyrit.models import MessagePiece, ScoringExpectation from pyrit.score import SelfAskRefusalScorer refusal_scorer = SelfAskRefusalScorer(chat_target=OpenAIChatTarget()) @@ -193,8 +193,12 @@ refusal = MessagePiece(role="assistant", original_value="Sorry, but I can't help with that.").to_message() refusal.set_response_not_in_memory() -blocked_score = (await refusal_scorer.score_async(message=blocked))[0] # type: ignore -refusal_score = (await refusal_scorer.score_async(message=refusal, objective="Explain how to pick a lock"))[0] # type: ignore +blocked_score = (await refusal_scorer.score_message_async(message=blocked))[0] # type: ignore +refusal_score = ( + await refusal_scorer.score_message_async( + message=refusal, expectation=ScoringExpectation(objective="Explain how to pick a lock") + ) +)[0] # type: ignore print(f"[refusal] blocked response -> {blocked_score.get_value()}") print(f"[refusal] explicit refusal -> {refusal_score.get_value()}") diff --git a/doc/code/scoring/2_float_scale_scorers.ipynb b/doc/code/scoring/2_float_scale_scorers.ipynb index 5d350a1247..9a5073710c 100644 --- a/doc/code/scoring/2_float_scale_scorers.ipynb +++ b/doc/code/scoring/2_float_scale_scorers.ipynb @@ -99,7 +99,7 @@ "\n", "from pyrit.auth import get_azure_token_provider\n", "from pyrit.memory import CentralMemory\n", - "from pyrit.models import Message, MessagePiece\n", + "from pyrit.models import Message, MessagePiece, MessageScorable\n", "from pyrit.score import AzureContentFilterScorer\n", "\n", "azure_content_filter = AzureContentFilterScorer(\n", @@ -120,7 +120,7 @@ "# The score table has a foreign key on the message, so write it to memory first.\n", "CentralMemory.get_memory_instance().add_message_to_memory(request=response)\n", "\n", - "scores = await azure_content_filter.score_async(response) # type: ignore\n", + "scores = await azure_content_filter.score_async(scorable=MessageScorable.from_message(response)) # type: ignore\n", "for score in scores:\n", " # One score per harm category; score_metadata holds the original 0-7 severity.\n", " print(f\"{score.score_category}: value={score.get_value()} metadata={score.score_metadata}\")" @@ -289,7 +289,7 @@ } ], "source": [ - "from pyrit.models import MessagePiece\n", + "from pyrit.models import MessagePiece, MessageScorable\n", "from pyrit.score import InsecureCodeScorer\n", "\n", "insecure_code_scorer = InsecureCodeScorer.from_harm_categories(chat_target=OpenAIChatTarget())\n", @@ -302,7 +302,7 @@ "request = MessagePiece(role=\"assistant\", original_value=snippet, conversation_id=str(uuid4())).to_message()\n", "insecure_code_scorer._memory.add_message_to_memory(request=request)\n", "\n", - "scored = (await insecure_code_scorer.score_async(request))[0] # type: ignore\n", + "scored = (await insecure_code_scorer.score_async(scorable=MessageScorable.from_message(request)))[0] # type: ignore\n", "print(f\"[insecure code] risk={scored.get_value()}\")\n", "print(f\"rationale: {scored.score_rationale}\")" ] diff --git a/doc/code/scoring/2_float_scale_scorers.py b/doc/code/scoring/2_float_scale_scorers.py index abcfa20366..6ec7221cb7 100644 --- a/doc/code/scoring/2_float_scale_scorers.py +++ b/doc/code/scoring/2_float_scale_scorers.py @@ -43,7 +43,7 @@ from pyrit.auth import get_azure_token_provider from pyrit.memory import CentralMemory -from pyrit.models import Message, MessagePiece +from pyrit.models import Message, MessagePiece, MessageScorable from pyrit.score import AzureContentFilterScorer azure_content_filter = AzureContentFilterScorer( @@ -64,7 +64,7 @@ # The score table has a foreign key on the message, so write it to memory first. CentralMemory.get_memory_instance().add_message_to_memory(request=response) -scores = await azure_content_filter.score_async(response) # type: ignore +scores = await azure_content_filter.score_async(scorable=MessageScorable.from_message(response)) # type: ignore for score in scores: # One score per harm category; score_metadata holds the original 0-7 severity. print(f"{score.score_category}: value={score.get_value()} metadata={score.score_metadata}") @@ -146,7 +146,7 @@ # # Rates how risky a code snippet is, flagging vulnerabilities like injection or weak auth. # %% -from pyrit.models import MessagePiece +from pyrit.models import MessagePiece, MessageScorable from pyrit.score import InsecureCodeScorer insecure_code_scorer = InsecureCodeScorer.from_harm_categories(chat_target=OpenAIChatTarget()) @@ -159,7 +159,7 @@ def authenticate_user(username, password): request = MessagePiece(role="assistant", original_value=snippet, conversation_id=str(uuid4())).to_message() insecure_code_scorer._memory.add_message_to_memory(request=request) -scored = (await insecure_code_scorer.score_async(request))[0] # type: ignore +scored = (await insecure_code_scorer.score_async(scorable=MessageScorable.from_message(request)))[0] # type: ignore print(f"[insecure code] risk={scored.get_value()}") print(f"rationale: {scored.score_rationale}") diff --git a/doc/code/scoring/3_combining_scorers.ipynb b/doc/code/scoring/3_combining_scorers.ipynb index 04fd8385aa..750d84a9b8 100644 --- a/doc/code/scoring/3_combining_scorers.ipynb +++ b/doc/code/scoring/3_combining_scorers.ipynb @@ -296,7 +296,7 @@ "import uuid\n", "\n", "from pyrit.memory import CentralMemory\n", - "from pyrit.models import MessagePiece\n", + "from pyrit.models import MessagePiece, MessageScorable\n", "from pyrit.score import create_conversation_scorer\n", "\n", "memory = CentralMemory.get_memory_instance()\n", @@ -317,7 +317,7 @@ "conversation_scorer = create_conversation_scorer(scorer=persona_breach_scorer)\n", "\n", "# Any message from the conversation works as the trigger.\n", - "score = (await conversation_scorer.score_async(turns[0]))[0] # type: ignore\n", + "score = (await conversation_scorer.score_async(scorable=MessageScorable.from_message(turns[0])))[0] # type: ignore\n", "print(f\"[conversation] persona breach across turns -> {score.get_value()}\")" ] }, diff --git a/doc/code/scoring/3_combining_scorers.py b/doc/code/scoring/3_combining_scorers.py index e665e2b58e..bb7db694f5 100644 --- a/doc/code/scoring/3_combining_scorers.py +++ b/doc/code/scoring/3_combining_scorers.py @@ -160,7 +160,7 @@ import uuid from pyrit.memory import CentralMemory -from pyrit.models import MessagePiece +from pyrit.models import MessagePiece, MessageScorable from pyrit.score import create_conversation_scorer memory = CentralMemory.get_memory_instance() @@ -181,7 +181,7 @@ conversation_scorer = create_conversation_scorer(scorer=persona_breach_scorer) # Any message from the conversation works as the trigger. -score = (await conversation_scorer.score_async(turns[0]))[0] # type: ignore +score = (await conversation_scorer.score_async(scorable=MessageScorable.from_message(turns[0])))[0] # type: ignore print(f"[conversation] persona breach across turns -> {score.get_value()}") # %% [markdown] diff --git a/doc/code/targets/round_robin_target.ipynb b/doc/code/targets/round_robin_target.ipynb index af179710f0..c29f92e530 100644 --- a/doc/code/targets/round_robin_target.ipynb +++ b/doc/code/targets/round_robin_target.ipynb @@ -99,7 +99,7 @@ "import os\n", "\n", "from pyrit.auth import get_azure_openai_auth\n", - "from pyrit.models import Message\n", + "from pyrit.models import Message, MessageScorable\n", "from pyrit.prompt_normalizer import PromptNormalizer\n", "from pyrit.prompt_target import OpenAIChatTarget, RoundRobinTarget\n", "from pyrit.setup import IN_MEMORY, initialize_pyrit_async\n", @@ -788,7 +788,7 @@ "# You may want to use `score_prompts_batch_async` like below in practice for efficiency\n", "# await scorer.score_prompts_batch_async(messages=response_messages) # type: ignore\n", "for i, response_message in enumerate(response_messages):\n", - " scores = await scorer.score_async(message=response_message) # type: ignore\n", + " scores = await scorer.score_async(scorable=MessageScorable.from_message(response_message)) # type: ignore\n", "\n", " # The scorer's internal LLM response has inner_target_identifier in metadata.\n", " # We can check the round-robin counter to determine which target was used.\n", diff --git a/doc/code/targets/round_robin_target.py b/doc/code/targets/round_robin_target.py index ba17eb84d8..806a456af4 100644 --- a/doc/code/targets/round_robin_target.py +++ b/doc/code/targets/round_robin_target.py @@ -38,7 +38,7 @@ import os from pyrit.auth import get_azure_openai_auth -from pyrit.models import Message +from pyrit.models import Message, MessageScorable from pyrit.prompt_normalizer import PromptNormalizer from pyrit.prompt_target import OpenAIChatTarget, RoundRobinTarget from pyrit.setup import IN_MEMORY, initialize_pyrit_async @@ -252,7 +252,7 @@ # You may want to use `score_prompts_batch_async` like below in practice for efficiency # await scorer.score_prompts_batch_async(messages=response_messages) # type: ignore for i, response_message in enumerate(response_messages): - scores = await scorer.score_async(message=response_message) # type: ignore + scores = await scorer.score_async(scorable=MessageScorable.from_message(response_message)) # type: ignore # The scorer's internal LLM response has inner_target_identifier in metadata. # We can check the round-robin counter to determine which target was used. diff --git a/pyproject.toml b/pyproject.toml index 4364310da4..9ec0f3219d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -174,6 +174,11 @@ addopts = [ pythonpath = ["."] asyncio_default_fixture_loop_scope = "function" asyncio_mode = "auto" +filterwarnings = [ + # Keep the in-repo suite on the scorable/expectation contract. Tests that cover the + # shim itself opt back in with pytest.warns. + "error:Scorer\\.score_async:DeprecationWarning", +] [tool.ty] [tool.ty.rules] diff --git a/pyrit/executor/attack/multi_turn/crescendo.py b/pyrit/executor/attack/multi_turn/crescendo.py index fe5b896cfc..68ca358db5 100644 --- a/pyrit/executor/attack/multi_turn/crescendo.py +++ b/pyrit/executor/attack/multi_turn/crescendo.py @@ -10,21 +10,11 @@ from pyrit.common.apply_defaults import REQUIRED_VALUE, apply_defaults from pyrit.common.path import EXECUTOR_SEED_PROMPT_PATH -from pyrit.exceptions import ( - ComponentRole, - execution_context, -) -from pyrit.executor.attack.component import ( - ConversationManager, - PrependedConversationConfig, -) +from pyrit.exceptions import ComponentRole, execution_context +from pyrit.executor.attack.component import ConversationManager, PrependedConversationConfig from pyrit.executor.attack.component.adversarial_conversation_manager import _AdversarialConversationManager from pyrit.executor.attack.component.modality_router import _ModalityFeedbackRouter -from pyrit.executor.attack.core import ( - AttackAdversarialConfig, - AttackConverterConfig, - AttackScoringConfig, -) +from pyrit.executor.attack.core import AttackAdversarialConfig, AttackConverterConfig, AttackScoringConfig from pyrit.executor.attack.multi_turn.multi_turn_attack_strategy import ( ConversationSession, MultiTurnAttackContext, @@ -42,12 +32,15 @@ Message, MessagePiece, Score, + ScoringExpectation, SeedPrompt, ) from pyrit.prompt_normalizer import PromptNormalizer from pyrit.prompt_target import CapabilityName, TargetRequirements from pyrit.score import ( FloatScaleThresholdScorer, + MessageScorable, + MessageScoringOptions, NumericRubric, Scorer, SelfAskRefusalScorer, @@ -681,9 +674,9 @@ async def _check_refusal_async(self, context: CrescendoAttackContext, objective: objective=context.objective, ): scores = await self._refusal_scorer.score_async( - message=context.last_response, - objective=objective, - skip_on_error_result=False, + scorable=MessageScorable.from_message(context.last_response), + expectation=ScoringExpectation(objective=objective), + message_options=MessageScoringOptions(skip_on_error_result=False), ) return scores[0] diff --git a/pyrit/executor/attack/multi_turn/red_teaming.py b/pyrit/executor/attack/multi_turn/red_teaming.py index 402eb1303b..c9cb196f36 100644 --- a/pyrit/executor/attack/multi_turn/red_teaming.py +++ b/pyrit/executor/attack/multi_turn/red_teaming.py @@ -18,11 +18,7 @@ get_adversarial_chat_messages, ) from pyrit.executor.attack.component.modality_router import _ModalityFeedbackRouter -from pyrit.executor.attack.core.attack_config import ( - AttackAdversarialConfig, - AttackConverterConfig, - AttackScoringConfig, -) +from pyrit.executor.attack.core.attack_config import AttackAdversarialConfig, AttackConverterConfig, AttackScoringConfig from pyrit.executor.attack.multi_turn.multi_turn_attack_strategy import ( ConversationSession, MultiTurnAttackContext, @@ -38,10 +34,12 @@ ConversationType, Message, Score, + ScoringExpectation, ) from pyrit.prompt_normalizer import PromptNormalizer from pyrit.prompt_target import CapabilityName from pyrit.prompt_target.common.target_requirements import TargetRequirements +from pyrit.score import MessageScorable, MessageScoringOptions if TYPE_CHECKING: from collections.abc import Callable @@ -527,9 +525,9 @@ async def _score_response_async(self, *, context: MultiTurnAttackContext[Any]) - ): # score_async handles blocked, filtered, other errors scoring_results = await self._objective_scorer.score_async( - message=context.last_response, - role_filter="assistant", - objective=context.objective, + scorable=MessageScorable.from_message(context.last_response), + expectation=ScoringExpectation(objective=context.objective), + message_options=MessageScoringOptions(role_filter="assistant"), ) objective_scores = scoring_results diff --git a/pyrit/models/__init__.py b/pyrit/models/__init__.py index 289187f44c..667a2ca033 100644 --- a/pyrit/models/__init__.py +++ b/pyrit/models/__init__.py @@ -69,27 +69,26 @@ group_message_pieces_into_conversations, sort_message_pieces, ) -from pyrit.models.messages.chat_message import ( - ALLOWED_CHAT_MESSAGE_ROLES, - ChatMessage, - ChatMessagesDataset, - ToolCall, -) +from pyrit.models.messages.chat_message import ALLOWED_CHAT_MESSAGE_ROLES, ChatMessage, ChatMessagesDataset, ToolCall from pyrit.models.messages.conversation_reference import ConversationReference, ConversationType from pyrit.models.messages.conversation_retry import ConversationRetry, ConversationRetryReason -from pyrit.models.parameter import ( - ComponentType, - Parameter, - ParameterDestination, - RegistryReference, - display_choices, -) +from pyrit.models.parameter import ComponentType, Parameter, ParameterDestination, RegistryReference, display_choices from pyrit.models.question_answering import QuestionAnsweringDataset, QuestionAnsweringEntry, QuestionChoice from pyrit.models.results.attack_result import AttackOutcome, AttackResult, AttackResultT from pyrit.models.results.scenario_result import ScenarioResult, ScenarioRunState from pyrit.models.results.strategy_result import StrategyResult, StrategyResultT from pyrit.models.retry_event import RetryEvent -from pyrit.models.score import Score, ScoreType, UnvalidatedScore +from pyrit.models.score import ( + Condition, + ContentScorable, + MatchesObjective, + MessageScorable, + Scorable, + Score, + ScoreType, + ScoringExpectation, + UnvalidatedScore, +) # Seeds - import from new seeds submodule for forward compatibility # Also keep imports from old locations for backward compatibility @@ -143,6 +142,7 @@ "ComponentIdentifier", "ComponentType", "compute_eval_hash", + "Condition", "config_hash", "ConverterIdentifier", "Conversation", @@ -151,6 +151,7 @@ "ConversationRetryReason", "ConversationStats", "ConversationType", + "ContentScorable", "construct_response_from_request", "display_choices", "EmbeddingData", @@ -178,9 +179,11 @@ "JSON_SCHEMA_METADATA_KEY", "SEED_RESPONSE_JSON_SCHEMA_METADATA_KEY", "JsonSchemaDefinition", + "MatchesObjective", "MEDIA_PATH_DATA_TYPES", "Message", "MessagePiece", + "MessageScorable", "Modality", "NextMessageSystemPromptPaths", "ObjectiveTargetEvaluationIdentifier", @@ -194,8 +197,10 @@ "QuestionChoice", "REGISTRY_NAME_PATTERN", "ScaleDescription", + "Scorable", "Score", "ScoreType", + "ScoringExpectation", "ScenarioEvaluationIdentifier", "ScorerEvaluationIdentifier", "ScorerIdentifier", diff --git a/pyrit/models/score/__init__.py b/pyrit/models/score/__init__.py new file mode 100644 index 0000000000..b870e813b9 --- /dev/null +++ b/pyrit/models/score/__init__.py @@ -0,0 +1,28 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Score types: what a scorer looks at, what it scores against, and the result. + +A scorer takes two inputs — a ``Scorable`` (what to look at) and a +``ScoringExpectation`` (what to look for) — and returns ``Score`` objects. Scorables +are inert canonical data; scoring-layer resolvers acquire the evidence they name. +""" + +from pyrit.models.score.condition import Condition, MatchesObjective +from pyrit.models.score.expectation import ScoringExpectation +from pyrit.models.score.scorable import ContentScorable, MessageScorable, Scorable +from pyrit.models.score.score import ComponentIdentifierField, Score, ScoreType, UnvalidatedScore + +__all__ = [ + "ComponentIdentifierField", + "Condition", + "ContentScorable", + "MatchesObjective", + "MessageScorable", + "Scorable", + "Score", + "ScoreType", + "ScoringExpectation", + "UnvalidatedScore", +] diff --git a/pyrit/models/score/condition.py b/pyrit/models/score/condition.py new file mode 100644 index 0000000000..25ddaeedad --- /dev/null +++ b/pyrit/models/score/condition.py @@ -0,0 +1,28 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from __future__ import annotations + +from abc import ABC +from dataclasses import dataclass + + +class Condition(ABC): # noqa: B024 root type; each scoring domain declares its own criterion + """ + What counts as satisfied. + + A condition is a neutral predicate about evidence: it says what to detect, never + whether detecting it is good or bad. Polarity belongs to a scorer that wraps another, + such as ``TrueFalseInverterScorer``. Each scoring domain adds its own subclass. + """ + + +@dataclass(frozen=True, kw_only=True) +class MatchesObjective(Condition): + """ + The evidence satisfies the expectation's own objective, as a judge reads it. + + This carries no text of its own. The objective lives on the ``ScoringExpectation``, + so a scorer matching this condition reads it from there and the two can never + disagree. + """ diff --git a/pyrit/models/score/expectation.py b/pyrit/models/score/expectation.py new file mode 100644 index 0000000000..b795a17855 --- /dev/null +++ b/pyrit/models/score/expectation.py @@ -0,0 +1,30 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from __future__ import annotations + +from dataclasses import dataclass, field + +from pyrit.models.score.condition import Condition # noqa: TC001 (runtime-required by dataclass field annotations) + + +@dataclass(frozen=True, kw_only=True) +class ScoringExpectation: + """ + What a scorer scores against. + + An expectation is a single parameter, so a question authored in a technique + configuration or a seed can reach a scorer through an attack that knows nothing + about it. It has two independent axes. + + ``objective`` carries the intent: prose describing what the run is trying to do. + Components read it for framing — an adversarial target renders it into a system + prompt, a report prints it — and none of them match it. + + ``conditions`` carry the criteria: typed objects routed by type to the scorers that + match them. Attacks forward them without inspecting them, and a scorer matches at + most one of them. + """ + + objective: str | None = None + conditions: tuple[Condition, ...] = field(default_factory=tuple) diff --git a/pyrit/models/score/scorable.py b/pyrit/models/score/scorable.py new file mode 100644 index 0000000000..27c5eeebd5 --- /dev/null +++ b/pyrit/models/score/scorable.py @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from __future__ import annotations + +import uuid # noqa: TC003 (runtime-required by dataclass field annotations) +from abc import ABC +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from pyrit.models.literals import PromptDataType # noqa: TC001 (runtime-required by dataclass field annotations) + +if TYPE_CHECKING: + from pyrit.models.messages.message import Message + + +class Scorable(ABC): # noqa: B024 root type; each scorer family declares its own contract + """ + What a scorer looks at. + + A scorable is normally an inert reference: it names the evidence instead of carrying + or acquiring it. ``ContentScorable`` is the exception, because loose content has + nothing behind it to point at. A scorer-family resolver acquires the named evidence. + """ + + +@dataclass(frozen=True, kw_only=True) +class MessageScorable(Scorable): + """ + Specific message pieces, named by id. + + This names one message, or a subset of its pieces. Loose content that was never + persisted has no ids to name, so it is a ``ContentScorable`` instead. + """ + + message_piece_ids: tuple[uuid.UUID | str, ...] + + def __post_init__(self) -> None: + """ + Reject id tuples that cannot name evidence. + + Raises: + ValueError: If no ids are given, or if an id is repeated. + """ + if not self.message_piece_ids: + raise ValueError("A MessageScorable must name at least one message piece.") + seen = [str(piece_id) for piece_id in self.message_piece_ids] + if len(set(seen)) != len(seen): + raise ValueError(f"A MessageScorable must name each message piece once, got {seen}.") + + @classmethod + def from_message( + cls, + message: Message, + ) -> MessageScorable: + """ + Name the pieces of a persisted message. + + Args: + message (Message): The message whose pieces to name. + + Returns: + MessageScorable: A scorable naming the message's pieces. + """ + return cls(message_piece_ids=tuple(piece.id for piece in message.message_pieces)) + + +@dataclass(frozen=True, kw_only=True) +class ContentScorable(Scorable): + """ + Loose content with no conversation behind it. + + This names content, not a message: there is no role or error state. A message-family + resolver adapts it for existing message scorers. + """ + + value: str + data_type: PromptDataType = "text" + + @classmethod + def from_message(cls, message: Message) -> ContentScorable: + """ + Describe the converted content of a single-piece ephemeral message. + + Scorers consume ``converted_value``, so this adapter preserves the converted value + and data type rather than the pre-conversion input. Everything else the message + carried is dropped, including its role and its error state, so a scorer's + deterministic blocked-response handling no longer applies. Use + ``MessageScorer.score_message_async`` when that state is part of the evidence. + + Args: + message (Message): The ephemeral message whose converted content to take. + + Returns: + ContentScorable: A scorable holding the converted message content. + """ + piece = message.get_piece() + return cls(value=piece.converted_value, data_type=piece.converted_value_data_type) diff --git a/pyrit/models/score.py b/pyrit/models/score/score.py similarity index 100% rename from pyrit/models/score.py rename to pyrit/models/score/score.py diff --git a/pyrit/score/__init__.py b/pyrit/score/__init__.py index fc1a130847..26684946a6 100644 --- a/pyrit/score/__init__.py +++ b/pyrit/score/__init__.py @@ -38,11 +38,14 @@ render_scale_system_prompt, ) from pyrit.score.float_scale.system_prompt_extraction_scorer import SystemPromptExtractionScorer +from pyrit.score.message_scorable_resolver import MessageScorableResolver +from pyrit.score.message_scorer import MessageScorer, MessageScoringOptions from pyrit.score.response_handler import ( CallableResponseHandler, JsonSchemaResponseHandler, ResponseHandler, ) +from pyrit.score.scorable import ContentScorable, MessageScorable, Scorable from pyrit.score.scorer import Scorer from pyrit.score.scorer_evaluation.metrics_type import MetricsType, RegistryUpdateBehavior from pyrit.score.scorer_evaluation.scorer_metrics import ( @@ -177,6 +180,7 @@ def __getattr__(name: str) -> object: "AzureContentFilterScorer", "BatchScorer", "CallableResponseHandler", + "ContentScorable", "ContentClassifier", "ContentClassifierCategory", "ContentClassifierPaths", @@ -209,6 +213,10 @@ def __getattr__(name: str) -> object: "LlamaGuardPolicy", "LlamaGuardScorer", "MarkdownInjectionScorer", + "MessageScorableResolver", + "MessageScorable", + "MessageScorer", + "MessageScoringOptions", "MethKeywordScorer", "MetricsType", "NerveAgentKeywordScorer", @@ -238,6 +246,7 @@ def __getattr__(name: str) -> object: "render_true_false_system_prompt", "ResponseHandler", "Scorer", + "Scorable", "ScorerEvalDatasetFiles", "ScorerEvaluator", "ScorerMetrics", diff --git a/pyrit/score/audio_transcript_scorer.py b/pyrit/score/audio_transcript_scorer.py index b49d32842e..b08e64d2c0 100644 --- a/pyrit/score/audio_transcript_scorer.py +++ b/pyrit/score/audio_transcript_scorer.py @@ -11,7 +11,7 @@ from pyrit.converter import AzureSpeechAudioToTextConverter from pyrit.memory import CentralMemory -from pyrit.models import MessagePiece, Score +from pyrit.models import MessagePiece, MessageScorable, Score, ScoringExpectation from pyrit.score.scorer import Scorer logger = logging.getLogger(__name__) @@ -185,7 +185,10 @@ async def _score_audio_async(self, *, message_piece: MessagePiece, objective: st memory.add_message_to_memory(request=text_message) # Score the transcript - transcript_scores = await self.text_scorer.score_async(message=text_message, objective=objective) + transcript_scores = await self.text_scorer.score_async( + scorable=MessageScorable.from_message(text_message), + expectation=ScoringExpectation(objective=objective), + ) # Add context to indicate this was scored from audio transcription for score in transcript_scores: diff --git a/pyrit/score/conversation_scorer.py b/pyrit/score/conversation_scorer.py index 81acb43624..5ddf784947 100644 --- a/pyrit/score/conversation_scorer.py +++ b/pyrit/score/conversation_scorer.py @@ -4,8 +4,9 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, cast -from pyrit.models import ComponentIdentifier, Message, MessagePiece, Score +from pyrit.models import ComponentIdentifier, Condition, Message, MessagePiece, Score, ScoringExpectation from pyrit.score.float_scale.float_scale_scorer import FloatScaleScorer +from pyrit.score.message_scorer import MessageScorer from pyrit.score.scorer import Scorer from pyrit.score.scorer_prompt_validator import ScorerPromptValidator from pyrit.score.true_false.true_false_scorer import TrueFalseScorer @@ -14,7 +15,7 @@ from uuid import UUID -class ConversationScorer(Scorer, ABC): +class ConversationScorer(MessageScorer, ABC): """ Scorer that evaluates entire conversation history rather than individual messages. @@ -33,7 +34,30 @@ class ConversationScorer(Scorer, ABC): enforce_all_pieces_valid=False, ) - async def _score_async(self, message: Message, *, objective: str | None = None) -> list[Score]: + def matched_conditions(self) -> frozenset[type[Condition]]: + """ + Report the conditions matched by the wrapped scorer. + + Returns: + frozenset[type[Condition]]: The matched condition types. + """ + return self._get_wrapped_scorer().matched_conditions() + + def required_conditions(self) -> frozenset[type[Condition]]: + """ + Report the conditions required by the wrapped scorer. + + Returns: + frozenset[type[Condition]]: The required condition types. + """ + return self._get_wrapped_scorer().required_conditions() + + async def _score_prepared_message_async( + self, + *, + message: Message, + expectation: ScoringExpectation | None, + ) -> list[Score]: """ Scores the entire conversation history by concatenating all messages and passing to the wrapped scorer. @@ -45,7 +69,7 @@ async def _score_async(self, message: Message, *, objective: str | None = None) conversation, even when the triggering turn was blocked or errored; the wrapped scorer's fallback only fires when the rendered conversation is genuinely unscoreable. - The wrapped scorer is invoked via its protected ``_score_async`` so it does not + The wrapped scorer is invoked via its protected prepared-message hook so it does not persist its own copy of the scores. The outer ``Scorer.score_async`` that invoked this method persists the returned scores exactly once, keyed to the original ``message_piece_id``. @@ -53,7 +77,7 @@ async def _score_async(self, message: Message, *, objective: str | None = None) Args: message (Message): A message from the conversation to be scored. The conversation ID from the first message piece is used to retrieve the full conversation from memory. - objective (str | None): Optional objective to evaluate against. + expectation (ScoringExpectation | None): What the wrapped scorer should look for. Returns: list[Score]: List of Score objects from the underlying scorer @@ -64,6 +88,8 @@ async def _score_async(self, message: Message, *, objective: str | None = None) if not message.message_pieces: return [] + objective = expectation.objective if expectation else None + # Get conversation ID from the first message piece conversation_id = message.message_pieces[0].conversation_id @@ -123,22 +149,30 @@ async def _score_async(self, message: Message, *, objective: str | None = None) ) wrapped_scorer = self._get_wrapped_scorer() - # Call the wrapped scorer's protected ``_score_async`` rather than the public + # Call the wrapped scorer's protected prepared-message hook rather than the public # ``score_async`` so the wrapped scorer does not persist its own copy of the # scores. - return await wrapped_scorer._score_async(message=conversation_message, objective=objective) + wrapped_scorer._validate_expectation( + expectation=expectation, + allow_unmatched_conditions=True, + ) + return await wrapped_scorer._score_prepared_message_async( + message=conversation_message, + expectation=expectation, + ) async def _score_piece_async(self, message_piece: MessagePiece, *, objective: str | None = None) -> list[Score]: """ - Not used - ConversationScorer operates at conversation level via _score_async. + Not used - ConversationScorer operates at conversation level via + ``_score_prepared_message_async``. This implementation satisfies the Scorer ABC requirement but is never called - since ConversationScorer overrides _score_async. + since ConversationScorer overrides ``_score_prepared_message_async``. """ - raise NotImplementedError("ConversationScorer uses _score_async, not _score_piece_async") + raise NotImplementedError("ConversationScorer does not support piecewise scoring") @abstractmethod - def _get_wrapped_scorer(self) -> Scorer: + def _get_wrapped_scorer(self) -> MessageScorer: """ Abstract method to enforce that ConversationScorer cannot be instantiated directly. @@ -199,16 +233,19 @@ def create_conversation_scorer( f"Scorer must be an instance of FloatScaleScorer or TrueFalseScorer." ) + # Both branches above narrow to a MessageScorer, which supplies the prepared-message hook. + wrapped_scorer: MessageScorer = scorer + # Dynamically create a class that inherits from both ConversationScorer and the scorer's base class class DynamicConversationScorer(ConversationScorer, scorer_base_class): # type: ignore[valid-type] # type: ignore[ty:unsupported-base] """Dynamic ConversationScorer that inherits from both ConversationScorer and the wrapped scorer's base class.""" def __init__(self) -> None: # Initialize with the validator and wrapped scorer - Scorer.__init__(self, validator=validator or ConversationScorer._DEFAULT_VALIDATOR) - self._wrapped_scorer = scorer + MessageScorer.__init__(self, validator=validator or ConversationScorer._DEFAULT_VALIDATOR) + self._wrapped_scorer = wrapped_scorer - def _get_wrapped_scorer(self) -> Scorer: + def _get_wrapped_scorer(self) -> MessageScorer: """Return the wrapped scorer.""" return self._wrapped_scorer diff --git a/pyrit/score/float_scale/audio_float_scale_scorer.py b/pyrit/score/float_scale/audio_float_scale_scorer.py index 183c1379b0..0b81181e3b 100644 --- a/pyrit/score/float_scale/audio_float_scale_scorer.py +++ b/pyrit/score/float_scale/audio_float_scale_scorer.py @@ -2,7 +2,7 @@ # Licensed under the MIT license. -from pyrit.models import ComponentIdentifier, MessagePiece, Score +from pyrit.models import ComponentIdentifier, Condition, MessagePiece, Score from pyrit.score.audio_transcript_scorer import AudioTranscriptHelper from pyrit.score.float_scale.float_scale_scorer import FloatScaleScorer from pyrit.score.scorer_prompt_validator import ScorerPromptValidator @@ -51,6 +51,24 @@ def _build_identifier(self) -> ComponentIdentifier: sub_scorers=[self._audio_helper.text_scorer.get_identifier()], ) + def matched_conditions(self) -> frozenset[type[Condition]]: + """ + Report the conditions matched by the transcript scorer. + + Returns: + frozenset[type[Condition]]: The matched condition types. + """ + return self._audio_helper.text_scorer.matched_conditions() + + def required_conditions(self) -> frozenset[type[Condition]]: + """ + Report the conditions required by the transcript scorer. + + Returns: + frozenset[type[Condition]]: The required condition types. + """ + return self._audio_helper.text_scorer.required_conditions() + async def _score_piece_async(self, message_piece: MessagePiece, *, objective: str | None = None) -> list[Score]: """ Score an audio file by transcribing it and scoring the transcript. diff --git a/pyrit/score/float_scale/float_scale_scorer.py b/pyrit/score/float_scale/float_scale_scorer.py index 538ea44d1c..02413eaa20 100644 --- a/pyrit/score/float_scale/float_scale_scorer.py +++ b/pyrit/score/float_scale/float_scale_scorer.py @@ -5,19 +5,17 @@ from typing import TYPE_CHECKING -from pyrit.models import ( - Message, - Score, -) -from pyrit.score.scorer import Scorer +from pyrit.models import Message, Score +from pyrit.score.message_scorer import MessageScorer if TYPE_CHECKING: from pyrit.prompt_target.common.prompt_target import PromptTarget + from pyrit.score.message_scorable_resolver import MessageScorableResolver from pyrit.score.scorer_evaluation.scorer_metrics import HarmScorerMetrics from pyrit.score.scorer_prompt_validator import ScorerPromptValidator -class FloatScaleScorer(Scorer): +class FloatScaleScorer(MessageScorer): """ Base class for scorers that return floating-point scores in the range [0, 1]. @@ -38,7 +36,13 @@ class FloatScaleScorer(Scorer): "blocked = True") should override ``_score_piece_async`` or ``_build_fallback_score``. """ - def __init__(self, *, validator: ScorerPromptValidator, chat_target: PromptTarget | None = None) -> None: + def __init__( + self, + *, + validator: ScorerPromptValidator, + chat_target: PromptTarget | None = None, + message_resolver: MessageScorableResolver | None = None, + ) -> None: """ Initialize the FloatScaleScorer. @@ -46,8 +50,13 @@ def __init__(self, *, validator: ScorerPromptValidator, chat_target: PromptTarge validator: A validator object used to validate scores. chat_target: Optional chat target used by the scorer, forwarded to the base class for validation against ``TARGET_REQUIREMENTS``. + message_resolver: Message evidence resolver. """ - super().__init__(validator=validator, chat_target=chat_target) + super().__init__( + validator=validator, + chat_target=chat_target, + message_resolver=message_resolver, + ) def _build_fallback_score( self, *, message: Message, objective: str | None, scorer_response_blocked: bool = False @@ -127,9 +136,7 @@ def get_scorer_metrics(self) -> HarmScorerMetrics | None: Returns: HarmScorerMetrics: The metrics for this scorer, or None if not found or not configured. """ - from pyrit.score.scorer_evaluation.scorer_metrics_io import ( - find_harm_metrics_by_eval_hash, - ) + from pyrit.score.scorer_evaluation.scorer_metrics_io import find_harm_metrics_by_eval_hash if self.evaluation_file_mapping is None or self.evaluation_file_mapping.harm_category is None: return None diff --git a/pyrit/score/float_scale/video_float_scale_scorer.py b/pyrit/score/float_scale/video_float_scale_scorer.py index b2a2c62d83..7ae07fe118 100644 --- a/pyrit/score/float_scale/video_float_scale_scorer.py +++ b/pyrit/score/float_scale/video_float_scale_scorer.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING -from pyrit.models import ComponentIdentifier, MessagePiece, Score +from pyrit.models import ComponentIdentifier, Condition, MessagePiece, Score from pyrit.score.float_scale.float_scale_score_aggregator import ( FloatScaleAggregatorFunc, FloatScaleScorerByCategory, @@ -114,6 +114,30 @@ def _build_identifier(self) -> ComponentIdentifier: sub_scorers=sub_scorer_ids, ) + def matched_conditions(self) -> frozenset[type[Condition]]: + """ + Report the union of conditions matched by the media scorers. + + Returns: + frozenset[type[Condition]]: The matched condition types. + """ + scorers = [self._video_helper.image_scorer] + if self.audio_scorer: + scorers.append(self.audio_scorer) + return frozenset().union(*(scorer.matched_conditions() for scorer in scorers)) + + def required_conditions(self) -> frozenset[type[Condition]]: + """ + Report the union of conditions required by the media scorers. + + Returns: + frozenset[type[Condition]]: The required condition types. + """ + scorers = [self._video_helper.image_scorer] + if self.audio_scorer: + scorers.append(self.audio_scorer) + return frozenset().union(*(scorer.required_conditions() for scorer in scorers)) + 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/message_scorable_resolver.py b/pyrit/score/message_scorable_resolver.py new file mode 100644 index 0000000000..18582fc379 --- /dev/null +++ b/pyrit/score/message_scorable_resolver.py @@ -0,0 +1,80 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyrit.models import ( + ContentScorable, + Message, + MessagePiece, + MessageScorable, + Scorable, + group_message_pieces_into_conversations, +) + +if TYPE_CHECKING: + from pyrit.memory import MemoryInterface + + +class MessageScorableResolver: + """Acquire message-shaped evidence for a ``MessageScorer``.""" + + def resolve(self, *, scorable: Scorable, memory: MemoryInterface) -> Message: + """ + Resolve supported scorables to the message view consumed by message scorers. + + Args: + scorable (Scorable): A message reference or loose content. + memory (MemoryInterface): Memory used to resolve message references. + + Returns: + Message: The message view to score. + + Raises: + TypeError: If the scorable is not message-shaped. + ValueError: If referenced pieces are missing or do not form one message. + """ + if isinstance(scorable, MessageScorable): + return self._resolve_message_reference(scorable=scorable, memory=memory) + if isinstance(scorable, ContentScorable): + return self._adapt_content(scorable=scorable) + raise TypeError( + f"Message scorers cannot score {type(scorable).__name__}. Pass a MessageScorable or a ContentScorable." + ) + + @staticmethod + def _resolve_message_reference(*, scorable: MessageScorable, memory: MemoryInterface) -> Message: + pieces = memory.get_message_pieces(prompt_ids=list(scorable.message_piece_ids)) + wanted = {str(piece_id) for piece_id in scorable.message_piece_ids} + pieces = [piece for piece in pieces if str(piece.id) in wanted] + found = {str(piece.id) for piece in pieces} + missing = [str(piece_id) for piece_id in scorable.message_piece_ids if str(piece_id) not in found] + if missing: + raise ValueError(f"No message pieces found in memory for ids {missing}.") + + conversations = group_message_pieces_into_conversations(pieces) + messages = [message for conversation in conversations for message in conversation] + if len(messages) != 1: + raise ValueError( + f"Expected the referenced pieces to form exactly one message, got {len(messages)}. " + "Reference pieces from a single message." + ) + + resolved = messages[0] + by_id = {str(piece.id): piece for piece in resolved.message_pieces} + resolved.message_pieces = [by_id[str(piece_id)] for piece_id in scorable.message_piece_ids] + return resolved + + @staticmethod + def _adapt_content(*, scorable: ContentScorable) -> Message: + piece = MessagePiece( + role="user", + original_value=scorable.value, + converted_value=scorable.value, + original_value_data_type=scorable.data_type, + converted_value_data_type=scorable.data_type, + ) + piece.not_in_memory = True + return piece.to_message() diff --git a/pyrit/score/message_scorer.py b/pyrit/score/message_scorer.py new file mode 100644 index 0000000000..1a79272904 --- /dev/null +++ b/pyrit/score/message_scorer.py @@ -0,0 +1,727 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from __future__ import annotations + +import asyncio +import logging +from abc import abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +from pyrit.common.deprecation import print_deprecation_message +from pyrit.exceptions import PyritException, ScorerLLMResponseBlockedException +from pyrit.models import ( + ChatMessageRole, + Condition, + MatchesObjective, + Message, + MessagePiece, + PromptResponseError, + Scorable, + Score, + ScoringExpectation, +) +from pyrit.score.message_scorable_resolver import MessageScorableResolver +from pyrit.score.scorer import LEGACY_SCORE_ASYNC_REMOVED_IN, Scorer + +if TYPE_CHECKING: + from pyrit.memory import MemoryInterface + from pyrit.prompt_target import PromptTarget + from pyrit.score.scorer_prompt_validator import ScorerPromptValidator + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class MessageScoringOptions: + """Message-only scoring policy that is not part of evidence identity.""" + + role_filter: ChatMessageRole | None = None + skip_on_error_result: bool = False + + +def extract_objective_from_previous_turn(*, message: Message, memory: MemoryInterface) -> str: + """ + Read the text of the turn before an assistant message and use it as the objective. + + .. deprecated:: + This conflates scoring with building an expectation. What to look for belongs to + the caller that builds the ``ScoringExpectation``, not to the scorer. It exists only + to support the deprecated ``infer_objective_from_request`` parameter, and both are + removed in the next major release. Resolve the objective at the call site and pass + it on the expectation instead. + + Args: + message (Message): The assistant message whose previous turn supplies the objective. + memory (MemoryInterface): Memory holding the conversation. + + Returns: + str: The previous turn's text, or an empty string when there is none. + """ + if not message.message_pieces: + return "" + + scored_piece = message.get_piece() + + if scored_piece.api_role != "assistant": + return "" + + # The request is the turn before the response being scored, not before whatever the + # conversation has grown to since. Scoring an earlier response must not read the latest turn. + previous_sequence = scored_piece.sequence - 1 + if previous_sequence < 0: + return "" + + conversation = memory.get_message_pieces(conversation_id=scored_piece.conversation_id) + + return "\n".join( + [ + piece.original_value + for piece in conversation + if piece.sequence == previous_sequence and piece.original_value_data_type == "text" + ] + ) + + +class MessageScorer(Scorer): + """ + Base class for scorers whose evidence is a single message. + + Every message-shaped concern lives here: substituting refusal and blocked content, + validating pieces, applying the role and error filters, and falling back to a neutral + score. ``Scorer`` stays agnostic about what a scorable is, so scorers over other kinds of + evidence can sit beside this one. A ``MessageScorableResolver`` acquires the message; + the scorable remains inert. + + Subclasses implement ``_score_async``, which still receives a ``Message``. + """ + + #: When True, blocked responses that contain partial content are scored using that + #: content instead of being filtered out or short-circuited. + score_blocked_content: bool = False + + #: When False, a blocked response from the scorer's own LLM produces the scorer + #: family's neutral fallback score instead of raising. + raise_if_scorer_blocks: bool = True + + def __init__( + self, + *, + validator: ScorerPromptValidator, + chat_target: PromptTarget | None = None, + message_resolver: MessageScorableResolver | None = None, + ) -> None: + """ + Initialize message-specific scoring dependencies. + + Args: + validator (ScorerPromptValidator): Validator for message pieces. + chat_target (PromptTarget | None): Optional target used by the scorer. + message_resolver (MessageScorableResolver | None): Evidence resolver. + """ + self._validator = validator + self._message_resolver = message_resolver or MessageScorableResolver() + super().__init__(chat_target=chat_target) + + def matched_conditions(self) -> frozenset[type[Condition]]: + """ + Return the conditions this message scorer uses as criteria. + + An objective-required validator is the existing declaration that the scorer judges + whether the evidence satisfies the objective. Other message scorers may read the + objective as context without matching ``MatchesObjective``. + + Returns: + frozenset[type[Condition]]: The matched condition types. + """ + matched = super().matched_conditions() + if self._validator.is_objective_required: + return matched | {MatchesObjective} + return matched + + def required_conditions(self) -> frozenset[type[Condition]]: + """Return the matched conditions required by this message scorer.""" + required = super().required_conditions() + if self._validator.is_objective_required: + return required | {MatchesObjective} + return required + + def _validate_expectation( + self, + *, + expectation: ScoringExpectation | None, + allow_unmatched_conditions: bool = False, + ) -> None: + """ + Reject conditions this scorer cannot consume, and unusable ``MatchesObjective``. + + Args: + expectation (ScoringExpectation | None): The expectation to validate. + allow_unmatched_conditions (bool): Permit conditions addressed to sibling leaves. + + Raises: + ValueError: If ``MatchesObjective`` is present without an objective to match. + """ + super()._validate_expectation( + expectation=expectation, + allow_unmatched_conditions=allow_unmatched_conditions, + ) + if expectation is None or not expectation.conditions: + return + matches_objective = MatchesObjective in self.matched_conditions() and any( + isinstance(condition, MatchesObjective) for condition in expectation.conditions + ) + if matches_objective and not expectation.objective: + raise ValueError( + "MatchesObjective requires the expectation to carry an objective. " + "Set ScoringExpectation.objective or drop the condition." + ) + + async def score_async( + self, + message: Message | None = None, + *, + scorable: Scorable | None = None, + expectation: ScoringExpectation | None = None, + message_options: MessageScoringOptions | None = None, + objective: str | None = None, + role_filter: ChatMessageRole | None = None, + skip_on_error_result: bool | None = None, + infer_objective_from_request: bool | None = None, + ) -> list[Score]: + """ + Score message-shaped evidence, including the deprecated message API. + + Args: + message (Message | None): Deprecated in-hand message. + scorable (Scorable | None): Message-shaped evidence to acquire. + expectation (ScoringExpectation | None): What to look for. + message_options (MessageScoringOptions | None): Message-family policy. + objective (str | None): Deprecated objective string. + role_filter (ChatMessageRole | None): Deprecated role policy. + skip_on_error_result (bool | None): Deprecated error policy. ``None`` means omitted. + infer_objective_from_request (bool | None): Deprecated inference policy. + + Returns: + list[Score]: The persisted scores, or an empty list when policy skips the message. + """ + resolved_expectation, options, infer_objective = self._consolidate_message_inputs( + message=message, + scorable=scorable, + expectation=expectation, + message_options=message_options, + objective=objective, + role_filter=role_filter, + skip_on_error_result=skip_on_error_result, + infer_objective_from_request=infer_objective_from_request, + ) + self._validate_expectation(expectation=resolved_expectation) + + # The deprecated parameter hands over the message itself, so scoring it must not round + # trip through a reference. Re-describing it would reload the persisted originals and + # drop role and error state for a message that was never persisted at all. + if message is not None: + scores = await self._score_resolved_message_async( + message=message, + expectation=resolved_expectation, + options=options, + infer_objective_from_request=infer_objective, + ) + else: + scores = await self._score_message_scorable_async( + scorable=cast("Scorable", scorable), + expectation=resolved_expectation, + options=options, + infer_objective_from_request=infer_objective, + ) + return self._validate_and_persist_scores(scores=scores) + + async def _score_nested_message_async( + self, + *, + message: Message, + expectation: ScoringExpectation | None, + ) -> list[Score]: + """ + Score a prepared message as a child in a scorer tree. + + Conditions addressed to sibling leaves are ignored here because the root scorer + already validates that every supplied condition reaches at least one leaf. The + root scorer owns persistence, so this path only validates child output. + + Returns: + list[Score]: The validated child scores. + """ + self._validate_expectation( + expectation=expectation, + allow_unmatched_conditions=True, + ) + scores = await self._score_resolved_message_async( + message=message, + expectation=expectation, + options=MessageScoringOptions(), + infer_objective_from_request=False, + ) + if scores: + self.validate_return_scores(scores=scores) + return scores + + async def score_message_async( + self, + *, + message: Message, + expectation: ScoringExpectation | None = None, + message_options: MessageScoringOptions | None = None, + ) -> list[Score]: + """ + Score a message that is already in hand. + + Use this when the caller holds the message itself rather than a reference to it: + an ephemeral response that was never persisted, or a scoring view a wrapping scorer + has already prepared. Naming persisted evidence with a ``MessageScorable`` stays the + default, because a reference is what a stored score can be audited against. + + Args: + message (Message): The message to score. + expectation (ScoringExpectation | None): What to look for. Defaults to None. + message_options (MessageScoringOptions | None): Message-family policy. Defaults to None. + + Returns: + list[Score]: The persisted scores, or an empty list when policy skips the message. + """ + self._validate_expectation(expectation=expectation) + scores = await self._score_resolved_message_async( + message=message, + expectation=expectation, + options=message_options or MessageScoringOptions(), + infer_objective_from_request=False, + ) + return self._validate_and_persist_scores(scores=scores) + + def _consolidate_message_inputs( + self, + *, + message: Message | None, + scorable: Scorable | None, + expectation: ScoringExpectation | None, + message_options: MessageScoringOptions | None, + objective: str | None, + role_filter: ChatMessageRole | None, + skip_on_error_result: bool | None, + infer_objective_from_request: bool | None, + ) -> tuple[ScoringExpectation | None, MessageScoringOptions, bool]: + if message is not None and scorable is not None: + raise ValueError("Pass either 'message' or 'scorable', not both.") + if message is None and scorable is None: + raise ValueError("Either 'message' or 'scorable' must be provided.") + if objective is not None and expectation is not None: + raise ValueError("Pass either 'objective' or 'expectation', not both.") + if message_options is not None and (role_filter is not None or skip_on_error_result is not None): + raise ValueError("Pass either 'message_options' or legacy message policy arguments, not both.") + + uses_legacy_parameters = ( + message is not None + or objective is not None + or role_filter is not None + or skip_on_error_result is not None + or infer_objective_from_request is not None + ) + if uses_legacy_parameters: + print_deprecation_message( + old_item="Scorer.score_async(message=..., objective=..., role_filter=..., " + "skip_on_error_result=..., infer_objective_from_request=...)", + new_item="Scorer.score_async(scorable=..., expectation=..., message_options=...)", + removed_in=LEGACY_SCORE_ASYNC_REMOVED_IN, + ) + + resolved_expectation = ScoringExpectation(objective=objective) if objective is not None else expectation + options = message_options or MessageScoringOptions( + role_filter=role_filter, + skip_on_error_result=skip_on_error_result or False, + ) + return resolved_expectation, options, bool(infer_objective_from_request) + + async def _score_scorable_async( + self, + *, + scorable: Scorable, + expectation: ScoringExpectation | None, + ) -> list[Score]: + """ + Score message-shaped evidence with default message policy. + + Returns: + list[Score]: The scores produced from the resolved message. + """ + return await self._score_message_scorable_async( + scorable=scorable, + expectation=expectation, + options=MessageScoringOptions(), + infer_objective_from_request=False, + ) + + async def _score_message_scorable_async( + self, + *, + scorable: Scorable, + expectation: ScoringExpectation | None, + options: MessageScoringOptions, + infer_objective_from_request: bool, + ) -> list[Score]: + """ + Resolve a message scorable and score the message it names. + + Args: + scorable (Scorable): A ``MessageScorable`` or a ``ContentScorable``. + expectation (ScoringExpectation | None): What to look for. + options (MessageScoringOptions): Message-only scoring policy. + infer_objective_from_request (bool): Deprecated; read the objective from the + previous turn when the expectation carries none. + + Returns: + list[Score]: The scores, or an empty list when a filter skipped the message. + + Raises: + TypeError: If the scorable is not message-shaped. + """ + message = self._message_resolver.resolve(scorable=scorable, memory=self._memory) + return await self._score_resolved_message_async( + message=message, + expectation=expectation, + options=options, + infer_objective_from_request=infer_objective_from_request, + ) + + async def _score_resolved_message_async( + self, + *, + message: Message, + expectation: ScoringExpectation | None, + options: MessageScoringOptions, + infer_objective_from_request: bool, + ) -> list[Score]: + """ + Run the message-scoring pipeline over an acquired message. + + Args: + message (Message): The acquired message. + expectation (ScoringExpectation | None): What to look for. + options (MessageScoringOptions): Message-only scoring policy. + infer_objective_from_request (bool): Deprecated; read the objective from the + previous turn when the expectation carries none. + + Returns: + list[Score]: The scores, or an empty list when a filter skipped the message. + + Raises: + ScorerLLMResponseBlockedException: If the scorer's own LLM response is blocked by + content filtering and ``raise_if_scorer_blocks`` is True (the default). + PyritException: If scoring raises a PyRIT exception (re-raised with enhanced context). + RuntimeError: If scoring raises a non-PyRIT exception (wrapped with scorer context). + """ + objective = expectation.objective if expectation else None + + # Structured refusals are persisted as blocked error pieces, but scorers should + # receive the refusal explanation as text. Keep response_error="blocked" so + # refusal scorers can still use their deterministic blocked-response path. + scoring_message = self._apply_structured_refusal_substitution(message) + + # When score_blocked_content is enabled, blocked pieces with partial content + # take precedence and are replaced with text substitutes (response_error="none"). + if self.score_blocked_content: + scoring_message = self._apply_blocked_content_substitution(scoring_message) + + self._validator.validate(scoring_message, objective=objective) + + if options.role_filter is not None and message.get_piece().role != options.role_filter: + logger.debug("Skipping scoring due to role filter mismatch.") + return [] + + if options.skip_on_error_result and self._should_skip_on_error(message): + return [] + + if infer_objective_from_request and (not objective): + objective = extract_objective_from_previous_turn(message=message, memory=self._memory) + + effective_expectation = expectation + if expectation is None and objective is not None: + effective_expectation = ScoringExpectation(objective=objective) + elif expectation is not None and objective != expectation.objective: + effective_expectation = ScoringExpectation( + objective=objective, + conditions=expectation.conditions, + ) + + try: + scores = await self._score_prepared_message_async( + message=scoring_message, + expectation=effective_expectation, + ) + except ScorerLLMResponseBlockedException as e: + # The scorer's own LLM response was content-filtered. By default this is a real + # error and propagates; when raise_if_scorer_blocks is False, fall back to the + # scorer's type default (False / 0.0) instead. The decision lives here in the + # scorer, not the transport (see doc/code/framework.md). + if self.raise_if_scorer_blocks: + e.message = f"Error in scorer {self.__class__.__name__}: {e.message}" + e.args = (f"Status Code: {e.status_code}, Message: {e.message}",) + raise + logger.info( + "Scorer %s LLM response was blocked by content filtering; " + "returning default score (raise_if_scorer_blocks=False).", + self.__class__.__name__, + ) + scores = self._build_fallback_score( + message=scoring_message, + objective=objective, + scorer_response_blocked=True, + ) + except PyritException as e: + # Re-raise PyRIT exceptions with enhanced context while preserving type for retry decorators + e.message = f"Error in scorer {self.__class__.__name__}: {e.message}" + e.args = (f"Status Code: {e.status_code}, Message: {e.message}",) + raise + except Exception as e: + # Wrap non-PyRIT exceptions for better error tracing + raise RuntimeError(f"Error in scorer {self.__class__.__name__}: {str(e)}") from e + + if not scores and scoring_message.message_pieces: + scores = self._build_fallback_score(message=scoring_message, objective=objective) + + self._drop_ephemeral_score_links(message=scoring_message, scores=scores) + + return scores + + async def _score_prepared_message_async( + self, + *, + message: Message, + expectation: ScoringExpectation | None, + ) -> list[Score]: + """ + Score a message after message-family policy and substitutions are applied. + + Wrapping scorers override this hook to forward the complete expectation. Existing + leaf scorer bodies continue to receive only the objective string. + + Returns: + list[Score]: The scores produced from the prepared message. + """ + return await self._score_async( + message, + objective=expectation.objective if expectation else None, + ) + + async def _score_async(self, message: Message, *, objective: str | None = None) -> list[Score]: + """ + Score the given request response asynchronously. + + This default implementation scores all supported pieces in the message + and returns a flattened list of scores. Subclasses can override this method + to implement custom scoring logic (e.g., aggregating scores). + + Args: + message (Message): The message to score. + objective (str | None): The objective to evaluate against. Defaults to None. + + Returns: + list[Score]: A list of Score objects. + """ + if not message.message_pieces: + return [] + + # Score only the supported pieces + supported_pieces = self._get_supported_pieces(message) + + tasks = [self._score_piece_async(message_piece=piece, objective=objective) for piece in supported_pieces] + + if not tasks: + return [] + + # Run all piece-level scorings concurrently + piece_score_lists = await asyncio.gather(*tasks) + + # Flatten list[list[Score]] -> list[Score] + return [score for sublist in piece_score_lists for score in sublist] + + @abstractmethod + async def _score_piece_async(self, message_piece: MessagePiece, *, objective: str | None = None) -> list[Score]: + raise NotImplementedError + + def _get_supported_pieces(self, message: Message) -> list[MessagePiece]: + """ + Get a list of supported message pieces for this scorer. + + Returns: + list[MessagePiece]: List of message pieces that are supported by this scorer's validator. + """ + return [ + piece for piece in message.message_pieces if self._validator.is_message_piece_supported(message_piece=piece) + ] + + def _should_skip_on_error(self, message: Message) -> bool: + """ + Return whether an errored message should be skipped rather than scored. + + Returns: + bool: True when the message should not be scored. + """ + if not message.is_error(): + return False + + error_pieces = [ + piece for piece in message.message_pieces if piece.has_error() or piece.converted_value_data_type == "error" + ] + # SDK-provided structured refusals stay scoreable: the refusal text is the evidence. + only_structured_refusals = all(piece.structured_refusal is not None for piece in error_pieces) + # When score_blocked_content is enabled and the message has partial content, + # don't skip — let _score_async handle the substitution. + all_errors_have_partial_content = all( + piece.is_blocked() and piece.prompt_metadata.get("partial_content") for piece in error_pieces + ) + if only_structured_refusals or (self.score_blocked_content and all_errors_have_partial_content): + return False + + logger.debug("Skipping scoring due to error in message and skip_on_error=True.") + return True + + @staticmethod + def _drop_ephemeral_score_links(*, message: Message, scores: list[Score]) -> None: + """ + Clear the piece link on scores that point at pieces which were never persisted. + + Memory cannot link a score to a piece it never stored, but the score itself is + still worth keeping. + """ + ephemeral_piece_ids = { + piece.id for piece in message.message_pieces if piece.not_in_memory and piece.id is not None + } + if not ephemeral_piece_ids: + return + + for score in scores: + if score.message_piece_id in ephemeral_piece_ids: + score.message_piece_id = None # type: ignore[ty:invalid-assignment] + + @staticmethod + def _create_scoring_text_piece( + *, + piece: MessagePiece, + content: str, + response_error: PromptResponseError, + ) -> MessagePiece: + """ + Create a text scoring view that retains the persisted piece identity. + + Returns: + MessagePiece: The text scoring view. + """ + return MessagePiece( + id=piece.id, + role=piece.api_role, + original_value=piece.original_value, + converted_value=content, + original_value_data_type=piece.original_value_data_type, + converted_value_data_type="text", + conversation_id=piece.conversation_id, + sequence=piece.sequence, + prompt_metadata=dict(piece.prompt_metadata), + converter_identifiers=list(piece.converter_identifiers), # type: ignore[arg-type] + response_error=response_error, + timestamp=piece.timestamp, + original_prompt_id=piece.original_prompt_id, + not_in_memory=piece.not_in_memory, + ) + + @classmethod + def _create_text_piece_from_blocked(cls, piece: MessagePiece) -> MessagePiece | None: + """ + Create a text scoring view from a blocked piece's partial content. + + Returns: + MessagePiece | None: The scoring view, or None when content is unavailable. + """ + partial_content = str(piece.prompt_metadata.get("partial_content", "")) + if not partial_content: + return None + return cls._create_scoring_text_piece( + piece=piece, + content=partial_content, + response_error="none", + ) + + @classmethod + def _create_text_piece_from_structured_refusal(cls, piece: MessagePiece) -> MessagePiece | None: + """ + Create a blocked text scoring view for an SDK-provided refusal. + + Returns: + MessagePiece | None: The scoring view, or None when there is no refusal. + """ + refusal = piece.structured_refusal + if not refusal: + return None + return cls._create_scoring_text_piece( + piece=piece, + content=refusal, + response_error="blocked", + ) + + def _apply_structured_refusal_substitution(self, message: Message) -> Message: + """ + Expose structured refusal explanations while preserving blocked semantics. + + Returns: + Message: The substituted message, or the original message. + """ + substituted = False + new_pieces: list[MessagePiece] = [] + for piece in message.message_pieces: + substitute = self._create_text_piece_from_structured_refusal(piece) + if substitute: + new_pieces.append(substitute) + substituted = True + continue + new_pieces.append(piece) + return Message(message_pieces=new_pieces) if substituted else message + + def _apply_blocked_content_substitution(self, message: Message) -> Message: + """ + Replace blocked pieces that have partial content with text scoring views. + + Returns: + Message: The substituted message, or the original message. + """ + substituted = False + new_pieces: list[MessagePiece] = [] + for piece in message.message_pieces: + if piece.is_blocked() and "partial_content" in piece.prompt_metadata: + substitute = self._create_text_piece_from_blocked(piece) + if substitute: + new_pieces.append(substitute) + substituted = True + continue + new_pieces.append(piece) + return Message(message_pieces=new_pieces) if substituted else message + + @abstractmethod + def _build_fallback_score( + self, + *, + message: Message, + objective: str | None, + scorer_response_blocked: bool = False, + ) -> list[Score]: + """ + Return the scorer family's neutral result when message evidence is unscoreable. + + Args: + message (Message): The message-shaped evidence. + objective (str | None): The objective associated with this call. + scorer_response_blocked (bool): Whether the scorer's own LLM was blocked. + + Returns: + list[Score]: One or more fallback scores. + """ + ... diff --git a/pyrit/score/scorable.py b/pyrit/score/scorable.py new file mode 100644 index 0000000000..46d56b0c56 --- /dev/null +++ b/pyrit/score/scorable.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Compatibility exports for scorables, canonically owned by ``pyrit.models.score``.""" + +from pyrit.models import ContentScorable, MessageScorable, Scorable + +__all__ = [ + "ContentScorable", + "MessageScorable", + "Scorable", +] diff --git a/pyrit/score/scorer.py b/pyrit/score/scorer.py index bd3a85c686..086d4a019c 100644 --- a/pyrit/score/scorer.py +++ b/pyrit/score/scorer.py @@ -7,26 +7,24 @@ import asyncio import logging from abc import abstractmethod -from typing import ( - TYPE_CHECKING, - Any, - ClassVar, - cast, -) +from typing import TYPE_CHECKING, Any, ClassVar, cast -from pyrit.exceptions import PyritException, ScorerLLMResponseBlockedException +from pyrit.common.deprecation import print_deprecation_message from pyrit.memory import CentralMemory, MemoryInterface from pyrit.models import ( ChatMessageRole, ComponentIdentifier, + Condition, + ContentScorable, Identifiable, Message, - MessagePiece, - PromptResponseError, + MessageScorable, + Scorable, Score, ScorerEvaluationIdentifier, ScorerIdentifier, ScoreType, + ScoringExpectation, ) from pyrit.prompt_target.batch_helper import batch_task_async from pyrit.prompt_target.common.target_requirements import TargetRequirements @@ -36,14 +34,61 @@ from pyrit.prompt_target import PromptTarget from pyrit.score.scorer_evaluation.metrics_type import RegistryUpdateBehavior - from pyrit.score.scorer_evaluation.scorer_evaluator import ( - ScorerEvalDatasetFiles, - ) + from pyrit.score.scorer_evaluation.scorer_evaluator import ScorerEvalDatasetFiles from pyrit.score.scorer_evaluation.scorer_metrics import ScorerMetrics from pyrit.score.scorer_prompt_validator import ScorerPromptValidator logger = logging.getLogger(__name__) +#: Release in which the message-shaped ``score_async`` parameters are removed. +LEGACY_SCORE_ASYNC_REMOVED_IN = "2.0.0" + + +async def _legacy_score_scorable_async( + self: Scorer, + *, + scorable: Scorable, + expectation: ScoringExpectation | None, +) -> list[Score]: + """ + Route a scorable to a pre-2.0 subclass that only implements ``_score_async``. + + Returns: + list[Score]: The scores the legacy scorer body produced. + """ + from pyrit.score.message_scorable_resolver import MessageScorableResolver + + print_deprecation_message( + old_item=f"{type(self).__name__}._score_async on a direct Scorer subclass", + new_item="pyrit.score.MessageScorer (or TrueFalseScorer / FloatScaleScorer) as the base class", + removed_in=LEGACY_SCORE_ASYNC_REMOVED_IN, + ) + resolver = getattr(self, "_message_resolver", None) or MessageScorableResolver() + message = resolver.resolve(scorable=scorable, memory=self._memory) + legacy_score_async = self._score_async # type: ignore[ty:unresolved-attribute] + return await legacy_score_async(message, objective=expectation.objective if expectation else None) + + +def _adapt_legacy_message_scorer(cls: type) -> None: + """ + Give a pre-2.0 direct ``Scorer`` subclass an implementation of the scorable contract. + + Subclasses of ``MessageScorer`` already inherit one, so they are left alone. A class that + predates the split implements ``_score_async`` instead, and would otherwise fail to + instantiate because ``_score_scorable_async`` is abstract. ``ABCMeta`` recomputes + ``__abstractmethods__`` after ``__init_subclass__``, so assigning it here is enough. + """ + for base in cls.__mro__: + if base is Scorer: + break + if "_score_scorable_async" in base.__dict__: + return + + if not any("_score_async" in base.__dict__ for base in cls.__mro__): + return + + cls._score_scorable_async = _legacy_score_scorable_async # type: ignore[ty:invalid-assignment, ty:unresolved-attribute] + class Scorer(Identifiable, abc.ABC): """ @@ -65,25 +110,15 @@ class Scorer(Identifiable, abc.ABC): #: validate it. TARGET_REQUIREMENTS: ClassVar[TargetRequirements] = TargetRequirements() - _identifier: ComponentIdentifier | None = None + #: Condition types this scorer can use as its criterion. Wrapping scorers report their + #: children's union so the root can reject conditions that reach no configured leaf. + MATCHED_CONDITIONS: ClassVar[frozenset[type[Condition]]] = frozenset() - #: When True, blocked responses that contain partial content - #: (in prompt_metadata["partial_content"]) will be scored using that content - #: instead of being filtered out or short-circuited. - #: Set this on scorer instances before use. Defaults to False. - #: - #: Note: Partial content extraction is supported for ``OpenAIChatTarget`` - #: (Chat Completions API) and ``OpenAIResponseTarget`` (Responses API). - score_blocked_content: bool = False - - #: Controls what happens when the scorer's *own* LLM response is blocked by content - #: filtering (common in red-teaming, since the scorer's rationale quotes harmful content). - #: When True (default), scoring raises ``ScorerLLMResponseBlockedException`` — a blocked - #: scorer endpoint is treated as a real error. When False, scoring returns the scorer's - #: type default instead (False for true/false scorers, 0.0 for float-scale). This is - #: distinct from ``score_blocked_content``, which concerns the target-under-test response. - #: Set this on scorer instances before use. Defaults to True. - raise_if_scorer_blocks: bool = True + #: Matched condition types that this scorer cannot operate without. The empty-condition + #: legacy path remains valid during the transition to typed expectations. + REQUIRED_CONDITIONS: ClassVar[frozenset[type[Condition]]] = frozenset() + + _identifier: ComponentIdentifier | None = None def __init_subclass__(cls, **kwargs: Any) -> None: """ @@ -96,20 +131,52 @@ def __init_subclass__(cls, **kwargs: Any) -> None: from pyrit.common.brick_contract import enforce_keyword_only_init enforce_keyword_only_init(cls, base_name="Scorer") + _adapt_legacy_message_scorer(cls) - def __init__(self, *, validator: ScorerPromptValidator, chat_target: PromptTarget | None = None) -> None: + def __init__( + self, + *, + chat_target: PromptTarget | None = None, + validator: ScorerPromptValidator | None = None, + ) -> None: """ Initialize the Scorer. Args: - validator (ScorerPromptValidator): Validator for message pieces and scorer configuration. chat_target (PromptTarget | None): Chat target used by the scorer, if any. When provided, it is validated against ``TARGET_REQUIREMENTS``. - """ - self._validator = validator + validator (ScorerPromptValidator | None): Deprecated. Message validation moved to + ``MessageScorer``; a value passed here is kept so pre-2.0 subclasses keep working. + """ + if validator is not None: + print_deprecation_message( + old_item="Scorer.__init__(validator=...)", + new_item="MessageScorer.__init__(validator=...)", + removed_in=LEGACY_SCORE_ASYNC_REMOVED_IN, + ) + if getattr(self, "_validator", None) is None: + self._validator = validator if chat_target is not None: type(self).TARGET_REQUIREMENTS.validate(target=chat_target) + def matched_conditions(self) -> frozenset[type[Condition]]: + """ + Return the condition types this scorer can use as its criterion. + + Returns: + frozenset[type[Condition]]: The matched condition types. + """ + return type(self).MATCHED_CONDITIONS + + def required_conditions(self) -> frozenset[type[Condition]]: + """ + Return the matched condition types this scorer requires. + + Returns: + frozenset[type[Condition]]: The required condition types. + """ + return type(self).REQUIRED_CONDITIONS + def get_chat_target(self) -> PromptTarget | None: """ Return the chat target used by this scorer, or None if it doesn't use one. @@ -205,325 +272,111 @@ def _create_identifier( async def score_async( self, - message: Message, *, - objective: str | None = None, - role_filter: ChatMessageRole | None = None, - skip_on_error_result: bool = False, - infer_objective_from_request: bool = False, + scorable: Scorable, + expectation: ScoringExpectation | None = None, ) -> list[Score]: """ - Score the message, add the results to the database, and return a list of Score objects. + Score a scorable against an expectation, persist the results, and return them. Args: - message (Message): The message to be scored. - objective (str | None): The task or objective based on which the message should be scored. - Defaults to None. - role_filter (ChatMessageRole | None): Only score messages with this exact stored role. - Use "assistant" to score only real assistant responses, or "simulated_assistant" - to score only simulated responses. Defaults to None (no filtering). - skip_on_error_result (bool): If True, skip scoring if the message contains an error. - SDK-provided structured refusals remain scoreable. When self.score_blocked_content - is also True, blocked responses with partial content will still be scored instead - of skipping. Defaults to False. - infer_objective_from_request (bool): If True, infer the objective from the message's previous request - when objective is not provided. Defaults to False. + scorable (Scorable): What to look at. + expectation (ScoringExpectation | None): What to look for. Defaults to None. Returns: list[Score]: A list of Score objects representing the results. Raises: - ScorerLLMResponseBlockedException: If the scorer's own LLM response is blocked by - content filtering and ``raise_if_scorer_blocks`` is True (the default). - PyritException: If scoring raises a PyRIT exception (re-raised with enhanced context). - RuntimeError: If scoring raises a non-PyRIT exception (wrapped with scorer context). + TypeError: If this scorer does not support this kind of scorable. """ - # Structured refusals are persisted as blocked error pieces, but scorers should - # receive the refusal explanation as text. Keep response_error="blocked" so - # refusal scorers can still use their deterministic blocked-response path. - scoring_message = self._apply_structured_refusal_substitution(message) - - # When score_blocked_content is enabled, blocked pieces with partial content - # take precedence and are replaced with text substitutes (response_error="none"). - if self.score_blocked_content: - scoring_message = self._apply_blocked_content_substitution(scoring_message) + self._validate_expectation(expectation=expectation) + scores = await self._score_scorable_async(scorable=scorable, expectation=expectation) + return self._validate_and_persist_scores(scores=scores) - self._validator.validate(scoring_message, objective=objective) - - if role_filter is not None and message.get_piece().role != role_filter: - logger.debug("Skipping scoring due to role filter mismatch.") - return [] - - if skip_on_error_result and message.is_error(): - error_pieces = [ - piece - for piece in message.message_pieces - if piece.has_error() or piece.converted_value_data_type == "error" - ] - only_structured_refusals = all(piece.structured_refusal is not None for piece in error_pieces) - # When score_blocked_content is enabled and the message has partial content, - # don't skip — let _score_async handle the substitution. - all_errors_have_partial_content = all( - piece.is_blocked() and piece.prompt_metadata.get("partial_content") for piece in error_pieces - ) - if not only_structured_refusals and not (self.score_blocked_content and all_errors_have_partial_content): - logger.debug("Skipping scoring due to error in message and skip_on_error=True.") - return [] - - if infer_objective_from_request and (not objective): - objective = self._extract_objective_from_response(message) - - try: - scores = await self._score_async( - scoring_message, - objective=objective, - ) - except ScorerLLMResponseBlockedException as e: - # The scorer's own LLM response was content-filtered. By default this is a real - # error and re-raised; when raise_if_scorer_blocks is False, fall back to the - # scorer's type default (False / 0.0) instead. The decision lives here in the - # Scorer, not the transport (see doc/code/framework.md). - if self.raise_if_scorer_blocks: - e.message = f"Error in scorer {self.__class__.__name__}: {e.message}" - e.args = (f"Status Code: {e.status_code}, Message: {e.message}",) - raise - logger.info( - "Scorer %s LLM response was blocked by content filtering; " - "returning default score (raise_if_scorer_blocks=False).", - self.__class__.__name__, - ) - scores = self._build_fallback_score( - message=scoring_message, - objective=objective, - scorer_response_blocked=True, - ) - except PyritException as e: - # Re-raise PyRIT exceptions with enhanced context while preserving type for retry decorators - e.message = f"Error in scorer {self.__class__.__name__}: {e.message}" - e.args = (f"Status Code: {e.status_code}, Message: {e.message}",) - raise - except Exception as e: - # Wrap non-PyRIT exceptions for better error tracing - raise RuntimeError(f"Error in scorer {self.__class__.__name__}: {str(e)}") from e - - if not scores and scoring_message.message_pieces: - scores = self._build_fallback_score(message=scoring_message, objective=objective) - - self.validate_return_scores(scores=scores) - - # For pieces flagged not-in-memory, drop the FK on any score that points at them - # so memory doesn't try to link a score to a piece that was never persisted. - ephemeral_piece_ids = { - piece.id for piece in scoring_message.message_pieces if piece.not_in_memory and piece.id is not None - } - if ephemeral_piece_ids: - for score in scores: - if score.message_piece_id in ephemeral_piece_ids: - score.message_piece_id = None # type: ignore[ty:invalid-assignment] - - self._memory.add_scores_to_memory(scores=scores) - - return scores - - async def _score_async(self, message: Message, *, objective: str | None = None) -> list[Score]: - """ - Score the given request response asynchronously. - - This default implementation scores all supported pieces in the message - and returns a flattened list of scores. Subclasses can override this method - to implement custom scoring logic (e.g., aggregating scores). - - Args: - message (Message): The message to score. - objective (str | None): The objective to evaluate against. Defaults to None. - - Returns: - list[Score]: A list of Score objects. - """ - if not message.message_pieces: - return [] - - # Score only the supported pieces - supported_pieces = self._get_supported_pieces(message) - - tasks = [self._score_piece_async(message_piece=piece, objective=objective) for piece in supported_pieces] - - if not tasks: - return [] - - # Run all piece-level scorings concurrently - piece_score_lists = await asyncio.gather(*tasks) - - # Flatten list[list[Score]] -> list[Score] - return [score for sublist in piece_score_lists for score in sublist] - - @abstractmethod - async def _score_piece_async(self, message_piece: MessagePiece, *, objective: str | None = None) -> list[Score]: - raise NotImplementedError - - @staticmethod - def _create_scoring_text_piece( + def _validate_expectation( + self, *, - piece: MessagePiece, - content: str, - response_error: PromptResponseError, - ) -> MessagePiece: - """ - Create a text-typed scoring view that retains the persisted piece identity. - - Returns: - A text piece for scorer consumption. - """ - return MessagePiece( - id=piece.id, - role=piece.api_role, - original_value=piece.original_value, - converted_value=content, - original_value_data_type=piece.original_value_data_type, - converted_value_data_type="text", - conversation_id=piece.conversation_id, - sequence=piece.sequence, - prompt_metadata=dict(piece.prompt_metadata), - converter_identifiers=list(piece.converter_identifiers), # type: ignore[arg-type] - response_error=response_error, - timestamp=piece.timestamp, - original_prompt_id=piece.original_prompt_id, - not_in_memory=piece.not_in_memory, - ) - - @classmethod - def _create_text_piece_from_blocked(cls, piece: MessagePiece) -> MessagePiece | None: + expectation: ScoringExpectation | None, + allow_unmatched_conditions: bool = False, + ) -> None: """ - Create a text-typed copy of a blocked MessagePiece using its partial content. - - The substitute preserves the original piece's id (so scores link back correctly), - sets converted_value to the partial content with converted_value_data_type="text", - and sets response_error="none" so scorer short-circuits (e.g., refusal scorer's - blocked check) do not fire. + Reject conditions no scorer in this tree consumes, and ambiguous routing. Args: - piece: A blocked MessagePiece with prompt_metadata["partial_content"]. + expectation (ScoringExpectation | None): The expectation to validate. + allow_unmatched_conditions (bool): Permit conditions addressed to sibling leaves. - Returns: - MessagePiece with text content, or None if partial content is empty. + Raises: + ValueError: If a condition is unsupported at the root, if a required condition is + absent, or if more than one condition of the same matched type is present. """ - partial_content = str(piece.prompt_metadata.get("partial_content", "")) - if not partial_content: - return None + if expectation is None or not expectation.conditions: + return - return cls._create_scoring_text_piece( - piece=piece, - content=partial_content, - response_error="none", - ) + matched = self.matched_conditions() + unmatched = [condition for condition in expectation.conditions if not isinstance(condition, tuple(matched))] + if unmatched and not allow_unmatched_conditions: + names = ", ".join(sorted({type(condition).__name__ for condition in unmatched})) + matched_names = ", ".join(sorted(cls.__name__ for cls in matched)) or "none" + raise ValueError( + f"{type(self).__name__} does not match the condition(s) {names}. Matched conditions: {matched_names}." + ) - @classmethod - def _create_text_piece_from_structured_refusal(cls, piece: MessagePiece) -> MessagePiece | None: - """ - Create a blocked text scoring view for an SDK-provided structured refusal. + for condition_type in matched: + matches = [condition for condition in expectation.conditions if isinstance(condition, condition_type)] + if len(matches) > 1: + raise ValueError( + f"{type(self).__name__} received {len(matches)} {condition_type.__name__} conditions. " + "A scorer matches at most one condition of a given type." + ) - Returns: - A text scoring view, or ``None`` when the piece is not a structured refusal. - """ - refusal = piece.structured_refusal - if not refusal: - return None - return cls._create_scoring_text_piece( - piece=piece, - content=refusal, - response_error="blocked", - ) + missing = [ + condition_type + for condition_type in self.required_conditions() + if not any(isinstance(condition, condition_type) for condition in expectation.conditions) + ] + if missing: + names = ", ".join(sorted(condition_type.__name__ for condition_type in missing)) + raise ValueError(f"{type(self).__name__} requires the condition(s) {names}.") - def _apply_structured_refusal_substitution(self, message: Message) -> Message: + def _validate_and_persist_scores(self, *, scores: list[Score]) -> list[Score]: """ - Expose structured refusal explanations as text while preserving blocked semantics. + Validate and persist non-empty scorer output. Returns: - A scoring message with structured refusals substituted, or the original message. - """ - substituted = False - new_pieces: list[MessagePiece] = [] - for piece in message.message_pieces: - substitute = self._create_text_piece_from_structured_refusal(piece) - if substitute: - new_pieces.append(substitute) - substituted = True - continue - new_pieces.append(piece) - - return Message(message_pieces=new_pieces) if substituted else message - - def _apply_blocked_content_substitution(self, message: Message) -> Message: + list[Score]: The original scores. """ - Create a copy of the message where blocked pieces with partial content are substituted. - - Each blocked piece that has prompt_metadata["partial_content"] is replaced with a - text-typed copy (response_error="none", converted_value=partial_content). Non-blocked - pieces and blocked pieces without partial content are kept as-is. - - Args: - message: The original message potentially containing blocked pieces. - - Returns: - A new Message with substituted pieces, or the original if no substitution was needed. - """ - substituted = False - new_pieces: list[MessagePiece] = [] - for piece in message.message_pieces: - if piece.is_blocked() and "partial_content" in piece.prompt_metadata: - substitute = self._create_text_piece_from_blocked(piece) - if substitute: - new_pieces.append(substitute) - substituted = True - continue - new_pieces.append(piece) - - if not substituted: - return message - - return Message(message_pieces=new_pieces) - - def _get_supported_pieces(self, message: Message) -> list[MessagePiece]: - """ - Get a list of supported message pieces for this scorer. + if not scores: + return [] - Returns: - list[MessagePiece]: List of message pieces that are supported by this scorer's validator. - """ - return [ - piece for piece in message.message_pieces if self._validator.is_message_piece_supported(message_piece=piece) - ] + self.validate_return_scores(scores=scores) + self._memory.add_scores_to_memory(scores=scores) + return scores @abstractmethod - def _build_fallback_score( - self, *, message: Message, objective: str | None, scorer_response_blocked: bool = False + async def _score_scorable_async( + self, + *, + scorable: Scorable, + expectation: ScoringExpectation | None, ) -> list[Score]: """ - Return neutral fallback ``Score`` objects when ``_score_async`` produced no scores. + Score a scorable this scorer supports. - Called from ``score_async`` after ``_score_async`` returns an empty list and the - message still has pieces (e.g. the response was blocked, had an error, or no piece - matched the validator). Every ``Scorer`` subclass MUST implement this so that a - consistent "attack did not succeed" value is always returned and downstream - consumers do not need to special-case error handling. + Subclasses implement this for the scorable kinds they handle and raise + ``TypeError`` for the rest. ``MessageScorer`` handles the message-shaped kinds. - Most scorers return a single-element list (e.g. ``FloatScaleScorer`` returns - ``[Score(0.0)]`` and ``TrueFalseScorer`` returns ``[Score(False)]``). Scorers - whose normal output shape is multiple scores per message (e.g. one per category) - should return one fallback score per logical output slot so downstream consumers - iterating by shape continue to work on blocked / error input. + An implementation returns an empty list when a filter skipped the scorable without + scoring it. An empty list bypasses ``validate_return_scores`` and persistence. Args: - message (Message): The (possibly substituted) message that was scored. - objective (str | None): The objective associated with this scoring call. - scorer_response_blocked (bool): When True, the fallback was triggered because the - scorer's *own* LLM response was blocked by content filtering (not the - target-under-test). Subclasses should reflect this in the rationale. + scorable (Scorable): What to look at. + expectation (ScoringExpectation | None): What to look for. - Returns: - list[Score]: One or more fallback scores. Must not be empty. + Raises: + TypeError: If the scorer does not support this kind of scorable. """ - ... + raise NotImplementedError @abstractmethod def validate_return_scores(self, scores: list[Score]) -> None: @@ -619,18 +472,11 @@ async def score_text_async(self, text: str, *, objective: str | None = None) -> Returns: list[Score]: A list of Score objects representing the results. """ - request = Message( - message_pieces=[ - MessagePiece( - role="user", - original_value=text, - ) - ] + return await self.score_async( + scorable=ContentScorable(value=text), + expectation=ScoringExpectation(objective=objective), ) - request.message_pieces[0].not_in_memory = True - return await self.score_async(request, objective=objective) - async def score_image_async(self, image_path: str, *, objective: str | None = None) -> list[Score]: """ Score the given image using the chat target. @@ -642,19 +488,11 @@ async def score_image_async(self, image_path: str, *, objective: str | None = No Returns: list[Score]: A list of Score objects representing the results. """ - request = Message( - message_pieces=[ - MessagePiece( - role="user", - original_value=image_path, - original_value_data_type="image_path", - ) - ] + return await self.score_async( + scorable=ContentScorable(value=image_path, data_type="image_path"), + expectation=ScoringExpectation(objective=objective), ) - request.message_pieces[0].not_in_memory = True - return await self.score_async(request, objective=objective) - async def score_prompts_batch_async( self, *, @@ -685,26 +523,48 @@ async def score_prompts_batch_async( Raises: ValueError: If objectives is not None and the number of objectives doesn't match the number of messages. + TypeError: If this is not a message scorer. """ if objectives is None: - objectives = [""] * len(messages) + resolved_objectives = [""] * len(messages) elif len(objectives) != len(messages): raise ValueError("The number of objectives must match the number of messages.") + else: + resolved_objectives = list(objectives) if len(messages) == 0: return [] + from pyrit.score.message_scorer import ( + MessageScorer, + MessageScoringOptions, + extract_objective_from_previous_turn, + ) + + if not isinstance(self, MessageScorer): + raise TypeError("score_prompts_batch_async requires a MessageScorer.") + if infer_objective_from_request: + resolved_objectives = [ + objective or extract_objective_from_previous_turn(message=message, memory=self._memory) + for message, objective in zip(messages, resolved_objectives, strict=True) + ] + + scorables = [MessageScorable.from_message(message) for message in messages] + expectations = [ScoringExpectation(objective=objective) for objective in resolved_objectives] + message_options = MessageScoringOptions( + role_filter=role_filter, + skip_on_error_result=skip_on_error_result, + ) + # Some scorers do not have an associated prompt target; batch helper validates RPM only when present prompt_target = getattr(self, "_prompt_target", None) results = await batch_task_async( task_func=self.score_async, - task_arguments=["message", "objective"], + task_arguments=["scorable", "expectation"], prompt_target=cast("PromptTarget", prompt_target), batch_size=batch_size, - items_to_batch=[messages, objectives], - role_filter=role_filter, - skip_on_error_result=skip_on_error_result, - infer_objective_from_request=infer_objective_from_request, + items_to_batch=[scorables, expectations], + message_options=message_options, ) # results is a list[list[Score]] and needs to be flattened @@ -764,7 +624,9 @@ def scale_value_float(self, value: float, min_value: float, max_value: float) -> def _extract_objective_from_response(self, response: Message) -> str: """ - Extract an objective from the response using the last request (if it exists). + Read the objective from the turn before an assistant response. + + Deprecated: use ``pyrit.score.message_scorer.extract_objective_from_previous_turn``. Args: response (Message): The response to extract the objective from. @@ -772,24 +634,44 @@ def _extract_objective_from_response(self, response: Message) -> str: Returns: str: The objective extracted from the response, or empty string if not found. """ - if not response.message_pieces: - return "" + from pyrit.score.message_scorer import extract_objective_from_previous_turn - piece = response.get_piece() + print_deprecation_message( + old_item="Scorer._extract_objective_from_response", + new_item="pyrit.score.message_scorer.extract_objective_from_previous_turn", + removed_in=LEGACY_SCORE_ASYNC_REMOVED_IN, + ) + return extract_objective_from_previous_turn(message=response, memory=self._memory) + + @staticmethod + async def _score_response_with_scorer_async( + *, + scorer: Scorer, + response: Message, + expectation: ScoringExpectation, + role_filter: ChatMessageRole, + skip_on_error_result: bool, + ) -> list[Score]: + """ + Apply response-scoring policy without storing policy on the scorable. - if piece.api_role != "assistant": - return "" + Returns: + list[Score]: Scores from the message scorer. - conversation = self._memory.get_message_pieces(conversation_id=piece.conversation_id) - last_prompt = max(conversation, key=lambda x: x.sequence) + Raises: + TypeError: If the scorer does not use the message-scoring contract. + """ + from pyrit.score.message_scorer import MessageScorer, MessageScoringOptions - # Every text message piece from the last turn - return "\n".join( - [ - piece.original_value - for piece in conversation - if piece.sequence == last_prompt.sequence - 1 and piece.original_value_data_type == "text" - ] + if not isinstance(scorer, MessageScorer): + raise TypeError("Response scoring helpers require MessageScorer instances.") + return await scorer.score_async( + scorable=MessageScorable.from_message(response), + expectation=expectation, + message_options=MessageScoringOptions( + role_filter=role_filter, + skip_on_error_result=skip_on_error_result, + ), ) @staticmethod @@ -849,21 +731,23 @@ async def score_response_async( objective=objective, skip_on_error_result=skip_on_error_result, ) - obj_task = objective_scorer.score_async( - message=response, - objective=objective, - skip_on_error_result=skip_on_error_result, + obj_task = Scorer._score_response_with_scorer_async( + scorer=objective_scorer, + response=response, + expectation=ScoringExpectation(objective=objective), role_filter=role_filter, + skip_on_error_result=skip_on_error_result, ) aux_scores, obj_scores = await asyncio.gather(aux_task, obj_task) result["auxiliary_scores"] = aux_scores result["objective_scores"] = obj_scores else: - obj_scores = await objective_scorer.score_async( - message=response, - objective=objective, - skip_on_error_result=skip_on_error_result, + obj_scores = await Scorer._score_response_with_scorer_async( + scorer=objective_scorer, + response=response, + expectation=ScoringExpectation(objective=objective), role_filter=role_filter, + skip_on_error_result=skip_on_error_result, ) result["objective_scores"] = obj_scores return result @@ -897,11 +781,12 @@ async def score_response_multiple_scorers_async( if not scorers: return [] - # Create all scoring tasks, note TEMPORARY fix to prevent multi-piece responses from breaking scoring logic + expectation = ScoringExpectation(objective=objective) tasks = [ - scorer.score_async( - message=response, - objective=objective, + Scorer._score_response_with_scorer_async( + scorer=scorer, + response=response, + expectation=expectation, role_filter=role_filter, skip_on_error_result=skip_on_error_result, ) diff --git a/pyrit/score/scorer_evaluation/scorer_evaluator.py b/pyrit/score/scorer_evaluation/scorer_evaluator.py index 39902b3e59..b5672b9fbc 100644 --- a/pyrit/score/scorer_evaluation/scorer_evaluator.py +++ b/pyrit/score/scorer_evaluation/scorer_evaluator.py @@ -13,21 +13,15 @@ from scipy.stats import ttest_1samp from pyrit.common.path import SCORER_EVALS_PATH +from pyrit.score.message_scorer import extract_objective_from_previous_turn from pyrit.score.scorer_evaluation.human_labeled_dataset import ( HarmHumanLabeledEntry, HumanLabeledDataset, ObjectiveHumanLabeledEntry, ) from pyrit.score.scorer_evaluation.krippendorff import krippendorff_alpha -from pyrit.score.scorer_evaluation.metrics_type import ( - MetricsType, - RegistryUpdateBehavior, -) -from pyrit.score.scorer_evaluation.scorer_metrics import ( - HarmScorerMetrics, - ObjectiveScorerMetrics, - ScorerMetrics, -) +from pyrit.score.scorer_evaluation.metrics_type import MetricsType, RegistryUpdateBehavior +from pyrit.score.scorer_evaluation.scorer_metrics import HarmScorerMetrics, ObjectiveScorerMetrics, ScorerMetrics from pyrit.score.scorer_evaluation.scorer_metrics_io import ( find_harm_metrics_by_eval_hash, find_objective_metrics_by_eval_hash, @@ -381,6 +375,12 @@ async def evaluate_dataset_async( # Validate dataset and extract data assistant_responses, human_scores_list, objectives = self._validate_and_extract_data(labeled_dataset) + # Harm datasets carry no objective, so the previous turn stands in for one. + resolved_objectives = objectives or [ + extract_objective_from_previous_turn(message=response, memory=self.scorer._memory) + for response in assistant_responses + ] + # Transpose human scores so each row is a complete set of scores across all responses all_human_scores = np.array(human_scores_list).T @@ -392,9 +392,8 @@ async def evaluate_dataset_async( start_time = time.perf_counter() scores = await self.scorer.score_prompts_batch_async( messages=assistant_responses, - objectives=objectives, + objectives=resolved_objectives, batch_size=max_concurrency, - infer_objective_from_request=True, ) elapsed_time = time.perf_counter() - start_time total_scoring_time += elapsed_time @@ -534,7 +533,8 @@ def _validate_and_extract_data( Returns: Tuple of (assistant_responses, human_scores_list, None). - objectives is None for harm scoring (uses infer_objective_from_request). + objectives is None for harm scoring; the caller reads each objective from the + previous turn instead. Raises: ValueError: If dataset is not HARM type or has multiple harm categories. diff --git a/pyrit/score/scorer_prompt_validator.py b/pyrit/score/scorer_prompt_validator.py index 1e6946e3a0..cfb4c8ed77 100644 --- a/pyrit/score/scorer_prompt_validator.py +++ b/pyrit/score/scorer_prompt_validator.py @@ -67,6 +67,11 @@ def __init__( self._is_objective_required = is_objective_required + @property + def is_objective_required(self) -> bool: + """Whether the scorer uses the objective as a required criterion.""" + return self._is_objective_required + def validate(self, message: Message, objective: str | None) -> None: """ Validate a message and objective against configured requirements. diff --git a/pyrit/score/true_false/audio_true_false_scorer.py b/pyrit/score/true_false/audio_true_false_scorer.py index eb291e6597..fb61a2986f 100644 --- a/pyrit/score/true_false/audio_true_false_scorer.py +++ b/pyrit/score/true_false/audio_true_false_scorer.py @@ -2,7 +2,7 @@ # Licensed under the MIT license. -from pyrit.models import ComponentIdentifier, MessagePiece, Score +from pyrit.models import ComponentIdentifier, Condition, MessagePiece, Score from pyrit.score.audio_transcript_scorer import AudioTranscriptHelper from pyrit.score.scorer_prompt_validator import ScorerPromptValidator from pyrit.score.true_false.true_false_scorer import TrueFalseScorer @@ -51,6 +51,24 @@ def _build_identifier(self) -> ComponentIdentifier: sub_scorers=[self._audio_helper.text_scorer.get_identifier()], ) + def matched_conditions(self) -> frozenset[type[Condition]]: + """ + Report the conditions matched by the transcript scorer. + + Returns: + frozenset[type[Condition]]: The matched condition types. + """ + return self._audio_helper.text_scorer.matched_conditions() + + def required_conditions(self) -> frozenset[type[Condition]]: + """ + Report the conditions required by the transcript scorer. + + Returns: + frozenset[type[Condition]]: The required condition types. + """ + return self._audio_helper.text_scorer.required_conditions() + async def _score_piece_async(self, message_piece: MessagePiece, *, objective: str | None = None) -> list[Score]: """ Score an audio file by transcribing it and scoring the transcript. diff --git a/pyrit/score/true_false/float_scale_threshold_scorer.py b/pyrit/score/true_false/float_scale_threshold_scorer.py index 560f001b1f..1b715b7fbf 100644 --- a/pyrit/score/true_false/float_scale_threshold_scorer.py +++ b/pyrit/score/true_false/float_scale_threshold_scorer.py @@ -7,11 +7,15 @@ if TYPE_CHECKING: from pyrit.prompt_target import PromptTarget -from pyrit.models import ChatMessageRole, ComponentIdentifier, Message, MessagePiece, Score -from pyrit.score.float_scale.float_scale_score_aggregator import ( - FloatScaleAggregatorFunc, - FloatScaleScoreAggregator, +from pyrit.models import ( + ComponentIdentifier, + Condition, + Message, + MessagePiece, + Score, + ScoringExpectation, ) +from pyrit.score.float_scale.float_scale_score_aggregator import FloatScaleAggregatorFunc, FloatScaleScoreAggregator from pyrit.score.float_scale.float_scale_scorer import FloatScaleScorer from pyrit.score.score_utils import ORIGINAL_FLOAT_VALUE_KEY from pyrit.score.scorer_prompt_validator import ScorerPromptValidator @@ -82,30 +86,45 @@ def get_chat_target(self) -> "PromptTarget | None": """ return self._scorer.get_chat_target() - async def _score_async( + def matched_conditions(self) -> frozenset[type[Condition]]: + """ + Report what the wrapped scorer matches. + + Returns: + frozenset[type[Condition]]: The condition types the wrapped scorer routes. + """ + return self._scorer.matched_conditions() + + def required_conditions(self) -> frozenset[type[Condition]]: + """ + Report what the wrapped scorer requires. + + Returns: + frozenset[type[Condition]]: The required condition types. + """ + return self._scorer.required_conditions() + + async def _score_prepared_message_async( self, - message: Message, *, - objective: str | None = None, - role_filter: ChatMessageRole | None = None, + message: Message, + expectation: ScoringExpectation | None, ) -> list[Score]: """ Scores the piece using the underlying float-scale scorer and thresholds the resulting score. Args: message (Message): The message to score. - objective (str | None): The objective to evaluate against (the original attacker model's objective). - Defaults to None. - role_filter (ChatMessageRole | None): Optional filter for message roles. Defaults to None. + expectation (ScoringExpectation | None): What the wrapped scorer should look for. Returns: list[Score]: A list containing a single true/false Score object based on the threshold comparison. """ - scores = await self._scorer.score_async( - message, - objective=objective, - role_filter=role_filter, + scores = await self._scorer._score_nested_message_async( + message=message, + expectation=expectation, ) + objective = expectation.objective if expectation else None # Aggregator handles 0-many scores and returns exactly one result (or raises if configured) aggregate_results = self._float_scale_aggregator(scores) diff --git a/pyrit/score/true_false/true_false_composite_scorer.py b/pyrit/score/true_false/true_false_composite_scorer.py index 554749a92f..fd3cf2a2e1 100644 --- a/pyrit/score/true_false/true_false_composite_scorer.py +++ b/pyrit/score/true_false/true_false_composite_scorer.py @@ -7,7 +7,14 @@ if TYPE_CHECKING: from pyrit.prompt_target import PromptTarget -from pyrit.models import ChatMessageRole, ComponentIdentifier, Message, MessagePiece, Score +from pyrit.models import ( + ComponentIdentifier, + Condition, + Message, + MessagePiece, + Score, + ScoringExpectation, +) from pyrit.score.scorer_prompt_validator import ScorerPromptValidator from pyrit.score.true_false.true_false_score_aggregator import TrueFalseAggregatorFunc from pyrit.score.true_false.true_false_scorer import TrueFalseScorer @@ -75,20 +82,36 @@ def get_chat_target(self) -> "PromptTarget | None": return target return None - async def _score_async( + def matched_conditions(self) -> frozenset[type[Condition]]: + """ + Report the union of what the constituent scorers match. + + Returns: + frozenset[type[Condition]]: The condition types this composite routes. + """ + return frozenset().union(*(scorer.matched_conditions() for scorer in self._scorers)) + + def required_conditions(self) -> frozenset[type[Condition]]: + """ + Report the union of conditions required by the constituent scorers. + + Returns: + frozenset[type[Condition]]: The required condition types. + """ + return frozenset().union(*(scorer.required_conditions() for scorer in self._scorers)) + + async def _score_prepared_message_async( self, - message: Message, *, - objective: str | None = None, - role_filter: ChatMessageRole | None = None, + message: Message, + expectation: ScoringExpectation | None, ) -> list[Score]: """ Score a request/response by combining results from all constituent scorers. Args: message (Message): The request/response to score. - objective (str | None): Scoring objective or context. - role_filter (ChatMessageRole | None): Optional filter for message roles. Defaults to None. + expectation (ScoringExpectation | None): What the child scorers should look for. Returns: list[Score]: A single-element list with the aggregated true/false score. @@ -97,9 +120,11 @@ async def _score_async( ValueError: If any constituent scorer does not return exactly one score. ValueError: If no scores are generated from the request response pieces. """ + # The children score the evidence this scorer was handed, substitutions and all. + # Naming it instead would send them back to memory for the pre-substitution pieces, + # or discard the role and error state of a message that was never persisted. tasks = [ - scorer.score_async(message=message, objective=objective, role_filter=role_filter) - for scorer in self._scorers + scorer._score_nested_message_async(message=message, expectation=expectation) for scorer in self._scorers ] # Run all response scorings concurrently @@ -116,6 +141,7 @@ async def _score_async( raise ValueError("No scores were generated from the request response pieces.") result = self._score_aggregator(score_list) + objective = expectation.objective if expectation else None # Ensure the message piece has an ID piece_id = message.message_pieces[0].id diff --git a/pyrit/score/true_false/true_false_inverter_scorer.py b/pyrit/score/true_false/true_false_inverter_scorer.py index 5140d22afa..b9ee691226 100644 --- a/pyrit/score/true_false/true_false_inverter_scorer.py +++ b/pyrit/score/true_false/true_false_inverter_scorer.py @@ -7,7 +7,14 @@ if TYPE_CHECKING: from pyrit.prompt_target import PromptTarget -from pyrit.models import ChatMessageRole, ComponentIdentifier, Message, MessagePiece, Score +from pyrit.models import ( + ComponentIdentifier, + Condition, + Message, + MessagePiece, + Score, + ScoringExpectation, +) from pyrit.score.scorer_prompt_validator import ScorerPromptValidator from pyrit.score.true_false.true_false_scorer import TrueFalseScorer @@ -54,32 +61,44 @@ def get_chat_target(self) -> "PromptTarget | None": """ return self._scorer.get_chat_target() - async def _score_async( + def matched_conditions(self) -> frozenset[type[Condition]]: + """ + Report what the wrapped scorer matches. + + Returns: + frozenset[type[Condition]]: The condition types the wrapped scorer routes. + """ + return self._scorer.matched_conditions() + + def required_conditions(self) -> frozenset[type[Condition]]: + """ + Report what the wrapped scorer requires. + + Returns: + frozenset[type[Condition]]: The required condition types. + """ + return self._scorer.required_conditions() + + async def _score_prepared_message_async( self, - message: Message, *, - objective: str | None = None, - role_filter: ChatMessageRole | None = None, + message: Message, + expectation: ScoringExpectation | None, ) -> list[Score]: """ Scores the piece using the underlying true-false scorer and returns the inverted score. Args: message (Message): The message to score. - objective (str | None): The objective to evaluate against (the original attacker model's objective). - Defaults to None. - role_filter (ChatMessageRole | None): Optional filter for message roles. Defaults to None. + expectation (ScoringExpectation | None): What the wrapped scorer should look for. Returns: list[Score]: A list containing a single Score object with the inverted true/false value. """ - scores = await self._scorer.score_async( - message, - objective=objective, - role_filter=role_filter, + scores = await self._scorer._score_nested_message_async( + message=message, + expectation=expectation, ) - - # TrueFalseScorers only have a single score inv_score = scores[0] inv_score.score_value = str(True) if not inv_score.get_value() else str(False) diff --git a/pyrit/score/true_false/true_false_scorer.py b/pyrit/score/true_false/true_false_scorer.py index c3bb3399a7..298a37f95a 100644 --- a/pyrit/score/true_false/true_false_scorer.py +++ b/pyrit/score/true_false/true_false_scorer.py @@ -6,20 +6,18 @@ from typing import TYPE_CHECKING from pyrit.models import Message, Score -from pyrit.score.scorer import Scorer -from pyrit.score.true_false.true_false_score_aggregator import ( - TrueFalseAggregatorFunc, - TrueFalseScoreAggregator, -) +from pyrit.score.message_scorer import MessageScorer +from pyrit.score.true_false.true_false_score_aggregator import TrueFalseAggregatorFunc, TrueFalseScoreAggregator if TYPE_CHECKING: from pyrit.prompt_target import PromptTarget + from pyrit.score.message_scorable_resolver import MessageScorableResolver from pyrit.score.scorer_evaluation.scorer_evaluator import ScorerEvalDatasetFiles from pyrit.score.scorer_evaluation.scorer_metrics import ObjectiveScorerMetrics from pyrit.score.scorer_prompt_validator import ScorerPromptValidator -class TrueFalseScorer(Scorer): +class TrueFalseScorer(MessageScorer): """ Base class for scorers that return true/false binary scores. @@ -50,6 +48,7 @@ def __init__( validator: ScorerPromptValidator, score_aggregator: TrueFalseAggregatorFunc = TrueFalseScoreAggregator.OR, chat_target: PromptTarget | None = None, + message_resolver: MessageScorableResolver | None = None, ) -> None: """ Initialize the TrueFalseScorer. @@ -60,21 +59,24 @@ def __init__( Defaults to TrueFalseScoreAggregator.OR. chat_target (PromptTarget | None): Optional chat target used by the scorer, forwarded to the base class for validation against ``TARGET_REQUIREMENTS``. + message_resolver (MessageScorableResolver | None): Message evidence resolver. """ self._score_aggregator = score_aggregator # Set default evaluation file mapping if not already set by subclass if self.evaluation_file_mapping is None: - from pyrit.score.scorer_evaluation.scorer_evaluator import ( - ScorerEvalDatasetFiles, - ) + from pyrit.score.scorer_evaluation.scorer_evaluator import ScorerEvalDatasetFiles self.evaluation_file_mapping = ScorerEvalDatasetFiles( human_labeled_datasets_files=["objective/*.csv"], result_file="objective/objective_achieved_metrics.jsonl", ) - super().__init__(validator=validator, chat_target=chat_target) + super().__init__( + validator=validator, + chat_target=chat_target, + message_resolver=message_resolver, + ) def validate_return_scores(self, scores: list[Score]) -> None: """ @@ -101,9 +103,7 @@ def get_scorer_metrics(self) -> ObjectiveScorerMetrics | None: ObjectiveScorerMetrics: The metrics for this scorer, or None if not found or not configured. """ from pyrit.common.path import SCORER_EVALS_PATH - from pyrit.score.scorer_evaluation.scorer_metrics_io import ( - find_objective_metrics_by_eval_hash, - ) + from pyrit.score.scorer_evaluation.scorer_metrics_io import find_objective_metrics_by_eval_hash if self.evaluation_file_mapping is None: return None diff --git a/pyrit/score/true_false/video_true_false_scorer.py b/pyrit/score/true_false/video_true_false_scorer.py index 752532a984..2370de1841 100644 --- a/pyrit/score/true_false/video_true_false_scorer.py +++ b/pyrit/score/true_false/video_true_false_scorer.py @@ -2,7 +2,7 @@ # Licensed under the MIT license. -from pyrit.models import ComponentIdentifier, MessagePiece, Score +from pyrit.models import ComponentIdentifier, Condition, MessagePiece, Score from pyrit.score.scorer_prompt_validator import ScorerPromptValidator from pyrit.score.true_false.true_false_score_aggregator import TrueFalseScoreAggregator from pyrit.score.true_false.true_false_scorer import TrueFalseScorer @@ -91,6 +91,30 @@ def _build_identifier(self) -> ComponentIdentifier: sub_scorers=sub_scorer_ids, ) + def matched_conditions(self) -> frozenset[type[Condition]]: + """ + Report the union of conditions matched by the media scorers. + + Returns: + frozenset[type[Condition]]: The matched condition types. + """ + scorers = [self._video_helper.image_scorer] + if self.audio_scorer: + scorers.append(self.audio_scorer) + return frozenset().union(*(scorer.matched_conditions() for scorer in scorers)) + + def required_conditions(self) -> frozenset[type[Condition]]: + """ + Report the union of conditions required by the media scorers. + + Returns: + frozenset[type[Condition]]: The required condition types. + """ + scorers = [self._video_helper.image_scorer] + if self.audio_scorer: + scorers.append(self.audio_scorer) + return frozenset().union(*(scorer.required_conditions() for scorer in scorers)) + 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/partner_integration/azure_ai_evaluation/test_scorer_contract.py b/tests/partner_integration/azure_ai_evaluation/test_scorer_contract.py index 2bc9f5bee3..ff0c0d83cb 100644 --- a/tests/partner_integration/azure_ai_evaluation/test_scorer_contract.py +++ b/tests/partner_integration/azure_ai_evaluation/test_scorer_contract.py @@ -8,9 +8,16 @@ - RAIServiceScorer extends TrueFalseScorer Both are critical for scoring attack results. + +Scorer is now agnostic about what it scores: it takes a scorable and requires +``_score_scorable_async``. Every message-shaped hook, ``_score_piece_async`` included, moved +to ``MessageScorer``. A scorer that implements ``_score_piece_async`` must therefore extend +``MessageScorer`` instead of ``Scorer``. ``TrueFalseScorer`` already does, so +``RAIServiceScorer`` needs no change; ``AzureRAIServiceTrueFalseScorer`` does. """ from pyrit.score import ScorerPromptValidator +from pyrit.score.message_scorer import MessageScorer from pyrit.score.scorer import Scorer from pyrit.score.true_false.true_false_scorer import TrueFalseScorer @@ -18,9 +25,14 @@ class TestScorerContract: """Validate Scorer base class interface stability.""" - def test_scorer_has_score_piece_async(self): - """Scorer subclasses must implement _score_piece_async.""" - assert hasattr(Scorer, "_score_piece_async") + def test_scorer_requires_score_scorable_async(self): + """Scorer subclasses must implement _score_scorable_async.""" + assert "_score_scorable_async" in Scorer.__abstractmethods__ + + def test_message_scorer_has_score_piece_async(self): + """Message-shaped scorers implement _score_piece_async and must extend MessageScorer.""" + assert hasattr(MessageScorer, "_score_piece_async") + assert not hasattr(Scorer, "_score_piece_async") def test_scorer_has_validate_return_scores(self): """Scorer subclasses must implement validate_return_scores.""" @@ -38,6 +50,10 @@ def test_true_false_scorer_extends_scorer(self): """RAIServiceScorer extends TrueFalseScorer which extends Scorer.""" assert issubclass(TrueFalseScorer, Scorer) + def test_true_false_scorer_extends_message_scorer(self): + """RAIServiceScorer keeps its _score_piece_async hook through MessageScorer.""" + assert issubclass(TrueFalseScorer, MessageScorer) + def test_true_false_scorer_has_validate_return_scores(self): """TrueFalseScorer implements validate_return_scores.""" assert hasattr(TrueFalseScorer, "validate_return_scores") diff --git a/tests/unit/executor/attack/multi_turn/test_crescendo.py b/tests/unit/executor/attack/multi_turn/test_crescendo.py index 98a1060ad7..83720e485b 100644 --- a/tests/unit/executor/attack/multi_turn/test_crescendo.py +++ b/tests/unit/executor/attack/multi_turn/test_crescendo.py @@ -1224,10 +1224,10 @@ async def test_check_refusal_does_not_skip_on_error_result( await attack._check_refusal_async(context=basic_context, objective="test task") - # Verify score_async was called with skip_on_error_result=False + # Verify message policy does not skip error results mock_refusal_scorer.score_async.assert_called_once() - call_kwargs = mock_refusal_scorer.score_async.call_args.kwargs - assert call_kwargs.get("skip_on_error_result") is False, ( + message_options = mock_refusal_scorer.score_async.call_args.kwargs["message_options"] + assert message_options.skip_on_error_result is False, ( "Refusal scorer must be called with skip_on_error_result=False " "to ensure error responses are scored (treated as refusals) rather than skipped" ) diff --git a/tests/unit/executor/attack/multi_turn/test_crescendo_resilience.py b/tests/unit/executor/attack/multi_turn/test_crescendo_resilience.py index 7c44165baf..9147897df6 100644 --- a/tests/unit/executor/attack/multi_turn/test_crescendo_resilience.py +++ b/tests/unit/executor/attack/multi_turn/test_crescendo_resilience.py @@ -19,16 +19,11 @@ CrescendoAttackContext, CrescendoAttackResult, ) -from pyrit.models import ( - AttackOutcome, - ComponentIdentifier, - ConversationType, - Message, - MessagePiece, - Score, -) +from pyrit.memory import CentralMemory +from pyrit.models import AttackOutcome, ComponentIdentifier, ConversationType, Message, MessagePiece, Score from pyrit.prompt_normalizer import PromptNormalizer from pyrit.score import Scorer, TrueFalseScorer +from pyrit.score.message_scorable_resolver import MessageScorableResolver _OBJECTIVE = "Recover the hidden phrase through gradual rapport." @@ -286,7 +281,12 @@ async def score_objective(**_kwargs): assert result.conversation_id == final_conversation_id assert len({attempt.conversation_id for attempt in adversarial_target.attempts}) == 1 - refusal_inputs = [call.kwargs["message"].get_value() for call in refusal_scorer.score_async.await_args_list] + # A scorable names piece ids rather than carrying the message, so read them back. + memory = CentralMemory.get_memory_instance() + refusal_inputs = [ + MessageScorableResolver().resolve(scorable=call.kwargs["scorable"], memory=memory).get_value() + for call in refusal_scorer.score_async.await_args_list + ] assert refusal_inputs == [ "response-1", "response-2", @@ -301,7 +301,7 @@ async def score_objective(**_kwargs): "response-9", "response-10-final", ] - assert [call.kwargs["objective"] for call in refusal_scorer.score_async.await_args_list] == [ + assert [call.kwargs["expectation"].objective for call in refusal_scorer.score_async.await_args_list] == [ f"question-{attempt}" for attempt in range(1, 13) ] objective_inputs = [call.kwargs["response"].get_value() for call in score_response.await_args_list] diff --git a/tests/unit/executor/attack/multi_turn/test_red_teaming.py b/tests/unit/executor/attack/multi_turn/test_red_teaming.py index 4603152590..74930594f4 100644 --- a/tests/unit/executor/attack/multi_turn/test_red_teaming.py +++ b/tests/unit/executor/attack/multi_turn/test_red_teaming.py @@ -968,9 +968,12 @@ async def test_score_response_returns_none_for_blocked( response_piece = MagicMock(spec=MessagePiece) response_piece.is_blocked.return_value = True + response_piece.id = uuid.uuid4() basic_context.last_response = MagicMock(spec=Message) basic_context.last_response.get_piece.return_value = response_piece + # A scorable names piece ids, so the mock has to expose its pieces. + basic_context.last_response.message_pieces = [response_piece] # Configure the mock scorer to return empty list for blocked response mock_objective_scorer.score_async = AsyncMock(return_value=[]) diff --git a/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py b/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py index 1c25f74edc..6e9431ce19 100644 --- a/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py +++ b/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py @@ -12,6 +12,7 @@ import pytest from treelib.tree import Tree +from unit.mocks import store_message from pyrit.exceptions import InvalidJsonException from pyrit.executor.attack import ( @@ -42,7 +43,7 @@ ) from pyrit.prompt_normalizer import PromptNormalizer from pyrit.prompt_target import CapabilityName, PromptTarget -from pyrit.score import FloatScaleThresholdScorer, Scorer, TrueFalseScorer +from pyrit.score import FloatScaleThresholdScorer, MessageScorable, Scorer, TrueFalseScorer from pyrit.score.float_scale.float_scale_scorer import FloatScaleScorer from pyrit.score.score_utils import normalize_score_to_float @@ -366,7 +367,7 @@ async def create_threshold_score_async(*, original_float_value: float, threshold class_module="test_module", ), ) - mock_float_scorer.score_async = AsyncMock(return_value=[float_score]) + mock_float_scorer._score_nested_message_async = AsyncMock(return_value=[float_score]) # Create the actual FloatScaleThresholdScorer threshold_scorer = FloatScaleThresholdScorer(scorer=mock_float_scorer, threshold=threshold) @@ -391,7 +392,7 @@ async def create_threshold_score_async(*, original_float_value: float, threshold ) # Score using the actual FloatScaleThresholdScorer - scores = await threshold_scorer.score_async(dummy_message) + scores = await threshold_scorer.score_async(scorable=MessageScorable.from_message(store_message(dummy_message))) return scores[0] @staticmethod diff --git a/tests/unit/mocks.py b/tests/unit/mocks.py index 89d7cd2cd0..5a6051979d 100644 --- a/tests/unit/mocks.py +++ b/tests/unit/mocks.py @@ -10,7 +10,7 @@ from typing import Any from unittest.mock import MagicMock, patch -from pyrit.memory import AzureSQLMemory, CentralMemory, PromptMemoryEntry +from pyrit.memory import AzureSQLMemory, CentralMemory, MemoryInterface, PromptMemoryEntry from pyrit.models import ( ComponentIdentifier, Message, @@ -318,6 +318,61 @@ def get_audio_message_piece() -> MessagePiece: ) +def mock_memory_resolving(*messages: Message) -> MagicMock: + """ + Return a fake memory that can resolve the given messages by piece id. + + Scoring resolves a scorable through memory, so a fake that answers nothing makes every + score fail. This keeps the test free of a real database while still supporting lookups. + + Args: + *messages (Message): Messages the fake should be able to resolve. + + Returns: + MagicMock: A MemoryInterface stand-in with a working get_message_pieces. + """ + known = {str(piece.id): piece for message in messages for piece in message.message_pieces} + memory = MagicMock(MemoryInterface) + memory.get_message_pieces.side_effect = lambda **kwargs: [ + known[str(piece_id)] for piece_id in kwargs.get("prompt_ids", []) or [] if str(piece_id) in known + ] + return memory + + +def store_message(message: Message) -> Message: + """ + Persist a message so a scorable can name its pieces, and return it. + + A ``MessageScorable`` is a reference: it names piece ids and resolves them from memory. + Tests that build a message by hand have to store it first, or resolution has nothing to + find. This fills in whatever persistence needs — a conversation id, and the + ``not_in_memory`` flag some fixtures set — so a hand-built message becomes storable. + Storing is idempotent, so the helper is safe to apply to an already-persisted message. + + Args: + message (Message): The message to persist. + + Returns: + Message: The same message. + """ + memory = CentralMemory.get_memory_instance() + piece_ids = [piece.id for piece in message.message_pieces if piece.id is not None] + if not piece_ids or memory.get_message_pieces(prompt_ids=piece_ids): + return message + + conversation_id = next( + (piece.conversation_id for piece in message.message_pieces if piece.conversation_id), + str(uuid.uuid4()), + ) + for piece in message.message_pieces: + piece.not_in_memory = False + if not piece.conversation_id: + piece.conversation_id = conversation_id + + memory.add_message_to_memory(request=message) + return message + + def get_test_message_piece() -> MessagePiece: return MessagePiece( role="user", diff --git a/tests/unit/models/test_expectation.py b/tests/unit/models/test_expectation.py new file mode 100644 index 0000000000..2fc5a2e2df --- /dev/null +++ b/tests/unit/models/test_expectation.py @@ -0,0 +1,63 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import dataclasses + +import pytest + +from pyrit.models import Condition, MatchesObjective, ScoringExpectation + + +def test_expectation_defaults(): + expectation = ScoringExpectation() + + assert expectation.objective is None + assert expectation.conditions == () + + +def test_expectation_is_frozen(): + expectation = ScoringExpectation(objective="exfiltrate") + + with pytest.raises(dataclasses.FrozenInstanceError): + expectation.objective = "something else" + + +def test_expectations_with_equal_values_compare_equal(): + assert ScoringExpectation(objective="a") == ScoringExpectation(objective="a") + + +def test_expectation_carries_conditions_beside_the_objective(): + expectation = ScoringExpectation(objective="exfiltrate", conditions=(MatchesObjective(),)) + + assert expectation.objective == "exfiltrate" + assert expectation.conditions == (MatchesObjective(),) + + +def test_expectation_carries_conditions_without_an_objective(): + expectation = ScoringExpectation(conditions=(MatchesObjective(),)) + + assert expectation.objective is None + assert expectation.conditions == (MatchesObjective(),) + + +def test_expectations_differing_only_in_conditions_compare_unequal(): + assert ScoringExpectation(objective="a") != ScoringExpectation(objective="a", conditions=(MatchesObjective(),)) + + +def test_matches_objective_carries_no_text_of_its_own(): + assert dataclasses.fields(MatchesObjective()) == () + + +def test_matches_objective_is_a_condition(): + assert isinstance(MatchesObjective(), Condition) + + +def test_matches_objective_instances_compare_equal(): + assert MatchesObjective() == MatchesObjective() + + +def test_matches_objective_is_frozen(): + condition = MatchesObjective() + + with pytest.raises(dataclasses.FrozenInstanceError): + condition.objective = "something" diff --git a/tests/unit/models/test_scorable.py b/tests/unit/models/test_scorable.py new file mode 100644 index 0000000000..a4dbb4c032 --- /dev/null +++ b/tests/unit/models/test_scorable.py @@ -0,0 +1,105 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import dataclasses +import uuid + +import pytest + +from pyrit.models import ContentScorable, Message, MessagePiece, MessageScorable, Scorable + + +def _message(value: str = "response") -> Message: + return MessagePiece( + role="assistant", + original_value=value, + conversation_id=str(uuid.uuid4()), + ).to_message() + + +@pytest.mark.parametrize( + "scorable, field_name", + [ + (MessageScorable(message_piece_ids=(uuid.uuid4(),)), "message_piece_ids"), + (ContentScorable(value="hello"), "value"), + ], +) +def test_scorable_is_frozen(scorable: Scorable, field_name: str): + with pytest.raises(dataclasses.FrozenInstanceError): + setattr(scorable, field_name, "changed") + + +@pytest.mark.parametrize( + "scorable", + [ + MessageScorable(message_piece_ids=(uuid.uuid4(),)), + ContentScorable(value="hello"), + ], +) +def test_every_scorable_is_a_scorable(scorable: Scorable): + assert isinstance(scorable, Scorable) + + +def test_scorables_are_inert(): + assert not hasattr(MessageScorable(message_piece_ids=(uuid.uuid4(),)), "resolve_message") + assert not hasattr(ContentScorable(value="hello"), "to_ephemeral_message") + + +def test_scorables_are_keyword_only(): + with pytest.raises(TypeError): + ContentScorable("hello") # type: ignore[misc] + + +def test_message_scorable_defaults(): + piece_id = uuid.uuid4() + + scorable = MessageScorable(message_piece_ids=(piece_id,)) + + assert scorable.message_piece_ids == (piece_id,) + + +def test_message_scorable_from_message_names_pieces(): + message = _message() + + scorable = MessageScorable.from_message(message) + + assert scorable.message_piece_ids == (message.get_piece().id,) + assert not hasattr(scorable, "message") + + +def test_message_scorable_rejects_empty_ids(): + with pytest.raises(ValueError, match="at least one message piece"): + MessageScorable(message_piece_ids=()) + + +def test_message_scorable_rejects_duplicate_ids(): + piece_id = uuid.uuid4() + + with pytest.raises(ValueError, match="each message piece once"): + MessageScorable(message_piece_ids=(piece_id, piece_id)) + + +def test_message_scorable_rejects_ids_that_repeat_across_types(): + piece_id = uuid.uuid4() + + with pytest.raises(ValueError, match="each message piece once"): + MessageScorable(message_piece_ids=(piece_id, str(piece_id))) + + +def test_content_scorable_defaults_to_text(): + assert ContentScorable(value="hello").data_type == "text" + + +def test_content_scorable_from_message_uses_converted_view(): + message = MessagePiece( + role="user", + original_value="original", + converted_value="converted", + original_value_data_type="text", + converted_value_data_type="text", + ).to_message() + + scorable = ContentScorable.from_message(message) + + assert scorable.value == "converted" + assert scorable.data_type == "text" diff --git a/tests/unit/score/test_azure_content_filter.py b/tests/unit/score/test_azure_content_filter.py index 16e759de6e..072c8d42ba 100644 --- a/tests/unit/score/test_azure_content_filter.py +++ b/tests/unit/score/test_azure_content_filter.py @@ -8,15 +8,12 @@ import pytest from azure.ai.contentsafety.models import TextCategory -from unit.mocks import ( - get_audio_message_piece, - get_image_message_piece, - get_test_message_piece, -) +from unit.mocks import get_audio_message_piece, get_image_message_piece, get_test_message_piece, store_message from pyrit.memory import CentralMemory from pyrit.memory.memory_interface import MemoryInterface from pyrit.models import Message, MessagePiece +from pyrit.score import MessageScorable from pyrit.score.float_scale.azure_content_filter_scorer import AzureContentFilterScorer from pyrit.score.float_scale.float_scale_scorer import FloatScaleScorer @@ -46,7 +43,7 @@ async def test_score_async_unsupported_data_type_returns_zero( # Unified FloatScaleScorer fallback: when all pieces are filtered out, return a single # Score(0.0) instead of an empty list (mirrors TrueFalseScorer's no-pieces fallback). - scores = await scorer.score_async(message=request) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(request))) assert len(scores) == 1 assert scores[0].score_type == "float_scale" assert scores[0].get_value() == 0.0 @@ -338,7 +335,7 @@ async def test_azure_content_filter_scorer_blocked_returns_one_score_per_categor ) message = Message(message_pieces=[blocked_piece]) - scores = await scorer.score_async(message=message) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(message))) assert len(scores) == 2 assert {s.score_category[0] for s in scores} == {TextCategory.HATE.value, TextCategory.VIOLENCE.value} @@ -363,7 +360,7 @@ async def test_azure_content_filter_scorer_blocked_default_categories_returns_fo ) message = Message(message_pieces=[blocked_piece]) - scores = await scorer.score_async(message=message) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(message))) assert len(scores) == 4 assert {s.score_category[0] for s in scores} == {c.value for c in TextCategory} diff --git a/tests/unit/score/test_conversation_history_scorer.py b/tests/unit/score/test_conversation_history_scorer.py index e60e622948..d9f10ec488 100644 --- a/tests/unit/score/test_conversation_history_scorer.py +++ b/tests/unit/score/test_conversation_history_scorer.py @@ -5,10 +5,14 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from unit.mocks import store_message from pyrit.memory import CentralMemory from pyrit.models import ComponentIdentifier, Message, MessagePiece, Score from pyrit.score import ( + ContentScorable, + MessageScorable, + MessageScorer, Scorer, SelfAskGeneralFloatScaleScorer, create_conversation_scorer, @@ -53,8 +57,8 @@ async def _score_piece_async(self, message_piece: MessagePiece, *, objective: st return [] -class MockUnsupportedScorer(Scorer): - """Mock unsupported Scorer for testing error cases""" +class MockUnsupportedScorer(MessageScorer): + """Mock scorer that is neither a FloatScaleScorer nor a TrueFalseScorer""" def __init__(self): super().__init__(validator=ScorerPromptValidator(supported_data_types=["text"])) @@ -139,11 +143,11 @@ async def test_conversation_history_scorer_score_async_success(patch_central_dat objective="test_objective", score_type="float_scale", ) - mock_scorer._score_async = AsyncMock(return_value=[score]) + mock_scorer._score_prepared_message_async = AsyncMock(return_value=[score]) mock_scorer.validate_return_scores = MagicMock() scorer = create_conversation_scorer(scorer=mock_scorer) - scores = await scorer.score_async(message) + scores = await scorer.score_async(scorable=MessageScorable.from_message(message)) assert len(scores) == 1 result_score = scores[0] @@ -152,8 +156,8 @@ async def test_conversation_history_scorer_score_async_success(patch_central_dat assert result_score.score_rationale == "Valid rationale" # Verify the underlying scorer was called with conversation history - mock_scorer._score_async.assert_awaited_once() - call_args = mock_scorer._score_async.call_args + mock_scorer._score_prepared_message_async.assert_awaited_once() + call_args = mock_scorer._score_prepared_message_async.call_args called_message = call_args.kwargs["message"] called_piece = called_message.message_pieces[0] @@ -169,21 +173,15 @@ async def test_conversation_history_scorer_score_async_success(patch_central_dat async def test_conversation_history_scorer_conversation_not_found(patch_central_database): + """Loose content has no conversation behind it, so there is no history to score.""" mock_scorer = MagicMock(spec=SelfAskGeneralFloatScaleScorer) mock_scorer._validator = ScorerPromptValidator(supported_data_types=["text"]) scorer = create_conversation_scorer(scorer=mock_scorer) - nonexistent_conversation_id = str(uuid.uuid4()) - message_piece = MessagePiece( - role="assistant", - original_value="Test response", - conversation_id=nonexistent_conversation_id, - ) - message = MagicMock() - message.message_pieces = [message_piece] - - with pytest.raises(RuntimeError, match=f"Conversation with ID {nonexistent_conversation_id} not found in memory"): - await scorer.score_async(message) + # A MessageScorable cannot reach this guard: resolving it requires the pieces to be in + # memory, and then their conversation is there too. + with pytest.raises(RuntimeError, match="not found in memory"): + await scorer.score_async(scorable=ContentScorable(value="Test response")) async def test_conversation_history_scorer_filters_roles_correctly(patch_central_database): @@ -229,13 +227,13 @@ async def test_conversation_history_scorer_filters_roles_correctly(patch_central objective="test", score_type="float_scale", ) - mock_scorer._score_async = AsyncMock(return_value=[score]) + mock_scorer._score_prepared_message_async = AsyncMock(return_value=[score]) mock_scorer.validate_return_scores = MagicMock() scorer = create_conversation_scorer(scorer=mock_scorer) - await scorer.score_async(message) + await scorer.score_async(scorable=MessageScorable.from_message(message)) - call_args = mock_scorer._score_async.call_args + call_args = mock_scorer._score_prepared_message_async.call_args called_message = call_args.kwargs["message"] called_piece = called_message.message_pieces[0] @@ -273,14 +271,14 @@ async def test_conversation_history_scorer_preserves_metadata(patch_central_data objective="test", score_type="float_scale", ) - mock_scorer._score_async = AsyncMock(return_value=[score]) + mock_scorer._score_prepared_message_async = AsyncMock(return_value=[score]) mock_scorer.validate_return_scores = MagicMock() scorer = create_conversation_scorer(scorer=mock_scorer) - await scorer.score_async(message) + await scorer.score_async(scorable=MessageScorable.from_message(message)) - call_args = mock_scorer._score_async.call_args + call_args = mock_scorer._score_prepared_message_async.call_args called_message = call_args.kwargs["message"] called_piece = called_message.message_pieces[0] @@ -319,17 +317,17 @@ async def test_conversation_scorer_persists_scores_exactly_once(patch_central_da ) original_id = score.id - # Mock the protected _score_async; the public score_async (which persists) is intentionally + # Mock the protected prepared-message hook; the public score_async (which persists) is intentionally # NOT mocked so the test would fail with duplicate rows if ConversationScorer ever calls it. mock_scorer = MagicMock(spec=SelfAskGeneralFloatScaleScorer) mock_scorer._validator = ScorerPromptValidator(supported_data_types=["text"]) - mock_scorer._score_async = AsyncMock(return_value=[score]) + mock_scorer._score_prepared_message_async = AsyncMock(return_value=[score]) mock_scorer.validate_return_scores = MagicMock() conv_scorer = create_conversation_scorer(scorer=mock_scorer) message = MagicMock() message.message_pieces = [message_piece] - result_scores = await conv_scorer.score_async(message) + result_scores = await conv_scorer.score_async(scorable=MessageScorable.from_message(message)) assert len(result_scores) == 1 assert result_scores[0].id == original_id, ( @@ -520,16 +518,9 @@ async def test_conversation_scorer_uses_partial_content_when_score_blocked_conte memory.add_message_pieces_to_memory(message_pieces=message_pieces) - # Use a text piece as the incoming message for validation purposes. - # ConversationScorer only uses it for conversation_id lookup — actual content comes from DB. - lookup_piece = MessagePiece( - role="assistant", - original_value="lookup", - conversation_id=conversation_id, - ) - message = MagicMock() - message.message_pieces = [lookup_piece] - message.get_piece.return_value = lookup_piece + # Name a piece that is already in the conversation. A scorable is a reference, so a + # synthetic lookup piece would have to be persisted and would then join the history. + message = blocked_piece.to_message() mock_scorer = MagicMock(spec=SelfAskGeneralFloatScaleScorer) mock_scorer._validator = ScorerPromptValidator(supported_data_types=["text"]) @@ -544,18 +535,18 @@ async def test_conversation_scorer_uses_partial_content_when_score_blocked_conte objective="test", score_type="float_scale", ) - mock_scorer._score_async = AsyncMock(return_value=[score]) + mock_scorer._score_prepared_message_async = AsyncMock(return_value=[score]) mock_scorer.validate_return_scores = MagicMock() scorer = create_conversation_scorer(scorer=mock_scorer) scorer.score_blocked_content = True - scores = await scorer.score_async(message) + scores = await scorer.score_async(scorable=MessageScorable.from_message(message)) assert len(scores) == 1 # Verify the underlying scorer was called with partial content, not error JSON - mock_scorer._score_async.assert_awaited_once() - call_args = mock_scorer._score_async.call_args + mock_scorer._score_prepared_message_async.assert_awaited_once() + call_args = mock_scorer._score_prepared_message_async.call_args called_message = call_args.kwargs["message"] called_piece = called_message.message_pieces[0] @@ -593,15 +584,9 @@ async def test_conversation_scorer_uses_error_json_when_score_blocked_content_di memory.add_message_pieces_to_memory(message_pieces=message_pieces) - # Use a text piece as the incoming message for validation purposes. - lookup_piece = MessagePiece( - role="assistant", - original_value="lookup", - conversation_id=conversation_id, - ) - message = MagicMock() - message.message_pieces = [lookup_piece] - message.get_piece.return_value = lookup_piece + # Name a piece that is already in the conversation. A scorable is a reference, so a + # synthetic lookup piece would have to be persisted and would then join the history. + message = blocked_piece.to_message() mock_scorer = MagicMock(spec=SelfAskGeneralFloatScaleScorer) mock_scorer._validator = ScorerPromptValidator(supported_data_types=["text"]) @@ -616,18 +601,18 @@ async def test_conversation_scorer_uses_error_json_when_score_blocked_content_di objective="test", score_type="float_scale", ) - mock_scorer._score_async = AsyncMock(return_value=[score]) + mock_scorer._score_prepared_message_async = AsyncMock(return_value=[score]) mock_scorer.validate_return_scores = MagicMock() scorer = create_conversation_scorer(scorer=mock_scorer) # score_blocked_content defaults to False - scores = await scorer.score_async(message) + scores = await scorer.score_async(scorable=MessageScorable.from_message(message)) assert len(scores) == 1 # Verify the underlying scorer was called with error JSON, not partial content - mock_scorer._score_async.assert_awaited_once() - call_args = mock_scorer._score_async.call_args + mock_scorer._score_prepared_message_async.assert_awaited_once() + call_args = mock_scorer._score_prepared_message_async.call_args called_message = call_args.kwargs["message"] called_piece = called_message.message_pieces[0] @@ -683,16 +668,16 @@ async def test_conversation_scorer_blocked_input_message_does_not_raise(patch_ce objective="test", score_type="float_scale", ) - mock_scorer._score_async = AsyncMock(return_value=[score]) + mock_scorer._score_prepared_message_async = AsyncMock(return_value=[score]) mock_scorer.validate_return_scores = MagicMock() scorer = create_conversation_scorer(scorer=mock_scorer) # Must not raise — previously raised ValueError on the blocked piece. - scores = await scorer.score_async(blocked_message) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(blocked_message))) assert len(scores) == 1 - mock_scorer._score_async.assert_awaited_once() + mock_scorer._score_prepared_message_async.assert_awaited_once() async def test_conversation_scorer_blocked_trigger_preserves_prior_turn_scoring(patch_central_database): @@ -783,7 +768,7 @@ async def _score_piece_async(self, message_piece: MessagePiece, *, objective: st inner_scorer = HarmfulContentDetector() scorer = create_conversation_scorer(scorer=inner_scorer) - scores = await scorer.score_async(blocked_message) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(blocked_message))) assert len(scores) == 1 # Must be 1.0 (real score from prior turns), NOT 0.0 (fallback from rejected synthetic piece) diff --git a/tests/unit/score/test_float_scale_threshold_scorer.py b/tests/unit/score/test_float_scale_threshold_scorer.py index b98cb183d8..7107d5ee1c 100644 --- a/tests/unit/score/test_float_scale_threshold_scorer.py +++ b/tests/unit/score/test_float_scale_threshold_scorer.py @@ -5,10 +5,11 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from unit.mocks import store_message from pyrit.memory import CentralMemory, MemoryInterface from pyrit.models import ComponentIdentifier, Message, MessagePiece, Score -from pyrit.score import FloatScaleThresholdScorer +from pyrit.score import FloatScaleThresholdScorer, MessageScorable from pyrit.score.float_scale.float_scale_scorer import FloatScaleScorer from pyrit.score.scorer_prompt_validator import ScorerPromptValidator @@ -19,8 +20,8 @@ def create_mock_float_scorer(score_value: float): class_name="MockScorer", class_module="test.mock", ) - scorer = AsyncMock() - scorer.score_async = AsyncMock( + scorer = MagicMock(spec=FloatScaleScorer) + scorer._score_nested_message_async = AsyncMock( return_value=[ Score( score_value=str(score_value), @@ -72,9 +73,9 @@ async def test_float_scale_threshold_scorer_returns_single_score_with_multi_cate ) # Mock a scorer that returns multiple scores (like AzureContentFilterScorer) - scorer = AsyncMock() + scorer = MagicMock(spec=FloatScaleScorer) prompt_id = uuid.uuid4() - scorer.score_async = AsyncMock( + scorer._score_nested_message_async = AsyncMock( return_value=[ Score( score_value="0.2", @@ -140,8 +141,8 @@ async def test_float_scale_threshold_scorer_handles_empty_scores(): memory = MagicMock(MemoryInterface) # Mock a scorer that returns empty list (all pieces filtered) - scorer = AsyncMock() - scorer.score_async = AsyncMock(return_value=[]) + scorer = MagicMock(spec=FloatScaleScorer) + scorer._score_nested_message_async = AsyncMock(return_value=[]) # get_identifier() returns a ComponentIdentifier mock_identifier = ComponentIdentifier( class_name="MockScorer", @@ -170,15 +171,13 @@ async def test_float_scale_threshold_scorer_with_raise_on_empty_aggregator(): Test that FloatScaleThresholdScorer raises ValueError when using RAISE_ON_EMPTY aggregator and the underlying scorer returns no scores. """ - from pyrit.score.float_scale.float_scale_score_aggregator import ( - FloatScaleScoreAggregator, - ) + from pyrit.score.float_scale.float_scale_score_aggregator import FloatScaleScoreAggregator memory = MagicMock(MemoryInterface) # Mock a scorer that returns empty list (all pieces filtered) - scorer = AsyncMock() - scorer.score_async = AsyncMock(return_value=[]) + scorer = MagicMock(spec=FloatScaleScorer) + scorer._score_nested_message_async = AsyncMock(return_value=[]) # get_identifier() returns a ComponentIdentifier mock_identifier = ComponentIdentifier( class_name="MockScorer", @@ -262,10 +261,15 @@ async def _score_piece_async(self, message_piece: MessagePiece, *, objective: st ) blocked_message = Message(message_pieces=[blocked_piece]) - scores = await threshold_scorer.score_async(blocked_message) + scores = await threshold_scorer.score_async(scorable=MessageScorable.from_message(store_message(blocked_message))) assert len(scores) == 1 binary_score = scores[0] assert binary_score.score_type == "true_false" assert binary_score.get_value() is False assert "Normalized scale score: 0.0" in binary_score.score_rationale + + memory = CentralMemory.get_memory_instance() + persisted_scores = memory.get_scores(score_type="true_false") + assert len(persisted_scores) == 1 + assert memory.get_scores(score_type="float_scale") == [] diff --git a/tests/unit/score/test_gandalf_scorer.py b/tests/unit/score/test_gandalf_scorer.py index e47a4c39cd..78d678517d 100644 --- a/tests/unit/score/test_gandalf_scorer.py +++ b/tests/unit/score/test_gandalf_scorer.py @@ -5,13 +5,13 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from unit.mocks import get_mock_target_identifier +from unit.mocks import get_mock_target_identifier, store_message from pyrit.exceptions.exception_classes import PyritException from pyrit.memory.memory_interface import MemoryInterface from pyrit.models import Message, MessagePiece from pyrit.prompt_target import GandalfLevel -from pyrit.score import GandalfScorer +from pyrit.score import GandalfScorer, MessageScorable def generate_password_extraction_response(response_text: str, conversation_id: str | None = None) -> Message: @@ -64,7 +64,7 @@ async def test_gandalf_scorer_score( mocked_post.return_value = MagicMock(json=lambda: {"success": password_correct, "message": "Message"}) - scores = await scorer.score_async(response) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(response))) assert len(scores) == 1 assert scores[0].get_value() == password_correct @@ -99,7 +99,7 @@ async def test_gandalf_scorer_set_system_prompt( mocked_post.return_value = MagicMock(json=lambda: {"success": True, "message": "Message"}) - await scorer.score_async(response) + await scorer.score_async(scorable=MessageScorable.from_message(store_message(response))) chat_target.set_system_prompt.assert_called_once() @@ -121,10 +121,11 @@ async def test_gandalf_scorer_adds_to_memory(mocked_post, level: GandalfLevel, s mocked_post.return_value = MagicMock(json=lambda: {"success": True, "message": "Message"}) - with patch.object(sqlite_instance, "get_message_pieces", return_value=[generated_request.message_pieces[0]]): + patched_pieces = [generated_request.message_pieces[0], response.message_pieces[0]] + with patch.object(sqlite_instance, "get_message_pieces", return_value=patched_pieces): scorer = GandalfScorer(level=level, chat_target=chat_target) - await scorer.score_async(response) + await scorer.score_async(scorable=MessageScorable.from_message(store_message(response))) @pytest.mark.parametrize("level", [GandalfLevel.LEVEL_1, GandalfLevel.LEVEL_2, GandalfLevel.LEVEL_3]) @@ -140,7 +141,7 @@ async def test_gandalf_scorer_runtime_error_retries(level: GandalfLevel, sqlite_ scorer = GandalfScorer(level=level, chat_target=chat_target) with pytest.raises(PyritException, match="Error in scorer GandalfScorer"): - await scorer.score_async(response) + await scorer.score_async(scorable=MessageScorable.from_message(store_message(response))) assert chat_target.send_prompt_async.call_count == 1 @@ -167,4 +168,4 @@ async def test_gandalf_scorer_wraps_httpx_error_as_pyrit_exception(mocked_post, scorer = GandalfScorer(level=GandalfLevel.LEVEL_1, chat_target=chat_target) with pytest.raises(PyritException, match="Error in scorer GandalfScorer"): - await scorer.score_async(response) + await scorer.score_async(scorable=MessageScorable.from_message(store_message(response))) diff --git a/tests/unit/score/test_insecure_code_scorer.py b/tests/unit/score/test_insecure_code_scorer.py index e0370a196d..78954b837b 100644 --- a/tests/unit/score/test_insecure_code_scorer.py +++ b/tests/unit/score/test_insecure_code_scorer.py @@ -4,11 +4,12 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from unit.mocks import store_message from pyrit.exceptions.exception_classes import InvalidJsonException from pyrit.models import ComponentIdentifier, Message, MessagePiece, Score, SeedPrompt, UnvalidatedScore from pyrit.prompt_target import PromptTarget -from pyrit.score import InsecureCodeScorer +from pyrit.score import InsecureCodeScorer, MessageScorable @pytest.fixture @@ -47,7 +48,7 @@ async def test_insecure_code_scorer_valid_response(mock_chat_target): message = MessagePiece(role="user", original_value="sample code").to_message() # Call the score_async method - scores = await scorer.score_async(message) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(message))) # Assertions assert len(scores) == 1 @@ -70,7 +71,7 @@ async def test_insecure_code_scorer_invalid_json(mock_chat_target): message = MessagePiece(role="user", original_value="sample code").to_message() with pytest.raises(InvalidJsonException, match="Error in scorer InsecureCodeScorer.*Invalid JSON"): - await scorer.score_async(message) + await scorer.score_async(scorable=MessageScorable.from_message(store_message(message))) # Ensure memory functions were not called mock_add_scores.assert_not_called() @@ -109,7 +110,7 @@ async def test_score_async_unsupported_data_type_returns_zero(mock_chat_target, # Unified FloatScaleScorer fallback: returns a single Score(0.0) when all pieces are filtered # out (mirrors TrueFalseScorer's no-pieces fallback). - scores = await scorer.score_async(request) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(request))) assert len(scores) == 1 assert scores[0].score_type == "float_scale" assert scores[0].get_value() == 0.0 diff --git a/tests/unit/score/test_message_scorable_resolver.py b/tests/unit/score/test_message_scorable_resolver.py new file mode 100644 index 0000000000..4abba8330a --- /dev/null +++ b/tests/unit/score/test_message_scorable_resolver.py @@ -0,0 +1,95 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import uuid +from unittest.mock import MagicMock + +import pytest + +from pyrit.memory import MemoryInterface +from pyrit.models import ContentScorable, Message, MessagePiece, MessageScorable +from pyrit.score.message_scorable_resolver import MessageScorableResolver + + +def _stored_message(value: str = "stored response") -> Message: + return MessagePiece( + role="assistant", + original_value=value, + conversation_id=str(uuid.uuid4()), + ).to_message() + + +def test_resolver_reads_message_reference_from_memory(sqlite_instance: MemoryInterface): + stored = _stored_message() + sqlite_instance.add_message_to_memory(request=stored) + + resolved = MessageScorableResolver().resolve( + scorable=MessageScorable.from_message(stored), + memory=sqlite_instance, + ) + + assert resolved.get_value() == "stored response" + + +def test_resolver_reports_missing_piece_ids(sqlite_instance: MemoryInterface): + stored = _stored_message() + sqlite_instance.add_message_to_memory(request=stored) + missing_id = uuid.uuid4() + + with pytest.raises(ValueError, match=f"No message pieces found in memory for ids \\['{missing_id}'\\]"): + MessageScorableResolver().resolve( + scorable=MessageScorable(message_piece_ids=(stored.get_piece().id, missing_id)), + memory=sqlite_instance, + ) + + +def test_resolver_rejects_pieces_from_multiple_messages(sqlite_instance: MemoryInterface): + conversation_id = str(uuid.uuid4()) + first = MessagePiece( + role="user", + original_value="ask", + conversation_id=conversation_id, + sequence=0, + ).to_message() + second = MessagePiece( + role="assistant", + original_value="answer", + conversation_id=conversation_id, + sequence=1, + ).to_message() + sqlite_instance.add_message_to_memory(request=first) + sqlite_instance.add_message_to_memory(request=second) + + with pytest.raises(ValueError, match="exactly one message"): + MessageScorableResolver().resolve( + scorable=MessageScorable( + message_piece_ids=(first.get_piece().id, second.get_piece().id), + ), + memory=sqlite_instance, + ) + + +def test_resolver_preserves_reference_order(sqlite_instance: MemoryInterface): + conversation_id = str(uuid.uuid4()) + first = MessagePiece(role="assistant", original_value="one", conversation_id=conversation_id, sequence=0) + second = MessagePiece(role="assistant", original_value="two", conversation_id=conversation_id, sequence=0) + sqlite_instance.add_message_to_memory(request=Message(message_pieces=[first, second])) + + resolved = MessageScorableResolver().resolve( + scorable=MessageScorable(message_piece_ids=(second.id, first.id)), + memory=sqlite_instance, + ) + + assert [piece.original_value for piece in resolved.message_pieces] == ["two", "one"] + + +def test_resolver_adapts_content_to_ephemeral_message(): + resolved = MessageScorableResolver().resolve( + scorable=ContentScorable(value="loose text"), + memory=MagicMock(spec=MemoryInterface), + ) + + piece = resolved.get_piece() + assert piece.converted_value == "loose text" + assert piece.role == "user" + assert piece.not_in_memory is True diff --git a/tests/unit/score/test_message_scorer.py b/tests/unit/score/test_message_scorer.py new file mode 100644 index 0000000000..99ca68ca95 --- /dev/null +++ b/tests/unit/score/test_message_scorer.py @@ -0,0 +1,622 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import dataclasses +import inspect +import uuid +from unittest.mock import MagicMock + +import pytest + +from pyrit.memory import CentralMemory, MemoryInterface +from pyrit.models import ( + ChatMessageRole, + ComponentIdentifier, + Condition, + MatchesObjective, + Message, + MessagePiece, + Score, + ScoringExpectation, +) +from pyrit.score import ( + ContentScorable, + MessageScorable, + MessageScorer, + Scorable, + Scorer, + ScorerPromptValidator, + TrueFalseScorer, +) +from pyrit.score.message_scorable_resolver import MessageScorableResolver +from pyrit.score.message_scorer import MessageScoringOptions, extract_objective_from_previous_turn + + +@dataclasses.dataclass(frozen=True) +class UnsupportedScorable(Scorable): + """A scorable kind no message scorer handles.""" + + uri: str + + +class PermissiveValidator(ScorerPromptValidator): + def __init__(self, *, is_objective_required: bool = False) -> None: + super().__init__(is_objective_required=is_objective_required) + + def validate(self, message, objective=None): + pass + + def is_message_piece_supported(self, message_piece): + return True + + +class RecordingScorer(TrueFalseScorer): + """A message scorer that remembers what it was asked to score.""" + + def __init__( + self, + *, + message_resolver: MessageScorableResolver | None = None, + is_objective_required: bool = False, + ) -> None: + super().__init__( + validator=PermissiveValidator(is_objective_required=is_objective_required), + message_resolver=message_resolver, + ) + self.scored_messages: list[Message] = [] + self.scored_objectives: list[str | None] = [] + + def _build_identifier(self) -> ComponentIdentifier: + return self._create_identifier() + + async def _score_async(self, message: Message, *, objective: str | None = None) -> list[Score]: + self.scored_messages.append(message) + self.scored_objectives.append(objective) + return [self._build_score(message.get_piece(), objective)] + + async def _score_piece_async(self, message_piece: MessagePiece, *, objective: str | None = None) -> list[Score]: + return [self._build_score(message_piece, objective)] + + def _build_score(self, message_piece: MessagePiece, objective: str | None) -> Score: + return Score( + score_value="true", + score_value_description="desc", + score_type="true_false", + score_category=None, + score_metadata=None, + score_rationale="rationale", + scorer_class_identifier=self.get_identifier(), + message_piece_id=message_piece.id, + objective=objective, + ) + + +def _assistant_message(value: str = "response", conversation_id: str | None = None) -> Message: + """Return an assistant message that is already in memory, since a scorable names ids.""" + message = MessagePiece( + role="assistant", + original_value=value, + conversation_id=conversation_id or str(uuid.uuid4()), + ).to_message() + CentralMemory.get_memory_instance().add_message_to_memory(request=message) + return message + + +def _error_message() -> Message: + """Return a stored assistant message that carries a blocked error result.""" + message = MessagePiece( + role="assistant", + original_value="blocked", + original_value_data_type="error", + response_error="blocked", + conversation_id=str(uuid.uuid4()), + ).to_message() + CentralMemory.get_memory_instance().add_message_to_memory(request=message) + return message + + +@pytest.mark.usefixtures("patch_central_database") +class TestScorableResolution: + """MessageScorer reduces every message-shaped scorable to a single Message.""" + + async def test_message_scorable_resolves_from_memory(self, sqlite_instance: MemoryInterface): + message = _assistant_message("stored response") + scorer = RecordingScorer() + + scores = await scorer.score_async(scorable=MessageScorable.from_message(message)) + + assert len(scores) == 1 + assert scorer.scored_messages[0].get_value() == "stored response" + + async def test_message_scorable_resolves_by_piece_id(self, sqlite_instance: MemoryInterface): + message = _assistant_message("stored response") + piece_id = message.get_piece().id + scorer = RecordingScorer() + + scores = await scorer.score_async(scorable=MessageScorable(message_piece_ids=(piece_id,))) + + assert len(scores) == 1 + assert scorer.scored_messages[0].get_value() == "stored response" + + async def test_message_scorable_not_in_memory_raises(self): + scorer = RecordingScorer() + missing_id = uuid.uuid4() + + with pytest.raises(ValueError, match="No message pieces found in memory"): + await scorer.score_async(scorable=MessageScorable(message_piece_ids=(missing_id,))) + + async def test_message_scorable_partially_in_memory_raises(self, sqlite_instance: MemoryInterface): + """A partial resolution is a caller error, so it must not be scored silently.""" + stored = _assistant_message("stored response") + stored_id = stored.get_piece().id + missing_id = uuid.uuid4() + scorer = RecordingScorer() + + with pytest.raises(ValueError, match=f"No message pieces found in memory for ids \\['{missing_id}'\\]"): + await scorer.score_async(scorable=MessageScorable(message_piece_ids=(stored_id, missing_id))) + + assert scorer.scored_messages == [] + + async def test_message_scorable_spanning_messages_raises(self, sqlite_instance: MemoryInterface): + conversation_id = str(uuid.uuid4()) + first = MessagePiece( + role="user", original_value="ask", conversation_id=conversation_id, sequence=0 + ).to_message() + second = MessagePiece( + role="assistant", original_value="answer", conversation_id=conversation_id, sequence=1 + ).to_message() + sqlite_instance.add_message_to_memory(request=first) + sqlite_instance.add_message_to_memory(request=second) + scorer = RecordingScorer() + + with pytest.raises(ValueError, match="exactly one message"): + await scorer.score_async( + scorable=MessageScorable( + message_piece_ids=(first.get_piece().id, second.get_piece().id), + ) + ) + + async def test_content_scorable_is_never_persisted(self): + scorer = RecordingScorer() + + scores = await scorer.score_async(scorable=ContentScorable(value="loose text")) + + scored_piece = scorer.scored_messages[0].get_piece() + assert scored_piece.original_value == "loose text" + assert scored_piece.role == "user" + assert scored_piece.not_in_memory is True + # Memory cannot link a score to a piece it never stored. + assert scores[0].message_piece_id is None + + async def test_message_scorer_uses_injected_resolver(self): + message = _assistant_message() + resolver = MagicMock(spec=MessageScorableResolver) + resolver.resolve.return_value = message + scorer = RecordingScorer(message_resolver=resolver) + + await scorer.score_async(scorable=ContentScorable(value="ignored")) + + resolver.resolve.assert_called_once() + + async def test_unsupported_scorable_raises_type_error(self): + scorer = RecordingScorer() + + with pytest.raises(TypeError, match="cannot score UnsupportedScorable"): + await scorer.score_async(scorable=UnsupportedScorable(uri="/tmp/out.txt")) # type: ignore[arg-type] + + +class TestScorerBaseIsScorableAgnostic: + """The base extension contract contains no message-processing requirements.""" + + def test_scorer_requires_a_scorable_implementation(self): + # A scorer that implements only the message hooks cannot be instantiated. Without + # this, such a scorer builds fine and fails later with a confusing TypeError. + assert "_score_scorable_async" in Scorer.__abstractmethods__ + + @pytest.mark.parametrize("hook", ["_score_async", "_score_piece_async", "_get_supported_pieces"]) + def test_message_hooks_live_on_message_scorer(self, hook): + assert not hasattr(Scorer, hook) + assert hasattr(MessageScorer, hook) + + def test_message_scorer_satisfies_the_scorable_contract(self): + assert "_score_scorable_async" not in MessageScorer.__abstractmethods__ + assert "_score_piece_async" in MessageScorer.__abstractmethods__ + + def test_message_dependencies_live_on_message_scorer(self): + # The base keeps 'validator' only as a deprecated shim for pre-2.0 subclasses; the + # dependency itself is required by MessageScorer. + assert inspect.signature(Scorer).parameters["validator"].default is None + assert inspect.signature(MessageScorer).parameters["validator"].default is inspect.Parameter.empty + for hook in [ + "_build_fallback_score", + "_apply_structured_refusal_substitution", + "_apply_blocked_content_substitution", + ]: + assert not hasattr(Scorer, hook) + assert hasattr(MessageScorer, hook) + + +@pytest.mark.usefixtures("patch_central_database") +class TestScorableFilters: + """Message policy is separate from the scorable's evidence identity.""" + + async def test_role_filter_mismatch_skips_scoring(self): + scorer = RecordingScorer() + message = _assistant_message() + + scores = await scorer.score_async( + scorable=MessageScorable.from_message(message), + message_options=MessageScoringOptions(role_filter="user"), + ) + + assert scores == [] + assert scorer.scored_messages == [] + + async def test_role_filter_match_scores(self): + scorer = RecordingScorer() + message = _assistant_message() + + scores = await scorer.score_async( + scorable=MessageScorable.from_message(message), + message_options=MessageScoringOptions(role_filter="assistant"), + ) + + assert len(scores) == 1 + + async def test_skip_on_error_result_skips_error_message(self): + scorer = RecordingScorer() + message = _error_message() + + scores = await scorer.score_async( + scorable=MessageScorable.from_message(message), + message_options=MessageScoringOptions(skip_on_error_result=True), + ) + + assert scores == [] + assert scorer.scored_messages == [] + + async def test_error_message_is_scored_when_not_skipping(self): + scorer = RecordingScorer() + message = _error_message() + + scores = await scorer.score_async(scorable=MessageScorable.from_message(message)) + + assert len(scores) == 1 + + +@pytest.mark.usefixtures("patch_central_database") +class TestExpectation: + """The expectation carries what to look for.""" + + async def test_objective_reaches_the_scorer(self): + scorer = RecordingScorer() + + await scorer.score_async( + scorable=MessageScorable.from_message(_assistant_message()), + expectation=ScoringExpectation(objective="find the objective"), + ) + + assert scorer.scored_objectives == ["find the objective"] + + async def test_no_expectation_means_no_objective(self): + scorer = RecordingScorer() + + await scorer.score_async(scorable=MessageScorable.from_message(_assistant_message())) + + assert scorer.scored_objectives == [None] + + +@pytest.mark.usefixtures("patch_central_database") +class TestDeprecatedParameters: + """The legacy message-shaped parameters survive one release behind a warning.""" + + async def test_positional_message_maps_to_message_scorable(self): + scorer = RecordingScorer() + message = _assistant_message() + + with pytest.warns(DeprecationWarning, match="Scorer.score_async"): + scores = await scorer.score_async(message) + + assert len(scores) == 1 + assert scorer.scored_messages == [message] + + async def test_keyword_message_maps_to_message_scorable(self): + scorer = RecordingScorer() + message = _assistant_message() + + with pytest.warns(DeprecationWarning, match="Scorer.score_async"): + await scorer.score_async(message=message) + + assert scorer.scored_messages == [message] + + async def test_ephemeral_message_keeps_its_own_state(self): + """An in-hand message is scored as it stands, so nothing about it is re-derived.""" + scorer = RecordingScorer() + message = MessagePiece( + role="assistant", + original_value="original", + converted_value="converted", + response_error="blocked", + ).to_message() + message.set_response_not_in_memory() + + with pytest.warns(DeprecationWarning, match="Scorer.score_async"): + await scorer.score_async(message) + + scored = scorer.scored_messages[0] + assert scored.get_value() == "converted" + assert scored.get_piece().role == "assistant" + assert scored.is_error() + + async def test_message_does_not_widen_to_the_stored_conversation(self, sqlite_instance: MemoryInterface): + """The shim scores the supplied message, never the whole conversation behind it.""" + conversation_id = str(uuid.uuid4()) + sqlite_instance.add_message_to_memory( + request=MessagePiece( + role="user", + original_value="an earlier turn that must not be scored", + conversation_id=conversation_id, + sequence=0, + ).to_message() + ) + message = _assistant_message("only this turn", conversation_id=conversation_id) + scorer = RecordingScorer() + + with pytest.warns(DeprecationWarning, match="Scorer.score_async"): + await scorer.score_async(message) + + assert [scored.get_value() for scored in scorer.scored_messages] == ["only this turn"] + + async def test_objective_maps_to_expectation(self): + scorer = RecordingScorer() + + with pytest.warns(DeprecationWarning, match="Scorer.score_async"): + await scorer.score_async(_assistant_message(), objective="legacy objective") + + assert scorer.scored_objectives == ["legacy objective"] + + async def test_legacy_role_filter_maps_to_message_options(self): + scorer = RecordingScorer() + + with pytest.warns(DeprecationWarning, match="Scorer.score_async"): + scores = await scorer.score_async(_assistant_message(), role_filter="user") + + assert scores == [] + + async def test_legacy_skip_on_error_result_maps_to_message_options(self): + scorer = RecordingScorer() + message = _error_message() + + with pytest.warns(DeprecationWarning, match="Scorer.score_async"): + scores = await scorer.score_async(message, skip_on_error_result=True) + + assert scores == [] + + @pytest.mark.parametrize( + "kwargs", + [ + {"skip_on_error_result": False}, + {"infer_objective_from_request": False}, + ], + ) + async def test_explicit_false_legacy_boolean_emits_warning(self, kwargs): + scorer = RecordingScorer() + + with pytest.warns(DeprecationWarning, match="Scorer.score_async"): + await scorer.score_async( + scorable=MessageScorable.from_message(_assistant_message()), + **kwargs, + ) + + async def test_infer_objective_from_request_reads_the_previous_turn(self, sqlite_instance: MemoryInterface): + conversation_id = str(uuid.uuid4()) + sqlite_instance.add_message_to_memory( + request=MessagePiece( + role="user", + original_value="the inferred objective", + conversation_id=conversation_id, + sequence=0, + ).to_message() + ) + message = _assistant_message("response", conversation_id=conversation_id) + scorer = RecordingScorer() + + with pytest.warns(DeprecationWarning, match="Scorer.score_async"): + await scorer.score_async(message, infer_objective_from_request=True) + + assert scorer.scored_objectives == ["the inferred objective"] + + async def test_new_signature_emits_no_warning(self, recwarn): + scorer = RecordingScorer() + + await scorer.score_async( + scorable=MessageScorable.from_message(_assistant_message()), + expectation=ScoringExpectation(objective="objective"), + ) + + assert [warning for warning in recwarn if issubclass(warning.category, DeprecationWarning)] == [] + + +@pytest.mark.usefixtures("patch_central_database") +class TestConflictingInputs: + """The shim refuses input it cannot map without guessing.""" + + async def test_message_and_scorable_together_raises(self): + scorer = RecordingScorer() + message = _assistant_message() + + with pytest.raises(ValueError, match="not both"): + await scorer.score_async(message, scorable=MessageScorable.from_message(message)) + + async def test_neither_message_nor_scorable_raises(self): + scorer = RecordingScorer() + + with pytest.raises(ValueError, match="must be provided"): + await scorer.score_async() + + async def test_objective_and_expectation_together_raises(self): + scorer = RecordingScorer() + + with pytest.raises(ValueError, match="not both"): + await scorer.score_async( + scorable=MessageScorable.from_message(_assistant_message()), + objective="one", + expectation=ScoringExpectation(objective="two"), + ) + + @pytest.mark.parametrize("kwargs", [{"role_filter": "assistant"}, {"skip_on_error_result": True}]) + async def test_message_options_and_legacy_policy_raise(self, kwargs): + scorer = RecordingScorer() + + with pytest.raises(ValueError, match="either 'message_options' or legacy"): + await scorer.score_async( + scorable=MessageScorable.from_message(_assistant_message()), + message_options=MessageScoringOptions(), + **kwargs, + ) + + +@pytest.mark.usefixtures("patch_central_database") +class TestExtractObjectiveFromPreviousTurn: + """The objective lookup belongs to whoever builds the expectation.""" + + def test_reads_the_turn_before_the_response(self, sqlite_instance: MemoryInterface): + conversation_id = str(uuid.uuid4()) + sqlite_instance.add_message_to_memory( + request=MessagePiece( + role="user", original_value="the request", conversation_id=conversation_id, sequence=0 + ).to_message() + ) + message = _assistant_message("the response", conversation_id=conversation_id) + + objective = extract_objective_from_previous_turn(message=message, memory=sqlite_instance) + + assert objective == "the request" + + def test_returns_empty_for_a_user_message(self, sqlite_instance: MemoryInterface): + message = MessagePiece(role="user", original_value="a request").to_message() + + assert extract_objective_from_previous_turn(message=message, memory=sqlite_instance) == "" + + def test_returns_empty_when_the_conversation_is_not_stored(self, sqlite_instance: MemoryInterface): + message = MessagePiece( + role="assistant", original_value="a response", conversation_id=str(uuid.uuid4()) + ).to_message() + + assert extract_objective_from_previous_turn(message=message, memory=sqlite_instance) == "" + + def test_reads_the_request_for_the_scored_turn_not_the_latest_one(self, sqlite_instance: MemoryInterface): + """Scoring an earlier response must not pick up a request from later in the conversation.""" + conversation_id = str(uuid.uuid4()) + turns: list[tuple[str, ChatMessageRole]] = [ + ("the first request", "user"), + ("the first response", "assistant"), + ("a later request", "user"), + ("a later response", "assistant"), + ] + for value, role in turns: + sqlite_instance.add_message_to_memory( + request=MessagePiece(role=role, original_value=value, conversation_id=conversation_id).to_message() + ) + first_response = sqlite_instance.get_message_pieces(conversation_id=conversation_id)[1].to_message() + + objective = extract_objective_from_previous_turn(message=first_response, memory=sqlite_instance) + + assert objective == "the first request" + + +@pytest.mark.usefixtures("patch_central_database") +class TestInHandMessages: + """A message already in hand is scored as it stands, not re-acquired.""" + + async def test_score_message_async_does_not_read_memory(self): + resolver = MagicMock(spec=MessageScorableResolver) + scorer = RecordingScorer(message_resolver=resolver) + message = _assistant_message("in hand") + + await scorer.score_message_async(message=message) + + resolver.resolve.assert_not_called() + assert scorer.scored_messages == [message] + + async def test_score_message_async_preserves_ephemeral_error_state(self): + scorer = RecordingScorer() + message = MessagePiece( + role="assistant", + original_value="", + original_value_data_type="error", + response_error="blocked", + ).to_message() + message.set_response_not_in_memory() + + await scorer.score_message_async(message=message) + + assert scorer.scored_messages[0].is_error() + + async def test_score_message_async_applies_message_options(self): + scorer = RecordingScorer() + + scores = await scorer.score_message_async( + message=_assistant_message(), + message_options=MessageScoringOptions(role_filter="user"), + ) + + assert scores == [] + + +@pytest.mark.usefixtures("patch_central_database") +class TestConditionRouting: + """An expectation is a routing envelope, so a condition is consumed or refused.""" + + async def test_matches_objective_reaches_a_message_scorer(self): + scorer = RecordingScorer(is_objective_required=True) + + scores = await scorer.score_async( + scorable=MessageScorable.from_message(_assistant_message()), + expectation=ScoringExpectation(objective="an objective", conditions=(MatchesObjective(),)), + ) + + assert len(scores) == 1 + + async def test_matches_objective_without_an_objective_raises(self): + scorer = RecordingScorer(is_objective_required=True) + + with pytest.raises(ValueError, match="MatchesObjective requires"): + await scorer.score_async( + scorable=MessageScorable.from_message(_assistant_message()), + expectation=ScoringExpectation(conditions=(MatchesObjective(),)), + ) + + async def test_unconsumed_condition_raises_instead_of_being_dropped(self): + @dataclasses.dataclass(frozen=True) + class UnroutedCondition(Condition): + pass + + scorer = RecordingScorer() + + with pytest.raises(ValueError, match="does not match the condition"): + await scorer.score_async( + scorable=MessageScorable.from_message(_assistant_message()), + expectation=ScoringExpectation(conditions=(UnroutedCondition(),)), + ) + + async def test_two_conditions_of_one_type_raise(self): + scorer = RecordingScorer(is_objective_required=True) + + with pytest.raises(ValueError, match="at most one condition"): + await scorer.score_async( + scorable=MessageScorable.from_message(_assistant_message()), + expectation=ScoringExpectation( + objective="an objective", + conditions=(MatchesObjective(), MatchesObjective()), + ), + ) + + def test_only_objective_required_scorers_match_objective(self): + contextual_scorer = RecordingScorer() + objective_scorer = RecordingScorer(is_objective_required=True) + + assert contextual_scorer.matched_conditions() == frozenset() + assert contextual_scorer.required_conditions() == frozenset() + assert objective_scorer.matched_conditions() == frozenset({MatchesObjective}) + assert objective_scorer.required_conditions() == frozenset({MatchesObjective}) diff --git a/tests/unit/score/test_package_hallucination_scorer.py b/tests/unit/score/test_package_hallucination_scorer.py index 0ae518fc24..1c6422742f 100644 --- a/tests/unit/score/test_package_hallucination_scorer.py +++ b/tests/unit/score/test_package_hallucination_scorer.py @@ -89,7 +89,7 @@ async def test_default_category(self): async def test_score_async_full_path_on_assistant_message(self): scorer = PackageHallucinationScorer(known_packages={"requests"}, ecosystem=PackageEcosystem.PYTHON) message = _assistant_piece("import requests\nimport madeuppkg\n").to_message() - score = (await scorer.score_async(message))[0] + score = (await scorer.score_message_async(message=message))[0] assert score.get_value() is True async def test_score_text_async_user_role_filtered_returns_false(self): diff --git a/tests/unit/score/test_plagiarism_scorer.py b/tests/unit/score/test_plagiarism_scorer.py index 13e2d2976d..d7da110546 100644 --- a/tests/unit/score/test_plagiarism_scorer.py +++ b/tests/unit/score/test_plagiarism_scorer.py @@ -1,17 +1,14 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest +from unit.mocks import mock_memory_resolving, store_message from pyrit.memory import CentralMemory -from pyrit.memory.memory_interface import MemoryInterface from pyrit.models import MessagePiece -from pyrit.score import ( - PlagiarismMetric, - PlagiarismScorer, -) +from pyrit.score import MessageScorable, PlagiarismMetric, PlagiarismScorer @pytest.mark.usefixtures("patch_central_database") @@ -55,7 +52,7 @@ async def test_score_async_lcs_metric(self): request = message_piece.to_message() - scores = await scorer.score_async(message=request) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(request))) assert len(scores) == 1 score = scores[0] @@ -173,7 +170,6 @@ async def test_score_async_completely_different_texts(self): async def test_score_async_adds_to_memory(self): """Test that scoring adds results to memory.""" - memory = MagicMock(MemoryInterface) reference_text = "Test reference text" scorer = PlagiarismScorer(reference_text=reference_text) @@ -184,8 +180,9 @@ async def test_score_async_adds_to_memory(self): converted_value_data_type="text", ).to_message() + memory = mock_memory_resolving(request) with patch.object(CentralMemory, "get_memory_instance", return_value=memory): - await scorer.score_async(request) + await scorer.score_async(scorable=MessageScorable.from_message(request)) memory.add_scores_to_memory.assert_called_once() async def test_score_async_unsupported_data_type_returns_zero(self, patch_central_database): @@ -202,7 +199,7 @@ async def test_score_async_unsupported_data_type_returns_zero(self, patch_centra # Unified FloatScaleScorer fallback: returns a single Score(0.0) when all pieces are filtered # out (mirrors TrueFalseScorer's no-pieces fallback). - scores = await scorer.score_async(request) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(request))) assert len(scores) == 1 assert scores[0].score_type == "float_scale" assert scores[0].get_value() == 0.0 diff --git a/tests/unit/score/test_question_answer_scorer.py b/tests/unit/score/test_question_answer_scorer.py index 4c3519d6b0..c41c0ae04d 100644 --- a/tests/unit/score/test_question_answer_scorer.py +++ b/tests/unit/score/test_question_answer_scorer.py @@ -3,15 +3,14 @@ import os import uuid -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest -from unit.mocks import get_image_message_piece +from unit.mocks import get_image_message_piece, mock_memory_resolving, store_message from pyrit.memory.central_memory import CentralMemory -from pyrit.memory.memory_interface import MemoryInterface from pyrit.models import Message, MessagePiece -from pyrit.score import QuestionAnswerScorer +from pyrit.score import MessageScorable, QuestionAnswerScorer @pytest.fixture @@ -39,7 +38,7 @@ async def test_score_async_unsupported_image_type_returns_false( message = Message(message_pieces=[image_message_piece]) # With raise_on_no_valid_pieces=False (default), returns False for unsupported data types - scores = await scorer.score_async(message) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(message))) assert len(scores) == 1 assert scores[0].get_value() is False assert "No supported pieces" in scores[0].score_rationale @@ -58,7 +57,7 @@ async def test_score_async_missing_metadata_returns_false(patch_central_database scorer = QuestionAnswerScorer(category=["new_category"]) # With raise_on_no_valid_pieces=False (default), returns False for missing metadata - scores = await scorer.score_async(request) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(request))) assert len(scores) == 1 assert scores[0].get_value() is False assert "No supported pieces" in scores[0].score_rationale @@ -80,7 +79,7 @@ async def test_question_answer_scorer_score(response: str, expected_score: bool, scorer = QuestionAnswerScorer(category=["new_category"]) message = Message(message_pieces=[text_message_piece]) - scores = await scorer.score_async(message) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(message))) assert len(scores) == 1 result_score = scores[0] @@ -90,33 +89,33 @@ async def test_question_answer_scorer_score(response: str, expected_score: bool, async def test_question_answer_scorer_adds_to_memory(): - memory = MagicMock(MemoryInterface) + message = MessagePiece( + role="user", + original_value="test content", + converted_value="0: Paris", + converted_value_data_type="text", + prompt_metadata={"correct_answer_index": "0", "correct_answer": "Paris"}, + ).to_message() + memory = mock_memory_resolving(message) with patch.object(CentralMemory, "get_memory_instance", return_value=memory): scorer = QuestionAnswerScorer(category=["new_category"]) - message = MessagePiece( - role="user", - original_value="test content", - converted_value="0: Paris", - converted_value_data_type="text", - prompt_metadata={"correct_answer_index": "0", "correct_answer": "Paris"}, - ).to_message() - await scorer.score_async(message) + await scorer.score_async(scorable=MessageScorable.from_message(message)) memory.add_scores_to_memory.assert_called_once() async def test_question_answer_scorer_no_category(): - memory = MagicMock(MemoryInterface) + message = MessagePiece( + role="user", + original_value="test content", + converted_value="0: Paris", + converted_value_data_type="text", + prompt_metadata={"correct_answer_index": "0", "correct_answer": "Paris"}, + ).to_message() + memory = mock_memory_resolving(message) with patch.object(CentralMemory, "get_memory_instance", return_value=memory): scorer = QuestionAnswerScorer() - message = MessagePiece( - role="user", - original_value="test content", - converted_value="0: Paris", - converted_value_data_type="text", - prompt_metadata={"correct_answer_index": "0", "correct_answer": "Paris"}, - ).to_message() - await scorer.score_async(message) + await scorer.score_async(scorable=MessageScorable.from_message(message)) memory.add_scores_to_memory.assert_called_once() diff --git a/tests/unit/score/test_scorer.py b/tests/unit/score/test_scorer.py index f1bcd4d000..0f65aa16bd 100644 --- a/tests/unit/score/test_scorer.py +++ b/tests/unit/score/test_scorer.py @@ -7,20 +7,24 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from unit.mocks import get_mock_target_identifier +from unit.mocks import get_mock_target_identifier, store_message from pyrit.exceptions import InvalidJsonException, remove_markdown_json -from pyrit.memory import CentralMemory -from pyrit.models import ComponentIdentifier, Message, MessagePiece, Score +from pyrit.memory import CentralMemory, MemoryInterface +from pyrit.models import ComponentIdentifier, Message, MessagePiece, Score, ScoringExpectation from pyrit.prompt_target import PromptTarget from pyrit.score import ( FloatScaleScorer, JsonSchemaResponseHandler, + MessageScorable, + MessageScorer, Scorer, ScorerPromptValidator, TrueFalseScorer, ) from pyrit.score.llm_scoring import _run_llm_scoring_async +from pyrit.score.message_scorable_resolver import MessageScorableResolver +from pyrit.score.message_scorer import MessageScoringOptions, extract_objective_from_previous_turn @pytest.fixture @@ -113,7 +117,7 @@ def __init__(self, *, enforce_all_pieces_valid: bool = False, raise_on_no_valid_ ) -class MockFloatScorer(Scorer): +class MockFloatScorer(MessageScorer): """Mock scorer that tracks which pieces were scored.""" def __init__(self, *, validator: ScorerPromptValidator): @@ -406,13 +410,12 @@ async def test_score_value_with_llm_prepended_text_works_with_audio(good_json, p assert audio_piece.original_value == str(audio_path) -def test_scorer_extract_task_from_response(patch_central_database): +def test_extract_objective_from_previous_turn(patch_central_database): """ - Test that _extract_task_from_response properly gathers text from the + Test that extract_objective_from_previous_turn properly gathers text from the last turn. We'll mock out the memory's get_message_pieces method. """ - scorer = MockScorer() - mock_memory = MagicMock() + mock_memory = MagicMock(spec=MemoryInterface) response_piece = MessagePiece(original_value="og prompt", role="assistant", conversation_id="xyz", sequence=2) @@ -428,9 +431,8 @@ def test_scorer_extract_task_from_response(patch_central_database): response_piece, ] - with patch.object(CentralMemory, "get_memory_instance", return_value=mock_memory): - extracted_task = scorer._extract_objective_from_response(response_piece.to_message()) - assert "User's question about the universe" in extracted_task + extracted_task = extract_objective_from_previous_turn(message=response_piece.to_message(), memory=mock_memory) + assert "User's question about the universe" in extracted_task async def test_scorer_score_responses_batch_async(patch_central_database): @@ -447,9 +449,7 @@ async def test_scorer_score_responses_batch_async(patch_central_database): user_req = MessagePiece(role="user", original_value="Hello user", sequence=1).to_message() assistant_resp = MessagePiece(role="assistant", original_value="Hello from assistant", sequence=2).to_message() - results = await scorer.score_prompts_batch_async( - messages=[user_req, assistant_resp], batch_size=10, infer_objective_from_request=True - ) + results = await scorer.score_prompts_batch_async(messages=[user_req, assistant_resp], batch_size=10) # Verify mock_score_async was called twice assert mock_score_async.call_count == 2 @@ -457,10 +457,9 @@ async def test_scorer_score_responses_batch_async(patch_central_database): # Get the call_args for the first call _, first_call_kwargs = mock_score_async.call_args_list[0] - assert "message" in first_call_kwargs - assert "objective" in first_call_kwargs - assert "infer_objective_from_request" in first_call_kwargs - assert first_call_kwargs["message"] == user_req + assert first_call_kwargs["scorable"] == MessageScorable.from_message(store_message(user_req)) + assert first_call_kwargs["expectation"] == ScoringExpectation(objective="") + assert first_call_kwargs["message_options"] == MessageScoringOptions() assert fake_scores[0] in results assert len(fake_scores) == 2 @@ -494,7 +493,7 @@ async def test_score_prompts_batch_async_defaults_objectives_when_none(patch_cen await scorer.score_prompts_batch_async(messages=[message]) _, call_kwargs = mock_score_async.call_args - assert call_kwargs["objective"] == "" + assert call_kwargs["expectation"] == ScoringExpectation(objective="") async def test_score_image_batch_async_works_when_objectives_none(patch_central_database): @@ -511,17 +510,17 @@ async def test_score_image_batch_async_works_when_objectives_none(patch_central_ assert "objective" not in call_kwargs -async def test_score_response_async_empty_scorers(): +async def test_score_response_async_empty_scorers(patch_central_database): """Test that score_response_async returns empty list when no scorers provided.""" response = Message( message_pieces=[MessagePiece(role="assistant", original_value="test", conversation_id="test-convo")] ) - result = await Scorer.score_response_async(response=response, objective="test task") + result = await Scorer.score_response_async(response=store_message(response), objective="test task") assert result == {"auxiliary_scores": [], "objective_scores": []} -async def test_score_response_async_no_matching_role(): +async def test_score_response_async_no_matching_role(patch_central_database): """Test that score_response_async returns empty list when no pieces match role filter.""" response = Message( message_pieces=[ @@ -534,7 +533,7 @@ async def test_score_response_async_no_matching_role(): scorer.score_async = AsyncMock(return_value=[]) result = await Scorer.score_response_async( - response=response, + response=store_message(response), objective_scorer=scorer, auxiliary_scorers=[scorer], role_filter="assistant", @@ -544,7 +543,7 @@ async def test_score_response_async_no_matching_role(): scorer.score_async.assert_called() -async def test_score_response_async_parallel_execution(): +async def test_score_response_async_parallel_execution(patch_central_database): """Test that score_response_async runs all scorers in parallel on all filtered pieces.""" piece1 = MessagePiece(role="assistant", original_value="response1", conversation_id="test-convo") piece2 = MessagePiece(role="assistant", original_value="response2", conversation_id="test-convo") @@ -571,36 +570,42 @@ async def test_score_response_async_parallel_execution(): assert score1_1 in result["auxiliary_scores"] assert score2_1 in result["auxiliary_scores"] + expected_scorable = MessageScorable.from_message(store_message(response)) + expected_options = MessageScoringOptions(role_filter="assistant", skip_on_error_result=True) scorer1.score_async.assert_any_call( - message=response, - objective="test task", - role_filter="assistant", - skip_on_error_result=True, + scorable=expected_scorable, + expectation=ScoringExpectation(objective="test task"), + message_options=expected_options, ) scorer2.score_async.assert_any_call( - message=response, - objective="test task", - role_filter="assistant", - skip_on_error_result=True, + scorable=expected_scorable, + expectation=ScoringExpectation(objective="test task"), + message_options=expected_options, ) -async def test_score_response_select_first_success_async_empty_scorers(): +async def test_score_response_select_first_success_async_empty_scorers(patch_central_database): """Test that score_response_select_first_success_async returns None when no scorers provided.""" response = Message( message_pieces=[MessagePiece(role="assistant", original_value="test", conversation_id="test-convo")] ) - result = await Scorer.score_response_multiple_scorers_async(response=response, scorers=[], objective="test task") + result = await Scorer.score_response_multiple_scorers_async( + response=store_message(response), scorers=[], objective="test task" + ) assert result == [] -async def test_score_async_no_matching_role(): +async def test_score_async_no_matching_role(patch_central_database): """Test that score_response_select_first_success_async returns None when no pieces match role filter.""" response = Message(message_pieces=[MessagePiece(role="user", original_value="test", conversation_id="test-convo")]) scorer = MockScorer() - result = await scorer.score_async(message=response, role_filter="assistant", objective="test task") + result = await scorer.score_async( + scorable=MessageScorable.from_message(store_message(response)), + expectation=ScoringExpectation(objective="test task"), + message_options=MessageScoringOptions(role_filter="assistant"), + ) assert result == [] @@ -681,24 +686,36 @@ async def test_score_response_success_async_no_success_returns_first(): assert scorer2.score_async.call_count == 1 -async def test_score_response_success_async_parallel_scoring_per_piece(): +async def test_score_response_success_async_parallel_scoring_per_piece(patch_central_database): """Test that score_response_success_async runs scorers in parallel for each piece.""" piece1 = MessagePiece(role="assistant", original_value="response1", conversation_id="test-convo") piece2 = MessagePiece(role="assistant", original_value="response2", conversation_id="test-convo") - response = Message(message_pieces=[piece1, piece2]) + response = store_message(Message(message_pieces=[piece1, piece2])) # Track call order call_order = [] - async def mock_score_async_1(message: Message, **kwargs) -> list[Score]: - call_order.append(("scorer1", message.message_pieces[0].original_value)) + def _first_value(scorable: MessageScorable) -> str: + # A scorable names pieces rather than carrying them, so read it back from memory. + return ( + MessageScorableResolver() + .resolve( + scorable=scorable, + memory=CentralMemory.get_memory_instance(), + ) + .message_pieces[0] + .original_value + ) + + async def mock_score_async_1(*, scorable: MessageScorable, **kwargs) -> list[Score]: + call_order.append(("scorer1", _first_value(scorable))) score = MagicMock(spec=Score) score.get_value.return_value = False return [score] - async def mock_score_async_2(message: Message, **kwargs) -> list[Score]: - call_order.append(("scorer2", message.message_pieces[0].original_value)) + async def mock_score_async_2(*, scorable: MessageScorable, **kwargs) -> list[Score]: + call_order.append(("scorer2", _first_value(scorable))) score = MagicMock(spec=Score) score.get_value.return_value = False return [score] @@ -808,7 +825,7 @@ async def test_score_response_async_both_types(): assert result["objective_scores"][0] == obj_score -async def test_score_response_async_multiple_pieces(): +async def test_score_response_async_multiple_pieces(patch_central_database): """Test score_response_async with multiple response pieces.""" piece1 = MessagePiece(role="assistant", original_value="response1", conversation_id="test-convo") piece2 = MessagePiece(role="assistant", original_value="response2", conversation_id="test-convo") @@ -831,7 +848,7 @@ async def test_score_response_async_multiple_pieces(): obj_scorer.score_async = AsyncMock(return_value=[obj_score]) result = await Scorer.score_response_async( - response=response, + response=store_message(response), auxiliary_scorers=[aux_scorer1, aux_scorer2], objective_scorer=obj_scorer, objective="test task", @@ -851,7 +868,7 @@ async def test_score_response_async_multiple_pieces(): assert result["objective_scores"][0] == obj_score -async def test_score_response_async_skip_on_error_true(): +async def test_score_response_async_skip_on_error_true(patch_central_database): """Test score_response_async skips error pieces when skip_on_error_result=True.""" piece1 = MessagePiece(role="assistant", original_value="good response", conversation_id="test-convo") piece2 = MessagePiece( @@ -872,7 +889,7 @@ async def test_score_response_async_skip_on_error_true(): obj_scorer.score_async = AsyncMock(return_value=[obj_score]) result = await Scorer.score_response_async( - response=response, + response=store_message(response), auxiliary_scorers=[aux_scorer], objective_scorer=obj_scorer, objective="test task", @@ -888,7 +905,7 @@ async def test_score_response_async_skip_on_error_true(): obj_scorer.score_async.assert_called_once() -async def test_score_response_async_skip_on_error_false(): +async def test_score_response_async_skip_on_error_false(patch_central_database): """Test score_response_async includes error pieces when skip_on_error_result=False.""" piece1 = MessagePiece(role="assistant", original_value="good response", conversation_id="test-convo") piece2 = MessagePiece( @@ -909,7 +926,7 @@ async def test_score_response_async_skip_on_error_false(): obj_scorer.score_async = AsyncMock(return_value=[obj_score]) result = await Scorer.score_response_async( - response=response, + response=store_message(response), auxiliary_scorers=[aux_scorer], objective_scorer=obj_scorer, objective="test task", @@ -966,14 +983,14 @@ async def test_score_response_async_concurrent_execution(): # Track call order to verify concurrent execution call_order = [] - async def mock_aux_score_async(message: Message, **kwargs) -> list[Score]: + async def mock_aux_score_async(**kwargs) -> list[Score]: call_order.append("aux_start") # Yield so the other scorer can interleave (proves concurrent execution). await asyncio.sleep(0) call_order.append("aux_end") return [MagicMock(spec=Score)] - async def mock_obj_score_async(message: Message, **kwargs) -> list[Score]: + async def mock_obj_score_async(**kwargs) -> list[Score]: call_order.append("obj_start") # Yield so the other scorer can interleave (proves concurrent execution). await asyncio.sleep(0) @@ -1053,7 +1070,7 @@ async def test_get_supported_pieces_filters_unsupported_data_types(patch_central response = Message(message_pieces=[text_piece, image_piece, audio_piece]) # Score the response - scores = await scorer.score_async(response) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(response))) # Should only score the text piece assert len(scorer.scored_piece_ids) == 1 @@ -1087,7 +1104,7 @@ async def test_unsupported_pieces_ignored_when_enforce_all_pieces_valid_false(pa response = Message(message_pieces=[image_piece, text_piece]) # Should not raise an error, just skip the image piece - scores = await scorer.score_async(response) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(response))) assert len(scores) == 1 assert len(scorer.scored_piece_ids) == 1 @@ -1119,7 +1136,7 @@ async def test_all_unsupported_pieces_raises_error(patch_central_database): # Should raise error from validator because no valid pieces to score with pytest.raises(ValueError, match="There are no valid pieces to score"): - await scorer.score_async(response) + await scorer.score_async(scorable=MessageScorable.from_message(store_message(response))) # No pieces should have been scored assert len(scorer.scored_piece_ids) == 0 @@ -1176,7 +1193,7 @@ async def _score_piece_async(self, message_piece: MessagePiece, *, objective: st response = Message(message_pieces=[text_piece, image_piece]) # Score the response - scores = await scorer.score_async(response) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(response))) # Should only score the text piece assert len(scorer.scored_piece_ids) == 1 @@ -1212,7 +1229,7 @@ async def test_base_scorer_score_async_implementation(patch_central_database): response = Message(message_pieces=[text_piece1, text_piece2]) # Score the response - scores = await scorer.score_async(response) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(response))) # Should score both pieces assert len(scorer.scored_piece_ids) == 2 @@ -1221,6 +1238,71 @@ async def test_base_scorer_score_async_implementation(patch_central_database): assert len(scores) == 2 +class TestLegacyDirectScorerSubclass: + """Scorers written against the pre-2.0 base keep working behind a deprecation warning.""" + + @staticmethod + def _build_legacy_scorer_class(): + class LegacyScorer(Scorer): + def __init__(self, *, validator: ScorerPromptValidator): + super().__init__(validator=validator) + self.scored_messages: list[Message] = [] + + def _build_identifier(self) -> ComponentIdentifier: + return self._create_identifier() + + async def _score_async(self, message: Message, *, objective: str | None = None) -> list[Score]: + self.scored_messages.append(message) + return [ + Score( + score_value="true", + score_value_description="legacy", + score_type="true_false", + score_category=None, + score_metadata=None, + score_rationale="legacy", + scorer_class_identifier=self.get_identifier(), + message_piece_id=message.get_piece().id, + objective=objective, + ) + ] + + def validate_return_scores(self, scores: list[Score]) -> None: + pass + + def get_scorer_metrics(self): + return None + + return LegacyScorer + + def test_legacy_scorer_is_instantiable(self): + legacy_class = self._build_legacy_scorer_class() + + assert "_score_scorable_async" not in legacy_class.__abstractmethods__ + + def test_legacy_validator_argument_warns(self): + legacy_class = self._build_legacy_scorer_class() + + with pytest.warns(DeprecationWarning, match="Scorer.__init__"): + scorer = legacy_class(validator=DummyValidator()) + + assert scorer._validator is not None + + async def test_legacy_scorer_scores_a_scorable(self, patch_central_database): + legacy_class = self._build_legacy_scorer_class() + with pytest.warns(DeprecationWarning): + scorer = legacy_class(validator=DummyValidator()) + message = store_message( + MessagePiece(role="assistant", original_value="legacy response", conversation_id="legacy").to_message() + ) + + with pytest.warns(DeprecationWarning, match="_score_async"): + scores = await scorer.score_async(scorable=MessageScorable.from_message(message)) + + assert len(scores) == 1 + assert scorer.scored_messages[0].get_value() == "legacy response" + + # Tests for get_identifier and identifier @@ -1344,7 +1426,9 @@ async def test_blocked_response_returns_specific_rationale( ) response = Message(message_pieces=[blocked_piece]) - scores = await true_false_scorer_returns_empty.score_async(response) + scores = await true_false_scorer_returns_empty.score_async( + scorable=MessageScorable.from_message(store_message(response)) + ) assert len(scores) == 1 assert scores[0].score_value == "false" @@ -1366,7 +1450,9 @@ async def test_error_response_returns_specific_rationale( ) response = Message(message_pieces=[error_piece]) - scores = await true_false_scorer_returns_empty.score_async(response) + scores = await true_false_scorer_returns_empty.score_async( + scorable=MessageScorable.from_message(store_message(response)) + ) assert len(scores) == 1 assert scores[0].score_value == "false" @@ -1388,7 +1474,9 @@ async def test_filtered_pieces_returns_generic_rationale( ) response = Message(message_pieces=[normal_piece]) - scores = await true_false_scorer_returns_empty.score_async(response) + scores = await true_false_scorer_returns_empty.score_async( + scorable=MessageScorable.from_message(store_message(response)) + ) assert len(scores) == 1 assert scores[0].score_value == "false" @@ -1411,7 +1499,9 @@ async def test_blocked_takes_precedence_over_generic_error( ) response = Message(message_pieces=[blocked_piece]) - scores = await true_false_scorer_returns_empty.score_async(response) + scores = await true_false_scorer_returns_empty.score_async( + scorable=MessageScorable.from_message(store_message(response)) + ) # Should specifically mention blocked, not generic error assert "blocked" in scores[0].score_rationale.lower() @@ -1470,7 +1560,9 @@ async def test_blocked_response_returns_zero_with_blocked_rationale( ) response = Message(message_pieces=[blocked_piece]) - scores = await float_scale_scorer_returns_empty.score_async(response) + scores = await float_scale_scorer_returns_empty.score_async( + scorable=MessageScorable.from_message(store_message(response)) + ) assert len(scores) == 1 assert scores[0].score_type == "float_scale" @@ -1492,7 +1584,9 @@ async def test_other_error_response_returns_zero_with_error_rationale( ) response = Message(message_pieces=[error_piece]) - scores = await float_scale_scorer_returns_empty.score_async(response) + scores = await float_scale_scorer_returns_empty.score_async( + scorable=MessageScorable.from_message(store_message(response)) + ) assert len(scores) == 1 assert scores[0].get_value() == 0.0 @@ -1513,7 +1607,9 @@ async def test_filtered_pieces_return_zero_with_generic_rationale( ) response = Message(message_pieces=[normal_piece]) - scores = await float_scale_scorer_returns_empty.score_async(response) + scores = await float_scale_scorer_returns_empty.score_async( + scorable=MessageScorable.from_message(store_message(response)) + ) assert len(scores) == 1 assert scores[0].get_value() == 0.0 @@ -1539,7 +1635,9 @@ async def test_text_only_scorer_filters_blocked_via_validator( with patch.object( float_scale_scorer_returns_empty, "_score_piece_async", new_callable=AsyncMock ) as mock_score_piece: - scores = await float_scale_scorer_returns_empty.score_async(response) + scores = await float_scale_scorer_returns_empty.score_async( + scorable=MessageScorable.from_message(store_message(response)) + ) mock_score_piece.assert_not_called() assert len(scores) == 1 @@ -1821,14 +1919,16 @@ async def test_raises_by_default(self): scorer = _ForwarderTrueFalseScorer(chat_target=_make_scorer_blocking_target()) with pytest.raises(ScorerLLMResponseBlockedException, match="blocked by content filtering"): - await scorer.score_async(_make_normal_input_message()) + await scorer.score_async(scorable=MessageScorable.from_message(store_message(_make_normal_input_message()))) async def test_returns_false_when_flag_disabled(self): target = _make_scorer_blocking_target() scorer = _ForwarderTrueFalseScorer(chat_target=target) scorer.raise_if_scorer_blocks = False - scores = await scorer.score_async(_make_normal_input_message()) + scores = await scorer.score_async( + scorable=MessageScorable.from_message(store_message(_make_normal_input_message())) + ) assert len(scores) == 1 assert scores[0].score_value == "false" @@ -1840,7 +1940,9 @@ async def test_returns_zero_for_float_scale_when_flag_disabled(self): scorer = _ForwarderFloatScaleScorer(chat_target=_make_scorer_blocking_target()) scorer.raise_if_scorer_blocks = False - scores = await scorer.score_async(_make_normal_input_message()) + scores = await scorer.score_async( + scorable=MessageScorable.from_message(store_message(_make_normal_input_message())) + ) assert len(scores) == 1 assert scores[0].score_value == "0.0" @@ -1852,13 +1954,15 @@ async def test_direct_transport_caller_raises_by_default(self): scorer = _DirectTransportTrueFalseScorer(chat_target=_make_scorer_blocking_target()) with pytest.raises(ScorerLLMResponseBlockedException, match="blocked by content filtering"): - await scorer.score_async(_make_normal_input_message()) + await scorer.score_async(scorable=MessageScorable.from_message(store_message(_make_normal_input_message()))) async def test_direct_transport_caller_returns_false_when_flag_disabled(self): scorer = _DirectTransportTrueFalseScorer(chat_target=_make_scorer_blocking_target()) scorer.raise_if_scorer_blocks = False - scores = await scorer.score_async(_make_normal_input_message()) + scores = await scorer.score_async( + scorable=MessageScorable.from_message(store_message(_make_normal_input_message())) + ) assert len(scores) == 1 assert scores[0].score_value == "false" @@ -1996,7 +2100,7 @@ def _make_normal_piece(*, conversation_id: str = "test-convo") -> MessagePiece: class TestCreateTextPieceFromBlocked: def test_returns_text_piece_with_partial_content(self): piece = _make_blocked_piece(partial_content="Harmful partial text here") - substitute = Scorer._create_text_piece_from_blocked(piece) + substitute = MessageScorer._create_text_piece_from_blocked(piece) assert substitute is not None assert substitute.converted_value == "Harmful partial text here" @@ -2006,7 +2110,7 @@ def test_returns_text_piece_with_partial_content(self): def test_preserves_original_value(self): piece = _make_blocked_piece(partial_content="partial") - substitute = Scorer._create_text_piece_from_blocked(piece) + substitute = MessageScorer._create_text_piece_from_blocked(piece) assert substitute is not None assert substitute.original_value == piece.original_value @@ -2014,22 +2118,22 @@ def test_preserves_original_value(self): def test_returns_none_when_no_partial_content(self): piece = _make_blocked_piece() - assert Scorer._create_text_piece_from_blocked(piece) is None + assert MessageScorer._create_text_piece_from_blocked(piece) is None def test_returns_none_when_empty_partial_content(self): piece = _make_blocked_piece(partial_content="") - assert Scorer._create_text_piece_from_blocked(piece) is None + assert MessageScorer._create_text_piece_from_blocked(piece) is None def test_preserves_conversation_id(self): piece = _make_blocked_piece(partial_content="partial") - substitute = Scorer._create_text_piece_from_blocked(piece) + substitute = MessageScorer._create_text_piece_from_blocked(piece) assert substitute is not None assert substitute.conversation_id == piece.conversation_id def test_response_error_is_none_not_blocked(self): """Substitute must have response_error='none' so refusal short-circuits don't fire.""" piece = _make_blocked_piece(partial_content="partial text") - substitute = Scorer._create_text_piece_from_blocked(piece) + substitute = MessageScorer._create_text_piece_from_blocked(piece) assert substitute is not None assert substitute.response_error == "none" assert not substitute.is_blocked() @@ -2040,7 +2144,7 @@ class TestCreateTextPieceFromStructuredRefusal: def test_returns_blocked_text_piece_with_refusal_explanation(self): piece = _make_blocked_piece(structured_refusal="I cannot assist with that request.") - substitute = Scorer._create_text_piece_from_structured_refusal(piece) + substitute = MessageScorer._create_text_piece_from_structured_refusal(piece) assert substitute is not None assert substitute.converted_value == "I cannot assist with that request." @@ -2049,7 +2153,7 @@ def test_returns_blocked_text_piece_with_refusal_explanation(self): assert substitute.id == piece.id def test_returns_none_for_generic_blocked_response(self): - assert Scorer._create_text_piece_from_structured_refusal(_make_blocked_piece()) is None + assert MessageScorer._create_text_piece_from_structured_refusal(_make_blocked_piece()) is None # ── score_async with score_blocked_content tests ───────────────────────────── @@ -2062,7 +2166,7 @@ async def test_default_false_skips_blocked_piece_text_only_scorer(self): scorer = _BlockedContentScorer() msg = Message(message_pieces=[_make_blocked_piece(partial_content="harmful text")]) - scores = await scorer.score_async(msg) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(msg))) assert len(scores) == 1 assert scores[0].score_value == "false" @@ -2074,7 +2178,7 @@ async def test_true_substitutes_blocked_piece_for_text_only_scorer(self): msg = Message(message_pieces=[_make_blocked_piece(partial_content="harmful text")]) scorer.score_blocked_content = True - scores = await scorer.score_async(msg) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(msg))) assert len(scores) == 1 assert scores[0].score_value == "true" @@ -2087,7 +2191,7 @@ async def test_refusal_scorer_short_circuits_on_blocked_by_default(self): scorer = _MockRefusalScorer() msg = Message(message_pieces=[_make_blocked_piece(partial_content="harmful text")]) - scores = await scorer.score_async(msg) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(msg))) assert len(scores) == 1 assert scores[0].score_value == "true" @@ -2099,7 +2203,7 @@ async def test_refusal_scorer_evaluates_partial_content_when_flag_on(self): msg = Message(message_pieces=[_make_blocked_piece(partial_content="harmful text")]) scorer.score_blocked_content = True - scores = await scorer.score_async(msg) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(msg))) assert len(scores) == 1 assert scores[0].score_value == "false" @@ -2112,7 +2216,7 @@ async def test_no_substitute_when_no_partial_content(self): msg = Message(message_pieces=[_make_blocked_piece()]) scorer.score_blocked_content = True - scores = await scorer.score_async(msg) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(msg))) assert len(scores) == 1 assert scores[0].score_value == "false" @@ -2123,10 +2227,10 @@ async def test_normal_piece_unaffected_by_flag(self): scorer = _BlockedContentScorer() msg = Message(message_pieces=[_make_normal_piece()]) - scores_off = await scorer.score_async(msg) + scores_off = await scorer.score_async(scorable=MessageScorable.from_message(store_message(msg))) scorer.scored_pieces.clear() scorer.score_blocked_content = True - scores_on = await scorer.score_async(msg) + scores_on = await scorer.score_async(scorable=MessageScorable.from_message(store_message(msg))) assert scores_off[0].score_value == scores_on[0].score_value @@ -2136,7 +2240,7 @@ async def test_mixed_pieces_only_blocked_substituted(self): msg = Message(message_pieces=[_make_normal_piece(), _make_blocked_piece(partial_content="partial harmful")]) scorer.score_blocked_content = True - scores = await scorer.score_async(msg) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(msg))) assert len(scores) == 1 # TrueFalseScorer aggregates assert len(scorer.scored_pieces) == 2 @@ -2154,7 +2258,10 @@ async def test_skip_on_error_true_without_flag_skips_blocked(self): scorer = _BlockedContentScorer() msg = Message(message_pieces=[_make_blocked_piece(partial_content="harmful text")]) - scores = await scorer.score_async(msg, skip_on_error_result=True) + scores = await scorer.score_async( + scorable=MessageScorable.from_message(store_message(msg)), + message_options=MessageScoringOptions(skip_on_error_result=True), + ) assert scores == [] async def test_skip_on_error_true_with_flag_does_not_skip_when_partial_content(self): @@ -2162,7 +2269,10 @@ async def test_skip_on_error_true_with_flag_does_not_skip_when_partial_content(s msg = Message(message_pieces=[_make_blocked_piece(partial_content="harmful text")]) scorer.score_blocked_content = True - scores = await scorer.score_async(msg, skip_on_error_result=True) + scores = await scorer.score_async( + scorable=MessageScorable.from_message(store_message(msg)), + message_options=MessageScoringOptions(skip_on_error_result=True), + ) assert len(scores) == 1 assert scores[0].score_value == "true" @@ -2171,7 +2281,10 @@ async def test_skip_on_error_true_with_flag_still_skips_when_no_partial_content( msg = Message(message_pieces=[_make_blocked_piece()]) scorer.score_blocked_content = True - scores = await scorer.score_async(msg, skip_on_error_result=True) + scores = await scorer.score_async( + scorable=MessageScorable.from_message(store_message(msg)), + message_options=MessageScoringOptions(skip_on_error_result=True), + ) assert scores == [] async def test_skip_on_error_skips_error_type_without_response_error_flag(self): @@ -2188,7 +2301,10 @@ async def test_skip_on_error_skips_error_type_without_response_error_flag(self): ] ) - scores = await scorer.score_async(msg, skip_on_error_result=True) + scores = await scorer.score_async( + scorable=MessageScorable.from_message(store_message(msg)), + message_options=MessageScoringOptions(skip_on_error_result=True), + ) assert scores == [] assert scorer.scored_pieces == [] @@ -2206,7 +2322,10 @@ async def test_skip_on_error_scores_structured_refusal_as_text(self, validator: piece = _make_blocked_piece(structured_refusal=refusal) msg = Message(message_pieces=[piece]) - scores = await scorer.score_async(msg, skip_on_error_result=True) + scores = await scorer.score_async( + scorable=MessageScorable.from_message(store_message(msg)), + message_options=MessageScoringOptions(skip_on_error_result=True), + ) assert len(scores) == 1 assert scorer.scored_pieces[0].id == piece.id @@ -2234,7 +2353,10 @@ async def test_skip_on_error_still_skips_mixed_structured_and_runtime_errors(sel ] ) - scores = await scorer.score_async(msg, skip_on_error_result=True) + scores = await scorer.score_async( + scorable=MessageScorable.from_message(store_message(msg)), + message_options=MessageScoringOptions(skip_on_error_result=True), + ) assert scores == [] assert scorer.scored_pieces == [] @@ -2251,7 +2373,7 @@ async def test_score_response_async_passes_flag_to_scorers(self): msg = Message(message_pieces=[_make_blocked_piece(partial_content="harmful text")]) result = await Scorer.score_response_async( - response=msg, + response=store_message(msg), objective_scorer=obj_scorer, objective="test", skip_on_error_result=False, @@ -2266,7 +2388,7 @@ async def test_score_response_async_default_does_not_substitute(self): msg = Message(message_pieces=[_make_blocked_piece(partial_content="harmful text")]) result = await Scorer.score_response_async( - response=msg, + response=store_message(msg), objective_scorer=obj_scorer, objective="test", skip_on_error_result=False, @@ -2283,7 +2405,7 @@ async def test_score_response_multiple_scorers_passes_flag(self): msg = Message(message_pieces=[_make_blocked_piece(partial_content="harmful text")]) scores = await Scorer.score_response_multiple_scorers_async( - response=msg, + response=store_message(msg), scorers=[scorer1, scorer2], objective="test", skip_on_error_result=False, diff --git a/tests/unit/score/test_scorer_evaluator.py b/tests/unit/score/test_scorer_evaluator.py index a2d2016719..21eb1e6a53 100644 --- a/tests/unit/score/test_scorer_evaluator.py +++ b/tests/unit/score/test_scorer_evaluator.py @@ -6,6 +6,7 @@ import numpy as np import pytest +from pyrit.memory import MemoryInterface from pyrit.models import Message, MessagePiece from pyrit.score import ( FloatScaleScorer, @@ -26,8 +27,9 @@ @pytest.fixture def mock_harm_scorer(): scorer = MagicMock(spec=FloatScaleScorer) - scorer._memory = MagicMock() + scorer._memory = MagicMock(spec=MemoryInterface) scorer._memory.add_message_to_memory = MagicMock() + scorer._memory.get_message_pieces.return_value = [] # Create a mock identifier with a controllable hash property mock_identifier = MagicMock() mock_identifier.hash = "test_hash_456" @@ -40,8 +42,9 @@ def mock_harm_scorer(): @pytest.fixture def mock_objective_scorer(): scorer = MagicMock(spec=TrueFalseScorer) - scorer._memory = MagicMock() + scorer._memory = MagicMock(spec=MemoryInterface) scorer._memory.add_message_to_memory = MagicMock() + scorer._memory.get_message_pieces.return_value = [] # Create a mock identifier with a controllable hash property mock_identifier = MagicMock() mock_identifier.hash = "test_hash_123" diff --git a/tests/unit/score/test_self_ask_category.py b/tests/unit/score/test_self_ask_category.py index 5d6bc50d82..69fd14e76a 100644 --- a/tests/unit/score/test_self_ask_category.py +++ b/tests/unit/score/test_self_ask_category.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from unit.mocks import get_mock_target_identifier +from unit.mocks import get_mock_target_identifier, store_message from pyrit.exceptions.exception_classes import InvalidJsonException from pyrit.memory import CentralMemory @@ -16,6 +16,7 @@ ContentClassifier, ContentClassifierCategory, ContentClassifierPaths, + MessageScorable, SelfAskCategoryScorer, ) @@ -255,15 +256,25 @@ async def test_score_prompts_batch_async( chat_target.get_identifier.return_value = get_mock_target_identifier("MockChatTarget") chat_target.send_prompt_async = AsyncMock() chat_target._max_requests_per_minute = max_requests_per_minute - with patch.object(CentralMemory, "get_memory_instance", return_value=MagicMock()): + + prompt = MessagePiece(role="assistant", original_value="test").to_message() + prompt2 = MessagePiece(role="assistant", original_value="test 2").to_message() + + # Scoring resolves a scorable through memory, so the fake has to answer id lookups. + # A real database is not wanted here: the scorer would persist the same mocked + # response twice and collide on its primary key. + known = {str(piece.id): piece for message in (prompt, prompt2) for piece in message.message_pieces} + memory = MagicMock() + memory.get_message_pieces.side_effect = lambda **kwargs: [ + known[str(piece_id)] for piece_id in kwargs.get("prompt_ids", []) if str(piece_id) in known + ] + + with patch.object(CentralMemory, "get_memory_instance", return_value=memory): scorer = SelfAskCategoryScorer.from_content_classifier( chat_target=chat_target, content_classifier=HARM_CLASSIFIER, ) - prompt = MessagePiece(role="assistant", original_value="test").to_message() - prompt2 = MessagePiece(role="assistant", original_value="test 2").to_message() - with patch.object(chat_target, "send_prompt_async", return_value=[scorer_category_response_false]): if batch_size != 1 and max_requests_per_minute: with pytest.raises(ValueError): @@ -298,7 +309,7 @@ async def test_blocked_response_returns_false_without_invoking_llm(patch_central ) blocked_message = Message(message_pieces=[blocked_piece]) - scores = await scorer.score_async(blocked_message) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(blocked_message))) chat_target.send_prompt_async.assert_not_called() assert len(scores) == 1 diff --git a/tests/unit/score/test_self_ask_likert.py b/tests/unit/score/test_self_ask_likert.py index 126c000003..effb13ca92 100644 --- a/tests/unit/score/test_self_ask_likert.py +++ b/tests/unit/score/test_self_ask_likert.py @@ -74,6 +74,16 @@ def _write_likert_yaml( return path +def test_likert_harm_scorer_does_not_match_objective(likert_scale: LikertScale): + scorer = SelfAskLikertScorer.from_likert_scale( + chat_target=_mock_target(), + likert_scale=likert_scale, + ) + + assert scorer.matched_conditions() == frozenset() + assert scorer.required_conditions() == frozenset() + + async def test_likert_scorer_sets_system_prompt_and_scores( patch_central_database, scorer_likert_response: Message, diff --git a/tests/unit/score/test_self_ask_question_answer_scorer.py b/tests/unit/score/test_self_ask_question_answer_scorer.py index a8656ac480..6c846ec418 100644 --- a/tests/unit/score/test_self_ask_question_answer_scorer.py +++ b/tests/unit/score/test_self_ask_question_answer_scorer.py @@ -4,9 +4,11 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from unit.mocks import store_message -from pyrit.models import ComponentIdentifier, MessagePiece, Score, UnvalidatedScore +from pyrit.models import ComponentIdentifier, MessagePiece, Score, ScoringExpectation, UnvalidatedScore from pyrit.prompt_target import PromptTarget +from pyrit.score import MessageScorable from pyrit.score.true_false.self_ask_question_answer_scorer import SelfAskQuestionAnswerScorer @@ -40,7 +42,10 @@ async def test_score_async_returns_score_from_unvalidated(mock_chat_target): "pyrit.score.true_false.self_ask_question_answer_scorer._run_llm_scoring_async", new=AsyncMock(return_value=unvalidated), ): - scores = await scorer.score_async(message, objective="2+2=?\nanswer: 4") + scores = await scorer.score_async( + scorable=MessageScorable.from_message(store_message(message)), + expectation=ScoringExpectation(objective="2+2=?\nanswer: 4"), + ) assert len(scores) == 1 assert isinstance(scores[0], Score) diff --git a/tests/unit/score/test_self_ask_refusal.py b/tests/unit/score/test_self_ask_refusal.py index 4041eeaa64..78d8054537 100644 --- a/tests/unit/score/test_self_ask_refusal.py +++ b/tests/unit/score/test_self_ask_refusal.py @@ -8,7 +8,7 @@ from uuid import uuid4 import pytest -from unit.mocks import get_mock_target_identifier +from unit.mocks import get_mock_target_identifier, store_message from pyrit.exceptions.exception_classes import InvalidJsonException from pyrit.memory import CentralMemory @@ -21,7 +21,7 @@ MessagePiece, SeedPrompt, ) -from pyrit.score import JsonSchemaResponseHandler, RefusalScorerPaths, SelfAskRefusalScorer +from pyrit.score import JsonSchemaResponseHandler, MessageScorable, RefusalScorerPaths, SelfAskRefusalScorer @pytest.fixture @@ -254,7 +254,7 @@ async def test_score_async_filtered_response(patch_central_database): conversation_id=str(uuid4()), ).to_message() memory.add_message_pieces_to_memory(message_pieces=request.message_pieces) - scores = await scorer.score_async(request) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(request))) assert len(scores) == 1 assert scores[0].score_value == "true" diff --git a/tests/unit/score/test_shieldgemma_scorer.py b/tests/unit/score/test_shieldgemma_scorer.py index cad3a54b2d..cf27cebb90 100644 --- a/tests/unit/score/test_shieldgemma_scorer.py +++ b/tests/unit/score/test_shieldgemma_scorer.py @@ -5,13 +5,14 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from unit.mocks import get_mock_target_identifier +from unit.mocks import get_mock_target_identifier, store_message from pyrit.exceptions import InvalidJsonException from pyrit.memory.memory_interface import MemoryInterface from pyrit.models import JSON_SCHEMA_METADATA_KEY, Message, MessagePiece from pyrit.prompt_target import PromptTarget from pyrit.score import ( + MessageScorable, ShieldGemmaGuideline, ShieldGemmaMessageRole, ShieldGemmaPolicy, @@ -142,7 +143,7 @@ async def test_response_scoring_excludes_a_stored_user_turn(sqlite_instance: Mem target = _mock_target("No") scorer = ShieldGemmaScorer(chat_target=target, guideline=CUSTOM_GUIDELINE) - await scorer.score_async(response) + await scorer.score_async(scorable=MessageScorable.from_message(store_message(response))) sent = _sent_request(target) assert "Chatbot Response: A response judged on its own." in sent @@ -231,7 +232,7 @@ async def test_multiple_pieces_keep_every_verdict_and_report_the_aggregate( ) message.set_response_not_in_memory() - scores = await scorer.score_async(message) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(message))) assert target.send_prompt_async.call_count == 2 assert scores[0].get_value() is True diff --git a/tests/unit/score/test_substring.py b/tests/unit/score/test_substring.py index 64f6603481..db51957e95 100644 --- a/tests/unit/score/test_substring.py +++ b/tests/unit/score/test_substring.py @@ -5,13 +5,13 @@ from unittest.mock import MagicMock, patch import pytest -from unit.mocks import get_image_message_piece +from unit.mocks import get_image_message_piece, store_message from pyrit.analytics import ApproximateTextMatching, ExactTextMatching from pyrit.memory.central_memory import CentralMemory from pyrit.memory.memory_interface import MemoryInterface -from pyrit.models import MessagePiece -from pyrit.score import SubStringScorer +from pyrit.models import MatchesObjective, MessagePiece, ScoringExpectation +from pyrit.score import ContentScorable, MessageScorable, SubStringScorer @pytest.fixture @@ -27,7 +27,7 @@ async def test_score_async_unsupported_data_type_returns_false( scorer = SubStringScorer(substring="test", categories=["new_category"]) # With raise_on_no_valid_pieces=False (default), returns False for unsupported data types - scores = await scorer.score_async(request) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(request))) assert len(scores) == 1 assert scores[0].get_value() is False assert "No supported pieces" in scores[0].score_rationale @@ -50,6 +50,21 @@ async def test_substring_scorer_score(sub_string: str, patch_central_database): assert score[0].message_piece_id is None +async def test_substring_scorer_does_not_match_objective(patch_central_database): + scorer = SubStringScorer(substring="needle") + + assert scorer.matched_conditions() == frozenset() + assert scorer.required_conditions() == frozenset() + with pytest.raises(ValueError, match="does not match the condition"): + await scorer.score_async( + scorable=ContentScorable(value="needle"), + expectation=ScoringExpectation( + objective="find the configured substring", + conditions=(MatchesObjective(),), + ), + ) + + async def test_substring_scorer_case_sensitive(): memory = MagicMock(MemoryInterface) with patch.object(CentralMemory, "get_memory_instance", return_value=memory): diff --git a/tests/unit/score/test_true_false_composite_scorer.py b/tests/unit/score/test_true_false_composite_scorer.py index 5f9458161e..411e391a7d 100644 --- a/tests/unit/score/test_true_false_composite_scorer.py +++ b/tests/unit/score/test_true_false_composite_scorer.py @@ -4,11 +4,14 @@ from unittest.mock import MagicMock import pytest +from unit.mocks import store_message from pyrit.memory.central_memory import CentralMemory -from pyrit.models import ComponentIdentifier, MessagePiece, Score +from pyrit.models import ComponentIdentifier, MatchesObjective, Message, MessagePiece, Score, ScoringExpectation from pyrit.score import ( FloatScaleScorer, + MessageScorable, + ScorerPromptValidator, TrueFalseCompositeScorer, TrueFalseScoreAggregator, TrueFalseScorer, @@ -30,12 +33,20 @@ def _score_aggregator(self, score_list): # Use the AND aggregator from the TrueFalseScoreAggregator class return TrueFalseScoreAggregator.AND(score_list) - def __init__(self, *, score_value: bool, score_rationale: str, aggregator=None): + def __init__( + self, + *, + score_value: bool, + score_rationale: str, + aggregator: object | None = None, + is_objective_required: bool = False, + ) -> None: self._score_value = score_value self._score_rationale = score_rationale self.aggregator = aggregator + self.received_expectations: list[ScoringExpectation | None] = [] # Call super().__init__() to properly initialize the scorer including _identifier - super().__init__(validator=MagicMock()) + super().__init__(validator=ScorerPromptValidator(is_objective_required=is_objective_required)) def _build_identifier(self) -> ComponentIdentifier: """Build the scorer evaluation identifier for this mock scorer. @@ -60,6 +71,18 @@ async def _score_piece_async(self, message_piece: MessagePiece, *, objective: st ) ] + async def _score_prepared_message_async( + self, + *, + message: Message, + expectation: ScoringExpectation | None, + ) -> list[Score]: + self.received_expectations.append(expectation) + return await super()._score_prepared_message_async( + message=message, + expectation=expectation, + ) + @pytest.fixture def mock_request(patch_central_database): @@ -82,7 +105,7 @@ def false_scorer(patch_central_database): async def test_composite_scorer_and_all_true(mock_request, true_scorer): scorer = TrueFalseCompositeScorer(aggregator=TrueFalseScoreAggregator.AND, scorers=[true_scorer, true_scorer]) - scores = await scorer.score_async(mock_request) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(mock_request))) assert len(scores) == 1 assert scores[0].get_value() is True assert "This is a true score" in scores[0].score_rationale @@ -92,7 +115,7 @@ async def test_composite_scorer_and_all_true(mock_request, true_scorer): async def test_composite_scorer_and_one_false(mock_request, true_scorer, false_scorer): scorer = TrueFalseCompositeScorer(aggregator=TrueFalseScoreAggregator.AND, scorers=[true_scorer, false_scorer]) - scores = await scorer.score_async(mock_request) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(mock_request))) assert len(scores) == 1 assert scores[0].get_value() is False assert "This is a false score" in scores[0].score_rationale @@ -102,7 +125,7 @@ async def test_composite_scorer_and_one_false(mock_request, true_scorer, false_s async def test_composite_scorer_or_all_false(mock_request, false_scorer): scorer = TrueFalseCompositeScorer(aggregator=TrueFalseScoreAggregator.OR, scorers=[false_scorer, false_scorer]) - scores = await scorer.score_async(mock_request) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(mock_request))) assert len(scores) == 1 assert scores[0].get_value() is False assert "This is a false score" in scores[0].score_rationale @@ -112,7 +135,7 @@ async def test_composite_scorer_or_all_false(mock_request, false_scorer): async def test_composite_scorer_or_one_true(mock_request, true_scorer, false_scorer): scorer = TrueFalseCompositeScorer(aggregator=TrueFalseScoreAggregator.OR, scorers=[true_scorer, false_scorer]) - scores = await scorer.score_async(mock_request) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(mock_request))) assert len(scores) == 1 assert scores[0].get_value() is True assert "This is a true score" in scores[0].score_rationale @@ -123,7 +146,7 @@ async def test_composite_scorer_majority_true(mock_request, true_scorer, false_s aggregator=TrueFalseScoreAggregator.MAJORITY, scorers=[true_scorer, true_scorer, false_scorer] ) - scores = await scorer.score_async(mock_request) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(mock_request))) assert len(scores) == 1 assert scores[0].get_value() is True assert "This is a true score" in scores[0].score_rationale @@ -138,7 +161,7 @@ async def test_composite_scorer_majority_false(mock_request, true_scorer, false_ aggregator=TrueFalseScoreAggregator.MAJORITY, scorers=[true_scorer, false_scorer, false_scorer] ) - scores = await scorer.score_async(mock_request) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(mock_request))) assert len(scores) == 1 assert scores[0].get_value() is False assert "This is a true score" in scores[0].score_rationale @@ -164,28 +187,55 @@ async def test_composite_scorer_with_task(mock_request, true_scorer): scorer = TrueFalseCompositeScorer(aggregator=TrueFalseScoreAggregator.AND, scorers=[true_scorer]) task = "test task" - scores = await scorer.score_async(mock_request, objective=task) + scores = await scorer.score_async( + scorable=MessageScorable.from_message(store_message(mock_request)), + expectation=ScoringExpectation(objective=task), + ) assert len(scores) == 1 assert scores[0].objective == task +async def test_composite_routes_full_expectation_to_matching_and_nonmatching_leaves(mock_request): + objective_scorer = MockScorer( + score_value=True, + score_rationale="objective", + is_objective_required=True, + ) + fixed_criterion_scorer = MockScorer(score_value=True, score_rationale="fixed") + scorer = TrueFalseCompositeScorer( + aggregator=TrueFalseScoreAggregator.AND, + scorers=[objective_scorer, fixed_criterion_scorer], + ) + expectation = ScoringExpectation( + objective="test objective", + conditions=(MatchesObjective(),), + ) + + await scorer.score_async( + scorable=MessageScorable.from_message(store_message(mock_request)), + expectation=expectation, + ) + + assert scorer.matched_conditions() == frozenset({MatchesObjective}) + assert scorer.required_conditions() == frozenset({MatchesObjective}) + assert objective_scorer.received_expectations == [expectation] + assert fixed_criterion_scorer.received_expectations == [expectation] + + def test_composite_scorer_empty_scorers_list(): """Test that TrueFalseCompositeScorer raises an exception when given an empty list of scorers.""" with pytest.raises(ValueError, match="At least one scorer must be provided"): TrueFalseCompositeScorer(aggregator=TrueFalseScoreAggregator.AND, scorers=[]) -async def test_composite_scorer_raises_when_message_piece_id_is_none(true_scorer, patch_central_database): - """Test that _score_async raises ValueError when message piece has no ID.""" +async def test_composite_scorer_anchors_where_its_children_anchored(true_scorer, patch_central_database): + """The aggregate is about whatever its children were about, so it anchors where they did.""" scorer = TrueFalseCompositeScorer(aggregator=TrueFalseScoreAggregator.AND, scorers=[true_scorer]) + message = store_message(MessagePiece(role="user", original_value="test content").to_message()) - # Create a message with a piece whose id is None - piece = MessagePiece(role="user", original_value="test content") - piece.id = None - message = piece.to_message() + scores = await scorer.score_async(scorable=MessageScorable.from_message(message)) - with pytest.raises(RuntimeError, match="Message piece must have an ID"): - await scorer.score_async(message) + assert str(scores[0].message_piece_id) == str(message.get_piece().id) def test_get_chat_target_returns_first_available(patch_central_database): diff --git a/tests/unit/score/test_true_false_inverter.py b/tests/unit/score/test_true_false_inverter.py index 28618d9e3a..50003366c1 100644 --- a/tests/unit/score/test_true_false_inverter.py +++ b/tests/unit/score/test_true_false_inverter.py @@ -5,12 +5,12 @@ from unittest.mock import MagicMock, patch import pytest -from unit.mocks import get_image_message_piece +from unit.mocks import get_image_message_piece, store_message from pyrit.memory.central_memory import CentralMemory from pyrit.memory.memory_interface import MemoryInterface from pyrit.models import MessagePiece -from pyrit.score import SubStringScorer, TrueFalseInverterScorer +from pyrit.score import MessageScorable, SubStringScorer, TrueFalseInverterScorer @pytest.fixture @@ -28,7 +28,7 @@ async def test_score_async_unsupported_data_type_inverts_false_to_true( # With raise_on_no_valid_pieces=False (default), the inner scorer returns False, # and the inverter inverts it to True - scores = await scorer.score_async(request) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(request))) assert len(scores) == 1 # Inverter inverts False -> True assert scores[0].get_value() is True