Apply structural chronology during core import
This commit is contained in:
parent
846016b7be
commit
296a4afb99
@ -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"));
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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('’', '\'');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user