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,
|
||||
IStoryIntelligenceDryRunService storyIntelligenceDryRun,
|
||||
IChapterStructureDryRunService chapterStructureDryRun,
|
||||
IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun) : Controller
|
||||
IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun,
|
||||
IStoryIntelligenceResultPersistenceService storyIntelligenceResults) : Controller
|
||||
{
|
||||
[HttpGet("")]
|
||||
[HttpGet("index")]
|
||||
@ -78,6 +79,33 @@ public sealed class AdminController(
|
||||
return View(await chapterStoryIntelligenceDryRun.ExecuteAsync(form, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("chapter-story-intelligence-test/save")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SaveChapterStoryIntelligenceRun(StoryIntelligenceSaveRunForm form)
|
||||
{
|
||||
if (currentUser.UserId is not int userId)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var runId = await storyIntelligenceResults.SaveDisplayedRunAsync(userId, form.Payload);
|
||||
TempData["AdminMessage"] = $"Story Intelligence run {runId:N0} saved. Saving the same displayed dry run again creates a separate audit record.";
|
||||
return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId });
|
||||
}
|
||||
|
||||
[HttpGet("story-intelligence-runs")]
|
||||
public async Task<IActionResult> StoryIntelligenceRuns()
|
||||
{
|
||||
return View(await storyIntelligenceResults.ListRunsAsync());
|
||||
}
|
||||
|
||||
[HttpGet("story-intelligence-runs/{id:int}")]
|
||||
public async Task<IActionResult> StoryIntelligenceRunDetails(int id)
|
||||
{
|
||||
var model = await storyIntelligenceResults.GetRunDetailAsync(id);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
[HttpGet("feature-requests")]
|
||||
public async Task<IActionResult> FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort)
|
||||
{
|
||||
|
||||
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<IOnboardingBuildRepository, OnboardingBuildRepository>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceRepository, StoryIntelligenceRepository>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
|
||||
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
||||
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
||||
builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>();
|
||||
@ -193,6 +194,7 @@ public class Program
|
||||
builder.Services.AddScoped<IStoryIntelligenceDryRunService, StoryIntelligenceDryRunService>();
|
||||
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
|
||||
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
|
||||
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
||||
|
||||
@ -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,
|
||||
|
||||
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 int ParagraphCount { get; init; }
|
||||
public string TotalDuration { get; init; } = string.Empty;
|
||||
public long? TotalDurationMs { get; init; }
|
||||
public int? TotalInputTokens { get; init; }
|
||||
public int? TotalOutputTokens { get; init; }
|
||||
public string ChapterDuration { get; init; } = string.Empty;
|
||||
public long? ChapterDurationMs { get; init; }
|
||||
public int ChapterRetryCount { get; init; }
|
||||
public int? ChapterInputTokens { get; init; }
|
||||
public int? ChapterOutputTokens { get; init; }
|
||||
@ -262,6 +264,7 @@ public sealed record ChapterStorySceneBlockViewModel
|
||||
public string SplitErrorMessage { get; init; } = string.Empty;
|
||||
public bool Success { get; init; }
|
||||
public string Duration { get; init; } = string.Empty;
|
||||
public long? DurationMs { get; init; }
|
||||
public int RetryCount { get; init; }
|
||||
public int? InputTokens { get; init; }
|
||||
public int? OutputTokens { get; init; }
|
||||
@ -272,6 +275,23 @@ public sealed record ChapterStorySceneBlockViewModel
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceSaveRunForm
|
||||
{
|
||||
public string Payload { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceSavedRunsViewModel
|
||||
{
|
||||
public IReadOnlyList<StoryIntelligenceSavedRunListItem> Runs { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceSavedRunDetailViewModel
|
||||
{
|
||||
public StoryIntelligenceSavedRun? Run { get; init; }
|
||||
public StoryIntelligenceSavedChapterResult? ChapterResult { get; init; }
|
||||
public IReadOnlyList<StoryIntelligenceSavedSceneResult> SceneResults { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class AdminFeatureRequestFilter
|
||||
{
|
||||
public string? Status { get; set; }
|
||||
|
||||
@ -2,13 +2,23 @@
|
||||
@{
|
||||
ViewData["Title"] = "Chapter Story Intelligence Test";
|
||||
var result = Model.Result;
|
||||
var canSave = result is not null && !result.PipelineStopped && string.IsNullOrWhiteSpace(result.ErrorMessage);
|
||||
var savePayload = canSave
|
||||
? System.Text.Json.JsonSerializer.Serialize(
|
||||
Model,
|
||||
new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
})
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Admin utility</p>
|
||||
<h1>Chapter Story Intelligence Test</h1>
|
||||
<p class="lead-text">This in-memory dry run detects suggested scene boundaries and then runs Scene Intelligence sequentially for each suggested scene. It does not save data.</p>
|
||||
<p class="lead-text">This dry run detects suggested scene boundaries, runs Scene Intelligence sequentially, and can save the resulting audit data only when you choose to save it.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -108,6 +118,22 @@
|
||||
<dt>Total output tokens</dt>
|
||||
<dd>@Display(result.TotalOutputTokens)</dd>
|
||||
</dl>
|
||||
|
||||
@if (canSave)
|
||||
{
|
||||
<form asp-action="SaveChapterStoryIntelligenceRun" method="post" class="mt-3">
|
||||
<textarea name="Payload" class="d-none" aria-hidden="true">@savePayload</textarea>
|
||||
<div class="button-row">
|
||||
<button class="btn btn-primary" type="submit">Save this run</button>
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRuns">View saved runs</a>
|
||||
</div>
|
||||
<p class="muted mt-2 mb-0">Saving creates a new audit record each time. It stores raw and parsed AI output plus validation counts, and does not create PlotDirector scenes or story entities.</p>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-warning mt-3 mb-0">This run cannot be saved because the combined pipeline did not complete.</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="edit-panel">
|
||||
|
||||
@ -85,6 +85,7 @@
|
||||
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceTest">Dry run</a>
|
||||
<a class="btn btn-outline-primary btn-sm" asp-action="ChapterStructureTest">Chapter test</a>
|
||||
<a class="btn btn-outline-primary btn-sm" asp-action="ChapterStoryIntelligenceTest">Pipeline test</a>
|
||||
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceRuns">Saved runs</a>
|
||||
</div>
|
||||
</article>
|
||||
<article class="project-card">
|
||||
|
||||
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