Compare commits

...

10 Commits

32 changed files with 5022 additions and 116 deletions

View File

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

View File

@ -10,7 +10,15 @@ var tests = new (string Name, Action Test)[]
("Truncated JSON is rejected", RejectsTruncatedJson), ("Truncated JSON is rejected", RejectsTruncatedJson),
("Repaired JSON deserialises into SceneIntelligenceScene", RepairedJsonDeserialises), ("Repaired JSON deserialises into SceneIntelligenceScene", RepairedJsonDeserialises),
("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs), ("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs),
("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput) ("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput),
("Character filtering rejects generic groups", CharacterFilteringRejectsGenericGroups),
("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames),
("Location filtering rejects merged phrases", LocationFilteringRejectsMergedPhrases),
("Location filtering preserves story locations", LocationFilteringPreservesStoryLocations),
("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants),
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets),
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants)
}; };
foreach (var test in tests) foreach (var test in tests)
@ -106,6 +114,138 @@ static void SharedParserReportsRawOutput()
Assert(result.RawJson == raw, "Raw malformed output was not retained."); Assert(result.RawJson == raw, "Raw malformed output was not retained.");
} }
static void CharacterFilteringRejectsGenericGroups()
{
Assert(IsGenericGroupReference("four examiners"), "Quantity + generic group should be rejected.");
Assert(IsGenericGroupReference("groups of men"), "Generic group phrase should be rejected.");
Assert(IsGenericGroupReference("neighbours"), "Generic plural group should be rejected.");
Assert(IsGenericGroupReference("passenger (blue Volkswagen Golf GTi)"), "Unnamed passenger descriptor should be rejected.");
Assert(IsGenericGroupReference("tubby lad"), "Unnamed descriptive person should be rejected.");
Assert(IsGenericGroupReference("lady examiner"), "Unnamed role descriptor should be rejected.");
Assert(IsGenericGroupReference("her mother"), "Determiner + family role should be rejected.");
Assert(IsGenericGroupReference("the child"), "Determiner + generic person should be rejected.");
}
static void CharacterFilteringPreservesTitledNames()
{
Assert(!IsGenericGroupReference("Reverend Potter"), "Reverend Potter should not be treated as generic.");
Assert(IsNamedOrTitledPerson("Reverend Potter"), "Reverend Potter should be recognised as a titled person.");
Assert(IsNamedOrTitledPerson("Detective Sergeant Summerhill"), "Detective Sergeant Summerhill should be recognised as a titled person.");
Assert(IsNamedOrTitledPerson("Mrs Patterson"), "Mrs Patterson should be recognised as a titled person.");
Assert(IsNamedOrTitledPerson("Aunt Elen"), "Aunt Elen should be recognised as a titled person.");
}
static bool IsGenericGroupReference(string name)
=> InvokePrivateCharacterFilter("IsGenericGroupReference", name);
static bool IsNamedOrTitledPerson(string name)
=> InvokePrivateCharacterFilter("IsNamedOrTitledPerson", name);
static bool InvokePrivateCharacterFilter(string methodName, string name)
{
var method = typeof(StoryIntelligenceCharacterImportService).GetMethod(
methodName,
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, $"{methodName} was not found.");
return method!.Invoke(null, [name]) is true;
}
static void LocationFilteringRejectsMergedPhrases()
{
Assert(IsMergedLocationPhrase("shop and staff room"), "Merged shop/staff room phrase should be rejected.");
Assert(IsMergedLocationPhrase("house and garden"), "Merged house/garden phrase should be rejected.");
Assert(IsMergedLocationPhrase("road and bridge"), "Merged road/bridge phrase should be rejected.");
Assert(IsMergedLocationPhrase("kitchen and hallway"), "Merged room phrase should be rejected.");
Assert(IsMergedLocationPhrase("front and rear garden"), "Merged garden phrase should be rejected.");
Assert(!IsMergedLocationPhrase("Rose and Crown"), "Named pub should not be treated as a merged location.");
}
static void LocationFilteringPreservesStoryLocations()
{
Assert(IsStoryLocation("The Doweries"), "The Doweries should be preserved.");
Assert(IsStoryLocation("Ashdown Trust"), "Ashdown Trust should be preserved.");
Assert(IsStoryLocation("St Luke's Church"), "St Luke's Church should be preserved.");
Assert(IsStoryLocation("Mrs Patterson's House"), "Mrs Patterson's House should be preserved.");
Assert(IsStoryLocation("Bristol Road"), "Named roads should be preserved.");
Assert(!IsStoryLocation("Kitchen"), "Standalone generic rooms should not be story locations.");
}
static void LocationCanonicalKeysMergeTrivialVariants()
{
Assert(LocationCanonicalKey("Kitchen") == LocationCanonicalKey("The Kitchen"), "The Kitchen should fold into Kitchen.");
Assert(LocationCanonicalKey("Interview room") == LocationCanonicalKey("Interview Room"), "Case-only room variants should merge.");
Assert(LocationCanonicalKey("Ashdown Trust (reception)") == LocationCanonicalKey("Ashdown Trust"), "Parenthetical variants should merge.");
}
static bool IsMergedLocationPhrase(string name)
=> InvokePrivateLocationFilter("IsMergedLocationPhrase", name);
static bool IsStoryLocation(string name)
=> InvokePrivateLocationFilter("IsStoryLocation", name);
static string LocationCanonicalKey(string name)
{
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
"CanonicalLocationKey",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "CanonicalLocationKey was not found.");
return (string)method!.Invoke(null, [name])!;
}
static bool InvokePrivateLocationFilter(string methodName, string name)
{
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
methodName,
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, $"{methodName} was not found.");
return method!.Invoke(null, [name]) is true;
}
static void AssetFilteringRejectsGenericObjects()
{
Assert(!IsAssetNameCandidate("chair"), "Chair should not be treated as a story asset.");
Assert(!IsAssetNameCandidate("table"), "Table should not be treated as a story asset.");
Assert(!IsAssetNameCandidate("street"), "Street should not be treated as a story asset.");
Assert(!IsAssetNameCandidate("car park"), "Car park should not be treated as a story asset.");
}
static void AssetFilteringPreservesStoryAssets()
{
Assert(IsAssetNameCandidate("TR6"), "TR6 should be treated as a story asset.");
Assert(IsAssetNameCandidate("red notebook"), "Red notebook should be treated as a story asset.");
Assert(IsAssetNameCandidate("passport"), "Passport should be treated as a story asset.");
Assert(IsAssetNameCandidate("car keys"), "Car keys should be treated as a story asset.");
Assert(IsAssetNameCandidate("driving licence"), "Driving licence should be treated as a story asset.");
}
static void AssetCanonicalKeysMergeTrivialVariants()
{
Assert(AssetCanonicalKey("red notebook") == AssetCanonicalKey("the notebook"), "Notebook variants should merge.");
Assert(AssetCanonicalKey("photo") == AssetCanonicalKey("photograph"), "Photo should fold into photograph.");
Assert(AssetCanonicalKey("driving license") == AssetCanonicalKey("driving licence"), "License/licence spelling should merge.");
}
static bool IsAssetNameCandidate(string name)
=> InvokePrivateAssetFilter("IsAssetNameCandidate", name);
static string AssetCanonicalKey(string name)
{
var method = typeof(StoryIntelligenceAssetImportService).GetMethod(
"CanonicalAssetKey",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "CanonicalAssetKey was not found.");
return (string)method!.Invoke(null, [name])!;
}
static bool InvokePrivateAssetFilter(string methodName, string name)
{
var method = typeof(StoryIntelligenceAssetImportService).GetMethod(
methodName,
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, $"{methodName} was not found.");
return method!.Invoke(null, [name]) is true;
}
static JsonSerializerOptions JsonOptions() static JsonSerializerOptions JsonOptions()
=> new() => new()
{ {

View File

@ -75,6 +75,9 @@ 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.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 }),
_ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID }) _ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID })
}; };
@ -156,6 +159,93 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
: View(model); : View(model);
} }
[HttpGet("story-intelligence/locations")]
public async Task<IActionResult> StoryIntelligenceLocations(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 });
}
return !model.PipelineDashboard.CharacterStageComplete
? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId })
: View(model);
}
[HttpGet("story-intelligence/assets")]
public async Task<IActionResult> StoryIntelligenceAssets(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 });
}
return !model.PipelineDashboard.LocationStageComplete
? RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId })
: 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)
@ -229,6 +319,75 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return model is null ? NotFound() : View(model); return model is null ? NotFound() : View(model);
} }
[HttpPost("story-intelligence/locations")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportStoryIntelligenceLocations(StoryIntelligenceLocationImportForm form)
{
var (progress, result) = await storyIntelligence.ImportLocationsAsync(form.BatchID, form);
if (progress is null)
{
return NotFound();
}
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success
? RedirectToAction(nameof(StoryIntelligenceLocationComplete), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID });
}
[HttpGet("story-intelligence/locations/complete")]
public async Task<IActionResult> StoryIntelligenceLocationComplete(Guid batchId)
{
var model = await storyIntelligence.GetLocationImportResultAsync(batchId);
return model is null ? NotFound() : View(model);
}
[HttpPost("story-intelligence/assets")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportStoryIntelligenceAssets(StoryIntelligenceAssetImportForm form)
{
var (progress, result) = await storyIntelligence.ImportAssetsAsync(form.BatchID, form);
if (progress is null)
{
return NotFound();
}
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success
? RedirectToAction(nameof(StoryIntelligenceAssetComplete), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID });
}
[HttpGet("story-intelligence/assets/complete")]
public async Task<IActionResult> StoryIntelligenceAssetComplete(Guid batchId)
{
var model = await storyIntelligence.GetAssetImportResultAsync(batchId);
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)
{ {
@ -243,6 +402,21 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
} }
if (!progress.PipelineDashboard.LocationStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
}
if (!progress.PipelineDashboard.AssetStageComplete)
{
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);
} }

View File

@ -0,0 +1,88 @@
# Phase 20AP - Asset Intelligence Review and Import
Phase 20AP adds the first implemented Asset Intelligence import stage to the Story Intelligence pipeline.
The stage uses only stored Scene Intelligence JSON from completed chapter runs. It does not make additional OpenAI calls and does not change the Scene Intelligence prompts.
## Implemented Asset Stage
After Character Review/Import and Location Review/Import, the pipeline now continues to:
- Review Assets
- Create Assets
- Complete
Resume support routes books with completed characters and locations, but incomplete assets, directly to Asset Review.
## Source Data
Asset candidates are built from:
- `assets[]` in stored Scene Intelligence results
- entity-mappable observations whose subject or object is an Asset/Object
The import does not reread the manuscript and does not send raw text back to AI.
## Candidate Filtering
The asset resolver keeps story-significant objects such as:
- TR6
- rucksack
- note
- letter
- necklace
- driving licence
- car keys
- passport
- envelope
- photograph
- map
- diary
- notebook
It suppresses generic scenery, furniture, broad locations and incidental items unless there is repeated story significance or clear asset signal.
## Duplicate and Alias Handling
Asset variants are folded into one review candidate where safe.
Examples:
- `red notebook`, `the notebook`, `old notebook` -> `Notebook`
- `photo` -> `Photograph`
- `driving license` -> `Driving licence`
The review screen supports:
- create new asset
- link to existing asset
- create separate asset
- alias/variant of another reviewed asset
- ignore
## Imported Fields
Approved assets create or link:
- `StoryAssets`
- `AssetAliases`
- `AssetEvents`
- `AssetCustodyEvents` only when owner/holder text resolves to an existing character
Asset kind is mapped to existing lookup values such as Document, Evidence, Vehicle or Physical Object. New lookup values are not created.
Scene appearances are imported as Asset Events so they appear in the Scene Inspector World panel and on Asset pages/timelines.
## Not Yet Implemented
The following remain future Story Intelligence stages:
- relationships
- knowledge
- continuity
- real timeline events
- full asset dependency modelling
- unresolved owner creation
No placeholder characters or owners are created by this phase.

View File

@ -28,6 +28,12 @@ public static class StoryIntelligencePipelineStages
public const string SceneImport = "SceneImport"; public const string SceneImport = "SceneImport";
public const string CharacterReview = "CharacterReview"; public const string CharacterReview = "CharacterReview";
public const string CharacterImport = "CharacterImport"; public const string CharacterImport = "CharacterImport";
public const string LocationReview = "LocationReview";
public const string LocationImport = "LocationImport";
public const string AssetReview = "AssetReview";
public const string AssetImport = "AssetImport";
public const string RelationshipReview = "RelationshipReview";
public const string RelationshipImport = "RelationshipImport";
public const string Complete = "Complete"; public const string Complete = "Complete";
} }
@ -56,7 +62,9 @@ public sealed class StoryIntelligenceBookPipelineState
public DateTime CreatedUtc { get; init; } public DateTime CreatedUtc { get; init; }
public DateTime UpdatedUtc { get; init; } public DateTime UpdatedUtc { get; init; }
public bool IsComplete => string.Equals(Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase); public bool IsComplete
=> string.Equals(Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase)
&& string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase);
} }
public sealed class StoryIntelligenceBookPipelineSaveRequest public sealed class StoryIntelligenceBookPipelineSaveRequest

View File

@ -208,6 +208,9 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>(); builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>(); builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>();
builder.Services.AddScoped<IStoryIntelligenceCharacterImportService, StoryIntelligenceCharacterImportService>(); builder.Services.AddScoped<IStoryIntelligenceCharacterImportService, StoryIntelligenceCharacterImportService>();
builder.Services.AddScoped<IStoryIntelligenceLocationImportService, StoryIntelligenceLocationImportService>();
builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>(); builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>(); builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>(); builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();

View File

