Skip to content

Commit 17b8bc7

Browse files
rlundeen2Copilot
andcommitted
Add IPythonMarkdownSink, get_default_sink auto-detection, markdown conversation/score printers, refactor display_image_response
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1bdc53d commit 17b8bc7

11 files changed

Lines changed: 748 additions & 353 deletions

File tree

pyrit/printer/attack_result/markdown.py

Lines changed: 38 additions & 260 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,50 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT license.
33

4-
import os
54
import warnings
65
from datetime import datetime, timezone
76

8-
from pyrit.models import AttackResult, ConversationType, Message, MessagePiece, Score
7+
from pyrit.models import AttackResult, ConversationType, Message, Score
98
from pyrit.printer.attack_result.base import AttackResultPrinterBase
9+
from pyrit.printer.conversation.markdown import MarkdownConversationPrinter
10+
from pyrit.printer.score.markdown import MarkdownScorePrinter
1011
from pyrit.printer.sink import Sink
1112

1213

1314
class 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"![Image]({posix_path})\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

Comments
 (0)