Phase 20AS – Relationship Intelligence Review and Import

This commit is contained in:
Nick Beckley 2026-07-09 22:00:28 +01:00
parent 7f9f051341
commit e8b42664f1
18 changed files with 1321 additions and 21 deletions

View File

@ -0,0 +1,51 @@
# Phase 20AS - Relationship Intelligence Review and Import
Phase 20AS adds Relationship Review as the next implemented Story Intelligence review stage after Assets.
## Implemented Behaviour
- Relationship candidates are built only from stored Scene Intelligence JSON.
- No manuscript reread occurs.
- No OpenAI calls are made.
- No Story Intelligence prompts are changed.
- Duplicate relationship detections are merged by resolved character pair, ignoring direction.
- Candidates are only actionable when both character names resolve to existing PlotDirector characters or aliases.
- Existing PlotDirector relationships are suggested when the same character pair already has a relationship.
- Authors can choose:
- Create new relationship
- Link existing relationship
- Merge with another reviewed relationship
- Ignore
## Imported Data
Approved relationship decisions can create or link:
- `CharacterRelationships`
- `RelationshipEvents`
Relationship events are created only from stored relationship signals or observations already present in the scene intelligence result. The importer does not invent relationship changes.
Imported relationship records and events are available to existing PlotDirector relationship surfaces, including character pages, relationship pages and relationship map views.
## Pipeline Position
The current implemented pipeline is now:
1. Review Scenes
2. Create Scenes
3. Review Characters
4. Create Characters
5. Review Locations
6. Create Locations
7. Review Assets
8. Create Assets
9. Review Relationships
10. Create Relationships
11. Complete
Future Story Intelligence stages remain:
- Knowledge
- Continuity
- Timeline Events

View File

@ -75,6 +75,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return target.Route switch
{
StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Assets => RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = target.BatchID }),
@ -211,6 +212,40 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
: View(model);
}
[HttpGet("story-intelligence/relationships")]
public async Task<IActionResult> StoryIntelligenceRelationships(Guid batchId)
{
var model = await storyIntelligence.GetProgressAsync(batchId);
if (model is null)
{
return NotFound();
}
if (model.HasActiveRuns)
{
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
}
if (!model.AllCommitted)
{
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
}
if (!model.PipelineDashboard.CharacterStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
}
if (!model.PipelineDashboard.LocationStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
}
return !model.PipelineDashboard.AssetStageComplete
? RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId })
: View(model);
}
[HttpPost("story-intelligence/cancel")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CancelStoryIntelligence(Guid batchId)
@ -330,6 +365,29 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return model is null ? NotFound() : View(model);
}
[HttpPost("story-intelligence/relationships")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportStoryIntelligenceRelationships(StoryIntelligenceRelationshipImportForm form)
{
var (progress, result) = await storyIntelligence.ImportRelationshipsAsync(form.BatchID, form);
if (progress is null)
{
return NotFound();
}
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success
? RedirectToAction(nameof(StoryIntelligenceRelationshipComplete), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = form.BatchID });
}
[HttpGet("story-intelligence/relationships/complete")]
public async Task<IActionResult> StoryIntelligenceRelationshipComplete(Guid batchId)
{
var model = await storyIntelligence.GetRelationshipImportResultAsync(batchId);
return model is null ? NotFound() : View(model);
}
[HttpGet("story-intelligence/complete")]
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
{
@ -354,6 +412,11 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId });
}
if (!progress.PipelineDashboard.RelationshipStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId });
}
var model = await storyIntelligence.GetCompletionAsync(batchId);
return model is null ? NotFound() : View(model);
}

View File

@ -32,6 +32,8 @@ public static class StoryIntelligencePipelineStages
public const string LocationImport = "LocationImport";
public const string AssetReview = "AssetReview";
public const string AssetImport = "AssetImport";
public const string RelationshipReview = "RelationshipReview";
public const string RelationshipImport = "RelationshipImport";
public const string Complete = "Complete";
}
@ -62,7 +64,7 @@ public sealed class StoryIntelligenceBookPipelineState
public bool IsComplete
=> string.Equals(Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase)
&& string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase);
&& string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase);
}
public sealed class StoryIntelligenceBookPipelineSaveRequest

View File

@ -210,6 +210,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceCharacterImportService, StoryIntelligenceCharacterImportService>();
builder.Services.AddScoped<IStoryIntelligenceLocationImportService, StoryIntelligenceLocationImportService>();
builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();

View File

