Reconnaître la date et l'heure dans un discours naturel


Défi


Bonjour, Habr! Je me suis intéressée aux compétences d'Alice et j'ai commencé à réfléchir aux avantages qu'elles pouvaient apporter. Il existe de nombreux jeux sympas différents sur le site (y compris le mien), mais je voulais créer un outil de travail vraiment nécessaire pour les performances vocales, et pas seulement copier le bot de chat existant avec des boutons.


La voix est pertinente lorsque les mains sont occupées ou que vous devez effectuer de nombreuses opérations séquentielles, en particulier sur l'écran du téléphone. C'est ainsi qu'est née l'idée d'une compétence qui, par une seule commande, extrait une indication de la date et de l'heure du texte et ajoute un événement avec ce texte à Google Agenda . Par exemple, si un utilisateur dit qu'après-demain à 23 heures, il y aura un beau coucher de soleil , alors la ligne Il y aura un beau coucher de soleil dans le calendrier pour après-demain à 23h00.


Sous le cutter se trouve la description de l'algorithme de la bibliothèque Hors : un outil de reconnaissance de la date et de l'heure dans la langue russe naturelle. Le cheval est le dieu slave du soleil.


Github | Nuget


Solutions existantes


dateparser en Python
La prise en charge de la langue russe est déclarée, mais en russe, la bibliothèque ne peut même pas faire face aux choses de base:


>>> 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 ') 

chronique sur Ruby
Une bibliothèque connue des rubistes qui, disent-ils, fait parfaitement son travail. Mais le soutien au Russe n'est pas trouvé.


Assistant Google
Puisque nous parlons d'ajouter de la voix à Google Agenda, nous demandons pourquoi ne pas utiliser l'Assistant? C'est possible, mais l'idée du projet est de faire le travail en une phrase sans gestes ni tapotements inutiles. Et pour le faire de manière fiable. L'assistant a des problèmes avec ceci pour l'instant:



Presets


J'ai écrit une bibliothèque sur .NETStandard 2.0 (C #) . Étant donné que la bibliothèque a été créée à l'origine pour Alice, tous les chiffres du texte sont censés être des nombres, car Alice effectue automatiquement cette conversion. Si vous avez des chiffres dans des chaînes, alors il y a un merveilleux article Doomer3D sur la façon de transformer des mots en nombres.


Morphologie


Lorsque vous travaillez avec la saisie vocale, la manière la plus fiable de distinguer les bons mots des mauvais est d'utiliser des formes de mots. Un ami et moi en avons parlé plus en détail dans un didacticiel vidéo pour l'école Alice de Yandex. Dans cet article, je vais laisser les coulisses travailler avec des formes de mots. Ces constructions apparaîtront dans le code:


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

Cette fonction renvoie true si le mot t est une forme quelconque de l'un des trois mots suivants, par exemple: past , past , previous .


Théorie


Pour comprendre comment saisir des dates dans le texte, vous devez répertorier les expressions typiques que nous utilisons pour indiquer des dates dans de vraies conversations. Par exemple:


- Je vais me promener demain
- Je vais me promener demain soir
- jeudi prochain, je vais au cinéma
- jeudi prochain à 21h je vais au cinéma
- Réunion du 21 mars à 10 h


Nous voyons que les mots dans leur ensemble sont divisés en trois types:


  1. Celles qui se réfèrent toujours aux dates et heures (noms des mois et des jours)
  2. Celles qui concernent la date et l'heure à une certaine position par rapport à d'autres mots ("jour", "soir", "suivant", nombres)
  3. Ceux qui ne datent et ne se produisent jamais

Avec le premier et le dernier, tout est clair, mais avec le second il y a des difficultés. La version originale de l'algorithme était un terrible code de spaghetti avec un grand nombre de if , car j'ai essayé de prendre en compte toutes les combinaisons et permutations possibles des mots dont j'avais besoin, mais j'ai ensuite trouvé une meilleure option. Le fait est que l'humanité a déjà inventé un système qui permet de prendre en compte rapidement et facilement les permutations et combinaisons de caractères: le moteur d'expression régulière.


Cuisiner une ligne


Nous séparons la ligne en jetons, supprimant les signes de ponctuation et réduisant tout en minuscules. Après cela, nous remplaçons chaque jeton non vide par un seul caractère pour faciliter le travail avec les expressions régulières.


