Fix Story Intelligence progress and Word marker sync
This commit is contained in:
parent
390ea19550
commit
cb0beb1a12
@ -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);
|
||||
|
||||
|
||||
@ -151,6 +151,47 @@ public sealed class CharactersController(ICharacterService characters) : Control
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Reanalyse(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var run = await characters.QueueCharacterReanalysisAsync(id);
|
||||
if (run is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
TempData["CharacterMessage"] = run.Status == "Running"
|
||||
? "Character analysis is already running."
|
||||
: "Character reanalysis queued.";
|
||||
|
||||
if (Request.Headers.XRequestedWith == "XMLHttpRequest")
|
||||
{
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
characterEnrichmentRunID = run.CharacterEnrichmentRunID,
|
||||
bookID = run.BookID,
|
||||
characterID = run.CharacterID,
|
||||
status = run.Status,
|
||||
currentMessage = run.CurrentMessage
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
TempData["CharacterMessage"] = ex.Message;
|
||||
if (Request.Headers.XRequestedWith == "XMLHttpRequest")
|
||||
{
|
||||
return BadRequest(new { success = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
private void ValidateSexWorkflow(CharacterEditViewModel model)
|
||||
{
|
||||
if (model.SexValueID == CharacterEditViewModel.CustomSexValue)
|
||||
|
||||
@ -13,6 +13,7 @@ public sealed class OnboardingController(
|
||||
IOnboardingService onboarding,
|
||||
IOnboardingStoryIntelligenceService storyIntelligence,
|
||||
IWholeBookPlotIntelligenceService wholeBookPlotIntelligence,
|
||||
ICharacterEnrichmentService characterEnrichment,
|
||||
ICurrentUserService currentUser,
|
||||
ILogger<OnboardingController> logger) : Controller
|
||||
{
|
||||
@ -748,6 +749,25 @@ public sealed class OnboardingController(
|
||||
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
|
||||
}
|
||||
|
||||
[HttpPost("story-intelligence/character-enrichment/retry")]
|
||||
public async Task<IActionResult> RetryStoryIntelligenceCharacterEnrichment(Guid batchId)
|
||||
{
|
||||
var model = await storyIntelligence.GetReviewCentreAsync(batchId);
|
||||
if (model is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!currentUser.UserId.HasValue)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
await characterEnrichment.RetryAsync(model.BookID, currentUser.UserId.Value);
|
||||
TempData["OnboardingStoryIntelligenceMessage"] = "Character enrichment has been queued again.";
|
||||
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
|
||||
}
|
||||
|
||||
private IActionResult RedirectToBook(StoryIntelligenceProgressViewModel model)
|
||||
=> RedirectToAction("Details", "Books", new { id = model.BookID });
|
||||
|
||||
|
||||
213
PlotLine/Data/CharacterEnrichmentRepository.cs
Normal file
213
PlotLine/Data/CharacterEnrichmentRepository.cs
Normal file
@ -0,0 +1,213 @@
|
||||
using System.Data;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Data;
|
||||
|
||||
public interface ICharacterEnrichmentRepository
|
||||
{
|
||||
Task<CharacterEnrichmentRun> QueueAsync(CharacterEnrichmentQueueRequest request);
|
||||
Task<CharacterEnrichmentRun> QueueCharacterAsync(CharacterEnrichmentCharacterQueueRequest request);
|
||||
Task<CharacterEnrichmentRun?> RetryAsync(int bookId, int userId);
|
||||
Task<CharacterEnrichmentRun?> ClaimNextAsync(int leaseMinutes);
|
||||
Task<CharacterEnrichmentRun?> GetCurrentAsync(int bookId);
|
||||
Task<CharacterEnrichmentRun?> GetCurrentForCharacterAsync(int characterId, int userId);
|
||||
Task<CharacterEnrichmentCharacterStatus?> GetCharacterStatusAsync(int characterId, int userId);
|
||||
Task UpdateProgressAsync(CharacterEnrichmentProgressUpdate update);
|
||||
Task CompleteAsync(CharacterEnrichmentCompletionRequest request, IReadOnlyList<CharacterEnrichmentCharacterResult> results);
|
||||
Task FailAsync(int runId, string errorMessage, string? errorDetail, long? durationMs);
|
||||
Task<CharacterEnrichmentContext> BuildContextAsync(int projectId, int bookId);
|
||||
Task<CharacterEnrichmentContext> BuildContextAsync(int projectId, int bookId, int? characterId);
|
||||
}
|
||||
|
||||
public sealed class CharacterEnrichmentRepository(ISqlConnectionFactory connectionFactory) : ICharacterEnrichmentRepository
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<CharacterEnrichmentRun> QueueAsync(CharacterEnrichmentQueueRequest request)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<CharacterEnrichmentRun>(
|
||||
"dbo.CharacterEnrichmentRun_Queue",
|
||||
request,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<CharacterEnrichmentRun> QueueCharacterAsync(CharacterEnrichmentCharacterQueueRequest request)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<CharacterEnrichmentRun>(
|
||||
"dbo.CharacterEnrichmentRun_QueueCharacter",
|
||||
request,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<CharacterEnrichmentRun?> RetryAsync(int bookId, int userId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<CharacterEnrichmentRun>(
|
||||
"dbo.CharacterEnrichmentRun_Retry",
|
||||
new { BookID = bookId, UserID = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<CharacterEnrichmentRun?> ClaimNextAsync(int leaseMinutes)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<CharacterEnrichmentRun>(
|
||||
"dbo.CharacterEnrichmentRun_ClaimNext",
|
||||
new { LeaseMinutes = leaseMinutes },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<CharacterEnrichmentRun?> GetCurrentAsync(int bookId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<CharacterEnrichmentRun>(
|
||||
"dbo.CharacterEnrichmentRun_GetCurrentByBook",
|
||||
new { BookID = bookId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<CharacterEnrichmentRun?> GetCurrentForCharacterAsync(int characterId, int userId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<CharacterEnrichmentRun>(
|
||||
"dbo.CharacterEnrichmentRun_GetCurrentByCharacter",
|
||||
new { CharacterID = characterId, UserID = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<CharacterEnrichmentCharacterStatus?> GetCharacterStatusAsync(int characterId, int userId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<CharacterEnrichmentCharacterStatus>(
|
||||
"dbo.CharacterEnrichmentCharacterStatus_Get",
|
||||
new { CharacterID = characterId, UserID = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task UpdateProgressAsync(CharacterEnrichmentProgressUpdate update)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.CharacterEnrichmentRun_UpdateProgress",
|
||||
update,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task CompleteAsync(CharacterEnrichmentCompletionRequest request, IReadOnlyList<CharacterEnrichmentCharacterResult> results)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.CharacterEnrichmentRun_Complete",
|
||||
new
|
||||
{
|
||||
request.CharacterEnrichmentRunID,
|
||||
request.Model,
|
||||
request.InputTokens,
|
||||
request.OutputTokens,
|
||||
request.TotalTokens,
|
||||
request.DurationMs,
|
||||
ResultsJson = JsonSerializer.Serialize(results, JsonOptions)
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task FailAsync(int runId, string errorMessage, string? errorDetail, long? durationMs)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.CharacterEnrichmentRun_Fail",
|
||||
new
|
||||
{
|
||||
CharacterEnrichmentRunID = runId,
|
||||
ErrorMessage = errorMessage,
|
||||
ErrorDetail = errorDetail,
|
||||
DurationMs = durationMs
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<CharacterEnrichmentContext> BuildContextAsync(int projectId, int bookId)
|
||||
=> await BuildContextAsync(projectId, bookId, null);
|
||||
|
||||
public async Task<CharacterEnrichmentContext> BuildContextAsync(int projectId, int bookId, int? characterId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
using var result = await connection.QueryMultipleAsync(
|
||||
"dbo.CharacterEnrichment_ContextGet",
|
||||
new { ProjectID = projectId, BookID = bookId, CharacterID = characterId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
var book = await result.ReadSingleAsync<ContextBookRow>();
|
||||
var characters = (await result.ReadAsync<CharacterEnrichmentCharacterContext>()).ToList();
|
||||
var aliases = (await result.ReadAsync<ContextAliasRow>()).ToList();
|
||||
var scenes = (await result.ReadAsync<ContextSceneRow>()).ToList();
|
||||
|
||||
var aliasesByCharacter = aliases
|
||||
.GroupBy(alias => alias.CharacterID)
|
||||
.ToDictionary(group => group.Key, group => (IReadOnlyList<string>)group.Select(alias => alias.Alias).ToList());
|
||||
var scenesByCharacter = scenes
|
||||
.GroupBy(scene => scene.CharacterID)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => (IReadOnlyList<CharacterEnrichmentSceneContext>)group.Select(scene => new CharacterEnrichmentSceneContext
|
||||
{
|
||||
SceneID = scene.SceneID,
|
||||
ChapterID = scene.ChapterID,
|
||||
ChapterNumber = scene.ChapterNumber,
|
||||
ChapterTitle = scene.ChapterTitle,
|
||||
SceneNumber = scene.SceneNumber,
|
||||
SceneTitle = scene.SceneTitle,
|
||||
SourceText = scene.SourceText
|
||||
}).ToList());
|
||||
|
||||
return new CharacterEnrichmentContext
|
||||
{
|
||||
ProjectID = projectId,
|
||||
BookID = bookId,
|
||||
BookTitle = book.BookTitle,
|
||||
StoryEra = book.StoryEra,
|
||||
SeriesStartDate = book.SeriesStartDate,
|
||||
Characters = characters.Select(character => new CharacterEnrichmentCharacterContext
|
||||
{
|
||||
CharacterID = character.CharacterID,
|
||||
CharacterName = character.CharacterName,
|
||||
BirthDate = character.BirthDate,
|
||||
AgeAtSeriesStart = character.AgeAtSeriesStart,
|
||||
Height = character.Height,
|
||||
EyeColour = character.EyeColour,
|
||||
DefaultDescription = character.DefaultDescription,
|
||||
Aliases = aliasesByCharacter.GetValueOrDefault(character.CharacterID) ?? [],
|
||||
Scenes = scenesByCharacter.GetValueOrDefault(character.CharacterID) ?? []
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class ContextBookRow
|
||||
{
|
||||
public string BookTitle { get; init; } = string.Empty;
|
||||
public string? StoryEra { get; init; }
|
||||
public DateTime? SeriesStartDate { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ContextAliasRow
|
||||
{
|
||||
public int CharacterID { get; init; }
|
||||
public string Alias { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class ContextSceneRow
|
||||
{
|
||||
public int CharacterID { get; init; }
|
||||
public int SceneID { get; init; }
|
||||
public int ChapterID { get; init; }
|
||||
public decimal ChapterNumber { get; init; }
|
||||
public string ChapterTitle { get; init; } = string.Empty;
|
||||
public decimal SceneNumber { get; init; }
|
||||
public string SceneTitle { get; init; } = string.Empty;
|
||||
public string SourceText { get; init; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@ -653,13 +653,19 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
||||
for (var index = 0; index < orderedScenes.Count; index++)
|
||||
{
|
||||
var item = orderedScenes[index];
|
||||
var existingScene = item.SceneID.HasValue
|
||||
? existingScenes.FirstOrDefault(scene => scene.SceneID == item.SceneID.Value)
|
||||
: existingScenes.Count > index ? existingScenes[index] : null;
|
||||
var preserveExistingChronology = HasExistingChronology(existingScene);
|
||||
var operation = "preparing scene";
|
||||
|
||||
try
|
||||
{
|
||||
operation = "saving scene";
|
||||
var relativeTimeText = TrimOptionalText(
|
||||
item.ParsedScene.Setting?.DateOrTimeReference ?? item.ParsedScene.Setting?.TimeOfDay,
|
||||
preserveExistingChronology
|
||||
? existingScene?.RelativeTimeText
|
||||
: item.RelativeTimeText ?? item.ParsedScene.Setting?.DateOrTimeReference ?? item.ParsedScene.Setting?.TimeOfDay,
|
||||
200,
|
||||
$"Scene {item.TemporarySceneNumber:N0} relative time",
|
||||
commitWarnings);
|
||||
@ -671,18 +677,18 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
||||
"dbo.Scene_Save",
|
||||
new
|
||||
{
|
||||
SceneID = item.SceneID ?? (existingScenes.Count > index ? existingScenes[index].SceneID : (int?)null),
|
||||
SceneID = item.SceneID ?? existingScene?.SceneID,
|
||||
ChapterID = request.ChapterID,
|
||||
SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber),
|
||||
SceneTitle = sceneTitle,
|
||||
Summary = item.Summary,
|
||||
TimeModeID = request.TimeModeID,
|
||||
StartDateTime = (DateTime?)null,
|
||||
EndDateTime = (DateTime?)null,
|
||||
DurationAmount = (decimal?)null,
|
||||
DurationUnitID = (int?)null,
|
||||
TimeModeID = preserveExistingChronology ? existingScene!.TimeModeID : item.TimeModeID,
|
||||
StartDateTime = preserveExistingChronology ? existingScene!.StartDateTime : item.StartDateTime,
|
||||
EndDateTime = preserveExistingChronology ? existingScene!.EndDateTime : item.EndDateTime,
|
||||
DurationAmount = preserveExistingChronology ? existingScene!.DurationAmount : item.DurationAmount,
|
||||
DurationUnitID = preserveExistingChronology ? existingScene!.DurationUnitID : item.DurationUnitID,
|
||||
RelativeTimeText = relativeTimeText,
|
||||
TimeConfidenceID = request.TimeConfidenceID,
|
||||
TimeConfidenceID = preserveExistingChronology ? existingScene!.TimeConfidenceID : item.TimeConfidenceID,
|
||||
ScenePurposeNotes = item.PurposeNotes,
|
||||
SceneOutcomeNotes = item.OutcomeNotes,
|
||||
RevisionStatusID = request.RevisionStatusID,
|
||||
@ -875,6 +881,14 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasExistingChronology(Scene? scene)
|
||||
=> scene is not null
|
||||
&& (scene.StartDateTime.HasValue
|
||||
|| scene.EndDateTime.HasValue
|
||||
|| scene.DurationAmount.HasValue
|
||||
|| scene.DurationUnitID.HasValue
|
||||
|| !string.IsNullOrWhiteSpace(scene.RelativeTimeText));
|
||||
|
||||
private static string? TrimOptionalText(string? value, int maxLength, string label, ICollection<string> warnings)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -14,6 +14,7 @@ public sealed class StoryIntelligenceHub(
|
||||
IStoryIntelligenceService storyIntelligence,
|
||||
IStoryIntelligenceResultRepository persistedRuns,
|
||||
IOnboardingStoryIntelligenceService onboardingStoryIntelligence,
|
||||
ICharacterEnrichmentService characterEnrichment,
|
||||
IStoryIntelligenceGlobalProgressService globalProgress) : Hub
|
||||
{
|
||||
public override async Task OnConnectedAsync()
|
||||
@ -66,8 +67,41 @@ public sealed class StoryIntelligenceHub(
|
||||
return await globalProgress.GetForCurrentUserAsync();
|
||||
}
|
||||
|
||||
public async Task<CharacterEnrichmentProgressEvent?> WatchCharacterEnrichment(int bookId)
|
||||
{
|
||||
var userId = RequireUserId();
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
|
||||
var run = await characterEnrichment.GetCurrentAsync(bookId);
|
||||
return run is null || run.UserID != userId ? null : ToCharacterEnrichmentProgress(run);
|
||||
}
|
||||
|
||||
public async Task<CharacterEnrichmentProgressEvent?> WatchCharacterEnrichmentForCharacter(int characterId)
|
||||
{
|
||||
var userId = RequireUserId();
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
|
||||
var run = await characterEnrichment.GetCurrentForCharacterAsync(characterId, userId);
|
||||
return run is null || run.UserID != userId ? null : ToCharacterEnrichmentProgress(run);
|
||||
}
|
||||
|
||||
public static string UserGroup(int userId) => $"story-intelligence:{userId}";
|
||||
|
||||
private static CharacterEnrichmentProgressEvent ToCharacterEnrichmentProgress(CharacterEnrichmentRun run)
|
||||
=> new()
|
||||
{
|
||||
CharacterEnrichmentRunID = run.CharacterEnrichmentRunID,
|
||||
UserID = run.UserID,
|
||||
ProjectID = run.ProjectID,
|
||||
BookID = run.BookID,
|
||||
CharacterID = run.CharacterID,
|
||||
Status = run.Status,
|
||||
TotalCharacters = run.TotalCharacters,
|
||||
ProcessedCharacters = run.ProcessedCharacters,
|
||||
CurrentStage = run.CurrentStage ?? string.Empty,
|
||||
CurrentMessage = run.CurrentMessage ?? string.Empty,
|
||||
ErrorMessage = run.ErrorMessage,
|
||||
UpdatedUtc = run.UpdatedUtc
|
||||
};
|
||||
|
||||
private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType, Scene? currentScene, string? latestSceneSummary)
|
||||
{
|
||||
var completedScenes = run.CompletedScenes ?? 0;
|
||||
|
||||
180
PlotLine/Models/CharacterEnrichmentModels.cs
Normal file
180
PlotLine/Models/CharacterEnrichmentModels.cs
Normal file
@ -0,0 +1,180 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PlotLine.Models;
|
||||
|
||||
public static class CharacterEnrichmentStatuses
|
||||
{
|
||||
public const string Queued = "Queued";
|
||||
public const string Running = "Running";
|
||||
public const string Completed = "Completed";
|
||||
public const string Failed = "Failed";
|
||||
public const string Superseded = "Superseded";
|
||||
}
|
||||
|
||||
public sealed class CharacterEnrichmentRun
|
||||
{
|
||||
public int CharacterEnrichmentRunID { get; set; }
|
||||
public int ProjectID { get; set; }
|
||||
public int BookID { get; set; }
|
||||
public int UserID { get; set; }
|
||||
public int? CharacterID { get; set; }
|
||||
public int? StoryIntelligenceBookPipelineID { get; set; }
|
||||
public string CharacterResolutionVersion { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = CharacterEnrichmentStatuses.Queued;
|
||||
public int TotalCharacters { get; set; }
|
||||
public int ProcessedCharacters { get; set; }
|
||||
public string? CurrentStage { get; set; }
|
||||
public string? CurrentMessage { get; set; }
|
||||
public DateTime QueuedUtc { get; set; }
|
||||
public DateTime? StartedUtc { get; set; }
|
||||
public DateTime? CompletedUtc { get; set; }
|
||||
public DateTime? FailedUtc { get; set; }
|
||||
public int AttemptCount { get; set; }
|
||||
public string? Model { get; set; }
|
||||
public int? InputTokens { get; set; }
|
||||
public int? OutputTokens { get; set; }
|
||||
public int? TotalTokens { get; set; }
|
||||
public long? DurationMs { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string? ErrorDetail { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; }
|
||||
public bool WasCoalesced { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CharacterEnrichmentQueueRequest
|
||||
{
|
||||
public int ProjectID { get; init; }
|
||||
public int BookID { get; init; }
|
||||
public int UserID { get; init; }
|
||||
public int? StoryIntelligenceBookPipelineID { get; init; }
|
||||
public string CharacterResolutionVersion { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CharacterEnrichmentCharacterQueueRequest
|
||||
{
|
||||
public int CharacterID { get; init; }
|
||||
public int UserID { get; init; }
|
||||
public int? BookID { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CharacterEnrichmentCharacterStatus
|
||||
{
|
||||
public int CharacterID { get; init; }
|
||||
public int? BookID { get; init; }
|
||||
public int? CharacterEnrichmentRunID { get; init; }
|
||||
public string Status { get; init; } = "Not analysed";
|
||||
public string? CurrentStage { get; init; }
|
||||
public string? CurrentMessage { get; init; }
|
||||
public DateTime? LastAnalysedUtc { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public string? DateOfBirth { get; init; }
|
||||
public string? AgeAtStartOfSeries { get; init; }
|
||||
public string? Height { get; init; }
|
||||
public string? EyeColour { get; init; }
|
||||
public DateTime UpdatedUtc { get; init; } = DateTime.UtcNow;
|
||||
public bool IsActive => Status is CharacterEnrichmentStatuses.Queued or CharacterEnrichmentStatuses.Running;
|
||||
public bool IsCompleted => Status == CharacterEnrichmentStatuses.Completed;
|
||||
}
|
||||
|
||||
public sealed class CharacterEnrichmentProgressUpdate
|
||||
{
|
||||
public int CharacterEnrichmentRunID { get; init; }
|
||||
public int TotalCharacters { get; init; }
|
||||
public int ProcessedCharacters { get; init; }
|
||||
public string CurrentStage { get; init; } = string.Empty;
|
||||
public string CurrentMessage { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CharacterEnrichmentCompletionRequest
|
||||
{
|
||||
public int CharacterEnrichmentRunID { get; init; }
|
||||
public string Model { get; init; } = string.Empty;
|
||||
public int? InputTokens { get; init; }
|
||||
public int? OutputTokens { get; init; }
|
||||
public int? TotalTokens { get; init; }
|
||||
public long? DurationMs { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CharacterEnrichmentContext
|
||||
{
|
||||
public int ProjectID { get; init; }
|
||||
public int BookID { get; init; }
|
||||
public string BookTitle { get; init; } = string.Empty;
|
||||
public string? StoryEra { get; init; }
|
||||
public DateTime? SeriesStartDate { get; init; }
|
||||
public IReadOnlyList<CharacterEnrichmentCharacterContext> Characters { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class CharacterEnrichmentCharacterContext
|
||||
{
|
||||
public int CharacterID { get; init; }
|
||||
public string CharacterName { get; init; } = string.Empty;
|
||||
public DateTime? BirthDate { get; init; }
|
||||
public int? AgeAtSeriesStart { get; init; }
|
||||
public string? Height { get; init; }
|
||||
public string? EyeColour { get; init; }
|
||||
public string? DefaultDescription { get; init; }
|
||||
public IReadOnlyList<string> Aliases { get; init; } = [];
|
||||
public IReadOnlyList<CharacterEnrichmentSceneContext> Scenes { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class CharacterEnrichmentSceneContext
|
||||
{
|
||||
public int SceneID { get; init; }
|
||||
public int ChapterID { get; init; }
|
||||
public decimal ChapterNumber { get; init; }
|
||||
public string ChapterTitle { get; init; } = string.Empty;
|
||||
public decimal SceneNumber { get; init; }
|
||||
public string SceneTitle { get; init; } = string.Empty;
|
||||
public string SourceText { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CharacterEnrichmentResult
|
||||
{
|
||||
public string SchemaVersion { get; init; } = "1";
|
||||
public IReadOnlyList<CharacterEnrichmentCharacterResult> Characters { get; init; } = [];
|
||||
|
||||
public static CharacterEnrichmentResult FromJson(string json)
|
||||
=> JsonSerializer.Deserialize<CharacterEnrichmentResult>(
|
||||
json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
|
||||
}
|
||||
|
||||
public sealed class CharacterEnrichmentCharacterResult
|
||||
{
|
||||
public int CharacterID { get; init; }
|
||||
public string Summary { get; init; } = string.Empty;
|
||||
public decimal Confidence { get; init; }
|
||||
public CharacterEnrichmentFactResult DateOfBirth { get; init; } = new();
|
||||
public CharacterEnrichmentFactResult AgeAtStartOfSeries { get; init; } = new();
|
||||
public CharacterEnrichmentFactResult Height { get; init; } = new();
|
||||
public CharacterEnrichmentFactResult EyeColour { get; init; } = new();
|
||||
public IReadOnlyList<int> EvidenceSceneIDs { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class CharacterEnrichmentFactResult
|
||||
{
|
||||
public string? Value { get; init; }
|
||||
public decimal Confidence { get; init; }
|
||||
public string? Evidence { get; init; }
|
||||
public int? SceneID { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CharacterEnrichmentProgressEvent
|
||||
{
|
||||
public int CharacterEnrichmentRunID { get; init; }
|
||||
public int UserID { get; init; }
|
||||
public int ProjectID { get; init; }
|
||||
public int BookID { get; init; }
|
||||
public int? CharacterID { get; init; }
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public int TotalCharacters { get; init; }
|
||||
public int ProcessedCharacters { get; init; }
|
||||
public string CurrentStage { get; init; } = string.Empty;
|
||||
public string CurrentMessage { get; init; } = string.Empty;
|
||||
public string? ErrorMessage { get; init; }
|
||||
public DateTime UpdatedUtc { get; init; } = DateTime.UtcNow;
|
||||
public bool IsActive => Status is CharacterEnrichmentStatuses.Queued or CharacterEnrichmentStatuses.Running;
|
||||
public bool IsCompleted => Status == CharacterEnrichmentStatuses.Completed;
|
||||
}
|
||||
@ -18,6 +18,7 @@ public sealed class SceneIntelligenceScene
|
||||
public List<SceneIntelligenceRelationship>? Relationships { get; init; }
|
||||
public List<SceneIntelligenceKnowledgeChange>? KnowledgeChanges { get; init; }
|
||||
public List<SceneIntelligenceTimelineClue>? TimelineClues { get; init; }
|
||||
public SceneIntelligenceTemporalAnalysis? TemporalAnalysis { get; init; }
|
||||
public List<SceneIntelligenceNarrativeArc>? NarrativeArcs { get; init; }
|
||||
public List<SceneIntelligenceQuestionRaised>? QuestionsRaised { get; init; }
|
||||
public List<SceneIntelligenceQuestionAnswered>? QuestionsAnswered { get; init; }
|
||||
@ -198,6 +199,24 @@ public sealed class SceneIntelligenceTimelineClue
|
||||
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SceneIntelligenceTemporalAnalysis
|
||||
{
|
||||
public string? AbsoluteDate { get; init; }
|
||||
public string? ExactTime { get; init; }
|
||||
public string? PartOfDay { get; init; }
|
||||
public string? RelativeTransition { get; init; }
|
||||
public int? RelativeAmount { get; init; }
|
||||
public string? RelativeUnit { get; init; }
|
||||
public string? TargetDayOfWeek { get; init; }
|
||||
public bool? CrossesMidnight { get; init; }
|
||||
public string? Evidence { get; init; }
|
||||
public decimal? Confidence { get; init; }
|
||||
public string? UnresolvedReason { get; init; }
|
||||
|
||||
[JsonExtensionData]
|
||||
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SceneIntelligenceQuestionRaised
|
||||
{
|
||||
public string? Question { get; init; }
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -153,6 +153,7 @@ public class Program
|
||||
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
|
||||
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
|
||||
builder.Services.AddScoped<IWholeBookPlotIntelligenceRepository, WholeBookPlotIntelligenceRepository>();
|
||||
builder.Services.AddScoped<ICharacterEnrichmentRepository, CharacterEnrichmentRepository>();
|
||||
builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>();
|
||||
builder.Services.AddScoped<ICharacterIntelligenceRepository, CharacterIntelligenceRepository>();
|
||||
builder.Services.AddScoped<INameIntelligenceRepository, NameIntelligenceRepository>();
|
||||
@ -236,6 +237,7 @@ public class Program
|
||||
builder.Services.AddScoped<IStoryIntelligenceNarrativeReviewService, StoryIntelligenceNarrativeReviewService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceReviewCandidateService, StoryIntelligenceReviewCandidateService>();
|
||||
builder.Services.AddScoped<IWholeBookPlotIntelligenceService, WholeBookPlotIntelligenceService>();
|
||||
builder.Services.AddScoped<ICharacterEnrichmentService, CharacterEnrichmentService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceSceneContextService, StoryIntelligenceSceneContextService>();
|
||||
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
|
||||
builder.Services.AddScoped<ICharacterIntelligenceService, CharacterIntelligenceService>();
|
||||
|
||||
428
PlotLine/Services/CharacterEnrichmentService.cs
Normal file
428
PlotLine/Services/CharacterEnrichmentService.cs
Normal file
@ -0,0 +1,428 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface ICharacterEnrichmentService
|
||||
{
|
||||
Task<CharacterEnrichmentRun?> QueueAfterCharacterResolutionAsync(int projectId, int bookId, int userId);
|
||||
Task<CharacterEnrichmentRun?> QueueForCharacterAsync(int characterId, int userId, int? bookId = null);
|
||||
Task<CharacterEnrichmentRun?> RetryAsync(int bookId, int userId);
|
||||
Task<CharacterEnrichmentRun?> GetCurrentAsync(int bookId);
|
||||
Task<CharacterEnrichmentRun?> GetCurrentForCharacterAsync(int characterId, int userId);
|
||||
Task<CharacterEnrichmentCharacterStatus?> GetCharacterStatusAsync(int characterId, int userId);
|
||||
Task<bool> ProcessNextAsync(CancellationToken cancellationToken);
|
||||
string BuildPromptForTest(CharacterEnrichmentContext context);
|
||||
StoryIntelligenceResponseContract BuildResponseContractForTest();
|
||||
}
|
||||
|
||||
public sealed class CharacterEnrichmentService(
|
||||
ICharacterEnrichmentRepository repository,
|
||||
IStoryIntelligencePipelineRepository pipelines,
|
||||
IStoryIntelligenceClient client,
|
||||
IOptions<StoryIntelligenceOptions> options,
|
||||
IStoryIntelligenceProgressNotifier notifier,
|
||||
ILogger<CharacterEnrichmentService> logger) : ICharacterEnrichmentService
|
||||
{
|
||||
private const string PromptVersion = "Character-Enrichment-V1";
|
||||
private const int BatchSize = 8;
|
||||
private const int MaxPromptCharacters = 350_000;
|
||||
private const decimal SummaryMinimumConfidence = 0.65m;
|
||||
private const decimal FactMinimumConfidence = 0.9m;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
private readonly StoryIntelligenceOptions settings = options.Value;
|
||||
|
||||
public async Task<CharacterEnrichmentRun?> QueueAfterCharacterResolutionAsync(int projectId, int bookId, int userId)
|
||||
{
|
||||
var pipeline = await pipelines.GetByBookForUserAsync(bookId, userId);
|
||||
return await repository.QueueAsync(new CharacterEnrichmentQueueRequest
|
||||
{
|
||||
ProjectID = projectId,
|
||||
BookID = bookId,
|
||||
UserID = userId,
|
||||
StoryIntelligenceBookPipelineID = pipeline?.StoryIntelligenceBookPipelineID,
|
||||
CharacterResolutionVersion = $"CharacterImport:{bookId}"
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<CharacterEnrichmentRun?> QueueForCharacterAsync(int characterId, int userId, int? bookId = null)
|
||||
{
|
||||
var run = await repository.QueueCharacterAsync(new CharacterEnrichmentCharacterQueueRequest
|
||||
{
|
||||
CharacterID = characterId,
|
||||
UserID = userId,
|
||||
BookID = bookId
|
||||
});
|
||||
if (run.WasCoalesced)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Manual character enrichment request ignored because an active run already exists. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} Status={Status}",
|
||||
run.CharacterEnrichmentRunID,
|
||||
run.ProjectID,
|
||||
run.BookID,
|
||||
run.CharacterID,
|
||||
run.Status);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Manual character enrichment queued. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} Status={Status}",
|
||||
run.CharacterEnrichmentRunID,
|
||||
run.ProjectID,
|
||||
run.BookID,
|
||||
run.CharacterID,
|
||||
run.Status);
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
public Task<CharacterEnrichmentRun?> RetryAsync(int bookId, int userId)
|
||||
=> repository.RetryAsync(bookId, userId);
|
||||
|
||||
public Task<CharacterEnrichmentRun?> GetCurrentAsync(int bookId)
|
||||
=> repository.GetCurrentAsync(bookId);
|
||||
|
||||
public Task<CharacterEnrichmentRun?> GetCurrentForCharacterAsync(int characterId, int userId)
|
||||
=> repository.GetCurrentForCharacterAsync(characterId, userId);
|
||||
|
||||
public Task<CharacterEnrichmentCharacterStatus?> GetCharacterStatusAsync(int characterId, int userId)
|
||||
=> repository.GetCharacterStatusAsync(characterId, userId);
|
||||
|
||||
public async Task<bool> ProcessNextAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var run = await repository.ClaimNextAsync(Math.Max(15, settings.ClaimLeaseMinutes));
|
||||
if (run is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var results = new List<CharacterEnrichmentCharacterResult>();
|
||||
var inputTokens = 0;
|
||||
var outputTokens = 0;
|
||||
var model = settings.EffectiveWholeBookPlotSynthesisModel;
|
||||
try
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Character enrichment starting. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID}",
|
||||
run.CharacterEnrichmentRunID,
|
||||
run.ProjectID,
|
||||
run.BookID,
|
||||
run.CharacterID);
|
||||
await PublishProgressAsync(run, 0, 0, "Preparing character analysis", "Preparing character analysis...", cancellationToken);
|
||||
|
||||
var context = await repository.BuildContextAsync(run.ProjectID, run.BookID, run.CharacterID);
|
||||
var characters = context.Characters
|
||||
.Where(character => character.Scenes.Count > 0)
|
||||
.OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
logger.LogInformation(
|
||||
"Character enrichment context prepared. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} Characters={CharacterCount}",
|
||||
run.CharacterEnrichmentRunID,
|
||||
run.ProjectID,
|
||||
run.BookID,
|
||||
run.CharacterID,
|
||||
characters.Count);
|
||||
|
||||
await PublishProgressAsync(run, characters.Count, 0, "Analysing characters", "Analysing characters...", cancellationToken);
|
||||
foreach (var batch in characters.Chunk(BatchSize))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var batchContext = new CharacterEnrichmentContext
|
||||
{
|
||||
ProjectID = context.ProjectID,
|
||||
BookID = context.BookID,
|
||||
BookTitle = context.BookTitle,
|
||||
StoryEra = context.StoryEra,
|
||||
SeriesStartDate = context.SeriesStartDate,
|
||||
Characters = batch
|
||||
};
|
||||
var prompt = BuildPrompt(batchContext);
|
||||
if (prompt.Length > MaxPromptCharacters)
|
||||
{
|
||||
throw new InvalidOperationException("Character enrichment prompt would exceed the configured safe context limit. No manuscript text was silently truncated.");
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Character enrichment batch starting. RunID={RunID} CharacterID={CharacterID} BatchCharacters={BatchCharacters} Processed={Processed}/{Total}",
|
||||
run.CharacterEnrichmentRunID,
|
||||
run.CharacterID,
|
||||
batch.Length,
|
||||
results.Count,
|
||||
characters.Count);
|
||||
await PublishProgressAsync(run, characters.Count, results.Count, "Creating summaries and extracting character details", "Creating summaries and extracting character details...", cancellationToken);
|
||||
|
||||
var clientResult = await client.ExecutePromptAsync(
|
||||
prompt,
|
||||
PromptVersion,
|
||||
cancellationToken,
|
||||
settings.EffectiveWholeBookPlotSynthesisModel,
|
||||
settings.WholeBookPlotSynthesisMaxOutputTokens.GetValueOrDefault(settings.MaxOutputTokens),
|
||||
responseContract: CharacterEnrichmentStructuredOutputSchema.Contract);
|
||||
model = clientResult.Model;
|
||||
inputTokens += clientResult.InputTokens ?? 0;
|
||||
outputTokens += clientResult.OutputTokens ?? 0;
|
||||
var parsed = CharacterEnrichmentResult.FromJson(ExtractOutputText(clientResult.RawResponseText));
|
||||
results.AddRange(FilterResults(parsed.Characters, batch.Select(character => character.CharacterID).ToHashSet()));
|
||||
await PublishProgressAsync(run, characters.Count, Math.Min(characters.Count, results.Count), "Saving character analysis", "Saving character analysis...", cancellationToken);
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
await repository.CompleteAsync(new CharacterEnrichmentCompletionRequest
|
||||
{
|
||||
CharacterEnrichmentRunID = run.CharacterEnrichmentRunID,
|
||||
Model = model,
|
||||
InputTokens = inputTokens == 0 ? null : inputTokens,
|
||||
OutputTokens = outputTokens == 0 ? null : outputTokens,
|
||||
TotalTokens = inputTokens + outputTokens == 0 ? null : inputTokens + outputTokens,
|
||||
DurationMs = stopwatch.ElapsedMilliseconds
|
||||
}, results);
|
||||
await notifier.PublishCharacterEnrichmentAsync(ToProgress(run, CharacterEnrichmentStatuses.Completed, results.Count, results.Count, "Complete", "Character summaries are ready."));
|
||||
logger.LogInformation(
|
||||
"Character enrichment completed. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} Characters={CharacterCount} DurationMs={DurationMs}",
|
||||
run.CharacterEnrichmentRunID,
|
||||
run.ProjectID,
|
||||
run.BookID,
|
||||
run.CharacterID,
|
||||
results.Count,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
await repository.FailAsync(run.CharacterEnrichmentRunID, ex.Message, ex.ToString(), stopwatch.ElapsedMilliseconds);
|
||||
await notifier.PublishCharacterEnrichmentAsync(ToProgress(run, CharacterEnrichmentStatuses.Failed, run.TotalCharacters, run.ProcessedCharacters, "Failed", "Character enrichment failed.", ex.Message));
|
||||
logger.LogError(ex, "Character enrichment failed. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID}", run.CharacterEnrichmentRunID, run.ProjectID, run.BookID, run.CharacterID);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public string BuildPromptForTest(CharacterEnrichmentContext context)
|
||||
=> BuildPrompt(context);
|
||||
|
||||
public StoryIntelligenceResponseContract BuildResponseContractForTest()
|
||||
=> CharacterEnrichmentStructuredOutputSchema.Contract;
|
||||
|
||||
private async Task PublishProgressAsync(CharacterEnrichmentRun run, int total, int processed, string stage, string message, CancellationToken cancellationToken)
|
||||
{
|
||||
await repository.UpdateProgressAsync(new CharacterEnrichmentProgressUpdate
|
||||
{
|
||||
CharacterEnrichmentRunID = run.CharacterEnrichmentRunID,
|
||||
TotalCharacters = total,
|
||||
ProcessedCharacters = processed,
|
||||
CurrentStage = stage,
|
||||
CurrentMessage = message
|
||||
});
|
||||
await notifier.PublishCharacterEnrichmentAsync(ToProgress(run, CharacterEnrichmentStatuses.Running, total, processed, stage, message));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CharacterEnrichmentCharacterResult> FilterResults(
|
||||
IReadOnlyList<CharacterEnrichmentCharacterResult> results,
|
||||
HashSet<int> allowedCharacterIds)
|
||||
=> results
|
||||
.Where(result => allowedCharacterIds.Contains(result.CharacterID))
|
||||
.Select(result => new CharacterEnrichmentCharacterResult
|
||||
{
|
||||
CharacterID = result.CharacterID,
|
||||
Summary = Clamp(result.Confidence) >= SummaryMinimumConfidence ? Clean(result.Summary, 2500) : string.Empty,
|
||||
Confidence = Clamp(result.Confidence),
|
||||
DateOfBirth = StrongFact(result.DateOfBirth),
|
||||
AgeAtStartOfSeries = StrongFact(result.AgeAtStartOfSeries),
|
||||
Height = StrongFact(result.Height),
|
||||
EyeColour = StrongFact(result.EyeColour),
|
||||
EvidenceSceneIDs = result.EvidenceSceneIDs.Where(id => id > 0).Distinct().Take(20).ToList()
|
||||
})
|
||||
.Where(result => !string.IsNullOrWhiteSpace(result.Summary)
|
||||
|| !string.IsNullOrWhiteSpace(result.DateOfBirth.Value)
|
||||
|| !string.IsNullOrWhiteSpace(result.AgeAtStartOfSeries.Value)
|
||||
|| !string.IsNullOrWhiteSpace(result.Height.Value)
|
||||
|| !string.IsNullOrWhiteSpace(result.EyeColour.Value))
|
||||
.ToList();
|
||||
|
||||
private static CharacterEnrichmentFactResult StrongFact(CharacterEnrichmentFactResult fact)
|
||||
=> Clamp(fact.Confidence) >= FactMinimumConfidence && !string.IsNullOrWhiteSpace(fact.Value) && fact.SceneID is > 0
|
||||
? new CharacterEnrichmentFactResult
|
||||
{
|
||||
Value = Clean(fact.Value, 120),
|
||||
Confidence = Clamp(fact.Confidence),
|
||||
Evidence = Clean(fact.Evidence, 700),
|
||||
SceneID = fact.SceneID
|
||||
}
|
||||
: new CharacterEnrichmentFactResult();
|
||||
|
||||
private static string BuildPrompt(CharacterEnrichmentContext context)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("You are PlotDirector's Character Enrichment engine.");
|
||||
builder.AppendLine("Create concise author-facing character summaries and extract only strongly evidenced character facts.");
|
||||
builder.AppendLine("Use only the supplied manuscript scenes. Do not invent, infer from behaviour alone, speculate about future events, or list every appearance.");
|
||||
builder.AppendLine("For dateOfBirth, ageAtStartOfSeries, height, and eyeColour, return a value only when direct manuscript wording strongly supports it. Otherwise return null value and confidence 0.");
|
||||
builder.AppendLine("If a character already has a stored value, preserve it by returning null unless the manuscript contains stronger direct evidence; the save layer will still refuse to overwrite existing author data.");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("[BOOK]");
|
||||
builder.AppendLine($"BookID: {context.BookID}");
|
||||
builder.AppendLine($"Title: {context.BookTitle}");
|
||||
builder.AppendLine($"Story era: {context.StoryEra}");
|
||||
builder.AppendLine($"Series start date: {context.SeriesStartDate:yyyy-MM-dd}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("[CHARACTERS]");
|
||||
foreach (var character in context.Characters)
|
||||
{
|
||||
builder.AppendLine();
|
||||
builder.AppendLine($"CharacterID: {character.CharacterID}");
|
||||
builder.AppendLine($"Name: {character.CharacterName}");
|
||||
builder.AppendLine($"Aliases: {string.Join(", ", character.Aliases)}");
|
||||
builder.AppendLine($"Existing birth date: {character.BirthDate:yyyy-MM-dd}");
|
||||
builder.AppendLine($"Existing age at series start: {character.AgeAtSeriesStart}");
|
||||
builder.AppendLine($"Existing height: {character.Height}");
|
||||
builder.AppendLine($"Existing eye colour: {character.EyeColour}");
|
||||
builder.AppendLine($"Existing description: {character.DefaultDescription}");
|
||||
builder.AppendLine("[SCENES]");
|
||||
foreach (var scene in character.Scenes.Take(60))
|
||||
{
|
||||
builder.AppendLine($"SceneID: {scene.SceneID}; Chapter {scene.ChapterNumber:0.##} {scene.ChapterTitle}; Scene {scene.SceneNumber:0.##} {scene.SceneTitle}");
|
||||
builder.AppendLine("<text>");
|
||||
builder.AppendLine(scene.SourceText);
|
||||
builder.AppendLine("</text>");
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static CharacterEnrichmentProgressEvent ToProgress(CharacterEnrichmentRun run, string status, int total, int processed, string stage, string message, string? error = null)
|
||||
=> new()
|
||||
{
|
||||
CharacterEnrichmentRunID = run.CharacterEnrichmentRunID,
|
||||
UserID = run.UserID,
|
||||
ProjectID = run.ProjectID,
|
||||
BookID = run.BookID,
|
||||
CharacterID = run.CharacterID,
|
||||
Status = status,
|
||||
TotalCharacters = total,
|
||||
ProcessedCharacters = processed,
|
||||
CurrentStage = stage,
|
||||
CurrentMessage = message,
|
||||
ErrorMessage = error,
|
||||
UpdatedUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
private static string ExtractOutputText(string rawResponseText)
|
||||
{
|
||||
using var response = JsonDocument.Parse(rawResponseText);
|
||||
foreach (var output in response.RootElement.GetProperty("output").EnumerateArray())
|
||||
{
|
||||
if (!output.TryGetProperty("content", out var content))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var item in content.EnumerateArray())
|
||||
{
|
||||
if (item.TryGetProperty("type", out var type)
|
||||
&& string.Equals(type.GetString(), "output_text", StringComparison.OrdinalIgnoreCase)
|
||||
&& item.TryGetProperty("text", out var text)
|
||||
&& !string.IsNullOrWhiteSpace(text.GetString()))
|
||||
{
|
||||
return text.GetString()!.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new JsonException("OpenAI response did not contain output_text content.");
|
||||
}
|
||||
|
||||
private static decimal Clamp(decimal value)
|
||||
=> Math.Clamp(value, 0m, 1m);
|
||||
|
||||
private static string Clean(string? value, int maxLength)
|
||||
{
|
||||
var clean = value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(clean))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return clean.Length <= maxLength ? clean : clean[..maxLength].TrimEnd();
|
||||
}
|
||||
}
|
||||
|
||||
public static class CharacterEnrichmentStructuredOutputSchema
|
||||
{
|
||||
public static readonly StoryIntelligenceResponseContract Contract = new()
|
||||
{
|
||||
Name = "character_enrichment",
|
||||
Strict = true,
|
||||
Schema = new Dictionary<string, object>
|
||||
{
|
||||
["type"] = "object",
|
||||
["additionalProperties"] = false,
|
||||
["required"] = new[] { "schemaVersion", "characters" },
|
||||
["properties"] = new Dictionary<string, object>
|
||||
{
|
||||
["schemaVersion"] = StringSchema(),
|
||||
["characters"] = ArraySchema(ObjectSchema(new Dictionary<string, object>
|
||||
{
|
||||
["characterID"] = IntegerSchema(),
|
||||
["summary"] = StringSchema(),
|
||||
["confidence"] = NumberSchema(),
|
||||
["dateOfBirth"] = FactSchema(),
|
||||
["ageAtStartOfSeries"] = FactSchema(),
|
||||
["height"] = FactSchema(),
|
||||
["eyeColour"] = FactSchema(),
|
||||
["evidenceSceneIDs"] = ArraySchema(IntegerSchema())
|
||||
}, "characterID", "summary", "confidence", "dateOfBirth", "ageAtStartOfSeries", "height", "eyeColour", "evidenceSceneIDs"))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private static Dictionary<string, object> FactSchema()
|
||||
=> ObjectSchema(new Dictionary<string, object>
|
||||
{
|
||||
["value"] = NullableStringSchema(),
|
||||
["confidence"] = NumberSchema(),
|
||||
["evidence"] = NullableStringSchema(),
|
||||
["sceneID"] = NullableIntegerSchema()
|
||||
}, "value", "confidence", "evidence", "sceneID");
|
||||
|
||||
private static Dictionary<string, object> ObjectSchema(Dictionary<string, object> properties, params string[] required)
|
||||
=> new()
|
||||
{
|
||||
["type"] = "object",
|
||||
["additionalProperties"] = false,
|
||||
["required"] = required,
|
||||
["properties"] = properties
|
||||
};
|
||||
|
||||
private static Dictionary<string, object> ArraySchema(object items)
|
||||
=> new()
|
||||
{
|
||||
["type"] = "array",
|
||||
["items"] = items
|
||||
};
|
||||
|
||||
private static Dictionary<string, object> StringSchema()
|
||||
=> new() { ["type"] = "string" };
|
||||
|
||||
private static Dictionary<string, object> NullableStringSchema()
|
||||
=> new() { ["type"] = new[] { "string", "null" } };
|
||||
|
||||
private static Dictionary<string, object> IntegerSchema()
|
||||
=> new() { ["type"] = "integer" };
|
||||
|
||||
private static Dictionary<string, object> NullableIntegerSchema()
|
||||
=> new() { ["type"] = new[] { "integer", "null" } };
|
||||
|
||||
private static Dictionary<string, object> NumberSchema()
|
||||
=> new() { ["type"] = "number", ["minimum"] = 0, ["maximum"] = 1 };
|
||||
}
|
||||
@ -269,6 +269,7 @@ public interface ICharacterService
|
||||
Task<CharacterMergeViewModel?> GetMergeAsync(int sourceCharacterId);
|
||||
Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId);
|
||||
Task<CharacterSaveResult> SaveCharacterAsync(CharacterEditViewModel model);
|
||||
Task<CharacterEnrichmentRun?> QueueCharacterReanalysisAsync(int characterId);
|
||||
Task SetPreferredNameAsync(int characterId, string preferredName);
|
||||
Task<CharacterMergeResult?> MergeAsync(CharacterMergeForm form);
|
||||
Task<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model);
|
||||
@ -1175,6 +1176,7 @@ public sealed class BookService(
|
||||
ISubscriptionService subscriptions,
|
||||
IProjectActivityService activity,
|
||||
IStoryIntelligencePipelineStateService storyIntelligencePipeline,
|
||||
IStoryIntelligenceResultRepository storyIntelligenceRuns,
|
||||
ICurrentUserService currentUser) : IBookService
|
||||
{
|
||||
public async Task<BookDetailViewModel?> GetDetailAsync(int bookId)
|
||||
@ -1194,6 +1196,9 @@ public sealed class BookService(
|
||||
var pipeline = currentUser.UserId is int pipelineUserId
|
||||
? await storyIntelligencePipeline.GetForBookAsync(bookId, pipelineUserId)
|
||||
: null;
|
||||
var runRows = currentUser.UserId is int runUserId
|
||||
? await storyIntelligenceRuns.ListRunsByBookForUserAsync(bookId, runUserId)
|
||||
: [];
|
||||
var hasCanonicalSceneSources = (await builds.ListSceneManuscriptSourcesByBookAsync(bookId)).Count > 0;
|
||||
|
||||
return new BookDetailViewModel
|
||||
@ -1205,6 +1210,7 @@ public sealed class BookService(
|
||||
: null,
|
||||
Chapters = await chapters.ListByBookAsync(bookId),
|
||||
StoryIntelligencePipeline = pipeline,
|
||||
StoryIntelligenceRuns = runRows,
|
||||
StoryIntelligenceAction = BuildStoryIntelligenceAction(pipeline, hasCanonicalSceneSources)
|
||||
};
|
||||
}
|
||||
@ -9247,7 +9253,8 @@ public sealed class CharacterService(
|
||||
ISubscriptionService subscriptions,
|
||||
IProjectActivityService activity,
|
||||
ICurrentUserService currentUser,
|
||||
IVisualIdentityImageService visualIdentityImages) : ICharacterService
|
||||
IVisualIdentityImageService visualIdentityImages,
|
||||
ICharacterEnrichmentService characterEnrichment) : ICharacterService
|
||||
{
|
||||
public async Task<CharacterListViewModel?> GetCharactersAsync(int projectId)
|
||||
{
|
||||
@ -9316,6 +9323,9 @@ public sealed class CharacterService(
|
||||
var projectCharacters = await characters.ListCharactersAsync(project.ProjectID);
|
||||
var relationshipLookups = await characters.GetLookupsAsync();
|
||||
var projectBooks = await books.ListByProjectAsync(project.ProjectID);
|
||||
var characterAnalysis = currentUser.UserId is int userId
|
||||
? await characterEnrichment.GetCharacterStatusAsync(character.CharacterID, userId) ?? new CharacterEnrichmentCharacterStatus { CharacterID = character.CharacterID }
|
||||
: new CharacterEnrichmentCharacterStatus { CharacterID = character.CharacterID };
|
||||
|
||||
var relationshipContainers = await characters.ListRelationshipsByCharacterAsync(character.CharacterID);
|
||||
var initialRelationships = await characters.ListInitialRelationshipsByCharacterAsync(character.CharacterID);
|
||||
@ -9367,10 +9377,21 @@ public sealed class CharacterService(
|
||||
CharacterOptions = projectCharacters.Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString())).ToList(),
|
||||
RelationshipTypeOptions = ToRelationshipTypeSelectList(relationshipLookups.RelationshipTypes),
|
||||
RelationshipStateOptions = relationshipLookups.RelationshipStates.Select(x => new SelectListItem(x.StateName, x.RelationshipStateID.ToString())).ToList(),
|
||||
BookOptions = projectBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookDisplayTitle}", x.BookID.ToString())).ToList()
|
||||
BookOptions = projectBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookDisplayTitle}", x.BookID.ToString())).ToList(),
|
||||
CharacterAnalysis = characterAnalysis
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<CharacterEnrichmentRun?> QueueCharacterReanalysisAsync(int characterId)
|
||||
{
|
||||
if (currentUser.UserId is not int userId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await characterEnrichment.QueueForCharacterAsync(characterId, userId);
|
||||
}
|
||||
|
||||
public async Task<CharacterMergeViewModel?> GetMergeAsync(int sourceCharacterId)
|
||||
{
|
||||
var source = await characters.GetCharacterAsync(sourceCharacterId);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -54,6 +54,11 @@ public sealed class PersistedStoryIntelligenceWorker(
|
||||
processedRun = await ProcessWholeBookPlotSynthesisAsync(stoppingToken);
|
||||
}
|
||||
|
||||
if (!processedRun)
|
||||
{
|
||||
processedRun = await ProcessCharacterEnrichmentAsync(stoppingToken);
|
||||
}
|
||||
|
||||
if (!processedRun)
|
||||
{
|
||||
await Task.Delay(pollDelay, stoppingToken);
|
||||
@ -68,6 +73,13 @@ public sealed class PersistedStoryIntelligenceWorker(
|
||||
return await synthesis.ProcessNextAsync(stoppingToken);
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessCharacterEnrichmentAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var enrichment = scope.ServiceProvider.GetRequiredService<ICharacterEnrichmentService>();
|
||||
return await enrichment.ProcessNextAsync(stoppingToken);
|
||||
}
|
||||
|
||||
private async Task RecoverReadySceneAnalysisAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
|
||||
@ -140,6 +140,8 @@ public sealed class StoryIntelligenceImportCommitService(
|
||||
|
||||
var timeModeId = FindId(lookupData.TimeModes, item => item.TimeModeName, item => item.TimeModeID, "Relative", "Unknown");
|
||||
var timeConfidenceId = FindId(lookupData.TimeConfidences, item => item.TimeConfidenceName, item => item.TimeConfidenceID, "Implied", "Unknown");
|
||||
var timeModeIds = lookupData.TimeModes.ToDictionary(item => item.TimeModeName, item => item.TimeModeID, StringComparer.OrdinalIgnoreCase);
|
||||
var timeConfidenceIds = lookupData.TimeConfidences.ToDictionary(item => item.TimeConfidenceName, item => item.TimeConfidenceID, StringComparer.OrdinalIgnoreCase);
|
||||
var revisionStatusId = FindId(lookupData.RevisionStatuses, item => item.StatusName, item => item.RevisionStatusID, "Planned");
|
||||
var timelineNoteTypeId = noteTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Timeline Note", StringComparison.OrdinalIgnoreCase))?.SceneNoteTypeID;
|
||||
if (timelineNoteTypeId is null)
|
||||
@ -158,7 +160,7 @@ public sealed class StoryIntelligenceImportCommitService(
|
||||
? await storyRuns.ListChapterScenesAsync(run.ChapterID.Value)
|
||||
: [];
|
||||
var canonicalById = canonicalScenes.ToDictionary(scene => scene.SceneID);
|
||||
var importScenes = new List<StoryIntelligenceSceneImportItem>();
|
||||
var rawImportScenes = new List<StoryIntelligenceSceneImportItem>();
|
||||
foreach (var sceneResult in sceneResults.OrderBy(scene => scene.TemporarySceneNumber))
|
||||
{
|
||||
canonicalById.TryGetValue(sceneResult.SceneID ?? 0, out var canonicalScene);
|
||||
@ -173,13 +175,23 @@ public sealed class StoryIntelligenceImportCommitService(
|
||||
}
|
||||
|
||||
warnings.Add($"Scene {sceneResult.TemporarySceneNumber:N0} will be created with limited detail because some analysis could not be read.");
|
||||
importScenes.Add(fallback);
|
||||
rawImportScenes.Add(fallback);
|
||||
continue;
|
||||
}
|
||||
|
||||
importScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap, canonicalScene));
|
||||
rawImportScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap, canonicalScene));
|
||||
}
|
||||
|
||||
var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null;
|
||||
var importScenes = ApplyTemporalResolution(
|
||||
rawImportScenes,
|
||||
StoryIntelligenceTemporalResolver.CreateChapterContext(chapter?.ChapterTitle, chapter?.Summary),
|
||||
timeModeIds,
|
||||
timeConfidenceIds,
|
||||
timeModeId,
|
||||
timeConfidenceId,
|
||||
warnings);
|
||||
|
||||
if (importScenes.Count == 0)
|
||||
{
|
||||
blockers.Add("No analysed scene results are available for this chapter.");
|
||||
@ -195,7 +207,6 @@ public sealed class StoryIntelligenceImportCommitService(
|
||||
|
||||
var projectName = run.ProjectID.HasValue ? (await projects.GetAsync(run.ProjectID.Value))?.ProjectName ?? $"Project {run.ProjectID.Value:N0}" : "None";
|
||||
var bookTitle = run.BookID.HasValue ? (await books.GetAsync(run.BookID.Value))?.BookDisplayTitle ?? $"Book {run.BookID.Value:N0}" : "None";
|
||||
var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null;
|
||||
var chapterLabel = chapter is null
|
||||
? "None"
|
||||
: string.IsNullOrWhiteSpace(chapter.ChapterTitle)
|
||||
@ -328,6 +339,67 @@ public sealed class StoryIntelligenceImportCommitService(
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<StoryIntelligenceSceneImportItem> ApplyTemporalResolution(
|
||||
IReadOnlyList<StoryIntelligenceSceneImportItem> scenes,
|
||||
StoryIntelligenceTemporalContext initialContext,
|
||||
IReadOnlyDictionary<string, int> timeModeIds,
|
||||
IReadOnlyDictionary<string, int> timeConfidenceIds,
|
||||
int defaultTimeModeId,
|
||||
int defaultTimeConfidenceId,
|
||||
ICollection<string> warnings)
|
||||
{
|
||||
var context = initialContext;
|
||||
var resolvedScenes = new List<StoryIntelligenceSceneImportItem>(scenes.Count);
|
||||
|
||||
foreach (var scene in scenes.OrderBy(item => item.TemporarySceneNumber))
|
||||
{
|
||||
var resolved = StoryIntelligenceTemporalResolver.Resolve(scene.ParsedScene, context);
|
||||
context = resolved.NextContext;
|
||||
|
||||
if (!timeModeIds.TryGetValue(resolved.TimeModeName, out var resolvedTimeModeId))
|
||||
{
|
||||
resolvedTimeModeId = defaultTimeModeId;
|
||||
warnings.Add($"Scene {scene.TemporarySceneNumber:N0} chronology used fallback time mode because '{resolved.TimeModeName}' was not available.");
|
||||
}
|
||||
|
||||
if (!timeConfidenceIds.TryGetValue(resolved.TimeConfidenceName, out var resolvedTimeConfidenceId))
|
||||
{
|
||||
resolvedTimeConfidenceId = defaultTimeConfidenceId;
|
||||
warnings.Add($"Scene {scene.TemporarySceneNumber:N0} chronology used fallback time confidence because '{resolved.TimeConfidenceName}' was not available.");
|
||||
}
|
||||
|
||||
resolvedScenes.Add(new StoryIntelligenceSceneImportItem
|
||||
{
|
||||
SceneResultID = scene.SceneResultID,
|
||||
SceneID = scene.SceneID,
|
||||
TemporarySceneNumber = scene.TemporarySceneNumber,
|
||||
StartParagraph = scene.StartParagraph,
|
||||
EndParagraph = scene.EndParagraph,
|
||||
SourceLabel = scene.SourceLabel,
|
||||
ParsedScene = scene.ParsedScene,
|
||||
PovCharacterID = scene.PovCharacterID,
|
||||
PurposeTypeIDs = scene.PurposeTypeIDs,
|
||||
Metrics = scene.Metrics,
|
||||
SceneTitle = scene.SceneTitle,
|
||||
Summary = scene.Summary,
|
||||
PurposeNotes = scene.PurposeNotes,
|
||||
OutcomeNotes = scene.OutcomeNotes,
|
||||
ImportNoteText = scene.ImportNoteText,
|
||||
TimeModeID = resolvedTimeModeId,
|
||||
StartDateTime = resolved.StartDateTime,
|
||||
EndDateTime = resolved.EndDateTime,
|
||||
DurationAmount = resolved.DurationAmount,
|
||||
DurationUnitID = resolved.DurationUnitID,
|
||||
RelativeTimeText = resolved.RelativeTimeText,
|
||||
TimeConfidenceID = resolvedTimeConfidenceId,
|
||||
AnalysisStatus = scene.AnalysisStatus,
|
||||
HasWarnings = scene.HasWarnings
|
||||
});
|
||||
}
|
||||
|
||||
return resolvedScenes;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<StoryIntelligenceSceneImportItem> BuildReviewScenes(
|
||||
IReadOnlyList<Scene> canonicalScenes,
|
||||
IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults,
|
||||
|
||||
@ -15,6 +15,7 @@ public sealed class StoryIntelligenceMaterialisationService(
|
||||
ISceneMetricTypeRepository metricTypes,
|
||||
ISceneRepository scenes,
|
||||
IChapterRepository chapters,
|
||||
ILookupRepository lookups,
|
||||
IStoryIntelligenceNarrativeReviewService narrative,
|
||||
IStoryIntelligenceReviewCandidateService reviewCandidates,
|
||||
ILogger<StoryIntelligenceMaterialisationService> logger) : IStoryIntelligenceMaterialisationService
|
||||
@ -85,12 +86,21 @@ public sealed class StoryIntelligenceMaterialisationService(
|
||||
.OrderBy(metric => metric.SortOrder)
|
||||
.ThenBy(metric => metric.MetricName)
|
||||
.ToList();
|
||||
var lookupData = await lookups.GetAllAsync();
|
||||
var timeModeIds = lookupData.TimeModes.ToDictionary(item => item.TimeModeName, item => item.TimeModeID, StringComparer.OrdinalIgnoreCase);
|
||||
var timeConfidenceIds = lookupData.TimeConfidences.ToDictionary(item => item.TimeConfidenceName, item => item.TimeConfidenceID, StringComparer.OrdinalIgnoreCase);
|
||||
var metricMap = BuildMetricMap(configuredMetrics);
|
||||
|
||||
var metricValuesSaved = 0;
|
||||
var chronologySaved = 0;
|
||||
var sceneResultsRead = 0;
|
||||
var sceneResultsParsed = 0;
|
||||
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
|
||||
var canonicalSceneMap = canonicalScenes
|
||||
.Where(scene => !scene.IsArchived)
|
||||
.GroupBy(scene => scene.SceneID)
|
||||
.ToDictionary(group => group.Key, group => group.First());
|
||||
var chapterMap = (await chapters.ListByBookAsync(bookId)).ToDictionary(chapter => chapter.ChapterID);
|
||||
|
||||
foreach (var run in scopedRuns.Where(run => run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
|
||||
{
|
||||
@ -107,7 +117,9 @@ public sealed class StoryIntelligenceMaterialisationService(
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var sceneResult in await results.ListSceneResultsAsync(run.StoryIntelligenceRunID))
|
||||
chapterMap.TryGetValue(run.ChapterID ?? 0, out var chapter);
|
||||
var temporalContext = StoryIntelligenceTemporalResolver.CreateChapterContext(chapter?.ChapterTitle, chapter?.Summary);
|
||||
foreach (var sceneResult in (await results.ListSceneResultsAsync(run.StoryIntelligenceRunID)).OrderBy(result => result.TemporarySceneNumber))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
sceneResultsRead++;
|
||||
@ -123,6 +135,17 @@ public sealed class StoryIntelligenceMaterialisationService(
|
||||
}
|
||||
|
||||
sceneResultsParsed++;
|
||||
var temporal = StoryIntelligenceTemporalResolver.Resolve(parsed, temporalContext);
|
||||
temporalContext = temporal.NextContext;
|
||||
if (sceneResult.SceneID is int sceneId
|
||||
&& canonicalSceneMap.TryGetValue(sceneId, out var canonicalScene)
|
||||
&& !HasExistingChronology(canonicalScene)
|
||||
&& TryApplyTemporalResolution(canonicalScene, temporal, timeModeIds, timeConfidenceIds))
|
||||
{
|
||||
await scenes.SaveAsync(canonicalScene);
|
||||
chronologySaved++;
|
||||
}
|
||||
|
||||
var metrics = BuildMetrics(parsed, metricMap);
|
||||
if (metrics.Count == 0)
|
||||
{
|
||||
@ -156,13 +179,14 @@ public sealed class StoryIntelligenceMaterialisationService(
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Story Intelligence deterministic materialisation completed for BookID={BookID}. PipelineID={PipelineID} Runs={RunCount} SceneResultsRead={SceneResultsRead} SceneResultsParsed={SceneResultsParsed} MetricsSaved={MetricsSaved} NarrativeCandidatesBefore={NarrativeCandidatesBefore} NarrativeCandidatesAfter={NarrativeCandidatesAfter}.",
|
||||
"Story Intelligence deterministic materialisation completed for BookID={BookID}. PipelineID={PipelineID} Runs={RunCount} SceneResultsRead={SceneResultsRead} SceneResultsParsed={SceneResultsParsed} MetricsSaved={MetricsSaved} ChronologySaved={ChronologySaved} NarrativeCandidatesBefore={NarrativeCandidatesBefore} NarrativeCandidatesAfter={NarrativeCandidatesAfter}.",
|
||||
bookId,
|
||||
pipeline.StoryIntelligenceBookPipelineID,
|
||||
scopedRuns.Count,
|
||||
sceneResultsRead,
|
||||
sceneResultsParsed,
|
||||
metricValuesSaved,
|
||||
chronologySaved,
|
||||
narrativeCandidatesBefore,
|
||||
narrativeCandidatesAfter);
|
||||
|
||||
@ -270,7 +294,7 @@ public sealed class StoryIntelligenceMaterialisationService(
|
||||
try
|
||||
{
|
||||
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(json, JsonOptions);
|
||||
if (direct?.Summary is not null || direct?.MetricValues?.Count > 0 || direct?.Metrics?.Count > 0)
|
||||
if (direct?.Summary is not null || direct?.TemporalAnalysis is not null || direct?.Setting is not null || direct?.MetricValues?.Count > 0 || direct?.Metrics?.Count > 0)
|
||||
{
|
||||
return direct;
|
||||
}
|
||||
@ -290,6 +314,40 @@ public sealed class StoryIntelligenceMaterialisationService(
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryApplyTemporalResolution(
|
||||
Scene scene,
|
||||
StoryIntelligenceTemporalResolution temporal,
|
||||
IReadOnlyDictionary<string, int> timeModeIds,
|
||||
IReadOnlyDictionary<string, int> timeConfidenceIds)
|
||||
{
|
||||
if (temporal.StartDateTime is null && string.IsNullOrWhiteSpace(temporal.RelativeTimeText))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!timeModeIds.TryGetValue(temporal.TimeModeName, out var timeModeId)
|
||||
|| !timeConfidenceIds.TryGetValue(temporal.TimeConfidenceName, out var timeConfidenceId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
scene.TimeModeID = timeModeId;
|
||||
scene.StartDateTime = temporal.StartDateTime;
|
||||
scene.EndDateTime = temporal.EndDateTime;
|
||||
scene.DurationAmount = temporal.DurationAmount;
|
||||
scene.DurationUnitID = temporal.DurationUnitID;
|
||||
scene.RelativeTimeText = temporal.RelativeTimeText;
|
||||
scene.TimeConfidenceID = timeConfidenceId;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasExistingChronology(Scene scene)
|
||||
=> scene.StartDateTime.HasValue
|
||||
|| scene.EndDateTime.HasValue
|
||||
|| scene.DurationAmount.HasValue
|
||||
|| scene.DurationUnitID.HasValue
|
||||
|| !string.IsNullOrWhiteSpace(scene.RelativeTimeText);
|
||||
|
||||
private static string Clean(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? string.Empty
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -106,6 +106,18 @@ public static class StoryIntelligenceSceneStructuredOutputSchema
|
||||
("absoluteDate", NullableString()),
|
||||
("evidence", Str()),
|
||||
("confidence", Confidence()))));
|
||||
var temporalAnalysis = Obj(Props(
|
||||
("absoluteDate", NullableString()),
|
||||
("exactTime", NullableString()),
|
||||
("partOfDay", NullableString()),
|
||||
("relativeTransition", NullableEnum("same", "later", "next", "following", "after", "before", "elapsed", "unknown")),
|
||||
("relativeAmount", NullableInteger()),
|
||||
("relativeUnit", NullableEnum("minute", "hour", "day", "week", "month", "year", "unknown")),
|
||||
("targetDayOfWeek", NullableEnum("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")),
|
||||
("crossesMidnight", NullableBoolean()),
|
||||
("evidence", NullableString()),
|
||||
("confidence", Confidence()),
|
||||
("unresolvedReason", NullableString())));
|
||||
var narrativeArcs = Arr(Obj(Props(
|
||||
("title", Str()),
|
||||
("description", Str()),
|
||||
@ -150,6 +162,7 @@ public static class StoryIntelligenceSceneStructuredOutputSchema
|
||||
("relationships", relationships),
|
||||
("knowledgeChanges", knowledgeChanges),
|
||||
("timelineClues", timelineClues),
|
||||
("temporalAnalysis", temporalAnalysis),
|
||||
("narrativeArcs", narrativeArcs),
|
||||
("questionsRaised", questionsRaised),
|
||||
("questionsAnswered", questionsAnswered),
|
||||
@ -196,6 +209,9 @@ public static class StoryIntelligenceSceneStructuredOutputSchema
|
||||
private static Dictionary<string, object> Bool()
|
||||
=> new() { ["type"] = "boolean" };
|
||||
|
||||
private static Dictionary<string, object> NullableBoolean()
|
||||
=> new() { ["type"] = new[] { "boolean", "null" } };
|
||||
|
||||
private static Dictionary<string, object> Confidence()
|
||||
=> new() { ["type"] = "number", ["minimum"] = 0, ["maximum"] = 1 };
|
||||
|
||||
|
||||
547
PlotLine/Services/StoryIntelligenceTemporalResolver.cs
Normal file
547
PlotLine/Services/StoryIntelligenceTemporalResolver.cs
Normal file
@ -0,0 +1,547 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public static class StoryIntelligenceTemporalResolver
|
||||
{
|
||||
private static readonly CultureInfo[] DateCultures = [CultureInfo.InvariantCulture, CultureInfo.GetCultureInfo("en-GB"), CultureInfo.GetCultureInfo("en-US")];
|
||||
private static readonly string[] DateFormats =
|
||||
[
|
||||
"d MMM yyyy",
|
||||
"dd MMM yyyy",
|
||||
"d MMMM yyyy",
|
||||
"dd MMMM yyyy",
|
||||
"MMM d yyyy",
|
||||
"MMMM d yyyy",
|
||||
"yyyy-MM-dd"
|
||||
];
|
||||
|
||||
private static readonly Dictionary<string, DayOfWeek> DaysOfWeek = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Monday"] = DayOfWeek.Monday,
|
||||
["Tuesday"] = DayOfWeek.Tuesday,
|
||||
["Wednesday"] = DayOfWeek.Wednesday,
|
||||
["Thursday"] = DayOfWeek.Thursday,
|
||||
["Friday"] = DayOfWeek.Friday,
|
||||
["Saturday"] = DayOfWeek.Saturday,
|
||||
["Sunday"] = DayOfWeek.Sunday
|
||||
};
|
||||
private static readonly Dictionary<string, int> ClockWords = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["one"] = 1,
|
||||
["two"] = 2,
|
||||
["three"] = 3,
|
||||
["four"] = 4,
|
||||
["five"] = 5,
|
||||
["six"] = 6,
|
||||
["seven"] = 7,
|
||||
["eight"] = 8,
|
||||
["nine"] = 9,
|
||||
["ten"] = 10,
|
||||
["eleven"] = 11,
|
||||
["twelve"] = 12
|
||||
};
|
||||
private static readonly Dictionary<string, int> MinuteWords = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["one"] = 1,
|
||||
["two"] = 2,
|
||||
["three"] = 3,
|
||||
["four"] = 4,
|
||||
["five"] = 5,
|
||||
["six"] = 6,
|
||||
["seven"] = 7,
|
||||
["eight"] = 8,
|
||||
["nine"] = 9,
|
||||
["ten"] = 10,
|
||||
["eleven"] = 11,
|
||||
["twelve"] = 12,
|
||||
["thirteen"] = 13,
|
||||
["fourteen"] = 14,
|
||||
["fifteen"] = 15,
|
||||
["sixteen"] = 16,
|
||||
["seventeen"] = 17,
|
||||
["eighteen"] = 18,
|
||||
["nineteen"] = 19,
|
||||
["twenty"] = 20,
|
||||
["thirty"] = 30,
|
||||
["forty"] = 40,
|
||||
["fifty"] = 50
|
||||
};
|
||||
|
||||
public static StoryIntelligenceTemporalContext CreateChapterContext(string? chapterTitle, string? chapterSummary = null, string? sourceLabel = null)
|
||||
{
|
||||
var anchor = ParseDate(FirstNonBlank(chapterTitle, sourceLabel, chapterSummary));
|
||||
return new StoryIntelligenceTemporalContext(anchor, null, null);
|
||||
}
|
||||
|
||||
public static StoryIntelligenceTemporalResolution Resolve(
|
||||
SceneIntelligenceScene? scene,
|
||||
StoryIntelligenceTemporalContext context)
|
||||
{
|
||||
var temporal = scene?.TemporalAnalysis;
|
||||
var fallbackText = FirstNonBlank(
|
||||
temporal?.Evidence,
|
||||
temporal?.AbsoluteDate,
|
||||
temporal?.ExactTime,
|
||||
temporal?.PartOfDay,
|
||||
scene?.Setting?.DateOrTimeReference,
|
||||
scene?.Setting?.TimeOfDay,
|
||||
scene?.TimelineClues?.Select(clue => FirstNonBlank(clue.Clue, clue.AbsoluteDate, clue.RelativeOrder)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)));
|
||||
|
||||
if (temporal is null)
|
||||
{
|
||||
temporal = InferFromLegacyFields(scene);
|
||||
}
|
||||
|
||||
var confidence = NormaliseConfidence(temporal?.Confidence);
|
||||
var absoluteDate = ParseDate(temporal?.AbsoluteDate) ?? ParseDate(fallbackText);
|
||||
var baseDate = absoluteDate ?? context.LastDate ?? context.ChapterAnchorDate;
|
||||
var resolvedDate = absoluteDate;
|
||||
var isDateDeterministic = absoluteDate.HasValue;
|
||||
var relativeText = FirstNonBlank(temporal?.Evidence, temporal?.AbsoluteDate, scene?.Setting?.DateOrTimeReference, scene?.Setting?.TimeOfDay);
|
||||
var currentPartOfDay = CleanPartOfDay(temporal?.PartOfDay ?? scene?.Setting?.TimeOfDay ?? fallbackText);
|
||||
var partOfDayForTime = currentPartOfDay ?? context.LastPartOfDay;
|
||||
var exactTime = ParseTime(FirstNonBlank(temporal?.ExactTime, temporal?.Evidence, scene?.Setting?.DateOrTimeReference), partOfDayForTime);
|
||||
|
||||
if (!resolvedDate.HasValue && baseDate.HasValue)
|
||||
{
|
||||
if (temporal?.CrossesMidnight == true)
|
||||
{
|
||||
resolvedDate = baseDate.Value.AddDays(1);
|
||||
isDateDeterministic = true;
|
||||
}
|
||||
else if (TryResolveRelativeDate(baseDate.Value, temporal, fallbackText, out var relativeDate, out var relativeIsExact))
|
||||
{
|
||||
resolvedDate = relativeDate;
|
||||
isDateDeterministic = relativeIsExact;
|
||||
}
|
||||
else if (IsSameDayReference(temporal, fallbackText))
|
||||
{
|
||||
resolvedDate = baseDate.Value.Date;
|
||||
isDateDeterministic = true;
|
||||
}
|
||||
else if (exactTime.HasValue)
|
||||
{
|
||||
resolvedDate = baseDate.Value.Date;
|
||||
isDateDeterministic = true;
|
||||
}
|
||||
else if (!HasApproximateSignal(temporal, fallbackText) && (currentPartOfDay is not null || HasSceneTemporalSignal(temporal, scene)))
|
||||
{
|
||||
resolvedDate = baseDate.Value.Date;
|
||||
isDateDeterministic = true;
|
||||
}
|
||||
else if (context.LastDate.HasValue && !HasApproximateSignal(temporal, fallbackText))
|
||||
{
|
||||
resolvedDate = context.LastDate.Value.Date;
|
||||
isDateDeterministic = true;
|
||||
}
|
||||
}
|
||||
|
||||
var mode = "Unknown";
|
||||
DateTime? start = null;
|
||||
if (resolvedDate.HasValue && isDateDeterministic)
|
||||
{
|
||||
if (exactTime.HasValue)
|
||||
{
|
||||
start = resolvedDate.Value.Date.Add(exactTime.Value);
|
||||
mode = "Exact DateTime";
|
||||
}
|
||||
else
|
||||
{
|
||||
start = resolvedDate.Value.Date;
|
||||
mode = "Exact Date";
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(relativeText))
|
||||
{
|
||||
mode = HasApproximateSignal(temporal, fallbackText) ? "Approximate" : "Relative";
|
||||
}
|
||||
|
||||
var displayText = string.Equals(mode, "Unknown", StringComparison.OrdinalIgnoreCase)
|
||||
? null
|
||||
: BuildRelativeText(start, exactTime, currentPartOfDay, relativeText, temporal);
|
||||
var resolvedConfidence = ResolveConfidenceName(confidence, isDateDeterministic, mode);
|
||||
var nextContext = context with
|
||||
{
|
||||
LastDate = start?.Date ?? (isDateDeterministic ? resolvedDate : context.LastDate),
|
||||
LastPartOfDay = currentPartOfDay ?? context.LastPartOfDay
|
||||
};
|
||||
|
||||
return new StoryIntelligenceTemporalResolution(start, null, null, null, displayText, mode, resolvedConfidence, nextContext);
|
||||
}
|
||||
|
||||
public static string AddRollingContextToPromptJson(string sceneContextJson, StoryIntelligenceTemporalContext context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sceneContextJson))
|
||||
{
|
||||
return sceneContextJson;
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(sceneContextJson);
|
||||
var clone = JsonSerializer.Deserialize<Dictionary<string, object?>>(document.RootElement.GetRawText()) ?? [];
|
||||
clone["rollingTemporalContext"] = new
|
||||
{
|
||||
chapterAnchorDate = context.ChapterAnchorDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
previousResolvedDate = context.LastDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
previousPartOfDay = context.LastPartOfDay
|
||||
};
|
||||
return JsonSerializer.Serialize(clone, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
}
|
||||
|
||||
private static SceneIntelligenceTemporalAnalysis? InferFromLegacyFields(SceneIntelligenceScene? scene)
|
||||
{
|
||||
var text = FirstNonBlank(scene?.Setting?.DateOrTimeReference, scene?.Setting?.TimeOfDay, scene?.TimelineClues?.FirstOrDefault()?.Clue);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SceneIntelligenceTemporalAnalysis
|
||||
{
|
||||
Evidence = text,
|
||||
PartOfDay = CleanPartOfDay(text),
|
||||
Confidence = scene?.Setting?.Confidence ?? scene?.TimelineClues?.FirstOrDefault()?.Confidence
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryResolveRelativeDate(DateTime baseDate, SceneIntelligenceTemporalAnalysis? temporal, string? fallbackText, out DateTime date, out bool exact)
|
||||
{
|
||||
date = baseDate.Date;
|
||||
exact = false;
|
||||
var text = Clean(FirstNonBlank(temporal?.Evidence, fallbackText, temporal?.RelativeTransition));
|
||||
if (temporal?.TargetDayOfWeek is not null && DaysOfWeek.TryGetValue(temporal.TargetDayOfWeek, out var targetDay))
|
||||
{
|
||||
var dayOffset = ((int)targetDay - (int)baseDate.DayOfWeek + 7) % 7;
|
||||
date = baseDate.Date.AddDays(dayOffset == 0 ? 7 : dayOffset);
|
||||
exact = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (temporal?.CrossesMidnight == true || Contains(text, "after midnight") || Contains(text, "past midnight"))
|
||||
{
|
||||
date = baseDate.Date.AddDays(1);
|
||||
exact = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
var amount = temporal?.RelativeAmount;
|
||||
var unit = Clean(temporal?.RelativeUnit);
|
||||
if (amount.HasValue && amount.Value != 0 && IsDayUnit(unit))
|
||||
{
|
||||
date = baseDate.Date.AddDays(amount.Value);
|
||||
exact = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (RegexMatch(text, @"\b(\d+)\s+days?\s+later\b", out var value) && int.TryParse(value, out var elapsedDays))
|
||||
{
|
||||
date = baseDate.Date.AddDays(elapsedDays);
|
||||
exact = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Contains(text, "following morning") || Contains(text, "next morning"))
|
||||
{
|
||||
date = baseDate.Date.AddDays(1);
|
||||
exact = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Contains(text, "later that") || Contains(text, "same day") || Contains(text, "that afternoon") || Contains(text, "that evening"))
|
||||
{
|
||||
date = baseDate.Date;
|
||||
exact = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static DateTime? ParseDate(string? value)
|
||||
{
|
||||
var clean = StripOrdinalSuffixes(Clean(value));
|
||||
if (string.IsNullOrWhiteSpace(clean))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var culture in DateCultures)
|
||||
{
|
||||
if (DateTime.TryParseExact(clean, DateFormats, culture, DateTimeStyles.AllowWhiteSpaces, out var exact))
|
||||
{
|
||||
return exact.Date;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(clean, culture, DateTimeStyles.AllowWhiteSpaces, out var parsed) && parsed.Year > 1000)
|
||||
{
|
||||
return parsed.Date;
|
||||
}
|
||||
}
|
||||
|
||||
var embeddedDate = System.Text.RegularExpressions.Regex.Match(
|
||||
clean,
|
||||
@"\b\d{1,2}\s+(?:Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)\s+\d{4}\b",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
if (embeddedDate.Success)
|
||||
{
|
||||
return ParseDate(embeddedDate.Value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseTime(string? value, string? partOfDay)
|
||||
{
|
||||
var clean = Clean(value)?.Replace('’', '\'');
|
||||
if (string.IsNullOrWhiteSpace(clean))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var hasMeridiem = clean.Contains("am", StringComparison.OrdinalIgnoreCase)
|
||||
|| clean.Contains("pm", StringComparison.OrdinalIgnoreCase)
|
||||
|| clean.Contains("morning", StringComparison.OrdinalIgnoreCase)
|
||||
|| clean.Contains("afternoon", StringComparison.OrdinalIgnoreCase)
|
||||
|| clean.Contains("evening", StringComparison.OrdinalIgnoreCase)
|
||||
|| clean.Contains("night", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!hasMeridiem && !clean.Contains(':') && string.IsNullOrWhiteSpace(partOfDay))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var pair in ClockWords)
|
||||
{
|
||||
if (clean.Contains($"{pair.Key} o'clock", StringComparison.OrdinalIgnoreCase)
|
||||
|| clean.Contains($"{pair.Key} oclock", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var clock = ApplyPartOfDayToClockHour(pair.Value, partOfDay ?? clean);
|
||||
if (clock.HasValue)
|
||||
{
|
||||
return clock;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (RegexMatch(clean, @"\b(\d{1,2})\s*o'?clock\b", out var hourText) && int.TryParse(hourText, out var hour))
|
||||
{
|
||||
var clock = ApplyPartOfDayToClockHour(hour, partOfDay ?? clean);
|
||||
if (clock.HasValue)
|
||||
{
|
||||
return clock;
|
||||
}
|
||||
}
|
||||
|
||||
if (RegexMatch(clean, @"\b(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\s*o'?clock\b", out hourText)
|
||||
&& ClockWords.TryGetValue(hourText, out hour))
|
||||
{
|
||||
var clock = ApplyPartOfDayToClockHour(hour, partOfDay ?? clean);
|
||||
if (clock.HasValue)
|
||||
{
|
||||
return clock;
|
||||
}
|
||||
}
|
||||
|
||||
if (TryParseWordClock(clean, partOfDay, out var wordClock))
|
||||
{
|
||||
return wordClock;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(clean, CultureInfo.InvariantCulture, DateTimeStyles.NoCurrentDateDefault, out var parsed))
|
||||
{
|
||||
return parsed.TimeOfDay;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseWordClock(string text, string? partOfDay, out TimeSpan time)
|
||||
{
|
||||
time = default;
|
||||
var match = System.Text.RegularExpressions.Regex.Match(
|
||||
text,
|
||||
@"\b(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)[-\s]+((?:one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty)(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?)\b",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
if (!match.Success || !ClockWords.TryGetValue(match.Groups[1].Value, out var hour) || !TryParseMinuteWords(match.Groups[2].Value, out var minute))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var clock = ApplyPartOfDayToClockHour(hour, partOfDay);
|
||||
if (!clock.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
time = clock.Value.Add(TimeSpan.FromMinutes(minute));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseMinuteWords(string value, out int minute)
|
||||
{
|
||||
minute = 0;
|
||||
var parts = value.Split(['-', ' '], StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length == 0 || parts.Length > 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!MinuteWords.TryGetValue(parts[0], out minute))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
if (!MinuteWords.TryGetValue(parts[1], out var ones) || ones >= 10)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
minute += ones;
|
||||
}
|
||||
|
||||
return minute is >= 0 and < 60;
|
||||
}
|
||||
|
||||
private static TimeSpan? ApplyPartOfDayToClockHour(int hour, string? partOfDay)
|
||||
{
|
||||
var lowerPart = Clean(partOfDay);
|
||||
if (Contains(lowerPart, "evening") || Contains(lowerPart, "night"))
|
||||
{
|
||||
hour = hour == 12 ? 12 : hour + 12;
|
||||
return TimeSpan.FromHours(hour);
|
||||
}
|
||||
|
||||
if (Contains(lowerPart, "morning"))
|
||||
{
|
||||
return TimeSpan.FromHours(hour == 12 ? 0 : hour);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? BuildRelativeText(DateTime? start, TimeSpan? exactTime, string? partOfDay, string? relativeText, SceneIntelligenceTemporalAnalysis? temporal)
|
||||
{
|
||||
var cleanRelative = Clean(FirstNonBlank(relativeText, temporal?.Evidence, temporal?.UnresolvedReason));
|
||||
if (start.HasValue && exactTime is null)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(partOfDay)
|
||||
? null
|
||||
: $"{start.Value:dd MMM yyyy}, {partOfDay}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(cleanRelative) && !start.HasValue)
|
||||
{
|
||||
return cleanRelative;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ResolveConfidenceName(decimal confidence, bool exactDate, string mode)
|
||||
{
|
||||
if (string.Equals(mode, "Unknown", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
if (confidence >= 0.9m && exactDate)
|
||||
{
|
||||
return "Confirmed";
|
||||
}
|
||||
|
||||
return exactDate ? "Implied" : "Estimated";
|
||||
}
|
||||
|
||||
private static decimal NormaliseConfidence(decimal? confidence)
|
||||
=> confidence is >= 0 and <= 1 ? confidence.Value : 0.0m;
|
||||
|
||||
private static bool IsSameDayReference(SceneIntelligenceTemporalAnalysis? temporal, string? fallbackText)
|
||||
=> Contains(Clean(FirstNonBlank(temporal?.RelativeTransition, temporal?.Evidence, fallbackText)), "same")
|
||||
|| Contains(Clean(FirstNonBlank(temporal?.Evidence, fallbackText)), "later that");
|
||||
|
||||
private static bool HasApproximateSignal(SceneIntelligenceTemporalAnalysis? temporal, string? fallbackText)
|
||||
=> Contains(Clean(temporal?.UnresolvedReason), "few")
|
||||
|| Contains(Clean(temporal?.Evidence), "few")
|
||||
|| Contains(Clean(fallbackText), "few")
|
||||
|| Contains(Clean(temporal?.UnresolvedReason), "approx")
|
||||
|| Contains(Clean(temporal?.Evidence), "approx")
|
||||
|| Contains(Clean(fallbackText), "approx");
|
||||
|
||||
private static bool HasSceneTemporalSignal(SceneIntelligenceTemporalAnalysis? temporal, SceneIntelligenceScene? scene)
|
||||
=> temporal is not null
|
||||
&& (temporal.Confidence > 0
|
||||
|| !string.IsNullOrWhiteSpace(temporal.AbsoluteDate)
|
||||
|| !string.IsNullOrWhiteSpace(temporal.ExactTime)
|
||||
|| !string.IsNullOrWhiteSpace(temporal.Evidence)
|
||||
|| !string.IsNullOrWhiteSpace(scene?.Setting?.DateOrTimeReference)
|
||||
|| !string.IsNullOrWhiteSpace(scene?.Setting?.TimeOfDay));
|
||||
|
||||
private static bool IsDayUnit(string? unit)
|
||||
=> string.Equals(unit, "day", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(unit, "days", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string? CleanPartOfDay(string? value)
|
||||
{
|
||||
var text = Clean(value);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var part in new[] { "morning", "afternoon", "evening", "night", "dawn", "dusk", "midday", "midnight" })
|
||||
{
|
||||
if (text.Contains(part, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return part;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool Contains(string? text, string value)
|
||||
=> !string.IsNullOrWhiteSpace(text) && text.Contains(value, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool RegexMatch(string? text, string pattern, out string value)
|
||||
{
|
||||
value = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var match = System.Text.RegularExpressions.Regex.Match(text, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
if (!match.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = match.Groups[1].Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string Clean(string? value)
|
||||
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
private static string StripOrdinalSuffixes(string value)
|
||||
=> System.Text.RegularExpressions.Regex.Replace(value, @"\b(\d{1,2})(st|nd|rd|th)\b", "$1", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
private static string? FirstNonBlank(params string?[] values)
|
||||
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
|
||||
}
|
||||
|
||||
public sealed record StoryIntelligenceTemporalContext(DateTime? ChapterAnchorDate, DateTime? LastDate, string? LastPartOfDay);
|
||||
|
||||
public sealed record StoryIntelligenceTemporalResolution(
|
||||
DateTime? StartDateTime,
|
||||
DateTime? EndDateTime,
|
||||
decimal? DurationAmount,
|
||||
int? DurationUnitID,
|
||||
string? RelativeTimeText,
|
||||
string TimeModeName,
|
||||
string TimeConfidenceName,
|
||||
StoryIntelligenceTemporalContext NextContext);
|
||||
@ -17,6 +17,9 @@ public sealed class StorySceneValidator : IStorySceneValidator
|
||||
private static readonly HashSet<string> ObservationTypes = CreateSet("CharacterAction", "CharacterKnowledge", "CharacterLocation", "RelationshipSignal", "AssetInteraction", "LocationSignal", "TimelineSignal", "QuestionSignal");
|
||||
private static readonly HashSet<string> EntityTypes = CreateSet("Character", "Location", "Asset", "Knowledge", "Relationship", "Timeline", "Scene", "Unknown");
|
||||
private static readonly HashSet<string> RelationshipSignals = CreateSet("trust", "mistrust", "affection", "fear", "authority", "secrecy", "conflict", "dependence", "protection", "rivalry", "obligation");
|
||||
private static readonly HashSet<string> TemporalTransitions = CreateSet("same", "later", "next", "following", "after", "before", "elapsed", "unknown");
|
||||
private static readonly HashSet<string> TemporalUnits = CreateSet("minute", "hour", "day", "week", "month", "year", "unknown");
|
||||
private static readonly HashSet<string> DaysOfWeek = CreateSet("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday");
|
||||
|
||||
public ValidationResult Validate(SceneIntelligenceScene? scene)
|
||||
{
|
||||
@ -42,6 +45,7 @@ public sealed class StorySceneValidator : IStorySceneValidator
|
||||
ValidateRelationships(scene.Relationships, result);
|
||||
ValidateKnowledgeChanges(scene.KnowledgeChanges, result);
|
||||
ValidateTimelineClues(scene.TimelineClues, result);
|
||||
ValidateTemporalAnalysis(scene.TemporalAnalysis, result);
|
||||
ValidateNarrativeArcs(scene.NarrativeArcs, result);
|
||||
ValidateQuestionsRaised(scene.QuestionsRaised, result);
|
||||
ValidateQuestionsAnswered(scene.QuestionsAnswered, result);
|
||||
@ -81,6 +85,7 @@ public sealed class StorySceneValidator : IStorySceneValidator
|
||||
RequiredArray(scene.Relationships, "relationships", result);
|
||||
RequiredArray(scene.KnowledgeChanges, "knowledgeChanges", result);
|
||||
RequiredArray(scene.TimelineClues, "timelineClues", result);
|
||||
RequiredObject(scene.TemporalAnalysis, "temporalAnalysis", result);
|
||||
RequiredArray(scene.NarrativeArcs, "narrativeArcs", result);
|
||||
RequiredArray(scene.QuestionsRaised, "questionsRaised", result);
|
||||
RequiredArray(scene.QuestionsAnswered, "questionsAnswered", result);
|
||||
@ -326,6 +331,27 @@ public sealed class StorySceneValidator : IStorySceneValidator
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateTemporalAnalysis(SceneIntelligenceTemporalAnalysis? value, ValidationResult result)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RequiredScalar(value.AbsoluteDate, "temporalAnalysis.absoluteDate", result, allowNull: true);
|
||||
RequiredScalar(value.ExactTime, "temporalAnalysis.exactTime", result, allowNull: true);
|
||||
RequiredScalar(value.PartOfDay, "temporalAnalysis.partOfDay", result, allowNull: true);
|
||||
NullableEnum(value.RelativeTransition, "temporalAnalysis.relativeTransition", TemporalTransitions, result);
|
||||
RequiredScalar(value.RelativeAmount, "temporalAnalysis.relativeAmount", result, allowNull: true);
|
||||
NullableEnum(value.RelativeUnit, "temporalAnalysis.relativeUnit", TemporalUnits, result);
|
||||
NullableEnum(value.TargetDayOfWeek, "temporalAnalysis.targetDayOfWeek", DaysOfWeek, result);
|
||||
RequiredScalar(value.CrossesMidnight, "temporalAnalysis.crossesMidnight", result, allowNull: true);
|
||||
RequiredScalar(value.Evidence, "temporalAnalysis.evidence", result, allowNull: true);
|
||||
Confidence(value.Confidence, "temporalAnalysis.confidence", result);
|
||||
RequiredScalar(value.UnresolvedReason, "temporalAnalysis.unresolvedReason", result, allowNull: true);
|
||||
Unknown(value.ExtensionData, "temporalAnalysis", result);
|
||||
}
|
||||
|
||||
private static void ValidateQuestionsRaised(List<SceneIntelligenceQuestionRaised>? values, ValidationResult result)
|
||||
{
|
||||
if (values is null)
|
||||
|
||||
465
PlotLine/Sql/183_Phase27J_CharacterEnrichmentRuns.sql
Normal file
465
PlotLine/Sql/183_Phase27J_CharacterEnrichmentRuns.sql
Normal file
@ -0,0 +1,465 @@
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.CharacterEnrichmentRuns', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.CharacterEnrichmentRuns
|
||||
(
|
||||
CharacterEnrichmentRunID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CharacterEnrichmentRuns PRIMARY KEY,
|
||||
ProjectID int NOT NULL,
|
||||
BookID int NOT NULL,
|
||||
UserID int NOT NULL,
|
||||
StoryIntelligenceBookPipelineID int NULL,
|
||||
CharacterResolutionVersion nvarchar(120) NOT NULL,
|
||||
Status nvarchar(30) NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_Status DEFAULT N'Queued',
|
||||
TotalCharacters int NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_TotalCharacters DEFAULT 0,
|
||||
ProcessedCharacters int NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_ProcessedCharacters DEFAULT 0,
|
||||
CurrentStage nvarchar(120) NULL,
|
||||
CurrentMessage nvarchar(300) NULL,
|
||||
QueuedUtc datetime2 NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_QueuedUtc DEFAULT SYSUTCDATETIME(),
|
||||
StartedUtc datetime2 NULL,
|
||||
CompletedUtc datetime2 NULL,
|
||||
FailedUtc datetime2 NULL,
|
||||
AttemptCount int NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_AttemptCount DEFAULT 0,
|
||||
Model nvarchar(100) NULL,
|
||||
InputTokens int NULL,
|
||||
OutputTokens int NULL,
|
||||
TotalTokens int NULL,
|
||||
DurationMs bigint NULL,
|
||||
ErrorMessage nvarchar(max) NULL,
|
||||
ErrorDetail nvarchar(max) NULL,
|
||||
IsCurrent bit NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_IsCurrent DEFAULT 1,
|
||||
UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_UpdatedUtc DEFAULT SYSUTCDATETIME(),
|
||||
CONSTRAINT FK_CharacterEnrichmentRuns_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID),
|
||||
CONSTRAINT FK_CharacterEnrichmentRuns_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID) ON DELETE CASCADE,
|
||||
CONSTRAINT FK_CharacterEnrichmentRuns_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID),
|
||||
CONSTRAINT FK_CharacterEnrichmentRuns_Pipeline FOREIGN KEY (StoryIntelligenceBookPipelineID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID)
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CharacterEnrichmentRuns_Queue' AND object_id = OBJECT_ID(N'dbo.CharacterEnrichmentRuns'))
|
||||
CREATE INDEX IX_CharacterEnrichmentRuns_Queue ON dbo.CharacterEnrichmentRuns(Status, QueuedUtc) INCLUDE (BookID, UserID);
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_CharacterEnrichmentRuns_CurrentVersion' AND object_id = OBJECT_ID(N'dbo.CharacterEnrichmentRuns'))
|
||||
CREATE UNIQUE INDEX UX_CharacterEnrichmentRuns_CurrentVersion
|
||||
ON dbo.CharacterEnrichmentRuns(BookID, CharacterResolutionVersion)
|
||||
WHERE IsCurrent = 1 AND Status IN (N'Queued', N'Running', N'Completed');
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.CharacterEnrichmentEvidence', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.CharacterEnrichmentEvidence
|
||||
(
|
||||
CharacterEnrichmentEvidenceID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CharacterEnrichmentEvidence PRIMARY KEY,
|
||||
CharacterEnrichmentRunID int NOT NULL,
|
||||
CharacterID int NOT NULL,
|
||||
SceneID int NULL,
|
||||
FieldName nvarchar(80) NOT NULL,
|
||||
Value nvarchar(500) NULL,
|
||||
Evidence nvarchar(700) NULL,
|
||||
Confidence decimal(5,4) NOT NULL CONSTRAINT DF_CharacterEnrichmentEvidence_Confidence DEFAULT 0,
|
||||
Source nvarchar(80) NOT NULL CONSTRAINT DF_CharacterEnrichmentEvidence_Source DEFAULT N'Character enrichment',
|
||||
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_CharacterEnrichmentEvidence_CreatedUtc DEFAULT SYSUTCDATETIME(),
|
||||
CONSTRAINT FK_CharacterEnrichmentEvidence_Run FOREIGN KEY (CharacterEnrichmentRunID) REFERENCES dbo.CharacterEnrichmentRuns(CharacterEnrichmentRunID) ON DELETE CASCADE,
|
||||
CONSTRAINT FK_CharacterEnrichmentEvidence_Character FOREIGN KEY (CharacterID) REFERENCES dbo.Characters(CharacterID),
|
||||
CONSTRAINT FK_CharacterEnrichmentEvidence_Scene FOREIGN KEY (SceneID) REFERENCES dbo.Scenes(SceneID)
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CharacterEnrichmentEvidence_Character' AND object_id = OBJECT_ID(N'dbo.CharacterEnrichmentEvidence'))
|
||||
CREATE INDEX IX_CharacterEnrichmentEvidence_Character ON dbo.CharacterEnrichmentEvidence(CharacterID, CharacterEnrichmentRunID, FieldName);
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Queue
|
||||
@ProjectID int,
|
||||
@BookID int,
|
||||
@UserID int,
|
||||
@StoryIntelligenceBookPipelineID int = NULL,
|
||||
@CharacterResolutionVersion nvarchar(120)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
DECLARE @ExistingID int, @TotalCharacters int;
|
||||
|
||||
SELECT @TotalCharacters = COUNT(DISTINCT c.CharacterID)
|
||||
FROM dbo.Characters c
|
||||
INNER JOIN dbo.SceneCharacters sc ON sc.CharacterID = c.CharacterID
|
||||
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
|
||||
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.IsArchived = 0
|
||||
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
|
||||
WHERE c.ProjectID = @ProjectID
|
||||
AND c.IsArchived = 0
|
||||
AND ch.BookID = @BookID;
|
||||
|
||||
SELECT TOP (1) @ExistingID = CharacterEnrichmentRunID
|
||||
FROM dbo.CharacterEnrichmentRuns
|
||||
WHERE BookID = @BookID
|
||||
AND CharacterResolutionVersion = @CharacterResolutionVersion
|
||||
AND IsCurrent = 1
|
||||
AND Status IN (N'Queued', N'Running', N'Completed')
|
||||
ORDER BY CharacterEnrichmentRunID DESC;
|
||||
|
||||
IF @ExistingID IS NULL
|
||||
BEGIN
|
||||
INSERT dbo.CharacterEnrichmentRuns
|
||||
(ProjectID, BookID, UserID, StoryIntelligenceBookPipelineID, CharacterResolutionVersion, Status, TotalCharacters, CurrentStage, CurrentMessage)
|
||||
VALUES
|
||||
(@ProjectID, @BookID, @UserID, @StoryIntelligenceBookPipelineID, @CharacterResolutionVersion, N'Queued', @TotalCharacters, N'Preparing character analysis', N'Preparing character analysis...');
|
||||
SET @ExistingID = CAST(SCOPE_IDENTITY() AS int);
|
||||
END;
|
||||
|
||||
SELECT * FROM dbo.CharacterEnrichmentRuns WHERE CharacterEnrichmentRunID = @ExistingID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Retry
|
||||
@BookID int,
|
||||
@UserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
DECLARE @PreviousID int, @ProjectID int, @PipelineID int, @Version nvarchar(120);
|
||||
SELECT TOP (1)
|
||||
@PreviousID = CharacterEnrichmentRunID,
|
||||
@ProjectID = ProjectID,
|
||||
@PipelineID = StoryIntelligenceBookPipelineID,
|
||||
@Version = CharacterResolutionVersion
|
||||
FROM dbo.CharacterEnrichmentRuns
|
||||
WHERE BookID = @BookID AND UserID = @UserID AND IsCurrent = 1 AND Status = N'Failed'
|
||||
ORDER BY CharacterEnrichmentRunID DESC;
|
||||
|
||||
IF @PreviousID IS NULL
|
||||
BEGIN
|
||||
SELECT TOP (0) * FROM dbo.CharacterEnrichmentRuns;
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
UPDATE dbo.CharacterEnrichmentRuns
|
||||
SET IsCurrent = 0,
|
||||
Status = N'Superseded',
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE CharacterEnrichmentRunID = @PreviousID;
|
||||
|
||||
EXEC dbo.CharacterEnrichmentRun_Queue
|
||||
@ProjectID = @ProjectID,
|
||||
@BookID = @BookID,
|
||||
@UserID = @UserID,
|
||||
@StoryIntelligenceBookPipelineID = @PipelineID,
|
||||
@CharacterResolutionVersion = @Version;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_ClaimNext
|
||||
@LeaseMinutes int = 90
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
DECLARE @RunID int;
|
||||
|
||||
SELECT TOP (1) @RunID = CharacterEnrichmentRunID
|
||||
FROM dbo.CharacterEnrichmentRuns WITH (UPDLOCK, READPAST)
|
||||
WHERE IsCurrent = 1
|
||||
AND (
|
||||
Status = N'Queued'
|
||||
OR (Status = N'Running' AND StartedUtc < DATEADD(minute, -ABS(@LeaseMinutes), SYSUTCDATETIME()))
|
||||
)
|
||||
ORDER BY QueuedUtc, CharacterEnrichmentRunID;
|
||||
|
||||
IF @RunID IS NULL
|
||||
BEGIN
|
||||
SELECT TOP (0) * FROM dbo.CharacterEnrichmentRuns;
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
UPDATE dbo.CharacterEnrichmentRuns
|
||||
SET Status = N'Running',
|
||||
StartedUtc = COALESCE(StartedUtc, SYSUTCDATETIME()),
|
||||
AttemptCount = AttemptCount + 1,
|
||||
CurrentStage = N'Preparing character analysis',
|
||||
CurrentMessage = N'Preparing character analysis...',
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE CharacterEnrichmentRunID = @RunID;
|
||||
|
||||
SELECT * FROM dbo.CharacterEnrichmentRuns WHERE CharacterEnrichmentRunID = @RunID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_GetCurrentByBook
|
||||
@BookID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT TOP (1) *
|
||||
FROM dbo.CharacterEnrichmentRuns
|
||||
WHERE BookID = @BookID AND IsCurrent = 1
|
||||
ORDER BY CharacterEnrichmentRunID DESC;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_UpdateProgress
|
||||
@CharacterEnrichmentRunID int,
|
||||
@TotalCharacters int,
|
||||
@ProcessedCharacters int,
|
||||
@CurrentStage nvarchar(120),
|
||||
@CurrentMessage nvarchar(300)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
UPDATE dbo.CharacterEnrichmentRuns
|
||||
SET TotalCharacters = @TotalCharacters,
|
||||
ProcessedCharacters = @ProcessedCharacters,
|
||||
CurrentStage = @CurrentStage,
|
||||
CurrentMessage = @CurrentMessage,
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE CharacterEnrichmentRunID = @CharacterEnrichmentRunID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichment_ContextGet
|
||||
@ProjectID int,
|
||||
@BookID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT
|
||||
BookTitle = CASE WHEN NULLIF(LTRIM(RTRIM(b.Subtitle)), N'') IS NULL THEN b.BookTitle ELSE CONCAT(b.BookTitle, N': ', b.Subtitle) END,
|
||||
b.StoryEra,
|
||||
SeriesStartDate = CAST(NULL AS date)
|
||||
FROM dbo.Books b
|
||||
WHERE b.BookID = @BookID AND b.ProjectID = @ProjectID AND b.IsArchived = 0;
|
||||
|
||||
SELECT DISTINCT
|
||||
c.CharacterID,
|
||||
c.CharacterName,
|
||||
c.BirthDate,
|
||||
c.AgeAtSeriesStart,
|
||||
c.Height,
|
||||
c.EyeColour,
|
||||
c.DefaultDescription
|
||||
FROM dbo.Characters c
|
||||
INNER JOIN dbo.SceneCharacters sc ON sc.CharacterID = c.CharacterID
|
||||
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
|
||||
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.IsArchived = 0
|
||||
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
|
||||
WHERE c.ProjectID = @ProjectID
|
||||
AND c.IsArchived = 0
|
||||
AND ch.BookID = @BookID
|
||||
ORDER BY c.CharacterName;
|
||||
|
||||
SELECT alias.CharacterID, alias.Alias
|
||||
FROM dbo.CharacterAliases alias
|
||||
INNER JOIN dbo.Characters c ON c.CharacterID = alias.CharacterID
|
||||
WHERE c.ProjectID = @ProjectID AND c.IsArchived = 0
|
||||
ORDER BY alias.CharacterID, alias.SortOrder, alias.Alias;
|
||||
|
||||
SELECT
|
||||
sc.CharacterID,
|
||||
s.SceneID,
|
||||
ch.ChapterID,
|
||||
ch.ChapterNumber,
|
||||
ch.ChapterTitle,
|
||||
s.SceneNumber,
|
||||
s.SceneTitle,
|
||||
sms.SourceText
|
||||
FROM dbo.SceneCharacters sc
|
||||
INNER JOIN dbo.Characters c ON c.CharacterID = sc.CharacterID AND c.ProjectID = @ProjectID AND c.IsArchived = 0
|
||||
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
|
||||
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.BookID = @BookID AND ch.IsArchived = 0
|
||||
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
|
||||
ORDER BY sc.CharacterID, ch.SortOrder, ch.ChapterNumber, s.SortOrder, s.SceneNumber, s.SceneID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Complete
|
||||
@CharacterEnrichmentRunID int,
|
||||
@Model nvarchar(100),
|
||||
@InputTokens int = NULL,
|
||||
@OutputTokens int = NULL,
|
||||
@TotalTokens int = NULL,
|
||||
@DurationMs bigint = NULL,
|
||||
@ResultsJson nvarchar(max)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
DECLARE @ProjectID int, @BookID int, @PipelineID int;
|
||||
SELECT @ProjectID = ProjectID, @BookID = BookID, @PipelineID = StoryIntelligenceBookPipelineID
|
||||
FROM dbo.CharacterEnrichmentRuns
|
||||
WHERE CharacterEnrichmentRunID = @CharacterEnrichmentRunID;
|
||||
|
||||
DECLARE @Results TABLE
|
||||
(
|
||||
CharacterID int NOT NULL PRIMARY KEY,
|
||||
Summary nvarchar(max) NULL,
|
||||
Confidence decimal(5,4) NOT NULL,
|
||||
DateOfBirthValue nvarchar(120) NULL,
|
||||
DateOfBirthConfidence decimal(5,4) NULL,
|
||||
DateOfBirthEvidence nvarchar(700) NULL,
|
||||
DateOfBirthSceneID int NULL,
|
||||
AgeValue nvarchar(120) NULL,
|
||||
AgeConfidence decimal(5,4) NULL,
|
||||
AgeEvidence nvarchar(700) NULL,
|
||||
AgeSceneID int NULL,
|
||||
HeightValue nvarchar(120) NULL,
|
||||
HeightConfidence decimal(5,4) NULL,
|
||||
HeightEvidence nvarchar(700) NULL,
|
||||
HeightSceneID int NULL,
|
||||
EyeColourValue nvarchar(120) NULL,
|
||||
EyeColourConfidence decimal(5,4) NULL,
|
||||
EyeColourEvidence nvarchar(700) NULL,
|
||||
EyeColourSceneID int NULL
|
||||
);
|
||||
|
||||
INSERT @Results
|
||||
SELECT
|
||||
CharacterID,
|
||||
NULLIF(LTRIM(RTRIM(Summary)), N''),
|
||||
Confidence,
|
||||
NULLIF(LTRIM(RTRIM(DateOfBirthValue)), N''),
|
||||
DateOfBirthConfidence,
|
||||
NULLIF(LTRIM(RTRIM(DateOfBirthEvidence)), N''),
|
||||
DateOfBirthSceneID,
|
||||
NULLIF(LTRIM(RTRIM(AgeValue)), N''),
|
||||
AgeConfidence,
|
||||
NULLIF(LTRIM(RTRIM(AgeEvidence)), N''),
|
||||
AgeSceneID,
|
||||
NULLIF(LTRIM(RTRIM(HeightValue)), N''),
|
||||
HeightConfidence,
|
||||
NULLIF(LTRIM(RTRIM(HeightEvidence)), N''),
|
||||
HeightSceneID,
|
||||
NULLIF(LTRIM(RTRIM(EyeColourValue)), N''),
|
||||
EyeColourConfidence,
|
||||
NULLIF(LTRIM(RTRIM(EyeColourEvidence)), N''),
|
||||
EyeColourSceneID
|
||||
FROM OPENJSON(@ResultsJson)
|
||||
WITH
|
||||
(
|
||||
CharacterID int '$.characterID',
|
||||
Summary nvarchar(max) '$.summary',
|
||||
Confidence decimal(5,4) '$.confidence',
|
||||
DateOfBirthValue nvarchar(120) '$.dateOfBirth.value',
|
||||
DateOfBirthConfidence decimal(5,4) '$.dateOfBirth.confidence',
|
||||
DateOfBirthEvidence nvarchar(700) '$.dateOfBirth.evidence',
|
||||
DateOfBirthSceneID int '$.dateOfBirth.sceneID',
|
||||
AgeValue nvarchar(120) '$.ageAtStartOfSeries.value',
|
||||
AgeConfidence decimal(5,4) '$.ageAtStartOfSeries.confidence',
|
||||
AgeEvidence nvarchar(700) '$.ageAtStartOfSeries.evidence',
|
||||
AgeSceneID int '$.ageAtStartOfSeries.sceneID',
|
||||
HeightValue nvarchar(120) '$.height.value',
|
||||
HeightConfidence decimal(5,4) '$.height.confidence',
|
||||
HeightEvidence nvarchar(700) '$.height.evidence',
|
||||
HeightSceneID int '$.height.sceneID',
|
||||
EyeColourValue nvarchar(120) '$.eyeColour.value',
|
||||
EyeColourConfidence decimal(5,4) '$.eyeColour.confidence',
|
||||
EyeColourEvidence nvarchar(700) '$.eyeColour.evidence',
|
||||
EyeColourSceneID int '$.eyeColour.sceneID'
|
||||
)
|
||||
WHERE CharacterID IS NOT NULL;
|
||||
|
||||
UPDATE c
|
||||
SET DefaultDescription = CASE WHEN NULLIF(LTRIM(RTRIM(c.DefaultDescription)), N'') IS NULL THEN r.Summary ELSE c.DefaultDescription END,
|
||||
BirthDate = CASE WHEN c.BirthDate IS NULL THEN TRY_CONVERT(date, r.DateOfBirthValue) ELSE c.BirthDate END,
|
||||
AgeAtSeriesStart = CASE WHEN c.AgeAtSeriesStart IS NULL THEN TRY_CONVERT(int, r.AgeValue) ELSE c.AgeAtSeriesStart END,
|
||||
Height = CASE WHEN NULLIF(LTRIM(RTRIM(c.Height)), N'') IS NULL THEN r.HeightValue ELSE c.Height END,
|
||||
EyeColour = CASE WHEN NULLIF(LTRIM(RTRIM(c.EyeColour)), N'') IS NULL THEN r.EyeColourValue ELSE c.EyeColour END,
|
||||
UpdatedDate = SYSUTCDATETIME()
|
||||
FROM dbo.Characters c
|
||||
INNER JOIN @Results r ON r.CharacterID = c.CharacterID
|
||||
WHERE c.ProjectID = @ProjectID;
|
||||
|
||||
MERGE dbo.CharacterIntelligenceProfiles WITH (HOLDLOCK) AS target
|
||||
USING
|
||||
(
|
||||
SELECT
|
||||
ImportSessionID = @PipelineID,
|
||||
ProjectID = @ProjectID,
|
||||
BookID = @BookID,
|
||||
CanonicalIdentityKey = CONCAT(N'character-enrichment-', r.CharacterID),
|
||||
CanonicalName = c.CharacterName,
|
||||
ProfileJson = (SELECT r.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER),
|
||||
Confidence = r.Confidence,
|
||||
NarrativeImportance = CAST(NULL AS nvarchar(60))
|
||||
FROM @Results r
|
||||
INNER JOIN dbo.Characters c ON c.CharacterID = r.CharacterID
|
||||
WHERE @PipelineID IS NOT NULL
|
||||
) AS source
|
||||
ON target.ImportSessionID = source.ImportSessionID
|
||||
AND target.CanonicalIdentityKey = source.CanonicalIdentityKey
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET CanonicalName = source.CanonicalName,
|
||||
ProfileJson = source.ProfileJson,
|
||||
Confidence = source.Confidence,
|
||||
EvidenceVersion = N'CharacterEnrichmentV1',
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (ImportSessionID, ProjectID, BookID, CanonicalIdentityKey, CanonicalName, ProfileJson, Confidence, NarrativeImportance, IsRecurring, EvidenceVersion, LastUpdatedSceneResultID)
|
||||
VALUES (source.ImportSessionID, source.ProjectID, source.BookID, source.CanonicalIdentityKey, source.CanonicalName, source.ProfileJson, source.Confidence, source.NarrativeImportance, 1, N'CharacterEnrichmentV1', NULL);
|
||||
|
||||
INSERT dbo.CharacterEnrichmentEvidence (CharacterEnrichmentRunID, CharacterID, SceneID, FieldName, Value, Evidence, Confidence)
|
||||
SELECT @CharacterEnrichmentRunID, CharacterID, NULL, N'Summary', Summary, NULL, Confidence
|
||||
FROM @Results
|
||||
WHERE Summary IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT @CharacterEnrichmentRunID, CharacterID, DateOfBirthSceneID, N'DateOfBirth', DateOfBirthValue, DateOfBirthEvidence, COALESCE(DateOfBirthConfidence, 0)
|
||||
FROM @Results WHERE DateOfBirthValue IS NOT NULL AND DateOfBirthSceneID IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT @CharacterEnrichmentRunID, CharacterID, AgeSceneID, N'AgeAtStartOfSeries', AgeValue, AgeEvidence, COALESCE(AgeConfidence, 0)
|
||||
FROM @Results WHERE AgeValue IS NOT NULL AND AgeSceneID IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT @CharacterEnrichmentRunID, CharacterID, HeightSceneID, N'Height', HeightValue, HeightEvidence, COALESCE(HeightConfidence, 0)
|
||||
FROM @Results WHERE HeightValue IS NOT NULL AND HeightSceneID IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT @CharacterEnrichmentRunID, CharacterID, EyeColourSceneID, N'EyeColour', EyeColourValue, EyeColourEvidence, COALESCE(EyeColourConfidence, 0)
|
||||
FROM @Results WHERE EyeColourValue IS NOT NULL AND EyeColourSceneID IS NOT NULL;
|
||||
|
||||
UPDATE dbo.CharacterEnrichmentRuns
|
||||
SET Status = N'Completed',
|
||||
ProcessedCharacters = TotalCharacters,
|
||||
CurrentStage = N'Complete',
|
||||
CurrentMessage = N'Character summaries are ready.',
|
||||
CompletedUtc = SYSUTCDATETIME(),
|
||||
Model = @Model,
|
||||
InputTokens = @InputTokens,
|
||||
OutputTokens = @OutputTokens,
|
||||
TotalTokens = @TotalTokens,
|
||||
DurationMs = @DurationMs,
|
||||
ErrorMessage = NULL,
|
||||
ErrorDetail = NULL,
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE CharacterEnrichmentRunID = @CharacterEnrichmentRunID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Fail
|
||||
@CharacterEnrichmentRunID int,
|
||||
@ErrorMessage nvarchar(max),
|
||||
@ErrorDetail nvarchar(max) = NULL,
|
||||
@DurationMs bigint = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
UPDATE dbo.CharacterEnrichmentRuns
|
||||
SET Status = N'Failed',
|
||||
FailedUtc = SYSUTCDATETIME(),
|
||||
CurrentStage = N'Failed',
|
||||
CurrentMessage = N'Character enrichment failed.',
|
||||
ErrorMessage = @ErrorMessage,
|
||||
ErrorDetail = @ErrorDetail,
|
||||
DurationMs = @DurationMs,
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE CharacterEnrichmentRunID = @CharacterEnrichmentRunID;
|
||||
END;
|
||||
GO
|
||||
@ -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
|
||||
351
PlotLine/Sql/185_Phase27L_ManualCharacterEnrichment.sql
Normal file
351
PlotLine/Sql/185_Phase27L_ManualCharacterEnrichment.sql
Normal file
@ -0,0 +1,351 @@
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.CharacterEnrichmentRuns', N'CharacterID') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.CharacterEnrichmentRuns ADD CharacterID int NULL;
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_CharacterEnrichmentRuns_Characters')
|
||||
BEGIN
|
||||
ALTER TABLE dbo.CharacterEnrichmentRuns
|
||||
ADD CONSTRAINT FK_CharacterEnrichmentRuns_Characters FOREIGN KEY (CharacterID) REFERENCES dbo.Characters(CharacterID);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_CharacterEnrichmentRuns_ActiveCharacter' AND object_id = OBJECT_ID(N'dbo.CharacterEnrichmentRuns'))
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX UX_CharacterEnrichmentRuns_ActiveCharacter
|
||||
ON dbo.CharacterEnrichmentRuns(BookID, CharacterID)
|
||||
WHERE CharacterID IS NOT NULL AND IsCurrent = 1 AND Status IN (N'Queued', N'Running');
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CharacterEnrichmentRuns_Character_Current' AND object_id = OBJECT_ID(N'dbo.CharacterEnrichmentRuns'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_CharacterEnrichmentRuns_Character_Current
|
||||
ON dbo.CharacterEnrichmentRuns(CharacterID, IsCurrent, UpdatedUtc DESC)
|
||||
INCLUDE (BookID, UserID, Status, CompletedUtc);
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_QueueCharacter
|
||||
@CharacterID int,
|
||||
@UserID int,
|
||||
@BookID int = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
DECLARE @ProjectID int, @ResolvedBookID int, @PipelineID int, @ExistingID int, @Version nvarchar(120), @WasCoalesced bit = 1;
|
||||
|
||||
SELECT TOP (1)
|
||||
@ProjectID = c.ProjectID,
|
||||
@ResolvedBookID = ch.BookID
|
||||
FROM dbo.Characters c
|
||||
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = c.ProjectID
|
||||
INNER JOIN dbo.SceneCharacters sc ON sc.CharacterID = c.CharacterID
|
||||
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
|
||||
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.IsArchived = 0
|
||||
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
|
||||
WHERE c.CharacterID = @CharacterID
|
||||
AND c.IsArchived = 0
|
||||
AND pua.UserID = @UserID
|
||||
AND pua.AccessRole = N'Owner'
|
||||
AND pua.IsActive = 1
|
||||
AND (@BookID IS NULL OR ch.BookID = @BookID)
|
||||
ORDER BY ch.SortOrder, ch.ChapterNumber, s.SortOrder, s.SceneNumber, s.SceneID;
|
||||
|
||||
IF @ProjectID IS NULL OR @ResolvedBookID IS NULL
|
||||
THROW 52221, 'Character could not be queued for enrichment for this user and book.', 1;
|
||||
|
||||
SELECT TOP (1) @PipelineID = StoryIntelligenceBookPipelineID
|
||||
FROM dbo.StoryIntelligenceBookPipelines
|
||||
WHERE ProjectID = @ProjectID AND BookID = @ResolvedBookID
|
||||
ORDER BY StoryIntelligenceBookPipelineID DESC;
|
||||
|
||||
SET @Version = CONCAT(N'ManualCharacter:', @CharacterID, N':Book:', @ResolvedBookID);
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
SELECT TOP (1) @ExistingID = CharacterEnrichmentRunID
|
||||
FROM dbo.CharacterEnrichmentRuns WITH (UPDLOCK, HOLDLOCK)
|
||||
WHERE BookID = @ResolvedBookID
|
||||
AND CharacterID = @CharacterID
|
||||
AND IsCurrent = 1
|
||||
AND Status IN (N'Queued', N'Running')
|
||||
ORDER BY CharacterEnrichmentRunID DESC;
|
||||
|
||||
IF @ExistingID IS NULL
|
||||
BEGIN
|
||||
UPDATE dbo.CharacterEnrichmentRuns
|
||||
SET IsCurrent = 0,
|
||||
Status = N'Superseded',
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE BookID = @ResolvedBookID
|
||||
AND CharacterID = @CharacterID
|
||||
AND IsCurrent = 1;
|
||||
|
||||
INSERT dbo.CharacterEnrichmentRuns
|
||||
(ProjectID, BookID, UserID, CharacterID, StoryIntelligenceBookPipelineID, CharacterResolutionVersion, Status, TotalCharacters, CurrentStage, CurrentMessage)
|
||||
VALUES
|
||||
(@ProjectID, @ResolvedBookID, @UserID, @CharacterID, @PipelineID, @Version, N'Queued', 1, N'Queued', N'Character reanalysis is queued.');
|
||||
|
||||
SET @ExistingID = CAST(SCOPE_IDENTITY() AS int);
|
||||
SET @WasCoalesced = 0;
|
||||
END;
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
|
||||
SELECT runRows.*, WasCoalesced = @WasCoalesced
|
||||
FROM dbo.CharacterEnrichmentRuns runRows
|
||||
WHERE runRows.CharacterEnrichmentRunID = @ExistingID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Retry
|
||||
@BookID int,
|
||||
@UserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
DECLARE @PreviousID int, @ProjectID int, @PipelineID int, @Version nvarchar(120);
|
||||
SELECT TOP (1)
|
||||
@PreviousID = CharacterEnrichmentRunID,
|
||||
@ProjectID = ProjectID,
|
||||
@PipelineID = StoryIntelligenceBookPipelineID,
|
||||
@Version = CharacterResolutionVersion
|
||||
FROM dbo.CharacterEnrichmentRuns
|
||||
WHERE BookID = @BookID AND UserID = @UserID AND CharacterID IS NULL AND IsCurrent = 1 AND Status = N'Failed'
|
||||
ORDER BY CharacterEnrichmentRunID DESC;
|
||||
|
||||
IF @PreviousID IS NULL
|
||||
BEGIN
|
||||
SELECT TOP (0) * FROM dbo.CharacterEnrichmentRuns;
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
UPDATE dbo.CharacterEnrichmentRuns
|
||||
SET IsCurrent = 0,
|
||||
Status = N'Superseded',
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE CharacterEnrichmentRunID = @PreviousID;
|
||||
|
||||
EXEC dbo.CharacterEnrichmentRun_Queue
|
||||
@ProjectID = @ProjectID,
|
||||
@BookID = @BookID,
|
||||
@UserID = @UserID,
|
||||
@StoryIntelligenceBookPipelineID = @PipelineID,
|
||||
@CharacterResolutionVersion = @Version;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_GetCurrentByBook
|
||||
@BookID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT TOP (1) *
|
||||
FROM dbo.CharacterEnrichmentRuns
|
||||
WHERE BookID = @BookID AND CharacterID IS NULL AND IsCurrent = 1
|
||||
ORDER BY CharacterEnrichmentRunID DESC;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_GetCurrentByCharacter
|
||||
@CharacterID int,
|
||||
@UserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT TOP (1) runRows.*
|
||||
FROM dbo.CharacterEnrichmentRuns runRows
|
||||
INNER JOIN dbo.Characters c ON c.CharacterID = @CharacterID AND c.ProjectID = runRows.ProjectID
|
||||
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = c.ProjectID
|
||||
WHERE runRows.CharacterID = @CharacterID
|
||||
AND runRows.UserID = @UserID
|
||||
AND runRows.IsCurrent = 1
|
||||
AND pua.UserID = @UserID
|
||||
AND pua.AccessRole = N'Owner'
|
||||
AND pua.IsActive = 1
|
||||
ORDER BY runRows.CharacterEnrichmentRunID DESC;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichment_ContextGet
|
||||
@ProjectID int,
|
||||
@BookID int,
|
||||
@CharacterID int = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT
|
||||
BookTitle = CASE WHEN NULLIF(LTRIM(RTRIM(b.Subtitle)), N'') IS NULL THEN b.BookTitle ELSE CONCAT(b.BookTitle, N': ', b.Subtitle) END,
|
||||
b.StoryEra,
|
||||
SeriesStartDate = CAST(NULL AS date)
|
||||
FROM dbo.Books b
|
||||
WHERE b.BookID = @BookID AND b.ProjectID = @ProjectID AND b.IsArchived = 0;
|
||||
|
||||
SELECT DISTINCT
|
||||
c.CharacterID,
|
||||
c.CharacterName,
|
||||
c.BirthDate,
|
||||
c.AgeAtSeriesStart,
|
||||
c.Height,
|
||||
c.EyeColour,
|
||||
c.DefaultDescription
|
||||
FROM dbo.Characters c
|
||||
INNER JOIN dbo.SceneCharacters sc ON sc.CharacterID = c.CharacterID
|
||||
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
|
||||
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.IsArchived = 0
|
||||
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
|
||||
WHERE c.ProjectID = @ProjectID
|
||||
AND c.IsArchived = 0
|
||||
AND ch.BookID = @BookID
|
||||
AND (@CharacterID IS NULL OR c.CharacterID = @CharacterID)
|
||||
ORDER BY c.CharacterName;
|
||||
|
||||
SELECT alias.CharacterID, alias.Alias
|
||||
FROM dbo.CharacterAliases alias
|
||||
INNER JOIN dbo.Characters c ON c.CharacterID = alias.CharacterID
|
||||
WHERE c.ProjectID = @ProjectID
|
||||
AND c.IsArchived = 0
|
||||
AND (@CharacterID IS NULL OR c.CharacterID = @CharacterID)
|
||||
ORDER BY alias.CharacterID, alias.SortOrder, alias.Alias;
|
||||
|
||||
SELECT
|
||||
sc.CharacterID,
|
||||
s.SceneID,
|
||||
ch.ChapterID,
|
||||
ch.ChapterNumber,
|
||||
ch.ChapterTitle,
|
||||
s.SceneNumber,
|
||||
s.SceneTitle,
|
||||
sms.SourceText
|
||||
FROM dbo.SceneCharacters sc
|
||||
INNER JOIN dbo.Characters c ON c.CharacterID = sc.CharacterID AND c.ProjectID = @ProjectID AND c.IsArchived = 0
|
||||
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
|
||||
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.BookID = @BookID AND ch.IsArchived = 0
|
||||
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
|
||||
WHERE @CharacterID IS NULL OR sc.CharacterID = @CharacterID
|
||||
ORDER BY sc.CharacterID, ch.SortOrder, ch.ChapterNumber, s.SortOrder, s.SceneNumber, s.SceneID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentCharacterStatus_Get
|
||||
@CharacterID int,
|
||||
@UserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @ProjectID int;
|
||||
SELECT @ProjectID = c.ProjectID
|
||||
FROM dbo.Characters c
|
||||
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = c.ProjectID
|
||||
WHERE c.CharacterID = @CharacterID
|
||||
AND c.IsArchived = 0
|
||||
AND pua.UserID = @UserID
|
||||
AND pua.AccessRole = N'Owner'
|
||||
AND pua.IsActive = 1;
|
||||
|
||||
IF @ProjectID IS NULL
|
||||
BEGIN
|
||||
SELECT TOP (0)
|
||||
CharacterID = CAST(NULL AS int),
|
||||
BookID = CAST(NULL AS int),
|
||||
CharacterEnrichmentRunID = CAST(NULL AS int),
|
||||
Status = CAST(NULL AS nvarchar(30)),
|
||||
CurrentStage = CAST(NULL AS nvarchar(120)),
|
||||
CurrentMessage = CAST(NULL AS nvarchar(300)),
|
||||
LastAnalysedUtc = CAST(NULL AS datetime2),
|
||||
ErrorMessage = CAST(NULL AS nvarchar(max)),
|
||||
Summary = CAST(NULL AS nvarchar(max)),
|
||||
DateOfBirth = CAST(NULL AS nvarchar(500)),
|
||||
AgeAtStartOfSeries = CAST(NULL AS nvarchar(500)),
|
||||
Height = CAST(NULL AS nvarchar(500)),
|
||||
EyeColour = CAST(NULL AS nvarchar(500)),
|
||||
UpdatedUtc = CAST(NULL AS datetime2);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @Attempt TABLE
|
||||
(
|
||||
CharacterEnrichmentRunID int NULL,
|
||||
BookID int NULL,
|
||||
Status nvarchar(30) NULL,
|
||||
CurrentStage nvarchar(120) NULL,
|
||||
CurrentMessage nvarchar(300) NULL,
|
||||
ErrorMessage nvarchar(max) NULL,
|
||||
UpdatedUtc datetime2 NULL
|
||||
);
|
||||
|
||||
INSERT @Attempt
|
||||
SELECT TOP (1)
|
||||
runRows.CharacterEnrichmentRunID,
|
||||
runRows.BookID,
|
||||
runRows.Status,
|
||||
runRows.CurrentStage,
|
||||
runRows.CurrentMessage,
|
||||
runRows.ErrorMessage,
|
||||
runRows.UpdatedUtc
|
||||
FROM dbo.CharacterEnrichmentRuns runRows
|
||||
WHERE runRows.UserID = @UserID
|
||||
AND runRows.ProjectID = @ProjectID
|
||||
AND runRows.CharacterID = @CharacterID
|
||||
AND runRows.IsCurrent = 1
|
||||
ORDER BY CASE runRows.Status WHEN N'Queued' THEN 0 WHEN N'Running' THEN 0 WHEN N'Failed' THEN 1 WHEN N'Completed' THEN 2 ELSE 9 END,
|
||||
runRows.UpdatedUtc DESC,
|
||||
runRows.CharacterEnrichmentRunID DESC;
|
||||
|
||||
DECLARE @SuccessRunID int, @SuccessBookID int, @LastAnalysedUtc datetime2;
|
||||
SELECT TOP (1)
|
||||
@SuccessRunID = runRows.CharacterEnrichmentRunID,
|
||||
@SuccessBookID = runRows.BookID,
|
||||
@LastAnalysedUtc = runRows.CompletedUtc
|
||||
FROM dbo.CharacterEnrichmentRuns runRows
|
||||
WHERE runRows.UserID = @UserID
|
||||
AND runRows.ProjectID = @ProjectID
|
||||
AND runRows.Status = N'Completed'
|
||||
AND runRows.CompletedUtc IS NOT NULL
|
||||
AND (
|
||||
runRows.CharacterID = @CharacterID
|
||||
OR EXISTS
|
||||
(
|
||||
SELECT 1
|
||||
FROM dbo.CharacterEnrichmentEvidence evidence
|
||||
WHERE evidence.CharacterEnrichmentRunID = runRows.CharacterEnrichmentRunID
|
||||
AND evidence.CharacterID = @CharacterID
|
||||
)
|
||||
)
|
||||
ORDER BY runRows.CompletedUtc DESC, runRows.CharacterEnrichmentRunID DESC;
|
||||
|
||||
SELECT
|
||||
CharacterID = @CharacterID,
|
||||
BookID = COALESCE((SELECT TOP (1) BookID FROM @Attempt), @SuccessBookID),
|
||||
CharacterEnrichmentRunID = COALESCE((SELECT TOP (1) CharacterEnrichmentRunID FROM @Attempt), @SuccessRunID),
|
||||
Status = COALESCE((SELECT TOP (1) Status FROM @Attempt), CASE WHEN @SuccessRunID IS NULL THEN N'Not analysed' ELSE N'Completed' END),
|
||||
CurrentStage = (SELECT TOP (1) CurrentStage FROM @Attempt),
|
||||
CurrentMessage = (SELECT TOP (1) CurrentMessage FROM @Attempt),
|
||||
LastAnalysedUtc = @LastAnalysedUtc,
|
||||
ErrorMessage = (SELECT TOP (1) ErrorMessage FROM @Attempt),
|
||||
Summary = MAX(CASE WHEN evidence.FieldName = N'Summary' THEN evidence.Value END),
|
||||
DateOfBirth = MAX(CASE WHEN evidence.FieldName = N'DateOfBirth' THEN evidence.Value END),
|
||||
AgeAtStartOfSeries = MAX(CASE WHEN evidence.FieldName = N'AgeAtStartOfSeries' THEN evidence.Value END),
|
||||
Height = MAX(CASE WHEN evidence.FieldName = N'Height' THEN evidence.Value END),
|
||||
EyeColour = MAX(CASE WHEN evidence.FieldName = N'EyeColour' THEN evidence.Value END),
|
||||
UpdatedUtc = COALESCE((SELECT TOP (1) UpdatedUtc FROM @Attempt), @LastAnalysedUtc, SYSUTCDATETIME())
|
||||
FROM (SELECT 1 AS Anchor) anchor
|
||||
LEFT JOIN dbo.CharacterEnrichmentEvidence evidence
|
||||
ON evidence.CharacterEnrichmentRunID = @SuccessRunID
|
||||
AND evidence.CharacterID = @CharacterID;
|
||||
END;
|
||||
GO
|
||||
@ -256,6 +256,7 @@ public sealed class BookDetailViewModel
|
||||
public ManuscriptDocumentModel? ManuscriptDocument { get; set; }
|
||||
public IReadOnlyList<Chapter> Chapters { get; set; } = [];
|
||||
public StoryIntelligenceBookPipelineState? StoryIntelligencePipeline { get; set; }
|
||||
public IReadOnlyList<StoryIntelligenceSavedRun> StoryIntelligenceRuns { get; set; } = [];
|
||||
public StoryIntelligenceBookActionViewModel StoryIntelligenceAction { get; set; } = new();
|
||||
}
|
||||
|
||||
@ -1884,6 +1885,7 @@ public sealed class CharacterDetailViewModel
|
||||
public IReadOnlyList<SelectListItem> RelationshipTypeOptions { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> RelationshipStateOptions { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
|
||||
public CharacterEnrichmentCharacterStatus CharacterAnalysis { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class CharacterMergeViewModel
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -19,6 +19,16 @@
|
||||
.OrderBy(name => !string.Equals(name, Model.Character.CharacterName, StringComparison.OrdinalIgnoreCase))
|
||||
.ThenBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var analysis = Model.CharacterAnalysis;
|
||||
var analysisStatus = string.IsNullOrWhiteSpace(analysis.Status) ? "Not analysed" : analysis.Status;
|
||||
var analysisActive = analysis.Status is CharacterEnrichmentStatuses.Queued or CharacterEnrichmentStatuses.Running;
|
||||
var analysisButtonText = analysis.Status switch
|
||||
{
|
||||
CharacterEnrichmentStatuses.Queued => "Queued...",
|
||||
CharacterEnrichmentStatuses.Running => "Analysing...",
|
||||
_ => "Reanalyse character"
|
||||
};
|
||||
var lastAnalysed = analysis.LastAnalysedUtc?.ToLocalTime().ToString("d MMMM yyyy, HH:mm");
|
||||
}
|
||||
|
||||
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
|
||||
@ -47,6 +57,10 @@
|
||||
<div class="dropdown-menu dropdown-menu-end">
|
||||
<a class="dropdown-item" asp-action="Edit" asp-route-id="@Model.Character.CharacterID">Edit character</a>
|
||||
<a class="dropdown-item" asp-action="Merge" asp-route-id="@Model.Character.CharacterID">Merge with another character</a>
|
||||
<form asp-action="Reanalyse" method="post" data-character-reanalyse-form>
|
||||
<input type="hidden" name="id" value="@Model.Character.CharacterID" />
|
||||
<button class="dropdown-item" type="submit" data-character-reanalyse-button @(analysisActive ? "disabled" : string.Empty)>@analysisButtonText</button>
|
||||
</form>
|
||||
<a class="dropdown-item" asp-controller="Exports" asp-action="Character" asp-route-id="@Model.Character.CharacterID">Export character</a>
|
||||
<a class="dropdown-item" asp-controller="Exports" asp-action="Character" asp-route-id="@Model.Character.CharacterID" asp-route-format="markdown">Markdown</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
@ -87,7 +101,7 @@
|
||||
}
|
||||
|
||||
<section id="character-overview" class="asset-detail-grid">
|
||||
<article class="plain-card">
|
||||
<article class="plain-card" data-character-analysis-panel data-character-id="@Model.Character.CharacterID" data-book-id="@analysis.BookID">
|
||||
<h2>Profile</h2>
|
||||
<p><strong>Preferred name:</strong> @Model.Character.CharacterName</p>
|
||||
<p><strong>Sex:</strong> @Model.Character.Sex</p>
|
||||
@ -95,6 +109,25 @@
|
||||
<p><strong>Height:</strong> @Model.Character.Height</p>
|
||||
<p><strong>Eye colour:</strong> @Model.Character.EyeColour</p>
|
||||
<p><strong>Story importance:</strong> @storyImportance</p>
|
||||
<hr />
|
||||
<p><strong>Character analysis:</strong> <span data-character-analysis-status>@analysisStatus</span></p>
|
||||
<p><strong>Last analysed:</strong> <span data-character-analysis-last>@(lastAnalysed ?? "Not analysed")</span></p>
|
||||
@if (!string.IsNullOrWhiteSpace(analysis.CurrentMessage))
|
||||
{
|
||||
<p class="muted" data-character-analysis-message>@analysis.CurrentMessage</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted" data-character-analysis-message></p>
|
||||
}
|
||||
</article>
|
||||
<article class="plain-card" data-character-generated-analysis>
|
||||
<h2>Story Intelligence Analysis</h2>
|
||||
<p class="@(string.IsNullOrWhiteSpace(analysis.Summary) ? "muted" : string.Empty)" data-character-analysis-summary>@(analysis.Summary ?? "No generated character analysis yet.")</p>
|
||||
<p><strong>Date of birth:</strong> <span data-character-analysis-dob>@(analysis.DateOfBirth ?? "Unknown")</span></p>
|
||||
<p><strong>Age at start of series:</strong> <span data-character-analysis-age>@(analysis.AgeAtStartOfSeries ?? "Unknown")</span></p>
|
||||
<p><strong>Height:</strong> <span data-character-analysis-height>@(analysis.Height ?? "Unknown")</span></p>
|
||||
<p><strong>Eye colour:</strong> <span data-character-analysis-eye>@(analysis.EyeColour ?? "Unknown")</span></p>
|
||||
</article>
|
||||
<article class="plain-card">
|
||||
<h2>Known names</h2>
|
||||
@ -528,6 +561,7 @@
|
||||
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/character-enrichment-detail.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
(() => {
|
||||
document.querySelectorAll("[data-relationship-editor]").forEach((editor) => {
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
@model StoryIntelligenceProgressViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Story Intelligence Review Centre";
|
||||
var runIds = string.Join(",", Model.Chapters.Select(chapter => chapter.RunID));
|
||||
var progressPercent = ProgressPercent(Model);
|
||||
}
|
||||
|
||||
<section class="story-review-centre-shell" aria-labelledby="story-review-centre-title">
|
||||
<section class="story-review-centre-shell"
|
||||
aria-labelledby="story-review-centre-title"
|
||||
data-story-intelligence-onboarding
|
||||
data-story-intelligence-batch-id="@Model.BatchID"
|
||||
data-story-intelligence-run-ids="@runIds"
|
||||
data-story-max-progress-percent="@progressPercent"
|
||||
data-story-intelligence-has-active-runs="@Model.HasActiveRuns.ToString().ToLowerInvariant()">
|
||||
<div class="story-review-centre-workspace">
|
||||
<div class="story-review-centre-heading">
|
||||
<p class="eyebrow">Story Intelligence</p>
|
||||
@ -23,18 +31,8 @@
|
||||
}
|
||||
|
||||
<section class="story-review-centre-grid" aria-label="Story Intelligence review categories">
|
||||
@ReviewModule(
|
||||
"Characters",
|
||||
Model.CharacterNeedsAttentionCount,
|
||||
Model.PipelineDashboard.CharacterStageComplete,
|
||||
false,
|
||||
"Resolve character identities before reviewing dependent Story Intelligence.",
|
||||
"Review Characters",
|
||||
"StoryIntelligenceCharacters",
|
||||
"View Characters",
|
||||
"Characters",
|
||||
new { projectId = Model.ProjectID },
|
||||
true)
|
||||
@* "Characters" "Review Characters" "StoryIntelligenceCharacters" "View Characters" new { projectId = Model.ProjectID } *@
|
||||
@ReviewCharacters()
|
||||
|
||||
@ReviewModule(
|
||||
"Assets",
|
||||
@ -104,6 +102,32 @@
|
||||
}
|
||||
</section>
|
||||
|
||||
<div class="story-run-state" aria-hidden="true" hidden>
|
||||
@foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber))
|
||||
{
|
||||
<div data-story-run-id="@chapter.RunID"
|
||||
data-story-chapter-title="@chapter.ChapterTitle"
|
||||
data-story-raw-status="@chapter.Status"
|
||||
data-story-chapter-number="@chapter.ChapterNumber"
|
||||
data-story-current-stage="@(chapter.CurrentStage ?? string.Empty)"
|
||||
data-story-current-scene-id="@(chapter.CurrentSceneID?.ToString() ?? string.Empty)"
|
||||
data-story-current-scene-number="@(chapter.CurrentSceneNumber?.ToString() ?? string.Empty)"
|
||||
data-story-current-scene-title="@(chapter.CurrentSceneDisplayTitle ?? string.Empty)"
|
||||
data-story-updated-utc="@(chapter.UpdatedUtc?.ToUniversalTime().ToString("O") ?? string.Empty)"
|
||||
data-story-source-word-count="@(chapter.SourceWordCount ?? 0)"
|
||||
data-story-duration-ms="@(chapter.TotalDurationMs ?? 0)">
|
||||
<span data-story-run-status>@chapter.Status</span>
|
||||
<span data-story-run-completed>@((chapter.CompletedScenes ?? 0).ToString("N0"))</span>
|
||||
<span data-story-run-total>@((chapter.TotalDetectedScenes ?? 0).ToString("N0"))</span>
|
||||
<span data-story-run-failed>@((chapter.FailedScenes ?? 0).ToString("N0"))</span>
|
||||
<span data-story-run-message>@(chapter.CurrentMessage ?? string.Empty)</span>
|
||||
<span data-story-run-error>@(chapter.ErrorMessage ?? string.Empty)</span>
|
||||
<span data-story-run-current-scene-title>@(chapter.CurrentSceneDisplayTitle ?? string.Empty)</span>
|
||||
<span data-story-run-latest-summary>@(chapter.LatestSceneSummary ?? string.Empty)</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="onboarding-actions story-review-centre-actions">
|
||||
<a class="btn btn-outline-secondary" asp-controller="Books" asp-action="Details" asp-route-id="@Model.BookID">Return to Book</a>
|
||||
<a class="btn btn-outline-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
|
||||
@ -172,17 +196,78 @@
|
||||
}
|
||||
|
||||
return new Microsoft.AspNetCore.Html.HtmlString($$"""
|
||||
<article class="story-review-centre-tile{{(blocked ? " story-review-centre-tile--locked" : string.Empty)}}" data-plot-synthesis-tile>
|
||||
<article class="story-review-centre-tile{{(blocked ? " story-review-centre-tile--locked" : string.Empty)}}" data-plot-synthesis-tile data-book-id="{{Model.BookID}}">
|
||||
<div>
|
||||
<h2>Plot Lines & Threads</h2>
|
||||
<p>{{System.Net.WebUtility.HtmlEncode(description)}}</p>
|
||||
</div>
|
||||
<strong class="{{statusClass}}">{{System.Net.WebUtility.HtmlEncode(status)}}</strong>
|
||||
{{ProgressHtml(Model.PlotSynthesis.IsRunning || Model.PlotSynthesis.IsQueued, null, Model.PlotSynthesis.IsQueued ? "Preparing manuscript..." : "Analysing plot structure...", null)}}
|
||||
{{actionHtml}}
|
||||
</article>
|
||||
""");
|
||||
}
|
||||
|
||||
private Microsoft.AspNetCore.Html.IHtmlContent ReviewCharacters()
|
||||
{
|
||||
var reviewComplete = Model.CharacterNeedsAttentionCount.GetValueOrDefault() == 0 && Model.PipelineDashboard.CharacterStageComplete;
|
||||
var enrichment = Model.CharacterEnrichment;
|
||||
var status = "Ready for review";
|
||||
var statusClass = "story-review-centre-tile__status story-review-centre-tile__status--warning";
|
||||
var description = "Resolve character identities before reviewing dependent Story Intelligence.";
|
||||
var actionHtml = $"<a class=\"btn btn-outline-primary btn-sm\" href=\"{System.Net.WebUtility.HtmlEncode(Url.Action("StoryIntelligenceCharacters", "Onboarding", new { batchId = Model.BatchID }) ?? "#")}\">Review Characters</a>";
|
||||
var progressHtml = string.Empty;
|
||||
|
||||
if (reviewComplete && enrichment.IsActive)
|
||||
{
|
||||
status = "Analysing";
|
||||
statusClass = "story-review-centre-tile__status story-review-centre-tile__status--ready";
|
||||
description = "PlotDirector is creating character summaries and extracting supported details.";
|
||||
actionHtml = "<span class=\"btn btn-outline-primary btn-sm disabled\" aria-disabled=\"true\">Analysing</span>";
|
||||
progressHtml = ProgressHtml(true, enrichment.ProgressPercent, string.IsNullOrWhiteSpace(enrichment.CurrentMessage) ? "Creating summaries and extracting character details..." : enrichment.CurrentMessage, enrichment.TotalCharacters > 0 ? $"{enrichment.ProcessedCharacters:N0} / {enrichment.TotalCharacters:N0}" : null);
|
||||
}
|
||||
else if (reviewComplete && enrichment.IsFailed)
|
||||
{
|
||||
status = "Failed";
|
||||
statusClass = "story-review-centre-tile__status story-review-centre-tile__status--warning";
|
||||
description = string.IsNullOrWhiteSpace(enrichment.ErrorMessage) ? "Character enrichment failed and can be retried." : enrichment.ErrorMessage;
|
||||
actionHtml = $$"""
|
||||
<form method="post" action="{{Url.Action("RetryStoryIntelligenceCharacterEnrichment", "Onboarding")}}">
|
||||
<input type="hidden" name="batchId" value="{{Model.BatchID}}" />
|
||||
<button class="btn btn-outline-primary btn-sm" type="submit">Retry</button>
|
||||
</form>
|
||||
""";
|
||||
}
|
||||
else if (reviewComplete && enrichment.IsCompleted)
|
||||
{
|
||||
status = "Complete";
|
||||
statusClass = "story-review-centre-tile__status story-review-centre-tile__status--ready";
|
||||
description = "Character identities, summaries, and supported details are ready.";
|
||||
var href = Url.Action("Index", "Characters", new { projectId = Model.ProjectID }) ?? "#";
|
||||
actionHtml = $"<a class=\"btn btn-outline-primary btn-sm\" href=\"{System.Net.WebUtility.HtmlEncode(href)}\">View Characters</a>";
|
||||
}
|
||||
else if (reviewComplete)
|
||||
{
|
||||
status = "Analysing";
|
||||
statusClass = "story-review-centre-tile__status story-review-centre-tile__status--ready";
|
||||
description = "Character enrichment is queued.";
|
||||
actionHtml = "<span class=\"btn btn-outline-primary btn-sm disabled\" aria-disabled=\"true\">Preparing</span>";
|
||||
progressHtml = ProgressHtml(true, null, "Preparing character analysis...", null);
|
||||
}
|
||||
|
||||
return new Microsoft.AspNetCore.Html.HtmlString($$"""
|
||||
<article class="story-review-centre-tile story-review-centre-tile--primary" data-character-enrichment-tile data-book-id="{{Model.BookID}}" data-project-id="{{Model.ProjectID}}">
|
||||
<div>
|
||||
<h2>Characters</h2>
|
||||
<p data-character-enrichment-description>{{System.Net.WebUtility.HtmlEncode(description)}}</p>
|
||||
</div>
|
||||
<strong class="{{statusClass}}" data-character-enrichment-status>{{System.Net.WebUtility.HtmlEncode(status)}}</strong>
|
||||
<div data-character-enrichment-progress>{{progressHtml}}</div>
|
||||
<div data-character-enrichment-action>{{actionHtml}}</div>
|
||||
</article>
|
||||
""");
|
||||
}
|
||||
|
||||
private string StatusText(string title, int? count, bool complete, bool blocked)
|
||||
{
|
||||
if (blocked)
|
||||
@ -282,4 +367,60 @@
|
||||
</article>
|
||||
""");
|
||||
}
|
||||
|
||||
private static string ProgressHtml(bool visible, int? percent, string message, string? countText)
|
||||
{
|
||||
if (!visible)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var determinate = percent.HasValue;
|
||||
var valueNow = percent.GetValueOrDefault();
|
||||
return $$"""
|
||||
<div class="story-review-centre-progress" data-progress-kind="{{(determinate ? "determinate" : "indeterminate")}}">
|
||||
<div class="story-review-centre-progress__bar" role="progressbar" aria-valuemin="0" aria-valuemax="100"{{(determinate ? $" aria-valuenow=\"{valueNow}\"" : string.Empty)}}>
|
||||
<span style="width: {{(determinate ? valueNow : 100)}}%"></span>
|
||||
</div>
|
||||
<p>{{System.Net.WebUtility.HtmlEncode(message)}}{{(string.IsNullOrWhiteSpace(countText) ? string.Empty : $" {System.Net.WebUtility.HtmlEncode(countText)}")}}</p>
|
||||
</div>
|
||||
""";
|
||||
}
|
||||
|
||||
private static int ProgressPercent(StoryIntelligenceProgressViewModel model)
|
||||
{
|
||||
if (model.ChapterCount == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var chapterProgress = model.Chapters.Sum(ChapterProgress);
|
||||
return Math.Clamp((int)Math.Floor(chapterProgress * 100m / model.ChapterCount), 0, 100);
|
||||
}
|
||||
|
||||
private static decimal ChapterProgress(StoryIntelligenceOnboardingChapterViewModel chapter)
|
||||
{
|
||||
if (chapter.HasCompletedCommit || chapter.IsRunComplete || chapter.IsFailed)
|
||||
{
|
||||
return 1m;
|
||||
}
|
||||
|
||||
if (chapter.Status == StoryIntelligenceRunStatuses.Pending)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
if ((chapter.TotalDetectedScenes ?? 0) > 0)
|
||||
{
|
||||
var sceneProgress = Math.Clamp((chapter.CompletedScenes ?? 0) / Math.Max(1m, chapter.TotalDetectedScenes ?? 1), 0m, 1m);
|
||||
return Math.Clamp(0.25m + (sceneProgress * 0.7m), 0m, 0.95m);
|
||||
}
|
||||
|
||||
return 0.15m;
|
||||
}
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/story-intelligence-progress.js" asp-append-version="true"></script>
|
||||
<script src="~/js/story-review-centre-progress.js" asp-append-version="true"></script>
|
||||
}
|
||||
|
||||
@ -1710,6 +1710,45 @@ summary.story-review-chapter-heading {
|
||||
background: color-mix(in srgb, var(--plotline-panel, var(--bs-body-bg)) 94%, var(--plotline-line, rgba(31, 42, 68, .12)));
|
||||
}
|
||||
|
||||
.story-review-centre-progress {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.story-review-centre-progress__bar {
|
||||
height: .5rem;
|
||||
overflow: hidden;
|
||||
background: rgba(31, 42, 68, .12);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.story-review-centre-progress__bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 12%;
|
||||
background: var(--plotline-accent, #3b82f6);
|
||||
transition: width .25s ease;
|
||||
}
|
||||
|
||||
.story-review-centre-progress[data-progress-kind="indeterminate"] .story-review-centre-progress__bar span {
|
||||
width: 45% !important;
|
||||
animation: story-review-progress-sweep 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.story-review-centre-progress p {
|
||||
margin: .35rem 0 0;
|
||||
font-size: .9rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
@keyframes story-review-progress-sweep {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(240%);
|
||||
}
|
||||
}
|
||||
|
||||
.story-review-centre-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
137
PlotLine/wwwroot/js/character-enrichment-detail.js
Normal file
137
PlotLine/wwwroot/js/character-enrichment-detail.js
Normal file
@ -0,0 +1,137 @@
|
||||
(() => {
|
||||
const panel = document.querySelector("[data-character-analysis-panel]");
|
||||
if (!panel || typeof signalR === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const characterId = Number(panel.dataset.characterId || 0);
|
||||
const initialBookId = Number(panel.dataset.bookId || 0);
|
||||
const form = document.querySelector("[data-character-reanalyse-form]");
|
||||
const button = document.querySelector("[data-character-reanalyse-button]");
|
||||
const status = document.querySelector("[data-character-analysis-status]");
|
||||
const message = document.querySelector("[data-character-analysis-message]");
|
||||
const last = document.querySelector("[data-character-analysis-last]");
|
||||
const summary = document.querySelector("[data-character-analysis-summary]");
|
||||
if (!characterId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setBusy = (label) => {
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.textContent = label;
|
||||
}
|
||||
};
|
||||
|
||||
const setReady = () => {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.textContent = "Reanalyse character";
|
||||
}
|
||||
};
|
||||
|
||||
const applySnapshot = (snapshot) => {
|
||||
if (!snapshot || Number(snapshot.characterID || snapshot.characterId || 0) !== characterId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStatus = snapshot.status || snapshot.Status || "";
|
||||
const nextMessage = snapshot.currentMessage || snapshot.CurrentMessage || "";
|
||||
if (status) {
|
||||
status.textContent = nextStatus || "Not analysed";
|
||||
}
|
||||
if (message) {
|
||||
message.textContent = nextMessage;
|
||||
}
|
||||
|
||||
if (nextStatus === "Queued") {
|
||||
setBusy("Queued...");
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextStatus === "Running") {
|
||||
setBusy("Analysing...");
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextStatus === "Completed") {
|
||||
setReady();
|
||||
if (last) {
|
||||
last.textContent = "Just now";
|
||||
}
|
||||
window.setTimeout(() => window.location.reload(), 700);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextStatus === "Failed") {
|
||||
setReady();
|
||||
if (summary && !summary.textContent.trim()) {
|
||||
summary.textContent = "No generated character analysis yet.";
|
||||
summary.classList.add("muted");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl("/hubs/story-intelligence")
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
const refresh = () => {
|
||||
connection.invoke("WatchCharacterEnrichmentForCharacter", characterId)
|
||||
.then(applySnapshot)
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
connection.on("CharacterEnrichmentProgressChanged", applySnapshot);
|
||||
connection.onreconnected(refresh);
|
||||
|
||||
const start = () => {
|
||||
connection.start()
|
||||
.then(refresh)
|
||||
.catch(() => window.setTimeout(start, 2000));
|
||||
};
|
||||
|
||||
form?.addEventListener("submit", async (event) => {
|
||||
if (!button || button.disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.fetch) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setBusy("Queued...");
|
||||
if (status) {
|
||||
status.textContent = "Queued";
|
||||
}
|
||||
if (message) {
|
||||
message.textContent = "Character reanalysis is queued.";
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(form.action, {
|
||||
method: "POST",
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||
body: new FormData(form)
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || payload?.success === false) {
|
||||
throw new Error(payload?.message || "Character reanalysis could not be queued.");
|
||||
}
|
||||
applySnapshot(payload);
|
||||
} catch (error) {
|
||||
setReady();
|
||||
if (status) {
|
||||
status.textContent = "Failed";
|
||||
}
|
||||
if (message) {
|
||||
message.textContent = error instanceof Error ? error.message : "Character reanalysis could not be queued.";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
start();
|
||||
})();
|
||||
@ -23,6 +23,21 @@
|
||||
window.console.debug(`[StoryIntelligenceProgress] ${message}`, data || "");
|
||||
}
|
||||
};
|
||||
const diagnosticInfo = (message, data) => {
|
||||
if (diagnosticsEnabled && window.console?.info) {
|
||||
window.console.info(`[StoryIntelligenceProgress] ${message}`, data || "");
|
||||
}
|
||||
};
|
||||
const diagnosticWarn = (message, data) => {
|
||||
if (diagnosticsEnabled && window.console?.warn) {
|
||||
window.console.warn(`[StoryIntelligenceProgress] ${message}`, data || "");
|
||||
}
|
||||
};
|
||||
const diagnosticError = (message, error, data) => {
|
||||
if (diagnosticsEnabled && window.console?.error) {
|
||||
window.console.error(`[StoryIntelligenceProgress] ${message}`, data || "", error || "");
|
||||
}
|
||||
};
|
||||
|
||||
const numberText = (value) => {
|
||||
const number = Number.parseInt(value ?? "0", 10);
|
||||
@ -416,6 +431,12 @@
|
||||
onboardingRoots.forEach((root) => {
|
||||
const chapter = root.querySelector(`[data-story-run-id="${runId}"]`);
|
||||
if (!chapter) {
|
||||
diagnosticWarn("No chapter element matched incoming run progress.", {
|
||||
incomingRunId: String(runId),
|
||||
domRunIds: [...document.querySelectorAll("[data-story-run-id]")]
|
||||
.map((node) => node.dataset.storyRunId)
|
||||
.filter(Boolean)
|
||||
});
|
||||
return;
|
||||
}
|
||||
matched = true;
|
||||
@ -448,8 +469,8 @@
|
||||
}
|
||||
if (currentSceneNumber !== null) {
|
||||
chapter.dataset.storyCurrentSceneNumber = String(currentSceneNumber);
|
||||
} else if (completed !== null) {
|
||||
chapter.dataset.storyCurrentSceneNumber = String(completed);
|
||||
} else {
|
||||
chapter.dataset.storyCurrentSceneNumber = "0";
|
||||
}
|
||||
if (currentSceneDisplayTitle) {
|
||||
chapter.dataset.storyCurrentSceneTitle = currentSceneDisplayTitle;
|
||||
@ -512,7 +533,7 @@
|
||||
|
||||
const totalNumber = Number.parseInt(total || "0", 10) || 0;
|
||||
const completedNumber = Number.parseInt(completed || "0", 10) || 0;
|
||||
root.querySelectorAll("[data-story-chapter-progress-bar]").forEach((node) => {
|
||||
chapter.querySelectorAll("[data-story-chapter-progress-bar]").forEach((node) => {
|
||||
const percent = totalNumber > 0 ? Math.floor((Math.min(completedNumber, totalNumber) * 100) / totalNumber) : 0;
|
||||
node.style.width = `${Math.max(0, Math.min(100, percent))}%`;
|
||||
});
|
||||
@ -560,11 +581,31 @@
|
||||
.build();
|
||||
|
||||
connection.on("StoryIntelligenceProgressChanged", applyLegacyProgress);
|
||||
connection.on("StoryIntelligenceRunProgressChanged", applyRunProgress);
|
||||
connection.on("StoryIntelligenceRunProgressChanged", (state) => {
|
||||
diagnosticInfo("StoryIntelligenceRunProgressChanged received.", {
|
||||
runId: field(state, "runID", "RunID") || field(state, "runId", "RunId"),
|
||||
chapterId: field(state, "chapterID", "ChapterID") || field(state, "chapterId", "ChapterId"),
|
||||
chapterNumber: field(state, "chapterNumber", "ChapterNumber"),
|
||||
status: field(state, "status", "Status"),
|
||||
currentStage: field(state, "currentStage", "CurrentStage"),
|
||||
currentSceneNumber: field(state, "currentSceneNumber", "CurrentSceneNumber"),
|
||||
completedScenes: field(state, "completedScenes", "CompletedScenes"),
|
||||
totalDetectedScenes: field(state, "totalDetectedScenes", "TotalDetectedScenes"),
|
||||
currentMessage: field(state, "currentMessage", "CurrentMessage")
|
||||
});
|
||||
applyRunProgress(state);
|
||||
});
|
||||
connection.onreconnecting((error) => {
|
||||
diagnosticError("SignalR reconnecting after connection loss.", error);
|
||||
});
|
||||
connection.onreconnected(() => {
|
||||
log("SignalR reconnected");
|
||||
legacyJobIds.forEach((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId).then(applyLegacyProgress).catch(() => {}));
|
||||
runIds.forEach((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10)).then(applyRunProgress).catch(() => {}));
|
||||
diagnosticInfo("SignalR reconnected.", { runIds, legacyJobIds });
|
||||
legacyJobIds.forEach((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId)
|
||||
.then(applyLegacyProgress)
|
||||
.catch((error) => diagnosticError("SignalR watch legacy job failed after reconnect.", error, { jobId })));
|
||||
runIds.forEach((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10))
|
||||
.then(applyRunProgress)
|
||||
.catch((error) => diagnosticError("SignalR watch run failed after reconnect.", error, { runId })));
|
||||
});
|
||||
connection.onclose(() => {
|
||||
log("SignalR closed; retrying start");
|
||||
@ -576,7 +617,7 @@
|
||||
window.clearTimeout(startRetryTimer);
|
||||
connection.start()
|
||||
.then(() => {
|
||||
log("SignalR connected", { runIds, legacyJobIds });
|
||||
diagnosticInfo("SignalR connected.", { runIds, legacyJobIds });
|
||||
return Promise.all([
|
||||
...legacyJobIds.map((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId)),
|
||||
...runIds.map((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10)))
|
||||
@ -591,8 +632,8 @@
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
log("SignalR start or watch failed; retrying");
|
||||
.catch((error) => {
|
||||
diagnosticError("SignalR start or watch failed; retrying.", error, { runIds, legacyJobIds });
|
||||
startRetryTimer = window.setTimeout(startWithRetry, 2000);
|
||||
});
|
||||
};
|
||||
|
||||
123
PlotLine/wwwroot/js/story-review-centre-progress.js
Normal file
123
PlotLine/wwwroot/js/story-review-centre-progress.js
Normal file
@ -0,0 +1,123 @@
|
||||
(() => {
|
||||
const characterTile = document.querySelector("[data-character-enrichment-tile]");
|
||||
const plotTile = document.querySelector("[data-plot-synthesis-tile]");
|
||||
if (!characterTile || typeof signalR === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const bookId = Number(characterTile.dataset.bookId || plotTile?.dataset.bookId || 0);
|
||||
const projectId = Number(characterTile.dataset.projectId || 0);
|
||||
if (!bookId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const encode = (value) => {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = value || "";
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
const progressHtml = (total, processed, message) => {
|
||||
const hasTotal = Number(total) > 0;
|
||||
const percent = hasTotal ? Math.max(0, Math.min(100, Math.round((Number(processed) || 0) * 100 / Number(total)))) : null;
|
||||
const count = hasTotal ? ` ${Number(processed || 0).toLocaleString()} / ${Number(total).toLocaleString()}` : "";
|
||||
return `
|
||||
<div class="story-review-centre-progress" data-progress-kind="${percent === null ? "indeterminate" : "determinate"}">
|
||||
<div class="story-review-centre-progress__bar" role="progressbar" aria-valuemin="0" aria-valuemax="100"${percent === null ? "" : ` aria-valuenow="${percent}"`}>
|
||||
<span style="width: ${percent === null ? 100 : percent}%"></span>
|
||||
</div>
|
||||
<p>${encode(message || "Creating summaries and extracting character details...")}${encode(count)}</p>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
const setAction = (html) => {
|
||||
const action = characterTile.querySelector("[data-character-enrichment-action]");
|
||||
if (action) {
|
||||
action.innerHTML = html;
|
||||
}
|
||||
};
|
||||
|
||||
const applyCharacter = (snapshot) => {
|
||||
if (!snapshot || Number(snapshot.bookID || snapshot.bookId) !== bookId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusValue = snapshot.status || snapshot.Status || "";
|
||||
const status = characterTile.querySelector("[data-character-enrichment-status]");
|
||||
const description = characterTile.querySelector("[data-character-enrichment-description]");
|
||||
const progress = characterTile.querySelector("[data-character-enrichment-progress]");
|
||||
const total = snapshot.totalCharacters ?? snapshot.TotalCharacters ?? 0;
|
||||
const processed = snapshot.processedCharacters ?? snapshot.ProcessedCharacters ?? 0;
|
||||
const message = snapshot.currentMessage || snapshot.CurrentMessage;
|
||||
|
||||
if (statusValue === "Queued" || statusValue === "Running") {
|
||||
if (status) {
|
||||
status.textContent = "Analysing";
|
||||
status.className = "story-review-centre-tile__status story-review-centre-tile__status--ready";
|
||||
}
|
||||
if (description) {
|
||||
description.textContent = "PlotDirector is creating character summaries and extracting supported details.";
|
||||
}
|
||||
if (progress) {
|
||||
progress.innerHTML = progressHtml(total, processed, message);
|
||||
}
|
||||
setAction('<span class="btn btn-outline-primary btn-sm disabled" aria-disabled="true">Analysing</span>');
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusValue === "Completed") {
|
||||
if (status) {
|
||||
status.textContent = "Complete";
|
||||
status.className = "story-review-centre-tile__status story-review-centre-tile__status--ready";
|
||||
}
|
||||
if (description) {
|
||||
description.textContent = "Character identities, summaries, and supported details are ready.";
|
||||
}
|
||||
if (progress) {
|
||||
progress.innerHTML = "";
|
||||
}
|
||||
setAction(`<a class="btn btn-outline-primary btn-sm" href="/Characters?projectId=${projectId}">View Characters</a>`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusValue === "Failed") {
|
||||
if (status) {
|
||||
status.textContent = "Failed";
|
||||
status.className = "story-review-centre-tile__status story-review-centre-tile__status--warning";
|
||||
}
|
||||
if (description) {
|
||||
description.textContent = snapshot.errorMessage || snapshot.ErrorMessage || "Character enrichment failed and can be retried.";
|
||||
}
|
||||
if (progress) {
|
||||
progress.innerHTML = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl("/hubs/story-intelligence")
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
const refresh = () => {
|
||||
connection.invoke("WatchCharacterEnrichment", bookId)
|
||||
.then(applyCharacter)
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
connection.on("CharacterEnrichmentProgressChanged", applyCharacter);
|
||||
connection.on("StoryIntelligenceGlobalProgressChanged", () => {
|
||||
if (plotTile?.querySelector(".story-review-centre-progress")) {
|
||||
window.setTimeout(() => window.location.reload(), 700);
|
||||
}
|
||||
});
|
||||
connection.onreconnected(refresh);
|
||||
|
||||
const start = () => {
|
||||
connection.start()
|
||||
.then(refresh)
|
||||
.catch(() => window.setTimeout(start, 2000));
|
||||
};
|
||||
|
||||
start();
|
||||
})();
|
||||
@ -2628,7 +2628,7 @@
|
||||
const title = instruction.markerTitle || instruction.MarkerTitle || "PlotDirector Chapter";
|
||||
const prefix = instruction.markerPrefix || instruction.MarkerPrefix || "PD-CHAPTER";
|
||||
if (tag && Number.isInteger(index) && paragraphs.items[index]) {
|
||||
markersChanged = ensureAnchorControl(paragraphs.items[index], title, tag, prefix) || markersChanged;
|
||||
markersChanged = await ensureAnchorControl(context, paragraphs.items[index], title, tag, prefix) || markersChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2638,7 +2638,7 @@
|
||||
const title = instruction.markerTitle || instruction.MarkerTitle || "PlotDirector Scene";
|
||||
const prefix = instruction.markerPrefix || instruction.MarkerPrefix || "PD-SCENE";
|
||||
if (tag && Number.isInteger(index) && paragraphs.items[index]) {
|
||||
markersChanged = ensureAnchorControl(paragraphs.items[index], title, tag, prefix) || markersChanged;
|
||||
markersChanged = await ensureAnchorControl(context, paragraphs.items[index], title, tag, prefix) || markersChanged;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -2646,7 +2646,7 @@
|
||||
const chapterId = chapterMap.get(chapter.temporaryChapterKey);
|
||||
const index = Number.parseInt(chapter.startPosition, 10);
|
||||
if (chapterId && Number.isInteger(index) && paragraphs.items[index]) {
|
||||
markersChanged = ensureAnchorControl(paragraphs.items[index], "PlotDirector Chapter", `PD-CHAPTER-${chapterId}`, "PD-CHAPTER") || markersChanged;
|
||||
markersChanged = await ensureAnchorControl(context, paragraphs.items[index], "PlotDirector Chapter", `PD-CHAPTER-${chapterId}`, "PD-CHAPTER") || markersChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2654,7 +2654,7 @@
|
||||
const sceneId = sceneMap.get(scene.temporarySceneKey);
|
||||
const index = Number.parseInt(scene.startPosition, 10);
|
||||
if (sceneId && Number.isInteger(index) && paragraphs.items[index]) {
|
||||
markersChanged = ensureAnchorControl(paragraphs.items[index], "PlotDirector Scene", `PD-SCENE-${sceneId}`, "PD-SCENE") || markersChanged;
|
||||
markersChanged = await ensureAnchorControl(context, paragraphs.items[index], "PlotDirector Scene", `PD-SCENE-${sceneId}`, "PD-SCENE") || markersChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3641,13 +3641,13 @@
|
||||
if (!chapterId) {
|
||||
throw new Error("Chapter ID is not available.");
|
||||
}
|
||||
ensureAnchorControl(paragraph, "PlotDirector Chapter", `PD-CHAPTER-${chapterId}`, "PD-CHAPTER");
|
||||
await ensureAnchorControl(context, paragraph, "PlotDirector Chapter", `PD-CHAPTER-${chapterId}`, "PD-CHAPTER");
|
||||
} else {
|
||||
const sceneId = item.pdScene?.sceneId;
|
||||
if (!sceneId) {
|
||||
throw new Error("Scene ID is not available.");
|
||||
}
|
||||
ensureAnchorControl(paragraph, "PlotDirector Scene", `PD-SCENE-${sceneId}`, "PD-SCENE");
|
||||
await ensureAnchorControl(context, paragraph, "PlotDirector Scene", `PD-SCENE-${sceneId}`, "PD-SCENE");
|
||||
}
|
||||
|
||||
await context.sync();
|
||||
@ -4609,17 +4609,37 @@
|
||||
return controls.find((control) => String(control.tag || "") === tag) || null;
|
||||
};
|
||||
|
||||
const ensureAnchorControl = (paragraph, title, tag, prefix) => {
|
||||
const ensureAnchorControl = async (context, paragraph, title, tag, prefix) => {
|
||||
const existing = findExactAnchorControl(paragraph, tag);
|
||||
if (existing && existing.tag === tag && existing.title === title) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const control = existing || paragraph.getRange().insertContentControl();
|
||||
if (existing) {
|
||||
existing.title = title;
|
||||
existing.tag = tag;
|
||||
existing.appearance = "Hidden";
|
||||
await context.sync();
|
||||
return true;
|
||||
}
|
||||
|
||||
const rangeLocations = ["Content", "Start", "Whole"];
|
||||
let lastError = null;
|
||||
for (const rangeLocation of rangeLocations) {
|
||||
try {
|
||||
const control = paragraph.getRange(rangeLocation).insertContentControl();
|
||||
control.title = title;
|
||||
control.tag = tag;
|
||||
control.appearance = "Hidden";
|
||||
await context.sync();
|
||||
return true;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
console.warn(`Unable to create PlotDirector marker ${tag} using the ${rangeLocation} paragraph range.`, error);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`Unable to create PlotDirector marker ${tag}.`);
|
||||
};
|
||||
|
||||
const attachResolvedPlotDirectorIds = async (successMessage = "PlotDirector IDs attached successfully.") => {
|
||||
@ -4657,8 +4677,8 @@
|
||||
throw new Error("Unable to locate the current Word headings.");
|
||||
}
|
||||
|
||||
ensureAnchorControl(chapterParagraph, "PlotDirector Chapter", `PD-CHAPTER-${resolvedChapterId}`, "PD-CHAPTER");
|
||||
ensureAnchorControl(sceneParagraph, "PlotDirector Scene", `PD-SCENE-${resolvedSceneId}`, "PD-SCENE");
|
||||
await ensureAnchorControl(context, chapterParagraph, "PlotDirector Chapter", `PD-CHAPTER-${resolvedChapterId}`, "PD-CHAPTER");
|
||||
await ensureAnchorControl(context, sceneParagraph, "PlotDirector Scene", `PD-SCENE-${resolvedSceneId}`, "PD-SCENE");
|
||||
await context.sync();
|
||||
});
|
||||
await inspectPlotDirectorMarkers().catch(() => null);
|
||||
@ -4711,7 +4731,7 @@
|
||||
throw new Error("Unable to locate the current Word chapter heading.");
|
||||
}
|
||||
|
||||
ensureAnchorControl(chapterParagraph, "PlotDirector Chapter", `PD-CHAPTER-${resolvedChapterId}`, "PD-CHAPTER");
|
||||
await ensureAnchorControl(context, chapterParagraph, "PlotDirector Chapter", `PD-CHAPTER-${resolvedChapterId}`, "PD-CHAPTER");
|
||||
await context.sync();
|
||||
});
|
||||
await inspectPlotDirectorMarkers().catch(() => null);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user