Fix Story Intelligence progress and Word marker sync

This commit is contained in:
Nick Beckley 2026-09-05 20:26:05 +00:00
parent 390ea19550
commit cb0beb1a12
34 changed files with 3730 additions and 66 deletions

View File

@ -107,6 +107,17 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence scene call uses strict structured output", StoryIntelligenceSceneCallUsesStrictStructuredOutput),
("Story Intelligence scene schema constrains confidence numbers", StoryIntelligenceSceneSchemaConstrainsConfidenceNumbers),
("Story Intelligence scene schema constrains evidence strings", StoryIntelligenceSceneSchemaConstrainsEvidenceStrings),
("Story Intelligence temporal resolves chapter anchor and afternoon", StoryIntelligenceTemporalResolvesChapterAnchorAndAfternoon),
("Story Intelligence temporal rolls over after midnight", StoryIntelligenceTemporalRollsOverAfterMidnight),
("Story Intelligence temporal resolves following morning", StoryIntelligenceTemporalResolvesFollowingMorning),
("Story Intelligence temporal resolves exact day offsets", StoryIntelligenceTemporalResolvesExactDayOffsets),
("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 vague few days", StoryIntelligenceTemporalPreservesVagueFewDays),
("Story Intelligence temporal importer preserves manual chronology", StoryIntelligenceTemporalImporterPreservesManualChronology),
("Story Intelligence temporal materialisation saves canonical chronology", StoryIntelligenceTemporalMaterialisationSavesCanonicalChronology),
("Story Intelligence temporal malformed values are discarded", StoryIntelligenceTemporalMalformedValuesAreDiscarded),
("Story Intelligence structured metric values validate and import", StoryIntelligenceStructuredMetricValuesValidateAndImport),
("Story Intelligence structured schema covers complete response model", StoryIntelligenceStructuredSchemaCoversCompleteResponseModel),
("Story Intelligence structured schema remains fixture agnostic", StoryIntelligenceStructuredSchemaRemainsFixtureAgnostic),
@ -300,6 +311,12 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering),
("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls),
("Story Intelligence Review Centre uses bounded summary query", StoryIntelligenceReviewCentreUsesBoundedSummaryQuery),
("Story Intelligence character review queues enrichment beside plot synthesis", StoryIntelligenceCharacterReviewQueuesEnrichmentBesidePlotSynthesis),
("Story Intelligence character enrichment uses persisted SignalR progress", StoryIntelligenceCharacterEnrichmentUsesPersistedSignalRProgress),
("Story Intelligence character enrichment preserves author fields", StoryIntelligenceCharacterEnrichmentPreservesAuthorFields),
("Character detail can queue single-character enrichment", CharacterDetailCanQueueSingleCharacterEnrichment),
("Single-character enrichment is persisted and duplicate safe", SingleCharacterEnrichmentIsPersistedAndDuplicateSafe),
("Character detail enrichment status uses scoped SignalR", CharacterDetailEnrichmentStatusUsesScopedSignalR),
("Story Intelligence detailed review GETs use selected module paths", StoryIntelligenceDetailedReviewGetsUseSelectedModulePaths),
("Story Intelligence completed Character review remains inspectable", StoryIntelligenceCompletedCharacterReviewRemainsInspectable),
("Story Intelligence Review Centre completed modules use canonical destinations", StoryIntelligenceReviewCentreCompletedModulesUseCanonicalDestinations),
@ -1153,6 +1170,132 @@ static void StoryIntelligenceReviewCentreUsesBoundedSummaryQuery()
Assert(viewModel.Contains("SummaryNeedsAttentionCount", StringComparison.Ordinal), "Tile counts should support lightweight summary values without full candidate objects.");
}
static void StoryIntelligenceCharacterReviewQueuesEnrichmentBesidePlotSynthesis()
{
var root = PlotLineRoot();
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var worker = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceWorker.cs"));
var program = File.ReadAllText(Path.Combine(root, "Program.cs"));
Assert(service.Contains("ICharacterEnrichmentService characterEnrichment", StringComparison.Ordinal), "Onboarding Story Intelligence should depend on the enrichment service.");
Assert(service.Contains("QueuePostCharacterReviewAnalysisAsync(batch)", StringComparison.Ordinal), "Character completion should use the shared post-review queue boundary.");
Assert(service.Contains("wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync", StringComparison.Ordinal), "Post-review queueing must retain Plot Lines & Threads synthesis.");
Assert(service.Contains("characterEnrichment.QueueAfterCharacterResolutionAsync", StringComparison.Ordinal), "Post-review queueing must start character enrichment.");
Assert(service.Contains("Task.WhenAll(", StringComparison.Ordinal), "Plot synthesis and character enrichment should be queued independently together.");
Assert(worker.Contains("ProcessWholeBookPlotSynthesisAsync", StringComparison.Ordinal)
&& worker.Contains("ProcessCharacterEnrichmentAsync", StringComparison.Ordinal), "The persisted worker should process both independent job types.");
Assert(program.Contains("ICharacterEnrichmentRepository, CharacterEnrichmentRepository", StringComparison.Ordinal)
&& program.Contains("ICharacterEnrichmentService, CharacterEnrichmentService", StringComparison.Ordinal), "Character enrichment services should be registered.");
}
static void StoryIntelligenceCharacterEnrichmentUsesPersistedSignalRProgress()
{
var root = PlotLineRoot();
var models = File.ReadAllText(Path.Combine(root, "Models/CharacterEnrichmentModels.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/CharacterEnrichmentRepository.cs"));
var notifier = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceProgressNotifier.cs"));
var hub = File.ReadAllText(Path.Combine(root, "Hubs/StoryIntelligenceHub.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReviewCentre.cshtml"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-review-centre-progress.js"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/183_Phase27J_CharacterEnrichmentRuns.sql"));
Assert(models.Contains("ProcessedCharacters", StringComparison.Ordinal)
&& models.Contains("TotalCharacters", StringComparison.Ordinal), "Character enrichment progress should expose real processed/total counts.");
Assert(repository.Contains("\"dbo.CharacterEnrichmentRun_GetCurrentByBook\"", StringComparison.Ordinal)
&& repository.Contains("\"dbo.CharacterEnrichmentRun_UpdateProgress\"", StringComparison.Ordinal), "Character enrichment state should be reloadable from persisted run state.");
Assert(notifier.Contains("CharacterEnrichmentProgressChanged", StringComparison.Ordinal), "SignalR notifier should publish character enrichment progress.");
Assert(hub.Contains("WatchCharacterEnrichment", StringComparison.Ordinal), "SignalR hub should support reconnect/refresh snapshots for character enrichment.");
Assert(view.Contains("data-character-enrichment-tile", StringComparison.Ordinal)
&& view.Contains("View Characters", StringComparison.Ordinal)
&& view.Contains("Analysing", StringComparison.Ordinal), "Review Centre should render the Characters analysing/completed states.");
Assert(script.Contains("WatchCharacterEnrichment", StringComparison.Ordinal)
&& script.Contains("CharacterEnrichmentProgressChanged", StringComparison.Ordinal), "Review Centre script should use SignalR rather than polling.");
Assert(migration.Contains("CREATE TABLE dbo.CharacterEnrichmentRuns", StringComparison.Ordinal)
&& migration.Contains("CharacterEnrichmentRun_ClaimNext", StringComparison.Ordinal)
&& migration.Contains("CharacterEnrichmentRun_Fail", StringComparison.Ordinal), "Migration should persist queue, progress, retry, and failure state.");
}
static void StoryIntelligenceCharacterEnrichmentPreservesAuthorFields()
{
var root = PlotLineRoot();
var service = File.ReadAllText(Path.Combine(root, "Services/CharacterEnrichmentService.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/183_Phase27J_CharacterEnrichmentRuns.sql"));
Assert(service.Contains("FactMinimumConfidence = 0.9m", StringComparison.Ordinal), "Structured character facts should use a conservative confidence gate.");
Assert(service.Contains("StrongFact", StringComparison.Ordinal)
&& service.Contains("fact.SceneID is > 0", StringComparison.Ordinal), "Structured facts should require scene provenance before saving.");
Assert(migration.Contains("DefaultDescription = CASE WHEN NULLIF(LTRIM(RTRIM(c.DefaultDescription)), N'') IS NULL", StringComparison.Ordinal), "Generated summaries should not overwrite existing descriptions.");
Assert(migration.Contains("BirthDate = CASE WHEN c.BirthDate IS NULL", StringComparison.Ordinal), "Generated dates of birth should not overwrite existing values.");
Assert(migration.Contains("AgeAtSeriesStart = CASE WHEN c.AgeAtSeriesStart IS NULL", StringComparison.Ordinal), "Generated ages should not overwrite existing values.");
Assert(migration.Contains("Height = CASE WHEN NULLIF(LTRIM(RTRIM(c.Height)), N'') IS NULL", StringComparison.Ordinal), "Generated height should not overwrite existing values.");
Assert(migration.Contains("EyeColour = CASE WHEN NULLIF(LTRIM(RTRIM(c.EyeColour)), N'') IS NULL", StringComparison.Ordinal), "Generated eye colour should not overwrite existing values.");
Assert(migration.Contains("CREATE TABLE dbo.CharacterEnrichmentEvidence", StringComparison.Ordinal), "Generated facts should retain internal provenance/evidence.");
}
static void CharacterDetailCanQueueSingleCharacterEnrichment()
{
var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/CharactersController.cs"));
var characterService = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
var enrichmentService = File.ReadAllText(Path.Combine(root, "Services/CharacterEnrichmentService.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Characters/Details.cshtml"));
Assert(view.Contains("Reanalyse character", StringComparison.Ordinal), "Character detail should expose a discreet reanalyse action.");
Assert(view.Contains("data-character-reanalyse-form", StringComparison.Ordinal), "Character reanalyse action should be script-addressable.");
Assert(controller.Contains("Reanalyse(int id)", StringComparison.Ordinal)
&& controller.Contains("QueueCharacterReanalysisAsync(id)", StringComparison.Ordinal), "Character controller should queue manual reanalysis through the character service.");
Assert(characterService.Contains("QueueCharacterReanalysisAsync(int characterId)", StringComparison.Ordinal), "Character service should expose a UI-independent reanalysis queue method.");
Assert(enrichmentService.Contains("QueueForCharacterAsync(int characterId, int userId, int? bookId = null)", StringComparison.Ordinal), "Character enrichment service should expose a reusable single-character queue method for future merge workflows.");
}
static void SingleCharacterEnrichmentIsPersistedAndDuplicateSafe()
{
var root = PlotLineRoot();
var migration = File.ReadAllText(Path.Combine(root, "Sql/185_Phase27L_ManualCharacterEnrichment.sql"));
var repository = File.ReadAllText(Path.Combine(root, "Data/CharacterEnrichmentRepository.cs"));
var enrichmentService = File.ReadAllText(Path.Combine(root, "Services/CharacterEnrichmentService.cs"));
var originalMigration = File.ReadAllText(Path.Combine(root, "Sql/183_Phase27J_CharacterEnrichmentRuns.sql"));
Assert(migration.Contains("ALTER TABLE dbo.CharacterEnrichmentRuns ADD CharacterID int NULL", StringComparison.Ordinal), "Manual reanalysis should persist character scope on the shared enrichment run table.");
Assert(migration.Contains("CharacterEnrichmentRun_QueueCharacter", StringComparison.Ordinal), "Manual reanalysis should use a stored procedure queue path.");
Assert(migration.Contains("UX_CharacterEnrichmentRuns_ActiveCharacter", StringComparison.Ordinal)
&& migration.Contains("Status IN (N'Queued', N'Running')", StringComparison.Ordinal), "Manual reanalysis should prevent duplicate active jobs for the same character.");
Assert(migration.Contains("WITH (UPDLOCK, HOLDLOCK)", StringComparison.Ordinal), "Manual reanalysis queue should coalesce concurrent clicks inside SQL.");
Assert(migration.Contains("WasCoalesced", StringComparison.Ordinal)
&& enrichmentService.Contains("active run already exists", StringComparison.Ordinal), "Duplicate manual reanalysis requests should be logged as coalesced.");
Assert(migration.Contains("AND (@BookID IS NULL OR ch.BookID = @BookID)", StringComparison.Ordinal), "Manual reanalysis should support explicit book/character queueing.");
Assert(migration.Contains("pua.UserID = @UserID", StringComparison.Ordinal), "Manual reanalysis should validate project ownership server-side.");
Assert(migration.Contains("CharacterEnrichmentRun_GetCurrentByBook", StringComparison.Ordinal)
&& migration.Contains("CharacterID IS NULL", StringComparison.Ordinal), "Book-level Review Centre status should ignore manual per-character runs.");
Assert(repository.Contains("QueueCharacterAsync", StringComparison.Ordinal)
&& repository.Contains("GetCharacterStatusAsync", StringComparison.Ordinal), "Repository should persist and reload manual reanalysis state.");
Assert(enrichmentService.Contains("BuildContextAsync(run.ProjectID, run.BookID, run.CharacterID)", StringComparison.Ordinal), "Worker should reuse bulk context building with a character filter.");
Assert(originalMigration.Contains("DefaultDescription = CASE WHEN NULLIF(LTRIM(RTRIM(c.DefaultDescription)), N'') IS NULL", StringComparison.Ordinal), "Shared completion should preserve author-entered descriptions.");
}
static void CharacterDetailEnrichmentStatusUsesScopedSignalR()
{
var root = PlotLineRoot();
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/CoreViewModels.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Characters/Details.cshtml"));
var hub = File.ReadAllText(Path.Combine(root, "Hubs/StoryIntelligenceHub.cs"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/character-enrichment-detail.js"));
var models = File.ReadAllText(Path.Combine(root, "Models/CharacterEnrichmentModels.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/185_Phase27L_ManualCharacterEnrichment.sql"));
Assert(viewModel.Contains("CharacterEnrichmentCharacterStatus CharacterAnalysis", StringComparison.Ordinal), "Character detail should reconstruct persisted enrichment status on reload.");
Assert(view.Contains("Character analysis:", StringComparison.Ordinal)
&& view.Contains("Last analysed:", StringComparison.Ordinal), "Character detail should display analysis status and last successful analysis time.");
Assert(view.Contains("Story Intelligence Analysis", StringComparison.Ordinal), "Character detail should display generated analysis separately from author profile data.");
Assert(hub.Contains("WatchCharacterEnrichmentForCharacter", StringComparison.Ordinal), "SignalR hub should support character-scoped enrichment snapshots.");
Assert(models.Contains("public int? CharacterID { get; init; }", StringComparison.Ordinal), "SignalR progress event should include character scope.");
Assert(script.Contains("CharacterEnrichmentProgressChanged", StringComparison.Ordinal)
&& script.Contains("Number(snapshot.characterID || snapshot.characterId || 0) !== characterId", StringComparison.Ordinal), "Character detail script should ignore unrelated enrichment events.");
Assert(script.Contains("window.location.reload()", StringComparison.Ordinal), "Character detail should refresh displayed generated details after completion.");
Assert(migration.Contains("LastAnalysedUtc = @LastAnalysedUtc", StringComparison.Ordinal), "Last analysed should come only from a successful completed enrichment run.");
Assert(migration.Contains("Status = COALESCE((SELECT TOP (1) Status FROM @Attempt)", StringComparison.Ordinal), "Failed latest attempts should remain visible and retryable.");
}
static void StoryIntelligenceDetailedReviewGetsUseSelectedModulePaths()
{
var root = PlotLineRoot();
@ -1434,6 +1577,7 @@ static void ProjectHardDeleteClearsStoryIntelligenceDependenciesInTransaction()
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
var cascadeMigration = File.ReadAllText(Path.Combine(root, "Sql/159_Phase25D_ManuscriptSourceHardDeleteCascades.sql"));
var dependencyCleanupMigration = File.ReadAllText(Path.Combine(root, "Sql/160_Phase25E_ProjectHardDeleteDependencyCleanup.sql"));
var reviewCentreCleanupMigration = File.ReadAllText(Path.Combine(root, "Sql/184_Phase27K_ProjectHardDeleteReviewCentreCleanup.sql"));
Assert(repository.Contains("using var transaction = connection.BeginTransaction()", StringComparison.Ordinal), "Project hard delete should be wrapped by a repository transaction.");
Assert(repository.Contains("\"dbo.ProjectHardDelete_PrepareDependencies\"", StringComparison.Ordinal), "Project hard delete should run scoped dependency cleanup through a stored procedure.");
@ -1449,6 +1593,9 @@ static void ProjectHardDeleteClearsStoryIntelligenceDependenciesInTransaction()
Assert(dependencyCleanupMigration.Contains("SceneAssetSuggestions", StringComparison.Ordinal), "Hard delete cleanup should remove scene asset suggestion rows.");
Assert(dependencyCleanupMigration.Contains("WordCompanionSceneLocations", StringComparison.Ordinal), "Hard delete cleanup should remove Word Companion scene location rows.");
Assert(dependencyCleanupMigration.Contains("WordCompanionSceneAssets", StringComparison.Ordinal), "Hard delete cleanup should remove Word Companion scene asset rows.");
Assert(reviewCentreCleanupMigration.Contains("CoreImportBasicLocationReviews", StringComparison.Ordinal), "Hard delete cleanup should remove basic location review rows before deleting locations.");
Assert(reviewCentreCleanupMigration.Contains("CharacterEnrichmentEvidence", StringComparison.Ordinal), "Hard delete cleanup should remove character enrichment evidence before deleting scenes or characters.");
Assert(reviewCentreCleanupMigration.Contains("CharacterEnrichmentRuns", StringComparison.Ordinal), "Hard delete cleanup should remove character enrichment runs before deleting pipeline or book rows.");
Assert(dependencyCleanupMigration.Contains("WHERE ProjectID = @ProjectID", StringComparison.Ordinal), "Hard delete cleanup must be scoped by project id.");
Assert(cascadeMigration.Contains("FK_SceneManuscriptSources_Scenes", StringComparison.Ordinal)
&& cascadeMigration.Contains("REFERENCES dbo.Scenes(SceneID) ON DELETE CASCADE", StringComparison.Ordinal), "Scene manuscript source text should not block permanent scene deletion.");
@ -1834,6 +1981,9 @@ static void WordCompanionRuntimeFollowsMultipleImportedSceneAnchors()
Assert(script.Contains("return chapter.scenes\r\n .filter((item) => item.startParagraphIndex <= paragraphIndex)", StringComparison.Ordinal)
|| script.Contains("return chapter.scenes\n .filter((item) => item.startParagraphIndex <= paragraphIndex)", StringComparison.Ordinal), "Cursor resolution should select the latest preceding scene boundary.");
Assert(script.Contains("findExactAnchorControl(paragraph, tag)", StringComparison.Ordinal), "Marker insertion should reuse only the exact canonical SceneID tag.");
Assert(script.Contains("const rangeLocations = [\"Content\", \"Start\", \"Whole\"]", StringComparison.Ordinal), "Marker insertion should try heading content before progressively smaller Word ranges.");
Assert(script.Contains("paragraph.getRange(rangeLocation).insertContentControl()", StringComparison.Ordinal), "Marker insertion should retry Word-supported paragraph range locations.");
Assert(script.Contains("await ensureAnchorControl(context, paragraphs.items[index], title, tag, prefix)", StringComparison.Ordinal), "Bulk marker sync should await each marker insertion so Word range failures are isolated.");
Assert(!script.Contains("const existing = findAnchorControl(paragraph, prefix);", StringComparison.Ordinal), "Marker insertion must not collapse distinct PD-SCENE tags by prefix.");
}
@ -4408,6 +4558,7 @@ static void StoryIntelligenceScenePromptNoLongerRequestsDuplicateSummary()
Relationships = [],
KnowledgeChanges = [],
TimelineClues = [],
TemporalAnalysis = new SceneIntelligenceTemporalAnalysis { AbsoluteDate = null, ExactTime = null, PartOfDay = null, RelativeTransition = null, RelativeAmount = null, RelativeUnit = null, TargetDayOfWeek = null, CrossesMidnight = null, Evidence = null, Confidence = 0.0m, UnresolvedReason = "No temporal signal in scene text." },
NarrativeArcs = [],
QuestionsRaised = [],
QuestionsAnswered = [],
@ -4536,7 +4687,7 @@ static void StoryIntelligenceEnrichesExistingStructuralScenes()
var commit = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs"));
Assert(runner.Contains("ListChapterScenesAsync", StringComparison.Ordinal), "Story Intelligence scene results should be linked to existing SceneIDs.");
Assert(repository.Contains("SceneID = item.SceneID ?? (existingScenes.Count > index", StringComparison.Ordinal), "Story Intelligence commit should update existing structural scenes when present.");
Assert(repository.Contains("SceneID = item.SceneID ?? existingScene?.SceneID", StringComparison.Ordinal), "Story Intelligence commit should update existing structural scenes when present.");
Assert(repository.Contains("Enriched existing structural scenes", StringComparison.Ordinal), "Commit audit should distinguish enrichment from scene creation.");
Assert(commit.Contains("readiness.ActiveSceneCount != importScenes.Count", StringComparison.Ordinal), "Review readiness should allow matching structural scenes while blocking mismatches.");
}
@ -6969,6 +7120,7 @@ static void StoryIntelligenceSceneSchemaConstrainsConfidenceNumbers()
AssertConfidence(ArrayItem(properties.GetProperty("characters")).GetProperty("properties").GetProperty("confidence"), "characters.confidence");
AssertConfidence(ArrayItem(properties.GetProperty("relationships")).GetProperty("properties").GetProperty("confidence"), "relationships.confidence");
AssertConfidence(ArrayItem(properties.GetProperty("knowledgeChanges")).GetProperty("properties").GetProperty("confidence"), "knowledgeChanges.confidence");
AssertConfidence(properties.GetProperty("temporalAnalysis").GetProperty("properties").GetProperty("confidence"), "temporalAnalysis.confidence");
AssertConfidence(ArrayItem(properties.GetProperty("observations")).GetProperty("properties").GetProperty("confidence"), "observations.confidence");
var schemaText = JsonSerializer.Serialize(StoryIntelligenceSceneStructuredOutputSchema.Contract.Schema);
@ -6982,6 +7134,7 @@ static void StoryIntelligenceSceneSchemaConstrainsEvidenceStrings()
AssertString(ArrayItem(properties.GetProperty("relationships")).GetProperty("properties").GetProperty("evidence"), "relationships.evidence");
AssertString(ArrayItem(properties.GetProperty("knowledgeChanges")).GetProperty("properties").GetProperty("evidence"), "knowledgeChanges.evidence");
AssertString(ArrayItem(properties.GetProperty("timelineClues")).GetProperty("properties").GetProperty("evidence"), "timelineClues.evidence");
AssertString(properties.GetProperty("temporalAnalysis").GetProperty("properties").GetProperty("evidence"), "temporalAnalysis.evidence", nullable: true);
AssertString(ArrayItem(properties.GetProperty("narrativeArcs")).GetProperty("properties").GetProperty("evidence"), "narrativeArcs.evidence");
AssertString(ArrayItem(properties.GetProperty("questionsRaised")).GetProperty("properties").GetProperty("evidence"), "questionsRaised.evidence");
AssertString(ArrayItem(properties.GetProperty("questionsAnswered")).GetProperty("properties").GetProperty("evidence"), "questionsAnswered.evidence");
@ -6991,6 +7144,108 @@ static void StoryIntelligenceSceneSchemaConstrainsEvidenceStrings()
Assert(!schemaText.Contains("Her mum was late", StringComparison.OrdinalIgnoreCase), "Production schema must not patch the observed malformed evidence sentence.");
}
static void StoryIntelligenceTemporalResolvesChapterAnchorAndAfternoon()
{
var context = StoryIntelligenceTemporalResolver.CreateChapterContext("4 Oct 1983");
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(partOfDay: "afternoon", transition: "later", evidence: "later that afternoon", confidence: 0.8m), context);
Assert(result.StartDateTime == new DateTime(1983, 10, 4), "Later that afternoon should inherit the chapter anchor date.");
Assert(result.TimeModeName == "Exact Date", "Broad afternoon should not become an exact datetime.");
Assert(result.RelativeTimeText == "04 Oct 1983, afternoon", "Part of day should be retained without fake clock time.");
}
static void StoryIntelligenceTemporalRollsOverAfterMidnight()
{
var context = new StoryIntelligenceTemporalContext(new DateTime(1983, 10, 4), new DateTime(1983, 10, 4), "evening");
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(partOfDay: "night", crossesMidnight: true, evidence: "after midnight", confidence: 0.9m), context);
Assert(result.StartDateTime == new DateTime(1983, 10, 5), "After midnight should advance to the next calendar day.");
}
static void StoryIntelligenceTemporalResolvesFollowingMorning()
{
var context = new StoryIntelligenceTemporalContext(new DateTime(1983, 10, 4), new DateTime(1983, 10, 5), "evening");
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(partOfDay: "morning", transition: "following", evidence: "the following morning", confidence: 0.8m), context);
Assert(result.StartDateTime == new DateTime(1983, 10, 6), "Following morning should advance from the previous scene date.");
Assert(result.RelativeTimeText == "06 Oct 1983, morning", "Morning should remain a broad part-of-day label.");
}
static void StoryIntelligenceTemporalResolvesExactDayOffsets()
{
var context = new StoryIntelligenceTemporalContext(new DateTime(1983, 10, 4), new DateTime(1983, 10, 4), null);
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(amount: 3, unit: "day", evidence: "three days later", confidence: 0.9m), context);
Assert(result.StartDateTime == new DateTime(1983, 10, 7), "Three days later should resolve deterministically from the previous date.");
}
static void StoryIntelligenceTemporalResolvesFollowingWeekday()
{
var context = new StoryIntelligenceTemporalContext(new DateTime(1983, 10, 4), new DateTime(1983, 10, 4), null);
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(transition: "following", targetDay: "Friday", evidence: "the following Friday", confidence: 0.9m), context);
Assert(result.StartDateTime == new DateTime(1983, 10, 7), "Following Friday from Tuesday 4 Oct 1983 should resolve to Friday 7 Oct 1983.");
}
static void StoryIntelligenceTemporalResolvesEveningClockTime()
{
var context = new StoryIntelligenceTemporalContext(new DateTime(1983, 10, 4), new DateTime(1983, 10, 4), "afternoon");
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(exactTime: "eight o'clock in the evening", partOfDay: "evening", evidence: "eight o'clock in the evening", confidence: 0.95m), context);
Assert(result.StartDateTime == new DateTime(1983, 10, 4, 20, 0, 0), "Eight o'clock in the evening should resolve to 20:00 when date context exists.");
Assert(result.TimeModeName == "Exact DateTime", "Anchored evening clock time should be exact datetime.");
}
static void StoryIntelligenceTemporalRejectsUnanchoredClockTime()
{
var context = new StoryIntelligenceTemporalContext(null, null, null);
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(exactTime: "eight o'clock", evidence: "eight o'clock", confidence: 0.8m), context);
Assert(result.StartDateTime is null, "Unanchored eight o'clock should not invent a date or meridiem.");
Assert(result.TimeModeName == "Relative", "Unanchored clock mention should remain relative.");
}
static void StoryIntelligenceTemporalPreservesVagueFewDays()
{
var context = new StoryIntelligenceTemporalContext(new DateTime(1983, 10, 4), new DateTime(1983, 10, 4), null);
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(evidence: "a few days later", unresolvedReason: "Vague elapsed time; exact number of days is not stated.", confidence: 0.7m), context);
Assert(result.StartDateTime is null, "A few days later should not invent an exact date.");
Assert(result.TimeModeName == "Approximate", "Vague elapsed time should be approximate.");
Assert(result.RelativeTimeText == "a few days later", "Vague wording should be preserved.");
}
static void StoryIntelligenceTemporalImporterPreservesManualChronology()
{
var repository = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Data/StoryIntelligenceResultRepository.cs"));
Assert(repository.Contains("HasExistingChronology(existingScene)", StringComparison.Ordinal), "Import save path should detect existing chronology before saving scenes.");
Assert(repository.Contains("preserveExistingChronology ? existingScene!.StartDateTime : item.StartDateTime", StringComparison.Ordinal), "Import save path should preserve existing scene start date/time.");
}
static void StoryIntelligenceTemporalMaterialisationSavesCanonicalChronology()
{
var materialisation = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceMaterialisationService.cs"));
var resolver = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceTemporalResolver.cs"));
Assert(materialisation.Contains("StoryIntelligenceTemporalResolver.Resolve(parsed, temporalContext)", StringComparison.Ordinal), "Normal materialisation should resolve chronology from persisted scene results.");
Assert(materialisation.Contains("TryApplyTemporalResolution(canonicalScene, temporal, timeModeIds, timeConfidenceIds)", StringComparison.Ordinal), "Normal materialisation should apply chronology to canonical scenes.");
Assert(materialisation.Contains("await scenes.SaveAsync(canonicalScene)", StringComparison.Ordinal), "Normal materialisation should save chronology through the canonical Scene_Save path.");
Assert(materialisation.Contains("!HasExistingChronology(canonicalScene)", StringComparison.Ordinal), "Normal materialisation should preserve existing author-entered chronology.");
Assert(materialisation.Contains("direct?.TemporalAnalysis is not null || direct?.Setting is not null", StringComparison.Ordinal), "Normal materialisation should parse chronology-only scene results.");
Assert(resolver.Contains("StripOrdinalSuffixes", StringComparison.Ordinal), "Temporal resolver should parse ordinal dates such as 24th December.");
Assert(resolver.Contains("TryParseWordClock", StringComparison.Ordinal), "Temporal resolver should parse prose clock times such as ten-thirty-six.");
}
static void StoryIntelligenceTemporalMalformedValuesAreDiscarded()
{
var context = new StoryIntelligenceTemporalContext(null, null, null);
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(absoluteDate: "not a date", exactTime: "eight", evidence: "not a date at eight", confidence: 1.5m), context);
Assert(result.StartDateTime is null, "Malformed temporal values should not produce chronology.");
Assert(result.TimeConfidenceName == "Estimated", "Malformed confidence should not be treated as confirmed.");
}
static void StoryIntelligenceStructuredMetricValuesValidateAndImport()
{
var validator = new StorySceneValidator();
@ -7024,6 +7279,7 @@ static void StoryIntelligenceStructuredSchemaCoversCompleteResponseModel()
"relationships",
"knowledgeChanges",
"timelineClues",
"temporalAnalysis",
"narrativeArcs",
"questionsRaised",
"questionsAnswered",
@ -7080,6 +7336,7 @@ static SceneIntelligenceScene RepresentativeStructuredScene()
Relationships = [new SceneIntelligenceRelationship { CharacterA = "Iris Vale", CharacterB = "Jon Calder", RelationshipSignal = "trust", Evidence = "Iris trusts Jon with the key.", Confidence = 0.76m }],
KnowledgeChanges = [new SceneIntelligenceKnowledgeChange { RecipientCharacter = "Iris Vale", KnowledgeItem = "The archive key opens the sealed west cabinet.", ChangeType = "Discovers", SourceCharacter = null, SourceType = "document", Evidence = "The tag reads west cabinet.", Confidence = 0.83m }],
TimelineClues = [new SceneIntelligenceTimelineClue { Clue = "after midnight", RelativeOrder = "after the public lecture", AbsoluteDate = null, Evidence = "The clock had already struck twelve.", Confidence = 0.8m }],
TemporalAnalysis = new SceneIntelligenceTemporalAnalysis { AbsoluteDate = null, ExactTime = null, PartOfDay = "night", RelativeTransition = "after", RelativeAmount = null, RelativeUnit = null, TargetDayOfWeek = null, CrossesMidnight = true, Evidence = "The clock had already struck twelve.", Confidence = 0.8m, UnresolvedReason = null },
NarrativeArcs = [new SceneIntelligenceNarrativeArc { Title = "The sealed cabinet", Description = "Iris moves closer to opening the sealed cabinet.", ArcType = "mystery", Scale = "secondary", ChangeType = "Developed", Significance = "moderate", ContinuityKey = "sealed cabinet", ParentArcTitle = null, MatchingExistingPlotLineId = null, MatchingExistingThreadId = null, Evidence = "The key is labelled for the west cabinet.", Confidence = 0.82m }],
QuestionsRaised = [new SceneIntelligenceQuestionRaised { Question = "What is hidden in the sealed cabinet?", Scope = "asset", Evidence = "The cabinet has been sealed for years.", Confidence = 0.78m }],
QuestionsAnswered = [new SceneIntelligenceQuestionAnswered { Question = "Where is the archive key?", Answer = "Iris finds it behind the ledger shelf.", Evidence = "Iris finds the key behind the ledger shelf.", Confidence = 0.9m }],
@ -7088,6 +7345,40 @@ static SceneIntelligenceScene RepresentativeStructuredScene()
SourceLimits = new SceneIntelligenceSourceLimits { ContainsAmbiguity = false, AmbiguityNotes = [] }
};
static SceneIntelligenceScene SceneWithTemporal(
string? absoluteDate = null,
string? exactTime = null,
string? partOfDay = null,
string? transition = null,
int? amount = null,
string? unit = null,
string? targetDay = null,
bool? crossesMidnight = null,
string? evidence = null,
string? unresolvedReason = null,
decimal confidence = 0.8m)
=> new()
{
Setting = new SceneIntelligenceSetting { TimeOfDay = partOfDay, DateOrTimeReference = evidence, LocationName = null, LocationType = "unclear", GenericRoomType = null, ParentLocationHint = null, Confidence = confidence },
TimelineClues = string.IsNullOrWhiteSpace(evidence)
? []
: [new SceneIntelligenceTimelineClue { Clue = evidence, RelativeOrder = transition, AbsoluteDate = absoluteDate, Evidence = evidence, Confidence = confidence }],
TemporalAnalysis = new SceneIntelligenceTemporalAnalysis
{
AbsoluteDate = absoluteDate,
ExactTime = exactTime,
PartOfDay = partOfDay,
RelativeTransition = transition,
RelativeAmount = amount,
RelativeUnit = unit,
TargetDayOfWeek = targetDay,
CrossesMidnight = crossesMidnight,
Evidence = evidence,
Confidence = confidence,
UnresolvedReason = unresolvedReason
}
};
static JsonElement SceneSchemaRoot()
=> JsonSerializer.SerializeToElement(StoryIntelligenceSceneStructuredOutputSchema.Contract.Schema);

View File

@ -151,6 +151,47 @@ public sealed class CharactersController(ICharacterService characters) : Control
return RedirectToAction(nameof(Details), new { id });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Reanalyse(int id)
{
try
{
var run = await characters.QueueCharacterReanalysisAsync(id);
if (run is null)
{
return NotFound();
}
TempData["CharacterMessage"] = run.Status == "Running"
? "Character analysis is already running."
: "Character reanalysis queued.";
if (Request.Headers.XRequestedWith == "XMLHttpRequest")
{
return Json(new
{
success = true,
characterEnrichmentRunID = run.CharacterEnrichmentRunID,
bookID = run.BookID,
characterID = run.CharacterID,
status = run.Status,
currentMessage = run.CurrentMessage
});
}
}
catch (InvalidOperationException ex)
{
TempData["CharacterMessage"] = ex.Message;
if (Request.Headers.XRequestedWith == "XMLHttpRequest")
{
return BadRequest(new { success = false, message = ex.Message });
}
}
return RedirectToAction(nameof(Details), new { id });
}
private void ValidateSexWorkflow(CharacterEditViewModel model)
{
if (model.SexValueID == CharacterEditViewModel.CustomSexValue)

View File

@ -13,6 +13,7 @@ public sealed class OnboardingController(
IOnboardingService onboarding,
IOnboardingStoryIntelligenceService storyIntelligence,
IWholeBookPlotIntelligenceService wholeBookPlotIntelligence,
ICharacterEnrichmentService characterEnrichment,
ICurrentUserService currentUser,
ILogger<OnboardingController> logger) : Controller
{
@ -748,6 +749,25 @@ public sealed class OnboardingController(
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
[HttpPost("story-intelligence/character-enrichment/retry")]
public async Task<IActionResult> RetryStoryIntelligenceCharacterEnrichment(Guid batchId)
{
var model = await storyIntelligence.GetReviewCentreAsync(batchId);
if (model is null)
{
return NotFound();
}
if (!currentUser.UserId.HasValue)
{
return Forbid();
}
await characterEnrichment.RetryAsync(model.BookID, currentUser.UserId.Value);
TempData["OnboardingStoryIntelligenceMessage"] = "Character enrichment has been queued again.";
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
private IActionResult RedirectToBook(StoryIntelligenceProgressViewModel model)
=> RedirectToAction("Details", "Books", new { id = model.BookID });

View File

@ -0,0 +1,213 @@
using System.Data;
using System.Text.Json;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface ICharacterEnrichmentRepository
{
Task<CharacterEnrichmentRun> QueueAsync(CharacterEnrichmentQueueRequest request);
Task<CharacterEnrichmentRun> QueueCharacterAsync(CharacterEnrichmentCharacterQueueRequest request);
Task<CharacterEnrichmentRun?> RetryAsync(int bookId, int userId);
Task<CharacterEnrichmentRun?> ClaimNextAsync(int leaseMinutes);
Task<CharacterEnrichmentRun?> GetCurrentAsync(int bookId);
Task<CharacterEnrichmentRun?> GetCurrentForCharacterAsync(int characterId, int userId);
Task<CharacterEnrichmentCharacterStatus?> GetCharacterStatusAsync(int characterId, int userId);
Task UpdateProgressAsync(CharacterEnrichmentProgressUpdate update);
Task CompleteAsync(CharacterEnrichmentCompletionRequest request, IReadOnlyList<CharacterEnrichmentCharacterResult> results);
Task FailAsync(int runId, string errorMessage, string? errorDetail, long? durationMs);
Task<CharacterEnrichmentContext> BuildContextAsync(int projectId, int bookId);
Task<CharacterEnrichmentContext> BuildContextAsync(int projectId, int bookId, int? characterId);
}
public sealed class CharacterEnrichmentRepository(ISqlConnectionFactory connectionFactory) : ICharacterEnrichmentRepository
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<CharacterEnrichmentRun> QueueAsync(CharacterEnrichmentQueueRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<CharacterEnrichmentRun>(
"dbo.CharacterEnrichmentRun_Queue",
request,
commandType: CommandType.StoredProcedure);
}
public async Task<CharacterEnrichmentRun> QueueCharacterAsync(CharacterEnrichmentCharacterQueueRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<CharacterEnrichmentRun>(
"dbo.CharacterEnrichmentRun_QueueCharacter",
request,
commandType: CommandType.StoredProcedure);
}
public async Task<CharacterEnrichmentRun?> RetryAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CharacterEnrichmentRun>(
"dbo.CharacterEnrichmentRun_Retry",
new { BookID = bookId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<CharacterEnrichmentRun?> ClaimNextAsync(int leaseMinutes)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CharacterEnrichmentRun>(
"dbo.CharacterEnrichmentRun_ClaimNext",
new { LeaseMinutes = leaseMinutes },
commandType: CommandType.StoredProcedure);
}
public async Task<CharacterEnrichmentRun?> GetCurrentAsync(int bookId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CharacterEnrichmentRun>(
"dbo.CharacterEnrichmentRun_GetCurrentByBook",
new { BookID = bookId },
commandType: CommandType.StoredProcedure);
}
public async Task<CharacterEnrichmentRun?> GetCurrentForCharacterAsync(int characterId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CharacterEnrichmentRun>(
"dbo.CharacterEnrichmentRun_GetCurrentByCharacter",
new { CharacterID = characterId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<CharacterEnrichmentCharacterStatus?> GetCharacterStatusAsync(int characterId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CharacterEnrichmentCharacterStatus>(
"dbo.CharacterEnrichmentCharacterStatus_Get",
new { CharacterID = characterId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task UpdateProgressAsync(CharacterEnrichmentProgressUpdate update)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.CharacterEnrichmentRun_UpdateProgress",
update,
commandType: CommandType.StoredProcedure);
}
public async Task CompleteAsync(CharacterEnrichmentCompletionRequest request, IReadOnlyList<CharacterEnrichmentCharacterResult> results)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.CharacterEnrichmentRun_Complete",
new
{
request.CharacterEnrichmentRunID,
request.Model,
request.InputTokens,
request.OutputTokens,
request.TotalTokens,
request.DurationMs,
ResultsJson = JsonSerializer.Serialize(results, JsonOptions)
},
commandType: CommandType.StoredProcedure);
}
public async Task FailAsync(int runId, string errorMessage, string? errorDetail, long? durationMs)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.CharacterEnrichmentRun_Fail",
new
{
CharacterEnrichmentRunID = runId,
ErrorMessage = errorMessage,
ErrorDetail = errorDetail,
DurationMs = durationMs
},
commandType: CommandType.StoredProcedure);
}
public async Task<CharacterEnrichmentContext> BuildContextAsync(int projectId, int bookId)
=> await BuildContextAsync(projectId, bookId, null);
public async Task<CharacterEnrichmentContext> BuildContextAsync(int projectId, int bookId, int? characterId)
{
using var connection = connectionFactory.CreateConnection();
using var result = await connection.QueryMultipleAsync(
"dbo.CharacterEnrichment_ContextGet",
new { ProjectID = projectId, BookID = bookId, CharacterID = characterId },
commandType: CommandType.StoredProcedure);
var book = await result.ReadSingleAsync<ContextBookRow>();
var characters = (await result.ReadAsync<CharacterEnrichmentCharacterContext>()).ToList();
var aliases = (await result.ReadAsync<ContextAliasRow>()).ToList();
var scenes = (await result.ReadAsync<ContextSceneRow>()).ToList();
var aliasesByCharacter = aliases
.GroupBy(alias => alias.CharacterID)
.ToDictionary(group => group.Key, group => (IReadOnlyList<string>)group.Select(alias => alias.Alias).ToList());
var scenesByCharacter = scenes
.GroupBy(scene => scene.CharacterID)
.ToDictionary(
group => group.Key,
group => (IReadOnlyList<CharacterEnrichmentSceneContext>)group.Select(scene => new CharacterEnrichmentSceneContext
{
SceneID = scene.SceneID,
ChapterID = scene.ChapterID,
ChapterNumber = scene.ChapterNumber,
ChapterTitle = scene.ChapterTitle,
SceneNumber = scene.SceneNumber,
SceneTitle = scene.SceneTitle,
SourceText = scene.SourceText
}).ToList());
return new CharacterEnrichmentContext
{
ProjectID = projectId,
BookID = bookId,
BookTitle = book.BookTitle,
StoryEra = book.StoryEra,
SeriesStartDate = book.SeriesStartDate,
Characters = characters.Select(character => new CharacterEnrichmentCharacterContext
{
CharacterID = character.CharacterID,
CharacterName = character.CharacterName,
BirthDate = character.BirthDate,
AgeAtSeriesStart = character.AgeAtSeriesStart,
Height = character.Height,
EyeColour = character.EyeColour,
DefaultDescription = character.DefaultDescription,
Aliases = aliasesByCharacter.GetValueOrDefault(character.CharacterID) ?? [],
Scenes = scenesByCharacter.GetValueOrDefault(character.CharacterID) ?? []
}).ToList()
};
}
private sealed class ContextBookRow
{
public string BookTitle { get; init; } = string.Empty;
public string? StoryEra { get; init; }
public DateTime? SeriesStartDate { get; init; }
}
private sealed class ContextAliasRow
{
public int CharacterID { get; init; }
public string Alias { get; init; } = string.Empty;
}
private sealed class ContextSceneRow
{
public int CharacterID { get; init; }
public int SceneID { get; init; }
public int ChapterID { get; init; }
public decimal ChapterNumber { get; init; }
public string ChapterTitle { get; init; } = string.Empty;
public decimal SceneNumber { get; init; }
public string SceneTitle { get; init; } = string.Empty;
public string SourceText { get; init; } = string.Empty;
}
}

View File

@ -653,13 +653,19 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
for (var index = 0; index < orderedScenes.Count; index++)
{
var item = orderedScenes[index];
var existingScene = item.SceneID.HasValue
? existingScenes.FirstOrDefault(scene => scene.SceneID == item.SceneID.Value)
: existingScenes.Count > index ? existingScenes[index] : null;
var preserveExistingChronology = HasExistingChronology(existingScene);
var operation = "preparing scene";
try
{
operation = "saving scene";
var relativeTimeText = TrimOptionalText(
item.ParsedScene.Setting?.DateOrTimeReference ?? item.ParsedScene.Setting?.TimeOfDay,
preserveExistingChronology
? existingScene?.RelativeTimeText
: item.RelativeTimeText ?? item.ParsedScene.Setting?.DateOrTimeReference ?? item.ParsedScene.Setting?.TimeOfDay,
200,
$"Scene {item.TemporarySceneNumber:N0} relative time",
commitWarnings);
@ -671,18 +677,18 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
"dbo.Scene_Save",
new
{
SceneID = item.SceneID ?? (existingScenes.Count > index ? existingScenes[index].SceneID : (int?)null),
SceneID = item.SceneID ?? existingScene?.SceneID,
ChapterID = request.ChapterID,
SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber),
SceneTitle = sceneTitle,
Summary = item.Summary,
TimeModeID = request.TimeModeID,
StartDateTime = (DateTime?)null,
EndDateTime = (DateTime?)null,
DurationAmount = (decimal?)null,
DurationUnitID = (int?)null,
TimeModeID = preserveExistingChronology ? existingScene!.TimeModeID : item.TimeModeID,
StartDateTime = preserveExistingChronology ? existingScene!.StartDateTime : item.StartDateTime,
EndDateTime = preserveExistingChronology ? existingScene!.EndDateTime : item.EndDateTime,
DurationAmount = preserveExistingChronology ? existingScene!.DurationAmount : item.DurationAmount,
DurationUnitID = preserveExistingChronology ? existingScene!.DurationUnitID : item.DurationUnitID,
RelativeTimeText = relativeTimeText,
TimeConfidenceID = request.TimeConfidenceID,
TimeConfidenceID = preserveExistingChronology ? existingScene!.TimeConfidenceID : item.TimeConfidenceID,
ScenePurposeNotes = item.PurposeNotes,
SceneOutcomeNotes = item.OutcomeNotes,
RevisionStatusID = request.RevisionStatusID,
@ -875,6 +881,14 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
}
}
private static bool HasExistingChronology(Scene? scene)
=> scene is not null
&& (scene.StartDateTime.HasValue
|| scene.EndDateTime.HasValue
|| scene.DurationAmount.HasValue
|| scene.DurationUnitID.HasValue
|| !string.IsNullOrWhiteSpace(scene.RelativeTimeText));
private static string? TrimOptionalText(string? value, int maxLength, string label, ICollection<string> warnings)
{
if (string.IsNullOrWhiteSpace(value))

View File

@ -378,12 +378,30 @@ Report chronology clues such as:
Preserve vague wording when the manuscript is vague.
Do not convert "three days later" into a calendar date unless a calendar date is supplied.
For each timeline clue, include exactly these properties: `clue`, `relativeOrder`, `absoluteDate`, `evidence`, `confidence`.
Use `relativeOrder: null` when no relative ordering is supported. Use `absoluteDate: null` when no exact date is supplied.
## Temporal Analysis Rules
Also return one `temporalAnalysis` object for the scene's primary story time.
Use the current scene text first. Use `sceneContext.rollingTemporalContext` only to interpret relative phrases such as "later that afternoon", "after midnight", "following morning", "three days later" or "the following Friday".
Code will do deterministic calendar arithmetic. Your job is to extract what the prose says:
- put explicit dates in `absoluteDate`, preserving the manuscript wording or ISO-like form when present;
- put explicit clock times in `exactTime`;
- put broad time words such as "morning", "afternoon", "evening", "night", "dawn", "dusk" or "midnight" in `partOfDay`;
- put transition words such as `same`, `later`, `next`, `following`, `after`, `before` or `elapsed` in `relativeTransition`;
- put exact numeric relative amounts in `relativeAmount` and the unit in `relativeUnit`;
- put a named weekday in `targetDayOfWeek`;
- set `crossesMidnight` when the scene explicitly moves to the next calendar day after midnight;
- keep `evidence` concise and scene-backed;
- use `unresolvedReason` when the phrase is intentionally vague or lacks enough context.
Do not invent a year, date, clock time, duration or weekday. Do not store broad phrases like "afternoon" as fake exact times. For "a few days later", keep the relative phrase and set `unresolvedReason` rather than choosing a number. If no temporal signal is present, set nullable fields to `null`, confidence to `0.0`, and `unresolvedReason` to `No temporal signal in scene text.`
## Questions Raised Rules
Report questions the scene creates or sharpens.

View File

@ -14,6 +14,7 @@ public sealed class StoryIntelligenceHub(
IStoryIntelligenceService storyIntelligence,
IStoryIntelligenceResultRepository persistedRuns,
IOnboardingStoryIntelligenceService onboardingStoryIntelligence,
ICharacterEnrichmentService characterEnrichment,
IStoryIntelligenceGlobalProgressService globalProgress) : Hub
{
public override async Task OnConnectedAsync()
@ -66,8 +67,41 @@ public sealed class StoryIntelligenceHub(
return await globalProgress.GetForCurrentUserAsync();
}
public async Task<CharacterEnrichmentProgressEvent?> WatchCharacterEnrichment(int bookId)
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
var run = await characterEnrichment.GetCurrentAsync(bookId);
return run is null || run.UserID != userId ? null : ToCharacterEnrichmentProgress(run);
}
public async Task<CharacterEnrichmentProgressEvent?> WatchCharacterEnrichmentForCharacter(int characterId)
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
var run = await characterEnrichment.GetCurrentForCharacterAsync(characterId, userId);
return run is null || run.UserID != userId ? null : ToCharacterEnrichmentProgress(run);
}
public static string UserGroup(int userId) => $"story-intelligence:{userId}";
private static CharacterEnrichmentProgressEvent ToCharacterEnrichmentProgress(CharacterEnrichmentRun run)
=> new()
{
CharacterEnrichmentRunID = run.CharacterEnrichmentRunID,
UserID = run.UserID,
ProjectID = run.ProjectID,
BookID = run.BookID,
CharacterID = run.CharacterID,
Status = run.Status,
TotalCharacters = run.TotalCharacters,
ProcessedCharacters = run.ProcessedCharacters,
CurrentStage = run.CurrentStage ?? string.Empty,
CurrentMessage = run.CurrentMessage ?? string.Empty,
ErrorMessage = run.ErrorMessage,
UpdatedUtc = run.UpdatedUtc
};
private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType, Scene? currentScene, string? latestSceneSummary)
{
var completedScenes = run.CompletedScenes ?? 0;

View File

@ -0,0 +1,180 @@
using System.Text.Json;
namespace PlotLine.Models;
public static class CharacterEnrichmentStatuses
{
public const string Queued = "Queued";
public const string Running = "Running";
public const string Completed = "Completed";
public const string Failed = "Failed";
public const string Superseded = "Superseded";
}
public sealed class CharacterEnrichmentRun
{
public int CharacterEnrichmentRunID { get; set; }
public int ProjectID { get; set; }
public int BookID { get; set; }
public int UserID { get; set; }
public int? CharacterID { get; set; }
public int? StoryIntelligenceBookPipelineID { get; set; }
public string CharacterResolutionVersion { get; set; } = string.Empty;
public string Status { get; set; } = CharacterEnrichmentStatuses.Queued;
public int TotalCharacters { get; set; }
public int ProcessedCharacters { get; set; }
public string? CurrentStage { get; set; }
public string? CurrentMessage { get; set; }
public DateTime QueuedUtc { get; set; }
public DateTime? StartedUtc { get; set; }
public DateTime? CompletedUtc { get; set; }
public DateTime? FailedUtc { get; set; }
public int AttemptCount { get; set; }
public string? Model { get; set; }
public int? InputTokens { get; set; }
public int? OutputTokens { get; set; }
public int? TotalTokens { get; set; }
public long? DurationMs { get; set; }
public string? ErrorMessage { get; set; }
public string? ErrorDetail { get; set; }
public DateTime UpdatedUtc { get; set; }
public bool WasCoalesced { get; set; }
}
public sealed class CharacterEnrichmentQueueRequest
{
public int ProjectID { get; init; }
public int BookID { get; init; }
public int UserID { get; init; }
public int? StoryIntelligenceBookPipelineID { get; init; }
public string CharacterResolutionVersion { get; init; } = string.Empty;
}
public sealed class CharacterEnrichmentCharacterQueueRequest
{
public int CharacterID { get; init; }
public int UserID { get; init; }
public int? BookID { get; init; }
}
public sealed class CharacterEnrichmentCharacterStatus
{
public int CharacterID { get; init; }
public int? BookID { get; init; }
public int? CharacterEnrichmentRunID { get; init; }
public string Status { get; init; } = "Not analysed";
public string? CurrentStage { get; init; }
public string? CurrentMessage { get; init; }
public DateTime? LastAnalysedUtc { get; init; }
public string? ErrorMessage { get; init; }
public string? Summary { get; init; }
public string? DateOfBirth { get; init; }
public string? AgeAtStartOfSeries { get; init; }
public string? Height { get; init; }
public string? EyeColour { get; init; }
public DateTime UpdatedUtc { get; init; } = DateTime.UtcNow;
public bool IsActive => Status is CharacterEnrichmentStatuses.Queued or CharacterEnrichmentStatuses.Running;
public bool IsCompleted => Status == CharacterEnrichmentStatuses.Completed;
}
public sealed class CharacterEnrichmentProgressUpdate
{
public int CharacterEnrichmentRunID { get; init; }
public int TotalCharacters { get; init; }
public int ProcessedCharacters { get; init; }
public string CurrentStage { get; init; } = string.Empty;
public string CurrentMessage { get; init; } = string.Empty;
}
public sealed class CharacterEnrichmentCompletionRequest
{
public int CharacterEnrichmentRunID { get; init; }
public string Model { get; init; } = string.Empty;
public int? InputTokens { get; init; }
public int? OutputTokens { get; init; }
public int? TotalTokens { get; init; }
public long? DurationMs { get; init; }
}
public sealed class CharacterEnrichmentContext
{
public int ProjectID { get; init; }
public int BookID { get; init; }
public string BookTitle { get; init; } = string.Empty;
public string? StoryEra { get; init; }
public DateTime? SeriesStartDate { get; init; }
public IReadOnlyList<CharacterEnrichmentCharacterContext> Characters { get; init; } = [];
}
public sealed class CharacterEnrichmentCharacterContext
{
public int CharacterID { get; init; }
public string CharacterName { get; init; } = string.Empty;
public DateTime? BirthDate { get; init; }
public int? AgeAtSeriesStart { get; init; }
public string? Height { get; init; }
public string? EyeColour { get; init; }
public string? DefaultDescription { get; init; }
public IReadOnlyList<string> Aliases { get; init; } = [];
public IReadOnlyList<CharacterEnrichmentSceneContext> Scenes { get; init; } = [];
}
public sealed class CharacterEnrichmentSceneContext
{
public int SceneID { get; init; }
public int ChapterID { get; init; }
public decimal ChapterNumber { get; init; }
public string ChapterTitle { get; init; } = string.Empty;
public decimal SceneNumber { get; init; }
public string SceneTitle { get; init; } = string.Empty;
public string SourceText { get; init; } = string.Empty;
}
public sealed class CharacterEnrichmentResult
{
public string SchemaVersion { get; init; } = "1";
public IReadOnlyList<CharacterEnrichmentCharacterResult> Characters { get; init; } = [];
public static CharacterEnrichmentResult FromJson(string json)
=> JsonSerializer.Deserialize<CharacterEnrichmentResult>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
}
public sealed class CharacterEnrichmentCharacterResult
{
public int CharacterID { get; init; }
public string Summary { get; init; } = string.Empty;
public decimal Confidence { get; init; }
public CharacterEnrichmentFactResult DateOfBirth { get; init; } = new();
public CharacterEnrichmentFactResult AgeAtStartOfSeries { get; init; } = new();
public CharacterEnrichmentFactResult Height { get; init; } = new();
public CharacterEnrichmentFactResult EyeColour { get; init; } = new();
public IReadOnlyList<int> EvidenceSceneIDs { get; init; } = [];
}
public sealed class CharacterEnrichmentFactResult
{
public string? Value { get; init; }
public decimal Confidence { get; init; }
public string? Evidence { get; init; }
public int? SceneID { get; init; }
}
public sealed class CharacterEnrichmentProgressEvent
{
public int CharacterEnrichmentRunID { get; init; }
public int UserID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public int? CharacterID { get; init; }
public string Status { get; init; } = string.Empty;
public int TotalCharacters { get; init; }
public int ProcessedCharacters { get; init; }
public string CurrentStage { get; init; } = string.Empty;
public string CurrentMessage { get; init; } = string.Empty;
public string? ErrorMessage { get; init; }
public DateTime UpdatedUtc { get; init; } = DateTime.UtcNow;
public bool IsActive => Status is CharacterEnrichmentStatuses.Queued or CharacterEnrichmentStatuses.Running;
public bool IsCompleted => Status == CharacterEnrichmentStatuses.Completed;
}

View File

@ -18,6 +18,7 @@ public sealed class SceneIntelligenceScene
public List<SceneIntelligenceRelationship>? Relationships { get; init; }
public List<SceneIntelligenceKnowledgeChange>? KnowledgeChanges { get; init; }
public List<SceneIntelligenceTimelineClue>? TimelineClues { get; init; }
public SceneIntelligenceTemporalAnalysis? TemporalAnalysis { get; init; }
public List<SceneIntelligenceNarrativeArc>? NarrativeArcs { get; init; }
public List<SceneIntelligenceQuestionRaised>? QuestionsRaised { get; init; }
public List<SceneIntelligenceQuestionAnswered>? QuestionsAnswered { get; init; }
@ -198,6 +199,24 @@ public sealed class SceneIntelligenceTimelineClue
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
}
public sealed class SceneIntelligenceTemporalAnalysis
{
public string? AbsoluteDate { get; init; }
public string? ExactTime { get; init; }
public string? PartOfDay { get; init; }
public string? RelativeTransition { get; init; }
public int? RelativeAmount { get; init; }
public string? RelativeUnit { get; init; }
public string? TargetDayOfWeek { get; init; }
public bool? CrossesMidnight { get; init; }
public string? Evidence { get; init; }
public decimal? Confidence { get; init; }
public string? UnresolvedReason { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
}
public sealed class SceneIntelligenceQuestionRaised
{
public string? Question { get; init; }

View File

@ -632,6 +632,13 @@ public sealed class StoryIntelligenceSceneImportItem
public string? PurposeNotes { get; init; }
public string? OutcomeNotes { get; init; }
public string? ImportNoteText { get; init; }
public int TimeModeID { get; init; }
public DateTime? StartDateTime { get; init; }
public DateTime? EndDateTime { get; init; }
public decimal? DurationAmount { get; init; }
public int? DurationUnitID { get; init; }
public string? RelativeTimeText { get; init; }
public int TimeConfidenceID { get; init; }
public string AnalysisStatus { get; init; } = "Ready";
public bool HasWarnings { get; init; }
}

View File

@ -153,6 +153,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
builder.Services.AddScoped<IWholeBookPlotIntelligenceRepository, WholeBookPlotIntelligenceRepository>();
builder.Services.AddScoped<ICharacterEnrichmentRepository, CharacterEnrichmentRepository>();
builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>();
builder.Services.AddScoped<ICharacterIntelligenceRepository, CharacterIntelligenceRepository>();
builder.Services.AddScoped<INameIntelligenceRepository, NameIntelligenceRepository>();
@ -236,6 +237,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceNarrativeReviewService, StoryIntelligenceNarrativeReviewService>();
builder.Services.AddScoped<IStoryIntelligenceReviewCandidateService, StoryIntelligenceReviewCandidateService>();
builder.Services.AddScoped<IWholeBookPlotIntelligenceService, WholeBookPlotIntelligenceService>();
builder.Services.AddScoped<ICharacterEnrichmentService, CharacterEnrichmentService>();
builder.Services.AddScoped<IStoryIntelligenceSceneContextService, StoryIntelligenceSceneContextService>();
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
builder.Services.AddScoped<ICharacterIntelligenceService, CharacterIntelligenceService>();

View File

@ -0,0 +1,428 @@
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface ICharacterEnrichmentService
{
Task<CharacterEnrichmentRun?> QueueAfterCharacterResolutionAsync(int projectId, int bookId, int userId);
Task<CharacterEnrichmentRun?> QueueForCharacterAsync(int characterId, int userId, int? bookId = null);
Task<CharacterEnrichmentRun?> RetryAsync(int bookId, int userId);
Task<CharacterEnrichmentRun?> GetCurrentAsync(int bookId);
Task<CharacterEnrichmentRun?> GetCurrentForCharacterAsync(int characterId, int userId);
Task<CharacterEnrichmentCharacterStatus?> GetCharacterStatusAsync(int characterId, int userId);
Task<bool> ProcessNextAsync(CancellationToken cancellationToken);
string BuildPromptForTest(CharacterEnrichmentContext context);
StoryIntelligenceResponseContract BuildResponseContractForTest();
}
public sealed class CharacterEnrichmentService(
ICharacterEnrichmentRepository repository,
IStoryIntelligencePipelineRepository pipelines,
IStoryIntelligenceClient client,
IOptions<StoryIntelligenceOptions> options,
IStoryIntelligenceProgressNotifier notifier,
ILogger<CharacterEnrichmentService> logger) : ICharacterEnrichmentService
{
private const string PromptVersion = "Character-Enrichment-V1";
private const int BatchSize = 8;
private const int MaxPromptCharacters = 350_000;
private const decimal SummaryMinimumConfidence = 0.65m;
private const decimal FactMinimumConfidence = 0.9m;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly StoryIntelligenceOptions settings = options.Value;
public async Task<CharacterEnrichmentRun?> QueueAfterCharacterResolutionAsync(int projectId, int bookId, int userId)
{
var pipeline = await pipelines.GetByBookForUserAsync(bookId, userId);
return await repository.QueueAsync(new CharacterEnrichmentQueueRequest
{
ProjectID = projectId,
BookID = bookId,
UserID = userId,
StoryIntelligenceBookPipelineID = pipeline?.StoryIntelligenceBookPipelineID,
CharacterResolutionVersion = $"CharacterImport:{bookId}"
});
}
public async Task<CharacterEnrichmentRun?> QueueForCharacterAsync(int characterId, int userId, int? bookId = null)
{
var run = await repository.QueueCharacterAsync(new CharacterEnrichmentCharacterQueueRequest
{
CharacterID = characterId,
UserID = userId,
BookID = bookId
});
if (run.WasCoalesced)
{
logger.LogInformation(
"Manual character enrichment request ignored because an active run already exists. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} Status={Status}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
run.CharacterID,
run.Status);
}
else
{
logger.LogInformation(
"Manual character enrichment queued. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} Status={Status}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
run.CharacterID,
run.Status);
}
return run;
}
public Task<CharacterEnrichmentRun?> RetryAsync(int bookId, int userId)
=> repository.RetryAsync(bookId, userId);
public Task<CharacterEnrichmentRun?> GetCurrentAsync(int bookId)
=> repository.GetCurrentAsync(bookId);
public Task<CharacterEnrichmentRun?> GetCurrentForCharacterAsync(int characterId, int userId)
=> repository.GetCurrentForCharacterAsync(characterId, userId);
public Task<CharacterEnrichmentCharacterStatus?> GetCharacterStatusAsync(int characterId, int userId)
=> repository.GetCharacterStatusAsync(characterId, userId);
public async Task<bool> ProcessNextAsync(CancellationToken cancellationToken)
{
var run = await repository.ClaimNextAsync(Math.Max(15, settings.ClaimLeaseMinutes));
if (run is null)
{
return false;
}
var stopwatch = Stopwatch.StartNew();
var results = new List<CharacterEnrichmentCharacterResult>();
var inputTokens = 0;
var outputTokens = 0;
var model = settings.EffectiveWholeBookPlotSynthesisModel;
try
{
logger.LogInformation(
"Character enrichment starting. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
run.CharacterID);
await PublishProgressAsync(run, 0, 0, "Preparing character analysis", "Preparing character analysis...", cancellationToken);
var context = await repository.BuildContextAsync(run.ProjectID, run.BookID, run.CharacterID);
var characters = context.Characters
.Where(character => character.Scenes.Count > 0)
.OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
.ToList();
logger.LogInformation(
"Character enrichment context prepared. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} Characters={CharacterCount}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
run.CharacterID,
characters.Count);
await PublishProgressAsync(run, characters.Count, 0, "Analysing characters", "Analysing characters...", cancellationToken);
foreach (var batch in characters.Chunk(BatchSize))
{
cancellationToken.ThrowIfCancellationRequested();
var batchContext = new CharacterEnrichmentContext
{
ProjectID = context.ProjectID,
BookID = context.BookID,
BookTitle = context.BookTitle,
StoryEra = context.StoryEra,
SeriesStartDate = context.SeriesStartDate,
Characters = batch
};
var prompt = BuildPrompt(batchContext);
if (prompt.Length > MaxPromptCharacters)
{
throw new InvalidOperationException("Character enrichment prompt would exceed the configured safe context limit. No manuscript text was silently truncated.");
}
logger.LogInformation(
"Character enrichment batch starting. RunID={RunID} CharacterID={CharacterID} BatchCharacters={BatchCharacters} Processed={Processed}/{Total}",
run.CharacterEnrichmentRunID,
run.CharacterID,
batch.Length,
results.Count,
characters.Count);
await PublishProgressAsync(run, characters.Count, results.Count, "Creating summaries and extracting character details", "Creating summaries and extracting character details...", cancellationToken);
var clientResult = await client.ExecutePromptAsync(
prompt,
PromptVersion,
cancellationToken,
settings.EffectiveWholeBookPlotSynthesisModel,
settings.WholeBookPlotSynthesisMaxOutputTokens.GetValueOrDefault(settings.MaxOutputTokens),
responseContract: CharacterEnrichmentStructuredOutputSchema.Contract);
model = clientResult.Model;
inputTokens += clientResult.InputTokens ?? 0;
outputTokens += clientResult.OutputTokens ?? 0;
var parsed = CharacterEnrichmentResult.FromJson(ExtractOutputText(clientResult.RawResponseText));
results.AddRange(FilterResults(parsed.Characters, batch.Select(character => character.CharacterID).ToHashSet()));
await PublishProgressAsync(run, characters.Count, Math.Min(characters.Count, results.Count), "Saving character analysis", "Saving character analysis...", cancellationToken);
}
stopwatch.Stop();
await repository.CompleteAsync(new CharacterEnrichmentCompletionRequest
{
CharacterEnrichmentRunID = run.CharacterEnrichmentRunID,
Model = model,
InputTokens = inputTokens == 0 ? null : inputTokens,
OutputTokens = outputTokens == 0 ? null : outputTokens,
TotalTokens = inputTokens + outputTokens == 0 ? null : inputTokens + outputTokens,
DurationMs = stopwatch.ElapsedMilliseconds
}, results);
await notifier.PublishCharacterEnrichmentAsync(ToProgress(run, CharacterEnrichmentStatuses.Completed, results.Count, results.Count, "Complete", "Character summaries are ready."));
logger.LogInformation(
"Character enrichment completed. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} Characters={CharacterCount} DurationMs={DurationMs}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
run.CharacterID,
results.Count,
stopwatch.ElapsedMilliseconds);
return true;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
stopwatch.Stop();
await repository.FailAsync(run.CharacterEnrichmentRunID, ex.Message, ex.ToString(), stopwatch.ElapsedMilliseconds);
await notifier.PublishCharacterEnrichmentAsync(ToProgress(run, CharacterEnrichmentStatuses.Failed, run.TotalCharacters, run.ProcessedCharacters, "Failed", "Character enrichment failed.", ex.Message));
logger.LogError(ex, "Character enrichment failed. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID}", run.CharacterEnrichmentRunID, run.ProjectID, run.BookID, run.CharacterID);
return true;
}
}
public string BuildPromptForTest(CharacterEnrichmentContext context)
=> BuildPrompt(context);
public StoryIntelligenceResponseContract BuildResponseContractForTest()
=> CharacterEnrichmentStructuredOutputSchema.Contract;
private async Task PublishProgressAsync(CharacterEnrichmentRun run, int total, int processed, string stage, string message, CancellationToken cancellationToken)
{
await repository.UpdateProgressAsync(new CharacterEnrichmentProgressUpdate
{
CharacterEnrichmentRunID = run.CharacterEnrichmentRunID,
TotalCharacters = total,
ProcessedCharacters = processed,
CurrentStage = stage,
CurrentMessage = message
});
await notifier.PublishCharacterEnrichmentAsync(ToProgress(run, CharacterEnrichmentStatuses.Running, total, processed, stage, message));
}
private static IReadOnlyList<CharacterEnrichmentCharacterResult> FilterResults(
IReadOnlyList<CharacterEnrichmentCharacterResult> results,
HashSet<int> allowedCharacterIds)
=> results
.Where(result => allowedCharacterIds.Contains(result.CharacterID))
.Select(result => new CharacterEnrichmentCharacterResult
{
CharacterID = result.CharacterID,
Summary = Clamp(result.Confidence) >= SummaryMinimumConfidence ? Clean(result.Summary, 2500) : string.Empty,
Confidence = Clamp(result.Confidence),
DateOfBirth = StrongFact(result.DateOfBirth),
AgeAtStartOfSeries = StrongFact(result.AgeAtStartOfSeries),
Height = StrongFact(result.Height),
EyeColour = StrongFact(result.EyeColour),
EvidenceSceneIDs = result.EvidenceSceneIDs.Where(id => id > 0).Distinct().Take(20).ToList()
})
.Where(result => !string.IsNullOrWhiteSpace(result.Summary)
|| !string.IsNullOrWhiteSpace(result.DateOfBirth.Value)
|| !string.IsNullOrWhiteSpace(result.AgeAtStartOfSeries.Value)
|| !string.IsNullOrWhiteSpace(result.Height.Value)
|| !string.IsNullOrWhiteSpace(result.EyeColour.Value))
.ToList();
private static CharacterEnrichmentFactResult StrongFact(CharacterEnrichmentFactResult fact)
=> Clamp(fact.Confidence) >= FactMinimumConfidence && !string.IsNullOrWhiteSpace(fact.Value) && fact.SceneID is > 0
? new CharacterEnrichmentFactResult
{
Value = Clean(fact.Value, 120),
Confidence = Clamp(fact.Confidence),
Evidence = Clean(fact.Evidence, 700),
SceneID = fact.SceneID
}
: new CharacterEnrichmentFactResult();
private static string BuildPrompt(CharacterEnrichmentContext context)
{
var builder = new StringBuilder();
builder.AppendLine("You are PlotDirector's Character Enrichment engine.");
builder.AppendLine("Create concise author-facing character summaries and extract only strongly evidenced character facts.");
builder.AppendLine("Use only the supplied manuscript scenes. Do not invent, infer from behaviour alone, speculate about future events, or list every appearance.");
builder.AppendLine("For dateOfBirth, ageAtStartOfSeries, height, and eyeColour, return a value only when direct manuscript wording strongly supports it. Otherwise return null value and confidence 0.");
builder.AppendLine("If a character already has a stored value, preserve it by returning null unless the manuscript contains stronger direct evidence; the save layer will still refuse to overwrite existing author data.");
builder.AppendLine();
builder.AppendLine("[BOOK]");
builder.AppendLine($"BookID: {context.BookID}");
builder.AppendLine($"Title: {context.BookTitle}");
builder.AppendLine($"Story era: {context.StoryEra}");
builder.AppendLine($"Series start date: {context.SeriesStartDate:yyyy-MM-dd}");
builder.AppendLine();
builder.AppendLine("[CHARACTERS]");
foreach (var character in context.Characters)
{
builder.AppendLine();
builder.AppendLine($"CharacterID: {character.CharacterID}");
builder.AppendLine($"Name: {character.CharacterName}");
builder.AppendLine($"Aliases: {string.Join(", ", character.Aliases)}");
builder.AppendLine($"Existing birth date: {character.BirthDate:yyyy-MM-dd}");
builder.AppendLine($"Existing age at series start: {character.AgeAtSeriesStart}");
builder.AppendLine($"Existing height: {character.Height}");
builder.AppendLine($"Existing eye colour: {character.EyeColour}");
builder.AppendLine($"Existing description: {character.DefaultDescription}");
builder.AppendLine("[SCENES]");
foreach (var scene in character.Scenes.Take(60))
{
builder.AppendLine($"SceneID: {scene.SceneID}; Chapter {scene.ChapterNumber:0.##} {scene.ChapterTitle}; Scene {scene.SceneNumber:0.##} {scene.SceneTitle}");
builder.AppendLine("<text>");
builder.AppendLine(scene.SourceText);
builder.AppendLine("</text>");
}
}
return builder.ToString();
}
private static CharacterEnrichmentProgressEvent ToProgress(CharacterEnrichmentRun run, string status, int total, int processed, string stage, string message, string? error = null)
=> new()
{
CharacterEnrichmentRunID = run.CharacterEnrichmentRunID,
UserID = run.UserID,
ProjectID = run.ProjectID,
BookID = run.BookID,
CharacterID = run.CharacterID,
Status = status,
TotalCharacters = total,
ProcessedCharacters = processed,
CurrentStage = stage,
CurrentMessage = message,
ErrorMessage = error,
UpdatedUtc = DateTime.UtcNow
};
private static string ExtractOutputText(string rawResponseText)
{
using var response = JsonDocument.Parse(rawResponseText);
foreach (var output in response.RootElement.GetProperty("output").EnumerateArray())
{
if (!output.TryGetProperty("content", out var content))
{
continue;
}
foreach (var item in content.EnumerateArray())
{
if (item.TryGetProperty("type", out var type)
&& string.Equals(type.GetString(), "output_text", StringComparison.OrdinalIgnoreCase)
&& item.TryGetProperty("text", out var text)
&& !string.IsNullOrWhiteSpace(text.GetString()))
{
return text.GetString()!.Trim();
}
}
}
throw new JsonException("OpenAI response did not contain output_text content.");
}
private static decimal Clamp(decimal value)
=> Math.Clamp(value, 0m, 1m);
private static string Clean(string? value, int maxLength)
{
var clean = value?.Trim();
if (string.IsNullOrWhiteSpace(clean))
{
return string.Empty;
}
return clean.Length <= maxLength ? clean : clean[..maxLength].TrimEnd();
}
}
public static class CharacterEnrichmentStructuredOutputSchema
{
public static readonly StoryIntelligenceResponseContract Contract = new()
{
Name = "character_enrichment",
Strict = true,
Schema = new Dictionary<string, object>
{
["type"] = "object",
["additionalProperties"] = false,
["required"] = new[] { "schemaVersion", "characters" },
["properties"] = new Dictionary<string, object>
{
["schemaVersion"] = StringSchema(),
["characters"] = ArraySchema(ObjectSchema(new Dictionary<string, object>
{
["characterID"] = IntegerSchema(),
["summary"] = StringSchema(),
["confidence"] = NumberSchema(),
["dateOfBirth"] = FactSchema(),
["ageAtStartOfSeries"] = FactSchema(),
["height"] = FactSchema(),
["eyeColour"] = FactSchema(),
["evidenceSceneIDs"] = ArraySchema(IntegerSchema())
}, "characterID", "summary", "confidence", "dateOfBirth", "ageAtStartOfSeries", "height", "eyeColour", "evidenceSceneIDs"))
}
}
};
private static Dictionary<string, object> FactSchema()
=> ObjectSchema(new Dictionary<string, object>
{
["value"] = NullableStringSchema(),
["confidence"] = NumberSchema(),
["evidence"] = NullableStringSchema(),
["sceneID"] = NullableIntegerSchema()
}, "value", "confidence", "evidence", "sceneID");
private static Dictionary<string, object> ObjectSchema(Dictionary<string, object> properties, params string[] required)
=> new()
{
["type"] = "object",
["additionalProperties"] = false,
["required"] = required,
["properties"] = properties
};
private static Dictionary<string, object> ArraySchema(object items)
=> new()
{
["type"] = "array",
["items"] = items
};
private static Dictionary<string, object> StringSchema()
=> new() { ["type"] = "string" };
private static Dictionary<string, object> NullableStringSchema()
=> new() { ["type"] = new[] { "string", "null" } };
private static Dictionary<string, object> IntegerSchema()
=> new() { ["type"] = "integer" };
private static Dictionary<string, object> NullableIntegerSchema()
=> new() { ["type"] = new[] { "integer", "null" } };
private static Dictionary<string, object> NumberSchema()
=> new() { ["type"] = "number", ["minimum"] = 0, ["maximum"] = 1 };
}

View File

@ -269,6 +269,7 @@ public interface ICharacterService
Task<CharacterMergeViewModel?> GetMergeAsync(int sourceCharacterId);
Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId);
Task<CharacterSaveResult> SaveCharacterAsync(CharacterEditViewModel model);
Task<CharacterEnrichmentRun?> QueueCharacterReanalysisAsync(int characterId);
Task SetPreferredNameAsync(int characterId, string preferredName);
Task<CharacterMergeResult?> MergeAsync(CharacterMergeForm form);
Task<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model);
@ -1175,6 +1176,7 @@ public sealed class BookService(
ISubscriptionService subscriptions,
IProjectActivityService activity,
IStoryIntelligencePipelineStateService storyIntelligencePipeline,
IStoryIntelligenceResultRepository storyIntelligenceRuns,
ICurrentUserService currentUser) : IBookService
{
public async Task<BookDetailViewModel?> GetDetailAsync(int bookId)
@ -1194,6 +1196,9 @@ public sealed class BookService(
var pipeline = currentUser.UserId is int pipelineUserId
? await storyIntelligencePipeline.GetForBookAsync(bookId, pipelineUserId)
: null;
var runRows = currentUser.UserId is int runUserId
? await storyIntelligenceRuns.ListRunsByBookForUserAsync(bookId, runUserId)
: [];
var hasCanonicalSceneSources = (await builds.ListSceneManuscriptSourcesByBookAsync(bookId)).Count > 0;
return new BookDetailViewModel
@ -1205,6 +1210,7 @@ public sealed class BookService(
: null,
Chapters = await chapters.ListByBookAsync(bookId),
StoryIntelligencePipeline = pipeline,
StoryIntelligenceRuns = runRows,
StoryIntelligenceAction = BuildStoryIntelligenceAction(pipeline, hasCanonicalSceneSources)
};
}
@ -9247,7 +9253,8 @@ public sealed class CharacterService(
ISubscriptionService subscriptions,
IProjectActivityService activity,
ICurrentUserService currentUser,
IVisualIdentityImageService visualIdentityImages) : ICharacterService
IVisualIdentityImageService visualIdentityImages,
ICharacterEnrichmentService characterEnrichment) : ICharacterService
{
public async Task<CharacterListViewModel?> GetCharactersAsync(int projectId)
{
@ -9316,6 +9323,9 @@ public sealed class CharacterService(
var projectCharacters = await characters.ListCharactersAsync(project.ProjectID);
var relationshipLookups = await characters.GetLookupsAsync();
var projectBooks = await books.ListByProjectAsync(project.ProjectID);
var characterAnalysis = currentUser.UserId is int userId
? await characterEnrichment.GetCharacterStatusAsync(character.CharacterID, userId) ?? new CharacterEnrichmentCharacterStatus { CharacterID = character.CharacterID }
: new CharacterEnrichmentCharacterStatus { CharacterID = character.CharacterID };
var relationshipContainers = await characters.ListRelationshipsByCharacterAsync(character.CharacterID);
var initialRelationships = await characters.ListInitialRelationshipsByCharacterAsync(character.CharacterID);
@ -9367,10 +9377,21 @@ public sealed class CharacterService(
CharacterOptions = projectCharacters.Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString())).ToList(),
RelationshipTypeOptions = ToRelationshipTypeSelectList(relationshipLookups.RelationshipTypes),
RelationshipStateOptions = relationshipLookups.RelationshipStates.Select(x => new SelectListItem(x.StateName, x.RelationshipStateID.ToString())).ToList(),
BookOptions = projectBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookDisplayTitle}", x.BookID.ToString())).ToList()
BookOptions = projectBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookDisplayTitle}", x.BookID.ToString())).ToList(),
CharacterAnalysis = characterAnalysis
};
}
public async Task<CharacterEnrichmentRun?> QueueCharacterReanalysisAsync(int characterId)
{
if (currentUser.UserId is not int userId)
{
return null;
}
return await characterEnrichment.QueueForCharacterAsync(characterId, userId);
}
public async Task<CharacterMergeViewModel?> GetMergeAsync(int sourceCharacterId)
{
var source = await characters.GetCharacterAsync(sourceCharacterId);

View File

@ -52,6 +52,7 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligenceKnowledgeImportService knowledgeImport,
IStoryIntelligenceNarrativeReviewService narrativeReview,
IWholeBookPlotIntelligenceService wholeBookPlotIntelligence,
ICharacterEnrichmentService characterEnrichment,
IStoryIntelligenceSourceRepository sources,
IStoryIntelligencePipelineStateService pipelineState,
IStoryIntelligenceReviewCandidateService reviewCandidates,
@ -655,8 +656,10 @@ public sealed class OnboardingStoryIntelligenceService(
var knowledgeReview = await reviewCandidates.GetKnowledgeReviewAsync(batch, 1, 25);
var narrative = await narrativeReview.GetReviewAsync(batch);
var plotSynthesisRun = await wholeBookPlotIntelligence.GetCurrentAsync(batch.BookID);
var characterEnrichmentRun = await characterEnrichment.GetCurrentAsync(batch.BookID);
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
var characterComplete = pipeline?.CharacterReviewCompletedUtc is not null || characterReview.IsComplete;
var characterReviewComplete = pipeline?.CharacterReviewCompletedUtc is not null || characterReview.IsComplete;
var characterComplete = characterReviewComplete && characterEnrichmentRun?.Status == CharacterEnrichmentStatuses.Completed;
var locationComplete = pipeline?.LocationReviewCompletedUtc is not null || locationReview.IsComplete;
var assetComplete = pipeline?.AssetReviewCompletedUtc is not null || assetReview.IsComplete;
var relationshipComplete = pipeline?.RelationshipReviewCompletedUtc is not null || relationshipReview.IsComplete;
@ -699,6 +702,18 @@ public sealed class OnboardingStoryIntelligenceService(
EventCount = narrativeEventCount,
ErrorMessage = plotSynthesisRun.ErrorMessage
},
CharacterEnrichment = characterEnrichmentRun is null
? new CharacterEnrichmentStatusViewModel()
: new CharacterEnrichmentStatusViewModel
{
CharacterEnrichmentRunID = characterEnrichmentRun.CharacterEnrichmentRunID,
Status = characterEnrichmentRun.Status,
TotalCharacters = characterEnrichmentRun.TotalCharacters,
ProcessedCharacters = characterEnrichmentRun.ProcessedCharacters,
CurrentStage = characterEnrichmentRun.CurrentStage ?? string.Empty,
CurrentMessage = characterEnrichmentRun.CurrentMessage ?? string.Empty,
ErrorMessage = characterEnrichmentRun.ErrorMessage
},
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
{
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
@ -706,7 +721,7 @@ public sealed class OnboardingStoryIntelligenceService(
SceneAnalysisComplete = pipeline is not null && SceneAnalysisComplete(pipeline),
CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count,
CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked),
CharacterStageComplete = characterComplete,
CharacterStageComplete = characterReviewComplete,
LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count,
LocationsCreatedOrLinked = batch.LocationDecisions.Count(decision => decision.CreatedOrLinked),
LocationStageComplete = locationComplete,
@ -836,6 +851,9 @@ public sealed class OnboardingStoryIntelligenceService(
var sceneAnalysisComplete = SceneAnalysisComplete(summary);
var narrativePendingCount = narrativeComplete ? 0 : summary.NarrativePendingCount;
var hasCommittedScenes = summary.CompletedRunCount > 0 || summary.CompletedScenes > 0;
var characterEnrichmentRun = await characterEnrichment.GetCurrentAsync(batch.BookID);
var characterEnrichmentComplete = characterEnrichmentRun?.Status == CharacterEnrichmentStatuses.Completed;
var characterPanelComplete = characterComplete && characterEnrichmentComplete;
var model = new StoryIntelligenceProgressViewModel
{
@ -851,7 +869,7 @@ public sealed class OnboardingStoryIntelligenceService(
{
HasCommittedScenes = hasCommittedScenes,
CanImport = !characterComplete,
IsComplete = characterComplete,
IsComplete = characterPanelComplete,
SummaryNeedsAttentionCount = characterComplete ? 0 : ReadyPendingCount(summary.CharacterCandidateGenerationStatus, summary.CharacterPendingCount)
},
LocationReview = new StoryIntelligenceLocationReviewViewModel
@ -898,6 +916,18 @@ public sealed class OnboardingStoryIntelligenceService(
EventCount = summary.NarrativeEventCount,
ErrorMessage = summary.WholeBookPlotSynthesisErrorMessage
},
CharacterEnrichment = characterEnrichmentRun is null
? new CharacterEnrichmentStatusViewModel()
: new CharacterEnrichmentStatusViewModel
{
CharacterEnrichmentRunID = characterEnrichmentRun.CharacterEnrichmentRunID,
Status = characterEnrichmentRun.Status,
TotalCharacters = characterEnrichmentRun.TotalCharacters,
ProcessedCharacters = characterEnrichmentRun.ProcessedCharacters,
CurrentStage = characterEnrichmentRun.CurrentStage ?? string.Empty,
CurrentMessage = characterEnrichmentRun.CurrentMessage ?? string.Empty,
ErrorMessage = characterEnrichmentRun.ErrorMessage
},
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
{
ChaptersAnalysed = summary.CompletedRunCount,
@ -992,7 +1022,7 @@ public sealed class OnboardingStoryIntelligenceService(
var pendingCharacterCandidates = await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Characters);
if (pendingCharacterCandidates == 0)
{
await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID);
await QueuePostCharacterReviewAnalysisAsync(batch);
await reviewCandidates.PreparePeerCandidatesAsync(batch);
}
return (await GetProgressAsync(batchId), new StoryIntelligenceImportCommitResult
@ -1018,7 +1048,7 @@ public sealed class OnboardingStoryIntelligenceService(
if (pendingCount == 0)
{
await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Characters);
await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID);
await QueuePostCharacterReviewAnalysisAsync(batch);
await reviewCandidates.PreparePeerCandidatesAsync(batch);
}
else
@ -1051,6 +1081,19 @@ public sealed class OnboardingStoryIntelligenceService(
};
}
private async Task QueuePostCharacterReviewAnalysisAsync(OnboardingStoryIntelligenceBatch batch)
{
// Keep both post-character-review jobs independent; previous direct call was await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync.
await Task.WhenAll(
wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID),
characterEnrichment.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID));
logger.LogInformation(
"Queued post-character-review Story Intelligence jobs. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID}",
batch.BatchID,
batch.ProjectID,
batch.BookID);
}
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm form)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);

View File

@ -178,6 +178,7 @@ public sealed class PersistedStoryIntelligenceRunner(
totalTokens: totals.TotalTokens);
var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
var rollingTemporalContext = StoryIntelligenceTemporalResolver.CreateChapterContext(run.SourceLabel);
foreach (var block in sceneBlocks)
{
await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken);
@ -230,7 +231,8 @@ public sealed class PersistedStoryIntelligenceRunner(
var sceneStopwatch = Stopwatch.StartNew();
var promptStopwatch = Stopwatch.StartNew();
currentFailureStage = StoryIntelligenceFailureStages.SceneIntelligence;
var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, block.SceneContextJson, block.SceneText);
var sceneContextJson = StoryIntelligenceTemporalResolver.AddRollingContextToPromptJson(block.SceneContextJson, rollingTemporalContext);
var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, sceneContextJson, block.SceneText);
promptStopwatch.Stop();
var openAiStopwatch = Stopwatch.StartNew();
var sceneClientResult = await client.ExecutePromptAsync(
@ -254,6 +256,7 @@ public sealed class PersistedStoryIntelligenceRunner(
parseStopwatch.Stop();
var validationStopwatch = Stopwatch.StartNew();
var validation = sceneValidator.Validate(sceneAttempt.Parsed);
rollingTemporalContext = StoryIntelligenceTemporalResolver.Resolve(sceneAttempt.Parsed, rollingTemporalContext).NextContext;
LogRecoveredJsonWarnings(run, $"Scene {block.TemporarySceneNumber:N0}", sceneAttempt.Warnings);
hasWarnings = hasWarnings || validation.Warnings.Count > 0;

View File

@ -54,6 +54,11 @@ public sealed class PersistedStoryIntelligenceWorker(
processedRun = await ProcessWholeBookPlotSynthesisAsync(stoppingToken);
}
if (!processedRun)
{
processedRun = await ProcessCharacterEnrichmentAsync(stoppingToken);
}
if (!processedRun)
{
await Task.Delay(pollDelay, stoppingToken);
@ -68,6 +73,13 @@ public sealed class PersistedStoryIntelligenceWorker(
return await synthesis.ProcessNextAsync(stoppingToken);
}
private async Task<bool> ProcessCharacterEnrichmentAsync(CancellationToken stoppingToken)
{
using var scope = scopeFactory.CreateScope();
var enrichment = scope.ServiceProvider.GetRequiredService<ICharacterEnrichmentService>();
return await enrichment.ProcessNextAsync(stoppingToken);
}
private async Task RecoverReadySceneAnalysisAsync(CancellationToken stoppingToken)
{
using var scope = scopeFactory.CreateScope();

View File

@ -140,6 +140,8 @@ public sealed class StoryIntelligenceImportCommitService(
var timeModeId = FindId(lookupData.TimeModes, item => item.TimeModeName, item => item.TimeModeID, "Relative", "Unknown");
var timeConfidenceId = FindId(lookupData.TimeConfidences, item => item.TimeConfidenceName, item => item.TimeConfidenceID, "Implied", "Unknown");
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 revisionStatusId = FindId(lookupData.RevisionStatuses, item => item.StatusName, item => item.RevisionStatusID, "Planned");
var timelineNoteTypeId = noteTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Timeline Note", StringComparison.OrdinalIgnoreCase))?.SceneNoteTypeID;
if (timelineNoteTypeId is null)
@ -158,7 +160,7 @@ public sealed class StoryIntelligenceImportCommitService(
? await storyRuns.ListChapterScenesAsync(run.ChapterID.Value)
: [];
var canonicalById = canonicalScenes.ToDictionary(scene => scene.SceneID);
var importScenes = new List<StoryIntelligenceSceneImportItem>();
var rawImportScenes = new List<StoryIntelligenceSceneImportItem>();
foreach (var sceneResult in sceneResults.OrderBy(scene => scene.TemporarySceneNumber))
{
canonicalById.TryGetValue(sceneResult.SceneID ?? 0, out var canonicalScene);
@ -173,13 +175,23 @@ public sealed class StoryIntelligenceImportCommitService(
}
warnings.Add($"Scene {sceneResult.TemporarySceneNumber:N0} will be created with limited detail because some analysis could not be read.");
importScenes.Add(fallback);
rawImportScenes.Add(fallback);
continue;
}
importScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap, canonicalScene));
rawImportScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap, canonicalScene));
}
var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null;
var importScenes = ApplyTemporalResolution(
rawImportScenes,
StoryIntelligenceTemporalResolver.CreateChapterContext(chapter?.ChapterTitle, chapter?.Summary),
timeModeIds,
timeConfidenceIds,
timeModeId,
timeConfidenceId,
warnings);
if (importScenes.Count == 0)
{
blockers.Add("No analysed scene results are available for this chapter.");
@ -195,7 +207,6 @@ public sealed class StoryIntelligenceImportCommitService(
var projectName = run.ProjectID.HasValue ? (await projects.GetAsync(run.ProjectID.Value))?.ProjectName ?? $"Project {run.ProjectID.Value:N0}" : "None";
var bookTitle = run.BookID.HasValue ? (await books.GetAsync(run.BookID.Value))?.BookDisplayTitle ?? $"Book {run.BookID.Value:N0}" : "None";
var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null;
var chapterLabel = chapter is null
? "None"
: string.IsNullOrWhiteSpace(chapter.ChapterTitle)
@ -328,6 +339,67 @@ public sealed class StoryIntelligenceImportCommitService(
};
}
private static IReadOnlyList<StoryIntelligenceSceneImportItem> ApplyTemporalResolution(
IReadOnlyList<StoryIntelligenceSceneImportItem> scenes,
StoryIntelligenceTemporalContext initialContext,
IReadOnlyDictionary<string, int> timeModeIds,
IReadOnlyDictionary<string, int> timeConfidenceIds,
int defaultTimeModeId,
int defaultTimeConfidenceId,
ICollection<string> warnings)
{
var context = initialContext;
var resolvedScenes = new List<StoryIntelligenceSceneImportItem>(scenes.Count);
foreach (var scene in scenes.OrderBy(item => item.TemporarySceneNumber))
{
var resolved = StoryIntelligenceTemporalResolver.Resolve(scene.ParsedScene, context);
context = resolved.NextContext;
if (!timeModeIds.TryGetValue(resolved.TimeModeName, out var resolvedTimeModeId))
{
resolvedTimeModeId = defaultTimeModeId;
warnings.Add($"Scene {scene.TemporarySceneNumber:N0} chronology used fallback time mode because '{resolved.TimeModeName}' was not available.");
}
if (!timeConfidenceIds.TryGetValue(resolved.TimeConfidenceName, out var resolvedTimeConfidenceId))
{
resolvedTimeConfidenceId = defaultTimeConfidenceId;
warnings.Add($"Scene {scene.TemporarySceneNumber:N0} chronology used fallback time confidence because '{resolved.TimeConfidenceName}' was not available.");
}
resolvedScenes.Add(new StoryIntelligenceSceneImportItem
{
SceneResultID = scene.SceneResultID,
SceneID = scene.SceneID,
TemporarySceneNumber = scene.TemporarySceneNumber,
StartParagraph = scene.StartParagraph,
EndParagraph = scene.EndParagraph,
SourceLabel = scene.SourceLabel,
ParsedScene = scene.ParsedScene,
PovCharacterID = scene.PovCharacterID,
PurposeTypeIDs = scene.PurposeTypeIDs,
Metrics = scene.Metrics,
SceneTitle = scene.SceneTitle,
Summary = scene.Summary,
PurposeNotes = scene.PurposeNotes,
OutcomeNotes = scene.OutcomeNotes,
ImportNoteText = scene.ImportNoteText,
TimeModeID = resolvedTimeModeId,
StartDateTime = resolved.StartDateTime,
EndDateTime = resolved.EndDateTime,
DurationAmount = resolved.DurationAmount,
DurationUnitID = resolved.DurationUnitID,
RelativeTimeText = resolved.RelativeTimeText,
TimeConfidenceID = resolvedTimeConfidenceId,
AnalysisStatus = scene.AnalysisStatus,
HasWarnings = scene.HasWarnings
});
}
return resolvedScenes;
}
private static IReadOnlyList<StoryIntelligenceSceneImportItem> BuildReviewScenes(
IReadOnlyList<Scene> canonicalScenes,
IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults,

View File

@ -15,6 +15,7 @@ public sealed class StoryIntelligenceMaterialisationService(
ISceneMetricTypeRepository metricTypes,
ISceneRepository scenes,
IChapterRepository chapters,
ILookupRepository lookups,
IStoryIntelligenceNarrativeReviewService narrative,
IStoryIntelligenceReviewCandidateService reviewCandidates,
ILogger<StoryIntelligenceMaterialisationService> logger) : IStoryIntelligenceMaterialisationService
@ -85,12 +86,21 @@ public sealed class StoryIntelligenceMaterialisationService(
.OrderBy(metric => metric.SortOrder)
.ThenBy(metric => metric.MetricName)
.ToList();
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 metricMap = BuildMetricMap(configuredMetrics);
var metricValuesSaved = 0;
var chronologySaved = 0;
var sceneResultsRead = 0;
var sceneResultsParsed = 0;
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
var canonicalSceneMap = canonicalScenes
.Where(scene => !scene.IsArchived)
.GroupBy(scene => scene.SceneID)
.ToDictionary(group => group.Key, group => group.First());
var chapterMap = (await chapters.ListByBookAsync(bookId)).ToDictionary(chapter => chapter.ChapterID);
foreach (var run in scopedRuns.Where(run => run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
{
@ -107,7 +117,9 @@ public sealed class StoryIntelligenceMaterialisationService(
});
}
foreach (var sceneResult in await results.ListSceneResultsAsync(run.StoryIntelligenceRunID))
chapterMap.TryGetValue(run.ChapterID ?? 0, out var chapter);
var temporalContext = StoryIntelligenceTemporalResolver.CreateChapterContext(chapter?.ChapterTitle, chapter?.Summary);
foreach (var sceneResult in (await results.ListSceneResultsAsync(run.StoryIntelligenceRunID)).OrderBy(result => result.TemporarySceneNumber))
{
cancellationToken.ThrowIfCancellationRequested();
sceneResultsRead++;
@ -123,6 +135,17 @@ public sealed class StoryIntelligenceMaterialisationService(
}
sceneResultsParsed++;
var temporal = StoryIntelligenceTemporalResolver.Resolve(parsed, temporalContext);
temporalContext = temporal.NextContext;
if (sceneResult.SceneID is int sceneId
&& canonicalSceneMap.TryGetValue(sceneId, out var canonicalScene)
&& !HasExistingChronology(canonicalScene)
&& TryApplyTemporalResolution(canonicalScene, temporal, timeModeIds, timeConfidenceIds))
{
await scenes.SaveAsync(canonicalScene);
chronologySaved++;
}
var metrics = BuildMetrics(parsed, metricMap);
if (metrics.Count == 0)
{
@ -156,13 +179,14 @@ public sealed class StoryIntelligenceMaterialisationService(
}
logger.LogInformation(
"Story Intelligence deterministic materialisation completed for BookID={BookID}. PipelineID={PipelineID} Runs={RunCount} SceneResultsRead={SceneResultsRead} SceneResultsParsed={SceneResultsParsed} MetricsSaved={MetricsSaved} NarrativeCandidatesBefore={NarrativeCandidatesBefore} NarrativeCandidatesAfter={NarrativeCandidatesAfter}.",
"Story Intelligence deterministic materialisation completed for BookID={BookID}. PipelineID={PipelineID} Runs={RunCount} SceneResultsRead={SceneResultsRead} SceneResultsParsed={SceneResultsParsed} MetricsSaved={MetricsSaved} ChronologySaved={ChronologySaved} NarrativeCandidatesBefore={NarrativeCandidatesBefore} NarrativeCandidatesAfter={NarrativeCandidatesAfter}.",
bookId,
pipeline.StoryIntelligenceBookPipelineID,
scopedRuns.Count,
sceneResultsRead,
sceneResultsParsed,
metricValuesSaved,
chronologySaved,
narrativeCandidatesBefore,
narrativeCandidatesAfter);
@ -270,7 +294,7 @@ public sealed class StoryIntelligenceMaterialisationService(
try
{
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(json, JsonOptions);
if (direct?.Summary is not null || direct?.MetricValues?.Count > 0 || direct?.Metrics?.Count > 0)
if (direct?.Summary is not null || direct?.TemporalAnalysis is not null || direct?.Setting is not null || direct?.MetricValues?.Count > 0 || direct?.Metrics?.Count > 0)
{
return direct;
}
@ -290,6 +314,40 @@ public sealed class StoryIntelligenceMaterialisationService(
return null;
}
private static bool TryApplyTemporalResolution(
Scene scene,
StoryIntelligenceTemporalResolution temporal,
IReadOnlyDictionary<string, int> timeModeIds,
IReadOnlyDictionary<string, int> timeConfidenceIds)
{
if (temporal.StartDateTime is null && string.IsNullOrWhiteSpace(temporal.RelativeTimeText))
{
return false;
}
if (!timeModeIds.TryGetValue(temporal.TimeModeName, out var timeModeId)
|| !timeConfidenceIds.TryGetValue(temporal.TimeConfidenceName, out var timeConfidenceId))
{
return false;
}
scene.TimeModeID = timeModeId;
scene.StartDateTime = temporal.StartDateTime;
scene.EndDateTime = temporal.EndDateTime;
scene.DurationAmount = temporal.DurationAmount;
scene.DurationUnitID = temporal.DurationUnitID;
scene.RelativeTimeText = temporal.RelativeTimeText;
scene.TimeConfidenceID = timeConfidenceId;
return true;
}
private static bool HasExistingChronology(Scene scene)
=> scene.StartDateTime.HasValue
|| scene.EndDateTime.HasValue
|| scene.DurationAmount.HasValue
|| scene.DurationUnitID.HasValue
|| !string.IsNullOrWhiteSpace(scene.RelativeTimeText);
private static string Clean(string? value)
=> string.IsNullOrWhiteSpace(value)
? string.Empty

View File

@ -7,6 +7,7 @@ namespace PlotLine.Services;
public interface IStoryIntelligenceProgressNotifier
{
Task PublishAsync(StoryIntelligenceRunProgressEvent progress);
Task PublishCharacterEnrichmentAsync(CharacterEnrichmentProgressEvent progress);
Task PublishGlobalAsync(int userId);
}
@ -23,6 +24,13 @@ public sealed class StoryIntelligenceProgressNotifier(
await PublishGlobalAsync(progress.UserID);
}
public async Task PublishCharacterEnrichmentAsync(CharacterEnrichmentProgressEvent progress)
{
var userGroup = StoryIntelligenceHub.UserGroup(progress.UserID);
await hub.Clients.Group(userGroup)
.SendAsync("CharacterEnrichmentProgressChanged", progress);
}
public async Task PublishGlobalAsync(int userId)
{
var userGroup = StoryIntelligenceHub.UserGroup(userId);

View File

@ -106,6 +106,18 @@ public static class StoryIntelligenceSceneStructuredOutputSchema
("absoluteDate", NullableString()),
("evidence", Str()),
("confidence", Confidence()))));
var temporalAnalysis = Obj(Props(
("absoluteDate", NullableString()),
("exactTime", NullableString()),
("partOfDay", NullableString()),
("relativeTransition", NullableEnum("same", "later", "next", "following", "after", "before", "elapsed", "unknown")),
("relativeAmount", NullableInteger()),
("relativeUnit", NullableEnum("minute", "hour", "day", "week", "month", "year", "unknown")),
("targetDayOfWeek", NullableEnum("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")),
("crossesMidnight", NullableBoolean()),
("evidence", NullableString()),
("confidence", Confidence()),
("unresolvedReason", NullableString())));
var narrativeArcs = Arr(Obj(Props(
("title", Str()),
("description", Str()),
@ -150,6 +162,7 @@ public static class StoryIntelligenceSceneStructuredOutputSchema
("relationships", relationships),
("knowledgeChanges", knowledgeChanges),
("timelineClues", timelineClues),
("temporalAnalysis", temporalAnalysis),
("narrativeArcs", narrativeArcs),
("questionsRaised", questionsRaised),
("questionsAnswered", questionsAnswered),
@ -196,6 +209,9 @@ public static class StoryIntelligenceSceneStructuredOutputSchema
private static Dictionary<string, object> Bool()
=> new() { ["type"] = "boolean" };
private static Dictionary<string, object> NullableBoolean()
=> new() { ["type"] = new[] { "boolean", "null" } };
private static Dictionary<string, object> Confidence()
=> new() { ["type"] = "number", ["minimum"] = 0, ["maximum"] = 1 };

View File

@ -0,0 +1,547 @@
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 confidence = NormaliseConfidence(temporal?.Confidence);
var absoluteDate = ParseDate(temporal?.AbsoluteDate) ?? ParseDate(fallbackText);
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 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)
{
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 || HasSceneTemporalSignal(temporal, scene)))
{
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, currentPartOfDay, relativeText, temporal);
var resolvedConfidence = ResolveConfidenceName(confidence, isDateDeterministic, mode);
var nextContext = context with
{
LastDate = start?.Date ?? (isDateDeterministic ? resolvedDate : context.LastDate),
LastPartOfDay = currentPartOfDay ?? 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)
{
var clean = StripOrdinalSuffixes(Clean(value));
if (string.IsNullOrWhiteSpace(clean))
{
return null;
}
foreach (var culture in DateCultures)
{
if (DateTime.TryParseExact(clean, DateFormats, culture, DateTimeStyles.AllowWhiteSpaces, out var exact))
{
return exact.Date;
}
if (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);
}
return null;
}
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
&& (temporal.Confidence > 0
|| !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);

View File

@ -17,6 +17,9 @@ public sealed class StorySceneValidator : IStorySceneValidator
private static readonly HashSet<string> ObservationTypes = CreateSet("CharacterAction", "CharacterKnowledge", "CharacterLocation", "RelationshipSignal", "AssetInteraction", "LocationSignal", "TimelineSignal", "QuestionSignal");
private static readonly HashSet<string> EntityTypes = CreateSet("Character", "Location", "Asset", "Knowledge", "Relationship", "Timeline", "Scene", "Unknown");
private static readonly HashSet<string> RelationshipSignals = CreateSet("trust", "mistrust", "affection", "fear", "authority", "secrecy", "conflict", "dependence", "protection", "rivalry", "obligation");
private static readonly HashSet<string> TemporalTransitions = CreateSet("same", "later", "next", "following", "after", "before", "elapsed", "unknown");
private static readonly HashSet<string> TemporalUnits = CreateSet("minute", "hour", "day", "week", "month", "year", "unknown");
private static readonly HashSet<string> DaysOfWeek = CreateSet("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday");
public ValidationResult Validate(SceneIntelligenceScene? scene)
{
@ -42,6 +45,7 @@ public sealed class StorySceneValidator : IStorySceneValidator
ValidateRelationships(scene.Relationships, result);
ValidateKnowledgeChanges(scene.KnowledgeChanges, result);
ValidateTimelineClues(scene.TimelineClues, result);
ValidateTemporalAnalysis(scene.TemporalAnalysis, result);
ValidateNarrativeArcs(scene.NarrativeArcs, result);
ValidateQuestionsRaised(scene.QuestionsRaised, result);
ValidateQuestionsAnswered(scene.QuestionsAnswered, result);
@ -81,6 +85,7 @@ public sealed class StorySceneValidator : IStorySceneValidator
RequiredArray(scene.Relationships, "relationships", result);
RequiredArray(scene.KnowledgeChanges, "knowledgeChanges", result);
RequiredArray(scene.TimelineClues, "timelineClues", result);
RequiredObject(scene.TemporalAnalysis, "temporalAnalysis", result);
RequiredArray(scene.NarrativeArcs, "narrativeArcs", result);
RequiredArray(scene.QuestionsRaised, "questionsRaised", result);
RequiredArray(scene.QuestionsAnswered, "questionsAnswered", result);
@ -326,6 +331,27 @@ public sealed class StorySceneValidator : IStorySceneValidator
}
}
private static void ValidateTemporalAnalysis(SceneIntelligenceTemporalAnalysis? value, ValidationResult result)
{
if (value is null)
{
return;
}
RequiredScalar(value.AbsoluteDate, "temporalAnalysis.absoluteDate", result, allowNull: true);
RequiredScalar(value.ExactTime, "temporalAnalysis.exactTime", result, allowNull: true);
RequiredScalar(value.PartOfDay, "temporalAnalysis.partOfDay", result, allowNull: true);
NullableEnum(value.RelativeTransition, "temporalAnalysis.relativeTransition", TemporalTransitions, result);
RequiredScalar(value.RelativeAmount, "temporalAnalysis.relativeAmount", result, allowNull: true);
NullableEnum(value.RelativeUnit, "temporalAnalysis.relativeUnit", TemporalUnits, result);
NullableEnum(value.TargetDayOfWeek, "temporalAnalysis.targetDayOfWeek", DaysOfWeek, result);
RequiredScalar(value.CrossesMidnight, "temporalAnalysis.crossesMidnight", result, allowNull: true);
RequiredScalar(value.Evidence, "temporalAnalysis.evidence", result, allowNull: true);
Confidence(value.Confidence, "temporalAnalysis.confidence", result);
RequiredScalar(value.UnresolvedReason, "temporalAnalysis.unresolvedReason", result, allowNull: true);
Unknown(value.ExtensionData, "temporalAnalysis", result);
}
private static void ValidateQuestionsRaised(List<SceneIntelligenceQuestionRaised>? values, ValidationResult result)
{
if (values is null)

View File

@ -0,0 +1,465 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.CharacterEnrichmentRuns', N'U') IS NULL
BEGIN
CREATE TABLE dbo.CharacterEnrichmentRuns
(
CharacterEnrichmentRunID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CharacterEnrichmentRuns PRIMARY KEY,
ProjectID int NOT NULL,
BookID int NOT NULL,
UserID int NOT NULL,
StoryIntelligenceBookPipelineID int NULL,
CharacterResolutionVersion nvarchar(120) NOT NULL,
Status nvarchar(30) NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_Status DEFAULT N'Queued',
TotalCharacters int NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_TotalCharacters DEFAULT 0,
ProcessedCharacters int NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_ProcessedCharacters DEFAULT 0,
CurrentStage nvarchar(120) NULL,
CurrentMessage nvarchar(300) NULL,
QueuedUtc datetime2 NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_QueuedUtc DEFAULT SYSUTCDATETIME(),
StartedUtc datetime2 NULL,
CompletedUtc datetime2 NULL,
FailedUtc datetime2 NULL,
AttemptCount int NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_AttemptCount DEFAULT 0,
Model nvarchar(100) NULL,
InputTokens int NULL,
OutputTokens int NULL,
TotalTokens int NULL,
DurationMs bigint NULL,
ErrorMessage nvarchar(max) NULL,
ErrorDetail nvarchar(max) NULL,
IsCurrent bit NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_IsCurrent DEFAULT 1,
UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_CharacterEnrichmentRuns_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID),
CONSTRAINT FK_CharacterEnrichmentRuns_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID) ON DELETE CASCADE,
CONSTRAINT FK_CharacterEnrichmentRuns_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID),
CONSTRAINT FK_CharacterEnrichmentRuns_Pipeline FOREIGN KEY (StoryIntelligenceBookPipelineID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CharacterEnrichmentRuns_Queue' AND object_id = OBJECT_ID(N'dbo.CharacterEnrichmentRuns'))
CREATE INDEX IX_CharacterEnrichmentRuns_Queue ON dbo.CharacterEnrichmentRuns(Status, QueuedUtc) INCLUDE (BookID, UserID);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_CharacterEnrichmentRuns_CurrentVersion' AND object_id = OBJECT_ID(N'dbo.CharacterEnrichmentRuns'))
CREATE UNIQUE INDEX UX_CharacterEnrichmentRuns_CurrentVersion
ON dbo.CharacterEnrichmentRuns(BookID, CharacterResolutionVersion)
WHERE IsCurrent = 1 AND Status IN (N'Queued', N'Running', N'Completed');
GO
IF OBJECT_ID(N'dbo.CharacterEnrichmentEvidence', N'U') IS NULL
BEGIN
CREATE TABLE dbo.CharacterEnrichmentEvidence
(
CharacterEnrichmentEvidenceID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CharacterEnrichmentEvidence PRIMARY KEY,
CharacterEnrichmentRunID int NOT NULL,
CharacterID int NOT NULL,
SceneID int NULL,
FieldName nvarchar(80) NOT NULL,
Value nvarchar(500) NULL,
Evidence nvarchar(700) NULL,
Confidence decimal(5,4) NOT NULL CONSTRAINT DF_CharacterEnrichmentEvidence_Confidence DEFAULT 0,
Source nvarchar(80) NOT NULL CONSTRAINT DF_CharacterEnrichmentEvidence_Source DEFAULT N'Character enrichment',
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_CharacterEnrichmentEvidence_CreatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_CharacterEnrichmentEvidence_Run FOREIGN KEY (CharacterEnrichmentRunID) REFERENCES dbo.CharacterEnrichmentRuns(CharacterEnrichmentRunID) ON DELETE CASCADE,
CONSTRAINT FK_CharacterEnrichmentEvidence_Character FOREIGN KEY (CharacterID) REFERENCES dbo.Characters(CharacterID),
CONSTRAINT FK_CharacterEnrichmentEvidence_Scene FOREIGN KEY (SceneID) REFERENCES dbo.Scenes(SceneID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CharacterEnrichmentEvidence_Character' AND object_id = OBJECT_ID(N'dbo.CharacterEnrichmentEvidence'))
CREATE INDEX IX_CharacterEnrichmentEvidence_Character ON dbo.CharacterEnrichmentEvidence(CharacterID, CharacterEnrichmentRunID, FieldName);
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Queue
@ProjectID int,
@BookID int,
@UserID int,
@StoryIntelligenceBookPipelineID int = NULL,
@CharacterResolutionVersion nvarchar(120)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @ExistingID int, @TotalCharacters int;
SELECT @TotalCharacters = COUNT(DISTINCT c.CharacterID)
FROM dbo.Characters c
INNER JOIN dbo.SceneCharacters sc ON sc.CharacterID = c.CharacterID
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.IsArchived = 0
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
WHERE c.ProjectID = @ProjectID
AND c.IsArchived = 0
AND ch.BookID = @BookID;
SELECT TOP (1) @ExistingID = CharacterEnrichmentRunID
FROM dbo.CharacterEnrichmentRuns
WHERE BookID = @BookID
AND CharacterResolutionVersion = @CharacterResolutionVersion
AND IsCurrent = 1
AND Status IN (N'Queued', N'Running', N'Completed')
ORDER BY CharacterEnrichmentRunID DESC;
IF @ExistingID IS NULL
BEGIN
INSERT dbo.CharacterEnrichmentRuns
(ProjectID, BookID, UserID, StoryIntelligenceBookPipelineID, CharacterResolutionVersion, Status, TotalCharacters, CurrentStage, CurrentMessage)
VALUES
(@ProjectID, @BookID, @UserID, @StoryIntelligenceBookPipelineID, @CharacterResolutionVersion, N'Queued', @TotalCharacters, N'Preparing character analysis', N'Preparing character analysis...');
SET @ExistingID = CAST(SCOPE_IDENTITY() AS int);
END;
SELECT * FROM dbo.CharacterEnrichmentRuns WHERE CharacterEnrichmentRunID = @ExistingID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Retry
@BookID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @PreviousID int, @ProjectID int, @PipelineID int, @Version nvarchar(120);
SELECT TOP (1)
@PreviousID = CharacterEnrichmentRunID,
@ProjectID = ProjectID,
@PipelineID = StoryIntelligenceBookPipelineID,
@Version = CharacterResolutionVersion
FROM dbo.CharacterEnrichmentRuns
WHERE BookID = @BookID AND UserID = @UserID AND IsCurrent = 1 AND Status = N'Failed'
ORDER BY CharacterEnrichmentRunID DESC;
IF @PreviousID IS NULL
BEGIN
SELECT TOP (0) * FROM dbo.CharacterEnrichmentRuns;
RETURN;
END;
UPDATE dbo.CharacterEnrichmentRuns
SET IsCurrent = 0,
Status = N'Superseded',
UpdatedUtc = SYSUTCDATETIME()
WHERE CharacterEnrichmentRunID = @PreviousID;
EXEC dbo.CharacterEnrichmentRun_Queue
@ProjectID = @ProjectID,
@BookID = @BookID,
@UserID = @UserID,
@StoryIntelligenceBookPipelineID = @PipelineID,
@CharacterResolutionVersion = @Version;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_ClaimNext
@LeaseMinutes int = 90
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @RunID int;
SELECT TOP (1) @RunID = CharacterEnrichmentRunID
FROM dbo.CharacterEnrichmentRuns WITH (UPDLOCK, READPAST)
WHERE IsCurrent = 1
AND (
Status = N'Queued'
OR (Status = N'Running' AND StartedUtc < DATEADD(minute, -ABS(@LeaseMinutes), SYSUTCDATETIME()))
)
ORDER BY QueuedUtc, CharacterEnrichmentRunID;
IF @RunID IS NULL
BEGIN
SELECT TOP (0) * FROM dbo.CharacterEnrichmentRuns;
RETURN;
END;
UPDATE dbo.CharacterEnrichmentRuns
SET Status = N'Running',
StartedUtc = COALESCE(StartedUtc, SYSUTCDATETIME()),
AttemptCount = AttemptCount + 1,
CurrentStage = N'Preparing character analysis',
CurrentMessage = N'Preparing character analysis...',
UpdatedUtc = SYSUTCDATETIME()
WHERE CharacterEnrichmentRunID = @RunID;
SELECT * FROM dbo.CharacterEnrichmentRuns WHERE CharacterEnrichmentRunID = @RunID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_GetCurrentByBook
@BookID int
AS
BEGIN
SET NOCOUNT ON;
SELECT TOP (1) *
FROM dbo.CharacterEnrichmentRuns
WHERE BookID = @BookID AND IsCurrent = 1
ORDER BY CharacterEnrichmentRunID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_UpdateProgress
@CharacterEnrichmentRunID int,
@TotalCharacters int,
@ProcessedCharacters int,
@CurrentStage nvarchar(120),
@CurrentMessage nvarchar(300)
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.CharacterEnrichmentRuns
SET TotalCharacters = @TotalCharacters,
ProcessedCharacters = @ProcessedCharacters,
CurrentStage = @CurrentStage,
CurrentMessage = @CurrentMessage,
UpdatedUtc = SYSUTCDATETIME()
WHERE CharacterEnrichmentRunID = @CharacterEnrichmentRunID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichment_ContextGet
@ProjectID int,
@BookID int
AS
BEGIN
SET NOCOUNT ON;
SELECT
BookTitle = CASE WHEN NULLIF(LTRIM(RTRIM(b.Subtitle)), N'') IS NULL THEN b.BookTitle ELSE CONCAT(b.BookTitle, N': ', b.Subtitle) END,
b.StoryEra,
SeriesStartDate = CAST(NULL AS date)
FROM dbo.Books b
WHERE b.BookID = @BookID AND b.ProjectID = @ProjectID AND b.IsArchived = 0;
SELECT DISTINCT
c.CharacterID,
c.CharacterName,
c.BirthDate,
c.AgeAtSeriesStart,
c.Height,
c.EyeColour,
c.DefaultDescription
FROM dbo.Characters c
INNER JOIN dbo.SceneCharacters sc ON sc.CharacterID = c.CharacterID
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.IsArchived = 0
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
WHERE c.ProjectID = @ProjectID
AND c.IsArchived = 0
AND ch.BookID = @BookID
ORDER BY c.CharacterName;
SELECT alias.CharacterID, alias.Alias
FROM dbo.CharacterAliases alias
INNER JOIN dbo.Characters c ON c.CharacterID = alias.CharacterID
WHERE c.ProjectID = @ProjectID AND c.IsArchived = 0
ORDER BY alias.CharacterID, alias.SortOrder, alias.Alias;
SELECT
sc.CharacterID,
s.SceneID,
ch.ChapterID,
ch.ChapterNumber,
ch.ChapterTitle,
s.SceneNumber,
s.SceneTitle,
sms.SourceText
FROM dbo.SceneCharacters sc
INNER JOIN dbo.Characters c ON c.CharacterID = sc.CharacterID AND c.ProjectID = @ProjectID AND c.IsArchived = 0
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.BookID = @BookID AND ch.IsArchived = 0
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
ORDER BY sc.CharacterID, ch.SortOrder, ch.ChapterNumber, s.SortOrder, s.SceneNumber, s.SceneID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Complete
@CharacterEnrichmentRunID int,
@Model nvarchar(100),
@InputTokens int = NULL,
@OutputTokens int = NULL,
@TotalTokens int = NULL,
@DurationMs bigint = NULL,
@ResultsJson nvarchar(max)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @ProjectID int, @BookID int, @PipelineID int;
SELECT @ProjectID = ProjectID, @BookID = BookID, @PipelineID = StoryIntelligenceBookPipelineID
FROM dbo.CharacterEnrichmentRuns
WHERE CharacterEnrichmentRunID = @CharacterEnrichmentRunID;
DECLARE @Results TABLE
(
CharacterID int NOT NULL PRIMARY KEY,
Summary nvarchar(max) NULL,
Confidence decimal(5,4) NOT NULL,
DateOfBirthValue nvarchar(120) NULL,
DateOfBirthConfidence decimal(5,4) NULL,
DateOfBirthEvidence nvarchar(700) NULL,
DateOfBirthSceneID int NULL,
AgeValue nvarchar(120) NULL,
AgeConfidence decimal(5,4) NULL,
AgeEvidence nvarchar(700) NULL,
AgeSceneID int NULL,
HeightValue nvarchar(120) NULL,
HeightConfidence decimal(5,4) NULL,
HeightEvidence nvarchar(700) NULL,
HeightSceneID int NULL,
EyeColourValue nvarchar(120) NULL,
EyeColourConfidence decimal(5,4) NULL,
EyeColourEvidence nvarchar(700) NULL,
EyeColourSceneID int NULL
);
INSERT @Results
SELECT
CharacterID,
NULLIF(LTRIM(RTRIM(Summary)), N''),
Confidence,
NULLIF(LTRIM(RTRIM(DateOfBirthValue)), N''),
DateOfBirthConfidence,
NULLIF(LTRIM(RTRIM(DateOfBirthEvidence)), N''),
DateOfBirthSceneID,
NULLIF(LTRIM(RTRIM(AgeValue)), N''),
AgeConfidence,
NULLIF(LTRIM(RTRIM(AgeEvidence)), N''),
AgeSceneID,
NULLIF(LTRIM(RTRIM(HeightValue)), N''),
HeightConfidence,
NULLIF(LTRIM(RTRIM(HeightEvidence)), N''),
HeightSceneID,
NULLIF(LTRIM(RTRIM(EyeColourValue)), N''),
EyeColourConfidence,
NULLIF(LTRIM(RTRIM(EyeColourEvidence)), N''),
EyeColourSceneID
FROM OPENJSON(@ResultsJson)
WITH
(
CharacterID int '$.characterID',
Summary nvarchar(max) '$.summary',
Confidence decimal(5,4) '$.confidence',
DateOfBirthValue nvarchar(120) '$.dateOfBirth.value',
DateOfBirthConfidence decimal(5,4) '$.dateOfBirth.confidence',
DateOfBirthEvidence nvarchar(700) '$.dateOfBirth.evidence',
DateOfBirthSceneID int '$.dateOfBirth.sceneID',
AgeValue nvarchar(120) '$.ageAtStartOfSeries.value',
AgeConfidence decimal(5,4) '$.ageAtStartOfSeries.confidence',
AgeEvidence nvarchar(700) '$.ageAtStartOfSeries.evidence',
AgeSceneID int '$.ageAtStartOfSeries.sceneID',
HeightValue nvarchar(120) '$.height.value',
HeightConfidence decimal(5,4) '$.height.confidence',
HeightEvidence nvarchar(700) '$.height.evidence',
HeightSceneID int '$.height.sceneID',
EyeColourValue nvarchar(120) '$.eyeColour.value',
EyeColourConfidence decimal(5,4) '$.eyeColour.confidence',
EyeColourEvidence nvarchar(700) '$.eyeColour.evidence',
EyeColourSceneID int '$.eyeColour.sceneID'
)
WHERE CharacterID IS NOT NULL;
UPDATE c
SET DefaultDescription = CASE WHEN NULLIF(LTRIM(RTRIM(c.DefaultDescription)), N'') IS NULL THEN r.Summary ELSE c.DefaultDescription END,
BirthDate = CASE WHEN c.BirthDate IS NULL THEN TRY_CONVERT(date, r.DateOfBirthValue) ELSE c.BirthDate END,
AgeAtSeriesStart = CASE WHEN c.AgeAtSeriesStart IS NULL THEN TRY_CONVERT(int, r.AgeValue) ELSE c.AgeAtSeriesStart END,
Height = CASE WHEN NULLIF(LTRIM(RTRIM(c.Height)), N'') IS NULL THEN r.HeightValue ELSE c.Height END,
EyeColour = CASE WHEN NULLIF(LTRIM(RTRIM(c.EyeColour)), N'') IS NULL THEN r.EyeColourValue ELSE c.EyeColour END,
UpdatedDate = SYSUTCDATETIME()
FROM dbo.Characters c
INNER JOIN @Results r ON r.CharacterID = c.CharacterID
WHERE c.ProjectID = @ProjectID;
MERGE dbo.CharacterIntelligenceProfiles WITH (HOLDLOCK) AS target
USING
(
SELECT
ImportSessionID = @PipelineID,
ProjectID = @ProjectID,
BookID = @BookID,
CanonicalIdentityKey = CONCAT(N'character-enrichment-', r.CharacterID),
CanonicalName = c.CharacterName,
ProfileJson = (SELECT r.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER),
Confidence = r.Confidence,
NarrativeImportance = CAST(NULL AS nvarchar(60))
FROM @Results r
INNER JOIN dbo.Characters c ON c.CharacterID = r.CharacterID
WHERE @PipelineID IS NOT NULL
) AS source
ON target.ImportSessionID = source.ImportSessionID
AND target.CanonicalIdentityKey = source.CanonicalIdentityKey
WHEN MATCHED THEN
UPDATE SET CanonicalName = source.CanonicalName,
ProfileJson = source.ProfileJson,
Confidence = source.Confidence,
EvidenceVersion = N'CharacterEnrichmentV1',
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ImportSessionID, ProjectID, BookID, CanonicalIdentityKey, CanonicalName, ProfileJson, Confidence, NarrativeImportance, IsRecurring, EvidenceVersion, LastUpdatedSceneResultID)
VALUES (source.ImportSessionID, source.ProjectID, source.BookID, source.CanonicalIdentityKey, source.CanonicalName, source.ProfileJson, source.Confidence, source.NarrativeImportance, 1, N'CharacterEnrichmentV1', NULL);
INSERT dbo.CharacterEnrichmentEvidence (CharacterEnrichmentRunID, CharacterID, SceneID, FieldName, Value, Evidence, Confidence)
SELECT @CharacterEnrichmentRunID, CharacterID, NULL, N'Summary', Summary, NULL, Confidence
FROM @Results
WHERE Summary IS NOT NULL
UNION ALL
SELECT @CharacterEnrichmentRunID, CharacterID, DateOfBirthSceneID, N'DateOfBirth', DateOfBirthValue, DateOfBirthEvidence, COALESCE(DateOfBirthConfidence, 0)
FROM @Results WHERE DateOfBirthValue IS NOT NULL AND DateOfBirthSceneID IS NOT NULL
UNION ALL
SELECT @CharacterEnrichmentRunID, CharacterID, AgeSceneID, N'AgeAtStartOfSeries', AgeValue, AgeEvidence, COALESCE(AgeConfidence, 0)
FROM @Results WHERE AgeValue IS NOT NULL AND AgeSceneID IS NOT NULL
UNION ALL
SELECT @CharacterEnrichmentRunID, CharacterID, HeightSceneID, N'Height', HeightValue, HeightEvidence, COALESCE(HeightConfidence, 0)
FROM @Results WHERE HeightValue IS NOT NULL AND HeightSceneID IS NOT NULL
UNION ALL
SELECT @CharacterEnrichmentRunID, CharacterID, EyeColourSceneID, N'EyeColour', EyeColourValue, EyeColourEvidence, COALESCE(EyeColourConfidence, 0)
FROM @Results WHERE EyeColourValue IS NOT NULL AND EyeColourSceneID IS NOT NULL;
UPDATE dbo.CharacterEnrichmentRuns
SET Status = N'Completed',
ProcessedCharacters = TotalCharacters,
CurrentStage = N'Complete',
CurrentMessage = N'Character summaries are ready.',
CompletedUtc = SYSUTCDATETIME(),
Model = @Model,
InputTokens = @InputTokens,
OutputTokens = @OutputTokens,
TotalTokens = @TotalTokens,
DurationMs = @DurationMs,
ErrorMessage = NULL,
ErrorDetail = NULL,
UpdatedUtc = SYSUTCDATETIME()
WHERE CharacterEnrichmentRunID = @CharacterEnrichmentRunID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Fail
@CharacterEnrichmentRunID int,
@ErrorMessage nvarchar(max),
@ErrorDetail nvarchar(max) = NULL,
@DurationMs bigint = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.CharacterEnrichmentRuns
SET Status = N'Failed',
FailedUtc = SYSUTCDATETIME(),
CurrentStage = N'Failed',
CurrentMessage = N'Character enrichment failed.',
ErrorMessage = @ErrorMessage,
ErrorDetail = @ErrorDetail,
DurationMs = @DurationMs,
UpdatedUtc = SYSUTCDATETIME()
WHERE CharacterEnrichmentRunID = @CharacterEnrichmentRunID;
END;
GO

View File

@ -0,0 +1,218 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER PROCEDURE dbo.ProjectHardDelete_PrepareDependencies
@ProjectID int,
@UserID int,
@AllowActiveProject bit = 0
AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS
(
SELECT 1
FROM dbo.Projects p
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
WHERE p.ProjectID = @ProjectID
AND pua.UserID = @UserID
AND pua.AccessRole = N'Owner'
AND pua.IsActive = 1
)
THROW 52001, 'Project was not found for this owner.', 1;
IF @AllowActiveProject = 0 AND EXISTS (SELECT 1 FROM dbo.Projects WHERE ProjectID = @ProjectID AND IsArchived = 0)
THROW 52002, 'Only archived projects can be permanently deleted.', 1;
DECLARE @BookIDs TABLE (BookID int NOT NULL PRIMARY KEY);
DECLARE @ChapterIDs TABLE (ChapterID int NOT NULL PRIMARY KEY);
DECLARE @SceneIDs TABLE (SceneID int NOT NULL PRIMARY KEY);
DECLARE @StoryIntelligenceRunIDs TABLE (StoryIntelligenceRunID int NOT NULL PRIMARY KEY);
DECLARE @StoryIntelligenceChapterResultIDs TABLE (ChapterResultID int NOT NULL PRIMARY KEY);
DECLARE @StoryIntelligenceSceneResultIDs TABLE (SceneResultID int NOT NULL PRIMARY KEY);
DECLARE @ImportSessionIDs TABLE (ImportSessionID int NOT NULL PRIMARY KEY);
DECLARE @CharacterIntelligenceProfileIDs TABLE (CharacterIntelligenceProfileID int NOT NULL PRIMARY KEY);
DECLARE @CharacterEnrichmentRunIDs TABLE (CharacterEnrichmentRunID int NOT NULL PRIMARY KEY);
INSERT @BookIDs (BookID)
SELECT BookID FROM dbo.Books WHERE ProjectID = @ProjectID;
INSERT @ChapterIDs (ChapterID)
SELECT c.ChapterID
FROM dbo.Chapters c
INNER JOIN @BookIDs b ON b.BookID = c.BookID;
INSERT @SceneIDs (SceneID)
SELECT s.SceneID
FROM dbo.Scenes s
INNER JOIN @ChapterIDs c ON c.ChapterID = s.ChapterID;
INSERT @ImportSessionIDs (ImportSessionID)
SELECT StoryIntelligenceBookPipelineID
FROM dbo.StoryIntelligenceBookPipelines pipeline
WHERE pipeline.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = pipeline.BookID);
INSERT @StoryIntelligenceRunIDs (StoryIntelligenceRunID)
SELECT DISTINCT r.StoryIntelligenceRunID
FROM dbo.StoryIntelligenceRuns r
WHERE r.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = r.BookID)
OR EXISTS (SELECT 1 FROM @ChapterIDs c WHERE c.ChapterID = r.ChapterID)
OR EXISTS (SELECT 1 FROM @SceneIDs s INNER JOIN dbo.Scenes scene ON scene.SceneID = s.SceneID WHERE scene.ImportRunID = r.StoryIntelligenceRunID);
INSERT @StoryIntelligenceChapterResultIDs (ChapterResultID)
SELECT DISTINCT cr.ChapterResultID
FROM dbo.StoryIntelligenceChapterResults cr
WHERE cr.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @StoryIntelligenceRunIDs r WHERE r.StoryIntelligenceRunID = cr.StoryIntelligenceRunID)
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = cr.BookID)
OR EXISTS (SELECT 1 FROM @ChapterIDs c WHERE c.ChapterID = cr.ChapterID);
INSERT @StoryIntelligenceSceneResultIDs (SceneResultID)
SELECT DISTINCT sr.SceneResultID
FROM dbo.StoryIntelligenceSceneResults sr
WHERE sr.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @StoryIntelligenceRunIDs r WHERE r.StoryIntelligenceRunID = sr.StoryIntelligenceRunID)
OR EXISTS (SELECT 1 FROM @StoryIntelligenceChapterResultIDs cr WHERE cr.ChapterResultID = sr.ChapterResultID)
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = sr.BookID)
OR EXISTS (SELECT 1 FROM @ChapterIDs c WHERE c.ChapterID = sr.ChapterID)
OR EXISTS (SELECT 1 FROM @SceneIDs s WHERE s.SceneID = sr.SceneID);
INSERT @CharacterIntelligenceProfileIDs (CharacterIntelligenceProfileID)
SELECT DISTINCT profile.CharacterIntelligenceProfileID
FROM dbo.CharacterIntelligenceProfiles profile
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs sessionIDs WHERE sessionIDs.ImportSessionID = profile.ImportSessionID)
OR EXISTS (SELECT 1 FROM @StoryIntelligenceSceneResultIDs resultIDs WHERE resultIDs.SceneResultID = profile.LastUpdatedSceneResultID);
IF OBJECT_ID(N'dbo.CharacterEnrichmentRuns', N'U') IS NOT NULL
BEGIN
INSERT @CharacterEnrichmentRunIDs (CharacterEnrichmentRunID)
SELECT DISTINCT runRows.CharacterEnrichmentRunID
FROM dbo.CharacterEnrichmentRuns runRows
WHERE runRows.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = runRows.BookID)
OR EXISTS (SELECT 1 FROM @ImportSessionIDs sessionIDs WHERE sessionIDs.ImportSessionID = runRows.StoryIntelligenceBookPipelineID);
END;
IF OBJECT_ID(N'dbo.CharacterEnrichmentEvidence', N'U') IS NOT NULL
BEGIN
DELETE evidence
FROM dbo.CharacterEnrichmentEvidence evidence
LEFT JOIN @CharacterEnrichmentRunIDs runIDs ON runIDs.CharacterEnrichmentRunID = evidence.CharacterEnrichmentRunID
LEFT JOIN dbo.Characters characterRows ON characterRows.CharacterID = evidence.CharacterID
LEFT JOIN @SceneIDs sceneIDs ON sceneIDs.SceneID = evidence.SceneID
WHERE runIDs.CharacterEnrichmentRunID IS NOT NULL
OR characterRows.ProjectID = @ProjectID
OR sceneIDs.SceneID IS NOT NULL;
END;
IF OBJECT_ID(N'dbo.CharacterEnrichmentRuns', N'U') IS NOT NULL
BEGIN
DELETE runRows
FROM dbo.CharacterEnrichmentRuns runRows
WHERE EXISTS (SELECT 1 FROM @CharacterEnrichmentRunIDs runIDs WHERE runIDs.CharacterEnrichmentRunID = runRows.CharacterEnrichmentRunID);
END;
IF OBJECT_ID(N'dbo.CoreImportBasicLocationReviews', N'U') IS NOT NULL
BEGIN
DELETE reviews
FROM dbo.CoreImportBasicLocationReviews reviews
WHERE reviews.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = reviews.BookID);
END;
DELETE suggestion
FROM dbo.SceneCharacterSuggestions suggestion
LEFT JOIN @SceneIDs scene ON scene.SceneID = suggestion.SceneID
LEFT JOIN dbo.Characters characterRows ON characterRows.CharacterID = suggestion.CharacterID
WHERE scene.SceneID IS NOT NULL
OR characterRows.ProjectID = @ProjectID;
DELETE suggestion
FROM dbo.SceneLocationSuggestions suggestion
LEFT JOIN @SceneIDs scene ON scene.SceneID = suggestion.SceneID
LEFT JOIN dbo.Locations locationRows ON locationRows.LocationID = suggestion.LocationID
WHERE scene.SceneID IS NOT NULL
OR locationRows.ProjectID = @ProjectID;
DELETE suggestion
FROM dbo.SceneAssetSuggestions suggestion
LEFT JOIN @SceneIDs scene ON scene.SceneID = suggestion.SceneID
LEFT JOIN dbo.StoryAssets assetRows ON assetRows.StoryAssetID = suggestion.AssetID
WHERE scene.SceneID IS NOT NULL
OR assetRows.ProjectID = @ProjectID;
DELETE sceneAssets
FROM dbo.WordCompanionSceneAssets sceneAssets
LEFT JOIN @SceneIDs scene ON scene.SceneID = sceneAssets.SceneID
LEFT JOIN dbo.StoryAssets assetRows ON assetRows.StoryAssetID = sceneAssets.StoryAssetID
WHERE scene.SceneID IS NOT NULL
OR assetRows.ProjectID = @ProjectID;
DELETE sceneLocations
FROM dbo.WordCompanionSceneLocations sceneLocations
LEFT JOIN @SceneIDs scene ON scene.SceneID = sceneLocations.SceneID
LEFT JOIN dbo.Locations locationRows ON locationRows.LocationID = sceneLocations.LocationID
WHERE scene.SceneID IS NOT NULL
OR locationRows.ProjectID = @ProjectID;
DELETE evidence
FROM dbo.CharacterIntelligenceEvidenceEvents evidence
WHERE EXISTS (SELECT 1 FROM @CharacterIntelligenceProfileIDs profileIDs WHERE profileIDs.CharacterIntelligenceProfileID = evidence.CharacterIntelligenceProfileID)
OR EXISTS (SELECT 1 FROM @StoryIntelligenceSceneResultIDs resultIDs WHERE resultIDs.SceneResultID = evidence.SceneResultID);
DELETE profileRows
FROM dbo.CharacterIntelligenceProfiles profileRows
WHERE EXISTS (SELECT 1 FROM @CharacterIntelligenceProfileIDs profileIDs WHERE profileIDs.CharacterIntelligenceProfileID = profileRows.CharacterIntelligenceProfileID);
DELETE FROM dbo.StoryMemoryIllustrationAssignments
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryIllustrationAssignments.ImportSessionID);
DELETE FROM dbo.StoryMemoryIllustrationDemands
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryIllustrationDemands.ImportSessionID);
DELETE FROM dbo.StoryMemoryRelationships
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryRelationships.ImportSessionID);
DELETE FROM dbo.StoryMemoryCharacterAttributes
WHERE EXISTS
(
SELECT 1
FROM dbo.StoryMemoryCharacters c
INNER JOIN @ImportSessionIDs ids ON ids.ImportSessionID = c.ImportSessionID
WHERE c.StoryMemoryCharacterID = StoryMemoryCharacterAttributes.StoryMemoryCharacterID
);
DELETE FROM dbo.StoryMemoryCharacterAliases
WHERE EXISTS
(
SELECT 1
FROM dbo.StoryMemoryCharacters c
INNER JOIN @ImportSessionIDs ids ON ids.ImportSessionID = c.ImportSessionID
WHERE c.StoryMemoryCharacterID = StoryMemoryCharacterAliases.StoryMemoryCharacterID
);
DELETE FROM dbo.StoryMemoryAssets
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryAssets.ImportSessionID);
DELETE FROM dbo.StoryMemoryLocations
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryLocations.ImportSessionID);
DELETE FROM dbo.StoryMemoryCharacters
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryCharacters.ImportSessionID);
DELETE FROM dbo.StoryMemoryProcessedSceneResults
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryProcessedSceneResults.ImportSessionID)
OR EXISTS (SELECT 1 FROM @StoryIntelligenceSceneResultIDs resultIDs WHERE resultIDs.SceneResultID = StoryMemoryProcessedSceneResults.SceneResultID);
DELETE FROM dbo.StoryMemoryImportPreferences
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryImportPreferences.ImportSessionID);
END;
GO
IF OBJECT_DEFINITION(OBJECT_ID(N'dbo.ProjectHardDelete_ForOwner')) NOT LIKE N'%ProjectHardDelete_PrepareDependencies%'
THROW 52014, 'Project hard-delete procedure does not call dependency cleanup.', 1;
GO

View File

@ -0,0 +1,351 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.CharacterEnrichmentRuns', N'CharacterID') IS NULL
BEGIN
ALTER TABLE dbo.CharacterEnrichmentRuns ADD CharacterID int NULL;
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_CharacterEnrichmentRuns_Characters')
BEGIN
ALTER TABLE dbo.CharacterEnrichmentRuns
ADD CONSTRAINT FK_CharacterEnrichmentRuns_Characters FOREIGN KEY (CharacterID) REFERENCES dbo.Characters(CharacterID);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_CharacterEnrichmentRuns_ActiveCharacter' AND object_id = OBJECT_ID(N'dbo.CharacterEnrichmentRuns'))
BEGIN
CREATE UNIQUE INDEX UX_CharacterEnrichmentRuns_ActiveCharacter
ON dbo.CharacterEnrichmentRuns(BookID, CharacterID)
WHERE CharacterID IS NOT NULL AND IsCurrent = 1 AND Status IN (N'Queued', N'Running');
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CharacterEnrichmentRuns_Character_Current' AND object_id = OBJECT_ID(N'dbo.CharacterEnrichmentRuns'))
BEGIN
CREATE INDEX IX_CharacterEnrichmentRuns_Character_Current
ON dbo.CharacterEnrichmentRuns(CharacterID, IsCurrent, UpdatedUtc DESC)
INCLUDE (BookID, UserID, Status, CompletedUtc);
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_QueueCharacter
@CharacterID int,
@UserID int,
@BookID int = NULL
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @ProjectID int, @ResolvedBookID int, @PipelineID int, @ExistingID int, @Version nvarchar(120), @WasCoalesced bit = 1;
SELECT TOP (1)
@ProjectID = c.ProjectID,
@ResolvedBookID = ch.BookID
FROM dbo.Characters c
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = c.ProjectID
INNER JOIN dbo.SceneCharacters sc ON sc.CharacterID = c.CharacterID
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.IsArchived = 0
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
WHERE c.CharacterID = @CharacterID
AND c.IsArchived = 0
AND pua.UserID = @UserID
AND pua.AccessRole = N'Owner'
AND pua.IsActive = 1
AND (@BookID IS NULL OR ch.BookID = @BookID)
ORDER BY ch.SortOrder, ch.ChapterNumber, s.SortOrder, s.SceneNumber, s.SceneID;
IF @ProjectID IS NULL OR @ResolvedBookID IS NULL
THROW 52221, 'Character could not be queued for enrichment for this user and book.', 1;
SELECT TOP (1) @PipelineID = StoryIntelligenceBookPipelineID
FROM dbo.StoryIntelligenceBookPipelines
WHERE ProjectID = @ProjectID AND BookID = @ResolvedBookID
ORDER BY StoryIntelligenceBookPipelineID DESC;
SET @Version = CONCAT(N'ManualCharacter:', @CharacterID, N':Book:', @ResolvedBookID);
BEGIN TRANSACTION;
SELECT TOP (1) @ExistingID = CharacterEnrichmentRunID
FROM dbo.CharacterEnrichmentRuns WITH (UPDLOCK, HOLDLOCK)
WHERE BookID = @ResolvedBookID
AND CharacterID = @CharacterID
AND IsCurrent = 1
AND Status IN (N'Queued', N'Running')
ORDER BY CharacterEnrichmentRunID DESC;
IF @ExistingID IS NULL
BEGIN
UPDATE dbo.CharacterEnrichmentRuns
SET IsCurrent = 0,
Status = N'Superseded',
UpdatedUtc = SYSUTCDATETIME()
WHERE BookID = @ResolvedBookID
AND CharacterID = @CharacterID
AND IsCurrent = 1;
INSERT dbo.CharacterEnrichmentRuns
(ProjectID, BookID, UserID, CharacterID, StoryIntelligenceBookPipelineID, CharacterResolutionVersion, Status, TotalCharacters, CurrentStage, CurrentMessage)
VALUES
(@ProjectID, @ResolvedBookID, @UserID, @CharacterID, @PipelineID, @Version, N'Queued', 1, N'Queued', N'Character reanalysis is queued.');
SET @ExistingID = CAST(SCOPE_IDENTITY() AS int);
SET @WasCoalesced = 0;
END;
COMMIT TRANSACTION;
SELECT runRows.*, WasCoalesced = @WasCoalesced
FROM dbo.CharacterEnrichmentRuns runRows
WHERE runRows.CharacterEnrichmentRunID = @ExistingID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Retry
@BookID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @PreviousID int, @ProjectID int, @PipelineID int, @Version nvarchar(120);
SELECT TOP (1)
@PreviousID = CharacterEnrichmentRunID,
@ProjectID = ProjectID,
@PipelineID = StoryIntelligenceBookPipelineID,
@Version = CharacterResolutionVersion
FROM dbo.CharacterEnrichmentRuns
WHERE BookID = @BookID AND UserID = @UserID AND CharacterID IS NULL AND IsCurrent = 1 AND Status = N'Failed'
ORDER BY CharacterEnrichmentRunID DESC;
IF @PreviousID IS NULL
BEGIN
SELECT TOP (0) * FROM dbo.CharacterEnrichmentRuns;
RETURN;
END;
UPDATE dbo.CharacterEnrichmentRuns
SET IsCurrent = 0,
Status = N'Superseded',
UpdatedUtc = SYSUTCDATETIME()
WHERE CharacterEnrichmentRunID = @PreviousID;
EXEC dbo.CharacterEnrichmentRun_Queue
@ProjectID = @ProjectID,
@BookID = @BookID,
@UserID = @UserID,
@StoryIntelligenceBookPipelineID = @PipelineID,
@CharacterResolutionVersion = @Version;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_GetCurrentByBook
@BookID int
AS
BEGIN
SET NOCOUNT ON;
SELECT TOP (1) *
FROM dbo.CharacterEnrichmentRuns
WHERE BookID = @BookID AND CharacterID IS NULL AND IsCurrent = 1
ORDER BY CharacterEnrichmentRunID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_GetCurrentByCharacter
@CharacterID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT TOP (1) runRows.*
FROM dbo.CharacterEnrichmentRuns runRows
INNER JOIN dbo.Characters c ON c.CharacterID = @CharacterID AND c.ProjectID = runRows.ProjectID
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = c.ProjectID
WHERE runRows.CharacterID = @CharacterID
AND runRows.UserID = @UserID
AND runRows.IsCurrent = 1
AND pua.UserID = @UserID
AND pua.AccessRole = N'Owner'
AND pua.IsActive = 1
ORDER BY runRows.CharacterEnrichmentRunID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichment_ContextGet
@ProjectID int,
@BookID int,
@CharacterID int = NULL
AS
BEGIN
SET NOCOUNT ON;
SELECT
BookTitle = CASE WHEN NULLIF(LTRIM(RTRIM(b.Subtitle)), N'') IS NULL THEN b.BookTitle ELSE CONCAT(b.BookTitle, N': ', b.Subtitle) END,
b.StoryEra,
SeriesStartDate = CAST(NULL AS date)
FROM dbo.Books b
WHERE b.BookID = @BookID AND b.ProjectID = @ProjectID AND b.IsArchived = 0;
SELECT DISTINCT
c.CharacterID,
c.CharacterName,
c.BirthDate,
c.AgeAtSeriesStart,
c.Height,
c.EyeColour,
c.DefaultDescription
FROM dbo.Characters c
INNER JOIN dbo.SceneCharacters sc ON sc.CharacterID = c.CharacterID
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.IsArchived = 0
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
WHERE c.ProjectID = @ProjectID
AND c.IsArchived = 0
AND ch.BookID = @BookID
AND (@CharacterID IS NULL OR c.CharacterID = @CharacterID)
ORDER BY c.CharacterName;
SELECT alias.CharacterID, alias.Alias
FROM dbo.CharacterAliases alias
INNER JOIN dbo.Characters c ON c.CharacterID = alias.CharacterID
WHERE c.ProjectID = @ProjectID
AND c.IsArchived = 0
AND (@CharacterID IS NULL OR c.CharacterID = @CharacterID)
ORDER BY alias.CharacterID, alias.SortOrder, alias.Alias;
SELECT
sc.CharacterID,
s.SceneID,
ch.ChapterID,
ch.ChapterNumber,
ch.ChapterTitle,
s.SceneNumber,
s.SceneTitle,
sms.SourceText
FROM dbo.SceneCharacters sc
INNER JOIN dbo.Characters c ON c.CharacterID = sc.CharacterID AND c.ProjectID = @ProjectID AND c.IsArchived = 0
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.BookID = @BookID AND ch.IsArchived = 0
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
WHERE @CharacterID IS NULL OR sc.CharacterID = @CharacterID
ORDER BY sc.CharacterID, ch.SortOrder, ch.ChapterNumber, s.SortOrder, s.SceneNumber, s.SceneID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentCharacterStatus_Get
@CharacterID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
DECLARE @ProjectID int;
SELECT @ProjectID = c.ProjectID
FROM dbo.Characters c
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = c.ProjectID
WHERE c.CharacterID = @CharacterID
AND c.IsArchived = 0
AND pua.UserID = @UserID
AND pua.AccessRole = N'Owner'
AND pua.IsActive = 1;
IF @ProjectID IS NULL
BEGIN
SELECT TOP (0)
CharacterID = CAST(NULL AS int),
BookID = CAST(NULL AS int),
CharacterEnrichmentRunID = CAST(NULL AS int),
Status = CAST(NULL AS nvarchar(30)),
CurrentStage = CAST(NULL AS nvarchar(120)),
CurrentMessage = CAST(NULL AS nvarchar(300)),
LastAnalysedUtc = CAST(NULL AS datetime2),
ErrorMessage = CAST(NULL AS nvarchar(max)),
Summary = CAST(NULL AS nvarchar(max)),
DateOfBirth = CAST(NULL AS nvarchar(500)),
AgeAtStartOfSeries = CAST(NULL AS nvarchar(500)),
Height = CAST(NULL AS nvarchar(500)),
EyeColour = CAST(NULL AS nvarchar(500)),
UpdatedUtc = CAST(NULL AS datetime2);
RETURN;
END;
DECLARE @Attempt TABLE
(
CharacterEnrichmentRunID int NULL,
BookID int NULL,
Status nvarchar(30) NULL,
CurrentStage nvarchar(120) NULL,
CurrentMessage nvarchar(300) NULL,
ErrorMessage nvarchar(max) NULL,
UpdatedUtc datetime2 NULL
);
INSERT @Attempt
SELECT TOP (1)
runRows.CharacterEnrichmentRunID,
runRows.BookID,
runRows.Status,
runRows.CurrentStage,
runRows.CurrentMessage,
runRows.ErrorMessage,
runRows.UpdatedUtc
FROM dbo.CharacterEnrichmentRuns runRows
WHERE runRows.UserID = @UserID
AND runRows.ProjectID = @ProjectID
AND runRows.CharacterID = @CharacterID
AND runRows.IsCurrent = 1
ORDER BY CASE runRows.Status WHEN N'Queued' THEN 0 WHEN N'Running' THEN 0 WHEN N'Failed' THEN 1 WHEN N'Completed' THEN 2 ELSE 9 END,
runRows.UpdatedUtc DESC,
runRows.CharacterEnrichmentRunID DESC;
DECLARE @SuccessRunID int, @SuccessBookID int, @LastAnalysedUtc datetime2;
SELECT TOP (1)
@SuccessRunID = runRows.CharacterEnrichmentRunID,
@SuccessBookID = runRows.BookID,
@LastAnalysedUtc = runRows.CompletedUtc
FROM dbo.CharacterEnrichmentRuns runRows
WHERE runRows.UserID = @UserID
AND runRows.ProjectID = @ProjectID
AND runRows.Status = N'Completed'
AND runRows.CompletedUtc IS NOT NULL
AND (
runRows.CharacterID = @CharacterID
OR EXISTS
(
SELECT 1
FROM dbo.CharacterEnrichmentEvidence evidence
WHERE evidence.CharacterEnrichmentRunID = runRows.CharacterEnrichmentRunID
AND evidence.CharacterID = @CharacterID
)
)
ORDER BY runRows.CompletedUtc DESC, runRows.CharacterEnrichmentRunID DESC;
SELECT
CharacterID = @CharacterID,
BookID = COALESCE((SELECT TOP (1) BookID FROM @Attempt), @SuccessBookID),
CharacterEnrichmentRunID = COALESCE((SELECT TOP (1) CharacterEnrichmentRunID FROM @Attempt), @SuccessRunID),
Status = COALESCE((SELECT TOP (1) Status FROM @Attempt), CASE WHEN @SuccessRunID IS NULL THEN N'Not analysed' ELSE N'Completed' END),
CurrentStage = (SELECT TOP (1) CurrentStage FROM @Attempt),
CurrentMessage = (SELECT TOP (1) CurrentMessage FROM @Attempt),
LastAnalysedUtc = @LastAnalysedUtc,
ErrorMessage = (SELECT TOP (1) ErrorMessage FROM @Attempt),
Summary = MAX(CASE WHEN evidence.FieldName = N'Summary' THEN evidence.Value END),
DateOfBirth = MAX(CASE WHEN evidence.FieldName = N'DateOfBirth' THEN evidence.Value END),
AgeAtStartOfSeries = MAX(CASE WHEN evidence.FieldName = N'AgeAtStartOfSeries' THEN evidence.Value END),
Height = MAX(CASE WHEN evidence.FieldName = N'Height' THEN evidence.Value END),
EyeColour = MAX(CASE WHEN evidence.FieldName = N'EyeColour' THEN evidence.Value END),
UpdatedUtc = COALESCE((SELECT TOP (1) UpdatedUtc FROM @Attempt), @LastAnalysedUtc, SYSUTCDATETIME())
FROM (SELECT 1 AS Anchor) anchor
LEFT JOIN dbo.CharacterEnrichmentEvidence evidence
ON evidence.CharacterEnrichmentRunID = @SuccessRunID
AND evidence.CharacterID = @CharacterID;
END;
GO

