PlotDirector/PlotLine/Services/StoryIntelligenceTemporalResolver.cs

600 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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