@ -16,6 +16,12 @@ public interface IOnboardingStoryIntelligenceService
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form);
Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId); Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm form);
Task<StoryIntelligenceLocationImportResultViewModel?> GetLocationImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm form);
Task<StoryIntelligenceAssetImportResultViewModel?> GetAssetImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportRelationshipsAsync(Guid batchId, StoryIntelligenceRelationshipImportForm form);
Task<StoryIntelligenceRelationshipImportResultViewModel?> GetRelationshipImportResultAsync(Guid batchId);
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId); Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId); Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
} }
@ -27,6 +33,9 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligenceResultRepository runs, IStoryIntelligenceResultRepository runs,
IStoryIntelligenceImportCommitService commits, IStoryIntelligenceImportCommitService commits,
IStoryIntelligenceCharacterImportService characterImport, IStoryIntelligenceCharacterImportService characterImport,
IStoryIntelligenceLocationImportService locationImport,
IStoryIntelligenceAssetImportService assetImport,
IStoryIntelligenceRelationshipImportService relationshipImport,
IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligencePipelineStateService pipelineState,
IStoryIntelligenceClient client, IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore, IOnboardingStoryIntelligenceBatchStore batchStore,
@ -259,6 +268,9 @@ public sealed class OnboardingStoryIntelligenceService(
} }
var characterReview = await characterImport.BuildReviewAsync(batch); var characterReview = await characterImport.BuildReviewAsync(batch);
var locationReview = await locationImport.BuildReviewAsync(batch);
var assetReview = await assetImport.BuildReviewAsync(batch);
var relationshipReview = await relationshipImport.BuildReviewAsync(batch);
return new StoryIntelligenceProgressViewModel return new StoryIntelligenceProgressViewModel
{ {
BatchID = batch.BatchID, BatchID = batch.BatchID,
@ -269,13 +281,25 @@ public sealed class OnboardingStoryIntelligenceService(
BookTitle = batch.BookTitle, BookTitle = batch.BookTitle,
Chapters = chapters, Chapters = chapters,
CharacterReview = characterReview, CharacterReview = characterReview,
LocationReview = locationReview,
AssetReview = assetReview,
RelationshipReview = relationshipReview,
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
{ {
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete), ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
ScenesImported = chapters.Sum(chapter => chapter.ScenesCreated), ScenesImported = chapters.Sum(chapter => chapter.ScenesCreated),
CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count, CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count,
CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked), CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked),
CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete,
LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count,
LocationsCreatedOrLinked = batch.LocationDecisions.Count(decision => decision.CreatedOrLinked),
LocationStageComplete = batch.LocationStageComplete || locationReview.IsComplete,
AssetsIdentified = assetReview.Candidates.Count + batch.AssetDecisions.Count,
AssetsCreatedOrLinked = batch.AssetDecisions.Count(decision => decision.CreatedOrLinked),
AssetStageComplete = batch.AssetStageComplete || assetReview.IsComplete,
RelationshipsIdentified = relationshipReview.Candidates.Count + batch.RelationshipDecisions.Count,
RelationshipsCreatedOrLinked = batch.RelationshipDecisions.Count(decision => decision.CreatedOrLinked),
RelationshipStageComplete = batch.RelationshipStageComplete || relationshipReview.IsComplete
} }
}; };
} }
@ -370,12 +394,113 @@ public sealed class OnboardingStoryIntelligenceService(
BookID = batch.BookID, BookID = batch.BookID,
CharactersCreated = batch.LastCharacterImportResult?.CharactersCreated ?? 0, CharactersCreated = batch.LastCharacterImportResult?.CharactersCreated ?? 0,
CharactersLinked = batch.LastCharacterImportResult?.CharactersLinked ?? 0, CharactersLinked = batch.LastCharacterImportResult?.CharactersLinked ?? 0,
CharactersAliased = batch.LastCharacterImportResult?.CharactersAliased ?? 0,
CharactersIgnored = batch.LastCharacterImportResult?.CharactersIgnored ?? 0, CharactersIgnored = batch.LastCharacterImportResult?.CharactersIgnored ?? 0,
PovLinksResolved = batch.LastCharacterImportResult?.PovLinksResolved ?? 0, PovLinksResolved = batch.LastCharacterImportResult?.PovLinksResolved ?? 0,
ScenePeoplePanelsUpdated = batch.LastCharacterImportResult?.ScenePeoplePanelsUpdated ?? 0 ScenePeoplePanelsUpdated = batch.LastCharacterImportResult?.ScenePeoplePanelsUpdated ?? 0
}; };
} }
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm 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 locationImport.ImportAsync(batch, form);
return (await GetProgressAsync(batchId), result);
}
public async Task<StoryIntelligenceLocationImportResultViewModel?> GetLocationImportResultAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return null;
}
return new StoryIntelligenceLocationImportResultViewModel
{
BatchID = batch.BatchID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
LocationsCreated = batch.LastLocationImportResult?.LocationsCreated ?? 0,
LocationsLinked = batch.LastLocationImportResult?.LocationsLinked ?? 0,
LocationAliasesAdded = batch.LastLocationImportResult?.LocationAliasesAdded ?? 0,
LocationsIgnored = batch.LastLocationImportResult?.LocationsIgnored ?? 0,
SceneLocationsUpdated = batch.LastLocationImportResult?.SceneLocationsUpdated ?? 0
};
}
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm 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 assetImport.ImportAsync(batch, form);
return (await GetProgressAsync(batchId), result);
}
public async Task<StoryIntelligenceAssetImportResultViewModel?> GetAssetImportResultAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return null;
}
return new StoryIntelligenceAssetImportResultViewModel
{
BatchID = batch.BatchID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
AssetsCreated = batch.LastAssetImportResult?.AssetsCreated ?? 0,
AssetsLinked = batch.LastAssetImportResult?.AssetsLinked ?? 0,
AssetAliasesAdded = batch.LastAssetImportResult?.AssetAliasesAdded ?? 0,
AssetsIgnored = batch.LastAssetImportResult?.AssetsIgnored ?? 0,
SceneAssetEventsCreated = batch.LastAssetImportResult?.SceneAssetEventsCreated ?? 0,
OwnershipLinksCreated = batch.LastAssetImportResult?.OwnershipLinksCreated ?? 0
};
}
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);
@ -395,7 +520,13 @@ public sealed class OnboardingStoryIntelligenceService(
ChaptersCommitted = progress.CommittedChapterCount, ChaptersCommitted = progress.CommittedChapterCount,
ScenesCreated = progress.Chapters.Sum(chapter => chapter.ScenesCreated), ScenesCreated = progress.Chapters.Sum(chapter => chapter.ScenesCreated),
CharactersCreatedOrLinked = progress.PipelineDashboard.CharactersCreatedOrLinked, CharactersCreatedOrLinked = progress.PipelineDashboard.CharactersCreatedOrLinked,
CharacterStageComplete = progress.PipelineDashboard.CharacterStageComplete CharacterStageComplete = progress.PipelineDashboard.CharacterStageComplete,
LocationsCreatedOrLinked = progress.PipelineDashboard.LocationsCreatedOrLinked,
LocationStageComplete = progress.PipelineDashboard.LocationStageComplete,
AssetsCreatedOrLinked = progress.PipelineDashboard.AssetsCreatedOrLinked,
AssetStageComplete = progress.PipelineDashboard.AssetStageComplete,
RelationshipsCreatedOrLinked = progress.PipelineDashboard.RelationshipsCreatedOrLinked,
RelationshipStageComplete = progress.PipelineDashboard.RelationshipStageComplete
}; };
} }
@ -437,16 +568,45 @@ public sealed class OnboardingStoryIntelligenceService(
.ToList() .ToList()
}; };
if (state.IsComplete) var characterStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.CharacterImport)
{ || state.CurrentStage is StoryIntelligencePipelineStages.LocationReview
batch.CharacterStageComplete = true; or StoryIntelligencePipelineStages.LocationImport
} or StoryIntelligencePipelineStages.AssetReview
or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview
or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.Complete;
var locationStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.LocationImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview
or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview
or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.Complete;
var assetStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.AssetImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview
or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.Complete;
var relationshipStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.RelationshipImport);
batch.CharacterStageComplete = characterStageComplete;
batch.LocationStageComplete = locationStageComplete;
batch.AssetStageComplete = assetStageComplete;
batch.RelationshipStageComplete = relationshipStageComplete;
await batchStore.SaveAsync(batch); await batchStore.SaveAsync(batch);
var route = state.CurrentStage switch var route = relationshipStageComplete
? StoryIntelligenceResumeRoutes.Complete
: assetStageComplete
? StoryIntelligenceResumeRoutes.Relationships
: locationStageComplete
? StoryIntelligenceResumeRoutes.Assets
: characterStageComplete
? StoryIntelligenceResumeRoutes.Locations
: state.CurrentStage switch
{ {
StoryIntelligencePipelineStages.Complete => StoryIntelligenceResumeRoutes.Complete, 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,
@ -457,6 +617,9 @@ public sealed class OnboardingStoryIntelligenceService(
return new OnboardingStoryIntelligenceResumeTarget(batch.BatchID, route); return new OnboardingStoryIntelligenceResumeTarget(batch.BatchID, route);
} }
private static bool HasCompletedStage(StoryIntelligenceBookPipelineState state, string stage)
=> string.Equals(state.LastCompletedStage, stage, StringComparison.OrdinalIgnoreCase);
private async Task<(UserOnboardingState State, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, OnboardingManuscriptBuildResult? Build, OnboardingWizardViewModel Wizard)?> GetContextAsync(int userId) private async Task<(UserOnboardingState State, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, OnboardingManuscriptBuildResult? Build, OnboardingWizardViewModel Wizard)?> GetContextAsync(int userId)
{ {
var state = await onboardingRepository.GetAsync(userId); var state = await onboardingRepository.GetAsync(userId);
@ -685,8 +848,17 @@ public sealed class OnboardingStoryIntelligenceBatch
public DateTime CreatedUtc { get; init; } = DateTime.UtcNow; public DateTime CreatedUtc { get; init; } = DateTime.UtcNow;
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = []; public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = []; public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
public List<OnboardingStoryIntelligenceLocationDecision> LocationDecisions { get; } = [];
public List<OnboardingStoryIntelligenceAssetDecision> AssetDecisions { get; } = [];
public List<OnboardingStoryIntelligenceRelationshipDecision> RelationshipDecisions { get; } = [];
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; } public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; }
public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; }
public StoryIntelligenceRelationshipImportBatchResult? LastRelationshipImportResult { get; set; }
public bool CharacterStageComplete { get; set; } public bool CharacterStageComplete { get; set; }
public bool LocationStageComplete { get; set; }
public bool AssetStageComplete { get; set; }
public bool RelationshipStageComplete { get; set; }
} }
public sealed class OnboardingStoryIntelligenceBatchItem public sealed class OnboardingStoryIntelligenceBatchItem
@ -707,19 +879,78 @@ public sealed class OnboardingStoryIntelligenceCharacterDecision
public bool CreatedOrLinked { get; init; } public bool CreatedOrLinked { get; init; }
} }
public sealed class OnboardingStoryIntelligenceLocationDecision
{
public string Key { get; init; } = string.Empty;
public string Action { get; init; } = string.Empty;
public string? LocationName { get; init; }
public int? LocationID { get; init; }
public bool CreatedOrLinked { get; init; }
}
public sealed class OnboardingStoryIntelligenceAssetDecision
{
public string Key { get; init; } = string.Empty;
public string Action { get; init; } = string.Empty;
public string? AssetName { get; init; }
public int? StoryAssetID { 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; }
public int CharactersLinked { get; init; } public int CharactersLinked { get; init; }
public int CharactersAliased { get; init; }
public int CharactersIgnored { get; init; } public int CharactersIgnored { get; init; }
public int PovLinksResolved { get; init; } public int PovLinksResolved { get; init; }
public int ScenePeoplePanelsUpdated { get; init; } public int ScenePeoplePanelsUpdated { get; init; }
} }
public sealed class StoryIntelligenceLocationImportBatchResult
{
public int LocationsCreated { get; init; }
public int LocationsLinked { get; init; }
public int LocationAliasesAdded { get; init; }
public int LocationsIgnored { get; init; }
public int SceneLocationsUpdated { get; init; }
}
public sealed class StoryIntelligenceAssetImportBatchResult
{
public int AssetsCreated { get; init; }
public int AssetsLinked { get; init; }
public int AssetAliasesAdded { get; init; }
public int AssetsIgnored { get; init; }
public int SceneAssetEventsCreated { 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 Assets = "Assets";
public const string Relationships = "Relationships";
public const string Complete = "Complete"; public const string Complete = "Complete";
} }

View File