@ -20,6 +20,8 @@ public interface IOnboardingStoryIntelligenceService
Task<StoryIntelligenceLocationImportResultViewModel?> GetLocationImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm form);
Task<StoryIntelligenceAssetImportResultViewModel?> GetAssetImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportRelationshipsAsync(Guid batchId, StoryIntelligenceRelationshipImportForm form);
Task<StoryIntelligenceRelationshipImportResultViewModel?> GetRelationshipImportResultAsync(Guid batchId);
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
}
@ -33,6 +35,7 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligenceCharacterImportService characterImport,
IStoryIntelligenceLocationImportService locationImport,
IStoryIntelligenceAssetImportService assetImport,
IStoryIntelligenceRelationshipImportService relationshipImport,
IStoryIntelligencePipelineStateService pipelineState,
IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore,
@ -267,6 +270,7 @@ public sealed class OnboardingStoryIntelligenceService(
var characterReview = await characterImport.BuildReviewAsync(batch);
var locationReview = await locationImport.BuildReviewAsync(batch);
var assetReview = await assetImport.BuildReviewAsync(batch);
var relationshipReview = await relationshipImport.BuildReviewAsync(batch);
return new StoryIntelligenceProgressViewModel
{
BatchID = batch.BatchID,
@ -279,6 +283,7 @@ public sealed class OnboardingStoryIntelligenceService(
CharacterReview = characterReview,
LocationReview = locationReview,
AssetReview = assetReview,
RelationshipReview = relationshipReview,
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
{
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
@ -291,7 +296,10 @@ public sealed class OnboardingStoryIntelligenceService(
LocationStageComplete = batch.LocationStageComplete || locationReview.IsComplete,
AssetsIdentified = assetReview.Candidates.Count + batch.AssetDecisions.Count,
AssetsCreatedOrLinked = batch.AssetDecisions.Count(decision => decision.CreatedOrLinked),
AssetStageComplete = batch.AssetStageComplete || assetReview.IsComplete
AssetStageComplete = batch.AssetStageComplete || assetReview.IsComplete,
RelationshipsIdentified = relationshipReview.Candidates.Count + batch.RelationshipDecisions.Count,
RelationshipsCreatedOrLinked = batch.RelationshipDecisions.Count(decision => decision.CreatedOrLinked),
RelationshipStageComplete = batch.RelationshipStageComplete || relationshipReview.IsComplete
}
};
}
@ -460,6 +468,39 @@ public sealed class OnboardingStoryIntelligenceService(
};
}
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportRelationshipsAsync(Guid batchId, StoryIntelligenceRelationshipImportForm form)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
}
var result = await relationshipImport.ImportAsync(batch, form);
return (await GetProgressAsync(batchId), result);
}
public async Task<StoryIntelligenceRelationshipImportResultViewModel?> GetRelationshipImportResultAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return null;
}
return new StoryIntelligenceRelationshipImportResultViewModel
{
BatchID = batch.BatchID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
RelationshipsCreated = batch.LastRelationshipImportResult?.RelationshipsCreated ?? 0,
RelationshipsLinked = batch.LastRelationshipImportResult?.RelationshipsLinked ?? 0,
RelationshipsMerged = batch.LastRelationshipImportResult?.RelationshipsMerged ?? 0,
RelationshipsIgnored = batch.LastRelationshipImportResult?.RelationshipsIgnored ?? 0,
RelationshipEventsCreated = batch.LastRelationshipImportResult?.RelationshipEventsCreated ?? 0
};
}
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
{
var progress = await GetProgressAsync(batchId);
@ -483,7 +524,9 @@ public sealed class OnboardingStoryIntelligenceService(
LocationsCreatedOrLinked = progress.PipelineDashboard.LocationsCreatedOrLinked,
LocationStageComplete = progress.PipelineDashboard.LocationStageComplete,
AssetsCreatedOrLinked = progress.PipelineDashboard.AssetsCreatedOrLinked,
AssetStageComplete = progress.PipelineDashboard.AssetStageComplete
AssetStageComplete = progress.PipelineDashboard.AssetStageComplete,
RelationshipsCreatedOrLinked = progress.PipelineDashboard.RelationshipsCreatedOrLinked,
RelationshipStageComplete = progress.PipelineDashboard.RelationshipStageComplete
};
}
@ -530,27 +573,40 @@ public sealed class OnboardingStoryIntelligenceService(
or StoryIntelligencePipelineStages.LocationImport
or StoryIntelligencePipelineStages.AssetReview
or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview
or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.Complete;
var locationStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.LocationImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview
or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview
or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.Complete;
var assetStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.AssetImport);
var assetStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.AssetImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview
or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.Complete;
var relationshipStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.RelationshipImport);
batch.CharacterStageComplete = characterStageComplete;
batch.LocationStageComplete = locationStageComplete;
batch.AssetStageComplete = assetStageComplete;
batch.RelationshipStageComplete = relationshipStageComplete;
await batchStore.SaveAsync(batch);
var route = assetStageComplete
var route = relationshipStageComplete
? StoryIntelligenceResumeRoutes.Complete
: assetStageComplete
? StoryIntelligenceResumeRoutes.Relationships
: locationStageComplete
? StoryIntelligenceResumeRoutes.Assets
: characterStageComplete
? StoryIntelligenceResumeRoutes.Locations
: state.CurrentStage switch
{
StoryIntelligencePipelineStages.RelationshipReview => StoryIntelligenceResumeRoutes.Relationships,
StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships,
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters,
StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview,
@ -794,12 +850,15 @@ public sealed class OnboardingStoryIntelligenceBatch
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
public List<OnboardingStoryIntelligenceLocationDecision> LocationDecisions { get; } = [];
public List<OnboardingStoryIntelligenceAssetDecision> AssetDecisions { get; } = [];
public List<OnboardingStoryIntelligenceRelationshipDecision> RelationshipDecisions { get; } = [];
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; }
public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; }
public StoryIntelligenceRelationshipImportBatchResult? LastRelationshipImportResult { get; set; }
public bool CharacterStageComplete { get; set; }
public bool LocationStageComplete { get; set; }
public bool AssetStageComplete { get; set; }
public bool RelationshipStageComplete { get; set; }
}
public sealed class OnboardingStoryIntelligenceBatchItem
@ -838,6 +897,15 @@ public sealed class OnboardingStoryIntelligenceAssetDecision
public bool CreatedOrLinked { get; init; }
}
public sealed class OnboardingStoryIntelligenceRelationshipDecision
{
public string Key { get; init; } = string.Empty;
public string Action { get; init; } = string.Empty;
public int? CharacterRelationshipID { get; init; }
public string? RelationshipLabel { get; init; }
public bool CreatedOrLinked { get; init; }
}
public sealed class StoryIntelligenceCharacterImportBatchResult
{
public int CharactersCreated { get; init; }
@ -867,12 +935,22 @@ public sealed class StoryIntelligenceAssetImportBatchResult
public int OwnershipLinksCreated { get; init; }
}
public sealed class StoryIntelligenceRelationshipImportBatchResult
{
public int RelationshipsCreated { get; init; }
public int RelationshipsLinked { get; init; }
public int RelationshipsMerged { get; init; }
public int RelationshipsIgnored { get; init; }
public int RelationshipEventsCreated { get; init; }
}
public static class StoryIntelligenceResumeRoutes
{
public const string SceneReview = "SceneReview";
public const string Characters = "Characters";
public const string Locations = "Locations";
public const string Assets = "Assets";
public const string Relationships = "Relationships";
public const string Complete = "Complete";
}

View File

