Phase 20O – Persist Story Intelligence Runs
This commit is contained in:
parent
7ccc0447a3
commit
35e76d8ac4
@ -15,7 +15,8 @@ public sealed class AdminController(
|
|||||||
IStoryIntelligenceDiagnosticsService storyIntelligenceDiagnostics,
|
IStoryIntelligenceDiagnosticsService storyIntelligenceDiagnostics,
|
||||||
IStoryIntelligenceDryRunService storyIntelligenceDryRun,
|
IStoryIntelligenceDryRunService storyIntelligenceDryRun,
|
||||||
IChapterStructureDryRunService chapterStructureDryRun,
|
IChapterStructureDryRunService chapterStructureDryRun,
|
||||||
IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun) : Controller
|
IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun,
|
||||||
|
IStoryIntelligenceResultPersistenceService storyIntelligenceResults) : Controller
|
||||||
{
|
{
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
[HttpGet("index")]
|
[HttpGet("index")]
|
||||||
@ -78,6 +79,33 @@ public sealed class AdminController(
|
|||||||
return View(await chapterStoryIntelligenceDryRun.ExecuteAsync(form, cancellationToken));
|
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")]
|
[HttpGet("feature-requests")]
|
||||||
public async Task<IActionResult> FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort)
|
public async Task<IActionResult> FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort)
|
||||||
{
|
{
|
||||||
|
|||||||
156
PlotLine/Data/StoryIntelligenceResultRepository.cs
Normal file
156
PlotLine/Data/StoryIntelligenceResultRepository.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
161
PlotLine/Models/StoryIntelligencePersistenceModels.cs
Normal file
161
PlotLine/Models/StoryIntelligencePersistenceModels.cs
Normal 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; }
|
||||||
|
}
|
||||||
@ -136,6 +136,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IOnboardingRepository, OnboardingRepository>();
|
builder.Services.AddScoped<IOnboardingRepository, OnboardingRepository>();
|
||||||
builder.Services.AddScoped<IOnboardingBuildRepository, OnboardingBuildRepository>();
|
builder.Services.AddScoped<IOnboardingBuildRepository, OnboardingBuildRepository>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceRepository, StoryIntelligenceRepository>();
|
builder.Services.AddScoped<IStoryIntelligenceRepository, StoryIntelligenceRepository>();
|
||||||
|
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
|
||||||
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
||||||
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
||||||
builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>();
|
builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>();
|
||||||
@ -193,6 +194,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IStoryIntelligenceDryRunService, StoryIntelligenceDryRunService>();
|
builder.Services.AddScoped<IStoryIntelligenceDryRunService, StoryIntelligenceDryRunService>();
|
||||||
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
|
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
|
||||||
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
|
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
|
||||||
|
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
|
||||||
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
||||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||||
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
||||||
|
|||||||
@ -71,9 +71,11 @@ public sealed class ChapterStoryIntelligenceDryRunService(
|
|||||||
ChapterContextJson = chapterContextJson,
|
ChapterContextJson = chapterContextJson,
|
||||||
ParagraphCount = paragraphs.Count,
|
ParagraphCount = paragraphs.Count,
|
||||||
TotalDuration = FormatDuration(totalStopwatch.Elapsed),
|
TotalDuration = FormatDuration(totalStopwatch.Elapsed),
|
||||||
|
TotalDurationMs = totalStopwatch.ElapsedMilliseconds,
|
||||||
TotalInputTokens = chapterResult.InputTokens,
|
TotalInputTokens = chapterResult.InputTokens,
|
||||||
TotalOutputTokens = chapterResult.OutputTokens,
|
TotalOutputTokens = chapterResult.OutputTokens,
|
||||||
ChapterDuration = FormatDuration(chapterResult.Duration),
|
ChapterDuration = FormatDuration(chapterResult.Duration),
|
||||||
|
ChapterDurationMs = Convert.ToInt64(chapterResult.Duration.TotalMilliseconds),
|
||||||
ChapterRetryCount = chapterResult.RetryCount,
|
ChapterRetryCount = chapterResult.RetryCount,
|
||||||
ChapterInputTokens = chapterResult.InputTokens,
|
ChapterInputTokens = chapterResult.InputTokens,
|
||||||
ChapterOutputTokens = chapterResult.OutputTokens,
|
ChapterOutputTokens = chapterResult.OutputTokens,
|
||||||
@ -114,9 +116,11 @@ public sealed class ChapterStoryIntelligenceDryRunService(
|
|||||||
ChapterContextJson = chapterContextJson,
|
ChapterContextJson = chapterContextJson,
|
||||||
ParagraphCount = paragraphs.Count,
|
ParagraphCount = paragraphs.Count,
|
||||||
TotalDuration = FormatDuration(totalStopwatch.Elapsed),
|
TotalDuration = FormatDuration(totalStopwatch.Elapsed),
|
||||||
|
TotalDurationMs = totalStopwatch.ElapsedMilliseconds,
|
||||||
TotalInputTokens = SumTokens(chapterResult.InputTokens, sceneBlocks.Select(block => block.InputTokens)),
|
TotalInputTokens = SumTokens(chapterResult.InputTokens, sceneBlocks.Select(block => block.InputTokens)),
|
||||||
TotalOutputTokens = SumTokens(chapterResult.OutputTokens, sceneBlocks.Select(block => block.OutputTokens)),
|
TotalOutputTokens = SumTokens(chapterResult.OutputTokens, sceneBlocks.Select(block => block.OutputTokens)),
|
||||||
ChapterDuration = FormatDuration(chapterResult.Duration),
|
ChapterDuration = FormatDuration(chapterResult.Duration),
|
||||||
|
ChapterDurationMs = Convert.ToInt64(chapterResult.Duration.TotalMilliseconds),
|
||||||
ChapterRetryCount = chapterResult.RetryCount,
|
ChapterRetryCount = chapterResult.RetryCount,
|
||||||
ChapterInputTokens = chapterResult.InputTokens,
|
ChapterInputTokens = chapterResult.InputTokens,
|
||||||
ChapterOutputTokens = chapterResult.OutputTokens,
|
ChapterOutputTokens = chapterResult.OutputTokens,
|
||||||
@ -144,6 +148,7 @@ public sealed class ChapterStoryIntelligenceDryRunService(
|
|||||||
ChapterContextJson = chapterContextJson,
|
ChapterContextJson = chapterContextJson,
|
||||||
ParagraphCount = paragraphs.Count,
|
ParagraphCount = paragraphs.Count,
|
||||||
TotalDuration = FormatDuration(totalStopwatch.Elapsed),
|
TotalDuration = FormatDuration(totalStopwatch.Elapsed),
|
||||||
|
TotalDurationMs = totalStopwatch.ElapsedMilliseconds,
|
||||||
ErrorMessage = ex.Message
|
ErrorMessage = ex.Message
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -190,6 +195,7 @@ public sealed class ChapterStoryIntelligenceDryRunService(
|
|||||||
Success = true,
|
Success = true,
|
||||||
SceneContextJson = sceneContextJson,
|
SceneContextJson = sceneContextJson,
|
||||||
Duration = FormatDuration(result.Duration),
|
Duration = FormatDuration(result.Duration),
|
||||||
|
DurationMs = Convert.ToInt64(result.Duration.TotalMilliseconds),
|
||||||
RetryCount = result.RetryCount,
|
RetryCount = result.RetryCount,
|
||||||
InputTokens = result.InputTokens,
|
InputTokens = result.InputTokens,
|
||||||
OutputTokens = result.OutputTokens,
|
OutputTokens = result.OutputTokens,
|
||||||
|
|||||||
187
PlotLine/Services/StoryIntelligenceResultPersistenceService.cs
Normal file
187
PlotLine/Services/StoryIntelligenceResultPersistenceService.cs
Normal 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;
|
||||||
|
}
|
||||||
360
PlotLine/Sql/115_Phase20I_StoryIntelligenceResultPersistence.sql
Normal file
360
PlotLine/Sql/115_Phase20I_StoryIntelligenceResultPersistence.sql
Normal 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
|
||||||
@ -234,9 +234,11 @@ public sealed class ChapterStoryIntelligenceDryRunResultViewModel
|
|||||||
public string ChapterContextJson { get; init; } = string.Empty;
|
public string ChapterContextJson { get; init; } = string.Empty;
|
||||||
public int ParagraphCount { get; init; }
|
public int ParagraphCount { get; init; }
|
||||||
public string TotalDuration { get; init; } = string.Empty;
|
public string TotalDuration { get; init; } = string.Empty;
|
||||||
|
public long? TotalDurationMs { get; init; }
|
||||||
public int? TotalInputTokens { get; init; }
|
public int? TotalInputTokens { get; init; }
|
||||||
public int? TotalOutputTokens { get; init; }
|
public int? TotalOutputTokens { get; init; }
|
||||||
public string ChapterDuration { get; init; } = string.Empty;
|
public string ChapterDuration { get; init; } = string.Empty;
|
||||||
|
public long? ChapterDurationMs { get; init; }
|
||||||
public int ChapterRetryCount { get; init; }
|
public int ChapterRetryCount { get; init; }
|
||||||
public int? ChapterInputTokens { get; init; }
|
public int? ChapterInputTokens { get; init; }
|
||||||
public int? ChapterOutputTokens { get; init; }
|
public int? ChapterOutputTokens { get; init; }
|
||||||
@ -262,6 +264,7 @@ public sealed record ChapterStorySceneBlockViewModel
|
|||||||
public string SplitErrorMessage { get; init; } = string.Empty;
|
public string SplitErrorMessage { get; init; } = string.Empty;
|
||||||
public bool Success { get; init; }
|
public bool Success { get; init; }
|
||||||
public string Duration { get; init; } = string.Empty;
|
public string Duration { get; init; } = string.Empty;
|
||||||
|
public long? DurationMs { get; init; }
|
||||||
public int RetryCount { get; init; }
|
public int RetryCount { get; init; }
|
||||||
public int? InputTokens { get; init; }
|
public int? InputTokens { get; init; }
|
||||||
public int? OutputTokens { get; init; }
|
public int? OutputTokens { get; init; }
|
||||||
@ -272,6 +275,23 @@ public sealed record ChapterStorySceneBlockViewModel
|
|||||||
public string? ErrorMessage { get; init; }
|
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 sealed class AdminFeatureRequestFilter
|
||||||
{
|
{
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
|
|||||||
@ -2,13 +2,23 @@
|
|||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Chapter Story Intelligence Test";
|
ViewData["Title"] = "Chapter Story Intelligence Test";
|
||||||
var result = Model.Result;
|
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 class="page-heading compact">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Admin utility</p>
|
<p class="eyebrow">Admin utility</p>
|
||||||
<h1>Chapter Story Intelligence Test</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -108,6 +118,22 @@
|
|||||||
<dt>Total output tokens</dt>
|
<dt>Total output tokens</dt>
|
||||||
<dd>@Display(result.TotalOutputTokens)</dd>
|
<dd>@Display(result.TotalOutputTokens)</dd>
|
||||||
</dl>
|
</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>
|
||||||
|
|
||||||
<section class="edit-panel">
|
<section class="edit-panel">
|
||||||
|
|||||||
@ -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="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="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="ChapterStoryIntelligenceTest">Pipeline test</a>
|
||||||
|
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceRuns">Saved runs</a>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="project-card">
|
<article class="project-card">
|
||||||
|
|||||||
183
PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml
Normal file
183
PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml
Normal 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";
|
||||||
|
}
|
||||||
99
PlotLine/Views/Admin/StoryIntelligenceRuns.cshtml
Normal file
99
PlotLine/Views/Admin/StoryIntelligenceRuns.cshtml
Normal 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";
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user