@ -0,0 +1,843 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceAssetImportService
{
Task<StoryIntelligenceAssetReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form);
}
public sealed class StoryIntelligenceAssetImportService(
IStoryIntelligenceResultRepository runs,
IAssetRepository assets,
ICharacterRepository characters,
ISceneRepository scenes,
IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceAssetImportService> logger) : IStoryIntelligenceAssetImportService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private static readonly HashSet<string> GenericObjects = CreateSet(
"chair", "chairs", "table", "tables", "wall", "walls", "floor", "floors", "window", "windows",
"door", "doors", "tree", "trees", "grass", "lamp", "lamps", "cup", "cups", "plate", "plates",
"knife and fork", "people", "person", "building", "car park", "street", "road", "room", "desk",
"bed", "sofa", "sink", "towel", "mug", "glass", "bottle", "phone", "telephone", "key", "keys",
"car", "vehicle", "coat", "dress", "shoes", "shirt", "trousers", "weather", "rain", "food", "drink");
private static readonly HashSet<string> AcceptedAssetNames = CreateSet(
"tr6", "passport", "photograph", "photo", "key", "keys", "car keys", "letter", "envelope", "note",
"notebook", "red notebook", "old notebook", "knife", "gun", "suitcase", "rucksack", "wedding ring",
"ring", "necklace", "cassette tape", "camera", "map", "diary", "watch", "driving licence",
"driving license", "licence", "license", "document", "folder", "wallet", "bank book", "scrap of paper");
private static readonly HashSet<string> StorySignals = CreateSet(
"lost", "found", "hidden", "stolen", "missing", "taken", "given", "carried", "used", "opened",
"revealed", "evidence", "clue", "important", "sought", "passport", "licence", "license", "letter",
"note", "photograph", "photo", "map", "diary", "notebook", "keys", "necklace", "ring");
private static readonly HashSet<string> AdjectivesToFold = CreateSet(
"red", "old", "new", "small", "large", "little", "anonymous", "folded", "sealed", "missing");
public async Task<StoryIntelligenceAssetReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{
var data = await BuildCandidateDataAsync(batch);
var decidedKeys = batch.AssetDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
return new StoryIntelligenceAssetReviewViewModel
{
HasCommittedScenes = data.HasCommittedScenes,
CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceAssetReviewCandidateViewModel
{
Key = candidate.Key,
AssetName = candidate.DisplayName,
ImportName = candidate.DisplayName,
Category = candidate.Category,
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence),
PossibleOwner = candidate.OwnerOrHolder,
ExampleFirstAppearance = candidate.FirstAppearanceNote,
ExampleContext = candidate.Description,
PossibleAliases = candidate.Aliases.OrderBy(value => value).ToList(),
ExistingAssetID = candidate.ExistingAssetID,
ExistingAssetName = candidate.ExistingAssetName
}).ToList()
};
}
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form)
{
var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing assets." };
}
var choices = form.Assets
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
if (choices.Count == 0)
{
if (data.Candidates.Count == 0)
{
batch.AssetStageComplete = true;
batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult();
await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID);
return new StoryIntelligenceImportCommitResult
{
Success = true,
Message = "Asset review completed. No asset decisions were waiting."
};
}
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one asset to create, link or ignore." };
}
var existingIndex = await BuildAssetIndexAsync(batch.ProjectID);
var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID);
var lookupData = await assets.GetLookupsAsync(batch.ProjectID);
var created = 0;
var linkedExisting = 0;
var ignored = 0;
var aliasesAdded = 0;
var sceneEventsCreated = 0;
var ownershipLinksCreated = 0;
var resolvedCandidateIds = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var candidateByKey = 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, StoryIntelligenceAssetImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
ignored++;
AddDecision(batch, candidate.Key, StoryIntelligenceAssetImportActions.Ignore, candidate.DisplayName, null, false);
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var requestedName = Clean(choice.ImportName);
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
var linkExisting = string.Equals(choice.Action, StoryIntelligenceAssetImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
var matchedExistingId = linkExisting
? candidate.ExistingAssetID
?? existingIndex.Find(importName)?.StoryAssetID
?? existingIndex.Find(candidate.DisplayName)?.StoryAssetID
: null;
var assetId = matchedExistingId;
if (!assetId.HasValue)
{
assetId = await assets.SaveAssetAsync(new StoryAsset
{
ProjectID = batch.ProjectID,
AssetName = importName,
AssetKindID = MatchAssetKind(lookupData.AssetKinds, candidate),
Description = BuildAssetDescription(candidate),
Importance = 5,
CurrentStateID = MatchAssetState(lookupData.AssetStates, "Known To Exist")
});
created++;
}
else
{
linkedExisting++;
}
resolvedCandidateIds[candidate.Key] = assetId.Value;
AddDecision(batch, candidate.Key, choice.Action, importName, assetId.Value, true);
foreach (var alias in candidate.Aliases)
{
if (await TryAddAliasAsync(assetId.Value, alias, importName))
{
aliasesAdded++;
}
}
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| !string.Equals(choice.Action, StoryIntelligenceAssetImportActions.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, StoryIntelligenceAssetImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| string.Equals(targetChoice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate)
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetAssetId))
{
logger.LogWarning(
"Story Intelligence asset alias candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
candidate.Key,
targetKey);
continue;
}
var targetName = targetCandidate.ExistingAssetName ?? targetChoice.ImportName ?? targetCandidate.DisplayName;
if (await TryAddAliasAsync(targetAssetId, candidate.DisplayName, targetName))
{
aliasesAdded++;
}
foreach (var alias in candidate.Aliases)
{
if (await TryAddAliasAsync(targetAssetId, alias, targetName))
{
aliasesAdded++;
}
}
resolvedCandidateIds[candidate.Key] = targetAssetId;
AddDecision(batch, candidate.Key, StoryIntelligenceAssetImportActions.Alias, candidate.DisplayName, targetAssetId, true);
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var assetId = string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase)
? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey))
: resolvedCandidateIds.GetValueOrDefault(candidate.Key);
if (assetId <= 0)
{
continue;
}
foreach (var appearance in candidate.Appearances
.Where(appearance => !appearance.AlreadyLinked)
.GroupBy(appearance => appearance.SceneID)
.Select(group => group.OrderByDescending(appearance => appearance.Confidence ?? 0m).First()))
{
await assets.SaveEventAsync(new AssetEvent
{
StoryAssetID = assetId,
SceneID = appearance.SceneID,
AssetEventTypeID = MatchAssetEventType(lookupData.AssetEventTypes, appearance.EventType),
ToStateID = MatchAssetState(lookupData.AssetStates, StateFromEventType(appearance.EventType)),
EventTitle = appearance.EventType,
EventDescription = appearance.Notes
});
sceneEventsCreated++;
var owner = Clean(appearance.OwnerOrHolder ?? candidate.OwnerOrHolder);
if (!string.IsNullOrWhiteSpace(owner)
&& characterIndex.Find(owner) is CharacterMatch ownerMatch
&& lookupData.AssetCustodyEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Has", StringComparison.OrdinalIgnoreCase)) is { } custodyType
&& lookupData.CustodyRoles.FirstOrDefault(role => string.Equals(role.RoleName, "Has Custody", StringComparison.OrdinalIgnoreCase)) is { } role)
{
await assets.SaveCustodyEventAsync(
new AssetCustodyEvent
{
StoryAssetID = assetId,
SceneID = appearance.SceneID,
AssetCustodyEventTypeID = custodyType.AssetCustodyEventTypeID,
Description = $"Initial owner/holder from Story Intelligence: {owner}"
},
owner,
[ownerMatch.CharacterID],
role.CustodyRoleID);
ownershipLinksCreated++;
}
}
}
batch.AssetStageComplete = true;
batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult
{
AssetsCreated = created,
AssetsLinked = linkedExisting,
AssetAliasesAdded = aliasesAdded,
AssetsIgnored = ignored,
SceneAssetEventsCreated = sceneEventsCreated,
OwnershipLinksCreated = ownershipLinksCreated
};
await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID);
logger.LogInformation(
"Imported Story Intelligence assets for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneEvents={SceneEvents} Ownership={Ownership}",
batch.BatchID,
created,
linkedExisting,
aliasesAdded,
ignored,
sceneEventsCreated,
ownershipLinksCreated);
return new StoryIntelligenceImportCommitResult
{
Success = true,
ScenesCreated = sceneEventsCreated,
Message = $"Assets imported. {created:N0} created, {linkedExisting:N0} linked, {aliasesAdded:N0} alias/variant(s) added, {ignored:N0} ignored, {sceneEventsCreated:N0} scene asset event(s) created."
};
}
private async Task<AssetCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
{
var existingIndex = await BuildAssetIndexAsync(batch.ProjectID);
var groups = new Dictionary<string, AssetCandidate>(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 assets.ListEventsBySceneAsync(importedScene.SceneID);
alreadyLinked += existingEvents.Select(assetEvent => assetEvent.StoryAssetID).Distinct().Count();
AddSceneAssets(groups, existingIndex, importedScene, parsed, existingEvents);
}
}
var candidates = groups.Values
.Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
.Where(IsVisibleAssetCandidate)
.OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count())
.ThenBy(candidate => candidate.DisplayName)
.ToList();
return new AssetCandidateData(hasCommittedScenes, alreadyLinked, candidates);
}
private static void AddSceneAssets(
Dictionary<string, AssetCandidate> groups,
AssetIndex existingIndex,
Scene importedScene,
SceneIntelligenceScene parsed,
IReadOnlyList<AssetEvent> existingEvents)
{
foreach (var asset in parsed.Assets ?? [])
{
AddAsset(groups, existingIndex, importedScene, parsed, asset, existingEvents);
}
foreach (var observation in parsed.Observations ?? [])
{
if (IsAssetEntity(observation.SubjectEntityType))
{
AddAsset(groups, existingIndex, importedScene, parsed, new SceneIntelligenceAsset
{
Name = observation.SubjectName,
AssetType = observation.ObservationType,
Status = observation.Predicate,
Confidence = observation.Confidence,
Notes = observation.Description
}, existingEvents);
}
if (IsAssetEntity(observation.ObjectEntityType))
{
AddAsset(groups, existingIndex, importedScene, parsed, new SceneIntelligenceAsset
{
Name = observation.ObjectName,
AssetType = observation.ObservationType,
Status = observation.Predicate,
MentionedOnly = true,
Confidence = observation.Confidence,
Notes = observation.Description
}, existingEvents);
}
}
}
private static void AddAsset(
Dictionary<string, AssetCandidate> groups,
AssetIndex existingIndex,
Scene importedScene,
SceneIntelligenceScene parsed,
SceneIntelligenceAsset asset,
IReadOnlyList<AssetEvent> existingEvents)
{
var cleanName = CleanAssetName(asset.Name);
if (!IsAssetNameCandidate(cleanName))
{
return;
}
var key = ResolveCandidateKey(groups, existingIndex, cleanName);
if (!groups.TryGetValue(key, out var candidate))
{
var match = existingIndex.Find(cleanName);
candidate = new AssetCandidate(key, DisplayAssetName(cleanName), match?.StoryAssetID, match?.AssetName)
{
Category = Categorise(cleanName, asset.AssetType)
};
groups[key] = candidate;
}
else if (!string.Equals(candidate.DisplayName, cleanName, StringComparison.OrdinalIgnoreCase))
{
candidate.Aliases.Add(cleanName);
}
candidate.Confidence = Max(candidate.Confidence, asset.Confidence);
candidate.Description ??= Clean(asset.Notes);
candidate.OwnerOrHolder ??= Clean(asset.OwnerOrHolder);
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, cleanName);
candidate.Appearances.Add(new AssetAppearanceImport(
importedScene.SceneID,
importedScene.SceneNumber,
importedScene.SceneTitle,
EventTypeFromAsset(asset),
asset.OwnerOrHolder,
asset.Notes,
asset.Confidence,
candidate.ExistingAssetID.HasValue && existingEvents.Any(existing => existing.StoryAssetID == candidate.ExistingAssetID.Value)));
}
private async Task<AssetIndex> BuildAssetIndexAsync(int projectId)
{
var index = new AssetIndex();
foreach (var asset in await assets.ListAssetsAsync(projectId))
{
index.Add(asset.AssetName, asset.StoryAssetID, asset.AssetName);
foreach (var alias in await assets.ListAliasesAsync(asset.StoryAssetID))
{
index.Add(alias.Alias, asset.StoryAssetID, asset.AssetName);
}
}
return index;
}
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 async Task<bool> TryAddAliasAsync(int storyAssetId, string alias, string primaryName)
{
var cleanAlias = Clean(alias);
if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, primaryName, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var existingAliases = await assets.ListAliasesAsync(storyAssetId);
if (existingAliases.Any(item => string.Equals(Clean(item.Alias), cleanAlias, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
await assets.AddAliasAsync(storyAssetId, cleanAlias, null);
return true;
}
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 int MatchAssetKind(IReadOnlyList<AssetKind> kinds, AssetCandidate candidate)
=> kinds.FirstOrDefault(kind =>
candidate.Category.Contains(kind.KindName, StringComparison.OrdinalIgnoreCase)
|| kind.KindName.Contains(candidate.Category, StringComparison.OrdinalIgnoreCase))?.AssetKindID
?? kinds.FirstOrDefault(kind => string.Equals(kind.KindName, "Physical Object", StringComparison.OrdinalIgnoreCase))?.AssetKindID
?? kinds.First().AssetKindID;
private static int MatchAssetEventType(IReadOnlyList<AssetEventType> eventTypes, string eventType)
=> eventTypes.FirstOrDefault(type => string.Equals(type.TypeName, eventType, StringComparison.OrdinalIgnoreCase))?.AssetEventTypeID
?? eventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Mentioned", StringComparison.OrdinalIgnoreCase))?.AssetEventTypeID
?? eventTypes.First().AssetEventTypeID;
private static int? MatchAssetState(IReadOnlyList<AssetState> states, string? stateName)
=> string.IsNullOrWhiteSpace(stateName)
? null
: states.FirstOrDefault(state => string.Equals(state.StateName, stateName, StringComparison.OrdinalIgnoreCase))?.AssetStateID;
private static string StateFromEventType(string eventType)
=> eventType switch
{
"First Mentioned" => "Known To Exist",
"Mentioned" => "Mentioned",
"Found" => "Found",
"Given" => "Possessed",
"Taken" => "Possessed",
"Lost" => "Lost",
"Hidden" => "Hidden",
"Used" => "Used",
"Opened" => "Opened",
"Revealed" => "Revealed",
"Investigated" => "Investigated",
_ => "Known To Exist"
};
private static string BuildAssetDescription(AssetCandidate candidate)
{
var lines = new List<string>();
if (!string.IsNullOrWhiteSpace(candidate.OwnerOrHolder))
{
lines.Add($"Possible owner/holder from Story Intelligence: {candidate.OwnerOrHolder}");
}
if (!string.IsNullOrWhiteSpace(candidate.Description))
{
lines.Add(candidate.Description);
}
return string.Join(Environment.NewLine, lines);
}
private static bool IsVisibleAssetCandidate(AssetCandidate candidate)
{
if (IsAcceptedStoryAssetName(candidate.DisplayName))
{
return true;
}
var sceneCount = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count();
var signalText = $"{candidate.DisplayName} {candidate.Category} {candidate.Description} {candidate.OwnerOrHolder} {string.Join(' ', candidate.Appearances.Select(a => a.Notes))}";
return sceneCount >= 2 && ContainsStoryAssetSignal(signalText);
}
private static bool IsAssetNameCandidate(string? name)
{
var clean = CleanAssetName(name);
if (string.IsNullOrWhiteSpace(clean) || clean.Length < 2 || !clean.Any(char.IsLetter))
{
return false;
}
if (IsAcceptedStoryAssetName(clean))
{
return true;
}
var normalised = Normalise(clean);
return !GenericObjects.Contains(normalised) && !GenericObjects.Contains(RemoveLeadingArticle(normalised));
}
private static bool IsAcceptedStoryAssetName(string? name)
{
var normalised = Normalise(CleanAssetName(name));
var withoutArticle = RemoveLeadingArticle(normalised);
return AcceptedAssetNames.Contains(normalised)
|| AcceptedAssetNames.Contains(withoutArticle)
|| normalised.Contains("tr6", StringComparison.OrdinalIgnoreCase)
|| normalised.Contains("'s car", StringComparison.OrdinalIgnoreCase)
|| normalised.Contains("s car", StringComparison.OrdinalIgnoreCase);
}
private static bool ContainsStoryAssetSignal(string value)
=> StorySignals.Any(signal => value.Contains(signal, StringComparison.OrdinalIgnoreCase));
private static string EventTypeFromAsset(SceneIntelligenceAsset asset)
{
var value = $"{asset.Status} {asset.Notes}".ToLowerInvariant();
if (value.Contains("found")) return "Found";
if (value.Contains("lost")) return "Lost";
if (value.Contains("given") || value.Contains("gives")) return "Given";
if (value.Contains("taken") || value.Contains("takes")) return "Taken";
if (value.Contains("stolen")) return "Stolen";
if (value.Contains("hidden") || value.Contains("hides")) return "Hidden";
if (value.Contains("opened")) return "Opened";
if (value.Contains("used") || value.Contains("uses")) return "Used";
if (value.Contains("revealed") || value.Contains("reveals")) return "Revealed";
if (value.Contains("investigated")) return "Investigated";
return asset.MentionedOnly == true ? "Mentioned" : "First Mentioned";
}
private static string Categorise(string name, string? assetType)
{
var value = $"{name} {assetType}".ToLowerInvariant();
if (value.Contains("passport") || value.Contains("licence") || value.Contains("license") || value.Contains("letter") || value.Contains("note") || value.Contains("map") || value.Contains("diary") || value.Contains("notebook") || value.Contains("document"))
{
return "Document";
}
if (value.Contains("photo") || value.Contains("evidence") || value.Contains("clue"))
{
return "Evidence";
}
if (value.Contains("car") || value.Contains("tr6") || value.Contains("vehicle"))
{
return "Vehicle";
}
if (value.Contains("necklace") || value.Contains("ring") || value.Contains("watch"))
{
return "Physical Object";
}
return string.IsNullOrWhiteSpace(assetType) ? "Physical Object" : Clean(assetType);
}
private static string ResolveCandidateKey(
IReadOnlyDictionary<string, AssetCandidate> groups,
AssetIndex existingIndex,
string name)
{
var existingMatch = existingIndex.Find(name);
if (existingMatch is not null)
{
return CanonicalAssetKey(existingMatch.AssetName);
}
var canonical = CanonicalAssetKey(name);
return groups.ContainsKey(canonical) ? canonical : canonical;
}
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string assetName)
{
var summary = Clean(parsed.Summary?.Short);
return string.IsNullOrWhiteSpace(summary)
? $"Scene {scene.SceneNumber:g}: {assetName}"
: $"Scene {scene.SceneNumber:g}: {summary}";
}
private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, string? assetName, int? storyAssetId, bool createdOrLinked)
{
batch.AssetDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase));
batch.AssetDecisions.Add(new OnboardingStoryIntelligenceAssetDecision
{
Key = key,
Action = action,
AssetName = assetName,
StoryAssetID = storyAssetId,
CreatedOrLinked = createdOrLinked
});
}
private static bool IsAssetEntity(string? entityType)
=> string.Equals(Clean(entityType), "Asset", StringComparison.OrdinalIgnoreCase)
|| string.Equals(Clean(entityType), "Object", StringComparison.OrdinalIgnoreCase);
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 DisplayAssetName(string name)
{
var clean = CleanAssetName(name);
return string.IsNullOrWhiteSpace(clean) ? clean : char.ToUpperInvariant(clean[0]) + clean[1..];
}
private static string CanonicalAssetKey(string name)
{
var clean = RemoveLeadingArticle(Normalise(CleanAssetName(name)));
var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
while (words.Count > 1 && AdjectivesToFold.Contains(words[0]))
{
words.RemoveAt(0);
}
if (words.Count == 2 && words[1].Equals("keys", StringComparison.OrdinalIgnoreCase))
{
return "keys";
}
if (clean is "photo")
{
return "photograph";
}
if (clean is "driving license")
{
return "driving licence";
}
return string.Join(' ', words);
}
private static string CleanAssetName(string? value)
=> Clean(value).Trim(' ', '.', ',', ';', ':', '!', '?', '"', '\'');
private static string RemoveLeadingArticle(string value)
=> value.StartsWith("the ", StringComparison.OrdinalIgnoreCase)
? value[4..]
: value.StartsWith("a ", StringComparison.OrdinalIgnoreCase)
? value[2..]
: value.StartsWith("an ", StringComparison.OrdinalIgnoreCase)
? value[3..]
: value;
private static string Normalise(string? value)
=> Clean(value).ToLowerInvariant();
private static string Clean(string? value)
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
private static HashSet<string> CreateSet(params string[] values)
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);
private sealed class AssetIndex
{
private readonly Dictionary<string, AssetMatch> byName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int storyAssetId, string assetName)
{
var key = CanonicalAssetKey(name ?? string.Empty);
if (!string.IsNullOrWhiteSpace(key))
{
byName.TryAdd(key, new AssetMatch(storyAssetId, assetName));
}
}
public AssetMatch? Find(string name)
=> byName.TryGetValue(CanonicalAssetKey(name), out var match) ? match : null;
}
private sealed class CharacterIndex
{
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int characterId, string characterName)
{
var key = Normalise(name);
if (!string.IsNullOrWhiteSpace(key))
{
byName.TryAdd(key, new CharacterMatch(characterId, characterName));
}
}
public CharacterMatch? Find(string name)
=> byName.TryGetValue(Normalise(name), out var match) ? match : null;
}
private sealed record AssetMatch(int StoryAssetID, string AssetName);
private sealed record CharacterMatch(int CharacterID, string CharacterName);
private sealed class AssetCandidate(string key, string displayName, int? existingAssetId, string? existingAssetName)
{
public string Key { get; } = key;
public string DisplayName { get; } = displayName;
public int? ExistingAssetID { get; } = existingAssetId;
public string? ExistingAssetName { get; } = existingAssetName;
public string Category { get; set; } = "Physical Object";
public string? OwnerOrHolder { get; set; }
public HashSet<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<AssetAppearanceImport> Appearances { get; } = [];
public decimal? Confidence { get; set; }
public string? Description { get; set; }
public string? FirstAppearanceNote { get; set; }
}
private sealed record AssetAppearanceImport(
int SceneID,
decimal SceneNumber,
string SceneTitle,
string EventType,
string? OwnerOrHolder,
string? Notes,
decimal? Confidence,
bool AlreadyLinked);
private sealed record AssetCandidateData(
bool HasCommittedScenes,
int AlreadyLinkedCount,
IReadOnlyList<AssetCandidate> Candidates);
}

View File

