320 lines
13 KiB
C#
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);
|