diff --git a/PlotLine/Controllers/AdminController.cs b/PlotLine/Controllers/AdminController.cs index 677afa7..130c76c 100644 --- a/PlotLine/Controllers/AdminController.cs +++ b/PlotLine/Controllers/AdminController.cs @@ -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 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 StoryIntelligenceRuns() + { + return View(await storyIntelligenceResults.ListRunsAsync()); + } + + [HttpGet("story-intelligence-runs/{id:int}")] + public async Task StoryIntelligenceRunDetails(int id) + { + var model = await storyIntelligenceResults.GetRunDetailAsync(id); + return model is null ? NotFound() : View(model); + } + [HttpGet("feature-requests")] public async Task FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort) { diff --git a/PlotLine/Data/StoryIntelligenceResultRepository.cs b/PlotLine/Data/StoryIntelligenceResultRepository.cs new file mode 100644 index 0000000..489df6d --- /dev/null +++ b/PlotLine/Data/StoryIntelligenceResultRepository.cs @@ -0,0 +1,156 @@ +using System.Data; +using Dapper; +using PlotLine.Models; + +namespace PlotLine.Data; + +public interface IStoryIntelligenceResultRepository +{ + Task SaveAsync(StoryIntelligenceRunSaveRequest request); + Task> ListRunsAsync(); + Task GetRunAsync(int runId); + Task GetChapterResultAsync(int runId); + Task> ListSceneResultsAsync(int runId); +} + +public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceResultRepository +{ + public async Task SaveAsync(StoryIntelligenceRunSaveRequest request) + { + using var connection = connectionFactory.CreateConnection(); + connection.Open(); + using var transaction = connection.BeginTransaction(); + + try + { + var runId = await connection.QuerySingleAsync( + "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( + "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> ListRunsAsync() + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.StoryIntelligenceRun_ListAdmin", + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task GetRunAsync(int runId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceRun_GetAdmin", + new { StoryIntelligenceRunID = runId }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetChapterResultAsync(int runId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceChapterResult_GetByRun", + new { StoryIntelligenceRunID = runId }, + commandType: CommandType.StoredProcedure); + } + + public async Task> ListSceneResultsAsync(int runId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.StoryIntelligenceSceneResult_ListByRun", + new { StoryIntelligenceRunID = runId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } +} diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs new file mode 100644 index 0000000..e35ec57 --- /dev/null +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -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 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; } +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index d413259..ce1170e 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -136,6 +136,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -193,6 +194,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/ChapterStoryIntelligenceDryRunService.cs b/PlotLine/Services/ChapterStoryIntelligenceDryRunService.cs index 61c154a..ca2a1da 100644 --- a/PlotLine/Services/ChapterStoryIntelligenceDryRunService.cs +++ b/PlotLine/Services/ChapterStoryIntelligenceDryRunService.cs @@ -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, diff --git a/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs b/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs new file mode 100644 index 0000000..af7acf4 --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs @@ -0,0 +1,187 @@ +using System.Text.Json; +using PlotLine.Data; +using PlotLine.Models; +using PlotLine.ViewModels; + +namespace PlotLine.Services; + +public interface IStoryIntelligenceResultPersistenceService +{ + Task SaveDisplayedRunAsync(int userId, string payload); + Task ListRunsAsync(); + Task GetRunDetailAsync(int runId); +} + +public sealed class StoryIntelligenceResultPersistenceService( + IStoryIntelligenceResultRepository repository, + ILogger logger) : IStoryIntelligenceResultPersistenceService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true + }; + + public async Task SaveDisplayedRunAsync(int userId, string payload) + { + if (string.IsNullOrWhiteSpace(payload)) + { + throw new InvalidOperationException("No dry-run result was submitted for saving."); + } + + var dryRun = JsonSerializer.Deserialize(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 ListRunsAsync() + => new() { Runs = await repository.ListRunsAsync() }; + + public async Task 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? 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; +} diff --git a/PlotLine/Sql/115_Phase20I_StoryIntelligenceResultPersistence.sql b/PlotLine/Sql/115_Phase20I_StoryIntelligenceResultPersistence.sql new file mode 100644 index 0000000..5792647 --- /dev/null +++ b/PlotLine/Sql/115_Phase20I_StoryIntelligenceResultPersistence.sql @@ -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 diff --git a/PlotLine/ViewModels/FeatureRequestViewModels.cs b/PlotLine/ViewModels/FeatureRequestViewModels.cs index b8a768d..eb75b73 100644 --- a/PlotLine/ViewModels/FeatureRequestViewModels.cs +++ b/PlotLine/ViewModels/FeatureRequestViewModels.cs @@ -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 Runs { get; init; } = []; +} + +public sealed class StoryIntelligenceSavedRunDetailViewModel +{ + public StoryIntelligenceSavedRun? Run { get; init; } + public StoryIntelligenceSavedChapterResult? ChapterResult { get; init; } + public IReadOnlyList SceneResults { get; init; } = []; +} + public sealed class AdminFeatureRequestFilter { public string? Status { get; set; } diff --git a/PlotLine/Views/Admin/ChapterStoryIntelligenceTest.cshtml b/PlotLine/Views/Admin/ChapterStoryIntelligenceTest.cshtml index 30d6698..1590d03 100644 --- a/PlotLine/Views/Admin/ChapterStoryIntelligenceTest.cshtml +++ b/PlotLine/Views/Admin/ChapterStoryIntelligenceTest.cshtml @@ -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; }

Admin utility

Chapter Story Intelligence Test

-

This in-memory dry run detects suggested scene boundaries and then runs Scene Intelligence sequentially for each suggested scene. It does not save data.

+

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.

@@ -108,6 +118,22 @@
Total output tokens
@Display(result.TotalOutputTokens)
+ + @if (canSave) + { +
+ +
+ + View saved runs +
+

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.

+
+ } + else + { +
This run cannot be saved because the combined pipeline did not complete.
+ }
diff --git a/PlotLine/Views/Admin/Index.cshtml b/PlotLine/Views/Admin/Index.cshtml index ad4f8df..195c0ac 100644 --- a/PlotLine/Views/Admin/Index.cshtml +++ b/PlotLine/Views/Admin/Index.cshtml @@ -85,6 +85,7 @@ Dry run Chapter test Pipeline test + Saved runs
diff --git a/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml b/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml new file mode 100644 index 0000000..82c6fe3 --- /dev/null +++ b/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml @@ -0,0 +1,183 @@ +@model StoryIntelligenceSavedRunDetailViewModel +@{ + ViewData["Title"] = "Story Intelligence Run"; + var run = Model.Run; +} + +
+
+

Admin utility

+

Story Intelligence Run @(run is null ? string.Empty : run.StoryIntelligenceRunID.ToString("N0"))

+

Saved audit output from the admin chapter-to-scene dry-run pipeline.

+
+
+ + + +@if (TempData["AdminMessage"] is string message) +{ +
@message
+} + +@if (run is null) +{ +
+

This Story Intelligence run could not be found.

+
+} +else +{ +
+

Run metadata

+
+
+
+

Status

+

@run.Status

+
+
+
+
+

Scenes

+

@Model.SceneResults.Count.ToString("N0")

+
+
+
+
+

Tokens

+

@Display(run.TotalTokens)

+
+
+
+
+

Duration

+

@DisplayDuration(run.TotalDurationMs)

+
+
+
+ +
+
Source type
+
@run.SourceType
+
Project
+
@DisplayName(run.ProjectTitle, run.ProjectID, "Project")
+
Book
+
@DisplayName(run.BookTitle, run.BookID, "Book")
+
Prompt version
+
@run.PromptVersion
+
Model
+
@run.Model
+
Started
+
@run.StartedUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC
+
Completed
+
@(run.CompletedUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC
+
Input tokens
+
@Display(run.TotalInputTokens)
+
Output tokens
+
@Display(run.TotalOutputTokens)
+
Error message
+
@Display(run.ErrorMessage)
+
+
+ +
+

Chapter result

+ @if (Model.ChapterResult is null) + { +

No chapter result was saved for this run.

+ } + else + { + var chapter = Model.ChapterResult; +
+
Source label
+
@chapter.SourceLabel
+
Chapter
+
@Display(chapter.ChapterNumber)
+
Prompt version
+
@chapter.PromptVersion
+
Validation
+
@chapter.ValidationErrorsCount.ToString("N0") error(s), @chapter.ValidationWarningsCount.ToString("N0") warning(s)
+
Duration
+
@DisplayDuration(chapter.DurationMs)
+
Tokens
+
@Display(chapter.TotalTokens) total (@Display(chapter.InputTokens) input, @Display(chapter.OutputTokens) output)
+
+ + + + + + + + } +
+ +
+

Scene results

+ @if (Model.SceneResults.Count == 0) + { +

No scene results were saved for this run.

+ } +
+ + @foreach (var scene in Model.SceneResults) + { +
+

Suggested Scene @scene.TemporarySceneNumber.ToString("N0")

+
+
Paragraph range
+
@Display(scene.StartParagraph)-@Display(scene.EndParagraph)
+
Source label
+
@scene.SourceLabel
+
Prompt version
+
@scene.PromptVersion
+
Validation
+
@scene.ValidationErrorsCount.ToString("N0") error(s), @scene.ValidationWarningsCount.ToString("N0") warning(s)
+
Duration
+
@DisplayDuration(scene.DurationMs)
+
Tokens
+
@Display(scene.TotalTokens) total (@Display(scene.InputTokens) input, @Display(scene.OutputTokens) output)
+
+ + + + + + + +
+ } +} + +@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"; +} diff --git a/PlotLine/Views/Admin/StoryIntelligenceRuns.cshtml b/PlotLine/Views/Admin/StoryIntelligenceRuns.cshtml new file mode 100644 index 0000000..41c67d8 --- /dev/null +++ b/PlotLine/Views/Admin/StoryIntelligenceRuns.cshtml @@ -0,0 +1,99 @@ +@model StoryIntelligenceSavedRunsViewModel +@{ + ViewData["Title"] = "Saved Story Intelligence Runs"; +} + +
+
+

Admin utility

+

Saved Story Intelligence Runs

+

Audit records saved from the admin chapter-to-scene dry-run pipeline.

+
+
+ + + +@if (TempData["AdminMessage"] is string message) +{ +
@message
+} + +
+ + + @if (Model.Runs.Count == 0) + { +

No Story Intelligence runs have been saved yet.

+ } + else + { +
+ + + + + + + + + + + + + + + + @foreach (var run in Model.Runs) + { + + + + + + + + + + + + } + +
Run IDCreatedProject / BookStatusScenesTokensDurationValidation
@run.StoryIntelligenceRunID.ToString("N0")@run.CreatedUtc.ToString("yyyy-MM-dd HH:mm") UTC +
@DisplayName(run.ProjectTitle, run.ProjectID, "Project")
+
@DisplayName(run.BookTitle, run.BookID, "Book")
+
@run.Status@run.SceneCount.ToString("N0")@Display(run.TotalTokens)@DisplayDuration(run.TotalDurationMs)@run.ValidationErrorsCount.ToString("N0") error(s), @run.ValidationWarningsCount.ToString("N0") warning(s)Open
+
+ } +
+ +@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"; +}