@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using PlotLine.Data; using PlotLine.Data;
using PlotLine.Models; using PlotLine.Models;
using PlotLine.ViewModels; using PlotLine.ViewModels;
@ -31,11 +32,38 @@ public sealed class StoryIntelligenceCharacterImportService(
private static readonly HashSet<string> GenericPeople = CreateSet( private static readonly HashSet<string> GenericPeople = CreateSet(
"man", "woman", "boy", "girl", "child", "children", "person", "people", "doctor", "nurse", "man", "woman", "boy", "girl", "child", "children", "person", "people", "doctor", "nurse",
"police officer", "driver", "receptionist", "attendant", "guard", "security guard", "waiter", "waitress", "police officer", "driver", "receptionist", "attendant", "guard", "security guard", "waiter", "waitress",
"clerk", "shopkeeper", "cashier", "crowd", "family", "group", "small group", "police"); "clerk", "shopkeeper", "cashier", "crowd", "family", "group", "small group", "police", "officer",
"officers", "staff", "customers", "boys", "girls", "men", "women", "neighbour", "neighbours",
"passenger", "passengers", "examiner", "examiners", "instructor", "instructors", "candidate", "candidates",
"social worker", "social workers", "responders");
private static readonly HashSet<string> GenericGroupHeads = CreateSet(
"man", "men", "woman", "women", "boy", "boys", "girl", "girls", "child", "children", "person", "people",
"group", "crowd", "family", "neighbour", "neighbours", "passenger", "passengers", "examiner", "examiners",
"driver", "drivers", "instructor", "instructors", "candidate", "candidates", "officer", "officers", "staff", "customers",
"responders", "worker", "workers", "lad", "lads");
private static readonly HashSet<string> QuantityWords = CreateSet(
"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "several", "some",
"many", "few", "couple", "various", "other", "groups");
private static readonly HashSet<string> DeterminerWords = CreateSet(
"the", "a", "an", "my", "your", "his", "her", "our", "their");
private static readonly HashSet<string> FamilyRoleNames = CreateSet(
"aunt", "uncle", "mother", "father", "mum", "mom", "mam", "dad", "brother", "sister", "grandmother",
"grandfather", "grandma", "grandad", "granddad");
private static readonly HashSet<string> CharacterTitlePrefixes = CreateSet(
"mr", "mrs", "miss", "ms", "dr", "reverend", "rev", "detective", "sergeant", "ds", "aunt", "uncle");
private static readonly HashSet<string> NonCharacterNames = CreateSet( private static readonly HashSet<string> NonCharacterNames = CreateSet(
"christmas", "house", "street", "door", "window", "car", "church", "building", "lobby", "room", "christmas", "house", "street", "door", "window", "car", "church", "building", "lobby", "room",
"hallway", "kitchen", "garage", "pavement", "road", "desk", "table", "phone", "letter", "note"); "hallway", "kitchen", "garage", "pavement", "road", "desk", "table", "phone", "letter", "note",
"social services", "under-flyover / link road");
private static readonly Regex ParentheticalSuffix = new(@"\s*\([^)]*\)\s*$", RegexOptions.Compiled);
private static readonly Regex LeadingQuantity = new(@"^\s*(\d+|one|two|three|four|five|six|seven|eight|nine|ten|several|some|many|few|couple|various|other)\s+", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public async Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) public async Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{ {
@ -86,9 +114,12 @@ public sealed class StoryIntelligenceCharacterImportService(
var created = 0; var created = 0;
var linkedExisting = 0; var linkedExisting = 0;
var ignored = 0; var ignored = 0;
var aliased = 0;
var linkedAppearances = 0; var linkedAppearances = 0;
var povLinks = 0; var povLinks = 0;
var resolvedCharacters = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); var resolvedCharacters = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var resolvedCandidateIds = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var candidateByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase);
foreach (var candidate in data.Candidates) foreach (var candidate in data.Candidates)
{ {
@ -104,14 +135,19 @@ public sealed class StoryIntelligenceCharacterImportService(
continue; continue;
} }
if (string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var requestedName = Clean(choice.ImportName); var requestedName = Clean(choice.ImportName);
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName; var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
var forceCreateSeparate = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.CreateSeparate, StringComparison.OrdinalIgnoreCase); var linkExisting = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
var matchedExistingId = forceCreateSeparate var matchedExistingId = linkExisting
? null ? candidate.ExistingCharacterID
: candidate.ExistingCharacterID
?? existingIndex.Find(importName)?.CharacterID ?? existingIndex.Find(importName)?.CharacterID
?? existingIndex.Find(candidate.DisplayName)?.CharacterID; ?? existingIndex.Find(candidate.DisplayName)?.CharacterID
: null;
var characterId = matchedExistingId; var characterId = matchedExistingId;
if (!characterId.HasValue) if (!characterId.HasValue)
@ -151,9 +187,60 @@ public sealed class StoryIntelligenceCharacterImportService(
linkedAppearances++; linkedAppearances++;
} }
resolvedCandidateIds[candidate.Key] = characterId.Value;
AddDecision(batch, candidate.Key, choice.Action, importName, characterId.Value, true); AddDecision(batch, candidate.Key, choice.Action, importName, characterId.Value, true);
} }
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| !string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.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, StoryIntelligenceCharacterImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate)
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetCharacterId))
{
logger.LogWarning(
"Story Intelligence character alias candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
candidate.Key,
targetKey);
continue;
}
var targetName = targetCandidate.ExistingCharacterName ?? targetChoice.ImportName ?? targetCandidate.DisplayName;
await TryAddAliasAsync(targetCharacterId, candidate.DisplayName, targetName);
AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId);
foreach (var alias in candidate.Aliases)
{
await TryAddAliasAsync(targetCharacterId, alias, targetName);
AddResolvedName(resolvedCharacters, alias, targetCharacterId);
}
foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked))
{
await characters.SaveSceneCharacterAsync(new SceneCharacter
{
SceneID = appearance.SceneID,
CharacterID = targetCharacterId,
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetCandidate.DisplayName, appearance.Notes)
});
linkedAppearances++;
}
aliased++;
AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Alias, candidate.DisplayName, targetCharacterId, true);
}
foreach (var scene in data.SceneAnalyses) foreach (var scene in data.SceneAnalyses)
{ {
var povName = Clean(scene.Parsed.PointOfView?.CharacterName); var povName = Clean(scene.Parsed.PointOfView?.CharacterName);
@ -172,16 +259,18 @@ public sealed class StoryIntelligenceCharacterImportService(
CharactersCreated = created, CharactersCreated = created,
CharactersLinked = linkedExisting, CharactersLinked = linkedExisting,
CharactersIgnored = ignored, CharactersIgnored = ignored,
CharactersAliased = aliased,
PovLinksResolved = povLinks, PovLinksResolved = povLinks,
ScenePeoplePanelsUpdated = linkedAppearances ScenePeoplePanelsUpdated = linkedAppearances
}; };
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID); await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
logger.LogInformation( logger.LogInformation(
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}", "Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}",
batch.BatchID, batch.BatchID,
created, created,
linkedExisting, linkedExisting,
aliased,
ignored, ignored,
linkedAppearances, linkedAppearances,
povLinks); povLinks);
@ -190,7 +279,7 @@ public sealed class StoryIntelligenceCharacterImportService(
{ {
Success = true, Success = true,
ScenesCreated = linkedAppearances, ScenesCreated = linkedAppearances,
Message = $"Characters imported. {created:N0} created, {linkedExisting:N0} linked, {ignored:N0} ignored, {linkedAppearances:N0} scene appearance(s) updated, {povLinks:N0} POV link(s) resolved." Message = $"Characters imported. {created:N0} created, {linkedExisting:N0} linked, {aliased:N0} alias decision(s) applied, {ignored:N0} ignored, {linkedAppearances:N0} scene appearance(s) updated, {povLinks:N0} POV link(s) resolved."
}; };
} }
@ -258,6 +347,7 @@ public sealed class StoryIntelligenceCharacterImportService(
var candidates = groups.Values var candidates = groups.Values
.Where(candidate => candidate.PresentAppearances.Count > 0 || candidate.MentionedOnlyAppearances.Count > 0) .Where(candidate => candidate.PresentAppearances.Count > 0 || candidate.MentionedOnlyAppearances.Count > 0)
.Where(candidate => candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Any(appearance => !appearance.AlreadyLinked)) .Where(candidate => candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Any(appearance => !appearance.AlreadyLinked))
.Where(IsVisibleCharacterCandidate)
.OrderByDescending(candidate => candidate.PresentAppearances.Select(appearance => appearance.SceneID).Distinct().Count()) .OrderByDescending(candidate => candidate.PresentAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
.ThenByDescending(candidate => candidate.MentionedOnlyAppearances.Select(appearance => appearance.SceneID).Distinct().Count()) .ThenByDescending(candidate => candidate.MentionedOnlyAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
.ThenBy(candidate => candidate.DisplayName) .ThenBy(candidate => candidate.DisplayName)
@ -515,6 +605,12 @@ public sealed class StoryIntelligenceCharacterImportService(
} }
} }
private static string? BuildAliasAppearanceNote(string aliasName, string targetName, string? notes)
{
var prefix = $"Imported as alias '{aliasName}' of {targetName}.";
return string.IsNullOrWhiteSpace(notes) ? prefix : $"{prefix} {notes}";
}
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, SceneIntelligenceCharacter character) private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, SceneIntelligenceCharacter character)
{ {
var note = Clean(character.Notes); var note = Clean(character.Notes);
@ -551,6 +647,30 @@ public sealed class StoryIntelligenceCharacterImportService(
private static bool IsCharacterEntity(string? entityType) private static bool IsCharacterEntity(string? entityType)
=> string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase); => string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);
private static bool IsVisibleCharacterCandidate(CharacterCandidate candidate)
{
var name = CleanCharacterName(candidate.DisplayName);
if (!IsCharacterNameCandidate(name))
{
return false;
}
if (IsNamedOrTitledPerson(name))
{
return true;
}
if (IsFamilyRoleOnly(name))
{
return candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances)
.Select(appearance => appearance.SceneID)
.Distinct()
.Count() >= 2;
}
return !IsGenericGroupReference(name);
}
private static bool IsCharacterNameCandidate(string? name) private static bool IsCharacterNameCandidate(string? name)
{ {
var clean = CleanCharacterName(name); var clean = CleanCharacterName(name);
@ -570,6 +690,80 @@ public sealed class StoryIntelligenceCharacterImportService(
return true; return true;
} }
private static bool IsGenericGroupReference(string? name)
{
var clean = CleanCharacterName(name);
if (string.IsNullOrWhiteSpace(clean))
{
return true;
}
var withoutParentheses = Clean(ParentheticalSuffix.Replace(clean, string.Empty));
var normalised = Normalise(withoutParentheses);
if (GenericPeople.Contains(normalised) || NonCharacterNames.Contains(normalised))
{
return true;
}
var words = WordsForFiltering(withoutParentheses);
if (words.Count == 0)
{
return true;
}
if (LeadingQuantity.IsMatch(withoutParentheses)
&& words.Any(word => GenericGroupHeads.Contains(word)))
{
return true;
}
if (words.Count > 1
&& words.Any(word => GenericGroupHeads.Contains(word))
&& !HasSpecificPossessiveFamilyReference(words))
{
return true;
}
if (words.Count > 1
&& DeterminerWords.Contains(words[0])
&& words.Skip(1).Any(word => FamilyRoleNames.Contains(word) || GenericGroupHeads.Contains(word)))
{
return true;
}
if (words.Count > 1
&& QuantityWords.Contains(words[0])
&& words.Skip(1).Any(word => GenericGroupHeads.Contains(word)))
{
return true;
}
return words.All(word => GenericGroupHeads.Contains(word) || QuantityWords.Contains(word));
}
private static bool IsNamedOrTitledPerson(string name)
{
var words = WordsForFiltering(name);
if (words.Count == 0)
{
return false;
}
return CharacterTitlePrefixes.Contains(words[0]) && words.Count > 1;
}
private static bool IsFamilyRoleOnly(string name)
{
var words = WordsForFiltering(name);
return words.Count == 1 && FamilyRoleNames.Contains(words[0]);
}
private static bool HasSpecificPossessiveFamilyReference(IReadOnlyList<string> words)
=> words.Count == 2
&& FamilyRoleNames.Contains(words[1])
&& words[0].EndsWith("s", StringComparison.OrdinalIgnoreCase)
&& !DeterminerWords.Contains(words[0]);
private static string ResolveCandidateKey( private static string ResolveCandidateKey(
IReadOnlyDictionary<string, CharacterCandidate> groups, IReadOnlyDictionary<string, CharacterCandidate> groups,
CharacterIndex existingIndex, CharacterIndex existingIndex,
@ -636,6 +830,15 @@ public sealed class StoryIntelligenceCharacterImportService(
return words; return words;
} }
private static List<string> WordsForFiltering(string? name)
=> CleanCharacterName(name)
.Replace("/", " ", StringComparison.Ordinal)
.Replace("-", " ", StringComparison.Ordinal)
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(word => word.Trim('.', ',', ';', ':', '!', '?', '"', '\'', '(', ')').ToLowerInvariant())
.Where(word => !string.IsNullOrWhiteSpace(word))
.ToList();
private static bool IsMergeableTitle(string value) private static bool IsMergeableTitle(string value)
=> value.Equals("instructor", StringComparison.OrdinalIgnoreCase) => value.Equals("instructor", StringComparison.OrdinalIgnoreCase)
|| value.Equals("detective", StringComparison.OrdinalIgnoreCase) || value.Equals("detective", StringComparison.OrdinalIgnoreCase)

View File

@ -0,0 +1,836 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceLocationImportService
{
Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form);
}
public sealed class StoryIntelligenceLocationImportService(
IStoryIntelligenceResultRepository runs,
ILocationRepository locations,
ISceneRepository scenes,
IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceLocationImportService> logger) : IStoryIntelligenceLocationImportService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private static readonly HashSet<string> GenericLocations = CreateSet(
"room", "place", "area", "building", "street", "road", "door", "doorway", "wall", "floor",
"pavement", "car", "car park", "parking lot", "stairs", "stairwell", "landing", "kitchen",
"bathroom", "hallway", "corridor", "lobby", "reception", "desk", "pew", "altar", "outside",
"inside", "nearby", "somewhere", "home", "house", "flat", "streets", "main road", "dual carriageway",
"flyover", "motorway roundabout", "under the tree", "under-flyover / link road", "sitting room", "ring road",
"entrance", "office", "bedroom", "garage", "seating area", "waiting room", "interview room", "garden");
private static readonly HashSet<string> ThrowawayLocationFragments = CreateSet(
"door", "porch", "exterior", "tree", "pavement");
private static readonly HashSet<string> InternalLocationWords = CreateSet(
"room", "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "stairwell", "stairs",
"landing", "doorway", "pew", "altar", "floor", "entrance", "office", "bedroom", "garage",
"seating area", "waiting room", "interview room", "garden", "staff room", "lift");
private static readonly HashSet<string> RepeatableGenericSettings = CreateSet(
"kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "office", "bedroom", "garage",
"seating area", "waiting room", "interview room", "staff room");
private static readonly HashSet<string> StoryLocationSignals = CreateSet(
"trust", "church", "school", "university", "police", "headquarters", "hq", "house", "flat", "pub",
"cafe", "shop", "factory", "centre", "center", "hospital", "station", "hotel", "estate", "doweries");
private static readonly HashSet<string> ConjunctionLocationWords = CreateSet(
"shop", "staff room", "house", "garden", "road", "bridge", "kitchen", "hallway", "front garden",
"rear garden", "front", "rear", "room", "office", "garage", "bathroom", "bedroom", "corridor", "street", "car park");
private static readonly Regex AddressPattern = new(@"^\d+\s+\p{L}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex NamedRoadPattern = new(@"\b(street|road|lane|avenue|drive|way|close|crescent|square|place)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public async Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{
var data = await BuildCandidateDataAsync(batch);
var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
return new StoryIntelligenceLocationReviewViewModel
{
HasCommittedScenes = data.HasCommittedScenes,
CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceLocationReviewCandidateViewModel
{
Key = candidate.Key,
LocationName = candidate.DisplayName,
ImportName = candidate.DisplayName,
Category = candidate.Category,
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence),
ParentLocationHint = candidate.ParentLocationHint,
ExampleFirstAppearance = candidate.FirstAppearanceNote,
ExampleContext = candidate.Description,
ExistingLocationID = candidate.ExistingLocationID,
ExistingLocationName = candidate.ExistingLocationName
}).ToList()
};
}
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form)
{
var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing locations." };
}
var choices = form.Locations
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
if (choices.Count == 0)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one location to create, link or ignore." };
}
var existingIndex = await BuildLocationIndexAsync(batch.ProjectID);
var lookupData = await locations.GetLookupsAsync();
var created = 0;
var linkedExisting = 0;
var ignored = 0;
var aliasesAdded = 0;
var sceneLocationsUpdated = 0;
var resolvedNames = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var resolvedCandidateIds = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var candidateByKey = 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, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
ignored++;
AddDecision(batch, candidate.Key, StoryIntelligenceLocationImportActions.Ignore, candidate.DisplayName, null, false);
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var requestedName = Clean(choice.ImportName);
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
var linkExisting = string.Equals(choice.Action, StoryIntelligenceLocationImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
var matchedExistingId = linkExisting
? candidate.ExistingLocationID
?? existingIndex.Find(importName)?.LocationID
?? existingIndex.Find(candidate.DisplayName)?.LocationID
: null;
var locationId = matchedExistingId;
if (!locationId.HasValue)
{
var parentId = ResolveParentLocationId(candidate.ParentLocationHint, existingIndex);
locationId = await locations.SaveAsync(new LocationItem
{
ProjectID = batch.ProjectID,
ParentLocationID = parentId,
LocationName = importName,
LocationTypeID = MatchLocationType(lookupData.LocationTypes, candidate),
Description = BuildLocationDescription(candidate),
DetectionPriority = 50
});
created++;
}
else
{
linkedExisting++;
}
AddResolvedName(resolvedNames, candidate.DisplayName, locationId.Value);
AddResolvedName(resolvedNames, importName, locationId.Value);
resolvedCandidateIds[candidate.Key] = locationId.Value;
AddDecision(batch, candidate.Key, choice.Action, importName, locationId.Value, true);
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| !string.Equals(choice.Action, StoryIntelligenceLocationImportActions.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, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| string.Equals(targetChoice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate)
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetLocationId))
{
logger.LogWarning(
"Story Intelligence location alias candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
candidate.Key,
targetKey);
continue;
}
var targetName = targetCandidate.ExistingLocationName ?? targetChoice.ImportName ?? targetCandidate.DisplayName;
if (await TryAddAliasAsync(targetLocationId, candidate.DisplayName, targetName))
{
aliasesAdded++;
}
AddResolvedName(resolvedNames, candidate.DisplayName, targetLocationId);
AddDecision(batch, candidate.Key, StoryIntelligenceLocationImportActions.Alias, candidate.DisplayName, targetLocationId, true);
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var locationId = string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase)
? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey))
: resolvedCandidateIds.GetValueOrDefault(candidate.Key);
if (locationId <= 0)
{
continue;
}
foreach (var appearance in candidate.Appearances.Where(appearance => appearance.PresentInScene && !appearance.AlreadyLinked))
{
var scene = await scenes.GetAsync(appearance.SceneID);
if (scene is null || scene.PrimaryLocationID.HasValue)
{
continue;
}
scene.PrimaryLocationID = locationId;
await scenes.SaveAsync(scene);
sceneLocationsUpdated++;
}
}
batch.LocationStageComplete = true;
batch.LastLocationImportResult = new StoryIntelligenceLocationImportBatchResult
{
LocationsCreated = created,
LocationsLinked = linkedExisting,
LocationAliasesAdded = aliasesAdded,
LocationsIgnored = ignored,
SceneLocationsUpdated = sceneLocationsUpdated
};
await pipelineState.RecordLocationImportAsync(batch.ProjectID, batch.BookID);
logger.LogInformation(
"Imported Story Intelligence locations for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneLocationsUpdated={SceneLocationsUpdated}",
batch.BatchID,
created,
linkedExisting,
aliasesAdded,
ignored,
sceneLocationsUpdated);
return new StoryIntelligenceImportCommitResult
{
Success = true,
ScenesCreated = sceneLocationsUpdated,
Message = $"Locations imported. {created:N0} created, {linkedExisting:N0} linked, {aliasesAdded:N0} alias/variant(s) added, {ignored:N0} ignored, {sceneLocationsUpdated:N0} scene location(s) updated."
};
}
private async Task<LocationCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
{
var existingIndex = await BuildLocationIndexAsync(batch.ProjectID);
var groups = new Dictionary<string, LocationCandidate>(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;
}
if (importedScene.PrimaryLocationID.HasValue)
{
alreadyLinked++;
}
AddSceneLocations(groups, existingIndex, importedScene, parsed);
}
}
var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var candidates = groups.Values
.Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
.Where(candidate => IsVisibleLocationCandidate(candidate))
.OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count())
.ThenBy(candidate => candidate.DisplayName)
.ToList();
return new LocationCandidateData(hasCommittedScenes, alreadyLinked, candidates, decidedKeys);
}
private static void AddSceneLocations(
Dictionary<string, LocationCandidate> groups,
LocationIndex existingIndex,
Scene importedScene,
SceneIntelligenceScene parsed)
{
var setting = parsed.Setting;
var settingName = FirstConfigured(setting?.LocationName, setting?.GenericRoomType);
AddLocation(groups, existingIndex, importedScene, parsed, settingName, setting?.LocationType, setting?.GenericRoomType, setting?.ParentLocationHint, true, false, true, setting?.Confidence, "Scene setting");
foreach (var location in parsed.Locations ?? [])
{
AddLocation(groups, existingIndex, importedScene, parsed, location.Name, location.LocationType, location.GenericRoomType, location.ParentLocationHint, location.PresentInScene == true, location.MentionedOnly == true, false, location.Confidence, location.Notes);
}
foreach (var observation in parsed.Observations ?? [])
{
if (IsLocationEntity(observation.SubjectEntityType))
{
AddLocation(groups, existingIndex, importedScene, parsed, observation.SubjectName, null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description);
}
if (IsLocationEntity(observation.ObjectEntityType))
{
AddLocation(groups, existingIndex, importedScene, parsed, observation.ObjectName, null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description);
}
}
}
private static void AddLocation(
Dictionary<string, LocationCandidate> groups,
LocationIndex existingIndex,
Scene importedScene,
SceneIntelligenceScene parsed,
string? name,
string? locationType,
string? genericRoomType,
string? parentLocationHint,
bool presentInScene,
bool mentionedOnly,
bool isSceneSetting,
decimal? confidence,
string? notes)
{
var cleanName = CleanLocationName(name);
if (!IsLocationNameCandidate(cleanName))
{
return;
}
var key = ResolveCandidateKey(groups, existingIndex, cleanName);
if (!groups.TryGetValue(key, out var candidate))
{
var match = existingIndex.Find(cleanName);
candidate = new LocationCandidate(key, cleanName, match?.LocationID, match?.LocationName)
{
Category = Categorise(cleanName, locationType, genericRoomType),
ParentLocationHint = Clean(parentLocationHint)
};
groups[key] = candidate;
}
else if (!string.Equals(candidate.DisplayName, cleanName, StringComparison.OrdinalIgnoreCase))
{
candidate.Aliases.Add(cleanName);
}
candidate.Confidence = Max(candidate.Confidence, confidence);
candidate.Description ??= Clean(notes);
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, cleanName);
candidate.ParentLocationHint ??= Clean(parentLocationHint);
candidate.Appearances.Add(new LocationAppearanceImport(
importedScene.SceneID,
importedScene.SceneNumber,
importedScene.SceneTitle,
presentInScene && !mentionedOnly,
mentionedOnly,
isSceneSetting,
notes,
confidence,
importedScene.PrimaryLocationID.HasValue && candidate.ExistingLocationID == importedScene.PrimaryLocationID));
}
private async Task<LocationIndex> BuildLocationIndexAsync(int projectId)
{
var index = new LocationIndex();
foreach (var location in await locations.ListByProjectAsync(projectId))
{
index.Add(location.LocationName, location.LocationID, location.LocationName);
index.Add(location.LocationPath, location.LocationID, location.LocationName);
foreach (var alias in await locations.ListAliasesAsync(location.LocationID))
{
index.Add(alias.Alias, location.LocationID, location.LocationName);
}
}
return index;
}
private async Task<bool> TryAddAliasAsync(int locationId, string alias, string primaryName)
{
var cleanAlias = Clean(alias);
if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, primaryName, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var existingAliases = await locations.ListAliasesAsync(locationId);
if (existingAliases.Any(item => string.Equals(Clean(item.Alias), cleanAlias, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
await locations.AddAliasAsync(locationId, cleanAlias, null);
return true;
}
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 int? MatchLocationType(IReadOnlyList<LocationType> types, LocationCandidate candidate)
=> types.FirstOrDefault(type =>
candidate.Category.Contains(type.TypeName, StringComparison.OrdinalIgnoreCase)
|| type.TypeName.Contains(candidate.Category, StringComparison.OrdinalIgnoreCase))?.LocationTypeID;
private static int? ResolveParentLocationId(string? parentHint, LocationIndex index)
=> string.IsNullOrWhiteSpace(parentHint) ? null : index.Find(parentHint)?.LocationID;
private static string BuildLocationDescription(LocationCandidate candidate)
{
var lines = new List<string>();
if (!string.IsNullOrWhiteSpace(candidate.ParentLocationHint))
{
lines.Add($"Parent/location hint from Story Intelligence: {candidate.ParentLocationHint}");
}
if (!string.IsNullOrWhiteSpace(candidate.Description))
{
lines.Add(candidate.Description);
}
return string.Join(Environment.NewLine, lines);
}
private static bool IsVisibleLocationCandidate(LocationCandidate candidate)
{
if (IsMergedLocationPhrase(candidate.DisplayName))
{
return false;
}
if (IsGenericComposite(candidate.DisplayName))
{
return false;
}
if (ContainsThrowawayFragment(candidate.DisplayName))
{
return false;
}
if (IsStoryLocation(candidate.DisplayName))
{
return true;
}
var presentSceneCount = candidate.Appearances
.Where(appearance => appearance.PresentInScene)
.Select(appearance => appearance.SceneID)
.Distinct()
.Count();
var settingSceneCount = candidate.Appearances
.Where(appearance => appearance.IsSceneSetting)
.Select(appearance => appearance.SceneID)
.Distinct()
.Count();
if (HasUsefulParentHint(candidate.ParentLocationHint))
{
return presentSceneCount > 0;
}
if (IsGenericLocation(candidate.DisplayName))
{
return settingSceneCount >= 2 && RepeatableGenericSettings.Contains(Normalise(candidate.DisplayName));
}
return IsNamedLocation(candidate.DisplayName);
}
private static bool IsLocationNameCandidate(string? name)
{
var clean = CleanLocationName(name);
return !string.IsNullOrWhiteSpace(clean) && clean.Length > 1 && clean.Any(char.IsLetter);
}
private static bool IsGenericLocation(string name)
{
var normalised = Normalise(name);
var withoutParentheses = Normalise(Regex.Replace(name, @"\s*\([^)]*\)\s*$", string.Empty));
return GenericLocations.Contains(normalised)
|| GenericLocations.Contains(withoutParentheses)
|| InternalLocationWords.Contains(normalised)
|| InternalLocationWords.Contains(withoutParentheses);
}
private static bool IsGenericComposite(string name)
{
var value = Normalise(name);
if (!value.Contains('/') && !value.Contains(" / "))
{
return false;
}
return true;
}
private static bool ContainsThrowawayFragment(string name)
{
var value = Normalise(name);
return ThrowawayLocationFragments.Any(fragment => value.Contains(fragment, StringComparison.OrdinalIgnoreCase));
}
private static bool IsMergedLocationPhrase(string name)
{
var value = Normalise(StripParenthetical(name));
if (!value.Contains(" and ", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (value is "rose and crown")
{
return false;
}
var parts = value.Split(" and ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return parts.Length > 1 && parts.All(part => ConjunctionLocationWords.Contains(part) || ContainsConjunctionLocationWord(part));
}
private static bool ContainsConjunctionLocationWord(string value)
=> ConjunctionLocationWords.Any(word => value.Contains(word, StringComparison.OrdinalIgnoreCase));
private static bool HasUsefulParentHint(string? parentHint)
{
var clean = Clean(parentHint);
return !string.IsNullOrWhiteSpace(clean)
&& !IsMergedLocationPhrase(clean)
&& !IsGenericLocation(clean)
&& !ContainsThrowawayFragment(clean);
}
private static bool IsStoryLocation(string name)
{
var clean = StripParenthetical(name);
var normalised = Normalise(clean);
if (IsGenericLocation(clean) || IsGenericComposite(clean))
{
return false;
}
if (AddressPattern.IsMatch(clean) || IsNamedRoad(clean))
{
return true;
}
return clean.Any(char.IsUpper)
&& (StoryLocationSignals.Any(signal => normalised.Contains(signal, StringComparison.OrdinalIgnoreCase))
|| clean.Contains('\'')
|| clean.Contains(''));
}
private static bool IsNamedLocation(string name)
{
var withoutParentheses = StripParenthetical(name);
return AddressPattern.IsMatch(withoutParentheses)
|| IsNamedRoad(name)
|| withoutParentheses.Any(char.IsUpper)
|| withoutParentheses.Contains('\'')
|| withoutParentheses.Contains('');
}
private static bool IsNamedRoad(string name)
{
var withoutParentheses = StripParenthetical(name);
return !name.Contains('/')
&& withoutParentheses.Any(char.IsUpper)
&& NamedRoadPattern.IsMatch(withoutParentheses)
&& !GenericLocations.Contains(Normalise(name))
&& !GenericLocations.Contains(Normalise(withoutParentheses));
}
private static string Categorise(string name, string? locationType, string? genericRoomType)
{
if (AddressPattern.IsMatch(name))
{
return "Address";
}
if (IsNamedRoad(name))
{
return "Road / street";
}
if (Normalise(locationType).Contains("generic", StringComparison.OrdinalIgnoreCase) || !string.IsNullOrWhiteSpace(genericRoomType))
{
return "Room / interior area";
}
if (Normalise(name).Contains("car", StringComparison.OrdinalIgnoreCase))
{
return "Vehicle-as-location";
}
return IsNamedLocation(name) ? "Named location" : "Ambiguous location";
}
private static bool IsLocationEntity(string? entityType)
=> string.Equals(Clean(entityType), "Location", StringComparison.OrdinalIgnoreCase);
private static bool IsPresentPredicate(string? predicate)
{
var value = Clean(predicate);
return value.Contains("isPresentAt", StringComparison.OrdinalIgnoreCase)
|| value.Contains("travelsTo", StringComparison.OrdinalIgnoreCase)
|| value.Contains("occursAt", StringComparison.OrdinalIgnoreCase);
}
private static string ResolveCandidateKey(
IReadOnlyDictionary<string, LocationCandidate> groups,
LocationIndex existingIndex,
string name)
{
var existingMatch = existingIndex.Find(name);
if (existingMatch is not null)
{
var existingKey = CanonicalLocationKey(existingMatch.LocationName);
if (groups.ContainsKey(existingKey))
{
return existingKey;
}
return existingKey;
}
var canonical = CanonicalLocationKey(name);
return groups.ContainsKey(canonical) ? canonical : canonical;
}
private static void AddResolvedName(Dictionary<string, int> map, string name, int locationId)
{
var clean = Clean(name);
if (!string.IsNullOrWhiteSpace(clean))
{
map[clean] = locationId;
}
}
private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, string? locationName, int? locationId, bool createdOrLinked)
{
batch.LocationDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase));
batch.LocationDecisions.Add(new OnboardingStoryIntelligenceLocationDecision
{
Key = key,
Action = action,
LocationName = locationName,
LocationID = locationId,
CreatedOrLinked = createdOrLinked
});
}
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string locationName)
{
var summary = Clean(parsed.Summary?.Short);
return string.IsNullOrWhiteSpace(summary)
? $"Scene {scene.SceneNumber:g}: {locationName}"
: $"Scene {scene.SceneNumber:g}: {summary}";
}
private static string FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
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 CanonicalLocationKey(string name)
{
var clean = StripParenthetical(CleanLocationName(name));
if (clean.StartsWith("the ", StringComparison.OrdinalIgnoreCase))
{
clean = clean[4..];
}
return Normalise(clean);
}
private static string StripParenthetical(string? value)
=> Regex.Replace(Clean(value), @"\s*\([^)]*\)\s*$", string.Empty);
private static string CleanLocationName(string? value)
{
var clean = Clean(value).Trim(' ', '.', ',', ';', ':', '!', '?', '"', '\'');
if (clean.EndsWith("'s", StringComparison.OrdinalIgnoreCase) || clean.EndsWith("s", StringComparison.OrdinalIgnoreCase))
{
return clean;
}
return clean;
}
private static string Normalise(string? value)
=> Clean(value).ToLowerInvariant();
private static string Clean(string? value)
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
private static HashSet<string> CreateSet(params string[] values)
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);
private sealed class LocationIndex
{
private readonly Dictionary<string, LocationMatch> byName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int locationId, string locationName)
{
var key = CanonicalLocationKey(name ?? string.Empty);
if (!string.IsNullOrWhiteSpace(key))
{
byName.TryAdd(key, new LocationMatch(locationId, locationName));
}
}
public LocationMatch? Find(string name)
=> byName.TryGetValue(CanonicalLocationKey(name), out var match) ? match : null;
}
private sealed record LocationMatch(int LocationID, string LocationName);
private sealed class LocationCandidate(string key, string displayName, int? existingLocationId, string? existingLocationName)
{
public string Key { get; } = key;
public string DisplayName { get; } = displayName;
public int? ExistingLocationID { get; } = existingLocationId;
public string? ExistingLocationName { get; } = existingLocationName;
public string Category { get; set; } = "Ambiguous location";
public string? ParentLocationHint { get; set; }
public HashSet<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<LocationAppearanceImport> Appearances { get; } = [];
public decimal? Confidence { get; set; }
public string? Description { get; set; }
public string? FirstAppearanceNote { get; set; }
}
private sealed record LocationAppearanceImport(
int SceneID,
decimal SceneNumber,
string SceneTitle,
bool PresentInScene,
bool MentionedOnly,
bool IsSceneSetting,
string? Notes,
decimal? Confidence,
bool AlreadyLinked);
private sealed record LocationCandidateData(
bool HasCommittedScenes,
int AlreadyLinkedCount,
IReadOnlyList<LocationCandidate> Candidates,
IReadOnlySet<string> DecidedKeys);
}

