Reconhecer data e hora na fala natural


Desafio


Olá Habr! Fiquei interessado nas habilidades para Alice e comecei a pensar em quais benefícios eles poderiam trazer. Existem muitos jogos legais diferentes no site (incluindo o meu), mas eu queria criar uma ferramenta de trabalho realmente necessária no desempenho de voz, e não apenas copiar o bot de bate-papo existente com botões.


A voz é relevante quando os ponteiros estão ocupados ou você precisa executar muitas operações seqüenciais, especialmente na tela do telefone. Assim, surgiu a idéia de uma habilidade que, por um comando, extrai uma indicação da data e hora do texto e adiciona um evento com esse texto ao Google Agenda . Por exemplo, se um usuário disser que depois de amanhã às 23 horas haverá um belo pôr do sol , a linha Haverá um belo pôr do sol no calendário para o dia depois de amanhã às 23:00.


Sob o gato, há uma descrição do algoritmo da biblioteca Hors : um reconhecedor de data e hora no discurso russo natural. Cavalo é o deus eslavo do sol.


Github Nuget


Soluções existentes


dateparser em Python
O suporte ao idioma russo é declarado, mas em russo a biblioteca não consegue lidar com coisas básicas:


>>> import dateparser >>> dateparser.parse(u'13  2015 .  13:34') datetime.datetime(2015, 1, 13, 13, 34) >>> dateparser.parse(u' ') >>> dateparser.parse(u'    9 ') >>> dateparser.parse(u'13 ') datetime.datetime(2019, 10, 13, 0, 0) >>> dateparser.parse(u'13   9 ') 

crônica em Ruby
Uma biblioteca conhecida pelos rubistas, que, dizem eles, funciona perfeitamente. Mas o apoio ao russo não foi encontrado.


Assistente do Google
Como estamos falando sobre adicionar voz ao Google Agenda, perguntamos: por que não usar o Assistente? É possível, mas a idéia do projeto é fazer o trabalho em uma frase sem gestos e toques desnecessários. E para fazê-lo de forma confiável. O Assistente tem problemas com isso por enquanto:



Predefinições


