404 lines
20 KiB
C#
404 lines
20 KiB
C#
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
|
using PlotLine.Data;
|
|
using PlotLine.Models;
|
|
using PlotLine.ViewModels;
|
|
|
|
namespace PlotLine.Services;
|
|
|
|
public interface IStoryIntelligenceNarrativeReviewService
|
|
{
|
|
Task<StoryIntelligenceNarrativeReviewViewModel> GetReviewAsync(OnboardingStoryIntelligenceBatch batch);
|
|
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId);
|
|
Task<StoryIntelligenceNarrativeImportResultViewModel?> GetResultAsync(OnboardingStoryIntelligenceBatch batch);
|
|
}
|
|
|
|
public sealed class StoryIntelligenceNarrativeReviewService(
|
|
IStoryIntelligenceNarrativeCandidateRepository candidates,
|
|
IStoryIntelligenceResultRepository runs,
|
|
ISceneRepository scenes,
|
|
IPlotRepository plots,
|
|
IStoryIntelligencePipelineStateService pipelineState) : IStoryIntelligenceNarrativeReviewService
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
public async Task<StoryIntelligenceNarrativeReviewViewModel> GetReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
|
{
|
|
await EnsureCandidatesAsync(batch);
|
|
var rows = await candidates.ListByBookAsync(batch.BookID);
|
|
var pending = rows
|
|
.Where(candidate => string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
var plotLines = await plots.ListPlotLinesAsync(batch.ProjectID);
|
|
|
|
return new StoryIntelligenceNarrativeReviewViewModel
|
|
{
|
|
HasCommittedScenes = await HasCommittedScenesAsync(batch),
|
|
CanImport = pending.Count > 0,
|
|
IsComplete = rows.Count > 0 && pending.Count == 0,
|
|
PlotLineOptions = plotLines
|
|
.OrderBy(plotLine => plotLine.SortOrder)
|
|
.ThenBy(plotLine => plotLine.PlotLineName)
|
|
.Select(plotLine => new SelectListItem(plotLine.PlotLineName, plotLine.PlotLineID.ToString()))
|
|
.ToList(),
|
|
Candidates = pending.Select(ToViewModel).ToList()
|
|
};
|
|
}
|
|
|
|
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId)
|
|
{
|
|
await EnsureCandidatesAsync(batch);
|
|
var rows = (await candidates.ListByBookAsync(batch.BookID)).ToDictionary(candidate => candidate.NarrativeCandidateID);
|
|
var lookups = await plots.GetLookupsAsync();
|
|
var plotLineTypeId = lookups.PlotLineTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Main Plot", StringComparison.OrdinalIgnoreCase))?.PlotLineTypeID
|
|
?? lookups.PlotLineTypes.FirstOrDefault()?.PlotLineTypeID
|
|
?? 0;
|
|
var plotImportanceId = lookups.PlotImportance.FirstOrDefault(importance => string.Equals(importance.ImportanceName, "Primary", StringComparison.OrdinalIgnoreCase))?.PlotImportanceID
|
|
?? lookups.PlotImportance.FirstOrDefault()?.PlotImportanceID
|
|
?? 0;
|
|
var threadTypeId = lookups.ThreadTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Question", StringComparison.OrdinalIgnoreCase))?.ThreadTypeID
|
|
?? lookups.ThreadTypes.FirstOrDefault()?.ThreadTypeID
|
|
?? 0;
|
|
var threadStatusId = lookups.ThreadStatuses.FirstOrDefault(status => string.Equals(status.StatusName, "Open", StringComparison.OrdinalIgnoreCase))?.ThreadStatusID
|
|
?? lookups.ThreadStatuses.FirstOrDefault()?.ThreadStatusID
|
|
?? 0;
|
|
var threadEventTypeId = lookups.ThreadEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Raised", StringComparison.OrdinalIgnoreCase))?.ThreadEventTypeID
|
|
?? lookups.ThreadEventTypes.FirstOrDefault()?.ThreadEventTypeID
|
|
?? 0;
|
|
var plotEventTypeId = lookups.PlotEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Progress", StringComparison.OrdinalIgnoreCase))?.PlotEventTypeID
|
|
?? lookups.PlotEventTypes.FirstOrDefault()?.PlotEventTypeID
|
|
?? 0;
|
|
|
|
var plotLinesCreated = 0;
|
|
var plotLinesMerged = 0;
|
|
var threadsCreated = 0;
|
|
var rejected = 0;
|
|
|
|
foreach (var choice in form.Candidates.Where(choice => choice.NarrativeCandidateID > 0))
|
|
{
|
|
if (!rows.TryGetValue(choice.NarrativeCandidateID, out var candidate)
|
|
|| !string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var action = string.IsNullOrWhiteSpace(choice.Action) ? StoryIntelligenceNarrativeReviewActions.AcceptNew : choice.Action;
|
|
if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.Reject, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, action, userId, null, null);
|
|
rejected++;
|
|
continue;
|
|
}
|
|
|
|
if (string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.PlotLine, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.MergeExisting, StringComparison.OrdinalIgnoreCase) && choice.ExistingPlotLineID is > 0)
|
|
{
|
|
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Merged, action, userId, choice.ExistingPlotLineID, null);
|
|
plotLinesMerged++;
|
|
continue;
|
|
}
|
|
|
|
var plotLineId = await plots.SavePlotLineAsync(new PlotLineItem
|
|
{
|
|
ProjectID = batch.ProjectID,
|
|
BookID = batch.BookID,
|
|
PlotLineName = CleanTitle(choice.Title, candidate.ProposedTitle),
|
|
Description = CleanNullable(choice.Description) ?? candidate.ProposedDescription,
|
|
PlotLineTypeID = plotLineTypeId,
|
|
PlotImportanceID = plotImportanceId,
|
|
SortOrder = 0,
|
|
Colour = "#2f6f63",
|
|
IsVisibleOnTimeline = true
|
|
});
|
|
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, plotLineId, null);
|
|
plotLinesCreated++;
|
|
continue;
|
|
}
|
|
|
|
var relatedPlotLineId = choice.RelatedPlotLineID ?? candidate.RelatedPlotLineID ?? choice.ExistingPlotLineID;
|
|
if (relatedPlotLineId is not > 0)
|
|
{
|
|
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, StoryIntelligenceNarrativeReviewActions.Reject, userId, null, null);
|
|
rejected++;
|
|
continue;
|
|
}
|
|
|
|
var firstSceneId = candidate.Evidence.OrderBy(evidence => evidence.SortOrder).FirstOrDefault()?.SceneID;
|
|
var threadId = await plots.SavePlotThreadAsync(new PlotThread
|
|
{
|
|
PlotLineID = relatedPlotLineId.Value,
|
|
ThreadTitle = CleanTitle(choice.Title, candidate.ProposedTitle),
|
|
Summary = CleanNullable(choice.Description) ?? candidate.ProposedDescription,
|
|
ThreadTypeID = threadTypeId,
|
|
ThreadStatusID = threadStatusId,
|
|
Importance = 5,
|
|
IntroducedSceneID = firstSceneId
|
|
});
|
|
|
|
foreach (var evidence in candidate.Evidence.Where(evidence => evidence.SceneID > 0))
|
|
{
|
|
await plots.SaveThreadEventAsync(new ThreadEvent
|
|
{
|
|
PlotThreadID = threadId,
|
|
SceneID = evidence.SceneID,
|
|
EventTypeID = threadEventTypeId,
|
|
PlotEventTypeID = plotEventTypeId,
|
|
EventTitle = string.IsNullOrWhiteSpace(evidence.ContributionType) ? candidate.ProposedTitle : evidence.ContributionType,
|
|
EventDescription = evidence.Evidence
|
|
});
|
|
}
|
|
|
|
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, null, threadId);
|
|
threadsCreated++;
|
|
}
|
|
|
|
batch.LastNarrativeImportResult = new StoryIntelligenceNarrativeImportBatchResult
|
|
{
|
|
PlotLinesCreated = plotLinesCreated,
|
|
PlotLinesMerged = plotLinesMerged,
|
|
ThreadsCreated = threadsCreated,
|
|
CandidatesRejected = rejected
|
|
};
|
|
batch.NarrativeStageComplete = true;
|
|
await pipelineState.RecordNarrativeImportAsync(batch.ProjectID, batch.BookID);
|
|
|
|
return new StoryIntelligenceImportCommitResult
|
|
{
|
|
Success = true,
|
|
Message = $"Narrative review applied. {plotLinesCreated:N0} Plot Line(s) created, {threadsCreated:N0} Thread(s) created, {plotLinesMerged:N0} merged, {rejected:N0} rejected."
|
|
};
|
|
}
|
|
|
|
public Task<StoryIntelligenceNarrativeImportResultViewModel?> GetResultAsync(OnboardingStoryIntelligenceBatch batch)
|
|
=> Task.FromResult<StoryIntelligenceNarrativeImportResultViewModel?>(new StoryIntelligenceNarrativeImportResultViewModel
|
|
{
|
|
BatchID = batch.BatchID,
|
|
ProjectID = batch.ProjectID,
|
|
BookID = batch.BookID,
|
|
PlotLinesCreated = batch.LastNarrativeImportResult?.PlotLinesCreated ?? 0,
|
|
PlotLinesMerged = batch.LastNarrativeImportResult?.PlotLinesMerged ?? 0,
|
|
ThreadsCreated = batch.LastNarrativeImportResult?.ThreadsCreated ?? 0,
|
|
CandidatesRejected = batch.LastNarrativeImportResult?.CandidatesRejected ?? 0
|
|
});
|
|
|
|
private async Task EnsureCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
|
|
{
|
|
var existingPlotLines = await plots.ListPlotLinesAsync(batch.ProjectID);
|
|
var existingThreads = await plots.ListPlotThreadsByProjectAsync(batch.ProjectID);
|
|
var existingPlotLineByKey = existingPlotLines
|
|
.GroupBy(plotLine => Key(plotLine.PlotLineName), StringComparer.OrdinalIgnoreCase)
|
|
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
|
|
var existingThreadByKey = existingThreads
|
|
.GroupBy(thread => Key(thread.ThreadTitle), StringComparer.OrdinalIgnoreCase)
|
|
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
|
|
var signals = new Dictionary<string, NarrativeSignal>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var item in batch.Items)
|
|
{
|
|
var commit = await runs.GetImportCommitAsync(item.RunID);
|
|
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
|
|
.Where(scene => scene.ImportRunID == item.RunID)
|
|
.ToList();
|
|
var importedByRange = importedScenes
|
|
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
|
|
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
|
|
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
|
|
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
|
|
|
|
foreach (var sceneResult in sceneResults)
|
|
{
|
|
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
|
|
var parsed = TryReadScene(sceneResult);
|
|
if (importedScene is null || parsed is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
foreach (var question in parsed.QuestionsRaised ?? [])
|
|
{
|
|
AddSignal(signals, batch, importedScene, CleanNullable(question.Question), "Question", "Open", question.Evidence, question.Confidence);
|
|
}
|
|
|
|
foreach (var clue in parsed.TimelineClues ?? [])
|
|
{
|
|
AddSignal(signals, batch, importedScene, CleanNullable(clue.Clue), "Timeline clue", "Introduced", clue.Evidence, clue.Confidence);
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var signal in signals.Values.Where(signal => signal.SceneIds.Count > 1).OrderBy(signal => signal.Title))
|
|
{
|
|
var existingThread = existingThreadByKey.GetValueOrDefault(Key(signal.Title));
|
|
var existingPlotLine = existingPlotLineByKey.GetValueOrDefault(Key(signal.Title));
|
|
var candidateType = signal.SceneIds.Count >= 3 && existingThread is null
|
|
? StoryIntelligenceNarrativeCandidateTypes.PlotLine
|
|
: StoryIntelligenceNarrativeCandidateTypes.Thread;
|
|
var candidateId = await candidates.SaveAsync(new StoryIntelligenceNarrativeCandidateSeed
|
|
{
|
|
ProjectID = batch.ProjectID,
|
|
BookID = batch.BookID,
|
|
CandidateType = candidateType,
|
|
CandidateKey = $"{candidateType}:{Key(signal.Title)}",
|
|
ProposedTitle = signal.Title,
|
|
ProposedDescription = signal.Description,
|
|
Category = signal.Category,
|
|
InferredState = signal.State,
|
|
ExistingPlotLineID = existingPlotLine?.PlotLineID,
|
|
ExistingPlotThreadID = existingThread?.PlotThreadID,
|
|
RelatedPlotLineID = existingThread?.PlotLineID,
|
|
Confidence = signal.Confidence
|
|
});
|
|
|
|
var sortOrder = 0;
|
|
foreach (var item in signal.Evidence.OrderBy(item => item.SceneID))
|
|
{
|
|
await candidates.SaveEvidenceAsync(candidateId, item.SceneID, item.ContributionType, item.Evidence, sortOrder++);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task<bool> HasCommittedScenesAsync(OnboardingStoryIntelligenceBatch batch)
|
|
{
|
|
foreach (var item in batch.Items)
|
|
{
|
|
var commit = await runs.GetImportCommitAsync(item.RunID);
|
|
if (commit is not null && string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static StoryIntelligenceNarrativeReviewCandidateViewModel ToViewModel(StoryIntelligenceNarrativeCandidate candidate)
|
|
=> new()
|
|
{
|
|
NarrativeCandidateID = candidate.NarrativeCandidateID,
|
|
CandidateType = candidate.CandidateType,
|
|
ProposedTitle = candidate.ProposedTitle,
|
|
ProposedDescription = candidate.ProposedDescription ?? string.Empty,
|
|
Category = candidate.Category ?? string.Empty,
|
|
InferredState = candidate.InferredState ?? string.Empty,
|
|
Confidence = candidate.Confidence.HasValue ? $"{candidate.Confidence.Value:P0}" : "Unknown",
|
|
ExistingPlotLineID = candidate.ExistingPlotLineID,
|
|
RelatedPlotLineID = candidate.RelatedPlotLineID,
|
|
RelatedPlotLineName = candidate.RelatedPlotLineName,
|
|
DefaultAction = candidate.ExistingPlotLineID.HasValue || candidate.ExistingPlotThreadID.HasValue
|
|
? StoryIntelligenceNarrativeReviewActions.MergeExisting
|
|
: StoryIntelligenceNarrativeReviewActions.AcceptNew,
|
|
Evidence = candidate.Evidence.Select(evidence => new StoryIntelligenceNarrativeEvidenceViewModel
|
|
{
|
|
SceneID = evidence.SceneID,
|
|
SceneLabel = $"Chapter {evidence.ChapterNumber:0.##}, Scene {evidence.SceneNumber:0.##}: {evidence.SceneTitle}",
|
|
ContributionType = evidence.ContributionType ?? string.Empty,
|
|
Evidence = evidence.Evidence ?? string.Empty
|
|
}).ToList()
|
|
};
|
|
|
|
private static void AddSignal(
|
|
Dictionary<string, NarrativeSignal> signals,
|
|
OnboardingStoryIntelligenceBatch batch,
|
|
Scene scene,
|
|
string? title,
|
|
string category,
|
|
string state,
|
|
string? evidence,
|
|
decimal? confidence)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(title) || title.Length < 12)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var key = Key(title);
|
|
if (!signals.TryGetValue(key, out var signal))
|
|
{
|
|
signal = new NarrativeSignal(batch.ProjectID, batch.BookID, title, category, state);
|
|
signals[key] = signal;
|
|
}
|
|
|
|
signal.SceneIds.Add(scene.SceneID);
|
|
signal.Confidence = confidence.HasValue
|
|
? Math.Max(signal.Confidence ?? 0m, confidence.Value)
|
|
: signal.Confidence;
|
|
signal.Evidence.Add(new NarrativeSignalEvidence(scene.SceneID, category, evidence));
|
|
}
|
|
|
|
private static Scene? ResolveImportedScene(
|
|
StoryIntelligenceSavedSceneResult sceneResult,
|
|
IReadOnlyDictionary<string, Scene> importedByRange,
|
|
IReadOnlyDictionary<int, Scene> importedByNumber)
|
|
{
|
|
if (sceneResult.StartParagraph.HasValue
|
|
&& sceneResult.EndParagraph.HasValue
|
|
&& importedByRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var byRange))
|
|
{
|
|
return byRange;
|
|
}
|
|
|
|
return importedByNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var byNumber) ? byNumber : null;
|
|
}
|
|
|
|
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result)
|
|
{
|
|
foreach (var json in new[] { result.ParsedJson, result.OutputTextJson })
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(json, JsonOptions);
|
|
if (direct?.Summary is not null)
|
|
{
|
|
return direct;
|
|
}
|
|
|
|
using var document = JsonDocument.Parse(json);
|
|
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
|
|
{
|
|
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
// Try the next persisted JSON form.
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string CleanTitle(string? preferred, string fallback)
|
|
=> string.IsNullOrWhiteSpace(preferred) ? fallback.Trim() : preferred.Trim();
|
|
|
|
private static string? CleanNullable(string? value)
|
|
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
|
|
private static string Key(string? value)
|
|
=> new((value ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
|
|
|
|
private sealed record NarrativeSignal(int ProjectID, int BookID, string Title, string Category, string State)
|
|
{
|
|
public HashSet<int> SceneIds { get; } = [];
|
|
public List<NarrativeSignalEvidence> Evidence { get; } = [];
|
|
public decimal? Confidence { get; set; }
|
|
public string Description => $"{Category} recurring across {SceneIds.Count:N0} scenes.";
|
|
}
|
|
|
|
private sealed record NarrativeSignalEvidence(int SceneID, string ContributionType, string? Evidence);
|
|
}
|