@ -12,6 +12,7 @@ public interface IStoryIntelligencePipelineStateService
Task RecordCharacterImportAsync(int projectId, int bookId);
Task RecordLocationImportAsync(int projectId, int bookId);
Task RecordAssetImportAsync(int projectId, int bookId);
Task RecordRelationshipImportAsync(int projectId, int bookId);
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
}
@ -112,8 +113,23 @@ public sealed class StoryIntelligencePipelineStateService(
{
ProjectID = projectId,
BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.Complete,
CurrentStage = StoryIntelligencePipelineStages.RelationshipReview,
LastCompletedStage = StoryIntelligencePipelineStages.AssetImport,
CurrentReviewStage = StoryIntelligencePipelineStages.RelationshipReview,
Status = StoryIntelligencePipelineStatuses.NeedsReview,
CompletedUtc = null,
LastRunID = null
});
}
public async Task RecordRelationshipImportAsync(int projectId, int bookId)
{
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
{
ProjectID = projectId,
BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.Complete,
LastCompletedStage = StoryIntelligencePipelineStages.RelationshipImport,
CurrentReviewStage = null,
Status = StoryIntelligencePipelineStatuses.Complete,
CompletedUtc = DateTime.UtcNow,

View File

@ -0,0 +1,670 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceRelationshipImportService
{
Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form);
}
public sealed class StoryIntelligenceRelationshipImportService(
IStoryIntelligenceResultRepository runs,
ICharacterRepository characters,
ISceneRepository scenes,
IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceRelationshipImportService> logger) : IStoryIntelligenceRelationshipImportService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public async Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{
var data = await BuildCandidateDataAsync(batch);
var decidedKeys = batch.RelationshipDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
return new StoryIntelligenceRelationshipReviewViewModel
{
HasCommittedScenes = data.HasCommittedScenes,
CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel
{
Key = candidate.Key,
CharacterAName = candidate.CharacterAName,
CharacterBName = candidate.CharacterBName,
RelationshipType = candidate.RelationshipType,
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence),
FirstAppearance = candidate.FirstAppearanceNote,
LastAppearance = candidate.LastAppearanceNote,
ExampleScene = candidate.ExampleScene,
ExampleContext = candidate.ExampleContext,
ExistingRelationshipID = candidate.ExistingRelationshipID,
ExistingRelationshipLabel = candidate.ExistingRelationshipLabel
}).ToList()
};
}
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form)
{
var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing relationships." };
}
var choices = form.Relationships
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
if (choices.Count == 0)
{
if (data.Candidates.Count == 0)
{
batch.RelationshipStageComplete = true;
batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult();
await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID);
return new StoryIntelligenceImportCommitResult
{
Success = true,
Message = "Relationship review completed. No relationship decisions were waiting."
};
}
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one relationship to create, link or ignore." };
}
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var created = 0;
var linkedExisting = 0;
var merged = 0;
var ignored = 0;
var eventsCreated = 0;
var resolvedRelationships = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var candidatesByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase);
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice))
{
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
ignored++;
AddDecision(batch, candidate.Key, StoryIntelligenceRelationshipImportActions.Ignore, null, RelationshipLabel(candidate), false);
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var relationshipId = string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase)
? candidate.ExistingRelationshipID
: null;
if (!relationshipId.HasValue)
{
relationshipId = await characters.SaveRelationshipAsync(new CharacterRelationship
{
ProjectID = batch.ProjectID,
CharacterAID = candidate.CharacterAID,
CharacterBID = candidate.CharacterBID,
RelationshipTypeID = MatchRelationshipType(lookupData.RelationshipTypes, choice.RelationshipType ?? candidate.RelationshipType),
IsPermanent = false,
StartSceneID = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).FirstOrDefault()?.SceneID,
EndSceneID = null,
IsKnownToReader = true,
Notes = BuildRelationshipNotes(candidate),
IsInitialRelationship = false,
InitialBookID = batch.BookID,
ReaderInitiallyKnows = true,
InitialRelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, candidate.RelationshipType),
InitialIntensity = ConfidenceIntensity(candidate.Confidence),
IsReciprocal = true
});
created++;
}
else
{
linkedExisting++;
}
resolvedRelationships[candidate.Key] = relationshipId.Value;
AddDecision(batch, candidate.Key, choice.Action, relationshipId.Value, RelationshipLabel(candidate), true);
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| !string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var targetKey = Clean(choice.AliasTargetKey);
if (string.IsNullOrWhiteSpace(targetKey)
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|| !choices.TryGetValue(targetKey, out var targetChoice)
|| string.Equals(targetChoice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| string.Equals(targetChoice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|| !candidatesByKey.ContainsKey(targetKey)
|| !resolvedRelationships.TryGetValue(targetKey, out var targetRelationshipId))
{
logger.LogWarning(
"Story Intelligence relationship merge candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
candidate.Key,
targetKey);
continue;
}
resolvedRelationships[candidate.Key] = targetRelationshipId;
merged++;
AddDecision(batch, candidate.Key, StoryIntelligenceRelationshipImportActions.Alias, targetRelationshipId, RelationshipLabel(candidate), true);
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| !resolvedRelationships.TryGetValue(candidate.Key, out var relationshipId))
{
continue;
}
foreach (var appearance in candidate.Appearances
.Where(appearance => !appearance.AlreadyLinked)
.GroupBy(appearance => appearance.SceneID)
.Select(group => group.OrderByDescending(appearance => appearance.Confidence ?? 0m).First()))
{
if (!HasMeaningfulRelationshipEvent(appearance))
{
continue;
}
await characters.SaveRelationshipEventAsync(new RelationshipEvent
{
CharacterRelationshipID = relationshipId,
SceneID = appearance.SceneID,
RelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, appearance.Signal ?? candidate.RelationshipType),
Intensity = ConfidenceIntensity(appearance.Confidence ?? candidate.Confidence),
IsKnownToOtherCharacter = true,
Description = BuildRelationshipEventDescription(candidate, appearance)
});
eventsCreated++;
}
}
batch.RelationshipStageComplete = true;
batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult
{
RelationshipsCreated = created,
RelationshipsLinked = linkedExisting,
RelationshipsMerged = merged,
RelationshipsIgnored = ignored,
RelationshipEventsCreated = eventsCreated
};
await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID);
logger.LogInformation(
"Imported Story Intelligence relationships for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored} Events={Events}",
batch.BatchID,
created,
linkedExisting,
merged,
ignored,
eventsCreated);
return new StoryIntelligenceImportCommitResult
{
Success = true,
ScenesCreated = eventsCreated,
Message = $"Relationships imported. {created:N0} created, {linkedExisting:N0} linked, {merged:N0} merged, {ignored:N0} ignored, {eventsCreated:N0} relationship event(s) added."
};
}
private async Task<RelationshipCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
{
var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID);
var existingRelationships = await characters.ListRelationshipsByProjectAsync(batch.ProjectID);
var existingByPair = existingRelationships
.GroupBy(relationship => PairKey(relationship.CharacterAID, relationship.CharacterBID))
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
var groups = new Dictionary<string, RelationshipCandidate>(StringComparer.OrdinalIgnoreCase);
var alreadyLinked = 0;
var hasCommittedScenes = false;
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;
}
hasCommittedScenes = true;
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 parsed = TryReadScene(sceneResult);
if (parsed is null)
{
continue;
}
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
if (importedScene is null)
{
continue;
}
var existingEvents = await characters.ListRelationshipEventsBySceneAsync(importedScene.SceneID);
alreadyLinked += existingEvents.Select(item => item.CharacterRelationshipID).Distinct().Count();
AddSceneRelationships(groups, characterIndex, existingByPair, importedScene, parsed, existingEvents);
}
}
var candidates = groups.Values
.Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
.OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count())
.ThenBy(candidate => candidate.CharacterAName)
.ThenBy(candidate => candidate.CharacterBName)
.ToList();
return new RelationshipCandidateData(hasCommittedScenes, alreadyLinked, candidates);
}
private static void AddSceneRelationships(
Dictionary<string, RelationshipCandidate> groups,
CharacterIndex characterIndex,
IReadOnlyDictionary<string, CharacterRelationship> existingByPair,
Scene importedScene,
SceneIntelligenceScene parsed,
IReadOnlyList<RelationshipEvent> existingEvents)
{
foreach (var relationship in parsed.Relationships ?? [])
{
AddRelationship(groups, characterIndex, existingByPair, importedScene, parsed, relationship.CharacterA, relationship.CharacterB, relationship.RelationshipSignal, relationship.Evidence, relationship.Confidence, existingEvents);
}
foreach (var observation in parsed.Observations ?? [])
{
if (!IsRelationshipObservation(observation))
{
continue;
}
AddRelationship(
groups,
characterIndex,
existingByPair,
importedScene,
parsed,
observation.SubjectName,
observation.ObjectName,
FirstConfigured(observation.Predicate, observation.ObservationType),
FirstConfigured(observation.Description, observation.Evidence),
observation.Confidence,
existingEvents);
}
}
private static void AddRelationship(
Dictionary<string, RelationshipCandidate> groups,
CharacterIndex characterIndex,
IReadOnlyDictionary<string, CharacterRelationship> existingByPair,
Scene importedScene,
SceneIntelligenceScene parsed,
string? characterAName,
string? characterBName,
string? relationshipSignal,
string? evidence,
decimal? confidence,
IReadOnlyList<RelationshipEvent> existingEvents)
{
var characterA = characterIndex.Find(characterAName);
var characterB = characterIndex.Find(characterBName);
if (characterA is null || characterB is null || characterA.CharacterID == characterB.CharacterID)
{
return;
}
var key = PairKey(characterA.CharacterID, characterB.CharacterID);
if (!groups.TryGetValue(key, out var candidate))
{
existingByPair.TryGetValue(key, out var existingMatch);
candidate = new RelationshipCandidate(
key,
characterA.CharacterID,
characterA.CharacterName,
characterB.CharacterID,
characterB.CharacterName,
RelationshipTypeFromSignal(relationshipSignal),
existingMatch?.CharacterRelationshipID,
existingMatch is null ? null : $"{existingMatch.CharacterAName} / {existingMatch.RelationshipTypeName} / {existingMatch.CharacterBName}");
groups[key] = candidate;
}
if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m))
{
candidate.RelationshipType = RelationshipTypeFromSignal(relationshipSignal);
}
candidate.Confidence = Max(candidate.Confidence, confidence);
candidate.FirstAppearanceNote ??= BuildSceneReference(importedScene);
candidate.LastAppearanceNote = BuildSceneReference(importedScene);
candidate.ExampleScene ??= BuildFirstAppearance(importedScene, parsed);
candidate.ExampleContext ??= Clean(evidence);
candidate.Appearances.Add(new RelationshipAppearanceImport(
importedScene.SceneID,
importedScene.SceneNumber,
importedScene.SceneTitle,
relationshipSignal,
evidence,
confidence,
candidate.ExistingRelationshipID.HasValue && existingEvents.Any(existing => existing.CharacterRelationshipID == candidate.ExistingRelationshipID.Value)));
}
private async Task<CharacterIndex> BuildCharacterIndexAsync(int projectId)
{
var index = new CharacterIndex();
foreach (var character in await characters.ListCharactersAsync(projectId))
{
index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
index.Add(character.ShortName, character.CharacterID, character.CharacterName);
foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
{
index.Add(alias.Alias, character.CharacterID, character.CharacterName);
}
}
return index;
}
private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber)
{
if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
{
return rangeMatch;
}
return byNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var numberMatch) ? numberMatch : 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 bool IsRelationshipObservation(SceneIntelligenceObservation observation)
{
var type = Clean(observation.ObservationType);
if (!type.Contains("relationship", StringComparison.OrdinalIgnoreCase)
&& !type.Contains("character", StringComparison.OrdinalIgnoreCase)
&& !RelationshipSignalWords.Any(word => type.Contains(word, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
return IsCharacterEntity(observation.SubjectEntityType)
&& IsCharacterEntity(observation.ObjectEntityType)
&& !string.IsNullOrWhiteSpace(observation.SubjectName)
&& !string.IsNullOrWhiteSpace(observation.ObjectName);
}
private static int MatchRelationshipType(IReadOnlyList<RelationshipType> types, string? signal)
{
var normalised = Normalise(signal);
var mapped = RelationshipTypeFromSignal(signal);
return types.FirstOrDefault(type => string.Equals(type.TypeName, mapped, StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
?? types.FirstOrDefault(type => normalised.Contains(Normalise(type.TypeName), StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
?? types.FirstOrDefault(type => string.Equals(type.TypeName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
?? types.First().RelationshipTypeID;
}
private static int MatchRelationshipState(IReadOnlyList<RelationshipState> states, string? signal)
{
var value = Normalise(signal);
var stateName =
value.Contains("reconcil") ? "Reconciled" :
value.Contains("romantic") || value.Contains("lover") || value.Contains("love") ? "Romantic" :
value.Contains("friend") || value.Contains("ally") || value.Contains("support") ? "Friendly" :
value.Contains("argument") || value.Contains("argue") || value.Contains("conflict") || value.Contains("hostile") || value.Contains("enemy") ? "Hostile" :
value.Contains("distrust") || value.Contains("suspect") || value.Contains("suspicious") ? "Distrustful" :
value.Contains("estranged") || value.Contains("separat") || value.Contains("deteriorat") ? "Estranged" :
value.Contains("tense") || value.Contains("tension") ? "Tense" :
"Unknown";
return states.FirstOrDefault(state => string.Equals(state.StateName, stateName, StringComparison.OrdinalIgnoreCase))?.RelationshipStateID
?? states.FirstOrDefault(state => string.Equals(state.StateName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipStateID
?? states.First().RelationshipStateID;
}
private static string RelationshipTypeFromSignal(string? signal)
{
var value = Normalise(signal);
if (value.Contains("mother") || value.Contains("father") || value.Contains("parent")) return "Parent";
if (value.Contains("daughter") || value.Contains("son") || value.Contains("child")) return "Child";
if (value.Contains("sister") || value.Contains("brother") || value.Contains("sibling")) return "Sibling";
if (value.Contains("spouse") || value.Contains("husband") || value.Contains("wife") || value.Contains("married")) return "Spouse";
if (value.Contains("romantic") || value.Contains("lover") || value.Contains("love")) return "Lover";
if (value.Contains("friend")) return "Friend";
if (value.Contains("enemy") || value.Contains("adversarial") || value.Contains("hostile")) return "Enemy";
if (value.Contains("rival")) return "Rival";
if (value.Contains("suspicious") || value.Contains("suspect")) return "Suspicious Of";
if (value.Contains("neighbour") || value.Contains("neighbor")) return "Neighbour";
if (value.Contains("teacher")) return "Teacher";
if (value.Contains("student")) return "Student";
if (value.Contains("employer") || value.Contains("boss")) return "Employer";
if (value.Contains("employee")) return "Employee";
if (value.Contains("colleague") || value.Contains("professional") || value.Contains("police") || value.Contains("detective") || value.Contains("sergeant")) return "Colleague";
if (value.Contains("ally") || value.Contains("support")) return "Ally";
if (value.Contains("guardian") || value.Contains("protector")) return "Protector";
if (value.Contains("estranged") || value.Contains("separat")) return "Estranged From";
return "Unknown";
}
private static bool HasMeaningfulRelationshipEvent(RelationshipAppearanceImport appearance)
{
var value = $"{appearance.Signal} {appearance.Evidence}";
return RelationshipEventWords.Any(word => value.Contains(word, StringComparison.OrdinalIgnoreCase));
}
private static string BuildRelationshipNotes(RelationshipCandidate candidate)
{
var lines = new List<string> { "Imported from Story Intelligence relationship review." };
if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
{
lines.Add(candidate.ExampleContext);
}
if (!string.IsNullOrWhiteSpace(candidate.FirstAppearanceNote))
{
lines.Add($"First detected: {candidate.FirstAppearanceNote}");
}
return string.Join(Environment.NewLine, lines);
}
private static string BuildRelationshipEventDescription(RelationshipCandidate candidate, RelationshipAppearanceImport appearance)
=> FirstConfigured(
Clean(appearance.Evidence),
Clean(appearance.Signal),
$"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}") ?? RelationshipLabel(candidate);
private static string RelationshipLabel(RelationshipCandidate candidate)
=> $"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}";
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed)
{
var summary = Clean(parsed.Summary?.Short);
return string.IsNullOrWhiteSpace(summary)
? BuildSceneReference(scene)
: $"{BuildSceneReference(scene)}: {summary}";
}
private static string BuildSceneReference(Scene scene)
=> $"Scene {scene.SceneNumber:g}";
private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, int? relationshipId, string? label, bool createdOrLinked)
{
batch.RelationshipDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase));
batch.RelationshipDecisions.Add(new OnboardingStoryIntelligenceRelationshipDecision
{
Key = key,
Action = action,
CharacterRelationshipID = relationshipId,
RelationshipLabel = label,
CreatedOrLinked = createdOrLinked
});
}
private static bool IsCharacterEntity(string? entityType)
=> string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);
private static int? ConfidenceIntensity(decimal? confidence)
=> confidence.HasValue ? Math.Clamp((int)Math.Round(confidence.Value * 10m, MidpointRounding.AwayFromZero), 1, 10) : null;
private static decimal? Max(decimal? current, decimal? next)
=> current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next;
private static string ConfidenceBand(decimal? confidence)
=> confidence switch
{
>= 0.75m => "High",
>= 0.45m => "Medium",
null => "Unknown",
_ => "Low"
};
private static string PairKey(int characterAId, int characterBId)
=> characterAId <= characterBId ? $"{characterAId}:{characterBId}" : $"{characterBId}:{characterAId}";
private static string? FirstConfigured(params string?[] values)
=> values.Select(Clean).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private static string Normalise(string? value)
=> Clean(value).ToLowerInvariant();
private static string Clean(string? value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : Whitespace.Replace(value, " ").Trim();
private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled);
private static readonly string[] RelationshipSignalWords =
[
"friend", "family", "romantic", "lover", "spouse", "parent", "child", "sibling", "enemy",
"rival", "ally", "argument", "conflict", "reconciled", "separated", "deteriorates", "meeting"
];
private static readonly string[] RelationshipEventWords =
[
"begins", "begin", "starts", "first meeting", "meet", "met", "deteriorates", "deteriorate",
"reconciles", "reconciliation", "reconciled", "argument", "argues", "separation", "separate",
"estranged", "confront", "conflict", "tension", "hostile", "distrust", "supports", "protects"
];
private sealed record RelationshipCandidateData(bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList<RelationshipCandidate> Candidates);
private sealed class RelationshipCandidate(
string key,
int characterAId,
string characterAName,
int characterBId,
string characterBName,
string relationshipType,
int? existingRelationshipId,
string? existingRelationshipLabel)
{
public string Key { get; } = key;
public int CharacterAID { get; } = characterAId;
public string CharacterAName { get; } = characterAName;
public int CharacterBID { get; } = characterBId;
public string CharacterBName { get; } = characterBName;
public string RelationshipType { get; set; } = relationshipType;
public int? ExistingRelationshipID { get; } = existingRelationshipId;
public string? ExistingRelationshipLabel { get; } = existingRelationshipLabel;
public decimal? Confidence { get; set; }
public string? FirstAppearanceNote { get; set; }
public string? LastAppearanceNote { get; set; }
public string? ExampleScene { get; set; }
public string? ExampleContext { get; set; }
public List<RelationshipAppearanceImport> Appearances { get; } = [];
}
private sealed record RelationshipAppearanceImport(
int SceneID,
decimal SceneNumber,
string SceneTitle,
string? Signal,
string? Evidence,
decimal? Confidence,
bool AlreadyLinked);
private sealed class CharacterIndex
{
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int characterId, string characterName)
{
var key = Clean(name);
if (string.IsNullOrWhiteSpace(key) || byName.ContainsKey(key))
{
return;
}
byName[key] = new CharacterMatch(characterId, characterName);
}
public CharacterMatch? Find(string? name)
=> byName.TryGetValue(Clean(name), out var match) ? match : null;
}
private sealed record CharacterMatch(int CharacterID, string CharacterName);
}

View File

@ -269,10 +269,12 @@ public sealed class StoryIntelligenceService(
return state.CurrentReviewStage switch
{
StoryIntelligencePipelineStages.RelationshipReview => "Waiting for Relationship Review",
StoryIntelligencePipelineStages.AssetReview => "Waiting for Asset Review",
StoryIntelligencePipelineStages.LocationReview => "Waiting for Location Review",
StoryIntelligencePipelineStages.CharacterReview => "Waiting for Character Review",
StoryIntelligencePipelineStages.SceneReview => "Needs Scene Review",
_ when StoryIntelligenceReadyForRelationships(state) => "Waiting for Relationship Review",
_ when StoryIntelligenceReadyForAssets(state) => "Waiting for Asset Review",
_ when StoryIntelligenceReadyForLocations(state) => "Waiting for Location Review",
_ => string.Equals(state.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase)
@ -286,24 +288,33 @@ public sealed class StoryIntelligenceService(
? "All currently available Story Intelligence stages are complete."
: state.CurrentReviewStage switch
{
StoryIntelligencePipelineStages.RelationshipReview => "Review detected relationships and decide what to create or link.",
StoryIntelligencePipelineStages.AssetReview => "Review detected assets and decide what to create or link.",
StoryIntelligencePipelineStages.LocationReview => "Review detected locations and decide what to create or link.",
StoryIntelligencePipelineStages.CharacterReview => "Review detected characters and decide what to create or link.",
StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them.",
_ when StoryIntelligenceReadyForRelationships(state) => "Review detected relationships and decide what to create or link.",
_ when StoryIntelligenceReadyForAssets(state) => "Review detected assets and decide what to create or link.",
_ when StoryIntelligenceReadyForLocations(state) => "Review detected locations and decide what to create or link.",
_ => "Continue from the next Story Intelligence stage."
};
private static bool StoryIntelligenceReadyForRelationships(StoryIntelligenceBookPipelineState state)
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase)
|| state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport;
private static bool StoryIntelligenceReadyForAssets(StoryIntelligenceBookPipelineState state)
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase)
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport
|| (string.Equals(state.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase));
&& !string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase));
private static bool StoryIntelligenceReadyForLocations(StoryIntelligenceBookPipelineState state)
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.CharacterImport, StringComparison.OrdinalIgnoreCase)
|| state.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport;
|| state.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport
or StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport;
private static string FormatElapsed(TimeSpan elapsed)
=> elapsed.TotalMinutes < 1

View File

@ -0,0 +1,38 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.StoryIntelligenceBookPipelines', N'U') IS NOT NULL
BEGIN
IF EXISTS
(
SELECT 1
FROM sys.check_constraints
WHERE name = N'CK_StoryIntelligenceBookPipelines_CurrentStage'
AND parent_object_id = OBJECT_ID(N'dbo.StoryIntelligenceBookPipelines')
)
BEGIN
ALTER TABLE dbo.StoryIntelligenceBookPipelines
DROP CONSTRAINT CK_StoryIntelligenceBookPipelines_CurrentStage;
END;
ALTER TABLE dbo.StoryIntelligenceBookPipelines
ADD CONSTRAINT CK_StoryIntelligenceBookPipelines_CurrentStage
CHECK (CurrentStage IN
(
N'Chapters',
N'SceneReview',
N'SceneImport',
N'CharacterReview',
N'CharacterImport',
N'LocationReview',
N'LocationImport',
N'AssetReview',
N'AssetImport',
N'RelationshipReview',
N'RelationshipImport',
N'Complete'
));
END;
GO

View File

@ -145,6 +145,7 @@ public sealed class StoryIntelligenceProgressViewModel
public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new();
public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new();
public StoryIntelligenceAssetReviewViewModel AssetReview { get; init; } = new();
public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; init; } = new();
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
public int ChapterCount => Chapters.Count;
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
@ -162,6 +163,7 @@ public sealed class StoryIntelligenceProgressViewModel
public bool HasCharactersToReview => CharacterReview.Candidates.Count > 0;
public bool HasLocationsToReview => LocationReview.Candidates.Count > 0;
public bool HasAssetsToReview => AssetReview.Candidates.Count > 0;
public bool HasRelationshipsToReview => RelationshipReview.Candidates.Count > 0;
}
public sealed class StoryIntelligencePipelineDashboardViewModel
@ -177,6 +179,9 @@ public sealed class StoryIntelligencePipelineDashboardViewModel
public int AssetsIdentified { get; init; }
public int AssetsCreatedOrLinked { get; init; }
public bool AssetStageComplete { get; init; }
public int RelationshipsIdentified { get; init; }
public int RelationshipsCreatedOrLinked { get; init; }
public bool RelationshipStageComplete { get; init; }
}
public sealed class StoryIntelligenceCharacterReviewViewModel
@ -322,6 +327,55 @@ public static class StoryIntelligenceAssetImportActions
public const string Alias = "Alias";
}
public sealed class StoryIntelligenceRelationshipReviewViewModel
{
public bool CanImport { get; init; }
public bool HasCommittedScenes { get; init; }
public int AlreadyLinkedCount { get; init; }
public bool IsComplete { get; init; }
public IReadOnlyList<StoryIntelligenceRelationshipReviewCandidateViewModel> Candidates { get; init; } = [];
}
public sealed class StoryIntelligenceRelationshipReviewCandidateViewModel
{
public string Key { get; init; } = string.Empty;
public string CharacterAName { get; init; } = string.Empty;
public string CharacterBName { get; init; } = string.Empty;
public string RelationshipType { get; init; } = "Unknown";
public int AppearsInScenes { get; init; }
public string Confidence { get; init; } = "Unknown";
public string? FirstAppearance { get; init; }
public string? LastAppearance { get; init; }
public string? ExampleScene { get; init; }
public string? ExampleContext { get; init; }
public int? ExistingRelationshipID { get; init; }
public string? ExistingRelationshipLabel { get; init; }
public bool IsExistingMatch => ExistingRelationshipID.HasValue;
public string ActionLabel => IsExistingMatch ? "Link existing" : "Create";
}
public sealed class StoryIntelligenceRelationshipImportForm
{
public Guid BatchID { get; set; }
public List<StoryIntelligenceRelationshipImportChoiceForm> Relationships { get; set; } = [];
}
public sealed class StoryIntelligenceRelationshipImportChoiceForm
{
public string Key { get; set; } = string.Empty;
public string Action { get; set; } = StoryIntelligenceRelationshipImportActions.CreateNew;
public string? RelationshipType { get; set; }
public string? AliasTargetKey { get; set; }
}
public static class StoryIntelligenceRelationshipImportActions
{
public const string CreateNew = "CreateNew";
public const string LinkExisting = "LinkExisting";
public const string Ignore = "Ignore";
public const string Alias = "Alias";
}
public sealed class StoryIntelligenceCompletionViewModel
{
public StoryIntelligenceJobProgress Job { get; init; } = new();
@ -339,6 +393,8 @@ public sealed class StoryIntelligenceCompletionViewModel
public bool LocationStageComplete { get; init; }
public int AssetsCreatedOrLinked { get; init; }
public bool AssetStageComplete { get; init; }
public int RelationshipsCreatedOrLinked { get; init; }
public bool RelationshipStageComplete { get; init; }
}
public sealed class StoryIntelligenceCharacterImportResultViewModel
@ -379,6 +435,18 @@ public sealed class StoryIntelligenceAssetImportResultViewModel
public int OwnershipLinksCreated { get; init; }
}
public sealed class StoryIntelligenceRelationshipImportResultViewModel
{
public Guid BatchID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public int RelationshipsCreated { get; init; }
public int RelationshipsLinked { get; init; }
public int RelationshipsMerged { get; init; }
public int RelationshipsIgnored { get; init; }
public int RelationshipEventsCreated { get; init; }
}
public sealed class StoryIntelligenceOnboardingChapterViewModel
{
public string TemporaryChapterKey { get; init; } = string.Empty;

View File

@ -200,23 +200,32 @@
return pipeline.CurrentReviewStage switch
{
StoryIntelligencePipelineStages.RelationshipReview => "Review detected relationships and decide what to create or link.",
StoryIntelligencePipelineStages.AssetReview => "Review detected assets and decide what to create or link.",
StoryIntelligencePipelineStages.LocationReview => "Review detected locations and decide what to create or link.",
StoryIntelligencePipelineStages.CharacterReview => "Scene creation is complete. Review detected characters to continue building the story database.",
StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them in PlotDirector.",
_ when StoryIntelligenceReadyForRelationships(pipeline) => "Review detected relationships and decide what to create or link.",
_ when StoryIntelligenceReadyForAssets(pipeline) => "Review detected assets and decide what to create or link.",
_ when StoryIntelligenceReadyForLocations(pipeline) => "Review detected locations and decide what to create or link.",
_ => "PlotDirector will resume from the next Story Intelligence stage."
};
}
private static bool StoryIntelligenceReadyForRelationships(StoryIntelligenceBookPipelineState pipeline)
=> string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase)
|| pipeline.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport;
private static bool StoryIntelligenceReadyForAssets(StoryIntelligenceBookPipelineState pipeline)
=> string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase)
|| pipeline.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport
|| (string.Equals(pipeline.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase));
&& !string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase));
private static bool StoryIntelligenceReadyForLocations(StoryIntelligenceBookPipelineState pipeline)
=> string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.CharacterImport, StringComparison.OrdinalIgnoreCase)
|| pipeline.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport;
|| pipeline.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport
or StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport;
}

