PlotDirector/PlotLine/Services/StoryIntelligenceSceneContextService.cs

320 lines
13 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IStoryIntelligenceSceneContextService
{
Task<StoryIntelligenceCanonicalSceneContext> BuildAsync(
StoryIntelligenceQueuedRun run,
IReadOnlyList<Scene> orderedChapterScenes,
Scene scene,
SceneManuscriptSourceRecord source,
IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines);
}
public sealed class StoryIntelligenceSceneContextService(
IBookRepository books,
IChapterRepository chapters,
ISceneRepository scenes,
ICharacterRepository characters) : IStoryIntelligenceSceneContextService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
public async Task<StoryIntelligenceCanonicalSceneContext> BuildAsync(
StoryIntelligenceQueuedRun run,
IReadOnlyList<Scene> orderedChapterScenes,
Scene scene,
SceneManuscriptSourceRecord source,
IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines)
{
var book = run.BookID.HasValue ? await books.GetAsync(run.BookID.Value) : null;
var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null;
var neighbourScenes = await BuildNeighbourScenesAsync(book, scene);
var currentCharacters = await BuildCurrentCharactersAsync(scene);
return new StoryIntelligenceCanonicalSceneContext(
new StoryIntelligenceBookContext(
run.ProjectID ?? book?.ProjectID,
run.BookID ?? book?.BookID,
book?.BookDisplayTitle,
book?.StoryEra,
BookContextOptions.StoryEraLabel(book?.StoryEra),
book?.PrimaryLocaleCode,
BookContextOptions.PrimaryLocaleLabel(book?.PrimaryLocaleCode)),
new StoryIntelligenceChapterContext(
run.ChapterID ?? chapter?.ChapterID,
run.ChapterNumber ?? chapter?.ChapterNumber,
chapter?.SortOrder,
chapter?.ChapterTitle,
chapter?.Summary),
new StoryIntelligenceCurrentSceneContext(
scene.SceneID,
scene.SceneNumber,
scene.SortOrder,
scene.SceneTitle,
FirstNonBlank(scene.StructuralSummary, scene.Summary, source.StructuralSummary),
scene.StructuralSetting,
scene.PrimaryLocationName,
scene.POVCharacterID,
FirstNonBlank(scene.POVCharacterName, scene.POVSuggestedName),
scene.POVSource,
source.SourceStartParagraph,
source.SourceEndParagraph,
source.WordStartParagraphIndex,
source.WordEndParagraphIndex,
source.SourceWordCount,
source.SourceCharacterCount,
source.SourceKind,
source.SourceText),
neighbourScenes,
currentCharacters,
configuredMetrics.Select(metric => new StoryIntelligenceMetricContext(
$"metric_{metric.MetricTypeID}",
metric.MetricTypeID,
metric.MetricName,
metric.Description,
metric.MinValue,
metric.MaxValue,
metric.DefaultValue)).ToList(),
existingPlotLines.Select(plotLine => new StoryIntelligencePlotLineContext(
plotLine.PlotLineID,
plotLine.PlotLineName,
plotLine.Description,
plotLine.PlotImportanceName,
plotLine.BookID)).ToList(),
run.SourceLabel ?? string.Empty);
}
private async Task<IReadOnlyList<StoryIntelligenceNeighbourSceneContext>> BuildNeighbourScenesAsync(Book? book, Scene currentScene)
{
if (book is null)
{
return [];
}
var orderedScenes = new List<Scene>();
foreach (var chapter in await chapters.ListByBookAsync(book.BookID))
{
orderedScenes.AddRange((await scenes.ListByChapterAsync(chapter.ChapterID)).Where(scene => !scene.IsArchived));
}
orderedScenes = orderedScenes
.OrderBy(scene => scene.ChapterSortOrder)
.ThenBy(scene => scene.ChapterNumber)
.ThenBy(scene => scene.SortOrder)
.ThenBy(scene => scene.SceneNumber)
.ThenBy(scene => scene.SceneID)
.ToList();
var currentIndex = orderedScenes.FindIndex(scene => scene.SceneID == currentScene.SceneID);
if (currentIndex < 0)
{
return [];
}
var neighbours = new List<StoryIntelligenceNeighbourSceneContext>();
if (currentIndex > 0)
{
neighbours.Add(BuildNeighbour("previous", orderedScenes[currentIndex - 1]));
}
if (currentIndex < orderedScenes.Count - 1)
{
neighbours.Add(BuildNeighbour("next", orderedScenes[currentIndex + 1]));
}
return neighbours;
}
private static StoryIntelligenceNeighbourSceneContext BuildNeighbour(string position, Scene scene)
=> new(
position,
scene.SceneID,
scene.ChapterID,
scene.ChapterNumber,
scene.SceneNumber,
scene.SceneTitle,
FirstNonBlank(scene.StructuralSummary, scene.Summary),
scene.StructuralSetting,
scene.POVCharacterID,
FirstNonBlank(scene.POVCharacterName, scene.POVSuggestedName));
private async Task<IReadOnlyList<StoryIntelligenceSceneCharacterContext>> BuildCurrentCharactersAsync(Scene scene)
{
var appearances = await characters.ListSceneCharactersAsync(scene.SceneID);
var characterIds = appearances.Select(appearance => appearance.CharacterID).ToHashSet();
if (scene.POVCharacterID.HasValue)
{
characterIds.Add(scene.POVCharacterID.Value);
}
var rows = new List<StoryIntelligenceSceneCharacterContext>();
foreach (var characterId in characterIds.OrderBy(id => id))
{
var appearance = appearances.FirstOrDefault(row => row.CharacterID == characterId);
var aliases = (await characters.ListAliasesAsync(characterId))
.Select(alias => alias.Alias)
.Where(alias => !string.IsNullOrWhiteSpace(alias))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(alias => alias, StringComparer.OrdinalIgnoreCase)
.ToList();
rows.Add(new StoryIntelligenceSceneCharacterContext(
characterId,
appearance?.CharacterName ?? (characterId == scene.POVCharacterID ? FirstNonBlank(scene.POVCharacterName, scene.POVSuggestedName) : null) ?? $"Character {characterId:N0}",
aliases,
characterId == scene.POVCharacterID,
appearance is not null,
appearance?.RoleInSceneTypeName,
appearance?.PresenceTypeName,
appearance?.MatchSource,
appearance?.MatchedText,
appearance?.OccurrenceCount,
appearance?.FirstOccurrenceIndex));
}
return rows;
}
private static string? FirstNonBlank(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
public static string ToPromptJson(StoryIntelligenceCanonicalSceneContext context)
=> JsonSerializer.Serialize(new
{
context.SourceLabel,
book = new
{
projectId = context.Book.ProjectID,
bookId = context.Book.BookID,
context.Book.Title,
context.Book.StoryEra,
context.Book.StoryEraLabel,
context.Book.PrimaryLocaleCode,
context.Book.PrimaryLocaleLabel
},
chapter = new
{
chapterId = context.Chapter.ChapterID,
chapterNumber = context.Chapter.ChapterNumber,
context.Chapter.SortOrder,
context.Chapter.Title,
context.Chapter.StructuralSummary
},
currentScene = new
{
sceneId = context.CurrentScene.SceneID,
sceneNumber = context.CurrentScene.SceneNumber,
context.CurrentScene.SortOrder,
context.CurrentScene.Title,
context.CurrentScene.StructuralSummary,
context.CurrentScene.StructuralSetting,
context.CurrentScene.PrimaryLocationName,
context.CurrentScene.POVCharacterID,
context.CurrentScene.POVCharacterName,
context.CurrentScene.POVSource,
sourceParagraphRange = new
{
context.CurrentScene.SourceStartParagraph,
context.CurrentScene.SourceEndParagraph,
context.CurrentScene.WordStartParagraphIndex,
context.CurrentScene.WordEndParagraphIndex,
context.CurrentScene.SourceWordCount,
context.CurrentScene.SourceCharacterCount,
context.CurrentScene.SourceKind
}
},
neighbourScenes = context.NeighbourScenes.Select(scene => new
{
scene.Position,
sceneId = scene.SceneID,
chapterId = scene.ChapterID,
scene.ChapterNumber,
scene.SceneNumber,
scene.Title,
scene.StructuralSummary,
scene.StructuralSetting,
povCharacterId = scene.POVCharacterID,
povCharacterName = scene.POVCharacterName
}),
sceneCharacters = context.SceneCharacters.Select(character => new
{
characterId = character.CharacterID,
character.CanonicalName,
character.Aliases,
character.IsPointOfView,
character.IsSceneMapped,
character.RoleInScene,
character.PresenceType,
character.MatchSource,
character.MatchedText,
character.OccurrenceCount,
character.FirstOccurrenceIndex
}),
configuredMetrics = context.ConfiguredMetrics.Select(metric => new
{
metric.Key,
metricTypeId = metric.MetricTypeID,
metric.Name,
metric.Description,
metric.MinValue,
metric.MaxValue,
metric.DefaultValue
}),
existingPlotLines = context.ExistingPlotLines.Select(plotLine => new
{
plotLineId = plotLine.PlotLineID,
plotLine.Name,
plotLine.Description,
plotLine.Status,
bookId = plotLine.BookID
})
}, JsonOptions);
}
public sealed record StoryIntelligenceCanonicalSceneContext(
StoryIntelligenceBookContext Book,
StoryIntelligenceChapterContext Chapter,
StoryIntelligenceCurrentSceneContext CurrentScene,
IReadOnlyList<StoryIntelligenceNeighbourSceneContext> NeighbourScenes,
IReadOnlyList<StoryIntelligenceSceneCharacterContext> SceneCharacters,
IReadOnlyList<StoryIntelligenceMetricContext> ConfiguredMetrics,
IReadOnlyList<StoryIntelligencePlotLineContext> ExistingPlotLines,
string SourceLabel);
public sealed record StoryIntelligenceBookContext(int? ProjectID, int? BookID, string? Title, string? StoryEra, string? StoryEraLabel, string? PrimaryLocaleCode, string? PrimaryLocaleLabel);
public sealed record StoryIntelligenceChapterContext(int? ChapterID, decimal? ChapterNumber, int? SortOrder, string? Title, string? StructuralSummary);
public sealed record StoryIntelligenceCurrentSceneContext(
int SceneID,
decimal SceneNumber,
int SortOrder,
string Title,
string? StructuralSummary,
string? StructuralSetting,
string? PrimaryLocationName,
int? POVCharacterID,
string? POVCharacterName,
string? POVSource,
int? SourceStartParagraph,
int? SourceEndParagraph,
int? WordStartParagraphIndex,
int? WordEndParagraphIndex,
int SourceWordCount,
int SourceCharacterCount,
string SourceKind,
[property: JsonIgnore] string SourceText);
public sealed record StoryIntelligenceNeighbourSceneContext(string Position, int SceneID, int ChapterID, decimal ChapterNumber, decimal SceneNumber, string Title, string? StructuralSummary, string? StructuralSetting, int? POVCharacterID, string? POVCharacterName);
public sealed record StoryIntelligenceSceneCharacterContext(int CharacterID, string CanonicalName, IReadOnlyList<string> Aliases, bool IsPointOfView, bool IsSceneMapped, string? RoleInScene, string? PresenceType, string? MatchSource, string? MatchedText, int? OccurrenceCount, int? FirstOccurrenceIndex);
public sealed record StoryIntelligenceMetricContext(string Key, int MetricTypeID, string Name, string? Description, int MinValue, int MaxValue, int DefaultValue);
public sealed record StoryIntelligencePlotLineContext(int PlotLineID, string Name, string? Description, string? Status, int? BookID);