Eu escrevi uma biblioteca no .NETStandard 2.0 (C #) . Como a biblioteca foi criada originalmente para Alice, todos os números no texto devem ser números, porque Alice faz essa conversão automaticamente. Se você tiver numerais em strings, há um maravilhoso artigo do Doomer3D sobre como transformar palavras em números.


Morfologia


Ao trabalhar com entrada de voz, a maneira mais confiável de distinguir as palavras certas das erradas é usar formas de palavras. Um amigo e eu conversamos sobre isso em mais detalhes em um tutorial em vídeo da Escola Alice da Yandex. Neste artigo, deixarei os bastidores trabalharem com formas de palavras. Tais construções aparecerão no código:


 Morph.HasOneOfLemmas(t, "", "", ""); 

Esta função retornará true se a palavra t for qualquer forma de uma das três seguintes palavras, por exemplo: passado , passado , anterior .


Teoria


Para entender como capturar datas no texto, você precisa listar as frases típicas que usamos para indicar datas em conversas reais. Por exemplo:


- Eu vou dar uma volta amanhã
- Vou dar uma volta amanhã à noite
- próxima quinta-feira eu vou ao cinema
- próxima quinta-feira às 21:00, vou ao cinema
- 21 de março, às 10 horas.


Vemos que as palavras como um todo são divididas em três tipos:


  1. Aqueles que sempre se referem a datas e horários (nomes de meses e dias)
  2. Aqueles relacionados à data e hora em uma determinada posição em relação a outras palavras ("dia", "tarde", "próximo", números)
  3. Aqueles que nunca têm data e hora

Com o primeiro e o último, tudo fica claro, mas com o segundo há dificuldades. A versão original do algoritmo era um terrível código de espaguete com um grande número de if , porque eu tentei levar em consideração todas as combinações e permutações possíveis das palavras que eu precisava, mas depois tive uma opção melhor. O fato é que a humanidade já inventou um sistema que permite que você leve rápida e facilmente em consideração permutações e combinações de caracteres: o mecanismo de expressão regular.


Cozinhando uma linha


Dividimos a linha em tokens, removendo os sinais de pontuação e reduzindo tudo para minúsculas. Depois disso, substituímos cada token não vazio por um único caractere para facilitar o trabalho com expressões regulares.


Token (palavra)Símbolo a substituir
"ano"Y
nome do mêsM
nome do dia da semanaD
de voltab
"depois"l (L inferior)
"através"eu
"dia de folga"W
minutoe
"hora"h
"dia"d
"semana"w
mêsm
"passado", "passado", "anterior"s
"this", "current", "current"vc
"mais próximo", "futuro"y
"próximo", "futuro"x
"depois de amanhã"6
amanhã5
hoje4
ontem3
"anteontem"2
manhãr
meio dian
"noite"v
"noite"g
metadeH
quartoQ
c, cf
"para", "por"t
emsobre
"número"#
"e"N
número maior que 1900 e menor que 99991
número não negativo inferior a 19010 0
data já processada pelo algoritmo@
qualquer outro token_

ParserExtractors.cs
internal static class ParserExtractors
{
internal static string CreatePatternFromToken(string token)
{
var t = token.ToLower().Replace("[^0-9--]", "").Trim();
if (Morph.HasOneOfLemmas(t, Keywords.Year)) return "Y";
if (Morph.HasOneOfLemmas(t, Keywords.Months().ToArray())) return "M";
if (Morph.HasOneOfLemmas(t, Keywords.DaysOfWeek().ToArray())) return "D";
if (Morph.HasOneOfLemmas(t, Keywords.PreviousPostfix)) return "b";
if (Morph.HasOneOfLemmas(t, Keywords.AfterPostfix)) return "l";
if (Morph.HasOneOfLemmas(t, Keywords.After)) return "i";
if (Morph.HasOneOfLemmas(t, Keywords.Holiday)) return "W";
var p = PeriodFromToken(t);
switch (p)
{
case Period.Minute:
return "e";
case Period.Hour:
return "h";
case Period.Day:
return "d";
case Period.Week:
return "w";
case Period.Month:
return "m";
}
var r = RelativeModeFromToken(t);
switch (r)
{
case RelativeMode.Previous:
return "s";
case RelativeMode.Current:
return "u";
case RelativeMode.CurrentNext:
return "y";
case RelativeMode.Next:
return "x";
}
var n = NeighbourDaysFromToken(t);
if (n > int.MinValue)
{
return (n + 4).ToString();
}
var d = DaytimeFromToken(t);
switch (d)
{
case DayTime.Morning:
return "r";
case DayTime.Noon:
return "n";
case DayTime.Day:
return "a";
case DayTime.Evening:
return "v";
case DayTime.Night:
return "g";
}
var pt = PartTimeFromToken(t);
switch (pt)
{
case PartTime.Quarter:
return "Q";
case PartTime.Half:
return "H";
}
if (int.TryParse(t, out var c))
{
if (c < 0 || c > 9999) return "_";
if (c > 1900) return "1";
return "0";
}
if (Morph.HasOneOfLemmas(t, Keywords.TimeFrom)) return "f";
if (Morph.HasOneOfLemmas(t, Keywords.TimeTo)) return "t";
if (Morph.HasOneOfLemmas(t, Keywords.TimeOn)) return "o";
if (Morph.HasOneOfLemmas(t, Keywords.DayInMonth)) return "#";
if (t == "") return "N";
return "_";
}
private static PartTime PartTimeFromToken(string t)
{
if (Morph.HasOneOfLemmas(t, Keywords.Quarter)) return PartTime.Quarter;
if (Morph.HasOneOfLemmas(t, Keywords.Half)) return PartTime.Half;
return PartTime.None;
}
private static DayTime DaytimeFromToken(string t)
{
if (Morph.HasOneOfLemmas(t, Keywords.Noon)) return DayTime.Noon;
if (Morph.HasOneOfLemmas(t, Keywords.Morning)) return DayTime.Morning;
if (Morph.HasOneOfLemmas(t, Keywords.Evening)) return DayTime.Evening;
if (Morph.HasOneOfLemmas(t, Keywords.Night)) return DayTime.Night;
if (Morph.HasOneOfLemmas(t, Keywords.DaytimeDay)) return DayTime.Day;
return DayTime.None;
}
private static Period PeriodFromToken(string t)
{
if (Morph.HasOneOfLemmas(t, Keywords.Year)) return Period.Year;
if (Morph.HasOneOfLemmas(t, Keywords.Month)) return Period.Month;
if (Morph.HasOneOfLemmas(t, Keywords.Week)) return Period.Week;
if (Morph.HasOneOfLemmas(t, Keywords.Day)) return Period.Day;
if (Morph.HasOneOfLemmas(t, Keywords.Hour)) return Period.Hour;
if (Morph.HasOneOfLemmas(t, Keywords.Minute)) return Period.Minute;
return Period.None;
}
private static int NeighbourDaysFromToken(string t)
{
if (Morph.HasOneOfLemmas(t, Keywords.Tomorrow)) return 1;
if (Morph.HasOneOfLemmas(t, Keywords.Today)) return 0;
if (Morph.HasOneOfLemmas(t, Keywords.AfterTomorrow)) return 2;
if (Morph.HasOneOfLemmas(t, Keywords.Yesterday)) return -1;
if (Morph.HasOneOfLemmas(t, Keywords.BeforeYesterday)) return -2;
return int.MinValue;
}
internal static RelativeMode RelativeModeFromToken(string t)
{
if (Morph.HasOneOfLemmas(t, Keywords.Current)) return RelativeMode.Current;
if (Morph.HasOneOfLemmas(t, Keywords.Next)) return RelativeMode.Next;
if (Morph.HasOneOfLemmas(t, Keywords.Previous)) return RelativeMode.Previous;
if (Morph.HasOneOfLemmas(t, Keywords.CurrentNext)) return RelativeMode.CurrentNext;
return RelativeMode.None;
}
}

Palavras-chave.cs
public class Keywords
{
public static readonly string[] After = {""};
public static readonly string[] AfterPostfix = {""};
public static readonly string[] PreviousPostfix = {""};
public static readonly string[] Next = {"", ""};
public static readonly string[] Previous = {"", "", ""};
public static readonly string[] Current = {"", "", ""};
public static readonly string[] CurrentNext = {"", ""};
public static readonly string[] Today = {""};
public static readonly string[] Tomorrow = {""};
public static readonly string[] AfterTomorrow = {""};
public static readonly string[] Yesterday = {""};
public static readonly string[] BeforeYesterday = {""};
public static readonly string[] Holiday = {""};
public static readonly string[] Second = {"", ""};
public static readonly string[] Minute = {"", ""};
public static readonly string[] Hour = {"", ""};
public static readonly string[] Day = {""};
public static readonly string[] Week = {""};
public static readonly string[] Month = {"", ""};
public static readonly string[] Year = {""};
public static readonly string[] Noon = {""};
public static readonly string[] Morning = {""};
public static readonly string[] Evening = {""};
public static readonly string[] Night = {""};
public static readonly string[] Half = {"", ""};
public static readonly string[] Quarter = {""};
public static readonly string[] DayInMonth = {""};
public static readonly string[] January = {"", ""};
public static readonly string[] February = {"", ""};
public static readonly string[] March = {"", ""};
public static readonly string[] April = {"", ""};
public static readonly string[] May = {"", ""};
public static readonly string[] June = {"", ""};
public static readonly string[] July = {"", ""};
public static readonly string[] August = {"", ""};
public static readonly string[] September = {"", "", ""};
public static readonly string[] October = {"", ""};
public static readonly string[] November = {"", "", ""};
public static readonly string[] December = {"", ""};
public static readonly string[] Monday = {"", ""};
public static readonly string[] Tuesday = {"", ""};
public static readonly string[] Wednesday = {"", ""};
public static readonly string[] Thursday = {"", ""};
public static readonly string[] Friday = {"", ""};
public static readonly string[] Saturday = {"", ""};
public static readonly string[] Sunday = {"", ""};
public static readonly string[] DaytimeDay = {"", ""};
public static readonly string[] TimeFrom = {"", ""};
public static readonly string[] TimeTo = {"", ""};
public static readonly string[] TimeOn = {""};
public static List<string[]> Months()
{
return new List<string[]>
{
January,
February,
March,
April,
May,
June,
July,
August,
September,
October,
November,
December
};
}
public static List<string[]> DaysOfWeek()
{
return new List<string[]>
{
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday
};
}
public List<string> AllValues()
{
var values = new List<string>();
GetType()
.GetFields(BindingFlags.Static | BindingFlags.Public)
.ToList()
.ForEach(f =>
{
var words = (string[]) f.GetValue(null);
words.ToList().ForEach(values.Add);
});
return values;
}
}
view raw Keywords.cs hosted with ❤ by GitHub

Aqui está o que acontece para as linhas que mencionamos:


StringResultado
Amanhã vou dar uma volta5__
amanhã à noite vou dar uma volta5v__
próxima quinta-feira eu vou ao cinemafxD_f_
próxima quinta-feira às 21:00 eu vou ao cinemafxDf0v_f_
21 de março às 10h na reunião0Mf0r_

Reconhecimento


Oh, horror! - Você diz - só ficou pior! Tal rabisco e uma pessoa não pode entender. Sim, eu tive que fazer um acordo entre a conveniência de ler por uma pessoa e a conveniência de trabalhar com pessoas regulares, veja abaixo exatamente como.


Em seguida, aplicamos um padrão chamado cadeia de tarefas : a matriz de dados de entrada é alimentada sequencialmente para diferentes processadores, que podem ou não alterá-lo e transmiti-lo ainda mais. Liguei para o manipulador Recognizer e fiz esse manipulador para cada variante de uma frase típica (como me pareceu) relacionada a data e hora.


Recognizer procura um determinado padrão de expressão regular em uma sequência e inicia uma função de processamento para cada correspondência encontrada que pode modificar a sequência de entrada e adicionar datas capturadas a uma matriz especial.


Recognizer.cs
public abstract class Recognizer
{
public void ParseTokens(DatesRawData data, DateTime userDate)
{
ForAllMatches(data.GetPattern, pattern: GetRegexPattern(), action: m => ParseMatch(data, m, userDate));
}
public static void ForAllMatches(Func<string> input, string pattern, Predicate<Match> action, bool reversed = false)
{
var matches = Regex.Matches(input.Invoke(), pattern);
if (matches.Count == 0)
{
return;
}
var match = reversed ? matches[matches.Count - 1] : matches[0];
var indexesToSkip = new HashSet<int>();
while (match != null && match.Success)
{
var text = input.Invoke();
var matchIndex = reversed ? text.Length - match.Index : match.Index;
if (!action.Invoke(match))
{
indexesToSkip.Add(matchIndex);
}
match = null;
text = input.Invoke();
matches = Regex.Matches(text, pattern);
for (var i = 0; i < matches.Count; i++)
{
var index = reversed ? matches.Count - i - 1 : i;
matchIndex = reversed ? text.Length - matches[index].Index : matches[index].Index;
if (!indexesToSkip.Contains(matchIndex))
{
match = matches[index];
break;
}
}
}
}
protected abstract string GetRegexPattern();
protected abstract bool ParseMatch(DatesRawData data, Match match, DateTime userDate);
}
view raw Recognizer.cs hosted with ❤ by GitHub

Além disso, os manipuladores devem ser chamados na ordem correta: de mais "confiante" para menos. Por exemplo, no início, você precisa executar manipuladores associados a palavras "estritas" que se relacionem exatamente com datas: nomes de dias, meses, palavras como "amanhã", "depois de amanhã" e assim por diante. E, no final, manipuladores que tentam determinar por saldos brutos se pode haver algo mais relacionado à data.


Por exemplo:


Data e mês


 "((0N?)+)(M|#)"; // 24, 25, 26...  27 / 

Aqui o encanto de usar expressões regulares começa a se manifestar. De uma maneira bastante simples, identificamos uma sequência complexa de tokens: um número diferente de zero de números não negativos menores que 1901 se seguem, possivelmente separados pela união "e", e o nome do mês ou a palavra "número" os segue.


Além disso, no grupo de partidas captamos imediatamente elementos específicos, se houver, e não capturamos se não houver nenhum, e desse conjunto compomos a data final. No código abaixo, haverá uma seção incompreensível relacionada às funções Fix , FixPeriod , passaremos a isso no final do artigo.


DaysMonthRecognizer.cs
public class DaysMonthRecognizer : Recognizer
{
protected override string GetRegexPattern()
{
return "((0N?)+)(M|#)"; // 24, 25, 26... 27 /
}
protected override bool ParseMatch(DatesRawData data, Match match, DateTime userDate)
{
var dates = new List<AbstractPeriod>();
var monthFixed = false;
// parse month
var mStr = data.Tokens[match.Index + match.Groups[1].Length].Value;
var month = ParserUtils.FindIndex(mStr, Keywords.Months()) + 1;
if (month == 0) month = userDate.Month; // # instead M
else monthFixed = true;
// create dates
for (var i = match.Index; i < match.Index + match.Groups[1].Length; i++)
{
var t = data.Tokens[i];
int.TryParse(t.Value, out var day);
if (day <= 0) continue; // this is "AND" or other token
// current token is number, store it as a day
var period = new AbstractPeriod
{
Date = new DateTime(
userDate.Year,
month,
ParserUtils.GetDayValidForMonth(userDate.Year, month, day)
)
};
// fix from week to day, and year/month if it was
period.Fix(FixPeriod.Week, FixPeriod.Day);
if (monthFixed) period.Fix(FixPeriod.Month);
// store
dates.Add(period);
// compare with last if month not fixed
if (!monthFixed && dates.Count > 0 && dates.Last().Date < period.Date)
{
period.Date = new DateTime(
userDate.Year,
month + 1,
ParserUtils.GetDayValidForMonth(userDate.Year, month + 1, day)
);
}
}
// replace all scanned tokens
data.ReplaceTokensByDates(match.Index, match.Length, dates.ToArray());
return true;
}
}

Lapso de tempo


 "(i)?((0?[Ymwdhe]N?)+)([bl])?"; // ()     2  4  10  (/) 

É importante observar que, às vezes, apenas o fato de coincidir com a temporada regular não é suficiente, e você ainda precisa adicionar alguma lógica ao manipulador. No entanto, foi possível fazer dois manipuladores separados para esses casos, mas me pareceu um excesso. Como resultado, eu combino o inicial "a" e o final "mais tarde / para trás", mas o código do manipulador começa com uma verificação:


 if (match.Groups[1].Success ^ match.Groups[4].Success) 

Onde ^ é o OR exclusivo.


TimeSpanRecognizer.cs
public class TimeSpanRecognizer : Recognizer
{
protected override string GetRegexPattern()
{
return "(i)?((0?[Ymwdhe]N?)+)([bl])?"; // () 2 4 10 (/)
}
protected override bool ParseMatch(DatesRawData data, Match match, DateTime userDate)
{
if (match.Groups[1].Success ^ match.Groups[4].Success)
{
// if "after" of "before", but not both and not neither
var letters = match.Groups[2].Value.Select(s => s.ToString()).ToList();
var lastNumber = 1;
var tokenIndex = match.Groups[2].Index;
var direction = 1; // moving to the future
if (match.Groups[4].Success && match.Groups[4].Value == "b")
{
direction = -1; // "before"
}
var date = new AbstractPeriod
{
SpanDirection = direction,
};
// save current day to offser object
var offset = new DateTimeOffset(userDate);
letters.ForEach(l =>
{
switch (l)
{
case "N": // "and", skip it
break;
case "0": // number, store it
int.TryParse(data.Tokens[tokenIndex].Value, out lastNumber);
break;
case "Y": // year(s)
offset = offset.AddYears(direction * lastNumber);
date.FixDownTo(FixPeriod.Month);
lastNumber = 1;
break;
case "m": // month(s)
offset = offset.AddMonths(direction * lastNumber);
date.FixDownTo(FixPeriod.Week);
lastNumber = 1;
break;
case "w": // week(s)
offset = offset.AddDays(7 * direction * lastNumber);
date.FixDownTo(FixPeriod.Day);
lastNumber = 1;
break;
case "d": // day(s)
offset = offset.AddDays(direction * lastNumber);
date.FixDownTo(FixPeriod.Day);
lastNumber = 1;
break;
case "h": // hour(s)
offset = offset.AddHours(direction * lastNumber);
date.FixDownTo(FixPeriod.Time);
lastNumber = 1;
break;
case "e": // minute(s)
offset = offset.AddMinutes(direction * lastNumber);
date.FixDownTo(FixPeriod.Time);
break;
}
tokenIndex++;
});
// set date
date.Date = new DateTime(offset.DateTime.Year, offset.DateTime.Month, offset.DateTime.Day);
if (date.IsFixed(FixPeriod.Time))
date.Time = new TimeSpan(offset.DateTime.Hour, offset.DateTime.Minute, 0);
date.Span = offset - userDate;
// remove and insert
data.ReplaceTokensByDates(match.Index, match.Length, date);
return true;
}
return false;
}
}

A propósito, para que essas funções funcionem, o mecanismo precisa transferir a data atual do usuário.


Ano


 "(1)Y?|(0)Y"; // [] 15 /2017 () 

Não posso mais uma vez observar a conveniência da abordagem com os frequentadores. Novamente, com uma expressão simples, indicamos imediatamente opções: o usuário nomeia um número semelhante a um ano, mas sem a palavra "ano", ou o usuário nomeia um número de dois dígitos (que pode ser uma data), mas adiciona a palavra "ano" e suporte para expressões "em 18 anos ", no entanto, não sabemos se isso significa 1918 ou 2018, por isso acreditamos que 2018.


YearRecognizer.cs
public class YearRecognizer : Recognizer
{
protected override string GetRegexPattern()
{
return "(1)Y?|(0)Y"; // [] 15 /2017 ()
}
protected override bool ParseMatch(DatesRawData data, Match match, DateTime userDate)
{
// just year number
int.TryParse(data.Tokens[match.Index].Value, out var n);
var year = ParserUtils.GetYearFromNumber(n);
// insert date
var date = new AbstractPeriod
{
Date = new DateTime(year, 1, 1)
};
date.Fix(FixPeriod.Year);
// remove and insert
data.ReplaceTokensByDates(match.Index, match.Length, date);
return true;
}
}
view raw YearRecognizer.cs hosted with ❤ by GitHub

Tempo


 "([rvgd])?([fot])?(Q|H)?(h|(0)(h)?)((0)e?)?([rvgd])?"; // (//) (/) /9 () (30 ()) (///) 

A expressão mais difícil da minha coleção é responsável pelas frases para indicar a hora do dia, das estritas às 21h30 e às quinze e as 23 horas.


TimeRecognizer.cs
public class TimeRecognizer : Recognizer
{
protected override string GetRegexPattern()
{
return "([rvgd])?([fot])?(Q|H)?(h|(0)(h)?)((0)e?)?([rvgd])?"; // (//) (/) /9 () (30 ()) (///)
}
protected override bool ParseMatch(DatesRawData data, Match match, DateTime userDate)
{
// determine if it is time
if (
match.Groups[5].Success //
|| match.Groups[6].Success // ""
|| match.Groups[4].Success // ""
|| match.Groups[1].Success // "///"
|| match.Groups[9].Success //
)
{
if (!match.Groups[5].Success)
{
// no number in phrase
var partOfDay = match.Groups[9].Success
? match.Groups[9].Value
: match.Groups[1].Success
? match.Groups[1].Value
: "";
// no part of day AND no "from" token in phrase, quit
if (partOfDay != "d" && partOfDay != "g" && !match.Groups[2].Success)
{
return false;
}
}
// hours and minutes
var hours = match.Groups[5].Success ? int.Parse(data.Tokens[match.Groups[5].Index].Value) : 1;
if (hours >= 0 && hours <= 23)
{
// try minutes
var minutes = 0;
if (match.Groups[8].Success)
{
var m = int.Parse(data.Tokens[match.Groups[8].Index].Value);
if (m >= 0 && m <= 59) minutes = m;
}
else if (match.Groups[3].Success && hours > 0)
{
switch (match.Groups[3].Value)
{
case "Q": // quarter
hours--;
minutes = 15;
break;
case "H": // half
hours--;
minutes = 30;
break;
}
}
// create time
var date = new AbstractPeriod();
date.Fix(FixPeriod.TimeUncertain);
if (hours > 12) date.Fix(FixPeriod.Time);
// correct time
if (hours <= 12)
{
var part = "d"; // default
if (match.Groups[9].Success || match.Groups[1].Success)
{
// part of day
part = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[9].Value;
date.Fix(FixPeriod.Time);
}
else
{
date.Fix(FixPeriod.TimeUncertain);
}
switch (part)
{
case "d": // day
if (hours <= 4) hours += 12;
break;
case "v": // evening
if (hours <= 11) hours += 12;
break;
case "g": // night
hours += 12;
break;
}
if (hours == 24) hours = 0;
}
date.Time = new TimeSpan(hours, minutes, 0);
// remove and insert
var toTime = data.Tokens[match.Index];
data.ReplaceTokensByDates(match.Index, match.Length, date);
if (match.Groups[2].Success && match.Groups[2].Value == "t")
{
// return "to" to correct period parsing
data.ReturnTokens(match.Index, "t", toTime);
}
return true;
}
}
return false;
}
}
view raw TimeRecognizer.cs hosted with ❤ by GitHub

Todos os manipuladores em ordem de aplicação


O sistema é modular, entende-se que você pode adicionar / remover manipuladores, alterar sua ordem e assim por diante. Eu fiz 11 deles (de cima para baixo em ordem de aplicação):


HandlerRegexExemplo de linha
HolidaysRecognizer.cs 1Wdia de folga , fim de semana
DatesPeriodRecognizer.csf?(0)[ot]0(M|#)de 26 a 27 de janeiro / data
DaysMonthRecognizer.cs((0N?)+)(M|#)24, 25, 26 de janeiro ... e 27 de janeiro / data
MonthRecognizer.cs([usxy])?M[no] (passado / presente / próximo) março
RelativeDayRecognizer.cs[2-6]anteontem, ontem, hoje, amanhã, depois de amanhã
TimeSpanRecognizer.cs(i)?((0?[Ymwdhe]N?)+)([bl])?(em) ano e mês e 2 dias 4 horas 10 minutos (mais tarde / mais tarde)
YearRecognizer.cs(1)Y?|(0)Y[no] 15 ano / 2017 (ano)
RelativeDateRecognizer.cs([usxy])([Ymwd])[on / on] próximo / este / ano / mês / semana / dia anterior
DayOfWeekRecognizer.cs([usxy])?(D)[no] (próximo / isto / anterior) segunda-feira
TimeRecognizer.cs([rvgd])?([fot])?(Q|H)?(h|(0)(h)?)((0)e?)?([rvgd])?(w / s / up) (meio quarto) hora / 9 (horas) (30 (minutos)) (manhã / dia / tarde / noite)
PartOfDayRecognizer.cs(@)?f?([ravgdn])f?(@)?(data) (w / s) manhã / tarde / noite / noite (w / s) (data)

1 "" "" "" " ",


Costurar juntos


Ok, definimos alguns tokens de data e hora elementares. Mas agora ainda precisamos levar em consideração todas as combinações de todos os tipos de tokens: o usuário pode nomear primeiro o dia e depois a hora. Ou nomeie o mês e o dia. Se ele diz apenas tempo, provavelmente é hoje, e assim por diante. Para fazer isso, criamos o conceito de fixação .


Uma correção no nosso caso é uma máscara de bit dentro de cada token de data e hora, que mostra quais elementos de data e hora estão definidos nele.


 public enum FixPeriod { None = 0, Time = 1, TimeUncertain = 2, Day = 4, Week = 8, Month = 16, Year = 32 } 

TimeUncertain é uma confirmação para indicar o tempo após o qual um refinamento pode ocorrer, por exemplo, à noite / pela manhã . Não pode ser dito 18 horas da manhã , mas pode ser dito 6 horas da noite , portanto, o número 6 tem, por assim dizer, menos "certeza" sobre que horas significa que o número 18 .


FraseFixaçãoMask
26 de março de 2019ano , mês , semana , den , vrm1, vrm2111100
26 númerosano, mês, semana, den , vrm1, vrm2000100
segunda-feiraano, mês, semana, den , vrm1, vrm2000100
próxima segundaano , mês , semana , den , vrm1, vrm2111100
semana que vemano , mês , semana , den, vrm1, vrm2111000
às 9 horasano, mês, semana, den, vrm1 , vrm2000010
às 21:00ano, mês, semana, den, vrm1 , vrm2000011

Em seguida, usaremos duas regras:


  • Se as datas estiverem próximas e elas não tiverem bits comuns, então as mesclamos em uma
  • Se as datas estão próximas e não as combinamos, mas uma delas possui bits que o segundo não possui, ocupamos os bits (e os valores correspondentes a eles) daquele em que estão, no que não têm, mas apenas para o maior: isto é, não podemos demorar um dia se apenas um mês for dado, mas podemos demorar um mês se apenas um dia for definido.


Portanto, se o usuário falar na segunda-feira às 21:00 , o token na segunda-feira , onde apenas o dia está definido, será combinado com o token às 21:00 , onde o horário será definido. Mas se ele disser em 10 e 15 de março do dia , o último token do dia 15 simplesmente levará o mês de março do token anterior.


A união é trivial, mas, com o empréstimo, procederemos com simplicidade. Vamos chamar a data base para a qual ocupamos e a segunda secundária , da qual queremos emprestar alguma coisa. Em seguida, copiamos a data secundária e deixamos apenas os bits que a base não possui:


 var baseDate = data.Dates[firstIndex]; var secondDate = data.Dates[secondIndex]; var secondCopy = secondDate.CopyOf(); secondCopy.Fixed &= (byte)~baseDate.Fixed; 

Por exemplo, se a base tiver um ano, não deixaremos a fixação do ano para a cópia do menor (mesmo que fosse). Se a base não tiver um mês, mas o secundário tiver, ela permanecerá com a cópia como secundária. Depois disso, realizamos o processo de combinação da data base e da cópia secundária.


Quando combinados, também temos uma data base e uma data absorvida . Vamos de cima para baixo, do maior período (ano) ao menor (tempo). Se a data base não possui algum parâmetro que o absorvido possui, nós o adicionamos à base do absorvido.


Collapse.cs
public static bool CanCollapse(AbstractPeriod basePeriod, AbstractPeriod coverPeriod)
{
if ((basePeriod.Fixed & coverPeriod.Fixed) != 0) return false;
return basePeriod.SpanDirection != -coverPeriod.SpanDirection || basePeriod.SpanDirection == 0;
}
public static bool CollapseTwo(AbstractPeriod basePeriod, AbstractPeriod coverPeriod)
{
if (!CanCollapse(basePeriod, coverPeriod)) return false;
// if span
if (basePeriod.SpanDirection != 0)
{
if (coverPeriod.SpanDirection != 0)
{
// if another date is Span, just add spans together
basePeriod.Span += coverPeriod.Span;
}
}
// take year if it is not here, but is in other date
if (!basePeriod.IsFixed(FixPeriod.Year) && coverPeriod.IsFixed(FixPeriod.Year))
{
basePeriod.Date = new DateTime(coverPeriod.Date.Year, basePeriod.Date.Month, basePeriod.Date.Day);
basePeriod.Fix(FixPeriod.Year);
}
// take month if it is not here, but is in other date
if (!basePeriod.IsFixed(FixPeriod.Month) && coverPeriod.IsFixed(FixPeriod.Month))
{
basePeriod.Date = new DateTime(basePeriod.Date.Year, coverPeriod.Date.Month, basePeriod.Date.Day);
basePeriod.Fix(FixPeriod.Month);
}
// week and day
if (!basePeriod.IsFixed(FixPeriod.Week) && coverPeriod.IsFixed(FixPeriod.Week))
{
// the week is in another date, check where is a day
if (basePeriod.IsFixed(FixPeriod.Day))
{
// set day of week, take date
basePeriod.Date = TakeDayOfWeekFrom(coverPeriod.Date, basePeriod.Date);
basePeriod.Fix(FixPeriod.Week);
}
else if (!coverPeriod.IsFixed(FixPeriod.Day))
{
// only week here, take it by taking a day
basePeriod.Date = new DateTime(basePeriod.Date.Year, basePeriod.Date.Month, coverPeriod.Date.Day);
basePeriod.Fix(FixPeriod.Week);
}
}
else if (basePeriod.IsFixed(FixPeriod.Week) && coverPeriod.IsFixed(FixPeriod.Day))
{
// here is a week, but day of week in other date
basePeriod.Date = TakeDayOfWeekFrom(basePeriod.Date, coverPeriod.Date);
basePeriod.Fix(FixPeriod.Week, FixPeriod.Day);
}
// day
if (!basePeriod.IsFixed(FixPeriod.Day) && coverPeriod.IsFixed(FixPeriod.Day))
{
if (coverPeriod.FixDayOfWeek)
{
// take only day of week from cover
basePeriod.Date = TakeDayOfWeekFrom(
new DateTime(
basePeriod.Date.Year, basePeriod.Date.Month,
basePeriod.IsFixed(FixPeriod.Week) ? basePeriod.Date.Day : 1
),
coverPeriod.Date,
!basePeriod.IsFixed(FixPeriod.Week)
);
basePeriod.Fix(FixPeriod.Week, FixPeriod.Day);
}
else
{
// take day from cover
basePeriod.Date = new DateTime(basePeriod.Date.Year, basePeriod.Date.Month, coverPeriod.Date.Day);
basePeriod.Fix(FixPeriod.Week, FixPeriod.Day);
}
}
// time
var timeGot = false;
if (!basePeriod.IsFixed(FixPeriod.Time) && coverPeriod.IsFixed(FixPeriod.Time))
{
basePeriod.Fix(FixPeriod.Time);
if (!basePeriod.IsFixed(FixPeriod.TimeUncertain))
{
basePeriod.Time = coverPeriod.Time;
}
else
{
if (basePeriod.Time.Hours <= 12 && coverPeriod.Time.Hours > 12)
{
basePeriod.Time += new TimeSpan(12, 0, 0);
}
}
timeGot = true;
}
if (!basePeriod.IsFixed(FixPeriod.TimeUncertain) && coverPeriod.IsFixed(FixPeriod.TimeUncertain))
{
basePeriod.Fix(FixPeriod.TimeUncertain);
if (basePeriod.IsFixed(FixPeriod.Time))
{
// take time from cover, but day part from base
var offset = coverPeriod.Time.Hours <= 12 && basePeriod.Time.Hours > 12 ? 12 : 0;
basePeriod.Time = new TimeSpan(coverPeriod.Time.Hours + offset, coverPeriod.Time.Minutes, 0);
}
else
{
basePeriod.Time = coverPeriod.Time;
timeGot = true;
}
}
// if this date is Span and we just got time from another non-span date, add this time to Span
if (timeGot && basePeriod.SpanDirection != 0 && coverPeriod.SpanDirection == 0)
{
basePeriod.Span += basePeriod.SpanDirection == 1 ? basePeriod.Time : -basePeriod.Time;
}
// set tokens edges
basePeriod.Start = Math.Min(basePeriod.Start, coverPeriod.Start);
basePeriod.End = Math.Max(basePeriod.End, coverPeriod.End);
return true;
}
view raw Collapse.cs hosted with ❤ by GitHub

Separadamente, é necessário levar em consideração algumas nuances:


  • A data base pode ter um dia fixo, mas não uma semana. Nesse caso, você precisa levar uma semana a partir da data de absorção, mas o dia da semana a partir da base.
  • No entanto, se a data absorvida não tiver um dia fixo, mas uma semana for fixada, precisamos tirar o dia dela (ou seja, ano + mês + data) e definir a semana dessa maneira, porque não há uma entidade separada "Semana" no objeto DateTime .
  • Se TimeUncertain fixado na data base e Time absorvido no absorvido, e o número de horas no absorvido for superior a 12, enquanto o base tiver menos, adicione 12 horas à base. Porque você não pode dizer das 5 às 17 da noite . O horário da data "incerta" não pode ser de metade do dia, se o horário da data "confiante" ao lado for da outra metade do dia. As pessoas não dizem isso. Se dissermos a frase das 17h às 17h , as duas datas terão um horário "confiante" e não haverá problema.

Após a combinação, se algumas datas tiverem bits vazios, as substituiremos por valores da data atual do usuário: por exemplo, se o usuário não nomeou o ano, estamos falando do ano atual, se você não nomeou o mês, o mês atual e assim por diante. Obviamente, qualquer objeto DateTime pode ser passado para o parâmetro "data atual" para flexibilidade.


Muletas


Nem todos os momentos da biblioteca foram pensados ​​com graça. Para fazê-lo funcionar corretamente, foram adicionadas as seguintes muletas:


  • As datas são combinadas separadamente para tokens que começam com "b \ s \ co" e separadamente para tokens que começam com "b \ to \ on". Porque, se o usuário nomeou o período, é impossível combinar tokens entre a data de início e a data de término.
  • Não comecei a introduzir um nível de fixação separado para o dia da semana, porque é necessário em exatamente um local: onde é claramente indicado por uma palavra com o nome do dia da semana. Fez uma bandeira para isso.
  • Uma execução separada combina datas que estão a uma certa distância uma da outra. Isso é controlado pelo parâmetro collapseDistance , que por padrão é de 4 tokens. Ou seja, por exemplo, a frase funcionará: depois de amanhã, uma reunião com um amigo às 12 . Mas não vai dar certo: depois de amanhã , encontrarei meu amigo amado e maravilhoso aos 12 anos .

Sumário



  • Você pode usar a biblioteca em seus projetos. Ela já lida com muitas opções, mas eu refino e refatoro. Solicitações pull com testes são bem-vindas. Em geral, faça um teste que pareça realista (como as pessoas dizem na vida), mas que quebra a biblioteca.
  • A demonstração ao vivo no .NET Fiddle também funciona, embora o código esteja supostamente sublinhado com erros, mas é iniciado. Na parte inferior do console, você pode inserir frases em russo, sem esquecer que os números devem ser números.
  • A mesma demonstração de um aplicativo de console

Live acabou assim:


Source: https://habr.com/ru/post/pt471204/


All Articles