Skip to content

Commit

Permalink
feat: format message in stream response
Browse files Browse the repository at this point in the history
[bsdayo#24] Extract the logic of the building answer string.
Support formating multiple response messages (containing adaptive cards and source attributions) in both `AskAsync` and `StreamAsync`.
  • Loading branch information
Caulm committed Jun 13, 2023
1 parent 01ea64a commit 4b302a2
Show file tree
Hide file tree
Showing 3 changed files with 169 additions and 83 deletions.
95 changes: 14 additions & 81 deletions src/BingChat/BingChatConversation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,16 @@ internal BingChatConversation(
public async Task<string> AskAsync(string message, CancellationToken ct = default)
{
var request = _request.ConstructInitialPayload(message);
var responses = new List<ChatResponse>();

await using var conn = await Connect(ct);

var response = await conn
.StreamAsync<ChatResponse>("chat", request, ct)
.FirstAsync(ct);

return BuildAnswer(response) ?? "<empty answer>";
responses.Add(response);
return BingChatParser.ParseMessage(response) ?? "<empty answer>";
}

/// <inheritdoc/>
Expand All @@ -70,105 +72,36 @@ public async IAsyncEnumerable<string> StreamAsync(
var request = _request.ConstructInitialPayload(message);
var chan = Channel.CreateUnbounded<string>();
var (rx, tx) = (chan.Reader, chan.Writer);
var responses = new List<ChatResponse>();

await using var conn = await Connect(ct);

var completedLength = 0;
var messageId = (string?)null;
using var updateCallback = conn.On<ChatResponse>("update", update =>
{
if (update.Messages is null or { Length: 0 })
return;

var message = update.Messages[0];
if (message is { MessageType: not null } or { Text: null or { Length: 0 } })
return;
if (messageId != (messageId ??= message.MessageId))
return;

tx.TryWrite(message.Text[completedLength..]);
completedLength = message.Text.Length;
responses.Add(update);
var answer = BingChatParser.ParseMessage(responses);
if (answer is null) return;
if (answer.Length > completedLength)
tx.TryWrite(answer[completedLength..]);
completedLength = answer.Length;
});

var responseCallback = conn.StreamAsync<ChatResponse>("chat", request, ct)
.FirstAsync(ct)
.AsTask()
.ContinueWith(response =>
{
// TODO: Properly format source attributions and adaptive cards, and append them at the end of the message.
// This is best done by extracting formatting logic from one-shot BuildAnswer used by AskAsync.
if (messageId != null)
{
var completedMessage = response.Result.Messages
.First(msg => msg.MessageId == messageId)
.Text;
if (completedMessage!.Length > completedLength)
tx.TryWrite(completedMessage[completedLength..]);
}
responses.Add(response.Result);
var answer = BingChatParser.ParseMessage(responses) ?? "<empty answer>";
if (answer.Length > completedLength)
tx.TryWrite(answer[completedLength..]);
tx.Complete();
}, ct);

await foreach (var word in rx.ReadAllAsync(ct)) yield return word;
}

private static string? BuildAnswer(ChatResponse response)
{
//Check status
if (!string.Equals(response.Result?.Value, "Success", StringComparison.OrdinalIgnoreCase))
{
throw new BingChatException($"{response.Result?.Value}: {response.Result?.Message}");
}

//Collect messages, including of types: Chat, SearchQuery, LoaderMessage, Disengaged
var messages = new List<string>();
foreach (var itemMessage in response.Messages)
{
//Not needed
if (itemMessage.Author != "bot") continue;
if (itemMessage.MessageType is "InternalSearchResult" or "RenderCardRequest")
continue;

//Not supported
if (itemMessage.MessageType is "GenerateContentQuery")
continue;

//From Text
var text = itemMessage.Text;

//From AdaptiveCards
var adaptiveCards = itemMessage.AdaptiveCards;
if (text is null && adaptiveCards?.Length > 0)
{
var bodies = new List<string>();
foreach (var body in adaptiveCards[0].Body)
{
if (body.Type != "TextBlock" || body.Text is null) continue;
bodies.Add(body.Text);
}
text = bodies.Count > 0 ? string.Join("\n", bodies) : null;
}

//From MessageType
text ??= $"<{itemMessage.MessageType}>";

//From SourceAttributions
var sourceAttributions = itemMessage.SourceAttributions;
if (sourceAttributions?.Length > 0)
{
text += "\n";
for (var nIndex = 0; nIndex < sourceAttributions.Length; nIndex++)
{
var sourceAttribution = sourceAttributions[nIndex];
text += $"\n[{nIndex + 1}]: {sourceAttribution.SeeMoreUrl} \"{sourceAttribution.ProviderDisplayName}\"";
}
}

messages.Add(text);
}

return messages.Count > 0 ? string.Join("\n\n", messages) : null;
}

private static async Task<HubConnection> Connect(CancellationToken ct)
{
var conn = new HubConnection(
Expand Down
153 changes: 153 additions & 0 deletions src/BingChat/BingChatParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
using BingChat.Model;

namespace BingChat
{
internal static class BingChatParser
{
/// <summary>
/// Build answer string on multiple responses, merging messages with duplicate Ids.
/// </summary>
public static string? ParseMessage(IList<ChatResponse> responses)
{
bool isStreamComplete = false;
var messageIds = new HashSet<string>();
var messages = new List<ResponseMessage>();

//Collect response, from newer to older
for (int responseIndex = responses.Count - 1; responseIndex >= 0; --responseIndex)
{
var response = responses[responseIndex];
if (response is null) continue;

//Check status
if (response.Result is not null && response.Result.Value != "Success")
throw new BingChatException($"{response.Result?.Value}: {response.Result?.Message}");
if (response.Result is not null)
isStreamComplete = true;
if (response.Messages is null) continue;

//Collect messages, from newer to older
for (int messageIndex = response.Messages.Length - 1; messageIndex >= 0; --messageIndex)
{
var message = response.Messages[messageIndex];
if (message is null) continue;
if (messageIds.Contains(message.MessageId))
continue;
messageIds.Add(message.MessageId);
messages.Add(message);
}
}

//Parse messages
var strMessages = new List<string>();
for (int messageIndex = messages.Count - 1; messageIndex >= 0; --messageIndex)
{
var message = messages[messageIndex];
if (message is null) continue;
//the last message in stream doesn't need source attribution
var strMessage = ParseMessage(message, isStreamComplete || (messageIndex > 0));
if (strMessage?.Length > 0)
strMessages.Add(strMessage);
}

return strMessages.Count > 0 ? string.Join("\n\n", strMessages) : null;
}

/// <summary>
/// Build answer string on a single response.
/// </summary>
public static string? ParseMessage(ChatResponse response)
{
//Check status
if (response.Result is not null && response.Result.Value != "Success")
throw new BingChatException($"{response.Result?.Value}: {response.Result?.Message}");
if (response.Messages is null) return null;

//Parse messages
var strMessages = new List<string>();
foreach (var message in response.Messages)
{
if (message is null) continue;
var strMessage = ParseMessage(message);
if (strMessage?.Length > 0)
strMessages.Add(strMessage);
}

return strMessages.Count > 0 ? string.Join("\n\n", strMessages) : null;
}

/// <summary>
/// Build answer string on a response message.
/// </summary>
private static string? ParseMessage(ResponseMessage message, bool needSourceAttribution = true)
{
//Not needed
if (message.Author != "bot") return null;
if (message.MessageType == "InternalSearchResult" ||
message.MessageType == "RenderCardRequest")
return null;

//Not supported
if (message.MessageType == "GenerateContentQuery")
return null;

//From Text
var text = message.Text;

//From AdaptiveCards
if (text is null && message.AdaptiveCards?.Length > 0)
text = ParseMessage(message.AdaptiveCards);

//From MessageType
text ??= $"<{message.MessageType}>";

//From SourceAttributions
if (needSourceAttribution && message.SourceAttributions?.Length > 0)
text += "\n\n" + ParseMessage(message.SourceAttributions);

return text;
}

/// <summary>
/// Build answer string on adaptive cards.
/// </summary>
private static string? ParseMessage(AdaptiveCard[] cards)
{
if (cards.Length == 0) return null;

var strMessages = new List<string>();
foreach (var card in cards)
{
if (card is null or {Bodies : null}) continue;
foreach (var body in card.Bodies)
{
//Plain text block
if (body.Type == "TextBlock" && body.Text is not null)
strMessages.Add(body.Text);

//Not supported
//TODO: Rich text block or other type
}
}

return strMessages.Count > 0 ? string.Join('\n', strMessages) : null;
}

/// <summary>
/// Build answer string on source attributions.
/// </summary>
private static string? ParseMessage(SourceAttribution[] sources)
{
if (sources.Length == 0) return null;

var strMessages = new List<string>();
for (var sourceIndex = 0; sourceIndex < sources.Length; ++sourceIndex)
{
var source = sources[sourceIndex];
strMessages.Add($"[{sourceIndex + 1}]: {source.SeeMoreUrl} \"{source.ProviderDisplayName}\"");
}

return strMessages.Count > 0 ? String.Join('\n', strMessages) : null;
}
}
}
4 changes: 2 additions & 2 deletions src/BingChat/Model/ChatResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace BingChat.Model;

internal sealed class ChatResponse
{
public ResponseMessage[] Messages { get; set; }
public ResponseMessage[]? Messages { get; set; }
public ResponseResult? Result { get; set; }
}

Expand All @@ -24,7 +24,7 @@ internal sealed class ResponseMessage

internal sealed class AdaptiveCard
{
public ResponseBody[] Body { get; set; }
public ResponseBody[]? Bodies { get; set; }
}

internal sealed class ResponseBody
Expand Down

0 comments on commit 4b302a2

Please # to comment.