Skip to content

Commit

Permalink
GitHub OpenAPI skill integration with planner (#792)
Browse files Browse the repository at this point in the history
### Motivation and Context
This PR enables GitHub integration with Copilot Planner. 


### Description
Server-side
- Added C# classes for GitHub schema objects
- Adds tokenLimit for Planner response content. Currently, it's set at
75% of the remaining limit after memories response and document context
have already been allocated.
- Added `OptimizeOpenApiSkillJson` method that is called on Planner
completion. This method:
- removes all new line characters (huge token suck) from API response
- truncates the API response by object until it's under the
relatedInformationTokenLimit.
- If the request can't be truncated, error message added to bot context
- For GitHub skills specifically, the API response is deserialized into
`PullRequest` or `PullRequest[]` objects to filter noisy properties.

Web app side
- If plugins require additional api requirements, these are added to the
ask variables (rather than headers in http request) -- this allows the
kernel/planner to directly consume these without any additional handling
server side
- `repo` and `owner` added as additional GitHub api requirements
- Rename ApiRequirements to ApiProperties. These properties can be
optional.


![image](https://user-images.githubusercontent.com/125500434/236005717-14016432-b5de-47b3-a128-e38418c267e3.png)
  • Loading branch information
teresaqhoang committed May 4, 2023
1 parent bc399e4 commit 9917ca3
Show file tree
Hide file tree
Showing 13 changed files with 442 additions and 89 deletions.
92 changes: 87 additions & 5 deletions samples/apps/copilot-chat-app/webapi/Skills/ChatSkill.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.AI.TextCompletion;
using Microsoft.SemanticKernel.Memory;
Expand All @@ -11,6 +12,7 @@
using Microsoft.SemanticKernel.SkillDefinition;
using SemanticKernel.Service.Config;
using SemanticKernel.Service.Model;
using SemanticKernel.Service.Skills.OpenApiSkills;
using SemanticKernel.Service.Storage;

namespace SemanticKernel.Service.Skills;
Expand Down Expand Up @@ -212,27 +214,30 @@ public async Task<string> AcquireExternalInformationAsync(SKContext context)
if (plan.Steps.Count > 0)
{
SKContext planContext = await plan.InvokeAsync(plannerContext);
int tokenLimit = int.Parse(context["tokenLimit"], new NumberFormatInfo());

// The result of the plan may be from an OpenAPI skill. Attempt to extract JSON from the response.
if (!this.TryExtractJsonFromOpenApiPlanResult(planContext.Variables.Input, out string planResult))
{
// If not, use result of the plan execution result directly.
planResult = planContext.Variables.Input;
}
else
{
int relatedInformationTokenLimit = (int)Math.Floor(tokenLimit * this._promptSettings.RelatedInformationContextWeight);
planResult = this.OptimizeOpenApiSkillJson(planResult, relatedInformationTokenLimit, plan);
}

string informationText = $"[START RELATED INFORMATION]\n{planResult.Trim()}\n[END RELATED INFORMATION]\n";

// Adjust the token limit using the number of tokens in the information text.
int tokenLimit = int.Parse(context["tokenLimit"], new NumberFormatInfo());
tokenLimit -= Utilities.TokenCount(informationText);
context.Variables.Set("tokenLimit", tokenLimit.ToString(new NumberFormatInfo()));

return informationText;
}
else
{
return string.Empty;
}

return string.Empty;
}

/// <summary>
Expand Down Expand Up @@ -404,6 +409,83 @@ private bool TryExtractJsonFromOpenApiPlanResult(string openApiSkillResponse, ou
return false;
}

/// <summary>
/// Try to optimize json from the planner response
/// based on token limit
/// </summary>
private string OptimizeOpenApiSkillJson(string jsonContent, int tokenLimit, Plan plan)
{
int jsonTokenLimit = (int)(tokenLimit * this._promptSettings.RelatedInformationContextWeight);

// Remove all new line characters + leading and trailing white space
jsonContent = Regex.Replace(jsonContent.Trim(), @"[\n\r]", string.Empty);
var document = JsonDocument.Parse(jsonContent);
string lastSkillInvoked = plan.Steps[^1].SkillName;

// Check if the last skill invoked was GitHubSkill and deserialize the JSON content accordingly
if (string.Equals(lastSkillInvoked, "GitHubSkill", StringComparison.Ordinal))
{
var pullRequestType = document.RootElement.ValueKind == JsonValueKind.Array ? typeof(PullRequest[]) : typeof(PullRequest);

// Deserializing limits the json content to only the fields defined in the GitHubSkill/Model classes
var pullRequest = JsonSerializer.Deserialize(jsonContent, pullRequestType);
jsonContent = pullRequest != null ? JsonSerializer.Serialize(pullRequest) : string.Empty;
document = JsonDocument.Parse(jsonContent);
}

int jsonContentTokenCount = Utilities.TokenCount(jsonContent);

// Return the JSON content if it does not exceed the token limit
if (jsonContentTokenCount < jsonTokenLimit)
{
return jsonContent;
}

List<object> itemList = new();

// Summary (List) Object
if (document.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in document.RootElement.EnumerateArray())
{
int itemTokenCount = Utilities.TokenCount(item.ToString());

if (jsonTokenLimit - itemTokenCount > 0)
{
itemList.Add(item);
jsonTokenLimit -= itemTokenCount;
}
else
{
break;
}
}
}

// Detail Object
if (document.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty property in document.RootElement.EnumerateObject())
{
int propertyTokenCount = Utilities.TokenCount(property.ToString());

if (jsonTokenLimit - propertyTokenCount > 0)
{
itemList.Add(property);
jsonTokenLimit -= propertyTokenCount;
}
else
{
break;
}
}
}

