-
Notifications
You must be signed in to change notification settings - Fork 287
Improve citation and reaction logic #452
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
ce8c1c9
Refactor: modularize Genesys handoff agent and services
sigaurmicrosoft 66b35c5
Refactor DI, logging, and Copilot integration for Genesys agent
sigaurmicrosoft 28afe32
Add argument validation and improve documentation
sigaurmicrosoft b9d4ce8
Merge branch 'main' into users/sigaur/fix-citation-and-reaction
MattB-msft File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| Microsoft Visual Studio Solution File, Format Version 12.00 | ||
| # Visual Studio Version 17 | ||
| VisualStudioVersion = 17.5.2.0 | ||
| MinimumVisualStudioVersion = 10.0.40219.1 | ||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenesysHandoff", "GenesysHandoff.csproj", "{A2C8EE13-9E68-DA15-21EC-7EFEBDF2E19B}" | ||
| EndProject | ||
| Global | ||
| GlobalSection(SolutionConfigurationPlatforms) = preSolution | ||
| Debug|Any CPU = Debug|Any CPU | ||
| Release|Any CPU = Release|Any CPU | ||
| EndGlobalSection | ||
| GlobalSection(ProjectConfigurationPlatforms) = postSolution | ||
| {A2C8EE13-9E68-DA15-21EC-7EFEBDF2E19B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||
| {A2C8EE13-9E68-DA15-21EC-7EFEBDF2E19B}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||
| {A2C8EE13-9E68-DA15-21EC-7EFEBDF2E19B}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||
| {A2C8EE13-9E68-DA15-21EC-7EFEBDF2E19B}.Release|Any CPU.Build.0 = Release|Any CPU | ||
| EndGlobalSection | ||
| GlobalSection(SolutionProperties) = preSolution | ||
| HideSolutionNode = FALSE | ||
| EndGlobalSection | ||
| GlobalSection(ExtensibilityGlobals) = postSolution | ||
| SolutionGuid = {B38FA8F3-EFAE-4108-BE23-9CD043AEA775} | ||
| EndGlobalSection | ||
| EndGlobal |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
91 changes: 91 additions & 0 deletions
91
samples/dotnet/GenesysHandoff/Services/ActivityResponseProcessor.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| using Microsoft.Agents.Core.Models; | ||
| using Microsoft.Agents.Core.Serialization; | ||
| using Microsoft.Extensions.Logging; | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Linq; | ||
|
|
||
| namespace GenesysHandoff.Services | ||
| { | ||
| /// <summary> | ||
| /// Processes incoming activities from Copilot Studio and prepares them for sending to users. | ||
| /// </summary> | ||
| public class ActivityResponseProcessor | ||
| { | ||
| private readonly ILogger<ActivityResponseProcessor> _logger; | ||
|
|
||
| public ActivityResponseProcessor(ILogger<ActivityResponseProcessor> logger) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(logger); | ||
| _logger = logger; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Creates a response activity from the incoming Copilot Studio activity by processing entities and preparing it for sending to the user. | ||
| /// </summary> | ||
| /// <param name="incomingActivity">The activity received from the Copilot Studio client.</param> | ||
| /// <param name="logContext">Optional context string to include in the log message for tracking purposes.</param> | ||
| /// <returns>A processed activity ready to be sent to the user with fixed citation entities.</returns> | ||
| public IActivity CreateResponseActivity(IActivity incomingActivity, string logContext = "") | ||
| { | ||
| ArgumentNullException.ThrowIfNull(incomingActivity); | ||
|
|
||
| _logger.LogInformation("Activity received from Copilot client{LogContext}", | ||
| string.IsNullOrEmpty(logContext) ? "" : $" ({logContext})"); | ||
|
|
||
| var responseActivity = MessageFactory.CreateMessageActivity(incomingActivity.Text); | ||
| responseActivity.Text = CitationUrlCleaner.RemoveCitationUrlsFromTail(responseActivity.Text, incomingActivity.Entities); | ||
| responseActivity.TextFormat = incomingActivity.TextFormat; | ||
| responseActivity.InputHint = incomingActivity.InputHint; | ||
| responseActivity.Attachments = incomingActivity.Attachments; | ||
| responseActivity.SuggestedActions = incomingActivity.SuggestedActions; | ||
|
|
||
| // Note: MembersAdded, MembersRemoved, ReactionsAdded, and ReactionsRemoved are NOT copied | ||
| // These properties are context-specific to the original message and should not be transferred | ||
| // to a new response activity | ||
|
|
||
| // Copy channel data but remove streamType and streamId if present | ||
| if (incomingActivity.ChannelData != null) | ||
| { | ||
| try | ||
| { | ||
| var originalChannelData = ProtocolJsonSerializer.ToObject<Dictionary<string, object>>( | ||
| ProtocolJsonSerializer.ToJson(incomingActivity.ChannelData)); | ||
|
|
||
| if (originalChannelData != null) | ||
| { | ||
| // Create a new mutable dictionary, excluding streamType and streamId | ||
| var channelData = new Dictionary<string, object>(originalChannelData.Count); | ||
| foreach (var kvp in originalChannelData) | ||
| { | ||
| if (kvp.Key != "streamType" && kvp.Key != "streamId") | ||
| { | ||
| channelData[kvp.Key] = kvp.Value; | ||
| } | ||
| } | ||
|
|
||
| if (channelData.Count > 0) | ||
| { | ||
| responseActivity.ChannelData = channelData; | ||
| } | ||
| } | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogWarning(ex, "Failed to process channel data. Channel data will be omitted from response."); | ||
| } | ||
| } | ||
|
|
||
| // Fix entities to remove streaminfo and fix citation appearance issues | ||
| if (incomingActivity.Entities != null && incomingActivity.Entities.Any()) | ||
| { | ||
| responseActivity.Entities = CitationEntityProcessor.FixCitationEntities(incomingActivity.Entities); | ||
| } | ||
|
|
||
| _logger.LogInformation("Activity being sent to user{LogContext}", | ||
| string.IsNullOrEmpty(logContext) ? "" : $" ({logContext})"); | ||
|
|
||
| return responseActivity; | ||
| } | ||
| } | ||
| } | ||
92 changes: 92 additions & 0 deletions
92
samples/dotnet/GenesysHandoff/Services/CitationEntityProcessor.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| using Microsoft.Agents.Core.Models; | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Linq; | ||
|
|
||
| namespace GenesysHandoff.Services | ||
| { | ||
| /// <summary> | ||
| /// Processes and fixes citation entities for proper rendering in Teams and other clients. | ||
| /// </summary> | ||
| public static class CitationEntityProcessor | ||
| { | ||
| /// <summary> | ||
| /// Filters entities to exclude streaminfo types and fixes invalid citation appearances. | ||
| /// Teams requires proper citation structure for interactive citation rendering. | ||
| /// </summary> | ||
| /// <param name="entities">The original entities from Copilot Studio.</param> | ||
| /// <returns>A filtered list of entities with valid citation formatting.</returns> | ||
| public static IList<Entity> FixCitationEntities(IList<Entity> entities) | ||
| { | ||
|
siddharthh98 marked this conversation as resolved.
|
||
| ArgumentNullException.ThrowIfNull(entities); | ||
|
|
||
| var filteredEntities = new List<Entity>(); | ||
| foreach (var entity in entities) | ||
| { | ||
| // Exclude streaminfo entities | ||
| if (entity.Type != null && entity.Type.Equals("streaminfo", StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| // Process AIEntity citations to ensure proper Teams rendering | ||
| if (entity is AIEntity aiEntity) | ||
| { | ||
| if (aiEntity.Citation != null && aiEntity.Citation.Count > 0) | ||
| { | ||
| var annotation = new AIEntity(); | ||
| foreach (var clientCitation in aiEntity.Citation) | ||
| { | ||
| if (clientCitation.Appearance == null) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| var clientCitationIconName = GetIconNameOrDefault(clientCitation.Appearance.Image?.Name); | ||
|
|
||
| annotation.Citation.Add(new ClientCitation( | ||
| clientCitation.Position, | ||
| clientCitation.Appearance.Name, | ||
| clientCitation.Appearance.Abstract, | ||
| clientCitation.Appearance.Text ?? string.Empty, | ||
| null, | ||
| clientCitation.Appearance.Url, | ||
| clientCitationIconName | ||
| )); | ||
| } | ||
| filteredEntities.Add(annotation); | ||
| } | ||
| else | ||
| { | ||
| filteredEntities.Add(entity); | ||
| } | ||
| } | ||
|
siddharthh98 marked this conversation as resolved.
|
||
| else | ||
| { | ||
| // Preserve all other entity types (mentions, reactions, etc.) | ||
| filteredEntities.Add(entity); | ||
| } | ||
| } | ||
| return filteredEntities; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets the icon name from the appearance image, defaulting to Image if the value is unknown or invalid. | ||
| /// </summary> | ||
| private static ClientCitationsIconNameEnum GetIconNameOrDefault(ClientCitationsIconNameEnum? iconName) | ||
| { | ||
| if (iconName == null) | ||
| { | ||
| return ClientCitationsIconNameEnum.Image; | ||
| } | ||
|
|
||
| // Check if the enum value is defined, otherwise use default | ||
| if (!Enum.IsDefined(typeof(ClientCitationsIconNameEnum), iconName.Value)) | ||
| { | ||
| return ClientCitationsIconNameEnum.Image; | ||
| } | ||
|
|
||
| return iconName.Value; | ||
| } | ||
| } | ||
| } | ||
98 changes: 98 additions & 0 deletions
98
samples/dotnet/GenesysHandoff/Services/CitationUrlCleaner.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| using Microsoft.Agents.Core.Models; | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Linq; | ||
| using System.Text.RegularExpressions; | ||
|
|
||
| namespace GenesysHandoff.Services | ||
| { | ||
| /// <summary> | ||
| /// Utility class for removing citation URLs and references from text content. | ||
| /// </summary> | ||
| public static class CitationUrlCleaner | ||
| { | ||
| private static readonly string[] CitationHeaders = ["sources:", "references:", "citations:"]; | ||
| private static readonly Regex CitationLinePattern = new(@"^\s*(\[\d+\]:|\d+[\.\)]|-\s*)\s*https?://", RegexOptions.IgnoreCase | RegexOptions.Compiled); | ||
|
|
||
| /// <summary> | ||
| /// Removes citation URLs and reference lines from the end of the text. | ||
| /// </summary> | ||
| public static string RemoveCitationUrlsFromTail(string text, IList<Entity>? entities) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(text)) | ||
| return text; | ||
|
|
||
| var citationUrls = ExtractCitationUrls(entities); | ||
| if (citationUrls.Count == 0) | ||
| return text; | ||
|
|
||
| // Split on newlines handling both \r\n and \n efficiently | ||
| // Split on \n first, then trim any remaining \r from each line | ||
| var lines = text.Split('\n') | ||
| .Select(line => line.TrimEnd('\r')) | ||
| .ToList(); | ||
|
|
||
| TrimTrailingBlankLines(lines); | ||
|
|
||
| int originalCount = lines.Count; | ||
| RemoveCitationLinesFromTail(lines, citationUrls); | ||
| TrimTrailingBlankLines(lines); | ||
|
|
||
| return lines.Count < originalCount ? string.Join("\n", lines) : text; | ||
| } | ||
|
|
||
| private static HashSet<string> ExtractCitationUrls(IList<Entity>? entities) | ||
| { | ||
| if (entities == null || entities.Count == 0) | ||
| return []; | ||
|
|
||
| return entities | ||
| .OfType<AIEntity>() | ||
| .Where(e => e.Citation != null) | ||
| .SelectMany(e => e.Citation) | ||
| .Select(c => c.Appearance?.Url) | ||
| .Where(u => !string.IsNullOrWhiteSpace(u)) | ||
| .OfType<string>() // Explicitly filter to non-null strings for type safety | ||
| .ToHashSet(StringComparer.OrdinalIgnoreCase); | ||
| } | ||
|
|
||
| private static void RemoveCitationLinesFromTail(List<string> lines, HashSet<string> citationUrls) | ||
| { | ||
| while (lines.Count > 0) | ||
| { | ||
| var lastLine = lines[^1].Trim(); | ||
|
|
||
| if (IsCitationLine(lastLine, citationUrls)) | ||
| { | ||
| lines.RemoveAt(lines.Count - 1); | ||
| } | ||
| else | ||
| { | ||
| break; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private static bool IsCitationLine(string line, HashSet<string> citationUrls) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(line)) | ||
| return true; | ||
|
|
||
| if (CitationHeaders.Any(h => line.Equals(h, StringComparison.OrdinalIgnoreCase))) | ||
| return true; | ||
|
|
||
| if (citationUrls.Any(url => line.Contains(url, StringComparison.OrdinalIgnoreCase))) | ||
| return true; | ||
|
|
||
| return CitationLinePattern.IsMatch(line); | ||
| } | ||
|
|
||
| private static void TrimTrailingBlankLines(List<string> lines) | ||
| { | ||
| while (lines.Count > 0 && string.IsNullOrWhiteSpace(lines[^1])) | ||
| { | ||
| lines.RemoveAt(lines.Count - 1); | ||
| } | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.