View File

@ -10,6 +10,9 @@ public interface IStoryIntelligencePipelineStateService
Task<StoryIntelligenceBookPipelineState?> EnsureForBookAsync(int bookId, int userId); Task<StoryIntelligenceBookPipelineState?> EnsureForBookAsync(int bookId, int userId);
Task RecordSceneImportAsync(int projectId, int bookId, int runId); Task RecordSceneImportAsync(int projectId, int bookId, int runId);
Task RecordCharacterImportAsync(int projectId, int bookId); Task RecordCharacterImportAsync(int projectId, int bookId);
Task RecordLocationImportAsync(int projectId, int bookId);
Task RecordAssetImportAsync(int projectId, int bookId);
Task RecordRelationshipImportAsync(int projectId, int bookId);
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId); Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
} }
@ -80,8 +83,53 @@ public sealed class StoryIntelligencePipelineStateService(
{ {
ProjectID = projectId, ProjectID = projectId,
BookID = bookId, BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.Complete, CurrentStage = StoryIntelligencePipelineStages.LocationReview,
LastCompletedStage = StoryIntelligencePipelineStages.CharacterImport, LastCompletedStage = StoryIntelligencePipelineStages.CharacterImport,
CurrentReviewStage = StoryIntelligencePipelineStages.LocationReview,
Status = StoryIntelligencePipelineStatuses.NeedsReview,
CompletedUtc = null,
LastRunID = null
});
}
public async Task RecordLocationImportAsync(int projectId, int bookId)
{
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
{
ProjectID = projectId,
BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.AssetReview,
LastCompletedStage = StoryIntelligencePipelineStages.LocationImport,
CurrentReviewStage = StoryIntelligencePipelineStages.AssetReview,
Status = StoryIntelligencePipelineStatuses.NeedsReview,
CompletedUtc = null,
LastRunID = null
});
}
public async Task RecordAssetImportAsync(int projectId, int bookId)
{
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
{
ProjectID = projectId,
BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.RelationshipReview,
LastCompletedStage = StoryIntelligencePipelineStages.AssetImport,
CurrentReviewStage = StoryIntelligencePipelineStages.RelationshipReview,
Status = StoryIntelligencePipelineStatuses.NeedsReview,
CompletedUtc = null,
LastRunID = null
});
}
public async Task RecordRelationshipImportAsync(int projectId, int bookId)
{
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
{
ProjectID = projectId,
BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.Complete,
LastCompletedStage = StoryIntelligencePipelineStages.RelationshipImport,
CurrentReviewStage = null, CurrentReviewStage = null,
Status = StoryIntelligencePipelineStatuses.Complete, Status = StoryIntelligencePipelineStatuses.Complete,
CompletedUtc = DateTime.UtcNow, CompletedUtc = DateTime.UtcNow,

View File

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

View File

@ -269,8 +269,14 @@ 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.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 StoryIntelligenceReadyForLocations(state) => "Waiting for Location Review",
_ => string.Equals(state.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase) _ => string.Equals(state.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase)
? "Story Intelligence In Progress" ? "Story Intelligence In Progress"
: "Story Intelligence Needs Review" : "Story Intelligence Needs Review"
@ -282,11 +288,34 @@ 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.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 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)
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase)
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport
|| (string.Equals(state.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase));
private static bool StoryIntelligenceReadyForLocations(StoryIntelligenceBookPipelineState state)
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.CharacterImport, StringComparison.OrdinalIgnoreCase)
|| state.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport
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
? $"{Math.Max(0, (int)elapsed.TotalSeconds)} sec" ? $"{Math.Max(0, (int)elapsed.TotalSeconds)} sec"

View File

@ -0,0 +1,28 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER PROCEDURE dbo.Scene_ListByChapter
@ChapterID int
AS
BEGIN
SET NOCOUNT ON;
SELECT s.SceneID, s.ChapterID, s.SceneNumber, s.SceneTitle, s.Summary, s.POVCharacterID,
s.PrimaryLocationID, s.FloorPlanID, s.InitialFloorPlanFloorID, s.SortOrder,
s.TimeModeID, tm.TimeModeName, s.StartDateTime, s.EndDateTime, s.DurationAmount,
s.DurationUnitID, du.DurationUnitName, s.RelativeTimeText, s.TimeConfidenceID,
tc.TimeConfidenceName, s.ScenePurposeNotes, s.SceneOutcomeNotes, s.RevisionStatusID,
rs.StatusName AS RevisionStatusName, s.CreatedDate, s.UpdatedDate, s.IsArchived,
s.ArchivedDate, s.ArchivedReason, s.IsLinkedToManuscript, s.ManuscriptDocumentID,
s.ExternalReference, s.LinkedUtc, s.ImportSource, s.ImportRunID,
s.SourceStartParagraph, s.SourceEndParagraph
FROM dbo.Scenes s
INNER JOIN dbo.TimeModes tm ON tm.TimeModeID = s.TimeModeID
LEFT JOIN dbo.DurationUnits du ON du.DurationUnitID = s.DurationUnitID
INNER JOIN dbo.TimeConfidences tc ON tc.TimeConfidenceID = s.TimeConfidenceID
INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = s.RevisionStatusID
WHERE s.ChapterID = @ChapterID AND s.IsArchived = 0
ORDER BY s.SortOrder, s.SceneNumber, s.SceneTitle;
END;
GO

