Phase 20AS – Relationship Intelligence Review and Import
This commit is contained in:
parent
7f9f051341
commit
e8b42664f1
51
Docs/StoryIntelligence_Phase20AS_RelationshipImport.md
Normal file
51
Docs/StoryIntelligence_Phase20AS_RelationshipImport.md
Normal 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
|
||||||
@ -75,6 +75,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
return target.Route switch
|
return target.Route switch
|
||||||
{
|
{
|
||||||
StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }),
|
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.Assets => RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = target.BatchID }),
|
||||||
StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = target.BatchID }),
|
StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = target.BatchID }),
|
||||||
StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceCharacters), 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);
|
: 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")]
|
[HttpPost("story-intelligence/cancel")]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> CancelStoryIntelligence(Guid batchId)
|
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);
|
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")]
|
[HttpGet("story-intelligence/complete")]
|
||||||
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
|
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
|
||||||
{
|
{
|
||||||
@ -354,6 +412,11 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId });
|
return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!progress.PipelineDashboard.RelationshipStageComplete)
|
||||||
|
{
|
||||||
|
return RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId });
|
||||||
|
}
|
||||||
|
|
||||||
var model = await storyIntelligence.GetCompletionAsync(batchId);
|
var model = await storyIntelligence.GetCompletionAsync(batchId);
|
||||||
return model is null ? NotFound() : View(model);
|
return model is null ? NotFound() : View(model);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,8 @@ public static class StoryIntelligencePipelineStages
|
|||||||
public const string LocationImport = "LocationImport";
|
public const string LocationImport = "LocationImport";
|
||||||
public const string AssetReview = "AssetReview";
|
public const string AssetReview = "AssetReview";
|
||||||
public const string AssetImport = "AssetImport";
|
public const string AssetImport = "AssetImport";
|
||||||
|
public const string RelationshipReview = "RelationshipReview";
|
||||||
|
public const string RelationshipImport = "RelationshipImport";
|
||||||
public const string Complete = "Complete";
|
public const string Complete = "Complete";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,7 +64,7 @@ public sealed class StoryIntelligenceBookPipelineState
|
|||||||
|
|
||||||
public bool IsComplete
|
public bool IsComplete
|
||||||
=> string.Equals(Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase)
|
=> 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
|
public sealed class StoryIntelligenceBookPipelineSaveRequest
|
||||||
|
|||||||
@ -210,6 +210,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IStoryIntelligenceCharacterImportService, StoryIntelligenceCharacterImportService>();
|
builder.Services.AddScoped<IStoryIntelligenceCharacterImportService, StoryIntelligenceCharacterImportService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceLocationImportService, StoryIntelligenceLocationImportService>();
|
builder.Services.AddScoped<IStoryIntelligenceLocationImportService, StoryIntelligenceLocationImportService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
|
builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
|
||||||
|
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
|
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
||||||
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
||||||
|
|||||||
@ -20,6 +20,8 @@ public interface IOnboardingStoryIntelligenceService
|
|||||||
Task<StoryIntelligenceLocationImportResultViewModel?> GetLocationImportResultAsync(Guid batchId);
|
Task<StoryIntelligenceLocationImportResultViewModel?> GetLocationImportResultAsync(Guid batchId);
|
||||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm form);
|
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm form);
|
||||||
Task<StoryIntelligenceAssetImportResultViewModel?> GetAssetImportResultAsync(Guid batchId);
|
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<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
|
||||||
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
|
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
|
||||||
}
|
}
|
||||||
@ -33,6 +35,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
IStoryIntelligenceCharacterImportService characterImport,
|
IStoryIntelligenceCharacterImportService characterImport,
|
||||||
IStoryIntelligenceLocationImportService locationImport,
|
IStoryIntelligenceLocationImportService locationImport,
|
||||||
IStoryIntelligenceAssetImportService assetImport,
|
IStoryIntelligenceAssetImportService assetImport,
|
||||||
|
IStoryIntelligenceRelationshipImportService relationshipImport,
|
||||||
IStoryIntelligencePipelineStateService pipelineState,
|
IStoryIntelligencePipelineStateService pipelineState,
|
||||||
IStoryIntelligenceClient client,
|
IStoryIntelligenceClient client,
|
||||||
IOnboardingStoryIntelligenceBatchStore batchStore,
|
IOnboardingStoryIntelligenceBatchStore batchStore,
|
||||||
@ -267,6 +270,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
var characterReview = await characterImport.BuildReviewAsync(batch);
|
var characterReview = await characterImport.BuildReviewAsync(batch);
|
||||||
var locationReview = await locationImport.BuildReviewAsync(batch);
|
var locationReview = await locationImport.BuildReviewAsync(batch);
|
||||||
var assetReview = await assetImport.BuildReviewAsync(batch);
|
var assetReview = await assetImport.BuildReviewAsync(batch);
|
||||||
|
var relationshipReview = await relationshipImport.BuildReviewAsync(batch);
|
||||||
return new StoryIntelligenceProgressViewModel
|
return new StoryIntelligenceProgressViewModel
|
||||||
{
|
{
|
||||||
BatchID = batch.BatchID,
|
BatchID = batch.BatchID,
|
||||||
@ -279,6 +283,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
CharacterReview = characterReview,
|
CharacterReview = characterReview,
|
||||||
LocationReview = locationReview,
|
LocationReview = locationReview,
|
||||||
AssetReview = assetReview,
|
AssetReview = assetReview,
|
||||||
|
RelationshipReview = relationshipReview,
|
||||||
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
|
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
|
||||||
{
|
{
|
||||||
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
|
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
|
||||||
@ -291,7 +296,10 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
LocationStageComplete = batch.LocationStageComplete || locationReview.IsComplete,
|
LocationStageComplete = batch.LocationStageComplete || locationReview.IsComplete,
|
||||||
AssetsIdentified = assetReview.Candidates.Count + batch.AssetDecisions.Count,
|
AssetsIdentified = assetReview.Candidates.Count + batch.AssetDecisions.Count,
|
||||||
AssetsCreatedOrLinked = batch.AssetDecisions.Count(decision => decision.CreatedOrLinked),
|
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)
|
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
|
||||||
{
|
{
|
||||||
var progress = await GetProgressAsync(batchId);
|
var progress = await GetProgressAsync(batchId);
|
||||||
@ -483,7 +524,9 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
LocationsCreatedOrLinked = progress.PipelineDashboard.LocationsCreatedOrLinked,
|
LocationsCreatedOrLinked = progress.PipelineDashboard.LocationsCreatedOrLinked,
|
||||||
LocationStageComplete = progress.PipelineDashboard.LocationStageComplete,
|
LocationStageComplete = progress.PipelineDashboard.LocationStageComplete,
|
||||||
AssetsCreatedOrLinked = progress.PipelineDashboard.AssetsCreatedOrLinked,
|
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.LocationImport
|
||||||
or StoryIntelligencePipelineStages.AssetReview
|
or StoryIntelligencePipelineStages.AssetReview
|
||||||
or StoryIntelligencePipelineStages.AssetImport
|
or StoryIntelligencePipelineStages.AssetImport
|
||||||
|
or StoryIntelligencePipelineStages.RelationshipReview
|
||||||
|
or StoryIntelligencePipelineStages.RelationshipImport
|
||||||
or StoryIntelligencePipelineStages.Complete;
|
or StoryIntelligencePipelineStages.Complete;
|
||||||
var locationStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.LocationImport)
|
var locationStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.LocationImport)
|
||||||
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview
|
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview
|
||||||
or StoryIntelligencePipelineStages.AssetImport
|
or StoryIntelligencePipelineStages.AssetImport
|
||||||
|
or StoryIntelligencePipelineStages.RelationshipReview
|
||||||
|
or StoryIntelligencePipelineStages.RelationshipImport
|
||||||
or StoryIntelligencePipelineStages.Complete;
|
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.CharacterStageComplete = characterStageComplete;
|
||||||
batch.LocationStageComplete = locationStageComplete;
|
batch.LocationStageComplete = locationStageComplete;
|
||||||
batch.AssetStageComplete = assetStageComplete;
|
batch.AssetStageComplete = assetStageComplete;
|
||||||
|
batch.RelationshipStageComplete = relationshipStageComplete;
|
||||||
|
|
||||||
await batchStore.SaveAsync(batch);
|
await batchStore.SaveAsync(batch);
|
||||||
|
|
||||||
var route = assetStageComplete
|
var route = relationshipStageComplete
|
||||||
? StoryIntelligenceResumeRoutes.Complete
|
? StoryIntelligenceResumeRoutes.Complete
|
||||||
: locationStageComplete
|
: assetStageComplete
|
||||||
|
? StoryIntelligenceResumeRoutes.Relationships
|
||||||
|
: locationStageComplete
|
||||||
? StoryIntelligenceResumeRoutes.Assets
|
? StoryIntelligenceResumeRoutes.Assets
|
||||||
: characterStageComplete
|
: characterStageComplete
|
||||||
? StoryIntelligenceResumeRoutes.Locations
|
? StoryIntelligenceResumeRoutes.Locations
|
||||||
: state.CurrentStage switch
|
: state.CurrentStage switch
|
||||||
{
|
{
|
||||||
|
StoryIntelligencePipelineStages.RelationshipReview => StoryIntelligenceResumeRoutes.Relationships,
|
||||||
|
StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships,
|
||||||
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
|
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
|
||||||
StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters,
|
StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters,
|
||||||
StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview,
|
StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview,
|
||||||
@ -794,12 +850,15 @@ public sealed class OnboardingStoryIntelligenceBatch
|
|||||||
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
|
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
|
||||||
public List<OnboardingStoryIntelligenceLocationDecision> LocationDecisions { get; } = [];
|
public List<OnboardingStoryIntelligenceLocationDecision> LocationDecisions { get; } = [];
|
||||||
public List<OnboardingStoryIntelligenceAssetDecision> AssetDecisions { get; } = [];
|
public List<OnboardingStoryIntelligenceAssetDecision> AssetDecisions { get; } = [];
|
||||||
|
public List<OnboardingStoryIntelligenceRelationshipDecision> RelationshipDecisions { get; } = [];
|
||||||
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
|
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
|
||||||
public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; }
|
public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; }
|
||||||
public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; }
|
public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; }
|
||||||
|
public StoryIntelligenceRelationshipImportBatchResult? LastRelationshipImportResult { get; set; }
|
||||||
public bool CharacterStageComplete { get; set; }
|
public bool CharacterStageComplete { get; set; }
|
||||||
public bool LocationStageComplete { get; set; }
|
public bool LocationStageComplete { get; set; }
|
||||||
public bool AssetStageComplete { get; set; }
|
public bool AssetStageComplete { get; set; }
|
||||||
|
public bool RelationshipStageComplete { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class OnboardingStoryIntelligenceBatchItem
|
public sealed class OnboardingStoryIntelligenceBatchItem
|
||||||
@ -838,6 +897,15 @@ public sealed class OnboardingStoryIntelligenceAssetDecision
|
|||||||
public bool CreatedOrLinked { get; init; }
|
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 sealed class StoryIntelligenceCharacterImportBatchResult
|
||||||
{
|
{
|
||||||
public int CharactersCreated { get; init; }
|
public int CharactersCreated { get; init; }
|
||||||
@ -867,12 +935,22 @@ public sealed class StoryIntelligenceAssetImportBatchResult
|
|||||||
public int OwnershipLinksCreated { get; init; }
|
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 static class StoryIntelligenceResumeRoutes
|
||||||
{
|
{
|
||||||
public const string SceneReview = "SceneReview";
|
public const string SceneReview = "SceneReview";
|
||||||
public const string Characters = "Characters";
|
public const string Characters = "Characters";
|
||||||
public const string Locations = "Locations";
|
public const string Locations = "Locations";
|
||||||
public const string Assets = "Assets";
|
public const string Assets = "Assets";
|
||||||
|
public const string Relationships = "Relationships";
|
||||||
public const string Complete = "Complete";
|
public const string Complete = "Complete";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ public interface IStoryIntelligencePipelineStateService
|
|||||||
Task RecordCharacterImportAsync(int projectId, int bookId);
|
Task RecordCharacterImportAsync(int projectId, int bookId);
|
||||||
Task RecordLocationImportAsync(int projectId, int bookId);
|
Task RecordLocationImportAsync(int projectId, int bookId);
|
||||||
Task RecordAssetImportAsync(int projectId, int bookId);
|
Task RecordAssetImportAsync(int projectId, int bookId);
|
||||||
|
Task RecordRelationshipImportAsync(int projectId, int bookId);
|
||||||
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
|
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,8 +113,23 @@ public sealed class StoryIntelligencePipelineStateService(
|
|||||||
{
|
{
|
||||||
ProjectID = projectId,
|
ProjectID = projectId,
|
||||||
BookID = bookId,
|
BookID = bookId,
|
||||||
CurrentStage = StoryIntelligencePipelineStages.Complete,
|
CurrentStage = StoryIntelligencePipelineStages.RelationshipReview,
|
||||||
LastCompletedStage = StoryIntelligencePipelineStages.AssetImport,
|
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,
|
CurrentReviewStage = null,
|
||||||
Status = StoryIntelligencePipelineStatuses.Complete,
|
Status = StoryIntelligencePipelineStatuses.Complete,
|
||||||
CompletedUtc = DateTime.UtcNow,
|
CompletedUtc = DateTime.UtcNow,
|
||||||
|
|||||||
670
PlotLine/Services/StoryIntelligenceRelationshipImportService.cs
Normal file
670
PlotLine/Services/StoryIntelligenceRelationshipImportService.cs
Normal 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);
|
||||||
|
}
|
||||||
@ -269,10 +269,12 @@ public sealed class StoryIntelligenceService(
|
|||||||
|
|
||||||
return state.CurrentReviewStage switch
|
return state.CurrentReviewStage switch
|
||||||
{
|
{
|
||||||
|
StoryIntelligencePipelineStages.RelationshipReview => "Waiting for Relationship Review",
|
||||||
StoryIntelligencePipelineStages.AssetReview => "Waiting for Asset Review",
|
StoryIntelligencePipelineStages.AssetReview => "Waiting for Asset Review",
|
||||||
StoryIntelligencePipelineStages.LocationReview => "Waiting for Location Review",
|
StoryIntelligencePipelineStages.LocationReview => "Waiting for Location Review",
|
||||||
StoryIntelligencePipelineStages.CharacterReview => "Waiting for Character Review",
|
StoryIntelligencePipelineStages.CharacterReview => "Waiting for Character Review",
|
||||||
StoryIntelligencePipelineStages.SceneReview => "Needs Scene Review",
|
StoryIntelligencePipelineStages.SceneReview => "Needs Scene Review",
|
||||||
|
_ when StoryIntelligenceReadyForRelationships(state) => "Waiting for Relationship Review",
|
||||||
_ when StoryIntelligenceReadyForAssets(state) => "Waiting for Asset Review",
|
_ when StoryIntelligenceReadyForAssets(state) => "Waiting for Asset Review",
|
||||||
_ when StoryIntelligenceReadyForLocations(state) => "Waiting for Location Review",
|
_ when StoryIntelligenceReadyForLocations(state) => "Waiting for Location Review",
|
||||||
_ => string.Equals(state.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase)
|
_ => string.Equals(state.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase)
|
||||||
@ -286,24 +288,33 @@ public sealed class StoryIntelligenceService(
|
|||||||
? "All currently available Story Intelligence stages are complete."
|
? "All currently available Story Intelligence stages are complete."
|
||||||
: state.CurrentReviewStage switch
|
: 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.AssetReview => "Review detected assets and decide what to create or link.",
|
||||||
StoryIntelligencePipelineStages.LocationReview => "Review detected locations 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.CharacterReview => "Review detected characters and decide what to create or link.",
|
||||||
StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them.",
|
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 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.",
|
_ when StoryIntelligenceReadyForLocations(state) => "Review detected locations and decide what to create or link.",
|
||||||
_ => "Continue from the next Story Intelligence stage."
|
_ => "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)
|
private static bool StoryIntelligenceReadyForAssets(StoryIntelligenceBookPipelineState state)
|
||||||
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase)
|
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase)
|
||||||
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
|
|| 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.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)
|
private static bool StoryIntelligenceReadyForLocations(StoryIntelligenceBookPipelineState state)
|
||||||
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.CharacterImport, StringComparison.OrdinalIgnoreCase)
|
=> 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)
|
private static string FormatElapsed(TimeSpan elapsed)
|
||||||
=> elapsed.TotalMinutes < 1
|
=> elapsed.TotalMinutes < 1
|
||||||
|
|||||||
@ -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
|
||||||
@ -145,6 +145,7 @@ public sealed class StoryIntelligenceProgressViewModel
|
|||||||
public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new();
|
public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new();
|
||||||
public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new();
|
public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new();
|
||||||
public StoryIntelligenceAssetReviewViewModel AssetReview { get; init; } = new();
|
public StoryIntelligenceAssetReviewViewModel AssetReview { get; init; } = new();
|
||||||
|
public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; init; } = new();
|
||||||
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
|
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
|
||||||
public int ChapterCount => Chapters.Count;
|
public int ChapterCount => Chapters.Count;
|
||||||
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
|
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 HasCharactersToReview => CharacterReview.Candidates.Count > 0;
|
||||||
public bool HasLocationsToReview => LocationReview.Candidates.Count > 0;
|
public bool HasLocationsToReview => LocationReview.Candidates.Count > 0;
|
||||||
public bool HasAssetsToReview => AssetReview.Candidates.Count > 0;
|
public bool HasAssetsToReview => AssetReview.Candidates.Count > 0;
|
||||||
|
public bool HasRelationshipsToReview => RelationshipReview.Candidates.Count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligencePipelineDashboardViewModel
|
public sealed class StoryIntelligencePipelineDashboardViewModel
|
||||||
@ -177,6 +179,9 @@ public sealed class StoryIntelligencePipelineDashboardViewModel
|
|||||||
public int AssetsIdentified { get; init; }
|
public int AssetsIdentified { get; init; }
|
||||||
public int AssetsCreatedOrLinked { get; init; }
|
public int AssetsCreatedOrLinked { get; init; }
|
||||||
public bool AssetStageComplete { 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
|
public sealed class StoryIntelligenceCharacterReviewViewModel
|
||||||
@ -322,6 +327,55 @@ public static class StoryIntelligenceAssetImportActions
|
|||||||
public const string Alias = "Alias";
|
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 sealed class StoryIntelligenceCompletionViewModel
|
||||||
{
|
{
|
||||||
public StoryIntelligenceJobProgress Job { get; init; } = new();
|
public StoryIntelligenceJobProgress Job { get; init; } = new();
|
||||||
@ -339,6 +393,8 @@ public sealed class StoryIntelligenceCompletionViewModel
|
|||||||
public bool LocationStageComplete { get; init; }
|
public bool LocationStageComplete { get; init; }
|
||||||
public int AssetsCreatedOrLinked { get; init; }
|
public int AssetsCreatedOrLinked { get; init; }
|
||||||
public bool AssetStageComplete { get; init; }
|
public bool AssetStageComplete { get; init; }
|
||||||
|
public int RelationshipsCreatedOrLinked { get; init; }
|
||||||
|
public bool RelationshipStageComplete { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceCharacterImportResultViewModel
|
public sealed class StoryIntelligenceCharacterImportResultViewModel
|
||||||
@ -379,6 +435,18 @@ public sealed class StoryIntelligenceAssetImportResultViewModel
|
|||||||
public int OwnershipLinksCreated { get; init; }
|
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 sealed class StoryIntelligenceOnboardingChapterViewModel
|
||||||
{
|
{
|
||||||
public string TemporaryChapterKey { get; init; } = string.Empty;
|
public string TemporaryChapterKey { get; init; } = string.Empty;
|
||||||
|
|||||||
@ -200,23 +200,32 @@
|
|||||||
|
|
||||||
return pipeline.CurrentReviewStage switch
|
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.AssetReview => "Review detected assets and decide what to create or link.",
|
||||||
StoryIntelligencePipelineStages.LocationReview => "Review detected locations 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.CharacterReview => "Scene creation is complete. Review detected characters to continue building the story database.",
|
||||||
StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them in PlotDirector.",
|
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 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.",
|
_ when StoryIntelligenceReadyForLocations(pipeline) => "Review detected locations and decide what to create or link.",
|
||||||
_ => "PlotDirector will resume from the next Story Intelligence stage."
|
_ => "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)
|
private static bool StoryIntelligenceReadyForAssets(StoryIntelligenceBookPipelineState pipeline)
|
||||||
=> string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase)
|
=> string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase)
|
||||||
|| pipeline.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
|
|| 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.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)
|
private static bool StoryIntelligenceReadyForLocations(StoryIntelligenceBookPipelineState pipeline)
|
||||||
=> string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.CharacterImport, StringComparison.OrdinalIgnoreCase)
|
=> 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,14 +5,14 @@
|
|||||||
|
|
||||||
<section class="onboarding-shell" aria-labelledby="story-asset-complete-title">
|
<section class="onboarding-shell" aria-labelledby="story-asset-complete-title">
|
||||||
<div class="onboarding-panel onboarding-review-panel">
|
<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">
|
<div class="onboarding-copy">
|
||||||
<p class="eyebrow">Build Story Database</p>
|
<p class="eyebrow">Build Story Database</p>
|
||||||
<div class="onboarding-success-heading">
|
<div class="onboarding-success-heading">
|
||||||
<span aria-hidden="true">✓</span>
|
<span aria-hidden="true">✓</span>
|
||||||
<h1 id="story-asset-complete-title">Assets created</h1>
|
<h1 id="story-asset-complete-title">Assets created</h1>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="onboarding-scan-counts onboarding-review-counts">
|
<div class="onboarding-scan-counts onboarding-review-counts">
|
||||||
@ -43,7 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="story-future-stage-grid">
|
<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 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>Review Continuity</strong><span>Coming Soon</span></article>
|
||||||
<article class="story-future-stage"><strong>Timeline Events</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">
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
<div class="onboarding-copy story-review-heading">
|
<div class="onboarding-copy story-review-heading">
|
||||||
<p class="eyebrow">Phase 3</p>
|
<p class="eyebrow">Phase 3</p>
|
||||||
<p class="story-review-heading__phase">Review Story Intelligence</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>
|
<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>
|
<p>Approve the important objects PlotDirector should create or link from your imported scenes. Nothing is created unless you approve it.</p>
|
||||||
</div>
|
</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>
|
<p class="mb-0">Assets are already up to date, or there were no story-significant asset suggestions ready to import.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="story-future-stage-grid">
|
<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 Knowledge")
|
||||||
@FutureStage("Review Continuity")
|
@FutureStage("Review Continuity")
|
||||||
@FutureStage("Timeline Events")
|
@FutureStage("Timeline Events")
|
||||||
|
|||||||
@ -24,7 +24,7 @@
|
|||||||
<li><span aria-hidden="true">✓</span><strong>Characters</strong></li>
|
<li><span aria-hidden="true">✓</span><strong>Characters</strong></li>
|
||||||
<li><span aria-hidden="true">✓</span><strong>Locations</strong></li>
|
<li><span aria-hidden="true">✓</span><strong>Locations</strong></li>
|
||||||
<li><span aria-hidden="true">✓</span><strong>Assets</strong></li>
|
<li><span aria-hidden="true">✓</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">✓</span><strong>Relationships</strong></li>
|
||||||
<li class="is-upcoming"><span aria-hidden="true">○</span><strong>Knowledge</strong><em>Coming Soon</em></li>
|
<li class="is-upcoming"><span aria-hidden="true">○</span><strong>Knowledge</strong><em>Coming Soon</em></li>
|
||||||
<li><span aria-hidden="true">✓</span><strong>Preparing continuity...</strong></li>
|
<li><span aria-hidden="true">✓</span><strong>Preparing continuity...</strong></li>
|
||||||
<li><span aria-hidden="true">✓</span><strong>Generating story indexes...</strong></li>
|
<li><span aria-hidden="true">✓</span><strong>Generating story indexes...</strong></li>
|
||||||
@ -53,6 +53,10 @@
|
|||||||
<span>Assets created / linked</span>
|
<span>Assets created / linked</span>
|
||||||
<strong>@Model.AssetsCreatedOrLinked.ToString("N0")</strong>
|
<strong>@Model.AssetsCreatedOrLinked.ToString("N0")</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Relationships created / linked</span>
|
||||||
|
<strong>@Model.RelationshipsCreatedOrLinked.ToString("N0")</strong>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Book</span>
|
<span>Book</span>
|
||||||
<strong>@Model.BookTitle</strong>
|
<strong>@Model.BookTitle</strong>
|
||||||
|
|||||||
@ -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">✓</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>
|
||||||
233
PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml
Normal file
233
PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml
Normal 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>");
|
||||||
|
}
|
||||||
@ -6,7 +6,7 @@
|
|||||||
new PipelineStage("Review Characters", "Characters", true),
|
new PipelineStage("Review Characters", "Characters", true),
|
||||||
new PipelineStage("Review Locations", "Locations", true),
|
new PipelineStage("Review Locations", "Locations", true),
|
||||||
new PipelineStage("Review Assets", "Assets", 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)
|
new PipelineStage("Review Knowledge", "Knowledge", false)
|
||||||
};
|
};
|
||||||
var implementedReviewStages = reviewStages.Where(stage => stage.IsImplemented).ToList();
|
var implementedReviewStages = reviewStages.Where(stage => stage.IsImplemented).ToList();
|
||||||
|
|||||||
@ -34,8 +34,12 @@
|
|||||||
<strong>@(Model.AssetStageComplete ? $"Done: {Model.AssetsCreatedOrLinked:N0}" : "In review")</strong>
|
<strong>@(Model.AssetStageComplete ? $"Done: {Model.AssetsCreatedOrLinked:N0}" : "In review")</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Relationships</span>
|
<span>Relationships identified</span>
|
||||||
<strong>Coming next</strong>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span>Knowledge</span>
|
<span>Knowledge</span>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user