Phase 20V – Story Intelligence Import Commit Foundation
This commit is contained in:
parent
5b83b001c1
commit
8089140607
@ -18,7 +18,8 @@ public sealed class AdminController(
|
||||
IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun,
|
||||
IStoryIntelligenceResultPersistenceService storyIntelligenceResults,
|
||||
IStoryIntelligenceExistingChapterQueueService existingChapterQueue,
|
||||
IStoryIntelligenceFullChapterTestService fullChapterTest) : Controller
|
||||
IStoryIntelligenceFullChapterTestService fullChapterTest,
|
||||
IStoryIntelligenceImportCommitService storyIntelligenceImportCommit) : Controller
|
||||
{
|
||||
[HttpGet("")]
|
||||
[HttpGet("index")]
|
||||
@ -200,6 +201,35 @@ public sealed class AdminController(
|
||||
return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id });
|
||||
}
|
||||
|
||||
[HttpGet("story-intelligence-runs/{id:int}/commit")]
|
||||
public async Task<IActionResult> ConfirmStoryIntelligenceImportCommit(int id)
|
||||
{
|
||||
var model = await storyIntelligenceImportCommit.BuildConfirmationAsync(id);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
[HttpPost("story-intelligence-runs/{id:int}/commit")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CommitStoryIntelligenceImport(int id)
|
||||
{
|
||||
if (currentUser.UserId is not int userId)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await storyIntelligenceImportCommit.CommitAsync(id, userId);
|
||||
TempData[result.Success ? "AdminMessage" : "AdminError"] = result.Message;
|
||||
return RedirectToAction(result.Success ? nameof(StoryIntelligenceRunDetails) : nameof(ConfirmStoryIntelligenceImportCommit), new { id });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
TempData["AdminError"] = ex.Message;
|
||||
return RedirectToAction(nameof(ConfirmStoryIntelligenceImportCommit), new { id });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("feature-requests")]
|
||||
public async Task<IActionResult> FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort)
|
||||
{
|
||||
|
||||
@ -33,6 +33,8 @@ public interface IStoryIntelligenceResultRepository
|
||||
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
|
||||
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId);
|
||||
Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId);
|
||||
Task<StoryIntelligenceImportCommit?> GetImportCommitAsync(int runId);
|
||||
Task<StoryIntelligenceImportCommitResult> CommitImportAsync(StoryIntelligenceImportCommitRequest request);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceResultRepository
|
||||
@ -374,4 +376,197 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
||||
commandType: CommandType.StoredProcedure);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceImportCommit?> GetImportCommitAsync(int runId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceImportCommit>(
|
||||
"dbo.StoryIntelligenceImportCommit_GetByRun",
|
||||
new { StoryIntelligenceRunID = runId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceImportCommitResult> CommitImportAsync(StoryIntelligenceImportCommitRequest request)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
connection.Open();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
|
||||
var scenesCreated = 0;
|
||||
var metricsCreated = 0;
|
||||
try
|
||||
{
|
||||
var alreadyCommitted = await connection.QuerySingleAsync<bool>(
|
||||
"dbo.StoryIntelligenceImportCommit_HasCompleted",
|
||||
new { request.StoryIntelligenceRunID },
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
if (alreadyCommitted)
|
||||
{
|
||||
transaction.Rollback();
|
||||
return new StoryIntelligenceImportCommitResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "This Story Intelligence run has already been committed."
|
||||
};
|
||||
}
|
||||
|
||||
var existingSceneCount = await connection.QuerySingleAsync<int>(
|
||||
"dbo.StoryIntelligenceImportCommit_CountChapterScenes",
|
||||
new { request.ChapterID },
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
if (existingSceneCount > 0)
|
||||
{
|
||||
transaction.Rollback();
|
||||
return new StoryIntelligenceImportCommitResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "This chapter already has scenes. Remove them first or wait for a future merge mode."
|
||||
};
|
||||
}
|
||||
|
||||
foreach (var item in request.Scenes.OrderBy(scene => scene.TemporarySceneNumber))
|
||||
{
|
||||
var sceneId = await connection.QuerySingleAsync<int>(
|
||||
"dbo.Scene_Save",
|
||||
new
|
||||
{
|
||||
SceneID = (int?)null,
|
||||
ChapterID = request.ChapterID,
|
||||
SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber),
|
||||
SceneTitle = item.SceneTitle,
|
||||
Summary = item.Summary,
|
||||
TimeModeID = request.TimeModeID,
|
||||
StartDateTime = (DateTime?)null,
|
||||
EndDateTime = (DateTime?)null,
|
||||
DurationAmount = (decimal?)null,
|
||||
DurationUnitID = (int?)null,
|
||||
RelativeTimeText = item.ParsedScene.Setting?.DateOrTimeReference ?? item.ParsedScene.Setting?.TimeOfDay,
|
||||
TimeConfidenceID = request.TimeConfidenceID,
|
||||
ScenePurposeNotes = item.PurposeNotes,
|
||||
SceneOutcomeNotes = item.OutcomeNotes,
|
||||
RevisionStatusID = request.RevisionStatusID,
|
||||
PrimaryLocationID = (int?)null,
|
||||
FloorPlanID = (int?)null,
|
||||
InitialFloorPlanFloorID = (int?)null
|
||||
},
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.StoryIntelligenceSceneSource_Save",
|
||||
new
|
||||
{
|
||||
SceneID = sceneId,
|
||||
ImportSource = "Story Intelligence",
|
||||
ImportRunID = request.StoryIntelligenceRunID,
|
||||
SourceStartParagraph = item.StartParagraph,
|
||||
SourceEndParagraph = item.EndParagraph
|
||||
},
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
if (item.PovCharacterID.HasValue)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.Scene_UpdatePovCharacter",
|
||||
new { SceneID = sceneId, POVCharacterID = item.PovCharacterID },
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
if (item.PurposeTypeIDs.Count > 0)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.Scene_SavePurposes",
|
||||
new { SceneID = sceneId, PurposeIds = string.Join(',', item.PurposeTypeIDs.Distinct()) },
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
foreach (var metric in item.Metrics)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.SceneMetric_SaveValue",
|
||||
new { SceneID = sceneId, metric.MetricTypeID, metric.Value, metric.Notes },
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
metricsCreated++;
|
||||
}
|
||||
|
||||
if (request.TimelineNoteTypeID.HasValue && !string.IsNullOrWhiteSpace(item.ImportNoteText))
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.SceneNote_Save",
|
||||
new
|
||||
{
|
||||
SceneNoteID = (int?)null,
|
||||
SceneID = sceneId,
|
||||
SceneNoteTypeID = request.TimelineNoteTypeID.Value,
|
||||
NoteTitle = "Story Intelligence import notes",
|
||||
NoteText = item.ImportNoteText,
|
||||
SortOrder = 10,
|
||||
IsPinned = false,
|
||||
IsResolved = false
|
||||
},
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
scenesCreated++;
|
||||
}
|
||||
|
||||
var commitId = await connection.QuerySingleAsync<int>(
|
||||
"dbo.StoryIntelligenceImportCommit_RecordCompleted",
|
||||
new
|
||||
{
|
||||
request.StoryIntelligenceRunID,
|
||||
request.ProjectID,
|
||||
request.BookID,
|
||||
request.ChapterID,
|
||||
CommittedByUserID = request.UserID,
|
||||
ScenesCreated = scenesCreated,
|
||||
MetricsCreated = metricsCreated,
|
||||
Notes = string.Join(Environment.NewLine, request.Warnings)
|
||||
},
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
transaction.Commit();
|
||||
return new StoryIntelligenceImportCommitResult
|
||||
{
|
||||
Success = true,
|
||||
CommitID = commitId,
|
||||
ScenesCreated = scenesCreated,
|
||||
MetricsCreated = metricsCreated,
|
||||
Message = $"Created {scenesCreated:N0} scene(s)."
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transaction.Rollback();
|
||||
await RecordFailedCommitAsync(request, scenesCreated, metricsCreated, ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecordFailedCommitAsync(StoryIntelligenceImportCommitRequest request, int scenesCreated, int metricsCreated, Exception ex)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.StoryIntelligenceImportCommit_RecordFailed",
|
||||
new
|
||||
{
|
||||
request.StoryIntelligenceRunID,
|
||||
request.ProjectID,
|
||||
request.BookID,
|
||||
request.ChapterID,
|
||||
CommittedByUserID = request.UserID,
|
||||
ScenesCreated = scenesCreated,
|
||||
MetricsCreated = metricsCreated,
|
||||
Notes = ex.Message
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
|
||||
@ -405,6 +405,10 @@ public sealed class Scene
|
||||
public int? ManuscriptDocumentID { get; set; }
|
||||
public string? ExternalReference { get; set; }
|
||||
public DateTime? LinkedUtc { get; set; }
|
||||
public string? ImportSource { get; set; }
|
||||
public int? ImportRunID { get; set; }
|
||||
public int? SourceStartParagraph { get; set; }
|
||||
public int? SourceEndParagraph { get; set; }
|
||||
public List<int> SelectedPurposeTypeIds { get; set; } = [];
|
||||
public List<ScenePurposeLabel> PurposeLabels { get; set; } = [];
|
||||
public List<SceneMetricValue> Metrics { get; set; } = [];
|
||||
|
||||
@ -356,3 +356,68 @@ public sealed class StoryIntelligenceSavedSceneResult
|
||||
public long? DurationMs { get; init; }
|
||||
public DateTime CreatedUtc { get; init; }
|
||||
}
|
||||
|
||||
public static class StoryIntelligenceImportCommitStatuses
|
||||
{
|
||||
public const string Pending = "Pending";
|
||||
public const string Completed = "Completed";
|
||||
public const string Failed = "Failed";
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceImportCommit
|
||||
{
|
||||
public int StoryIntelligenceImportCommitID { get; init; }
|
||||
public int StoryIntelligenceRunID { get; init; }
|
||||
public int? ProjectID { get; init; }
|
||||
public int? BookID { get; init; }
|
||||
public int? ChapterID { get; init; }
|
||||
public DateTime CommittedAt { get; init; }
|
||||
public int CommittedByUserID { get; init; }
|
||||
public int ScenesCreated { get; init; }
|
||||
public int MetricsCreated { get; init; }
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public string? Notes { get; init; }
|
||||
public DateTime CreatedUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceSceneImportItem
|
||||
{
|
||||
public int SceneResultID { 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 SceneIntelligenceScene ParsedScene { get; init; } = new();
|
||||
public int? PovCharacterID { get; init; }
|
||||
public IReadOnlyList<int> PurposeTypeIDs { get; init; } = [];
|
||||
public IReadOnlyList<SceneMetricValue> Metrics { get; init; } = [];
|
||||
public string SceneTitle { get; init; } = string.Empty;
|
||||
public string? Summary { get; init; }
|
||||
public string? PurposeNotes { get; init; }
|
||||
public string? OutcomeNotes { get; init; }
|
||||
public string? ImportNoteText { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceImportCommitRequest
|
||||
{
|
||||
public int StoryIntelligenceRunID { get; init; }
|
||||
public int UserID { get; init; }
|
||||
public int ProjectID { get; init; }
|
||||
public int BookID { get; init; }
|
||||
public int ChapterID { get; init; }
|
||||
public int TimeModeID { get; init; }
|
||||
public int TimeConfidenceID { get; init; }
|
||||
public int RevisionStatusID { get; init; }
|
||||
public int? TimelineNoteTypeID { get; init; }
|
||||
public IReadOnlyList<StoryIntelligenceSceneImportItem> Scenes { get; init; } = [];
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceImportCommitResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public int? CommitID { get; init; }
|
||||
public int ScenesCreated { get; init; }
|
||||
public int MetricsCreated { get; init; }
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
@ -203,6 +203,7 @@ public class Program
|
||||
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>();
|
||||
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
||||
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
|
||||
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
||||
|
||||
446
PlotLine/Services/StoryIntelligenceImportCommitService.cs
Normal file
446
PlotLine/Services/StoryIntelligenceImportCommitService.cs
Normal file
@ -0,0 +1,446 @@
|
||||
using System.Text.Json;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface IStoryIntelligenceImportCommitService
|
||||
{
|
||||
Task<StoryIntelligenceImportConfirmationViewModel?> BuildConfirmationAsync(int runId);
|
||||
Task<StoryIntelligenceImportCommitResult> CommitAsync(int runId, int userId);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceImportCommitService(
|
||||
IStoryIntelligenceResultRepository storyRuns,
|
||||
IProjectRepository projects,
|
||||
IBookRepository books,
|
||||
IChapterRepository chapters,
|
||||
ISceneRepository scenes,
|
||||
ILookupRepository lookups,
|
||||
ISceneMetricTypeRepository metricTypes,
|
||||
IWriterWorkspaceRepository writerWorkspace,
|
||||
ICharacterRepository characters,
|
||||
ILogger<StoryIntelligenceImportCommitService> logger) : IStoryIntelligenceImportCommitService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string[]> MetricAliases = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["action"] = ["Action"],
|
||||
["emotion"] = ["Emotion", "Emotional Weight", "Emotional Resonance"],
|
||||
["tension"] = ["Tension"],
|
||||
["conflict"] = ["Conflict", "Relationship Conflict"],
|
||||
["mystery"] = ["Mystery"],
|
||||
["romance"] = ["Romance", "Emotional Intimacy"],
|
||||
["humour"] = ["Humour", "Humor", "Comedy", "Comic Relief"],
|
||||
["revelation"] = ["Revelation", "Reveal"],
|
||||
["pacingIntensity"] = ["PacingIntensity", "Pacing Intensity", "Overall Intensity"]
|
||||
};
|
||||
|
||||
public async Task<StoryIntelligenceImportConfirmationViewModel?> BuildConfirmationAsync(int runId)
|
||||
{
|
||||
var prepared = await PrepareAsync(runId);
|
||||
if (prepared is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return prepared.Confirmation;
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceImportCommitResult> CommitAsync(int runId, int userId)
|
||||
{
|
||||
var prepared = await PrepareAsync(runId);
|
||||
if (prepared is null)
|
||||
{
|
||||
return new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence run could not be found." };
|
||||
}
|
||||
|
||||
if (!prepared.Confirmation.CanCommit)
|
||||
{
|
||||
return new StoryIntelligenceImportCommitResult
|
||||
{
|
||||
Success = false,
|
||||
Message = prepared.Confirmation.HasCompletedCommit
|
||||
? "This Story Intelligence run has already been committed."
|
||||
: string.Join(" ", prepared.Confirmation.Blockers)
|
||||
};
|
||||
}
|
||||
|
||||
var result = await storyRuns.CommitImportAsync(new StoryIntelligenceImportCommitRequest
|
||||
{
|
||||
StoryIntelligenceRunID = prepared.Run.StoryIntelligenceRunID,
|
||||
UserID = userId,
|
||||
ProjectID = prepared.Run.ProjectID!.Value,
|
||||
BookID = prepared.Run.BookID!.Value,
|
||||
ChapterID = prepared.Run.ChapterID!.Value,
|
||||
TimeModeID = prepared.TimeModeID,
|
||||
TimeConfidenceID = prepared.TimeConfidenceID,
|
||||
RevisionStatusID = prepared.RevisionStatusID,
|
||||
TimelineNoteTypeID = prepared.TimelineNoteTypeID,
|
||||
Scenes = prepared.Scenes,
|
||||
Warnings = prepared.Confirmation.Warnings
|
||||
});
|
||||
|
||||
logger.LogInformation(
|
||||
"Committed Story Intelligence run {StoryIntelligenceRunID}. CommitID={CommitID} ScenesCreated={ScenesCreated} MetricsCreated={MetricsCreated}",
|
||||
runId,
|
||||
result.CommitID,
|
||||
result.ScenesCreated,
|
||||
result.MetricsCreated);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<PreparedImport?> PrepareAsync(int runId)
|
||||
{
|
||||
var run = await storyRuns.GetRunAsync(runId);
|
||||
if (run is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var existingCommit = await storyRuns.GetImportCommitAsync(runId);
|
||||
var blockers = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
if (run.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
|
||||
{
|
||||
blockers.Add("Only completed Story Intelligence runs can be committed.");
|
||||
}
|
||||
|
||||
if (run.ProjectID is null || run.BookID is null || run.ChapterID is null)
|
||||
{
|
||||
blockers.Add("The run must have a project, book and chapter before it can create scenes.");
|
||||
}
|
||||
|
||||
var sceneResults = await storyRuns.ListSceneResultsAsync(runId);
|
||||
if (sceneResults.Count == 0)
|
||||
{
|
||||
blockers.Add("No saved scene results are available to import.");
|
||||
}
|
||||
|
||||
var existingScenes = run.ChapterID.HasValue
|
||||
? await scenes.ListByChapterAsync(run.ChapterID.Value)
|
||||
: [];
|
||||
if (existingScenes.Count > 0)
|
||||
{
|
||||
blockers.Add("This chapter already has scenes. Existing scenes must be removed or a future merge mode implemented before importing.");
|
||||
}
|
||||
|
||||
if (existingCommit is not null && string.Equals(existingCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
blockers.Add("This Story Intelligence run has already been committed.");
|
||||
}
|
||||
|
||||
var lookupData = await lookups.GetAllAsync();
|
||||
var activeMetricTypes = run.ProjectID.HasValue
|
||||
? (await metricTypes.ListForManagementAsync(run.ProjectID.Value)).Where(metric => metric.IsActive).ToList()
|
||||
: [];
|
||||
var noteTypes = await writerWorkspace.ListNoteTypesAsync();
|
||||
var characterMap = run.ProjectID.HasValue
|
||||
? await BuildCharacterMapAsync(run.ProjectID.Value)
|
||||
: new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var timeModeId = FindId(lookupData.TimeModes, item => item.TimeModeName, item => item.TimeModeID, "Relative", "Unknown");
|
||||
var timeConfidenceId = FindId(lookupData.TimeConfidences, item => item.TimeConfidenceName, item => item.TimeConfidenceID, "Implied", "Unknown");
|
||||
var revisionStatusId = FindId(lookupData.RevisionStatuses, item => item.StatusName, item => item.RevisionStatusID, "Planned");
|
||||
var timelineNoteTypeId = noteTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Timeline Note", StringComparison.OrdinalIgnoreCase))?.SceneNoteTypeID;
|
||||
if (timelineNoteTypeId is null)
|
||||
{
|
||||
warnings.Add("Timeline clues will not be imported because the Timeline Note type is not available.");
|
||||
}
|
||||
|
||||
var purposeMap = lookupData.ScenePurposeTypes.ToDictionary(type => Clean(type.PurposeName), type => type.ScenePurposeTypeID, StringComparer.OrdinalIgnoreCase);
|
||||
var metricMap = BuildMetricMap(activeMetricTypes);
|
||||
var importScenes = new List<StoryIntelligenceSceneImportItem>();
|
||||
foreach (var sceneResult in sceneResults
|
||||
.Where(scene => scene.ValidationErrorsCount == 0)
|
||||
.OrderBy(scene => scene.TemporarySceneNumber))
|
||||
{
|
||||
var parsed = TryReadScene(sceneResult);
|
||||
if (parsed is null)
|
||||
{
|
||||
warnings.Add($"Suggested scene {sceneResult.TemporarySceneNumber:N0} was skipped because its parsed JSON could not be read.");
|
||||
continue;
|
||||
}
|
||||
|
||||
importScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap));
|
||||
}
|
||||
|
||||
if (importScenes.Count == 0)
|
||||
{
|
||||
blockers.Add("No valid scene intelligence results are available to import.");
|
||||
}
|
||||
|
||||
var projectName = run.ProjectID.HasValue ? (await projects.GetAsync(run.ProjectID.Value))?.ProjectName ?? $"Project {run.ProjectID.Value:N0}" : "None";
|
||||
var bookTitle = run.BookID.HasValue ? (await books.GetAsync(run.BookID.Value))?.BookDisplayTitle ?? $"Book {run.BookID.Value:N0}" : "None";
|
||||
var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null;
|
||||
var chapterLabel = chapter is null
|
||||
? "None"
|
||||
: string.IsNullOrWhiteSpace(chapter.ChapterTitle)
|
||||
? $"Chapter {chapter.ChapterNumber:0.##}"
|
||||
: $"Chapter {chapter.ChapterNumber:0.##}: {chapter.ChapterTitle}";
|
||||
|
||||
return new PreparedImport(
|
||||
run,
|
||||
new StoryIntelligenceImportConfirmationViewModel
|
||||
{
|
||||
Run = run,
|
||||
ExistingCommit = existingCommit,
|
||||
ProjectName = projectName,
|
||||
BookTitle = bookTitle,
|
||||
ChapterLabel = chapterLabel,
|
||||
ExistingSceneCount = existingScenes.Count,
|
||||
ScenesToCreate = importScenes.Count,
|
||||
MetricsToImport = importScenes.Sum(scene => scene.Metrics.Count),
|
||||
MetricNames = importScenes.SelectMany(scene => scene.Metrics.Select(metric => metric.MetricName)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(name => name).ToList(),
|
||||
Warnings = warnings,
|
||||
Blockers = blockers
|
||||
},
|
||||
importScenes,
|
||||
timeModeId,
|
||||
timeConfidenceId,
|
||||
revisionStatusId,
|
||||
timelineNoteTypeId);
|
||||
}
|
||||
|
||||
private static StoryIntelligenceSceneImportItem BuildImportScene(
|
||||
StoryIntelligenceSavedSceneResult sceneResult,
|
||||
SceneIntelligenceScene parsed,
|
||||
IReadOnlyDictionary<string, int> characterMap,
|
||||
IReadOnlyDictionary<string, int> purposeMap,
|
||||
IReadOnlyDictionary<string, SceneMetricType> metricMap)
|
||||
{
|
||||
var povName = Clean(parsed.PointOfView?.CharacterName);
|
||||
var povCharacterId = !string.IsNullOrWhiteSpace(povName)
|
||||
&& !string.Equals(povName, "Narrator", StringComparison.OrdinalIgnoreCase)
|
||||
&& characterMap.TryGetValue(povName, out var characterId)
|
||||
? characterId
|
||||
: (int?)null;
|
||||
var summary = Clean(parsed.Summary?.Short);
|
||||
var sceneTitle = GenerateTitle(sceneResult.TemporarySceneNumber, summary);
|
||||
|
||||
return new StoryIntelligenceSceneImportItem
|
||||
{
|
||||
SceneResultID = sceneResult.SceneResultID,
|
||||
TemporarySceneNumber = sceneResult.TemporarySceneNumber,
|
||||
StartParagraph = sceneResult.StartParagraph,
|
||||
EndParagraph = sceneResult.EndParagraph,
|
||||
SourceLabel = sceneResult.SourceLabel,
|
||||
ParsedScene = parsed,
|
||||
PovCharacterID = povCharacterId,
|
||||
PurposeTypeIDs = MatchPurposes(parsed.ScenePurpose?.ObservedFunction, purposeMap),
|
||||
Metrics = BuildMetrics(parsed, metricMap),
|
||||
SceneTitle = sceneTitle,
|
||||
Summary = string.IsNullOrWhiteSpace(summary) ? null : summary,
|
||||
PurposeNotes = BuildPurposeNotes(parsed, sceneResult, povName, povCharacterId),
|
||||
OutcomeNotes = string.IsNullOrWhiteSpace(parsed.Summary?.Detailed) ? null : $"Detailed summary: {parsed.Summary.Detailed}",
|
||||
ImportNoteText = BuildImportNote(parsed, povName, povCharacterId)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, int>> BuildCharacterMapAsync(int projectId)
|
||||
{
|
||||
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var character in await characters.ListCharactersAsync(projectId))
|
||||
{
|
||||
AddCharacterMap(map, character.CharacterName, character.CharacterID);
|
||||
AddCharacterMap(map, character.ShortName, character.CharacterID);
|
||||
foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
|
||||
{
|
||||
AddCharacterMap(map, alias.Alias, character.CharacterID);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static void AddCharacterMap(Dictionary<string, int> map, string? name, int characterId)
|
||||
{
|
||||
var clean = Clean(name);
|
||||
if (!string.IsNullOrWhiteSpace(clean))
|
||||
{
|
||||
map.TryAdd(clean, characterId);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, SceneMetricType> BuildMetricMap(IReadOnlyList<SceneMetricType> metricTypes)
|
||||
{
|
||||
var byName = metricTypes.ToDictionary(metric => Clean(metric.MetricName), metric => metric, StringComparer.OrdinalIgnoreCase);
|
||||
var map = new Dictionary<string, SceneMetricType>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (schemaName, aliases) in MetricAliases)
|
||||
{
|
||||
var metric = aliases.Select(alias => byName.TryGetValue(Clean(alias), out var match) ? match : null).FirstOrDefault(match => match is not null);
|
||||
if (metric is not null)
|
||||
{
|
||||
map[schemaName] = metric;
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SceneMetricValue> BuildMetrics(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, SceneMetricType> metricMap)
|
||||
{
|
||||
if (parsed.Metrics is null || parsed.Metrics.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var values = new List<SceneMetricValue>();
|
||||
foreach (var (name, metric) in parsed.Metrics)
|
||||
{
|
||||
if (!metric.Score.HasValue || !metricMap.TryGetValue(name, out var metricType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
values.Add(new SceneMetricValue
|
||||
{
|
||||
MetricTypeID = metricType.MetricTypeID,
|
||||
MetricName = metricType.MetricName,
|
||||
Value = Math.Clamp(metric.Score.Value, metricType.MinValue, metricType.MaxValue),
|
||||
Notes = metric.Confidence.HasValue ? $"Story Intelligence confidence {metric.Confidence.Value:0.##}." : "Imported from Story Intelligence."
|
||||
});
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<int> MatchPurposes(string? observedFunction, IReadOnlyDictionary<string, int> purposeMap)
|
||||
{
|
||||
var value = Clean(observedFunction);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var matches = purposeMap
|
||||
.Where(pair => value.Contains(pair.Key, StringComparison.OrdinalIgnoreCase) || pair.Key.Contains(value, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(pair => pair.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
return matches;
|
||||
}
|
||||
|
||||
private static string? BuildPurposeNotes(SceneIntelligenceScene parsed, StoryIntelligenceSavedSceneResult result, string povName, int? povCharacterId)
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"Story Intelligence import source: run {result.StoryIntelligenceRunID:N0}, paragraphs {DisplayRange(result.StartParagraph, result.EndParagraph)}."
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(parsed.ScenePurpose?.ObservedFunction))
|
||||
{
|
||||
lines.Add($"Observed function: {parsed.ScenePurpose.ObservedFunction}");
|
||||
}
|
||||
|
||||
if (parsed.Setting is not null)
|
||||
{
|
||||
var setting = string.Join("; ", new[]
|
||||
{
|
||||
Label("Location", parsed.Setting.LocationName),
|
||||
Label("Location type", parsed.Setting.LocationType),
|
||||
Label("Room", parsed.Setting.GenericRoomType),
|
||||
Label("Parent hint", parsed.Setting.ParentLocationHint),
|
||||
Label("Time", parsed.Setting.DateOrTimeReference ?? parsed.Setting.TimeOfDay)
|
||||
}.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
if (!string.IsNullOrWhiteSpace(setting))
|
||||
{
|
||||
lines.Add($"Setting notes: {setting}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(povName) && !povCharacterId.HasValue)
|
||||
{
|
||||
lines.Add($"POV not linked: {povName}");
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
|
||||
private static string? BuildImportNote(SceneIntelligenceScene parsed, string povName, int? povCharacterId)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
if (parsed.TimelineClues?.Count > 0)
|
||||
{
|
||||
lines.Add("Timeline clues:");
|
||||
lines.AddRange(parsed.TimelineClues.Select(clue => $"- {Clean(clue.AbsoluteDate ?? clue.RelativeOrder ?? clue.Clue)}"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(povName) && !povCharacterId.HasValue)
|
||||
{
|
||||
lines.Add($"POV left unlinked because '{povName}' did not resolve to an existing character.");
|
||||
}
|
||||
|
||||
return lines.Count == 0 ? null : string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
|
||||
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(result.ParsedJson))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(result.ParsedJson, JsonOptions);
|
||||
if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion))
|
||||
{
|
||||
return direct;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int FindId<T>(IReadOnlyList<T> items, Func<T, string> name, Func<T, int> id, params string[] preferredNames)
|
||||
{
|
||||
foreach (var preferred in preferredNames)
|
||||
{
|
||||
var match = items.FirstOrDefault(item => string.Equals(name(item), preferred, StringComparison.OrdinalIgnoreCase));
|
||||
if (match is not null)
|
||||
{
|
||||
return id(match);
|
||||
}
|
||||
}
|
||||
|
||||
return items.Count > 0 ? id(items[0]) : throw new InvalidOperationException("Required lookup data is missing.");
|
||||
}
|
||||
|
||||
private static string GenerateTitle(int sceneNumber, string summary)
|
||||
=> string.IsNullOrWhiteSpace(summary)
|
||||
? $"Scene {sceneNumber:N0}"
|
||||
: $"Scene {sceneNumber:N0} - {TrimTo(summary, 60)}";
|
||||
|
||||
private static string TrimTo(string value, int maxLength)
|
||||
=> value.Length <= maxLength ? value : value[..maxLength].TrimEnd() + "...";
|
||||
|
||||
private static string DisplayRange(int? start, int? end)
|
||||
=> start.HasValue && end.HasValue ? $"{start.Value:N0}-{end.Value:N0}" : "unknown";
|
||||
|
||||
private static string? Label(string label, string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : $"{label}: {value}";
|
||||
|
||||
private static string Clean(string? value)
|
||||
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
private sealed record PreparedImport(
|
||||
StoryIntelligenceSavedRun Run,
|
||||
StoryIntelligenceImportConfirmationViewModel Confirmation,
|
||||
IReadOnlyList<StoryIntelligenceSceneImportItem> Scenes,
|
||||
int TimeModeID,
|
||||
int TimeConfidenceID,
|
||||
int RevisionStatusID,
|
||||
int? TimelineNoteTypeID);
|
||||
}
|
||||
@ -111,7 +111,8 @@ public sealed class StoryIntelligenceResultPersistenceService(
|
||||
Run = run,
|
||||
ChapterResult = await repository.GetChapterResultAsync(runId),
|
||||
SceneResults = sceneResults,
|
||||
ImportPreview = await importResolver.ResolveAsync(run, sceneResults)
|
||||
ImportPreview = await importResolver.ResolveAsync(run, sceneResults),
|
||||
ImportCommit = await repository.GetImportCommitAsync(runId)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
165
PlotLine/Sql/121_Phase20V_StoryIntelligenceImportCommit.sql
Normal file
165
PlotLine/Sql/121_Phase20V_StoryIntelligenceImportCommit.sql
Normal file
@ -0,0 +1,165 @@
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.StoryIntelligenceImportCommits', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.StoryIntelligenceImportCommits
|
||||
(
|
||||
StoryIntelligenceImportCommitID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceImportCommits PRIMARY KEY,
|
||||
StoryIntelligenceRunID int NOT NULL,
|
||||
ProjectID int NULL,
|
||||
BookID int NULL,
|
||||
ChapterID int NULL,
|
||||
CommittedAt datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceImportCommits_CommittedAt DEFAULT SYSUTCDATETIME(),
|
||||
CommittedByUserID int NOT NULL,
|
||||
ScenesCreated int NOT NULL CONSTRAINT DF_StoryIntelligenceImportCommits_ScenesCreated DEFAULT 0,
|
||||
MetricsCreated int NOT NULL CONSTRAINT DF_StoryIntelligenceImportCommits_MetricsCreated DEFAULT 0,
|
||||
Status nvarchar(40) NOT NULL,
|
||||
Notes nvarchar(max) NULL,
|
||||
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceImportCommits_CreatedUtc DEFAULT SYSUTCDATETIME(),
|
||||
CONSTRAINT FK_StoryIntelligenceImportCommits_Runs FOREIGN KEY (StoryIntelligenceRunID) REFERENCES dbo.StoryIntelligenceRuns(StoryIntelligenceRunID),
|
||||
CONSTRAINT FK_StoryIntelligenceImportCommits_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID),
|
||||
CONSTRAINT FK_StoryIntelligenceImportCommits_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID),
|
||||
CONSTRAINT FK_StoryIntelligenceImportCommits_Chapters FOREIGN KEY (ChapterID) REFERENCES dbo.Chapters(ChapterID),
|
||||
CONSTRAINT FK_StoryIntelligenceImportCommits_AppUser FOREIGN KEY (CommittedByUserID) REFERENCES dbo.AppUser(UserID)
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryIntelligenceImportCommits_CompletedRun' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceImportCommits'))
|
||||
CREATE UNIQUE INDEX UX_StoryIntelligenceImportCommits_CompletedRun ON dbo.StoryIntelligenceImportCommits(StoryIntelligenceRunID) WHERE Status = N'Completed';
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.Scenes', N'ImportSource') IS NULL
|
||||
ALTER TABLE dbo.Scenes ADD ImportSource nvarchar(80) NULL;
|
||||
GO
|
||||
IF COL_LENGTH(N'dbo.Scenes', N'ImportRunID') IS NULL
|
||||
ALTER TABLE dbo.Scenes ADD ImportRunID int NULL;
|
||||
GO
|
||||
IF COL_LENGTH(N'dbo.Scenes', N'SourceStartParagraph') IS NULL
|
||||
ALTER TABLE dbo.Scenes ADD SourceStartParagraph int NULL;
|
||||
GO
|
||||
IF COL_LENGTH(N'dbo.Scenes', N'SourceEndParagraph') IS NULL
|
||||
ALTER TABLE dbo.Scenes ADD SourceEndParagraph int NULL;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_Scenes_StoryIntelligenceRuns_ImportRunID')
|
||||
ALTER TABLE dbo.Scenes ADD CONSTRAINT FK_Scenes_StoryIntelligenceRuns_ImportRunID FOREIGN KEY (ImportRunID) REFERENCES dbo.StoryIntelligenceRuns(StoryIntelligenceRunID);
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceImportCommit_GetByRun
|
||||
@StoryIntelligenceRunID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT TOP (1) StoryIntelligenceImportCommitID, StoryIntelligenceRunID, ProjectID, BookID, ChapterID,
|
||||
CommittedAt, CommittedByUserID, ScenesCreated, MetricsCreated, Status, Notes, CreatedUtc
|
||||
FROM dbo.StoryIntelligenceImportCommits
|
||||
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
|
||||
ORDER BY CASE WHEN Status = N'Completed' THEN 0 ELSE 1 END, CreatedUtc DESC;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceImportCommit_HasCompleted
|
||||
@StoryIntelligenceRunID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT CAST(CASE WHEN EXISTS
|
||||
(
|
||||
SELECT 1
|
||||
FROM dbo.StoryIntelligenceImportCommits
|
||||
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
|
||||
AND Status = N'Completed'
|
||||
) THEN 1 ELSE 0 END AS bit);
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceImportCommit_CountChapterScenes
|
||||
@ChapterID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT COUNT(1)
|
||||
FROM dbo.Scenes
|
||||
WHERE ChapterID = @ChapterID
|
||||
AND IsArchived = 0;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceSceneSource_Save
|
||||
@SceneID int,
|
||||
@ImportSource nvarchar(80),
|
||||
@ImportRunID int,
|
||||
@SourceStartParagraph int = NULL,
|
||||
@SourceEndParagraph int = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
UPDATE dbo.Scenes
|
||||
SET ImportSource = @ImportSource,
|
||||
ImportRunID = @ImportRunID,
|
||||
SourceStartParagraph = @SourceStartParagraph,
|
||||
SourceEndParagraph = @SourceEndParagraph,
|
||||
UpdatedDate = SYSUTCDATETIME()
|
||||
WHERE SceneID = @SceneID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceImportCommit_RecordCompleted
|
||||
@StoryIntelligenceRunID int,
|
||||
@ProjectID int = NULL,
|
||||
@BookID int = NULL,
|
||||
@ChapterID int = NULL,
|
||||
@CommittedByUserID int,
|
||||
@ScenesCreated int,
|
||||
@MetricsCreated int,
|
||||
@Notes nvarchar(max) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
INSERT dbo.StoryIntelligenceImportCommits
|
||||
(
|
||||
StoryIntelligenceRunID, ProjectID, BookID, ChapterID, CommittedAt, CommittedByUserID,
|
||||
ScenesCreated, MetricsCreated, Status, Notes
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@StoryIntelligenceRunID, @ProjectID, @BookID, @ChapterID, SYSUTCDATETIME(), @CommittedByUserID,
|
||||
@ScenesCreated, @MetricsCreated, N'Completed', @Notes
|
||||
);
|
||||
|
||||
SELECT CAST(SCOPE_IDENTITY() AS int);
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceImportCommit_RecordFailed
|
||||
@StoryIntelligenceRunID int,
|
||||
@ProjectID int = NULL,
|
||||
@BookID int = NULL,
|
||||
@ChapterID int = NULL,
|
||||
@CommittedByUserID int,
|
||||
@ScenesCreated int,
|
||||
@MetricsCreated int,
|
||||
@Notes nvarchar(max) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
INSERT dbo.StoryIntelligenceImportCommits
|
||||
(
|
||||
StoryIntelligenceRunID, ProjectID, BookID, ChapterID, CommittedAt, CommittedByUserID,
|
||||
ScenesCreated, MetricsCreated, Status, Notes
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@StoryIntelligenceRunID, @ProjectID, @BookID, @ChapterID, SYSUTCDATETIME(), @CommittedByUserID,
|
||||
@ScenesCreated, @MetricsCreated, N'Failed', @Notes
|
||||
);
|
||||
END;
|
||||
GO
|
||||
@ -293,6 +293,25 @@ public sealed class StoryIntelligenceSavedRunDetailViewModel
|
||||
public StoryIntelligenceSavedChapterResult? ChapterResult { get; init; }
|
||||
public IReadOnlyList<StoryIntelligenceSavedSceneResult> SceneResults { get; init; } = [];
|
||||
public StoryIntelligenceImportPreview? ImportPreview { get; init; }
|
||||
public StoryIntelligenceImportCommit? ImportCommit { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceImportConfirmationViewModel
|
||||
{
|
||||
public StoryIntelligenceSavedRun Run { get; init; } = new();
|
||||
public StoryIntelligenceImportCommit? ExistingCommit { get; init; }
|
||||
public string ProjectName { get; init; } = string.Empty;
|
||||
public string BookTitle { get; init; } = string.Empty;
|
||||
public string ChapterLabel { get; init; } = string.Empty;
|
||||
public int ExistingSceneCount { get; init; }
|
||||
public int ScenesToCreate { get; init; }
|
||||
public int MetricsToImport { get; init; }
|
||||
public IReadOnlyList<string> MetricNames { get; init; } = [];
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
public IReadOnlyList<string> Blockers { get; init; } = [];
|
||||
public bool HasCompletedCommit => ExistingCommit is not null
|
||||
&& string.Equals(ExistingCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase);
|
||||
public bool CanCommit => Blockers.Count == 0 && !HasCompletedCommit;
|
||||
}
|
||||
|
||||
public sealed class AdminFeatureRequestFilter
|
||||
|
||||
106
PlotLine/Views/Admin/ConfirmStoryIntelligenceImportCommit.cshtml
Normal file
106
PlotLine/Views/Admin/ConfirmStoryIntelligenceImportCommit.cshtml
Normal file
@ -0,0 +1,106 @@
|
||||
@model StoryIntelligenceImportConfirmationViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Commit Story Intelligence Import";
|
||||
}
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Admin utility</p>
|
||||
<h1>Commit Story Intelligence Import</h1>
|
||||
<p class="lead-text">Create PlotDirector scenes from a completed Story Intelligence run.</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"> / </span>
|
||||
<a asp-action="StoryIntelligenceRunDetails" asp-route-id="@Model.Run.StoryIntelligenceRunID">Run @Model.Run.StoryIntelligenceRunID.ToString("N0")</a>
|
||||
<span class="muted"> / Commit Import</span>
|
||||
</nav>
|
||||
|
||||
@if (TempData["AdminError"] is string error)
|
||||
{
|
||||
<div class="alert alert-danger">@error</div>
|
||||
}
|
||||
|
||||
<section class="edit-panel">
|
||||
<h2>Confirmation</h2>
|
||||
<div class="alert alert-warning">
|
||||
This phase imports scenes and scene intelligence only. Characters, locations, assets, relationships and knowledge remain preview-only.
|
||||
</div>
|
||||
|
||||
@if (Model.ExistingCommit is not null && string.Equals(Model.ExistingCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<div class="alert alert-success">
|
||||
This run has already been committed. It created @Model.ExistingCommit.ScenesCreated.ToString("N0") scene@(Model.ExistingCommit.ScenesCreated == 1 ? string.Empty : "s")
|
||||
on @Model.ExistingCommit.CommittedAt.ToString("yyyy-MM-dd HH:mm:ss") UTC.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.Blockers.Count > 0)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<strong>Import is blocked.</strong>
|
||||
<ul class="mb-0">
|
||||
@foreach (var blocker in Model.Blockers)
|
||||
{
|
||||
<li>@blocker</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<dl>
|
||||
<dt>Project</dt>
|
||||
<dd>@Model.ProjectName</dd>
|
||||
<dt>Book</dt>
|
||||
<dd>@Model.BookTitle</dd>
|
||||
<dt>Chapter</dt>
|
||||
<dd>@Model.ChapterLabel</dd>
|
||||
<dt>Scenes to create</dt>
|
||||
<dd>@Model.ScenesToCreate.ToString("N0")</dd>
|
||||
<dt>Existing scene count for this chapter</dt>
|
||||
<dd>@Model.ExistingSceneCount.ToString("N0")</dd>
|
||||
<dt>Metric values to import</dt>
|
||||
<dd>@Model.MetricsToImport.ToString("N0")</dd>
|
||||
<dt>Metrics</dt>
|
||||
<dd>@(Model.MetricNames.Count == 0 ? "None" : string.Join(", ", Model.MetricNames))</dd>
|
||||
</dl>
|
||||
|
||||
@if (Model.ExistingSceneCount > 0)
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
This chapter already has scenes. The current import mode will not merge, replace, or overwrite existing scenes.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.Warnings.Count > 0)
|
||||
{
|
||||
<h3>Warnings</h3>
|
||||
<ul>
|
||||
@foreach (var warning in Model.Warnings)
|
||||
{
|
||||
<li>@warning</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<h3>Not Imported Yet</h3>
|
||||
<ul>
|
||||
<li>Characters</li>
|
||||
<li>Locations</li>
|
||||
<li>Assets</li>
|
||||
<li>Relationships</li>
|
||||
<li>Knowledge changes</li>
|
||||
<li>Real timeline events</li>
|
||||
</ul>
|
||||
|
||||
<form asp-action="CommitStoryIntelligenceImport" asp-route-id="@Model.Run.StoryIntelligenceRunID" method="post" class="mt-3">
|
||||
<button type="submit" class="btn btn-primary" disabled="@(!Model.CanCommit)">Create Scenes From This Run</button>
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRunDetails" asp-route-id="@Model.Run.StoryIntelligenceRunID">Back to run</a>
|
||||
</form>
|
||||
</section>
|
||||
@ -32,6 +32,10 @@
|
||||
{
|
||||
<div class="alert alert-success">@message</div>
|
||||
}
|
||||
@if (TempData["AdminError"] is string error)
|
||||
{
|
||||
<div class="alert alert-danger">@error</div>
|
||||
}
|
||||
|
||||
@if (run is null)
|
||||
{
|
||||
@ -158,6 +162,21 @@ else
|
||||
<a class="btn btn-outline-primary" asp-action="StoryIntelligenceFullChapterTest" asp-route-sourceRunId="@run.StoryIntelligenceRunID">Run same source again with different models</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-3">
|
||||
@if (Model.ImportCommit is not null && string.Equals(Model.ImportCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<div class="alert alert-success mb-0">
|
||||
This run was committed on @Model.ImportCommit.CommittedAt.ToString("yyyy-MM-dd HH:mm:ss") UTC.
|
||||
Created @Model.ImportCommit.ScenesCreated.ToString("N0") scene@(Model.ImportCommit.ScenesCreated == 1 ? string.Empty : "s")
|
||||
and @Model.ImportCommit.MetricsCreated.ToString("N0") metric value@(Model.ImportCommit.MetricsCreated == 1 ? string.Empty : "s").
|
||||
</div>
|
||||
}
|
||||
else if (CanCommitImport(run, Model.SceneResults))
|
||||
{
|
||||
<a class="btn btn-primary" asp-action="ConfirmStoryIntelligenceImportCommit" asp-route-id="@run.StoryIntelligenceRunID">Commit Import</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="edit-panel">
|
||||
@ -555,6 +574,13 @@ else
|
||||
or StoryIntelligenceRunStatuses.Failed
|
||||
or StoryIntelligenceRunStatuses.Cancelled);
|
||||
|
||||
private static bool CanCommitImport(StoryIntelligenceSavedRun run, IReadOnlyList<StoryIntelligenceSavedSceneResult> scenes)
|
||||
=> run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings
|
||||
&& run.ProjectID.HasValue
|
||||
&& run.BookID.HasValue
|
||||
&& run.ChapterID.HasValue
|
||||
&& scenes.Count > 0;
|
||||
|
||||
private static string SceneFailureStage(StoryIntelligenceSavedSceneResult scene)
|
||||
=> SceneErrorMessage(scene).Contains("boundary", StringComparison.OrdinalIgnoreCase)
|
||||
? StoryIntelligenceFailureStages.SceneSplit
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user