Phase 20Q — Real Chapter Source From Existing Book

This commit is contained in:
Nick Beckley 2026-07-04 22:07:00 +01:00
parent 9f9688bcb5
commit 7ccf334c93
13 changed files with 694 additions and 9 deletions

View File

@ -16,7 +16,8 @@ public sealed class AdminController(
IStoryIntelligenceDryRunService storyIntelligenceDryRun,
IChapterStructureDryRunService chapterStructureDryRun,
IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun,
IStoryIntelligenceResultPersistenceService storyIntelligenceResults) : Controller
IStoryIntelligenceResultPersistenceService storyIntelligenceResults,
IStoryIntelligenceExistingChapterQueueService existingChapterQueue) : Controller
{
[HttpGet("")]
[HttpGet("index")]
@ -107,6 +108,31 @@ public sealed class AdminController(
return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId });
}
[HttpGet("story-intelligence-existing-chapter")]
public async Task<IActionResult> StoryIntelligenceExistingChapter([FromQuery] StoryIntelligenceExistingChapterQueueForm form)
{
return View(await existingChapterQueue.BuildViewModelAsync(form));
}
[HttpPost("story-intelligence-existing-chapter")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> QueueStoryIntelligenceExistingChapter(StoryIntelligenceExistingChapterQueueForm form)
{
if (currentUser.UserId is not int userId)
{
return Forbid();
}
var model = await existingChapterQueue.QueueAsync(userId, form);
if (model.QueuedRunID is int runId)
{
TempData["AdminMessage"] = $"Story Intelligence run {runId:N0} queued from existing chapter. The background worker will process it progressively.";
return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId });
}
return View(nameof(StoryIntelligenceExistingChapter), model);
}
[HttpGet("story-intelligence-runs")]
public async Task<IActionResult> StoryIntelligenceRuns()
{

View File

@ -163,6 +163,8 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
request.UserID,
request.ProjectID,
request.BookID,
request.ChapterID,
request.ChapterNumber,
request.SourceType,
request.SourceLabel,
SourceText = request.ChapterText,

View File

@ -0,0 +1,54 @@
using System.Data;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IStoryIntelligenceSourceRepository
{
Task<IReadOnlyList<StoryIntelligenceSourceOption>> ListProjectsAsync();
Task<IReadOnlyList<StoryIntelligenceSourceOption>> ListBooksAsync(int projectId);
Task<IReadOnlyList<StoryIntelligenceSourceOption>> ListChaptersAsync(int bookId);
Task<StoryIntelligenceExistingChapterSource?> GetChapterSourceAsync(int chapterId);
}
public sealed class StoryIntelligenceSourceRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceSourceRepository
{
public async Task<IReadOnlyList<StoryIntelligenceSourceOption>> ListProjectsAsync()
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceSourceOption>(
"dbo.StoryIntelligenceSource_ProjectListAdmin",
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<IReadOnlyList<StoryIntelligenceSourceOption>> ListBooksAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceSourceOption>(
"dbo.StoryIntelligenceSource_BookListAdmin",
new { ProjectID = projectId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<IReadOnlyList<StoryIntelligenceSourceOption>> ListChaptersAsync(int bookId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceSourceOption>(
"dbo.StoryIntelligenceSource_ChapterListAdmin",
new { BookID = bookId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<StoryIntelligenceExistingChapterSource?> GetChapterSourceAsync(int chapterId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceExistingChapterSource>(
"dbo.StoryIntelligenceSource_ChapterText_GetAdmin",
new { ChapterID = chapterId },
commandType: CommandType.StoredProcedure);
}
}

View File

@ -60,6 +60,8 @@ public sealed class StoryIntelligenceRunQueueRequest
public int UserID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public int? ChapterID { get; init; }
public decimal? ChapterNumber { get; init; }
public string SourceType { get; init; } = "AdminText";
public string SourceLabel { get; init; } = string.Empty;
public string ChapterText { get; init; } = string.Empty;
@ -122,6 +124,8 @@ public sealed class StoryIntelligenceSavedRunListItem
public string? ProjectTitle { get; init; }
public int? BookID { get; init; }
public string? BookTitle { get; init; }
public int? ChapterID { get; init; }
public decimal? ChapterNumber { get; init; }
public string Status { get; init; } = string.Empty;
public string SourceType { get; init; } = string.Empty;
public string? FailureStage { get; init; }
@ -142,6 +146,8 @@ public sealed class StoryIntelligenceSavedRun
public string? ProjectTitle { get; init; }
public int? BookID { get; init; }
public string? BookTitle { get; init; }
public int? ChapterID { get; init; }
public decimal? ChapterNumber { get; init; }
public string Status { get; init; } = string.Empty;
public string SourceType { get; init; } = string.Empty;
public string? SourceFileName { get; init; }
@ -184,6 +190,8 @@ public sealed class StoryIntelligenceQueuedRun
public int UserID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public int? ChapterID { get; init; }
public decimal? ChapterNumber { get; init; }
public string Status { get; init; } = string.Empty;
public string SourceType { get; init; } = string.Empty;
public string? SourceLabel { get; init; }
@ -217,6 +225,48 @@ public sealed class StoryIntelligenceQueuedRun
public bool CancellationRequested => CancellationRequestedUtc.HasValue;
}
public sealed class StoryIntelligenceSourceOption
{
public int Value { get; init; }
public string Text { get; init; } = string.Empty;
}
public sealed class StoryIntelligenceExistingChapterSource
{
public int ProjectID { get; init; }
public string ProjectName { get; init; } = string.Empty;
public int BookID { get; init; }
public string BookTitle { get; init; } = string.Empty;
public string? BookSubtitle { get; init; }
public int ChapterID { get; init; }
public decimal ChapterNumber { get; init; }
public string ChapterTitle { get; init; } = string.Empty;
public string? SourceText { get; init; }
public int? SourceWordCount { get; init; }
public int? SourceCharacterCount { get; init; }
public bool HasSourceText => !string.IsNullOrWhiteSpace(SourceText);
}
public sealed class StoryIntelligenceExistingChapterQueueForm
{
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public int? ChapterID { get; init; }
}
public sealed class StoryIntelligenceExistingChapterQueueViewModel
{
public StoryIntelligenceExistingChapterQueueForm Form { get; init; } = new();
public IReadOnlyList<StoryIntelligenceSourceOption> Projects { get; init; } = [];
public IReadOnlyList<StoryIntelligenceSourceOption> Books { get; init; } = [];
public IReadOnlyList<StoryIntelligenceSourceOption> Chapters { get; init; } = [];
public StoryIntelligenceExistingChapterSource? SelectedChapter { get; init; }
public string? WarningMessage { get; init; }
public string? ErrorMessage { get; init; }
public int? QueuedRunID { get; init; }
}
public sealed class StoryIntelligenceSavedChapterResult
{
public int ChapterResultID { get; init; }

View File

@ -138,6 +138,7 @@ public class Program
builder.Services.AddScoped<IOnboardingBuildRepository, OnboardingBuildRepository>();
builder.Services.AddScoped<IStoryIntelligenceRepository, StoryIntelligenceRepository>();
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>();
@ -196,6 +197,7 @@ public class Program
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();

View File

@ -95,8 +95,8 @@ public sealed class PersistedStoryIntelligenceRunner(
{
ProjectID = run.ProjectID,
BookID = run.BookID,
ChapterID = null,
ChapterNumber = 1,
ChapterID = run.ChapterID,
ChapterNumber = ChapterNumber(run),
SourceLabel = SourceLabel(run),
PromptVersion = chapterPromptVersion,
Model = chapterClientResult.Model,
@ -172,7 +172,7 @@ public sealed class PersistedStoryIntelligenceRunner(
{
ProjectID = run.ProjectID,
BookID = run.BookID,
ChapterID = null,
ChapterID = run.ChapterID,
SceneID = null,
TemporarySceneNumber = block.TemporarySceneNumber,
StartParagraph = block.StartParagraph,
@ -274,7 +274,7 @@ public sealed class PersistedStoryIntelligenceRunner(
{
ProjectID = run.ProjectID,
BookID = run.BookID,
ChapterID = null,
ChapterID = run.ChapterID,
SceneID = null,
TemporarySceneNumber = block.TemporarySceneNumber,
StartParagraph = block.StartParagraph,
@ -336,8 +336,8 @@ public sealed class PersistedStoryIntelligenceRunner(
{
projectId = run.ProjectID,
bookId = run.BookID,
chapterId = (int?)null,
chapterNumber = 1,
chapterId = run.ChapterID,
chapterNumber = ChapterNumber(run),
sourceLabel = SourceLabel(run)
}, JsonOptions);
@ -346,9 +346,9 @@ public sealed class PersistedStoryIntelligenceRunner(
{
projectId = run.ProjectID,
bookId = run.BookID,
chapterId = (int?)null,
chapterId = run.ChapterID,
sceneId = (int?)null,
chapterNumber = 1,
chapterNumber = ChapterNumber(run),
sceneNumber = boundary.SceneNumber,
sourceLabel = $"{SourceLabel(run)}, suggested scene {boundary.SceneNumber}"
}, JsonOptions);
@ -388,6 +388,9 @@ public sealed class PersistedStoryIntelligenceRunner(
private static string SourceLabel(StoryIntelligenceQueuedRun run)
=> string.IsNullOrWhiteSpace(run.SourceLabel) ? "Admin persisted Story Intelligence chapter" : run.SourceLabel.Trim();
private static decimal ChapterNumber(StoryIntelligenceQueuedRun run)
=> run.ChapterNumber ?? 1;
private static string BuildValidationDetail(PlotLine.Models.StoryIntelligence.ValidationResult validation)
=> string.Join(
Environment.NewLine,

View File

@ -0,0 +1,145 @@
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IStoryIntelligenceExistingChapterQueueService
{
Task<StoryIntelligenceExistingChapterQueueViewModel> BuildViewModelAsync(StoryIntelligenceExistingChapterQueueForm form);
Task<StoryIntelligenceExistingChapterQueueViewModel> QueueAsync(int userId, StoryIntelligenceExistingChapterQueueForm form);
}
public sealed class StoryIntelligenceExistingChapterQueueService(
IStoryIntelligenceSourceRepository sources,
IStoryIntelligenceResultRepository runs,
IStoryIntelligenceClient client,
ILogger<StoryIntelligenceExistingChapterQueueService> logger) : IStoryIntelligenceExistingChapterQueueService
{
private const string NoStoredChapterTextMessage = "No stored chapter text is currently available for this chapter. A future Word Companion/document extraction phase is required.";
public async Task<StoryIntelligenceExistingChapterQueueViewModel> BuildViewModelAsync(StoryIntelligenceExistingChapterQueueForm form)
{
var selected = await GetSelectedChapterAsync(form);
return await BuildViewModelAsync(form, selected, warningMessage: BuildWarning(selected), errorMessage: null, queuedRunId: null);
}
public async Task<StoryIntelligenceExistingChapterQueueViewModel> QueueAsync(int userId, StoryIntelligenceExistingChapterQueueForm form)
{
if (form.ChapterID is not int chapterId)
{
return await BuildViewModelAsync(
form,
selectedChapter: null,
warningMessage: null,
errorMessage: "Choose a chapter before queueing a Story Intelligence run.",
queuedRunId: null);
}
var selected = await sources.GetChapterSourceAsync(chapterId);
if (selected is null)
{
return await BuildViewModelAsync(
form,
selectedChapter: null,
warningMessage: null,
errorMessage: "The selected chapter could not be found.",
queuedRunId: null);
}
if (!selected.HasSourceText)
{
return await BuildViewModelAsync(
NormalizeForm(selected),
selected,
NoStoredChapterTextMessage,
errorMessage: null,
queuedRunId: null);
}
var sourceText = selected.SourceText!.Trim();
var clientStatus = client.GetConfigurationStatus();
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
{
UserID = userId,
ProjectID = selected.ProjectID,
BookID = selected.BookID,
ChapterID = selected.ChapterID,
ChapterNumber = selected.ChapterNumber,
SourceType = "ExistingChapter",
SourceLabel = BuildSourceLabel(selected),
ChapterText = sourceText,
SourceWordCount = selected.SourceWordCount ?? CountWords(sourceText),
SourceCharacterCount = selected.SourceCharacterCount ?? sourceText.Length,
SourceChapterCount = 1,
Model = clientStatus.Model,
PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V1; Scene=Scene-Prompt-V1"
});
logger.LogInformation(
"Queued Story Intelligence run {StoryIntelligenceRunID} from existing chapter {ChapterID}. ProjectID={ProjectID} BookID={BookID} SourceWordCount={SourceWordCount} SourceCharacterCount={SourceCharacterCount}",
runId,
selected.ChapterID,
selected.ProjectID,
selected.BookID,
selected.SourceWordCount,
selected.SourceCharacterCount);
return await BuildViewModelAsync(NormalizeForm(selected), selected, warningMessage: null, errorMessage: null, queuedRunId: runId);
}
private async Task<StoryIntelligenceExistingChapterQueueViewModel> BuildViewModelAsync(
StoryIntelligenceExistingChapterQueueForm form,
StoryIntelligenceExistingChapterSource? selectedChapter,
string? warningMessage,
string? errorMessage,
int? queuedRunId)
{
var normalizedForm = selectedChapter is not null ? NormalizeForm(selectedChapter) : form;
var projects = await sources.ListProjectsAsync();
var books = normalizedForm.ProjectID is int projectId
? await sources.ListBooksAsync(projectId)
: [];
var chapters = normalizedForm.BookID is int bookId
? await sources.ListChaptersAsync(bookId)
: [];
return new StoryIntelligenceExistingChapterQueueViewModel
{
Form = normalizedForm,
Projects = projects,
Books = books,
Chapters = chapters,
SelectedChapter = selectedChapter,
WarningMessage = warningMessage,
ErrorMessage = errorMessage,
QueuedRunID = queuedRunId
};
}
private async Task<StoryIntelligenceExistingChapterSource?> GetSelectedChapterAsync(StoryIntelligenceExistingChapterQueueForm form)
=> form.ChapterID is int chapterId ? await sources.GetChapterSourceAsync(chapterId) : null;
private static string? BuildWarning(StoryIntelligenceExistingChapterSource? selected)
=> selected is not null && !selected.HasSourceText ? NoStoredChapterTextMessage : null;
private static StoryIntelligenceExistingChapterQueueForm NormalizeForm(StoryIntelligenceExistingChapterSource selected)
=> new()
{
ProjectID = selected.ProjectID,
BookID = selected.BookID,
ChapterID = selected.ChapterID
};
private static string BuildSourceLabel(StoryIntelligenceExistingChapterSource selected)
{
var book = string.IsNullOrWhiteSpace(selected.BookSubtitle)
? selected.BookTitle
: $"{selected.BookTitle}: {selected.BookSubtitle}";
return $"{selected.ProjectName} / {book} / Chapter {selected.ChapterNumber}: {selected.ChapterTitle}";
}
private static int? CountWords(string? value)
=> string.IsNullOrWhiteSpace(value)
? null
: value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
}

View File

@ -74,6 +74,8 @@ public sealed class StoryIntelligenceResultPersistenceService(
UserID = userId,
ProjectID = form.ProjectID,
BookID = form.BookID,
ChapterID = form.ChapterID,
ChapterNumber = form.ChapterNumber,
SourceType = "AdminText",
SourceLabel = CleanSourceLabel(form.SourceLabel),
ChapterText = form.ChapterText,

View File

@ -0,0 +1,281 @@
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'ChapterID') IS NULL
BEGIN
ALTER TABLE dbo.StoryIntelligenceRuns ADD ChapterID int NULL;
END;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'ChapterNumber') IS NULL
BEGIN
ALTER TABLE dbo.StoryIntelligenceRuns ADD ChapterNumber decimal(9, 2) NULL;
END;
GO
IF NOT EXISTS
(
SELECT 1
FROM sys.foreign_keys
WHERE name = N'FK_StoryIntelligenceRuns_Chapters'
AND parent_object_id = OBJECT_ID(N'dbo.StoryIntelligenceRuns')
)
BEGIN
ALTER TABLE dbo.StoryIntelligenceRuns
ADD CONSTRAINT FK_StoryIntelligenceRuns_Chapters
FOREIGN KEY (ChapterID) REFERENCES dbo.Chapters(ChapterID);
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceSource_ProjectListAdmin
AS
BEGIN
SET NOCOUNT ON;
SELECT
ProjectID AS Value,
ProjectName AS Text
FROM dbo.Projects
WHERE IsArchived = 0
ORDER BY ProjectName;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceSource_BookListAdmin
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
SELECT
BookID AS Value,
CASE
WHEN NULLIF(LTRIM(RTRIM(Subtitle)), N'') IS NULL THEN BookTitle
ELSE CONCAT(BookTitle, N': ', Subtitle)
END AS Text
FROM dbo.Books
WHERE ProjectID = @ProjectID
AND IsArchived = 0
ORDER BY SortOrder, BookNumber, BookTitle;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceSource_ChapterListAdmin
@BookID int
AS
BEGIN
SET NOCOUNT ON;
SELECT
ChapterID AS Value,
CONCAT(N'Chapter ', CONVERT(nvarchar(20), ChapterNumber), N': ', ChapterTitle) AS Text
FROM dbo.Chapters
WHERE BookID = @BookID
AND IsArchived = 0
ORDER BY SortOrder, ChapterNumber, ChapterTitle;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceSource_ChapterText_GetAdmin
@ChapterID int
AS
BEGIN
SET NOCOUNT ON;
SELECT
p.ProjectID,
p.ProjectName,
b.BookID,
b.BookTitle,
b.Subtitle AS BookSubtitle,
c.ChapterID,
c.ChapterNumber,
c.ChapterTitle,
CAST(NULL AS nvarchar(max)) AS SourceText,
CAST(NULL AS int) AS SourceWordCount,
CAST(NULL AS int) AS SourceCharacterCount
FROM dbo.Chapters c
INNER JOIN dbo.Books b ON b.BookID = c.BookID
INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID
WHERE c.ChapterID = @ChapterID
AND c.IsArchived = 0
AND b.IsArchived = 0
AND p.IsArchived = 0;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_QueueAdminText
@UserID int,
@ProjectID int = NULL,
@BookID int = NULL,
@ChapterID int = NULL,
@ChapterNumber decimal(9, 2) = NULL,
@SourceType nvarchar(50),
@SourceLabel nvarchar(300),
@SourceText nvarchar(max),
@SourceWordCount int = NULL,
@SourceCharacterCount int = NULL,
@SourceChapterCount int = NULL,
@Model nvarchar(100),
@PromptVersionsSummary nvarchar(500)
AS
BEGIN
SET NOCOUNT ON;
INSERT dbo.StoryIntelligenceRuns
(
UserID, ProjectID, BookID, ChapterID, ChapterNumber, Status, SourceType, SourceLabel, SourceText,
SourceWordCount, SourceCharacterCount, SourceChapterCount, Model,
PromptVersion, PromptVersionsSummary, StartedUtc, CurrentStage, CurrentMessage,
CompletedScenes, FailedScenes
)
VALUES
(
@UserID, @ProjectID, @BookID, @ChapterID, @ChapterNumber, N'Pending', @SourceType, @SourceLabel, @SourceText,
@SourceWordCount, @SourceCharacterCount, @SourceChapterCount, @Model,
@PromptVersionsSummary, @PromptVersionsSummary, SYSUTCDATETIME(), N'Pending',
N'Queued for Story Intelligence processing.', 0, 0
);
SELECT CAST(SCOPE_IDENTITY() AS int) AS StoryIntelligenceRunID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ClaimNextPending
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @StoryIntelligenceRunID int;
SELECT TOP (1) @StoryIntelligenceRunID = StoryIntelligenceRunID
FROM dbo.StoryIntelligenceRuns WITH (UPDLOCK, READPAST)
WHERE Status = N'Pending'
ORDER BY CreatedUtc;
IF @StoryIntelligenceRunID IS NULL
BEGIN
SELECT TOP (0)
StoryIntelligenceRunID, UserID, ProjectID, BookID, ChapterID, ChapterNumber, Status, SourceType, SourceLabel,
SourceText, SourceWordCount, SourceCharacterCount, SourceChapterCount, PromptVersion,
PromptVersionsSummary, Model, StartedUtc, CompletedUtc, FailureStage, TotalInputTokens,
TotalOutputTokens, TotalTokens, TotalDurationMs, EstimatedCostGBP, EstimatedCostUSD,
ErrorMessage, ErrorDetail, CurrentStage, CurrentMessage, TotalDetectedScenes,
CompletedScenes, FailedScenes, CancellationRequestedUtc, CancelledUtc, CreatedUtc, UpdatedUtc
FROM dbo.StoryIntelligenceRuns;
RETURN;
END;
UPDATE dbo.StoryIntelligenceRuns
SET Status = N'Running',
StartedUtc = SYSUTCDATETIME(),
CurrentStage = N'ChapterStructure',
CurrentMessage = N'Running Chapter Structure analysis.',
UpdatedUtc = SYSUTCDATETIME()
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
AND Status = N'Pending';
SELECT
StoryIntelligenceRunID, UserID, ProjectID, BookID, ChapterID, ChapterNumber, Status, SourceType, SourceLabel,
SourceText, SourceWordCount, SourceCharacterCount, SourceChapterCount, PromptVersion,
PromptVersionsSummary, Model, StartedUtc, CompletedUtc, FailureStage, TotalInputTokens,
TotalOutputTokens, TotalTokens, TotalDurationMs, EstimatedCostGBP, EstimatedCostUSD,
ErrorMessage, ErrorDetail, CurrentStage, CurrentMessage, TotalDetectedScenes,
CompletedScenes, FailedScenes, CancellationRequestedUtc, CancelledUtc, CreatedUtc, UpdatedUtc
FROM dbo.StoryIntelligenceRuns
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ListAdmin
AS
BEGIN
SET NOCOUNT ON;
SELECT
r.StoryIntelligenceRunID,
r.CreatedUtc,
r.UserID,
r.ProjectID,
p.ProjectName AS ProjectTitle,
r.BookID,
b.BookTitle,
r.ChapterID,
r.ChapterNumber,
r.Status,
r.SourceType,
r.FailureStage,
COUNT(sr.SceneResultID) AS SceneCount,
r.TotalTokens,
r.TotalDurationMs,
r.EstimatedCostGBP,
r.EstimatedCostUSD,
COALESCE(cr.ValidationErrorsCount, 0) + COALESCE(SUM(sr.ValidationErrorsCount), 0) AS ValidationErrorsCount,
COALESCE(cr.ValidationWarningsCount, 0) + COALESCE(SUM(sr.ValidationWarningsCount), 0) AS ValidationWarningsCount
FROM dbo.StoryIntelligenceRuns r
LEFT JOIN dbo.Projects p ON p.ProjectID = r.ProjectID
LEFT JOIN dbo.Books b ON b.BookID = r.BookID
LEFT JOIN dbo.StoryIntelligenceChapterResults cr ON cr.StoryIntelligenceRunID = r.StoryIntelligenceRunID
LEFT JOIN dbo.StoryIntelligenceSceneResults sr ON sr.StoryIntelligenceRunID = r.StoryIntelligenceRunID
GROUP BY
r.StoryIntelligenceRunID, r.CreatedUtc, r.UserID, r.ProjectID, p.ProjectName,
r.BookID, b.BookTitle, r.ChapterID, r.ChapterNumber, r.Status, r.SourceType,
r.FailureStage, r.TotalTokens, r.TotalDurationMs, r.EstimatedCostGBP, r.EstimatedCostUSD,
cr.ValidationErrorsCount, cr.ValidationWarningsCount
ORDER BY r.CreatedUtc DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_GetAdmin
@StoryIntelligenceRunID int
AS
BEGIN
SET NOCOUNT ON;
SELECT
r.StoryIntelligenceRunID,
r.UserID,
r.ProjectID,
p.ProjectName AS ProjectTitle,
r.BookID,
b.BookTitle,
r.ChapterID,
r.ChapterNumber,
r.Status,
r.SourceType,
r.SourceFileName,
r.SourceFileSizeBytes,
r.SourceWordCount,
r.SourceCharacterCount,
r.SourceChapterCount,
r.SourceDetectedImagesCount,
r.SourceDetectedTablesCount,
r.SourceDetectedFootnotesCount,
r.SourceDetectedCommentsCount,
r.CurrentStage,
r.CurrentMessage,
r.TotalDetectedScenes,
r.CompletedScenes,
r.FailedScenes,
r.CancellationRequestedUtc,
r.CancelledUtc,
r.PromptVersion,
r.PromptVersionsSummary,
r.Model,
r.StartedUtc,
r.CompletedUtc,
r.FailureStage,
r.TotalInputTokens,
r.TotalOutputTokens,
r.TotalTokens,
r.TotalDurationMs,
r.EstimatedCostGBP,
r.EstimatedCostUSD,
r.ErrorMessage,
r.ErrorDetail,
r.CreatedUtc,
r.UpdatedUtc
FROM dbo.StoryIntelligenceRuns r
LEFT JOIN dbo.Projects p ON p.ProjectID = r.ProjectID
LEFT JOIN dbo.Books b ON b.BookID = r.BookID
WHERE r.StoryIntelligenceRunID = @StoryIntelligenceRunID;
END;
GO

View File

@ -85,6 +85,7 @@
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceTest">Dry run</a>
<a class="btn btn-outline-primary btn-sm" asp-action="ChapterStructureTest">Chapter test</a>
<a class="btn btn-outline-primary btn-sm" asp-action="ChapterStoryIntelligenceTest">Pipeline test</a>
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceExistingChapter">Queue chapter</a>
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceRuns">Saved runs</a>
</div>
</article>

View File

@ -0,0 +1,109 @@
@model StoryIntelligenceExistingChapterQueueViewModel
@{
ViewData["Title"] = "Queue Story Intelligence From Chapter";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Admin utility</p>
<h1>Queue from existing chapter</h1>
<p class="lead-text">Run the persisted Story Intelligence pipeline from read-only PlotDirector chapter data.</p>
</div>
</div>
<nav class="mb-3" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<span class="muted"> / </span>
<a asp-action="Index">Admin</a>
<span class="muted"> / Queue from existing chapter</span>
</nav>
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
{
<div class="alert alert-danger">@Model.ErrorMessage</div>
}
@if (!string.IsNullOrWhiteSpace(Model.WarningMessage))
{
<div class="alert alert-warning">@Model.WarningMessage</div>
}
<section class="edit-panel">
<form method="get" asp-action="StoryIntelligenceExistingChapter">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="ProjectID">Project</label>
<select class="form-select" id="ProjectID" name="ProjectID">
<option value="">Choose project</option>
@foreach (var project in Model.Projects)
{
<option value="@project.Value" selected="@(Model.Form.ProjectID == project.Value)">@project.Text</option>
}
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="BookID">Book</label>
<select class="form-select" id="BookID" name="BookID">
<option value="">Choose book</option>
@foreach (var book in Model.Books)
{
<option value="@book.Value" selected="@(Model.Form.BookID == book.Value)">@book.Text</option>
}
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="ChapterID">Chapter</label>
<select class="form-select" id="ChapterID" name="ChapterID">
<option value="">Choose chapter</option>
@foreach (var chapter in Model.Chapters)
{
<option value="@chapter.Value" selected="@(Model.Form.ChapterID == chapter.Value)">@chapter.Text</option>
}
</select>
</div>
</div>
<div class="button-row mt-3">
<button type="submit" class="btn btn-outline-primary">Load selection</button>
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRuns">Saved runs</a>
</div>
</form>
</section>
@if (Model.SelectedChapter is not null)
{
<section class="edit-panel">
<h2>Selected source</h2>
<dl class="row mb-0">
<dt class="col-sm-3">Project</dt>
<dd class="col-sm-9">@Model.SelectedChapter.ProjectName</dd>
<dt class="col-sm-3">Book</dt>
<dd class="col-sm-9">@DisplayBook(Model.SelectedChapter)</dd>
<dt class="col-sm-3">Chapter</dt>
<dd class="col-sm-9">Chapter @Model.SelectedChapter.ChapterNumber: @Model.SelectedChapter.ChapterTitle</dd>
<dt class="col-sm-3">Word count</dt>
<dd class="col-sm-9">@DisplayCount(Model.SelectedChapter.SourceWordCount)</dd>
<dt class="col-sm-3">Character count</dt>
<dd class="col-sm-9">@DisplayCount(Model.SelectedChapter.SourceCharacterCount)</dd>
<dt class="col-sm-3">Text source</dt>
<dd class="col-sm-9">@(Model.SelectedChapter.HasSourceText ? "Stored chapter manuscript text is available." : "No stored chapter manuscript text is available.")</dd>
</dl>
<form method="post" asp-action="QueueStoryIntelligenceExistingChapter" class="mt-3">
@Html.AntiForgeryToken()
<input type="hidden" name="ProjectID" value="@Model.Form.ProjectID" />
<input type="hidden" name="BookID" value="@Model.Form.BookID" />
<input type="hidden" name="ChapterID" value="@Model.Form.ChapterID" />
<button type="submit" class="btn btn-primary" disabled="@(!Model.SelectedChapter.HasSourceText)">Queue Story Intelligence run</button>
</form>
</section>
}
@functions {
private static string DisplayCount(int? value)
=> value.HasValue ? value.Value.ToString("N0") : "Not available";
private static string DisplayBook(StoryIntelligenceExistingChapterSource source)
=> string.IsNullOrWhiteSpace(source.BookSubtitle)
? source.BookTitle
: $"{source.BookTitle}: {source.BookSubtitle}";
}

View File

@ -104,6 +104,8 @@ else
<dd>@DisplayName(run.ProjectTitle, run.ProjectID, "Project")</dd>
<dt>Book</dt>
<dd>@DisplayName(run.BookTitle, run.BookID, "Book")</dd>
<dt>Chapter</dt>
<dd>@DisplayChapter(run.ChapterID, run.ChapterNumber)</dd>
<dt>Prompt version</dt>
<dd>@run.PromptVersion</dd>
<dt>Prompt versions summary</dt>
@ -239,4 +241,11 @@ else
: id.HasValue
? $"{label} {id.Value:N0}"
: "None";
private static string DisplayChapter(int? chapterId, decimal? chapterNumber)
=> chapterId.HasValue
? chapterNumber.HasValue
? $"Chapter {chapterNumber.Value:0.##} (ID {chapterId.Value:N0})"
: $"Chapter ID {chapterId.Value:N0}"
: "None";
}

View File

@ -26,6 +26,7 @@
<section class="edit-panel">
<div class="button-row mb-3">
<a class="btn btn-primary" asp-action="ChapterStoryIntelligenceTest">Run pipeline test</a>
<a class="btn btn-outline-primary" asp-action="StoryIntelligenceExistingChapter">Queue from existing chapter</a>
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceDiagnostics">Diagnostics</a>
</div>