return itemList.Count > 0
? JsonSerializer.Serialize(itemList)
: String.Format(CultureInfo.InvariantCulture, "JSON response for {0} is too large to be consumed at this time.", lastSkillInvoked);
}

/// <summary>
/// Save a new message to the chat history.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Text.Json.Serialization;

namespace SemanticKernel.Service.Skills.OpenApiSkills;

/// <summary>
/// Represents a pull request label.
/// </summary>
public class Label
{
/// <summary>
/// Gets or sets the ID of the label.
/// </summary>
[JsonPropertyName("id")]
public long Id { get; set; }

/// <summary>
/// Gets or sets the name of the label.
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; }

/// <summary>
/// Gets or sets the description of the label.
/// </summary>
[JsonPropertyName("description")]
public string Description { get; set; }

/// <summary>
/// Initializes a new instance of the <see cref="Label"/> class.
/// </summary>
/// <param name="id">The ID of the label.</param>
/// <param name="name">The name of the label.</param>
/// <param name="description">The description of the label.</param>
public Label(long id, string name, string description)
{
this.Id = id;
this.Name = name;
this.Description = description;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using System.Text.Json.Serialization;

namespace SemanticKernel.Service.Skills.OpenApiSkills;

/// <summary>
/// Represents a GitHub Pull Request.
/// </summary>
public class PullRequest
{
/// <summary>
/// Gets or sets the URL of the pull request
/// </summary>
[JsonPropertyName("url")]
public System.Uri Url { get; set; }

/// <summary>
/// Gets or sets the unique identifier of the pull request
/// </summary>
[JsonPropertyName("id")]
public int Id { get; set; }

/// <summary>
/// Gets or sets the number of the pull request
/// </summary>
[JsonPropertyName("number")]
public int Number { get; set; }

/// <summary>
/// Gets or sets the state of the pull request
/// </summary>
[JsonPropertyName("state")]
public string State { get; set; }

/// <summary>
/// Whether the pull request is locked
/// </summary>
[JsonPropertyName("locked")]
public bool Locked { get; set; }

/// <summary>
/// Gets or sets the title of the pull request
/// </summary>
[JsonPropertyName("title")]
public string Title { get; set; }

/// <summary>
/// Gets or sets the user who created the pull request
/// </summary>
[JsonPropertyName("user")]
public GitHubUser User { get; set; }

/// <summary>
/// Gets or sets the labels associated with the pull request
/// </summary>
[JsonPropertyName("labels")]
public List<Label> Labels { get; set; }

/// <summary>
/// Gets or sets the date and time when the pull request was last updated
/// </summary>
[JsonPropertyName("updated_at")]
public DateTime UpdatedAt { get; set; }

/// <summary>
/// Gets or sets the date and time when the pull request was closed
/// </summary>
[JsonPropertyName("closed_at")]
public DateTime? ClosedAt { get; set; }

/// <summary>
/// Gets or sets the date and time when the pull request was merged
/// </summary>
[JsonPropertyName("merged_at")]
public DateTime? MergedAt { get; set; }

/// <summary>
/// Initializes a new instance of the <see cref="PullRequest"/> class, representing a pull request on GitHub.
/// </summary>
/// <param name="url">The URL of the pull request.</param>
/// <param name="id">The unique identifier of the pull request.</param>
/// <param name="number">The number of the pull request within the repository.</param>
/// <param name="state">The state of the pull request, such as "open", "closed", or "merged".</param>
/// <param name="locked">A value indicating whether the pull request is locked for comments or changes.</param>
/// <param name="title">The title of the pull request.</param>
/// <param name="user">The user who created the pull request.</param>
/// <param name="labels">A list of labels assigned to the pull request.</param>
/// <param name="updatedAt">The date and time when the pull request was last updated.</param>
/// <param name="closedAt">The date and time when the pull request was closed, or null if it is not closed.</param>
/// <param name="mergedAt">The date and time when the pull request was merged, or null if it is not merged.</param>
public PullRequest(
System.Uri url,
int id,
int number,
string state,
bool locked,
string title,
GitHubUser user,
List<Label> labels,
DateTime updatedAt,
DateTime? closedAt,
DateTime? mergedAt
)
{
this.Url = url;
this.Id = id;
this.Number = number;
this.State = state;
this.Locked = locked;
this.Title = title;
this.User = user;
this.Labels = labels;
this.UpdatedAt = updatedAt;
this.ClosedAt = closedAt;
this.MergedAt = mergedAt;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;

namespace SemanticKernel.Service.Skills.OpenApiSkills;

/// <summary>
/// Represents a GitHub Repo.
/// </summary>
public class Repo
{
/// <summary>
/// Gets or sets the name of the repo
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; }

/// <summary>
/// Gets or sets the full name of the repo
/// </summary>
[JsonPropertyName("full_name")]
public string FullName { get; set; }


/// <summary>
/// Initializes a new instance of the <see cref="Repo"/>.
/// </summary>
/// <param name="name">The name of the repository, e.g. "dotnet/runtime".</param>
/// <param name="fullName">The full name of the repository, e.g. "Microsoft/dotnet/runtime".</param>
public Repo(string name, string fullName)
{
this.Name = name;
this.FullName = fullName;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Text.Json.Serialization;

namespace SemanticKernel.Service.Skills.OpenApiSkills;

/// <summary>
/// Represents a user on GitHub.
/// </summary>
public class GitHubUser
{
/// <summary>
/// The user's name.
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; }

/// <summary>
/// The user's email address.
/// </summary>
[JsonPropertyName("email")]
public string Email { get; set; }

/// <summary>
/// The user's numeric ID.
/// </summary>
[JsonPropertyName("id")]
public int Id { get; set; }

/// <summary>
/// The user's type, e.g. User or Organization.
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; }

/// <summary>
/// Whether the user is a site admin.
/// </summary>
[JsonPropertyName("site_admin")]
public bool SiteAdmin { get; set; }

/// <summary>
/// Creates a new instance of the User class.
/// </summary>
/// <param name="name">The user's name.</param>
/// <param name="email">The user's email address.</param>
/// <param name="id">The user's numeric ID.</param>
/// <param name="type">The user's type.</param>
/// <param name="siteAdmin">Whether the user is a site admin.</param>
public GitHubUser(string name, string email, int id, string type, bool siteAdmin)
{
this.Name = name;
this.Email = email;
this.Id = id;
this.Type = type;
this.SiteAdmin = siteAdmin;
}
}
7 changes: 7 additions & 0 deletions samples/apps/copilot-chat-app/webapi/Skills/PromptSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ public PromptSettings(PromptsConfig promptsConfig)
/// </summary>
internal double DocumentContextWeight { get; } = 0.3;

/// <summary>
/// Weight of information returned from planner (i.e., responses from OpenAPI skills).
/// Percentage calculated from remaining token limit after memories response and document context have already been allocated.
/// Contextual prompt excludes all the system commands.
/// </summary>
internal double RelatedInformationContextWeight { get; } = 0.75;

/// <summary>
/// Maximum number of tokens per line that will be used to split a document into lines.
/// Setting this to a low value will result in higher context granularity, but
Expand Down

0 comments on commit 9917ca3

Please sign in to comment.