Apply structural chronology during core import

This commit is contained in:
Nick Beckley 2026-09-05 21:14:16 +00:00
parent 846016b7be
commit 296a4afb99
3 changed files with 255 additions and 3 deletions

View File

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

View File

@ -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<ManuscriptStructureSceneBoundary> 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<ManuscriptStructureSceneBoundary> structuralScenes,

View File

@ -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(?<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('’', '\'');