Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion dotnet/src/Microsoft.Agents.AI.Purview/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@ disables enforcement for every error, not just transient ones.
Every content item on a message is submitted for evaluation, not just its text: `DataContent` is sent
as Purview binary content, and `FunctionCallContent`, `FunctionResultContent` and other structured
content are serialized to text. Only `UsageContent` is skipped, because it carries token counts rather
than user data.
than user data. Text annotations such as citations, `ErrorContent.Details` and `DataContent.Name`
are submitted as additional text entries.

### Remote references are not dereferenced

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,11 +278,39 @@ private static List<ContentBase> MapMessageContents(ChatMessage message)
{
mapped.Add(purviewContent);
}

AddSupplementalContent(content, mapped);
}

return mapped;
}

/// <summary>
/// Add entries for user or model supplied data that <see cref="MapContent"/> does not carry:
/// annotations (for example citations), error details and the name of a data item.
/// </summary>
private static void AddSupplementalContent(AIContent content, List<ContentBase> mapped)
{
switch (content)
{
case TextContent or TextReasoningContent when content.Annotations is { Count: > 0 }:
foreach (AIAnnotation annotation in content.Annotations)
{
mapped.Add(new PurviewTextContent(JsonSerializer.Serialize(annotation, AIJsonUtilities.DefaultOptions.GetTypeInfo(annotation.GetType()))));
}

break;

case ErrorContent { Details: { Length: > 0 } details }:
mapped.Add(new PurviewTextContent(details));
break;

case DataContent { Name: { Length: > 0 } name }:
mapped.Add(new PurviewTextContent(name));
break;
}
}

/// <summary>
/// Map a single <see cref="AIContent"/> onto a Purview content item.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1214,6 +1214,58 @@ await this._processor.ProcessMessagesAsync(
Assert.NotEqual(binaryEntry.Identifier, functionCallEntry.Identifier);
}

/// <summary>
/// Verifies annotations, error details and data names are submitted alongside the main payload.
/// </summary>
[Fact]
public async Task ProcessMessagesAsync_WithAnnotationsErrorDetailsAndDataName_SubmitsThemForEvaluationAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.Assistant,
[
new TextContent("see sources") { Annotations = [new CitationAnnotation { Title = "cited-title-111", Url = new Uri("https://example.com/cited") }] },
new ErrorContent("failed") { Details = "error-details-222" },
new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream") { Name = "file-name-333.bin" },
new DataContent(ReadOnlyMemory<byte>.Empty, "application/octet-stream") { Name = "empty-file-name-444.bin" }
])
];
PurviewSettings settings = CreateValidPurviewSettings();
TokenInfo tokenInfo = new() { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };

this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateApplicableProtectionScopesResponse());

List<ProcessContentRequest> capturedRequests = [];
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.Callback<ProcessContentRequest, CancellationToken>((request, _) => capturedRequests.Add(request))
.ReturnsAsync(new ProcessContentResponse());

// Act
await this._processor.ProcessMessagesAsync(
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);

// Assert
List<string> texts = [];
foreach (ProcessContentRequest request in capturedRequests)
{
if (request.ContentToProcess.ContentEntries[0].Content is PurviewTextContent text)
{
texts.Add(text.Data);
}
}

Assert.Contains(texts, t => t.Contains("cited-title-111", StringComparison.Ordinal));
Assert.Contains("error-details-222", texts);
Assert.Contains("file-name-333.bin", texts);
Assert.Contains("empty-file-name-444.bin", texts);
}

[Fact]
public async Task ProcessMessagesAsync_WithEmptyTextAndData_SkipsEmptyContentAsync()
{
Expand Down
2 changes: 1 addition & 1 deletion python/packages/purview/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ disables enforcement for every error, not just transient ones.
Every content item on a message is submitted for evaluation, not just its text: binary/data content is
sent as Purview binary content, and function calls, function results and other structured content are
serialized to text. Only `usage` content is skipped, because it carries token counts rather than user
data.
data. Annotations on text content, such as citations, are submitted as additional text entries.

Evaluation covers the response as it stands when the middleware runs. Because middleware can replace
the response afterwards, what is evaluated depends on where the middleware is attached — see
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,14 @@ def _map_content(content: Content) -> ContentBase | None:

def _map_message_contents(message: Message) -> list[ContentBase]:
"""Map every non-empty content item of a message to Purview content entries."""
return [purview_content for content in message.contents if (purview_content := _map_content(content))]
mapped: list[ContentBase] = []
for content in message.contents:
if purview_content := _map_content(content):
mapped.append(purview_content)
# Annotations (for example citations) are not part of the text, so they are evaluated separately.
if content.type in ("text", "text_reasoning") and content.annotations:
mapped.extend(PurviewTextContent(data=_serialize_for_evaluation(a)) for a in content.annotations)
return mapped


def _is_blocking_action(action_info: DlpActionInfo) -> bool:
Expand Down
21 changes: 21 additions & 0 deletions python/packages/purview/tests/purview/test_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,27 @@ async def test_map_messages_submits_each_content_item_in_separate_request(
assert all(entry.content is not None for entry in entries)
assert not any(isinstance(entry.content, PurviewTextContent) and entry.content.data == "" for entry in entries)

async def test_map_messages_submits_annotations_for_evaluation(self, processor: ScopedContentProcessor) -> None:
"""Test annotations on text content are submitted in addition to the text."""
from agent_framework import Content

content = Content.from_text(
"see sources",
annotations=[{"type": "citation", "title": "cited-title-111", "url": "https://example.com/cited"}],
)
messages = [Message(role="assistant", contents=[content])]

requests, _ = await processor._map_messages(messages, Activity.UPLOAD_TEXT)

texts = [
entry.content.data
for r in requests
for entry in r.content_to_process.content_entries
if isinstance(entry.content, PurviewTextContent)
]
assert "see sources" in texts
assert any("cited-title-111" in text for text in texts)

async def test_map_messages_skips_empty_text_and_data(self, processor: ScopedContentProcessor) -> None:
"""Test _map_messages does not create requests for empty text or binary data."""
from agent_framework import Content
Expand Down
Loading