diff --git a/OpenAI-Extension.sln b/OpenAI-Extension.sln index 5fcccae..a171313 100644 --- a/OpenAI-Extension.sln +++ b/OpenAI-Extension.sln @@ -45,7 +45,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ooproc", "ooproc", "{E6B8C0 EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Functions.Worker.Extensions.OpenAI", "src\Functions.Worker.Extensions.OpenAI\Functions.Worker.Extensions.OpenAI.csproj", "{EBFED369-EBBB-49F8-B2F2-236AF3063271}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpIsolatedSamples", "samples\other\dotnet\csharp-ooproc\CSharpIsolatedSamples\CSharpIsolatedSamples.csproj", "{537FD9B3-1288-461B-BEFD-8DF323A50BF1}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CSharpIsolatedSamples", "samples\other\dotnet\csharp-ooproc\CSharpIsolatedSamples\CSharpIsolatedSamples.csproj", "{537FD9B3-1288-461B-BEFD-8DF323A50BF1}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/CSharpIsolatedSamples.csproj b/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/CSharpIsolatedSamples.csproj index 86cf16b..7868a4d 100644 --- a/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/CSharpIsolatedSamples.csproj +++ b/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/CSharpIsolatedSamples.csproj @@ -1,4 +1,4 @@ - + net6.0 v4 @@ -7,11 +7,9 @@ enable - - - - + + diff --git a/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/ChatBots.cs b/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/ChatBots.cs new file mode 100644 index 0000000..c47221a --- /dev/null +++ b/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/ChatBots.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Functions.Worker.Extensions.OpenAI.Chat; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using FromBodyAttribute = Microsoft.Azure.Functions.Worker.Http.FromBodyAttribute; + +namespace CSharpIsolatedSamples; + +// IMPORTANT: This sample unfortunately doesn't work. The chat bot will always enter a "Failed" state after creation. +// Tracking issue: https://github.com/cgillum/azure-functions-openai-extension/issues/21 + +public class ChatBots +{ + public record CreateRequest(string Instructions); + + [Function(nameof(CreateChatBot))] + public ChatBotCreateResult CreateChatBot( + [HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "chats/{chatId}")] HttpRequest httpReq, + [FromBody] CreateRequest createReq, + string chatId) + { + var responseJson = new { chatId }; + return new ChatBotCreateResult( + new ChatBotCreateRequest(chatId, createReq.Instructions), + new ObjectResult(responseJson) { StatusCode = 202 }); + } + + public class ChatBotCreateResult + { + public ChatBotCreateResult(ChatBotCreateRequest createChatBotRequest, IActionResult httpResponse) + { + this.CreateRequest = createChatBotRequest; + this.HttpResponse = httpResponse; + } + + [ChatBotCreateOutput] + public ChatBotCreateRequest CreateRequest { get; set; } + public IActionResult HttpResponse { get; set; } + } + + [Function(nameof(GetChatState))] + public ChatBotState GetChatState( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "chats/{chatId}")] HttpRequest req, + string chatId, + [ChatBotQueryInput("{chatId}", TimestampUtc = "{Query.timestampUTC}")] ChatBotState state) + { + return state; + } + + [Function(nameof(PostUserResponse))] + public async Task PostUserResponse( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "chats/{chatId}")] HttpRequest req, + string chatId) + { + // Get the message from the raw request body + using StreamReader reader = new(req.Body); + string userMessage = await reader.ReadToEndAsync(); + + if (string.IsNullOrEmpty(userMessage)) + { + return new ChatBotPostResult(null, new BadRequestObjectResult(new { message = "Request body is empty" })); + } + + return new ChatBotPostResult( + new ChatBotPostRequest(userMessage), + new AcceptedResult()); + } + + public class ChatBotPostResult + { + public ChatBotPostResult(ChatBotPostRequest? postRequest, IActionResult httpResponse) + { + this.PostRequest = postRequest; + this.HttpResponse = httpResponse; + } + + [ChatBotPostOutput("{chatId}")] + public ChatBotPostRequest? PostRequest { get; set; } + public IActionResult HttpResponse { get; set; } + } +} diff --git a/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/DocumentSearch.cs b/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/DocumentSearch.cs new file mode 100644 index 0000000..822ad2e --- /dev/null +++ b/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/DocumentSearch.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Functions.Worker.Extensions.OpenAI; +using Functions.Worker.Extensions.OpenAI.Embeddings; +using Functions.Worker.Extensions.OpenAI.Search; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using FromBodyAttribute = Microsoft.Azure.Functions.Worker.Http.FromBodyAttribute; + +namespace CSharpIsolatedSamples; + +class DocumentSearch +{ + public record EmbeddingsRequest(string FilePath); + public record SemanticSearchRequest(string Prompt); + + // REVIEW: There are several assumptions about how the Embeddings binding and the SemanticSearch bindings + // work together. We should consider creating a higher-level of abstraction for this. + [Function(nameof(Ingest))] + public IngestResult Ingest( + [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req, + [FromBody] EmbeddingsRequest input, + [EmbeddingsInput("{FilePath}", InputType.FilePath, Model = "text-embedding-ada-002-private")] EmbeddingsContext embeddings) + { + string title = Path.GetFileNameWithoutExtension(input.FilePath); + return new IngestResult( + new SearchableDocument(title, embeddings), + new OkObjectResult(new { status = "success", title, chunks = embeddings.Count })); + } + + public class IngestResult + { + public IngestResult(SearchableDocument? doc, IActionResult httpResponse) + { + this.Document = doc; + this.HttpResponse = httpResponse; + } + + [SemanticSearchOutput("KustoConnectionString", "Documents")] + public SearchableDocument? Document { get; set; } + public IActionResult HttpResponse { get; set; } + } + + [Function(nameof(Prompt))] + public static IActionResult Prompt( + [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req, + [FromBody] SemanticSearchRequest unused, + [SemanticSearchInput("KustoConnectionString", "Documents", Query = "{Prompt}", EmbeddingsModel = "text-embedding-ada-002-private", ChatModel = "gpt-35-turbo")] SemanticSearchContext result) + { + return new ContentResult { Content = result.Response, ContentType = "text/plain" }; + } +} diff --git a/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/Program.cs b/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/Program.cs index 8368996..fa84ef6 100644 --- a/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/Program.cs +++ b/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/Program.cs @@ -1,12 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; using Microsoft.Extensions.Hosting; var host = new HostBuilder() - .ConfigureFunctionsWorkerDefaults() + .ConfigureFunctionsWebApplication() .Build(); host.Run(); diff --git a/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/Properties/launchSettings.json b/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/Properties/launchSettings.json new file mode 100644 index 0000000..db9c7bd --- /dev/null +++ b/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "CSharpIsolatedSamples": { + "commandName": "Project", + "commandLineArgs": "--port 7179", + "launchBrowser": false + } + } +} \ No newline at end of file diff --git a/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/TextCompletions.cs b/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/TextCompletions.cs index b63cb78..1cf8951 100644 --- a/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/TextCompletions.cs +++ b/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/TextCompletions.cs @@ -1,9 +1,10 @@ using Functions.Worker.Extensions.OpenAI; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; using OpenAI.ObjectModels.ResponseModels; +using FromBodyAttribute = Microsoft.Azure.Functions.Worker.Http.FromBodyAttribute; namespace CSharpIsolatedSamples; @@ -11,18 +12,29 @@ namespace CSharpIsolatedSamples; /// These samples show how to use the OpenAI Completions APIs. For more details on the Completions APIs, see /// https://platform.openai.com/docs/guides/completion. /// -public static class TextCompletions +public class TextCompletions { + readonly ILogger logger; + + public TextCompletions(ILogger logger) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + /// /// This sample demonstrates the "templating" pattern, where the function takes a parameter /// and embeds it into a text prompt, which is then sent to the OpenAI completions API. /// [Function(nameof(WhoIs))] - public static string WhoIs( - [HttpTrigger(AuthorizationLevel.Function, Route = "whois/{name}")] HttpRequestData req, + public IActionResult WhoIs( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "whois/{name}")] HttpRequest req, [TextCompletionInput("Who is {name}?")] CompletionCreateResponse response) { - return response.Choices[0].Text; + return new ContentResult + { + Content = response.Choices[0].Text.Trim(), + ContentType = "text/plain; charset=utf-8", + }; } /// @@ -30,10 +42,10 @@ public static string WhoIs( /// response as the output. /// [Function(nameof(GenericCompletion))] - public static IActionResult GenericCompletion( - [HttpTrigger(AuthorizationLevel.Function, "post")] PromptPayload payload, - [TextCompletionInput("{Prompt}", Model = "text-davinci-003")] CompletionCreateResponse response, - ILogger log) + public IActionResult GenericCompletion( + [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest request, + [FromBody] PromptPayload payload, + [TextCompletionInput("{Prompt}", Model = "text-davinci-003")] CompletionCreateResponse response) { if (!response.Successful) { @@ -41,8 +53,8 @@ public static IActionResult GenericCompletion( return new ObjectResult(error) { StatusCode = 500 }; } - log.LogInformation("Prompt = {prompt}, Response = {response}", payload.Prompt, response); - string text = response.Choices[0].Text; + this.logger.LogInformation("Prompt = {prompt}, Response = {response}", payload.Prompt, response); + string text = response.Choices[0].Text.Trim(); return new OkObjectResult(text); } diff --git a/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/TextEmbeddings.cs b/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/TextEmbeddings.cs new file mode 100644 index 0000000..2df243e --- /dev/null +++ b/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/TextEmbeddings.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Functions.Worker.Extensions.OpenAI; +using Functions.Worker.Extensions.OpenAI.Embeddings; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; +using FromBodyAttribute = Microsoft.Azure.Functions.Worker.Http.FromBodyAttribute; + +namespace CSharpIsolatedSamples; + +/// +/// Examples of working with OpenAI embeddings. +/// +public class TextEmbeddings +{ + readonly ILogger logger; + + public TextEmbeddings(ILogger logger) + { + this.logger = logger; + } + + public record EmbeddingsRequest(string RawText, string FilePath); + + /// + /// Example showing how to use the input binding to generate embeddings + /// for a raw text string. + /// + [Function(nameof(GenerateEmbeddings_Http_Request))] + public IActionResult GenerateEmbeddings_Http_Request( + [HttpTrigger(AuthorizationLevel.Function, "post", Route = "embeddings")] HttpRequest req, + [FromBody] EmbeddingsRequest input, + [EmbeddingsInput("{RawText}", InputType.RawText)] EmbeddingsContext embeddings) + { + this.logger.LogInformation( + "Received {count} embedding(s) for input text containing {length} characters.", + embeddings.Count, + input.RawText.Length); + + // TODO: Store the embeddings into a database or other storage. + + return new OkObjectResult($"Generated {embeddings.Count} chunk(s) from source text"); + } + + /// + /// Example showing how to use the input binding to generate embeddings + /// for text contained in a file on the file system. + /// + [Function(nameof(GetEmbeddings_Http_FilePath))] + public IActionResult GetEmbeddings_Http_FilePath( + [HttpTrigger(AuthorizationLevel.Function, "post", Route = "embeddings-from-file")] HttpRequest req, + [FromBody] EmbeddingsRequest input, + [EmbeddingsInput("{FilePath}", InputType.FilePath, MaxChunkLength = 512)] EmbeddingsContext embeddings) + { + this.logger.LogInformation( + "Received {count} embedding(s) for input file '{path}'.", + embeddings.Response.Data.Count, + input.FilePath); + + // TODO: Store the embeddings into a database or other storage. + + return new OkObjectResult($"Generated {embeddings.Count} chunk(s) from source file"); + } +} diff --git a/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/examples.http b/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/examples.http new file mode 100644 index 0000000..93873fa --- /dev/null +++ b/samples/other/dotnet/csharp-ooproc/CSharpIsolatedSamples/examples.http @@ -0,0 +1,56 @@ +# For more info on HTTP files go to https://aka.ms/vs/httpfile + +### Demo 1.1: WhoIs API + +POST http://localhost:7179/api/whois/pikachu + + +### Demo 1.2: Generic Text Completion API + +POST http://localhost:7179/api/GenericCompletion +Content-Type: application/json + +{"Prompt": "Once upon a time"} + + +### Demo 2.1: Embeddings from HTTP request +POST http://localhost:7179/api/embeddings +Content-Type: application/json + +{"RawText": "Hello world"} + + +### Demo 2.2: Embeddings from file (NOTE: replace path/to/file.txt with a real file path) +POST http://localhost:7179/api/embeddings-from-file +Content-Type: application/json + +{"FilePath": "path/to/file.txt"} + + +## Demo 3: Chat bots (NOTE: Currently broken: https://github.com/cgillum/azure-functions-openai-extension/issues/21) + +### Create a chat bot +PUT http://localhost:7179/api/chats/test123 +Content-Type: application/json + +{ + "instructions": "You are a helpful chatbot. In all your English responses, speak as if you are Shakespeare." +} + + +### Send the first message to the chatbot +POST http://localhost:7179/api/chats/test123 +Content-Type: text/plain + +Who won SuperBowl XLVIII in 2014? + + +### Get the chatbot's response +GET http://localhost:7179/api/chats/test123?timestampUTC=2023-08-10T07:51:10Z + + +### Send the second message to the chatbot +POST http://localhost:7179/api/chats/test123 +Content-Type: text/plain + +Amazing! Do you know who performed the halftime show? diff --git a/src/Functions.Worker.Extensions.OpenAI/Chat/ChatBotCreateOutputAttribute.cs b/src/Functions.Worker.Extensions.OpenAI/Chat/ChatBotCreateOutputAttribute.cs new file mode 100644 index 0000000..6a72a70 --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/Chat/ChatBotCreateOutputAttribute.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; + +namespace Functions.Worker.Extensions.OpenAI.Chat; + +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] +public class ChatBotCreateOutputAttribute : OutputBindingAttribute +{ + // No configuration needed +} + +public class ChatBotCreateRequest +{ + public ChatBotCreateRequest() + { + // For deserialization + this.Id = string.Empty; + } + + public ChatBotCreateRequest(string id) + { + this.Id = id; + } + + public ChatBotCreateRequest(string id, string? instructions) + { + this.Id = id; + + if (!string.IsNullOrWhiteSpace(instructions)) + { + this.Instructions = instructions; + } + } + + public string Id { get; set; } + public string Instructions { get; set; } = "You are a helpful chat bot."; + public DateTime? ExpiresAt { get; set; } +} \ No newline at end of file diff --git a/src/Functions.Worker.Extensions.OpenAI/Chat/ChatBotPostOutputAttribute.cs b/src/Functions.Worker.Extensions.OpenAI/Chat/ChatBotPostOutputAttribute.cs new file mode 100644 index 0000000..773a209 --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/Chat/ChatBotPostOutputAttribute.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; + +namespace Functions.Worker.Extensions.OpenAI.Chat; + +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] +public class ChatBotPostOutputAttribute : OutputBindingAttribute +{ + public ChatBotPostOutputAttribute(string id) + { + this.Id = id; + } + + /// + /// Gets the ID of the chat bot to update. + /// + public string Id { get; } +} + +public record ChatBotPostRequest(string UserMessage) +{ + public string Id { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/Functions.Worker.Extensions.OpenAI/Chat/ChatBotStateInputAttribute.cs b/src/Functions.Worker.Extensions.OpenAI/Chat/ChatBotStateInputAttribute.cs new file mode 100644 index 0000000..ff318fe --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/Chat/ChatBotStateInputAttribute.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; +using OpenAI.ObjectModels.RequestModels; + +namespace Functions.Worker.Extensions.OpenAI.Chat; + +[AttributeUsage(AttributeTargets.Parameter)] +public class ChatBotQueryInputAttribute : InputBindingAttribute +{ + public ChatBotQueryInputAttribute(string id) + { + this.Id = id; + } + + /// + /// Gets the ID of the chat bot to query. + /// + public string Id { get; } + + /// + /// Gets or sets the timestamp of the earliest message in the chat history to fetch. + /// The timestamp should be in ISO 8601 format - for example, 2023-08-01T00:00:00Z. + /// + public string TimestampUtc { get; set; } = string.Empty; +} + +public record ChatBotState( + string Id, + bool Exists, + ChatBotStatus Status, + DateTime CreatedAt, + DateTime LastUpdatedAt, + int TotalMessages, + IReadOnlyList RecentMessages); + + +// IMPORTANT: Do not change the names or order of these enum values! +public enum ChatBotStatus +{ + Uninitialized, + Active, + Expired, +} + +record struct MessageRecord(DateTime Timestamp, ChatMessage Message); + +class ChatBotRuntimeState +{ + [JsonPropertyName("messages")] + public List? ChatMessages { get; set; } + + [JsonPropertyName("expiresAt")] + public DateTime ExpiresAt { get; set; } + + [JsonPropertyName("status")] + public ChatBotStatus Status { get; set; } = ChatBotStatus.Uninitialized; +} \ No newline at end of file diff --git a/src/Functions.Worker.Extensions.OpenAI/Embeddings/EmbeddingsContext.cs b/src/Functions.Worker.Extensions.OpenAI/Embeddings/EmbeddingsContext.cs new file mode 100644 index 0000000..2097e41 --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/Embeddings/EmbeddingsContext.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using OpenAI.ObjectModels.RequestModels; +using OpenAI.ObjectModels.ResponseModels; + +namespace Functions.Worker.Extensions.OpenAI.Embeddings; + +/// +/// Binding target for the . +/// +/// The embeddings request that was sent to OpenAI. +/// The embeddings response that was received from OpenAI. +public record EmbeddingsContext(EmbeddingCreateRequest Request, EmbeddingCreateResponse Response) +{ + /// + /// Gets the number of embeddings that were returned in the response. + /// + public int Count => this.Response.Data.Count; +} diff --git a/src/Functions.Worker.Extensions.OpenAI/Embeddings/EmbeddingsInputAttribute.cs b/src/Functions.Worker.Extensions.OpenAI/Embeddings/EmbeddingsInputAttribute.cs new file mode 100644 index 0000000..d3e1caf --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/Embeddings/EmbeddingsInputAttribute.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; + +namespace Functions.Worker.Extensions.OpenAI.Embeddings; + +/// +/// Input binding attribute for converting function trigger input into OpenAI embeddings. +/// +/// +/// More information on OpenAI embeddings can be found at +/// https://platform.openai.com/docs/guides/embeddings/what-are-embeddings. +/// +[AttributeUsage(AttributeTargets.Parameter)] +public sealed class EmbeddingsInputAttribute : InputBindingAttribute +{ + /// + /// Initializes a new instance of the class with the specified input. + /// + /// The input source containing the data to generate embeddings for. + /// The type of the input. + /// Thrown if is null. + public EmbeddingsInputAttribute(string input, InputType inputType) + { + this.Input = input ?? throw new ArgumentNullException(nameof(input)); + this.InputType = inputType; + } + + /// + /// Gets or sets the ID of the model to use. + /// + public string Model { get; set; } = "text-embedding-ada-002"; + + /// + /// Gets or sets the maximum number of characters to chunk the input into. + /// + /// + /// + /// At the time of writing, the maximum input tokens allowed for second-generation input embedding models + /// like text-embedding-ada-002 is 8191. 1 token is ~4 chars in English, which translates to roughly 32K + /// characters of English input that can fit into a single chunk. + /// + /// + public int MaxChunkLength { get; set; } = 8 * 1024; // REVIEW: Is 8K a good default? + + /// + /// Gets the input to generate embeddings for. + /// + public string Input { get; } + + /// + /// Gets the type of the input. + /// + public InputType InputType { get; } + + /// + /// Gets or sets a value indicating whether the binding should throw if there is an error calling the OpenAI + /// endpoint. + /// + /// + /// The default value is true. Set this to false to handle errors manually in the function code. + /// + public bool ThrowOnError { get; set; } = true; +} diff --git a/src/Functions.Worker.Extensions.OpenAI/InputType.cs b/src/Functions.Worker.Extensions.OpenAI/InputType.cs new file mode 100644 index 0000000..03c7ede --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/InputType.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Functions.Worker.Extensions.OpenAI; + +/// +/// Options for interpreting input binding data. +/// +public enum InputType +{ + /// + /// The input data is raw text. + /// + RawText, + + /// + /// The input data is a file path that contains the text. + /// + FilePath, + + /// + /// The input data is a URL that can be invoked to get the text. + /// + URL, +} diff --git a/src/Functions.Worker.Extensions.OpenAI/Search/SearchResult.cs b/src/Functions.Worker.Extensions.OpenAI/Search/SearchResult.cs new file mode 100644 index 0000000..803dfd7 --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/Search/SearchResult.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Functions.Worker.Extensions.OpenAI.Search; + +/// +/// Represents the results of a semantic search. +/// +public class SearchResult +{ + public SearchResult(string sourceName, string snippet) + { + if (snippet == null) + { + throw new ArgumentNullException(nameof(snippet)); + } + + if (sourceName == null) + { + throw new ArgumentNullException(nameof(sourceName)); + } + + this.SourceName = Normalize(sourceName); + this.NormalizedSnippet = Normalize(snippet); + } + + /// + /// Gets or sets the name of source from which the results were pulled. For example, this may be the name of a file. + /// + public string SourceName { get; set; } + + /// + /// Gets or sets the snippet of text that was found in the source. + /// + public string NormalizedSnippet { get; set; } + + static string Normalize(string snippet) + { + // NOTE: .NET 6 has an optimized string.ReplaceLineEndings method. At the time of writing, we're targeting + // .NET Standard, so we don't have access to that more efficient implementation. + return snippet.Replace("\r\n", " ").Replace('\n', ' ').Replace('\r', ' '); + } + + /// + /// Returns a formatted version of the search result. + /// + public override string ToString() + { + return this.SourceName + ": " + this.NormalizedSnippet; + } +} diff --git a/src/Functions.Worker.Extensions.OpenAI/Search/SearchableDocument.cs b/src/Functions.Worker.Extensions.OpenAI/Search/SearchableDocument.cs new file mode 100644 index 0000000..4c8045d --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/Search/SearchableDocument.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Functions.Worker.Extensions.OpenAI.Embeddings; + +namespace Functions.Worker.Extensions.OpenAI.Search; + +public record SearchableDocument( + string Title, + EmbeddingsContext Embeddings) +{ + public ConnectionInfo? ConnectionInfo { get; set; } +} + +public record ConnectionInfo(string ConnectionName, string CollectionName); diff --git a/src/Functions.Worker.Extensions.OpenAI/Search/SemanticSearchContext.cs b/src/Functions.Worker.Extensions.OpenAI/Search/SemanticSearchContext.cs new file mode 100644 index 0000000..bcb380c --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/Search/SemanticSearchContext.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Functions.Worker.Extensions.OpenAI.Embeddings; +using OpenAI.ObjectModels.ResponseModels; + +namespace Functions.Worker.Extensions.OpenAI.Search; + +/// +/// Input binding target for the . +/// +/// The embeddings context associated with the semantic search. +/// The chat response from the large language model. +public record SemanticSearchContext(EmbeddingsContext Embeddings, ChatCompletionCreateResponse Chat) +{ + /// + /// Gets the latest response message from the OpenAI Chat API. + /// + public string Response => this.Chat.Choices.Last().Message.Content; +} diff --git a/src/Functions.Worker.Extensions.OpenAI/Search/SemanticSearchInputAttribute.cs b/src/Functions.Worker.Extensions.OpenAI/Search/SemanticSearchInputAttribute.cs new file mode 100644 index 0000000..a907e4f --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/Search/SemanticSearchInputAttribute.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; +using OpenAI.ObjectModels; + +namespace Functions.Worker.Extensions.OpenAI.Search; + +/// +/// Binding attribute for semantic search (input bindings) and semantic document storage (output bindings). +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class SemanticSearchInputAttribute : InputBindingAttribute +{ + /// + /// Initializes a new instance of the class with the specified connection + /// and collection names. + /// + /// + /// The name of an app setting or environment variable which contains a connection string value. + /// + /// The name of the collection or table to search or store. + /// + /// Thrown if either or are null. + /// + public SemanticSearchInputAttribute(string connectionName, string collection) + { + this.ConnectionName = connectionName ?? throw new ArgumentNullException(nameof(connectionName)); + this.Collection = collection ?? throw new ArgumentNullException(nameof(collection)); + } + + /// + /// Gets or sets the name of an app setting or environment variable which contains a connection string value. + /// + /// + /// This property supports binding expressions. + /// + public string ConnectionName { get; set; } + + /// + /// The name of the collection or table to search. + /// + /// + /// This property supports binding expressions. + /// + public string Collection { get; set; } + + /// + /// Gets or sets the semantic query text to use for searching. + /// This property is only used for the semantic search input binding. + /// + /// + /// This property supports binding expressions. + /// + public string? Query { get; set; } + + /// + /// Gets or sets the ID of the model to use for embeddings. + /// The default value is "text-embedding-ada-002". + /// + /// + /// This property supports binding expressions. + /// + public string EmbeddingsModel { get; set; } = Models.TextEmbeddingAdaV2; + + /// + /// Gets or sets the name of the Large Language Model to invoke for chat responses. + /// The default value is "gpt-3.5-turbo". + /// + /// + /// This property supports binding expressions. + /// + public string ChatModel { get; set; } = Models.Gpt_3_5_Turbo; + + /// + /// Gets or sets the system prompt to use for prompting the large language model. + /// + /// + /// + /// The system prompt will be appended with knowledge that is fetched as a result of the . + /// The combined prompt will then be sent to the OpenAI Chat API. + /// + /// This property supports binding expressions. + /// + /// + public string SystemPrompt { get; set; } = """ + You are a helpful assistant. You are responding to requests from a user about internal emails and documents. + You can and should refer to the internal documents to help respond to requests. If a user makes a request that's + not covered by the internal emails and documents, explain that you don't know the answer or that you don't have + access to the information. + + The following is a list of documents that you can refer to when answering questions. The documents are in the format + [filename]: [text] and are separated by newlines. If you answer a question by referencing any of the documents, + please cite the document in your answer. For example, if you answer a question by referencing info.txt, + you should add "Reference: info.txt" to the end of your answer on a separate line. + + """; + + /// + /// Gets or sets the number of knowledge items to inject into the . + /// + public int MaxKnowledgeCount { get; set; } = 1; + + /// + /// Gets or sets a value indicating whether the binding should throw if there is an error calling the OpenAI + /// endpoint. + /// + /// + /// The default value is true. Set this to false to handle errors manually in the function code. + /// + public bool ThrowOnError { get; set; } = true; +} diff --git a/src/Functions.Worker.Extensions.OpenAI/Search/SemanticSearchOutputAttribute.cs b/src/Functions.Worker.Extensions.OpenAI/Search/SemanticSearchOutputAttribute.cs new file mode 100644 index 0000000..649fc48 --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/Search/SemanticSearchOutputAttribute.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; +using OpenAI.ObjectModels; + +namespace Functions.Worker.Extensions.OpenAI.Search; + +/// +/// Binding attribute for semantic search (input bindings) and semantic document storage (output bindings). +/// +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] +public class SemanticSearchOutputAttribute : OutputBindingAttribute +{ + /// + /// Initializes a new instance of the class with the specified connection + /// and collection names. + /// + /// + /// The name of an app setting or environment variable which contains a connection string value. + /// + /// The name of the collection or table to search or store. + /// + /// Thrown if either or are null. + /// + public SemanticSearchOutputAttribute(string connectionName, string collection) + { + this.ConnectionName = connectionName ?? throw new ArgumentNullException(nameof(connectionName)); + this.Collection = collection ?? throw new ArgumentNullException(nameof(collection)); + } + + /// + /// Gets or sets the name of an app setting or environment variable which contains a connection string value. + /// + /// + /// This property supports binding expressions. + /// + public string ConnectionName { get; set; } + + /// + /// The name of the collection or table to search. + /// + /// + /// This property supports binding expressions. + /// + public string Collection { get; set; } + + /// + /// Gets or sets the ID of the model to use for embeddings. + /// The default value is "text-embedding-ada-002". + /// + /// + /// This property supports binding expressions. + /// + public string EmbeddingsModel { get; set; } = Models.TextEmbeddingAdaV2; + + /// + /// Gets or sets the number of knowledge items to inject into the . + /// + public int MaxKnowledgeCount { get; set; } = 1; + + /// + /// Gets or sets a value indicating whether the binding should throw if there is an error calling the OpenAI + /// endpoint. + /// + /// + /// The default value is true. Set this to false to handle errors manually in the function code. + /// + public bool ThrowOnError { get; set; } = true; +} diff --git a/src/Functions.Worker.Extensions.OpenAI/TextCompletionInputAttribute.cs b/src/Functions.Worker.Extensions.OpenAI/TextCompletionInputAttribute.cs index 8de35cd..57747df 100644 --- a/src/Functions.Worker.Extensions.OpenAI/TextCompletionInputAttribute.cs +++ b/src/Functions.Worker.Extensions.OpenAI/TextCompletionInputAttribute.cs @@ -13,6 +13,7 @@ namespace Functions.Worker.Extensions.OpenAI; /// /// Input binding attribute for capturing OpenAI completions in function executions. /// +[AttributeUsage(AttributeTargets.Parameter)] public sealed class TextCompletionInputAttribute : InputBindingAttribute { /// diff --git a/src/Functions.Worker.Extensions.OpenAI/_CSharpLanguageHelpers.cs b/src/Functions.Worker.Extensions.OpenAI/_CSharpLanguageHelpers.cs new file mode 100644 index 0000000..d1863a6 --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/_CSharpLanguageHelpers.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +// This file defines several classes and methods that exist in .NET Core but not in .NET Standard. +// They are defined here to enable certain C# features that otherwise require higher framework versions. +// Redefining types in this way is a standard practice for libary authors that are forced to target .NET Standard. +#if NETSTANDARD + +#nullable enable + +using System.ComponentModel; + +// Copied from https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Diagnostics/CodeAnalysis/NullableAttributes.cs#L69 +namespace System.Diagnostics.CodeAnalysis +{ + sealed class NotNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + public NotNullWhenAttribute(bool returnValue) => this.ReturnValue = returnValue; + + /// Gets the return value condition. + public bool ReturnValue { get; } + } +} + +namespace System.Runtime.CompilerServices +{ + /// + /// Reserved to be used by the compiler for tracking metadata. + /// This class should not be used by developers in source code. + /// This dummy class is required to compile records when targeting .NET Standard + /// + [EditorBrowsable(EditorBrowsableState.Never)] + static class IsExternalInit { } +} +#endif \ No newline at end of file