Если вам понравилось прочитанное, присоединяйтесь ко мне на linkedin или следите за мной на dev.to 🙂
Привет и добро пожаловать во вторую часть серии постов о том, как использовать Azure, luis.ai и API twitter для создания чат-бота, который можно использовать для поиска и анализа настроений в некоторых твитах.
В этой части я перейду к кодированию и интеграции с использованием функционального приложения.
Вы можете ознакомиться с репозиториями для этого проекта, перейдя по ссылкам ниже:
https://github.com/Albert-Bennett/TwitterSentimentAnalysisFunctionApp
https://github.com/Albert-Bennett/TwitterSentimentAnalysisChatBot
Функциональное приложение
Для начала нам нужно создать новое функциональное приложение. Мое приложение настроено на http-триггер и Open API. Часть Open API на самом деле не требуется для этого проекта, но я люблю иметь автоматизированную документацию под рукой на случай, если мне понадобится обратиться к функциональности или другим деталям функционального приложения по ходу работы.
Также стоит отметить, что для проекта я буду использовать .net 6.0.
Запрос и ответ
Итак, план функционального приложения состоит в том, что оно принимает запрос get, который определяет набор хэш-тегов и число, определяющее максимальное количество твитов для поиска в API twitter.
Для этого мы убедимся, что http-триггер принимает get-запрос и определяет переменные на основе параметров запроса.
int maxTweets = 0;
if (!int.TryParse(req.Query["max_tweets"], out maxTweets))
{
return new BadRequestObjectResult("max_tweets must be a number greater than 0 and less than 100");
}
string hashtagQueryParam = req.Query["hashtags"];
string[] hashtags = hashtagQueryParam.Split('u002C');
Определив параметры запроса, мы должны решить, что будет возвращаться из функционального приложения при успешном ответе. Я думаю, что лучше всего было бы вернуть X самых популярных твитов из тех, что мы получили из twitter, а также подробную информацию об анализе настроения найденных твитов. Для этого мы структурируем объект ответа следующим образом:
public enum TweetSentiment
{
Neutral = 0,
Positive = 1,
Negative = 2,
InConclusive = 3
}
internal class FNResponse
{
public TweetData[] MostPopularTweets { get; set; }
public int NumberOfTweetsFound { get; set; }
public Dictionary<TweetSentiment, int> TweetSentimentAnalysis { get; set; }
}
Мы используем перечисление для управления ответами анализа настроений, поскольку возможно отсутствие результатов для одной или нескольких категорий анализа (положительный, отрицательный, нейтральный, окончательный). Результат ‘InConclusive’ возвращается только для твитов, для которых анализ не может не проводится, или если при проведении анализа настроения для этого твита произошла ошибка.
API Twitter
Далее нам нужно проверить, какую конечную точку нам нужно вызвать и какой ответ мы получим на это действие в API Twitter. Эти данные нужны нам для того, чтобы знать, что мы должны вызывать в API и как моделировать наши объекты данных.
Из документации мы видим, что конечная точка, которую нам нужно вызвать, находится по адресу: https://api.twitter.com/2/tweets/search/recent.
К этому нужно добавить кодировку URL для символа хэштега — %23. Это связано с тем, что # является специальным символом и может вызвать проблемы при добавлении его в URL. Кроме того, в стандартном API Twitter нет возможности поиска по определенному хэштегу, только поиск по содержанию, поэтому для поиска по хэштегам нам нужно добавить его в поисковый запрос.
Далее, посмотрев на объект ответа из документации и протестировав конечную точку через Postman, мы видим, что нам нужно смоделировать наши данные таким образом.
public class TwitterSearchResult
{
public TweetData[] data { get; set; }
public TwitterSearchResultMetaData meta { get; set; }
}
public class TweetData
{
public TweetPublicMetrics public_metrics { get; set; }
public string text { get; set; }
}
public class TweetPublicMetrics
{
public int retweet_count { get; set; }
public int reply_count { get; set; }
public int like_count { get; set; }
public int quote_count { get; set; }
/// <summary>
/// This method is just going to add up all of the public metrics to get an arbitrary popularity metric
/// </summary>
/// <returns></returns>
public int GetPopularity()
{
return retweet_count + reply_count + like_count + quote_count;
}
}
public class TwitterSearchResultMetaData
{
public string next_token { get; set; }
}
Нам не нужен ID объекта твита, поскольку мы не будем его использовать, но нам нужно знать публичные метрики (лайки, ответы и т.д.) и, конечно же, текст твита. Есть и другие данные, которые возвращаются из API twitter при вызове этой конечной точки, но нам они не нужны, поэтому я смоделировал данные для них.
Имея всю эту информацию, мы можем составить представление о том, что мы хотим отправить в twitter и что получить обратно.
Сервис Twitter
Супер! Со всей этой информацией мы можем приступить к созданию служб, которые нам нужны, чтобы заставить функциональное приложение делать то, что мы хотим.
Для начала нам нужен сервис TwitterService.
public class TwitterService : ITwitterService
{
readonly IHttpClientFactory _httpClientFactory;
readonly string bearerToken;
readonly string baseUrl;
public TwitterService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
bearerToken = Environment.GetEnvironmentVariable("TwitterBearerToken");
baseUrl = Environment.GetEnvironmentVariable("TwitterBaseUrl");
}
public async Task<TweetData[]> FindTweetsByHashtag(FNRequestBody request)
{
int maxResults = request.MaxTweets;
string paginationToken = null;
string hashtagQuery = GetHashtagQuery(request.HashTags);
List<TweetData> tweetsFound = new();
while (tweetsFound.Count < maxResults && (paginationToken != null || tweetsFound.Count == 0))
{
var twitterSearchResult = await GetNextSetOfTweets(paginationToken, hashtagQuery);
if (twitterSearchResult != null)
{
paginationToken = twitterSearchResult.meta.next_token;
if (tweetsFound.Count + twitterSearchResult.meta.result_count > maxResults)
{
var diff = maxResults - tweetsFound.Count;
for (int i = 0; i < diff; i++)
{
tweetsFound.Add(twitterSearchResult.data[i]);
}
}
else
{
tweetsFound.AddRange(twitterSearchResult.data);
}
}
else
{
return tweetsFound.ToArray();
}
}
return tweetsFound.ToArray();
}
string GetHashtagQuery(string[] hashTags)
{
string query = string.Empty;
foreach (string hashTag in hashTags)
{
var tag = hashTag.StartsWith('#') ? hashTag : $"#{hashTag}";
query = string.IsNullOrEmpty(query) ? tag : query + $" {tag}";
}
return query;
}
async Task<TwitterSearchResult> GetNextSetOfTweets(string paginationToken, string query)
{
string paginationSubQuery = string.IsNullOrEmpty(paginationToken) ? string.Empty : $"&next_token={paginationToken}";
string url = HttpUtility.UrlEncode($"{baseUrl}?query={query}&tweet.fields=public_metrics{paginationSubQuery}");
var message = new HttpRequestMessage(HttpMethod.Get, url)
{
Headers =
{
{ "Authorization", $"Bearer {bearerToken}" }
}
};
using (var client = _httpClientFactory.CreateClient())
{
var response = await client.SendAsync(message);
if (response.IsSuccessStatusCode)
{
using var contentStream = await response.Content.ReadAsStreamAsync();
return await JsonSerializer.DeserializeAsync<TwitterSearchResult>(contentStream);
}
}
return null;
}
Проще говоря, сервис twitter подключается к twitter. Он создает конечную точку и посылает запрос к API twitter. Затем он отправляет нам данные, которые мы можем использовать в luis для анализа настроений. Единственная загвоздка с вызовом API заключается в том, что API twitter отправляет только 10 результатов за раз. Чтобы обойти это, они также отправляют токен для постраничной обработки. Мы можем использовать этот токен для получения следующего набора результатов и так далее, пока не получим максимальное количество запрошенных результатов.
Моделирование данных luis
Следующая часть немного проще, так как мы можем протестировать конечную точку прогнозирования luis, которую мы собрали на предыдущем этапе в Postman. С его помощью мы можем увидеть, что представляет собой возвращаемый объект из API luis.
С этой информацией мы можем смоделировать наши данные следующим образом:
public class LuisQueryResult
{
public LuisPrediction prediction { get; set; }
}
public class LuisPrediction
{
public LuisSentiment sentiment { get; set; }
}
public class LuisSentiment
{
public string label { get; set; }
}
Хотя объект ответа от Луиса был намного сложнее, и есть параметры запроса, которые мы можем добавить, чтобы получить больше данных, я хотел бы сохранить ответы как можно более компактными. Кроме того, для нашего проекта нам действительно нужна только часть ответа, касающаяся анализа настроений.
Служба luis
Это будет почти то же самое, что и с сервисом twitter. Нам нужно составить запрос и отправить его в виде get-запроса на конечную точку, а затем преобразовать результаты.
public class LuisService : ILuisService
{
readonly IHttpClientFactory _httpClientFactory;
readonly string subscriptionKey;
readonly string appId;
readonly string baseUrl;
readonly string endpoint;
public LuisService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
subscriptionKey = Environment.GetEnvironmentVariable("SubscriptionKey");
appId = Environment.GetEnvironmentVariable("AppId");
baseUrl = Environment.GetEnvironmentVariable("LuisBaseUrl");
endpoint = Environment.GetEnvironmentVariable("LuisEndpoint");
}
public async Task<Dictionary<TweetSentiment, int>> GetSentimentAnalysisOnTweets(TweetData[] tweets)
{
Dictionary<TweetSentiment, int> sentimentAnalysis = new Dictionary<TweetSentiment, int>();
foreach (TweetData tweet in tweets)
{
var analysis = await GetSentimentAnalaysis(tweet.text);
if (sentimentAnalysis.ContainsKey(analysis))
{
sentimentAnalysis[analysis]++;
}
else
{
sentimentAnalysis.Add(analysis, 1);
}
}
return sentimentAnalysis;
}
async Task<TweetSentiment> GetSentimentAnalaysis(string text)
{
string url = $"{baseUrl}{appId}{endpoint}?subscription-key={subscriptionKey}&query={HttpUtility.UrlEncode(text)}";
using (var client = _httpClientFactory.CreateClient())
{
var response = await client.GetAsync(url);
if (response.IsSuccessStatusCode)
{
using var contentStream = await response.Content.ReadAsStreamAsync();
var result = await JsonSerializer.DeserializeAsync<LuisQueryResult>(contentStream);
switch (result.prediction.sentiment.label)
{
case LuisConstants.NegativeResult:
return TweetSentiment.Negative;
case LuisConstants.PositiveResult:
return TweetSentiment.Positive;
default:
return TweetSentiment.Neutral;
}
}
}
return TweetSentiment.InConclusive;
}
}
Единственная загвоздка в этом сервисе — добавление настроения «InConclusive». Это нужно для тех случаев, когда API luis возвращает неудачный ответ из-за неправильного запроса или слишком частого обращения к API за короткий промежуток времени.
Склеиваем все вместе
Наконец, перейдем к склеиванию всего этого вместе. Нам нужно использовать эти два сервиса для создания тела ответа и использовать метрику популярности, которую мы добавили в объект public_metric, чтобы вернуть X самых популярных твитов с выполненным анализом настроения.
public async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Function, "get", Route = null)] HttpRequest req)
{
int maxTweets = 0;
if (!int.TryParse(req.Query["max_tweets"], out maxTweets))
{
return new BadRequestObjectResult("max_tweets must be a number greater than 0 and less than 100");
}
if (maxTweets <= 0)
{
return new BadRequestObjectResult("max_tweets must be greater than 0");
}
else if (maxTweets > 100)
{
return new BadRequestObjectResult("max_tweets must be less than 100");
}
string hashtagQueryParam = req.Query["hashtags"];
string[] hashtags = hashtagQueryParam.Split('u002C');
if (hashtags == null || hashtags.Length == 0)
{
return new BadRequestObjectResult("You must include hashtags to search in the request body");
}
var foundTweets = await _twitterService.FindTweetsByHashtag(hashtags, maxTweets);
if (foundTweets != null)
{
var maxNumberOfPopularTweets = int.Parse(Environment.GetEnvironmentVariable("MaxNumberOfPopularTweets"));
var mostPopularTweets = foundTweets.OrderByDescending(x => x.public_metrics.GetPopularity()).Take(maxNumberOfPopularTweets).ToArray();
var fnResponse = new FNResponse
{
MostPopularTweets = mostPopularTweets,
NumberOfTweetsFound = foundTweets.Length,
TweetSentimentAnalysis = await _luisService.GetSentimentAnalysisOnTweets(foundTweets)
};
return new return new OkObjectResult(fnResponse);
}
return new BadRequestObjectResult($"No tweets found searching for the following hashtags: {hashtags}.");
}
}
В нашей точке входа на самом деле не так уж и много, только проверка данных и сборка различных точек данных.
После этого результат должен выглядеть примерно так.
Последняя часть, которую я собираюсь рассмотреть, это локальный файл настроек, куда я поместил все конфигурации для функционального приложения на случай, если вы захотите попробовать заставить бота работать самостоятельно.
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "dotnet",
"TwitterBearerToken": "[your twitter bearer token]",
"TwitterBaseUrl": "https://api.twitter.com/2/tweets/search/recent",
"MaxNumberOfPopularTweets": 3,
"SubscriptionKey": "[your azure account subscription key]",
"AppId": "[your luis app id]",
"LuisBaseUrl": "https://[the name of your luis app].cognitiveservices.azure.com/luis/prediction/v3.0/apps/",
"LuisEndpoint": "/slots/production/predict"
}
}
Для демонстрационных целей размещение конфигурации здесь вполне подходит, но в более производственной среде они должны храниться в чем-то вроде KeyVault. Если только вы не тестируете локально, в этом случае их также можно хранить локально, если файл local.settings не фиксируется в репозитории и вы используете непроизводственные настройки.
В этой серии постов я просто перечисляю ключевые части приложения. Есть и другие задачи кода, которые я выполнил и которые можно увидеть в репозиториях этого проекта. Если вам нужен краткий курс по этому проекту или по функциональным приложениям, у меня есть несколько постов, где я рассказываю об этом, которые можно найти ниже:
- https://dev.to/albertbennett/how-to-natural-language-processing-and-the-microsoft-bot-framework-1oob
- https://dev.to/albertbennett/solid-principals-for-oop-2e49
- https://dev.to/albertbennett/the-basics-of-azure-function-apps-29ei
На этом спасибо, что прочитали мой пост и увидимся в третьей части для финала и бот стороны этого чат-бота.