View File

@ -5,14 +5,14 @@
<section class="onboarding-shell" aria-labelledby="story-asset-complete-title">
<div class="onboarding-panel onboarding-review-panel">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Complete" })" />
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Relationships" })" />
<div class="onboarding-copy">
<p class="eyebrow">Build Story Database</p>
<div class="onboarding-success-heading">
<span aria-hidden="true">&check;</span>
<h1 id="story-asset-complete-title">Assets created</h1>
</div>
<p>PlotDirector updated your story database from the approved asset decisions. Later Story Intelligence stages will continue from here.</p>
<p>PlotDirector updated your story database from the approved asset decisions. Next, review the relationships detected between imported characters.</p>
</div>
<div class="onboarding-scan-counts onboarding-review-counts">
@ -43,7 +43,7 @@
</div>
<div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Relationships</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Relationships</strong><span>Next</span></article>
<article class="story-future-stage"><strong>Review Knowledge</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Continuity</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Timeline Events</strong><span>Coming Soon</span></article>
@ -51,7 +51,7 @@
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Back to assets</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID">Review relationships</a>
</div>
</div>
</section>

View File

@ -9,7 +9,7 @@
<div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 3</p>
<p class="story-review-heading__phase">Review Story Intelligence</p>
<p class="story-review-heading__stage">Stage 4 of 4</p>
<p class="story-review-heading__stage">Stage 4 of 5</p>
<h1 id="story-asset-title">Review Assets</h1>
<p>Approve the important objects PlotDirector should create or link from your imported scenes. Nothing is created unless you approve it.</p>
</div>
@ -33,7 +33,7 @@
<p class="mb-0">Assets are already up to date, or there were no story-significant asset suggestions ready to import.</p>
</div>
<div class="story-future-stage-grid">
@FutureStage("Review Relationships")
<article class="story-future-stage"><strong>Review Relationships</strong><span>Next</span></article>
@FutureStage("Review Knowledge")
@FutureStage("Review Continuity")
@FutureStage("Timeline Events")

