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),
|
("Truncated JSON is rejected", RejectsTruncatedJson),
|
||||||
("Repaired JSON deserialises into SceneIntelligenceScene", RepairedJsonDeserialises),
|
("Repaired JSON deserialises into SceneIntelligenceScene", RepairedJsonDeserialises),
|
||||||
("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs),
|
("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs),
|
||||||
("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput)
|
("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput),
|
||||||
|
("Character filtering rejects generic groups", CharacterFilteringRejectsGenericGroups),
|
||||||
|
("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames),
|
||||||
|
("Location filtering rejects merged phrases", LocationFilteringRejectsMergedPhrases),
|
||||||
|
("Location filtering preserves story locations", LocationFilteringPreservesStoryLocations),
|
||||||
|
("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants),
|
||||||
|
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
|
||||||
|
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets),
|
||||||
|
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants)
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var test in tests)
|
foreach (var test in tests)
|
||||||
@ -106,6 +114,138 @@ static void SharedParserReportsRawOutput()
|
|||||||
Assert(result.RawJson == raw, "Raw malformed output was not retained.");
|
Assert(result.RawJson == raw, "Raw malformed output was not retained.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void CharacterFilteringRejectsGenericGroups()
|
||||||
|
{
|
||||||
|
Assert(IsGenericGroupReference("four examiners"), "Quantity + generic group should be rejected.");
|
||||||
|
Assert(IsGenericGroupReference("groups of men"), "Generic group phrase should be rejected.");
|
||||||
|
Assert(IsGenericGroupReference("neighbours"), "Generic plural group should be rejected.");
|
||||||
|
Assert(IsGenericGroupReference("passenger (blue Volkswagen Golf GTi)"), "Unnamed passenger descriptor should be rejected.");
|
||||||
|
Assert(IsGenericGroupReference("tubby lad"), "Unnamed descriptive person should be rejected.");
|
||||||
|
Assert(IsGenericGroupReference("lady examiner"), "Unnamed role descriptor should be rejected.");
|
||||||
|
Assert(IsGenericGroupReference("her mother"), "Determiner + family role should be rejected.");
|
||||||
|
Assert(IsGenericGroupReference("the child"), "Determiner + generic person should be rejected.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void CharacterFilteringPreservesTitledNames()
|
||||||
|
{
|
||||||
|
Assert(!IsGenericGroupReference("Reverend Potter"), "Reverend Potter should not be treated as generic.");
|
||||||
|
Assert(IsNamedOrTitledPerson("Reverend Potter"), "Reverend Potter should be recognised as a titled person.");
|
||||||
|
Assert(IsNamedOrTitledPerson("Detective Sergeant Summerhill"), "Detective Sergeant Summerhill should be recognised as a titled person.");
|
||||||
|
Assert(IsNamedOrTitledPerson("Mrs Patterson"), "Mrs Patterson should be recognised as a titled person.");
|
||||||
|
Assert(IsNamedOrTitledPerson("Aunt Elen"), "Aunt Elen should be recognised as a titled person.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool IsGenericGroupReference(string name)
|
||||||
|
=> InvokePrivateCharacterFilter("IsGenericGroupReference", name);
|
||||||
|
|
||||||
|
static bool IsNamedOrTitledPerson(string name)
|
||||||
|
=> InvokePrivateCharacterFilter("IsNamedOrTitledPerson", name);
|
||||||
|
|
||||||
|
static bool InvokePrivateCharacterFilter(string methodName, string name)
|
||||||
|
{
|
||||||
|
var method = typeof(StoryIntelligenceCharacterImportService).GetMethod(
|
||||||
|
methodName,
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||||
|
Assert(method is not null, $"{methodName} was not found.");
|
||||||
|
return method!.Invoke(null, [name]) is true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void LocationFilteringRejectsMergedPhrases()
|
||||||
|
{
|
||||||
|
Assert(IsMergedLocationPhrase("shop and staff room"), "Merged shop/staff room phrase should be rejected.");
|
||||||
|
Assert(IsMergedLocationPhrase("house and garden"), "Merged house/garden phrase should be rejected.");
|
||||||
|
Assert(IsMergedLocationPhrase("road and bridge"), "Merged road/bridge phrase should be rejected.");
|
||||||
|
Assert(IsMergedLocationPhrase("kitchen and hallway"), "Merged room phrase should be rejected.");
|
||||||
|
Assert(IsMergedLocationPhrase("front and rear garden"), "Merged garden phrase should be rejected.");
|
||||||
|
Assert(!IsMergedLocationPhrase("Rose and Crown"), "Named pub should not be treated as a merged location.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void LocationFilteringPreservesStoryLocations()
|
||||||
|
{
|
||||||
|
Assert(IsStoryLocation("The Doweries"), "The Doweries should be preserved.");
|
||||||
|
Assert(IsStoryLocation("Ashdown Trust"), "Ashdown Trust should be preserved.");
|
||||||
|
Assert(IsStoryLocation("St Luke's Church"), "St Luke's Church should be preserved.");
|
||||||
|
Assert(IsStoryLocation("Mrs Patterson's House"), "Mrs Patterson's House should be preserved.");
|
||||||
|
Assert(IsStoryLocation("Bristol Road"), "Named roads should be preserved.");
|
||||||
|
Assert(!IsStoryLocation("Kitchen"), "Standalone generic rooms should not be story locations.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void LocationCanonicalKeysMergeTrivialVariants()
|
||||||
|
{
|
||||||
|
Assert(LocationCanonicalKey("Kitchen") == LocationCanonicalKey("The Kitchen"), "The Kitchen should fold into Kitchen.");
|
||||||
|
Assert(LocationCanonicalKey("Interview room") == LocationCanonicalKey("Interview Room"), "Case-only room variants should merge.");
|
||||||
|
Assert(LocationCanonicalKey("Ashdown Trust (reception)") == LocationCanonicalKey("Ashdown Trust"), "Parenthetical variants should merge.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool IsMergedLocationPhrase(string name)
|
||||||
|
=> InvokePrivateLocationFilter("IsMergedLocationPhrase", name);
|
||||||
|
|
||||||
|
static bool IsStoryLocation(string name)
|
||||||
|
=> InvokePrivateLocationFilter("IsStoryLocation", name);
|
||||||
|
|
||||||
|
static string LocationCanonicalKey(string name)
|
||||||
|
{
|
||||||
|
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
|
||||||
|
"CanonicalLocationKey",
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||||
|
Assert(method is not null, "CanonicalLocationKey was not found.");
|
||||||
|
return (string)method!.Invoke(null, [name])!;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool InvokePrivateLocationFilter(string methodName, string name)
|
||||||
|
{
|
||||||
|
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
|
||||||
|
methodName,
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||||
|
Assert(method is not null, $"{methodName} was not found.");
|
||||||
|
return method!.Invoke(null, [name]) is true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void AssetFilteringRejectsGenericObjects()
|
||||||
|
{
|
||||||
|
Assert(!IsAssetNameCandidate("chair"), "Chair should not be treated as a story asset.");
|
||||||
|
Assert(!IsAssetNameCandidate("table"), "Table should not be treated as a story asset.");
|
||||||
|
Assert(!IsAssetNameCandidate("street"), "Street should not be treated as a story asset.");
|
||||||
|
Assert(!IsAssetNameCandidate("car park"), "Car park should not be treated as a story asset.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void AssetFilteringPreservesStoryAssets()
|
||||||
|
{
|
||||||
|
Assert(IsAssetNameCandidate("TR6"), "TR6 should be treated as a story asset.");
|
||||||
|
Assert(IsAssetNameCandidate("red notebook"), "Red notebook should be treated as a story asset.");
|
||||||
|
Assert(IsAssetNameCandidate("passport"), "Passport should be treated as a story asset.");
|
||||||
|
Assert(IsAssetNameCandidate("car keys"), "Car keys should be treated as a story asset.");
|
||||||
|
Assert(IsAssetNameCandidate("driving licence"), "Driving licence should be treated as a story asset.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void AssetCanonicalKeysMergeTrivialVariants()
|
||||||
|
{
|
||||||
|
Assert(AssetCanonicalKey("red notebook") == AssetCanonicalKey("the notebook"), "Notebook variants should merge.");
|
||||||
|
Assert(AssetCanonicalKey("photo") == AssetCanonicalKey("photograph"), "Photo should fold into photograph.");
|
||||||
|
Assert(AssetCanonicalKey("driving license") == AssetCanonicalKey("driving licence"), "License/licence spelling should merge.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool IsAssetNameCandidate(string name)
|
||||||
|
=> InvokePrivateAssetFilter("IsAssetNameCandidate", name);
|
||||||
|
|
||||||
|
static string AssetCanonicalKey(string name)
|
||||||
|
{
|
||||||
|
var method = typeof(StoryIntelligenceAssetImportService).GetMethod(
|
||||||
|
"CanonicalAssetKey",
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||||
|
Assert(method is not null, "CanonicalAssetKey was not found.");
|
||||||
|
return (string)method!.Invoke(null, [name])!;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool InvokePrivateAssetFilter(string methodName, string name)
|
||||||
|
{
|
||||||
|
var method = typeof(StoryIntelligenceAssetImportService).GetMethod(
|
||||||
|
methodName,
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||||
|
Assert(method is not null, $"{methodName} was not found.");
|
||||||
|
return method!.Invoke(null, [name]) is true;
|
||||||
|
}
|
||||||
|
|
||||||
static JsonSerializerOptions JsonOptions()
|
static JsonSerializerOptions JsonOptions()
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -75,6 +75,9 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
return target.Route switch
|
return target.Route switch
|
||||||
{
|
{
|
||||||
StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }),
|
StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }),
|
||||||
|
StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = target.BatchID }),
|
||||||
|
StoryIntelligenceResumeRoutes.Assets => RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = target.BatchID }),
|
||||||
|
StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = target.BatchID }),
|
||||||
StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = target.BatchID }),
|
StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = target.BatchID }),
|
||||||
_ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID })
|
_ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID })
|
||||||
};
|
};
|
||||||
@ -156,6 +159,93 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
: View(model);
|
: View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("story-intelligence/locations")]
|
||||||
|
public async Task<IActionResult> StoryIntelligenceLocations(Guid batchId)
|
||||||
|
{
|
||||||
|
var model = await storyIntelligence.GetProgressAsync(batchId);
|
||||||
|
if (model is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.HasActiveRuns)
|
||||||
|
{
|
||||||
|
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!model.AllCommitted)
|
||||||
|
{
|
||||||
|
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return !model.PipelineDashboard.CharacterStageComplete
|
||||||
|
? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId })
|
||||||
|
: View(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("story-intelligence/assets")]
|
||||||
|
public async Task<IActionResult> StoryIntelligenceAssets(Guid batchId)
|
||||||
|
{
|
||||||
|
var model = await storyIntelligence.GetProgressAsync(batchId);
|
||||||
|
if (model is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.HasActiveRuns)
|
||||||
|
{
|
||||||
|
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!model.AllCommitted)
|
||||||
|
{
|
||||||
|
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!model.PipelineDashboard.CharacterStageComplete)
|
||||||
|
{
|
||||||
|
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return !model.PipelineDashboard.LocationStageComplete
|
||||||
|
? RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId })
|
||||||
|
: View(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("story-intelligence/relationships")]
|
||||||
|
public async Task<IActionResult> StoryIntelligenceRelationships(Guid batchId)
|
||||||
|
{
|
||||||
|
var model = await storyIntelligence.GetProgressAsync(batchId);
|
||||||
|
if (model is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.HasActiveRuns)
|
||||||
|
{
|
||||||
|
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!model.AllCommitted)
|
||||||
|
{
|
||||||
|
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!model.PipelineDashboard.CharacterStageComplete)
|
||||||
|
{
|
||||||
|
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!model.PipelineDashboard.LocationStageComplete)
|
||||||
|
{
|
||||||
|
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return !model.PipelineDashboard.AssetStageComplete
|
||||||
|
? RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId })
|
||||||
|
: View(model);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("story-intelligence/cancel")]
|
[HttpPost("story-intelligence/cancel")]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> CancelStoryIntelligence(Guid batchId)
|
public async Task<IActionResult> CancelStoryIntelligence(Guid batchId)
|
||||||
@ -229,6 +319,75 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
return model is null ? NotFound() : View(model);
|
return model is null ? NotFound() : View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("story-intelligence/locations")]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> ImportStoryIntelligenceLocations(StoryIntelligenceLocationImportForm form)
|
||||||
|
{
|
||||||
|
var (progress, result) = await storyIntelligence.ImportLocationsAsync(form.BatchID, form);
|
||||||
|
if (progress is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
|
||||||
|
return result.Success
|
||||||
|
? RedirectToAction(nameof(StoryIntelligenceLocationComplete), new { batchId = form.BatchID })
|
||||||
|
: RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("story-intelligence/locations/complete")]
|
||||||
|
public async Task<IActionResult> StoryIntelligenceLocationComplete(Guid batchId)
|
||||||
|
{
|
||||||
|
var model = await storyIntelligence.GetLocationImportResultAsync(batchId);
|
||||||
|
return model is null ? NotFound() : View(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("story-intelligence/assets")]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> ImportStoryIntelligenceAssets(StoryIntelligenceAssetImportForm form)
|
||||||
|
{
|
||||||
|
var (progress, result) = await storyIntelligence.ImportAssetsAsync(form.BatchID, form);
|
||||||
|
if (progress is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
|
||||||
|
return result.Success
|
||||||
|
? RedirectToAction(nameof(StoryIntelligenceAssetComplete), new { batchId = form.BatchID })
|
||||||
|
: RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("story-intelligence/assets/complete")]
|
||||||
|
public async Task<IActionResult> StoryIntelligenceAssetComplete(Guid batchId)
|
||||||
|
{
|
||||||
|
var model = await storyIntelligence.GetAssetImportResultAsync(batchId);
|
||||||
|
return model is null ? NotFound() : View(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("story-intelligence/relationships")]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> ImportStoryIntelligenceRelationships(StoryIntelligenceRelationshipImportForm form)
|
||||||
|
{
|
||||||
|
var (progress, result) = await storyIntelligence.ImportRelationshipsAsync(form.BatchID, form);
|
||||||
|
if (progress is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
|
||||||
|
return result.Success
|
||||||
|
? RedirectToAction(nameof(StoryIntelligenceRelationshipComplete), new { batchId = form.BatchID })
|
||||||
|
: RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = form.BatchID });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("story-intelligence/relationships/complete")]
|
||||||
|
public async Task<IActionResult> StoryIntelligenceRelationshipComplete(Guid batchId)
|
||||||
|
{
|
||||||
|
var model = await storyIntelligence.GetRelationshipImportResultAsync(batchId);
|
||||||
|
return model is null ? NotFound() : View(model);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("story-intelligence/complete")]
|
[HttpGet("story-intelligence/complete")]
|
||||||
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
|
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
|
||||||
{
|
{
|
||||||
@ -243,6 +402,21 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
|
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!progress.PipelineDashboard.LocationStageComplete)
|
||||||
|
{
|
||||||
|
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!progress.PipelineDashboard.AssetStageComplete)
|
||||||
|
{
|
||||||
|
return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!progress.PipelineDashboard.RelationshipStageComplete)
|
||||||
|
{
|
||||||
|
return RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId });
|
||||||
|
}
|
||||||
|
|
||||||
var model = await storyIntelligence.GetCompletionAsync(batchId);
|
var model = await storyIntelligence.GetCompletionAsync(batchId);
|
||||||
return model is null ? NotFound() : View(model);
|
return model is null ? NotFound() : View(model);
|
||||||
}
|
}
|
||||||
|
|||||||
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 SceneImport = "SceneImport";
|
||||||
public const string CharacterReview = "CharacterReview";
|
public const string CharacterReview = "CharacterReview";
|
||||||
public const string CharacterImport = "CharacterImport";
|
public const string CharacterImport = "CharacterImport";
|
||||||
|
public const string LocationReview = "LocationReview";
|
||||||
|
public const string LocationImport = "LocationImport";
|
||||||
|
public const string AssetReview = "AssetReview";
|
||||||
|
public const string AssetImport = "AssetImport";
|
||||||
|
public const string RelationshipReview = "RelationshipReview";
|
||||||
|
public const string RelationshipImport = "RelationshipImport";
|
||||||
public const string Complete = "Complete";
|
public const string Complete = "Complete";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,7 +62,9 @@ public sealed class StoryIntelligenceBookPipelineState
|
|||||||
public DateTime CreatedUtc { get; init; }
|
public DateTime CreatedUtc { get; init; }
|
||||||
public DateTime UpdatedUtc { get; init; }
|
public DateTime UpdatedUtc { get; init; }
|
||||||
|
|
||||||
public bool IsComplete => string.Equals(Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase);
|
public bool IsComplete
|
||||||
|
=> string.Equals(Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceBookPipelineSaveRequest
|
public sealed class StoryIntelligenceBookPipelineSaveRequest
|
||||||
|
|||||||
@ -208,6 +208,9 @@ public class Program
|
|||||||
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
|
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>();
|
builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceCharacterImportService, StoryIntelligenceCharacterImportService>();
|
builder.Services.AddScoped<IStoryIntelligenceCharacterImportService, StoryIntelligenceCharacterImportService>();
|
||||||
|
builder.Services.AddScoped<IStoryIntelligenceLocationImportService, StoryIntelligenceLocationImportService>();
|
||||||
|
builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
|
||||||
|
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
|
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
||||||
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
||||||
|
|||||||
@ -16,6 +16,12 @@ public interface IOnboardingStoryIntelligenceService
|
|||||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId);
|
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId);
|
||||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form);
|
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form);
|
||||||
Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId);
|
Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId);
|
||||||
|
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm form);
|
||||||
|
Task<StoryIntelligenceLocationImportResultViewModel?> GetLocationImportResultAsync(Guid batchId);
|
||||||
|
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm form);
|
||||||
|
Task<StoryIntelligenceAssetImportResultViewModel?> GetAssetImportResultAsync(Guid batchId);
|
||||||
|
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportRelationshipsAsync(Guid batchId, StoryIntelligenceRelationshipImportForm form);
|
||||||
|
Task<StoryIntelligenceRelationshipImportResultViewModel?> GetRelationshipImportResultAsync(Guid batchId);
|
||||||
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
|
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
|
||||||
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
|
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
|
||||||
}
|
}
|
||||||
@ -27,6 +33,9 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
IStoryIntelligenceResultRepository runs,
|
IStoryIntelligenceResultRepository runs,
|
||||||
IStoryIntelligenceImportCommitService commits,
|
IStoryIntelligenceImportCommitService commits,
|
||||||
IStoryIntelligenceCharacterImportService characterImport,
|
IStoryIntelligenceCharacterImportService characterImport,
|
||||||
|
IStoryIntelligenceLocationImportService locationImport,
|
||||||
|
IStoryIntelligenceAssetImportService assetImport,
|
||||||
|
IStoryIntelligenceRelationshipImportService relationshipImport,
|
||||||
IStoryIntelligencePipelineStateService pipelineState,
|
IStoryIntelligencePipelineStateService pipelineState,
|
||||||
IStoryIntelligenceClient client,
|
IStoryIntelligenceClient client,
|
||||||
IOnboardingStoryIntelligenceBatchStore batchStore,
|
IOnboardingStoryIntelligenceBatchStore batchStore,
|
||||||
@ -259,6 +268,9 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var characterReview = await characterImport.BuildReviewAsync(batch);
|
var characterReview = await characterImport.BuildReviewAsync(batch);
|
||||||
|
var locationReview = await locationImport.BuildReviewAsync(batch);
|
||||||
|
var assetReview = await assetImport.BuildReviewAsync(batch);
|
||||||
|
var relationshipReview = await relationshipImport.BuildReviewAsync(batch);
|
||||||
return new StoryIntelligenceProgressViewModel
|
return new StoryIntelligenceProgressViewModel
|
||||||
{
|
{
|
||||||
BatchID = batch.BatchID,
|
BatchID = batch.BatchID,
|
||||||
@ -269,13 +281,25 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
BookTitle = batch.BookTitle,
|
BookTitle = batch.BookTitle,
|
||||||
Chapters = chapters,
|
Chapters = chapters,
|
||||||
CharacterReview = characterReview,
|
CharacterReview = characterReview,
|
||||||
|
LocationReview = locationReview,
|
||||||
|
AssetReview = assetReview,
|
||||||
|
RelationshipReview = relationshipReview,
|
||||||
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
|
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
|
||||||
{
|
{
|
||||||
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
|
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
|
||||||
ScenesImported = chapters.Sum(chapter => chapter.ScenesCreated),
|
ScenesImported = chapters.Sum(chapter => chapter.ScenesCreated),
|
||||||
CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count,
|
CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count,
|
||||||
CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked),
|
CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked),
|
||||||
CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete
|
CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete,
|
||||||
|
LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count,
|
||||||
|
LocationsCreatedOrLinked = batch.LocationDecisions.Count(decision => decision.CreatedOrLinked),
|
||||||
|
LocationStageComplete = batch.LocationStageComplete || locationReview.IsComplete,
|
||||||
|
AssetsIdentified = assetReview.Candidates.Count + batch.AssetDecisions.Count,
|
||||||
|
AssetsCreatedOrLinked = batch.AssetDecisions.Count(decision => decision.CreatedOrLinked),
|
||||||
|
AssetStageComplete = batch.AssetStageComplete || assetReview.IsComplete,
|
||||||
|
RelationshipsIdentified = relationshipReview.Candidates.Count + batch.RelationshipDecisions.Count,
|
||||||
|
RelationshipsCreatedOrLinked = batch.RelationshipDecisions.Count(decision => decision.CreatedOrLinked),
|
||||||
|
RelationshipStageComplete = batch.RelationshipStageComplete || relationshipReview.IsComplete
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -370,12 +394,113 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
BookID = batch.BookID,
|
BookID = batch.BookID,
|
||||||
CharactersCreated = batch.LastCharacterImportResult?.CharactersCreated ?? 0,
|
CharactersCreated = batch.LastCharacterImportResult?.CharactersCreated ?? 0,
|
||||||
CharactersLinked = batch.LastCharacterImportResult?.CharactersLinked ?? 0,
|
CharactersLinked = batch.LastCharacterImportResult?.CharactersLinked ?? 0,
|
||||||
|
CharactersAliased = batch.LastCharacterImportResult?.CharactersAliased ?? 0,
|
||||||
CharactersIgnored = batch.LastCharacterImportResult?.CharactersIgnored ?? 0,
|
CharactersIgnored = batch.LastCharacterImportResult?.CharactersIgnored ?? 0,
|
||||||
PovLinksResolved = batch.LastCharacterImportResult?.PovLinksResolved ?? 0,
|
PovLinksResolved = batch.LastCharacterImportResult?.PovLinksResolved ?? 0,
|
||||||
ScenePeoplePanelsUpdated = batch.LastCharacterImportResult?.ScenePeoplePanelsUpdated ?? 0
|
ScenePeoplePanelsUpdated = batch.LastCharacterImportResult?.ScenePeoplePanelsUpdated ?? 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm form)
|
||||||
|
{
|
||||||
|
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
||||||
|
if (batch is null)
|
||||||
|
{
|
||||||
|
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await locationImport.ImportAsync(batch, form);
|
||||||
|
return (await GetProgressAsync(batchId), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StoryIntelligenceLocationImportResultViewModel?> GetLocationImportResultAsync(Guid batchId)
|
||||||
|
{
|
||||||
|
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
||||||
|
if (batch is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StoryIntelligenceLocationImportResultViewModel
|
||||||
|
{
|
||||||
|
BatchID = batch.BatchID,
|
||||||
|
ProjectID = batch.ProjectID,
|
||||||
|
BookID = batch.BookID,
|
||||||
|
LocationsCreated = batch.LastLocationImportResult?.LocationsCreated ?? 0,
|
||||||
|
LocationsLinked = batch.LastLocationImportResult?.LocationsLinked ?? 0,
|
||||||
|
LocationAliasesAdded = batch.LastLocationImportResult?.LocationAliasesAdded ?? 0,
|
||||||
|
LocationsIgnored = batch.LastLocationImportResult?.LocationsIgnored ?? 0,
|
||||||
|
SceneLocationsUpdated = batch.LastLocationImportResult?.SceneLocationsUpdated ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm form)
|
||||||
|
{
|
||||||
|
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
||||||
|
if (batch is null)
|
||||||
|
{
|
||||||
|
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await assetImport.ImportAsync(batch, form);
|
||||||
|
return (await GetProgressAsync(batchId), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StoryIntelligenceAssetImportResultViewModel?> GetAssetImportResultAsync(Guid batchId)
|
||||||
|
{
|
||||||
|
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
||||||
|
if (batch is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StoryIntelligenceAssetImportResultViewModel
|
||||||
|
{
|
||||||
|
BatchID = batch.BatchID,
|
||||||
|
ProjectID = batch.ProjectID,
|
||||||
|
BookID = batch.BookID,
|
||||||
|
AssetsCreated = batch.LastAssetImportResult?.AssetsCreated ?? 0,
|
||||||
|
AssetsLinked = batch.LastAssetImportResult?.AssetsLinked ?? 0,
|
||||||
|
AssetAliasesAdded = batch.LastAssetImportResult?.AssetAliasesAdded ?? 0,
|
||||||
|
AssetsIgnored = batch.LastAssetImportResult?.AssetsIgnored ?? 0,
|
||||||
|
SceneAssetEventsCreated = batch.LastAssetImportResult?.SceneAssetEventsCreated ?? 0,
|
||||||
|
OwnershipLinksCreated = batch.LastAssetImportResult?.OwnershipLinksCreated ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportRelationshipsAsync(Guid batchId, StoryIntelligenceRelationshipImportForm form)
|
||||||
|
{
|
||||||
|
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
||||||
|
if (batch is null)
|
||||||
|
{
|
||||||
|
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await relationshipImport.ImportAsync(batch, form);
|
||||||
|
return (await GetProgressAsync(batchId), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StoryIntelligenceRelationshipImportResultViewModel?> GetRelationshipImportResultAsync(Guid batchId)
|
||||||
|
{
|
||||||
|
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
||||||
|
if (batch is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StoryIntelligenceRelationshipImportResultViewModel
|
||||||
|
{
|
||||||
|
BatchID = batch.BatchID,
|
||||||
|
ProjectID = batch.ProjectID,
|
||||||
|
BookID = batch.BookID,
|
||||||
|
RelationshipsCreated = batch.LastRelationshipImportResult?.RelationshipsCreated ?? 0,
|
||||||
|
RelationshipsLinked = batch.LastRelationshipImportResult?.RelationshipsLinked ?? 0,
|
||||||
|
RelationshipsMerged = batch.LastRelationshipImportResult?.RelationshipsMerged ?? 0,
|
||||||
|
RelationshipsIgnored = batch.LastRelationshipImportResult?.RelationshipsIgnored ?? 0,
|
||||||
|
RelationshipEventsCreated = batch.LastRelationshipImportResult?.RelationshipEventsCreated ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
|
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
|
||||||
{
|
{
|
||||||
var progress = await GetProgressAsync(batchId);
|
var progress = await GetProgressAsync(batchId);
|
||||||
@ -395,7 +520,13 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
ChaptersCommitted = progress.CommittedChapterCount,
|
ChaptersCommitted = progress.CommittedChapterCount,
|
||||||
ScenesCreated = progress.Chapters.Sum(chapter => chapter.ScenesCreated),
|
ScenesCreated = progress.Chapters.Sum(chapter => chapter.ScenesCreated),
|
||||||
CharactersCreatedOrLinked = progress.PipelineDashboard.CharactersCreatedOrLinked,
|
CharactersCreatedOrLinked = progress.PipelineDashboard.CharactersCreatedOrLinked,
|
||||||
CharacterStageComplete = progress.PipelineDashboard.CharacterStageComplete
|
CharacterStageComplete = progress.PipelineDashboard.CharacterStageComplete,
|
||||||
|
LocationsCreatedOrLinked = progress.PipelineDashboard.LocationsCreatedOrLinked,
|
||||||
|
LocationStageComplete = progress.PipelineDashboard.LocationStageComplete,
|
||||||
|
AssetsCreatedOrLinked = progress.PipelineDashboard.AssetsCreatedOrLinked,
|
||||||
|
AssetStageComplete = progress.PipelineDashboard.AssetStageComplete,
|
||||||
|
RelationshipsCreatedOrLinked = progress.PipelineDashboard.RelationshipsCreatedOrLinked,
|
||||||
|
RelationshipStageComplete = progress.PipelineDashboard.RelationshipStageComplete
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -437,26 +568,58 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
.ToList()
|
.ToList()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (state.IsComplete)
|
var characterStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.CharacterImport)
|
||||||
{
|
|| state.CurrentStage is StoryIntelligencePipelineStages.LocationReview
|
||||||
batch.CharacterStageComplete = true;
|
or StoryIntelligencePipelineStages.LocationImport
|
||||||
}
|
or StoryIntelligencePipelineStages.AssetReview
|
||||||
|
or StoryIntelligencePipelineStages.AssetImport
|
||||||
|
or StoryIntelligencePipelineStages.RelationshipReview
|
||||||
|
or StoryIntelligencePipelineStages.RelationshipImport
|
||||||
|
or StoryIntelligencePipelineStages.Complete;
|
||||||
|
var locationStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.LocationImport)
|
||||||
|
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview
|
||||||
|
or StoryIntelligencePipelineStages.AssetImport
|
||||||
|
or StoryIntelligencePipelineStages.RelationshipReview
|
||||||
|
or StoryIntelligencePipelineStages.RelationshipImport
|
||||||
|
or StoryIntelligencePipelineStages.Complete;
|
||||||
|
var assetStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.AssetImport)
|
||||||
|
|| state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview
|
||||||
|
or StoryIntelligencePipelineStages.RelationshipImport
|
||||||
|
or StoryIntelligencePipelineStages.Complete;
|
||||||
|
var relationshipStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.RelationshipImport);
|
||||||
|
|
||||||
|
batch.CharacterStageComplete = characterStageComplete;
|
||||||
|
batch.LocationStageComplete = locationStageComplete;
|
||||||
|
batch.AssetStageComplete = assetStageComplete;
|
||||||
|
batch.RelationshipStageComplete = relationshipStageComplete;
|
||||||
|
|
||||||
await batchStore.SaveAsync(batch);
|
await batchStore.SaveAsync(batch);
|
||||||
|
|
||||||
var route = state.CurrentStage switch
|
var route = relationshipStageComplete
|
||||||
|
? StoryIntelligenceResumeRoutes.Complete
|
||||||
|
: assetStageComplete
|
||||||
|
? StoryIntelligenceResumeRoutes.Relationships
|
||||||
|
: locationStageComplete
|
||||||
|
? StoryIntelligenceResumeRoutes.Assets
|
||||||
|
: characterStageComplete
|
||||||
|
? StoryIntelligenceResumeRoutes.Locations
|
||||||
|
: state.CurrentStage switch
|
||||||
{
|
{
|
||||||
StoryIntelligencePipelineStages.Complete => StoryIntelligenceResumeRoutes.Complete,
|
StoryIntelligencePipelineStages.RelationshipReview => StoryIntelligenceResumeRoutes.Relationships,
|
||||||
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
|
StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships,
|
||||||
StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters,
|
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
|
||||||
StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview,
|
StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters,
|
||||||
StoryIntelligencePipelineStages.SceneImport => StoryIntelligenceResumeRoutes.SceneReview,
|
StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview,
|
||||||
_ => StoryIntelligenceResumeRoutes.SceneReview
|
StoryIntelligencePipelineStages.SceneImport => StoryIntelligenceResumeRoutes.SceneReview,
|
||||||
};
|
_ => StoryIntelligenceResumeRoutes.SceneReview
|
||||||
|
};
|
||||||
|
|
||||||
return new OnboardingStoryIntelligenceResumeTarget(batch.BatchID, route);
|
return new OnboardingStoryIntelligenceResumeTarget(batch.BatchID, route);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool HasCompletedStage(StoryIntelligenceBookPipelineState state, string stage)
|
||||||
|
=> string.Equals(state.LastCompletedStage, stage, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private async Task<(UserOnboardingState State, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, OnboardingManuscriptBuildResult? Build, OnboardingWizardViewModel Wizard)?> GetContextAsync(int userId)
|
private async Task<(UserOnboardingState State, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, OnboardingManuscriptBuildResult? Build, OnboardingWizardViewModel Wizard)?> GetContextAsync(int userId)
|
||||||
{
|
{
|
||||||
var state = await onboardingRepository.GetAsync(userId);
|
var state = await onboardingRepository.GetAsync(userId);
|
||||||
@ -685,8 +848,17 @@ public sealed class OnboardingStoryIntelligenceBatch
|
|||||||
public DateTime CreatedUtc { get; init; } = DateTime.UtcNow;
|
public DateTime CreatedUtc { get; init; } = DateTime.UtcNow;
|
||||||
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
|
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
|
||||||
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
|
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
|
||||||
|
public List<OnboardingStoryIntelligenceLocationDecision> LocationDecisions { get; } = [];
|
||||||
|
public List<OnboardingStoryIntelligenceAssetDecision> AssetDecisions { get; } = [];
|
||||||
|
public List<OnboardingStoryIntelligenceRelationshipDecision> RelationshipDecisions { get; } = [];
|
||||||
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
|
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
|
||||||
|
public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; }
|
||||||
|
public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; }
|
||||||
|
public StoryIntelligenceRelationshipImportBatchResult? LastRelationshipImportResult { get; set; }
|
||||||
public bool CharacterStageComplete { get; set; }
|
public bool CharacterStageComplete { get; set; }
|
||||||
|
public bool LocationStageComplete { get; set; }
|
||||||
|
public bool AssetStageComplete { get; set; }
|
||||||
|
public bool RelationshipStageComplete { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class OnboardingStoryIntelligenceBatchItem
|
public sealed class OnboardingStoryIntelligenceBatchItem
|
||||||
@ -707,19 +879,78 @@ public sealed class OnboardingStoryIntelligenceCharacterDecision
|
|||||||
public bool CreatedOrLinked { get; init; }
|
public bool CreatedOrLinked { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class OnboardingStoryIntelligenceLocationDecision
|
||||||
|
{
|
||||||
|
public string Key { get; init; } = string.Empty;
|
||||||
|
public string Action { get; init; } = string.Empty;
|
||||||
|
public string? LocationName { get; init; }
|
||||||
|
public int? LocationID { get; init; }
|
||||||
|
public bool CreatedOrLinked { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OnboardingStoryIntelligenceAssetDecision
|
||||||
|
{
|
||||||
|
public string Key { get; init; } = string.Empty;
|
||||||
|
public string Action { get; init; } = string.Empty;
|
||||||
|
public string? AssetName { get; init; }
|
||||||
|
public int? StoryAssetID { get; init; }
|
||||||
|
public bool CreatedOrLinked { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OnboardingStoryIntelligenceRelationshipDecision
|
||||||
|
{
|
||||||
|
public string Key { get; init; } = string.Empty;
|
||||||
|
public string Action { get; init; } = string.Empty;
|
||||||
|
public int? CharacterRelationshipID { get; init; }
|
||||||
|
public string? RelationshipLabel { get; init; }
|
||||||
|
public bool CreatedOrLinked { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceCharacterImportBatchResult
|
public sealed class StoryIntelligenceCharacterImportBatchResult
|
||||||
{
|
{
|
||||||
public int CharactersCreated { get; init; }
|
public int CharactersCreated { get; init; }
|
||||||
public int CharactersLinked { get; init; }
|
public int CharactersLinked { get; init; }
|
||||||
|
public int CharactersAliased { get; init; }
|
||||||
public int CharactersIgnored { get; init; }
|
public int CharactersIgnored { get; init; }
|
||||||
public int PovLinksResolved { get; init; }
|
public int PovLinksResolved { get; init; }
|
||||||
public int ScenePeoplePanelsUpdated { get; init; }
|
public int ScenePeoplePanelsUpdated { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceLocationImportBatchResult
|
||||||
|
{
|
||||||
|
public int LocationsCreated { get; init; }
|
||||||
|
public int LocationsLinked { get; init; }
|
||||||
|
public int LocationAliasesAdded { get; init; }
|
||||||
|
public int LocationsIgnored { get; init; }
|
||||||
|
public int SceneLocationsUpdated { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceAssetImportBatchResult
|
||||||
|
{
|
||||||
|
public int AssetsCreated { get; init; }
|
||||||
|
public int AssetsLinked { get; init; }
|
||||||
|
public int AssetAliasesAdded { get; init; }
|
||||||
|
public int AssetsIgnored { get; init; }
|
||||||
|
public int SceneAssetEventsCreated { get; init; }
|
||||||
|
public int OwnershipLinksCreated { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceRelationshipImportBatchResult
|
||||||
|
{
|
||||||
|
public int RelationshipsCreated { get; init; }
|
||||||
|
public int RelationshipsLinked { get; init; }
|
||||||
|
public int RelationshipsMerged { get; init; }
|
||||||
|
public int RelationshipsIgnored { get; init; }
|
||||||
|
public int RelationshipEventsCreated { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public static class StoryIntelligenceResumeRoutes
|
public static class StoryIntelligenceResumeRoutes
|
||||||
{
|
{
|
||||||
public const string SceneReview = "SceneReview";
|
public const string SceneReview = "SceneReview";
|
||||||
public const string Characters = "Characters";
|
public const string Characters = "Characters";
|
||||||
|
public const string Locations = "Locations";
|
||||||
|
public const string Assets = "Assets";
|
||||||
|
public const string Relationships = "Relationships";
|
||||||
public const string Complete = "Complete";
|
public const string Complete = "Complete";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using PlotLine.Data;
|
using PlotLine.Data;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
using PlotLine.ViewModels;
|
using PlotLine.ViewModels;
|
||||||
@ -31,11 +32,38 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
private static readonly HashSet<string> GenericPeople = CreateSet(
|
private static readonly HashSet<string> GenericPeople = CreateSet(
|
||||||
"man", "woman", "boy", "girl", "child", "children", "person", "people", "doctor", "nurse",
|
"man", "woman", "boy", "girl", "child", "children", "person", "people", "doctor", "nurse",
|
||||||
"police officer", "driver", "receptionist", "attendant", "guard", "security guard", "waiter", "waitress",
|
"police officer", "driver", "receptionist", "attendant", "guard", "security guard", "waiter", "waitress",
|
||||||
"clerk", "shopkeeper", "cashier", "crowd", "family", "group", "small group", "police");
|
"clerk", "shopkeeper", "cashier", "crowd", "family", "group", "small group", "police", "officer",
|
||||||
|
"officers", "staff", "customers", "boys", "girls", "men", "women", "neighbour", "neighbours",
|
||||||
|
"passenger", "passengers", "examiner", "examiners", "instructor", "instructors", "candidate", "candidates",
|
||||||
|
"social worker", "social workers", "responders");
|
||||||
|
|
||||||
|
private static readonly HashSet<string> GenericGroupHeads = CreateSet(
|
||||||
|
"man", "men", "woman", "women", "boy", "boys", "girl", "girls", "child", "children", "person", "people",
|
||||||
|
"group", "crowd", "family", "neighbour", "neighbours", "passenger", "passengers", "examiner", "examiners",
|
||||||
|
"driver", "drivers", "instructor", "instructors", "candidate", "candidates", "officer", "officers", "staff", "customers",
|
||||||
|
"responders", "worker", "workers", "lad", "lads");
|
||||||
|
|
||||||
|
private static readonly HashSet<string> QuantityWords = CreateSet(
|
||||||
|
"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "several", "some",
|
||||||
|
"many", "few", "couple", "various", "other", "groups");
|
||||||
|
|
||||||
|
private static readonly HashSet<string> DeterminerWords = CreateSet(
|
||||||
|
"the", "a", "an", "my", "your", "his", "her", "our", "their");
|
||||||
|
|
||||||
|
private static readonly HashSet<string> FamilyRoleNames = CreateSet(
|
||||||
|
"aunt", "uncle", "mother", "father", "mum", "mom", "mam", "dad", "brother", "sister", "grandmother",
|
||||||
|
"grandfather", "grandma", "grandad", "granddad");
|
||||||
|
|
||||||
|
private static readonly HashSet<string> CharacterTitlePrefixes = CreateSet(
|
||||||
|
"mr", "mrs", "miss", "ms", "dr", "reverend", "rev", "detective", "sergeant", "ds", "aunt", "uncle");
|
||||||
|
|
||||||
private static readonly HashSet<string> NonCharacterNames = CreateSet(
|
private static readonly HashSet<string> NonCharacterNames = CreateSet(
|
||||||
"christmas", "house", "street", "door", "window", "car", "church", "building", "lobby", "room",
|
"christmas", "house", "street", "door", "window", "car", "church", "building", "lobby", "room",
|
||||||
"hallway", "kitchen", "garage", "pavement", "road", "desk", "table", "phone", "letter", "note");
|
"hallway", "kitchen", "garage", "pavement", "road", "desk", "table", "phone", "letter", "note",
|
||||||
|
"social services", "under-flyover / link road");
|
||||||
|
|
||||||
|
private static readonly Regex ParentheticalSuffix = new(@"\s*\([^)]*\)\s*$", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex LeadingQuantity = new(@"^\s*(\d+|one|two|three|four|five|six|seven|eight|nine|ten|several|some|many|few|couple|various|other)\s+", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
public async Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
public async Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
||||||
{
|
{
|
||||||
@ -86,9 +114,12 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
var created = 0;
|
var created = 0;
|
||||||
var linkedExisting = 0;
|
var linkedExisting = 0;
|
||||||
var ignored = 0;
|
var ignored = 0;
|
||||||
|
var aliased = 0;
|
||||||
var linkedAppearances = 0;
|
var linkedAppearances = 0;
|
||||||
var povLinks = 0;
|
var povLinks = 0;
|
||||||
var resolvedCharacters = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
var resolvedCharacters = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var resolvedCandidateIds = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var candidateByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
foreach (var candidate in data.Candidates)
|
foreach (var candidate in data.Candidates)
|
||||||
{
|
{
|
||||||
@ -104,14 +135,19 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var requestedName = Clean(choice.ImportName);
|
var requestedName = Clean(choice.ImportName);
|
||||||
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
|
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
|
||||||
var forceCreateSeparate = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.CreateSeparate, StringComparison.OrdinalIgnoreCase);
|
var linkExisting = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
|
||||||
var matchedExistingId = forceCreateSeparate
|
var matchedExistingId = linkExisting
|
||||||
? null
|
? candidate.ExistingCharacterID
|
||||||
: candidate.ExistingCharacterID
|
|
||||||
?? existingIndex.Find(importName)?.CharacterID
|
?? existingIndex.Find(importName)?.CharacterID
|
||||||
?? existingIndex.Find(candidate.DisplayName)?.CharacterID;
|
?? existingIndex.Find(candidate.DisplayName)?.CharacterID
|
||||||
|
: null;
|
||||||
var characterId = matchedExistingId;
|
var characterId = matchedExistingId;
|
||||||
|
|
||||||
if (!characterId.HasValue)
|
if (!characterId.HasValue)
|
||||||
@ -151,9 +187,60 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
linkedAppearances++;
|
linkedAppearances++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolvedCandidateIds[candidate.Key] = characterId.Value;
|
||||||
AddDecision(batch, candidate.Key, choice.Action, importName, characterId.Value, true);
|
AddDecision(batch, candidate.Key, choice.Action, importName, characterId.Value, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var candidate in data.Candidates)
|
||||||
|
{
|
||||||
|
if (!choices.TryGetValue(candidate.Key, out var choice)
|
||||||
|
|| !string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetKey = Clean(choice.AliasTargetKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(targetKey)
|
||||||
|
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| !choices.TryGetValue(targetKey, out var targetChoice)
|
||||||
|
|| string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate)
|
||||||
|
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetCharacterId))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Story Intelligence character alias candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
|
||||||
|
candidate.Key,
|
||||||
|
targetKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetName = targetCandidate.ExistingCharacterName ?? targetChoice.ImportName ?? targetCandidate.DisplayName;
|
||||||
|
await TryAddAliasAsync(targetCharacterId, candidate.DisplayName, targetName);
|
||||||
|
AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId);
|
||||||
|
foreach (var alias in candidate.Aliases)
|
||||||
|
{
|
||||||
|
await TryAddAliasAsync(targetCharacterId, alias, targetName);
|
||||||
|
AddResolvedName(resolvedCharacters, alias, targetCharacterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked))
|
||||||
|
{
|
||||||
|
await characters.SaveSceneCharacterAsync(new SceneCharacter
|
||||||
|
{
|
||||||
|
SceneID = appearance.SceneID,
|
||||||
|
CharacterID = targetCharacterId,
|
||||||
|
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
|
||||||
|
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
|
||||||
|
AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetCandidate.DisplayName, appearance.Notes)
|
||||||
|
});
|
||||||
|
linkedAppearances++;
|
||||||
|
}
|
||||||
|
|
||||||
|
aliased++;
|
||||||
|
AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Alias, candidate.DisplayName, targetCharacterId, true);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var scene in data.SceneAnalyses)
|
foreach (var scene in data.SceneAnalyses)
|
||||||
{
|
{
|
||||||
var povName = Clean(scene.Parsed.PointOfView?.CharacterName);
|
var povName = Clean(scene.Parsed.PointOfView?.CharacterName);
|
||||||
@ -172,16 +259,18 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
CharactersCreated = created,
|
CharactersCreated = created,
|
||||||
CharactersLinked = linkedExisting,
|
CharactersLinked = linkedExisting,
|
||||||
CharactersIgnored = ignored,
|
CharactersIgnored = ignored,
|
||||||
|
CharactersAliased = aliased,
|
||||||
PovLinksResolved = povLinks,
|
PovLinksResolved = povLinks,
|
||||||
ScenePeoplePanelsUpdated = linkedAppearances
|
ScenePeoplePanelsUpdated = linkedAppearances
|
||||||
};
|
};
|
||||||
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
|
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}",
|
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}",
|
||||||
batch.BatchID,
|
batch.BatchID,
|
||||||
created,
|
created,
|
||||||
linkedExisting,
|
linkedExisting,
|
||||||
|
aliased,
|
||||||
ignored,
|
ignored,
|
||||||
linkedAppearances,
|
linkedAppearances,
|
||||||
povLinks);
|
povLinks);
|
||||||
@ -190,7 +279,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
ScenesCreated = linkedAppearances,
|
ScenesCreated = linkedAppearances,
|
||||||
Message = $"Characters imported. {created:N0} created, {linkedExisting:N0} linked, {ignored:N0} ignored, {linkedAppearances:N0} scene appearance(s) updated, {povLinks:N0} POV link(s) resolved."
|
Message = $"Characters imported. {created:N0} created, {linkedExisting:N0} linked, {aliased:N0} alias decision(s) applied, {ignored:N0} ignored, {linkedAppearances:N0} scene appearance(s) updated, {povLinks:N0} POV link(s) resolved."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,6 +347,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
var candidates = groups.Values
|
var candidates = groups.Values
|
||||||
.Where(candidate => candidate.PresentAppearances.Count > 0 || candidate.MentionedOnlyAppearances.Count > 0)
|
.Where(candidate => candidate.PresentAppearances.Count > 0 || candidate.MentionedOnlyAppearances.Count > 0)
|
||||||
.Where(candidate => candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Any(appearance => !appearance.AlreadyLinked))
|
.Where(candidate => candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Any(appearance => !appearance.AlreadyLinked))
|
||||||
|
.Where(IsVisibleCharacterCandidate)
|
||||||
.OrderByDescending(candidate => candidate.PresentAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
|
.OrderByDescending(candidate => candidate.PresentAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
|
||||||
.ThenByDescending(candidate => candidate.MentionedOnlyAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
|
.ThenByDescending(candidate => candidate.MentionedOnlyAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
|
||||||
.ThenBy(candidate => candidate.DisplayName)
|
.ThenBy(candidate => candidate.DisplayName)
|
||||||
@ -515,6 +605,12 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? BuildAliasAppearanceNote(string aliasName, string targetName, string? notes)
|
||||||
|
{
|
||||||
|
var prefix = $"Imported as alias '{aliasName}' of {targetName}.";
|
||||||
|
return string.IsNullOrWhiteSpace(notes) ? prefix : $"{prefix} {notes}";
|
||||||
|
}
|
||||||
|
|
||||||
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, SceneIntelligenceCharacter character)
|
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, SceneIntelligenceCharacter character)
|
||||||
{
|
{
|
||||||
var note = Clean(character.Notes);
|
var note = Clean(character.Notes);
|
||||||
@ -551,6 +647,30 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
private static bool IsCharacterEntity(string? entityType)
|
private static bool IsCharacterEntity(string? entityType)
|
||||||
=> string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);
|
=> string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static bool IsVisibleCharacterCandidate(CharacterCandidate candidate)
|
||||||
|
{
|
||||||
|
var name = CleanCharacterName(candidate.DisplayName);
|
||||||
|
if (!IsCharacterNameCandidate(name))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsNamedOrTitledPerson(name))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsFamilyRoleOnly(name))
|
||||||
|
{
|
||||||
|
return candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances)
|
||||||
|
.Select(appearance => appearance.SceneID)
|
||||||
|
.Distinct()
|
||||||
|
.Count() >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !IsGenericGroupReference(name);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsCharacterNameCandidate(string? name)
|
private static bool IsCharacterNameCandidate(string? name)
|
||||||
{
|
{
|
||||||
var clean = CleanCharacterName(name);
|
var clean = CleanCharacterName(name);
|
||||||
@ -570,6 +690,80 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsGenericGroupReference(string? name)
|
||||||
|
{
|
||||||
|
var clean = CleanCharacterName(name);
|
||||||
|
if (string.IsNullOrWhiteSpace(clean))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var withoutParentheses = Clean(ParentheticalSuffix.Replace(clean, string.Empty));
|
||||||
|
var normalised = Normalise(withoutParentheses);
|
||||||
|
if (GenericPeople.Contains(normalised) || NonCharacterNames.Contains(normalised))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var words = WordsForFiltering(withoutParentheses);
|
||||||
|
if (words.Count == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LeadingQuantity.IsMatch(withoutParentheses)
|
||||||
|
&& words.Any(word => GenericGroupHeads.Contains(word)))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (words.Count > 1
|
||||||
|
&& words.Any(word => GenericGroupHeads.Contains(word))
|
||||||
|
&& !HasSpecificPossessiveFamilyReference(words))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (words.Count > 1
|
||||||
|
&& DeterminerWords.Contains(words[0])
|
||||||
|
&& words.Skip(1).Any(word => FamilyRoleNames.Contains(word) || GenericGroupHeads.Contains(word)))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (words.Count > 1
|
||||||
|
&& QuantityWords.Contains(words[0])
|
||||||
|
&& words.Skip(1).Any(word => GenericGroupHeads.Contains(word)))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return words.All(word => GenericGroupHeads.Contains(word) || QuantityWords.Contains(word));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsNamedOrTitledPerson(string name)
|
||||||
|
{
|
||||||
|
var words = WordsForFiltering(name);
|
||||||
|
if (words.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CharacterTitlePrefixes.Contains(words[0]) && words.Count > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsFamilyRoleOnly(string name)
|
||||||
|
{
|
||||||
|
var words = WordsForFiltering(name);
|
||||||
|
return words.Count == 1 && FamilyRoleNames.Contains(words[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasSpecificPossessiveFamilyReference(IReadOnlyList<string> words)
|
||||||
|
=> words.Count == 2
|
||||||
|
&& FamilyRoleNames.Contains(words[1])
|
||||||
|
&& words[0].EndsWith("s", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !DeterminerWords.Contains(words[0]);
|
||||||
|
|
||||||
private static string ResolveCandidateKey(
|
private static string ResolveCandidateKey(
|
||||||
IReadOnlyDictionary<string, CharacterCandidate> groups,
|
IReadOnlyDictionary<string, CharacterCandidate> groups,
|
||||||
CharacterIndex existingIndex,
|
CharacterIndex existingIndex,
|
||||||
@ -636,6 +830,15 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
return words;
|
return words;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<string> WordsForFiltering(string? name)
|
||||||
|
=> CleanCharacterName(name)
|
||||||
|
.Replace("/", " ", StringComparison.Ordinal)
|
||||||
|
.Replace("-", " ", StringComparison.Ordinal)
|
||||||
|
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(word => word.Trim('.', ',', ';', ':', '!', '?', '"', '\'', '(', ')').ToLowerInvariant())
|
||||||
|
.Where(word => !string.IsNullOrWhiteSpace(word))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
private static bool IsMergeableTitle(string value)
|
private static bool IsMergeableTitle(string value)
|
||||||
=> value.Equals("instructor", StringComparison.OrdinalIgnoreCase)
|
=> value.Equals("instructor", StringComparison.OrdinalIgnoreCase)
|
||||||
|| value.Equals("detective", StringComparison.OrdinalIgnoreCase)
|
|| value.Equals("detective", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|||||||
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<StoryIntelligenceBookPipelineState?> EnsureForBookAsync(int bookId, int userId);
|
||||||
Task RecordSceneImportAsync(int projectId, int bookId, int runId);
|
Task RecordSceneImportAsync(int projectId, int bookId, int runId);
|
||||||
Task RecordCharacterImportAsync(int projectId, int bookId);
|
Task RecordCharacterImportAsync(int projectId, int bookId);
|
||||||
|
Task RecordLocationImportAsync(int projectId, int bookId);
|
||||||
|
Task RecordAssetImportAsync(int projectId, int bookId);
|
||||||
|
Task RecordRelationshipImportAsync(int projectId, int bookId);
|
||||||
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
|
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,8 +83,53 @@ public sealed class StoryIntelligencePipelineStateService(
|
|||||||
{
|
{
|
||||||
ProjectID = projectId,
|
ProjectID = projectId,
|
||||||
BookID = bookId,
|
BookID = bookId,
|
||||||
CurrentStage = StoryIntelligencePipelineStages.Complete,
|
CurrentStage = StoryIntelligencePipelineStages.LocationReview,
|
||||||
LastCompletedStage = StoryIntelligencePipelineStages.CharacterImport,
|
LastCompletedStage = StoryIntelligencePipelineStages.CharacterImport,
|
||||||
|
CurrentReviewStage = StoryIntelligencePipelineStages.LocationReview,
|
||||||
|
Status = StoryIntelligencePipelineStatuses.NeedsReview,
|
||||||
|
CompletedUtc = null,
|
||||||
|
LastRunID = null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RecordLocationImportAsync(int projectId, int bookId)
|
||||||
|
{
|
||||||
|
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
|
||||||
|
{
|
||||||
|
ProjectID = projectId,
|
||||||
|
BookID = bookId,
|
||||||
|
CurrentStage = StoryIntelligencePipelineStages.AssetReview,
|
||||||
|
LastCompletedStage = StoryIntelligencePipelineStages.LocationImport,
|
||||||
|
CurrentReviewStage = StoryIntelligencePipelineStages.AssetReview,
|
||||||
|
Status = StoryIntelligencePipelineStatuses.NeedsReview,
|
||||||
|
CompletedUtc = null,
|
||||||
|
LastRunID = null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RecordAssetImportAsync(int projectId, int bookId)
|
||||||
|
{
|
||||||
|
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
|
||||||
|
{
|
||||||
|
ProjectID = projectId,
|
||||||
|
BookID = bookId,
|
||||||
|
CurrentStage = StoryIntelligencePipelineStages.RelationshipReview,
|
||||||
|
LastCompletedStage = StoryIntelligencePipelineStages.AssetImport,
|
||||||
|
CurrentReviewStage = StoryIntelligencePipelineStages.RelationshipReview,
|
||||||
|
Status = StoryIntelligencePipelineStatuses.NeedsReview,
|
||||||
|
CompletedUtc = null,
|
||||||
|
LastRunID = null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RecordRelationshipImportAsync(int projectId, int bookId)
|
||||||
|
{
|
||||||
|
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
|
||||||
|
{
|
||||||
|
ProjectID = projectId,
|
||||||
|
BookID = bookId,
|
||||||
|
CurrentStage = StoryIntelligencePipelineStages.Complete,
|
||||||
|
LastCompletedStage = StoryIntelligencePipelineStages.RelationshipImport,
|
||||||
CurrentReviewStage = null,
|
CurrentReviewStage = null,
|
||||||
Status = StoryIntelligencePipelineStatuses.Complete,
|
Status = StoryIntelligencePipelineStatuses.Complete,
|
||||||
CompletedUtc = DateTime.UtcNow,
|
CompletedUtc = DateTime.UtcNow,
|
||||||
|
|||||||
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
|
return state.CurrentReviewStage switch
|
||||||
{
|
{
|
||||||
|
StoryIntelligencePipelineStages.RelationshipReview => "Waiting for Relationship Review",
|
||||||
|
StoryIntelligencePipelineStages.AssetReview => "Waiting for Asset Review",
|
||||||
|
StoryIntelligencePipelineStages.LocationReview => "Waiting for Location Review",
|
||||||
StoryIntelligencePipelineStages.CharacterReview => "Waiting for Character Review",
|
StoryIntelligencePipelineStages.CharacterReview => "Waiting for Character Review",
|
||||||
StoryIntelligencePipelineStages.SceneReview => "Needs Scene Review",
|
StoryIntelligencePipelineStages.SceneReview => "Needs Scene Review",
|
||||||
|
_ when StoryIntelligenceReadyForRelationships(state) => "Waiting for Relationship Review",
|
||||||
|
_ when StoryIntelligenceReadyForAssets(state) => "Waiting for Asset Review",
|
||||||
|
_ when StoryIntelligenceReadyForLocations(state) => "Waiting for Location Review",
|
||||||
_ => string.Equals(state.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase)
|
_ => string.Equals(state.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase)
|
||||||
? "Story Intelligence In Progress"
|
? "Story Intelligence In Progress"
|
||||||
: "Story Intelligence Needs Review"
|
: "Story Intelligence Needs Review"
|
||||||
@ -282,11 +288,34 @@ public sealed class StoryIntelligenceService(
|
|||||||
? "All currently available Story Intelligence stages are complete."
|
? "All currently available Story Intelligence stages are complete."
|
||||||
: state.CurrentReviewStage switch
|
: state.CurrentReviewStage switch
|
||||||
{
|
{
|
||||||
|
StoryIntelligencePipelineStages.RelationshipReview => "Review detected relationships and decide what to create or link.",
|
||||||
|
StoryIntelligencePipelineStages.AssetReview => "Review detected assets and decide what to create or link.",
|
||||||
|
StoryIntelligencePipelineStages.LocationReview => "Review detected locations and decide what to create or link.",
|
||||||
StoryIntelligencePipelineStages.CharacterReview => "Review detected characters and decide what to create or link.",
|
StoryIntelligencePipelineStages.CharacterReview => "Review detected characters and decide what to create or link.",
|
||||||
StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them.",
|
StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them.",
|
||||||
|
_ when StoryIntelligenceReadyForRelationships(state) => "Review detected relationships and decide what to create or link.",
|
||||||
|
_ when StoryIntelligenceReadyForAssets(state) => "Review detected assets and decide what to create or link.",
|
||||||
|
_ when StoryIntelligenceReadyForLocations(state) => "Review detected locations and decide what to create or link.",
|
||||||
_ => "Continue from the next Story Intelligence stage."
|
_ => "Continue from the next Story Intelligence stage."
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static bool StoryIntelligenceReadyForRelationships(StoryIntelligenceBookPipelineState state)
|
||||||
|
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport;
|
||||||
|
|
||||||
|
private static bool StoryIntelligenceReadyForAssets(StoryIntelligenceBookPipelineState state)
|
||||||
|
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
|
||||||
|
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport
|
||||||
|
|| (string.Equals(state.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private static bool StoryIntelligenceReadyForLocations(StoryIntelligenceBookPipelineState state)
|
||||||
|
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.CharacterImport, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| state.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport
|
||||||
|
or StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
|
||||||
|
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport;
|
||||||
|
|
||||||
private static string FormatElapsed(TimeSpan elapsed)
|
private static string FormatElapsed(TimeSpan elapsed)
|
||||||
=> elapsed.TotalMinutes < 1
|
=> elapsed.TotalMinutes < 1
|
||||||
? $"{Math.Max(0, (int)elapsed.TotalSeconds)} sec"
|
? $"{Math.Max(0, (int)elapsed.TotalSeconds)} sec"
|
||||||
|
|||||||
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 string? BookTitle { get; init; }
|
||||||
public IReadOnlyList<StoryIntelligenceOnboardingChapterViewModel> Chapters { get; init; } = [];
|
public IReadOnlyList<StoryIntelligenceOnboardingChapterViewModel> Chapters { get; init; } = [];
|
||||||
public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new();
|
public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new();
|
||||||
|
public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new();
|
||||||
|
public StoryIntelligenceAssetReviewViewModel AssetReview { get; init; } = new();
|
||||||
|
public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; init; } = new();
|
||||||
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
|
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
|
||||||
public int ChapterCount => Chapters.Count;
|
public int ChapterCount => Chapters.Count;
|
||||||
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
|
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
|
||||||
@ -158,6 +161,9 @@ public sealed class StoryIntelligenceProgressViewModel
|
|||||||
public bool AllCommitted => Chapters.Count > 0 && Chapters.All(chapter => chapter.HasCompletedCommit);
|
public bool AllCommitted => Chapters.Count > 0 && Chapters.All(chapter => chapter.HasCompletedCommit);
|
||||||
public bool HasReadyScenesToCreate => Chapters.Any(chapter => chapter.CanCommit);
|
public bool HasReadyScenesToCreate => Chapters.Any(chapter => chapter.CanCommit);
|
||||||
public bool HasCharactersToReview => CharacterReview.Candidates.Count > 0;
|
public bool HasCharactersToReview => CharacterReview.Candidates.Count > 0;
|
||||||
|
public bool HasLocationsToReview => LocationReview.Candidates.Count > 0;
|
||||||
|
public bool HasAssetsToReview => AssetReview.Candidates.Count > 0;
|
||||||
|
public bool HasRelationshipsToReview => RelationshipReview.Candidates.Count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligencePipelineDashboardViewModel
|
public sealed class StoryIntelligencePipelineDashboardViewModel
|
||||||
@ -167,6 +173,15 @@ public sealed class StoryIntelligencePipelineDashboardViewModel
|
|||||||
public int CharactersIdentified { get; init; }
|
public int CharactersIdentified { get; init; }
|
||||||
public int CharactersCreatedOrLinked { get; init; }
|
public int CharactersCreatedOrLinked { get; init; }
|
||||||
public bool CharacterStageComplete { get; init; }
|
public bool CharacterStageComplete { get; init; }
|
||||||
|
public int LocationsIdentified { get; init; }
|
||||||
|
public int LocationsCreatedOrLinked { get; init; }
|
||||||
|
public bool LocationStageComplete { get; init; }
|
||||||
|
public int AssetsIdentified { get; init; }
|
||||||
|
public int AssetsCreatedOrLinked { get; init; }
|
||||||
|
public bool AssetStageComplete { get; init; }
|
||||||
|
public int RelationshipsIdentified { get; init; }
|
||||||
|
public int RelationshipsCreatedOrLinked { get; init; }
|
||||||
|
public bool RelationshipStageComplete { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceCharacterReviewViewModel
|
public sealed class StoryIntelligenceCharacterReviewViewModel
|
||||||
@ -202,15 +217,163 @@ public sealed class StoryIntelligenceCharacterImportForm
|
|||||||
public sealed class StoryIntelligenceCharacterImportChoiceForm
|
public sealed class StoryIntelligenceCharacterImportChoiceForm
|
||||||
{
|
{
|
||||||
public string Key { get; set; } = string.Empty;
|
public string Key { get; set; } = string.Empty;
|
||||||
public string Action { get; set; } = StoryIntelligenceCharacterImportActions.Approve;
|
public string Action { get; set; } = StoryIntelligenceCharacterImportActions.CreateNew;
|
||||||
public string? ImportName { get; set; }
|
public string? ImportName { get; set; }
|
||||||
|
public string? AliasTargetKey { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class StoryIntelligenceCharacterImportActions
|
public static class StoryIntelligenceCharacterImportActions
|
||||||
{
|
{
|
||||||
public const string Approve = "Approve";
|
public const string CreateNew = "CreateNew";
|
||||||
|
public const string LinkExisting = "LinkExisting";
|
||||||
public const string Ignore = "Ignore";
|
public const string Ignore = "Ignore";
|
||||||
public const string CreateSeparate = "CreateSeparate";
|
public const string Alias = "Alias";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceLocationReviewViewModel
|
||||||
|
{
|
||||||
|
public bool CanImport { get; init; }
|
||||||
|
public bool HasCommittedScenes { get; init; }
|
||||||
|
public int AlreadyLinkedCount { get; init; }
|
||||||
|
public bool IsComplete { get; init; }
|
||||||
|
public IReadOnlyList<StoryIntelligenceLocationReviewCandidateViewModel> Candidates { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceLocationReviewCandidateViewModel
|
||||||
|
{
|
||||||
|
public string Key { get; init; } = string.Empty;
|
||||||
|
public string LocationName { get; init; } = string.Empty;
|
||||||
|
public string ImportName { get; init; } = string.Empty;
|
||||||
|
public string Category { get; init; } = "Ambiguous location";
|
||||||
|
public int AppearsInScenes { get; init; }
|
||||||
|
public string Confidence { get; init; } = "Unknown";
|
||||||
|
public string? ParentLocationHint { get; init; }
|
||||||
|
public string? ExampleFirstAppearance { get; init; }
|
||||||
|
public string? ExampleContext { get; init; }
|
||||||
|
public int? ExistingLocationID { get; init; }
|
||||||
|
public string? ExistingLocationName { get; init; }
|
||||||
|
public bool IsExistingMatch => ExistingLocationID.HasValue;
|
||||||
|
public string ActionLabel => IsExistingMatch ? "Link existing" : "Create";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceLocationImportForm
|
||||||
|
{
|
||||||
|
public Guid BatchID { get; set; }
|
||||||
|
public List<StoryIntelligenceLocationImportChoiceForm> Locations { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceLocationImportChoiceForm
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
public string Action { get; set; } = StoryIntelligenceLocationImportActions.CreateNew;
|
||||||
|
public string? ImportName { get; set; }
|
||||||
|
public string? AliasTargetKey { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class StoryIntelligenceLocationImportActions
|
||||||
|
{
|
||||||
|
public const string CreateNew = "CreateNew";
|
||||||
|
public const string LinkExisting = "LinkExisting";
|
||||||
|
public const string Ignore = "Ignore";
|
||||||
|
public const string Alias = "Alias";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceAssetReviewViewModel
|
||||||
|
{
|
||||||
|
public bool CanImport { get; init; }
|
||||||
|
public bool HasCommittedScenes { get; init; }
|
||||||
|
public int AlreadyLinkedCount { get; init; }
|
||||||
|
public bool IsComplete { get; init; }
|
||||||
|
public IReadOnlyList<StoryIntelligenceAssetReviewCandidateViewModel> Candidates { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceAssetReviewCandidateViewModel
|
||||||
|
{
|
||||||
|
public string Key { get; init; } = string.Empty;
|
||||||
|
public string AssetName { get; init; } = string.Empty;
|
||||||
|
public string ImportName { get; init; } = string.Empty;
|
||||||
|
public string Category { get; init; } = "Story asset";
|
||||||
|
public int AppearsInScenes { get; init; }
|
||||||
|
public string Confidence { get; init; } = "Unknown";
|
||||||
|
public string? PossibleOwner { get; init; }
|
||||||
|
public string? ExampleFirstAppearance { get; init; }
|
||||||
|
public string? ExampleContext { get; init; }
|
||||||
|
public IReadOnlyList<string> PossibleAliases { get; init; } = [];
|
||||||
|
public int? ExistingAssetID { get; init; }
|
||||||
|
public string? ExistingAssetName { get; init; }
|
||||||
|
public bool IsExistingMatch => ExistingAssetID.HasValue;
|
||||||
|
public string ActionLabel => IsExistingMatch ? "Link existing" : "Create";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceAssetImportForm
|
||||||
|
{
|
||||||
|
public Guid BatchID { get; set; }
|
||||||
|
public List<StoryIntelligenceAssetImportChoiceForm> Assets { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceAssetImportChoiceForm
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
public string Action { get; set; } = StoryIntelligenceAssetImportActions.CreateNew;
|
||||||
|
public string? ImportName { get; set; }
|
||||||
|
public string? AliasTargetKey { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class StoryIntelligenceAssetImportActions
|
||||||
|
{
|
||||||
|
public const string CreateNew = "CreateNew";
|
||||||
|
public const string LinkExisting = "LinkExisting";
|
||||||
|
public const string Ignore = "Ignore";
|
||||||
|
public const string Alias = "Alias";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceRelationshipReviewViewModel
|
||||||
|
{
|
||||||
|
public bool CanImport { get; init; }
|
||||||
|
public bool HasCommittedScenes { get; init; }
|
||||||
|
public int AlreadyLinkedCount { get; init; }
|
||||||
|
public bool IsComplete { get; init; }
|
||||||
|
public IReadOnlyList<StoryIntelligenceRelationshipReviewCandidateViewModel> Candidates { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceRelationshipReviewCandidateViewModel
|
||||||
|
{
|
||||||
|
public string Key { get; init; } = string.Empty;
|
||||||
|
public string CharacterAName { get; init; } = string.Empty;
|
||||||
|
public string CharacterBName { get; init; } = string.Empty;
|
||||||
|
public string RelationshipType { get; init; } = "Unknown";
|
||||||
|
public int AppearsInScenes { get; init; }
|
||||||
|
public string Confidence { get; init; } = "Unknown";
|
||||||
|
public string? FirstAppearance { get; init; }
|
||||||
|
public string? LastAppearance { get; init; }
|
||||||
|
public string? ExampleScene { get; init; }
|
||||||
|
public string? ExampleContext { get; init; }
|
||||||
|
public int? ExistingRelationshipID { get; init; }
|
||||||
|
public string? ExistingRelationshipLabel { get; init; }
|
||||||
|
public bool IsExistingMatch => ExistingRelationshipID.HasValue;
|
||||||
|
public string ActionLabel => IsExistingMatch ? "Link existing" : "Create";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceRelationshipImportForm
|
||||||
|
{
|
||||||
|
public Guid BatchID { get; set; }
|
||||||
|
public List<StoryIntelligenceRelationshipImportChoiceForm> Relationships { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceRelationshipImportChoiceForm
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
public string Action { get; set; } = StoryIntelligenceRelationshipImportActions.CreateNew;
|
||||||
|
public string? RelationshipType { get; set; }
|
||||||
|
public string? AliasTargetKey { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class StoryIntelligenceRelationshipImportActions
|
||||||
|
{
|
||||||
|
public const string CreateNew = "CreateNew";
|
||||||
|
public const string LinkExisting = "LinkExisting";
|
||||||
|
public const string Ignore = "Ignore";
|
||||||
|
public const string Alias = "Alias";
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceCompletionViewModel
|
public sealed class StoryIntelligenceCompletionViewModel
|
||||||
@ -226,6 +389,12 @@ public sealed class StoryIntelligenceCompletionViewModel
|
|||||||
public int ScenesCreated { get; init; }
|
public int ScenesCreated { get; init; }
|
||||||
public int CharactersCreatedOrLinked { get; init; }
|
public int CharactersCreatedOrLinked { get; init; }
|
||||||
public bool CharacterStageComplete { get; init; }
|
public bool CharacterStageComplete { get; init; }
|
||||||
|
public int LocationsCreatedOrLinked { get; init; }
|
||||||
|
public bool LocationStageComplete { get; init; }
|
||||||
|
public int AssetsCreatedOrLinked { get; init; }
|
||||||
|
public bool AssetStageComplete { get; init; }
|
||||||
|
public int RelationshipsCreatedOrLinked { get; init; }
|
||||||
|
public bool RelationshipStageComplete { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceCharacterImportResultViewModel
|
public sealed class StoryIntelligenceCharacterImportResultViewModel
|
||||||
@ -235,11 +404,49 @@ public sealed class StoryIntelligenceCharacterImportResultViewModel
|
|||||||
public int BookID { get; init; }
|
public int BookID { get; init; }
|
||||||
public int CharactersCreated { get; init; }
|
public int CharactersCreated { get; init; }
|
||||||
public int CharactersLinked { get; init; }
|
public int CharactersLinked { get; init; }
|
||||||
|
public int CharactersAliased { get; init; }
|
||||||
public int CharactersIgnored { get; init; }
|
public int CharactersIgnored { get; init; }
|
||||||
public int PovLinksResolved { get; init; }
|
public int PovLinksResolved { get; init; }
|
||||||
public int ScenePeoplePanelsUpdated { get; init; }
|
public int ScenePeoplePanelsUpdated { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceLocationImportResultViewModel
|
||||||
|
{
|
||||||
|
public Guid BatchID { get; init; }
|
||||||
|
public int ProjectID { get; init; }
|
||||||
|
public int BookID { get; init; }
|
||||||
|
public int LocationsCreated { get; init; }
|
||||||
|
public int LocationsLinked { get; init; }
|
||||||
|
public int LocationAliasesAdded { get; init; }
|
||||||
|
public int LocationsIgnored { get; init; }
|
||||||
|
public int SceneLocationsUpdated { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceAssetImportResultViewModel
|
||||||
|
{
|
||||||
|
public Guid BatchID { get; init; }
|
||||||
|
public int ProjectID { get; init; }
|
||||||
|
public int BookID { get; init; }
|
||||||
|
public int AssetsCreated { get; init; }
|
||||||
|
public int AssetsLinked { get; init; }
|
||||||
|
public int AssetAliasesAdded { get; init; }
|
||||||
|
public int AssetsIgnored { get; init; }
|
||||||
|
public int SceneAssetEventsCreated { get; init; }
|
||||||
|
public int OwnershipLinksCreated { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceRelationshipImportResultViewModel
|
||||||
|
{
|
||||||
|
public Guid BatchID { get; init; }
|
||||||
|
public int ProjectID { get; init; }
|
||||||
|
public int BookID { get; init; }
|
||||||
|
public int RelationshipsCreated { get; init; }
|
||||||
|
public int RelationshipsLinked { get; init; }
|
||||||
|
public int RelationshipsMerged { get; init; }
|
||||||
|
public int RelationshipsIgnored { get; init; }
|
||||||
|
public int RelationshipEventsCreated { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceOnboardingChapterViewModel
|
public sealed class StoryIntelligenceOnboardingChapterViewModel
|
||||||
{
|
{
|
||||||
public string TemporaryChapterKey { get; init; } = string.Empty;
|
public string TemporaryChapterKey { get; init; } = string.Empty;
|
||||||
|
|||||||
@ -200,9 +200,32 @@
|
|||||||
|
|
||||||
return pipeline.CurrentReviewStage switch
|
return pipeline.CurrentReviewStage switch
|
||||||
{
|
{
|
||||||
|
StoryIntelligencePipelineStages.RelationshipReview => "Review detected relationships and decide what to create or link.",
|
||||||
|
StoryIntelligencePipelineStages.AssetReview => "Review detected assets and decide what to create or link.",
|
||||||
|
StoryIntelligencePipelineStages.LocationReview => "Review detected locations and decide what to create or link.",
|
||||||
StoryIntelligencePipelineStages.CharacterReview => "Scene creation is complete. Review detected characters to continue building the story database.",
|
StoryIntelligencePipelineStages.CharacterReview => "Scene creation is complete. Review detected characters to continue building the story database.",
|
||||||
StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them in PlotDirector.",
|
StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them in PlotDirector.",
|
||||||
|
_ when StoryIntelligenceReadyForRelationships(pipeline) => "Review detected relationships and decide what to create or link.",
|
||||||
|
_ when StoryIntelligenceReadyForAssets(pipeline) => "Review detected assets and decide what to create or link.",
|
||||||
|
_ when StoryIntelligenceReadyForLocations(pipeline) => "Review detected locations and decide what to create or link.",
|
||||||
_ => "PlotDirector will resume from the next Story Intelligence stage."
|
_ => "PlotDirector will resume from the next Story Intelligence stage."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool StoryIntelligenceReadyForRelationships(StoryIntelligenceBookPipelineState pipeline)
|
||||||
|
=> string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| pipeline.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport;
|
||||||
|
|
||||||
|
private static bool StoryIntelligenceReadyForAssets(StoryIntelligenceBookPipelineState pipeline)
|
||||||
|
=> string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| pipeline.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
|
||||||
|
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport
|
||||||
|
|| (string.Equals(pipeline.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private static bool StoryIntelligenceReadyForLocations(StoryIntelligenceBookPipelineState pipeline)
|
||||||
|
=> string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.CharacterImport, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| pipeline.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport
|
||||||
|
or StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
|
||||||
|
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
<section class="onboarding-shell" aria-labelledby="story-character-complete-title">
|
||||||
<div class="onboarding-panel onboarding-review-panel">
|
<div class="onboarding-panel onboarding-review-panel">
|
||||||
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Create Characters" })" />
|
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Locations" })" />
|
||||||
<div class="onboarding-copy">
|
<div class="onboarding-copy">
|
||||||
<p class="eyebrow">Build Story Database</p>
|
<p class="eyebrow">Build Story Database</p>
|
||||||
<div class="onboarding-success-heading">
|
<div class="onboarding-success-heading">
|
||||||
@ -24,6 +24,10 @@
|
|||||||
<span>Characters linked</span>
|
<span>Characters linked</span>
|
||||||
<strong>@Model.CharactersLinked.ToString("N0")</strong>
|
<strong>@Model.CharactersLinked.ToString("N0")</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Aliases added</span>
|
||||||
|
<strong>@Model.CharactersAliased.ToString("N0")</strong>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Characters ignored</span>
|
<span>Characters ignored</span>
|
||||||
<strong>@Model.CharactersIgnored.ToString("N0")</strong>
|
<strong>@Model.CharactersIgnored.ToString("N0")</strong>
|
||||||
@ -39,7 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="story-future-stage-grid">
|
<div class="story-future-stage-grid">
|
||||||
<article class="story-future-stage"><strong>Review Locations</strong><span>Coming Soon</span></article>
|
<article class="story-future-stage"><strong>Review Locations</strong><span>Next</span></article>
|
||||||
<article class="story-future-stage"><strong>Review Assets</strong><span>Coming Soon</span></article>
|
<article class="story-future-stage"><strong>Review Assets</strong><span>Coming Soon</span></article>
|
||||||
<article class="story-future-stage"><strong>Review Relationships</strong><span>Coming Soon</span></article>
|
<article class="story-future-stage"><strong>Review Relationships</strong><span>Coming Soon</span></article>
|
||||||
<article class="story-future-stage"><strong>Review Knowledge</strong><span>Coming Soon</span></article>
|
<article class="story-future-stage"><strong>Review Knowledge</strong><span>Coming Soon</span></article>
|
||||||
@ -47,7 +51,7 @@
|
|||||||
|
|
||||||
<div class="onboarding-actions">
|
<div class="onboarding-actions">
|
||||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID">Back to characters</a>
|
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID">Back to characters</a>
|
||||||
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a>
|
<a class="btn btn-primary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Review locations</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -6,8 +6,10 @@
|
|||||||
<section class="onboarding-shell" aria-labelledby="story-character-title">
|
<section class="onboarding-shell" aria-labelledby="story-character-title">
|
||||||
<div class="onboarding-panel onboarding-review-panel story-review-page">
|
<div class="onboarding-panel onboarding-review-panel story-review-page">
|
||||||
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Characters" })" />
|
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Characters" })" />
|
||||||
<div class="onboarding-copy">
|
<div class="onboarding-copy story-review-heading">
|
||||||
<p class="eyebrow">Review Story Intelligence</p>
|
<p class="eyebrow">Phase 3</p>
|
||||||
|
<p class="story-review-heading__phase">Review Story Intelligence</p>
|
||||||
|
<p class="story-review-heading__stage">Stage 2 of 4</p>
|
||||||
<h1 id="story-character-title">Review Characters</h1>
|
<h1 id="story-character-title">Review Characters</h1>
|
||||||
<p>Approve the characters PlotDirector should create or link from your imported scenes. You stay in control of every character record.</p>
|
<p>Approve the characters PlotDirector should create or link from your imported scenes. You stay in control of every character record.</p>
|
||||||
</div>
|
</div>
|
||||||
@ -38,15 +40,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="onboarding-actions">
|
<div class="onboarding-actions">
|
||||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReview" asp-route-batchId="@Model.BatchID">Back to scenes</a>
|
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReview" asp-route-batchId="@Model.BatchID">Back to scenes</a>
|
||||||
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a>
|
<a class="btn btn-primary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Continue</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="story-review-card-actions">
|
<div class="story-review-card-actions">
|
||||||
<button class="btn btn-outline-primary btn-sm" type="button" data-character-bulk="approve-all">Approve all</button>
|
<button class="btn btn-outline-primary btn-sm" type="button" data-character-bulk="create-all">Create all</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="approve-selected">Approve selected</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="link-existing">Link existing matches</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="ignore-selected">Ignore selected</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="ignore-all">Ignore all</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="expand">Expand all</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="expand">Expand all</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="collapse">Collapse all</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="collapse">Collapse all</button>
|
||||||
</div>
|
</div>
|
||||||
@ -59,7 +61,6 @@
|
|||||||
var candidate = Model.CharacterReview.Candidates[i];
|
var candidate = Model.CharacterReview.Candidates[i];
|
||||||
<details class="story-character-card" open data-character-card>
|
<details class="story-character-card" open data-character-card>
|
||||||
<summary>
|
<summary>
|
||||||
<input type="checkbox" checked data-character-selected aria-label="Select @candidate.CharacterName" />
|
|
||||||
<span>
|
<span>
|
||||||
<strong>@candidate.CharacterName</strong>
|
<strong>@candidate.CharacterName</strong>
|
||||||
<small>@candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
|
<small>@candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
|
||||||
@ -69,7 +70,7 @@
|
|||||||
|
|
||||||
<input type="hidden" name="Characters[@i].Key" value="@candidate.Key" />
|
<input type="hidden" name="Characters[@i].Key" value="@candidate.Key" />
|
||||||
|
|
||||||
<div class="story-character-card__body">
|
<div class="story-character-card__body" data-character-key="@candidate.Key" data-character-name="@candidate.CharacterName">
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>First appearance</dt><dd>@Display(candidate.ExampleFirstAppearance)</dd></div>
|
<div><dt>First appearance</dt><dd>@Display(candidate.ExampleFirstAppearance)</dd></div>
|
||||||
<div><dt>Possible aliases</dt><dd>@(candidate.PossibleAliases.Count == 0 ? "None detected" : string.Join(", ", candidate.PossibleAliases))</dd></div>
|
<div><dt>Possible aliases</dt><dd>@(candidate.PossibleAliases.Count == 0 ? "None detected" : string.Join(", ", candidate.PossibleAliases))</dd></div>
|
||||||
@ -87,24 +88,38 @@
|
|||||||
<fieldset class="story-character-actions">
|
<fieldset class="story-character-actions">
|
||||||
<legend>Decision</legend>
|
<legend>Decision</legend>
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Approve" checked data-character-action />
|
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.CreateNew" checked data-character-action />
|
||||||
@(candidate.IsExistingMatch ? "Link to existing" : "Create new character")
|
Create new character
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<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>
|
||||||
@if (candidate.IsExistingMatch)
|
|
||||||
{
|
|
||||||
<label>
|
|
||||||
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.CreateSeparate" data-character-action />
|
|
||||||
Create separate character
|
|
||||||
</label>
|
|
||||||
}
|
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Ignore" data-character-action />
|
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Ignore" data-character-action />
|
||||||
Ignore
|
Ignore
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<label class="form-label" for="character-import-name-@i">Import name</label>
|
<div data-character-import-name-panel>
|
||||||
<input id="character-import-name-@i" class="form-control" name="Characters[@i].ImportName" value="@candidate.ImportName" />
|
<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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
}
|
}
|
||||||
@ -129,24 +144,63 @@
|
|||||||
const cards = () => Array.from(form.querySelectorAll("[data-character-card]"));
|
const cards = () => Array.from(form.querySelectorAll("[data-character-card]"));
|
||||||
const setAction = (card, action) => {
|
const setAction = (card, action) => {
|
||||||
const input = card.querySelector(`[data-character-action][value="${action}"]`);
|
const input = card.querySelector(`[data-character-action][value="${action}"]`);
|
||||||
if (input) input.checked = true;
|
if (input) {
|
||||||
|
input.checked = true;
|
||||||
|
updateCard(card);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const actionFor = (card) => card.querySelector("[data-character-action]:checked")?.value || "@StoryIntelligenceCharacterImportActions.CreateNew";
|
||||||
|
const isCanonicalTarget = (card) => {
|
||||||
|
const action = actionFor(card);
|
||||||
|
return action !== "@StoryIntelligenceCharacterImportActions.Ignore" && action !== "@StoryIntelligenceCharacterImportActions.Alias";
|
||||||
|
};
|
||||||
|
const updateAliasTargets = () => {
|
||||||
|
const allCards = cards();
|
||||||
|
for (const card of allCards) {
|
||||||
|
const body = card.querySelector("[data-character-key]");
|
||||||
|
const ownKey = body?.getAttribute("data-character-key") || "";
|
||||||
|
const select = card.querySelector("[data-character-alias-target]");
|
||||||
|
if (!select) continue;
|
||||||
|
|
||||||
|
const previous = select.value;
|
||||||
|
for (const option of Array.from(select.options)) {
|
||||||
|
if (!option.value) continue;
|
||||||
|
const targetCard = allCards.find(item => item.querySelector("[data-character-key]")?.getAttribute("data-character-key") === option.value);
|
||||||
|
option.disabled = option.value === ownKey || !targetCard || !isCanonicalTarget(targetCard);
|
||||||
|
}
|
||||||
|
if (select.selectedOptions[0]?.disabled) {
|
||||||
|
select.value = "";
|
||||||
|
} else {
|
||||||
|
select.value = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const updateCard = (card) => {
|
||||||
|
const action = actionFor(card);
|
||||||
|
const namePanel = card.querySelector("[data-character-import-name-panel]");
|
||||||
|
const aliasPanel = card.querySelector("[data-character-alias-panel]");
|
||||||
|
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceCharacterImportActions.Ignore" || action === "@StoryIntelligenceCharacterImportActions.Alias";
|
||||||
|
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceCharacterImportActions.Alias";
|
||||||
|
updateAliasTargets();
|
||||||
};
|
};
|
||||||
document.querySelectorAll("[data-character-bulk]").forEach(button => {
|
document.querySelectorAll("[data-character-bulk]").forEach(button => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const action = button.getAttribute("data-character-bulk");
|
const action = button.getAttribute("data-character-bulk");
|
||||||
const selectedCards = cards().filter(card => card.querySelector("[data-character-selected]")?.checked);
|
if (action === "create-all") cards().forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.CreateNew"));
|
||||||
if (action === "approve-all") cards().forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.Approve"));
|
if (action === "link-existing") cards().forEach(card => {
|
||||||
if (action === "approve-selected") selectedCards.forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.Approve"));
|
if (card.querySelector(`[data-character-action][value="@StoryIntelligenceCharacterImportActions.LinkExisting"]:not(:disabled)`)) {
|
||||||
if (action === "ignore-selected") selectedCards.forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.Ignore"));
|
setAction(card, "@StoryIntelligenceCharacterImportActions.LinkExisting");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (action === "ignore-all") cards().forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.Ignore"));
|
||||||
if (action === "expand") cards().forEach(card => card.open = true);
|
if (action === "expand") cards().forEach(card => card.open = true);
|
||||||
if (action === "collapse") cards().forEach(card => card.open = false);
|
if (action === "collapse") cards().forEach(card => card.open = false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
form.addEventListener("submit", () => {
|
form.querySelectorAll("[data-character-action]").forEach(input => {
|
||||||
cards()
|
input.addEventListener("change", () => updateCard(input.closest("[data-character-card]")));
|
||||||
.filter(card => !card.querySelector("[data-character-selected]")?.checked)
|
|
||||||
.forEach(card => setAction(card, "@StoryIntelligenceCharacterImportActions.Ignore"));
|
|
||||||
});
|
});
|
||||||
|
cards().forEach(updateCard);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,15 +6,32 @@
|
|||||||
<section class="onboarding-shell" aria-labelledby="story-complete-title">
|
<section class="onboarding-shell" aria-labelledby="story-complete-title">
|
||||||
<div class="onboarding-panel onboarding-review-panel">
|
<div class="onboarding-panel onboarding-review-panel">
|
||||||
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Complete" })" />
|
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Complete" })" />
|
||||||
<div class="onboarding-copy">
|
<div class="onboarding-copy story-review-heading">
|
||||||
<p class="eyebrow">Story Intelligence pipeline</p>
|
<p class="eyebrow">Phase 4</p>
|
||||||
|
<p class="story-review-heading__phase">Build Story Database</p>
|
||||||
<div class="onboarding-success-heading">
|
<div class="onboarding-success-heading">
|
||||||
<span aria-hidden="true">✓</span>
|
<span aria-hidden="true">✓</span>
|
||||||
<h1 id="story-complete-title">Story Intelligence complete</h1>
|
<h1 id="story-complete-title">Story Intelligence complete</h1>
|
||||||
</div>
|
</div>
|
||||||
<p>PlotDirector completed every implemented Story Intelligence stage for this manuscript. Future stages will continue from this same pipeline as they become available.</p>
|
<p>PlotDirector has built the implemented story database records from your reviewed manuscript analysis.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="story-database-build" aria-label="Story database build checklist">
|
||||||
|
<h2>Building your Story Database...</h2>
|
||||||
|
<ul>
|
||||||
|
<li><span aria-hidden="true">✓</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 class="onboarding-scan-counts onboarding-review-counts">
|
||||||
<div>
|
<div>
|
||||||
<span>Chapters analysed</span>
|
<span>Chapters analysed</span>
|
||||||
@ -28,19 +45,24 @@
|
|||||||
<span>Characters created / linked</span>
|
<span>Characters created / linked</span>
|
||||||
<strong>@Model.CharactersCreatedOrLinked.ToString("N0")</strong>
|
<strong>@Model.CharactersCreatedOrLinked.ToString("N0")</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Locations created / linked</span>
|
||||||
|
<strong>@Model.LocationsCreatedOrLinked.ToString("N0")</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Assets created / linked</span>
|
||||||
|
<strong>@Model.AssetsCreatedOrLinked.ToString("N0")</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Relationships created / linked</span>
|
||||||
|
<strong>@Model.RelationshipsCreatedOrLinked.ToString("N0")</strong>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Book</span>
|
<span>Book</span>
|
||||||
<strong>@Model.BookTitle</strong>
|
<strong>@Model.BookTitle</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="story-future-stage-grid">
|
|
||||||
<article class="story-future-stage"><strong>Review Locations</strong><span>Coming Soon</span></article>
|
|
||||||
<article class="story-future-stage"><strong>Review Assets</strong><span>Coming Soon</span></article>
|
|
||||||
<article class="story-future-stage"><strong>Review Relationships</strong><span>Coming Soon</span></article>
|
|
||||||
<article class="story-future-stage"><strong>Review Knowledge</strong><span>Coming Soon</span></article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="onboarding-actions">
|
<div class="onboarding-actions">
|
||||||
<a class="btn btn-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">View scenes</a>
|
<a class="btn btn-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">View scenes</a>
|
||||||
<a class="btn btn-outline-primary" asp-controller="Books" asp-action="Details" asp-route-id="@Model.BookID">View book</a>
|
<a class="btn btn-outline-primary" asp-controller="Books" asp-action="Details" asp-route-id="@Model.BookID">View book</a>
|
||||||
|
|||||||
@ -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">
|
<section class="onboarding-shell" aria-labelledby="story-review-title">
|
||||||
<div class="onboarding-panel onboarding-review-panel story-review-page">
|
<div class="onboarding-panel onboarding-review-panel story-review-page">
|
||||||
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Scenes" })" />
|
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Scenes" })" />
|
||||||
<div class="onboarding-copy">
|
<div class="onboarding-copy story-review-heading">
|
||||||
<p class="eyebrow">Review Story Intelligence</p>
|
<p class="eyebrow">Phase 3</p>
|
||||||
|
<p class="story-review-heading__phase">Review Story Intelligence</p>
|
||||||
|
<p class="story-review-heading__stage">Stage 1 of 4</p>
|
||||||
<h1 id="story-review-title">Review Scenes</h1>
|
<h1 id="story-review-title">Review Scenes</h1>
|
||||||
<p>PlotDirector has finished reading your manuscript. Review the detected scenes before creating them in your story database.</p>
|
<p>PlotDirector has finished reading your manuscript. Review the detected scenes before creating them in your story database.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,50 +1,130 @@
|
|||||||
@model StoryIntelligencePipelineHeaderViewModel
|
@model StoryIntelligencePipelineHeaderViewModel
|
||||||
@{
|
@{
|
||||||
var phases = new[]
|
var reviewStages = new[]
|
||||||
{
|
{
|
||||||
new { Name = "Phase 1", Label = "Prepare manuscript", Stages = new[] { "Connect Word Companion", "Scan Manuscript", "Review Chapters" } },
|
new PipelineStage("Review Scenes", "Scenes", true),
|
||||||
new { Name = "Phase 2", Label = "Analyse manuscript", Stages = new[] { "Analyse Manuscript" } },
|
new PipelineStage("Review Characters", "Characters", true),
|
||||||
new { Name = "Phase 3", Label = "Review Story Intelligence", Stages = new[] { "Review Scenes", "Review Characters", "Review Locations", "Review Assets", "Review Relationships", "Review Knowledge" } },
|
new PipelineStage("Review Locations", "Locations", true),
|
||||||
new { Name = "Phase 4", Label = "Build Story Database", Stages = new[] { "Create Scenes", "Create Characters", "Complete" } }
|
new PipelineStage("Review Assets", "Assets", true),
|
||||||
|
new PipelineStage("Review Relationships", "Relationships", true),
|
||||||
|
new PipelineStage("Review Knowledge", "Knowledge", false)
|
||||||
};
|
};
|
||||||
var flatStages = phases.SelectMany(phase => phase.Stages).ToList();
|
var implementedReviewStages = reviewStages.Where(stage => stage.IsImplemented).ToList();
|
||||||
var currentIndex = Math.Max(0, flatStages.FindIndex(stage => string.Equals(stage, Model.CurrentStage, StringComparison.OrdinalIgnoreCase)));
|
var currentReviewIndex = implementedReviewStages.FindIndex(stage => string.Equals(stage.StageName, Model.CurrentStage, StringComparison.OrdinalIgnoreCase));
|
||||||
var percent = flatStages.Count <= 1 ? 100 : Math.Clamp((currentIndex * 100) / (flatStages.Count - 1), 0, 100);
|
var isReviewPhase = currentReviewIndex >= 0;
|
||||||
|
var isComplete = string.Equals(Model.CurrentStage, "Complete", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isAnalyse = string.Equals(Model.CurrentStage, "Analyse Manuscript", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var activePhase = isComplete ? 4 : isReviewPhase ? 3 : isAnalyse ? 2 : 1;
|
||||||
|
var progressPercent = isReviewPhase
|
||||||
|
? Math.Clamp(((currentReviewIndex + 1) * 100) / implementedReviewStages.Count, 0, 100)
|
||||||
|
: isComplete ? 100 : 100;
|
||||||
|
var currentStageNumber = isReviewPhase ? currentReviewIndex + 1 : isComplete ? implementedReviewStages.Count : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="onboarding-progress story-pipeline-progress" aria-label="Story Intelligence pipeline progress">
|
<section class="story-pipeline-header" aria-label="Story Intelligence pipeline">
|
||||||
<span>@Model.CurrentStage</span>
|
<div class="story-pipeline-current">
|
||||||
<div class="onboarding-progress-track">
|
<p class="eyebrow">Phase @activePhase</p>
|
||||||
<span style="width:@percent%"></span>
|
<h2>@CurrentPhaseTitle(activePhase)</h2>
|
||||||
|
@if (isReviewPhase)
|
||||||
|
{
|
||||||
|
<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>
|
||||||
</div>
|
|
||||||
<ol class="onboarding-stepper story-pipeline-stepper" aria-label="Story Intelligence pipeline">
|
<div class="onboarding-progress story-pipeline-progress" aria-label="@CurrentPhaseTitle(activePhase) progress">
|
||||||
@foreach (var phase in phases)
|
<span>@ProgressLabel(activePhase, Model.CurrentStage)</span>
|
||||||
{
|
<div class="onboarding-progress-track">
|
||||||
var firstStageIndex = flatStages.FindIndex(stage => phase.Stages.Contains(stage));
|
<span style="width:@progressPercent%"></span>
|
||||||
var lastStageIndex = firstStageIndex + phase.Stages.Length - 1;
|
</div>
|
||||||
var phaseClass = currentIndex > lastStageIndex
|
</div>
|
||||||
? "is-complete"
|
|
||||||
: currentIndex >= firstStageIndex && currentIndex <= lastStageIndex
|
<ol class="story-pipeline-phase-list">
|
||||||
? "is-current"
|
<li class="@(activePhase > 1 ? "is-complete" : activePhase == 1 ? "is-current" : "is-upcoming")">
|
||||||
: "is-upcoming";
|
<strong>Prepare Manuscript</strong>
|
||||||
<li class="@phaseClass">
|
<span>7 completed steps</span>
|
||||||
<span>@phase.Name.Replace("Phase ", string.Empty)</span>
|
<ul>
|
||||||
<strong>@phase.Label</strong>
|
<li>Welcome</li>
|
||||||
<small>@StageSummary(phase.Stages, currentIndex, firstStageIndex)</small>
|
<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>
|
||||||
}
|
<li class="@(activePhase > 2 ? "is-complete" : activePhase == 2 ? "is-current" : "is-upcoming")">
|
||||||
</ol>
|
<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"
|
||||||
|
: index == currentReviewIndex
|
||||||
|
? "is-current"
|
||||||
|
: "is-upcoming";
|
||||||
|
<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 {
|
@functions {
|
||||||
private static string StageSummary(IReadOnlyList<string> stages, int currentIndex, int firstStageIndex)
|
private sealed record PipelineStage(string StageName, string Label, bool IsImplemented);
|
||||||
{
|
|
||||||
var localIndex = currentIndex - firstStageIndex;
|
|
||||||
if (localIndex >= 0 && localIndex < stages.Count)
|
|
||||||
{
|
|
||||||
return stages[localIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
return stages.Count == 1 ? stages[0] : $"{stages.Count} stages";
|
private static string CurrentPhaseTitle(int phase)
|
||||||
}
|
=> phase switch
|
||||||
|
{
|
||||||
|
1 => "Prepare Manuscript",
|
||||||
|
2 => "Analyse Manuscript",
|
||||||
|
3 => "Review Story Intelligence",
|
||||||
|
_ => "Build Story Database"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string ProgressLabel(int phase, string currentStage)
|
||||||
|
=> phase switch
|
||||||
|
{
|
||||||
|
3 => currentStage,
|
||||||
|
4 => "Finalising import",
|
||||||
|
_ => "Completed"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string ReviewSummary(bool isReviewPhase, int currentStageNumber, int implementedStageCount)
|
||||||
|
=> isReviewPhase
|
||||||
|
? $"{Math.Max(0, currentStageNumber - 1)} of {implementedStageCount} review stages completed"
|
||||||
|
: $"{implementedStageCount} review stages completed";
|
||||||
|
|
||||||
|
private static string StageMarker(string stageClass, bool isImplemented)
|
||||||
|
=> !isImplemented ? "○" : stageClass == "is-current" ? "►" : stageClass == "is-complete" ? "✓" : "○";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,16 +18,28 @@
|
|||||||
<strong>@(Model.CharacterStageComplete ? $"Done: {Model.CharactersCreatedOrLinked:N0}" : "In review")</strong>
|
<strong>@(Model.CharacterStageComplete ? $"Done: {Model.CharactersCreatedOrLinked:N0}" : "In review")</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Locations</span>
|
<span>Locations identified</span>
|
||||||
<strong>Coming next</strong>
|
<strong>@Model.LocationsIdentified.ToString("N0")</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Assets</span>
|
<span>Locations created / linked</span>
|
||||||
<strong>Coming next</strong>
|
<strong>@(Model.LocationStageComplete ? $"Done: {Model.LocationsCreatedOrLinked:N0}" : "In review")</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Relationships</span>
|
<span>Assets identified</span>
|
||||||
<strong>Coming next</strong>
|
<strong>@Model.AssetsIdentified.ToString("N0")</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Assets created / linked</span>
|
||||||
|
<strong>@(Model.AssetStageComplete ? $"Done: {Model.AssetsCreatedOrLinked:N0}" : "In review")</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Relationships identified</span>
|
||||||
|
<strong>@Model.RelationshipsIdentified.ToString("N0")</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Relationships created / linked</span>
|
||||||
|
<strong>@(Model.RelationshipStageComplete ? $"Done: {Model.RelationshipsCreatedOrLinked:N0}" : "In review")</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Knowledge</span>
|
<span>Knowledge</span>
|
||||||
|
|||||||
@ -807,6 +807,195 @@ summary.story-review-chapter-heading {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.story-pipeline-header {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-current {
|
||||||
|
display: grid;
|
||||||
|
gap: .15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-current h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.45rem, 3vw, 2.15rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-current p:not(.eyebrow) {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-progress {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-phase-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: .75rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-phase-list > li {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: .45rem;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid rgba(31, 42, 68, .12);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .85rem;
|
||||||
|
background: rgba(255, 255, 255, .58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-phase-list > li.is-current {
|
||||||
|
border-color: rgba(47, 111, 99, .42);
|
||||||
|
background: rgba(47, 111, 99, .08);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(47, 111, 99, .12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-phase-list > li.is-complete {
|
||||||
|
background: rgba(47, 111, 99, .06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-phase-list > li.is-complete > strong::before {
|
||||||
|
content: "✓ ";
|
||||||
|
color: #2f6f63;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-phase-list > li > strong {
|
||||||
|
font-size: .98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-phase-list > li > span {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: .82rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-phase-list ul {
|
||||||
|
display: grid;
|
||||||
|
gap: .2rem;
|
||||||
|
margin: .25rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: .8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-review-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-review-list li strong {
|
||||||
|
font-size: .82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-review-list li span {
|
||||||
|
width: 1rem;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-review-list li.is-complete span {
|
||||||
|
color: #2f6f63;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-review-list li.is-current {
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-review-list li.is-current span,
|
||||||
|
.story-pipeline-review-list li.is-current strong {
|
||||||
|
color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-pipeline-review-list li em {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: .74rem;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-review-heading {
|
||||||
|
display: grid;
|
||||||
|
gap: .15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-review-heading p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-review-heading__phase {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: .92rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-review-heading__stage {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: .84rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-database-build {
|
||||||
|
display: grid;
|
||||||
|
gap: .8rem;
|
||||||
|
border: 1px solid rgba(47, 111, 99, .18);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(47, 111, 99, .07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-database-build h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-database-build ul {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: .5rem .9rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-database-build li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .45rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-database-build li span {
|
||||||
|
color: #2f6f63;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-database-build li em {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: .76rem;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-database-build li.is-upcoming {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-database-build li.is-upcoming span {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
.story-review-summary {
|
.story-review-summary {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
@ -1361,6 +1550,11 @@ summary.story-review-chapter-heading {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
.story-pipeline-phase-list,
|
||||||
|
.story-database-build ul {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.onboarding-choice-grid,
|
.onboarding-choice-grid,
|
||||||
.onboarding-option-grid--software {
|
.onboarding-option-grid--software {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user