
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:
- Aqueles que sempre se referem a datas e horários (nomes de meses e dias)
- Aqueles relacionados à data e hora em uma determinada posição em relação a outras palavras ("dia", "tarde", "próximo", números)
- 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.
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; |
| } |
| } |
Aqui está o que acontece para as linhas que mencionamos:
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); |
| } |
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|#)";
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])?";
É 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";
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; |
| } |
| } |
Tempo
"([rvgd])?([fot])?(Q|H)?(h|(0)(h)?)((0)e?)?([rvgd])?";
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; |
| } |
| } |
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):
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
.
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; |
| } |
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: