From cb0beb1a121fa439ee37dc76bfc9d63176749a0e Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 5 Sep 2026 20:26:05 +0000 Subject: [PATCH] Fix Story Intelligence progress and Word marker sync --- PlotLine.Tests/Program.cs | 293 +++++++++- PlotLine/Controllers/CharactersController.cs | 41 ++ PlotLine/Controllers/OnboardingController.cs | 20 + .../Data/CharacterEnrichmentRepository.cs | 213 +++++++ .../Data/StoryIntelligenceResultRepository.cs | 30 +- PlotLine/Docs/AI/Scene-Prompt-V2.md | 22 +- PlotLine/Hubs/StoryIntelligenceHub.cs | 34 ++ PlotLine/Models/CharacterEnrichmentModels.cs | 180 ++++++ PlotLine/Models/SceneIntelligenceModels.cs | 19 + .../StoryIntelligencePersistenceModels.cs | 7 + PlotLine/Program.cs | 2 + .../Services/CharacterEnrichmentService.cs | 428 ++++++++++++++ PlotLine/Services/CoreServices.cs | 25 +- .../OnboardingStoryIntelligenceService.cs | 53 +- .../PersistedStoryIntelligenceRunner.cs | 5 +- .../PersistedStoryIntelligenceWorker.cs | 12 + .../StoryIntelligenceImportCommitService.cs | 80 ++- ...StoryIntelligenceMaterialisationService.cs | 64 +- .../StoryIntelligenceProgressNotifier.cs | 8 + ...IntelligenceSceneStructuredOutputSchema.cs | 16 + .../StoryIntelligenceTemporalResolver.cs | 547 ++++++++++++++++++ PlotLine/Services/StorySceneValidator.cs | 26 + .../183_Phase27J_CharacterEnrichmentRuns.sql | 465 +++++++++++++++ ...K_ProjectHardDeleteReviewCentreCleanup.sql | 218 +++++++ ...185_Phase27L_ManualCharacterEnrichment.sql | 351 +++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 2 + PlotLine/ViewModels/OnboardingViewModels.cs | 20 + PlotLine/Views/Characters/Details.cshtml | 36 +- .../StoryIntelligenceReviewCentre.cshtml | 169 +++++- PlotLine/wwwroot/css/onboarding.css | 39 ++ .../wwwroot/js/character-enrichment-detail.js | 137 +++++ .../wwwroot/js/story-intelligence-progress.js | 61 +- .../js/story-review-centre-progress.js | 123 ++++ PlotLine/wwwroot/js/word-companion-host.js | 50 +- 34 files changed, 3730 insertions(+), 66 deletions(-) create mode 100644 PlotLine/Data/CharacterEnrichmentRepository.cs create mode 100644 PlotLine/Models/CharacterEnrichmentModels.cs create mode 100644 PlotLine/Services/CharacterEnrichmentService.cs create mode 100644 PlotLine/Services/StoryIntelligenceTemporalResolver.cs create mode 100644 PlotLine/Sql/183_Phase27J_CharacterEnrichmentRuns.sql create mode 100644 PlotLine/Sql/184_Phase27K_ProjectHardDeleteReviewCentreCleanup.sql create mode 100644 PlotLine/Sql/185_Phase27L_ManualCharacterEnrichment.sql create mode 100644 PlotLine/wwwroot/js/character-enrichment-detail.js create mode 100644 PlotLine/wwwroot/js/story-review-centre-progress.js diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index aed8e52..495ca51 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -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); diff --git a/PlotLine/Controllers/CharactersController.cs b/PlotLine/Controllers/CharactersController.cs index 66030c8..8feb6b8 100644 --- a/PlotLine/Controllers/CharactersController.cs +++ b/PlotLine/Controllers/CharactersController.cs @@ -151,6 +151,47 @@ public sealed class CharactersController(ICharacterService characters) : Control return RedirectToAction(nameof(Details), new { id }); } + [HttpPost] + [ValidateAntiForgeryToken] + public async Task 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) diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 223f6b7..cedb302 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -13,6 +13,7 @@ public sealed class OnboardingController( IOnboardingService onboarding, IOnboardingStoryIntelligenceService storyIntelligence, IWholeBookPlotIntelligenceService wholeBookPlotIntelligence, + ICharacterEnrichmentService characterEnrichment, ICurrentUserService currentUser, ILogger 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 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 }); diff --git a/PlotLine/Data/CharacterEnrichmentRepository.cs b/PlotLine/Data/CharacterEnrichmentRepository.cs new file mode 100644 index 0000000..e80e2fc --- /dev/null +++ b/PlotLine/Data/CharacterEnrichmentRepository.cs @@ -0,0 +1,213 @@ +using System.Data; +using System.Text.Json; +using Dapper; +using PlotLine.Models; + +namespace PlotLine.Data; + +public interface ICharacterEnrichmentRepository +{ + Task QueueAsync(CharacterEnrichmentQueueRequest request); + Task QueueCharacterAsync(CharacterEnrichmentCharacterQueueRequest request); + Task RetryAsync(int bookId, int userId); + Task ClaimNextAsync(int leaseMinutes); + Task GetCurrentAsync(int bookId); + Task GetCurrentForCharacterAsync(int characterId, int userId); + Task GetCharacterStatusAsync(int characterId, int userId); + Task UpdateProgressAsync(CharacterEnrichmentProgressUpdate update); + Task CompleteAsync(CharacterEnrichmentCompletionRequest request, IReadOnlyList results); + Task FailAsync(int runId, string errorMessage, string? errorDetail, long? durationMs); + Task BuildContextAsync(int projectId, int bookId); + Task 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 QueueAsync(CharacterEnrichmentQueueRequest request) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.CharacterEnrichmentRun_Queue", + request, + commandType: CommandType.StoredProcedure); + } + + public async Task QueueCharacterAsync(CharacterEnrichmentCharacterQueueRequest request) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.CharacterEnrichmentRun_QueueCharacter", + request, + commandType: CommandType.StoredProcedure); + } + + public async Task RetryAsync(int bookId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.CharacterEnrichmentRun_Retry", + new { BookID = bookId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task ClaimNextAsync(int leaseMinutes) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.CharacterEnrichmentRun_ClaimNext", + new { LeaseMinutes = leaseMinutes }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetCurrentAsync(int bookId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.CharacterEnrichmentRun_GetCurrentByBook", + new { BookID = bookId }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetCurrentForCharacterAsync(int characterId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.CharacterEnrichmentRun_GetCurrentByCharacter", + new { CharacterID = characterId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetCharacterStatusAsync(int characterId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "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 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 BuildContextAsync(int projectId, int bookId) + => await BuildContextAsync(projectId, bookId, null); + + public async Task 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(); + var characters = (await result.ReadAsync()).ToList(); + var aliases = (await result.ReadAsync()).ToList(); + var scenes = (await result.ReadAsync()).ToList(); + + var aliasesByCharacter = aliases + .GroupBy(alias => alias.CharacterID) + .ToDictionary(group => group.Key, group => (IReadOnlyList)group.Select(alias => alias.Alias).ToList()); + var scenesByCharacter = scenes + .GroupBy(scene => scene.CharacterID) + .ToDictionary( + group => group.Key, + group => (IReadOnlyList)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; + } +} diff --git a/PlotLine/Data/StoryIntelligenceResultRepository.cs b/PlotLine/Data/StoryIntelligenceResultRepository.cs index da843a3..7faae60 100644 --- a/PlotLine/Data/StoryIntelligenceResultRepository.cs +++ b/PlotLine/Data/StoryIntelligenceResultRepository.cs @@ -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 warnings) { if (string.IsNullOrWhiteSpace(value)) diff --git a/PlotLine/Docs/AI/Scene-Prompt-V2.md b/PlotLine/Docs/AI/Scene-Prompt-V2.md index b1fe208..53135d1 100644 --- a/PlotLine/Docs/AI/Scene-Prompt-V2.md +++ b/PlotLine/Docs/AI/Scene-Prompt-V2.md @@ -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. diff --git a/PlotLine/Hubs/StoryIntelligenceHub.cs b/PlotLine/Hubs/StoryIntelligenceHub.cs index 2dcfe91..6a724ee 100644 --- a/PlotLine/Hubs/StoryIntelligenceHub.cs +++ b/PlotLine/Hubs/StoryIntelligenceHub.cs @@ -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 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 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; diff --git a/PlotLine/Models/CharacterEnrichmentModels.cs b/PlotLine/Models/CharacterEnrichmentModels.cs new file mode 100644 index 0000000..f394c96 --- /dev/null +++ b/PlotLine/Models/CharacterEnrichmentModels.cs @@ -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 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 Aliases { get; init; } = []; + public IReadOnlyList 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 Characters { get; init; } = []; + + public static CharacterEnrichmentResult FromJson(string json) + => JsonSerializer.Deserialize( + 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 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; +} diff --git a/PlotLine/Models/SceneIntelligenceModels.cs b/PlotLine/Models/SceneIntelligenceModels.cs index 33bc4ff..22fed98 100644 --- a/PlotLine/Models/SceneIntelligenceModels.cs +++ b/PlotLine/Models/SceneIntelligenceModels.cs @@ -18,6 +18,7 @@ public sealed class SceneIntelligenceScene public List? Relationships { get; init; } public List? KnowledgeChanges { get; init; } public List? TimelineClues { get; init; } + public SceneIntelligenceTemporalAnalysis? TemporalAnalysis { get; init; } public List? NarrativeArcs { get; init; } public List? QuestionsRaised { get; init; } public List? QuestionsAnswered { get; init; } @@ -198,6 +199,24 @@ public sealed class SceneIntelligenceTimelineClue public Dictionary? 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? ExtensionData { get; init; } +} + public sealed class SceneIntelligenceQuestionRaised { public string? Question { get; init; } diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index 695680a..44b8412 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -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; } } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 65aed3c..590dc5b 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -153,6 +153,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -236,6 +237,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/CharacterEnrichmentService.cs b/PlotLine/Services/CharacterEnrichmentService.cs new file mode 100644 index 0000000..e6c44ae --- /dev/null +++ b/PlotLine/Services/CharacterEnrichmentService.cs @@ -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 QueueAfterCharacterResolutionAsync(int projectId, int bookId, int userId); + Task QueueForCharacterAsync(int characterId, int userId, int? bookId = null); + Task RetryAsync(int bookId, int userId); + Task GetCurrentAsync(int bookId); + Task GetCurrentForCharacterAsync(int characterId, int userId); + Task GetCharacterStatusAsync(int characterId, int userId); + Task ProcessNextAsync(CancellationToken cancellationToken); + string BuildPromptForTest(CharacterEnrichmentContext context); + StoryIntelligenceResponseContract BuildResponseContractForTest(); +} + +public sealed class CharacterEnrichmentService( + ICharacterEnrichmentRepository repository, + IStoryIntelligencePipelineRepository pipelines, + IStoryIntelligenceClient client, + IOptions options, + IStoryIntelligenceProgressNotifier notifier, + ILogger 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 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 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 RetryAsync(int bookId, int userId) + => repository.RetryAsync(bookId, userId); + + public Task GetCurrentAsync(int bookId) + => repository.GetCurrentAsync(bookId); + + public Task GetCurrentForCharacterAsync(int characterId, int userId) + => repository.GetCurrentForCharacterAsync(characterId, userId); + + public Task GetCharacterStatusAsync(int characterId, int userId) + => repository.GetCharacterStatusAsync(characterId, userId); + + public async Task 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(); + 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 FilterResults( + IReadOnlyList results, + HashSet 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(""); + builder.AppendLine(scene.SourceText); + builder.AppendLine(""); + } + } + + 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 + { + ["type"] = "object", + ["additionalProperties"] = false, + ["required"] = new[] { "schemaVersion", "characters" }, + ["properties"] = new Dictionary + { + ["schemaVersion"] = StringSchema(), + ["characters"] = ArraySchema(ObjectSchema(new Dictionary + { + ["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 FactSchema() + => ObjectSchema(new Dictionary + { + ["value"] = NullableStringSchema(), + ["confidence"] = NumberSchema(), + ["evidence"] = NullableStringSchema(), + ["sceneID"] = NullableIntegerSchema() + }, "value", "confidence", "evidence", "sceneID"); + + private static Dictionary ObjectSchema(Dictionary properties, params string[] required) + => new() + { + ["type"] = "object", + ["additionalProperties"] = false, + ["required"] = required, + ["properties"] = properties + }; + + private static Dictionary ArraySchema(object items) + => new() + { + ["type"] = "array", + ["items"] = items + }; + + private static Dictionary StringSchema() + => new() { ["type"] = "string" }; + + private static Dictionary NullableStringSchema() + => new() { ["type"] = new[] { "string", "null" } }; + + private static Dictionary IntegerSchema() + => new() { ["type"] = "integer" }; + + private static Dictionary NullableIntegerSchema() + => new() { ["type"] = new[] { "integer", "null" } }; + + private static Dictionary NumberSchema() + => new() { ["type"] = "number", ["minimum"] = 0, ["maximum"] = 1 }; +} diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index f38e8ed..d43353a 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -269,6 +269,7 @@ public interface ICharacterService Task GetMergeAsync(int sourceCharacterId); Task GetAvatarCropAsync(int characterImageId); Task SaveCharacterAsync(CharacterEditViewModel model); + Task QueueCharacterReanalysisAsync(int characterId); Task SetPreferredNameAsync(int characterId, string preferredName); Task MergeAsync(CharacterMergeForm form); Task 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 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 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 QueueCharacterReanalysisAsync(int characterId) + { + if (currentUser.UserId is not int userId) + { + return null; + } + + return await characterEnrichment.QueueForCharacterAsync(characterId, userId); + } + public async Task GetMergeAsync(int sourceCharacterId) { var source = await characters.GetCharacterAsync(sourceCharacterId); diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index 25c4c4f..ca2a26c 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -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); diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index e085e9a..a5ab380 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -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; diff --git a/PlotLine/Services/PersistedStoryIntelligenceWorker.cs b/PlotLine/Services/PersistedStoryIntelligenceWorker.cs index 4d5d704..7af7217 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceWorker.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceWorker.cs @@ -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 ProcessCharacterEnrichmentAsync(CancellationToken stoppingToken) + { + using var scope = scopeFactory.CreateScope(); + var enrichment = scope.ServiceProvider.GetRequiredService(); + return await enrichment.ProcessNextAsync(stoppingToken); + } + private async Task RecoverReadySceneAnalysisAsync(CancellationToken stoppingToken) { using var scope = scopeFactory.CreateScope(); diff --git a/PlotLine/Services/StoryIntelligenceImportCommitService.cs b/PlotLine/Services/StoryIntelligenceImportCommitService.cs index a6e8983..6db8721 100644 --- a/PlotLine/Services/StoryIntelligenceImportCommitService.cs +++ b/PlotLine/Services/StoryIntelligenceImportCommitService.cs @@ -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(); + var rawImportScenes = new List(); 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 ApplyTemporalResolution( + IReadOnlyList scenes, + StoryIntelligenceTemporalContext initialContext, + IReadOnlyDictionary timeModeIds, + IReadOnlyDictionary timeConfidenceIds, + int defaultTimeModeId, + int defaultTimeConfidenceId, + ICollection warnings) + { + var context = initialContext; + var resolvedScenes = new List(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 BuildReviewScenes( IReadOnlyList canonicalScenes, IReadOnlyList sceneResults, diff --git a/PlotLine/Services/StoryIntelligenceMaterialisationService.cs b/PlotLine/Services/StoryIntelligenceMaterialisationService.cs index 876fe17..0cafa1f 100644 --- a/PlotLine/Services/StoryIntelligenceMaterialisationService.cs +++ b/PlotLine/Services/StoryIntelligenceMaterialisationService.cs @@ -15,6 +15,7 @@ public sealed class StoryIntelligenceMaterialisationService( ISceneMetricTypeRepository metricTypes, ISceneRepository scenes, IChapterRepository chapters, + ILookupRepository lookups, IStoryIntelligenceNarrativeReviewService narrative, IStoryIntelligenceReviewCandidateService reviewCandidates, ILogger 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(); + 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(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 timeModeIds, + IReadOnlyDictionary 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 diff --git a/PlotLine/Services/StoryIntelligenceProgressNotifier.cs b/PlotLine/Services/StoryIntelligenceProgressNotifier.cs index 16ea032..4a9fc45 100644 --- a/PlotLine/Services/StoryIntelligenceProgressNotifier.cs +++ b/PlotLine/Services/StoryIntelligenceProgressNotifier.cs @@ -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); diff --git a/PlotLine/Services/StoryIntelligenceSceneStructuredOutputSchema.cs b/PlotLine/Services/StoryIntelligenceSceneStructuredOutputSchema.cs index 24b697a..d8f0f56 100644 --- a/PlotLine/Services/StoryIntelligenceSceneStructuredOutputSchema.cs +++ b/PlotLine/Services/StoryIntelligenceSceneStructuredOutputSchema.cs @@ -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 Bool() => new() { ["type"] = "boolean" }; + private static Dictionary NullableBoolean() + => new() { ["type"] = new[] { "boolean", "null" } }; + private static Dictionary Confidence() => new() { ["type"] = "number", ["minimum"] = 0, ["maximum"] = 1 }; diff --git a/PlotLine/Services/StoryIntelligenceTemporalResolver.cs b/PlotLine/Services/StoryIntelligenceTemporalResolver.cs new file mode 100644 index 0000000..19f4978 --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceTemporalResolver.cs @@ -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 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 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 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>(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); diff --git a/PlotLine/Services/StorySceneValidator.cs b/PlotLine/Services/StorySceneValidator.cs index 4dd4959..04287df 100644 --- a/PlotLine/Services/StorySceneValidator.cs +++ b/PlotLine/Services/StorySceneValidator.cs @@ -17,6 +17,9 @@ public sealed class StorySceneValidator : IStorySceneValidator private static readonly HashSet ObservationTypes = CreateSet("CharacterAction", "CharacterKnowledge", "CharacterLocation", "RelationshipSignal", "AssetInteraction", "LocationSignal", "TimelineSignal", "QuestionSignal"); private static readonly HashSet EntityTypes = CreateSet("Character", "Location", "Asset", "Knowledge", "Relationship", "Timeline", "Scene", "Unknown"); private static readonly HashSet RelationshipSignals = CreateSet("trust", "mistrust", "affection", "fear", "authority", "secrecy", "conflict", "dependence", "protection", "rivalry", "obligation"); + private static readonly HashSet TemporalTransitions = CreateSet("same", "later", "next", "following", "after", "before", "elapsed", "unknown"); + private static readonly HashSet TemporalUnits = CreateSet("minute", "hour", "day", "week", "month", "year", "unknown"); + private static readonly HashSet 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? values, ValidationResult result) { if (values is null) diff --git a/PlotLine/Sql/183_Phase27J_CharacterEnrichmentRuns.sql b/PlotLine/Sql/183_Phase27J_CharacterEnrichmentRuns.sql new file mode 100644 index 0000000..0e8a949 --- /dev/null +++ b/PlotLine/Sql/183_Phase27J_CharacterEnrichmentRuns.sql @@ -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 diff --git a/PlotLine/Sql/184_Phase27K_ProjectHardDeleteReviewCentreCleanup.sql b/PlotLine/Sql/184_Phase27K_ProjectHardDeleteReviewCentreCleanup.sql new file mode 100644 index 0000000..9728c27 --- /dev/null +++ b/PlotLine/Sql/184_Phase27K_ProjectHardDeleteReviewCentreCleanup.sql @@ -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 diff --git a/PlotLine/Sql/185_Phase27L_ManualCharacterEnrichment.sql b/PlotLine/Sql/185_Phase27L_ManualCharacterEnrichment.sql new file mode 100644 index 0000000..89343f4 --- /dev/null +++ b/PlotLine/Sql/185_Phase27L_ManualCharacterEnrichment.sql @@ -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 diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 8b50ec8..7ae8449 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -256,6 +256,7 @@ public sealed class BookDetailViewModel public ManuscriptDocumentModel? ManuscriptDocument { get; set; } public IReadOnlyList Chapters { get; set; } = []; public StoryIntelligenceBookPipelineState? StoryIntelligencePipeline { get; set; } + public IReadOnlyList StoryIntelligenceRuns { get; set; } = []; public StoryIntelligenceBookActionViewModel StoryIntelligenceAction { get; set; } = new(); } @@ -1884,6 +1885,7 @@ public sealed class CharacterDetailViewModel public IReadOnlyList RelationshipTypeOptions { get; set; } = []; public IReadOnlyList RelationshipStateOptions { get; set; } = []; public IReadOnlyList BookOptions { get; set; } = []; + public CharacterEnrichmentCharacterStatus CharacterAnalysis { get; set; } = new(); } public sealed class CharacterMergeViewModel diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index c13b800..dd2d4f9 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -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; } diff --git a/PlotLine/Views/Characters/Details.cshtml b/PlotLine/Views/Characters/Details.cshtml index 9ba8613..e5a5d26 100644 --- a/PlotLine/Views/Characters/Details.cshtml +++ b/PlotLine/Views/Characters/Details.cshtml @@ -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"); }