Erkennen Sie Datum und Uhrzeit in natürlicher Sprache


Herausforderung


Hallo Habr! Ich interessierte mich für Fähigkeiten für Alice und begann zu überlegen, welche Vorteile sie bringen könnten. Es gibt viele verschiedene coole Spiele auf der Website (einschließlich meiner), aber ich wollte ein funktionierendes Tool erstellen, das für die Sprachleistung wirklich benötigt wird, und nicht nur den vorhandenen Chat-Bot mit Schaltflächen kopieren.


Die Stimme ist relevant, wenn entweder die Hände beschäftigt sind oder Sie viele aufeinanderfolgende Vorgänge ausführen müssen, insbesondere auf dem Telefonbildschirm. So entstand die Idee einer Fertigkeit, die mit einem Befehl eine Angabe von Datum und Uhrzeit aus dem Text extrahiert und Google Kalender ein Ereignis mit diesem Text hinzufügt. Wenn ein Benutzer beispielsweise sagt, dass übermorgen um 23:00 Uhr ein wunderschöner Sonnenuntergang angezeigt wird , wird im Kalender für übermorgen um 23:00 Uhr ein wunderschöner Sonnenuntergang im Kalender angezeigt.


Unter dem Cutter befindet sich die Beschreibung des Algorithmus der Hors- Bibliothek: ein Datums- und Zeiterkenner in natürlicher russischer Sprache. Pferd ist der slawische Gott der Sonne.


Github | Nuget


Bestehende Lösungen


Dateparser in Python
Die Unterstützung der russischen Sprache wird erklärt, aber auf Russisch kann die Bibliothek nicht einmal grundlegende Dinge bewältigen:


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

chronisch auf Ruby
Eine Bibliothek, die Rubisten bekannt ist und die ihren Job perfekt macht. Aber Unterstützung für den Russen wird nicht gefunden.


Google-Assistent
Da wir über das Hinzufügen von Sprache zu Google Kalender sprechen, fragen wir, warum Sie nicht den Assistenten verwenden sollten. Es ist möglich, aber die Idee des Projekts ist es, die Arbeit in einem Satz ohne unnötige Gesten und Klicks zu erledigen. Und das zuverlässig. Der Assistent hat vorerst Probleme damit:



Voreinstellungen


