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 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 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 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 confidence = NormaliseConfidence(temporal?.Confidence); var absoluteDate = ParseDate(temporal?.AbsoluteDate) ?? ParseDate(fallbackText); 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 partOfDayForTime = currentPartOfDay ?? context.LastPartOfDay; var exactTime = ParseTime(FirstNonBlank(temporal?.ExactTime, temporal?.Evidence, scene?.Setting?.DateOrTimeReference), partOfDayForTime); if (!resolvedDate.HasValue && baseDate.HasValue) { 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 || HasSceneTemporalSignal(temporal, scene))) { 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, currentPartOfDay, relativeText, temporal); var resolvedConfidence = ResolveConfidenceName(confidence, isDateDeterministic, mode); var nextContext = context with { LastDate = start?.Date ?? (isDateDeterministic ? resolvedDate : context.LastDate), LastPartOfDay = currentPartOfDay ?? 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>(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) { var clean = StripOrdinalSuffixes(Clean(value)); if (string.IsNullOrWhiteSpace(clean)) { return null; } foreach (var culture in DateCultures) { if (DateTime.TryParseExact(clean, DateFormats, culture, DateTimeStyles.AllowWhiteSpaces, out var exact)) { return exact.Date; } if (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); } return null; } 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 && (temporal.Confidence > 0 || !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);