diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 8f8df28..f434a50 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -114,7 +114,10 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence temporal resolves following weekday", StoryIntelligenceTemporalResolvesFollowingWeekday), ("Story Intelligence temporal resolves evening clock time", StoryIntelligenceTemporalResolvesEveningClockTime), ("Story Intelligence temporal rejects unanchored clock time", StoryIntelligenceTemporalRejectsUnanchoredClockTime), + ("Story Intelligence temporal preserves yearless calendar date clues", StoryIntelligenceTemporalPreservesYearlessCalendarDateClues), + ("Story Intelligence temporal resolves yearless calendar dates from context", StoryIntelligenceTemporalResolvesYearlessCalendarDatesFromContext), ("Story Intelligence temporal preserves vague few days", StoryIntelligenceTemporalPreservesVagueFewDays), + ("Core import applies structural chronology to canonical scenes", CoreImportAppliesStructuralChronologyToCanonicalScenes), ("Story Intelligence temporal importer preserves manual chronology", StoryIntelligenceTemporalImporterPreservesManualChronology), ("Story Intelligence temporal materialisation saves canonical chronology", StoryIntelligenceTemporalMaterialisationSavesCanonicalChronology), ("Story Intelligence temporal malformed values are discarded", StoryIntelligenceTemporalMalformedValuesAreDiscarded), @@ -7211,6 +7214,26 @@ static void StoryIntelligenceTemporalRejectsUnanchoredClockTime() Assert(result.TimeModeName == "Relative", "Unanchored clock mention should remain relative."); } +static void StoryIntelligenceTemporalPreservesYearlessCalendarDateClues() +{ + var context = new StoryIntelligenceTemporalContext(null, null, null); + var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(absoluteDate: "28th December", partOfDay: "morning", evidence: "28th December", confidence: 0.8m), context); + + Assert(result.StartDateTime is null, "Yearless calendar dates should not silently use the current calendar year."); + Assert(result.TimeModeName == "Relative", "Yearless calendar dates without context should remain temporal clues."); + Assert(result.RelativeTimeText == "28th December", "Yearless calendar date evidence should be preserved."); +} + +static void StoryIntelligenceTemporalResolvesYearlessCalendarDatesFromContext() +{ + var context = new StoryIntelligenceTemporalContext(new DateTime(1982, 12, 24), new DateTime(1982, 12, 24), null); + var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(absoluteDate: "28th December", partOfDay: "morning", evidence: "28th December", confidence: 0.8m), context); + + Assert(result.StartDateTime == new DateTime(1982, 12, 28), "Yearless calendar dates should use the reliable rolling/chapter context year."); + Assert(result.TimeModeName == "Exact Date", "Context-resolved calendar dates should become exact dates."); + Assert(result.RelativeTimeText == "28 Dec 1982, morning", "Resolved yearless dates should retain the part-of-day label."); +} + static void StoryIntelligenceTemporalPreservesVagueFewDays() { var context = new StoryIntelligenceTemporalContext(new DateTime(1983, 10, 4), new DateTime(1983, 10, 4), null); @@ -7221,6 +7244,19 @@ static void StoryIntelligenceTemporalPreservesVagueFewDays() Assert(result.RelativeTimeText == "a few days later", "Vague wording should be preserved."); } +static void CoreImportAppliesStructuralChronologyToCanonicalScenes() +{ + var root = PlotLineRoot(); + var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs")); + var resolver = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceTemporalResolver.cs")); + + Assert(onboarding.Contains("await ApplyStructuralChronologyAsync(structuralScenes, result)", StringComparison.Ordinal), "Fresh core import should apply chronology after source text has canonical SceneIDs."); + Assert(onboarding.Contains("await ApplyStructuralChronologyAsync(recoveredScenes, existingBuild)", StringComparison.Ordinal), "Recovered core import should apply chronology when missing scene sources are repaired."); + Assert(onboarding.Contains("ExtractStructuralTemporalEvidence(structuralScene.SourceText)", StringComparison.Ordinal), "Core import chronology should use SceneManuscriptSources-equivalent structural source text."); + Assert(onboarding.Contains("HasExistingChronology(canonicalScene)", StringComparison.Ordinal), "Core import chronology should preserve existing author-entered chronology."); + Assert(resolver.Contains("HasExplicitYear(clean)", StringComparison.Ordinal), "Temporal resolver should avoid current-year date parsing for yearless manuscript dates."); +} + static void StoryIntelligenceTemporalImporterPreservesManualChronology() { var repository = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Data/StoryIntelligenceResultRepository.cs")); diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index 2f20712..9b46103 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -47,6 +47,7 @@ public sealed class OnboardingService( ICharacterRepository characters, IChapterRepository chapters, ISceneRepository scenes, + ILookupRepository lookups, IOnboardingBuildRepository builds, IManuscriptStructureAnalysisService structureAnalysis, IManuscriptTextStatisticsService manuscriptTextStatistics, @@ -350,6 +351,7 @@ public sealed class OnboardingService( await SaveStructuralChapterSourcesAsync(preview, existingBuild, recoveredScenes); await SaveSceneManuscriptSourcesAsync(preview, recoveredScenes, existingBuild); await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID); + await ApplyStructuralChronologyAsync(recoveredScenes, existingBuild); await ApplyStructuralPovAsync(preview, recoveredScenes, existingBuild); } else @@ -437,6 +439,7 @@ public sealed class OnboardingService( await SaveStructuralChapterSourcesAsync(preview, result, structuralScenes); await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result); await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID); + await ApplyStructuralChronologyAsync(structuralScenes, result); await ApplyStructuralPovAsync(preview, structuralScenes, result); var canonicalSceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID); var locationReview = await coreImportBasicLocations.EnsurePreparedAsync( @@ -547,6 +550,174 @@ public sealed class OnboardingService( } } + private async Task ApplyStructuralChronologyAsync( + IReadOnlyList structuralScenes, + OnboardingManuscriptBuildResult result) + { + if (structuralScenes.Count == 0 || result.SceneMappings.Count == 0) + { + return; + } + + var lookupData = await lookups.GetAllAsync(); + var timeModeIds = lookupData.TimeModes.ToDictionary(item => item.TimeModeName, item => item.TimeModeID, StringComparer.OrdinalIgnoreCase); + var timeConfidenceIds = lookupData.TimeConfidences.ToDictionary(item => item.TimeConfidenceName, item => item.TimeConfidenceID, StringComparer.OrdinalIgnoreCase); + var sceneMap = result.SceneMappings.ToDictionary(mapping => mapping.TemporarySceneKey, StringComparer.Ordinal); + + foreach (var chapterScenes in structuralScenes + .GroupBy(scene => scene.TemporaryChapterKey) + .OrderBy(group => group.Min(scene => scene.WordStartPosition))) + { + var temporalContext = StoryIntelligenceTemporalResolver.CreateChapterContext( + chapterScenes.FirstOrDefault()?.SourceText, + chapterScenes.FirstOrDefault()?.StructuralSummary, + chapterScenes.FirstOrDefault()?.StructuralSetting); + + foreach (var structuralScene in chapterScenes.OrderBy(scene => scene.SceneNumberWithinChapter)) + { + if (!sceneMap.TryGetValue(structuralScene.TemporarySceneKey, out var mappedScene)) + { + continue; + } + + var temporalScene = BuildStructuralTemporalScene(structuralScene); + if (temporalScene is null) + { + continue; + } + + var resolved = StoryIntelligenceTemporalResolver.Resolve(temporalScene, temporalContext); + temporalContext = resolved.NextContext; + if (string.Equals(resolved.TimeModeName, "Unknown", StringComparison.OrdinalIgnoreCase) + || (!resolved.StartDateTime.HasValue && string.IsNullOrWhiteSpace(resolved.RelativeTimeText))) + { + continue; + } + + var canonicalScene = await scenes.GetAsync(mappedScene.SceneID); + if (canonicalScene is null || HasExistingChronology(canonicalScene)) + { + continue; + } + + if (!timeModeIds.TryGetValue(resolved.TimeModeName, out var timeModeId) + || !timeConfidenceIds.TryGetValue(resolved.TimeConfidenceName, out var timeConfidenceId)) + { + continue; + } + + canonicalScene.TimeModeID = timeModeId; + canonicalScene.TimeModeName = resolved.TimeModeName; + canonicalScene.StartDateTime = resolved.StartDateTime; + canonicalScene.EndDateTime = resolved.EndDateTime; + canonicalScene.DurationAmount = resolved.DurationAmount; + canonicalScene.DurationUnitID = resolved.DurationUnitID; + canonicalScene.RelativeTimeText = resolved.RelativeTimeText; + canonicalScene.TimeConfidenceID = timeConfidenceId; + canonicalScene.TimeConfidenceName = resolved.TimeConfidenceName; + await scenes.SaveAsync(canonicalScene); + } + } + } + + private static SceneIntelligenceScene? BuildStructuralTemporalScene(ManuscriptStructureSceneBoundary structuralScene) + { + var evidence = ExtractStructuralTemporalEvidence(structuralScene.SourceText) + ?? ExtractStructuralTemporalEvidence(structuralScene.StructuralSetting) + ?? ExtractStructuralTemporalEvidence(structuralScene.StructuralSummary); + var partOfDay = FirstTemporalPartOfDay(structuralScene.StructuralSetting) + ?? FirstTemporalPartOfDay(structuralScene.SourceText) + ?? FirstTemporalPartOfDay(structuralScene.StructuralSummary); + + if (string.IsNullOrWhiteSpace(evidence) && string.IsNullOrWhiteSpace(partOfDay)) + { + return null; + } + + return new SceneIntelligenceScene + { + Setting = new SceneIntelligenceSetting + { + DateOrTimeReference = evidence, + TimeOfDay = partOfDay, + Confidence = string.IsNullOrWhiteSpace(evidence) ? 0.65m : 0.9m + }, + TemporalAnalysis = new SceneIntelligenceTemporalAnalysis + { + AbsoluteDate = evidence, + PartOfDay = partOfDay, + Evidence = evidence ?? partOfDay, + Confidence = string.IsNullOrWhiteSpace(evidence) ? 0.65m : 0.9m + }, + TimelineClues = string.IsNullOrWhiteSpace(evidence) + ? [] + : [new SceneIntelligenceTimelineClue { Clue = evidence, AbsoluteDate = evidence, Evidence = evidence, Confidence = 0.9m }] + }; + } + + private static string? ExtractStructuralTemporalEvidence(string? value) + { + var text = Clean(value); + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + var explicitDate = Regex.Match( + text, + @"\b(?:\d{1,2}(?:st|nd|rd|th)?\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)|(?: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{1,2}(?:st|nd|rd|th)?),?\s+\d{4}\b", + RegexOptions.IgnoreCase); + if (explicitDate.Success) + { + return explicitDate.Value; + } + + var yearlessDate = Regex.Match( + text, + @"\b(?:\d{1,2}(?:st|nd|rd|th)?\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)|(?: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{1,2}(?:st|nd|rd|th)?)\b", + RegexOptions.IgnoreCase); + if (yearlessDate.Success) + { + return yearlessDate.Value; + } + + var relative = Regex.Match( + text, + @"\b(?:the\s+)?(?:same|following|next)\s+(?:morning|afternoon|evening|night|day|week|month|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)\b|\b(?:\d+|one|two|three|four|five|six|seven|eight|nine|ten)\s+days?\s+later\b|\bafter midnight\b|\bbefore dawn\b", + RegexOptions.IgnoreCase); + return relative.Success ? relative.Value : null; + } + + private static string? FirstTemporalPartOfDay(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 HasExistingChronology(Scene scene) + => scene.StartDateTime.HasValue + || scene.EndDateTime.HasValue + || scene.DurationAmount.HasValue + || scene.DurationUnitID.HasValue + || !string.IsNullOrWhiteSpace(scene.RelativeTimeText) + || (!string.IsNullOrWhiteSpace(scene.TimeModeName) + && !string.Equals(scene.TimeModeName, "Unknown", StringComparison.OrdinalIgnoreCase)) + || (!string.IsNullOrWhiteSpace(scene.TimeConfidenceName) + && !string.Equals(scene.TimeConfidenceName, "Unknown", StringComparison.OrdinalIgnoreCase)); + private async Task ApplyStructuralPovAsync( ManuscriptScanPreview preview, IReadOnlyList structuralScenes, diff --git a/PlotLine/Services/StoryIntelligenceTemporalResolver.cs b/PlotLine/Services/StoryIntelligenceTemporalResolver.cs index 19f4978..7563ac4 100644 --- a/PlotLine/Services/StoryIntelligenceTemporalResolver.cs +++ b/PlotLine/Services/StoryIntelligenceTemporalResolver.cs @@ -96,7 +96,8 @@ public static class StoryIntelligenceTemporalResolver } var confidence = NormaliseConfidence(temporal?.Confidence); - var absoluteDate = ParseDate(temporal?.AbsoluteDate) ?? ParseDate(fallbackText); + 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; @@ -259,7 +260,7 @@ public static class StoryIntelligenceTemporalResolver return false; } - private static DateTime? ParseDate(string? value) + private static DateTime? ParseDate(string? value, DateTime? contextDate = null) { var clean = StripOrdinalSuffixes(Clean(value)); if (string.IsNullOrWhiteSpace(clean)) @@ -267,6 +268,8 @@ public static class StoryIntelligenceTemporalResolver return null; } + var hasExplicitYear = HasExplicitYear(clean); + foreach (var culture in DateCultures) { if (DateTime.TryParseExact(clean, DateFormats, culture, DateTimeStyles.AllowWhiteSpaces, out var exact)) @@ -274,7 +277,7 @@ public static class StoryIntelligenceTemporalResolver return exact.Date; } - if (DateTime.TryParse(clean, culture, DateTimeStyles.AllowWhiteSpaces, out var parsed) && parsed.Year > 1000) + if (hasExplicitYear && DateTime.TryParse(clean, culture, DateTimeStyles.AllowWhiteSpaces, out var parsed) && parsed.Year > 1000) { return parsed.Date; } @@ -289,9 +292,51 @@ public static class StoryIntelligenceTemporalResolver return ParseDate(embeddedDate.Value); } + if (contextDate.HasValue) + { + var monthDay = 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)\b", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (!monthDay.Success) + { + monthDay = System.Text.RegularExpressions.Regex.Match( + clean, + @"\b(?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{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('’', '\'');