PlotDirector/PlotLine/Services/StoryIntelligenceImportCommitService.cs

991 lines
41 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,
IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceImportCommitService> logger) : IStoryIntelligenceImportCommitService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
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,
ChapterSummary = prepared.ChapterSummary,
ChapterPurposeID = prepared.ChapterPurposeID,
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);
if (result.Success && prepared.Run.ProjectID.HasValue && prepared.Run.BookID.HasValue)
{
await pipelineState.RecordSceneImportAsync(prepared.Run.ProjectID.Value, prepared.Run.BookID.Value, prepared.Run.StoryIntelligenceRunID);
}
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.");
}
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();
if (run.ProjectID.HasValue)
{
await metricTypes.AddMissingDefaultsAsync(run.ProjectID.Value);
}
var activeMetricTypes = run.ProjectID.HasValue
? (await metricTypes.ListForManagementAsync(run.ProjectID.Value)).Where(metric => metric.IsActive && metric.IsEnabledForProject).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 timeModeIds = lookupData.TimeModes.ToDictionary(item => item.TimeModeName, item => item.TimeModeID, StringComparer.OrdinalIgnoreCase);
var timeConfidenceIds = lookupData.TimeConfidences.ToDictionary(item => item.TimeConfidenceName, item => item.TimeConfidenceID, StringComparer.OrdinalIgnoreCase);
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 and Story Intelligence import notes 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 chapterPurposeMap = lookupData.ChapterPurposeTypes.ToDictionary(type => Clean(type.PurposeName), type => type.ChapterPurposeID, StringComparer.OrdinalIgnoreCase);
var metricMap = BuildMetricMap(activeMetricTypes);
var chapterResult = await storyRuns.GetChapterResultAsync(runId);
var parsedChapter = TryReadChapter(chapterResult);
var chapterSummary = Clean(parsedChapter?.ChapterSummary);
var chapterPurposeId = MatchChapterPurpose(parsedChapter, chapterPurposeMap);
var canonicalScenes = run.ChapterID.HasValue
? await storyRuns.ListChapterScenesAsync(run.ChapterID.Value)
: [];
var canonicalById = canonicalScenes.ToDictionary(scene => scene.SceneID);
var rawImportScenes = new List<StoryIntelligenceSceneImportItem>();
foreach (var sceneResult in sceneResults.OrderBy(scene => scene.TemporarySceneNumber))
{
canonicalById.TryGetValue(sceneResult.SceneID ?? 0, out var canonicalScene);
var parsed = TryReadScene(sceneResult);
if (parsed is null)
{
var fallback = BuildFallbackImportScene(sceneResult, metricMap, canonicalScene);
if (fallback is null)
{
warnings.Add($"Scene {sceneResult.TemporarySceneNumber:N0} could not be prepared automatically and needs review.");
continue;
}
warnings.Add($"Scene {sceneResult.TemporarySceneNumber:N0} will be created with limited detail because some analysis could not be read.");
rawImportScenes.Add(fallback);
continue;
}
rawImportScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap, canonicalScene));
}
var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null;
var importScenes = ApplyTemporalResolution(
rawImportScenes,
StoryIntelligenceTemporalResolver.CreateChapterContext(chapter?.ChapterTitle, chapter?.Summary),
timeModeIds,
timeConfidenceIds,
timeModeId,
timeConfidenceId,
warnings);
if (importScenes.Count == 0)
{
blockers.Add("No analysed scene results are available for this chapter.");
}
var readiness = run.ChapterID.HasValue
? await storyRuns.GetChapterImportReadinessAsync(run.ChapterID.Value)
: null;
if (readiness is not null && !readiness.CanCommit && readiness.ActiveSceneCount != importScenes.Count)
{
blockers.Add($"This chapter contains {readiness.ActiveSceneCount:N0} existing scene(s), but Story Intelligence produced {importScenes.Count:N0} scene result(s).");
}
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 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,
CommitMode = readiness?.CommitMode ?? string.Empty,
ScenesToCreate = canonicalScenes.Count > 0 ? canonicalScenes.Count : 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,
Scenes = BuildReviewScenes(canonicalScenes, sceneResults, importScenes)
},
importScenes,
timeModeId,
timeConfidenceId,
revisionStatusId,
timelineNoteTypeId,
string.IsNullOrWhiteSpace(chapterSummary) ? null : chapterSummary,
chapterPurposeId);
}
private static StoryIntelligenceSceneImportItem BuildImportScene(
StoryIntelligenceSavedSceneResult sceneResult,
SceneIntelligenceScene parsed,
IReadOnlyDictionary<string, int> characterMap,
IReadOnlyDictionary<string, int> purposeMap,
IReadOnlyDictionary<string, SceneMetricType> metricMap,
Scene? canonicalScene = null)
{
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 = CanonicalSummary(canonicalScene) ?? Clean(parsed.Summary?.Short);
var sceneTitle = GenerateTitle(sceneResult.TemporarySceneNumber, summary);
return new StoryIntelligenceSceneImportItem
{
SceneResultID = sceneResult.SceneResultID,
SceneID = sceneResult.SceneID,
TemporarySceneNumber = sceneResult.TemporarySceneNumber,
StartParagraph = sceneResult.StartParagraph,
EndParagraph = sceneResult.EndParagraph,
SourceLabel = sceneResult.SourceLabel,
ParsedScene = parsed,
PovCharacterID = povCharacterId,
PurposeTypeIDs = MatchPurposes(parsed, purposeMap),
Metrics = BuildMetrics(parsed, metricMap),
SceneTitle = canonicalScene is not null && !string.IsNullOrWhiteSpace(canonicalScene.SceneTitle) ? canonicalScene.SceneTitle : sceneTitle,
Summary = string.IsNullOrWhiteSpace(summary) ? null : summary,
PurposeNotes = BuildPurposeNotes(parsed, sceneResult, povName, povCharacterId),
OutcomeNotes = canonicalScene is null && !string.IsNullOrWhiteSpace(parsed.Summary?.Detailed) ? Clean(parsed.Summary.Detailed) : null,
ImportNoteText = BuildImportNote(parsed, povName, povCharacterId),
AnalysisStatus = sceneResult.ValidationErrorsCount > 0 ? "Needs review" : "Ready",
HasWarnings = sceneResult.ValidationWarningsCount > 0 || sceneResult.ValidationErrorsCount > 0
};
}
private static StoryIntelligenceSceneImportItem? BuildFallbackImportScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<string, SceneMetricType> metricMap,
Scene? canonicalScene = null)
{
if (!sceneResult.StartParagraph.HasValue || !sceneResult.EndParagraph.HasValue || sceneResult.StartParagraph > sceneResult.EndParagraph)
{
return null;
}
var summary = CanonicalSummary(canonicalScene)
?? ExtractSummaryFromJsonText(sceneResult.OutputTextJson)
?? ExtractSummaryFromJsonText(sceneResult.ParsedJson)
?? $"Scene {sceneResult.TemporarySceneNumber:N0} from paragraphs {DisplayRange(sceneResult.StartParagraph, sceneResult.EndParagraph)}.";
var parsed = new SceneIntelligenceScene
{
SchemaVersion = "fallback",
Summary = new SceneIntelligenceSummary
{
Short = summary,
Detailed = summary,
Confidence = null
},
PointOfView = new SceneIntelligencePointOfView
{
CharacterName = null,
NarrativeMode = "Unknown",
Confidence = null
},
Setting = new SceneIntelligenceSetting
{
LocationName = null,
LocationType = "Unknown",
Confidence = null
},
Metrics = new Dictionary<string, SceneIntelligenceMetric>()
};
return new StoryIntelligenceSceneImportItem
{
SceneResultID = sceneResult.SceneResultID,
SceneID = sceneResult.SceneID,
TemporarySceneNumber = sceneResult.TemporarySceneNumber,
StartParagraph = sceneResult.StartParagraph,
EndParagraph = sceneResult.EndParagraph,
SourceLabel = sceneResult.SourceLabel,
ParsedScene = parsed,
PovCharacterID = null,
PurposeTypeIDs = [],
Metrics = [],
SceneTitle = canonicalScene is not null && !string.IsNullOrWhiteSpace(canonicalScene.SceneTitle) ? canonicalScene.SceneTitle : GenerateTitle(sceneResult.TemporarySceneNumber, summary),
Summary = summary,
PurposeNotes = $"Story Intelligence import source: run {sceneResult.StoryIntelligenceRunID:N0}, paragraphs {DisplayRange(sceneResult.StartParagraph, sceneResult.EndParagraph)}.{Environment.NewLine}Some optional analysis could not be read, so this scene was created with limited detail.",
OutcomeNotes = null,
ImportNoteText = "Scene created with limited Story Intelligence detail. Review the source manuscript for POV, setting, metrics and timeline notes.",
AnalysisStatus = "Analysis failed",
HasWarnings = true
};
}
private static IReadOnlyList<StoryIntelligenceSceneImportItem> ApplyTemporalResolution(
IReadOnlyList<StoryIntelligenceSceneImportItem> scenes,
StoryIntelligenceTemporalContext initialContext,
IReadOnlyDictionary<string, int> timeModeIds,
IReadOnlyDictionary<string, int> timeConfidenceIds,
int defaultTimeModeId,
int defaultTimeConfidenceId,
ICollection<string> warnings)
{
var context = initialContext;
var resolvedScenes = new List<StoryIntelligenceSceneImportItem>(scenes.Count);
foreach (var scene in scenes.OrderBy(item => item.TemporarySceneNumber))
{
var resolved = StoryIntelligenceTemporalResolver.Resolve(scene.ParsedScene, context);
context = resolved.NextContext;
if (!timeModeIds.TryGetValue(resolved.TimeModeName, out var resolvedTimeModeId))
{
resolvedTimeModeId = defaultTimeModeId;
warnings.Add($"Scene {scene.TemporarySceneNumber:N0} chronology used fallback time mode because '{resolved.TimeModeName}' was not available.");
}
if (!timeConfidenceIds.TryGetValue(resolved.TimeConfidenceName, out var resolvedTimeConfidenceId))
{
resolvedTimeConfidenceId = defaultTimeConfidenceId;
warnings.Add($"Scene {scene.TemporarySceneNumber:N0} chronology used fallback time confidence because '{resolved.TimeConfidenceName}' was not available.");
}
resolvedScenes.Add(new StoryIntelligenceSceneImportItem
{
SceneResultID = scene.SceneResultID,
SceneID = scene.SceneID,
TemporarySceneNumber = scene.TemporarySceneNumber,
StartParagraph = scene.StartParagraph,
EndParagraph = scene.EndParagraph,
SourceLabel = scene.SourceLabel,
ParsedScene = scene.ParsedScene,
PovCharacterID = scene.PovCharacterID,
PurposeTypeIDs = scene.PurposeTypeIDs,
Metrics = scene.Metrics,
SceneTitle = scene.SceneTitle,
Summary = scene.Summary,
PurposeNotes = scene.PurposeNotes,
OutcomeNotes = scene.OutcomeNotes,
ImportNoteText = scene.ImportNoteText,
TimeModeID = resolvedTimeModeId,
StartDateTime = resolved.StartDateTime,
EndDateTime = resolved.EndDateTime,
DurationAmount = resolved.DurationAmount,
DurationUnitID = resolved.DurationUnitID,
RelativeTimeText = resolved.RelativeTimeText,
TimeConfidenceID = resolvedTimeConfidenceId,
AnalysisStatus = scene.AnalysisStatus,
HasWarnings = scene.HasWarnings
});
}
return resolvedScenes;
}
private static IReadOnlyList<StoryIntelligenceSceneImportItem> BuildReviewScenes(
IReadOnlyList<Scene> canonicalScenes,
IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults,
IReadOnlyList<StoryIntelligenceSceneImportItem> importScenes)
{
if (canonicalScenes.Count == 0)
{
return importScenes;
}
var resultsBySceneId = sceneResults
.Where(result => result.SceneID.HasValue)
.GroupBy(result => result.SceneID!.Value)
.ToDictionary(
group => group.Key,
group => group
.OrderBy(result => result.ValidationErrorsCount > 0)
.ThenByDescending(result => result.SceneResultID)
.First());
var importsBySceneId = importScenes
.Where(scene => scene.SceneID.HasValue)
.GroupBy(scene => scene.SceneID!.Value)
.ToDictionary(group => group.Key, group => group.First());
return canonicalScenes
.OrderBy(scene => scene.SortOrder)
.ThenBy(scene => scene.SceneNumber)
.ThenBy(scene => scene.SceneID)
.Select((scene, index) =>
{
var sceneNumber = SceneDisplayNumber(scene.SceneNumber, index + 1);
resultsBySceneId.TryGetValue(scene.SceneID, out var result);
importsBySceneId.TryGetValue(scene.SceneID, out var importScene);
var parsed = result is null ? null : TryReadScene(result);
var summary = CanonicalSummary(scene)
?? FirstConfigured(importScene?.Summary, parsed?.Summary?.Short, parsed?.Summary?.Detailed, $"Scene {sceneNumber:N0}")
?? $"Scene {sceneNumber:N0}";
return new StoryIntelligenceSceneImportItem
{
SceneResultID = result?.SceneResultID ?? 0,
SceneID = scene.SceneID,
TemporarySceneNumber = sceneNumber,
StartParagraph = result?.StartParagraph,
EndParagraph = result?.EndParagraph,
SourceLabel = result?.SourceLabel ?? $"Scene {sceneNumber:N0}",
ParsedScene = parsed ?? new SceneIntelligenceScene(),
PovCharacterID = importScene?.PovCharacterID,
PurposeTypeIDs = importScene?.PurposeTypeIDs ?? [],
Metrics = importScene?.Metrics ?? [],
SceneTitle = string.IsNullOrWhiteSpace(scene.SceneTitle) ? $"Scene {sceneNumber:N0}" : scene.SceneTitle,
Summary = summary,
PurposeNotes = importScene?.PurposeNotes,
OutcomeNotes = null,
ImportNoteText = importScene?.ImportNoteText,
AnalysisStatus = result is null
? "Analysis missing"
: result.ValidationErrorsCount > 0
? "Analysis failed"
: "Ready",
HasWarnings = result is null || result.ValidationErrorsCount > 0 || result.ValidationWarningsCount > 0
};
})
.ToList();
}
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 IReadOnlyDictionary<string, SceneMetricType> BuildMetricMap(IReadOnlyList<SceneMetricType> metricTypes)
=> StoryIntelligenceMaterialisationService.BuildMetricMap(metricTypes);
private static IReadOnlyList<SceneMetricValue> BuildMetrics(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, SceneMetricType> metricMap)
=> StoryIntelligenceMaterialisationService.BuildMetrics(parsed, metricMap);
private static IReadOnlyList<int> MatchPurposes(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, int> purposeMap)
{
var values = ReadPurposeTexts(parsed).Select(Clean).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (values.Count == 0)
{
return [];
}
var matches = purposeMap
.Where(pair => values.Any(value => 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}");
}
var purposeLabels = ReadPurposeLabels(parsed.ScenePurpose).ToList();
if (purposeLabels.Count > 0)
{
lines.Add($"Purpose labels observed: {string.Join(", ", purposeLabels)}");
}
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>();
var timelineClues = ReadFactualTimelineClues(parsed).ToList();
if (timelineClues.Count > 0)
{
lines.Add("Timeline clues:");
lines.AddRange(timelineClues.Select(clue => $"- {clue}"));
}
var sceneNotes = ReadUsefulSceneNotes(parsed).ToList();
if (sceneNotes.Count > 0)
{
if (lines.Count > 0)
{
lines.Add(string.Empty);
}
lines.Add("Scene notes:");
lines.AddRange(sceneNotes.Select(note => $"- {note}"));
}
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 IEnumerable<string> ReadPurposeTexts(SceneIntelligenceScene parsed)
{
if (!string.IsNullOrWhiteSpace(parsed.ScenePurpose?.ObservedFunction))
{
yield return parsed.ScenePurpose.ObservedFunction;
}
foreach (var label in ReadPurposeLabels(parsed.ScenePurpose))
{
yield return label;
}
}
private static IEnumerable<string> ReadPurposeLabels(SceneIntelligenceScenePurpose? purpose)
{
if (purpose?.ExtensionData is null)
{
yield break;
}
foreach (var propertyName in new[] { "purposeLabels", "labels", "label" })
{
if (!purpose.ExtensionData.TryGetValue(propertyName, out var element))
{
continue;
}
if (element.ValueKind == JsonValueKind.String)
{
var label = Clean(element.GetString());
if (!string.IsNullOrWhiteSpace(label))
{
yield return label;
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var label = Clean(item.GetString());
if (!string.IsNullOrWhiteSpace(label))
{
yield return label;
}
}
}
}
}
}
private static IEnumerable<string> ReadFactualTimelineClues(SceneIntelligenceScene parsed)
{
if (parsed.TimelineClues is null)
{
yield break;
}
foreach (var clue in parsed.TimelineClues)
{
var value = Clean(clue.AbsoluteDate ?? clue.RelativeOrder ?? clue.Clue);
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
var source = $"{clue.Clue} {clue.RelativeOrder} {clue.AbsoluteDate} {clue.Evidence}";
if (source.Contains("speculat", StringComparison.OrdinalIgnoreCase)
|| source.Contains("uncertain", StringComparison.OrdinalIgnoreCase)
|| source.Contains("unknown", StringComparison.OrdinalIgnoreCase))
{
continue;
}
yield return value;
}
}
private static IEnumerable<string> ReadUsefulSceneNotes(SceneIntelligenceScene parsed)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var note in ReadSettingNotes(parsed.Setting))
{
if (seen.Add(note))
{
yield return note;
}
}
if (parsed.SourceLimits?.AmbiguityNotes is not null)
{
foreach (var ambiguity in parsed.SourceLimits.AmbiguityNotes.Select(Clean).Where(value => !string.IsNullOrWhiteSpace(value)))
{
var note = $"Ambiguity: {ambiguity}";
if (seen.Add(note))
{
yield return note;
}
}
}
if (parsed.Observations is null)
{
yield break;
}
foreach (var observation in parsed.Observations)
{
var note = Clean(observation.Description);
if (string.IsNullOrWhiteSpace(note) || !IsUsefulSceneObservation(observation))
{
continue;
}
if (seen.Add(note))
{
yield return note;
}
}
}
private static IEnumerable<string> ReadSettingNotes(SceneIntelligenceSetting? setting)
{
if (setting is null)
{
yield break;
}
var atmosphere = ReadStringExtension(setting.ExtensionData, "atmosphere", "mood", "tone");
if (!string.IsNullOrWhiteSpace(atmosphere))
{
yield return $"Atmosphere: {atmosphere}";
}
}
private static bool IsUsefulSceneObservation(SceneIntelligenceObservation observation)
{
var type = Clean(observation.ObservationType);
var predicate = Clean(observation.Predicate);
return type.Contains("setting", StringComparison.OrdinalIgnoreCase)
|| type.Contains("atmosphere", StringComparison.OrdinalIgnoreCase)
|| type.Contains("scene", StringComparison.OrdinalIgnoreCase)
|| predicate.Contains("observes", StringComparison.OrdinalIgnoreCase)
|| predicate.Contains("reveals", StringComparison.OrdinalIgnoreCase)
|| predicate.Contains("confirms", StringComparison.OrdinalIgnoreCase)
|| predicate.Contains("discovers", StringComparison.OrdinalIgnoreCase);
}
private static string? ReadStringExtension(Dictionary<string, JsonElement>? data, params string[] names)
{
if (data is null)
{
return null;
}
foreach (var name in names)
{
if (data.TryGetValue(name, out var element) && element.ValueKind == JsonValueKind.String)
{
var value = Clean(element.GetString());
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
}
return null;
}
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;
}
using var document = JsonDocument.Parse(result.ParsedJson);
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
{
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
}
}
catch (JsonException)
{
}
return null;
}
private static ChapterStructureModel? TryReadChapter(StoryIntelligenceSavedChapterResult? result)
{
if (string.IsNullOrWhiteSpace(result?.ParsedJson))
{
return null;
}
try
{
return JsonSerializer.Deserialize<ChapterStructureModel>(result.ParsedJson, JsonOptions);
}
catch (JsonException)
{
return null;
}
}
private static int? MatchChapterPurpose(ChapterStructureModel? chapter, IReadOnlyDictionary<string, int> purposeMap)
{
if (chapter is null || purposeMap.Count == 0)
{
return null;
}
var texts = new List<string>();
if (!string.IsNullOrWhiteSpace(chapter.ChapterSummary))
{
texts.Add(chapter.ChapterSummary);
}
if (chapter.ExtensionData is not null)
{
foreach (var name in new[] { "chapterPurpose", "primaryPurpose", "purpose" })
{
if (chapter.ExtensionData.TryGetValue(name, out var element) && element.ValueKind == JsonValueKind.String)
{
texts.Add(element.GetString() ?? string.Empty);
}
}
}
texts.AddRange((chapter.SceneBoundaries ?? []).Select(boundary => boundary.Reason ?? string.Empty));
var combined = Clean(string.Join(" ", texts));
if (string.IsNullOrWhiteSpace(combined))
{
return null;
}
var direct = purposeMap.FirstOrDefault(pair => combined.Contains(pair.Key, StringComparison.OrdinalIgnoreCase));
if (direct.Value > 0)
{
return direct.Value;
}
var lowered = combined.ToLowerInvariant();
foreach (var (key, value) in purposeMap)
{
var purpose = key.ToLowerInvariant();
if ((purpose.Contains("investigation") && ContainsAny(lowered, "investigat", "question", "search", "clue"))
|| (purpose.Contains("revelation") && ContainsAny(lowered, "reveal", "discover", "learns", "truth"))
|| (purpose.Contains("relationship") && ContainsAny(lowered, "relationship", "bond", "trust", "conflict between"))
|| (purpose.Contains("character") && ContainsAny(lowered, "character", "grief", "fear", "choice", "realises", "realizes"))
|| (purpose.Contains("escalation") && ContainsAny(lowered, "escalat", "danger", "worsens", "pressure"))
|| (purpose.Contains("turning") && ContainsAny(lowered, "turning point", "changes direction", "decision"))
|| (purpose.Contains("transition") && ContainsAny(lowered, "transition", "moves from", "sets up"))
|| (purpose.Contains("resolution") && ContainsAny(lowered, "resolve", "settles", "concludes"))
|| (purpose.Contains("world") && ContainsAny(lowered, "world", "setting", "institution", "rules")))
{
return value;
}
}
return null;
}
private static bool ContainsAny(string value, params string[] needles)
=> needles.Any(needle => value.Contains(needle, StringComparison.OrdinalIgnoreCase));
private static string? ExtractSummaryFromJsonText(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
try
{
using var document = JsonDocument.Parse(value);
if (TryReadSummary(document.RootElement, out var summary))
{
return TrimTo(summary!, 500);
}
}
catch (JsonException)
{
}
var shortIndex = value.IndexOf("\"short\"", StringComparison.OrdinalIgnoreCase);
if (shortIndex >= 0)
{
var colonIndex = value.IndexOf(':', shortIndex);
if (colonIndex >= 0)
{
var afterColon = value[(colonIndex + 1)..].TrimStart();
if (afterColon.Length > 1 && afterColon[0] == '"')
{
var endIndex = afterColon.IndexOf('"', 1);
if (endIndex > 1)
{
return TrimTo(afterColon[1..endIndex], 500);
}
}
}
}
return null;
}
private static bool TryReadSummary(JsonElement element, out string? summary)
{
summary = null;
if (element.ValueKind != JsonValueKind.Object)
{
return false;
}
if (element.TryGetProperty("parsedScene", out var parsedScene))
{
return TryReadSummary(parsedScene, out summary);
}
if (!element.TryGetProperty("summary", out var summaryElement) || summaryElement.ValueKind != JsonValueKind.Object)
{
return false;
}
foreach (var propertyName in new[] { "short", "detailed" })
{
if (summaryElement.TryGetProperty(propertyName, out var valueElement)
&& valueElement.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(valueElement.GetString()))
{
summary = valueElement.GetString();
return true;
}
}
return false;
}
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 int SceneDisplayNumber(decimal sceneNumber, int ordinal)
=> decimal.Truncate(sceneNumber) == sceneNumber && sceneNumber > 0 && sceneNumber <= int.MaxValue
? Convert.ToInt32(sceneNumber)
: ordinal;
private static string? CanonicalSummary(Scene? scene)
=> scene is null ? null : FirstConfigured(scene.StructuralSummary, scene.Summary);
private static string? FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
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,
string? ChapterSummary,
int? ChapterPurposeID);
}