11# Copyright (c) Microsoft Corporation.
22# Licensed under the MIT license.
33
4- import os
54import warnings
65from datetime import datetime , timezone
76
8- from pyrit .models import AttackResult , ConversationType , Message , MessagePiece , Score
7+ from pyrit .models import AttackResult , ConversationType , Message , Score
98from pyrit .printer .attack_result .base import AttackResultPrinterBase
9+ from pyrit .printer .conversation .markdown import MarkdownConversationPrinter
10+ from pyrit .printer .score .markdown import MarkdownScorePrinter
1011from pyrit .printer .sink import Sink
1112
1213
1314class MarkdownAttackResultPrinter (AttackResultPrinterBase ):
1415 """
1516 Markdown printer for attack results optimized for Jupyter notebooks.
1617
17- Contains all formatting logic. Subclasses implement get_conversation_async
18- and get_scores_async for data fetching.
18+ Composes a conversation printer for message rendering and a score printer
19+ for inline score display. Subclasses implement data- fetching methods .
1920 """
2021
21- def __init__ (self , * , sink : Sink | None = None , display_inline : bool = True ) -> None :
22+ def __init__ (
23+ self ,
24+ * ,
25+ sink : Sink | None = None ,
26+ display_inline : bool = True ,
27+ conversation_printer : MarkdownConversationPrinter | None = None ,
28+ score_printer : MarkdownScorePrinter | None = None ,
29+ ) -> None :
2230 """
2331 Initialize the markdown printer.
2432
2533 Args:
2634 sink (Sink | None): Output sink. Defaults to StdoutSink().
2735 display_inline (bool): Kept for backward compatibility but unused.
2836 All output is routed through the sink. Defaults to True.
37+ conversation_printer (MarkdownConversationPrinter | None): Conversation printer.
38+ Defaults to a new MarkdownConversationPrinter with matching sink.
39+ score_printer (MarkdownScorePrinter | None): Score printer.
40+ Defaults to a new MarkdownScorePrinter with matching sink.
2941 """
3042 super ().__init__ (sink = sink )
3143 self ._display_inline = display_inline
32-
33- def _format_score (self , score : Score , indent : str = "" ) -> str :
34- """
35- Format a score object as markdown with proper styling.
36-
37- Args:
38- score (Score): The score object to format.
39- indent (str): String prefix for indentation. Defaults to "".
40-
41- Returns:
42- str: Formatted markdown representation of the score.
43- """
44- lines = []
45-
46- score_value = score .get_value ()
47- if isinstance (score_value , bool ):
48- value_str = str (score_value )
49- elif isinstance (score_value , (int , float )):
50- value_str = f"**{ score_value :.2f} **" if isinstance (score_value , float ) else f"**{ score_value } **"
51- else :
52- value_str = f"**{ score_value } **"
53-
54- lines .append (f"{ indent } - **Score Type:** { score .score_type } " )
55- lines .append (f"{ indent } - **Value:** { value_str } " )
56- category_str = ", " .join (score .score_category ) if score .score_category else "N/A"
57- lines .append (f"{ indent } - **Category:** { category_str } " )
58-
59- if score .score_rationale :
60- rationale_lines = score .score_rationale .split ("\n " )
61- if len (rationale_lines ) > 1 :
62- lines .append (f"{ indent } - **Rationale:**" )
63- lines .extend (f"{ indent } { line } " for line in rationale_lines )
64- else :
65- lines .append (f"{ indent } - **Rationale:** { score .score_rationale } " )
66-
67- if score .score_metadata :
68- lines .append (f"{ indent } - **Metadata:** `{ score .score_metadata } `" )
69-
70- return "\n " .join (lines )
44+ self ._score_printer = score_printer or MarkdownScorePrinter (sink = sink )
45+ self ._conversation_printer = conversation_printer or MarkdownConversationPrinter (
46+ sink = sink , score_printer = self ._score_printer ,
47+ )
7148
7249 async def render_async (
7350 self ,
@@ -153,8 +130,8 @@ async def print_conversation_async(self, result: AttackResult, *, include_scores
153130 warnings .warn (
154131 "print_conversation_async is deprecated, use write_async instead" , DeprecationWarning , stacklevel = 2
155132 )
156- markdown_lines = await self ._get_conversation_markdown_async (result = result , include_scores = include_scores )
157- await self ._write_async ("\n " .join (markdown_lines ))
133+ lines = await self ._get_conversation_markdown_async (result = result , include_scores = include_scores )
134+ await self ._write_async ("\n " .join (lines ))
158135
159136 async def print_summary_async (self , result : AttackResult ) -> None :
160137 """Deprecated. Use write_async instead."""
@@ -175,223 +152,16 @@ async def _get_conversation_markdown_async(
175152 Returns:
176153 list[str]: Markdown strings for the conversation.
177154 """
178- markdown_lines : list [str ] = []
179-
180155 if not result .conversation_id :
181- markdown_lines .append ("*No conversation ID available*\n " )
182- return markdown_lines
156+ return ["*No conversation ID available*\n " ]
183157
184158 messages = await self ._get_conversation_async (result .conversation_id )
185159
186160 if not messages :
187- markdown_lines .append (f"*No conversation found for ID: { result .conversation_id } *\n " )
188- return markdown_lines
189-
190- turn_number = 0
191-
192- for message in messages :
193- if not message .message_pieces :
194- continue
195-
196- message_role = message .get_piece ().api_role
197-
198- if message_role == "system" :
199- markdown_lines .extend (self ._format_system_message (message ))
200- elif message_role == "user" :
201- turn_number += 1
202- markdown_lines .extend (await self ._format_user_message_async (message = message , turn_number = turn_number ))
203- else :
204- markdown_lines .extend (await self ._format_assistant_message_async (message = message ))
205-
206- if include_scores :
207- markdown_lines .extend (await self ._format_message_scores_async (message ))
208-
209- return markdown_lines
210-
211- def _format_system_message (self , message : Message ) -> list [str ]:
212- """
213- Format a system message as markdown.
214-
215- Args:
216- message (Message): The system message to format.
217-
218- Returns:
219- list[str]: Markdown strings for the system message.
220- """
221- lines = ["\n ### System Message\n " ]
222- lines .extend (f"{ piece .converted_value } \n " for piece in message .message_pieces )
223- return lines
224-
225- async def _format_user_message_async (self , * , message : Message , turn_number : int ) -> list [str ]:
226- """
227- Format a user message as markdown with turn numbering.
228-
229- Args:
230- message (Message): The user message to format.
231- turn_number (int): The conversation turn number.
232-
233- Returns:
234- list[str]: Markdown strings for the user message.
235- """
236- lines = [f"\n ### Turn { turn_number } \n " , "#### User\n " ]
237-
238- for piece in message .message_pieces :
239- lines .extend (await self ._format_piece_content_async (piece = piece , show_original = True ))
240-
241- return lines
161+ return [f"*No conversation found for ID: { result .conversation_id } *\n " ]
242162
243- async def _format_assistant_message_async (self , * , message : Message ) -> list [str ]:
244- """
245- Format an assistant response message as markdown.
246-
247- Args:
248- message (Message): The response message to format.
249-
250- Returns:
251- list[str]: Markdown strings for the response message.
252- """
253- lines : list [str ] = []
254- piece = message .message_pieces [0 ]
255- role_name = "Assistant (Simulated)" if piece .is_simulated else piece .api_role .capitalize ()
256-
257- lines .append (f"\n #### { role_name } \n " )
258-
259- for piece in message .message_pieces :
260- lines .extend (await self ._format_piece_content_async (piece = piece , show_original = False ))
261-
262- return lines
263-
264- def _get_audio_mime_type (self , * , audio_path : str ) -> str :
265- """
266- Determine the MIME type for an audio file based on its file extension.
267-
268- Args:
269- audio_path (str): The path to the audio file.
270-
271- Returns:
272- str: The appropriate MIME type for the audio file.
273- """
274- if audio_path .lower ().endswith (".wav" ):
275- return "audio/wav"
276- if audio_path .lower ().endswith (".ogg" ):
277- return "audio/ogg"
278- if audio_path .lower ().endswith (".m4a" ):
279- return "audio/mp4"
280- return "audio/mpeg"
281-
282- def _format_image_content (self , * , image_path : str ) -> list [str ]:
283- """
284- Format image content as markdown.
285-
286- Args:
287- image_path (str): The path to the image file.
288-
289- Returns:
290- list[str]: Markdown lines for the image.
291- """
292- relative_path = os .path .relpath (image_path )
293- posix_path = relative_path .replace ("\\ " , "/" )
294- return [f"\n " ]
295-
296- def _format_audio_content (self , * , audio_path : str ) -> list [str ]:
297- """
298- Format audio content as HTML5 audio player.
299-
300- Args:
301- audio_path (str): The path to the audio file.
302-
303- Returns:
304- list[str]: Markdown lines for the audio player.
305- """
306- lines : list [str ] = []
307- lines .append ("<audio controls>" )
308-
309- audio_type = self ._get_audio_mime_type (audio_path = audio_path )
310-
311- lines .append (f'<source src="{ audio_path } " type="{ audio_type } ">' )
312- lines .append ("Your browser does not support the audio element." )
313- lines .append ("</audio>\n " )
314-
315- return lines
316-
317- def _format_error_content (self , * , piece : MessagePiece ) -> list [str ]:
318- """
319- Format error response content with proper styling.
320-
321- Args:
322- piece (MessagePiece): The message piece containing the error.
323-
324- Returns:
325- list[str]: Markdown lines for the error response.
326- """
327- lines : list [str ] = []
328- lines .append ("**Error Response:**\n " )
329- lines .append (f"*Error Type: { piece .response_error } *\n " )
330- lines .append ("```json" )
331- lines .append (piece .converted_value )
332- lines .append ("```\n " )
333-
334- return lines
335-
336- def _format_text_content (self , * , piece : MessagePiece , show_original : bool ) -> list [str ]:
337- """
338- Format regular text content.
339-
340- Args:
341- piece (MessagePiece): The message piece containing the text.
342- show_original (bool): Whether to show original value if different.
343-
344- Returns:
345- list[str]: Markdown lines for the text content.
346- """
347- lines : list [str ] = []
348-
349- if show_original and piece .converted_value != piece .original_value :
350- lines .append ("**Original:**\n " )
351- lines .append (f"{ piece .original_value } \n " )
352- lines .append ("\n **Converted:**\n " )
353-
354- lines .append (f"{ piece .converted_value } \n " )
355-
356- return lines
357-
358- async def _format_piece_content_async (self , * , piece : MessagePiece , show_original : bool ) -> list [str ]:
359- """
360- Format a single piece content based on its data type.
361-
362- Args:
363- piece (MessagePiece): The message piece to format.
364- show_original (bool): Whether to show original value if different.
365-
366- Returns:
367- list[str]: Markdown lines for this piece.
368- """
369- if piece .converted_value_data_type == "image_path" :
370- return self ._format_image_content (image_path = piece .converted_value )
371- if piece .converted_value_data_type == "audio_path" :
372- return self ._format_audio_content (audio_path = piece .converted_value )
373- if piece .has_error ():
374- return self ._format_error_content (piece = piece )
375- return self ._format_text_content (piece = piece , show_original = show_original )
376-
377- async def _format_message_scores_async (self , message : Message ) -> list [str ]:
378- """
379- Format scores for all pieces in a message as markdown.
380-
381- Args:
382- message (Message): The message containing pieces to format scores for.
383-
384- Returns:
385- list[str]: Markdown strings for the scores.
386- """
387- lines : list [str ] = []
388- for piece in message .message_pieces :
389- scores = await self ._get_scores_async (prompt_ids = [str (piece .id )])
390- if scores :
391- lines .append ("\n ##### Scores\n " )
392- lines .extend (self ._format_score (score , indent = "" ) for score in scores )
393- lines .append ("" )
394- return lines
163+ rendered = await self ._conversation_printer .render_async (messages , include_scores = include_scores )
164+ return [rendered ]
395165
396166 async def _get_summary_markdown_async (self , result : AttackResult ) -> list [str ]:
397167 """
@@ -432,7 +202,7 @@ async def _get_summary_markdown_async(self, result: AttackResult) -> list[str]:
432202
433203 if result .last_score :
434204 markdown_lines .append ("\n ### Final Score\n " )
435- markdown_lines .append (self ._format_score (result .last_score ))
205+ markdown_lines .append (self ._score_printer . _format_score (result .last_score ))
436206
437207 return markdown_lines
438208
@@ -484,7 +254,9 @@ async def _get_pruned_conversations_markdown_async(self, result: AttackResult) -
484254 scores = await self ._get_scores_async (prompt_ids = [str (piece .id )])
485255 if scores :
486256 markdown_lines .append ("\n **Score:**\n " )
487- markdown_lines .extend (self ._format_score (score , indent = "" ) for score in scores )
257+ markdown_lines .extend (
258+ self ._score_printer ._format_score (score , indent = "" ) for score in scores
259+ )
488260
489261 return markdown_lines
490262
@@ -562,9 +334,15 @@ def __init__(self, *, sink: Sink | None = None, display_inline: bool = True) ->
562334 display_inline (bool): Kept for backward compatibility but unused.
563335 All output is routed through the sink. Defaults to True.
564336 """
565- super ().__init__ (sink = sink , display_inline = display_inline )
566337 from pyrit .memory import CentralMemory
338+ from pyrit .printer .conversation .markdown import MarkdownConversationMemoryPrinter
567339
340+ score_printer = MarkdownScorePrinter (sink = sink )
341+ conversation_printer = MarkdownConversationMemoryPrinter (sink = sink , score_printer = score_printer )
342+ super ().__init__ (
343+ sink = sink , display_inline = display_inline ,
344+ conversation_printer = conversation_printer , score_printer = score_printer ,
345+ )
568346 self ._memory = CentralMemory .get_memory_instance ()
569347
570348 async def render_async (
0 commit comments