Implement whole-book plot intelligence

This commit is contained in:
Nick Beckley 2026-08-29 18:57:46 +00:00
parent 85286428c2
commit b639b12163
24 changed files with 2561 additions and 64 deletions

View File

@ -202,6 +202,9 @@ var tests = new (string Name, Action Test)[]
("Narrative candidate merge and parent fields are persisted", NarrativeCandidateMergeAndParentFieldsArePersisted),
("Narrative prompt uses one scene call with compact schema", NarrativePromptUsesOneSceneCallWithCompactSchema),
("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 stage follows knowledge", StoryIntelligenceNarrativeStageFollowsKnowledge),
("Word Companion replays live current scene to writing panes", WordCompanionReplaysLiveCurrentSceneToWritingPanes),
@ -690,7 +693,7 @@ static void StoryIntelligenceReviewCentreUsesIndependentTileGrid()
"\"Assets\"",
"\"Knowledge\"",
"\"Locations\"",
"\"Plot Lines & Threads\"",
"ReviewPlotSynthesis()",
"\"Relationships\"",
"ReviewMetrics"
};
@ -3820,12 +3823,12 @@ static void StoryIntelligenceStartsFromCoreImportComplete()
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.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(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.");
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.PrimaryLocaleLabel(book?.PrimaryLocaleCode)", StringComparison.Ordinal), "Story Intelligence should derive locale from Book context.");
}
@ -4542,7 +4545,7 @@ static void PermanentAnalyseMyBookStartsFromSavedBook()
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.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(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.");
@ -4985,7 +4988,7 @@ static void StoryIntelligenceStabilisationRoutesNormalUsersToBook()
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("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(context.Contains("asp-controller=\"Books\" asp-action=\"Details\"", StringComparison.Ordinal), "Existing analysis context page should return to Book details.");
}
@ -5249,6 +5252,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()
{
string? requestBody = null;

View File

@ -8,7 +8,11 @@ namespace PlotLine.Controllers;
[Authorize]
[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("")]
public async Task<IActionResult> Index()
@ -134,14 +138,80 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
[HttpPost("story-intelligence/start")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> StartStoryIntelligence()
=> RedirectToAction(nameof(StoryIntelligencePlotContext));
[HttpGet("story-intelligence/plot-context")]
public async Task<IActionResult> StoryIntelligencePlotContext()
{
var job = await storyIntelligence.StartAsync();
if (job is null)
var overview = await storyIntelligence.GetOverviewAsync();
if (!overview.BookID.HasValue || !overview.CanStart)
{
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";
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);
if (progress is not null)
{
@ -163,23 +233,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction("Details", "Books", new { id = bookId });
}
try
{
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 });
}
return RedirectToAction(nameof(StoryIntelligenceBookPlotContext), new { bookId });
}
[HttpPost("story-intelligence/skip")]
@ -578,6 +632,25 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
: 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")]
public IActionResult StoryIntelligenceNarrativeComplete(Guid batchId)
{

View File

@ -3260,6 +3260,13 @@ public sealed class StoryIntelligenceNarrativeCandidateRepository(ISqlConnection
candidate.Significance,
candidate.ExistingPlotLineID,
candidate.ExistingPlotThreadID,
candidate.WholeBookPlotSynthesisRunID,
candidate.AuthorPlotSeedID,
candidate.ExistingPlotLineMatchConfidence,
candidate.ExistingPlotLineMatchReason,
candidate.SeedMatchConfidence,
candidate.SeedMatchReason,
candidate.Source,
candidate.RelatedPlotLineID,
candidate.ParentCandidateKey,
candidate.ParentNarrativeCandidateID,
@ -3293,13 +3300,20 @@ public sealed class StoryIntelligenceNarrativeCandidateRepository(ISqlConnection
commandType: CommandType.StoredProcedure);
var candidates = (await result.ReadAsync<StoryIntelligenceNarrativeCandidate>()).ToList();
var evidence = (await result.ReadAsync<StoryIntelligenceNarrativeCandidateEvidence>()).ToList();
var events = result.IsConsumed
? []
: (await result.ReadAsync<StoryIntelligenceNarrativeCandidateEvent>()).ToList();
var evidenceByCandidate = evidence
.GroupBy(item => item.NarrativeCandidateID)
.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)
{
candidate.Evidence = evidenceByCandidate.TryGetValue(candidate.NarrativeCandidateID, out var items) ? items : [];
candidate.Events = eventsByCandidate.TryGetValue(candidate.NarrativeCandidateID, out var candidateEvents) ? candidateEvents : [];
}
return candidates;

View 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; }
}
}

