diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index f434a50..2e6c3df 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -116,6 +116,13 @@ var tests = new (string Name, Action Test)[] ("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 inherits previous morning when extraction is empty", StoryIntelligenceTemporalInheritsPreviousMorningWhenExtractionIsEmpty), + ("Story Intelligence temporal inherits previous evening when extraction is empty", StoryIntelligenceTemporalInheritsPreviousEveningWhenExtractionIsEmpty), + ("Story Intelligence temporal inherits previous night when extraction is empty", StoryIntelligenceTemporalInheritsPreviousNightWhenExtractionIsEmpty), + ("Story Intelligence temporal inherits after midnight advancement", StoryIntelligenceTemporalInheritsAfterMidnightAdvancement), + ("Story Intelligence temporal inherits chapter anchor when extraction is empty", StoryIntelligenceTemporalInheritsChapterAnchorWhenExtractionIsEmpty), + ("Story Intelligence temporal stays unknown without context or extraction", StoryIntelligenceTemporalStaysUnknownWithoutContextOrExtraction), + ("Story Intelligence temporal explicit date overrides previous context", StoryIntelligenceTemporalExplicitDateOverridesPreviousContext), ("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), @@ -7234,6 +7241,73 @@ static void StoryIntelligenceTemporalResolvesYearlessCalendarDatesFromContext() Assert(result.RelativeTimeText == "28 Dec 1982, morning", "Resolved yearless dates should retain the part-of-day label."); } +static void StoryIntelligenceTemporalInheritsPreviousMorningWhenExtractionIsEmpty() +{ + var context = new StoryIntelligenceTemporalContext(new DateTime(1982, 12, 28), new DateTime(1982, 12, 28), "morning"); + var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithoutTemporalExtraction(), context); + + Assert(result.StartDateTime == new DateTime(1982, 12, 28), "Empty extraction should inherit the previous resolved date."); + Assert(result.TimeModeName == "Exact Date", "Inherited exact dates should remain exact dates."); + Assert(result.RelativeTimeText == "28 Dec 1982, morning", "Empty extraction should inherit the previous part of day."); +} + +static void StoryIntelligenceTemporalInheritsPreviousEveningWhenExtractionIsEmpty() +{ + var context = new StoryIntelligenceTemporalContext(new DateTime(1982, 12, 28), new DateTime(1982, 12, 28), "evening"); + var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithoutTemporalExtraction(), context); + + Assert(result.StartDateTime == new DateTime(1982, 12, 28), "Empty extraction should inherit the previous evening date."); + Assert(result.RelativeTimeText == "28 Dec 1982, evening", "Empty extraction should preserve evening precision."); +} + +static void StoryIntelligenceTemporalInheritsPreviousNightWhenExtractionIsEmpty() +{ + var context = new StoryIntelligenceTemporalContext(new DateTime(1982, 12, 28), new DateTime(1982, 12, 28), "night"); + var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithoutTemporalExtraction(), context); + + Assert(result.StartDateTime == new DateTime(1982, 12, 28), "Empty extraction should inherit the previous night date."); + Assert(result.RelativeTimeText == "28 Dec 1982, night", "Empty extraction should preserve night precision."); +} + +static void StoryIntelligenceTemporalInheritsAfterMidnightAdvancement() +{ + var context = new StoryIntelligenceTemporalContext(new DateTime(1982, 12, 28), new DateTime(1982, 12, 28), "night"); + var advanced = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(partOfDay: "night", crossesMidnight: true, evidence: "after midnight", confidence: 0.9m), context); + var inherited = StoryIntelligenceTemporalResolver.Resolve(SceneWithoutTemporalExtraction(), advanced.NextContext); + + Assert(advanced.StartDateTime == new DateTime(1982, 12, 29), "After midnight should advance from the previous scene date."); + Assert(inherited.StartDateTime == new DateTime(1982, 12, 29), "Empty extraction after midnight should inherit the advanced date."); +} + +static void StoryIntelligenceTemporalInheritsChapterAnchorWhenExtractionIsEmpty() +{ + var context = new StoryIntelligenceTemporalContext(new DateTime(1982, 12, 28), null, null); + var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithoutTemporalExtraction(), context); + + Assert(result.StartDateTime == new DateTime(1982, 12, 28), "Empty extraction should inherit chapter anchor when no previous scene chronology exists."); + Assert(result.TimeModeName == "Exact Date", "Chapter anchor fallback should produce the anchored date."); + Assert(result.RelativeTimeText is null, "Chapter anchor fallback should not invent a part of day."); +} + +static void StoryIntelligenceTemporalStaysUnknownWithoutContextOrExtraction() +{ + var context = new StoryIntelligenceTemporalContext(null, null, null); + var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithoutTemporalExtraction(), context); + + Assert(result.StartDateTime is null, "No extraction and no context should remain unknown."); + Assert(result.TimeModeName == "Unknown", "Unknown should be the final fallback when there is no chronology context."); + Assert(result.RelativeTimeText is null, "Unknown chronology should not invent display text."); +} + +static void StoryIntelligenceTemporalExplicitDateOverridesPreviousContext() +{ + var context = new StoryIntelligenceTemporalContext(new DateTime(1982, 12, 28), new DateTime(1982, 12, 28), "night"); + var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(absoluteDate: "29 Dec 1982", partOfDay: "morning", evidence: "29 Dec 1982 morning", confidence: 0.95m), context); + + Assert(result.StartDateTime == new DateTime(1982, 12, 29), "Explicit date should override the previous resolved date."); + Assert(result.RelativeTimeText == "29 Dec 1982, morning", "Explicit replacement should keep the current scene part of day."); +} + static void StoryIntelligenceTemporalPreservesVagueFewDays() { var context = new StoryIntelligenceTemporalContext(new DateTime(1983, 10, 4), new DateTime(1983, 10, 4), null); @@ -7253,6 +7327,7 @@ static void CoreImportAppliesStructuralChronologyToCanonicalScenes() 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("StoryIntelligenceTemporalResolver.Resolve(BuildStructuralTemporalScene(structuralScene), temporalContext)", StringComparison.Ordinal), "Cue-free structural scenes should still enter the rolling resolver so they can inherit chronology."); 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."); } @@ -7421,6 +7496,15 @@ static SceneIntelligenceScene SceneWithTemporal( } }; +static SceneIntelligenceScene SceneWithoutTemporalExtraction() + => new() + { + Summary = new SceneIntelligenceSummary { Short = "The scene continues without a new time cue.", Confidence = 0.8m }, + Setting = new SceneIntelligenceSetting { LocationName = "Pub", LocationType = "interior", Confidence = 0.8m }, + TemporalAnalysis = null, + TimelineClues = [] + }; + static JsonElement SceneSchemaRoot() => JsonSerializer.SerializeToElement(StoryIntelligenceSceneStructuredOutputSchema.Contract.Schema); diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index 9b46103..526e8b8 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -580,13 +580,7 @@ public sealed class OnboardingService( continue; } - var temporalScene = BuildStructuralTemporalScene(structuralScene); - if (temporalScene is null) - { - continue; - } - - var resolved = StoryIntelligenceTemporalResolver.Resolve(temporalScene, temporalContext); + var resolved = StoryIntelligenceTemporalResolver.Resolve(BuildStructuralTemporalScene(structuralScene), temporalContext); temporalContext = resolved.NextContext; if (string.Equals(resolved.TimeModeName, "Unknown", StringComparison.OrdinalIgnoreCase) || (!resolved.StartDateTime.HasValue && string.IsNullOrWhiteSpace(resolved.RelativeTimeText))) diff --git a/PlotLine/Services/StoryIntelligenceTemporalResolver.cs b/PlotLine/Services/StoryIntelligenceTemporalResolver.cs index 7563ac4..70a6a7e 100644 --- a/PlotLine/Services/StoryIntelligenceTemporalResolver.cs +++ b/PlotLine/Services/StoryIntelligenceTemporalResolver.cs @@ -95,6 +95,7 @@ public static class StoryIntelligenceTemporalResolver 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); @@ -103,12 +104,19 @@ public static class StoryIntelligenceTemporalResolver 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 (temporal?.CrossesMidnight == true) + if (!hasCurrentTemporalSignal) + { + resolvedDate = baseDate.Value.Date; + isDateDeterministic = true; + } + else if (temporal?.CrossesMidnight == true) { resolvedDate = baseDate.Value.AddDays(1); isDateDeterministic = true; @@ -128,7 +136,7 @@ public static class StoryIntelligenceTemporalResolver resolvedDate = baseDate.Value.Date; isDateDeterministic = true; } - else if (!HasApproximateSignal(temporal, fallbackText) && (currentPartOfDay is not null || HasSceneTemporalSignal(temporal, scene))) + else if (!HasApproximateSignal(temporal, fallbackText) && (currentPartOfDay is not null || hasCurrentTemporalSignal)) { resolvedDate = baseDate.Value.Date; isDateDeterministic = true; @@ -162,12 +170,12 @@ public static class StoryIntelligenceTemporalResolver var displayText = string.Equals(mode, "Unknown", StringComparison.OrdinalIgnoreCase) ? null - : BuildRelativeText(start, exactTime, currentPartOfDay, relativeText, temporal); + : BuildRelativeText(start, exactTime, displayPartOfDay, relativeText, temporal); var resolvedConfidence = ResolveConfidenceName(confidence, isDateDeterministic, mode); var nextContext = context with { LastDate = start?.Date ?? (isDateDeterministic ? resolvedDate : context.LastDate), - LastPartOfDay = currentPartOfDay ?? context.LastPartOfDay + LastPartOfDay = displayPartOfDay ?? context.LastPartOfDay }; return new StoryIntelligenceTemporalResolution(start, null, null, null, displayText, mode, resolvedConfidence, nextContext); @@ -518,8 +526,7 @@ public static class StoryIntelligenceTemporalResolver private static bool HasSceneTemporalSignal(SceneIntelligenceTemporalAnalysis? temporal, SceneIntelligenceScene? scene) => temporal is not null - && (temporal.Confidence > 0 - || !string.IsNullOrWhiteSpace(temporal.AbsoluteDate) + && (!string.IsNullOrWhiteSpace(temporal.AbsoluteDate) || !string.IsNullOrWhiteSpace(temporal.ExactTime) || !string.IsNullOrWhiteSpace(temporal.Evidence) || !string.IsNullOrWhiteSpace(scene?.Setting?.DateOrTimeReference)