Skip to content

Commit 5abdadf

Browse files
authored
.NET: Also include citations and error details in purview checks. (#9375)
* Also include citations and error details in purview checks. * Address PR comments
1 parent 74db3c1 commit 5abdadf

6 files changed

Lines changed: 112 additions & 3 deletions

File tree

‎dotnet/src/Microsoft.Agents.AI.Purview/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,8 @@ disables enforcement for every error, not just transient ones.
381381
Every content item on a message is submitted for evaluation, not just its text: `DataContent` is sent
382382
as Purview binary content, and `FunctionCallContent`, `FunctionResultContent` and other structured
383383
content are serialized to text. Only `UsageContent` is skipped, because it carries token counts rather
384-
than user data.
384+
than user data. Text annotations such as citations, `ErrorContent.Details` and `DataContent.Name`
385+
are submitted as additional text entries.
385386

386387
### Remote references are not dereferenced
387388

‎dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,11 +278,39 @@ private static List<ContentBase> MapMessageContents(ChatMessage message)
278278
{
279279
mapped.Add(purviewContent);
280280
}
281+
282+
AddSupplementalContent(content, mapped);
281283
}
282284

283285
return mapped;
284286
}
285287

288+
/// <summary>
289+
/// Add entries for user or model supplied data that <see cref="MapContent"/> does not carry:
290+
/// annotations (for example citations), error details and the name of a data item.
291+
/// </summary>
292+
private static void AddSupplementalContent(AIContent content, List<ContentBase> mapped)
293+
{
294+
switch (content)
295+
{
296+
case TextContent or TextReasoningContent when content.Annotations is { Count: > 0 }:
297+
foreach (AIAnnotation annotation in content.Annotations)
298+
{
299+
mapped.Add(new PurviewTextContent(JsonSerializer.Serialize(annotation, AIJsonUtilities.DefaultOptions.GetTypeInfo(annotation.GetType()))));
300+
}
301+
302+
break;
303+
304+
case ErrorContent { Details: { Length: > 0 } details }:
305+
mapped.Add(new PurviewTextContent(details));
306+
break;
307+
308+
case DataContent { Name: { Length: > 0 } name }:
309+
mapped.Add(new PurviewTextContent(name));
310+
break;
311+
}
312+
}
313+
286314
/// <summary>
287315
/// Map a single <see cref="AIContent"/> onto a Purview content item.
288316
/// </summary>

‎dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,6 +1214,58 @@ await this._processor.ProcessMessagesAsync(
12141214
Assert.NotEqual(binaryEntry.Identifier, functionCallEntry.Identifier);
12151215
}
12161216

1217+
/// <summary>
1218+
/// Verifies annotations, error details and data names are submitted alongside the main payload.
1219+
/// </summary>
1220+
[Fact]
1221+
public async Task ProcessMessagesAsync_WithAnnotationsErrorDetailsAndDataName_SubmitsThemForEvaluationAsync()
1222+
{
1223+
// Arrange
1224+
List<ChatMessage> messages =
1225+
[
1226+
new(ChatRole.Assistant,
1227+
[
1228+
new TextContent("see sources") { Annotations = [new CitationAnnotation { Title = "cited-title-111", Url = new Uri("https://example.com/cited") }] },
1229+
new ErrorContent("failed") { Details = "error-details-222" },
1230+
new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream") { Name = "file-name-333.bin" },
1231+
new DataContent(ReadOnlyMemory<byte>.Empty, "application/octet-stream") { Name = "empty-file-name-444.bin" }
1232+
])
1233+
];
1234+
PurviewSettings settings = CreateValidPurviewSettings();
1235+
TokenInfo tokenInfo = new() { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
1236+
1237+
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
1238+
.ReturnsAsync(tokenInfo);
1239+
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
1240+
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
1241+
.ReturnsAsync(CreateApplicableProtectionScopesResponse());
1242+
1243+
List<ProcessContentRequest> capturedRequests = [];
1244+
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
1245+
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
1246+
.Callback<ProcessContentRequest, CancellationToken>((request, _) => capturedRequests.Add(request))
1247+
.ReturnsAsync(new ProcessContentResponse());
1248+
1249+
// Act
1250+
await this._processor.ProcessMessagesAsync(
1251+
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
1252+
1253+
// Assert
1254+
List<string> texts = [];
1255+
foreach (ProcessContentRequest request in capturedRequests)
1256+
{
1257+
if (request.ContentToProcess.ContentEntries[0].Content is PurviewTextContent text)
1258+
{
1259+
texts.Add(text.Data);
1260+
}
1261+
}
1262+
1263+
Assert.Contains(texts, t => t.Contains("cited-title-111", StringComparison.Ordinal));
1264+
Assert.Contains("error-details-222", texts);
1265+
Assert.Contains("file-name-333.bin", texts);
1266+
Assert.Contains("empty-file-name-444.bin", texts);
1267+
}
1268+
12171269
[Fact]
12181270
public async Task ProcessMessagesAsync_WithEmptyTextAndData_SkipsEmptyContentAsync()
12191271
{

‎python/packages/purview/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ disables enforcement for every error, not just transient ones.
468468
Every content item on a message is submitted for evaluation, not just its text: binary/data content is
469469
sent as Purview binary content, and function calls, function results and other structured content are
470470
serialized to text. Only `usage` content is skipped, because it carries token counts rather than user
471-
data.
471+
data. Annotations on text content, such as citations, are submitted as additional text entries.
472472

473473
Evaluation covers the response as it stands when the middleware runs. Because middleware can replace
474474
the response afterwards, what is evaluated depends on where the middleware is attached — see

‎python/packages/purview/agent_framework_purview/_processor.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,14 @@ def _map_content(content: Content) -> ContentBase | None:
137137

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

142149

143150
def _is_blocking_action(action_info: DlpActionInfo) -> bool:

‎python/packages/purview/tests/purview/test_processor.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,27 @@ async def test_map_messages_submits_each_content_item_in_separate_request(
193193
assert all(entry.content is not None for entry in entries)
194194
assert not any(isinstance(entry.content, PurviewTextContent) and entry.content.data == "" for entry in entries)
195195

196+
async def test_map_messages_submits_annotations_for_evaluation(self, processor: ScopedContentProcessor) -> None:
197+
"""Test annotations on text content are submitted in addition to the text."""
198+
from agent_framework import Content
199+
200+
content = Content.from_text(
201+
"see sources",
202+
annotations=[{"type": "citation", "title": "cited-title-111", "url": "https://example.com/cited"}],
203+
)
204+
messages = [Message(role="assistant", contents=[content])]
205+
206+
requests, _ = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
207+
208+
texts = [
209+
entry.content.data
210+
for r in requests
211+
for entry in r.content_to_process.content_entries
212+
if isinstance(entry.content, PurviewTextContent)
213+
]
214+
assert "see sources" in texts
215+
assert any("cited-title-111" in text for text in texts)
216+
196217
async def test_map_messages_skips_empty_text_and_data(self, processor: ScopedContentProcessor) -> None:
197218
"""Test _map_messages does not create requests for empty text or binary data."""
198219
from agent_framework import Content

0 commit comments

Comments
 (0)