View File

@ -24,7 +24,7 @@
<li><span aria-hidden="true">&check;</span><strong>Characters</strong></li>
<li><span aria-hidden="true">&check;</span><strong>Locations</strong></li>
<li><span aria-hidden="true">&check;</span><strong>Assets</strong></li>
<li class="is-upcoming"><span aria-hidden="true">○</span><strong>Relationships</strong><em>Coming Soon</em></li>
<li><span aria-hidden="true">&check;</span><strong>Relationships</strong></li>
<li class="is-upcoming"><span aria-hidden="true">○</span><strong>Knowledge</strong><em>Coming Soon</em></li>
<li><span aria-hidden="true">&check;</span><strong>Preparing continuity...</strong></li>
<li><span aria-hidden="true">&check;</span><strong>Generating story indexes...</strong></li>
@ -53,6 +53,10 @@
<span>Assets created / linked</span>
<strong>@Model.AssetsCreatedOrLinked.ToString("N0")</strong>
</div>
<div>
<span>Relationships created / linked</span>
<strong>@Model.RelationshipsCreatedOrLinked.ToString("N0")</strong>
</div>
<div>
<span>Book</span>
<strong>@Model.BookTitle</strong>

View File

@ -0,0 +1,52 @@
@model StoryIntelligenceRelationshipImportResultViewModel
@{
ViewData["Title"] = "Relationships created";
}
<section class="onboarding-shell" aria-labelledby="story-relationship-complete-title">
<div class="onboarding-panel onboarding-review-panel">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Complete" })" />
<div class="onboarding-copy">
<p class="eyebrow">Build Story Database</p>
<div class="onboarding-success-heading">
<span aria-hidden="true">&check;</span>
<h1 id="story-relationship-complete-title">Relationships created</h1>
</div>
<p>PlotDirector updated your story database from the approved relationship decisions. The implemented Story Intelligence import stages are now complete.</p>
</div>
<div class="onboarding-scan-counts onboarding-review-counts">
<div>
<span>Relationships created</span>
<strong>@Model.RelationshipsCreated.ToString("N0")</strong>
</div>
<div>
<span>Relationships linked</span>
<strong>@Model.RelationshipsLinked.ToString("N0")</strong>
</div>
<div>
<span>Relationships merged</span>
<strong>@Model.RelationshipsMerged.ToString("N0")</strong>
</div>
<div>
<span>Relationships ignored</span>
<strong>@Model.RelationshipsIgnored.ToString("N0")</strong>
</div>
<div>
<span>Relationship events added</span>
<strong>@Model.RelationshipEventsCreated.ToString("N0")</strong>
</div>
</div>
<div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Knowledge</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Continuity</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Timeline Events</strong><span>Coming Soon</span></article>
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID">Back to relationships</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a>
</div>
</div>
</section>

