Phase 20V – Story Intelligence Import Commit Foundation

This commit is contained in:
Nick Beckley 2026-07-05 20:54:34 +01:00
parent 5b83b001c1
commit 8089140607
11 changed files with 1060 additions and 2 deletions

View File

@ -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)
{

View File

@ -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);
}
}

View File

@ -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; } = [];

View File

@ -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;
}

View File

@ -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>();

View 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);
}

View File

@ -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)
};
}

View 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

View File

@ -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

View 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>

View File

@ -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