Phase 20O – Persist Story Intelligence Runs

This commit is contained in:
Nick Beckley 2026-07-04 21:04:40 +01:00
parent 7ccc0447a3
commit 35e76d8ac4
12 changed files with 1231 additions and 2 deletions

View File

@ -15,7 +15,8 @@ public sealed class AdminController(
IStoryIntelligenceDiagnosticsService storyIntelligenceDiagnostics,
IStoryIntelligenceDryRunService storyIntelligenceDryRun,
IChapterStructureDryRunService chapterStructureDryRun,
IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun) : Controller
IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun,
IStoryIntelligenceResultPersistenceService storyIntelligenceResults) : Controller
{
[HttpGet("")]
[HttpGet("index")]
@ -78,6 +79,33 @@ public sealed class AdminController(
return View(await chapterStoryIntelligenceDryRun.ExecuteAsync(form, cancellationToken));
}
[HttpPost("chapter-story-intelligence-test/save")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveChapterStoryIntelligenceRun(StoryIntelligenceSaveRunForm form)
{
if (currentUser.UserId is not int userId)
{
return Forbid();
}
var runId = await storyIntelligenceResults.SaveDisplayedRunAsync(userId, form.Payload);
TempData["AdminMessage"] = $"Story Intelligence run {runId:N0} saved. Saving the same displayed dry run again creates a separate audit record.";
return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId });
}
[HttpGet("story-intelligence-runs")]
public async Task<IActionResult> StoryIntelligenceRuns()
{
return View(await storyIntelligenceResults.ListRunsAsync());
}
[HttpGet("story-intelligence-runs/{id:int}")]
public async Task<IActionResult> StoryIntelligenceRunDetails(int id)
{
var model = await storyIntelligenceResults.GetRunDetailAsync(id);
return model is null ? NotFound() : View(model);
}
[HttpGet("feature-requests")]
public async Task<IActionResult> FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort)
{

View File

@ -0,0 +1,156 @@
using System.Data;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IStoryIntelligenceResultRepository
{
Task<int> SaveAsync(StoryIntelligenceRunSaveRequest request);
Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync();
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId);
Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId);
}
public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceResultRepository
{
public async Task<int> SaveAsync(StoryIntelligenceRunSaveRequest request)
{
using var connection = connectionFactory.CreateConnection();
connection.Open();
using var transaction = connection.BeginTransaction();
try
{
var runId = await connection.QuerySingleAsync<int>(
"dbo.StoryIntelligenceRun_Save",
new
{
request.UserID,
request.ProjectID,
request.BookID,
request.Status,
request.SourceType,
request.PromptVersion,
request.Model,
request.StartedUtc,
request.CompletedUtc,
request.TotalInputTokens,
request.TotalOutputTokens,
request.TotalTokens,
request.TotalDurationMs,
request.ErrorMessage
},
transaction,
commandType: CommandType.StoredProcedure);
int? chapterResultId = null;
if (request.ChapterResult is not null)
{
var chapter = request.ChapterResult;
chapterResultId = await connection.QuerySingleAsync<int>(
"dbo.StoryIntelligenceChapterResult_Save",
new
{
StoryIntelligenceRunID = runId,
chapter.ProjectID,
chapter.BookID,
chapter.ChapterID,
chapter.ChapterNumber,
chapter.SourceLabel,
chapter.PromptVersion,
chapter.Model,
chapter.RawResponseJson,
chapter.OutputTextJson,
chapter.ParsedJson,
chapter.ValidationErrorsCount,
chapter.ValidationWarningsCount,
chapter.InputTokens,
chapter.OutputTokens,
chapter.TotalTokens,
chapter.DurationMs
},
transaction,
commandType: CommandType.StoredProcedure);
}
foreach (var scene in request.SceneResults)
{
await connection.ExecuteAsync(
"dbo.StoryIntelligenceSceneResult_Save",
new
{
StoryIntelligenceRunID = runId,
ChapterResultID = chapterResultId,
scene.ProjectID,
scene.BookID,
scene.ChapterID,
scene.SceneID,
scene.TemporarySceneNumber,
scene.StartParagraph,
scene.EndParagraph,
scene.SourceLabel,
scene.PromptVersion,
scene.Model,
scene.RawResponseJson,
scene.OutputTextJson,
scene.ParsedJson,
scene.ValidationErrorsCount,
scene.ValidationWarningsCount,
scene.InputTokens,
scene.OutputTokens,
scene.TotalTokens,
scene.DurationMs
},
transaction,
commandType: CommandType.StoredProcedure);
}
transaction.Commit();
return runId;
}
catch
{
transaction.Rollback();
throw;
}
}
public async Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync()
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceSavedRunListItem>(
"dbo.StoryIntelligenceRun_ListAdmin",
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceSavedRun>(
"dbo.StoryIntelligenceRun_GetAdmin",
new { StoryIntelligenceRunID = runId },
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceSavedChapterResult>(
"dbo.StoryIntelligenceChapterResult_GetByRun",
new { StoryIntelligenceRunID = runId },
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceSavedSceneResult>(
"dbo.StoryIntelligenceSceneResult_ListByRun",
new { StoryIntelligenceRunID = runId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
}

View File

@ -0,0 +1,161 @@
namespace PlotLine.Models;
public static class StoryIntelligenceRunStatuses
{
public const string Completed = "Completed";
public const string CompletedWithIssues = "CompletedWithIssues";
public const string Failed = "Failed";
}
public sealed class StoryIntelligenceRunSaveRequest
{
public int UserID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public string Status { get; init; } = StoryIntelligenceRunStatuses.Completed;
public string SourceType { get; init; } = "AdminDryRun";
public string PromptVersion { get; init; } = string.Empty;
public string Model { get; init; } = string.Empty;
public DateTime StartedUtc { get; init; }
public DateTime? CompletedUtc { get; init; }
public int? TotalInputTokens { get; init; }
public int? TotalOutputTokens { get; init; }
public int? TotalTokens { get; init; }
public long? TotalDurationMs { get; init; }
public string? ErrorMessage { get; init; }
public StoryIntelligenceChapterResultSaveRequest? ChapterResult { get; init; }
public IReadOnlyList<StoryIntelligenceSceneResultSaveRequest> SceneResults { get; init; } = [];
}
public sealed class StoryIntelligenceChapterResultSaveRequest
{
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public int? ChapterID { get; init; }
public decimal? ChapterNumber { get; init; }
public string SourceLabel { get; init; } = string.Empty;
public string PromptVersion { get; init; } = string.Empty;
public string Model { get; init; } = string.Empty;
public string RawResponseJson { get; init; } = string.Empty;
public string OutputTextJson { get; init; } = string.Empty;
public string ParsedJson { get; init; } = string.Empty;
public int ValidationErrorsCount { get; init; }
public int ValidationWarningsCount { get; init; }
public int? InputTokens { get; init; }
public int? OutputTokens { get; init; }
public int? TotalTokens { get; init; }
public long? DurationMs { get; init; }
}
public sealed class StoryIntelligenceSceneResultSaveRequest
{
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public int? ChapterID { get; init; }
public int? SceneID { get; init; }
public int TemporarySceneNumber { get; init; }
public int? StartParagraph { get; init; }
public int? EndParagraph { get; init; }
public string SourceLabel { get; init; } = string.Empty;
public string PromptVersion { get; init; } = string.Empty;
public string Model { get; init; } = string.Empty;
public string RawResponseJson { get; init; } = string.Empty;
public string OutputTextJson { get; init; } = string.Empty;
public string ParsedJson { get; init; } = string.Empty;
public int ValidationErrorsCount { get; init; }
public int ValidationWarningsCount { get; init; }
public int? InputTokens { get; init; }
public int? OutputTokens { get; init; }
public int? TotalTokens { get; init; }
public long? DurationMs { get; init; }
}
public sealed class StoryIntelligenceSavedRunListItem
{
public int StoryIntelligenceRunID { get; init; }
public DateTime CreatedUtc { get; init; }
public int UserID { get; init; }
public int? ProjectID { get; init; }
public string? ProjectTitle { get; init; }
public int? BookID { get; init; }
public string? BookTitle { get; init; }
public string Status { get; init; } = string.Empty;
public int SceneCount { get; init; }
public int? TotalTokens { get; init; }
public long? TotalDurationMs { get; init; }
public int ValidationErrorsCount { get; init; }
public int ValidationWarningsCount { get; init; }
}
public sealed class StoryIntelligenceSavedRun
{
public int StoryIntelligenceRunID { get; init; }
public int UserID { get; init; }
public int? ProjectID { get; init; }
public string? ProjectTitle { get; init; }
public int? BookID { get; init; }
public string? BookTitle { get; init; }
public string Status { get; init; } = string.Empty;
public string SourceType { get; init; } = string.Empty;
public string PromptVersion { get; init; } = string.Empty;
public string Model { get; init; } = string.Empty;
public DateTime StartedUtc { get; init; }
public DateTime? CompletedUtc { get; init; }
public int? TotalInputTokens { get; init; }
public int? TotalOutputTokens { get; init; }
public int? TotalTokens { get; init; }
public long? TotalDurationMs { get; init; }
public string? ErrorMessage { get; init; }
public DateTime CreatedUtc { get; init; }
public DateTime UpdatedUtc { get; init; }
}
public sealed class StoryIntelligenceSavedChapterResult
{
public int ChapterResultID { get; init; }
public int StoryIntelligenceRunID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public int? ChapterID { get; init; }
public decimal? ChapterNumber { get; init; }
public string SourceLabel { get; init; } = string.Empty;
public string PromptVersion { get; init; } = string.Empty;
public string Model { get; init; } = string.Empty;
public string RawResponseJson { get; init; } = string.Empty;
public string OutputTextJson { get; init; } = string.Empty;
public string ParsedJson { get; init; } = string.Empty;
public int ValidationErrorsCount { get; init; }
public int ValidationWarningsCount { get; init; }
public int? InputTokens { get; init; }
public int? OutputTokens { get; init; }
public int? TotalTokens { get; init; }
public long? DurationMs { get; init; }
public DateTime CreatedUtc { get; init; }
}
public sealed class StoryIntelligenceSavedSceneResult
{
public int SceneResultID { get; init; }
public int StoryIntelligenceRunID { get; init; }
public int? ChapterResultID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public int? ChapterID { get; init; }
public int? SceneID { get; init; }
public int TemporarySceneNumber { get; init; }
public int? StartParagraph { get; init; }
public int? EndParagraph { get; init; }
public string SourceLabel { get; init; } = string.Empty;
public string PromptVersion { get; init; } = string.Empty;
public string Model { get; init; } = string.Empty;
public string RawResponseJson { get; init; } = string.Empty;
public string OutputTextJson { get; init; } = string.Empty;
public string ParsedJson { get; init; } = string.Empty;
public int ValidationErrorsCount { get; init; }
public int ValidationWarningsCount { get; init; }
public int? InputTokens { get; init; }
public int? OutputTokens { get; init; }
public int? TotalTokens { get; init; }
public long? DurationMs { get; init; }
public DateTime CreatedUtc { get; init; }
}

View File

@ -136,6 +136,7 @@ public class Program
builder.Services.AddScoped<IOnboardingRepository, OnboardingRepository>();
builder.Services.AddScoped<IOnboardingBuildRepository, OnboardingBuildRepository>();
builder.Services.AddScoped<IStoryIntelligenceRepository, StoryIntelligenceRepository>();
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>();
@ -193,6 +194,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceDryRunService, StoryIntelligenceDryRunService>();
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();

View File

@ -71,9 +71,11 @@ public sealed class ChapterStoryIntelligenceDryRunService(
ChapterContextJson = chapterContextJson,
ParagraphCount = paragraphs.Count,
TotalDuration = FormatDuration(totalStopwatch.Elapsed),
TotalDurationMs = totalStopwatch.ElapsedMilliseconds,
TotalInputTokens = chapterResult.InputTokens,
TotalOutputTokens = chapterResult.OutputTokens,
ChapterDuration = FormatDuration(chapterResult.Duration),
ChapterDurationMs = Convert.ToInt64(chapterResult.Duration.TotalMilliseconds),
ChapterRetryCount = chapterResult.RetryCount,
ChapterInputTokens = chapterResult.InputTokens,
ChapterOutputTokens = chapterResult.OutputTokens,
@ -114,9 +116,11 @@ public sealed class ChapterStoryIntelligenceDryRunService(
ChapterContextJson = chapterContextJson,
ParagraphCount = paragraphs.Count,
TotalDuration = FormatDuration(totalStopwatch.Elapsed),
TotalDurationMs = totalStopwatch.ElapsedMilliseconds,
TotalInputTokens = SumTokens(chapterResult.InputTokens, sceneBlocks.Select(block => block.InputTokens)),
TotalOutputTokens = SumTokens(chapterResult.OutputTokens, sceneBlocks.Select(block => block.OutputTokens)),
ChapterDuration = FormatDuration(chapterResult.Duration),
ChapterDurationMs = Convert.ToInt64(chapterResult.Duration.TotalMilliseconds),
ChapterRetryCount = chapterResult.RetryCount,
ChapterInputTokens = chapterResult.InputTokens,
ChapterOutputTokens = chapterResult.OutputTokens,
@ -144,6 +148,7 @@ public sealed class ChapterStoryIntelligenceDryRunService(
ChapterContextJson = chapterContextJson,
ParagraphCount = paragraphs.Count,
TotalDuration = FormatDuration(totalStopwatch.Elapsed),
TotalDurationMs = totalStopwatch.ElapsedMilliseconds,
ErrorMessage = ex.Message
};
@ -190,6 +195,7 @@ public sealed class ChapterStoryIntelligenceDryRunService(
Success = true,
SceneContextJson = sceneContextJson,
Duration = FormatDuration(result.Duration),
DurationMs = Convert.ToInt64(result.Duration.TotalMilliseconds),
RetryCount = result.RetryCount,
InputTokens = result.InputTokens,
OutputTokens = result.OutputTokens,

View File

@ -0,0 +1,187 @@
using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceResultPersistenceService
{
Task<int> SaveDisplayedRunAsync(int userId, string payload);
Task<StoryIntelligenceSavedRunsViewModel> ListRunsAsync();
Task<StoryIntelligenceSavedRunDetailViewModel?> GetRunDetailAsync(int runId);
}
public sealed class StoryIntelligenceResultPersistenceService(
IStoryIntelligenceResultRepository repository,
ILogger<StoryIntelligenceResultPersistenceService> logger) : IStoryIntelligenceResultPersistenceService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
public async Task<int> SaveDisplayedRunAsync(int userId, string payload)
{
if (string.IsNullOrWhiteSpace(payload))
{
throw new InvalidOperationException("No dry-run result was submitted for saving.");
}
var dryRun = JsonSerializer.Deserialize<ChapterStoryIntelligenceDryRunViewModel>(payload, JsonOptions)
?? throw new InvalidOperationException("The submitted dry-run result could not be read.");
if (dryRun.Result is null)
{
throw new InvalidOperationException("The submitted dry-run result is empty.");
}
var request = BuildSaveRequest(userId, dryRun);
var runId = await repository.SaveAsync(request);
logger.LogInformation(
"Saved Story Intelligence admin dry-run. StoryIntelligenceRunID={StoryIntelligenceRunID} UserID={UserID} Status={Status} ChapterPromptVersion={ChapterPromptVersion} ScenePromptVersion={ScenePromptVersion} Model={Model} SceneCount={SceneCount} ValidationErrors={ValidationErrors} ValidationWarnings={ValidationWarnings} TotalDurationMs={TotalDurationMs} TotalTokens={TotalTokens}",
runId,
userId,
request.Status,
dryRun.Result.ChapterPromptVersion,
dryRun.Result.ScenePromptVersion,
dryRun.Result.Model,
request.SceneResults.Count,
CountErrors(dryRun.Result),
CountWarnings(dryRun.Result),
request.TotalDurationMs,
request.TotalTokens);
return runId;
}
public async Task<StoryIntelligenceSavedRunsViewModel> ListRunsAsync()
=> new() { Runs = await repository.ListRunsAsync() };
public async Task<StoryIntelligenceSavedRunDetailViewModel?> GetRunDetailAsync(int runId)
{
var run = await repository.GetRunAsync(runId);
if (run is null)
{
return null;
}
return new StoryIntelligenceSavedRunDetailViewModel
{
Run = run,
ChapterResult = await repository.GetChapterResultAsync(runId),
SceneResults = await repository.ListSceneResultsAsync(runId)
};
}
private static StoryIntelligenceRunSaveRequest BuildSaveRequest(int userId, ChapterStoryIntelligenceDryRunViewModel dryRun)
{
var form = dryRun.Form;
var result = dryRun.Result!;
var completedUtc = DateTime.UtcNow;
var startedUtc = result.TotalDurationMs.HasValue
? completedUtc.AddMilliseconds(-result.TotalDurationMs.Value)
: completedUtc;
var errorCount = CountErrors(result);
var warningCount = CountWarnings(result);
var status = !string.IsNullOrWhiteSpace(result.ErrorMessage) || result.PipelineStopped
? StoryIntelligenceRunStatuses.Failed
: errorCount > 0 || warningCount > 0 || result.SceneBlocks.Any(block => !block.Success && block.SplitValid)
? StoryIntelligenceRunStatuses.CompletedWithIssues
: StoryIntelligenceRunStatuses.Completed;
return new StoryIntelligenceRunSaveRequest
{
UserID = userId,
ProjectID = form.ProjectID,
BookID = form.BookID,
Status = status,
SourceType = "AdminDryRun",
PromptVersion = $"Chapter={result.ChapterPromptVersion}; Scene={result.ScenePromptVersion}",
Model = result.Model,
StartedUtc = startedUtc,
CompletedUtc = completedUtc,
TotalInputTokens = result.TotalInputTokens,
TotalOutputTokens = result.TotalOutputTokens,
TotalTokens = SumTokens(result.TotalInputTokens, result.TotalOutputTokens),
TotalDurationMs = result.TotalDurationMs,
ErrorMessage = FirstError(result),
ChapterResult = BuildChapterResult(form, result),
SceneResults = result.SceneBlocks.Select(block => BuildSceneResult(form, result, block)).ToList()
};
}
private static StoryIntelligenceChapterResultSaveRequest BuildChapterResult(
ChapterStoryIntelligenceDryRunForm form,
ChapterStoryIntelligenceDryRunResultViewModel result)
=> new()
{
ProjectID = form.ProjectID,
BookID = form.BookID,
ChapterID = form.ChapterID,
ChapterNumber = form.ChapterNumber,
SourceLabel = CleanSourceLabel(form.SourceLabel),
PromptVersion = result.ChapterPromptVersion,
Model = result.Model,
RawResponseJson = result.ChapterRawResponseText,
OutputTextJson = result.ChapterJsonText,
ParsedJson = SerializeParsed(result.ParsedChapter),
ValidationErrorsCount = result.ChapterValidation?.Errors.Count ?? 0,
ValidationWarningsCount = result.ChapterValidation?.Warnings.Count ?? 0,
InputTokens = result.ChapterInputTokens,
OutputTokens = result.ChapterOutputTokens,
TotalTokens = SumTokens(result.ChapterInputTokens, result.ChapterOutputTokens),
DurationMs = result.ChapterDurationMs
};
private static StoryIntelligenceSceneResultSaveRequest BuildSceneResult(
ChapterStoryIntelligenceDryRunForm form,
ChapterStoryIntelligenceDryRunResultViewModel result,
ChapterStorySceneBlockViewModel block)
=> new()
{
ProjectID = form.ProjectID,
BookID = form.BookID,
ChapterID = form.ChapterID,
SceneID = null,
TemporarySceneNumber = block.TemporarySceneNumber,
StartParagraph = block.StartParagraph > 0 ? block.StartParagraph : null,
EndParagraph = block.EndParagraph > 0 ? block.EndParagraph : null,
SourceLabel = $"{CleanSourceLabel(form.SourceLabel)}, suggested scene {block.TemporarySceneNumber}",
PromptVersion = result.ScenePromptVersion,
Model = result.Model,
RawResponseJson = block.RawResponseText,
OutputTextJson = block.SceneJsonText,
ParsedJson = SerializeParsed(block.ParsedScene),
ValidationErrorsCount = block.Validation?.Errors.Count ?? 0,
ValidationWarningsCount = block.Validation?.Warnings.Count ?? 0,
InputTokens = block.InputTokens,
OutputTokens = block.OutputTokens,
TotalTokens = SumTokens(block.InputTokens, block.OutputTokens),
DurationMs = block.DurationMs
};
private static string SerializeParsed<T>(T? value)
=> value is null ? string.Empty : JsonSerializer.Serialize(value, JsonOptions);
private static string CleanSourceLabel(string? value)
=> string.IsNullOrWhiteSpace(value) ? "Admin pipeline test chapter" : value.Trim();
private static int? SumTokens(int? inputTokens, int? outputTokens)
=> inputTokens.HasValue || outputTokens.HasValue ? (inputTokens ?? 0) + (outputTokens ?? 0) : null;
private static int CountErrors(ChapterStoryIntelligenceDryRunResultViewModel result)
=> (result.ChapterValidation?.Errors.Count ?? 0)
+ result.SceneBlocks.Sum(block => block.Validation?.Errors.Count ?? 0);
private static int CountWarnings(ChapterStoryIntelligenceDryRunResultViewModel result)
=> (result.ChapterValidation?.Warnings.Count ?? 0)
+ result.SceneBlocks.Sum(block => block.Validation?.Warnings.Count ?? 0);
private static string? FirstError(ChapterStoryIntelligenceDryRunResultViewModel result)
=> !string.IsNullOrWhiteSpace(result.ErrorMessage)
? result.ErrorMessage
: result.SceneBlocks.FirstOrDefault(block => !string.IsNullOrWhiteSpace(block.ErrorMessage))?.ErrorMessage;
}

View File

@ -0,0 +1,360 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.StoryIntelligenceRuns', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryIntelligenceRuns
(
StoryIntelligenceRunID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceRuns PRIMARY KEY,
UserID int NOT NULL,
ProjectID int NULL,
BookID int NULL,
Status nvarchar(50) NOT NULL,
SourceType nvarchar(50) NOT NULL,
PromptVersion nvarchar(200) NOT NULL,
Model nvarchar(100) NOT NULL,
StartedUtc datetime2 NOT NULL,
CompletedUtc datetime2 NULL,
TotalInputTokens int NULL,
TotalOutputTokens int NULL,
TotalTokens int NULL,
TotalDurationMs bigint NULL,
ErrorMessage nvarchar(max) NULL,
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceRuns_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceRuns_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_StoryIntelligenceRuns_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID)
);
END;
GO
IF OBJECT_ID(N'dbo.StoryIntelligenceChapterResults', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryIntelligenceChapterResults
(
ChapterResultID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceChapterResults PRIMARY KEY,
StoryIntelligenceRunID int NOT NULL,
ProjectID int NULL,
BookID int NULL,
ChapterID int NULL,
ChapterNumber decimal(9,2) NULL,
SourceLabel nvarchar(300) NOT NULL,
PromptVersion nvarchar(100) NOT NULL,
Model nvarchar(100) NOT NULL,
RawResponseJson nvarchar(max) NOT NULL,
OutputTextJson nvarchar(max) NOT NULL,
ParsedJson nvarchar(max) NOT NULL,
ValidationErrorsCount int NOT NULL CONSTRAINT DF_StoryIntelligenceChapterResults_ValidationErrorsCount DEFAULT 0,
ValidationWarningsCount int NOT NULL CONSTRAINT DF_StoryIntelligenceChapterResults_ValidationWarningsCount DEFAULT 0,
InputTokens int NULL,
OutputTokens int NULL,
TotalTokens int NULL,
DurationMs bigint NULL,
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceChapterResults_CreatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_StoryIntelligenceChapterResults_Run FOREIGN KEY (StoryIntelligenceRunID) REFERENCES dbo.StoryIntelligenceRuns(StoryIntelligenceRunID)
);
END;
GO
IF OBJECT_ID(N'dbo.StoryIntelligenceSceneResults', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryIntelligenceSceneResults
(
SceneResultID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceSceneResults PRIMARY KEY,
StoryIntelligenceRunID int NOT NULL,
ChapterResultID int NULL,
ProjectID int NULL,
BookID int NULL,
ChapterID int NULL,
SceneID int NULL,
TemporarySceneNumber int NOT NULL,
StartParagraph int NULL,
EndParagraph int NULL,
SourceLabel nvarchar(300) NOT NULL,
PromptVersion nvarchar(100) NOT NULL,
Model nvarchar(100) NOT NULL,
RawResponseJson nvarchar(max) NOT NULL,
OutputTextJson nvarchar(max) NOT NULL,
ParsedJson nvarchar(max) NOT NULL,
ValidationErrorsCount int NOT NULL CONSTRAINT DF_StoryIntelligenceSceneResults_ValidationErrorsCount DEFAULT 0,
ValidationWarningsCount int NOT NULL CONSTRAINT DF_StoryIntelligenceSceneResults_ValidationWarningsCount DEFAULT 0,
InputTokens int NULL,
OutputTokens int NULL,
TotalTokens int NULL,
DurationMs bigint NULL,
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceSceneResults_CreatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_StoryIntelligenceSceneResults_Run FOREIGN KEY (StoryIntelligenceRunID) REFERENCES dbo.StoryIntelligenceRuns(StoryIntelligenceRunID),
CONSTRAINT FK_StoryIntelligenceSceneResults_ChapterResult FOREIGN KEY (ChapterResultID) REFERENCES dbo.StoryIntelligenceChapterResults(ChapterResultID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_StoryIntelligenceRuns_CreatedUtc' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceRuns'))
CREATE INDEX IX_StoryIntelligenceRuns_CreatedUtc ON dbo.StoryIntelligenceRuns(CreatedUtc DESC);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_StoryIntelligenceRuns_ProjectBook' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceRuns'))
CREATE INDEX IX_StoryIntelligenceRuns_ProjectBook ON dbo.StoryIntelligenceRuns(ProjectID, BookID, CreatedUtc DESC);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_StoryIntelligenceChapterResults_Run' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceChapterResults'))
CREATE INDEX IX_StoryIntelligenceChapterResults_Run ON dbo.StoryIntelligenceChapterResults(StoryIntelligenceRunID);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_StoryIntelligenceSceneResults_Run' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceSceneResults'))
CREATE INDEX IX_StoryIntelligenceSceneResults_Run ON dbo.StoryIntelligenceSceneResults(StoryIntelligenceRunID, TemporarySceneNumber);
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_Save
@UserID int,
@ProjectID int = NULL,
@BookID int = NULL,
@Status nvarchar(50),
@SourceType nvarchar(50),
@PromptVersion nvarchar(200),
@Model nvarchar(100),
@StartedUtc datetime2,
@CompletedUtc datetime2 = NULL,
@TotalInputTokens int = NULL,
@TotalOutputTokens int = NULL,
@TotalTokens int = NULL,
@TotalDurationMs bigint = NULL,
@ErrorMessage nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
INSERT dbo.StoryIntelligenceRuns
(
UserID, ProjectID, BookID, Status, SourceType, PromptVersion, Model,
StartedUtc, CompletedUtc, TotalInputTokens, TotalOutputTokens, TotalTokens,
TotalDurationMs, ErrorMessage
)
VALUES
(
@UserID, @ProjectID, @BookID, @Status, @SourceType, @PromptVersion, @Model,
@StartedUtc, @CompletedUtc, @TotalInputTokens, @TotalOutputTokens, @TotalTokens,
@TotalDurationMs, @ErrorMessage
);
SELECT CAST(SCOPE_IDENTITY() AS int) AS StoryIntelligenceRunID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceChapterResult_Save
@StoryIntelligenceRunID int,
@ProjectID int = NULL,
@BookID int = NULL,
@ChapterID int = NULL,
@ChapterNumber decimal(9,2) = NULL,
@SourceLabel nvarchar(300),
@PromptVersion nvarchar(100),
@Model nvarchar(100),
@RawResponseJson nvarchar(max),
@OutputTextJson nvarchar(max),
@ParsedJson nvarchar(max),
@ValidationErrorsCount int,
@ValidationWarningsCount int,
@InputTokens int = NULL,
@OutputTokens int = NULL,
@TotalTokens int = NULL,
@DurationMs bigint = NULL
AS
BEGIN
SET NOCOUNT ON;
INSERT dbo.StoryIntelligenceChapterResults
(
StoryIntelligenceRunID, ProjectID, BookID, ChapterID, ChapterNumber,
SourceLabel, PromptVersion, Model, RawResponseJson, OutputTextJson, ParsedJson,
ValidationErrorsCount, ValidationWarningsCount, InputTokens, OutputTokens, TotalTokens, DurationMs
)
VALUES
(
@StoryIntelligenceRunID, @ProjectID, @BookID, @ChapterID, @ChapterNumber,
@SourceLabel, @PromptVersion, @Model, @RawResponseJson, @OutputTextJson, @ParsedJson,
@ValidationErrorsCount, @ValidationWarningsCount, @InputTokens, @OutputTokens, @TotalTokens, @DurationMs
);
SELECT CAST(SCOPE_IDENTITY() AS int) AS ChapterResultID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceSceneResult_Save
@StoryIntelligenceRunID int,
@ChapterResultID int = NULL,
@ProjectID int = NULL,
@BookID int = NULL,
@ChapterID int = NULL,
@SceneID int = NULL,
@TemporarySceneNumber int,
@StartParagraph int = NULL,
@EndParagraph int = NULL,
@SourceLabel nvarchar(300),
@PromptVersion nvarchar(100),
@Model nvarchar(100),
@RawResponseJson nvarchar(max),
@OutputTextJson nvarchar(max),
@ParsedJson nvarchar(max),
@ValidationErrorsCount int,
@ValidationWarningsCount int,
@InputTokens int = NULL,
@OutputTokens int = NULL,
@TotalTokens int = NULL,
@DurationMs bigint = NULL
AS
BEGIN
SET NOCOUNT ON;
INSERT dbo.StoryIntelligenceSceneResults
(
StoryIntelligenceRunID, ChapterResultID, ProjectID, BookID, ChapterID, SceneID,
TemporarySceneNumber, StartParagraph, EndParagraph, SourceLabel, PromptVersion,
Model, RawResponseJson, OutputTextJson, ParsedJson, ValidationErrorsCount,
ValidationWarningsCount, InputTokens, OutputTokens, TotalTokens, DurationMs
)
VALUES
(
@StoryIntelligenceRunID, @ChapterResultID, @ProjectID, @BookID, @ChapterID, @SceneID,
@TemporarySceneNumber, @StartParagraph, @EndParagraph, @SourceLabel, @PromptVersion,
@Model, @RawResponseJson, @OutputTextJson, @ParsedJson, @ValidationErrorsCount,
@ValidationWarningsCount, @InputTokens, @OutputTokens, @TotalTokens, @DurationMs
);
SELECT CAST(SCOPE_IDENTITY() AS int) AS SceneResultID;
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.Status,
COUNT(sr.SceneResultID) AS SceneCount,
r.TotalTokens,
r.TotalDurationMs,
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.Status, r.TotalTokens, r.TotalDurationMs,
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.Status,
r.SourceType,
r.PromptVersion,
r.Model,
r.StartedUtc,
r.CompletedUtc,
r.TotalInputTokens,
r.TotalOutputTokens,
r.TotalTokens,
r.TotalDurationMs,
r.ErrorMessage,
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
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceChapterResult_GetByRun
@StoryIntelligenceRunID int
AS
BEGIN
SET NOCOUNT ON;
SELECT TOP (1)
ChapterResultID,
StoryIntelligenceRunID,
ProjectID,
BookID,
ChapterID,
ChapterNumber,
SourceLabel,
PromptVersion,
Model,
RawResponseJson,
OutputTextJson,
ParsedJson,
ValidationErrorsCount,
ValidationWarningsCount,
InputTokens,
OutputTokens,
TotalTokens,
DurationMs,
CreatedUtc
FROM dbo.StoryIntelligenceChapterResults
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
ORDER BY ChapterResultID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceSceneResult_ListByRun
@StoryIntelligenceRunID int
AS
BEGIN
SET NOCOUNT ON;
SELECT
SceneResultID,
StoryIntelligenceRunID,
ChapterResultID,
ProjectID,
BookID,
ChapterID,
SceneID,
TemporarySceneNumber,
StartParagraph,
EndParagraph,
SourceLabel,
PromptVersion,
Model,
RawResponseJson,
OutputTextJson,
ParsedJson,
ValidationErrorsCount,
ValidationWarningsCount,
InputTokens,
OutputTokens,
TotalTokens,
DurationMs,
CreatedUtc
FROM dbo.StoryIntelligenceSceneResults
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
ORDER BY TemporarySceneNumber, SceneResultID;
END;
GO

View File

@ -234,9 +234,11 @@ public sealed class ChapterStoryIntelligenceDryRunResultViewModel
public string ChapterContextJson { get; init; } = string.Empty;
public int ParagraphCount { get; init; }
public string TotalDuration { get; init; } = string.Empty;
public long? TotalDurationMs { get; init; }
public int? TotalInputTokens { get; init; }
public int? TotalOutputTokens { get; init; }
public string ChapterDuration { get; init; } = string.Empty;
public long? ChapterDurationMs { get; init; }
public int ChapterRetryCount { get; init; }
public int? ChapterInputTokens { get; init; }
public int? ChapterOutputTokens { get; init; }
@ -262,6 +264,7 @@ public sealed record ChapterStorySceneBlockViewModel
public string SplitErrorMessage { get; init; } = string.Empty;
public bool Success { get; init; }
public string Duration { get; init; } = string.Empty;
public long? DurationMs { get; init; }
public int RetryCount { get; init; }
public int? InputTokens { get; init; }
public int? OutputTokens { get; init; }
@ -272,6 +275,23 @@ public sealed record ChapterStorySceneBlockViewModel
public string? ErrorMessage { get; init; }
}
public sealed class StoryIntelligenceSaveRunForm
{
public string Payload { get; set; } = string.Empty;
}
public sealed class StoryIntelligenceSavedRunsViewModel
{
public IReadOnlyList<StoryIntelligenceSavedRunListItem> Runs { get; init; } = [];
}
public sealed class StoryIntelligenceSavedRunDetailViewModel
{
public StoryIntelligenceSavedRun? Run { get; init; }
public StoryIntelligenceSavedChapterResult? ChapterResult { get; init; }
public IReadOnlyList<StoryIntelligenceSavedSceneResult> SceneResults { get; init; } = [];
}
public sealed class AdminFeatureRequestFilter
{
public string? Status { get; set; }

View File

@ -2,13 +2,23 @@
@{
ViewData["Title"] = "Chapter Story Intelligence Test";
var result = Model.Result;
var canSave = result is not null && !result.PipelineStopped && string.IsNullOrWhiteSpace(result.ErrorMessage);
var savePayload = canSave
? System.Text.Json.JsonSerializer.Serialize(
Model,
new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
WriteIndented = false
})
: string.Empty;
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Admin utility</p>
<h1>Chapter Story Intelligence Test</h1>
<p class="lead-text">This in-memory dry run detects suggested scene boundaries and then runs Scene Intelligence sequentially for each suggested scene. It does not save data.</p>
<p class="lead-text">This dry run detects suggested scene boundaries, runs Scene Intelligence sequentially, and can save the resulting audit data only when you choose to save it.</p>
</div>
</div>
@ -108,6 +118,22 @@
<dt>Total output tokens</dt>
<dd>@Display(result.TotalOutputTokens)</dd>
</dl>
@if (canSave)
{
<form asp-action="SaveChapterStoryIntelligenceRun" method="post" class="mt-3">
<textarea name="Payload" class="d-none" aria-hidden="true">@savePayload</textarea>
<div class="button-row">
<button class="btn btn-primary" type="submit">Save this run</button>
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRuns">View saved runs</a>
</div>
<p class="muted mt-2 mb-0">Saving creates a new audit record each time. It stores raw and parsed AI output plus validation counts, and does not create PlotDirector scenes or story entities.</p>
</form>
}
else
{
<div class="alert alert-warning mt-3 mb-0">This run cannot be saved because the combined pipeline did not complete.</div>
}
</section>
<section class="edit-panel">

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="StoryIntelligenceRuns">Saved runs</a>
</div>
</article>
<article class="project-card">

View File

@ -0,0 +1,183 @@
@model StoryIntelligenceSavedRunDetailViewModel
@{
ViewData["Title"] = "Story Intelligence Run";
var run = Model.Run;
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Admin utility</p>
<h1>Story Intelligence Run @(run is null ? string.Empty : run.StoryIntelligenceRunID.ToString("N0"))</h1>
<p class="lead-text">Saved audit output from the admin chapter-to-scene dry-run pipeline.</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"> / </span>
<a asp-action="StoryIntelligenceRuns">Saved Story Intelligence Runs</a>
<span class="muted"> / Run @(run is null ? string.Empty : run.StoryIntelligenceRunID.ToString("N0"))</span>
</nav>
@if (TempData["AdminMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
@if (run is null)
{
<section class="edit-panel">
<p class="mb-0">This Story Intelligence run could not be found.</p>
</section>
}
else
{
<section class="edit-panel">
<h2>Run metadata</h2>
<div class="row g-3">
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Status</p>
<h3 class="mb-0">@run.Status</h3>
</div>
</div>
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Scenes</p>
<h3 class="mb-0">@Model.SceneResults.Count.ToString("N0")</h3>
</div>
</div>
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Tokens</p>
<h3 class="mb-0">@Display(run.TotalTokens)</h3>
</div>
</div>
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Duration</p>
<h3 class="mb-0">@DisplayDuration(run.TotalDurationMs)</h3>
</div>
</div>
</div>
<dl class="mt-3 mb-0">
<dt>Source type</dt>
<dd>@run.SourceType</dd>
<dt>Project</dt>
<dd>@DisplayName(run.ProjectTitle, run.ProjectID, "Project")</dd>
<dt>Book</dt>
<dd>@DisplayName(run.BookTitle, run.BookID, "Book")</dd>
<dt>Prompt version</dt>
<dd>@run.PromptVersion</dd>
<dt>Model</dt>
<dd>@run.Model</dd>
<dt>Started</dt>
<dd>@run.StartedUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</dd>
<dt>Completed</dt>
<dd>@(run.CompletedUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC</dd>
<dt>Input tokens</dt>
<dd>@Display(run.TotalInputTokens)</dd>
<dt>Output tokens</dt>
<dd>@Display(run.TotalOutputTokens)</dd>
<dt>Error message</dt>
<dd>@Display(run.ErrorMessage)</dd>
</dl>
</section>
<section class="edit-panel">
<h2>Chapter result</h2>
@if (Model.ChapterResult is null)
{
<p class="mb-0">No chapter result was saved for this run.</p>
}
else
{
var chapter = Model.ChapterResult;
<dl>
<dt>Source label</dt>
<dd>@chapter.SourceLabel</dd>
<dt>Chapter</dt>
<dd>@Display(chapter.ChapterNumber)</dd>
<dt>Prompt version</dt>
<dd>@chapter.PromptVersion</dd>
<dt>Validation</dt>
<dd>@chapter.ValidationErrorsCount.ToString("N0") error(s), @chapter.ValidationWarningsCount.ToString("N0") warning(s)</dd>
<dt>Duration</dt>
<dd>@DisplayDuration(chapter.DurationMs)</dd>
<dt>Tokens</dt>
<dd>@Display(chapter.TotalTokens) total (@Display(chapter.InputTokens) input, @Display(chapter.OutputTokens) output)</dd>
</dl>
<label class="form-label" for="chapterParsedJson">Parsed JSON</label>
<textarea class="form-control font-monospace" id="chapterParsedJson" rows="10" readonly>@chapter.ParsedJson</textarea>
<label class="form-label mt-3" for="chapterOutputJson">Extracted output JSON</label>
<textarea class="form-control font-monospace" id="chapterOutputJson" rows="10" readonly>@chapter.OutputTextJson</textarea>
<label class="form-label mt-3" for="chapterRawJson">Raw OpenAI response JSON</label>
<textarea class="form-control font-monospace" id="chapterRawJson" rows="10" readonly>@chapter.RawResponseJson</textarea>
}
</section>
<section class="edit-panel">
<h2>Scene results</h2>
@if (Model.SceneResults.Count == 0)
{
<p class="mb-0">No scene results were saved for this run.</p>
}
</section>
@foreach (var scene in Model.SceneResults)
{
<section class="edit-panel">
<h2>Suggested Scene @scene.TemporarySceneNumber.ToString("N0")</h2>
<dl>
<dt>Paragraph range</dt>
<dd>@Display(scene.StartParagraph)-@Display(scene.EndParagraph)</dd>
<dt>Source label</dt>
<dd>@scene.SourceLabel</dd>
<dt>Prompt version</dt>
<dd>@scene.PromptVersion</dd>
<dt>Validation</dt>
<dd>@scene.ValidationErrorsCount.ToString("N0") error(s), @scene.ValidationWarningsCount.ToString("N0") warning(s)</dd>
<dt>Duration</dt>
<dd>@DisplayDuration(scene.DurationMs)</dd>
<dt>Tokens</dt>
<dd>@Display(scene.TotalTokens) total (@Display(scene.InputTokens) input, @Display(scene.OutputTokens) output)</dd>
</dl>
<label class="form-label" for="sceneParsedJson-@scene.SceneResultID">Parsed JSON</label>
<textarea class="form-control font-monospace" id="sceneParsedJson-@scene.SceneResultID" rows="10" readonly>@scene.ParsedJson</textarea>
<label class="form-label mt-3" for="sceneOutputJson-@scene.SceneResultID">Extracted output JSON</label>
<textarea class="form-control font-monospace" id="sceneOutputJson-@scene.SceneResultID" rows="10" readonly>@scene.OutputTextJson</textarea>
<label class="form-label mt-3" for="sceneRawJson-@scene.SceneResultID">Raw OpenAI response JSON</label>
<textarea class="form-control font-monospace" id="sceneRawJson-@scene.SceneResultID" rows="10" readonly>@scene.RawResponseJson</textarea>
</section>
}
}
@functions {
private static string Display(object? value) => value switch
{
null => "None",
string text when string.IsNullOrWhiteSpace(text) => "None",
decimal number => number.ToString("0.##"),
int number => number.ToString("N0"),
_ => value.ToString() ?? "None"
};
private static string DisplayDuration(long? milliseconds)
=> milliseconds.HasValue
? TimeSpan.FromMilliseconds(milliseconds.Value).TotalSeconds < 1
? $"{milliseconds.Value:N0} ms"
: $"{TimeSpan.FromMilliseconds(milliseconds.Value).TotalSeconds:0.00} sec"
: "None";
private static string DisplayName(string? title, int? id, string label)
=> !string.IsNullOrWhiteSpace(title)
? title
: id.HasValue
? $"{label} {id.Value:N0}"
: "None";
}

View File

@ -0,0 +1,99 @@
@model StoryIntelligenceSavedRunsViewModel
@{
ViewData["Title"] = "Saved Story Intelligence Runs";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Admin utility</p>
<h1>Saved Story Intelligence Runs</h1>
<p class="lead-text">Audit records saved from the admin chapter-to-scene dry-run pipeline.</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"> / Saved Story Intelligence Runs</span>
</nav>
@if (TempData["AdminMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
<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-secondary" asp-action="StoryIntelligenceDiagnostics">Diagnostics</a>
</div>
@if (Model.Runs.Count == 0)
{
<p class="mb-0">No Story Intelligence runs have been saved yet.</p>
}
else
{
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Run ID</th>
<th>Created</th>
<th>Project / Book</th>
<th>Status</th>
<th>Scenes</th>
<th>Tokens</th>
<th>Duration</th>
<th>Validation</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var run in Model.Runs)
{
<tr>
<td>@run.StoryIntelligenceRunID.ToString("N0")</td>
<td>@run.CreatedUtc.ToString("yyyy-MM-dd HH:mm") UTC</td>
<td>
<div>@DisplayName(run.ProjectTitle, run.ProjectID, "Project")</div>
<div class="muted">@DisplayName(run.BookTitle, run.BookID, "Book")</div>
</td>
<td>@run.Status</td>
<td>@run.SceneCount.ToString("N0")</td>
<td>@Display(run.TotalTokens)</td>
<td>@DisplayDuration(run.TotalDurationMs)</td>
<td>@run.ValidationErrorsCount.ToString("N0") error(s), @run.ValidationWarningsCount.ToString("N0") warning(s)</td>
<td><a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceRunDetails" asp-route-id="@run.StoryIntelligenceRunID">Open</a></td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
@functions {
private static string Display(object? value) => value switch
{
null => "None",
string text when string.IsNullOrWhiteSpace(text) => "None",
int number => number.ToString("N0"),
_ => value.ToString() ?? "None"
};
private static string DisplayDuration(long? milliseconds)
=> milliseconds.HasValue
? TimeSpan.FromMilliseconds(milliseconds.Value).TotalSeconds < 1
? $"{milliseconds.Value:N0} ms"
: $"{TimeSpan.FromMilliseconds(milliseconds.Value).TotalSeconds:0.00} sec"
: "None";
private static string DisplayName(string? title, int? id, string label)
=> !string.IsNullOrWhiteSpace(title)
? title
: id.HasValue
? $"{label} {id.Value:N0}"
: "None";
}