Merge pull request #189 from 13xforever/vnext

Support fuzzy matches
This commit is contained in:
Ilya 2019-01-25 21:59:20 +05:00 committed by GitHub
commit 6a11348113
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 213 additions and 57 deletions

View File

@ -16,6 +16,7 @@ using DSharpPlus;
using DSharpPlus.CommandsNext;
using DSharpPlus.CommandsNext.Attributes;
using DSharpPlus.Entities;
using DuoVia.FuzzyStrings;
using Microsoft.EntityFrameworkCore;
namespace CompatBot.Commands
@ -111,13 +112,12 @@ Example usage:
[Description("Provides information about available filters for the !top command")]
public async Task Filters(CommandContext ctx)
{
var getDmTask = ctx.CreateDmAsync();
var embed = new DiscordEmbedBuilder {Description = "List of recognized tokens in each filter category", Color = Config.Colors.Help}
.AddField("Statuses", DicToDesc(ApiConfig.Statuses))
.AddField("Release types", DicToDesc(ApiConfig.ReleaseTypes))
.Build();
var dm = await getDmTask.ConfigureAwait(false);
await dm.SendMessageAsync(embed: embed).ConfigureAwait(false);
var ch = await ctx.GetChannelForSpamAsync().ConfigureAwait(false);
await ch.SendMessageAsync(embed: embed).ConfigureAwait(false);
}
[Group("latest"), Aliases("download"), TriggersTyping]
@ -215,7 +215,10 @@ Example usage:
return;
}
var channel = LimitedToSpamChannel.IsSpamChannel(ctx.Channel) ? ctx.Channel : await ctx.Member.CreateDmChannelAsync().ConfigureAwait(false);
#if DEBUG
await Task.Delay(5_000).ConfigureAwait(false);
#endif
var channel = await ctx.GetChannelForSpamAsync().ConfigureAwait(false);
foreach (var msg in FormatSearchResults(ctx, result))
await channel.SendAutosplitMessageAsync(msg, blockStart:"", blockEnd:"").ConfigureAwait(false);
}
@ -234,12 +237,14 @@ Example usage:
if (string.IsNullOrEmpty(request.customHeader))
{
result.AppendLine($"{authorMention} searched for: ***{request.search.Sanitize()}***");
if (request.search.Contains("persona", StringComparison.InvariantCultureIgnoreCase))
if (request.search.Contains("persona", StringComparison.InvariantCultureIgnoreCase)
|| request.search.Contains("p5", StringComparison.InvariantCultureIgnoreCase))
result.AppendLine("Did you try searching for ***Unnamed*** instead?");
else if (!ctx.Channel.IsPrivate &&
ctx.Message.Author.Id == 197163728867688448 &&
(compatResult.Results.Values.Any(i => i.Title.Contains("afrika", StringComparison.InvariantCultureIgnoreCase) ||
i.Title.Contains("africa", StringComparison.InvariantCultureIgnoreCase)))
else if (!ctx.Channel.IsPrivate
&& ctx.Message.Author.Id == 197163728867688448
&& (compatResult.Results.Values.Any(i =>
i.Title.Contains("afrika", StringComparison.InvariantCultureIgnoreCase)
|| i.Title.Contains("africa", StringComparison.InvariantCultureIgnoreCase)))
)
{
var sqvat = ctx.Client.GetEmoji(":sqvat:", Config.Reactions.No);
@ -258,9 +263,13 @@ Example usage:
if (returnCode.displayResults)
{
foreach (var resultInfo in compatResult.Results.Take(request.amountRequested))
var sortedList = compatResult.GetSortedList();
foreach (var resultInfo in sortedList.Take(request.amountRequested))
{
var info = resultInfo.AsString();
#if DEBUG
info = $"`{CompatApiResultUtils.GetScore(request.search, resultInfo.Value):0.000000}` {info}";
#endif
result.AppendLine(info);
}
yield return result.ToString();

View File

@ -24,9 +24,10 @@ namespace CompatBot.Commands
[GroupCommand]
public async Task ShowExplanation(CommandContext ctx, [RemainingText, Description("Term to explain")] string term)
{
var sourceTerm = term;
if (string.IsNullOrEmpty(term))
{
var ch = LimitedToSpamChannel.IsSpamChannel(ctx.Channel) ? ctx.Channel : await ctx.Member.CreateDmChannelAsync().ConfigureAwait(false);
var ch = await ctx.GetChannelForSpamAsync().ConfigureAwait(false);
await ch.SendMessageAsync("Please specify term to explain").ConfigureAwait(false);
await List(ctx).ConfigureAwait(false);
return;
@ -39,42 +40,48 @@ namespace CompatBot.Commands
return;
term = term.ToLowerInvariant();
using (var db = new BotDb())
var result = await LookupTerm(term).ConfigureAwait(false);
if (string.IsNullOrEmpty(result.explanation))
{
var explanation = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == term).ConfigureAwait(false);
if (explanation != null)
term = term.StripQuotes();
var idx = term.LastIndexOf(" to ");
if (idx > 0)
{
await ctx.RespondAsync(explanation.Text).ConfigureAwait(false);
return;
var potentialUserId = term.Substring(idx + 4).Trim();
bool hasMention = false;
try
{
var lookup = await new DiscordUserConverter().ConvertAsync(potentialUserId, ctx)
.ConfigureAwait(false);
hasMention = lookup.HasValue;
}
catch
{
}
if (hasMention)
{
term = term.Substring(0, idx).TrimEnd();
result = await LookupTerm(term).ConfigureAwait(false);
}
}
}
term = term.StripQuotes();
var idx = term.LastIndexOf(" to ");
if (idx > 0)
try
{
var potentialUserId = term.Substring(idx + 4).Trim();
bool hasMention = false;
try
if (!string.IsNullOrEmpty(result.explanation))
{
var lookup = await new DiscordUserConverter().ConvertAsync(potentialUserId, ctx).ConfigureAwait(false);
hasMention = lookup.HasValue;
}
catch { }
if (hasMention)
{
term = term.Substring(0, idx).TrimEnd();
using (var db = new BotDb())
{
var explanation = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == term).ConfigureAwait(false);
if (explanation != null)
{
await ctx.RespondAsync(explanation.Text).ConfigureAwait(false);
return;
}
}
if (!string.IsNullOrEmpty(result.fuzzyMatch))
await ctx.RespondAsync($"Showing explanation for `{result.fuzzyMatch}`:").ConfigureAwait(false);
await ctx.RespondAsync(result.explanation).ConfigureAwait(false);
return;
}
}
catch (Exception e)
{
Config.Log.Error(e, "Failed to explain " + sourceTerm);
return;
}
string inSpecificLocation = null;
if (!LimitedToSpamChannel.IsSpamChannel(ctx.Channel))
@ -172,7 +179,7 @@ namespace CompatBot.Commands
[Description("List all known terms that could be used for !explain command")]
public async Task List(CommandContext ctx)
{
var responseChannel = LimitedToSpamChannel.IsSpamChannel(ctx.Channel) ? ctx.Channel : await ctx.CreateDmAsync().ConfigureAwait(false);
var responseChannel = await ctx.GetChannelForSpamAsync().ConfigureAwait(false);
using (var db = new BotDb())
{
var keywords = await db.Explanation.Select(e => e.Keyword).OrderBy(t => t).ToListAsync().ConfigureAwait(false);
@ -246,6 +253,27 @@ namespace CompatBot.Commands
}
}
private async Task<(string explanation, string fuzzyMatch)> LookupTerm(string term)
{
string fuzzyMatch = null;
Explanation explanation;
using (var db = new BotDb())
{
explanation = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == term).ConfigureAwait(false);
if (explanation == null)
{
var termList = await db.Explanation.Select(e => e.Keyword).ToListAsync().ConfigureAwait(false);
var bestSuggestion = termList.OrderByDescending(term.GetFuzzyCoefficientCached).First();
if (term.GetFuzzyCoefficientCached(bestSuggestion) > 0.2)
{
explanation = await db.Explanation.FirstOrDefaultAsync(e => e.Keyword == bestSuggestion).ConfigureAwait(false);
fuzzyMatch = bestSuggestion;
}
}
}
return (explanation?.Text, fuzzyMatch);
}
private async Task DumpLink(CommandContext ctx, string messageLink)
{
string explanation = null;

View File

@ -75,7 +75,7 @@ namespace CompatBot.Commands
return;
}
var responseChannel = LimitedToSpamChannel.IsSpamChannel(ctx.Channel) ? ctx.Channel : await ctx.CreateDmAsync().ConfigureAwait(false);
var responseChannel = await ctx.GetChannelForSpamAsync().ConfigureAwait(false);
const int maxTitleLength = 80;
var maxNum = openPrList.Max(pr => pr.Number).ToString().Length + 1;
var maxAuthor = openPrList.Max(pr => pr.User.Login.Length);

View File

@ -16,19 +16,20 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DSharpPlus" Version="4.0.0-nightly-00560" />
<PackageReference Include="DSharpPlus.CommandsNext" Version="4.0.0-nightly-00560" />
<PackageReference Include="DSharpPlus.Interactivity" Version="4.0.0-nightly-00560" />
<PackageReference Include="MathParser.org-mXparser" Version="4.2.2" />
<PackageReference Include="DSharpPlus" Version="4.0.0-nightly-00564" />
<PackageReference Include="DSharpPlus.CommandsNext" Version="4.0.0-nightly-00564" />
<PackageReference Include="DSharpPlus.Interactivity" Version="4.0.0-nightly-00564" />
<PackageReference Include="DuoVia.FuzzyStrings" Version="2.0.1" />
<PackageReference Include="MathParser.org-mXparser" Version="4.3.1" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="5.2.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="2.2.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="2.2.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="2.2.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="2.2.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="2.2.0" />
<PackageReference Include="NLog" Version="4.5.11" />
<PackageReference Include="NReco.Text.AhoCorasickDoubleArrayTrie" Version="1.0.1" />
<PackageReference Include="SharpCompress" Version="0.22.0" />
<PackageReference Include="System.IO.Pipelines" Version="4.5.2" />
<PackageReference Include="System.IO.Pipelines" Version="4.5.3" />
</ItemGroup>
<ItemGroup>

View File

@ -2,7 +2,6 @@
using System.Collections.Concurrent;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using CompatApiClient;
using CompatApiClient.Utils;
@ -12,11 +11,10 @@ using DSharpPlus.EventArgs;
namespace CompatBot.EventHandlers
{
internal sealed class IsTheGamePlayableHandler
internal static class IsTheGamePlayableHandler
{
private const RegexOptions DefaultOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.ExplicitCapture;
private static readonly Regex GameNameStatusMention = new Regex(@"((is|does|can I play)\s+|^)(?<game_title>.+?)(\s+((now|currently)\s+)?((is )?playable|work(ing)?)|\?)", DefaultOptions);
private static readonly SemaphoreSlim TheDoor = new SemaphoreSlim(1, 1);
private static readonly Regex GameNameStatusMention = new Regex(@"((is|does|can I play)\s+|(?<dumb>^))(?<game_title>.+?)(\s+((now|currently)\s+)?((is )?playable|work(s|ing)?)(?(dumb)\?))", DefaultOptions);
private static readonly ConcurrentDictionary<ulong, DateTime> CooldownBuckets = new ConcurrentDictionary<ulong, DateTime>();
private static readonly TimeSpan CooldownThreshold = TimeSpan.FromSeconds(5);
private static readonly Client Client = new Client();
@ -67,7 +65,10 @@ namespace CompatBot.EventHandlers
var status = await Client.GetCompatResultAsync(requestBuilder, Config.Cts.Token).ConfigureAwait(false);
if (status.ReturnCode == 0 && status.Results.Any())
{
var info = status.Results.First().Value;
var info = status.GetSortedList().First().Value;
if (CompatApiResultUtils.GetScore(gameTitle, info) < 0.2)
return;
var botSpamChannel = await args.Client.GetChannelAsync(Config.BotSpamId).ConfigureAwait(false);
var msg = $"{args.Author.Mention} {info.Title} is {info.Status.ToLowerInvariant()} since {info.ToUpdated()}\n" +
$"for more results please use compatibility list (<https://rpcs3.net/compatibility>) or `{Config.CommandPrefix}c` command in {botSpamChannel.Mention} (`!c {gameTitle.Sanitize()}`)";

View File

@ -143,6 +143,8 @@ namespace CompatBot.EventHandlers.LogParsing
["'sys_fs_open' failed"] = new Regex(@"'sys_fs_open' failed .+\xE2\x80\x9C/dev_bdvd/(?<broken_filename>.+)\xE2\x80\x9D.*?\r?$", DefaultOptions),
["'sys_fs_opendir' failed"] = new Regex(@"'sys_fs_opendir' failed .+\xE2\x80\x9C/dev_bdvd/(?<broken_directory>.+)\xE2\x80\x9D.*?\r?$", DefaultOptions),
["LDR: EDAT: "] = new Regex(@"EDAT: Block at offset (?<edat_block_offset>0x[0-9a-f]+) has invalid hash!.*?\r?$", DefaultOptions),
["PS3 firmware is not installed"] = new Regex(@"(?<fw_missing_msg>PS3 firmware is not installed.+)\r?$", DefaultOptions),
["do you have the PS3 firmware installed"] = new Regex(@"(?<fw_missing_something>do you have the PS3 firmware installed.*)\r?$", DefaultOptions),
},
OnSectionEnd = MarkAsCompleteAndReset,
EndTrigger = "All threads stopped...",
@ -186,7 +188,9 @@ namespace CompatBot.EventHandlers.LogParsing
"build_and_specs",
"vulkan_gpu", "d3d_gpu",
"driver_version", "driver_manuf",
"driver_manuf_new", "driver_version_new"
"driver_manuf_new", "driver_version_new",
"vulkan_found_device", "vulkan_compatible_device_name",
"vulkan_gpu", "vulkan_driver_version_raw"
);
#if DEBUG
Console.WriteLine("===== cleared");

View File

@ -11,7 +11,7 @@ namespace CompatBot.EventHandlers
private static readonly TimeSpan PassiveCheckInterval = TimeSpan.FromHours(1);
private static readonly TimeSpan ActiveCheckInterval = TimeSpan.FromSeconds(5);
public static TimeSpan CheckInterval { get; private set; } = PassiveCheckInterval;
public static DateTime? RapidStart { get; private set; } = null;
public static DateTime? RapidStart { get; private set; }
public static Task OnMessageCreated(MessageCreateEventArgs args)
{

View File

@ -40,12 +40,14 @@ namespace CompatBot.EventHandlers
try
{
var explanation = await GetLogUploadExplanationAsync().ConfigureAwait(false);
var lastBotMessages = await args.Channel.GetMessagesBeforeAsync(args.Message.Id, 20, DateTime.UtcNow.AddSeconds(-30)).ConfigureAwait(false);
foreach (var msg in lastBotMessages)
if (BotShutupHandler.NeedToSilence(msg).needToChill)
if (BotShutupHandler.NeedToSilence(msg).needToChill
|| (msg.Author.IsCurrent && msg.Content == explanation))
return;
var explanation = await GetLogUploadExplanationAsync().ConfigureAwait(false);
await args.Channel.SendMessageAsync(explanation).ConfigureAwait(false);
lastMention = DateTime.UtcNow;
}

View File

@ -1,5 +1,6 @@
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using CompatBot.Commands.Attributes;
using DSharpPlus.CommandsNext;
using DSharpPlus.Entities;
@ -14,6 +15,11 @@ namespace CompatBot.Utils
return ctx.Channel.IsPrivate ? ctx.Channel : await ctx.Member.CreateDmChannelAsync().ConfigureAwait(false);
}
public static Task<DiscordChannel> GetChannelForSpamAsync(this CommandContext ctx)
{
return LimitedToSpamChannel.IsSpamChannel(ctx.Channel) ? Task.FromResult(ctx.Channel) : ctx.CreateDmAsync();
}
public static Task<string> GetUserNameAsync(this CommandContext ctx, ulong userId, bool? forDmPurposes = null, string defaultName = "Unknown user")
{
return ctx.Client.GetUserNameAsync(ctx.Channel, userId, forDmPurposes, defaultName);

View File

@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CompatApiClient.POCOs;
namespace CompatBot.Utils
{
internal static class CompatApiResultUtils
{
public static List<KeyValuePair<string, TitleInfo>> GetSortedList(this CompatResult result)
{
var search = result.RequestBuilder.search;
var sortedList = result.Results.ToList();
if (!string.IsNullOrEmpty(search))
sortedList = sortedList
.OrderByDescending(kvp => GetScore(search, kvp.Value))
.ThenBy(kvp => kvp.Value.Title)
.ThenBy(kvp => kvp.Key)
.ToList();
if (GetScore(search, sortedList.First().Value) < 0.2)
sortedList = sortedList
.OrderBy(kvp => kvp.Value.Title)
.ThenBy(kvp => kvp.Key)
.ToList();
return sortedList;
}
public static double GetScore(string search, TitleInfo titleInfo)
{
var score = Math.Max(
search.GetFuzzyCoefficientCached(titleInfo.Title),
search.GetFuzzyCoefficientCached(titleInfo.AlternativeTitle)
);
if (score > 0.3)
return score;
return 0;
}
}
}

View File

@ -476,6 +476,11 @@ namespace CompatBot.Utils.ResultFormatters
if (!string.IsNullOrEmpty(items["xaudio_init_error"]))
notes.AppendLine("XAudio initialization failed; make sure you have audio output device working");
if (!string.IsNullOrEmpty(items["fw_missing_msg"])
|| !string.IsNullOrEmpty(items["fw_missing_something"]))
notes.AppendLine("PS3 firmware is missing or corrupted");
if (state.Error == LogParseState.ErrorCode.SizeLimit)
notes.AppendLine("The log was too large, so only the last processed run is shown");

View File

@ -3,6 +3,9 @@ using System.Buffers;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using DuoVia.FuzzyStrings;
using HomoglyphConverter;
using Microsoft.Extensions.Caching.Memory;
namespace CompatBot.Utils
{
@ -13,6 +16,7 @@ namespace CompatBot.Utils
.GetEncoding()
?? Encoding.ASCII;
private static readonly Encoding Utf8 = new UTF8Encoding(false);
private static readonly MemoryCache FuzzyPairCache = new MemoryCache(new MemoryCacheOptions {ExpirationScanFrequency = TimeSpan.FromMinutes(10)});
private static readonly HashSet<char> SpaceCharacters = new HashSet<char>
{
@ -105,5 +109,62 @@ namespace CompatBot.Utils
return len == 0 ? "" : str.Substring(start, len);
}
internal static string GetAcronym(this string str)
{
if (string.IsNullOrEmpty(str))
return str;
var result = "";
bool previousWasLetter = false;
foreach (var c in str)
{
var isLetter = char.IsLetterOrDigit(c);
if (isLetter && !previousWasLetter)
result += c;
previousWasLetter = isLetter;
}
return result;
}
internal static double GetFuzzyCoefficientCached(this string strA, string strB)
{
strA = strA?.ToLowerInvariant() ?? "";
strB = strB?.ToLowerInvariant() ?? "";
var cacheKey = GetFuzzyCacheKey(strA, strB);
if (!FuzzyPairCache.TryGetValue(cacheKey, out FuzzyCacheValue match)
|| strA != match.StrA
|| strB != match.StrB)
match = new FuzzyCacheValue
{
StrA = strA,
StrB = strB,
Coefficient = Normalizer.ToCanonicalForm(strA).GetScoreWithAcronym(Normalizer.ToCanonicalForm(strB)),
};
FuzzyPairCache.Set(cacheKey, match);
return match.Coefficient;
}
private static double GetScoreWithAcronym(this string strA, string strB)
{
return Math.Max(
strA.DiceCoefficient(strB),
strA.DiceCoefficient(strB.GetAcronym().ToLowerInvariant())
);
}
private static (long, int) GetFuzzyCacheKey(string strA, string strB)
{
var hashPair = (((long) (strA.GetHashCode())) << (sizeof(int) * 8)) | (((long) strB.GetHashCode()) & ((long) uint.MaxValue));
var lengthPair = (strA.Length << (sizeof(short) * 8)) | (strB.Length & ushort.MaxValue);
return (hashPair, lengthPair);
}
private class FuzzyCacheValue
{
public string StrA;
public string StrB;
public double Coefficient;
}
}
}