Ich habe eine Bibliothek auf .NETStandard 2.0 (C #) geschrieben . Da die Bibliothek ursprünglich für Alice erstellt wurde, sollen alle Ziffern im Text Zahlen sein, da Alice diese Konvertierung automatisch durchführt. Wenn Sie Ziffern in Zeichenfolgen haben, gibt es einen wunderbaren Doomer3D- Artikel darüber, wie man Wörter in Zahlen umwandelt .


Morphologie


Wenn Sie mit Spracheingabe arbeiten, können Sie die richtigen Wörter am zuverlässigsten von den falschen unterscheiden, indem Sie Wortformen verwenden. Ein Freund und ich haben darüber in einem Video-Tutorial für die Alice School von Yandex ausführlicher gesprochen. In diesem Artikel werde ich die Arbeit mit Wortformen hinter den Kulissen lassen. Solche Konstrukte erscheinen im Code:


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

Diese Funktion gibt true zurück true wenn das Wort t eine Form eines der drei folgenden Wörter ist, z. B. past , past , previous .


Theorie


Um zu verstehen, wie Datumsangaben im Text erfasst werden, müssen Sie die typischen Ausdrücke auflisten, mit denen wir Datumsangaben in realen Konversationen angeben. Zum Beispiel:


- Ich gehe morgen spazieren
- Ich werde morgen Abend spazieren gehen
- nächsten Donnerstag gehe ich ins Kino
- nächsten Donnerstag um 21 Uhr gehe ich ins Kino
- Sitzung am 21. März um 10 Uhr


Wir sehen, dass die Wörter als Ganzes in drei Typen unterteilt sind:


  1. Diejenigen, die sich immer auf Datum und Uhrzeit beziehen (Namen von Monaten und Tagen)
  2. Diejenigen, die sich auf das Datum und die Uhrzeit an einer bestimmten Position im Verhältnis zu anderen Wörtern beziehen ("Tag", "Abend", "Weiter", Zahlen)
  3. Diejenigen, die niemals Datum und Uhrzeit haben

Beim ersten und letzten ist alles klar, aber beim zweiten gibt es Schwierigkeiten. Die ursprüngliche Version des Algorithmus war ein schrecklicher Spaghetti-Code mit einer großen Anzahl von if , weil ich versuchte, alle möglichen Kombinationen und Permutationen der benötigten Wörter zu berücksichtigen, aber dann kam ich auf eine bessere Option. Tatsache ist, dass die Menschheit bereits ein System erfunden hat, mit dem Sie Permutationen und Zeichenkombinationen schnell und einfach berücksichtigen können: die Engine für reguläre Ausdrücke.


Eine Linie kochen


Wir brechen die Linie in Token auf, entfernen die Satzzeichen und reduzieren alles auf Kleinbuchstaben. Danach ersetzen wir jedes nicht leere Token durch ein einzelnes Zeichen, um die Arbeit mit regulären Ausdrücken zu erleichtern.


Token (Wort)Symbol zu ersetzen
"Jahr"Y.
MonatsnameM.
WochentagsnameD.
"zurück"b
"später"l (unteres L)
"durch"ich
"freier Tag"W.
Minutee
"Stunde"h
"Tag"d
"Woche"w
Monatm
"Vergangenheit", "Vergangenheit", "Vergangenheit"s
"this", "current", "current"u
"am nächsten", "Zukunft"y
"weiter", "Zukunft"x
"übermorgen"6
morgen5
heute4
gestern3
"vorgestern"2
Morgenr
Mittagn
"Abend"v
"Nacht"g
halbH.
QuartalQ.
c, cf
"zu", "von"t
aufüber
"Nummer"#
"und"N.
Zahl größer als 1900 und kleiner als 99991
nicht negative Zahl kleiner als 19010
Datum bereits vom Algorithmus verarbeitet@
jedes andere Zeichen_

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

Folgendes passiert für die Zeilen, die wir erwähnt haben:


StringErgebnis
Ich werde morgen spazieren gehen5__
Morgen Abend gehe ich spazieren5v__
nächsten Donnerstag gehe ich ins KinofxD_f_
nächsten Donnerstag um 21 Uhr gehe ich ins KinofxDf0v_f_
21. März um 10 Uhr Sitzung0Mf0r_

Anerkennung


- Oh, Entsetzen! - Du sagst, - es wurde nur noch schlimmer! Solch ein Kauderwelsch und eine Person können nicht verstehen. Ja, ich musste einen Kompromiss zwischen der Bequemlichkeit des Lesens durch eine Person und der Bequemlichkeit der Arbeit mit Stammgästen eingehen, siehe unten genau, wie.


Dann wenden wir ein Muster an, das als Aufgabenkette bezeichnet wird : Das Eingabedatenarray wird nacheinander verschiedenen Prozessoren zugeführt, die es möglicherweise ändern oder nicht und weiter übertragen. Ich rief den Handler Recognizer und erstellte einen solchen Handler für jede Variante eines typischen (wie mir schien) Ausdrucks, der sich auf Datum und Uhrzeit bezieht.


Recognizer sucht nach einem bestimmten regulären Ausdrucksmuster in einer Zeichenfolge und startet eine Verarbeitungsfunktion für jede gefundene Übereinstimmung, mit der die Eingabezeichenfolge geändert und abgefangene Daten zu einem speziellen Array hinzugefügt werden können.


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

Darüber hinaus müssen die Handler in der richtigen Reihenfolge aufgerufen werden: von "sicherer" zu weniger. Zum Beispiel müssen Sie ganz am Anfang Handler ausführen, die mit "strengen" Wörtern verknüpft sind, die sich genau auf Datumsangaben beziehen: die Namen von Tagen, Monaten, Wörtern wie "morgen", "übermorgen" usw. Und am Ende Handler, die versuchen, anhand von Rohbilanzen zu bestimmen, ob noch etwas mit dem Datum zu tun hat.


Zum Beispiel:


Datum und Monat


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

Hier beginnt sich der Reiz der Verwendung regulärer Ausdrücke zu manifestieren. Auf ziemlich einfache Weise haben wir eine komplexe Folge von Token identifiziert: Eine Anzahl von nicht negativen Zahlen unter 1901 ungleich Null folgt aufeinander, möglicherweise getrennt durch die Vereinigung „und“, und entweder der Name des Monats oder das Wort „Zahl“ folgt ihnen.


Darüber hinaus fangen wir in der Matchgruppe sofort bestimmte Elemente, falls vorhanden, und nicht, wenn keine vorhanden sind, und stellen aus diesem Satz das Enddatum zusammen. Im folgenden Code wird es einen unverständlichen Abschnitt geben, der sich auf die Funktionen Fix , FixPeriod . Wir werden am Ende des Artikels darauf FixPeriod .


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

Zeitraffer


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

Es ist wichtig zu beachten, dass manchmal nur die Tatsache des Zusammentreffens mit der regulären Saison nicht ausreicht und Sie dem Handler noch etwas Logik hinzufügen müssen. Es war zwar möglich, für solche Fälle zwei separate Handler zu erstellen, aber es schien mir ein Übermaß zu sein. Infolgedessen stimme ich sowohl mit dem anfänglichen "Durch" als auch mit dem endgültigen "Später / Rückwärts" überein, aber der Handler-Code beginnt mit einer Überprüfung:


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

Wobei ^ das exklusive ODER ist.


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

Damit solche Funktionen funktionieren, muss die Engine übrigens das aktuelle Benutzerdatum übertragen.


Jahr


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

Ich kann die Bequemlichkeit des Ansatzes mit Stammgästen nicht noch einmal bemerken. Wiederum haben wir mit einem einfachen Ausdruck sofort Optionen angegeben: Der Benutzer benennt eine Zahl ähnlich einem Jahr, jedoch ohne das Wort „Jahr“, oder der Benutzer benennt eine zweistellige Zahl (die ein Datum sein kann), fügt jedoch das Wort „Jahr“ hinzu und unterstützt Ausdrücke „in 18 Jahre ", aber wir wissen nicht, ob es 1918 oder 2018 bedeutet, also glauben wir, dass 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

Zeit


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

Der schwierigste Ausdruck in meiner Sammlung ist dafür verantwortlich, dass die Phrasen die Tageszeit angeben, von streng um 9.30 Uhr bis viertel bis 23.00 Uhr.


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

Alle Handler in der Reihenfolge der Anwendung


Das System ist modular aufgebaut. Es versteht sich, dass Sie Handler hinzufügen / entfernen, ihre Reihenfolge ändern usw. können. Ich habe 11 davon gemacht (von oben nach unten in der Reihenfolge der Anwendung):


HandlerRegexLinienbeispiel
HolidaysRecognizer.cs 1WRuhetag , Wochenende
DatesPeriodRecognizer.csf?(0)[ot]0(M|#)vom 26. Januar bis 27. Januar / Datum
DaysMonthRecognizer.cs((0N?)+)(M|#)24., 25., 26. Januar ... und 27. Januar / Datum
MonthRecognizer.cs([usxy])?M[in] (vergangene / diese / nächste) März
RelativeDayRecognizer.cs[2-6]vorgestern, gestern, heute, morgen, übermorgen
TimeSpanRecognizer.cs(i)?((0?[Ymwdhe]N?)+)([bl])?(in) Jahr und Monat und 2 Tage 4 Stunden 10 Minuten (später / zurück)
YearRecognizer.cs(1)Y?|(0)Y[in] 15 Jahr / 2017 (Jahr)
RelativeDateRecognizer.cs([usxy])([Ymwd])[am / am] nächstes / dieses / vorherige Jahr / Monat / Woche / Tag
DayOfWeekRecognizer.cs([usxy])?(D)[in] (nächster / dieser / vorheriger) Montag
TimeRecognizer.cs([rvgd])?([fot])?(Q|H)?(h|(0)(h)?)((0)e?)?([rvgd])?(w / s / up) (halbe / viertel) Stunde / 9 (Stunden) (30 (Minuten)) (morgens / Tag / Abend / Nacht)
PartOfDayRecognizer.cs(@)?f?([ravgdn])f?(@)?(Datum) (w / s) Morgen / Nachmittag / Abend / Nacht (w / s) (Datum)

1 "" "" "" " ",


Zusammennähen


Ok, wir haben einige elementare Datums- / Uhrzeit-Token definiert. Aber jetzt müssen wir noch irgendwie alle Kombinationen aller Arten von Token berücksichtigen: Der Benutzer kann zuerst den Tag und dann die Uhrzeit benennen. Oder nennen Sie den Monat und den Tag. Wenn er nur Zeit sagt, dann geht es wahrscheinlich um heute und so weiter. Dazu entwickeln wir das Konzept der Fixierung .


Ein Fix in unserem Fall ist eine Bitmaske in jedem Datums- / Uhrzeit-Token, die anzeigt, welche Datums- und Zeitelemente darin festgelegt sind.


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

TimeUncertain ist ein Commit, um die Zeit anzugeben, nach der eine Verfeinerung folgen kann, beispielsweise abends / morgens . Es kann nicht 18 Uhr morgens gesagt werden, aber es kann 6 Uhr abends gesagt werden, daher hat die Nummer 6 sozusagen weniger "Gewissheit" darüber, welche Zeit es bedeutet als die Nummer 18 .


PhraseFixierungMaske
26. März 2019Jahr , Monat , Woche , Höhle , vrm1, vrm2111100
26 ZahlenJahr, Monat, Woche, Höhle , vrm1, vrm2000100
MontagJahr, Monat, Woche, Höhle , vrm1, vrm2000100
nächsten MontagJahr , Monat , Woche , Höhle , vrm1, vrm2111100
nächste WocheJahr , Monat , Woche , Höhle, vrm1, vrm2111000
um 9 UhrJahr, Monat, Woche, Höhle, vrm1 , vrm2000010
um 21 UhrJahr, Monat, Woche, Höhle, vrm1 , vrm2000011

Als nächstes werden wir zwei Regeln verwenden:


  • Wenn die Daten nahe sind und sie keine gemeinsamen Bits haben, führen wir sie zu einem zusammen
  • Wenn die Daten nahe sind und wir sie nicht kombiniert haben, aber eines von ihnen Bits hat, die das zweite nicht hat, dann belegen wir die Bits (und die ihnen entsprechenden Werte) von dem, wo sie sind, bis zu dem, wo sie nicht sind. aber nur zum Größeren: Das heißt, wir können keinen Tag nehmen, wenn nur ein Monat angegeben ist, aber wir können einen Monat nehmen, wenn nur ein Tag festgelegt ist.


Wenn der Benutzer am Montag um 21 Uhr spricht, wird das Token am Montag , bei dem nur der Tag festgelegt ist, mit dem Token um 21 Uhr kombiniert, bei dem die Uhrzeit festgelegt ist. Wenn er jedoch am 10. und 15. März des Tages sagt, nimmt der letzte Token am 15. des Tages einfach den Monat März vom vorherigen Token.


Die Gewerkschaft ist trivial, aber mit dem Ausleihen werden wir einfach weitermachen. Wir nennen das Basisdatum, für das wir uns beschäftigen, und die sekundäre Sekunde, von der wir etwas ausleihen möchten. Dann kopieren wir das sekundäre Datum und lassen nur die Bits übrig, die die Basis nicht hat:


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

Wenn die Basis beispielsweise ein Jahr hatte, werden wir die Jahresfixierung für die Kopie des Minderjährigen nicht verlassen (selbst wenn dies der Fall war). Wenn die Basis keinen Monat hatte, die sekundäre jedoch, bleibt sie bei der Kopie als sekundäre. Danach führen wir den Prozess des Kombinierens des Basisdatums und der sekundären Kopie durch.


In Kombination haben wir auch ein Basisdatum und ein absorbiertes Datum. Wir gehen von oben nach unten vom größten Zeitraum (Jahr) zum kleinsten (Zeit). Wenn das Basisdatum keinen Parameter hat, den das absorbierte hat, fügen wir ihn der Basis des absorbierten hinzu.


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

Separat müssen Sie einige Nuancen berücksichtigen:


  • Das Basisdatum kann einen festen Tag haben, aber keine Woche. In diesem Fall müssen Sie eine Woche ab dem absorbierten Datum, aber den Wochentag von der Basis nehmen.
  • Wenn für das absorbierte Datum jedoch kein Tag festgelegt ist, sondern eine Woche festgelegt ist, müssen wir den Tag daraus DateTime Jahr + Monat + Datum) und die Woche auf diese Weise festlegen, da das DateTime Objekt keine separate Entität "Woche" DateTime .
  • Wenn TimeUncertain auf dem Basisdatum festgelegt ist, während Time auf dem absorbierten Datum absorbiert wird und die Anzahl der Stunden auf dem absorbierten Datum mehr als 12 beträgt, während das Basisdatum weniger beträgt, sollten der Basis 12 Stunden hinzugefügt werden. Weil Sie nicht von 5 bis 17 Uhr abends sagen können. Die Zeit des "unsicheren" Datums kann nicht von einer Hälfte des Tages stammen, wenn die Zeit des "sicheren" Datums daneben von der anderen Hälfte des Tages stammt. Die Leute sagen das nicht. Wenn wir den Satz von 5 bis 17 Uhr sagten, haben beide Daten eine „sichere“ Zeit, und es gibt kein Problem.

Wenn nach dem Kombinieren einige Daten leere Bits haben, ersetzen wir sie durch Werte aus dem aktuellen Datum des Benutzers: Wenn der Benutzer beispielsweise das Jahr nicht benannt hat, sprechen wir über das aktuelle Jahr, wenn Sie den Monat nicht benannt haben, dann über den aktuellen Monat und so weiter. Natürlich kann jedes DateTime Objekt aus DateTime der Flexibilität an den Parameter "aktuelles Datum" übergeben werden.


Krücken


Nicht alle Momente der Bibliothek wurden anmutig durchdacht. Damit es richtig funktioniert, wurden die folgenden Krücken hinzugefügt:


  • Die Daten werden separat für Token kombiniert, die mit "b \ s \ co" beginnen, und separat für Token, die mit "b \ to \ on" beginnen. Denn wenn der Benutzer den Zeitraum benannt hat, ist es unmöglich, Token ab dem Startdatum und dem Enddatum zu kombinieren.
  • Ich habe nicht begonnen, eine separate Fixierungsstufe für den Wochentag einzuführen, da diese an genau einer Stelle benötigt wird: wo sie eindeutig durch ein Wort mit dem Namen des Wochentags angegeben wird. Habe dafür eine Flagge gemacht.
  • Ein separater Lauf kombiniert Daten, die in einem bestimmten Abstand voneinander liegen. Dies wird durch den Parameter collapseDistance gesteuert, der standardmäßig 4 Token beträgt. Das heißt zum Beispiel, der Satz wird funktionieren: Übermorgen ein Treffen mit einem Freund um 12 Uhr . Aber es wird nicht funktionieren: Übermorgen werde ich meinen geliebten und wunderbaren Freund um 12 treffen .

Zusammenfassung



  • Sie können die Bibliothek in Ihren Projekten verwenden. Sie kommt bereits mit vielen Optionen zurecht, aber ich verfeinere und überarbeite sie. Pull-Anfragen mit Tests sind willkommen. Überlegen Sie sich im Allgemeinen einen Test, der realistisch klingt (wie die Leute im Leben sagen), aber die Bibliothek zerstört.
  • Live-Demo auf .NET Fiddle funktioniert auch, obwohl der Code angeblich mit Fehlern unterstrichen ist, aber es beginnt. Am unteren Rand der Konsole können Sie Phrasen auf Russisch eingeben, ohne zu vergessen, dass Zahlen Zahlen sein sollten.
  • Dieselbe Demo wie eine Konsolenanwendung

Live stellte sich so heraus:


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


All Articles