View File

@ -768,6 +768,10 @@ public sealed class ThreadEvent
public IReadOnlyList<int> TargetPlotLineIDs { get; set; } = [];
public string EventTitle { get; set; } = string.Empty;
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 UpdatedDate { get; set; }
}

View File

@ -101,6 +101,7 @@ public sealed class StoryIntelligenceOptions
public string Model { get; init; } = string.Empty;
public string ChapterStructureModel { 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 ImageGenerationSize { get; init; } = "1024x1024";
public string ImageGenerationQuality { get; init; } = "medium";
@ -123,6 +124,7 @@ public sealed class StoryIntelligenceOptions
public string EffectiveModel => FirstConfigured(Model);
public string EffectiveChapterStructureModel => FirstConfigured(ChapterStructureModel, Model);
public string EffectiveSceneIntelligenceModel => FirstConfigured(SceneIntelligenceModel, Model);
public string EffectiveWholeBookPlotSynthesisModel => FirstConfigured(WholeBookPlotSynthesisModel, Model);
public static string BuildModelSummary(string? chapterStructureModel, string? sceneIntelligenceModel)
=> $"Chapter={FirstConfigured(chapterStructureModel)}; Scene={FirstConfigured(sceneIntelligenceModel)}";

View File

@ -39,6 +39,13 @@ public sealed class StoryIntelligenceNarrativeCandidate
public string? ExistingPlotLineName { get; set; }
public int? ExistingPlotThreadID { 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 string? RelatedPlotLineName { get; set; }
public string? ParentCandidateKey { get; set; }
@ -57,6 +64,7 @@ public sealed class StoryIntelligenceNarrativeCandidate
public DateTime CreatedUtc { get; set; }
public DateTime UpdatedUtc { get; set; }
public IReadOnlyList<StoryIntelligenceNarrativeCandidateEvidence> Evidence { get; set; } = [];
public IReadOnlyList<StoryIntelligenceNarrativeCandidateEvent> Events { get; set; } = [];
}
public sealed class StoryIntelligenceNarrativeCandidateEvidence
@ -88,8 +96,38 @@ public sealed class StoryIntelligenceNarrativeCandidateSeed
public string? Significance { get; init; }
public int? ExistingPlotLineID { 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 string? ParentCandidateKey { get; init; }
public int? ParentNarrativeCandidateID { 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; }
}

View 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; }
}

View File

@ -150,6 +150,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
builder.Services.AddScoped<IWholeBookPlotIntelligenceRepository, WholeBookPlotIntelligenceRepository>();
builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>();
builder.Services.AddScoped<ICharacterIntelligenceRepository, CharacterIntelligenceRepository>();
builder.Services.AddScoped<INameIntelligenceRepository, NameIntelligenceRepository>();
@ -230,6 +231,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
builder.Services.AddScoped<IStoryIntelligenceNarrativeReviewService, StoryIntelligenceNarrativeReviewService>();
builder.Services.AddScoped<IWholeBookPlotIntelligenceService, WholeBookPlotIntelligenceService>();
builder.Services.AddScoped<IStoryIntelligenceSceneContextService, StoryIntelligenceSceneContextService>();
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
builder.Services.AddScoped<ICharacterIntelligenceService, CharacterIntelligenceService>();

View File

