Compare commits
10 Commits
3cf68740dd
...
e8b42664f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8b42664f1 | ||
|
|
7f9f051341 | ||
|
|
ab3b591e4d | ||
|
|
d44217162c | ||
|
|
11de81abed | ||
|
|
55cded0b40 | ||
|
|
4b6719e242 | ||
|
|
ef51765eb4 | ||
|
|
f4897e5d5f | ||
|
|
4fee5846ab |
51
Docs/StoryIntelligence_Phase20AS_RelationshipImport.md
Normal file
51
Docs/StoryIntelligence_Phase20AS_RelationshipImport.md
Normal file
@ -0,0 +1,51 @@
|
||||
# Phase 20AS - Relationship Intelligence Review and Import
|
||||
|
||||
Phase 20AS adds Relationship Review as the next implemented Story Intelligence review stage after Assets.
|
||||
|
||||
## Implemented Behaviour
|
||||
|
||||
- Relationship candidates are built only from stored Scene Intelligence JSON.
|
||||
- No manuscript reread occurs.
|
||||
- No OpenAI calls are made.
|
||||
- No Story Intelligence prompts are changed.
|
||||
- Duplicate relationship detections are merged by resolved character pair, ignoring direction.
|
||||
- Candidates are only actionable when both character names resolve to existing PlotDirector characters or aliases.
|
||||
- Existing PlotDirector relationships are suggested when the same character pair already has a relationship.
|
||||
- Authors can choose:
|
||||
- Create new relationship
|
||||
- Link existing relationship
|
||||
- Merge with another reviewed relationship
|
||||
- Ignore
|
||||
|
||||
## Imported Data
|
||||
|
||||
Approved relationship decisions can create or link:
|
||||
|
||||
- `CharacterRelationships`
|
||||
- `RelationshipEvents`
|
||||
|
||||
Relationship events are created only from stored relationship signals or observations already present in the scene intelligence result. The importer does not invent relationship changes.
|
||||
|
||||
Imported relationship records and events are available to existing PlotDirector relationship surfaces, including character pages, relationship pages and relationship map views.
|
||||
|
||||
## Pipeline Position
|
||||
|
||||
The current implemented pipeline is now:
|
||||
|
||||
1. Review Scenes
|
||||
2. Create Scenes
|
||||
3. Review Characters
|
||||
4. Create Characters
|
||||
5. Review Locations
|
||||
6. Create Locations
|
||||
7. Review Assets
|
||||
8. Create Assets
|
||||
9. Review Relationships
|
||||
10. Create Relationships
|
||||
11. Complete
|
||||
|
||||
Future Story Intelligence stages remain:
|
||||
|
||||
- Knowledge
|
||||
- Continuity
|
||||
- Timeline Events
|
||||
@ -10,7 +10,15 @@ var tests = new (string Name, Action Test)[]
|
||||
("Truncated JSON is rejected", RejectsTruncatedJson),
|
||||
("Repaired JSON deserialises into SceneIntelligenceScene", RepairedJsonDeserialises),
|
||||
("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)
|
||||
@ -106,6 +114,138 @@ static void SharedParserReportsRawOutput()
|
||||
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()
|
||||
=> new()
|
||||
{
|
||||
|
||||
@ -75,6 +75,9 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
||||
return target.Route switch
|
||||
{
|
||||
StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }),
|
||||
StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = target.BatchID }),
|
||||
StoryIntelligenceResumeRoutes.Assets => RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = target.BatchID }),
|
||||
StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = target.BatchID }),
|
||||
StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = target.BatchID }),
|
||||
_ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID })
|
||||
};
|
||||
@ -156,6 +159,93 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
||||
: 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")]
|
||||
[ValidateAntiForgeryToken]
|
||||
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);
|
||||
}
|
||||
|
||||
[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")]
|
||||
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
|
||||
{
|
||||
@ -243,6 +402,21 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
||||
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);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
88
PlotLine/Docs/StoryIntelligence_Phase20AP_AssetImport.md
Normal file
88
PlotLine/Docs/StoryIntelligence_Phase20AP_AssetImport.md
Normal 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.
|
||||
@ -28,6 +28,12 @@ public static class StoryIntelligencePipelineStages
|
||||
public const string SceneImport = "SceneImport";
|
||||
public const string CharacterReview = "CharacterReview";
|
||||
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";
|
||||
}
|
||||
|
||||
@ -56,7 +62,9 @@ public sealed class StoryIntelligenceBookPipelineState
|
||||
public DateTime CreatedUtc { 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
|
||||
|
||||
@ -208,6 +208,9 @@ public class Program
|
||||
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceCharacterImportService, StoryIntelligenceCharacterImportService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceLocationImportService, StoryIntelligenceLocationImportService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
|
||||
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
||||
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
||||
|
||||
@ -16,6 +16,12 @@ public interface IOnboardingStoryIntelligenceService
|
||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId);
|
||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form);
|
||||
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<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
|
||||
}
|
||||
@ -27,6 +33,9 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
IStoryIntelligenceResultRepository runs,
|
||||
IStoryIntelligenceImportCommitService commits,
|
||||
IStoryIntelligenceCharacterImportService characterImport,
|
||||
IStoryIntelligenceLocationImportService locationImport,
|
||||
IStoryIntelligenceAssetImportService assetImport,
|
||||
IStoryIntelligenceRelationshipImportService relationshipImport,
|
||||
IStoryIntelligencePipelineStateService pipelineState,
|
||||
IStoryIntelligenceClient client,
|
||||
IOnboardingStoryIntelligenceBatchStore batchStore,
|
||||
@ -259,6 +268,9 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
}
|
||||
|
||||
var characterReview = await characterImport.BuildReviewAsync(batch);
|
||||
var locationReview = await locationImport.BuildReviewAsync(batch);
|
||||
var assetReview = await assetImport.BuildReviewAsync(batch);
|
||||
var relationshipReview = await relationshipImport.BuildReviewAsync(batch);
|
||||
return new StoryIntelligenceProgressViewModel
|
||||
{
|
||||
BatchID = batch.BatchID,
|
||||
@ -269,13 +281,25 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
BookTitle = batch.BookTitle,
|
||||
Chapters = chapters,
|
||||
CharacterReview = characterReview,
|
||||
LocationReview = locationReview,
|
||||
AssetReview = assetReview,
|
||||
RelationshipReview = relationshipReview,
|
||||
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
|
||||
{
|
||||
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
|
||||
ScenesImported = chapters.Sum(chapter => chapter.ScenesCreated),
|
||||
CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count,
|
||||
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,
|
||||
CharactersCreated = batch.LastCharacterImportResult?.CharactersCreated ?? 0,
|
||||
CharactersLinked = batch.LastCharacterImportResult?.CharactersLinked ?? 0,
|
||||
CharactersAliased = batch.LastCharacterImportResult?.CharactersAliased ?? 0,
|
||||
CharactersIgnored = batch.LastCharacterImportResult?.CharactersIgnored ?? 0,
|
||||
PovLinksResolved = batch.LastCharacterImportResult?.PovLinksResolved ?? 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)
|
||||
{
|
||||
var progress = await GetProgressAsync(batchId);
|
||||
@ -395,7 +520,13 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
ChaptersCommitted = progress.CommittedChapterCount,
|
||||
ScenesCreated = progress.Chapters.Sum(chapter => chapter.ScenesCreated),
|
||||
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()
|
||||
};
|
||||
|
||||
if (state.IsComplete)
|
||||
{
|
||||
batch.CharacterStageComplete = true;
|
||||
}
|
||||
var characterStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.CharacterImport)
|
||||
|| state.CurrentStage is StoryIntelligencePipelineStages.LocationReview
|
||||
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);
|
||||
|
||||
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.CharacterImport => StoryIntelligenceResumeRoutes.Characters,
|
||||
StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview,
|
||||
@ -457,6 +617,9 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
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)
|
||||
{
|
||||
var state = await onboardingRepository.GetAsync(userId);
|
||||
@ -685,8 +848,17 @@ public sealed class OnboardingStoryIntelligenceBatch
|
||||
public DateTime CreatedUtc { get; init; } = DateTime.UtcNow;
|
||||
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
|
||||
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
|
||||
public List<OnboardingStoryIntelligenceLocationDecision> LocationDecisions { get; } = [];
|
||||
public List<OnboardingStoryIntelligenceAssetDecision> AssetDecisions { get; } = [];
|
||||
public List<OnboardingStoryIntelligenceRelationshipDecision> RelationshipDecisions { get; } = [];
|
||||
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
|
||||
public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; }
|
||||
public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; }
|
||||
public StoryIntelligenceRelationshipImportBatchResult? LastRelationshipImportResult { get; set; }
|
||||
public bool CharacterStageComplete { get; set; }
|
||||
public bool LocationStageComplete { get; set; }
|
||||
public bool AssetStageComplete { get; set; }
|
||||
public bool RelationshipStageComplete { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OnboardingStoryIntelligenceBatchItem
|
||||
@ -707,19 +879,78 @@ public sealed class OnboardingStoryIntelligenceCharacterDecision
|
||||
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 int CharactersCreated { get; init; }
|
||||
public int CharactersLinked { get; init; }
|
||||
public int CharactersAliased { get; init; }
|
||||
public int CharactersIgnored { get; init; }
|
||||
public int PovLinksResolved { 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 const string SceneReview = "SceneReview";
|
||||
public const string Characters = "Characters";
|
||||
public const string Locations = "Locations";
|
||||
public const string Assets = "Assets";
|
||||
public const string Relationships = "Relationships";
|
||||
public const string Complete = "Complete";
|
||||
}
|
||||
|
||||
|
||||
843
PlotLine/Services/StoryIntelligenceAssetImportService.cs
Normal file
843
PlotLine/Services/StoryIntelligenceAssetImportService.cs
Normal 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);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.ViewModels;
|
||||
@ -31,11 +32,38 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
private static readonly HashSet<string> GenericPeople = CreateSet(
|
||||
"man", "woman", "boy", "girl", "child", "children", "person", "people", "doctor", "nurse",
|
||||
"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(
|
||||
"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)
|
||||
{
|
||||
@ -86,9 +114,12 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
var created = 0;
|
||||
var linkedExisting = 0;
|
||||
var ignored = 0;
|
||||
var aliased = 0;
|
||||
var linkedAppearances = 0;
|
||||
var povLinks = 0;
|
||||
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)
|
||||
{
|
||||
@ -104,14 +135,19 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var requestedName = Clean(choice.ImportName);
|
||||
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
|
||||
var forceCreateSeparate = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.CreateSeparate, StringComparison.OrdinalIgnoreCase);
|
||||
var matchedExistingId = forceCreateSeparate
|
||||
? null
|
||||
: candidate.ExistingCharacterID
|
||||
var linkExisting = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
|
||||
var matchedExistingId = linkExisting
|
||||
? candidate.ExistingCharacterID
|
||||
?? existingIndex.Find(importName)?.CharacterID
|
||||
?? existingIndex.Find(candidate.DisplayName)?.CharacterID;
|
||||
?? existingIndex.Find(candidate.DisplayName)?.CharacterID
|
||||
: null;
|
||||
var characterId = matchedExistingId;
|
||||
|
||||
if (!characterId.HasValue)
|
||||
@ -151,9 +187,60 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
linkedAppearances++;
|
||||
}
|
||||
|
||||
resolvedCandidateIds[candidate.Key] = characterId.Value;
|
||||
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)
|
||||
{
|
||||
var povName = Clean(scene.Parsed.PointOfView?.CharacterName);
|
||||
@ -172,16 +259,18 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
CharactersCreated = created,
|
||||
CharactersLinked = linkedExisting,
|
||||
CharactersIgnored = ignored,
|
||||
CharactersAliased = aliased,
|
||||
PovLinksResolved = povLinks,
|
||||
ScenePeoplePanelsUpdated = linkedAppearances
|
||||
};
|
||||
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
|
||||
|
||||
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,
|
||||
created,
|
||||
linkedExisting,
|
||||
aliased,
|
||||
ignored,
|
||||
linkedAppearances,
|
||||
povLinks);
|
||||
@ -190,7 +279,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
{
|
||||
Success = true,
|
||||
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
|
||||
.Where(candidate => candidate.PresentAppearances.Count > 0 || candidate.MentionedOnlyAppearances.Count > 0)
|
||||
.Where(candidate => candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Any(appearance => !appearance.AlreadyLinked))
|
||||
.Where(IsVisibleCharacterCandidate)
|
||||
.OrderByDescending(candidate => candidate.PresentAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
|
||||
.ThenByDescending(candidate => candidate.MentionedOnlyAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
|
||||
.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)
|
||||
{
|
||||
var note = Clean(character.Notes);
|
||||
@ -551,6 +647,30 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
private static bool IsCharacterEntity(string? entityType)
|
||||
=> 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)
|
||||
{
|
||||
var clean = CleanCharacterName(name);
|
||||
@ -570,6 +690,80 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
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(
|
||||
IReadOnlyDictionary<string, CharacterCandidate> groups,
|
||||
CharacterIndex existingIndex,
|
||||
@ -636,6 +830,15 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
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)
|
||||
=> value.Equals("instructor", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("detective", StringComparison.OrdinalIgnoreCase)
|
||||
|
||||
836
PlotLine/Services/StoryIntelligenceLocationImportService.cs
Normal file
836
PlotLine/Services/StoryIntelligenceLocationImportService.cs
Normal 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);
|
||||
}
|
||||
@ -10,6 +10,9 @@ public interface IStoryIntelligencePipelineStateService
|
||||
Task<StoryIntelligenceBookPipelineState?> EnsureForBookAsync(int bookId, int userId);
|
||||
Task RecordSceneImportAsync(int projectId, int bookId, int runId);
|
||||
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);
|
||||
}
|
||||
|
||||
@ -80,8 +83,53 @@ public sealed class StoryIntelligencePipelineStateService(
|
||||
{
|
||||
ProjectID = projectId,
|
||||
BookID = bookId,
|
||||
CurrentStage = StoryIntelligencePipelineStages.Complete,
|
||||
CurrentStage = StoryIntelligencePipelineStages.LocationReview,
|
||||
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,
|
||||
Status = StoryIntelligencePipelineStatuses.Complete,
|
||||
CompletedUtc = DateTime.UtcNow,
|
||||
|
||||
670
PlotLine/Services/StoryIntelligenceRelationshipImportService.cs
Normal file
670
PlotLine/Services/StoryIntelligenceRelationshipImportService.cs
Normal file
@ -0,0 +1,670 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface IStoryIntelligenceRelationshipImportService
|
||||
{
|
||||
Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
|
||||
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceRelationshipImportService(
|
||||
IStoryIntelligenceResultRepository runs,
|
||||
ICharacterRepository characters,
|
||||
ISceneRepository scenes,
|
||||
IStoryIntelligencePipelineStateService pipelineState,
|
||||
ILogger<StoryIntelligenceRelationshipImportService> logger) : IStoryIntelligenceRelationshipImportService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public async Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
||||
{
|
||||
var data = await BuildCandidateDataAsync(batch);
|
||||
var decidedKeys = batch.RelationshipDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
|
||||
return new StoryIntelligenceRelationshipReviewViewModel
|
||||
{
|
||||
HasCommittedScenes = data.HasCommittedScenes,
|
||||
CanImport = visibleCandidates.Count > 0,
|
||||
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
||||
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
|
||||
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel
|
||||
{
|
||||
Key = candidate.Key,
|
||||
CharacterAName = candidate.CharacterAName,
|
||||
CharacterBName = candidate.CharacterBName,
|
||||
RelationshipType = candidate.RelationshipType,
|
||||
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
|
||||
Confidence = ConfidenceBand(candidate.Confidence),
|
||||
FirstAppearance = candidate.FirstAppearanceNote,
|
||||
LastAppearance = candidate.LastAppearanceNote,
|
||||
ExampleScene = candidate.ExampleScene,
|
||||
ExampleContext = candidate.ExampleContext,
|
||||
ExistingRelationshipID = candidate.ExistingRelationshipID,
|
||||
ExistingRelationshipLabel = candidate.ExistingRelationshipLabel
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form)
|
||||
{
|
||||
var data = await BuildCandidateDataAsync(batch);
|
||||
if (!data.HasCommittedScenes)
|
||||
{
|
||||
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing relationships." };
|
||||
}
|
||||
|
||||
var choices = form.Relationships
|
||||
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
|
||||
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
|
||||
if (choices.Count == 0)
|
||||
{
|
||||
if (data.Candidates.Count == 0)
|
||||
{
|
||||
batch.RelationshipStageComplete = true;
|
||||
batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult();
|
||||
await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID);
|
||||
return new StoryIntelligenceImportCommitResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Relationship review completed. No relationship decisions were waiting."
|
||||
};
|
||||
}
|
||||
|
||||
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one relationship to create, link or ignore." };
|
||||
}
|
||||
|
||||
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
|
||||
var created = 0;
|
||||
var linkedExisting = 0;
|
||||
var merged = 0;
|
||||
var ignored = 0;
|
||||
var eventsCreated = 0;
|
||||
var resolvedRelationships = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var candidatesByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var candidate in data.Candidates)
|
||||
{
|
||||
if (!choices.TryGetValue(candidate.Key, out var choice))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ignored++;
|
||||
AddDecision(batch, candidate.Key, StoryIntelligenceRelationshipImportActions.Ignore, null, RelationshipLabel(candidate), false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var relationshipId = string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase)
|
||||
? candidate.ExistingRelationshipID
|
||||
: null;
|
||||
|
||||
if (!relationshipId.HasValue)
|
||||
{
|
||||
relationshipId = await characters.SaveRelationshipAsync(new CharacterRelationship
|
||||
{
|
||||
ProjectID = batch.ProjectID,
|
||||
CharacterAID = candidate.CharacterAID,
|
||||
CharacterBID = candidate.CharacterBID,
|
||||
RelationshipTypeID = MatchRelationshipType(lookupData.RelationshipTypes, choice.RelationshipType ?? candidate.RelationshipType),
|
||||
IsPermanent = false,
|
||||
StartSceneID = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).FirstOrDefault()?.SceneID,
|
||||
EndSceneID = null,
|
||||
IsKnownToReader = true,
|
||||
Notes = BuildRelationshipNotes(candidate),
|
||||
IsInitialRelationship = false,
|
||||
InitialBookID = batch.BookID,
|
||||
ReaderInitiallyKnows = true,
|
||||
InitialRelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, candidate.RelationshipType),
|
||||
InitialIntensity = ConfidenceIntensity(candidate.Confidence),
|
||||
IsReciprocal = true
|
||||
});
|
||||
created++;
|
||||
}
|
||||
else
|
||||
{
|
||||
linkedExisting++;
|
||||
}
|
||||
|
||||
resolvedRelationships[candidate.Key] = relationshipId.Value;
|
||||
AddDecision(batch, candidate.Key, choice.Action, relationshipId.Value, RelationshipLabel(candidate), true);
|
||||
}
|
||||
|
||||
foreach (var candidate in data.Candidates)
|
||||
{
|
||||
if (!choices.TryGetValue(candidate.Key, out var choice)
|
||||
|| !string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetKey = Clean(choice.AliasTargetKey);
|
||||
if (string.IsNullOrWhiteSpace(targetKey)
|
||||
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|
||||
|| !choices.TryGetValue(targetKey, out var targetChoice)
|
||||
|| string.Equals(targetChoice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(targetChoice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|
||||
|| !candidatesByKey.ContainsKey(targetKey)
|
||||
|| !resolvedRelationships.TryGetValue(targetKey, out var targetRelationshipId))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Story Intelligence relationship merge candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
|
||||
candidate.Key,
|
||||
targetKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
resolvedRelationships[candidate.Key] = targetRelationshipId;
|
||||
merged++;
|
||||
AddDecision(batch, candidate.Key, StoryIntelligenceRelationshipImportActions.Alias, targetRelationshipId, RelationshipLabel(candidate), true);
|
||||
}
|
||||
|
||||
foreach (var candidate in data.Candidates)
|
||||
{
|
||||
if (!choices.TryGetValue(candidate.Key, out var choice)
|
||||
|| string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|
||||
|| !resolvedRelationships.TryGetValue(candidate.Key, out var relationshipId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var appearance in candidate.Appearances
|
||||
.Where(appearance => !appearance.AlreadyLinked)
|
||||
.GroupBy(appearance => appearance.SceneID)
|
||||
.Select(group => group.OrderByDescending(appearance => appearance.Confidence ?? 0m).First()))
|
||||
{
|
||||
if (!HasMeaningfulRelationshipEvent(appearance))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await characters.SaveRelationshipEventAsync(new RelationshipEvent
|
||||
{
|
||||
CharacterRelationshipID = relationshipId,
|
||||
SceneID = appearance.SceneID,
|
||||
RelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, appearance.Signal ?? candidate.RelationshipType),
|
||||
Intensity = ConfidenceIntensity(appearance.Confidence ?? candidate.Confidence),
|
||||
IsKnownToOtherCharacter = true,
|
||||
Description = BuildRelationshipEventDescription(candidate, appearance)
|
||||
});
|
||||
eventsCreated++;
|
||||
}
|
||||
}
|
||||
|
||||
batch.RelationshipStageComplete = true;
|
||||
batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult
|
||||
{
|
||||
RelationshipsCreated = created,
|
||||
RelationshipsLinked = linkedExisting,
|
||||
RelationshipsMerged = merged,
|
||||
RelationshipsIgnored = ignored,
|
||||
RelationshipEventsCreated = eventsCreated
|
||||
};
|
||||
await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID);
|
||||
|
||||
logger.LogInformation(
|
||||
"Imported Story Intelligence relationships for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored} Events={Events}",
|
||||
batch.BatchID,
|
||||
created,
|
||||
linkedExisting,
|
||||
merged,
|
||||
ignored,
|
||||
eventsCreated);
|
||||
|
||||
return new StoryIntelligenceImportCommitResult
|
||||
{
|
||||
Success = true,
|
||||
ScenesCreated = eventsCreated,
|
||||
Message = $"Relationships imported. {created:N0} created, {linkedExisting:N0} linked, {merged:N0} merged, {ignored:N0} ignored, {eventsCreated:N0} relationship event(s) added."
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<RelationshipCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
|
||||
{
|
||||
var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID);
|
||||
var existingRelationships = await characters.ListRelationshipsByProjectAsync(batch.ProjectID);
|
||||
var existingByPair = existingRelationships
|
||||
.GroupBy(relationship => PairKey(relationship.CharacterAID, relationship.CharacterBID))
|
||||
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
|
||||
var groups = new Dictionary<string, RelationshipCandidate>(StringComparer.OrdinalIgnoreCase);
|
||||
var alreadyLinked = 0;
|
||||
var hasCommittedScenes = false;
|
||||
|
||||
foreach (var item in batch.Items)
|
||||
{
|
||||
var commit = await runs.GetImportCommitAsync(item.RunID);
|
||||
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
hasCommittedScenes = true;
|
||||
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
|
||||
.Where(scene => scene.ImportRunID == item.RunID)
|
||||
.ToList();
|
||||
var importedByRange = importedScenes
|
||||
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
|
||||
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
|
||||
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
|
||||
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
|
||||
|
||||
foreach (var sceneResult in sceneResults)
|
||||
{
|
||||
var parsed = TryReadScene(sceneResult);
|
||||
if (parsed is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
|
||||
if (importedScene is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var existingEvents = await characters.ListRelationshipEventsBySceneAsync(importedScene.SceneID);
|
||||
alreadyLinked += existingEvents.Select(item => item.CharacterRelationshipID).Distinct().Count();
|
||||
AddSceneRelationships(groups, characterIndex, existingByPair, importedScene, parsed, existingEvents);
|
||||
}
|
||||
}
|
||||
|
||||
var candidates = groups.Values
|
||||
.Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
|
||||
.OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count())
|
||||
.ThenBy(candidate => candidate.CharacterAName)
|
||||
.ThenBy(candidate => candidate.CharacterBName)
|
||||
.ToList();
|
||||
|
||||
return new RelationshipCandidateData(hasCommittedScenes, alreadyLinked, candidates);
|
||||
}
|
||||
|
||||
private static void AddSceneRelationships(
|
||||
Dictionary<string, RelationshipCandidate> groups,
|
||||
CharacterIndex characterIndex,
|
||||
IReadOnlyDictionary<string, CharacterRelationship> existingByPair,
|
||||
Scene importedScene,
|
||||
SceneIntelligenceScene parsed,
|
||||
IReadOnlyList<RelationshipEvent> existingEvents)
|
||||
{
|
||||
foreach (var relationship in parsed.Relationships ?? [])
|
||||
{
|
||||
AddRelationship(groups, characterIndex, existingByPair, importedScene, parsed, relationship.CharacterA, relationship.CharacterB, relationship.RelationshipSignal, relationship.Evidence, relationship.Confidence, existingEvents);
|
||||
}
|
||||
|
||||
foreach (var observation in parsed.Observations ?? [])
|
||||
{
|
||||
if (!IsRelationshipObservation(observation))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddRelationship(
|
||||
groups,
|
||||
characterIndex,
|
||||
existingByPair,
|
||||
importedScene,
|
||||
parsed,
|
||||
observation.SubjectName,
|
||||
observation.ObjectName,
|
||||
FirstConfigured(observation.Predicate, observation.ObservationType),
|
||||
FirstConfigured(observation.Description, observation.Evidence),
|
||||
observation.Confidence,
|
||||
existingEvents);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddRelationship(
|
||||
Dictionary<string, RelationshipCandidate> groups,
|
||||
CharacterIndex characterIndex,
|
||||
IReadOnlyDictionary<string, CharacterRelationship> existingByPair,
|
||||
Scene importedScene,
|
||||
SceneIntelligenceScene parsed,
|
||||
string? characterAName,
|
||||
string? characterBName,
|
||||
string? relationshipSignal,
|
||||
string? evidence,
|
||||
decimal? confidence,
|
||||
IReadOnlyList<RelationshipEvent> existingEvents)
|
||||
{
|
||||
var characterA = characterIndex.Find(characterAName);
|
||||
var characterB = characterIndex.Find(characterBName);
|
||||
if (characterA is null || characterB is null || characterA.CharacterID == characterB.CharacterID)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var key = PairKey(characterA.CharacterID, characterB.CharacterID);
|
||||
if (!groups.TryGetValue(key, out var candidate))
|
||||
{
|
||||
existingByPair.TryGetValue(key, out var existingMatch);
|
||||
candidate = new RelationshipCandidate(
|
||||
key,
|
||||
characterA.CharacterID,
|
||||
characterA.CharacterName,
|
||||
characterB.CharacterID,
|
||||
characterB.CharacterName,
|
||||
RelationshipTypeFromSignal(relationshipSignal),
|
||||
existingMatch?.CharacterRelationshipID,
|
||||
existingMatch is null ? null : $"{existingMatch.CharacterAName} / {existingMatch.RelationshipTypeName} / {existingMatch.CharacterBName}");
|
||||
groups[key] = candidate;
|
||||
}
|
||||
|
||||
if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m))
|
||||
{
|
||||
candidate.RelationshipType = RelationshipTypeFromSignal(relationshipSignal);
|
||||
}
|
||||
|
||||
candidate.Confidence = Max(candidate.Confidence, confidence);
|
||||
candidate.FirstAppearanceNote ??= BuildSceneReference(importedScene);
|
||||
candidate.LastAppearanceNote = BuildSceneReference(importedScene);
|
||||
candidate.ExampleScene ??= BuildFirstAppearance(importedScene, parsed);
|
||||
candidate.ExampleContext ??= Clean(evidence);
|
||||
candidate.Appearances.Add(new RelationshipAppearanceImport(
|
||||
importedScene.SceneID,
|
||||
importedScene.SceneNumber,
|
||||
importedScene.SceneTitle,
|
||||
relationshipSignal,
|
||||
evidence,
|
||||
confidence,
|
||||
candidate.ExistingRelationshipID.HasValue && existingEvents.Any(existing => existing.CharacterRelationshipID == candidate.ExistingRelationshipID.Value)));
|
||||
}
|
||||
|
||||
private async Task<CharacterIndex> BuildCharacterIndexAsync(int projectId)
|
||||
{
|
||||
var index = new CharacterIndex();
|
||||
foreach (var character in await characters.ListCharactersAsync(projectId))
|
||||
{
|
||||
index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
|
||||
index.Add(character.ShortName, character.CharacterID, character.CharacterName);
|
||||
foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
|
||||
{
|
||||
index.Add(alias.Alias, character.CharacterID, character.CharacterName);
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
private static Scene? ResolveImportedScene(
|
||||
StoryIntelligenceSavedSceneResult sceneResult,
|
||||
IReadOnlyDictionary<string, Scene> byRange,
|
||||
IReadOnlyDictionary<int, Scene> byNumber)
|
||||
{
|
||||
if (sceneResult.StartParagraph.HasValue
|
||||
&& sceneResult.EndParagraph.HasValue
|
||||
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
|
||||
{
|
||||
return rangeMatch;
|
||||
}
|
||||
|
||||
return byNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var numberMatch) ? numberMatch : null;
|
||||
}
|
||||
|
||||
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(result.ParsedJson))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(result.ParsedJson, JsonOptions);
|
||||
if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion))
|
||||
{
|
||||
return direct;
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(result.ParsedJson);
|
||||
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
|
||||
{
|
||||
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsRelationshipObservation(SceneIntelligenceObservation observation)
|
||||
{
|
||||
var type = Clean(observation.ObservationType);
|
||||
if (!type.Contains("relationship", StringComparison.OrdinalIgnoreCase)
|
||||
&& !type.Contains("character", StringComparison.OrdinalIgnoreCase)
|
||||
&& !RelationshipSignalWords.Any(word => type.Contains(word, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsCharacterEntity(observation.SubjectEntityType)
|
||||
&& IsCharacterEntity(observation.ObjectEntityType)
|
||||
&& !string.IsNullOrWhiteSpace(observation.SubjectName)
|
||||
&& !string.IsNullOrWhiteSpace(observation.ObjectName);
|
||||
}
|
||||
|
||||
private static int MatchRelationshipType(IReadOnlyList<RelationshipType> types, string? signal)
|
||||
{
|
||||
var normalised = Normalise(signal);
|
||||
var mapped = RelationshipTypeFromSignal(signal);
|
||||
return types.FirstOrDefault(type => string.Equals(type.TypeName, mapped, StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
|
||||
?? types.FirstOrDefault(type => normalised.Contains(Normalise(type.TypeName), StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
|
||||
?? types.FirstOrDefault(type => string.Equals(type.TypeName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
|
||||
?? types.First().RelationshipTypeID;
|
||||
}
|
||||
|
||||
private static int MatchRelationshipState(IReadOnlyList<RelationshipState> states, string? signal)
|
||||
{
|
||||
var value = Normalise(signal);
|
||||
var stateName =
|
||||
value.Contains("reconcil") ? "Reconciled" :
|
||||
value.Contains("romantic") || value.Contains("lover") || value.Contains("love") ? "Romantic" :
|
||||
value.Contains("friend") || value.Contains("ally") || value.Contains("support") ? "Friendly" :
|
||||
value.Contains("argument") || value.Contains("argue") || value.Contains("conflict") || value.Contains("hostile") || value.Contains("enemy") ? "Hostile" :
|
||||
value.Contains("distrust") || value.Contains("suspect") || value.Contains("suspicious") ? "Distrustful" :
|
||||
value.Contains("estranged") || value.Contains("separat") || value.Contains("deteriorat") ? "Estranged" :
|
||||
value.Contains("tense") || value.Contains("tension") ? "Tense" :
|
||||
"Unknown";
|
||||
return states.FirstOrDefault(state => string.Equals(state.StateName, stateName, StringComparison.OrdinalIgnoreCase))?.RelationshipStateID
|
||||
?? states.FirstOrDefault(state => string.Equals(state.StateName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipStateID
|
||||
?? states.First().RelationshipStateID;
|
||||
}
|
||||
|
||||
private static string RelationshipTypeFromSignal(string? signal)
|
||||
{
|
||||
var value = Normalise(signal);
|
||||
if (value.Contains("mother") || value.Contains("father") || value.Contains("parent")) return "Parent";
|
||||
if (value.Contains("daughter") || value.Contains("son") || value.Contains("child")) return "Child";
|
||||
if (value.Contains("sister") || value.Contains("brother") || value.Contains("sibling")) return "Sibling";
|
||||
if (value.Contains("spouse") || value.Contains("husband") || value.Contains("wife") || value.Contains("married")) return "Spouse";
|
||||
if (value.Contains("romantic") || value.Contains("lover") || value.Contains("love")) return "Lover";
|
||||
if (value.Contains("friend")) return "Friend";
|
||||
if (value.Contains("enemy") || value.Contains("adversarial") || value.Contains("hostile")) return "Enemy";
|
||||
if (value.Contains("rival")) return "Rival";
|
||||
if (value.Contains("suspicious") || value.Contains("suspect")) return "Suspicious Of";
|
||||
if (value.Contains("neighbour") || value.Contains("neighbor")) return "Neighbour";
|
||||
if (value.Contains("teacher")) return "Teacher";
|
||||
if (value.Contains("student")) return "Student";
|
||||
if (value.Contains("employer") || value.Contains("boss")) return "Employer";
|
||||
if (value.Contains("employee")) return "Employee";
|
||||
if (value.Contains("colleague") || value.Contains("professional") || value.Contains("police") || value.Contains("detective") || value.Contains("sergeant")) return "Colleague";
|
||||
if (value.Contains("ally") || value.Contains("support")) return "Ally";
|
||||
if (value.Contains("guardian") || value.Contains("protector")) return "Protector";
|
||||
if (value.Contains("estranged") || value.Contains("separat")) return "Estranged From";
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
private static bool HasMeaningfulRelationshipEvent(RelationshipAppearanceImport appearance)
|
||||
{
|
||||
var value = $"{appearance.Signal} {appearance.Evidence}";
|
||||
return RelationshipEventWords.Any(word => value.Contains(word, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string BuildRelationshipNotes(RelationshipCandidate candidate)
|
||||
{
|
||||
var lines = new List<string> { "Imported from Story Intelligence relationship review." };
|
||||
if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
|
||||
{
|
||||
lines.Add(candidate.ExampleContext);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(candidate.FirstAppearanceNote))
|
||||
{
|
||||
lines.Add($"First detected: {candidate.FirstAppearanceNote}");
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
|
||||
private static string BuildRelationshipEventDescription(RelationshipCandidate candidate, RelationshipAppearanceImport appearance)
|
||||
=> FirstConfigured(
|
||||
Clean(appearance.Evidence),
|
||||
Clean(appearance.Signal),
|
||||
$"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}") ?? RelationshipLabel(candidate);
|
||||
|
||||
private static string RelationshipLabel(RelationshipCandidate candidate)
|
||||
=> $"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}";
|
||||
|
||||
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed)
|
||||
{
|
||||
var summary = Clean(parsed.Summary?.Short);
|
||||
return string.IsNullOrWhiteSpace(summary)
|
||||
? BuildSceneReference(scene)
|
||||
: $"{BuildSceneReference(scene)}: {summary}";
|
||||
}
|
||||
|
||||
private static string BuildSceneReference(Scene scene)
|
||||
=> $"Scene {scene.SceneNumber:g}";
|
||||
|
||||
private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, int? relationshipId, string? label, bool createdOrLinked)
|
||||
{
|
||||
batch.RelationshipDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase));
|
||||
batch.RelationshipDecisions.Add(new OnboardingStoryIntelligenceRelationshipDecision
|
||||
{
|
||||
Key = key,
|
||||
Action = action,
|
||||
CharacterRelationshipID = relationshipId,
|
||||
RelationshipLabel = label,
|
||||
CreatedOrLinked = createdOrLinked
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsCharacterEntity(string? entityType)
|
||||
=> string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static int? ConfidenceIntensity(decimal? confidence)
|
||||
=> confidence.HasValue ? Math.Clamp((int)Math.Round(confidence.Value * 10m, MidpointRounding.AwayFromZero), 1, 10) : null;
|
||||
|
||||
private static decimal? Max(decimal? current, decimal? next)
|
||||
=> current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next;
|
||||
|
||||
private static string ConfidenceBand(decimal? confidence)
|
||||
=> confidence switch
|
||||
{
|
||||
>= 0.75m => "High",
|
||||
>= 0.45m => "Medium",
|
||||
null => "Unknown",
|
||||
_ => "Low"
|
||||
};
|
||||
|
||||
private static string PairKey(int characterAId, int characterBId)
|
||||
=> characterAId <= characterBId ? $"{characterAId}:{characterBId}" : $"{characterBId}:{characterAId}";
|
||||
|
||||
private static string? FirstConfigured(params string?[] values)
|
||||
=> values.Select(Clean).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
|
||||
|
||||
private static string Normalise(string? value)
|
||||
=> Clean(value).ToLowerInvariant();
|
||||
|
||||
private static string Clean(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? string.Empty : Whitespace.Replace(value, " ").Trim();
|
||||
|
||||
private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled);
|
||||
|
||||
private static readonly string[] RelationshipSignalWords =
|
||||
[
|
||||
"friend", "family", "romantic", "lover", "spouse", "parent", "child", "sibling", "enemy",
|
||||
"rival", "ally", "argument", "conflict", "reconciled", "separated", "deteriorates", "meeting"
|
||||
];
|
||||
|
||||
private static readonly string[] RelationshipEventWords =
|
||||
[
|
||||
"begins", "begin", "starts", "first meeting", "meet", "met", "deteriorates", "deteriorate",
|
||||
"reconciles", "reconciliation", "reconciled", "argument", "argues", "separation", "separate",
|
||||
"estranged", "confront", "conflict", "tension", "hostile", "distrust", "supports", "protects"
|
||||
];
|
||||
|
||||
private sealed record RelationshipCandidateData(bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList<RelationshipCandidate> Candidates);
|
||||
|
||||
private sealed class RelationshipCandidate(
|
||||
string key,
|
||||
int characterAId,
|
||||
string characterAName,
|
||||
int characterBId,
|
||||
string characterBName,
|
||||
string relationshipType,
|
||||
int? existingRelationshipId,
|
||||
string? existingRelationshipLabel)
|
||||
{
|
||||
public string Key { get; } = key;
|
||||
public int CharacterAID { get; } = characterAId;
|
||||
public string CharacterAName { get; } = characterAName;
|
||||
public int CharacterBID { get; } = characterBId;
|
||||
public string CharacterBName { get; } = characterBName;
|
||||
public string RelationshipType { get; set; } = relationshipType;
|
||||
public int? ExistingRelationshipID { get; } = existingRelationshipId;
|
||||
public string? ExistingRelationshipLabel { get; } = existingRelationshipLabel;
|
||||
public decimal? Confidence { get; set; }
|
||||
public string? FirstAppearanceNote { get; set; }
|
||||
public string? LastAppearanceNote { get; set; }
|
||||
public string? ExampleScene { get; set; }
|
||||
public string? ExampleContext { get; set; }
|
||||
public List<RelationshipAppearanceImport> Appearances { get; } = [];
|
||||
}
|
||||
|
||||
private sealed record RelationshipAppearanceImport(
|
||||
int SceneID,
|
||||
decimal SceneNumber,
|
||||
string SceneTitle,
|
||||
string? Signal,
|
||||
string? Evidence,
|
||||
decimal? Confidence,
|
||||
bool AlreadyLinked);
|
||||
|
||||
private sealed class CharacterIndex
|
||||
{
|
||||
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void Add(string? name, int characterId, string characterName)
|
||||
{
|
||||
var key = Clean(name);
|
||||
if (string.IsNullOrWhiteSpace(key) || byName.ContainsKey(key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
byName[key] = new CharacterMatch(characterId, characterName);
|
||||
}
|
||||
|
||||
public CharacterMatch? Find(string? name)
|
||||
=> byName.TryGetValue(Clean(name), out var match) ? match : null;
|
||||
}
|
||||
|
||||
private sealed record CharacterMatch(int CharacterID, string CharacterName);
|
||||
}
|
||||
@ -269,8 +269,14 @@ public sealed class StoryIntelligenceService(
|
||||
|
||||
return state.CurrentReviewStage switch
|
||||
{
|
||||
StoryIntelligencePipelineStages.RelationshipReview => "Waiting for Relationship Review",
|
||||
StoryIntelligencePipelineStages.AssetReview => "Waiting for Asset Review",
|
||||
StoryIntelligencePipelineStages.LocationReview => "Waiting for Location Review",
|
||||
StoryIntelligencePipelineStages.CharacterReview => "Waiting for Character Review",
|
||||
StoryIntelligencePipelineStages.SceneReview => "Needs Scene Review",
|
||||
_ when StoryIntelligenceReadyForRelationships(state) => "Waiting for Relationship Review",
|
||||
_ when StoryIntelligenceReadyForAssets(state) => "Waiting for Asset Review",
|
||||
_ when StoryIntelligenceReadyForLocations(state) => "Waiting for Location Review",
|
||||
_ => string.Equals(state.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase)
|
||||
? "Story Intelligence In Progress"
|
||||
: "Story Intelligence Needs Review"
|
||||
@ -282,11 +288,34 @@ public sealed class StoryIntelligenceService(
|
||||
? "All currently available Story Intelligence stages are complete."
|
||||
: state.CurrentReviewStage switch
|
||||
{
|
||||
StoryIntelligencePipelineStages.RelationshipReview => "Review detected relationships and decide what to create or link.",
|
||||
StoryIntelligencePipelineStages.AssetReview => "Review detected assets and decide what to create or link.",
|
||||
StoryIntelligencePipelineStages.LocationReview => "Review detected locations and decide what to create or link.",
|
||||
StoryIntelligencePipelineStages.CharacterReview => "Review detected characters and decide what to create or link.",
|
||||
StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them.",
|
||||
_ when StoryIntelligenceReadyForRelationships(state) => "Review detected relationships and decide what to create or link.",
|
||||
_ when StoryIntelligenceReadyForAssets(state) => "Review detected assets and decide what to create or link.",
|
||||
_ when StoryIntelligenceReadyForLocations(state) => "Review detected locations and decide what to create or link.",
|
||||
_ => "Continue from the next Story Intelligence stage."
|
||||
};
|
||||
|
||||
private static bool StoryIntelligenceReadyForRelationships(StoryIntelligenceBookPipelineState state)
|
||||
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase)
|
||||
|| state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport;
|
||||
|
||||
private static bool StoryIntelligenceReadyForAssets(StoryIntelligenceBookPipelineState state)
|
||||
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase)
|
||||
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
|
||||
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport
|
||||
|| (string.Equals(state.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.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)
|
||||
=> elapsed.TotalMinutes < 1
|
||||
? $"{Math.Max(0, (int)elapsed.TotalSeconds)} sec"
|
||||
|
||||
28
PlotLine/Sql/128_Phase20AL_SceneListImportTracking.sql
Normal file
28
PlotLine/Sql/128_Phase20AL_SceneListImportTracking.sql
Normal 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
|
||||
34
PlotLine/Sql/129_Phase20AN_LocationReviewPipelineStages.sql
Normal file
34
PlotLine/Sql/129_Phase20AN_LocationReviewPipelineStages.sql
Normal 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
|
||||
36
PlotLine/Sql/130_Phase20AP_AssetReviewPipelineStages.sql
Normal file
36
PlotLine/Sql/130_Phase20AP_AssetReviewPipelineStages.sql
Normal 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
|
||||
@ -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
|
||||
@ -143,6 +143,9 @@ public sealed class StoryIntelligenceProgressViewModel
|
||||
public string? BookTitle { get; init; }
|
||||
public IReadOnlyList<StoryIntelligenceOnboardingChapterViewModel> Chapters { get; init; } = [];
|
||||
public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new();
|
||||
public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new();
|
||||
public StoryIntelligenceAssetReviewViewModel AssetReview { get; init; } = new();
|
||||
public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; init; } = new();
|
||||
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
|
||||
public int ChapterCount => Chapters.Count;
|
||||
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
|
||||
@ -158,6 +161,9 @@ public sealed class StoryIntelligenceProgressViewModel
|
||||
public bool AllCommitted => Chapters.Count > 0 && Chapters.All(chapter => chapter.HasCompletedCommit);
|
||||
public bool HasReadyScenesToCreate => Chapters.Any(chapter => chapter.CanCommit);
|
||||
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
|
||||
@ -167,6 +173,15 @@ public sealed class StoryIntelligencePipelineDashboardViewModel
|
||||
public int CharactersIdentified { get; init; }
|
||||
public int CharactersCreatedOrLinked { 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
|
||||
@ -202,15 +217,163 @@ public sealed class StoryIntelligenceCharacterImportForm
|
||||
public sealed class StoryIntelligenceCharacterImportChoiceForm
|
||||
{
|
||||
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? AliasTargetKey { get; set; }
|
||||
}
|
||||
|
||||
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 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
|
||||
@ -226,6 +389,12 @@ public sealed class StoryIntelligenceCompletionViewModel
|
||||
public int ScenesCreated { get; init; }
|
||||
public int CharactersCreatedOrLinked { 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
|
||||
@ -235,11 +404,49 @@ public sealed class StoryIntelligenceCharacterImportResultViewModel
|
||||
public int BookID { get; init; }
|
||||
public int CharactersCreated { get; init; }
|
||||
public int CharactersLinked { get; init; }
|
||||
public int CharactersAliased { get; init; }
|
||||
public int CharactersIgnored { get; init; }
|
||||
public int PovLinksResolved { 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 string TemporaryChapterKey { get; init; } = string.Empty;
|
||||
|
||||
@ -200,9 +200,32 @@
|
||||
|
||||
return pipeline.CurrentReviewStage switch
|
||||
{
|
||||
StoryIntelligencePipelineStages.RelationshipReview => "Review detected relationships and decide what to create or link.",
|
||||
StoryIntelligencePipelineStages.AssetReview => "Review detected assets and decide what to create or link.",
|
||||
StoryIntelligencePipelineStages.LocationReview => "Review detected locations and decide what to create or link.",
|
||||
StoryIntelligencePipelineStages.CharacterReview => "Scene creation is complete. Review detected characters to continue building the story database.",
|
||||
StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them in PlotDirector.",
|
||||
_ when StoryIntelligenceReadyForRelationships(pipeline) => "Review detected relationships and decide what to create or link.",
|
||||
_ when StoryIntelligenceReadyForAssets(pipeline) => "Review detected assets and decide what to create or link.",
|
||||
_ when StoryIntelligenceReadyForLocations(pipeline) => "Review detected locations and decide what to create or link.",
|
||||
_ => "PlotDirector will resume from the next Story Intelligence stage."
|
||||
};
|
||||
}
|
||||
|
||||
private static bool StoryIntelligenceReadyForRelationships(StoryIntelligenceBookPipelineState pipeline)
|
||||
=> string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase)
|
||||
|| pipeline.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport;
|
||||
|
||||
private static bool StoryIntelligenceReadyForAssets(StoryIntelligenceBookPipelineState pipeline)
|
||||
=> string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase)
|
||||
|| pipeline.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
|
||||
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport
|
||||
|| (string.Equals(pipeline.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.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;
|
||||
}
|
||||
|
||||
@ -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">✓</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>
|
||||
232
PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml
Normal file
232
PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml
Normal 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>");
|
||||
}
|
||||
@ -5,7 +5,7 @@
|
||||
|
||||
<section class="onboarding-shell" aria-labelledby="story-character-complete-title">
|
||||
<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">
|
||||
<p class="eyebrow">Build Story Database</p>
|
||||
<div class="onboarding-success-heading">
|
||||
@ -24,6 +24,10 @@
|
||||
<span>Characters linked</span>
|
||||
<strong>@Model.CharactersLinked.ToString("N0")</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Aliases added</span>
|
||||
<strong>@Model.CharactersAliased.ToString("N0")</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Characters ignored</span>
|
||||
<strong>@Model.CharactersIgnored.ToString("N0")</strong>
|
||||
@ -39,7 +43,7 @@
|
||||
</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 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 Relationships</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">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@ -6,8 +6,10 @@
|
||||
<section class="onboarding-shell" aria-labelledby="story-character-title">
|
||||
<div class="onboarding-panel onboarding-review-panel story-review-page">
|
||||
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Characters" })" />
|
||||
<div class="onboarding-copy">
|
||||
<p class="eyebrow">Review Story Intelligence</p>
|
||||
<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 2 of 4</p>
|
||||
<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>
|
||||
</div>
|
||||
@ -38,15 +40,15 @@
|
||||
</div>
|
||||
<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-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>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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-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="ignore-selected">Ignore selected</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="link-existing">Link existing matches</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="collapse">Collapse all</button>
|
||||
</div>
|
||||
@ -59,7 +61,6 @@
|
||||
var candidate = Model.CharacterReview.Candidates[i];
|
||||
<details class="story-character-card" open data-character-card>
|
||||
<summary>
|
||||
<input type="checkbox" checked data-character-selected aria-label="Select @candidate.CharacterName" />
|
||||
<span>
|
||||
<strong>@candidate.CharacterName</strong>
|
||||
<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" />
|
||||
|
||||
<div class="story-character-card__body">
|
||||
<div class="story-character-card__body" data-character-key="@candidate.Key" data-character-name="@candidate.CharacterName">
|
||||
<dl>
|
||||
<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>
|
||||
@ -87,25 +88,39 @@
|
||||
<fieldset class="story-character-actions">
|
||||
<legend>Decision</legend>
|
||||
<label>
|
||||
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Approve" checked data-character-action />
|
||||
@(candidate.IsExistingMatch ? "Link to existing" : "Create new character")
|
||||
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.CreateNew" checked data-character-action />
|
||||
Create new character
|
||||
</label>
|
||||
@if (candidate.IsExistingMatch)
|
||||
{
|
||||
<label>
|
||||
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.CreateSeparate" data-character-action />
|
||||
Create separate character
|
||||
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.LinkExisting" data-character-action @(candidate.IsExistingMatch ? string.Empty : "disabled") />
|
||||
Link existing character
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Alias" data-character-action />
|
||||
Alias of another character
|
||||
</label>
|
||||
}
|
||||
<label>
|
||||
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Ignore" data-character-action />
|
||||
Ignore
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div data-character-import-name-panel>
|
||||
<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" />
|
||||
</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>
|
||||
}
|
||||
</div>
|
||||
@ -129,24 +144,63 @@
|
||||
const cards = () => Array.from(form.querySelectorAll("[data-character-card]"));
|
||||
const setAction = (card, 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 => {
|
||||
button.addEventListener("click", () => {
|
||||
const action = button.getAttribute("data-character-bulk");
|
||||
const selectedCards = cards().filter(card => card.querySelector("[data-character-selected]")?.checked);
|
||||
if (action === "approve-all") cards().forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.Approve"));
|
||||
if (action === "approve-selected") selectedCards.forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.Approve"));
|
||||
if (action === "ignore-selected") selectedCards.forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.Ignore"));
|
||||
if (action === "create-all") cards().forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.CreateNew"));
|
||||
if (action === "link-existing") cards().forEach(card => {
|
||||
if (card.querySelector(`[data-character-action][value="@StoryIntelligenceCharacterImportActions.LinkExisting"]:not(:disabled)`)) {
|
||||
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 === "collapse") cards().forEach(card => card.open = false);
|
||||
});
|
||||
});
|
||||
form.addEventListener("submit", () => {
|
||||
cards()
|
||||
.filter(card => !card.querySelector("[data-character-selected]")?.checked)
|
||||
.forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.Ignore"));
|
||||
form.querySelectorAll("[data-character-action]").forEach(input => {
|
||||
input.addEventListener("change", () => updateCard(input.closest("[data-character-card]")));
|
||||
});
|
||||
cards().forEach(updateCard);
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
|
||||
@ -6,15 +6,32 @@
|
||||
<section class="onboarding-shell" aria-labelledby="story-complete-title">
|
||||
<div class="onboarding-panel onboarding-review-panel">
|
||||
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Complete" })" />
|
||||
<div class="onboarding-copy">
|
||||
<p class="eyebrow">Story Intelligence pipeline</p>
|
||||
<div class="onboarding-copy story-review-heading">
|
||||
<p class="eyebrow">Phase 4</p>
|
||||
<p class="story-review-heading__phase">Build Story Database</p>
|
||||
<div class="onboarding-success-heading">
|
||||
<span aria-hidden="true">✓</span>
|
||||
<h1 id="story-complete-title">Story Intelligence complete</h1>
|
||||
</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>
|
||||
|
||||
<section class="story-database-build" aria-label="Story database build checklist">
|
||||
<h2>Building your Story Database...</h2>
|
||||
<ul>
|
||||
<li><span aria-hidden="true">✓</span><strong>Chapters</strong></li>
|
||||
<li><span aria-hidden="true">✓</span><strong>Scenes</strong></li>
|
||||
<li><span aria-hidden="true">✓</span><strong>Characters</strong></li>
|
||||
<li><span aria-hidden="true">✓</span><strong>Locations</strong></li>
|
||||
<li><span aria-hidden="true">✓</span><strong>Assets</strong></li>
|
||||
<li><span aria-hidden="true">✓</span><strong>Relationships</strong></li>
|
||||
<li class="is-upcoming"><span aria-hidden="true">○</span><strong>Knowledge</strong><em>Coming Soon</em></li>
|
||||
<li><span aria-hidden="true">✓</span><strong>Preparing continuity...</strong></li>
|
||||
<li><span aria-hidden="true">✓</span><strong>Generating story indexes...</strong></li>
|
||||
<li><span aria-hidden="true">✓</span><strong>Finalising project...</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="onboarding-scan-counts onboarding-review-counts">
|
||||
<div>
|
||||
<span>Chapters analysed</span>
|
||||
@ -28,19 +45,24 @@
|
||||
<span>Characters created / linked</span>
|
||||
<strong>@Model.CharactersCreatedOrLinked.ToString("N0")</strong>
|
||||
</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>
|
||||
<span>Book</span>
|
||||
<strong>@Model.BookTitle</strong>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@ -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">✓</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>
|
||||
221
PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml
Normal file
221
PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml
Normal 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>");
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
@model StoryIntelligenceRelationshipImportResultViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Relationships created";
|
||||
}
|
||||
|
||||
<section class="onboarding-shell" aria-labelledby="story-relationship-complete-title">
|
||||
<div class="onboarding-panel onboarding-review-panel">
|
||||
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Complete" })" />
|
||||
<div class="onboarding-copy">
|
||||
<p class="eyebrow">Build Story Database</p>
|
||||
<div class="onboarding-success-heading">
|
||||
<span aria-hidden="true">✓</span>
|
||||
<h1 id="story-relationship-complete-title">Relationships created</h1>
|
||||
</div>
|
||||
<p>PlotDirector updated your story database from the approved relationship decisions. The implemented Story Intelligence import stages are now complete.</p>
|
||||
</div>
|
||||
|
||||
<div class="onboarding-scan-counts onboarding-review-counts">
|
||||
<div>
|
||||
<span>Relationships created</span>
|
||||
<strong>@Model.RelationshipsCreated.ToString("N0")</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Relationships linked</span>
|
||||
<strong>@Model.RelationshipsLinked.ToString("N0")</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Relationships merged</span>
|
||||
<strong>@Model.RelationshipsMerged.ToString("N0")</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Relationships ignored</span>
|
||||
<strong>@Model.RelationshipsIgnored.ToString("N0")</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Relationship events added</span>
|
||||
<strong>@Model.RelationshipEventsCreated.ToString("N0")</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="story-future-stage-grid">
|
||||
<article class="story-future-stage"><strong>Review Knowledge</strong><span>Coming Soon</span></article>
|
||||
<article class="story-future-stage"><strong>Review Continuity</strong><span>Coming Soon</span></article>
|
||||
<article class="story-future-stage"><strong>Timeline Events</strong><span>Coming Soon</span></article>
|
||||
</div>
|
||||
|
||||
<div class="onboarding-actions">
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID">Back to relationships</a>
|
||||
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
233
PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml
Normal file
233
PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml
Normal file
@ -0,0 +1,233 @@
|
||||
@model StoryIntelligenceProgressViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Review relationships";
|
||||
}
|
||||
|
||||
<section class="onboarding-shell" aria-labelledby="story-relationship-title">
|
||||
<div class="onboarding-panel onboarding-review-panel story-review-page">
|
||||
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Relationships" })" />
|
||||
<div class="onboarding-copy story-review-heading">
|
||||
<p class="eyebrow">Phase 3</p>
|
||||
<p class="story-review-heading__phase">Review Story Intelligence</p>
|
||||
<p class="story-review-heading__stage">Stage 5 of 5</p>
|
||||
<h1 id="story-relationship-title">Review Relationships</h1>
|
||||
<p>Approve the character relationships PlotDirector should create or link from your imported scenes. Nothing is created unless you approve it.</p>
|
||||
</div>
|
||||
|
||||
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
||||
{
|
||||
<div class="alert alert-danger">@error</div>
|
||||
}
|
||||
@if (TempData["OnboardingStoryIntelligenceMessage"] is string message)
|
||||
{
|
||||
<div class="alert alert-success">@message</div>
|
||||
}
|
||||
|
||||
<partial name="_StoryIntelligencePipelineSummary" model="Model.PipelineDashboard" />
|
||||
|
||||
<section class="story-review-chapter-list" aria-label="Relationship review">
|
||||
@if (Model.RelationshipReview.Candidates.Count == 0)
|
||||
{
|
||||
<div class="story-review-note">
|
||||
<strong>No relationship decisions are waiting.</strong>
|
||||
<p class="mb-0">Relationships are already up to date, or there were no relationship suggestions ready to import from the stored scene analysis.</p>
|
||||
</div>
|
||||
<div class="story-future-stage-grid">
|
||||
@FutureStage("Review Knowledge")
|
||||
@FutureStage("Review Continuity")
|
||||
@FutureStage("Timeline Events")
|
||||
</div>
|
||||
<div class="onboarding-actions">
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Back to assets</a>
|
||||
<form asp-action="ImportStoryIntelligenceRelationships" method="post">
|
||||
<input type="hidden" name="BatchID" value="@Model.BatchID" />
|
||||
<button class="btn btn-primary" type="submit">Continue</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="story-review-card-actions">
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" data-relationship-bulk="create-all">Create all</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="link-existing">Link existing matches</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="ignore-all">Ignore all</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="expand">Expand all</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="collapse">Collapse all</button>
|
||||
</div>
|
||||
|
||||
<form asp-action="ImportStoryIntelligenceRelationships" method="post" data-relationship-review-form>
|
||||
<input type="hidden" name="BatchID" value="@Model.BatchID" />
|
||||
<div class="story-character-card-grid">
|
||||
@for (var i = 0; i < Model.RelationshipReview.Candidates.Count; i++)
|
||||
{
|
||||
var candidate = Model.RelationshipReview.Candidates[i];
|
||||
<details class="story-character-card" open data-relationship-card>
|
||||
<summary>
|
||||
<span>
|
||||
<strong>@candidate.CharacterAName</strong>
|
||||
<small>@candidate.RelationshipType / @candidate.CharacterBName - @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
|
||||
</span>
|
||||
<em>@candidate.Confidence</em>
|
||||
</summary>
|
||||
|
||||
<input type="hidden" name="Relationships[@i].Key" value="@candidate.Key" />
|
||||
|
||||
<div class="story-character-card__body" data-relationship-key="@candidate.Key">
|
||||
<dl>
|
||||
<div><dt>Character A</dt><dd>@candidate.CharacterAName</dd></div>
|
||||
<div><dt>Character B</dt><dd>@candidate.CharacterBName</dd></div>
|
||||
<div><dt>First seen</dt><dd>@Display(candidate.FirstAppearance)</dd></div>
|
||||
<div><dt>Last seen</dt><dd>@Display(candidate.LastAppearance)</dd></div>
|
||||
<div><dt>Existing match</dt><dd>@(candidate.ExistingRelationshipLabel ?? "None")</dd></div>
|
||||
</dl>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(candidate.ExampleScene))
|
||||
{
|
||||
<div class="story-review-note">
|
||||
<strong>Example scene</strong>
|
||||
<p>@candidate.ExampleScene</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
|
||||
{
|
||||
<div class="story-review-note">
|
||||
<strong>Supporting context</strong>
|
||||
<p>@candidate.ExampleContext</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (candidate.IsExistingMatch)
|
||||
{
|
||||
<div class="story-review-note">
|
||||
<strong>Possible existing relationship</strong>
|
||||
<p>@candidate.CharacterAName and @candidate.CharacterBName may already have a relationship in PlotDirector. Choose whether to link them or create a separate relationship.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<fieldset class="story-character-actions">
|
||||
<legend>Decision</legend>
|
||||
<label>
|
||||
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.CreateNew" checked data-relationship-action />
|
||||
Create new relationship
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.LinkExisting" data-relationship-action @(candidate.IsExistingMatch ? string.Empty : "disabled") />
|
||||
Link existing relationship
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.Alias" data-relationship-action />
|
||||
Merge with another relationship
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.Ignore" data-relationship-action />
|
||||
Ignore
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div data-relationship-type-panel>
|
||||
<label class="form-label" for="relationship-type-@i">Relationship type</label>
|
||||
<input id="relationship-type-@i" class="form-control" name="Relationships[@i].RelationshipType" value="@candidate.RelationshipType" />
|
||||
</div>
|
||||
|
||||
<div data-relationship-alias-panel hidden>
|
||||
<label class="form-label" for="relationship-alias-target-@i">Merge target</label>
|
||||
<select id="relationship-alias-target-@i" class="form-select" name="Relationships[@i].AliasTargetKey" data-relationship-alias-target>
|
||||
<option value="">Choose relationship...</option>
|
||||
@foreach (var target in Model.RelationshipReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
<option value="@target.Key">@target.CharacterAName / @target.RelationshipType / @target.CharacterBName@(target.IsExistingMatch ? $" -> {target.ExistingRelationshipLabel}" : string.Empty)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="onboarding-actions">
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Back to assets</a>
|
||||
<button class="btn btn-primary" type="submit">Create relationships</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
(() => {
|
||||
const form = document.querySelector("[data-relationship-review-form]");
|
||||
if (!form) return;
|
||||
|
||||
const cards = () => Array.from(form.querySelectorAll("[data-relationship-card]"));
|
||||
const setAction = (card, action) => {
|
||||
const input = card.querySelector(`[data-relationship-action][value="${action}"]`);
|
||||
if (input) {
|
||||
input.checked = true;
|
||||
updateCard(card);
|
||||
}
|
||||
};
|
||||
const actionFor = (card) => card.querySelector("[data-relationship-action]:checked")?.value || "@StoryIntelligenceRelationshipImportActions.CreateNew";
|
||||
const isCanonicalTarget = (card) => {
|
||||
const action = actionFor(card);
|
||||
return action !== "@StoryIntelligenceRelationshipImportActions.Ignore" && action !== "@StoryIntelligenceRelationshipImportActions.Alias";
|
||||
};
|
||||
const updateAliasTargets = () => {
|
||||
const allCards = cards();
|
||||
for (const card of allCards) {
|
||||
const body = card.querySelector("[data-relationship-key]");
|
||||
const ownKey = body?.getAttribute("data-relationship-key") || "";
|
||||
const select = card.querySelector("[data-relationship-alias-target]");
|
||||
if (!select) continue;
|
||||
|
||||
const previous = select.value;
|
||||
for (const option of Array.from(select.options)) {
|
||||
if (!option.value) continue;
|
||||
const targetCard = allCards.find(item => item.querySelector("[data-relationship-key]")?.getAttribute("data-relationship-key") === option.value);
|
||||
option.disabled = option.value === ownKey || !targetCard || !isCanonicalTarget(targetCard);
|
||||
}
|
||||
if (select.selectedOptions[0]?.disabled) {
|
||||
select.value = "";
|
||||
} else {
|
||||
select.value = previous;
|
||||
}
|
||||
}
|
||||
};
|
||||
const updateCard = (card) => {
|
||||
const action = actionFor(card);
|
||||
const typePanel = card.querySelector("[data-relationship-type-panel]");
|
||||
const aliasPanel = card.querySelector("[data-relationship-alias-panel]");
|
||||
if (typePanel) typePanel.hidden = action === "@StoryIntelligenceRelationshipImportActions.Ignore" || action === "@StoryIntelligenceRelationshipImportActions.Alias";
|
||||
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceRelationshipImportActions.Alias";
|
||||
updateAliasTargets();
|
||||
};
|
||||
document.querySelectorAll("[data-relationship-bulk]").forEach(button => {
|
||||
button.addEventListener("click", () => {
|
||||
const action = button.getAttribute("data-relationship-bulk");
|
||||
if (action === "create-all") cards().forEach(card => setAction(card, "@StoryIntelligenceRelationshipImportActions.CreateNew"));
|
||||
if (action === "link-existing") cards().forEach(card => {
|
||||
if (card.querySelector(`[data-relationship-action][value="@StoryIntelligenceRelationshipImportActions.LinkExisting"]:not(:disabled)`)) {
|
||||
setAction(card, "@StoryIntelligenceRelationshipImportActions.LinkExisting");
|
||||
}
|
||||
});
|
||||
if (action === "ignore-all") cards().forEach(card => setAction(card, "@StoryIntelligenceRelationshipImportActions.Ignore"));
|
||||
if (action === "expand") cards().forEach(card => card.open = true);
|
||||
if (action === "collapse") cards().forEach(card => card.open = false);
|
||||
});
|
||||
});
|
||||
form.querySelectorAll("[data-relationship-action]").forEach(input => {
|
||||
input.addEventListener("change", () => updateCard(input.closest("[data-relationship-card]")));
|
||||
});
|
||||
cards().forEach(updateCard);
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
|
||||
@functions {
|
||||
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value;
|
||||
|
||||
private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label)
|
||||
=> new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Coming Soon</span></article>");
|
||||
}
|
||||
@ -14,8 +14,10 @@
|
||||
<section class="onboarding-shell" aria-labelledby="story-review-title">
|
||||
<div class="onboarding-panel onboarding-review-panel story-review-page">
|
||||
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Scenes" })" />
|
||||
<div class="onboarding-copy">
|
||||
<p class="eyebrow">Review Story Intelligence</p>
|
||||
<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 1 of 4</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -1,50 +1,130 @@
|
||||
@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 { Name = "Phase 2", Label = "Analyse manuscript", Stages = new[] { "Analyse Manuscript" } },
|
||||
new { Name = "Phase 3", Label = "Review Story Intelligence", Stages = new[] { "Review Scenes", "Review Characters", "Review Locations", "Review Assets", "Review Relationships", "Review Knowledge" } },
|
||||
new { Name = "Phase 4", Label = "Build Story Database", Stages = new[] { "Create Scenes", "Create Characters", "Complete" } }
|
||||
new PipelineStage("Review Scenes", "Scenes", true),
|
||||
new PipelineStage("Review Characters", "Characters", true),
|
||||
new PipelineStage("Review Locations", "Locations", true),
|
||||
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 currentIndex = Math.Max(0, flatStages.FindIndex(stage => string.Equals(stage, Model.CurrentStage, StringComparison.OrdinalIgnoreCase)));
|
||||
var percent = flatStages.Count <= 1 ? 100 : Math.Clamp((currentIndex * 100) / (flatStages.Count - 1), 0, 100);
|
||||
var implementedReviewStages = reviewStages.Where(stage => stage.IsImplemented).ToList();
|
||||
var currentReviewIndex = implementedReviewStages.FindIndex(stage => string.Equals(stage.StageName, Model.CurrentStage, StringComparison.OrdinalIgnoreCase));
|
||||
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">
|
||||
<span>@Model.CurrentStage</span>
|
||||
<div class="onboarding-progress-track">
|
||||
<span style="width:@percent%"></span>
|
||||
</div>
|
||||
</div>
|
||||
<ol class="onboarding-stepper story-pipeline-stepper" aria-label="Story Intelligence pipeline">
|
||||
@foreach (var phase in phases)
|
||||
<section class="story-pipeline-header" aria-label="Story Intelligence pipeline">
|
||||
<div class="story-pipeline-current">
|
||||
<p class="eyebrow">Phase @activePhase</p>
|
||||
<h2>@CurrentPhaseTitle(activePhase)</h2>
|
||||
@if (isReviewPhase)
|
||||
{
|
||||
var firstStageIndex = flatStages.FindIndex(stage => phase.Stages.Contains(stage));
|
||||
var lastStageIndex = firstStageIndex + phase.Stages.Length - 1;
|
||||
var phaseClass = currentIndex > lastStageIndex
|
||||
<p>Stage @currentStageNumber of @implementedReviewStages.Count</p>
|
||||
}
|
||||
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"
|
||||
: currentIndex >= firstStageIndex && currentIndex <= lastStageIndex
|
||||
: index == currentReviewIndex
|
||||
? "is-current"
|
||||
: "is-upcoming";
|
||||
<li class="@phaseClass">
|
||||
<span>@phase.Name.Replace("Phase ", string.Empty)</span>
|
||||
<strong>@phase.Label</strong>
|
||||
<small>@StageSummary(phase.Stages, currentIndex, firstStageIndex)</small>
|
||||
<li class="@stageClass">
|
||||
<span aria-hidden="true">@StageMarker(stageClass, stage.IsImplemented)</span>
|
||||
<strong>@stage.Label</strong>
|
||||
@if (!stage.IsImplemented)
|
||||
{
|
||||
<em>Coming Soon</em>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="@(activePhase == 4 ? "is-current" : "is-upcoming")">
|
||||
<strong>Build Story Database</strong>
|
||||
<span>Final import and completion</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
@functions {
|
||||
private static string StageSummary(IReadOnlyList<string> stages, int currentIndex, int firstStageIndex)
|
||||
{
|
||||
var localIndex = currentIndex - firstStageIndex;
|
||||
if (localIndex >= 0 && localIndex < stages.Count)
|
||||
{
|
||||
return stages[localIndex];
|
||||
}
|
||||
private sealed record PipelineStage(string StageName, string Label, bool IsImplemented);
|
||||
|
||||
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" ? "✓" : "○";
|
||||
}
|
||||
|
||||
@ -18,16 +18,28 @@
|
||||
<strong>@(Model.CharacterStageComplete ? $"Done: {Model.CharactersCreatedOrLinked:N0}" : "In review")</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Locations</span>
|
||||
<strong>Coming next</strong>
|
||||
<span>Locations identified</span>
|
||||
<strong>@Model.LocationsIdentified.ToString("N0")</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Assets</span>
|
||||
<strong>Coming next</strong>
|
||||
<span>Locations created / linked</span>
|
||||
<strong>@(Model.LocationStageComplete ? $"Done: {Model.LocationsCreatedOrLinked:N0}" : "In review")</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Relationships</span>
|
||||
<strong>Coming next</strong>
|
||||
<span>Assets identified</span>
|
||||
<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>
|
||||
<span>Knowledge</span>
|
||||
|
||||
@ -807,6 +807,195 @@ summary.story-review-chapter-heading {
|
||||
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 {
|
||||
padding: 1rem;
|
||||
}
|
||||
@ -1361,6 +1550,11 @@ summary.story-review-chapter-heading {
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.story-pipeline-phase-list,
|
||||
.story-database-build ul {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.onboarding-choice-grid,
|
||||
.onboarding-option-grid--software {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user