View File

@ -0,0 +1,34 @@
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'Complete'
));
END;
GO

View File

@ -0,0 +1,36 @@
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'Complete'
));
END;
GO

View File

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

View File

@ -143,6 +143,9 @@ public sealed class StoryIntelligenceProgressViewModel
public string? BookTitle { get; init; } public string? BookTitle { get; init; }
public IReadOnlyList<StoryIntelligenceOnboardingChapterViewModel> Chapters { get; init; } = []; public IReadOnlyList<StoryIntelligenceOnboardingChapterViewModel> Chapters { get; init; } = [];
public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new(); public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new();
public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new();
public StoryIntelligenceAssetReviewViewModel AssetReview { get; init; } = new();
public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; init; } = new();
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new(); public 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);
@ -158,6 +161,9 @@ public sealed class StoryIntelligenceProgressViewModel
public bool AllCommitted => Chapters.Count > 0 && Chapters.All(chapter => chapter.HasCompletedCommit); public bool AllCommitted => Chapters.Count > 0 && Chapters.All(chapter => chapter.HasCompletedCommit);
public bool HasReadyScenesToCreate => Chapters.Any(chapter => chapter.CanCommit); public bool HasReadyScenesToCreate => Chapters.Any(chapter => chapter.CanCommit);
public bool HasCharactersToReview => CharacterReview.Candidates.Count > 0; public bool HasCharactersToReview => CharacterReview.Candidates.Count > 0;
public bool HasLocationsToReview => LocationReview.Candidates.Count > 0;
public bool HasAssetsToReview => AssetReview.Candidates.Count > 0;
public bool HasRelationshipsToReview => RelationshipReview.Candidates.Count > 0;
} }
public sealed class StoryIntelligencePipelineDashboardViewModel public sealed class StoryIntelligencePipelineDashboardViewModel
@ -167,6 +173,15 @@ public sealed class StoryIntelligencePipelineDashboardViewModel
public int CharactersIdentified { get; init; } public int CharactersIdentified { get; init; }
public int CharactersCreatedOrLinked { get; init; } public int CharactersCreatedOrLinked { get; init; }
public bool CharacterStageComplete { get; init; } public bool CharacterStageComplete { get; init; }
public int LocationsIdentified { get; init; }
public int LocationsCreatedOrLinked { get; init; }
public bool LocationStageComplete { get; init; }
public int AssetsIdentified { get; init; }
public int AssetsCreatedOrLinked { get; init; }
public bool AssetStageComplete { get; init; }
public int RelationshipsIdentified { get; init; }
public int RelationshipsCreatedOrLinked { get; init; }
public bool RelationshipStageComplete { get; init; }
} }
public sealed class StoryIntelligenceCharacterReviewViewModel public sealed class StoryIntelligenceCharacterReviewViewModel
@ -202,15 +217,163 @@ public sealed class StoryIntelligenceCharacterImportForm
public sealed class StoryIntelligenceCharacterImportChoiceForm public sealed class StoryIntelligenceCharacterImportChoiceForm
{ {
public string Key { get; set; } = string.Empty; public string Key { get; set; } = string.Empty;
public string Action { get; set; } = StoryIntelligenceCharacterImportActions.Approve; public string Action { get; set; } = StoryIntelligenceCharacterImportActions.CreateNew;
public string? ImportName { get; set; } public string? ImportName { get; set; }
public string? AliasTargetKey { get; set; }
} }
public static class StoryIntelligenceCharacterImportActions public static class StoryIntelligenceCharacterImportActions
{ {
public const string Approve = "Approve"; public const string CreateNew = "CreateNew";
public const string LinkExisting = "LinkExisting";
public const string Ignore = "Ignore"; public const string Ignore = "Ignore";
public const string CreateSeparate = "CreateSeparate"; public const string Alias = "Alias";
}
public sealed class StoryIntelligenceLocationReviewViewModel
{
public bool CanImport { get; init; }
public bool HasCommittedScenes { get; init; }
public int AlreadyLinkedCount { get; init; }
public bool IsComplete { get; init; }
public IReadOnlyList<StoryIntelligenceLocationReviewCandidateViewModel> Candidates { get; init; } = [];
}
public sealed class StoryIntelligenceLocationReviewCandidateViewModel
{
public string Key { get; init; } = string.Empty;
public string LocationName { get; init; } = string.Empty;
public string ImportName { get; init; } = string.Empty;
public string Category { get; init; } = "Ambiguous location";
public int AppearsInScenes { get; init; }
public string Confidence { get; init; } = "Unknown";
public string? ParentLocationHint { get; init; }
public string? ExampleFirstAppearance { get; init; }
public string? ExampleContext { get; init; }
public int? ExistingLocationID { get; init; }
public string? ExistingLocationName { get; init; }
public bool IsExistingMatch => ExistingLocationID.HasValue;
public string ActionLabel => IsExistingMatch ? "Link existing" : "Create";
}
public sealed class StoryIntelligenceLocationImportForm
{
public Guid BatchID { get; set; }
public List<StoryIntelligenceLocationImportChoiceForm> Locations { get; set; } = [];
}
public sealed class StoryIntelligenceLocationImportChoiceForm
{
public string Key { get; set; } = string.Empty;
public string Action { get; set; } = StoryIntelligenceLocationImportActions.CreateNew;
public string? ImportName { get; set; }
public string? AliasTargetKey { get; set; }
}
public static class StoryIntelligenceLocationImportActions
{
public const string CreateNew = "CreateNew";
public const string LinkExisting = "LinkExisting";
public const string Ignore = "Ignore";
public const string Alias = "Alias";
}
public sealed class StoryIntelligenceAssetReviewViewModel
{
public bool CanImport { get; init; }
public bool HasCommittedScenes { get; init; }
public int AlreadyLinkedCount { get; init; }
public bool IsComplete { get; init; }
public IReadOnlyList<StoryIntelligenceAssetReviewCandidateViewModel> Candidates { get; init; } = [];
}
public sealed class StoryIntelligenceAssetReviewCandidateViewModel
{
public string Key { get; init; } = string.Empty;
public string AssetName { get; init; } = string.Empty;
public string ImportName { get; init; } = string.Empty;
public string Category { get; init; } = "Story asset";
public int AppearsInScenes { get; init; }
public string Confidence { get; init; } = "Unknown";
public string? PossibleOwner { get; init; }
public string? ExampleFirstAppearance { get; init; }
public string? ExampleContext { get; init; }
public IReadOnlyList<string> PossibleAliases { get; init; } = [];
public int? ExistingAssetID { get; init; }
public string? ExistingAssetName { get; init; }
public bool IsExistingMatch => ExistingAssetID.HasValue;
public string ActionLabel => IsExistingMatch ? "Link existing" : "Create";
}
public sealed class StoryIntelligenceAssetImportForm
{
public Guid BatchID { get; set; }
public List<StoryIntelligenceAssetImportChoiceForm> Assets { get; set; } = [];
}
public sealed class StoryIntelligenceAssetImportChoiceForm
{
public string Key { get; set; } = string.Empty;
public string Action { get; set; } = StoryIntelligenceAssetImportActions.CreateNew;
public string? ImportName { get; set; }
public string? AliasTargetKey { get; set; }
}
public static class StoryIntelligenceAssetImportActions
{
public const string CreateNew = "CreateNew";
public const string LinkExisting = "LinkExisting";
public const string Ignore = "Ignore";
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
@ -226,6 +389,12 @@ public sealed class StoryIntelligenceCompletionViewModel
public int ScenesCreated { get; init; } public int ScenesCreated { get; init; }
public int CharactersCreatedOrLinked { get; init; } public int CharactersCreatedOrLinked { get; init; }
public bool CharacterStageComplete { get; init; } public bool CharacterStageComplete { get; init; }
public int LocationsCreatedOrLinked { get; init; }
public bool LocationStageComplete { get; init; }
public int AssetsCreatedOrLinked { get; init; }
public bool AssetStageComplete { get; init; }
public int RelationshipsCreatedOrLinked { get; init; }
public bool RelationshipStageComplete { get; init; }
} }
public sealed class StoryIntelligenceCharacterImportResultViewModel public sealed class StoryIntelligenceCharacterImportResultViewModel
@ -235,11 +404,49 @@ public sealed class StoryIntelligenceCharacterImportResultViewModel
public int BookID { get; init; } public int BookID { get; init; }
public int CharactersCreated { get; init; } public int CharactersCreated { get; init; }
public int CharactersLinked { get; init; } public int CharactersLinked { get; init; }
public int CharactersAliased { get; init; }
public int CharactersIgnored { get; init; } public int CharactersIgnored { get; init; }
public int PovLinksResolved { get; init; } public int PovLinksResolved { get; init; }
public int ScenePeoplePanelsUpdated { get; init; } public int ScenePeoplePanelsUpdated { get; init; }
} }
public sealed class StoryIntelligenceLocationImportResultViewModel
{
public Guid BatchID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public int LocationsCreated { get; init; }
public int LocationsLinked { get; init; }
public int LocationAliasesAdded { get; init; }
public int LocationsIgnored { get; init; }
public int SceneLocationsUpdated { get; init; }
}
public sealed class StoryIntelligenceAssetImportResultViewModel
{
public Guid BatchID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public int AssetsCreated { get; init; }
public int AssetsLinked { get; init; }
public int AssetAliasesAdded { get; init; }
public int AssetsIgnored { get; init; }
public int SceneAssetEventsCreated { 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;

View File

@ -200,9 +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.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 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)
=> string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase)
|| pipeline.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport
|| (string.Equals(pipeline.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase));
private static bool StoryIntelligenceReadyForLocations(StoryIntelligenceBookPipelineState pipeline)
=> string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.CharacterImport, StringComparison.OrdinalIgnoreCase)
|| pipeline.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport
or StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport;
} }

View File

@ -0,0 +1,57 @@
@model StoryIntelligenceAssetImportResultViewModel
@{
ViewData["Title"] = "Assets created";
}
<section class="onboarding-shell" aria-labelledby="story-asset-complete-title">
<div class="onboarding-panel onboarding-review-panel">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Relationships" })" />
<div class="onboarding-copy">
<p class="eyebrow">Build Story Database</p>
<div class="onboarding-success-heading">
<span aria-hidden="true">&check;</span>
<h1 id="story-asset-complete-title">Assets created</h1>
</div>
<p>PlotDirector updated your story database from the approved asset decisions. Next, review the relationships detected between imported characters.</p>
</div>
<div class="onboarding-scan-counts onboarding-review-counts">
<div>
<span>Assets created</span>
<strong>@Model.AssetsCreated.ToString("N0")</strong>
</div>
<div>
<span>Assets linked</span>
<strong>@Model.AssetsLinked.ToString("N0")</strong>
</div>
<div>
<span>Aliases added</span>
<strong>@Model.AssetAliasesAdded.ToString("N0")</strong>
</div>
<div>
<span>Assets ignored</span>
<strong>@Model.AssetsIgnored.ToString("N0")</strong>
</div>
<div>
<span>Scene World panels updated</span>
<strong>@Model.SceneAssetEventsCreated.ToString("N0")</strong>
</div>
<div>
<span>Ownership links</span>
<strong>@Model.OwnershipLinksCreated.ToString("N0")</strong>
</div>
</div>
<div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Relationships</strong><span>Next</span></article>
<article class="story-future-stage"><strong>Review Knowledge</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Continuity</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Timeline Events</strong><span>Coming Soon</span></article>
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Back to assets</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID">Review relationships</a>
</div>
</div>
</section>

View File

