diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/README.md b/dotnet/src/Microsoft.Agents.AI.Purview/README.md index 5f7ee3d9b2f..f89546be2fd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/README.md +++ b/dotnet/src/Microsoft.Agents.AI.Purview/README.md @@ -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 diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs index a456f2d7891..2848d97db93 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs @@ -278,11 +278,39 @@ private static List MapMessageContents(ChatMessage message) { mapped.Add(purviewContent); } + + AddSupplementalContent(content, mapped); } return mapped; } + /// + /// Add entries for user or model supplied data that does not carry: + /// annotations (for example citations), error details and the name of a data item. + /// + private static void AddSupplementalContent(AIContent content, List 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; + } + } + /// /// Map a single onto a Purview content item. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs index 7afad015ca1..c3df179d740 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs @@ -1214,6 +1214,58 @@ await this._processor.ProcessMessagesAsync( Assert.NotEqual(binaryEntry.Identifier, functionCallEntry.Identifier); } + /// + /// Verifies annotations, error details and data names are submitted alongside the main payload. + /// + [Fact] + public async Task ProcessMessagesAsync_WithAnnotationsErrorDetailsAndDataName_SubmitsThemForEvaluationAsync() + { + // Arrange + List 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.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(), null)) + .ReturnsAsync(tokenInfo); + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateApplicableProtectionScopesResponse()); + + List capturedRequests = []; + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .Callback((request, _) => capturedRequests.Add(request)) + .ReturnsAsync(new ProcessContentResponse()); + + // Act + await this._processor.ProcessMessagesAsync( + messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + List 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() { diff --git a/python/packages/purview/README.md b/python/packages/purview/README.md index d25e84bf695..ba44ca26d85 100644 --- a/python/packages/purview/README.md +++ b/python/packages/purview/README.md @@ -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 diff --git a/python/packages/purview/agent_framework_purview/_processor.py b/python/packages/purview/agent_framework_purview/_processor.py index 919e97723e3..e26a3abee8b 100644 --- a/python/packages/purview/agent_framework_purview/_processor.py +++ b/python/packages/purview/agent_framework_purview/_processor.py @@ -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: diff --git a/python/packages/purview/tests/purview/test_processor.py b/python/packages/purview/tests/purview/test_processor.py index f5d2fe81efb..70f83f4704c 100644 --- a/python/packages/purview/tests/purview/test_processor.py +++ b/python/packages/purview/tests/purview/test_processor.py @@ -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