View File

@ -0,0 +1,233 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Review relationships";
}
<section class="onboarding-shell" aria-labelledby="story-relationship-title">
<div class="onboarding-panel onboarding-review-panel story-review-page">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Relationships" })" />
<div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 3</p>
<p class="story-review-heading__phase">Review Story Intelligence</p>
<p class="story-review-heading__stage">Stage 5 of 5</p>
<h1 id="story-relationship-title">Review Relationships</h1>
<p>Approve the character relationships PlotDirector should create or link from your imported scenes. Nothing is created unless you approve it.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
{
<div class="alert alert-danger">@error</div>
}
@if (TempData["OnboardingStoryIntelligenceMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
<partial name="_StoryIntelligencePipelineSummary" model="Model.PipelineDashboard" />
<section class="story-review-chapter-list" aria-label="Relationship review">
@if (Model.RelationshipReview.Candidates.Count == 0)
{
<div class="story-review-note">
<strong>No relationship decisions are waiting.</strong>
<p class="mb-0">Relationships are already up to date, or there were no relationship suggestions ready to import from the stored scene analysis.</p>
</div>
<div class="story-future-stage-grid">
@FutureStage("Review Knowledge")
@FutureStage("Review Continuity")
@FutureStage("Timeline Events")
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Back to assets</a>
<form asp-action="ImportStoryIntelligenceRelationships" method="post">
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<button class="btn btn-primary" type="submit">Continue</button>
</form>
</div>
}
else
{
<div class="story-review-card-actions">
<button class="btn btn-outline-primary btn-sm" type="button" data-relationship-bulk="create-all">Create all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="link-existing">Link existing matches</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="ignore-all">Ignore all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="expand">Expand all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="collapse">Collapse all</button>
</div>
<form asp-action="ImportStoryIntelligenceRelationships" method="post" data-relationship-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<div class="story-character-card-grid">
@for (var i = 0; i < Model.RelationshipReview.Candidates.Count; i++)
{
var candidate = Model.RelationshipReview.Candidates[i];
<details class="story-character-card" open data-relationship-card>
<summary>
<span>
<strong>@candidate.CharacterAName</strong>
<small>@candidate.RelationshipType / @candidate.CharacterBName - @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
</span>
<em>@candidate.Confidence</em>
</summary>
<input type="hidden" name="Relationships[@i].Key" value="@candidate.Key" />
<div class="story-character-card__body" data-relationship-key="@candidate.Key">
<dl>
<div><dt>Character A</dt><dd>@candidate.CharacterAName</dd></div>
<div><dt>Character B</dt><dd>@candidate.CharacterBName</dd></div>
<div><dt>First seen</dt><dd>@Display(candidate.FirstAppearance)</dd></div>
<div><dt>Last seen</dt><dd>@Display(candidate.LastAppearance)</dd></div>
<div><dt>Existing match</dt><dd>@(candidate.ExistingRelationshipLabel ?? "None")</dd></div>
</dl>
@if (!string.IsNullOrWhiteSpace(candidate.ExampleScene))
{
<div class="story-review-note">
<strong>Example scene</strong>
<p>@candidate.ExampleScene</p>
</div>
}
@if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
{
<div class="story-review-note">
<strong>Supporting context</strong>
<p>@candidate.ExampleContext</p>
</div>
}
@if (candidate.IsExistingMatch)
{
<div class="story-review-note">
<strong>Possible existing relationship</strong>
<p>@candidate.CharacterAName and @candidate.CharacterBName may already have a relationship in PlotDirector. Choose whether to link them or create a separate relationship.</p>
</div>
}
<fieldset class="story-character-actions">
<legend>Decision</legend>
<label>
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.CreateNew" checked data-relationship-action />
Create new relationship
</label>
<label>
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.LinkExisting" data-relationship-action @(candidate.IsExistingMatch ? string.Empty : "disabled") />
Link existing relationship
</label>
<label>
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.Alias" data-relationship-action />
Merge with another relationship
</label>
<label>
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.Ignore" data-relationship-action />
Ignore
</label>
</fieldset>
<div data-relationship-type-panel>
<label class="form-label" for="relationship-type-@i">Relationship type</label>
<input id="relationship-type-@i" class="form-control" name="Relationships[@i].RelationshipType" value="@candidate.RelationshipType" />
</div>
<div data-relationship-alias-panel hidden>
<label class="form-label" for="relationship-alias-target-@i">Merge target</label>
<select id="relationship-alias-target-@i" class="form-select" name="Relationships[@i].AliasTargetKey" data-relationship-alias-target>
<option value="">Choose relationship...</option>
@foreach (var target in Model.RelationshipReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
{
<option value="@target.Key">@target.CharacterAName / @target.RelationshipType / @target.CharacterBName@(target.IsExistingMatch ? $" -> {target.ExistingRelationshipLabel}" : string.Empty)</option>
}
</select>
</div>
</div>
</details>
}
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Back to assets</a>
<button class="btn btn-primary" type="submit">Create relationships</button>
</div>
</form>
}
</section>
</div>
</section>
@section Scripts {
<script>
(() => {
const form = document.querySelector("[data-relationship-review-form]");
if (!form) return;
const cards = () => Array.from(form.querySelectorAll("[data-relationship-card]"));
const setAction = (card, action) => {
const input = card.querySelector(`[data-relationship-action][value="${action}"]`);
if (input) {
input.checked = true;
updateCard(card);
}
};
const actionFor = (card) => card.querySelector("[data-relationship-action]:checked")?.value || "@StoryIntelligenceRelationshipImportActions.CreateNew";
const isCanonicalTarget = (card) => {
const action = actionFor(card);
return action !== "@StoryIntelligenceRelationshipImportActions.Ignore" && action !== "@StoryIntelligenceRelationshipImportActions.Alias";
};
const updateAliasTargets = () => {
const allCards = cards();
for (const card of allCards) {
const body = card.querySelector("[data-relationship-key]");
const ownKey = body?.getAttribute("data-relationship-key") || "";
const select = card.querySelector("[data-relationship-alias-target]");
if (!select) continue;
const previous = select.value;
for (const option of Array.from(select.options)) {
if (!option.value) continue;
const targetCard = allCards.find(item => item.querySelector("[data-relationship-key]")?.getAttribute("data-relationship-key") === option.value);
option.disabled = option.value === ownKey || !targetCard || !isCanonicalTarget(targetCard);
}
if (select.selectedOptions[0]?.disabled) {
select.value = "";
} else {
select.value = previous;
}
}
};
const updateCard = (card) => {
const action = actionFor(card);
const typePanel = card.querySelector("[data-relationship-type-panel]");
const aliasPanel = card.querySelector("[data-relationship-alias-panel]");
if (typePanel) typePanel.hidden = action === "@StoryIntelligenceRelationshipImportActions.Ignore" || action === "@StoryIntelligenceRelationshipImportActions.Alias";
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceRelationshipImportActions.Alias";
updateAliasTargets();
};
document.querySelectorAll("[data-relationship-bulk]").forEach(button => {
button.addEventListener("click", () => {
const action = button.getAttribute("data-relationship-bulk");
if (action === "create-all") cards().forEach(card => setAction(card, "@StoryIntelligenceRelationshipImportActions.CreateNew"));
if (action === "link-existing") cards().forEach(card => {
if (card.querySelector(`[data-relationship-action][value="@StoryIntelligenceRelationshipImportActions.LinkExisting"]:not(:disabled)`)) {
setAction(card, "@StoryIntelligenceRelationshipImportActions.LinkExisting");
}
});
if (action === "ignore-all") cards().forEach(card => setAction(card, "@StoryIntelligenceRelationshipImportActions.Ignore"));
if (action === "expand") cards().forEach(card => card.open = true);
if (action === "collapse") cards().forEach(card => card.open = false);
});
});
form.querySelectorAll("[data-relationship-action]").forEach(input => {
input.addEventListener("change", () => updateCard(input.closest("[data-relationship-card]")));
});
cards().forEach(updateCard);
})();
</script>
}
@functions {
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value;
private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label)
=> new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Coming Soon</span></article>");
}

View File

@ -6,7 +6,7 @@
new PipelineStage("Review Characters", "Characters", true),
new PipelineStage("Review Locations", "Locations", true),
new PipelineStage("Review Assets", "Assets", true),
new PipelineStage("Review Relationships", "Relationships", false),
new PipelineStage("Review Relationships", "Relationships", true),
new PipelineStage("Review Knowledge", "Knowledge", false)
};
var implementedReviewStages = reviewStages.Where(stage => stage.IsImplemented).ToList();

View File

@ -34,8 +34,12 @@
<strong>@(Model.AssetStageComplete ? $"Done: {Model.AssetsCreatedOrLinked:N0}" : "In review")</strong>
</div>
<div>
<span>Relationships</span>
<strong>Coming next</strong>
<span>Relationships identified</span>
<strong>@Model.RelationshipsIdentified.ToString("N0")</strong>
</div>
<div>
<span>Relationships created / linked</span>
<strong>@(Model.RelationshipStageComplete ? $"Done: {Model.RelationshipsCreatedOrLinked:N0}" : "In review")</strong>
</div>
<div>
<span>Knowledge</span>