600 lines
22 KiB
C#
600 lines
22 KiB
C#
using System.Globalization;
|
||
using System.Text.Json;
|
||
using PlotLine.Models;
|
||
|
||
namespace PlotLine.Services;
|
||
|
||
public static class StoryIntelligenceTemporalResolver
|
||
{
|
||
private static readonly CultureInfo[] DateCultures = [CultureInfo.InvariantCulture, CultureInfo.GetCultureInfo("en-GB"), CultureInfo.GetCultureInfo("en-US")];
|
||
private static readonly string[] DateFormats =
|
||
[
|
||
"d MMM yyyy",
|
||
"dd MMM yyyy",
|
||
"d MMMM yyyy",
|
||
"dd MMMM yyyy",
|
||
"MMM d yyyy",
|
||
"MMMM d yyyy",
|
||
"yyyy-MM-dd"
|
||
];
|
||
|
||
private static readonly Dictionary<string, DayOfWeek> DaysOfWeek = new(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["Monday"] = DayOfWeek.Monday,
|
||
["Tuesday"] = DayOfWeek.Tuesday,
|
||
["Wednesday"] = DayOfWeek.Wednesday,
|
||
["Thursday"] = DayOfWeek.Thursday,
|
||
["Friday"] = DayOfWeek.Friday,
|
||
["Saturday"] = DayOfWeek.Saturday,
|
||
["Sunday"] = DayOfWeek.Sunday
|
||
};
|
||
private static readonly Dictionary<string, int> ClockWords = new(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["one"] = 1,
|
||
["two"] = 2,
|
||
["three"] = 3,
|
||
["four"] = 4,
|
||
["five"] = 5,
|
||
["six"] = 6,
|
||
["seven"] = 7,
|
||
["eight"] = 8,
|
||
["nine"] = 9,
|
||
["ten"] = 10,
|
||
["eleven"] = 11,
|
||
["twelve"] = 12
|
||
};
|
||
private static readonly Dictionary<string, int> MinuteWords = new(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["one"] = 1,
|
||
["two"] = 2,
|
||
["three"] = 3,
|
||
["four"] = 4,
|
||
["five"] = 5,
|
||
["six"] = 6,
|
||
["seven"] = 7,
|
||
["eight"] = 8,
|
||
["nine"] = 9,
|
||
["ten"] = 10,
|
||
["eleven"] = 11,
|
||
["twelve"] = 12,
|
||
["thirteen"] = 13,
|
||
["fourteen"] = 14,
|
||
["fifteen"] = 15,
|
||
["sixteen"] = 16,
|
||
["seventeen"] = 17,
|
||
["eighteen"] = 18,
|
||
["nineteen"] = 19,
|
||
["twenty"] = 20,
|
||
["thirty"] = 30,
|
||
["forty"] = 40,
|
||
["fifty"] = 50
|
||
};
|
||
|
||
public static StoryIntelligenceTemporalContext CreateChapterContext(string? chapterTitle, string? chapterSummary = null, string? sourceLabel = null)
|
||
{
|
||
var anchor = ParseDate(FirstNonBlank(chapterTitle, sourceLabel, chapterSummary));
|
||
return new StoryIntelligenceTemporalContext(anchor, null, null);
|
||
}
|
||
|
||
public static StoryIntelligenceTemporalResolution Resolve(
|
||
SceneIntelligenceScene? scene,
|
||
StoryIntelligenceTemporalContext context)
|
||
{
|
||
var temporal = scene?.TemporalAnalysis;
|
||
var fallbackText = FirstNonBlank(
|
||
temporal?.Evidence,
|
||
temporal?.AbsoluteDate,
|
||
temporal?.ExactTime,
|
||
temporal?.PartOfDay,
|
||
scene?.Setting?.DateOrTimeReference,
|
||
scene?.Setting?.TimeOfDay,
|
||
scene?.TimelineClues?.Select(clue => FirstNonBlank(clue.Clue, clue.AbsoluteDate, clue.RelativeOrder)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)));
|
||
|
||
if (temporal is null)
|
||
{
|
||
temporal = InferFromLegacyFields(scene);
|
||
}
|
||
|
||
var hasCurrentTemporalSignal = HasSceneTemporalSignal(temporal, scene);
|
||
var confidence = NormaliseConfidence(temporal?.Confidence);
|
||
var contextDate = context.LastDate ?? context.ChapterAnchorDate;
|
||
var absoluteDate = ParseDate(temporal?.AbsoluteDate, contextDate) ?? ParseDate(fallbackText, contextDate);
|
||
var baseDate = absoluteDate ?? context.LastDate ?? context.ChapterAnchorDate;
|
||
var resolvedDate = absoluteDate;
|
||
var isDateDeterministic = absoluteDate.HasValue;
|
||
var relativeText = FirstNonBlank(temporal?.Evidence, temporal?.AbsoluteDate, scene?.Setting?.DateOrTimeReference, scene?.Setting?.TimeOfDay);
|
||
var currentPartOfDay = CleanPartOfDay(temporal?.PartOfDay ?? scene?.Setting?.TimeOfDay ?? fallbackText);
|
||
var inheritedPartOfDay = !hasCurrentTemporalSignal && context.LastDate.HasValue ? context.LastPartOfDay : null;
|
||
var displayPartOfDay = currentPartOfDay ?? inheritedPartOfDay;
|
||
var partOfDayForTime = currentPartOfDay ?? context.LastPartOfDay;
|
||
var exactTime = ParseTime(FirstNonBlank(temporal?.ExactTime, temporal?.Evidence, scene?.Setting?.DateOrTimeReference), partOfDayForTime);
|
||
|
||
if (!resolvedDate.HasValue && baseDate.HasValue)
|
||
{
|
||
if (!hasCurrentTemporalSignal)
|
||
{
|
||
resolvedDate = baseDate.Value.Date;
|
||
isDateDeterministic = true;
|
||
}
|
||
else if (temporal?.CrossesMidnight == true)
|
||
{
|
||
resolvedDate = baseDate.Value.AddDays(1);
|
||
isDateDeterministic = true;
|
||
}
|
||
else if (TryResolveRelativeDate(baseDate.Value, temporal, fallbackText, out var relativeDate, out var relativeIsExact))
|
||
{
|
||
resolvedDate = relativeDate;
|
||
isDateDeterministic = relativeIsExact;
|
||
}
|
||
else if (IsSameDayReference(temporal, fallbackText))
|
||
{
|
||
resolvedDate = baseDate.Value.Date;
|
||
isDateDeterministic = true;
|
||
}
|
||
else if (exactTime.HasValue)
|
||
{
|
||
resolvedDate = baseDate.Value.Date;
|
||
isDateDeterministic = true;
|
||
}
|
||
else if (!HasApproximateSignal(temporal, fallbackText) && (currentPartOfDay is not null || hasCurrentTemporalSignal))
|
||
{
|
||
resolvedDate = baseDate.Value.Date;
|
||
isDateDeterministic = true;
|
||
}
|
||
else if (context.LastDate.HasValue && !HasApproximateSignal(temporal, fallbackText))
|
||
{
|
||
resolvedDate = context.LastDate.Value.Date;
|
||
isDateDeterministic = true;
|
||
}
|
||
}
|
||
|
||
var mode = "Unknown";
|
||
DateTime? start = null;
|
||
if (resolvedDate.HasValue && isDateDeterministic)
|
||
{
|
||
if (exactTime.HasValue)
|
||
{
|
||
start = resolvedDate.Value.Date.Add(exactTime.Value);
|
||
mode = "Exact DateTime";
|
||
}
|
||
else
|
||
{
|
||
start = resolvedDate.Value.Date;
|
||
mode = "Exact Date";
|
||
}
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(relativeText))
|
||
{
|
||
mode = HasApproximateSignal(temporal, fallbackText) ? "Approximate" : "Relative";
|
||
}
|
||
|
||
var displayText = string.Equals(mode, "Unknown", StringComparison.OrdinalIgnoreCase)
|
||
? null
|
||
: BuildRelativeText(start, exactTime, displayPartOfDay, relativeText, temporal);
|
||
var resolvedConfidence = ResolveConfidenceName(confidence, isDateDeterministic, mode);
|
||
var nextContext = context with
|
||
{
|
||
LastDate = start?.Date ?? (isDateDeterministic ? resolvedDate : context.LastDate),
|
||
LastPartOfDay = displayPartOfDay ?? context.LastPartOfDay
|
||
};
|
||
|
||
return new StoryIntelligenceTemporalResolution(start, null, null, null, displayText, mode, resolvedConfidence, nextContext);
|
||
}
|
||
|
||
public static string AddRollingContextToPromptJson(string sceneContextJson, StoryIntelligenceTemporalContext context)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(sceneContextJson))
|
||
{
|
||
return sceneContextJson;
|
||
}
|
||
|
||
using var document = JsonDocument.Parse(sceneContextJson);
|
||
var clone = JsonSerializer.Deserialize<Dictionary<string, object?>>(document.RootElement.GetRawText()) ?? [];
|
||
clone["rollingTemporalContext"] = new
|
||
{
|
||
chapterAnchorDate = context.ChapterAnchorDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||
previousResolvedDate = context.LastDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||
previousPartOfDay = context.LastPartOfDay
|
||
};
|
||
return JsonSerializer.Serialize(clone, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||
}
|
||
|
||
private static SceneIntelligenceTemporalAnalysis? InferFromLegacyFields(SceneIntelligenceScene? scene)
|
||
{
|
||
var text = FirstNonBlank(scene?.Setting?.DateOrTimeReference, scene?.Setting?.TimeOfDay, scene?.TimelineClues?.FirstOrDefault()?.Clue);
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return new SceneIntelligenceTemporalAnalysis
|
||
{
|
||
Evidence = text,
|
||
PartOfDay = CleanPartOfDay(text),
|
||
Confidence = scene?.Setting?.Confidence ?? scene?.TimelineClues?.FirstOrDefault()?.Confidence
|
||
};
|
||
}
|
||
|
||
private static bool TryResolveRelativeDate(DateTime baseDate, SceneIntelligenceTemporalAnalysis? temporal, string? fallbackText, out DateTime date, out bool exact)
|
||
{
|
||
date = baseDate.Date;
|
||
exact = false;
|
||
var text = Clean(FirstNonBlank(temporal?.Evidence, fallbackText, temporal?.RelativeTransition));
|
||
if (temporal?.TargetDayOfWeek is not null && DaysOfWeek.TryGetValue(temporal.TargetDayOfWeek, out var targetDay))
|
||
{
|
||
var dayOffset = ((int)targetDay - (int)baseDate.DayOfWeek + 7) % 7;
|
||
date = baseDate.Date.AddDays(dayOffset == 0 ? 7 : dayOffset);
|
||
exact = true;
|
||
return true;
|
||
}
|
||
|
||
if (temporal?.CrossesMidnight == true || Contains(text, "after midnight") || Contains(text, "past midnight"))
|
||
{
|
||
date = baseDate.Date.AddDays(1);
|
||
exact = true;
|
||
return true;
|
||
}
|
||
|
||
var amount = temporal?.RelativeAmount;
|
||
var unit = Clean(temporal?.RelativeUnit);
|
||
if (amount.HasValue && amount.Value != 0 && IsDayUnit(unit))
|
||
{
|
||
date = baseDate.Date.AddDays(amount.Value);
|
||
exact = true;
|
||
return true;
|
||
}
|
||
|
||
if (RegexMatch(text, @"\b(\d+)\s+days?\s+later\b", out var value) && int.TryParse(value, out var elapsedDays))
|
||
{
|
||
date = baseDate.Date.AddDays(elapsedDays);
|
||
exact = true;
|
||
return true;
|
||
}
|
||
|
||
if (Contains(text, "following morning") || Contains(text, "next morning"))
|
||
{
|
||
date = baseDate.Date.AddDays(1);
|
||
exact = true;
|
||
return true;
|
||
}
|
||
|
||
if (Contains(text, "later that") || Contains(text, "same day") || Contains(text, "that afternoon") || Contains(text, "that evening"))
|
||
{
|
||
date = baseDate.Date;
|
||
exact = true;
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private static DateTime? ParseDate(string? value, DateTime? contextDate = null)
|
||
{
|
||
var clean = StripOrdinalSuffixes(Clean(value));
|
||
if (string.IsNullOrWhiteSpace(clean))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var hasExplicitYear = HasExplicitYear(clean);
|
||
|
||
foreach (var culture in DateCultures)
|
||
{
|
||
if (DateTime.TryParseExact(clean, DateFormats, culture, DateTimeStyles.AllowWhiteSpaces, out var exact))
|
||
{
|
||
return exact.Date;
|
||
}
|
||
|
||
if (hasExplicitYear && DateTime.TryParse(clean, culture, DateTimeStyles.AllowWhiteSpaces, out var parsed) && parsed.Year > 1000)
|
||
{
|
||
return parsed.Date;
|
||
}
|
||
}
|
||
|
||
var embeddedDate = System.Text.RegularExpressions.Regex.Match(
|
||
clean,
|
||
@"\b\d{1,2}\s+(?:Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)\s+\d{4}\b",
|
||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||
if (embeddedDate.Success)
|
||
{
|
||
return ParseDate(embeddedDate.Value);
|
||
}
|
||
|
||
if (contextDate.HasValue)
|
||
{
|
||
var monthDay = System.Text.RegularExpressions.Regex.Match(
|
||
clean,
|
||
@"\b(?<day>\d{1,2})\s+(?<month>Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)\b",
|
||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||
if (!monthDay.Success)
|
||
{
|
||
monthDay = System.Text.RegularExpressions.Regex.Match(
|
||
clean,
|
||
@"\b(?<month>Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)\s+(?<day>\d{1,2})\b",
|
||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||
}
|
||
|
||
if (monthDay.Success
|
||
&& int.TryParse(monthDay.Groups["day"].Value, out var day)
|
||
&& TryMonthNumber(monthDay.Groups["month"].Value, out var month)
|
||
&& day >= 1
|
||
&& day <= DateTime.DaysInMonth(contextDate.Value.Year, month))
|
||
{
|
||
return new DateTime(contextDate.Value.Year, month, day);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static bool HasExplicitYear(string value)
|
||
=> System.Text.RegularExpressions.Regex.IsMatch(value, @"\b(?:1[5-9]\d{2}|20\d{2}|21\d{2})\b");
|
||
|
||
private static bool TryMonthNumber(string value, out int month)
|
||
{
|
||
month = 0;
|
||
foreach (var culture in DateCultures)
|
||
{
|
||
if (DateTime.TryParseExact(value, ["MMM", "MMMM"], culture, DateTimeStyles.AllowWhiteSpaces, out var parsed))
|
||
{
|
||
month = parsed.Month;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private static TimeSpan? ParseTime(string? value, string? partOfDay)
|
||
{
|
||
var clean = Clean(value)?.Replace('’', '\'');
|
||
if (string.IsNullOrWhiteSpace(clean))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var hasMeridiem = clean.Contains("am", StringComparison.OrdinalIgnoreCase)
|
||
|| clean.Contains("pm", StringComparison.OrdinalIgnoreCase)
|
||
|| clean.Contains("morning", StringComparison.OrdinalIgnoreCase)
|
||
|| clean.Contains("afternoon", StringComparison.OrdinalIgnoreCase)
|
||
|| clean.Contains("evening", StringComparison.OrdinalIgnoreCase)
|
||
|| clean.Contains("night", StringComparison.OrdinalIgnoreCase);
|
||
|
||
if (!hasMeridiem && !clean.Contains(':') && string.IsNullOrWhiteSpace(partOfDay))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
foreach (var pair in ClockWords)
|
||
{
|
||
if (clean.Contains($"{pair.Key} o'clock", StringComparison.OrdinalIgnoreCase)
|
||
|| clean.Contains($"{pair.Key} oclock", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var clock = ApplyPartOfDayToClockHour(pair.Value, partOfDay ?? clean);
|
||
if (clock.HasValue)
|
||
{
|
||
return clock;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (RegexMatch(clean, @"\b(\d{1,2})\s*o'?clock\b", out var hourText) && int.TryParse(hourText, out var hour))
|
||
{
|
||
var clock = ApplyPartOfDayToClockHour(hour, partOfDay ?? clean);
|
||
if (clock.HasValue)
|
||
{
|
||
return clock;
|
||
}
|
||
}
|
||
|
||
if (RegexMatch(clean, @"\b(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\s*o'?clock\b", out hourText)
|
||
&& ClockWords.TryGetValue(hourText, out hour))
|
||
{
|
||
var clock = ApplyPartOfDayToClockHour(hour, partOfDay ?? clean);
|
||
if (clock.HasValue)
|
||
{
|
||
return clock;
|
||
}
|
||
}
|
||
|
||
if (TryParseWordClock(clean, partOfDay, out var wordClock))
|
||
{
|
||
return wordClock;
|
||
}
|
||
|
||
if (DateTime.TryParse(clean, CultureInfo.InvariantCulture, DateTimeStyles.NoCurrentDateDefault, out var parsed))
|
||
{
|
||
return parsed.TimeOfDay;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static bool TryParseWordClock(string text, string? partOfDay, out TimeSpan time)
|
||
{
|
||
time = default;
|
||
var match = System.Text.RegularExpressions.Regex.Match(
|
||
text,
|
||
@"\b(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)[-\s]+((?:one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty)(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?)\b",
|
||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||
if (!match.Success || !ClockWords.TryGetValue(match.Groups[1].Value, out var hour) || !TryParseMinuteWords(match.Groups[2].Value, out var minute))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var clock = ApplyPartOfDayToClockHour(hour, partOfDay);
|
||
if (!clock.HasValue)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
time = clock.Value.Add(TimeSpan.FromMinutes(minute));
|
||
return true;
|
||
}
|
||
|
||
private static bool TryParseMinuteWords(string value, out int minute)
|
||
{
|
||
minute = 0;
|
||
var parts = value.Split(['-', ' '], StringSplitOptions.RemoveEmptyEntries);
|
||
if (parts.Length == 0 || parts.Length > 2)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (!MinuteWords.TryGetValue(parts[0], out minute))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (parts.Length == 2)
|
||
{
|
||
if (!MinuteWords.TryGetValue(parts[1], out var ones) || ones >= 10)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
minute += ones;
|
||
}
|
||
|
||
return minute is >= 0 and < 60;
|
||
}
|
||
|
||
private static TimeSpan? ApplyPartOfDayToClockHour(int hour, string? partOfDay)
|
||
{
|
||
var lowerPart = Clean(partOfDay);
|
||
if (Contains(lowerPart, "evening") || Contains(lowerPart, "night"))
|
||
{
|
||
hour = hour == 12 ? 12 : hour + 12;
|
||
return TimeSpan.FromHours(hour);
|
||
}
|
||
|
||
if (Contains(lowerPart, "morning"))
|
||
{
|
||
return TimeSpan.FromHours(hour == 12 ? 0 : hour);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static string? BuildRelativeText(DateTime? start, TimeSpan? exactTime, string? partOfDay, string? relativeText, SceneIntelligenceTemporalAnalysis? temporal)
|
||
{
|
||
var cleanRelative = Clean(FirstNonBlank(relativeText, temporal?.Evidence, temporal?.UnresolvedReason));
|
||
if (start.HasValue && exactTime is null)
|
||
{
|
||
return string.IsNullOrWhiteSpace(partOfDay)
|
||
? null
|
||
: $"{start.Value:dd MMM yyyy}, {partOfDay}";
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(cleanRelative) && !start.HasValue)
|
||
{
|
||
return cleanRelative;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static string ResolveConfidenceName(decimal confidence, bool exactDate, string mode)
|
||
{
|
||
if (string.Equals(mode, "Unknown", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return "Unknown";
|
||
}
|
||
|
||
if (confidence >= 0.9m && exactDate)
|
||
{
|
||
return "Confirmed";
|
||
}
|
||
|
||
return exactDate ? "Implied" : "Estimated";
|
||
}
|
||
|
||
private static decimal NormaliseConfidence(decimal? confidence)
|
||
=> confidence is >= 0 and <= 1 ? confidence.Value : 0.0m;
|
||
|
||
private static bool IsSameDayReference(SceneIntelligenceTemporalAnalysis? temporal, string? fallbackText)
|
||
=> Contains(Clean(FirstNonBlank(temporal?.RelativeTransition, temporal?.Evidence, fallbackText)), "same")
|
||
|| Contains(Clean(FirstNonBlank(temporal?.Evidence, fallbackText)), "later that");
|
||
|
||
private static bool HasApproximateSignal(SceneIntelligenceTemporalAnalysis? temporal, string? fallbackText)
|
||
=> Contains(Clean(temporal?.UnresolvedReason), "few")
|
||
|| Contains(Clean(temporal?.Evidence), "few")
|
||
|| Contains(Clean(fallbackText), "few")
|
||
|| Contains(Clean(temporal?.UnresolvedReason), "approx")
|
||
|| Contains(Clean(temporal?.Evidence), "approx")
|
||
|| Contains(Clean(fallbackText), "approx");
|
||
|
||
private static bool HasSceneTemporalSignal(SceneIntelligenceTemporalAnalysis? temporal, SceneIntelligenceScene? scene)
|
||
=> temporal is not null
|
||
&& (!string.IsNullOrWhiteSpace(temporal.AbsoluteDate)
|
||
|| !string.IsNullOrWhiteSpace(temporal.ExactTime)
|
||
|| !string.IsNullOrWhiteSpace(temporal.Evidence)
|
||
|| !string.IsNullOrWhiteSpace(scene?.Setting?.DateOrTimeReference)
|
||
|| !string.IsNullOrWhiteSpace(scene?.Setting?.TimeOfDay));
|
||
|
||
private static bool IsDayUnit(string? unit)
|
||
=> string.Equals(unit, "day", StringComparison.OrdinalIgnoreCase)
|
||
|| string.Equals(unit, "days", StringComparison.OrdinalIgnoreCase);
|
||
|
||
private static string? CleanPartOfDay(string? value)
|
||
{
|
||
var text = Clean(value);
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
foreach (var part in new[] { "morning", "afternoon", "evening", "night", "dawn", "dusk", "midday", "midnight" })
|
||
{
|
||
if (text.Contains(part, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return part;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static bool Contains(string? text, string value)
|
||
=> !string.IsNullOrWhiteSpace(text) && text.Contains(value, StringComparison.OrdinalIgnoreCase);
|
||
|
||
private static bool RegexMatch(string? text, string pattern, out string value)
|
||
{
|
||
value = string.Empty;
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var match = System.Text.RegularExpressions.Regex.Match(text, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||
if (!match.Success)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
value = match.Groups[1].Value;
|
||
return true;
|
||
}
|
||
|
||
private static string Clean(string? value)
|
||
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||
|
||
private static string StripOrdinalSuffixes(string value)
|
||
=> System.Text.RegularExpressions.Regex.Replace(value, @"\b(\d{1,2})(st|nd|rd|th)\b", "$1", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||
|
||
private static string? FirstNonBlank(params string?[] values)
|
||
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
|
||
}
|
||
|
||
public sealed record StoryIntelligenceTemporalContext(DateTime? ChapterAnchorDate, DateTime? LastDate, string? LastPartOfDay);
|
||
|
||
public sealed record StoryIntelligenceTemporalResolution(
|
||
DateTime? StartDateTime,
|
||
DateTime? EndDateTime,
|
||
decimal? DurationAmount,
|
||
int? DurationUnitID,
|
||
string? RelativeTimeText,
|
||
string TimeModeName,
|
||
string TimeConfidenceName,
|
||
StoryIntelligenceTemporalContext NextContext);
|