Jeton (mot)Symbole à remplacer
"année"Oui
nom du moisM
nom de la semaineD
retourb
"plus tard"l (inférieur L)
"à travers"je
"jour de congé"W
minutee
"heure"h
"jour"d
"semaine"w
moism
"passé", "passé", "précédent"s
"ceci", "courant", "courant"u
"plus proche", "futur"y
"suivant", "futur"x
"après-demain"6
demain5
aujourd'hui4
hier3
"avant-hier"2
le matinr
midin
"soirée"v
"nuit"g
la moitiéH
trimestreQ
c, cf
"à", "par"t
surà propos
"nombre"#
"et"N
nombre supérieur à 1900 et inférieur à 99991
nombre non négatif inférieur à 19010
date déjà traitée par l'algorithme@
tout autre jeton_

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;
}
}

Keywords.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

Voici ce qui se passe pour les lignes que nous avons mentionnées:


StringRésultat
Je vais me promener demain5__
demain soir je vais me promener5v__
jeudi prochain, je vais au cinémafxD_f_
jeudi prochain à 21h je vais au cinémafxDf0v_f_
Réunion du 21 mars à 10 h0Mf0r_

La reconnaissance


- Oh, l'horreur! - Vous dites, - Ça n'a fait qu'empirer! Un tel charabia et une personne ne peut pas comprendre. Oui, j'ai dû faire un compromis entre la commodité de la lecture par une personne et la commodité de travailler avec des habitués, voir ci-dessous exactement comment.


Ensuite, nous appliquons un modèle appelé la chaîne de tâches : le tableau de données d'entrée est envoyé séquentiellement à différents processeurs, qui peuvent ou non le modifier et le transmettre plus loin. J'ai appelé le gestionnaire de Recognizer et créé un tel gestionnaire pour chaque variante d'une phrase typique (comme il me semblait) concernant la date et l'heure.


Recognizer recherche un modèle d'expression régulière donné dans une chaîne et lance une fonction de traitement pour chaque correspondance trouvée qui peut modifier la chaîne d'entrée et ajouter des dates capturées à un tableau spécial.


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

De plus, les gestionnaires doivent être appelés dans le bon ordre: de plus "confiants" à moins. Par exemple, au tout début, vous devez exécuter des gestionnaires associés à des mots "stricts" qui se rapportent exactement aux dates: les noms des jours, des mois, des mots comme "demain", "après-demain", etc. Et à la fin, les gestionnaires qui essaient de déterminer par des soldes bruts s'il peut y avoir autre chose lié à la date.


Par exemple:


Date et mois


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

Ici, le charme de l'utilisation des expressions régulières commence à se manifester. D'une manière assez simple, nous avons identifié une séquence complexe de jetons: un nombre non nul de nombres non négatifs inférieurs à 1901 se succèdent, éventuellement séparés par l'union «et», et soit le nom du mois soit le mot «nombre» les suit.


De plus, dans le groupe de correspondance, nous capturons immédiatement des éléments spécifiques, le cas échéant, et n'attrapons pas s'il n'y en a pas, et à partir de cet ensemble, nous composons la date finale. Dans le code ci-dessous, il y aura une section incompréhensible liée aux fonctions Fix , FixPeriod , nous y passerons à la fin de l'article.


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;
}
}

Laps de temps


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

Il est important de noter que parfois le simple fait de coïncider avec la saison régulière ne suffit pas, et vous devez encore ajouter une certaine logique au gestionnaire. Bien qu'il soit possible de faire deux gestionnaires distincts pour de tels cas, mais cela me semblait un excès. En conséquence, je fais correspondre à la fois le "travers" initial et le "plus tard / en arrière" final, mais le code du gestionnaire commence par une vérification:


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

^ est le OU exclusif.


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;
}
}

Soit dit en passant, pour que ces fonctions fonctionnent, le moteur doit transférer la date actuelle de l'utilisateur.


Année


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

Je ne peux encore une fois noter la commodité de l'approche avec les habitués. Encore une fois, avec une expression simple, nous avons immédiatement indiqué des options: l'utilisateur nomme un nombre similaire à une année, mais sans le mot «année», ou l'utilisateur nomme un nombre à deux chiffres (qui peut être une date), mais ajoute le mot «année» et prend en charge les expressions «dans 18 ans, "cependant, nous ne savons pas si cela signifie 1918 ou 2018, nous pensons donc 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

Le temps


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

L'expression la plus difficile de ma collection est responsable des phrases pour indiquer l'heure de la journée, de stricte à 9h30 au quart à 23h.


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

Tous les gestionnaires par ordre d'application


Le système est modulaire, il est entendu que vous pouvez ajouter / supprimer des gestionnaires, modifier leur ordre, etc. J'en ai fait 11 (de haut en bas par ordre d'application):


GestionnaireRegexExemple de ligne
HolidaysRecognizer.cs 1Wjour de congé , week - end
DatesPeriodRecognizer.csf?(0)[ot]0(M|#)du 26 janvier au 27 janvier / date
DaysMonthRecognizer.cs((0N?)+)(M|#)24, 25, 26 janvier ... et 27 janvier / date
MonthRecognizer.cs([usxy])?M[en] (passé / ce / prochain) mars
RelativeDayRecognizer.cs[2-6]avant-hier, hier, aujourd'hui, demain, après-demain
TimeSpanRecognizer.cs(i)?((0?[Ymwdhe]N?)+)([bl])?(en) année et mois et 2 jours 4 heures 10 minutes (plus tard / retour)
YearRecognizer.cs(1)Y?|(0)Y[en] 15 ans / 2017 (année)
RelativeDateRecognizer.cs([usxy])([Ymwd])[on / on] suivant / cette / année précédente / mois / semaine / jour
DayOfWeekRecognizer.cs([usxy])?(D)[en] (suivant / ce / précédent) lundi
TimeRecognizer.cs([rvgd])?([fot])?(Q|H)?(h|(0)(h)?)((0)e?)?([rvgd])?(w / s / up) (demi / quart) heure / 9 (heures) (30 (minutes)) (matin / jour / soir / nuit)
PartOfDayRecognizer.cs(@)?f?([ravgdn])f?(@)?(date) (w / s) matin / après-midi / soir / nuit (w / s) (date)

1 "" "" "" " ",


Coudre ensemble


Ok, nous avons défini des jetons élémentaires date-heure. Mais maintenant, nous devons toujours tenir compte de toutes les combinaisons de tous les types de jetons: l'utilisateur peut d'abord nommer le jour, puis l'heure. Ou nommez le mois et le jour. S'il ne dit que le temps, alors c'est probablement au sujet d'aujourd'hui, et ainsi de suite. Pour ce faire, nous proposons le concept de fixation .


Un correctif dans notre cas est un masque de bits à l'intérieur de chaque jeton date-heure, qui montre quels éléments de date et d'heure y sont définis.


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

TimeUncertain est un commit pour indiquer le temps après lequel un raffinement peut suivre, par exemple, le soir / matin . On ne peut pas dire 18 heures du matin , mais on peut dire 6 heures du soir , donc le chiffre 6 a, pour ainsi dire, moins de «certitude» sur l'heure qu'il signifie que le chiffre 18 .


PhraseFixationMasque
26 mars 2019année , mois , semaine , tanière , vrm1, vrm2111100
26 numérosannée, mois, semaine, tanière , vrm1, vrm2000100
lundiannée, mois, semaine, tanière , vrm1, vrm2000100
lundi prochainannée , mois , semaine , tanière , vrm1, vrm2111100
la semaine prochaineannée , mois , semaine , tanière, vrm1, vrm2111000
à 9 heuresannée, mois, semaine, tanière, vrm1 , vrm2000010
à 21 hannée, mois, semaine, tanière, vrm1 , vrm2000011

Ensuite, nous utiliserons deux règles:


  • Si les dates sont proches et qu'elles n'ont pas de bits communs, nous les fusionnons en un seul
  • Si les dates sont proches et que nous ne les avons pas combinées, mais que l'une d'elles a des bits que le second n'a pas, alors nous occupons les bits (et les valeurs qui leur correspondent) de celui où ils sont, dans celui où ils ne le font pas, mais seulement pour le plus grand: c'est-à-dire que nous ne pouvons pas prendre un jour si seulement un mois est donné, mais nous pouvons prendre un mois si seulement un jour est fixé.


Ainsi, si l'utilisateur parle le lundi à 21 heures , le jeton du lundi , où seul le jour est défini, est combiné avec le jeton à 21 heures , où l'heure est définie. Mais s'il dit le 10 et le 15 mars de la journée , le dernier jeton du 15 prendra simplement le mois de mars du jeton précédent.


L'union est banale, mais avec l'emprunt, nous procéderons simplement. Nous appellerons la date de base pour laquelle nous occupons, et la seconde secondaire , à partir de laquelle nous voulons emprunter quelque chose. Ensuite, nous copions la date secondaire et ne laissons que les bits que la base n'a pas:


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

Par exemple, si la base avait une année, nous ne laisserons pas l'année de fixation pour la copie du mineur (même si elle l'était). Si la base n'a pas eu un mois, mais que le secondaire l'a, elle restera avec la copie comme secondaire. Après cela, nous effectuons le processus de combinaison de la date de base et de la copie secondaire.


Une fois combinés, nous avons également une date de base et une date absorbée . Nous allons de haut en bas de la plus grande période (année) à la plus petite (temps). Si la date de base n'a pas de paramètre que l'absorbé a, nous l'ajoutons à la base de l'absorbé.


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

Séparément, vous devez prendre en compte quelques nuances:


  • La date de base peut avoir un jour fixe, mais pas une semaine. Dans ce cas, vous devez prendre une semaine à partir de la date absorbée, mais le jour de la semaine à partir de la base.
  • Cependant, si la date absorbée n'a pas de jour fixe, mais qu'une semaine est fixe, nous devons prendre le jour (c'est-à-dire, année + mois + date) et définir la semaine de cette manière, car il n'y a pas d'entité distincte "Semaine" dans l'objet DateTime .
  • Si TimeUncertain fixé à la date de base, et Time absorbé à celui absorbé, et le nombre d'heures à l'absorbé est supérieur à 12, tandis que la base est inférieure, alors 12 heures doivent être ajoutées à la base. Parce que vous ne pouvez pas dire de 5 à 17 heures du soir . L'heure de la date "incertaine" ne peut pas être à partir d'une moitié de la journée, si l'heure de la date "confiante" à côté de celle-ci est à partir de l'autre moitié de la journée. Les gens ne disent pas ça. Si nous avons dit la phrase de 5 heures du matin à 17 heures , alors les deux dates ont une heure «confiante», et il n'y a aucun problème.

Après avoir combiné, si certaines dates ont des bits vides, nous les remplaçons par des valeurs de la date actuelle de l'utilisateur: par exemple, si l'utilisateur n'a pas nommé l'année, nous parlons de l'année en cours, si vous n'avez pas nommé le mois, puis le mois en cours et ainsi de suite. Bien sûr, tout objet DateTime peut être passé au paramètre "date actuelle" pour plus de flexibilité.


Béquilles


Tous les moments de la bibliothèque n'ont pas été pensés avec élégance. Pour le faire fonctionner correctement, les béquilles suivantes ont été ajoutées:


  • Les dates sont combinées séparément pour les jetons commençant par "b \ s \ co" et séparément pour les jetons commençant par "b \ to \ on". Parce que, si l'utilisateur a nommé la période, il est impossible de combiner des jetons entre la date de début et la date de fin.
  • Je n'ai pas commencé à introduire un niveau de fixation séparé pour le jour de la semaine, car il est nécessaire à exactement un endroit: où il est clairement donné par un mot avec le nom du jour de la semaine. J'ai fait un drapeau pour ça.
  • Une exécution distincte combine des dates qui sont à une certaine distance les unes des autres. Ceci est contrôlé par le paramètre collapseDistance , qui par défaut est de 4 jetons. C'est-à-dire, par exemple, la phrase fonctionnera: après-demain, une réunion avec un ami à 12 ans . Mais ça ne marchera pas: après-demain, je rencontrerai ma chère et merveilleuse amie à 12 ans .

Résumé



  • Vous pouvez utiliser la bibliothèque dans vos projets. Elle fait déjà face à de nombreuses options, mais je l'affine et la refactorise. Les demandes de tirage avec tests sont les bienvenues. En général, proposez un test qui semble réaliste (comme les gens le disent dans la vie), mais qui brise la bibliothèque.
  • La démonstration en direct sur .NET Fiddle fonctionne également, bien que le code soit prétendument souligné d'erreurs, mais il démarre. Au bas de la console, vous pouvez saisir des phrases en russe, sans oublier que les chiffres doivent être des chiffres.
  • La même démo qu'une application console

Vivez ça s'est avéré comme ceci:


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


All Articles