@ -43,6 +43,7 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligenceRelationshipImportService relationshipImport,
IStoryIntelligenceKnowledgeImportService knowledgeImport,
IStoryIntelligenceNarrativeReviewService narrativeReview,
IWholeBookPlotIntelligenceService wholeBookPlotIntelligence,
IStoryIntelligenceSourceRepository sources,
IStoryIntelligencePipelineStateService pipelineState,
IStoryMemoryRepository storyMemory,
@ -644,7 +645,12 @@ public sealed class OnboardingStoryIntelligenceService(
var relationshipReview = await relationshipImport.BuildReviewAsync(batch);
var knowledgeReview = await knowledgeImport.BuildReviewAsync(batch);
var narrative = await narrativeReview.GetReviewAsync(batch);
var plotSynthesisRun = await wholeBookPlotIntelligence.GetCurrentAsync(batch.BookID);
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
{
BatchID = batch.BatchID,
@ -661,6 +667,23 @@ public sealed class OnboardingStoryIntelligenceService(
RelationshipReview = relationshipReview,
KnowledgeReview = knowledgeReview,
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
{
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),

View File

@ -51,11 +51,23 @@ public sealed class PersistedStoryIntelligenceWorker(
if (!processedRun)
{
await RecoverReadySceneAnalysisAsync(stoppingToken);
processedRun = await ProcessWholeBookPlotSynthesisAsync(stoppingToken);
}
if (!processedRun)
{
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)
{
using var scope = scopeFactory.CreateScope();

View File

@ -90,6 +90,7 @@ public sealed class ProjectAccessFilter(IProjectAccessService access, ICurrentUs
["Dynamics"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project", ["bookId"] = "Book", ["sceneId"] = "Scene" },
["Exports"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project", ["sceneId"] = "Scene" },
["Scenes"] = new(StringComparer.OrdinalIgnoreCase) { ["chapterId"] = "Chapter", ["sceneId"] = "Scene", ["projectId"] = "Project", ["bookId"] = "Book" },
["Onboarding"] = new(StringComparer.OrdinalIgnoreCase) { ["bookId"] = "Book" },
["Acceptance"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project" },
["StoryBible"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project" }
};

View File

@ -19,6 +19,7 @@ public sealed class StoryIntelligenceCharacterImportService(
IStoryIntelligencePipelineStateService pipelineState,
INameIntelligenceService nameIntelligence,
ICoreImportCharacterMappingService coreImportCharacterMapping,
IWholeBookPlotIntelligenceService wholeBookPlotIntelligence,
ICurrentUserService currentUser,
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
{
@ -372,6 +373,7 @@ public sealed class StoryIntelligenceCharacterImportService(
};
var remap = await coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID);
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID);
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}",

View File

@ -33,8 +33,9 @@ public sealed class StoryIntelligenceNarrativeReviewService(
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
.Where(candidate => string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase))
.ToList();
@ -72,8 +73,9 @@ public sealed class StoryIntelligenceNarrativeReviewService(
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId)
{
await EnsureCandidatesAsync(batch);
var rows = (await candidates.ListByBookAsync(batch.BookID)).ToDictionary(candidate => candidate.NarrativeCandidateID);
var rows = (await candidates.ListByBookAsync(batch.BookID))
.Where(IsWholeBookCandidate)
.ToDictionary(candidate => candidate.NarrativeCandidateID);
var lookups = await plots.GetLookupsAsync();
var plotLineTypeId = lookups.PlotLineTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Main Plot", StringComparison.OrdinalIgnoreCase))?.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
?? lookups.ThreadStatuses.FirstOrDefault()?.ThreadStatusID
?? 0;
var threadEventTypeId = lookups.ThreadEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Raised", StringComparison.OrdinalIgnoreCase))?.ThreadEventTypeID
?? lookups.ThreadEventTypes.FirstOrDefault()?.ThreadEventTypeID
?? 0;
var threadEventTypeByName = lookups.ThreadEventTypes
.GroupBy(type => type.TypeName, StringComparer.OrdinalIgnoreCase)
.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
?? lookups.PlotEventTypes.FirstOrDefault()?.PlotEventTypeID
?? 0;
@ -221,7 +226,9 @@ public sealed class StoryIntelligenceNarrativeReviewService(
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
{
PlotLineID = relatedPlotLineId.Value,
@ -233,17 +240,47 @@ public sealed class StoryIntelligenceNarrativeReviewService(
IntroducedSceneID = firstSceneId
});
foreach (var evidence in EffectiveEvidence(candidate, rows, choices).Where(evidence => evidence.SceneID > 0))
if (effectiveEvents.Count > 0)
{
await SaveThreadEventAsync(connection, transaction, new ThreadEvent
foreach (var threadEvent in effectiveEvents.Where(item => item.SceneID > 0))
{
PlotThreadID = threadId,
SceneID = evidence.SceneID,
EventTypeID = threadEventTypeId,
PlotEventTypeID = plotEventTypeId,
EventTitle = string.IsNullOrWhiteSpace(evidence.ContributionType) ? candidate.ProposedTitle : evidence.ContributionType,
EventDescription = evidence.Evidence
});
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))
{
await SaveThreadEventAsync(connection, transaction, new ThreadEvent
{
PlotThreadID = threadId,
SceneID = evidence.SceneID,
EventTypeID = threadEventTypeByName.GetValueOrDefault(
string.IsNullOrWhiteSpace(evidence.ContributionType) ? "Mentioned" : evidence.ContributionType,
threadEventTypeId),
PlotEventTypeID = plotEventTypeId,
EventTitle = string.IsNullOrWhiteSpace(evidence.ContributionType) ? candidate.ProposedTitle : evidence.ContributionType,
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);
@ -503,7 +540,11 @@ public sealed class StoryIntelligenceNarrativeReviewService(
threadEvent.PlotEventTypeID,
threadEvent.TargetPlotLineID,
threadEvent.EventTitle,
threadEvent.EventDescription
threadEvent.EventDescription,
threadEvent.Source,
threadEvent.SourceReferenceID,
threadEvent.SourceKey,
threadEvent.Confidence
},
transaction,
commandType: CommandType.StoredProcedure);
@ -573,9 +614,24 @@ public sealed class StoryIntelligenceNarrativeReviewService(
SceneLabel = $"Chapter {evidence.ChapterNumber:0.##}, Scene {evidence.SceneNumber:0.##}: {evidence.SceneTitle}",
ContributionType = evidence.ContributionType ?? 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()
};
private static bool IsWholeBookCandidate(StoryIntelligenceNarrativeCandidate candidate)
=> string.Equals(candidate.Source, WholeBookPlotSynthesisSources.WholeBook, StringComparison.OrdinalIgnoreCase)
|| candidate.WholeBookPlotSynthesisRunID.HasValue;
private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<int, Scene> importedById,
@ -709,6 +765,81 @@ public sealed class StoryIntelligenceNarrativeReviewService(
.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)
=> StoryIntelligenceNarrativeSignalConsolidator.Key(value);
}

View File

@ -158,6 +158,7 @@ public sealed class StoryIntelligenceClient(
Model = settings.EffectiveModel,
ChapterStructureModel = settings.EffectiveChapterStructureModel,
SceneIntelligenceModel = settings.EffectiveSceneIntelligenceModel,
WholeBookPlotSynthesisModel = settings.EffectiveWholeBookPlotSynthesisModel,
MaxOutputTokens = settings.MaxOutputTokens,
TimeoutSeconds = settings.TimeoutSeconds,
ClientConstructed = true,
@ -831,6 +832,7 @@ public sealed class StoryIntelligenceClientConfigurationStatus
public string Model { get; init; } = string.Empty;
public string ChapterStructureModel { 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 TimeoutSeconds { get; init; }
public bool ClientConstructed { get; init; }

View 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 };
}

View 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

View File

@ -143,6 +143,21 @@ public sealed class StoryIntelligenceOverviewViewModel
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 StoryIntelligenceJobProgress Job { get; init; } = new();
@ -160,6 +175,7 @@ public sealed class StoryIntelligenceProgressViewModel
public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; init; } = new();
public StoryIntelligenceKnowledgeReviewViewModel KnowledgeReview { get; init; } = new();
public StoryIntelligenceNarrativeReviewViewModel NarrativeReview { get; init; } = new();
public WholeBookPlotSynthesisStatusViewModel PlotSynthesis { get; init; } = new();
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
public int ChapterCount => Chapters.Count;
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
@ -200,6 +216,20 @@ public sealed class StoryIntelligenceProgressViewModel
+ 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 int ChaptersAnalysed { get; init; }
@ -249,6 +279,10 @@ public sealed class StoryIntelligenceNarrativeReviewCandidateViewModel
public string Significance { get; init; } = string.Empty;
public string Confidence { get; init; } = "Unknown";
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 string? RelatedPlotLineName { get; init; }
public int? ParentNarrativeCandidateID { get; init; }
@ -256,6 +290,19 @@ public sealed class StoryIntelligenceNarrativeReviewCandidateViewModel
public IReadOnlyList<StoryIntelligenceNarrativeReviewCandidateViewModel> ChildThreads { get; init; } = [];
public string DefaultAction { get; init; } = StoryIntelligenceNarrativeReviewActions.AcceptNew;
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
@ -283,6 +330,16 @@ public sealed class StoryIntelligenceNarrativeImportChoiceForm
public int? RelatedPlotLineID { get; set; }
public int? ParentNarrativeCandidateID { 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

View File

@ -75,9 +75,11 @@
</div>
@if (pipeline is null)
{
<form asp-controller="Onboarding" asp-action="StartBookStoryIntelligence" asp-route-bookId="@Model.Book.BookID" method="post">
<button class="btn btn-primary" type="submit" disabled="@(!action.CanStart)">@action.ActionLabel</button>
</form>
<a class="btn btn-primary @(action.CanStart ? string.Empty : "disabled")"
aria-disabled="@(!action.CanStart)"
asp-controller="Onboarding"
asp-action="StoryIntelligenceBookPlotContext"
asp-route-bookId="@Model.Book.BookID">@action.ActionLabel</a>
}
else
{

View File

@ -111,9 +111,7 @@
<div class="onboarding-actions">
@if (coreComplete)
{
<form asp-controller="Onboarding" asp-action="StartStoryIntelligence" method="post">
<button class="btn btn-primary" type="submit">Analyse my book</button>
</form>
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligencePlotContext">Analyse my book</a>
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Not now</a>
}
else

View File

@ -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>
}

View File

@ -7,6 +7,11 @@
? Model.NarrativeReview.Candidates.ToList()
: Model.NarrativeReview.Candidates.Take(previewLimit).ToList();
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">
@ -137,15 +142,59 @@
</ul>
</div>
}
<div class="story-review-note">
<strong>Supporting scenes</strong>
<ul>
@foreach (var evidence in candidate.Evidence)
@if (candidate.Events.Count > 0)
{
<div class="story-review-note">
<strong>Proposed timeline events</strong>
@for (var e = 0; e < candidate.Events.Count; e++)
{
<li><strong>@evidence.SceneLabel</strong>: @(string.IsNullOrWhiteSpace(evidence.Evidence) ? evidence.ContributionType : evidence.Evidence)</li>
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>
}
</ul>
</div>
</div>
}
else
{
<div class="story-review-note">
<strong>Supporting scenes</strong>
<ul>
@foreach (var evidence in candidate.Evidence)
{
<li><strong>@evidence.SceneLabel</strong>: @(string.IsNullOrWhiteSpace(evidence.Evidence) ? evidence.ContributionType : evidence.Evidence)</li>
}
</ul>
</div>
}
</div>
</details>
}

View File

@ -60,14 +60,7 @@
ModuleAction("Locations", Model.LocationNeedsAttentionCount, Model.PipelineDashboard.LocationStageComplete),
"StoryIntelligenceLocations")
@ReviewModule(
"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")
@ReviewPlotSynthesis()
@ReviewModule(
"Relationships",
@ -104,6 +97,77 @@
private string ModuleAction(string title, int count, bool complete)
=> 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)
{
if (blocked)

View File

@ -27,6 +27,7 @@
"StoryIntelligence": {
"ChapterStructureModel": "gpt-5-mini",
"SceneIntelligenceModel": "gpt-5-mini",
"WholeBookPlotSynthesisModel": "gpt-5",
"SceneIntelligenceMaxOutputTokens": 10000,
"MaxGlobalConcurrentAiRequests": 4,
"MaxConcurrentAiRequestsPerBook": 2,