Compare commits
2 Commits
85286428c2
...
80d13c6a7f
| Author | SHA1 | Date | |
|---|---|---|---|
| 80d13c6a7f | |||
| b639b12163 |
@ -202,6 +202,9 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Narrative candidate merge and parent fields are persisted", NarrativeCandidateMergeAndParentFieldsArePersisted),
|
("Narrative candidate merge and parent fields are persisted", NarrativeCandidateMergeAndParentFieldsArePersisted),
|
||||||
("Narrative prompt uses one scene call with compact schema", NarrativePromptUsesOneSceneCallWithCompactSchema),
|
("Narrative prompt uses one scene call with compact schema", NarrativePromptUsesOneSceneCallWithCompactSchema),
|
||||||
("Narrative production code remains fixture agnostic", NarrativeProductionCodeRemainsFixtureAgnostic),
|
("Narrative production code remains fixture agnostic", NarrativeProductionCodeRemainsFixtureAgnostic),
|
||||||
|
("Whole-book Plot Intelligence prompt carries full manuscript context", WholeBookPlotIntelligencePromptCarriesFullManuscriptContext),
|
||||||
|
("Whole-book Plot Intelligence schema requires timeline event types", WholeBookPlotIntelligenceSchemaRequiresTimelineEventTypes),
|
||||||
|
("Whole-book Plot Intelligence production code remains fixture agnostic", WholeBookPlotIntelligenceProductionCodeRemainsFixtureAgnostic),
|
||||||
("Story Intelligence narrative review creates canonical records via existing procs", StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs),
|
("Story Intelligence narrative review creates canonical records via existing procs", StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs),
|
||||||
("Story Intelligence narrative stage follows knowledge", StoryIntelligenceNarrativeStageFollowsKnowledge),
|
("Story Intelligence narrative stage follows knowledge", StoryIntelligenceNarrativeStageFollowsKnowledge),
|
||||||
("Word Companion replays live current scene to writing panes", WordCompanionReplaysLiveCurrentSceneToWritingPanes),
|
("Word Companion replays live current scene to writing panes", WordCompanionReplaysLiveCurrentSceneToWritingPanes),
|
||||||
@ -261,6 +264,7 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Story Intelligence global banner remains visible through finalising gaps", StoryIntelligenceGlobalBannerRemainsVisibleThroughFinalisingGaps),
|
("Story Intelligence global banner remains visible through finalising gaps", StoryIntelligenceGlobalBannerRemainsVisibleThroughFinalisingGaps),
|
||||||
("Story Intelligence global banner rejects stale live snapshots", StoryIntelligenceGlobalBannerRejectsStaleLiveSnapshots),
|
("Story Intelligence global banner rejects stale live snapshots", StoryIntelligenceGlobalBannerRejectsStaleLiveSnapshots),
|
||||||
("Story Intelligence live global progress publishes authoritative scene aggregates", StoryIntelligenceLiveGlobalProgressPublishesAuthoritativeSceneAggregates),
|
("Story Intelligence live global progress publishes authoritative scene aggregates", StoryIntelligenceLiveGlobalProgressPublishesAuthoritativeSceneAggregates),
|
||||||
|
("Story Intelligence global progress survives initial SignalR start races", StoryIntelligenceGlobalProgressSurvivesInitialSignalRStartRaces),
|
||||||
("Story Intelligence scene analysis completion barrier releases review", StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview),
|
("Story Intelligence scene analysis completion barrier releases review", StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview),
|
||||||
("Story Intelligence active progress only appears in global strip", StoryIntelligenceActiveProgressOnlyAppearsInGlobalStrip),
|
("Story Intelligence active progress only appears in global strip", StoryIntelligenceActiveProgressOnlyAppearsInGlobalStrip),
|
||||||
("Story Intelligence book actions do not open review centre while running", StoryIntelligenceBookActionsDoNotOpenReviewCentreWhileRunning)
|
("Story Intelligence book actions do not open review centre while running", StoryIntelligenceBookActionsDoNotOpenReviewCentreWhileRunning)
|
||||||
@ -468,13 +472,34 @@ static void StoryIntelligenceLiveGlobalProgressPublishesAuthoritativeSceneAggreg
|
|||||||
Assert(globalService.Contains("Task<StoryIntelligenceGlobalProgressViewModel> GetForUserAsync(int userId)", StringComparison.Ordinal), "Background publishing should be able to retrieve the same user-scoped aggregate used by page load.");
|
Assert(globalService.Contains("Task<StoryIntelligenceGlobalProgressViewModel> GetForUserAsync(int userId)", StringComparison.Ordinal), "Background publishing should be able to retrieve the same user-scoped aggregate used by page load.");
|
||||||
Assert(notifier.Contains("GetForUserAsync(userId)", StringComparison.Ordinal), "Live progress publish should fetch the authoritative aggregate for the run owner.");
|
Assert(notifier.Contains("GetForUserAsync(userId)", StringComparison.Ordinal), "Live progress publish should fetch the authoritative aggregate for the run owner.");
|
||||||
Assert(notifier.Contains("StoryIntelligenceGlobalProgressChanged", StringComparison.Ordinal), "Live progress publish should send a compact aggregate SignalR event.");
|
Assert(notifier.Contains("StoryIntelligenceGlobalProgressChanged", StringComparison.Ordinal), "Live progress publish should send a compact aggregate SignalR event.");
|
||||||
Assert(script.Contains("connection.on(\"StoryIntelligenceGlobalProgressChanged\", render)", StringComparison.Ordinal), "The global banner should render authoritative aggregate payloads directly.");
|
Assert(script.Contains("connection.on(\"StoryIntelligenceGlobalProgressChanged\"", StringComparison.Ordinal)
|
||||||
Assert(script.Contains("connection.onreconnected(refresh)", StringComparison.Ordinal), "Reconnect should rejoin the user group and immediately catch up from persisted aggregate state.");
|
&& script.Contains("render(snapshot);", StringComparison.Ordinal), "The global banner should render authoritative aggregate payloads directly.");
|
||||||
|
Assert(script.Contains("connection.onreconnected", StringComparison.Ordinal)
|
||||||
|
&& script.Contains("refresh();", StringComparison.Ordinal), "Reconnect should rejoin the user group and immediately catch up from persisted aggregate state.");
|
||||||
Assert(script.Contains("updatedTime === lastSnapshotTime && snapshotScore < lastSnapshotScore", StringComparison.Ordinal), "Equal-timestamp out-of-order snapshots should not regress completed/failed scene totals.");
|
Assert(script.Contains("updatedTime === lastSnapshotTime && snapshotScore < lastSnapshotScore", StringComparison.Ordinal), "Equal-timestamp out-of-order snapshots should not regress completed/failed scene totals.");
|
||||||
Assert(layout.Contains("data-completed-scenes", StringComparison.Ordinal), "Initial server render should seed completed count for monotonic live comparisons.");
|
Assert(layout.Contains("data-completed-scenes", StringComparison.Ordinal), "Initial server render should seed completed count for monotonic live comparisons.");
|
||||||
Assert(layout.Contains("data-failed-scenes", StringComparison.Ordinal), "Initial server render should seed terminal failed count for monotonic live comparisons.");
|
Assert(layout.Contains("data-failed-scenes", StringComparison.Ordinal), "Initial server render should seed terminal failed count for monotonic live comparisons.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void StoryIntelligenceGlobalProgressSurvivesInitialSignalRStartRaces()
|
||||||
|
{
|
||||||
|
var root = PlotLineRoot();
|
||||||
|
var layout = File.ReadAllText(Path.Combine(root, "Views/Shared/_Layout.cshtml"));
|
||||||
|
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js"));
|
||||||
|
|
||||||
|
Assert(layout.Contains("User.Identity?.IsAuthenticated == true", StringComparison.Ordinal), "Authenticated pages should always render the global progress host so a later import can appear without a full refresh.");
|
||||||
|
Assert(layout.Contains("data-story-intelligence-has-active-work", StringComparison.Ordinal), "Initial render should expose whether the hidden strip currently has active work.");
|
||||||
|
Assert(layout.Contains("hidden=\"@(!storyIntelligenceProgress.HasActiveWork)\"", StringComparison.Ordinal), "The global strip should be hidden, not absent, when no active work is visible at page render.");
|
||||||
|
Assert(script.Contains("const startWithRetry = () =>", StringComparison.Ordinal), "Initial SignalR start failures should retry instead of leaving the banner permanently stale.");
|
||||||
|
Assert(script.Contains("startRetryTimer = window.setTimeout(startWithRetry, 2000)", StringComparison.Ordinal), "Initial start retry should be bounded and low-noise.");
|
||||||
|
Assert(script.Contains("connection.onclose(() =>", StringComparison.Ordinal), "The client should recover after automatic reconnect is exhausted.");
|
||||||
|
Assert(script.Contains("connection.onreconnected(() =>", StringComparison.Ordinal), "Reconnect should immediately refresh authoritative persisted progress.");
|
||||||
|
Assert(script.Contains(".then(() =>") && script.Contains("refresh();"), "Successful connection start should close the render/subscription race by fetching the current aggregate.");
|
||||||
|
Assert(script.Contains("snapshotKey !== lastSnapshotKey", StringComparison.Ordinal), "Progress monotonicity should reset only when the active book scope changes.");
|
||||||
|
Assert(script.Contains("snapshotScore < lastSnapshotScore", StringComparison.Ordinal), "A newer but backwards snapshot for the same book must not regress the banner.");
|
||||||
|
Assert(script.Contains("StoryIntelligenceGlobalProgress]"), "Development builds should expose low-noise client diagnostics.");
|
||||||
|
}
|
||||||
|
|
||||||
static void StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview()
|
static void StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview()
|
||||||
{
|
{
|
||||||
var root = PlotLineRoot();
|
var root = PlotLineRoot();
|
||||||
@ -578,7 +603,8 @@ static void StoryIntelligenceRecoveredJsonRepairStaysOperational()
|
|||||||
Assert(notificationSql.Contains("Status IN (N'Failed', N'Cancelled')", StringComparison.Ordinal), "Failure notification should be based on final failed/cancelled runs.");
|
Assert(notificationSql.Contains("Status IN (N'Failed', N'Cancelled')", StringComparison.Ordinal), "Failure notification should be based on final failed/cancelled runs.");
|
||||||
Assert(notificationSql.Contains("SET @NotificationKind = CASE WHEN @HasFailedRuns = 1 THEN N'Failure' ELSE N'Success' END", StringComparison.Ordinal), "Recovered runs should not generate failure notifications.");
|
Assert(notificationSql.Contains("SET @NotificationKind = CASE WHEN @HasFailedRuns = 1 THEN N'Failure' ELSE N'Success' END", StringComparison.Ordinal), "Recovered runs should not generate failure notifications.");
|
||||||
Assert(progressScript.Contains("current.failedScenes += failed", StringComparison.Ordinal), "Onboarding Needs review should use persisted terminal FailedScenes only.");
|
Assert(progressScript.Contains("current.failedScenes += failed", StringComparison.Ordinal), "Onboarding Needs review should use persisted terminal FailedScenes only.");
|
||||||
Assert(globalScript.Contains("const failed = readNumber(progress.failedScenes ?? progress.FailedScenes) || 0", StringComparison.Ordinal), "Global banner should use terminal failed-scene count only.");
|
Assert(globalScript.Contains("const failed = readNumber(progress.failedScenes ?? progress.FailedScenes)", StringComparison.Ordinal)
|
||||||
|
&& globalScript.Contains("readNumber(primary.failedScenes ?? primary.FailedScenes)", StringComparison.Ordinal), "Global banner should use terminal failed-scene count only.");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void StoryIntelligenceTerminalFailuresRemainUserVisible()
|
static void StoryIntelligenceTerminalFailuresRemainUserVisible()
|
||||||
@ -690,7 +716,7 @@ static void StoryIntelligenceReviewCentreUsesIndependentTileGrid()
|
|||||||
"\"Assets\"",
|
"\"Assets\"",
|
||||||
"\"Knowledge\"",
|
"\"Knowledge\"",
|
||||||
"\"Locations\"",
|
"\"Locations\"",
|
||||||
"\"Plot Lines & Threads\"",
|
"ReviewPlotSynthesis()",
|
||||||
"\"Relationships\"",
|
"\"Relationships\"",
|
||||||
"ReviewMetrics"
|
"ReviewMetrics"
|
||||||
};
|
};
|
||||||
@ -3820,12 +3846,12 @@ static void StoryIntelligenceStartsFromCoreImportComplete()
|
|||||||
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
|
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
|
||||||
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
|
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
|
||||||
|
|
||||||
Assert(complete.Contains("asp-action=\"StartStoryIntelligence\"", StringComparison.Ordinal), "Core Import Complete should start Story Intelligence directly.");
|
Assert(complete.Contains("asp-action=\"StoryIntelligencePlotContext\"", StringComparison.Ordinal), "Core Import Complete should open the optional author Plot Line context screen before analysis.");
|
||||||
Assert(!complete.Contains("asp-action=\"StoryIntelligence\"", StringComparison.Ordinal), "Core Import Complete should not route through the redundant ready page.");
|
Assert(!complete.Contains("asp-action=\"StoryIntelligence\"", StringComparison.Ordinal), "Core Import Complete should not route through the redundant ready page.");
|
||||||
Assert(controller.Contains("public async Task<IActionResult> StoryIntelligence()\n {", StringComparison.Ordinal)
|
Assert(controller.Contains("public async Task<IActionResult> StoryIntelligence()\n {", StringComparison.Ordinal)
|
||||||
&& controller.Contains("return RedirectToAction(nameof(BuildComplete)", StringComparison.Ordinal), "Old ready route should be a compatibility redirect.");
|
&& controller.Contains("return RedirectToAction(nameof(BuildComplete)", StringComparison.Ordinal), "Old ready route should be a compatibility redirect.");
|
||||||
Assert(controller.Contains("public async Task<IActionResult> StoryIntelligenceContext()\n {\n return await StoryIntelligence();", StringComparison.Ordinal), "Old context route should not remain an active wizard page.");
|
Assert(controller.Contains("public async Task<IActionResult> StoryIntelligenceContext()\n {\n return await StoryIntelligence();", StringComparison.Ordinal), "Old context route should not remain an active wizard page.");
|
||||||
Assert(controller.Contains("var job = await storyIntelligence.StartAsync();", StringComparison.Ordinal), "Story Intelligence start should not require portrait/context form fields.");
|
Assert(controller.Contains("StartStoryIntelligenceWithPlotContext", StringComparison.Ordinal), "Story Intelligence start should save optional Plot Line context before queuing analysis.");
|
||||||
Assert(service.Contains("BookContextOptions.StoryEraLabel(book?.StoryEra)", StringComparison.Ordinal), "Story Intelligence should derive era from Book context.");
|
Assert(service.Contains("BookContextOptions.StoryEraLabel(book?.StoryEra)", StringComparison.Ordinal), "Story Intelligence should derive era from Book context.");
|
||||||
Assert(service.Contains("BookContextOptions.PrimaryLocaleLabel(book?.PrimaryLocaleCode)", StringComparison.Ordinal), "Story Intelligence should derive locale from Book context.");
|
Assert(service.Contains("BookContextOptions.PrimaryLocaleLabel(book?.PrimaryLocaleCode)", StringComparison.Ordinal), "Story Intelligence should derive locale from Book context.");
|
||||||
}
|
}
|
||||||
@ -4542,7 +4568,7 @@ static void PermanentAnalyseMyBookStartsFromSavedBook()
|
|||||||
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
|
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
|
||||||
var core = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
|
var core = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
|
||||||
|
|
||||||
Assert(bookView.Contains("StartBookStoryIntelligence", StringComparison.Ordinal), "Book details should expose a permanent Analyse My Book action.");
|
Assert(bookView.Contains("StoryIntelligenceBookPlotContext", StringComparison.Ordinal), "Book details should expose a permanent Analyse My Book action through author Plot Line context.");
|
||||||
Assert(controller.Contains("story-intelligence/book/{bookId:int}/start", StringComparison.Ordinal), "Permanent Story Intelligence start should have a book-scoped route.");
|
Assert(controller.Contains("story-intelligence/book/{bookId:int}/start", StringComparison.Ordinal), "Permanent Story Intelligence start should have a book-scoped route.");
|
||||||
Assert(service.Contains("Task<OnboardingStoryIntelligenceBatch?> StartBookAsync(int bookId)", StringComparison.Ordinal), "Story Intelligence service should support starting from a saved book.");
|
Assert(service.Contains("Task<OnboardingStoryIntelligenceBatch?> StartBookAsync(int bookId)", StringComparison.Ordinal), "Story Intelligence service should support starting from a saved book.");
|
||||||
Assert(service.Contains("sources.GetChapterSourceAsync(chapter.ChapterID)", StringComparison.Ordinal), "Saved-book analysis should use durable persisted chapter text.");
|
Assert(service.Contains("sources.GetChapterSourceAsync(chapter.ChapterID)", StringComparison.Ordinal), "Saved-book analysis should use durable persisted chapter text.");
|
||||||
@ -4985,7 +5011,7 @@ static void StoryIntelligenceStabilisationRoutesNormalUsersToBook()
|
|||||||
var startAction = ExtractBetween(controller, "public async Task<IActionResult> StartStoryIntelligence()", "[HttpPost(\"story-intelligence/book/{bookId:int}/start\")]");
|
var startAction = ExtractBetween(controller, "public async Task<IActionResult> StartStoryIntelligence()", "[HttpPost(\"story-intelligence/book/{bookId:int}/start\")]");
|
||||||
Assert(startAction.Contains("return RedirectToBook(progress);", StringComparison.Ordinal), "Fresh onboarding Story Intelligence start should return to the Book page.");
|
Assert(startAction.Contains("return RedirectToBook(progress);", StringComparison.Ordinal), "Fresh onboarding Story Intelligence start should return to the Book page.");
|
||||||
Assert(!startAction.Contains("RedirectToAction(nameof(StoryIntelligenceProgress)", StringComparison.Ordinal), "Fresh onboarding start must not route normal users to the old progress page.");
|
Assert(!startAction.Contains("RedirectToAction(nameof(StoryIntelligenceProgress)", StringComparison.Ordinal), "Fresh onboarding start must not route normal users to the old progress page.");
|
||||||
Assert(buildComplete.Contains("asp-action=\"StartStoryIntelligence\"", StringComparison.Ordinal), "Core Import Complete should still start the analysis.");
|
Assert(buildComplete.Contains("asp-action=\"StoryIntelligencePlotContext\"", StringComparison.Ordinal), "Core Import Complete should open optional plot context before analysis.");
|
||||||
Assert(overview.Contains("asp-controller=\"Books\" asp-action=\"Details\"", StringComparison.Ordinal), "Existing analysis overview should return to Book details.");
|
Assert(overview.Contains("asp-controller=\"Books\" asp-action=\"Details\"", StringComparison.Ordinal), "Existing analysis overview should return to Book details.");
|
||||||
Assert(context.Contains("asp-controller=\"Books\" asp-action=\"Details\"", StringComparison.Ordinal), "Existing analysis context page should return to Book details.");
|
Assert(context.Contains("asp-controller=\"Books\" asp-action=\"Details\"", StringComparison.Ordinal), "Existing analysis context page should return to Book details.");
|
||||||
}
|
}
|
||||||
@ -5249,6 +5275,149 @@ static void NarrativeProductionCodeRemainsFixtureAgnostic()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void WholeBookPlotIntelligencePromptCarriesFullManuscriptContext()
|
||||||
|
{
|
||||||
|
var context = new WholeBookPlotSynthesisContext
|
||||||
|
{
|
||||||
|
ProjectID = 71,
|
||||||
|
BookID = 72,
|
||||||
|
BookTitle = "The Glass Meridian",
|
||||||
|
Characters =
|
||||||
|
[
|
||||||
|
new WholeBookPlotSynthesisCharacter
|
||||||
|
{
|
||||||
|
CharacterID = 101,
|
||||||
|
CanonicalName = "Nora Ell",
|
||||||
|
Aliases = ["Nora", "Doctor Ell"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
ExistingPlotLines =
|
||||||
|
[
|
||||||
|
new WholeBookPlotSynthesisExistingPlotLine
|
||||||
|
{
|
||||||
|
PlotLineID = 201,
|
||||||
|
Name = "The eclipse ledger",
|
||||||
|
BookID = null,
|
||||||
|
Threads =
|
||||||
|
[
|
||||||
|
new WholeBookPlotSynthesisExistingThread
|
||||||
|
{
|
||||||
|
PlotThreadID = 301,
|
||||||
|
Title = "The missing observatory pages",
|
||||||
|
Status = "Open"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
AuthorSeeds =
|
||||||
|
[
|
||||||
|
new AuthorPlotSeed
|
||||||
|
{
|
||||||
|
AuthorPlotSeedID = 401,
|
||||||
|
Description = "A public research rivalry hides a private collaboration that changes the expedition."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Chapters =
|
||||||
|
[
|
||||||
|
new WholeBookPlotSynthesisChapter
|
||||||
|
{
|
||||||
|
ChapterID = 501,
|
||||||
|
ChapterNumber = 1,
|
||||||
|
ChapterTitle = "North Window",
|
||||||
|
Scenes =
|
||||||
|
[
|
||||||
|
new WholeBookPlotSynthesisScene
|
||||||
|
{
|
||||||
|
SceneID = 601,
|
||||||
|
SceneNumber = 1,
|
||||||
|
SceneTitle = "A coded invitation",
|
||||||
|
PovCharacterName = "Nora Ell",
|
||||||
|
StructuralSetting = "Hilltop observatory",
|
||||||
|
SourceText = "Nora finds a coded invitation tucked inside the observatory ledger."
|
||||||
|
},
|
||||||
|
new WholeBookPlotSynthesisScene
|
||||||
|
{
|
||||||
|
SceneID = 602,
|
||||||
|
SceneNumber = 2,
|
||||||
|
SceneTitle = "The false chart",
|
||||||
|
PovCharacterName = "Nora Ell",
|
||||||
|
StructuralSetting = "Map room",
|
||||||
|
SourceText = "The false chart sends the expedition toward the wrong meridian."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
SourceAudit = new WholeBookPlotSynthesisSourceAudit
|
||||||
|
{
|
||||||
|
SceneCount = 2,
|
||||||
|
SourceRowCount = 2,
|
||||||
|
MissingSourceCount = 0,
|
||||||
|
DuplicateSourceSceneCount = 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var prompt = InvokeWholeBookPrompt(context);
|
||||||
|
Assert(prompt.Contains("[BOOK]", StringComparison.Ordinal), "Whole-book prompt should include book marker.");
|
||||||
|
Assert(prompt.Contains("BookID: 72", StringComparison.Ordinal), "Whole-book prompt should include BookID.");
|
||||||
|
Assert(prompt.Contains("[CHAPTER 1]", StringComparison.Ordinal), "Whole-book prompt should include deterministic chapter markers.");
|
||||||
|
Assert(prompt.Contains("SceneID: 601", StringComparison.Ordinal) && prompt.Contains("SceneID: 602", StringComparison.Ordinal), "Whole-book prompt should include canonical SceneIDs.");
|
||||||
|
Assert(prompt.Contains("Nora finds a coded invitation", StringComparison.Ordinal), "Whole-book prompt should include full scene source text.");
|
||||||
|
Assert(prompt.Contains("AuthorPlotSeedID: 401", StringComparison.Ordinal), "Whole-book prompt should include persisted author seeds.");
|
||||||
|
Assert(prompt.Contains("ThreadID: 301", StringComparison.Ordinal), "Whole-book prompt should include existing series/project thread context.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void WholeBookPlotIntelligenceSchemaRequiresTimelineEventTypes()
|
||||||
|
{
|
||||||
|
var schema = JsonSerializer.SerializeToElement(WholeBookPlotSynthesisStructuredOutputSchema.Contract.Schema);
|
||||||
|
var eventSchema = schema.GetProperty("properties")
|
||||||
|
.GetProperty("plotLines").GetProperty("items").GetProperty("properties")
|
||||||
|
.GetProperty("threads").GetProperty("items").GetProperty("properties")
|
||||||
|
.GetProperty("events").GetProperty("items").GetProperty("properties");
|
||||||
|
var eventTypes = eventSchema.GetProperty("eventType").GetProperty("enum")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(item => item.GetString())
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var expected in new[] { "Introduced", "Mentioned", "Developed", "Complicated", "Clue Planted", "Question Raised", "Question Answered", "False Lead", "Revealed", "Payoff", "Resolved", "Contradicted", "Retconned", "Note" })
|
||||||
|
{
|
||||||
|
Assert(eventTypes.Contains(expected), $"Whole-book schema should permit ThreadEvent type {expected}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert(WholeBookPlotSynthesisStructuredOutputSchema.Contract.Strict, "Whole-book schema should use strict structured output.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void WholeBookPlotIntelligenceProductionCodeRemainsFixtureAgnostic()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
|
var productionFiles = new[]
|
||||||
|
{
|
||||||
|
"Services/WholeBookPlotIntelligenceService.cs",
|
||||||
|
"Data/WholeBookPlotIntelligenceRepository.cs",
|
||||||
|
"Views/Onboarding/StoryIntelligenceAuthorPlotContext.cshtml",
|
||||||
|
"Views/Onboarding/StoryIntelligenceReviewCentre.cshtml",
|
||||||
|
"Views/Onboarding/StoryIntelligenceNarrative.cshtml",
|
||||||
|
"Sql/174_Phase27_WholeBookPlotIntelligence.sql"
|
||||||
|
};
|
||||||
|
var forbidden = new[] { "Memory Tin", "Triumph TR6", "Mrs Patterson", "Maggie", "Beth", "Rob", "Rosie", "Sophie", "Cindy", "Rick", "Alpha Flame" };
|
||||||
|
|
||||||
|
foreach (var relativePath in productionFiles)
|
||||||
|
{
|
||||||
|
var text = File.ReadAllText(Path.Combine(root, relativePath));
|
||||||
|
foreach (var term in forbidden)
|
||||||
|
{
|
||||||
|
Assert(!text.Contains(term, StringComparison.OrdinalIgnoreCase), $"Whole-book production file {relativePath} must not contain fixture term {term}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static string InvokeWholeBookPrompt(WholeBookPlotSynthesisContext context)
|
||||||
|
{
|
||||||
|
var method = typeof(WholeBookPlotIntelligenceService).GetMethod(
|
||||||
|
"BuildPrompt",
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||||
|
return (string)(method?.Invoke(null, [context, true]) ?? throw new InvalidOperationException("Whole-book prompt builder was not found."));
|
||||||
|
}
|
||||||
|
|
||||||
static void StoryIntelligenceSceneCallUsesStrictStructuredOutput()
|
static void StoryIntelligenceSceneCallUsesStrictStructuredOutput()
|
||||||
{
|
{
|
||||||
string? requestBody = null;
|
string? requestBody = null;
|
||||||
|
|||||||
@ -8,7 +8,11 @@ namespace PlotLine.Controllers;
|
|||||||
|
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("onboarding")]
|
[Route("onboarding")]
|
||||||
public sealed class OnboardingController(IOnboardingService onboarding, IOnboardingStoryIntelligenceService storyIntelligence) : Controller
|
public sealed class OnboardingController(
|
||||||
|
IOnboardingService onboarding,
|
||||||
|
IOnboardingStoryIntelligenceService storyIntelligence,
|
||||||
|
IWholeBookPlotIntelligenceService wholeBookPlotIntelligence,
|
||||||
|
ICurrentUserService currentUser) : Controller
|
||||||
{
|
{
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
@ -134,14 +138,80 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
[HttpPost("story-intelligence/start")]
|
[HttpPost("story-intelligence/start")]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> StartStoryIntelligence()
|
public async Task<IActionResult> StartStoryIntelligence()
|
||||||
|
=> RedirectToAction(nameof(StoryIntelligencePlotContext));
|
||||||
|
|
||||||
|
[HttpGet("story-intelligence/plot-context")]
|
||||||
|
public async Task<IActionResult> StoryIntelligencePlotContext()
|
||||||
{
|
{
|
||||||
var job = await storyIntelligence.StartAsync();
|
var overview = await storyIntelligence.GetOverviewAsync();
|
||||||
if (job is null)
|
if (!overview.BookID.HasValue || !overview.CanStart)
|
||||||
{
|
{
|
||||||
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";
|
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";
|
||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var model = await wholeBookPlotIntelligence.GetAuthorPlotContextAsync(overview.BookID.Value);
|
||||||
|
if (model is null)
|
||||||
|
{
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
}
|
||||||
|
|
||||||
|
return View("StoryIntelligenceAuthorPlotContext", model);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("story-intelligence/book/{bookId:int}/plot-context")]
|
||||||
|
public async Task<IActionResult> StoryIntelligenceBookPlotContext(int bookId)
|
||||||
|
{
|
||||||
|
var existing = await storyIntelligence.ResumeBookAsync(bookId);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
TempData["ArchiveMessage"] = "Story Intelligence is already available for this book. Progress is shown in the page header.";
|
||||||
|
return RedirectToAction("Details", "Books", new { id = bookId });
|
||||||
|
}
|
||||||
|
|
||||||
|
var model = await wholeBookPlotIntelligence.GetAuthorPlotContextAsync(bookId);
|
||||||
|
if (model is null)
|
||||||
|
{
|
||||||
|
TempData["ArchiveError"] = "Story Intelligence could not find that book.";
|
||||||
|
return RedirectToAction("Details", "Books", new { id = bookId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return View("StoryIntelligenceAuthorPlotContext", model);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("story-intelligence/plot-context/start")]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> StartStoryIntelligenceWithPlotContext(StoryIntelligenceAuthorPlotContextForm form)
|
||||||
|
{
|
||||||
|
if (form.ProjectID <= 0 || form.BookID <= 0)
|
||||||
|
{
|
||||||
|
TempData["OnboardingStoryIntelligenceError"] = "Story Intelligence could not find the selected book.";
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
}
|
||||||
|
|
||||||
|
await wholeBookPlotIntelligence.SaveAuthorPlotContextAsync(form.ProjectID, form.BookID, form.PlotSeeds);
|
||||||
|
|
||||||
|
OnboardingStoryIntelligenceBatch? job;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var overview = await storyIntelligence.GetOverviewAsync();
|
||||||
|
var isCurrentOnboardingBook = overview.BookID == form.BookID && overview.PreviewID != Guid.Empty && overview.CanStart;
|
||||||
|
job = isCurrentOnboardingBook
|
||||||
|
? await storyIntelligence.StartAsync()
|
||||||
|
: await storyIntelligence.StartBookAsync(form.BookID);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
TempData["ArchiveError"] = ex.Message;
|
||||||
|
return RedirectToAction("Details", "Books", new { id = form.BookID });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job is null)
|
||||||
|
{
|
||||||
|
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";
|
||||||
|
return RedirectToAction("Details", "Books", new { id = form.BookID });
|
||||||
|
}
|
||||||
|
|
||||||
var progress = await storyIntelligence.GetProgressAsync(job.BatchID);
|
var progress = await storyIntelligence.GetProgressAsync(job.BatchID);
|
||||||
if (progress is not null)
|
if (progress is not null)
|
||||||
{
|
{
|
||||||
@ -163,23 +233,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
return RedirectToAction("Details", "Books", new { id = bookId });
|
return RedirectToAction("Details", "Books", new { id = bookId });
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
return RedirectToAction(nameof(StoryIntelligenceBookPlotContext), new { bookId });
|
||||||
{
|
|
||||||
var job = await storyIntelligence.StartBookAsync(bookId);
|
|
||||||
if (job is null)
|
|
||||||
{
|
|
||||||
TempData["ArchiveError"] = "Story Intelligence could not find that book.";
|
|
||||||
return RedirectToAction("Details", "Books", new { id = bookId });
|
|
||||||
}
|
|
||||||
|
|
||||||
TempData["ArchiveMessage"] = "Story Intelligence analysis has started. You can keep working while PlotDirector analyses the book in the background.";
|
|
||||||
return RedirectToAction("Details", "Books", new { id = bookId });
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex)
|
|
||||||
{
|
|
||||||
TempData["ArchiveError"] = ex.Message;
|
|
||||||
return RedirectToAction("Details", "Books", new { id = bookId });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("story-intelligence/skip")]
|
[HttpPost("story-intelligence/skip")]
|
||||||
@ -578,6 +632,25 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
: RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId = form.BatchID });
|
: RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId = form.BatchID });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("story-intelligence/plot-synthesis/retry")]
|
||||||
|
public async Task<IActionResult> RetryStoryIntelligencePlotSynthesis(Guid batchId)
|
||||||
|
{
|
||||||
|
var model = await storyIntelligence.GetProgressAsync(batchId);
|
||||||
|
if (model is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentUser.UserId.HasValue)
|
||||||
|
{
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
await wholeBookPlotIntelligence.RetryAsync(model.BookID, currentUser.UserId.Value);
|
||||||
|
TempData["OnboardingStoryIntelligenceMessage"] = "Plot Lines analysis has been queued again.";
|
||||||
|
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("story-intelligence/narrative/complete")]
|
[HttpGet("story-intelligence/narrative/complete")]
|
||||||
public IActionResult StoryIntelligenceNarrativeComplete(Guid batchId)
|
public IActionResult StoryIntelligenceNarrativeComplete(Guid batchId)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -3260,6 +3260,13 @@ public sealed class StoryIntelligenceNarrativeCandidateRepository(ISqlConnection
|
|||||||
candidate.Significance,
|
candidate.Significance,
|
||||||
candidate.ExistingPlotLineID,
|
candidate.ExistingPlotLineID,
|
||||||
candidate.ExistingPlotThreadID,
|
candidate.ExistingPlotThreadID,
|
||||||
|
candidate.WholeBookPlotSynthesisRunID,
|
||||||
|
candidate.AuthorPlotSeedID,
|
||||||
|
candidate.ExistingPlotLineMatchConfidence,
|
||||||
|
candidate.ExistingPlotLineMatchReason,
|
||||||
|
candidate.SeedMatchConfidence,
|
||||||
|
candidate.SeedMatchReason,
|
||||||
|
candidate.Source,
|
||||||
candidate.RelatedPlotLineID,
|
candidate.RelatedPlotLineID,
|
||||||
candidate.ParentCandidateKey,
|
candidate.ParentCandidateKey,
|
||||||
candidate.ParentNarrativeCandidateID,
|
candidate.ParentNarrativeCandidateID,
|
||||||
@ -3293,13 +3300,20 @@ public sealed class StoryIntelligenceNarrativeCandidateRepository(ISqlConnection
|
|||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
var candidates = (await result.ReadAsync<StoryIntelligenceNarrativeCandidate>()).ToList();
|
var candidates = (await result.ReadAsync<StoryIntelligenceNarrativeCandidate>()).ToList();
|
||||||
var evidence = (await result.ReadAsync<StoryIntelligenceNarrativeCandidateEvidence>()).ToList();
|
var evidence = (await result.ReadAsync<StoryIntelligenceNarrativeCandidateEvidence>()).ToList();
|
||||||
|
var events = result.IsConsumed
|
||||||
|
? []
|
||||||
|
: (await result.ReadAsync<StoryIntelligenceNarrativeCandidateEvent>()).ToList();
|
||||||
var evidenceByCandidate = evidence
|
var evidenceByCandidate = evidence
|
||||||
.GroupBy(item => item.NarrativeCandidateID)
|
.GroupBy(item => item.NarrativeCandidateID)
|
||||||
.ToDictionary(group => group.Key, group => (IReadOnlyList<StoryIntelligenceNarrativeCandidateEvidence>)group.ToList());
|
.ToDictionary(group => group.Key, group => (IReadOnlyList<StoryIntelligenceNarrativeCandidateEvidence>)group.ToList());
|
||||||
|
var eventsByCandidate = events
|
||||||
|
.GroupBy(item => item.NarrativeCandidateID)
|
||||||
|
.ToDictionary(group => group.Key, group => (IReadOnlyList<StoryIntelligenceNarrativeCandidateEvent>)group.ToList());
|
||||||
|
|
||||||
foreach (var candidate in candidates)
|
foreach (var candidate in candidates)
|
||||||
{
|
{
|
||||||
candidate.Evidence = evidenceByCandidate.TryGetValue(candidate.NarrativeCandidateID, out var items) ? items : [];
|
candidate.Evidence = evidenceByCandidate.TryGetValue(candidate.NarrativeCandidateID, out var items) ? items : [];
|
||||||
|
candidate.Events = eventsByCandidate.TryGetValue(candidate.NarrativeCandidateID, out var candidateEvents) ? candidateEvents : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return candidates;
|
return candidates;
|
||||||
|
|||||||
365
PlotLine/Data/WholeBookPlotIntelligenceRepository.cs
Normal file
365
PlotLine/Data/WholeBookPlotIntelligenceRepository.cs
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
using System.Data;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Dapper;
|
||||||
|
using PlotLine.Models;
|
||||||
|
|
||||||
|
namespace PlotLine.Data;
|
||||||
|
|
||||||
|
public interface IWholeBookPlotIntelligenceRepository
|
||||||
|
{
|
||||||
|
Task SaveAuthorSeedsAsync(AuthorPlotSeedSaveRequest request);
|
||||||
|
Task<IReadOnlyList<AuthorPlotSeed>> ListAuthorSeedsAsync(int bookId);
|
||||||
|
Task<WholeBookPlotSynthesisRun> QueueAsync(WholeBookPlotSynthesisQueueRequest request);
|
||||||
|
Task<WholeBookPlotSynthesisRun?> RetryAsync(int bookId, int userId);
|
||||||
|
Task<WholeBookPlotSynthesisRun?> ClaimNextAsync(int leaseMinutes);
|
||||||
|
Task<WholeBookPlotSynthesisRun?> GetCurrentAsync(int bookId);
|
||||||
|
Task CompleteAsync(WholeBookPlotSynthesisCompletionRequest request, WholeBookPlotSynthesisResult result);
|
||||||
|
Task FailAsync(int runId, string errorMessage, string? errorDetail, long? durationMs);
|
||||||
|
Task<WholeBookPlotSynthesisContext> BuildContextAsync(int projectId, int bookId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WholeBookPlotIntelligenceRepository(ISqlConnectionFactory connectionFactory) : IWholeBookPlotIntelligenceRepository
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
public async Task SaveAuthorSeedsAsync(AuthorPlotSeedSaveRequest request)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"dbo.AuthorPlotSeed_SaveForBook",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
request.ProjectID,
|
||||||
|
request.BookID,
|
||||||
|
SeedsJson = JsonSerializer.Serialize(
|
||||||
|
request.Descriptions
|
||||||
|
.Where(description => !string.IsNullOrWhiteSpace(description))
|
||||||
|
.Select(description => new { description = description.Trim() })
|
||||||
|
.ToList(),
|
||||||
|
JsonOptions)
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AuthorPlotSeed>> ListAuthorSeedsAsync(int bookId)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
var rows = await connection.QueryAsync<AuthorPlotSeed>(
|
||||||
|
"dbo.AuthorPlotSeed_ListByBook",
|
||||||
|
new { BookID = bookId },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WholeBookPlotSynthesisRun> QueueAsync(WholeBookPlotSynthesisQueueRequest request)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
return await connection.QuerySingleAsync<WholeBookPlotSynthesisRun>(
|
||||||
|
"dbo.WholeBookPlotSynthesisRun_Queue",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
request.ProjectID,
|
||||||
|
request.BookID,
|
||||||
|
request.UserID,
|
||||||
|
request.SourceStoryIntelligenceRunID,
|
||||||
|
request.CharacterResolutionVersion
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WholeBookPlotSynthesisRun?> RetryAsync(int bookId, int userId)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<WholeBookPlotSynthesisRun>(
|
||||||
|
"dbo.WholeBookPlotSynthesisRun_Retry",
|
||||||
|
new { BookID = bookId, UserID = userId },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WholeBookPlotSynthesisRun?> ClaimNextAsync(int leaseMinutes)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<WholeBookPlotSynthesisRun>(
|
||||||
|
"dbo.WholeBookPlotSynthesisRun_ClaimNext",
|
||||||
|
new { LeaseMinutes = leaseMinutes },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WholeBookPlotSynthesisRun?> GetCurrentAsync(int bookId)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<WholeBookPlotSynthesisRun>(
|
||||||
|
"dbo.WholeBookPlotSynthesisRun_GetCurrentByBook",
|
||||||
|
new { BookID = bookId },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CompleteAsync(WholeBookPlotSynthesisCompletionRequest request, WholeBookPlotSynthesisResult result)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
connection.Open();
|
||||||
|
using var transaction = connection.BeginTransaction();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"dbo.WholeBookPlotSynthesisRun_Complete",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
request.WholeBookPlotSynthesisRunID,
|
||||||
|
request.Model,
|
||||||
|
request.OpenAIResponseID,
|
||||||
|
request.InputTokens,
|
||||||
|
request.OutputTokens,
|
||||||
|
request.CachedInputTokens,
|
||||||
|
request.TotalTokens,
|
||||||
|
request.DurationMs,
|
||||||
|
request.ResultJson
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
|
foreach (var plotLine in result.PlotLines)
|
||||||
|
{
|
||||||
|
var candidateId = await connection.QuerySingleAsync<int>(
|
||||||
|
"dbo.WholeBookPlotSynthesisCandidate_Save",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
request.WholeBookPlotSynthesisRunID,
|
||||||
|
CandidateType = StoryIntelligenceNarrativeCandidateTypes.PlotLine,
|
||||||
|
CandidateKey = CandidateKey(StoryIntelligenceNarrativeCandidateTypes.PlotLine, plotLine.Key, plotLine.ProposedTitle),
|
||||||
|
plotLine.ProposedTitle,
|
||||||
|
ProposedDescription = plotLine.Description,
|
||||||
|
Category = "Plot Line",
|
||||||
|
InferredState = plotLine.BookStatus,
|
||||||
|
ChangeType = plotLine.BookStatus,
|
||||||
|
plotLine.Significance,
|
||||||
|
plotLine.ExistingPlotLineID,
|
||||||
|
ExistingPlotThreadID = (int?)null,
|
||||||
|
RelatedPlotLineID = (int?)null,
|
||||||
|
ParentCandidateKey = (string?)null,
|
||||||
|
ParentNarrativeCandidateID = (int?)null,
|
||||||
|
plotLine.AuthorPlotSeedID,
|
||||||
|
plotLine.ExistingPlotLineMatchConfidence,
|
||||||
|
plotLine.ExistingPlotLineMatchReason,
|
||||||
|
plotLine.SeedMatchConfidence,
|
||||||
|
plotLine.SeedMatchReason,
|
||||||
|
plotLine.Confidence
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
|
var sortOrder = 0;
|
||||||
|
foreach (var thread in plotLine.Threads)
|
||||||
|
{
|
||||||
|
var threadCandidateId = await connection.QuerySingleAsync<int>(
|
||||||
|
"dbo.WholeBookPlotSynthesisCandidate_Save",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
request.WholeBookPlotSynthesisRunID,
|
||||||
|
CandidateType = StoryIntelligenceNarrativeCandidateTypes.Thread,
|
||||||
|
CandidateKey = CandidateKey(StoryIntelligenceNarrativeCandidateTypes.Thread, thread.Key, thread.ProposedTitle),
|
||||||
|
thread.ProposedTitle,
|
||||||
|
ProposedDescription = thread.Description,
|
||||||
|
Category = "Thread",
|
||||||
|
InferredState = thread.Status,
|
||||||
|
ChangeType = thread.Status,
|
||||||
|
Significance = plotLine.Significance,
|
||||||
|
ExistingPlotLineID = plotLine.ExistingPlotLineID,
|
||||||
|
thread.ExistingPlotThreadID,
|
||||||
|
RelatedPlotLineID = plotLine.ExistingPlotLineID,
|
||||||
|
ParentCandidateKey = CandidateKey(StoryIntelligenceNarrativeCandidateTypes.PlotLine, plotLine.Key, plotLine.ProposedTitle),
|
||||||
|
ParentNarrativeCandidateID = candidateId,
|
||||||
|
plotLine.AuthorPlotSeedID,
|
||||||
|
ExistingPlotLineMatchConfidence = thread.ExistingThreadMatchConfidence,
|
||||||
|
ExistingPlotLineMatchReason = thread.MatchReason,
|
||||||
|
SeedMatchConfidence = plotLine.SeedMatchConfidence,
|
||||||
|
SeedMatchReason = plotLine.SeedMatchReason,
|
||||||
|
thread.Confidence
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
|
foreach (var item in thread.Events.Where(item => item.SceneID > 0))
|
||||||
|
{
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"dbo.WholeBookPlotSynthesisCandidateEvent_Save",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
NarrativeCandidateID = threadCandidateId,
|
||||||
|
SynthesisRunID = request.WholeBookPlotSynthesisRunID,
|
||||||
|
EventKey = string.IsNullOrWhiteSpace(item.Key)
|
||||||
|
? $"{thread.Key}:{item.SceneID}:{item.EventType}:{sortOrder}"
|
||||||
|
: item.Key,
|
||||||
|
item.SceneID,
|
||||||
|
item.EventType,
|
||||||
|
EventTitle = item.Title,
|
||||||
|
EventDescription = item.Description,
|
||||||
|
item.Evidence,
|
||||||
|
item.Confidence,
|
||||||
|
SortOrder = sortOrder++
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"dbo.StoryIntelligenceNarrativeCandidateEvidence_Save",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
NarrativeCandidateID = threadCandidateId,
|
||||||
|
item.SceneID,
|
||||||
|
ContributionType = item.EventType,
|
||||||
|
Evidence = item.Evidence,
|
||||||
|
SortOrder = sortOrder
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.Commit();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
transaction.Rollback();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task FailAsync(int runId, string errorMessage, string? errorDetail, long? durationMs)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"dbo.WholeBookPlotSynthesisRun_Fail",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
WholeBookPlotSynthesisRunID = runId,
|
||||||
|
ErrorMessage = errorMessage,
|
||||||
|
ErrorDetail = errorDetail,
|
||||||
|
DurationMs = durationMs
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WholeBookPlotSynthesisContext> BuildContextAsync(int projectId, int bookId)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
using var result = await connection.QueryMultipleAsync(
|
||||||
|
"dbo.WholeBookPlotSynthesis_ContextGet",
|
||||||
|
new { ProjectID = projectId, BookID = bookId },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
|
var book = await result.ReadSingleAsync<ContextBookRow>();
|
||||||
|
var sourceRows = (await result.ReadAsync<ContextSceneRow>()).ToList();
|
||||||
|
var characters = (await result.ReadAsync<WholeBookPlotSynthesisCharacter>()).ToList();
|
||||||
|
var aliases = (await result.ReadAsync<ContextCharacterAliasRow>()).ToList();
|
||||||
|
var plotLines = (await result.ReadAsync<WholeBookPlotSynthesisExistingPlotLine>()).ToList();
|
||||||
|
var threads = (await result.ReadAsync<WholeBookPlotSynthesisExistingThreadRow>()).ToList();
|
||||||
|
var seeds = (await result.ReadAsync<AuthorPlotSeed>()).ToList();
|
||||||
|
var audit = await result.ReadSingleAsync<WholeBookPlotSynthesisSourceAudit>();
|
||||||
|
|
||||||
|
var aliasesByCharacter = aliases
|
||||||
|
.GroupBy(alias => alias.CharacterID)
|
||||||
|
.ToDictionary(group => group.Key, group => (IReadOnlyList<string>)group.Select(alias => alias.Alias).ToList());
|
||||||
|
characters = characters
|
||||||
|
.Select(character => new WholeBookPlotSynthesisCharacter
|
||||||
|
{
|
||||||
|
CharacterID = character.CharacterID,
|
||||||
|
CanonicalName = character.CanonicalName,
|
||||||
|
Aliases = aliasesByCharacter.GetValueOrDefault(character.CharacterID) ?? []
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var threadsByPlotLine = threads
|
||||||
|
.GroupBy(thread => thread.PlotLineID)
|
||||||
|
.ToDictionary(
|
||||||
|
group => group.Key,
|
||||||
|
group => (IReadOnlyList<WholeBookPlotSynthesisExistingThread>)group.Select(thread => new WholeBookPlotSynthesisExistingThread
|
||||||
|
{
|
||||||
|
PlotThreadID = thread.PlotThreadID,
|
||||||
|
Title = thread.Title,
|
||||||
|
Summary = thread.Summary,
|
||||||
|
Status = thread.Status
|
||||||
|
}).ToList());
|
||||||
|
plotLines = plotLines
|
||||||
|
.Select(plotLine => new WholeBookPlotSynthesisExistingPlotLine
|
||||||
|
{
|
||||||
|
PlotLineID = plotLine.PlotLineID,
|
||||||
|
Name = plotLine.Name,
|
||||||
|
Description = plotLine.Description,
|
||||||
|
Importance = plotLine.Importance,
|
||||||
|
BookID = plotLine.BookID,
|
||||||
|
Threads = threadsByPlotLine.GetValueOrDefault(plotLine.PlotLineID) ?? []
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var chapters = sourceRows
|
||||||
|
.GroupBy(row => new { row.ChapterID, row.ChapterNumber, row.ChapterTitle })
|
||||||
|
.Select(group => new WholeBookPlotSynthesisChapter
|
||||||
|
{
|
||||||
|
ChapterID = group.Key.ChapterID,
|
||||||
|
ChapterNumber = group.Key.ChapterNumber,
|
||||||
|
ChapterTitle = group.Key.ChapterTitle,
|
||||||
|
Scenes = group.Select(row => new WholeBookPlotSynthesisScene
|
||||||
|
{
|
||||||
|
SceneID = row.SceneID,
|
||||||
|
SceneNumber = row.SceneNumber,
|
||||||
|
SceneTitle = row.SceneTitle,
|
||||||
|
StructuralSummary = row.StructuralSummary,
|
||||||
|
StructuralSetting = row.StructuralSetting,
|
||||||
|
PovCharacterName = row.PovCharacterName,
|
||||||
|
SourceText = row.SourceText
|
||||||
|
}).ToList()
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new WholeBookPlotSynthesisContext
|
||||||
|
{
|
||||||
|
ProjectID = projectId,
|
||||||
|
BookID = bookId,
|
||||||
|
BookTitle = book.BookTitle,
|
||||||
|
Chapters = chapters,
|
||||||
|
Characters = characters,
|
||||||
|
ExistingPlotLines = plotLines,
|
||||||
|
AuthorSeeds = seeds,
|
||||||
|
SourceAudit = audit
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CandidateKey(string type, string key, string title)
|
||||||
|
=> $"{type}:{(string.IsNullOrWhiteSpace(key) ? title : key).Trim().ToLowerInvariant()}";
|
||||||
|
|
||||||
|
private sealed class ContextBookRow
|
||||||
|
{
|
||||||
|
public string BookTitle { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ContextSceneRow
|
||||||
|
{
|
||||||
|
public int ChapterID { get; init; }
|
||||||
|
public decimal ChapterNumber { get; init; }
|
||||||
|
public string ChapterTitle { get; init; } = string.Empty;
|
||||||
|
public int SceneID { get; init; }
|
||||||
|
public decimal SceneNumber { get; init; }
|
||||||
|
public string SceneTitle { get; init; } = string.Empty;
|
||||||
|
public string? StructuralSummary { get; init; }
|
||||||
|
public string? StructuralSetting { get; init; }
|
||||||
|
public string? PovCharacterName { get; init; }
|
||||||
|
public string SourceText { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ContextCharacterAliasRow
|
||||||
|
{
|
||||||
|
public int CharacterID { get; init; }
|
||||||
|
public string Alias { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class WholeBookPlotSynthesisExistingThreadRow
|
||||||
|
{
|
||||||
|
public int PlotThreadID { get; init; }
|
||||||
|
public int PlotLineID { get; init; }
|
||||||
|
public string Title { get; init; } = string.Empty;
|
||||||
|
public string? Summary { get; init; }
|
||||||
|
public string? Status { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -768,6 +768,10 @@ public sealed class ThreadEvent
|
|||||||
public IReadOnlyList<int> TargetPlotLineIDs { get; set; } = [];
|
public IReadOnlyList<int> TargetPlotLineIDs { get; set; } = [];
|
||||||
public string EventTitle { get; set; } = string.Empty;
|
public string EventTitle { get; set; } = string.Empty;
|
||||||
public string? EventDescription { get; set; }
|
public string? EventDescription { get; set; }
|
||||||
|
public string Source { get; set; } = "Author";
|
||||||
|
public int? SourceReferenceID { get; set; }
|
||||||
|
public string? SourceKey { get; set; }
|
||||||
|
public decimal? Confidence { get; set; }
|
||||||
public DateTime CreatedDate { get; set; }
|
public DateTime CreatedDate { get; set; }
|
||||||
public DateTime UpdatedDate { get; set; }
|
public DateTime UpdatedDate { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -101,6 +101,7 @@ public sealed class StoryIntelligenceOptions
|
|||||||
public string Model { get; init; } = string.Empty;
|
public string Model { get; init; } = string.Empty;
|
||||||
public string ChapterStructureModel { get; init; } = string.Empty;
|
public string ChapterStructureModel { get; init; } = string.Empty;
|
||||||
public string SceneIntelligenceModel { get; init; } = string.Empty;
|
public string SceneIntelligenceModel { get; init; } = string.Empty;
|
||||||
|
public string WholeBookPlotSynthesisModel { get; init; } = string.Empty;
|
||||||
public string ImageGenerationModel { get; init; } = "gpt-image-1.5";
|
public string ImageGenerationModel { get; init; } = "gpt-image-1.5";
|
||||||
public string ImageGenerationSize { get; init; } = "1024x1024";
|
public string ImageGenerationSize { get; init; } = "1024x1024";
|
||||||
public string ImageGenerationQuality { get; init; } = "medium";
|
public string ImageGenerationQuality { get; init; } = "medium";
|
||||||
@ -123,6 +124,7 @@ public sealed class StoryIntelligenceOptions
|
|||||||
public string EffectiveModel => FirstConfigured(Model);
|
public string EffectiveModel => FirstConfigured(Model);
|
||||||
public string EffectiveChapterStructureModel => FirstConfigured(ChapterStructureModel, Model);
|
public string EffectiveChapterStructureModel => FirstConfigured(ChapterStructureModel, Model);
|
||||||
public string EffectiveSceneIntelligenceModel => FirstConfigured(SceneIntelligenceModel, Model);
|
public string EffectiveSceneIntelligenceModel => FirstConfigured(SceneIntelligenceModel, Model);
|
||||||
|
public string EffectiveWholeBookPlotSynthesisModel => FirstConfigured(WholeBookPlotSynthesisModel, Model);
|
||||||
|
|
||||||
public static string BuildModelSummary(string? chapterStructureModel, string? sceneIntelligenceModel)
|
public static string BuildModelSummary(string? chapterStructureModel, string? sceneIntelligenceModel)
|
||||||
=> $"Chapter={FirstConfigured(chapterStructureModel)}; Scene={FirstConfigured(sceneIntelligenceModel)}";
|
=> $"Chapter={FirstConfigured(chapterStructureModel)}; Scene={FirstConfigured(sceneIntelligenceModel)}";
|
||||||
|
|||||||
@ -39,6 +39,13 @@ public sealed class StoryIntelligenceNarrativeCandidate
|
|||||||
public string? ExistingPlotLineName { get; set; }
|
public string? ExistingPlotLineName { get; set; }
|
||||||
public int? ExistingPlotThreadID { get; set; }
|
public int? ExistingPlotThreadID { get; set; }
|
||||||
public string? ExistingPlotThreadTitle { get; set; }
|
public string? ExistingPlotThreadTitle { get; set; }
|
||||||
|
public int? WholeBookPlotSynthesisRunID { get; set; }
|
||||||
|
public int? AuthorPlotSeedID { get; set; }
|
||||||
|
public decimal? ExistingPlotLineMatchConfidence { get; set; }
|
||||||
|
public string? ExistingPlotLineMatchReason { get; set; }
|
||||||
|
public decimal? SeedMatchConfidence { get; set; }
|
||||||
|
public string? SeedMatchReason { get; set; }
|
||||||
|
public string? Source { get; set; }
|
||||||
public int? RelatedPlotLineID { get; set; }
|
public int? RelatedPlotLineID { get; set; }
|
||||||
public string? RelatedPlotLineName { get; set; }
|
public string? RelatedPlotLineName { get; set; }
|
||||||
public string? ParentCandidateKey { get; set; }
|
public string? ParentCandidateKey { get; set; }
|
||||||
@ -57,6 +64,7 @@ public sealed class StoryIntelligenceNarrativeCandidate
|
|||||||
public DateTime CreatedUtc { get; set; }
|
public DateTime CreatedUtc { get; set; }
|
||||||
public DateTime UpdatedUtc { get; set; }
|
public DateTime UpdatedUtc { get; set; }
|
||||||
public IReadOnlyList<StoryIntelligenceNarrativeCandidateEvidence> Evidence { get; set; } = [];
|
public IReadOnlyList<StoryIntelligenceNarrativeCandidateEvidence> Evidence { get; set; } = [];
|
||||||
|
public IReadOnlyList<StoryIntelligenceNarrativeCandidateEvent> Events { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceNarrativeCandidateEvidence
|
public sealed class StoryIntelligenceNarrativeCandidateEvidence
|
||||||
@ -88,8 +96,38 @@ public sealed class StoryIntelligenceNarrativeCandidateSeed
|
|||||||
public string? Significance { get; init; }
|
public string? Significance { get; init; }
|
||||||
public int? ExistingPlotLineID { get; init; }
|
public int? ExistingPlotLineID { get; init; }
|
||||||
public int? ExistingPlotThreadID { get; init; }
|
public int? ExistingPlotThreadID { get; init; }
|
||||||
|
public int? WholeBookPlotSynthesisRunID { get; init; }
|
||||||
|
public int? AuthorPlotSeedID { get; init; }
|
||||||
|
public decimal? ExistingPlotLineMatchConfidence { get; init; }
|
||||||
|
public string? ExistingPlotLineMatchReason { get; init; }
|
||||||
|
public decimal? SeedMatchConfidence { get; init; }
|
||||||
|
public string? SeedMatchReason { get; init; }
|
||||||
|
public string? Source { get; init; }
|
||||||
public int? RelatedPlotLineID { get; init; }
|
public int? RelatedPlotLineID { get; init; }
|
||||||
public string? ParentCandidateKey { get; init; }
|
public string? ParentCandidateKey { get; init; }
|
||||||
public int? ParentNarrativeCandidateID { get; init; }
|
public int? ParentNarrativeCandidateID { get; init; }
|
||||||
public decimal? Confidence { get; init; }
|
public decimal? Confidence { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceNarrativeCandidateEvent
|
||||||
|
{
|
||||||
|
public int NarrativeCandidateEventID { get; set; }
|
||||||
|
public int NarrativeCandidateID { get; set; }
|
||||||
|
public int? WholeBookPlotSynthesisRunID { get; set; }
|
||||||
|
public string EventKey { get; set; } = string.Empty;
|
||||||
|
public int SceneID { get; set; }
|
||||||
|
public decimal SceneNumber { get; set; }
|
||||||
|
public string SceneTitle { get; set; } = string.Empty;
|
||||||
|
public decimal ChapterNumber { get; set; }
|
||||||
|
public string ChapterTitle { get; set; } = string.Empty;
|
||||||
|
public string EventType { get; set; } = string.Empty;
|
||||||
|
public string EventTitle { get; set; } = string.Empty;
|
||||||
|
public string? EventDescription { get; set; }
|
||||||
|
public string? Evidence { get; set; }
|
||||||
|
public decimal? Confidence { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsIgnored { get; set; }
|
||||||
|
public int? AcceptedThreadEventID { get; set; }
|
||||||
|
public DateTime CreatedUtc { get; set; }
|
||||||
|
public DateTime UpdatedUtc { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
211
PlotLine/Models/WholeBookPlotIntelligenceModels.cs
Normal file
211
PlotLine/Models/WholeBookPlotIntelligenceModels.cs
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace PlotLine.Models;
|
||||||
|
|
||||||
|
public static class WholeBookPlotSynthesisStatuses
|
||||||
|
{
|
||||||
|
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 static class WholeBookPlotSynthesisSources
|
||||||
|
{
|
||||||
|
public const string Author = "Author";
|
||||||
|
public const string WholeBook = "WholeBookPlotSynthesis";
|
||||||
|
public const string SceneSignals = "SceneSignals";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AuthorPlotSeed
|
||||||
|
{
|
||||||
|
public int AuthorPlotSeedID { get; set; }
|
||||||
|
public int ProjectID { get; set; }
|
||||||
|
public int BookID { get; set; }
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string Status { get; set; } = "Active";
|
||||||
|
public int? MatchedPlotLineID { get; set; }
|
||||||
|
public string Source { get; set; } = WholeBookPlotSynthesisSources.Author;
|
||||||
|
public DateTime CreatedUtc { get; set; }
|
||||||
|
public DateTime UpdatedUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AuthorPlotSeedSaveRequest
|
||||||
|
{
|
||||||
|
public int ProjectID { get; init; }
|
||||||
|
public int BookID { get; init; }
|
||||||
|
public IReadOnlyList<string> Descriptions { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WholeBookPlotSynthesisRun
|
||||||
|
{
|
||||||
|
public int WholeBookPlotSynthesisRunID { get; set; }
|
||||||
|
public int ProjectID { get; set; }
|
||||||
|
public int BookID { get; set; }
|
||||||
|
public int UserID { get; set; }
|
||||||
|
public int? SourceStoryIntelligenceRunID { get; set; }
|
||||||
|
public string CharacterResolutionVersion { get; set; } = string.Empty;
|
||||||
|
public string Status { get; set; } = WholeBookPlotSynthesisStatuses.Queued;
|
||||||
|
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 string? OpenAIResponseID { get; set; }
|
||||||
|
public int? InputTokens { get; set; }
|
||||||
|
public int? OutputTokens { get; set; }
|
||||||
|
public int? CachedInputTokens { get; set; }
|
||||||
|
public int? TotalTokens { get; set; }
|
||||||
|
public long? DurationMs { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public string? ErrorDetail { get; set; }
|
||||||
|
public string? ResultJson { get; set; }
|
||||||
|
public DateTime UpdatedUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WholeBookPlotSynthesisQueueRequest
|
||||||
|
{
|
||||||
|
public int ProjectID { get; init; }
|
||||||
|
public int BookID { get; init; }
|
||||||
|
public int UserID { get; init; }
|
||||||
|
public int? SourceStoryIntelligenceRunID { get; init; }
|
||||||
|
public string CharacterResolutionVersion { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WholeBookPlotSynthesisCompletionRequest
|
||||||
|
{
|
||||||
|
public int WholeBookPlotSynthesisRunID { get; init; }
|
||||||
|
public string Model { get; init; } = string.Empty;
|
||||||
|
public string? OpenAIResponseID { get; init; }
|
||||||
|
public int? InputTokens { get; init; }
|
||||||
|
public int? OutputTokens { get; init; }
|
||||||
|
public int? CachedInputTokens { get; init; }
|
||||||
|
public int? TotalTokens { get; init; }
|
||||||
|
public long? DurationMs { get; init; }
|
||||||
|
public string ResultJson { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WholeBookPlotSynthesisContext
|
||||||
|
{
|
||||||
|
public int ProjectID { get; init; }
|
||||||
|
public int BookID { get; init; }
|
||||||
|
public string BookTitle { get; init; } = string.Empty;
|
||||||
|
public IReadOnlyList<WholeBookPlotSynthesisChapter> Chapters { get; init; } = [];
|
||||||
|
public IReadOnlyList<WholeBookPlotSynthesisCharacter> Characters { get; init; } = [];
|
||||||
|
public IReadOnlyList<WholeBookPlotSynthesisExistingPlotLine> ExistingPlotLines { get; init; } = [];
|
||||||
|
public IReadOnlyList<AuthorPlotSeed> AuthorSeeds { get; init; } = [];
|
||||||
|
public WholeBookPlotSynthesisSourceAudit SourceAudit { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WholeBookPlotSynthesisSourceAudit
|
||||||
|
{
|
||||||
|
public int SceneCount { get; init; }
|
||||||
|
public int SourceRowCount { get; init; }
|
||||||
|
public int MissingSourceCount { get; init; }
|
||||||
|
public int DuplicateSourceSceneCount { get; init; }
|
||||||
|
public bool CanReconstruct => SceneCount > 0 && SceneCount == SourceRowCount && MissingSourceCount == 0 && DuplicateSourceSceneCount == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WholeBookPlotSynthesisChapter
|
||||||
|
{
|
||||||
|
public int ChapterID { get; init; }
|
||||||
|
public decimal ChapterNumber { get; init; }
|
||||||
|
public string ChapterTitle { get; init; } = string.Empty;
|
||||||
|
public IReadOnlyList<WholeBookPlotSynthesisScene> Scenes { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WholeBookPlotSynthesisScene
|
||||||
|
{
|
||||||
|
public int SceneID { get; init; }
|
||||||
|
public decimal SceneNumber { get; init; }
|
||||||
|
public string SceneTitle { get; init; } = string.Empty;
|
||||||
|
public string? StructuralSummary { get; init; }
|
||||||
|
public string? StructuralSetting { get; init; }
|
||||||
|
public string? PovCharacterName { get; init; }
|
||||||
|
public string SourceText { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WholeBookPlotSynthesisCharacter
|
||||||
|
{
|
||||||
|
public int CharacterID { get; init; }
|
||||||
|
public string CanonicalName { get; init; } = string.Empty;
|
||||||
|
public IReadOnlyList<string> Aliases { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WholeBookPlotSynthesisExistingPlotLine
|
||||||
|
{
|
||||||
|
public int PlotLineID { get; init; }
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
public string? Description { get; init; }
|
||||||
|
public string? Importance { get; init; }
|
||||||
|
public int? BookID { get; init; }
|
||||||
|
public IReadOnlyList<WholeBookPlotSynthesisExistingThread> Threads { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WholeBookPlotSynthesisExistingThread
|
||||||
|
{
|
||||||
|
public int PlotThreadID { get; init; }
|
||||||
|
public string Title { get; init; } = string.Empty;
|
||||||
|
public string? Summary { get; init; }
|
||||||
|
public string? Status { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WholeBookPlotSynthesisResult
|
||||||
|
{
|
||||||
|
public WholeBookPlotSynthesisMetadata Metadata { get; init; } = new();
|
||||||
|
public IReadOnlyList<WholeBookPlotSynthesisPlotLineResult> PlotLines { get; init; } = [];
|
||||||
|
|
||||||
|
public static WholeBookPlotSynthesisResult FromJson(string json)
|
||||||
|
=> JsonSerializer.Deserialize<WholeBookPlotSynthesisResult>(
|
||||||
|
json,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WholeBookPlotSynthesisMetadata
|
||||||
|
{
|
||||||
|
public string BookStatus { get; init; } = string.Empty;
|
||||||
|
public string Summary { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WholeBookPlotSynthesisPlotLineResult
|
||||||
|
{
|
||||||
|
public string Key { get; init; } = string.Empty;
|
||||||
|
public string ProposedTitle { get; init; } = string.Empty;
|
||||||
|
public string Description { get; init; } = string.Empty;
|
||||||
|
public string Significance { get; init; } = string.Empty;
|
||||||
|
public string BookStatus { get; init; } = string.Empty;
|
||||||
|
public int? ExistingPlotLineID { get; init; }
|
||||||
|
public decimal? ExistingPlotLineMatchConfidence { get; init; }
|
||||||
|
public string? ExistingPlotLineMatchReason { get; init; }
|
||||||
|
public int? AuthorPlotSeedID { get; init; }
|
||||||
|
public decimal? SeedMatchConfidence { get; init; }
|
||||||
|
public string? SeedMatchReason { get; init; }
|
||||||
|
public decimal? Confidence { get; init; }
|
||||||
|
public IReadOnlyList<WholeBookPlotSynthesisThreadResult> Threads { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WholeBookPlotSynthesisThreadResult
|
||||||
|
{
|
||||||
|
public string Key { get; init; } = string.Empty;
|
||||||
|
public string ProposedTitle { get; init; } = string.Empty;
|
||||||
|
public string Description { get; init; } = string.Empty;
|
||||||
|
public string Status { get; init; } = string.Empty;
|
||||||
|
public int? ExistingPlotThreadID { get; init; }
|
||||||
|
public decimal? ExistingThreadMatchConfidence { get; init; }
|
||||||
|
public string? MatchReason { get; init; }
|
||||||
|
public decimal? Confidence { get; init; }
|
||||||
|
public IReadOnlyList<WholeBookPlotSynthesisEventResult> Events { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WholeBookPlotSynthesisEventResult
|
||||||
|
{
|
||||||
|
public string Key { get; init; } = string.Empty;
|
||||||
|
public int SceneID { get; init; }
|
||||||
|
public string EventType { get; init; } = string.Empty;
|
||||||
|
public string Title { get; init; } = string.Empty;
|
||||||
|
public string Description { get; init; } = string.Empty;
|
||||||
|
public string Evidence { get; init; } = string.Empty;
|
||||||
|
public decimal? Confidence { get; init; }
|
||||||
|
}
|
||||||
@ -150,6 +150,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
|
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
|
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
|
||||||
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
|
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
|
||||||
|
builder.Services.AddScoped<IWholeBookPlotIntelligenceRepository, WholeBookPlotIntelligenceRepository>();
|
||||||
builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>();
|
builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>();
|
||||||
builder.Services.AddScoped<ICharacterIntelligenceRepository, CharacterIntelligenceRepository>();
|
builder.Services.AddScoped<ICharacterIntelligenceRepository, CharacterIntelligenceRepository>();
|
||||||
builder.Services.AddScoped<INameIntelligenceRepository, NameIntelligenceRepository>();
|
builder.Services.AddScoped<INameIntelligenceRepository, NameIntelligenceRepository>();
|
||||||
@ -230,6 +231,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
|
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
|
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceNarrativeReviewService, StoryIntelligenceNarrativeReviewService>();
|
builder.Services.AddScoped<IStoryIntelligenceNarrativeReviewService, StoryIntelligenceNarrativeReviewService>();
|
||||||
|
builder.Services.AddScoped<IWholeBookPlotIntelligenceService, WholeBookPlotIntelligenceService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceSceneContextService, StoryIntelligenceSceneContextService>();
|
builder.Services.AddScoped<IStoryIntelligenceSceneContextService, StoryIntelligenceSceneContextService>();
|
||||||
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
|
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
|
||||||
builder.Services.AddScoped<ICharacterIntelligenceService, CharacterIntelligenceService>();
|
builder.Services.AddScoped<ICharacterIntelligenceService, CharacterIntelligenceService>();
|
||||||
|
|||||||
@ -43,6 +43,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
IStoryIntelligenceRelationshipImportService relationshipImport,
|
IStoryIntelligenceRelationshipImportService relationshipImport,
|
||||||
IStoryIntelligenceKnowledgeImportService knowledgeImport,
|
IStoryIntelligenceKnowledgeImportService knowledgeImport,
|
||||||
IStoryIntelligenceNarrativeReviewService narrativeReview,
|
IStoryIntelligenceNarrativeReviewService narrativeReview,
|
||||||
|
IWholeBookPlotIntelligenceService wholeBookPlotIntelligence,
|
||||||
IStoryIntelligenceSourceRepository sources,
|
IStoryIntelligenceSourceRepository sources,
|
||||||
IStoryIntelligencePipelineStateService pipelineState,
|
IStoryIntelligencePipelineStateService pipelineState,
|
||||||
IStoryMemoryRepository storyMemory,
|
IStoryMemoryRepository storyMemory,
|
||||||
@ -644,7 +645,12 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
var relationshipReview = await relationshipImport.BuildReviewAsync(batch);
|
var relationshipReview = await relationshipImport.BuildReviewAsync(batch);
|
||||||
var knowledgeReview = await knowledgeImport.BuildReviewAsync(batch);
|
var knowledgeReview = await knowledgeImport.BuildReviewAsync(batch);
|
||||||
var narrative = await narrativeReview.GetReviewAsync(batch);
|
var narrative = await narrativeReview.GetReviewAsync(batch);
|
||||||
|
var plotSynthesisRun = await wholeBookPlotIntelligence.GetCurrentAsync(batch.BookID);
|
||||||
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
||||||
|
var narrativePendingCount = narrative.Candidates.Count;
|
||||||
|
var narrativeThreadCount = narrative.Candidates.Count(candidate =>
|
||||||
|
string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.Thread, StringComparison.OrdinalIgnoreCase));
|
||||||
|
var narrativeEventCount = narrative.Candidates.Sum(candidate => candidate.Events.Count);
|
||||||
return new StoryIntelligenceProgressViewModel
|
return new StoryIntelligenceProgressViewModel
|
||||||
{
|
{
|
||||||
BatchID = batch.BatchID,
|
BatchID = batch.BatchID,
|
||||||
@ -661,6 +667,23 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
RelationshipReview = relationshipReview,
|
RelationshipReview = relationshipReview,
|
||||||
KnowledgeReview = knowledgeReview,
|
KnowledgeReview = knowledgeReview,
|
||||||
NarrativeReview = narrative,
|
NarrativeReview = narrative,
|
||||||
|
PlotSynthesis = plotSynthesisRun is null
|
||||||
|
? new WholeBookPlotSynthesisStatusViewModel
|
||||||
|
{
|
||||||
|
Status = string.Empty,
|
||||||
|
CandidateCount = narrativePendingCount,
|
||||||
|
ThreadCount = narrativeThreadCount,
|
||||||
|
EventCount = narrativeEventCount
|
||||||
|
}
|
||||||
|
: new WholeBookPlotSynthesisStatusViewModel
|
||||||
|
{
|
||||||
|
WholeBookPlotSynthesisRunID = plotSynthesisRun.WholeBookPlotSynthesisRunID,
|
||||||
|
Status = plotSynthesisRun.Status,
|
||||||
|
CandidateCount = narrativePendingCount,
|
||||||
|
ThreadCount = narrativeThreadCount,
|
||||||
|
EventCount = narrativeEventCount,
|
||||||
|
ErrorMessage = plotSynthesisRun.ErrorMessage
|
||||||
|
},
|
||||||
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
|
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
|
||||||
{
|
{
|
||||||
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
|
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
|
||||||
|
|||||||
@ -51,11 +51,23 @@ public sealed class PersistedStoryIntelligenceWorker(
|
|||||||
if (!processedRun)
|
if (!processedRun)
|
||||||
{
|
{
|
||||||
await RecoverReadySceneAnalysisAsync(stoppingToken);
|
await RecoverReadySceneAnalysisAsync(stoppingToken);
|
||||||
|
processedRun = await ProcessWholeBookPlotSynthesisAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!processedRun)
|
||||||
|
{
|
||||||
await Task.Delay(pollDelay, stoppingToken);
|
await Task.Delay(pollDelay, stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<bool> ProcessWholeBookPlotSynthesisAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
using var scope = scopeFactory.CreateScope();
|
||||||
|
var synthesis = scope.ServiceProvider.GetRequiredService<IWholeBookPlotIntelligenceService>();
|
||||||
|
return await synthesis.ProcessNextAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task RecoverReadySceneAnalysisAsync(CancellationToken stoppingToken)
|
private async Task RecoverReadySceneAnalysisAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = scopeFactory.CreateScope();
|
||||||
|
|||||||
@ -90,6 +90,7 @@ public sealed class ProjectAccessFilter(IProjectAccessService access, ICurrentUs
|
|||||||
["Dynamics"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project", ["bookId"] = "Book", ["sceneId"] = "Scene" },
|
["Dynamics"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project", ["bookId"] = "Book", ["sceneId"] = "Scene" },
|
||||||
["Exports"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project", ["sceneId"] = "Scene" },
|
["Exports"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project", ["sceneId"] = "Scene" },
|
||||||
["Scenes"] = new(StringComparer.OrdinalIgnoreCase) { ["chapterId"] = "Chapter", ["sceneId"] = "Scene", ["projectId"] = "Project", ["bookId"] = "Book" },
|
["Scenes"] = new(StringComparer.OrdinalIgnoreCase) { ["chapterId"] = "Chapter", ["sceneId"] = "Scene", ["projectId"] = "Project", ["bookId"] = "Book" },
|
||||||
|
["Onboarding"] = new(StringComparer.OrdinalIgnoreCase) { ["bookId"] = "Book" },
|
||||||
["Acceptance"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project" },
|
["Acceptance"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project" },
|
||||||
["StoryBible"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project" }
|
["StoryBible"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project" }
|
||||||
};
|
};
|
||||||
|
|||||||
@ -19,6 +19,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
IStoryIntelligencePipelineStateService pipelineState,
|
IStoryIntelligencePipelineStateService pipelineState,
|
||||||
INameIntelligenceService nameIntelligence,
|
INameIntelligenceService nameIntelligence,
|
||||||
ICoreImportCharacterMappingService coreImportCharacterMapping,
|
ICoreImportCharacterMappingService coreImportCharacterMapping,
|
||||||
|
IWholeBookPlotIntelligenceService wholeBookPlotIntelligence,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser,
|
||||||
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
|
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
|
||||||
{
|
{
|
||||||
@ -372,6 +373,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
};
|
};
|
||||||
var remap = await coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID);
|
var remap = await coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID);
|
||||||
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
|
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
|
||||||
|
await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID);
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks} RemappedScenes={RemappedScenes} RemappedLinks={RemappedLinks}",
|
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks} RemappedScenes={RemappedScenes} RemappedLinks={RemappedLinks}",
|
||||||
|
|||||||
@ -33,8 +33,9 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
|||||||
|
|
||||||
public async Task<StoryIntelligenceNarrativeReviewViewModel> GetReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
public async Task<StoryIntelligenceNarrativeReviewViewModel> GetReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
||||||
{
|
{
|
||||||
await EnsureCandidatesAsync(batch);
|
var rows = (await candidates.ListByBookAsync(batch.BookID))
|
||||||
var rows = await candidates.ListByBookAsync(batch.BookID);
|
.Where(IsWholeBookCandidate)
|
||||||
|
.ToList();
|
||||||
var pending = rows
|
var pending = rows
|
||||||
.Where(candidate => string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase))
|
.Where(candidate => string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase))
|
||||||
.ToList();
|
.ToList();
|
||||||
@ -72,8 +73,9 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
|||||||
|
|
||||||
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId)
|
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId)
|
||||||
{
|
{
|
||||||
await EnsureCandidatesAsync(batch);
|
var rows = (await candidates.ListByBookAsync(batch.BookID))
|
||||||
var rows = (await candidates.ListByBookAsync(batch.BookID)).ToDictionary(candidate => candidate.NarrativeCandidateID);
|
.Where(IsWholeBookCandidate)
|
||||||
|
.ToDictionary(candidate => candidate.NarrativeCandidateID);
|
||||||
var lookups = await plots.GetLookupsAsync();
|
var lookups = await plots.GetLookupsAsync();
|
||||||
var plotLineTypeId = lookups.PlotLineTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Main Plot", StringComparison.OrdinalIgnoreCase))?.PlotLineTypeID
|
var plotLineTypeId = lookups.PlotLineTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Main Plot", StringComparison.OrdinalIgnoreCase))?.PlotLineTypeID
|
||||||
?? lookups.PlotLineTypes.FirstOrDefault()?.PlotLineTypeID
|
?? lookups.PlotLineTypes.FirstOrDefault()?.PlotLineTypeID
|
||||||
@ -87,9 +89,12 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
|||||||
var threadStatusId = lookups.ThreadStatuses.FirstOrDefault(status => string.Equals(status.StatusName, "Open", StringComparison.OrdinalIgnoreCase))?.ThreadStatusID
|
var threadStatusId = lookups.ThreadStatuses.FirstOrDefault(status => string.Equals(status.StatusName, "Open", StringComparison.OrdinalIgnoreCase))?.ThreadStatusID
|
||||||
?? lookups.ThreadStatuses.FirstOrDefault()?.ThreadStatusID
|
?? lookups.ThreadStatuses.FirstOrDefault()?.ThreadStatusID
|
||||||
?? 0;
|
?? 0;
|
||||||
var threadEventTypeId = lookups.ThreadEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Raised", StringComparison.OrdinalIgnoreCase))?.ThreadEventTypeID
|
var threadEventTypeByName = lookups.ThreadEventTypes
|
||||||
?? lookups.ThreadEventTypes.FirstOrDefault()?.ThreadEventTypeID
|
.GroupBy(type => type.TypeName, StringComparer.OrdinalIgnoreCase)
|
||||||
?? 0;
|
.ToDictionary(group => group.Key, group => group.First().ThreadEventTypeID, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var threadEventTypeId = threadEventTypeByName.TryGetValue("Mentioned", out var mentionedTypeId)
|
||||||
|
? mentionedTypeId
|
||||||
|
: lookups.ThreadEventTypes.FirstOrDefault()?.ThreadEventTypeID ?? 0;
|
||||||
var plotEventTypeId = lookups.PlotEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Progress", StringComparison.OrdinalIgnoreCase))?.PlotEventTypeID
|
var plotEventTypeId = lookups.PlotEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Progress", StringComparison.OrdinalIgnoreCase))?.PlotEventTypeID
|
||||||
?? lookups.PlotEventTypes.FirstOrDefault()?.PlotEventTypeID
|
?? lookups.PlotEventTypes.FirstOrDefault()?.PlotEventTypeID
|
||||||
?? 0;
|
?? 0;
|
||||||
@ -221,7 +226,9 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var firstSceneId = candidate.Evidence.OrderBy(evidence => evidence.SortOrder).FirstOrDefault()?.SceneID;
|
var effectiveEvents = EffectiveEvents(candidate, rows, choices).ToList();
|
||||||
|
var firstSceneId = effectiveEvents.OrderBy(item => item.SortOrder).FirstOrDefault()?.SceneID
|
||||||
|
?? candidate.Evidence.OrderBy(evidence => evidence.SortOrder).FirstOrDefault()?.SceneID;
|
||||||
var threadId = await SavePlotThreadAsync(connection, transaction, new PlotThread
|
var threadId = await SavePlotThreadAsync(connection, transaction, new PlotThread
|
||||||
{
|
{
|
||||||
PlotLineID = relatedPlotLineId.Value,
|
PlotLineID = relatedPlotLineId.Value,
|
||||||
@ -233,18 +240,48 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
|||||||
IntroducedSceneID = firstSceneId
|
IntroducedSceneID = firstSceneId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (effectiveEvents.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var threadEvent in effectiveEvents.Where(item => item.SceneID > 0))
|
||||||
|
{
|
||||||
|
await SaveThreadEventAsync(connection, transaction, new ThreadEvent
|
||||||
|
{
|
||||||
|
PlotThreadID = threadId,
|
||||||
|
SceneID = threadEvent.SceneID,
|
||||||
|
EventTypeID = threadEventTypeByName.GetValueOrDefault(
|
||||||
|
string.IsNullOrWhiteSpace(threadEvent.EventType) ? "Mentioned" : threadEvent.EventType,
|
||||||
|
threadEventTypeId),
|
||||||
|
PlotEventTypeID = plotEventTypeId,
|
||||||
|
EventTitle = CleanTitle(threadEvent.EventTitle, candidate.ProposedTitle),
|
||||||
|
EventDescription = CleanNullable(threadEvent.EventDescription) ?? threadEvent.Evidence,
|
||||||
|
Source = WholeBookPlotSynthesisSources.WholeBook,
|
||||||
|
SourceReferenceID = threadEvent.NarrativeCandidateEventID,
|
||||||
|
SourceKey = $"{threadEvent.WholeBookPlotSynthesisRunID}:{threadEvent.NarrativeCandidateID}:{threadEvent.EventKey}",
|
||||||
|
Confidence = threadEvent.Confidence
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
foreach (var evidence in EffectiveEvidence(candidate, rows, choices).Where(evidence => evidence.SceneID > 0))
|
foreach (var evidence in EffectiveEvidence(candidate, rows, choices).Where(evidence => evidence.SceneID > 0))
|
||||||
{
|
{
|
||||||
await SaveThreadEventAsync(connection, transaction, new ThreadEvent
|
await SaveThreadEventAsync(connection, transaction, new ThreadEvent
|
||||||
{
|
{
|
||||||
PlotThreadID = threadId,
|
PlotThreadID = threadId,
|
||||||
SceneID = evidence.SceneID,
|
SceneID = evidence.SceneID,
|
||||||
EventTypeID = threadEventTypeId,
|
EventTypeID = threadEventTypeByName.GetValueOrDefault(
|
||||||
|
string.IsNullOrWhiteSpace(evidence.ContributionType) ? "Mentioned" : evidence.ContributionType,
|
||||||
|
threadEventTypeId),
|
||||||
PlotEventTypeID = plotEventTypeId,
|
PlotEventTypeID = plotEventTypeId,
|
||||||
EventTitle = string.IsNullOrWhiteSpace(evidence.ContributionType) ? candidate.ProposedTitle : evidence.ContributionType,
|
EventTitle = string.IsNullOrWhiteSpace(evidence.ContributionType) ? candidate.ProposedTitle : evidence.ContributionType,
|
||||||
EventDescription = evidence.Evidence
|
EventDescription = evidence.Evidence,
|
||||||
|
Source = WholeBookPlotSynthesisSources.WholeBook,
|
||||||
|
SourceReferenceID = candidate.NarrativeCandidateID,
|
||||||
|
SourceKey = $"{candidate.WholeBookPlotSynthesisRunID}:{candidate.NarrativeCandidateID}:{evidence.SceneID}:{evidence.SortOrder}",
|
||||||
|
Confidence = candidate.Confidence
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await MarkReviewedAsync(connection, transaction, candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, null, threadId);
|
await MarkReviewedAsync(connection, transaction, candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, null, threadId);
|
||||||
threadsCreated++;
|
threadsCreated++;
|
||||||
@ -503,7 +540,11 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
|||||||
threadEvent.PlotEventTypeID,
|
threadEvent.PlotEventTypeID,
|
||||||
threadEvent.TargetPlotLineID,
|
threadEvent.TargetPlotLineID,
|
||||||
threadEvent.EventTitle,
|
threadEvent.EventTitle,
|
||||||
threadEvent.EventDescription
|
threadEvent.EventDescription,
|
||||||
|
threadEvent.Source,
|
||||||
|
threadEvent.SourceReferenceID,
|
||||||
|
threadEvent.SourceKey,
|
||||||
|
threadEvent.Confidence
|
||||||
},
|
},
|
||||||
transaction,
|
transaction,
|
||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
@ -573,9 +614,24 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
|||||||
SceneLabel = $"Chapter {evidence.ChapterNumber:0.##}, Scene {evidence.SceneNumber:0.##}: {evidence.SceneTitle}",
|
SceneLabel = $"Chapter {evidence.ChapterNumber:0.##}, Scene {evidence.SceneNumber:0.##}: {evidence.SceneTitle}",
|
||||||
ContributionType = evidence.ContributionType ?? string.Empty,
|
ContributionType = evidence.ContributionType ?? string.Empty,
|
||||||
Evidence = evidence.Evidence ?? string.Empty
|
Evidence = evidence.Evidence ?? string.Empty
|
||||||
|
}).ToList(),
|
||||||
|
Events = candidate.Events.Select(item => new StoryIntelligenceNarrativeEventViewModel
|
||||||
|
{
|
||||||
|
NarrativeCandidateEventID = item.NarrativeCandidateEventID,
|
||||||
|
SceneID = item.SceneID,
|
||||||
|
SceneLabel = $"Chapter {item.ChapterNumber:0.##}, Scene {item.SceneNumber:0.##}: {item.SceneTitle}",
|
||||||
|
EventType = item.EventType,
|
||||||
|
EventTitle = item.EventTitle,
|
||||||
|
EventDescription = item.EventDescription ?? string.Empty,
|
||||||
|
Evidence = item.Evidence ?? string.Empty,
|
||||||
|
Confidence = item.Confidence.HasValue ? $"{item.Confidence.Value:P0}" : "Unknown"
|
||||||
}).ToList()
|
}).ToList()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static bool IsWholeBookCandidate(StoryIntelligenceNarrativeCandidate candidate)
|
||||||
|
=> string.Equals(candidate.Source, WholeBookPlotSynthesisSources.WholeBook, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| candidate.WholeBookPlotSynthesisRunID.HasValue;
|
||||||
|
|
||||||
private static Scene? ResolveImportedScene(
|
private static Scene? ResolveImportedScene(
|
||||||
StoryIntelligenceSavedSceneResult sceneResult,
|
StoryIntelligenceSavedSceneResult sceneResult,
|
||||||
IReadOnlyDictionary<int, Scene> importedById,
|
IReadOnlyDictionary<int, Scene> importedById,
|
||||||
@ -709,6 +765,81 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<StoryIntelligenceNarrativeCandidateEvent> EffectiveEvents(
|
||||||
|
StoryIntelligenceNarrativeCandidate candidate,
|
||||||
|
IReadOnlyDictionary<int, StoryIntelligenceNarrativeCandidate> candidates,
|
||||||
|
IReadOnlyDictionary<int, StoryIntelligenceNarrativeImportChoiceForm> choices)
|
||||||
|
{
|
||||||
|
var rows = new List<StoryIntelligenceNarrativeCandidateEvent>();
|
||||||
|
AddEvents(candidate, rows, choices);
|
||||||
|
foreach (var choice in choices.Values)
|
||||||
|
{
|
||||||
|
if (choice.MergeTargetNarrativeCandidateID != candidate.NarrativeCandidateID
|
||||||
|
|| !string.Equals(choice.Action, StoryIntelligenceNarrativeReviewActions.MergeCandidate, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| !candidates.TryGetValue(choice.NarrativeCandidateID, out var merged)
|
||||||
|
|| !string.Equals(merged.CandidateType, candidate.CandidateType, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddEvents(merged, rows, choices);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.Where(item => item.SceneID > 0)
|
||||||
|
.GroupBy(item => new { item.NarrativeCandidateEventID, item.SceneID, item.EventKey })
|
||||||
|
.Select(group => group.First())
|
||||||
|
.OrderBy(item => item.SortOrder)
|
||||||
|
.ThenBy(item => item.SceneID)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddEvents(
|
||||||
|
StoryIntelligenceNarrativeCandidate candidate,
|
||||||
|
List<StoryIntelligenceNarrativeCandidateEvent> rows,
|
||||||
|
IReadOnlyDictionary<int, StoryIntelligenceNarrativeImportChoiceForm> choices)
|
||||||
|
{
|
||||||
|
var eventChoices = choices.TryGetValue(candidate.NarrativeCandidateID, out var choice)
|
||||||
|
? choice.Events.ToDictionary(item => item.NarrativeCandidateEventID)
|
||||||
|
: new Dictionary<int, StoryIntelligenceNarrativeEventChoiceForm>();
|
||||||
|
|
||||||
|
foreach (var item in candidate.Events)
|
||||||
|
{
|
||||||
|
if (eventChoices.TryGetValue(item.NarrativeCandidateEventID, out var eventChoice))
|
||||||
|
{
|
||||||
|
if (!eventChoice.Include)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.Add(new StoryIntelligenceNarrativeCandidateEvent
|
||||||
|
{
|
||||||
|
NarrativeCandidateEventID = item.NarrativeCandidateEventID,
|
||||||
|
NarrativeCandidateID = item.NarrativeCandidateID,
|
||||||
|
WholeBookPlotSynthesisRunID = item.WholeBookPlotSynthesisRunID,
|
||||||
|
EventKey = item.EventKey,
|
||||||
|
SceneID = item.SceneID,
|
||||||
|
SceneNumber = item.SceneNumber,
|
||||||
|
SceneTitle = item.SceneTitle,
|
||||||
|
ChapterNumber = item.ChapterNumber,
|
||||||
|
ChapterTitle = item.ChapterTitle,
|
||||||
|
EventType = CleanTitle(eventChoice.EventType, item.EventType),
|
||||||
|
EventTitle = CleanTitle(eventChoice.EventTitle, item.EventTitle),
|
||||||
|
EventDescription = CleanNullable(eventChoice.EventDescription) ?? item.EventDescription,
|
||||||
|
Evidence = item.Evidence,
|
||||||
|
Confidence = item.Confidence,
|
||||||
|
SortOrder = item.SortOrder
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.IsIgnored)
|
||||||
|
{
|
||||||
|
rows.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string Key(string? value)
|
private static string Key(string? value)
|
||||||
=> StoryIntelligenceNarrativeSignalConsolidator.Key(value);
|
=> StoryIntelligenceNarrativeSignalConsolidator.Key(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -158,6 +158,7 @@ public sealed class StoryIntelligenceClient(
|
|||||||
Model = settings.EffectiveModel,
|
Model = settings.EffectiveModel,
|
||||||
ChapterStructureModel = settings.EffectiveChapterStructureModel,
|
ChapterStructureModel = settings.EffectiveChapterStructureModel,
|
||||||
SceneIntelligenceModel = settings.EffectiveSceneIntelligenceModel,
|
SceneIntelligenceModel = settings.EffectiveSceneIntelligenceModel,
|
||||||
|
WholeBookPlotSynthesisModel = settings.EffectiveWholeBookPlotSynthesisModel,
|
||||||
MaxOutputTokens = settings.MaxOutputTokens,
|
MaxOutputTokens = settings.MaxOutputTokens,
|
||||||
TimeoutSeconds = settings.TimeoutSeconds,
|
TimeoutSeconds = settings.TimeoutSeconds,
|
||||||
ClientConstructed = true,
|
ClientConstructed = true,
|
||||||
@ -831,6 +832,7 @@ public sealed class StoryIntelligenceClientConfigurationStatus
|
|||||||
public string Model { get; init; } = string.Empty;
|
public string Model { get; init; } = string.Empty;
|
||||||
public string ChapterStructureModel { get; init; } = string.Empty;
|
public string ChapterStructureModel { get; init; } = string.Empty;
|
||||||
public string SceneIntelligenceModel { get; init; } = string.Empty;
|
public string SceneIntelligenceModel { get; init; } = string.Empty;
|
||||||
|
public string WholeBookPlotSynthesisModel { get; init; } = string.Empty;
|
||||||
public int MaxOutputTokens { get; init; }
|
public int MaxOutputTokens { get; init; }
|
||||||
public int TimeoutSeconds { get; init; }
|
public int TimeoutSeconds { get; init; }
|
||||||
public bool ClientConstructed { get; init; }
|
public bool ClientConstructed { get; init; }
|
||||||
|
|||||||
414
PlotLine/Services/WholeBookPlotIntelligenceService.cs
Normal file
414
PlotLine/Services/WholeBookPlotIntelligenceService.cs
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
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 IWholeBookPlotIntelligenceService
|
||||||
|
{
|
||||||
|
Task<StoryIntelligenceAuthorPlotContextViewModel?> GetAuthorPlotContextAsync(int? bookId = null);
|
||||||
|
Task SaveAuthorPlotContextAsync(int projectId, int bookId, IReadOnlyList<string> descriptions);
|
||||||
|
Task<WholeBookPlotSynthesisRun?> QueueAfterCharacterResolutionAsync(int projectId, int bookId, int userId);
|
||||||
|
Task<WholeBookPlotSynthesisRun?> RetryAsync(int bookId, int userId);
|
||||||
|
Task<WholeBookPlotSynthesisRun?> GetCurrentAsync(int bookId);
|
||||||
|
Task<bool> ProcessNextAsync(CancellationToken cancellationToken);
|
||||||
|
string BuildPromptForTest(WholeBookPlotSynthesisContext context);
|
||||||
|
StoryIntelligenceResponseContract BuildResponseContractForTest();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WholeBookPlotIntelligenceService(
|
||||||
|
IWholeBookPlotIntelligenceRepository repository,
|
||||||
|
IStoryIntelligencePipelineRepository pipelines,
|
||||||
|
IBookRepository books,
|
||||||
|
IStoryIntelligenceClient client,
|
||||||
|
IOptions<StoryIntelligenceOptions> options,
|
||||||
|
IStoryIntelligenceProgressNotifier notifier,
|
||||||
|
ICurrentUserService currentUser,
|
||||||
|
ILogger<WholeBookPlotIntelligenceService> logger) : IWholeBookPlotIntelligenceService
|
||||||
|
{
|
||||||
|
private const string PromptVersion = "Whole-Book-Plot-Synthesis-V1";
|
||||||
|
private const int MaxPromptCharacters = 900_000;
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
private readonly StoryIntelligenceOptions settings = options.Value;
|
||||||
|
|
||||||
|
public async Task<StoryIntelligenceAuthorPlotContextViewModel?> GetAuthorPlotContextAsync(int? bookId = null)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId;
|
||||||
|
if (!userId.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var book = bookId.HasValue ? await books.GetAsync(bookId.Value) : null;
|
||||||
|
if (book is null && bookId.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var descriptions = book is null
|
||||||
|
? new List<string>()
|
||||||
|
: (await repository.ListAuthorSeedsAsync(book.BookID)).Select(seed => seed.Description).ToList();
|
||||||
|
|
||||||
|
while (descriptions.Count < 3)
|
||||||
|
{
|
||||||
|
descriptions.Add(string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StoryIntelligenceAuthorPlotContextViewModel
|
||||||
|
{
|
||||||
|
ProjectID = book?.ProjectID ?? 0,
|
||||||
|
BookID = book?.BookID ?? 0,
|
||||||
|
BookTitle = book?.BookDisplayTitle ?? string.Empty,
|
||||||
|
Descriptions = descriptions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SaveAuthorPlotContextAsync(int projectId, int bookId, IReadOnlyList<string> descriptions)
|
||||||
|
=> repository.SaveAuthorSeedsAsync(new AuthorPlotSeedSaveRequest
|
||||||
|
{
|
||||||
|
ProjectID = projectId,
|
||||||
|
BookID = bookId,
|
||||||
|
Descriptions = descriptions
|
||||||
|
});
|
||||||
|
|
||||||
|
public async Task<WholeBookPlotSynthesisRun?> QueueAfterCharacterResolutionAsync(int projectId, int bookId, int userId)
|
||||||
|
{
|
||||||
|
var pipeline = await pipelines.GetByBookForUserAsync(bookId, userId);
|
||||||
|
var latestRunId = pipeline?.LastRunID ?? (await pipelines.ListCommittedRunsByBookAsync(bookId, userId))
|
||||||
|
.OrderByDescending(run => run.StoryIntelligenceRunID)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?.StoryIntelligenceRunID;
|
||||||
|
return await repository.QueueAsync(new WholeBookPlotSynthesisQueueRequest
|
||||||
|
{
|
||||||
|
ProjectID = projectId,
|
||||||
|
BookID = bookId,
|
||||||
|
UserID = userId,
|
||||||
|
SourceStoryIntelligenceRunID = latestRunId,
|
||||||
|
CharacterResolutionVersion = $"CharacterImport:{bookId}"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WholeBookPlotSynthesisRun?> RetryAsync(int bookId, int userId)
|
||||||
|
=> repository.RetryAsync(bookId, userId);
|
||||||
|
|
||||||
|
public Task<WholeBookPlotSynthesisRun?> GetCurrentAsync(int bookId)
|
||||||
|
=> repository.GetCurrentAsync(bookId);
|
||||||
|
|
||||||
|
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();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var context = await repository.BuildContextAsync(run.ProjectID, run.BookID);
|
||||||
|
if (!context.SourceAudit.CanReconstruct)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Scene manuscript source audit failed. Scenes={context.SourceAudit.SceneCount}, sources={context.SourceAudit.SourceRowCount}, missing={context.SourceAudit.MissingSourceCount}, duplicateSourceScenes={context.SourceAudit.DuplicateSourceSceneCount}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var prompt = BuildPrompt(context);
|
||||||
|
if (prompt.Length > MaxPromptCharacters)
|
||||||
|
{
|
||||||
|
prompt = BuildPrompt(context, includeOptionalContext: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prompt.Length > MaxPromptCharacters)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Whole-book Plot Intelligence prompt would exceed the configured safe context limit. No manuscript text was silently truncated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await client.ExecutePromptAsync(
|
||||||
|
prompt,
|
||||||
|
PromptVersion,
|
||||||
|
cancellationToken,
|
||||||
|
settings.EffectiveWholeBookPlotSynthesisModel,
|
||||||
|
responseContract: WholeBookPlotSynthesisStructuredOutputSchema.Contract);
|
||||||
|
var outputJson = ExtractOutputText(result.RawResponseText);
|
||||||
|
var parsed = WholeBookPlotSynthesisResult.FromJson(outputJson);
|
||||||
|
stopwatch.Stop();
|
||||||
|
|
||||||
|
await repository.CompleteAsync(new WholeBookPlotSynthesisCompletionRequest
|
||||||
|
{
|
||||||
|
WholeBookPlotSynthesisRunID = run.WholeBookPlotSynthesisRunID,
|
||||||
|
Model = result.Model,
|
||||||
|
OpenAIResponseID = ExtractResponseId(result.RawResponseText),
|
||||||
|
InputTokens = result.InputTokens,
|
||||||
|
OutputTokens = result.OutputTokens,
|
||||||
|
CachedInputTokens = ExtractCachedTokens(result.RawResponseText),
|
||||||
|
TotalTokens = (result.InputTokens ?? 0) + (result.OutputTokens ?? 0) > 0
|
||||||
|
? (result.InputTokens ?? 0) + (result.OutputTokens ?? 0)
|
||||||
|
: null,
|
||||||
|
DurationMs = stopwatch.ElapsedMilliseconds,
|
||||||
|
ResultJson = outputJson
|
||||||
|
}, parsed);
|
||||||
|
await notifier.PublishGlobalAsync(run.UserID);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Whole-book Plot Intelligence completed. RunID={RunID} ProjectID={ProjectID} BookID={BookID} PlotLines={PlotLineCount} DurationMs={DurationMs}",
|
||||||
|
run.WholeBookPlotSynthesisRunID,
|
||||||
|
run.ProjectID,
|
||||||
|
run.BookID,
|
||||||
|
parsed.PlotLines.Count,
|
||||||
|
stopwatch.ElapsedMilliseconds);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
stopwatch.Stop();
|
||||||
|
await repository.FailAsync(run.WholeBookPlotSynthesisRunID, ex.Message, ex.ToString(), stopwatch.ElapsedMilliseconds);
|
||||||
|
await notifier.PublishGlobalAsync(run.UserID);
|
||||||
|
logger.LogError(ex, "Whole-book Plot Intelligence failed. RunID={RunID} BookID={BookID}", run.WholeBookPlotSynthesisRunID, run.BookID);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string BuildPromptForTest(WholeBookPlotSynthesisContext context)
|
||||||
|
=> BuildPrompt(context);
|
||||||
|
|
||||||
|
public StoryIntelligenceResponseContract BuildResponseContractForTest()
|
||||||
|
=> WholeBookPlotSynthesisStructuredOutputSchema.Contract;
|
||||||
|
|
||||||
|
private static string BuildPrompt(WholeBookPlotSynthesisContext context, bool includeOptionalContext = true)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.AppendLine("You are PlotDirector's Whole-Book Plot Intelligence engine.");
|
||||||
|
builder.AppendLine("Read the complete manuscript and identify sustained plot lines, subordinate threads, and every meaningful scene touch.");
|
||||||
|
builder.AppendLine("Return only data that conforms to the strict schema. Reference only SceneIDs, PlotLineIDs, PlotThreadIDs, and AuthorPlotSeedIDs supplied in this request.");
|
||||||
|
builder.AppendLine("A major Plot Line is a sustained narrative arc, mystery, conflict, goal, hidden truth, character struggle, investigation, relationship arc, or continuing question.");
|
||||||
|
builder.AppendLine("Do not promote isolated incidents, simple presence, locations, or recurring props without narrative development into major Plot Lines.");
|
||||||
|
builder.AppendLine("A Thread is a meaningful subordinate strand within a Plot Line. Do not fragment every scene development into a separate Thread.");
|
||||||
|
builder.AppendLine("Create an event for a scene when the scene meaningfully mentions, reinforces, advances, complicates, foreshadows, questions, misdirects, reveals, pays off, contradicts, or resolves the thread.");
|
||||||
|
builder.AppendLine("Do not attach a scene merely because associated characters are present.");
|
||||||
|
builder.AppendLine("Use Mentioned only as the fallback for genuine reinforcement when no more informative event type applies.");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("[BOOK]");
|
||||||
|
builder.AppendLine($"BookID: {context.BookID}");
|
||||||
|
builder.AppendLine($"Title: {context.BookTitle}");
|
||||||
|
builder.AppendLine();
|
||||||
|
|
||||||
|
if (includeOptionalContext)
|
||||||
|
{
|
||||||
|
builder.AppendLine("[CANONICAL CHARACTERS]");
|
||||||
|
foreach (var character in context.Characters)
|
||||||
|
{
|
||||||
|
builder.AppendLine($"CharacterID: {character.CharacterID}; CanonicalName: {character.CanonicalName}; Aliases: {string.Join(", ", character.Aliases)}");
|
||||||
|
}
|
||||||
|
builder.AppendLine();
|
||||||
|
|
||||||
|
builder.AppendLine("[EXISTING PROJECT PLOT LINES]");
|
||||||
|
foreach (var plotLine in context.ExistingPlotLines)
|
||||||
|
{
|
||||||
|
builder.AppendLine($"PlotLineID: {plotLine.PlotLineID}; Name: {plotLine.Name}; Importance: {plotLine.Importance}; BookID: {plotLine.BookID?.ToString() ?? "series"}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(plotLine.Description))
|
||||||
|
{
|
||||||
|
builder.AppendLine($"Description: {plotLine.Description}");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var thread in plotLine.Threads)
|
||||||
|
{
|
||||||
|
builder.AppendLine($" ThreadID: {thread.PlotThreadID}; Title: {thread.Title}; Status: {thread.Status}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.AppendLine();
|
||||||
|
|
||||||
|
builder.AppendLine("[AUTHOR PLOT SEEDS]");
|
||||||
|
foreach (var seed in context.AuthorSeeds)
|
||||||
|
{
|
||||||
|
builder.AppendLine($"AuthorPlotSeedID: {seed.AuthorPlotSeedID}; Description: {seed.Description}");
|
||||||
|
}
|
||||||
|
builder.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AppendLine("[MANUSCRIPT]");
|
||||||
|
foreach (var chapter in context.Chapters)
|
||||||
|
{
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine($"[CHAPTER {chapter.ChapterNumber:0.##}]");
|
||||||
|
builder.AppendLine($"ChapterID: {chapter.ChapterID}");
|
||||||
|
builder.AppendLine($"Title: {chapter.ChapterTitle}");
|
||||||
|
foreach (var scene in chapter.Scenes)
|
||||||
|
{
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("[SCENE]");
|
||||||
|
builder.AppendLine($"SceneID: {scene.SceneID}");
|
||||||
|
builder.AppendLine($"SceneNumber: {scene.SceneNumber:0.##}");
|
||||||
|
builder.AppendLine($"Title: {scene.SceneTitle}");
|
||||||
|
builder.AppendLine($"POV: {scene.PovCharacterName}");
|
||||||
|
builder.AppendLine($"Setting: {scene.StructuralSetting}");
|
||||||
|
builder.AppendLine("<text>");
|
||||||
|
builder.AppendLine(scene.SourceText);
|
||||||
|
builder.AppendLine("</text>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractOutputText(string rawResponseText)
|
||||||
|
{
|
||||||
|
var response = JsonSerializer.Deserialize<WholeBookOpenAIResponseEnvelope>(rawResponseText, JsonOptions)
|
||||||
|
?? throw new JsonException("OpenAI response envelope was empty.");
|
||||||
|
return response.Output?
|
||||||
|
.SelectMany(item => item.Content ?? [])
|
||||||
|
.FirstOrDefault(content => string.Equals(content.Type, "output_text", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.IsNullOrWhiteSpace(content.Text))
|
||||||
|
?.Text
|
||||||
|
?.Trim()
|
||||||
|
?? throw new JsonException("OpenAI response did not contain output_text content.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractResponseId(string rawResponseText)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<WholeBookOpenAIResponseEnvelope>(rawResponseText, JsonOptions)?.Id;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? ExtractCachedTokens(string rawResponseText)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<WholeBookOpenAIResponseEnvelope>(rawResponseText, JsonOptions)?.Usage?.InputTokenDetails?.CachedTokens;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class WholeBookOpenAIResponseEnvelope
|
||||||
|
{
|
||||||
|
public string? Id { get; init; }
|
||||||
|
public List<WholeBookOpenAIResponseOutputItem>? Output { get; init; }
|
||||||
|
public WholeBookOpenAIResponseUsage? Usage { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class WholeBookOpenAIResponseOutputItem
|
||||||
|
{
|
||||||
|
public List<WholeBookOpenAIResponseContentItem>? Content { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class WholeBookOpenAIResponseContentItem
|
||||||
|
{
|
||||||
|
public string? Type { get; init; }
|
||||||
|
public string? Text { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class WholeBookOpenAIResponseUsage
|
||||||
|
{
|
||||||
|
[JsonPropertyName("input_tokens")]
|
||||||
|
public int? InputTokens { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("output_tokens")]
|
||||||
|
public int? OutputTokens { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("input_tokens_details")]
|
||||||
|
public WholeBookOpenAIInputTokenDetails? InputTokenDetails { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class WholeBookOpenAIInputTokenDetails
|
||||||
|
{
|
||||||
|
[JsonPropertyName("cached_tokens")]
|
||||||
|
public int? CachedTokens { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class WholeBookPlotSynthesisStructuredOutputSchema
|
||||||
|
{
|
||||||
|
public static readonly StoryIntelligenceResponseContract Contract = new()
|
||||||
|
{
|
||||||
|
Name = "whole_book_plot_synthesis",
|
||||||
|
Strict = true,
|
||||||
|
Schema = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["type"] = "object",
|
||||||
|
["additionalProperties"] = false,
|
||||||
|
["required"] = new[] { "metadata", "plotLines" },
|
||||||
|
["properties"] = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["metadata"] = ObjectSchema(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["bookStatus"] = StringSchema(),
|
||||||
|
["summary"] = StringSchema()
|
||||||
|
}, "bookStatus", "summary"),
|
||||||
|
["plotLines"] = ArraySchema(ObjectSchema(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["key"] = StringSchema(),
|
||||||
|
["proposedTitle"] = StringSchema(),
|
||||||
|
["description"] = StringSchema(),
|
||||||
|
["significance"] = StringSchema(),
|
||||||
|
["bookStatus"] = StringSchema(),
|
||||||
|
["existingPlotLineID"] = NullableIntegerSchema(),
|
||||||
|
["existingPlotLineMatchConfidence"] = NullableNumberSchema(),
|
||||||
|
["existingPlotLineMatchReason"] = NullableStringSchema(),
|
||||||
|
["authorPlotSeedID"] = NullableIntegerSchema(),
|
||||||
|
["seedMatchConfidence"] = NullableNumberSchema(),
|
||||||
|
["seedMatchReason"] = NullableStringSchema(),
|
||||||
|
["confidence"] = NullableNumberSchema(),
|
||||||
|
["threads"] = ArraySchema(ObjectSchema(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["key"] = StringSchema(),
|
||||||
|
["proposedTitle"] = StringSchema(),
|
||||||
|
["description"] = StringSchema(),
|
||||||
|
["status"] = StringSchema(),
|
||||||
|
["existingPlotThreadID"] = NullableIntegerSchema(),
|
||||||
|
["existingThreadMatchConfidence"] = NullableNumberSchema(),
|
||||||
|
["matchReason"] = NullableStringSchema(),
|
||||||
|
["confidence"] = NullableNumberSchema(),
|
||||||
|
["events"] = ArraySchema(ObjectSchema(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["key"] = StringSchema(),
|
||||||
|
["sceneID"] = IntegerSchema(),
|
||||||
|
["eventType"] = EnumSchema("Introduced", "Mentioned", "Developed", "Complicated", "Clue Planted", "Question Raised", "Question Answered", "False Lead", "Revealed", "Payoff", "Resolved", "Contradicted", "Retconned", "Note"),
|
||||||
|
["title"] = StringSchema(),
|
||||||
|
["description"] = StringSchema(),
|
||||||
|
["evidence"] = StringSchema(),
|
||||||
|
["confidence"] = NullableNumberSchema()
|
||||||
|
}, "key", "sceneID", "eventType", "title", "description", "evidence", "confidence"))
|
||||||
|
}, "key", "proposedTitle", "description", "status", "existingPlotThreadID", "existingThreadMatchConfidence", "matchReason", "confidence", "events"))
|
||||||
|
}, "key", "proposedTitle", "description", "significance", "bookStatus", "existingPlotLineID", "existingPlotLineMatchConfidence", "existingPlotLineMatchReason", "authorPlotSeedID", "seedMatchConfidence", "seedMatchReason", "confidence", "threads"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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> IntegerSchema() => new() { ["type"] = "integer" };
|
||||||
|
private static Dictionary<string, object> NullableIntegerSchema() => new() { ["type"] = new[] { "integer", "null" } };
|
||||||
|
private static Dictionary<string, object> NullableNumberSchema() => new() { ["type"] = new[] { "number", "null" }, ["minimum"] = 0, ["maximum"] = 1 };
|
||||||
|
private static Dictionary<string, object> NullableStringSchema() => new() { ["type"] = new[] { "string", "null" } };
|
||||||
|
private static Dictionary<string, object> EnumSchema(params string[] values) => new() { ["type"] = "string", ["enum"] = values };
|
||||||
|
}
|
||||||
817
PlotLine/Sql/174_Phase27_WholeBookPlotIntelligence.sql
Normal file
817
PlotLine/Sql/174_Phase27_WholeBookPlotIntelligence.sql
Normal file
@ -0,0 +1,817 @@
|
|||||||
|
SET ANSI_NULLS ON;
|
||||||
|
GO
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.AuthorPlotSeeds', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.AuthorPlotSeeds
|
||||||
|
(
|
||||||
|
AuthorPlotSeedID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_AuthorPlotSeeds PRIMARY KEY,
|
||||||
|
ProjectID int NOT NULL,
|
||||||
|
BookID int NOT NULL,
|
||||||
|
Description nvarchar(max) NOT NULL,
|
||||||
|
Status nvarchar(30) NOT NULL CONSTRAINT DF_AuthorPlotSeeds_Status DEFAULT N'Active',
|
||||||
|
MatchedPlotLineID int NULL,
|
||||||
|
Source nvarchar(40) NOT NULL CONSTRAINT DF_AuthorPlotSeeds_Source DEFAULT N'Author',
|
||||||
|
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_AuthorPlotSeeds_CreatedUtc DEFAULT SYSUTCDATETIME(),
|
||||||
|
UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_AuthorPlotSeeds_UpdatedUtc DEFAULT SYSUTCDATETIME(),
|
||||||
|
CONSTRAINT FK_AuthorPlotSeeds_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID),
|
||||||
|
CONSTRAINT FK_AuthorPlotSeeds_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT FK_AuthorPlotSeeds_MatchedPlotLines FOREIGN KEY (MatchedPlotLineID) REFERENCES dbo.PlotLines(PlotLineID) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_AuthorPlotSeeds_Books' AND parent_object_id = OBJECT_ID(N'dbo.AuthorPlotSeeds'))
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.AuthorPlotSeeds DROP CONSTRAINT FK_AuthorPlotSeeds_Books;
|
||||||
|
ALTER TABLE dbo.AuthorPlotSeeds WITH CHECK
|
||||||
|
ADD CONSTRAINT FK_AuthorPlotSeeds_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID) ON DELETE CASCADE;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_AuthorPlotSeeds_MatchedPlotLines' AND parent_object_id = OBJECT_ID(N'dbo.AuthorPlotSeeds'))
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.AuthorPlotSeeds DROP CONSTRAINT FK_AuthorPlotSeeds_MatchedPlotLines;
|
||||||
|
ALTER TABLE dbo.AuthorPlotSeeds WITH CHECK
|
||||||
|
ADD CONSTRAINT FK_AuthorPlotSeeds_MatchedPlotLines FOREIGN KEY (MatchedPlotLineID) REFERENCES dbo.PlotLines(PlotLineID) ON DELETE SET NULL;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_AuthorPlotSeeds_Book_Status' AND object_id = OBJECT_ID(N'dbo.AuthorPlotSeeds'))
|
||||||
|
CREATE INDEX IX_AuthorPlotSeeds_Book_Status ON dbo.AuthorPlotSeeds(BookID, Status);
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.WholeBookPlotSynthesisRuns', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.WholeBookPlotSynthesisRuns
|
||||||
|
(
|
||||||
|
WholeBookPlotSynthesisRunID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_WholeBookPlotSynthesisRuns PRIMARY KEY,
|
||||||
|
ProjectID int NOT NULL,
|
||||||
|
BookID int NOT NULL,
|
||||||
|
UserID int NOT NULL,
|
||||||
|
SourceStoryIntelligenceRunID int NULL,
|
||||||
|
SourceStoryIntelligenceRunKey AS ISNULL(SourceStoryIntelligenceRunID, 0) PERSISTED,
|
||||||
|
CharacterResolutionVersion nvarchar(120) NOT NULL,
|
||||||
|
Status nvarchar(30) NOT NULL CONSTRAINT DF_WholeBookPlotSynthesisRuns_Status DEFAULT N'Queued',
|
||||||
|
QueuedUtc datetime2 NOT NULL CONSTRAINT DF_WholeBookPlotSynthesisRuns_QueuedUtc DEFAULT SYSUTCDATETIME(),
|
||||||
|
StartedUtc datetime2 NULL,
|
||||||
|
CompletedUtc datetime2 NULL,
|
||||||
|
FailedUtc datetime2 NULL,
|
||||||
|
AttemptCount int NOT NULL CONSTRAINT DF_WholeBookPlotSynthesisRuns_AttemptCount DEFAULT 0,
|
||||||
|
Model nvarchar(100) NULL,
|
||||||
|
OpenAIResponseID nvarchar(120) NULL,
|
||||||
|
InputTokens int NULL,
|
||||||
|
OutputTokens int NULL,
|
||||||
|
CachedInputTokens int NULL,
|
||||||
|
TotalTokens int NULL,
|
||||||
|
DurationMs bigint NULL,
|
||||||
|
ErrorMessage nvarchar(max) NULL,
|
||||||
|
ErrorDetail nvarchar(max) NULL,
|
||||||
|
ResultJson nvarchar(max) NULL,
|
||||||
|
IsCurrent bit NOT NULL CONSTRAINT DF_WholeBookPlotSynthesisRuns_IsCurrent DEFAULT 1,
|
||||||
|
UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_WholeBookPlotSynthesisRuns_UpdatedUtc DEFAULT SYSUTCDATETIME(),
|
||||||
|
CONSTRAINT FK_WholeBookPlotSynthesisRuns_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID),
|
||||||
|
CONSTRAINT FK_WholeBookPlotSynthesisRuns_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT FK_WholeBookPlotSynthesisRuns_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID),
|
||||||
|
CONSTRAINT FK_WholeBookPlotSynthesisRuns_SourceRun FOREIGN KEY (SourceStoryIntelligenceRunID) REFERENCES dbo.StoryIntelligenceRuns(StoryIntelligenceRunID) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH(N'dbo.WholeBookPlotSynthesisRuns', N'SourceStoryIntelligenceRunKey') IS NULL
|
||||||
|
ALTER TABLE dbo.WholeBookPlotSynthesisRuns ADD SourceStoryIntelligenceRunKey AS ISNULL(SourceStoryIntelligenceRunID, 0) PERSISTED;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_WholeBookPlotSynthesisRuns_Queue' AND object_id = OBJECT_ID(N'dbo.WholeBookPlotSynthesisRuns'))
|
||||||
|
CREATE INDEX IX_WholeBookPlotSynthesisRuns_Queue ON dbo.WholeBookPlotSynthesisRuns(Status, QueuedUtc) INCLUDE (BookID, UserID);
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_WholeBookPlotSynthesisRuns_CurrentVersion' AND object_id = OBJECT_ID(N'dbo.WholeBookPlotSynthesisRuns'))
|
||||||
|
CREATE UNIQUE INDEX UX_WholeBookPlotSynthesisRuns_CurrentVersion
|
||||||
|
ON dbo.WholeBookPlotSynthesisRuns(BookID, SourceStoryIntelligenceRunKey, CharacterResolutionVersion)
|
||||||
|
WHERE IsCurrent = 1 AND Status IN (N'Queued', N'Running', N'Completed');
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH(N'dbo.StoryIntelligenceNarrativeCandidates', N'WholeBookPlotSynthesisRunID') IS NULL
|
||||||
|
ALTER TABLE dbo.StoryIntelligenceNarrativeCandidates ADD WholeBookPlotSynthesisRunID int NULL;
|
||||||
|
GO
|
||||||
|
IF COL_LENGTH(N'dbo.StoryIntelligenceNarrativeCandidates', N'AuthorPlotSeedID') IS NULL
|
||||||
|
ALTER TABLE dbo.StoryIntelligenceNarrativeCandidates ADD AuthorPlotSeedID int NULL;
|
||||||
|
GO
|
||||||
|
IF COL_LENGTH(N'dbo.StoryIntelligenceNarrativeCandidates', N'ExistingPlotLineMatchConfidence') IS NULL
|
||||||
|
ALTER TABLE dbo.StoryIntelligenceNarrativeCandidates ADD ExistingPlotLineMatchConfidence decimal(5,4) NULL;
|
||||||
|
GO
|
||||||
|
IF COL_LENGTH(N'dbo.StoryIntelligenceNarrativeCandidates', N'ExistingPlotLineMatchReason') IS NULL
|
||||||
|
ALTER TABLE dbo.StoryIntelligenceNarrativeCandidates ADD ExistingPlotLineMatchReason nvarchar(max) NULL;
|
||||||
|
GO
|
||||||
|
IF COL_LENGTH(N'dbo.StoryIntelligenceNarrativeCandidates', N'SeedMatchConfidence') IS NULL
|
||||||
|
ALTER TABLE dbo.StoryIntelligenceNarrativeCandidates ADD SeedMatchConfidence decimal(5,4) NULL;
|
||||||
|
GO
|
||||||
|
IF COL_LENGTH(N'dbo.StoryIntelligenceNarrativeCandidates', N'SeedMatchReason') IS NULL
|
||||||
|
ALTER TABLE dbo.StoryIntelligenceNarrativeCandidates ADD SeedMatchReason nvarchar(max) NULL;
|
||||||
|
GO
|
||||||
|
IF COL_LENGTH(N'dbo.StoryIntelligenceNarrativeCandidates', N'Source') IS NULL
|
||||||
|
ALTER TABLE dbo.StoryIntelligenceNarrativeCandidates ADD Source nvarchar(60) NULL;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.StoryIntelligenceNarrativeCandidateEvents', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.StoryIntelligenceNarrativeCandidateEvents
|
||||||
|
(
|
||||||
|
NarrativeCandidateEventID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceNarrativeCandidateEvents PRIMARY KEY,
|
||||||
|
NarrativeCandidateID int NOT NULL,
|
||||||
|
WholeBookPlotSynthesisRunID int NULL,
|
||||||
|
EventKey nvarchar(260) NOT NULL,
|
||||||
|
SceneID int NOT NULL,
|
||||||
|
EventType nvarchar(100) NOT NULL,
|
||||||
|
EventTitle nvarchar(200) NOT NULL,
|
||||||
|
EventDescription nvarchar(max) NULL,
|
||||||
|
Evidence nvarchar(max) NULL,
|
||||||
|
Confidence decimal(5,4) NULL,
|
||||||
|
SortOrder int NOT NULL CONSTRAINT DF_StoryIntelligenceNarrativeCandidateEvents_SortOrder DEFAULT 0,
|
||||||
|
IsIgnored bit NOT NULL CONSTRAINT DF_StoryIntelligenceNarrativeCandidateEvents_IsIgnored DEFAULT 0,
|
||||||
|
AcceptedThreadEventID int NULL,
|
||||||
|
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceNarrativeCandidateEvents_CreatedUtc DEFAULT SYSUTCDATETIME(),
|
||||||
|
UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceNarrativeCandidateEvents_UpdatedUtc DEFAULT SYSUTCDATETIME(),
|
||||||
|
CONSTRAINT FK_StoryIntelligenceNarrativeCandidateEvents_Candidates FOREIGN KEY (NarrativeCandidateID) REFERENCES dbo.StoryIntelligenceNarrativeCandidates(NarrativeCandidateID) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT FK_StoryIntelligenceNarrativeCandidateEvents_SynthesisRun FOREIGN KEY (WholeBookPlotSynthesisRunID) REFERENCES dbo.WholeBookPlotSynthesisRuns(WholeBookPlotSynthesisRunID),
|
||||||
|
CONSTRAINT FK_StoryIntelligenceNarrativeCandidateEvents_Scenes FOREIGN KEY (SceneID) REFERENCES dbo.Scenes(SceneID),
|
||||||
|
CONSTRAINT FK_StoryIntelligenceNarrativeCandidateEvents_AcceptedThreadEvents FOREIGN KEY (AcceptedThreadEventID) REFERENCES dbo.ThreadEvents(ThreadEventID)
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryIntelligenceNarrativeCandidateEvents_Key' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceNarrativeCandidateEvents'))
|
||||||
|
CREATE UNIQUE INDEX UX_StoryIntelligenceNarrativeCandidateEvents_Key ON dbo.StoryIntelligenceNarrativeCandidateEvents(NarrativeCandidateID, EventKey);
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH(N'dbo.ThreadEvents', N'Source') IS NULL
|
||||||
|
ALTER TABLE dbo.ThreadEvents ADD Source nvarchar(60) NOT NULL CONSTRAINT DF_ThreadEvents_Source DEFAULT N'Author';
|
||||||
|
GO
|
||||||
|
IF COL_LENGTH(N'dbo.ThreadEvents', N'SourceReferenceID') IS NULL
|
||||||
|
ALTER TABLE dbo.ThreadEvents ADD SourceReferenceID int NULL;
|
||||||
|
GO
|
||||||
|
IF COL_LENGTH(N'dbo.ThreadEvents', N'SourceKey') IS NULL
|
||||||
|
ALTER TABLE dbo.ThreadEvents ADD SourceKey nvarchar(260) NULL;
|
||||||
|
GO
|
||||||
|
IF COL_LENGTH(N'dbo.ThreadEvents', N'Confidence') IS NULL
|
||||||
|
ALTER TABLE dbo.ThreadEvents ADD Confidence decimal(5,4) NULL;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_ThreadEvents_SourceKey' AND object_id = OBJECT_ID(N'dbo.ThreadEvents'))
|
||||||
|
CREATE UNIQUE INDEX UX_ThreadEvents_SourceKey ON dbo.ThreadEvents(Source, SourceKey) WHERE SourceKey IS NOT NULL;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.AuthorPlotSeed_SaveForBook
|
||||||
|
@ProjectID int,
|
||||||
|
@BookID int,
|
||||||
|
@SeedsJson nvarchar(max)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
|
||||||
|
DECLARE @Seeds TABLE (Description nvarchar(max) NOT NULL, SortOrder int IDENTITY(1,1));
|
||||||
|
|
||||||
|
INSERT @Seeds (Description)
|
||||||
|
SELECT DISTINCT LEFT(LTRIM(RTRIM(JSON_VALUE(value, '$.description'))), 4000)
|
||||||
|
FROM OPENJSON(@SeedsJson)
|
||||||
|
WHERE NULLIF(LTRIM(RTRIM(JSON_VALUE(value, '$.description'))), N'') IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE dbo.AuthorPlotSeeds
|
||||||
|
SET Status = N'Archived',
|
||||||
|
UpdatedUtc = SYSUTCDATETIME()
|
||||||
|
WHERE ProjectID = @ProjectID
|
||||||
|
AND BookID = @BookID
|
||||||
|
AND Source = N'Author'
|
||||||
|
AND Status = N'Active'
|
||||||
|
AND NOT EXISTS
|
||||||
|
(
|
||||||
|
SELECT 1 FROM @Seeds seed WHERE seed.Description = AuthorPlotSeeds.Description
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT dbo.AuthorPlotSeeds (ProjectID, BookID, Description, Status, Source)
|
||||||
|
SELECT @ProjectID, @BookID, seed.Description, N'Active', N'Author'
|
||||||
|
FROM @Seeds seed
|
||||||
|
WHERE NOT EXISTS
|
||||||
|
(
|
||||||
|
SELECT 1
|
||||||
|
FROM dbo.AuthorPlotSeeds existing
|
||||||
|
WHERE existing.ProjectID = @ProjectID
|
||||||
|
AND existing.BookID = @BookID
|
||||||
|
AND existing.Source = N'Author'
|
||||||
|
AND existing.Description = seed.Description
|
||||||
|
AND existing.Status = N'Active'
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.AuthorPlotSeed_ListByBook
|
||||||
|
@BookID int
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SELECT AuthorPlotSeedID, ProjectID, BookID, Description, Status, MatchedPlotLineID, Source, CreatedUtc, UpdatedUtc
|
||||||
|
FROM dbo.AuthorPlotSeeds
|
||||||
|
WHERE BookID = @BookID AND Status = N'Active'
|
||||||
|
ORDER BY AuthorPlotSeedID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.WholeBookPlotSynthesisRun_Queue
|
||||||
|
@ProjectID int,
|
||||||
|
@BookID int,
|
||||||
|
@UserID int,
|
||||||
|
@SourceStoryIntelligenceRunID int = NULL,
|
||||||
|
@CharacterResolutionVersion nvarchar(120)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
|
||||||
|
DECLARE @ExistingID int;
|
||||||
|
SELECT TOP (1) @ExistingID = WholeBookPlotSynthesisRunID
|
||||||
|
FROM dbo.WholeBookPlotSynthesisRuns
|
||||||
|
WHERE BookID = @BookID
|
||||||
|
AND ISNULL(SourceStoryIntelligenceRunID, 0) = ISNULL(@SourceStoryIntelligenceRunID, 0)
|
||||||
|
AND CharacterResolutionVersion = @CharacterResolutionVersion
|
||||||
|
AND IsCurrent = 1
|
||||||
|
AND Status IN (N'Queued', N'Running', N'Completed')
|
||||||
|
ORDER BY WholeBookPlotSynthesisRunID DESC;
|
||||||
|
|
||||||
|
IF @ExistingID IS NULL
|
||||||
|
BEGIN
|
||||||
|
INSERT dbo.WholeBookPlotSynthesisRuns (ProjectID, BookID, UserID, SourceStoryIntelligenceRunID, CharacterResolutionVersion, Status)
|
||||||
|
VALUES (@ProjectID, @BookID, @UserID, @SourceStoryIntelligenceRunID, @CharacterResolutionVersion, N'Queued');
|
||||||
|
SET @ExistingID = CAST(SCOPE_IDENTITY() AS int);
|
||||||
|
END;
|
||||||
|
|
||||||
|
SELECT * FROM dbo.WholeBookPlotSynthesisRuns WHERE WholeBookPlotSynthesisRunID = @ExistingID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.WholeBookPlotSynthesisRun_Retry
|
||||||
|
@BookID int,
|
||||||
|
@UserID int
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
|
||||||
|
DECLARE @PreviousID int, @ProjectID int, @SourceRunID int, @Version nvarchar(120);
|
||||||
|
SELECT TOP (1)
|
||||||
|
@PreviousID = WholeBookPlotSynthesisRunID,
|
||||||
|
@ProjectID = ProjectID,
|
||||||
|
@SourceRunID = SourceStoryIntelligenceRunID,
|
||||||
|
@Version = CharacterResolutionVersion
|
||||||
|
FROM dbo.WholeBookPlotSynthesisRuns
|
||||||
|
WHERE BookID = @BookID AND UserID = @UserID AND IsCurrent = 1 AND Status = N'Failed'
|
||||||
|
ORDER BY WholeBookPlotSynthesisRunID DESC;
|
||||||
|
|
||||||
|
IF @PreviousID IS NULL
|
||||||
|
BEGIN
|
||||||
|
SELECT TOP (0) * FROM dbo.WholeBookPlotSynthesisRuns;
|
||||||
|
RETURN;
|
||||||
|
END;
|
||||||
|
|
||||||
|
UPDATE dbo.WholeBookPlotSynthesisRuns
|
||||||
|
SET IsCurrent = 0,
|
||||||
|
Status = N'Superseded',
|
||||||
|
UpdatedUtc = SYSUTCDATETIME()
|
||||||
|
WHERE WholeBookPlotSynthesisRunID = @PreviousID;
|
||||||
|
|
||||||
|
INSERT dbo.WholeBookPlotSynthesisRuns (ProjectID, BookID, UserID, SourceStoryIntelligenceRunID, CharacterResolutionVersion, Status)
|
||||||
|
VALUES (@ProjectID, @BookID, @UserID, @SourceRunID, @Version, N'Queued');
|
||||||
|
|
||||||
|
SELECT * FROM dbo.WholeBookPlotSynthesisRuns WHERE WholeBookPlotSynthesisRunID = CAST(SCOPE_IDENTITY() AS int);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.WholeBookPlotSynthesisRun_ClaimNext
|
||||||
|
@LeaseMinutes int = 90
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
|
||||||
|
DECLARE @RunID int;
|
||||||
|
|
||||||
|
SELECT TOP (1) @RunID = WholeBookPlotSynthesisRunID
|
||||||
|
FROM dbo.WholeBookPlotSynthesisRuns WITH (UPDLOCK, READPAST)
|
||||||
|
WHERE IsCurrent = 1
|
||||||
|
AND (
|
||||||
|
Status = N'Queued'
|
||||||
|
OR (Status = N'Running' AND StartedUtc < DATEADD(minute, -ABS(@LeaseMinutes), SYSUTCDATETIME()))
|
||||||
|
)
|
||||||
|
ORDER BY QueuedUtc, WholeBookPlotSynthesisRunID;
|
||||||
|
|
||||||
|
IF @RunID IS NULL
|
||||||
|
BEGIN
|
||||||
|
SELECT TOP (0) * FROM dbo.WholeBookPlotSynthesisRuns;
|
||||||
|
RETURN;
|
||||||
|
END;
|
||||||
|
|
||||||
|
UPDATE dbo.WholeBookPlotSynthesisRuns
|
||||||
|
SET Status = N'Running',
|
||||||
|
StartedUtc = SYSUTCDATETIME(),
|
||||||
|
AttemptCount = AttemptCount + 1,
|
||||||
|
UpdatedUtc = SYSUTCDATETIME()
|
||||||
|
WHERE WholeBookPlotSynthesisRunID = @RunID;
|
||||||
|
|
||||||
|
SELECT * FROM dbo.WholeBookPlotSynthesisRuns WHERE WholeBookPlotSynthesisRunID = @RunID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.WholeBookPlotSynthesisRun_GetCurrentByBook
|
||||||
|
@BookID int
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SELECT TOP (1) *
|
||||||
|
FROM dbo.WholeBookPlotSynthesisRuns
|
||||||
|
WHERE BookID = @BookID AND IsCurrent = 1
|
||||||
|
ORDER BY WholeBookPlotSynthesisRunID DESC;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.WholeBookPlotSynthesisRun_Complete
|
||||||
|
@WholeBookPlotSynthesisRunID int,
|
||||||
|
@Model nvarchar(100),
|
||||||
|
@OpenAIResponseID nvarchar(120) = NULL,
|
||||||
|
@InputTokens int = NULL,
|
||||||
|
@OutputTokens int = NULL,
|
||||||
|
@CachedInputTokens int = NULL,
|
||||||
|
@TotalTokens int = NULL,
|
||||||
|
@DurationMs bigint = NULL,
|
||||||
|
@ResultJson nvarchar(max)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
UPDATE dbo.WholeBookPlotSynthesisRuns
|
||||||
|
SET Status = N'Completed',
|
||||||
|
CompletedUtc = SYSUTCDATETIME(),
|
||||||
|
Model = @Model,
|
||||||
|
OpenAIResponseID = @OpenAIResponseID,
|
||||||
|
InputTokens = @InputTokens,
|
||||||
|
OutputTokens = @OutputTokens,
|
||||||
|
CachedInputTokens = @CachedInputTokens,
|
||||||
|
TotalTokens = @TotalTokens,
|
||||||
|
DurationMs = @DurationMs,
|
||||||
|
ResultJson = @ResultJson,
|
||||||
|
ErrorMessage = NULL,
|
||||||
|
ErrorDetail = NULL,
|
||||||
|
UpdatedUtc = SYSUTCDATETIME()
|
||||||
|
WHERE WholeBookPlotSynthesisRunID = @WholeBookPlotSynthesisRunID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.WholeBookPlotSynthesisRun_Fail
|
||||||
|
@WholeBookPlotSynthesisRunID int,
|
||||||
|
@ErrorMessage nvarchar(max),
|
||||||
|
@ErrorDetail nvarchar(max) = NULL,
|
||||||
|
@DurationMs bigint = NULL
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
UPDATE dbo.WholeBookPlotSynthesisRuns
|
||||||
|
SET Status = N'Failed',
|
||||||
|
FailedUtc = SYSUTCDATETIME(),
|
||||||
|
ErrorMessage = @ErrorMessage,
|
||||||
|
ErrorDetail = @ErrorDetail,
|
||||||
|
DurationMs = @DurationMs,
|
||||||
|
UpdatedUtc = SYSUTCDATETIME()
|
||||||
|
WHERE WholeBookPlotSynthesisRunID = @WholeBookPlotSynthesisRunID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.WholeBookPlotSynthesis_ContextGet
|
||||||
|
@ProjectID int,
|
||||||
|
@BookID int
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
SELECT BookTitle = b.BookTitle
|
||||||
|
FROM dbo.Books b
|
||||||
|
WHERE b.BookID = @BookID AND b.ProjectID = @ProjectID AND b.IsArchived = 0;
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
c.ChapterID,
|
||||||
|
c.ChapterNumber,
|
||||||
|
c.ChapterTitle,
|
||||||
|
s.SceneID,
|
||||||
|
s.SceneNumber,
|
||||||
|
s.SceneTitle,
|
||||||
|
s.StructuralSummary,
|
||||||
|
s.StructuralSetting,
|
||||||
|
pov.CharacterName AS PovCharacterName,
|
||||||
|
sms.SourceText
|
||||||
|
FROM dbo.Chapters c
|
||||||
|
INNER JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0
|
||||||
|
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
|
||||||
|
LEFT JOIN dbo.Characters pov ON pov.CharacterID = COALESCE(s.POVCharacterID, c.POVCharacterID)
|
||||||
|
WHERE c.BookID = @BookID AND c.IsArchived = 0
|
||||||
|
ORDER BY c.SortOrder, c.ChapterNumber, s.SortOrder, s.SceneNumber, s.SceneID;
|
||||||
|
|
||||||
|
SELECT CharacterID, CanonicalName = CharacterName
|
||||||
|
FROM dbo.Characters
|
||||||
|
WHERE ProjectID = @ProjectID AND IsArchived = 0
|
||||||
|
ORDER BY CharacterName;
|
||||||
|
|
||||||
|
SELECT alias.CharacterID, alias.Alias
|
||||||
|
FROM dbo.CharacterAliases alias
|
||||||
|
INNER JOIN dbo.Characters character ON character.CharacterID = alias.CharacterID
|
||||||
|
WHERE character.ProjectID = @ProjectID
|
||||||
|
AND character.IsArchived = 0
|
||||||
|
ORDER BY alias.CharacterID, alias.Alias;
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
pl.PlotLineID,
|
||||||
|
Name = pl.PlotLineName,
|
||||||
|
pl.Description,
|
||||||
|
Importance = pi.ImportanceName,
|
||||||
|
pl.BookID
|
||||||
|
FROM dbo.PlotLines pl
|
||||||
|
LEFT JOIN dbo.PlotImportance pi ON pi.PlotImportanceID = pl.PlotImportanceID
|
||||||
|
WHERE pl.ProjectID = @ProjectID AND pl.IsArchived = 0
|
||||||
|
ORDER BY pl.SortOrder, pl.PlotLineName;
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
pt.PlotThreadID,
|
||||||
|
pt.PlotLineID,
|
||||||
|
Title = pt.ThreadTitle,
|
||||||
|
pt.Summary,
|
||||||
|
Status = ts.StatusName
|
||||||
|
FROM dbo.PlotThreads pt
|
||||||
|
INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID
|
||||||
|
LEFT JOIN dbo.ThreadStatuses ts ON ts.ThreadStatusID = pt.ThreadStatusID
|
||||||
|
WHERE pl.ProjectID = @ProjectID
|
||||||
|
AND pl.IsArchived = 0
|
||||||
|
AND pt.IsArchived = 0
|
||||||
|
ORDER BY pt.PlotLineID, pt.Importance DESC, pt.ThreadTitle;
|
||||||
|
|
||||||
|
EXEC dbo.AuthorPlotSeed_ListByBook @BookID;
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
SceneCount = (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM dbo.Scenes s
|
||||||
|
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
|
||||||
|
WHERE c.BookID = @BookID AND c.IsArchived = 0 AND s.IsArchived = 0
|
||||||
|
),
|
||||||
|
SourceRowCount = (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM dbo.SceneManuscriptSources sms
|
||||||
|
INNER JOIN dbo.Scenes s ON s.SceneID = sms.SceneID
|
||||||
|
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
|
||||||
|
WHERE c.BookID = @BookID AND c.IsArchived = 0 AND s.IsArchived = 0
|
||||||
|
),
|
||||||
|
MissingSourceCount = (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM dbo.Scenes s
|
||||||
|
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
|
||||||
|
LEFT JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
|
||||||
|
WHERE c.BookID = @BookID AND c.IsArchived = 0 AND s.IsArchived = 0 AND sms.SceneID IS NULL
|
||||||
|
),
|
||||||
|
DuplicateSourceSceneCount = 0;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.WholeBookPlotSynthesisCandidate_Save
|
||||||
|
@WholeBookPlotSynthesisRunID int,
|
||||||
|
@CandidateType nvarchar(30),
|
||||||
|
@CandidateKey nvarchar(220),
|
||||||
|
@ProposedTitle nvarchar(200),
|
||||||
|
@ProposedDescription nvarchar(max) = NULL,
|
||||||
|
@Category nvarchar(100) = NULL,
|
||||||
|
@InferredState nvarchar(100) = NULL,
|
||||||
|
@ChangeType nvarchar(40) = NULL,
|
||||||
|
@Significance nvarchar(20) = NULL,
|
||||||
|
@ExistingPlotLineID int = NULL,
|
||||||
|
@ExistingPlotThreadID int = NULL,
|
||||||
|
@RelatedPlotLineID int = NULL,
|
||||||
|
@ParentCandidateKey nvarchar(220) = NULL,
|
||||||
|
@ParentNarrativeCandidateID int = NULL,
|
||||||
|
@AuthorPlotSeedID int = NULL,
|
||||||
|
@ExistingPlotLineMatchConfidence decimal(5,4) = NULL,
|
||||||
|
@ExistingPlotLineMatchReason nvarchar(max) = NULL,
|
||||||
|
@SeedMatchConfidence decimal(5,4) = NULL,
|
||||||
|
@SeedMatchReason nvarchar(max) = NULL,
|
||||||
|
@Confidence decimal(5,4) = NULL
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
DECLARE @ProjectID int, @BookID int, @NarrativeCandidateID int;
|
||||||
|
SELECT @ProjectID = ProjectID, @BookID = BookID
|
||||||
|
FROM dbo.WholeBookPlotSynthesisRuns
|
||||||
|
WHERE WholeBookPlotSynthesisRunID = @WholeBookPlotSynthesisRunID;
|
||||||
|
|
||||||
|
SELECT @NarrativeCandidateID = NarrativeCandidateID
|
||||||
|
FROM dbo.StoryIntelligenceNarrativeCandidates
|
||||||
|
WHERE BookID = @BookID AND CandidateType = @CandidateType AND CandidateKey = @CandidateKey;
|
||||||
|
|
||||||
|
IF @NarrativeCandidateID IS NULL
|
||||||
|
BEGIN
|
||||||
|
INSERT dbo.StoryIntelligenceNarrativeCandidates
|
||||||
|
(
|
||||||
|
ProjectID, BookID, CandidateType, CandidateKey, ProposedTitle, ProposedDescription, Category,
|
||||||
|
InferredState, ChangeType, Significance, ExistingPlotLineID, ExistingPlotThreadID, RelatedPlotLineID,
|
||||||
|
ParentCandidateKey, ParentNarrativeCandidateID, WholeBookPlotSynthesisRunID, AuthorPlotSeedID,
|
||||||
|
ExistingPlotLineMatchConfidence, ExistingPlotLineMatchReason, SeedMatchConfidence, SeedMatchReason,
|
||||||
|
Confidence, Source
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
@ProjectID, @BookID, @CandidateType, @CandidateKey, @ProposedTitle, @ProposedDescription, @Category,
|
||||||
|
@InferredState, @ChangeType, @Significance, @ExistingPlotLineID, @ExistingPlotThreadID, @RelatedPlotLineID,
|
||||||
|
@ParentCandidateKey, @ParentNarrativeCandidateID, @WholeBookPlotSynthesisRunID, @AuthorPlotSeedID,
|
||||||
|
@ExistingPlotLineMatchConfidence, @ExistingPlotLineMatchReason, @SeedMatchConfidence, @SeedMatchReason,
|
||||||
|
@Confidence, N'WholeBookPlotSynthesis'
|
||||||
|
);
|
||||||
|
SET @NarrativeCandidateID = CAST(SCOPE_IDENTITY() AS int);
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
BEGIN
|
||||||
|
UPDATE dbo.StoryIntelligenceNarrativeCandidates
|
||||||
|
SET ProposedTitle = @ProposedTitle,
|
||||||
|
ProposedDescription = @ProposedDescription,
|
||||||
|
Category = @Category,
|
||||||
|
InferredState = @InferredState,
|
||||||
|
ChangeType = @ChangeType,
|
||||||
|
Significance = @Significance,
|
||||||
|
ExistingPlotLineID = @ExistingPlotLineID,
|
||||||
|
ExistingPlotThreadID = @ExistingPlotThreadID,
|
||||||
|
RelatedPlotLineID = @RelatedPlotLineID,
|
||||||
|
ParentCandidateKey = @ParentCandidateKey,
|
||||||
|
ParentNarrativeCandidateID = @ParentNarrativeCandidateID,
|
||||||
|
WholeBookPlotSynthesisRunID = @WholeBookPlotSynthesisRunID,
|
||||||
|
AuthorPlotSeedID = @AuthorPlotSeedID,
|
||||||
|
ExistingPlotLineMatchConfidence = @ExistingPlotLineMatchConfidence,
|
||||||
|
ExistingPlotLineMatchReason = @ExistingPlotLineMatchReason,
|
||||||
|
SeedMatchConfidence = @SeedMatchConfidence,
|
||||||
|
SeedMatchReason = @SeedMatchReason,
|
||||||
|
Confidence = @Confidence,
|
||||||
|
Source = N'WholeBookPlotSynthesis',
|
||||||
|
UpdatedUtc = SYSUTCDATETIME()
|
||||||
|
WHERE NarrativeCandidateID = @NarrativeCandidateID
|
||||||
|
AND Status = N'Pending';
|
||||||
|
END;
|
||||||
|
|
||||||
|
SELECT @NarrativeCandidateID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.WholeBookPlotSynthesisCandidateEvent_Save
|
||||||
|
@NarrativeCandidateID int,
|
||||||
|
@SynthesisRunID int,
|
||||||
|
@EventKey nvarchar(260),
|
||||||
|
@SceneID int,
|
||||||
|
@EventType nvarchar(100),
|
||||||
|
@EventTitle nvarchar(200),
|
||||||
|
@EventDescription nvarchar(max) = NULL,
|
||||||
|
@Evidence nvarchar(max) = NULL,
|
||||||
|
@Confidence decimal(5,4) = NULL,
|
||||||
|
@SortOrder int = 0
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
MERGE dbo.StoryIntelligenceNarrativeCandidateEvents AS target
|
||||||
|
USING (SELECT @NarrativeCandidateID AS NarrativeCandidateID, @EventKey AS EventKey) AS source
|
||||||
|
ON target.NarrativeCandidateID = source.NarrativeCandidateID AND target.EventKey = source.EventKey
|
||||||
|
WHEN MATCHED THEN
|
||||||
|
UPDATE SET WholeBookPlotSynthesisRunID = @SynthesisRunID,
|
||||||
|
SceneID = @SceneID,
|
||||||
|
EventType = @EventType,
|
||||||
|
EventTitle = @EventTitle,
|
||||||
|
EventDescription = @EventDescription,
|
||||||
|
Evidence = @Evidence,
|
||||||
|
Confidence = @Confidence,
|
||||||
|
SortOrder = @SortOrder,
|
||||||
|
UpdatedUtc = SYSUTCDATETIME()
|
||||||
|
WHEN NOT MATCHED THEN
|
||||||
|
INSERT (NarrativeCandidateID, WholeBookPlotSynthesisRunID, EventKey, SceneID, EventType, EventTitle, EventDescription, Evidence, Confidence, SortOrder)
|
||||||
|
VALUES (@NarrativeCandidateID, @SynthesisRunID, @EventKey, @SceneID, @EventType, @EventTitle, @EventDescription, @Evidence, @Confidence, @SortOrder);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceNarrativeCandidate_Save
|
||||||
|
@ProjectID int,
|
||||||
|
@BookID int,
|
||||||
|
@CandidateType nvarchar(30),
|
||||||
|
@CandidateKey nvarchar(220),
|
||||||
|
@ProposedTitle nvarchar(200),
|
||||||
|
@ProposedDescription nvarchar(max) = NULL,
|
||||||
|
@Category nvarchar(100) = NULL,
|
||||||
|
@InferredState nvarchar(100) = NULL,
|
||||||
|
@ChangeType nvarchar(40) = NULL,
|
||||||
|
@Significance nvarchar(20) = NULL,
|
||||||
|
@ExistingPlotLineID int = NULL,
|
||||||
|
@ExistingPlotThreadID int = NULL,
|
||||||
|
@WholeBookPlotSynthesisRunID int = NULL,
|
||||||
|
@AuthorPlotSeedID int = NULL,
|
||||||
|
@ExistingPlotLineMatchConfidence decimal(5,4) = NULL,
|
||||||
|
@ExistingPlotLineMatchReason nvarchar(max) = NULL,
|
||||||
|
@SeedMatchConfidence decimal(5,4) = NULL,
|
||||||
|
@SeedMatchReason nvarchar(max) = NULL,
|
||||||
|
@Source nvarchar(60) = NULL,
|
||||||
|
@RelatedPlotLineID int = NULL,
|
||||||
|
@ParentCandidateKey nvarchar(220) = NULL,
|
||||||
|
@ParentNarrativeCandidateID int = NULL,
|
||||||
|
@Confidence decimal(5,4) = NULL
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
DECLARE @NarrativeCandidateID int;
|
||||||
|
|
||||||
|
SELECT @NarrativeCandidateID = NarrativeCandidateID
|
||||||
|
FROM dbo.StoryIntelligenceNarrativeCandidates
|
||||||
|
WHERE BookID = @BookID AND CandidateType = @CandidateType AND CandidateKey = @CandidateKey;
|
||||||
|
|
||||||
|
IF @ParentNarrativeCandidateID IS NULL AND @ParentCandidateKey IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
SELECT @ParentNarrativeCandidateID = NarrativeCandidateID
|
||||||
|
FROM dbo.StoryIntelligenceNarrativeCandidates
|
||||||
|
WHERE BookID = @BookID
|
||||||
|
AND CandidateType = N'PlotLine'
|
||||||
|
AND CandidateKey = @ParentCandidateKey;
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF @NarrativeCandidateID IS NULL
|
||||||
|
BEGIN
|
||||||
|
INSERT dbo.StoryIntelligenceNarrativeCandidates
|
||||||
|
(ProjectID, BookID, CandidateType, CandidateKey, ProposedTitle, ProposedDescription, Category, InferredState,
|
||||||
|
ChangeType, Significance, ExistingPlotLineID, ExistingPlotThreadID, RelatedPlotLineID, ParentCandidateKey,
|
||||||
|
ParentNarrativeCandidateID, WholeBookPlotSynthesisRunID, AuthorPlotSeedID, ExistingPlotLineMatchConfidence,
|
||||||
|
ExistingPlotLineMatchReason, SeedMatchConfidence, SeedMatchReason, Confidence, Source)
|
||||||
|
VALUES
|
||||||
|
(@ProjectID, @BookID, @CandidateType, @CandidateKey, @ProposedTitle, @ProposedDescription, @Category, @InferredState,
|
||||||
|
@ChangeType, @Significance, @ExistingPlotLineID, @ExistingPlotThreadID, @RelatedPlotLineID, @ParentCandidateKey,
|
||||||
|
@ParentNarrativeCandidateID, @WholeBookPlotSynthesisRunID, @AuthorPlotSeedID, @ExistingPlotLineMatchConfidence,
|
||||||
|
@ExistingPlotLineMatchReason, @SeedMatchConfidence, @SeedMatchReason, @Confidence, COALESCE(@Source, N'SceneSignals'));
|
||||||
|
SET @NarrativeCandidateID = CAST(SCOPE_IDENTITY() AS int);
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
BEGIN
|
||||||
|
UPDATE dbo.StoryIntelligenceNarrativeCandidates
|
||||||
|
SET ProposedTitle = @ProposedTitle,
|
||||||
|
ProposedDescription = @ProposedDescription,
|
||||||
|
Category = @Category,
|
||||||
|
InferredState = @InferredState,
|
||||||
|
ChangeType = @ChangeType,
|
||||||
|
Significance = @Significance,
|
||||||
|
ExistingPlotLineID = @ExistingPlotLineID,
|
||||||
|
ExistingPlotThreadID = @ExistingPlotThreadID,
|
||||||
|
RelatedPlotLineID = @RelatedPlotLineID,
|
||||||
|
ParentCandidateKey = @ParentCandidateKey,
|
||||||
|
ParentNarrativeCandidateID = @ParentNarrativeCandidateID,
|
||||||
|
WholeBookPlotSynthesisRunID = @WholeBookPlotSynthesisRunID,
|
||||||
|
AuthorPlotSeedID = @AuthorPlotSeedID,
|
||||||
|
ExistingPlotLineMatchConfidence = @ExistingPlotLineMatchConfidence,
|
||||||
|
ExistingPlotLineMatchReason = @ExistingPlotLineMatchReason,
|
||||||
|
SeedMatchConfidence = @SeedMatchConfidence,
|
||||||
|
SeedMatchReason = @SeedMatchReason,
|
||||||
|
Confidence = @Confidence,
|
||||||
|
Source = COALESCE(@Source, Source, N'SceneSignals'),
|
||||||
|
UpdatedUtc = SYSUTCDATETIME()
|
||||||
|
WHERE NarrativeCandidateID = @NarrativeCandidateID
|
||||||
|
AND Status = N'Pending';
|
||||||
|
END;
|
||||||
|
|
||||||
|
SELECT @NarrativeCandidateID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceNarrativeCandidate_ListByBook
|
||||||
|
@BookID int
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
SELECT nc.NarrativeCandidateID, nc.ProjectID, nc.BookID, nc.CandidateType, nc.CandidateKey,
|
||||||
|
nc.ProposedTitle, nc.ProposedDescription, nc.Category, nc.InferredState, nc.ChangeType, nc.Significance,
|
||||||
|
nc.ExistingPlotLineID, epl.PlotLineName AS ExistingPlotLineName,
|
||||||
|
nc.ExistingPlotThreadID, ept.ThreadTitle AS ExistingPlotThreadTitle,
|
||||||
|
nc.WholeBookPlotSynthesisRunID, nc.AuthorPlotSeedID, nc.ExistingPlotLineMatchConfidence,
|
||||||
|
nc.ExistingPlotLineMatchReason, nc.SeedMatchConfidence, nc.SeedMatchReason, nc.Source,
|
||||||
|
nc.RelatedPlotLineID, rpl.PlotLineName AS RelatedPlotLineName,
|
||||||
|
nc.ParentCandidateKey, nc.ParentNarrativeCandidateID, parent.ProposedTitle AS ParentCandidateTitle,
|
||||||
|
nc.MergeTargetNarrativeCandidateID,
|
||||||
|
nc.Confidence, nc.Status, nc.ReviewAction, nc.AcceptedPlotLineID, apl.PlotLineName AS AcceptedPlotLineName,
|
||||||
|
nc.AcceptedPlotThreadID, apt.ThreadTitle AS AcceptedPlotThreadTitle, nc.ReviewedByUserID, nc.ReviewedUtc,
|
||||||
|
nc.CreatedUtc, nc.UpdatedUtc
|
||||||
|
FROM dbo.StoryIntelligenceNarrativeCandidates nc
|
||||||
|
LEFT JOIN dbo.PlotLines epl ON epl.PlotLineID = nc.ExistingPlotLineID
|
||||||
|
LEFT JOIN dbo.PlotThreads ept ON ept.PlotThreadID = nc.ExistingPlotThreadID
|
||||||
|
LEFT JOIN dbo.PlotLines rpl ON rpl.PlotLineID = nc.RelatedPlotLineID
|
||||||
|
LEFT JOIN dbo.StoryIntelligenceNarrativeCandidates parent ON parent.NarrativeCandidateID = nc.ParentNarrativeCandidateID
|
||||||
|
LEFT JOIN dbo.PlotLines apl ON apl.PlotLineID = nc.AcceptedPlotLineID
|
||||||
|
LEFT JOIN dbo.PlotThreads apt ON apt.PlotThreadID = nc.AcceptedPlotThreadID
|
||||||
|
WHERE nc.BookID = @BookID
|
||||||
|
ORDER BY CASE nc.CandidateType WHEN N'PlotLine' THEN 0 ELSE 1 END, nc.ProposedTitle;
|
||||||
|
|
||||||
|
SELECT e.NarrativeCandidateEvidenceID, e.NarrativeCandidateID, e.SceneID,
|
||||||
|
s.SceneNumber, s.SceneTitle, c.ChapterNumber, c.ChapterTitle,
|
||||||
|
e.ContributionType, e.Evidence, e.SortOrder, e.CreatedUtc
|
||||||
|
FROM dbo.StoryIntelligenceNarrativeCandidateEvidence e
|
||||||
|
INNER JOIN dbo.StoryIntelligenceNarrativeCandidates nc ON nc.NarrativeCandidateID = e.NarrativeCandidateID
|
||||||
|
INNER JOIN dbo.Scenes s ON s.SceneID = e.SceneID
|
||||||
|
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
|
||||||
|
WHERE nc.BookID = @BookID
|
||||||
|
ORDER BY e.NarrativeCandidateID, c.SortOrder, s.SortOrder, e.SortOrder;
|
||||||
|
|
||||||
|
SELECT ev.NarrativeCandidateEventID, ev.NarrativeCandidateID, ev.WholeBookPlotSynthesisRunID, ev.EventKey,
|
||||||
|
ev.SceneID, s.SceneNumber, s.SceneTitle, c.ChapterNumber, c.ChapterTitle,
|
||||||
|
ev.EventType, ev.EventTitle, ev.EventDescription, ev.Evidence, ev.Confidence, ev.SortOrder,
|
||||||
|
ev.IsIgnored, ev.AcceptedThreadEventID, ev.CreatedUtc, ev.UpdatedUtc
|
||||||
|
FROM dbo.StoryIntelligenceNarrativeCandidateEvents ev
|
||||||
|
INNER JOIN dbo.StoryIntelligenceNarrativeCandidates nc ON nc.NarrativeCandidateID = ev.NarrativeCandidateID
|
||||||
|
INNER JOIN dbo.Scenes s ON s.SceneID = ev.SceneID
|
||||||
|
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
|
||||||
|
WHERE nc.BookID = @BookID
|
||||||
|
ORDER BY ev.NarrativeCandidateID, c.SortOrder, s.SortOrder, ev.SortOrder;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.ThreadEvent_Save
|
||||||
|
@ThreadEventID int = NULL,
|
||||||
|
@PlotThreadID int,
|
||||||
|
@SceneID int,
|
||||||
|
@EventTypeID int,
|
||||||
|
@PlotEventTypeID int = 2,
|
||||||
|
@TargetPlotLineID int = NULL,
|
||||||
|
@EventTitle nvarchar(200),
|
||||||
|
@EventDescription nvarchar(max) = NULL,
|
||||||
|
@Source nvarchar(60) = N'Author',
|
||||||
|
@SourceReferenceID int = NULL,
|
||||||
|
@SourceKey nvarchar(260) = NULL,
|
||||||
|
@Confidence decimal(5,4) = NULL
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
IF (@ThreadEventID IS NULL OR @ThreadEventID = 0) AND @SourceKey IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
SELECT @ThreadEventID = ThreadEventID
|
||||||
|
FROM dbo.ThreadEvents
|
||||||
|
WHERE Source = @Source AND SourceKey = @SourceKey;
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF @ThreadEventID IS NULL OR @ThreadEventID = 0
|
||||||
|
BEGIN
|
||||||
|
INSERT dbo.ThreadEvents (PlotThreadID, SceneID, EventTypeID, PlotEventTypeID, TargetPlotLineID, EventTitle, EventDescription, Source, SourceReferenceID, SourceKey, Confidence)
|
||||||
|
VALUES (@PlotThreadID, @SceneID, @EventTypeID, @PlotEventTypeID, @TargetPlotLineID, @EventTitle, @EventDescription, @Source, @SourceReferenceID, @SourceKey, @Confidence);
|
||||||
|
SET @ThreadEventID = CAST(SCOPE_IDENTITY() AS int);
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
BEGIN
|
||||||
|
UPDATE dbo.ThreadEvents
|
||||||
|
SET PlotThreadID = @PlotThreadID,
|
||||||
|
SceneID = @SceneID,
|
||||||
|
EventTypeID = @EventTypeID,
|
||||||
|
PlotEventTypeID = @PlotEventTypeID,
|
||||||
|
TargetPlotLineID = @TargetPlotLineID,
|
||||||
|
EventTitle = @EventTitle,
|
||||||
|
EventDescription = @EventDescription,
|
||||||
|
Source = @Source,
|
||||||
|
SourceReferenceID = @SourceReferenceID,
|
||||||
|
SourceKey = @SourceKey,
|
||||||
|
Confidence = @Confidence,
|
||||||
|
UpdatedDate = SYSUTCDATETIME()
|
||||||
|
WHERE ThreadEventID = @ThreadEventID
|
||||||
|
AND (Source <> N'Author' OR @Source = N'Author');
|
||||||
|
END;
|
||||||
|
|
||||||
|
DECLARE @EventTypeName nvarchar(100);
|
||||||
|
SELECT @EventTypeName = TypeName FROM dbo.ThreadEventTypes WHERE ThreadEventTypeID = @EventTypeID;
|
||||||
|
|
||||||
|
IF @EventTypeName = N'Introduced'
|
||||||
|
BEGIN
|
||||||
|
UPDATE pt
|
||||||
|
SET IntroducedSceneID = COALESCE(IntroducedSceneID, @SceneID),
|
||||||
|
ThreadStatusID = CASE WHEN ts.StatusName = N'Planned' THEN introduced.ThreadStatusID ELSE pt.ThreadStatusID END,
|
||||||
|
UpdatedDate = SYSUTCDATETIME()
|
||||||
|
FROM dbo.PlotThreads pt
|
||||||
|
INNER JOIN dbo.ThreadStatuses ts ON ts.ThreadStatusID = pt.ThreadStatusID
|
||||||
|
CROSS APPLY (SELECT ThreadStatusID FROM dbo.ThreadStatuses WHERE StatusName = N'Introduced') introduced
|
||||||
|
WHERE pt.PlotThreadID = @PlotThreadID;
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF @EventTypeName IN (N'Resolved', N'Payoff')
|
||||||
|
BEGIN
|
||||||
|
UPDATE pt
|
||||||
|
SET ActualResolutionSceneID = COALESCE(ActualResolutionSceneID, @SceneID),
|
||||||
|
ThreadStatusID = CASE WHEN @EventTypeName = N'Resolved' THEN resolved.ThreadStatusID ELSE pt.ThreadStatusID END,
|
||||||
|
UpdatedDate = SYSUTCDATETIME()
|
||||||
|
FROM dbo.PlotThreads pt
|
||||||
|
CROSS APPLY (SELECT ThreadStatusID FROM dbo.ThreadStatuses WHERE StatusName = N'Resolved') resolved
|
||||||
|
WHERE pt.PlotThreadID = @PlotThreadID;
|
||||||
|
END;
|
||||||
|
|
||||||
|
SELECT @ThreadEventID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
@ -143,6 +143,21 @@ public sealed class StoryIntelligenceOverviewViewModel
|
|||||||
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
|
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceAuthorPlotContextViewModel
|
||||||
|
{
|
||||||
|
public int ProjectID { get; init; }
|
||||||
|
public int BookID { get; init; }
|
||||||
|
public string BookTitle { get; init; } = string.Empty;
|
||||||
|
public IReadOnlyList<string> Descriptions { get; init; } = ["", "", ""];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceAuthorPlotContextForm
|
||||||
|
{
|
||||||
|
public int ProjectID { get; set; }
|
||||||
|
public int BookID { get; set; }
|
||||||
|
public List<string> PlotSeeds { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceProgressViewModel
|
public sealed class StoryIntelligenceProgressViewModel
|
||||||
{
|
{
|
||||||
public StoryIntelligenceJobProgress Job { get; init; } = new();
|
public StoryIntelligenceJobProgress Job { get; init; } = new();
|
||||||
@ -160,6 +175,7 @@ public sealed class StoryIntelligenceProgressViewModel
|
|||||||
public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; init; } = new();
|
public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; init; } = new();
|
||||||
public StoryIntelligenceKnowledgeReviewViewModel KnowledgeReview { get; init; } = new();
|
public StoryIntelligenceKnowledgeReviewViewModel KnowledgeReview { get; init; } = new();
|
||||||
public StoryIntelligenceNarrativeReviewViewModel NarrativeReview { get; init; } = new();
|
public StoryIntelligenceNarrativeReviewViewModel NarrativeReview { get; init; } = new();
|
||||||
|
public WholeBookPlotSynthesisStatusViewModel PlotSynthesis { get; init; } = new();
|
||||||
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
|
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
|
||||||
public int ChapterCount => Chapters.Count;
|
public int ChapterCount => Chapters.Count;
|
||||||
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
|
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
|
||||||
@ -200,6 +216,20 @@ public sealed class StoryIntelligenceProgressViewModel
|
|||||||
+ AnalysisIssueCount;
|
+ AnalysisIssueCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class WholeBookPlotSynthesisStatusViewModel
|
||||||
|
{
|
||||||
|
public int? WholeBookPlotSynthesisRunID { get; init; }
|
||||||
|
public string Status { get; init; } = string.Empty;
|
||||||
|
public int CandidateCount { get; init; }
|
||||||
|
public int ThreadCount { get; init; }
|
||||||
|
public int EventCount { get; init; }
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
public bool IsQueued => string.Equals(Status, WholeBookPlotSynthesisStatuses.Queued, StringComparison.OrdinalIgnoreCase);
|
||||||
|
public bool IsRunning => string.Equals(Status, WholeBookPlotSynthesisStatuses.Running, StringComparison.OrdinalIgnoreCase);
|
||||||
|
public bool IsCompleted => string.Equals(Status, WholeBookPlotSynthesisStatuses.Completed, StringComparison.OrdinalIgnoreCase);
|
||||||
|
public bool IsFailed => string.Equals(Status, WholeBookPlotSynthesisStatuses.Failed, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligencePipelineDashboardViewModel
|
public sealed class StoryIntelligencePipelineDashboardViewModel
|
||||||
{
|
{
|
||||||
public int ChaptersAnalysed { get; init; }
|
public int ChaptersAnalysed { get; init; }
|
||||||
@ -249,6 +279,10 @@ public sealed class StoryIntelligenceNarrativeReviewCandidateViewModel
|
|||||||
public string Significance { get; init; } = string.Empty;
|
public string Significance { get; init; } = string.Empty;
|
||||||
public string Confidence { get; init; } = "Unknown";
|
public string Confidence { get; init; } = "Unknown";
|
||||||
public int? ExistingPlotLineID { get; init; }
|
public int? ExistingPlotLineID { get; init; }
|
||||||
|
public int? AuthorPlotSeedID { get; init; }
|
||||||
|
public string Source { get; init; } = string.Empty;
|
||||||
|
public string? ExistingPlotLineMatchReason { get; init; }
|
||||||
|
public string? SeedMatchReason { get; init; }
|
||||||
public int? RelatedPlotLineID { get; init; }
|
public int? RelatedPlotLineID { get; init; }
|
||||||
public string? RelatedPlotLineName { get; init; }
|
public string? RelatedPlotLineName { get; init; }
|
||||||
public int? ParentNarrativeCandidateID { get; init; }
|
public int? ParentNarrativeCandidateID { get; init; }
|
||||||
@ -256,6 +290,19 @@ public sealed class StoryIntelligenceNarrativeReviewCandidateViewModel
|
|||||||
public IReadOnlyList<StoryIntelligenceNarrativeReviewCandidateViewModel> ChildThreads { get; init; } = [];
|
public IReadOnlyList<StoryIntelligenceNarrativeReviewCandidateViewModel> ChildThreads { get; init; } = [];
|
||||||
public string DefaultAction { get; init; } = StoryIntelligenceNarrativeReviewActions.AcceptNew;
|
public string DefaultAction { get; init; } = StoryIntelligenceNarrativeReviewActions.AcceptNew;
|
||||||
public IReadOnlyList<StoryIntelligenceNarrativeEvidenceViewModel> Evidence { get; init; } = [];
|
public IReadOnlyList<StoryIntelligenceNarrativeEvidenceViewModel> Evidence { get; init; } = [];
|
||||||
|
public IReadOnlyList<StoryIntelligenceNarrativeEventViewModel> Events { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceNarrativeEventViewModel
|
||||||
|
{
|
||||||
|
public int NarrativeCandidateEventID { get; init; }
|
||||||
|
public int SceneID { get; init; }
|
||||||
|
public string SceneLabel { get; init; } = string.Empty;
|
||||||
|
public string EventType { get; init; } = string.Empty;
|
||||||
|
public string EventTitle { get; init; } = string.Empty;
|
||||||
|
public string EventDescription { get; init; } = string.Empty;
|
||||||
|
public string Evidence { get; init; } = string.Empty;
|
||||||
|
public string Confidence { get; init; } = "Unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceNarrativeEvidenceViewModel
|
public sealed class StoryIntelligenceNarrativeEvidenceViewModel
|
||||||
@ -283,6 +330,16 @@ public sealed class StoryIntelligenceNarrativeImportChoiceForm
|
|||||||
public int? RelatedPlotLineID { get; set; }
|
public int? RelatedPlotLineID { get; set; }
|
||||||
public int? ParentNarrativeCandidateID { get; set; }
|
public int? ParentNarrativeCandidateID { get; set; }
|
||||||
public int? MergeTargetNarrativeCandidateID { get; set; }
|
public int? MergeTargetNarrativeCandidateID { get; set; }
|
||||||
|
public List<StoryIntelligenceNarrativeEventChoiceForm> Events { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceNarrativeEventChoiceForm
|
||||||
|
{
|
||||||
|
public int NarrativeCandidateEventID { get; set; }
|
||||||
|
public bool Include { get; set; } = true;
|
||||||
|
public string EventType { get; set; } = string.Empty;
|
||||||
|
public string EventTitle { get; set; } = string.Empty;
|
||||||
|
public string? EventDescription { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceNarrativeImportResultViewModel
|
public sealed class StoryIntelligenceNarrativeImportResultViewModel
|
||||||
|
|||||||
@ -75,9 +75,11 @@
|
|||||||
</div>
|
</div>
|
||||||
@if (pipeline is null)
|
@if (pipeline is null)
|
||||||
{
|
{
|
||||||
<form asp-controller="Onboarding" asp-action="StartBookStoryIntelligence" asp-route-bookId="@Model.Book.BookID" method="post">
|
<a class="btn btn-primary @(action.CanStart ? string.Empty : "disabled")"
|
||||||
<button class="btn btn-primary" type="submit" disabled="@(!action.CanStart)">@action.ActionLabel</button>
|
aria-disabled="@(!action.CanStart)"
|
||||||
</form>
|
asp-controller="Onboarding"
|
||||||
|
asp-action="StoryIntelligenceBookPlotContext"
|
||||||
|
asp-route-bookId="@Model.Book.BookID">@action.ActionLabel</a>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@ -111,9 +111,7 @@
|
|||||||
<div class="onboarding-actions">
|
<div class="onboarding-actions">
|
||||||
@if (coreComplete)
|
@if (coreComplete)
|
||||||
{
|
{
|
||||||
<form asp-controller="Onboarding" asp-action="StartStoryIntelligence" method="post">
|
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligencePlotContext">Analyse my book</a>
|
||||||
<button class="btn btn-primary" type="submit">Analyse my book</button>
|
|
||||||
</form>
|
|
||||||
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Not now</a>
|
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Not now</a>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@ -0,0 +1,69 @@
|
|||||||
|
@model StoryIntelligenceAuthorPlotContextViewModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Help PlotDirector understand your story";
|
||||||
|
var descriptions = Model.Descriptions.Count >= 3
|
||||||
|
? Model.Descriptions.ToList()
|
||||||
|
: Model.Descriptions.Concat(Enumerable.Repeat(string.Empty, 3 - Model.Descriptions.Count)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="onboarding-shell" aria-labelledby="story-plot-context-title">
|
||||||
|
<div class="onboarding-panel onboarding-review-panel">
|
||||||
|
<div class="onboarding-copy">
|
||||||
|
<p class="eyebrow">Story Intelligence</p>
|
||||||
|
<h1 id="story-plot-context-title">Help PlotDirector understand your story</h1>
|
||||||
|
<p>Plot lines are the major stories, mysteries, conflicts and character arcs that develop through your book or series.</p>
|
||||||
|
<p>If you already know some important plot lines, you can tell PlotDirector about them here. A sentence or two is enough. PlotDirector will still analyse the manuscript and try to discover other plot lines for you.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form asp-action="StartStoryIntelligenceWithPlotContext" method="post" class="story-plot-context-form" data-plot-context-form>
|
||||||
|
<input type="hidden" name="ProjectID" value="@Model.ProjectID" />
|
||||||
|
<input type="hidden" name="BookID" value="@Model.BookID" />
|
||||||
|
|
||||||
|
<div class="story-plot-context-list" data-plot-context-list>
|
||||||
|
@for (var i = 0; i < descriptions.Count; i++)
|
||||||
|
{
|
||||||
|
<div class="story-plot-context-item">
|
||||||
|
<label for="plot-seed-@i">Plot line @((i + 1).ToString("N0"))</label>
|
||||||
|
<textarea id="plot-seed-@i"
|
||||||
|
class="form-control"
|
||||||
|
name="PlotSeeds[@i]"
|
||||||
|
rows="4"
|
||||||
|
maxlength="4000"
|
||||||
|
placeholder="Example: A chance meeting between two characters appears accidental, but was secretly arranged by another character to manipulate one of them. The manipulation continues throughout the story.">@descriptions[i]</textarea>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-outline-primary btn-sm" type="button" data-add-plot-context>+ Add another plot line</button>
|
||||||
|
|
||||||
|
<div class="onboarding-actions">
|
||||||
|
<button class="btn btn-primary" type="submit">Analyse my book</button>
|
||||||
|
<a class="btn btn-outline-secondary" asp-controller="Books" asp-action="Details" asp-route-id="@Model.BookID">Not now</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const list = document.querySelector("[data-plot-context-list]");
|
||||||
|
const add = document.querySelector("[data-add-plot-context]");
|
||||||
|
if (!list || !add) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
add.addEventListener("click", () => {
|
||||||
|
const index = list.querySelectorAll(".story-plot-context-item").length;
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "story-plot-context-item";
|
||||||
|
wrapper.innerHTML = `
|
||||||
|
<label for="plot-seed-${index}">Plot line ${index + 1}</label>
|
||||||
|
<textarea id="plot-seed-${index}" class="form-control" name="PlotSeeds[${index}]" rows="4" maxlength="4000"></textarea>
|
||||||
|
`;
|
||||||
|
list.appendChild(wrapper);
|
||||||
|
wrapper.querySelector("textarea")?.focus();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
}
|
||||||
@ -7,6 +7,11 @@
|
|||||||
? Model.NarrativeReview.Candidates.ToList()
|
? Model.NarrativeReview.Candidates.ToList()
|
||||||
: Model.NarrativeReview.Candidates.Take(previewLimit).ToList();
|
: Model.NarrativeReview.Candidates.Take(previewLimit).ToList();
|
||||||
var hiddenCandidateCount = Math.Max(0, Model.NarrativeReview.Candidates.Count - narrativeCandidates.Count);
|
var hiddenCandidateCount = Math.Max(0, Model.NarrativeReview.Candidates.Count - narrativeCandidates.Count);
|
||||||
|
var eventTypes = new[]
|
||||||
|
{
|
||||||
|
"Introduced", "Mentioned", "Developed", "Complicated", "Clue Planted", "Question Raised", "Question Answered",
|
||||||
|
"False Lead", "Revealed", "Payoff", "Resolved", "Contradicted", "Retconned", "Note"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="onboarding-shell" aria-labelledby="story-narrative-title">
|
<section class="onboarding-shell" aria-labelledby="story-narrative-title">
|
||||||
@ -137,6 +142,49 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (candidate.Events.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="story-review-note">
|
||||||
|
<strong>Proposed timeline events</strong>
|
||||||
|
@for (var e = 0; e < candidate.Events.Count; e++)
|
||||||
|
{
|
||||||
|
var item = candidate.Events[e];
|
||||||
|
<div class="story-review-event">
|
||||||
|
<input type="hidden" name="Candidates[@i].Events[@e].NarrativeCandidateEventID" value="@item.NarrativeCandidateEventID" />
|
||||||
|
<input type="hidden" name="Candidates[@i].Events[@e].Include" value="false" />
|
||||||
|
<label class="story-review-event__include">
|
||||||
|
<input type="checkbox" name="Candidates[@i].Events[@e].Include" value="true" checked />
|
||||||
|
<span>@item.SceneLabel</span>
|
||||||
|
</label>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label>Event type</label>
|
||||||
|
<select class="form-select" name="Candidates[@i].Events[@e].EventType">
|
||||||
|
@foreach (var eventType in eventTypes)
|
||||||
|
{
|
||||||
|
<option value="@eventType" selected="@(string.Equals(item.EventType, eventType, StringComparison.OrdinalIgnoreCase))">@eventType</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label>Title</label>
|
||||||
|
<input class="form-control" name="Candidates[@i].Events[@e].EventTitle" value="@item.EventTitle" maxlength="200" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea class="form-control" name="Candidates[@i].Events[@e].EventDescription" rows="2">@item.EventDescription</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(item.Evidence))
|
||||||
|
{
|
||||||
|
<p class="text-muted mb-0">@item.Evidence</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
<div class="story-review-note">
|
<div class="story-review-note">
|
||||||
<strong>Supporting scenes</strong>
|
<strong>Supporting scenes</strong>
|
||||||
<ul>
|
<ul>
|
||||||
@ -146,6 +194,7 @@
|
|||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,14 +60,7 @@
|
|||||||
ModuleAction("Locations", Model.LocationNeedsAttentionCount, Model.PipelineDashboard.LocationStageComplete),
|
ModuleAction("Locations", Model.LocationNeedsAttentionCount, Model.PipelineDashboard.LocationStageComplete),
|
||||||
"StoryIntelligenceLocations")
|
"StoryIntelligenceLocations")
|
||||||
|
|
||||||
@ReviewModule(
|
@ReviewPlotSynthesis()
|
||||||
"Plot Lines & Threads",
|
|
||||||
Model.NarrativeNeedsAttentionCount,
|
|
||||||
Model.PipelineDashboard.NarrativeStageComplete,
|
|
||||||
!Model.CharacterGateSatisfied,
|
|
||||||
"Recurring arcs and threads discovered from scene evidence.",
|
|
||||||
ModuleAction("Plot Lines & Threads", Model.NarrativeNeedsAttentionCount, Model.PipelineDashboard.NarrativeStageComplete),
|
|
||||||
"StoryIntelligenceNarrative")
|
|
||||||
|
|
||||||
@ReviewModule(
|
@ReviewModule(
|
||||||
"Relationships",
|
"Relationships",
|
||||||
@ -104,6 +97,77 @@
|
|||||||
private string ModuleAction(string title, int count, bool complete)
|
private string ModuleAction(string title, int count, bool complete)
|
||||||
=> count > 0 && !complete ? $"Review {title}" : $"View {title}";
|
=> count > 0 && !complete ? $"Review {title}" : $"View {title}";
|
||||||
|
|
||||||
|
private Microsoft.AspNetCore.Html.IHtmlContent ReviewPlotSynthesis()
|
||||||
|
{
|
||||||
|
var blocked = !Model.CharacterGateSatisfied;
|
||||||
|
var status = "Preparing analysis";
|
||||||
|
var statusClass = "story-review-centre-tile__status story-review-centre-tile__status--locked";
|
||||||
|
var description = "Whole-book plot synthesis starts after Characters are resolved.";
|
||||||
|
var actionHtml = "<span class=\"btn btn-outline-secondary btn-sm disabled\" aria-disabled=\"true\">Locked</span>";
|
||||||
|
|
||||||
|
if (!blocked)
|
||||||
|
{
|
||||||
|
statusClass = "story-review-centre-tile__status story-review-centre-tile__status--ready";
|
||||||
|
if (Model.PlotSynthesis.IsQueued)
|
||||||
|
{
|
||||||
|
status = "Preparing";
|
||||||
|
description = "PlotDirector is preparing the whole-book plot synthesis.";
|
||||||
|
actionHtml = "<span class=\"btn btn-outline-primary btn-sm disabled\" aria-disabled=\"true\">Preparing</span>";
|
||||||
|
}
|
||||||
|
else if (Model.PlotSynthesis.IsRunning)
|
||||||
|
{
|
||||||
|
status = "Analysing";
|
||||||
|
description = "PlotDirector is reading the full book to find sustained Plot Lines, Threads, and scene events.";
|
||||||
|
actionHtml = "<span class=\"btn btn-outline-primary btn-sm disabled\" aria-disabled=\"true\">Analysing</span>";
|
||||||
|
}
|
||||||
|
else if (Model.PlotSynthesis.IsFailed)
|
||||||
|
{
|
||||||
|
status = "Failed";
|
||||||
|
statusClass = "story-review-centre-tile__status story-review-centre-tile__status--warning";
|
||||||
|
description = string.IsNullOrWhiteSpace(Model.PlotSynthesis.ErrorMessage)
|
||||||
|
? "Whole-book plot synthesis failed and can be retried."
|
||||||
|
: Model.PlotSynthesis.ErrorMessage;
|
||||||
|
actionHtml = $$"""
|
||||||
|
<form method="post" action="{{Url.Action("RetryStoryIntelligencePlotSynthesis", "Onboarding")}}">
|
||||||
|
<input type="hidden" name="batchId" value="{{Model.BatchID}}" />
|
||||||
|
<button class="btn btn-outline-primary btn-sm" type="submit">Retry</button>
|
||||||
|
</form>
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
else if (Model.PlotSynthesis.IsCompleted && Model.NarrativeNeedsAttentionCount > 0)
|
||||||
|
{
|
||||||
|
status = $"{Model.NarrativeNeedsAttentionCount:N0} need attention";
|
||||||
|
statusClass = "story-review-centre-tile__status story-review-centre-tile__status--warning";
|
||||||
|
description = $"{Model.PlotSynthesis.ThreadCount:N0} thread candidates and {Model.PlotSynthesis.EventCount:N0} proposed scene events are ready.";
|
||||||
|
var href = Url.Action("StoryIntelligenceNarrative", "Onboarding", new { batchId = Model.BatchID }) ?? "#";
|
||||||
|
actionHtml = $"<a class=\"btn btn-outline-primary btn-sm\" href=\"{System.Net.WebUtility.HtmlEncode(href)}\">Review Plot Lines & Threads</a>";
|
||||||
|
}
|
||||||
|
else if (Model.PipelineDashboard.NarrativeStageComplete)
|
||||||
|
{
|
||||||
|
status = "Complete";
|
||||||
|
description = "Plot Lines and Threads review has been saved.";
|
||||||
|
var href = Url.Action("StoryIntelligenceNarrative", "Onboarding", new { batchId = Model.BatchID }) ?? "#";
|
||||||
|
actionHtml = $"<a class=\"btn btn-outline-primary btn-sm\" href=\"{System.Net.WebUtility.HtmlEncode(href)}\">View Plot Lines & Threads</a>";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
description = "Plot synthesis will start automatically once character resolution is saved.";
|
||||||
|
actionHtml = "<span class=\"btn btn-outline-primary btn-sm disabled\" aria-disabled=\"true\">Preparing</span>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Microsoft.AspNetCore.Html.HtmlString($$"""
|
||||||
|
<article class="story-review-centre-tile{{(blocked ? " story-review-centre-tile--locked" : string.Empty)}}" data-plot-synthesis-tile>
|
||||||
|
<div>
|
||||||
|
<h2>Plot Lines & Threads</h2>
|
||||||
|
<p>{{System.Net.WebUtility.HtmlEncode(description)}}</p>
|
||||||
|
</div>
|
||||||
|
<strong class="{{statusClass}}">{{System.Net.WebUtility.HtmlEncode(status)}}</strong>
|
||||||
|
{{actionHtml}}
|
||||||
|
</article>
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
private string StatusText(string title, int count, bool complete, bool blocked)
|
private string StatusText(string title, int count, bool complete, bool blocked)
|
||||||
{
|
{
|
||||||
if (blocked)
|
if (blocked)
|
||||||
|
|||||||
@ -228,7 +228,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@if (storyIntelligenceProgress.HasActiveWork)
|
@if (User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
var primaryBook = storyIntelligenceProgress.Books.FirstOrDefault(book => book.HasActiveRuns);
|
var primaryBook = storyIntelligenceProgress.Books.FirstOrDefault(book => book.HasActiveRuns);
|
||||||
var progressUpdatedUtc = storyIntelligenceProgress.UpdatedUtc?.ToUniversalTime().ToString("O", System.Globalization.CultureInfo.InvariantCulture);
|
var progressUpdatedUtc = storyIntelligenceProgress.UpdatedUtc?.ToUniversalTime().ToString("O", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
@ -242,8 +242,10 @@
|
|||||||
data-story-intelligence-updated-utc="@progressUpdatedUtc"
|
data-story-intelligence-updated-utc="@progressUpdatedUtc"
|
||||||
data-book-id="@primaryBook?.BookID"
|
data-book-id="@primaryBook?.BookID"
|
||||||
data-project-id="@primaryBook?.ProjectID"
|
data-project-id="@primaryBook?.ProjectID"
|
||||||
|
data-story-intelligence-has-active-work="@storyIntelligenceProgress.HasActiveWork.ToString().ToLowerInvariant()"
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite">
|
aria-live="polite"
|
||||||
|
hidden="@(!storyIntelligenceProgress.HasActiveWork)">
|
||||||
<div class="story-intelligence-global-strip__inner">
|
<div class="story-intelligence-global-strip__inner">
|
||||||
<div class="story-intelligence-global-strip__copy">
|
<div class="story-intelligence-global-strip__copy">
|
||||||
<span class="story-intelligence-global-strip__dot" aria-hidden="true"></span>
|
<span class="story-intelligence-global-strip__dot" aria-hidden="true"></span>
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
"StoryIntelligence": {
|
"StoryIntelligence": {
|
||||||
"ChapterStructureModel": "gpt-5-mini",
|
"ChapterStructureModel": "gpt-5-mini",
|
||||||
"SceneIntelligenceModel": "gpt-5-mini",
|
"SceneIntelligenceModel": "gpt-5-mini",
|
||||||
|
"WholeBookPlotSynthesisModel": "gpt-5",
|
||||||
"SceneIntelligenceMaxOutputTokens": 10000,
|
"SceneIntelligenceMaxOutputTokens": 10000,
|
||||||
"MaxGlobalConcurrentAiRequests": 4,
|
"MaxGlobalConcurrentAiRequests": 4,
|
||||||
"MaxConcurrentAiRequestsPerBook": 2,
|
"MaxConcurrentAiRequestsPerBook": 2,
|
||||||
|
|||||||
@ -8,6 +8,16 @@
|
|||||||
const detail = strip.querySelector("[data-story-intelligence-global-detail]");
|
const detail = strip.querySelector("[data-story-intelligence-global-detail]");
|
||||||
const bar = strip.querySelector("[data-story-intelligence-global-bar]");
|
const bar = strip.querySelector("[data-story-intelligence-global-bar]");
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
|
let startRetryTimer = null;
|
||||||
|
const diagnosticsEnabled =
|
||||||
|
location.hostname === "localhost"
|
||||||
|
|| location.hostname === "127.0.0.1"
|
||||||
|
|| location.hostname.startsWith("dev.");
|
||||||
|
const log = (message, data) => {
|
||||||
|
if (diagnosticsEnabled && window.console?.debug) {
|
||||||
|
window.console.debug(`[StoryIntelligenceGlobalProgress] ${message}`, data || "");
|
||||||
|
}
|
||||||
|
};
|
||||||
let lastSnapshotTime = Date.parse(strip.dataset.storyIntelligenceUpdatedUtc || "") || 0;
|
let lastSnapshotTime = Date.parse(strip.dataset.storyIntelligenceUpdatedUtc || "") || 0;
|
||||||
const readNumber = (value) => {
|
const readNumber = (value) => {
|
||||||
const parsed = Number.parseInt(value || "", 10);
|
const parsed = Number.parseInt(value || "", 10);
|
||||||
@ -16,6 +26,7 @@
|
|||||||
let lastSnapshotScore =
|
let lastSnapshotScore =
|
||||||
(readNumber(strip.dataset.completedScenes) || 0)
|
(readNumber(strip.dataset.completedScenes) || 0)
|
||||||
+ (readNumber(strip.dataset.failedScenes) || 0);
|
+ (readNumber(strip.dataset.failedScenes) || 0);
|
||||||
|
let lastSnapshotKey = `${strip.dataset.projectId || ""}:${strip.dataset.bookId || ""}`;
|
||||||
|
|
||||||
const formatCount = (value) => new Intl.NumberFormat().format(Math.max(0, value || 0));
|
const formatCount = (value) => new Intl.NumberFormat().format(Math.max(0, value || 0));
|
||||||
const estimateRemaining = (elapsedMs, completed, failed, total, status) => {
|
const estimateRemaining = (elapsedMs, completed, failed, total, status) => {
|
||||||
@ -52,15 +63,36 @@
|
|||||||
const primary = books.find((book) => book.hasActiveRuns || book.HasActiveRuns) || books[0];
|
const primary = books.find((book) => book.hasActiveRuns || book.HasActiveRuns) || books[0];
|
||||||
const activeBookCount = readNumber(progress.activeBookCount ?? progress.ActiveBookCount) || books.length;
|
const activeBookCount = readNumber(progress.activeBookCount ?? progress.ActiveBookCount) || books.length;
|
||||||
const status = progress.status || progress.Status || primary.status || primary.Status || "";
|
const status = progress.status || progress.Status || primary.status || primary.Status || "";
|
||||||
const total = readNumber(progress.totalExpectedScenes ?? progress.TotalExpectedScenes) || 0;
|
const total = readNumber(progress.totalExpectedScenes ?? progress.TotalExpectedScenes)
|
||||||
const completed = readNumber(progress.completedScenes ?? progress.CompletedScenes) || 0;
|
?? readNumber(primary.totalExpectedScenes ?? primary.TotalExpectedScenes)
|
||||||
const failed = readNumber(progress.failedScenes ?? progress.FailedScenes) || 0;
|
?? 0;
|
||||||
const active = readNumber(progress.activeScenes ?? progress.ActiveScenes) || 0;
|
const completed = readNumber(progress.completedScenes ?? progress.CompletedScenes)
|
||||||
|
?? readNumber(primary.completedScenes ?? primary.CompletedScenes)
|
||||||
|
?? 0;
|
||||||
|
const failed = readNumber(progress.failedScenes ?? progress.FailedScenes)
|
||||||
|
?? readNumber(primary.failedScenes ?? primary.FailedScenes)
|
||||||
|
?? 0;
|
||||||
|
const active = readNumber(progress.activeScenes ?? progress.ActiveScenes)
|
||||||
|
?? readNumber(primary.activeScenes ?? primary.ActiveScenes)
|
||||||
|
?? 0;
|
||||||
const updatedRaw = progress.updatedUtc || progress.UpdatedUtc || primary.updatedUtc || primary.UpdatedUtc || "";
|
const updatedRaw = progress.updatedUtc || progress.UpdatedUtc || primary.updatedUtc || primary.UpdatedUtc || "";
|
||||||
const updatedTime = Date.parse(updatedRaw);
|
const updatedTime = Date.parse(updatedRaw);
|
||||||
|
const snapshotKey = `${primary.projectID ?? primary.projectId ?? ""}:${primary.bookID ?? primary.bookId ?? ""}`;
|
||||||
const snapshotScore = completed + failed;
|
const snapshotScore = completed + failed;
|
||||||
|
if (snapshotKey !== lastSnapshotKey) {
|
||||||
|
lastSnapshotKey = snapshotKey;
|
||||||
|
lastSnapshotScore = 0;
|
||||||
|
lastSnapshotTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshotScore < lastSnapshotScore) {
|
||||||
|
log("ignored backwards snapshot", { completed, failed, updatedUtc: updatedRaw, snapshotScore, lastSnapshotScore });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (Number.isFinite(updatedTime)) {
|
if (Number.isFinite(updatedTime)) {
|
||||||
if (updatedTime < lastSnapshotTime || (updatedTime === lastSnapshotTime && snapshotScore < lastSnapshotScore)) {
|
if (updatedTime < lastSnapshotTime || (updatedTime === lastSnapshotTime && snapshotScore < lastSnapshotScore)) {
|
||||||
|
log("ignored stale snapshot", { completed, failed, updatedUtc: updatedRaw, snapshotScore, lastSnapshotScore });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,6 +105,8 @@
|
|||||||
lastSnapshotScore = Math.max(lastSnapshotScore, snapshotScore);
|
lastSnapshotScore = Math.max(lastSnapshotScore, snapshotScore);
|
||||||
strip.dataset.completedScenes = String(completed);
|
strip.dataset.completedScenes = String(completed);
|
||||||
strip.dataset.failedScenes = String(failed);
|
strip.dataset.failedScenes = String(failed);
|
||||||
|
strip.dataset.projectId = String(primary.projectID ?? primary.projectId ?? "");
|
||||||
|
strip.dataset.bookId = String(primary.bookID ?? primary.bookId ?? "");
|
||||||
|
|
||||||
strip.hidden = false;
|
strip.hidden = false;
|
||||||
strip.dataset.connection = "live";
|
strip.dataset.connection = "live";
|
||||||
@ -105,13 +139,18 @@
|
|||||||
title.textContent = primary.summaryText || primary.SummaryText || title.textContent;
|
title.textContent = primary.summaryText || primary.SummaryText || title.textContent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
log("applied snapshot", { completed, failed, total, active, status, updatedUtc: updatedRaw });
|
||||||
};
|
};
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
connection.invoke("WatchGlobalStoryIntelligenceProgress")
|
connection.invoke("WatchGlobalStoryIntelligenceProgress")
|
||||||
.then(render)
|
.then((snapshot) => {
|
||||||
|
log("authoritative refresh received", snapshot);
|
||||||
|
render(snapshot);
|
||||||
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
strip.dataset.connection = "offline";
|
strip.dataset.connection = "offline";
|
||||||
|
log("authoritative refresh failed");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -126,11 +165,34 @@
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
connection.on("StoryIntelligenceRunProgressChanged", scheduleRefresh);
|
connection.on("StoryIntelligenceRunProgressChanged", scheduleRefresh);
|
||||||
connection.on("StoryIntelligenceGlobalProgressChanged", render);
|
connection.on("StoryIntelligenceGlobalProgressChanged", (snapshot) => {
|
||||||
connection.onreconnected(refresh);
|
log("live snapshot received", snapshot);
|
||||||
|
render(snapshot);
|
||||||
|
});
|
||||||
|
connection.onreconnected(() => {
|
||||||
|
log("SignalR reconnected");
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
connection.onclose(() => {
|
||||||
|
strip.dataset.connection = "offline";
|
||||||
|
log("SignalR closed; retrying start");
|
||||||
|
startWithRetry();
|
||||||
|
});
|
||||||
|
|
||||||
|
const startWithRetry = () => {
|
||||||
|
window.clearTimeout(startRetryTimer);
|
||||||
connection.start()
|
connection.start()
|
||||||
.then(refresh)
|
.then(() => {
|
||||||
|
strip.dataset.connection = "live";
|
||||||
|
log("SignalR connected");
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
strip.dataset.connection = "offline";
|
strip.dataset.connection = "offline";
|
||||||
|
log("SignalR initial start failed; retrying");
|
||||||
|
startRetryTimer = window.setTimeout(startWithRetry, 2000);
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
startWithRetry();
|
||||||
})();
|
})();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user