@ -0,0 +1,232 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Review assets";
}
<section class="onboarding-shell" aria-labelledby="story-asset-title">
<div class="onboarding-panel onboarding-review-panel story-review-page">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Assets" })" />
<div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 3</p>
<p class="story-review-heading__phase">Review Story Intelligence</p>
<p class="story-review-heading__stage">Stage 4 of 5</p>
<h1 id="story-asset-title">Review Assets</h1>
<p>Approve the important objects PlotDirector should create or link from your imported scenes. Nothing is created unless you approve it.</p>
</div>
@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="Asset review">
@if (Model.AssetReview.Candidates.Count == 0)
{
<div class="story-review-note">
<strong>No asset decisions are waiting.</strong>
<p class="mb-0">Assets are already up to date, or there were no story-significant asset suggestions ready to import.</p>
</div>
<div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Relationships</strong><span>Next</span></article>
@FutureStage("Review Knowledge")
@FutureStage("Review Continuity")
@FutureStage("Timeline Events")
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Back to locations</a>
<form asp-action="ImportStoryIntelligenceAssets" 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-asset-bulk="create-all">Create all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="link-existing">Link existing matches</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="ignore-all">Ignore all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="expand">Expand all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="collapse">Collapse all</button>
</div>
<form asp-action="ImportStoryIntelligenceAssets" method="post" data-asset-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<div class="story-character-card-grid">
@for (var i = 0; i < Model.AssetReview.Candidates.Count; i++)
{
var candidate = Model.AssetReview.Candidates[i];
<details class="story-character-card" open data-asset-card>
<summary>
<span>
<strong>@candidate.AssetName</strong>
<small>@candidate.Category · @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
</span>
<em>@candidate.Confidence</em>
</summary>
<input type="hidden" name="Assets[@i].Key" value="@candidate.Key" />
<div class="story-character-card__body" data-asset-key="@candidate.Key" data-asset-name="@candidate.AssetName">
<dl>
<div><dt>First appearance</dt><dd>@Display(candidate.ExampleFirstAppearance)</dd></div>
<div><dt>Possible owner</dt><dd>@Display(candidate.PossibleOwner)</dd></div>
<div><dt>Existing match</dt><dd>@(candidate.ExistingAssetName ?? "None")</dd></div>
</dl>
@if (candidate.PossibleAliases.Any())
{
<div class="story-review-note">
<strong>Possible aliases</strong>
<p>@string.Join(", ", candidate.PossibleAliases)</p>
</div>
}
@if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
{
<div class="story-review-note">
<strong>Scene context</strong>
<p>@candidate.ExampleContext</p>
</div>
}
@if (candidate.IsExistingMatch)
{
<div class="story-review-note">
<strong>Possible existing asset</strong>
<p>@candidate.AssetName may already be @candidate.ExistingAssetName. Choose whether to link them or create a separate asset.</p>
</div>
}
<fieldset class="story-character-actions">
<legend>Decision</legend>
<label>
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.CreateNew" checked data-asset-action />
Create new asset
</label>
<label>
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.LinkExisting" data-asset-action @(candidate.IsExistingMatch ? string.Empty : "disabled") />
Link existing asset
</label>
<label>
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.Alias" data-asset-action />
Alias of another asset
</label>
<label>
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.Ignore" data-asset-action />
Ignore
</label>
</fieldset>
<div data-asset-import-name-panel>
<label class="form-label" for="asset-import-name-@i">Import name</label>
<input id="asset-import-name-@i" class="form-control" name="Assets[@i].ImportName" value="@candidate.ImportName" />
</div>
<div data-asset-alias-panel hidden>
<label class="form-label" for="asset-alias-target-@i">Alias/variant target</label>
<select id="asset-alias-target-@i" class="form-select" name="Assets[@i].AliasTargetKey" data-asset-alias-target>
<option value="">Choose asset...</option>
@foreach (var target in Model.AssetReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
{
<option value="@target.Key">@target.AssetName@(target.IsExistingMatch ? $" -> {target.ExistingAssetName}" : string.Empty)</option>
}
</select>
</div>
</div>
</details>
}
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Back to locations</a>
<button class="btn btn-primary" type="submit">Create assets</button>
</div>
</form>
}
</section>
</div>
</section>
@section Scripts {
<script>
(() => {
const form = document.querySelector("[data-asset-review-form]");
if (!form) return;
const cards = () => Array.from(form.querySelectorAll("[data-asset-card]"));
const setAction = (card, action) => {
const input = card.querySelector(`[data-asset-action][value="${action}"]`);
if (input) {
input.checked = true;
updateCard(card);
}
};
const actionFor = (card) => card.querySelector("[data-asset-action]:checked")?.value || "@StoryIntelligenceAssetImportActions.CreateNew";
const isCanonicalTarget = (card) => {
const action = actionFor(card);
return action !== "@StoryIntelligenceAssetImportActions.Ignore" && action !== "@StoryIntelligenceAssetImportActions.Alias";
};
const updateAliasTargets = () => {
const allCards = cards();
for (const card of allCards) {
const body = card.querySelector("[data-asset-key]");
const ownKey = body?.getAttribute("data-asset-key") || "";
const select = card.querySelector("[data-asset-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-asset-key]")?.getAttribute("data-asset-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 namePanel = card.querySelector("[data-asset-import-name-panel]");
const aliasPanel = card.querySelector("[data-asset-alias-panel]");
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceAssetImportActions.Ignore" || action === "@StoryIntelligenceAssetImportActions.Alias";
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceAssetImportActions.Alias";
updateAliasTargets();
};
document.querySelectorAll("[data-asset-bulk]").forEach(button => {
button.addEventListener("click", () => {
const action = button.getAttribute("data-asset-bulk");
if (action === "create-all") cards().forEach(card => setAction(card, "@StoryIntelligenceAssetImportActions.CreateNew"));
if (action === "link-existing") cards().forEach(card => {
if (card.querySelector(`[data-asset-action][value="@StoryIntelligenceAssetImportActions.LinkExisting"]:not(:disabled)`)) {
setAction(card, "@StoryIntelligenceAssetImportActions.LinkExisting");
}
});
if (action === "ignore-all") cards().forEach(card => setAction(card, "@StoryIntelligenceAssetImportActions.Ignore"));
if (action === "expand") cards().forEach(card => card.open = true);
if (action === "collapse") cards().forEach(card => card.open = false);
});
});
form.querySelectorAll("[data-asset-action]").forEach(input => {
input.addEventListener("change", () => updateCard(input.closest("[data-asset-card]")));
});
cards().forEach(updateCard);
})();
</script>
}
@functions {
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value;
private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label)
=> new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Coming Soon</span></article>");
}

View File

@ -5,7 +5,7 @@
<section class="onboarding-shell" aria-labelledby="story-character-complete-title"> <section class="onboarding-shell" aria-labelledby="story-character-complete-title">
<div class="onboarding-panel onboarding-review-panel"> <div class="onboarding-panel onboarding-review-panel">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Create Characters" })" /> <partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Locations" })" />
<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">
@ -24,6 +24,10 @@
<span>Characters linked</span> <span>Characters linked</span>
<strong>@Model.CharactersLinked.ToString("N0")</strong> <strong>@Model.CharactersLinked.ToString("N0")</strong>
</div> </div>
<div>
<span>Aliases added</span>
<strong>@Model.CharactersAliased.ToString("N0")</strong>
</div>
<div> <div>
<span>Characters ignored</span> <span>Characters ignored</span>
<strong>@Model.CharactersIgnored.ToString("N0")</strong> <strong>@Model.CharactersIgnored.ToString("N0")</strong>
@ -39,7 +43,7 @@
</div> </div>
<div class="story-future-stage-grid"> <div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Locations</strong><span>Coming Soon</span></article> <article class="story-future-stage"><strong>Review Locations</strong><span>Next</span></article>
<article class="story-future-stage"><strong>Review Assets</strong><span>Coming Soon</span></article> <article class="story-future-stage"><strong>Review Assets</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Relationships</strong><span>Coming Soon</span></article> <article class="story-future-stage"><strong>Review Relationships</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 Knowledge</strong><span>Coming Soon</span></article>
@ -47,7 +51,7 @@
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID">Back to characters</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID">Back to characters</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a> <a class="btn btn-primary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Review locations</a>
</div> </div>
</div> </div>
</section> </section>

View File

@ -6,8 +6,10 @@
<section class="onboarding-shell" aria-labelledby="story-character-title"> <section class="onboarding-shell" aria-labelledby="story-character-title">
<div class="onboarding-panel onboarding-review-panel story-review-page"> <div class="onboarding-panel onboarding-review-panel story-review-page">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Characters" })" /> <partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Characters" })" />
<div class="onboarding-copy"> <div class="onboarding-copy story-review-heading">
<p class="eyebrow">Review Story Intelligence</p> <p class="eyebrow">Phase 3</p>
<p class="story-review-heading__phase">Review Story Intelligence</p>
<p class="story-review-heading__stage">Stage 2 of 4</p>
<h1 id="story-character-title">Review Characters</h1> <h1 id="story-character-title">Review Characters</h1>
<p>Approve the characters PlotDirector should create or link from your imported scenes. You stay in control of every character record.</p> <p>Approve the characters PlotDirector should create or link from your imported scenes. You stay in control of every character record.</p>
</div> </div>
@ -38,15 +40,15 @@
</div> </div>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReview" asp-route-batchId="@Model.BatchID">Back to scenes</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReview" asp-route-batchId="@Model.BatchID">Back to scenes</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a> <a class="btn btn-primary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Continue</a>
</div> </div>
} }
else else
{ {
<div class="story-review-card-actions"> <div class="story-review-card-actions">
<button class="btn btn-outline-primary btn-sm" type="button" data-character-bulk="approve-all">Approve all</button> <button class="btn btn-outline-primary btn-sm" type="button" data-character-bulk="create-all">Create all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="approve-selected">Approve selected</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="link-existing">Link existing matches</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="ignore-selected">Ignore selected</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="ignore-all">Ignore all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="expand">Expand all</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="expand">Expand all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="collapse">Collapse all</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="collapse">Collapse all</button>
</div> </div>
@ -59,7 +61,6 @@
var candidate = Model.CharacterReview.Candidates[i]; var candidate = Model.CharacterReview.Candidates[i];
<details class="story-character-card" open data-character-card> <details class="story-character-card" open data-character-card>
<summary> <summary>
<input type="checkbox" checked data-character-selected aria-label="Select @candidate.CharacterName" />
<span> <span>
<strong>@candidate.CharacterName</strong> <strong>@candidate.CharacterName</strong>
<small>@candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small> <small>@candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
@ -69,7 +70,7 @@
<input type="hidden" name="Characters[@i].Key" value="@candidate.Key" /> <input type="hidden" name="Characters[@i].Key" value="@candidate.Key" />
<div class="story-character-card__body"> <div class="story-character-card__body" data-character-key="@candidate.Key" data-character-name="@candidate.CharacterName">
<dl> <dl>
<div><dt>First appearance</dt><dd>@Display(candidate.ExampleFirstAppearance)</dd></div> <div><dt>First appearance</dt><dd>@Display(candidate.ExampleFirstAppearance)</dd></div>
<div><dt>Possible aliases</dt><dd>@(candidate.PossibleAliases.Count == 0 ? "None detected" : string.Join(", ", candidate.PossibleAliases))</dd></div> <div><dt>Possible aliases</dt><dd>@(candidate.PossibleAliases.Count == 0 ? "None detected" : string.Join(", ", candidate.PossibleAliases))</dd></div>
@ -87,25 +88,39 @@
<fieldset class="story-character-actions"> <fieldset class="story-character-actions">
<legend>Decision</legend> <legend>Decision</legend>
<label> <label>
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Approve" checked data-character-action /> <input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.CreateNew" checked data-character-action />
@(candidate.IsExistingMatch ? "Link to existing" : "Create new character") Create new character
</label> </label>
@if (candidate.IsExistingMatch)
{
<label> <label>
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.CreateSeparate" data-character-action /> <input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.LinkExisting" data-character-action @(candidate.IsExistingMatch ? string.Empty : "disabled") />
Create separate character Link existing character
</label>
<label>
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Alias" data-character-action />
Alias of another character
</label> </label>
}
<label> <label>
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Ignore" data-character-action /> <input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Ignore" data-character-action />
Ignore Ignore
</label> </label>
</fieldset> </fieldset>
<div data-character-import-name-panel>
<label class="form-label" for="character-import-name-@i">Import name</label> <label class="form-label" for="character-import-name-@i">Import name</label>
<input id="character-import-name-@i" class="form-control" name="Characters[@i].ImportName" value="@candidate.ImportName" /> <input id="character-import-name-@i" class="form-control" name="Characters[@i].ImportName" value="@candidate.ImportName" />
</div> </div>
<div data-character-alias-panel hidden>
<label class="form-label" for="character-alias-target-@i">Alias target</label>
<select id="character-alias-target-@i" class="form-select" name="Characters[@i].AliasTargetKey" data-character-alias-target>
<option value="">Choose character...</option>
@foreach (var target in Model.CharacterReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
{
<option value="@target.Key">@target.CharacterName@(target.IsExistingMatch ? $" -> {target.ExistingCharacterName}" : string.Empty)</option>
}
</select>
</div>
</div>
</details> </details>
} }
</div> </div>
@ -129,24 +144,63 @@
const cards = () => Array.from(form.querySelectorAll("[data-character-card]")); const cards = () => Array.from(form.querySelectorAll("[data-character-card]"));
const setAction = (card, action) => { const setAction = (card, action) => {
const input = card.querySelector(`[data-character-action][value="${action}"]`); const input = card.querySelector(`[data-character-action][value="${action}"]`);
if (input) input.checked = true; if (input) {
input.checked = true;
updateCard(card);
}
};
const actionFor = (card) => card.querySelector("[data-character-action]:checked")?.value || "@StoryIntelligenceCharacterImportActions.CreateNew";
const isCanonicalTarget = (card) => {
const action = actionFor(card);
return action !== "@StoryIntelligenceCharacterImportActions.Ignore" && action !== "@StoryIntelligenceCharacterImportActions.Alias";
};
const updateAliasTargets = () => {
const allCards = cards();
for (const card of allCards) {
const body = card.querySelector("[data-character-key]");
const ownKey = body?.getAttribute("data-character-key") || "";
const select = card.querySelector("[data-character-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-character-key]")?.getAttribute("data-character-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 namePanel = card.querySelector("[data-character-import-name-panel]");
const aliasPanel = card.querySelector("[data-character-alias-panel]");
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceCharacterImportActions.Ignore" || action === "@StoryIntelligenceCharacterImportActions.Alias";
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceCharacterImportActions.Alias";
updateAliasTargets();
}; };
document.querySelectorAll("[data-character-bulk]").forEach(button => { document.querySelectorAll("[data-character-bulk]").forEach(button => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const action = button.getAttribute("data-character-bulk"); const action = button.getAttribute("data-character-bulk");
const selectedCards = cards().filter(card => card.querySelector("[data-character-selected]")?.checked); if (action === "create-all") cards().forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.CreateNew"));
if (action === "approve-all") cards().forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.Approve")); if (action === "link-existing") cards().forEach(card => {
if (action === "approve-selected") selectedCards.forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.Approve")); if (card.querySelector(`[data-character-action][value="@StoryIntelligenceCharacterImportActions.LinkExisting"]:not(:disabled)`)) {
if (action === "ignore-selected") selectedCards.forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.Ignore")); setAction(card, "@StoryIntelligenceCharacterImportActions.LinkExisting");
}
});
if (action === "ignore-all") cards().forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.Ignore"));
if (action === "expand") cards().forEach(card => card.open = true); if (action === "expand") cards().forEach(card => card.open = true);
if (action === "collapse") cards().forEach(card => card.open = false); if (action === "collapse") cards().forEach(card => card.open = false);
}); });
}); });
form.addEventListener("submit", () => { form.querySelectorAll("[data-character-action]").forEach(input => {
cards() input.addEventListener("change", () => updateCard(input.closest("[data-character-card]")));
.filter(card => !card.querySelector("[data-character-selected]")?.checked)
.forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.Ignore"));
}); });
cards().forEach(updateCard);
})(); })();
</script> </script>
} }

View File

@ -6,15 +6,32 @@
<section class="onboarding-shell" aria-labelledby="story-complete-title"> <section class="onboarding-shell" aria-labelledby="story-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 = "Complete" })" />
<div class="onboarding-copy"> <div class="onboarding-copy story-review-heading">
<p class="eyebrow">Story Intelligence pipeline</p> <p class="eyebrow">Phase 4</p>
<p class="story-review-heading__phase">Build Story Database</p>
<div class="onboarding-success-heading"> <div class="onboarding-success-heading">
<span aria-hidden="true">&check;</span> <span aria-hidden="true">&check;</span>
<h1 id="story-complete-title">Story Intelligence complete</h1> <h1 id="story-complete-title">Story Intelligence complete</h1>
</div> </div>
<p>PlotDirector completed every implemented Story Intelligence stage for this manuscript. Future stages will continue from this same pipeline as they become available.</p> <p>PlotDirector has built the implemented story database records from your reviewed manuscript analysis.</p>
</div> </div>
<section class="story-database-build" aria-label="Story database build checklist">
<h2>Building your Story Database...</h2>
<ul>
<li><span aria-hidden="true">&check;</span><strong>Chapters</strong></li>
<li><span aria-hidden="true">&check;</span><strong>Scenes</strong></li>
<li><span aria-hidden="true">&check;</span><strong>Characters</strong></li>
<li><span aria-hidden="true">&check;</span><strong>Locations</strong></li>
<li><span aria-hidden="true">&check;</span><strong>Assets</strong></li>
<li><span aria-hidden="true">&check;</span><strong>Relationships</strong></li>
<li class="is-upcoming"><span aria-hidden="true">○</span><strong>Knowledge</strong><em>Coming Soon</em></li>
<li><span aria-hidden="true">&check;</span><strong>Preparing continuity...</strong></li>
<li><span aria-hidden="true">&check;</span><strong>Generating story indexes...</strong></li>
<li><span aria-hidden="true">&check;</span><strong>Finalising project...</strong></li>
</ul>
</section>
<div class="onboarding-scan-counts onboarding-review-counts"> <div class="onboarding-scan-counts onboarding-review-counts">
<div> <div>
<span>Chapters analysed</span> <span>Chapters analysed</span>
@ -28,19 +45,24 @@
<span>Characters created / linked</span> <span>Characters created / linked</span>
<strong>@Model.CharactersCreatedOrLinked.ToString("N0")</strong> <strong>@Model.CharactersCreatedOrLinked.ToString("N0")</strong>
</div> </div>
<div>
<span>Locations created / linked</span>
<strong>@Model.LocationsCreatedOrLinked.ToString("N0")</strong>
</div>
<div>
<span>Assets created / linked</span>
<strong>@Model.AssetsCreatedOrLinked.ToString("N0")</strong>
</div>
<div>
<span>Relationships created / linked</span>
<strong>@Model.RelationshipsCreatedOrLinked.ToString("N0")</strong>
</div>
<div> <div>
<span>Book</span> <span>Book</span>
<strong>@Model.BookTitle</strong> <strong>@Model.BookTitle</strong>
</div> </div>
</div> </div>
<div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Locations</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Assets</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Relationships</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Knowledge</strong><span>Coming Soon</span></article>
</div>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">View scenes</a> <a class="btn btn-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">View scenes</a>
<a class="btn btn-outline-primary" asp-controller="Books" asp-action="Details" asp-route-id="@Model.BookID">View book</a> <a class="btn btn-outline-primary" asp-controller="Books" asp-action="Details" asp-route-id="@Model.BookID">View book</a>

View File

@ -0,0 +1,53 @@
@model StoryIntelligenceLocationImportResultViewModel
@{
ViewData["Title"] = "Locations created";
}
<section class="onboarding-shell" aria-labelledby="story-location-complete-title">
<div class="onboarding-panel onboarding-review-panel">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Assets" })" />
<div class="onboarding-copy">
<p class="eyebrow">Build Story Database</p>
<div class="onboarding-success-heading">
<span aria-hidden="true">&check;</span>
<h1 id="story-location-complete-title">Locations created</h1>
</div>
<p>PlotDirector updated your story database from the approved location decisions. Future Story Intelligence stages will continue from here.</p>
</div>
<div class="onboarding-scan-counts onboarding-review-counts">
<div>
<span>Locations created</span>
<strong>@Model.LocationsCreated.ToString("N0")</strong>
</div>
<div>
<span>Locations linked</span>
<strong>@Model.LocationsLinked.ToString("N0")</strong>
</div>
<div>
<span>Aliases added</span>
<strong>@Model.LocationAliasesAdded.ToString("N0")</strong>
</div>
<div>
<span>Locations ignored</span>
<strong>@Model.LocationsIgnored.ToString("N0")</strong>
</div>
<div>
<span>Scene World panels updated</span>
<strong>@Model.SceneLocationsUpdated.ToString("N0")</strong>
</div>
</div>
<div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Assets</strong><span>Next</span></article>
<article class="story-future-stage"><strong>Review Relationships</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>
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Back to locations</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Review assets</a>
</div>
</div>
</section>

View File

