452 lines
19 KiB
C#
452 lines
19 KiB
C#
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,
|
|
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 readiness = run.ChapterID.HasValue
|
|
? await storyRuns.GetChapterImportReadinessAsync(run.ChapterID.Value)
|
|
: null;
|
|
if (readiness is not null && !readiness.CanCommit)
|
|
{
|
|
blockers.Add(readiness.BlockReason ?? "This chapter contains existing authored scenes.");
|
|
}
|
|
else if (readiness?.HasUntouchedDefaultPlaceholder == true)
|
|
{
|
|
warnings.Add("This chapter contains only the default empty scene. It will be replaced by Scene 1 from the import.");
|
|
}
|
|
|
|
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 = readiness?.ActiveSceneCount ?? 0,
|
|
HasUntouchedDefaultPlaceholder = readiness?.HasUntouchedDefaultPlaceholder == true,
|
|
CommitMode = readiness?.CommitMode ?? string.Empty,
|
|
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);
|
|
}
|