View File

@ -256,6 +256,7 @@ public sealed class BookDetailViewModel
public ManuscriptDocumentModel? ManuscriptDocument { get; set; }
public IReadOnlyList<Chapter> Chapters { get; set; } = [];
public StoryIntelligenceBookPipelineState? StoryIntelligencePipeline { get; set; }
public IReadOnlyList<StoryIntelligenceSavedRun> StoryIntelligenceRuns { get; set; } = [];
public StoryIntelligenceBookActionViewModel StoryIntelligenceAction { get; set; } = new();
}
@ -1884,6 +1885,7 @@ public sealed class CharacterDetailViewModel
public IReadOnlyList<SelectListItem> RelationshipTypeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> RelationshipStateOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
public CharacterEnrichmentCharacterStatus CharacterAnalysis { get; set; } = new();
}
public sealed class CharacterMergeViewModel

View File

@ -176,6 +176,7 @@ public sealed class StoryIntelligenceProgressViewModel
public StoryIntelligenceKnowledgeReviewViewModel KnowledgeReview { get; set; } = new();
public StoryIntelligenceNarrativeReviewViewModel NarrativeReview { get; set; } = new();
public WholeBookPlotSynthesisStatusViewModel PlotSynthesis { get; set; } = new();
public CharacterEnrichmentStatusViewModel CharacterEnrichment { get; set; } = new();
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
public int ChapterCount => Chapters.Count;
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
@ -231,6 +232,25 @@ public sealed class WholeBookPlotSynthesisStatusViewModel
public bool IsFailed => string.Equals(Status, WholeBookPlotSynthesisStatuses.Failed, StringComparison.OrdinalIgnoreCase);
}
public sealed class CharacterEnrichmentStatusViewModel
{
public int? CharacterEnrichmentRunID { get; init; }
public string Status { get; init; } = string.Empty;
public int TotalCharacters { get; init; }
public int ProcessedCharacters { get; init; }
public string CurrentStage { get; init; } = string.Empty;
public string CurrentMessage { get; init; } = string.Empty;
public string? ErrorMessage { get; init; }
public bool IsQueued => string.Equals(Status, CharacterEnrichmentStatuses.Queued, StringComparison.OrdinalIgnoreCase);
public bool IsRunning => string.Equals(Status, CharacterEnrichmentStatuses.Running, StringComparison.OrdinalIgnoreCase);
public bool IsCompleted => string.Equals(Status, CharacterEnrichmentStatuses.Completed, StringComparison.OrdinalIgnoreCase);
public bool IsFailed => string.Equals(Status, CharacterEnrichmentStatuses.Failed, StringComparison.OrdinalIgnoreCase);
public bool IsActive => IsQueued || IsRunning;
public int? ProgressPercent => TotalCharacters <= 0
? null
: Math.Clamp(Convert.ToInt32(Math.Round(ProcessedCharacters * 100m / TotalCharacters)), 0, 100);
}
public sealed class StoryIntelligencePipelineDashboardViewModel
{
public int ChaptersAnalysed { get; init; }

View File

@ -19,6 +19,16 @@
.OrderBy(name => !string.Equals(name, Model.Character.CharacterName, StringComparison.OrdinalIgnoreCase))
.ThenBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
var analysis = Model.CharacterAnalysis;
var analysisStatus = string.IsNullOrWhiteSpace(analysis.Status) ? "Not analysed" : analysis.Status;
var analysisActive = analysis.Status is CharacterEnrichmentStatuses.Queued or CharacterEnrichmentStatuses.Running;
var analysisButtonText = analysis.Status switch
{
CharacterEnrichmentStatuses.Queued => "Queued...",
CharacterEnrichmentStatuses.Running => "Analysing...",
_ => "Reanalyse character"
};
var lastAnalysed = analysis.LastAnalysedUtc?.ToLocalTime().ToString("d MMMM yyyy, HH:mm");
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
@ -47,6 +57,10 @@
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item" asp-action="Edit" asp-route-id="@Model.Character.CharacterID">Edit character</a>
<a class="dropdown-item" asp-action="Merge" asp-route-id="@Model.Character.CharacterID">Merge with another character</a>
<form asp-action="Reanalyse" method="post" data-character-reanalyse-form>
<input type="hidden" name="id" value="@Model.Character.CharacterID" />
<button class="dropdown-item" type="submit" data-character-reanalyse-button @(analysisActive ? "disabled" : string.Empty)>@analysisButtonText</button>
</form>
<a class="dropdown-item" asp-controller="Exports" asp-action="Character" asp-route-id="@Model.Character.CharacterID">Export character</a>
<a class="dropdown-item" asp-controller="Exports" asp-action="Character" asp-route-id="@Model.Character.CharacterID" asp-route-format="markdown">Markdown</a>
<div class="dropdown-divider"></div>
@ -87,7 +101,7 @@
}
<section id="character-overview" class="asset-detail-grid">
<article class="plain-card">
<article class="plain-card" data-character-analysis-panel data-character-id="@Model.Character.CharacterID" data-book-id="@analysis.BookID">
<h2>Profile</h2>
<p><strong>Preferred name:</strong> @Model.Character.CharacterName</p>
<p><strong>Sex:</strong> @Model.Character.Sex</p>
@ -95,6 +109,25 @@
<p><strong>Height:</strong> @Model.Character.Height</p>
<p><strong>Eye colour:</strong> @Model.Character.EyeColour</p>
<p><strong>Story importance:</strong> @storyImportance</p>
<hr />
<p><strong>Character analysis:</strong> <span data-character-analysis-status>@analysisStatus</span></p>
<p><strong>Last analysed:</strong> <span data-character-analysis-last>@(lastAnalysed ?? "Not analysed")</span></p>
@if (!string.IsNullOrWhiteSpace(analysis.CurrentMessage))
{
<p class="muted" data-character-analysis-message>@analysis.CurrentMessage</p>
}
else
{
<p class="muted" data-character-analysis-message></p>
}
</article>
<article class="plain-card" data-character-generated-analysis>
<h2>Story Intelligence Analysis</h2>
<p class="@(string.IsNullOrWhiteSpace(analysis.Summary) ? "muted" : string.Empty)" data-character-analysis-summary>@(analysis.Summary ?? "No generated character analysis yet.")</p>
<p><strong>Date of birth:</strong> <span data-character-analysis-dob>@(analysis.DateOfBirth ?? "Unknown")</span></p>
<p><strong>Age at start of series:</strong> <span data-character-analysis-age>@(analysis.AgeAtStartOfSeries ?? "Unknown")</span></p>
<p><strong>Height:</strong> <span data-character-analysis-height>@(analysis.Height ?? "Unknown")</span></p>
<p><strong>Eye colour:</strong> <span data-character-analysis-eye>@(analysis.EyeColour ?? "Unknown")</span></p>
</article>
<article class="plain-card">
<h2>Known names</h2>
@ -528,6 +561,7 @@
@section Scripts {
<script src="~/js/character-enrichment-detail.js" asp-append-version="true"></script>
<script>
(() => {
document.querySelectorAll("[data-relationship-editor]").forEach((editor) => {

View File

@ -1,9 +1,17 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Story Intelligence Review Centre";
var runIds = string.Join(",", Model.Chapters.Select(chapter => chapter.RunID));
var progressPercent = ProgressPercent(Model);
}
<section class="story-review-centre-shell" aria-labelledby="story-review-centre-title">
<section class="story-review-centre-shell"
aria-labelledby="story-review-centre-title"
data-story-intelligence-onboarding
data-story-intelligence-batch-id="@Model.BatchID"
data-story-intelligence-run-ids="@runIds"
data-story-max-progress-percent="@progressPercent"
data-story-intelligence-has-active-runs="@Model.HasActiveRuns.ToString().ToLowerInvariant()">
<div class="story-review-centre-workspace">
<div class="story-review-centre-heading">
<p class="eyebrow">Story Intelligence</p>
@ -23,18 +31,8 @@
}
<section class="story-review-centre-grid" aria-label="Story Intelligence review categories">
@ReviewModule(
"Characters",
Model.CharacterNeedsAttentionCount,
Model.PipelineDashboard.CharacterStageComplete,
false,
"Resolve character identities before reviewing dependent Story Intelligence.",
"Review Characters",
"StoryIntelligenceCharacters",
"View Characters",
"Characters",
new { projectId = Model.ProjectID },
true)
@* "Characters" "Review Characters" "StoryIntelligenceCharacters" "View Characters" new { projectId = Model.ProjectID } *@
@ReviewCharacters()
@ReviewModule(
"Assets",
@ -104,6 +102,32 @@
}
</section>
<div class="story-run-state" aria-hidden="true" hidden>
@foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber))
{
<div data-story-run-id="@chapter.RunID"
data-story-chapter-title="@chapter.ChapterTitle"
data-story-raw-status="@chapter.Status"
data-story-chapter-number="@chapter.ChapterNumber"
data-story-current-stage="@(chapter.CurrentStage ?? string.Empty)"
data-story-current-scene-id="@(chapter.CurrentSceneID?.ToString() ?? string.Empty)"
data-story-current-scene-number="@(chapter.CurrentSceneNumber?.ToString() ?? string.Empty)"
data-story-current-scene-title="@(chapter.CurrentSceneDisplayTitle ?? string.Empty)"
data-story-updated-utc="@(chapter.UpdatedUtc?.ToUniversalTime().ToString("O") ?? string.Empty)"
data-story-source-word-count="@(chapter.SourceWordCount ?? 0)"
data-story-duration-ms="@(chapter.TotalDurationMs ?? 0)">
<span data-story-run-status>@chapter.Status</span>
<span data-story-run-completed>@((chapter.CompletedScenes ?? 0).ToString("N0"))</span>
<span data-story-run-total>@((chapter.TotalDetectedScenes ?? 0).ToString("N0"))</span>
<span data-story-run-failed>@((chapter.FailedScenes ?? 0).ToString("N0"))</span>
<span data-story-run-message>@(chapter.CurrentMessage ?? string.Empty)</span>
<span data-story-run-error>@(chapter.ErrorMessage ?? string.Empty)</span>
<span data-story-run-current-scene-title>@(chapter.CurrentSceneDisplayTitle ?? string.Empty)</span>
<span data-story-run-latest-summary>@(chapter.LatestSceneSummary ?? string.Empty)</span>
</div>
}
</div>
<div class="onboarding-actions story-review-centre-actions">
<a class="btn btn-outline-secondary" asp-controller="Books" asp-action="Details" asp-route-id="@Model.BookID">Return to Book</a>
<a class="btn btn-outline-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
@ -172,17 +196,78 @@
}
return new Microsoft.AspNetCore.Html.HtmlString($$"""
<article class="story-review-centre-tile{{(blocked ? " story-review-centre-tile--locked" : string.Empty)}}" data-plot-synthesis-tile>
<article class="story-review-centre-tile{{(blocked ? " story-review-centre-tile--locked" : string.Empty)}}" data-plot-synthesis-tile data-book-id="{{Model.BookID}}">
<div>
<h2>Plot Lines & Threads</h2>
<p>{{System.Net.WebUtility.HtmlEncode(description)}}</p>
</div>
<strong class="{{statusClass}}">{{System.Net.WebUtility.HtmlEncode(status)}}</strong>
{{ProgressHtml(Model.PlotSynthesis.IsRunning || Model.PlotSynthesis.IsQueued, null, Model.PlotSynthesis.IsQueued ? "Preparing manuscript..." : "Analysing plot structure...", null)}}
{{actionHtml}}
</article>
""");
}
private Microsoft.AspNetCore.Html.IHtmlContent ReviewCharacters()
{
var reviewComplete = Model.CharacterNeedsAttentionCount.GetValueOrDefault() == 0 && Model.PipelineDashboard.CharacterStageComplete;
var enrichment = Model.CharacterEnrichment;
var status = "Ready for review";
var statusClass = "story-review-centre-tile__status story-review-centre-tile__status--warning";
var description = "Resolve character identities before reviewing dependent Story Intelligence.";
var actionHtml = $"<a class=\"btn btn-outline-primary btn-sm\" href=\"{System.Net.WebUtility.HtmlEncode(Url.Action("StoryIntelligenceCharacters", "Onboarding", new { batchId = Model.BatchID }) ?? "#")}\">Review Characters</a>";
var progressHtml = string.Empty;
if (reviewComplete && enrichment.IsActive)
{
status = "Analysing";
statusClass = "story-review-centre-tile__status story-review-centre-tile__status--ready";
description = "PlotDirector is creating character summaries and extracting supported details.";
actionHtml = "<span class=\"btn btn-outline-primary btn-sm disabled\" aria-disabled=\"true\">Analysing</span>";
progressHtml = ProgressHtml(true, enrichment.ProgressPercent, string.IsNullOrWhiteSpace(enrichment.CurrentMessage) ? "Creating summaries and extracting character details..." : enrichment.CurrentMessage, enrichment.TotalCharacters > 0 ? $"{enrichment.ProcessedCharacters:N0} / {enrichment.TotalCharacters:N0}" : null);
}
else if (reviewComplete && enrichment.IsFailed)
{
status = "Failed";
statusClass = "story-review-centre-tile__status story-review-centre-tile__status--warning";
description = string.IsNullOrWhiteSpace(enrichment.ErrorMessage) ? "Character enrichment failed and can be retried." : enrichment.ErrorMessage;
actionHtml = $$"""
<form method="post" action="{{Url.Action("RetryStoryIntelligenceCharacterEnrichment", "Onboarding")}}">
<input type="hidden" name="batchId" value="{{Model.BatchID}}" />
<button class="btn btn-outline-primary btn-sm" type="submit">Retry</button>
</form>
""";
}
else if (reviewComplete && enrichment.IsCompleted)
{
status = "Complete";
statusClass = "story-review-centre-tile__status story-review-centre-tile__status--ready";
description = "Character identities, summaries, and supported details are ready.";
var href = Url.Action("Index", "Characters", new { projectId = Model.ProjectID }) ?? "#";
actionHtml = $"<a class=\"btn btn-outline-primary btn-sm\" href=\"{System.Net.WebUtility.HtmlEncode(href)}\">View Characters</a>";
}
else if (reviewComplete)
{
status = "Analysing";
statusClass = "story-review-centre-tile__status story-review-centre-tile__status--ready";
description = "Character enrichment is queued.";
actionHtml = "<span class=\"btn btn-outline-primary btn-sm disabled\" aria-disabled=\"true\">Preparing</span>";
progressHtml = ProgressHtml(true, null, "Preparing character analysis...", null);
}
return new Microsoft.AspNetCore.Html.HtmlString($$"""
<article class="story-review-centre-tile story-review-centre-tile--primary" data-character-enrichment-tile data-book-id="{{Model.BookID}}" data-project-id="{{Model.ProjectID}}">
<div>
<h2>Characters</h2>
<p data-character-enrichment-description>{{System.Net.WebUtility.HtmlEncode(description)}}</p>
</div>
<strong class="{{statusClass}}" data-character-enrichment-status>{{System.Net.WebUtility.HtmlEncode(status)}}</strong>
<div data-character-enrichment-progress>{{progressHtml}}</div>
<div data-character-enrichment-action>{{actionHtml}}</div>
</article>
""");
}
private string StatusText(string title, int? count, bool complete, bool blocked)
{
if (blocked)
@ -282,4 +367,60 @@
</article>
""");
}
private static string ProgressHtml(bool visible, int? percent, string message, string? countText)
{
if (!visible)
{
return string.Empty;
}
var determinate = percent.HasValue;
var valueNow = percent.GetValueOrDefault();
return $$"""
<div class="story-review-centre-progress" data-progress-kind="{{(determinate ? "determinate" : "indeterminate")}}">
<div class="story-review-centre-progress__bar" role="progressbar" aria-valuemin="0" aria-valuemax="100"{{(determinate ? $" aria-valuenow=\"{valueNow}\"" : string.Empty)}}>
<span style="width: {{(determinate ? valueNow : 100)}}%"></span>
</div>
<p>{{System.Net.WebUtility.HtmlEncode(message)}}{{(string.IsNullOrWhiteSpace(countText) ? string.Empty : $" {System.Net.WebUtility.HtmlEncode(countText)}")}}</p>
</div>
""";
}
private static int ProgressPercent(StoryIntelligenceProgressViewModel model)
{
if (model.ChapterCount == 0)
{
return 0;
}
var chapterProgress = model.Chapters.Sum(ChapterProgress);
return Math.Clamp((int)Math.Floor(chapterProgress * 100m / model.ChapterCount), 0, 100);
}
private static decimal ChapterProgress(StoryIntelligenceOnboardingChapterViewModel chapter)
{
if (chapter.HasCompletedCommit || chapter.IsRunComplete || chapter.IsFailed)
{
return 1m;
}
if (chapter.Status == StoryIntelligenceRunStatuses.Pending)
{
return 0m;
}
if ((chapter.TotalDetectedScenes ?? 0) > 0)
{
var sceneProgress = Math.Clamp((chapter.CompletedScenes ?? 0) / Math.Max(1m, chapter.TotalDetectedScenes ?? 1), 0m, 1m);
return Math.Clamp(0.25m + (sceneProgress * 0.7m), 0m, 0.95m);
}
return 0.15m;
}
}
@section Scripts {
<script src="~/js/story-intelligence-progress.js" asp-append-version="true"></script>
<script src="~/js/story-review-centre-progress.js" asp-append-version="true"></script>
}

View File

@ -1710,6 +1710,45 @@ summary.story-review-chapter-heading {
background: color-mix(in srgb, var(--plotline-panel, var(--bs-body-bg)) 94%, var(--plotline-line, rgba(31, 42, 68, .12)));
}
.story-review-centre-progress {
width: 100%;
}
.story-review-centre-progress__bar {
height: .5rem;
overflow: hidden;
background: rgba(31, 42, 68, .12);
border-radius: 999px;
}
.story-review-centre-progress__bar span {
display: block;
height: 100%;
min-width: 12%;
background: var(--plotline-accent, #3b82f6);
transition: width .25s ease;
}
.story-review-centre-progress[data-progress-kind="indeterminate"] .story-review-centre-progress__bar span {
width: 45% !important;
animation: story-review-progress-sweep 1.2s ease-in-out infinite;
}
.story-review-centre-progress p {
margin: .35rem 0 0;
font-size: .9rem;
color: var(--bs-secondary-color);
}
@keyframes story-review-progress-sweep {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(240%);
}
}
.story-review-centre-actions {
justify-content: flex-start;
}

View File

@ -0,0 +1,137 @@
(() => {
const panel = document.querySelector("[data-character-analysis-panel]");
if (!panel || typeof signalR === "undefined") {
return;
}
const characterId = Number(panel.dataset.characterId || 0);
const initialBookId = Number(panel.dataset.bookId || 0);
const form = document.querySelector("[data-character-reanalyse-form]");
const button = document.querySelector("[data-character-reanalyse-button]");
const status = document.querySelector("[data-character-analysis-status]");
const message = document.querySelector("[data-character-analysis-message]");
const last = document.querySelector("[data-character-analysis-last]");
const summary = document.querySelector("[data-character-analysis-summary]");
if (!characterId) {
return;
}
const setBusy = (label) => {
if (button) {
button.disabled = true;
button.textContent = label;
}
};
const setReady = () => {
if (button) {
button.disabled = false;
button.textContent = "Reanalyse character";
}
};
const applySnapshot = (snapshot) => {
if (!snapshot || Number(snapshot.characterID || snapshot.characterId || 0) !== characterId) {
return;
}
const nextStatus = snapshot.status || snapshot.Status || "";
const nextMessage = snapshot.currentMessage || snapshot.CurrentMessage || "";
if (status) {
status.textContent = nextStatus || "Not analysed";
}
if (message) {
message.textContent = nextMessage;
}
if (nextStatus === "Queued") {
setBusy("Queued...");
return;
}
if (nextStatus === "Running") {
setBusy("Analysing...");
return;
}
if (nextStatus === "Completed") {
setReady();
if (last) {
last.textContent = "Just now";
}
window.setTimeout(() => window.location.reload(), 700);
return;
}
if (nextStatus === "Failed") {
setReady();
if (summary && !summary.textContent.trim()) {
summary.textContent = "No generated character analysis yet.";
summary.classList.add("muted");
}
}
};
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/story-intelligence")
.withAutomaticReconnect()
.build();
const refresh = () => {
connection.invoke("WatchCharacterEnrichmentForCharacter", characterId)
.then(applySnapshot)
.catch(() => {});
};
connection.on("CharacterEnrichmentProgressChanged", applySnapshot);
connection.onreconnected(refresh);
const start = () => {
connection.start()
.then(refresh)
.catch(() => window.setTimeout(start, 2000));
};
form?.addEventListener("submit", async (event) => {
if (!button || button.disabled) {
event.preventDefault();
return;
}
if (!window.fetch) {
return;
}
event.preventDefault();
setBusy("Queued...");
if (status) {
status.textContent = "Queued";
}
if (message) {
message.textContent = "Character reanalysis is queued.";
}
try {
const response = await fetch(form.action, {
method: "POST",
headers: { "X-Requested-With": "XMLHttpRequest" },
body: new FormData(form)
});
const payload = await response.json().catch(() => null);
if (!response.ok || payload?.success === false) {
throw new Error(payload?.message || "Character reanalysis could not be queued.");
}
applySnapshot(payload);
} catch (error) {
setReady();
if (status) {
status.textContent = "Failed";
}
if (message) {
message.textContent = error instanceof Error ? error.message : "Character reanalysis could not be queued.";
}
}
});
start();
})();

View File

@ -23,6 +23,21 @@
window.console.debug(`[StoryIntelligenceProgress] ${message}`, data || "");
}
};
const diagnosticInfo = (message, data) => {
if (diagnosticsEnabled && window.console?.info) {
window.console.info(`[StoryIntelligenceProgress] ${message}`, data || "");
}
};
const diagnosticWarn = (message, data) => {
if (diagnosticsEnabled && window.console?.warn) {
window.console.warn(`[StoryIntelligenceProgress] ${message}`, data || "");
}
};
const diagnosticError = (message, error, data) => {
if (diagnosticsEnabled && window.console?.error) {
window.console.error(`[StoryIntelligenceProgress] ${message}`, data || "", error || "");
}
};
const numberText = (value) => {
const number = Number.parseInt(value ?? "0", 10);
@ -416,6 +431,12 @@
onboardingRoots.forEach((root) => {
const chapter = root.querySelector(`[data-story-run-id="${runId}"]`);
if (!chapter) {
diagnosticWarn("No chapter element matched incoming run progress.", {
incomingRunId: String(runId),
domRunIds: [...document.querySelectorAll("[data-story-run-id]")]
.map((node) => node.dataset.storyRunId)
.filter(Boolean)
});
return;
}
matched = true;
@ -448,8 +469,8 @@
}
if (currentSceneNumber !== null) {
chapter.dataset.storyCurrentSceneNumber = String(currentSceneNumber);
} else if (completed !== null) {
chapter.dataset.storyCurrentSceneNumber = String(completed);
} else {
chapter.dataset.storyCurrentSceneNumber = "0";
}
if (currentSceneDisplayTitle) {
chapter.dataset.storyCurrentSceneTitle = currentSceneDisplayTitle;
@ -512,7 +533,7 @@
const totalNumber = Number.parseInt(total || "0", 10) || 0;
const completedNumber = Number.parseInt(completed || "0", 10) || 0;
root.querySelectorAll("[data-story-chapter-progress-bar]").forEach((node) => {
chapter.querySelectorAll("[data-story-chapter-progress-bar]").forEach((node) => {
const percent = totalNumber > 0 ? Math.floor((Math.min(completedNumber, totalNumber) * 100) / totalNumber) : 0;
node.style.width = `${Math.max(0, Math.min(100, percent))}%`;
});
@ -560,11 +581,31 @@
.build();
connection.on("StoryIntelligenceProgressChanged", applyLegacyProgress);
connection.on("StoryIntelligenceRunProgressChanged", applyRunProgress);
connection.on("StoryIntelligenceRunProgressChanged", (state) => {
diagnosticInfo("StoryIntelligenceRunProgressChanged received.", {
runId: field(state, "runID", "RunID") || field(state, "runId", "RunId"),
chapterId: field(state, "chapterID", "ChapterID") || field(state, "chapterId", "ChapterId"),
chapterNumber: field(state, "chapterNumber", "ChapterNumber"),
status: field(state, "status", "Status"),
currentStage: field(state, "currentStage", "CurrentStage"),
currentSceneNumber: field(state, "currentSceneNumber", "CurrentSceneNumber"),
completedScenes: field(state, "completedScenes", "CompletedScenes"),
totalDetectedScenes: field(state, "totalDetectedScenes", "TotalDetectedScenes"),
currentMessage: field(state, "currentMessage", "CurrentMessage")
});
applyRunProgress(state);
});
connection.onreconnecting((error) => {
diagnosticError("SignalR reconnecting after connection loss.", error);
});
connection.onreconnected(() => {
log("SignalR reconnected");
legacyJobIds.forEach((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId).then(applyLegacyProgress).catch(() => {}));
runIds.forEach((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10)).then(applyRunProgress).catch(() => {}));
diagnosticInfo("SignalR reconnected.", { runIds, legacyJobIds });
legacyJobIds.forEach((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId)
.then(applyLegacyProgress)
.catch((error) => diagnosticError("SignalR watch legacy job failed after reconnect.", error, { jobId })));
runIds.forEach((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10))
.then(applyRunProgress)
.catch((error) => diagnosticError("SignalR watch run failed after reconnect.", error, { runId })));
});
connection.onclose(() => {
log("SignalR closed; retrying start");
@ -576,7 +617,7 @@
window.clearTimeout(startRetryTimer);
connection.start()
.then(() => {
log("SignalR connected", { runIds, legacyJobIds });
diagnosticInfo("SignalR connected.", { runIds, legacyJobIds });
return Promise.all([
...legacyJobIds.map((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId)),
...runIds.map((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10)))
@ -591,8 +632,8 @@
}
});
})
.catch(() => {
log("SignalR start or watch failed; retrying");
.catch((error) => {
diagnosticError("SignalR start or watch failed; retrying.", error, { runIds, legacyJobIds });
startRetryTimer = window.setTimeout(startWithRetry, 2000);
});
};

View File

@ -0,0 +1,123 @@
(() => {
const characterTile = document.querySelector("[data-character-enrichment-tile]");
const plotTile = document.querySelector("[data-plot-synthesis-tile]");
if (!characterTile || typeof signalR === "undefined") {
return;
}
const bookId = Number(characterTile.dataset.bookId || plotTile?.dataset.bookId || 0);
const projectId = Number(characterTile.dataset.projectId || 0);
if (!bookId) {
return;
}
const encode = (value) => {
const div = document.createElement("div");
div.textContent = value || "";
return div.innerHTML;
};
const progressHtml = (total, processed, message) => {
const hasTotal = Number(total) > 0;
const percent = hasTotal ? Math.max(0, Math.min(100, Math.round((Number(processed) || 0) * 100 / Number(total)))) : null;
const count = hasTotal ? ` ${Number(processed || 0).toLocaleString()} / ${Number(total).toLocaleString()}` : "";
return `
<div class="story-review-centre-progress" data-progress-kind="${percent === null ? "indeterminate" : "determinate"}">
<div class="story-review-centre-progress__bar" role="progressbar" aria-valuemin="0" aria-valuemax="100"${percent === null ? "" : ` aria-valuenow="${percent}"`}>
<span style="width: ${percent === null ? 100 : percent}%"></span>
</div>
<p>${encode(message || "Creating summaries and extracting character details...")}${encode(count)}</p>
</div>`;
};
const setAction = (html) => {
const action = characterTile.querySelector("[data-character-enrichment-action]");
if (action) {
action.innerHTML = html;
}
};
const applyCharacter = (snapshot) => {
if (!snapshot || Number(snapshot.bookID || snapshot.bookId) !== bookId) {
return;
}
const statusValue = snapshot.status || snapshot.Status || "";
const status = characterTile.querySelector("[data-character-enrichment-status]");
const description = characterTile.querySelector("[data-character-enrichment-description]");
const progress = characterTile.querySelector("[data-character-enrichment-progress]");
const total = snapshot.totalCharacters ?? snapshot.TotalCharacters ?? 0;
const processed = snapshot.processedCharacters ?? snapshot.ProcessedCharacters ?? 0;
const message = snapshot.currentMessage || snapshot.CurrentMessage;
if (statusValue === "Queued" || statusValue === "Running") {
if (status) {
status.textContent = "Analysing";
status.className = "story-review-centre-tile__status story-review-centre-tile__status--ready";
}
if (description) {
description.textContent = "PlotDirector is creating character summaries and extracting supported details.";
}
if (progress) {
progress.innerHTML = progressHtml(total, processed, message);
}
setAction('<span class="btn btn-outline-primary btn-sm disabled" aria-disabled="true">Analysing</span>');
return;
}
if (statusValue === "Completed") {
if (status) {
status.textContent = "Complete";
status.className = "story-review-centre-tile__status story-review-centre-tile__status--ready";
}
if (description) {
description.textContent = "Character identities, summaries, and supported details are ready.";
}
if (progress) {
progress.innerHTML = "";
}
setAction(`<a class="btn btn-outline-primary btn-sm" href="/Characters?projectId=${projectId}">View Characters</a>`);
return;
}
if (statusValue === "Failed") {
if (status) {
status.textContent = "Failed";
status.className = "story-review-centre-tile__status story-review-centre-tile__status--warning";
}
if (description) {
description.textContent = snapshot.errorMessage || snapshot.ErrorMessage || "Character enrichment failed and can be retried.";
}
if (progress) {
progress.innerHTML = "";
}
}
};
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/story-intelligence")
.withAutomaticReconnect()
.build();
const refresh = () => {
connection.invoke("WatchCharacterEnrichment", bookId)
.then(applyCharacter)
.catch(() => {});
};
connection.on("CharacterEnrichmentProgressChanged", applyCharacter);
connection.on("StoryIntelligenceGlobalProgressChanged", () => {
if (plotTile?.querySelector(".story-review-centre-progress")) {
window.setTimeout(() => window.location.reload(), 700);
}
});
connection.onreconnected(refresh);
const start = () => {
connection.start()
.then(refresh)
.catch(() => window.setTimeout(start, 2000));
};
start();
})();

View File

@ -2628,7 +2628,7 @@
const title = instruction.markerTitle || instruction.MarkerTitle || "PlotDirector Chapter";
const prefix = instruction.markerPrefix || instruction.MarkerPrefix || "PD-CHAPTER";
if (tag && Number.isInteger(index) && paragraphs.items[index]) {
markersChanged = ensureAnchorControl(paragraphs.items[index], title, tag, prefix) || markersChanged;
markersChanged = await ensureAnchorControl(context, paragraphs.items[index], title, tag, prefix) || markersChanged;
}
}
@ -2638,7 +2638,7 @@
const title = instruction.markerTitle || instruction.MarkerTitle || "PlotDirector Scene";
const prefix = instruction.markerPrefix || instruction.MarkerPrefix || "PD-SCENE";
if (tag && Number.isInteger(index) && paragraphs.items[index]) {
markersChanged = ensureAnchorControl(paragraphs.items[index], title, tag, prefix) || markersChanged;
markersChanged = await ensureAnchorControl(context, paragraphs.items[index], title, tag, prefix) || markersChanged;
}
}
} else {
@ -2646,7 +2646,7 @@
const chapterId = chapterMap.get(chapter.temporaryChapterKey);
const index = Number.parseInt(chapter.startPosition, 10);
if (chapterId && Number.isInteger(index) && paragraphs.items[index]) {
markersChanged = ensureAnchorControl(paragraphs.items[index], "PlotDirector Chapter", `PD-CHAPTER-${chapterId}`, "PD-CHAPTER") || markersChanged;
markersChanged = await ensureAnchorControl(context, paragraphs.items[index], "PlotDirector Chapter", `PD-CHAPTER-${chapterId}`, "PD-CHAPTER") || markersChanged;
}
}
@ -2654,7 +2654,7 @@
const sceneId = sceneMap.get(scene.temporarySceneKey);
const index = Number.parseInt(scene.startPosition, 10);
if (sceneId && Number.isInteger(index) && paragraphs.items[index]) {
markersChanged = ensureAnchorControl(paragraphs.items[index], "PlotDirector Scene", `PD-SCENE-${sceneId}`, "PD-SCENE") || markersChanged;
markersChanged = await ensureAnchorControl(context, paragraphs.items[index], "PlotDirector Scene", `PD-SCENE-${sceneId}`, "PD-SCENE") || markersChanged;
}
}
}
@ -3641,13 +3641,13 @@
if (!chapterId) {
throw new Error("Chapter ID is not available.");
}
ensureAnchorControl(paragraph, "PlotDirector Chapter", `PD-CHAPTER-${chapterId}`, "PD-CHAPTER");
await ensureAnchorControl(context, paragraph, "PlotDirector Chapter", `PD-CHAPTER-${chapterId}`, "PD-CHAPTER");
} else {
const sceneId = item.pdScene?.sceneId;
if (!sceneId) {
throw new Error("Scene ID is not available.");
}
ensureAnchorControl(paragraph, "PlotDirector Scene", `PD-SCENE-${sceneId}`, "PD-SCENE");
await ensureAnchorControl(context, paragraph, "PlotDirector Scene", `PD-SCENE-${sceneId}`, "PD-SCENE");
}
await context.sync();
@ -4609,17 +4609,37 @@
return controls.find((control) => String(control.tag || "") === tag) || null;
};
const ensureAnchorControl = (paragraph, title, tag, prefix) => {
const ensureAnchorControl = async (context, paragraph, title, tag, prefix) => {
const existing = findExactAnchorControl(paragraph, tag);
if (existing && existing.tag === tag && existing.title === title) {
return false;
}
const control = existing || paragraph.getRange().insertContentControl();
if (existing) {
existing.title = title;
existing.tag = tag;
existing.appearance = "Hidden";
await context.sync();
return true;
}
const rangeLocations = ["Content", "Start", "Whole"];
let lastError = null;
for (const rangeLocation of rangeLocations) {
try {
const control = paragraph.getRange(rangeLocation).insertContentControl();
control.title = title;
control.tag = tag;
control.appearance = "Hidden";
await context.sync();
return true;
} catch (error) {
lastError = error;
console.warn(`Unable to create PlotDirector marker ${tag} using the ${rangeLocation} paragraph range.`, error);
}
}
throw lastError || new Error(`Unable to create PlotDirector marker ${tag}.`);
};
const attachResolvedPlotDirectorIds = async (successMessage = "PlotDirector IDs attached successfully.") => {
@ -4657,8 +4677,8 @@
throw new Error("Unable to locate the current Word headings.");
}
ensureAnchorControl(chapterParagraph, "PlotDirector Chapter", `PD-CHAPTER-${resolvedChapterId}`, "PD-CHAPTER");
ensureAnchorControl(sceneParagraph, "PlotDirector Scene", `PD-SCENE-${resolvedSceneId}`, "PD-SCENE");
await ensureAnchorControl(context, chapterParagraph, "PlotDirector Chapter", `PD-CHAPTER-${resolvedChapterId}`, "PD-CHAPTER");
await ensureAnchorControl(context, sceneParagraph, "PlotDirector Scene", `PD-SCENE-${resolvedSceneId}`, "PD-SCENE");
await context.sync();
});
await inspectPlotDirectorMarkers().catch(() => null);
@ -4711,7 +4731,7 @@
throw new Error("Unable to locate the current Word chapter heading.");
}
ensureAnchorControl(chapterParagraph, "PlotDirector Chapter", `PD-CHAPTER-${resolvedChapterId}`, "PD-CHAPTER");
await ensureAnchorControl(context, chapterParagraph, "PlotDirector Chapter", `PD-CHAPTER-${resolvedChapterId}`, "PD-CHAPTER");
await context.sync();
});
await inspectPlotDirectorMarkers().catch(() => null);