@ -0,0 +1,221 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Review locations";
}
<section class="onboarding-shell" aria-labelledby="story-location-title">
<div class="onboarding-panel onboarding-review-panel story-review-page">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Locations" })" />
<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 3 of 4</p>
<h1 id="story-location-title">Review Locations</h1>
<p>Approve the places 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="Location review">
@if (Model.LocationReview.Candidates.Count == 0)
{
<div class="story-review-note">
<strong>No location decisions are waiting.</strong>
<p class="mb-0">Locations are already up to date, or there were no location suggestions ready to import.</p>
</div>
<div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Assets</strong><span>Next</span></article>
@FutureStage("Review Relationships")
@FutureStage("Review Knowledge")
@FutureStage("Review Continuity")
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID">Back to characters</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Continue</a>
</div>
}
else
{
<div class="story-review-card-actions">
<button class="btn btn-outline-primary btn-sm" type="button" data-location-bulk="create-all">Create all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="link-existing">Link existing matches</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="ignore-all">Ignore all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="expand">Expand all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button>
</div>
<form asp-action="ImportStoryIntelligenceLocations" method="post" data-location-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<div class="story-character-card-grid">
@for (var i = 0; i < Model.LocationReview.Candidates.Count; i++)
{
var candidate = Model.LocationReview.Candidates[i];
<details class="story-character-card" open data-location-card>
<summary>
<span>
<strong>@candidate.LocationName</strong>
<small>@candidate.Category · @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
</span>
<em>@candidate.Confidence</em>
</summary>
<input type="hidden" name="Locations[@i].Key" value="@candidate.Key" />
<div class="story-character-card__body" data-location-key="@candidate.Key" data-location-name="@candidate.LocationName">
<dl>
<div><dt>First appearance</dt><dd>@Display(candidate.ExampleFirstAppearance)</dd></div>
<div><dt>Parent hint</dt><dd>@Display(candidate.ParentLocationHint)</dd></div>
<div><dt>Existing match</dt><dd>@(candidate.ExistingLocationName ?? "None")</dd></div>
</dl>
@if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
{
<div class="story-review-note">
<strong>Scene context</strong>
<p>@candidate.ExampleContext</p>
</div>
}
@if (candidate.IsExistingMatch)
{
<div class="story-review-note">
<strong>Possible existing location</strong>
<p>@candidate.LocationName may already be @candidate.ExistingLocationName. Choose whether to link them or create a separate location.</p>
</div>
}
<fieldset class="story-character-actions">
<legend>Decision</legend>
<label>
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.CreateNew" checked data-location-action />
Create new location
</label>
<label>
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.LinkExisting" data-location-action @(candidate.IsExistingMatch ? string.Empty : "disabled") />
Link existing location
</label>
<label>
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.Alias" data-location-action />
Alias of another location
</label>
<label>
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.Ignore" data-location-action />
Ignore
</label>
</fieldset>
<div data-location-import-name-panel>
<label class="form-label" for="location-import-name-@i">Import name</label>
<input id="location-import-name-@i" class="form-control" name="Locations[@i].ImportName" value="@candidate.ImportName" />
</div>
<div data-location-alias-panel hidden>
<label class="form-label" for="location-alias-target-@i">Alias/variant target</label>
<select id="location-alias-target-@i" class="form-select" name="Locations[@i].AliasTargetKey" data-location-alias-target>
<option value="">Choose location...</option>
@foreach (var target in Model.LocationReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
{
<option value="@target.Key">@target.LocationName@(target.IsExistingMatch ? $" -> {target.ExistingLocationName}" : string.Empty)</option>
}
</select>
</div>
</div>
</details>
}
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID">Back to characters</a>
<button class="btn btn-primary" type="submit">Create locations</button>
</div>
</form>
}
</section>
</div>
</section>
@section Scripts {
<script>
(() => {
const form = document.querySelector("[data-location-review-form]");
if (!form) return;
const cards = () => Array.from(form.querySelectorAll("[data-location-card]"));
const setAction = (card, action) => {
const input = card.querySelector(`[data-location-action][value="${action}"]`);
if (input) {
input.checked = true;
updateCard(card);
}
};
const actionFor = (card) => card.querySelector("[data-location-action]:checked")?.value || "@StoryIntelligenceLocationImportActions.CreateNew";
const isCanonicalTarget = (card) => {
const action = actionFor(card);
return action !== "@StoryIntelligenceLocationImportActions.Ignore" && action !== "@StoryIntelligenceLocationImportActions.Alias";
};
const updateAliasTargets = () => {
const allCards = cards();
for (const card of allCards) {
const body = card.querySelector("[data-location-key]");
const ownKey = body?.getAttribute("data-location-key") || "";
const select = card.querySelector("[data-location-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-location-key]")?.getAttribute("data-location-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 namePanel = card.querySelector("[data-location-import-name-panel]");
const aliasPanel = card.querySelector("[data-location-alias-panel]");
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.Alias";
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceLocationImportActions.Alias";
updateAliasTargets();
};
document.querySelectorAll("[data-location-bulk]").forEach(button => {
button.addEventListener("click", () => {
const action = button.getAttribute("data-location-bulk");
if (action === "create-all") cards().forEach(card => setAction(card, "@StoryIntelligenceLocationImportActions.CreateNew"));
if (action === "link-existing") cards().forEach(card => {
if (card.querySelector(`[data-location-action][value="@StoryIntelligenceLocationImportActions.LinkExisting"]:not(:disabled)`)) {
setAction(card, "@StoryIntelligenceLocationImportActions.LinkExisting");
}
});
if (action === "ignore-all") cards().forEach(card => setAction(card, "@StoryIntelligenceLocationImportActions.Ignore"));
if (action === "expand") cards().forEach(card => card.open = true);
if (action === "collapse") cards().forEach(card => card.open = false);
});
});
form.querySelectorAll("[data-location-action]").forEach(input => {
input.addEventListener("change", () => updateCard(input.closest("[data-location-card]")));
});
cards().forEach(updateCard);
})();
</script>
}
@functions {
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value;
private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label)
=> new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Coming Soon</span></article>");
}

View File

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

View File

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

View File

@ -14,8 +14,10 @@
<section class="onboarding-shell" aria-labelledby="story-review-title"> <section class="onboarding-shell" aria-labelledby="story-review-title">
<div class="onboarding-panel onboarding-review-panel story-review-page"> <div class="onboarding-panel onboarding-review-panel story-review-page">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Scenes" })" /> <partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Scenes" })" />
<div class="onboarding-copy"> <div class="onboarding-copy story-review-heading">
<p class="eyebrow">Review Story Intelligence</p> <p class="eyebrow">Phase 3</p>
<p class="story-review-heading__phase">Review Story Intelligence</p>
<p class="story-review-heading__stage">Stage 1 of 4</p>
<h1 id="story-review-title">Review Scenes</h1> <h1 id="story-review-title">Review Scenes</h1>
<p>PlotDirector has finished reading your manuscript. Review the detected scenes before creating them in your story database.</p> <p>PlotDirector has finished reading your manuscript. Review the detected scenes before creating them in your story database.</p>
</div> </div>

View File

@ -1,50 +1,130 @@
@model StoryIntelligencePipelineHeaderViewModel @model StoryIntelligencePipelineHeaderViewModel
@{ @{
var phases = new[] var reviewStages = new[]
{ {
new { Name = "Phase 1", Label = "Prepare manuscript", Stages = new[] { "Connect Word Companion", "Scan Manuscript", "Review Chapters" } }, new PipelineStage("Review Scenes", "Scenes", true),
new { Name = "Phase 2", Label = "Analyse manuscript", Stages = new[] { "Analyse Manuscript" } }, new PipelineStage("Review Characters", "Characters", true),
new { Name = "Phase 3", Label = "Review Story Intelligence", Stages = new[] { "Review Scenes", "Review Characters", "Review Locations", "Review Assets", "Review Relationships", "Review Knowledge" } }, new PipelineStage("Review Locations", "Locations", true),
new { Name = "Phase 4", Label = "Build Story Database", Stages = new[] { "Create Scenes", "Create Characters", "Complete" } } new PipelineStage("Review Assets", "Assets", true),
new PipelineStage("Review Relationships", "Relationships", true),
new PipelineStage("Review Knowledge", "Knowledge", false)
}; };
var flatStages = phases.SelectMany(phase => phase.Stages).ToList(); var implementedReviewStages = reviewStages.Where(stage => stage.IsImplemented).ToList();
var currentIndex = Math.Max(0, flatStages.FindIndex(stage => string.Equals(stage, Model.CurrentStage, StringComparison.OrdinalIgnoreCase))); var currentReviewIndex = implementedReviewStages.FindIndex(stage => string.Equals(stage.StageName, Model.CurrentStage, StringComparison.OrdinalIgnoreCase));
var percent = flatStages.Count <= 1 ? 100 : Math.Clamp((currentIndex * 100) / (flatStages.Count - 1), 0, 100); var isReviewPhase = currentReviewIndex >= 0;
var isComplete = string.Equals(Model.CurrentStage, "Complete", StringComparison.OrdinalIgnoreCase);
var isAnalyse = string.Equals(Model.CurrentStage, "Analyse Manuscript", StringComparison.OrdinalIgnoreCase);
var activePhase = isComplete ? 4 : isReviewPhase ? 3 : isAnalyse ? 2 : 1;
var progressPercent = isReviewPhase
? Math.Clamp(((currentReviewIndex + 1) * 100) / implementedReviewStages.Count, 0, 100)
: isComplete ? 100 : 100;
var currentStageNumber = isReviewPhase ? currentReviewIndex + 1 : isComplete ? implementedReviewStages.Count : 0;
} }
<div class="onboarding-progress story-pipeline-progress" aria-label="Story Intelligence pipeline progress"> <section class="story-pipeline-header" aria-label="Story Intelligence pipeline">
<span>@Model.CurrentStage</span> <div class="story-pipeline-current">
<div class="onboarding-progress-track"> <p class="eyebrow">Phase @activePhase</p>
<span style="width:@percent%"></span> <h2>@CurrentPhaseTitle(activePhase)</h2>
</div> @if (isReviewPhase)
</div>
<ol class="onboarding-stepper story-pipeline-stepper" aria-label="Story Intelligence pipeline">
@foreach (var phase in phases)
{ {
var firstStageIndex = flatStages.FindIndex(stage => phase.Stages.Contains(stage)); <p>Stage @currentStageNumber of @implementedReviewStages.Count</p>
var lastStageIndex = firstStageIndex + phase.Stages.Length - 1; }
var phaseClass = currentIndex > lastStageIndex else if (isComplete)
{
<p>Final import and completion</p>
}
else if (isAnalyse)
{
<p>Story Intelligence Analysis</p>
}
else
{
<p>Prepare the manuscript for analysis</p>
}
</div>
<div class="onboarding-progress story-pipeline-progress" aria-label="@CurrentPhaseTitle(activePhase) progress">
<span>@ProgressLabel(activePhase, Model.CurrentStage)</span>
<div class="onboarding-progress-track">
<span style="width:@progressPercent%"></span>
</div>
</div>
<ol class="story-pipeline-phase-list">
<li class="@(activePhase > 1 ? "is-complete" : activePhase == 1 ? "is-current" : "is-upcoming")">
<strong>Prepare Manuscript</strong>
<span>7 completed steps</span>
<ul>
<li>Welcome</li>
<li>Writing Preferences</li>
<li>Project</li>
<li>Book</li>
<li>Connect Word</li>
<li>Scan Manuscript</li>
<li>Review Chapters</li>
</ul>
</li>
<li class="@(activePhase > 2 ? "is-complete" : activePhase == 2 ? "is-current" : "is-upcoming")">
<strong>Analyse Manuscript</strong>
<span>Story Intelligence Analysis</span>
</li>
<li class="@(activePhase > 3 ? "is-complete" : activePhase == 3 ? "is-current" : "is-upcoming")">
<strong>Review Story Intelligence</strong>
<span>@ReviewSummary(isReviewPhase, currentStageNumber, implementedReviewStages.Count)</span>
<ul class="story-pipeline-review-list">
@foreach (var stage in reviewStages)
{
var index = implementedReviewStages.FindIndex(item => string.Equals(item.StageName, stage.StageName, StringComparison.OrdinalIgnoreCase));
var stageClass = !stage.IsImplemented
? "is-upcoming"
: isComplete || index < currentReviewIndex
? "is-complete" ? "is-complete"
: currentIndex >= firstStageIndex && currentIndex <= lastStageIndex : index == currentReviewIndex
? "is-current" ? "is-current"
: "is-upcoming"; : "is-upcoming";
<li class="@phaseClass"> <li class="@stageClass">
<span>@phase.Name.Replace("Phase ", string.Empty)</span> <span aria-hidden="true">@StageMarker(stageClass, stage.IsImplemented)</span>
<strong>@phase.Label</strong> <strong>@stage.Label</strong>
<small>@StageSummary(phase.Stages, currentIndex, firstStageIndex)</small> @if (!stage.IsImplemented)
{
<em>Coming Soon</em>
}
</li> </li>
} }
</ul>
</li>
<li class="@(activePhase == 4 ? "is-current" : "is-upcoming")">
<strong>Build Story Database</strong>
<span>Final import and completion</span>
</li>
</ol> </ol>
</section>
@functions { @functions {
private static string StageSummary(IReadOnlyList<string> stages, int currentIndex, int firstStageIndex) private sealed record PipelineStage(string StageName, string Label, bool IsImplemented);
{
var localIndex = currentIndex - firstStageIndex;
if (localIndex >= 0 && localIndex < stages.Count)
{
return stages[localIndex];
}
return stages.Count == 1 ? stages[0] : $"{stages.Count} stages"; private static string CurrentPhaseTitle(int phase)
} => phase switch
{
1 => "Prepare Manuscript",
2 => "Analyse Manuscript",
3 => "Review Story Intelligence",
_ => "Build Story Database"
};
private static string ProgressLabel(int phase, string currentStage)
=> phase switch
{
3 => currentStage,
4 => "Finalising import",
_ => "Completed"
};
private static string ReviewSummary(bool isReviewPhase, int currentStageNumber, int implementedStageCount)
=> isReviewPhase
? $"{Math.Max(0, currentStageNumber - 1)} of {implementedStageCount} review stages completed"
: $"{implementedStageCount} review stages completed";
private static string StageMarker(string stageClass, bool isImplemented)
=> !isImplemented ? "○" : stageClass == "is-current" ? "►" : stageClass == "is-complete" ? "✓" : "○";
} }

View File

@ -18,16 +18,28 @@
<strong>@(Model.CharacterStageComplete ? $"Done: {Model.CharactersCreatedOrLinked:N0}" : "In review")</strong> <strong>@(Model.CharacterStageComplete ? $"Done: {Model.CharactersCreatedOrLinked:N0}" : "In review")</strong>
</div> </div>
<div> <div>
<span>Locations</span> <span>Locations identified</span>
<strong>Coming next</strong> <strong>@Model.LocationsIdentified.ToString("N0")</strong>
</div> </div>
<div> <div>
<span>Assets</span> <span>Locations created / linked</span>
<strong>Coming next</strong> <strong>@(Model.LocationStageComplete ? $"Done: {Model.LocationsCreatedOrLinked:N0}" : "In review")</strong>
</div> </div>
<div> <div>
<span>Relationships</span> <span>Assets identified</span>
<strong>Coming next</strong> <strong>@Model.AssetsIdentified.ToString("N0")</strong>
</div>
<div>
<span>Assets created / linked</span>
<strong>@(Model.AssetStageComplete ? $"Done: {Model.AssetsCreatedOrLinked:N0}" : "In review")</strong>
</div>
<div>
<span>Relationships identified</span>
<strong>@Model.RelationshipsIdentified.ToString("N0")</strong>
</div>
<div>
<span>Relationships created / linked</span>
<strong>@(Model.RelationshipStageComplete ? $"Done: {Model.RelationshipsCreatedOrLinked:N0}" : "In review")</strong>
</div> </div>
<div> <div>
<span>Knowledge</span> <span>Knowledge</span>

View File

@ -807,6 +807,195 @@ summary.story-review-chapter-heading {
gap: 1rem; gap: 1rem;
} }
.story-pipeline-header {
display: grid;
gap: 1rem;
margin-bottom: 1.5rem;
}
.story-pipeline-current {
display: grid;
gap: .15rem;
}
.story-pipeline-current h2 {
margin: 0;
font-size: clamp(1.45rem, 3vw, 2.15rem);
}
.story-pipeline-current p:not(.eyebrow) {
margin: 0;
color: var(--bs-secondary-color);
font-weight: 800;
}
.story-pipeline-progress {
margin-bottom: 0;
}
.story-pipeline-phase-list {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: .75rem;
margin: 0;
padding: 0;
list-style: none;
}
.story-pipeline-phase-list > li {
display: grid;
align-content: start;
gap: .45rem;
min-width: 0;
border: 1px solid rgba(31, 42, 68, .12);
border-radius: 8px;
padding: .85rem;
background: rgba(255, 255, 255, .58);
}
.story-pipeline-phase-list > li.is-current {
border-color: rgba(47, 111, 99, .42);
background: rgba(47, 111, 99, .08);
box-shadow: inset 0 0 0 1px rgba(47, 111, 99, .12);
}
.story-pipeline-phase-list > li.is-complete {
background: rgba(47, 111, 99, .06);
}
.story-pipeline-phase-list > li.is-complete > strong::before {
content: "✓ ";
color: #2f6f63;
}
.story-pipeline-phase-list > li > strong {
font-size: .98rem;
}
.story-pipeline-phase-list > li > span {
color: var(--bs-secondary-color);
font-size: .82rem;
font-weight: 800;
}
.story-pipeline-phase-list ul {
display: grid;
gap: .2rem;
margin: .25rem 0 0;
padding: 0;
list-style: none;
color: var(--bs-secondary-color);
font-size: .8rem;
}
.story-pipeline-review-list li {
display: flex;
align-items: center;
gap: .35rem;
}
.story-pipeline-review-list li strong {
font-size: .82rem;
}
.story-pipeline-review-list li span {
width: 1rem;
color: var(--bs-secondary-color);
font-weight: 900;
}
.story-pipeline-review-list li.is-complete span {
color: #2f6f63;
}
.story-pipeline-review-list li.is-current {
color: var(--bs-body-color);
}
.story-pipeline-review-list li.is-current span,
.story-pipeline-review-list li.is-current strong {
color: var(--bs-primary);
}
.story-pipeline-review-list li em {
color: var(--bs-secondary-color);
font-size: .74rem;
font-style: normal;
font-weight: 800;
}
.story-review-heading {
display: grid;
gap: .15rem;
}
.story-review-heading p {
margin: 0;
}
.story-review-heading__phase {
color: var(--bs-secondary-color);
font-size: .92rem;
font-weight: 800;
}
.story-review-heading__stage {
color: var(--bs-secondary-color);
font-size: .84rem;
font-weight: 800;
}
.story-database-build {
display: grid;
gap: .8rem;
border: 1px solid rgba(47, 111, 99, .18);
border-radius: 8px;
padding: 1rem;
background: rgba(47, 111, 99, .07);
}
.story-database-build h2 {
margin: 0;
font-size: 1.2rem;
}
.story-database-build ul {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .5rem .9rem;
margin: 0;
padding: 0;
list-style: none;
}
.story-database-build li {
display: flex;
align-items: center;
gap: .45rem;
font-weight: 800;
}
.story-database-build li span {
color: #2f6f63;
font-weight: 900;
}
.story-database-build li em {
color: var(--bs-secondary-color);
font-size: .76rem;
font-style: normal;
font-weight: 800;
margin-left: auto;
}
.story-database-build li.is-upcoming {
color: var(--bs-secondary-color);
}
.story-database-build li.is-upcoming span {
color: var(--bs-secondary-color);
}
.story-review-summary { .story-review-summary {
padding: 1rem; padding: 1rem;
} }
@ -1361,6 +1550,11 @@ summary.story-review-chapter-heading {
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.story-pipeline-phase-list,
.story-database-build ul {
grid-template-columns: 1fr;
}
.onboarding-choice-grid, .onboarding-choice-grid,
.onboarding-option-grid--software { .onboarding-option-grid--software {
grid-template-columns: 1fr; grid-template-columns: 1fr;