Phase 20AP – Asset Intelligence Review and Import
This commit is contained in:
parent
55cded0b40
commit
11de81abed
@ -15,7 +15,10 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames),
|
("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames),
|
||||||
("Location filtering rejects merged phrases", LocationFilteringRejectsMergedPhrases),
|
("Location filtering rejects merged phrases", LocationFilteringRejectsMergedPhrases),
|
||||||
("Location filtering preserves story locations", LocationFilteringPreservesStoryLocations),
|
("Location filtering preserves story locations", LocationFilteringPreservesStoryLocations),
|
||||||
("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants)
|
("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)
|
||||||
@ -198,6 +201,51 @@ static bool InvokePrivateLocationFilter(string methodName, string name)
|
|||||||
return method!.Invoke(null, [name]) is true;
|
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,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
return target.Route switch
|
return target.Route switch
|
||||||
{
|
{
|
||||||
StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }),
|
StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }),
|
||||||
|
StoryIntelligenceResumeRoutes.Assets => RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = target.BatchID }),
|
||||||
StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = target.BatchID }),
|
StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = target.BatchID }),
|
||||||
StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = target.BatchID }),
|
StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = target.BatchID }),
|
||||||
_ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID })
|
_ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID })
|
||||||
@ -181,6 +182,35 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
: View(model);
|
: 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);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("story-intelligence/cancel")]
|
[HttpPost("story-intelligence/cancel")]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> CancelStoryIntelligence(Guid batchId)
|
public async Task<IActionResult> CancelStoryIntelligence(Guid batchId)
|
||||||
@ -277,6 +307,29 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
return model is null ? NotFound() : View(model);
|
return model is null ? NotFound() : View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("story-intelligence/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);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("story-intelligence/complete")]
|
[HttpGet("story-intelligence/complete")]
|
||||||
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
|
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
|
||||||
{
|
{
|
||||||
@ -296,6 +349,11 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
|
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!progress.PipelineDashboard.AssetStageComplete)
|
||||||
|
{
|
||||||
|
return RedirectToAction(nameof(StoryIntelligenceAssets), 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.
|
||||||
@ -30,6 +30,8 @@ public static class StoryIntelligencePipelineStages
|
|||||||
public const string CharacterImport = "CharacterImport";
|
public const string CharacterImport = "CharacterImport";
|
||||||
public const string LocationReview = "LocationReview";
|
public const string LocationReview = "LocationReview";
|
||||||
public const string LocationImport = "LocationImport";
|
public const string LocationImport = "LocationImport";
|
||||||
|
public const string AssetReview = "AssetReview";
|
||||||
|
public const string AssetImport = "AssetImport";
|
||||||
public const string Complete = "Complete";
|
public const string Complete = "Complete";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -209,6 +209,7 @@ public class Program
|
|||||||
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<IStoryIntelligenceLocationImportService, StoryIntelligenceLocationImportService>();
|
||||||
|
builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
|
||||||
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>();
|
||||||
|
|||||||
@ -18,6 +18,8 @@ public interface IOnboardingStoryIntelligenceService
|
|||||||
Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId);
|
Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId);
|
||||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm form);
|
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm form);
|
||||||
Task<StoryIntelligenceLocationImportResultViewModel?> GetLocationImportResultAsync(Guid batchId);
|
Task<StoryIntelligenceLocationImportResultViewModel?> GetLocationImportResultAsync(Guid batchId);
|
||||||
|
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm form);
|
||||||
|
Task<StoryIntelligenceAssetImportResultViewModel?> GetAssetImportResultAsync(Guid batchId);
|
||||||
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
|
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
|
||||||
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
|
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
|
||||||
}
|
}
|
||||||
@ -30,6 +32,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
IStoryIntelligenceImportCommitService commits,
|
IStoryIntelligenceImportCommitService commits,
|
||||||
IStoryIntelligenceCharacterImportService characterImport,
|
IStoryIntelligenceCharacterImportService characterImport,
|
||||||
IStoryIntelligenceLocationImportService locationImport,
|
IStoryIntelligenceLocationImportService locationImport,
|
||||||
|
IStoryIntelligenceAssetImportService assetImport,
|
||||||
IStoryIntelligencePipelineStateService pipelineState,
|
IStoryIntelligencePipelineStateService pipelineState,
|
||||||
IStoryIntelligenceClient client,
|
IStoryIntelligenceClient client,
|
||||||
IOnboardingStoryIntelligenceBatchStore batchStore,
|
IOnboardingStoryIntelligenceBatchStore batchStore,
|
||||||
@ -263,6 +266,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
|
|
||||||
var characterReview = await characterImport.BuildReviewAsync(batch);
|
var characterReview = await characterImport.BuildReviewAsync(batch);
|
||||||
var locationReview = await locationImport.BuildReviewAsync(batch);
|
var locationReview = await locationImport.BuildReviewAsync(batch);
|
||||||
|
var assetReview = await assetImport.BuildReviewAsync(batch);
|
||||||
return new StoryIntelligenceProgressViewModel
|
return new StoryIntelligenceProgressViewModel
|
||||||
{
|
{
|
||||||
BatchID = batch.BatchID,
|
BatchID = batch.BatchID,
|
||||||
@ -274,6 +278,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
Chapters = chapters,
|
Chapters = chapters,
|
||||||
CharacterReview = characterReview,
|
CharacterReview = characterReview,
|
||||||
LocationReview = locationReview,
|
LocationReview = locationReview,
|
||||||
|
AssetReview = assetReview,
|
||||||
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
|
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
|
||||||
{
|
{
|
||||||
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
|
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
|
||||||
@ -283,7 +288,10 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete,
|
CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete,
|
||||||
LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count,
|
LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count,
|
||||||
LocationsCreatedOrLinked = batch.LocationDecisions.Count(decision => decision.CreatedOrLinked),
|
LocationsCreatedOrLinked = batch.LocationDecisions.Count(decision => decision.CreatedOrLinked),
|
||||||
LocationStageComplete = batch.LocationStageComplete || locationReview.IsComplete
|
LocationStageComplete = batch.LocationStageComplete || locationReview.IsComplete,
|
||||||
|
AssetsIdentified = assetReview.Candidates.Count + batch.AssetDecisions.Count,
|
||||||
|
AssetsCreatedOrLinked = batch.AssetDecisions.Count(decision => decision.CreatedOrLinked),
|
||||||
|
AssetStageComplete = batch.AssetStageComplete || assetReview.IsComplete
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -418,6 +426,40 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
|
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
|
||||||
{
|
{
|
||||||
var progress = await GetProgressAsync(batchId);
|
var progress = await GetProgressAsync(batchId);
|
||||||
@ -439,7 +481,9 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
CharactersCreatedOrLinked = progress.PipelineDashboard.CharactersCreatedOrLinked,
|
CharactersCreatedOrLinked = progress.PipelineDashboard.CharactersCreatedOrLinked,
|
||||||
CharacterStageComplete = progress.PipelineDashboard.CharacterStageComplete,
|
CharacterStageComplete = progress.PipelineDashboard.CharacterStageComplete,
|
||||||
LocationsCreatedOrLinked = progress.PipelineDashboard.LocationsCreatedOrLinked,
|
LocationsCreatedOrLinked = progress.PipelineDashboard.LocationsCreatedOrLinked,
|
||||||
LocationStageComplete = progress.PipelineDashboard.LocationStageComplete
|
LocationStageComplete = progress.PipelineDashboard.LocationStageComplete,
|
||||||
|
AssetsCreatedOrLinked = progress.PipelineDashboard.AssetsCreatedOrLinked,
|
||||||
|
AssetStageComplete = progress.PipelineDashboard.AssetStageComplete
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -482,6 +526,12 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (state.IsComplete)
|
if (state.IsComplete)
|
||||||
|
{
|
||||||
|
batch.CharacterStageComplete = true;
|
||||||
|
batch.LocationStageComplete = true;
|
||||||
|
batch.AssetStageComplete = true;
|
||||||
|
}
|
||||||
|
else if (state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport)
|
||||||
{
|
{
|
||||||
batch.CharacterStageComplete = true;
|
batch.CharacterStageComplete = true;
|
||||||
batch.LocationStageComplete = true;
|
batch.LocationStageComplete = true;
|
||||||
@ -496,6 +546,8 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
var route = state.CurrentStage switch
|
var route = state.CurrentStage switch
|
||||||
{
|
{
|
||||||
StoryIntelligencePipelineStages.Complete => StoryIntelligenceResumeRoutes.Complete,
|
StoryIntelligencePipelineStages.Complete => StoryIntelligenceResumeRoutes.Complete,
|
||||||
|
StoryIntelligencePipelineStages.AssetReview => StoryIntelligenceResumeRoutes.Assets,
|
||||||
|
StoryIntelligencePipelineStages.AssetImport => StoryIntelligenceResumeRoutes.Assets,
|
||||||
StoryIntelligencePipelineStages.LocationReview => StoryIntelligenceResumeRoutes.Locations,
|
StoryIntelligencePipelineStages.LocationReview => StoryIntelligenceResumeRoutes.Locations,
|
||||||
StoryIntelligencePipelineStages.LocationImport => StoryIntelligenceResumeRoutes.Locations,
|
StoryIntelligencePipelineStages.LocationImport => StoryIntelligenceResumeRoutes.Locations,
|
||||||
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
|
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
|
||||||
@ -737,10 +789,13 @@ public sealed class OnboardingStoryIntelligenceBatch
|
|||||||
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<OnboardingStoryIntelligenceLocationDecision> LocationDecisions { get; } = [];
|
||||||
|
public List<OnboardingStoryIntelligenceAssetDecision> AssetDecisions { get; } = [];
|
||||||
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
|
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
|
||||||
public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; }
|
public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; }
|
||||||
|
public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; }
|
||||||
public bool CharacterStageComplete { get; set; }
|
public bool CharacterStageComplete { get; set; }
|
||||||
public bool LocationStageComplete { get; set; }
|
public bool LocationStageComplete { get; set; }
|
||||||
|
public bool AssetStageComplete { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class OnboardingStoryIntelligenceBatchItem
|
public sealed class OnboardingStoryIntelligenceBatchItem
|
||||||
@ -770,6 +825,15 @@ public sealed class OnboardingStoryIntelligenceLocationDecision
|
|||||||
public bool CreatedOrLinked { 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 StoryIntelligenceCharacterImportBatchResult
|
public sealed class StoryIntelligenceCharacterImportBatchResult
|
||||||
{
|
{
|
||||||
public int CharactersCreated { get; init; }
|
public int CharactersCreated { get; init; }
|
||||||
@ -789,11 +853,22 @@ public sealed class StoryIntelligenceLocationImportBatchResult
|
|||||||
public int SceneLocationsUpdated { 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 static class StoryIntelligenceResumeRoutes
|
public static class StoryIntelligenceResumeRoutes
|
||||||
{
|
{
|
||||||
public const string SceneReview = "SceneReview";
|
public const string SceneReview = "SceneReview";
|
||||||
public const string Characters = "Characters";
|
public const string Characters = "Characters";
|
||||||
public const string Locations = "Locations";
|
public const string Locations = "Locations";
|
||||||
|
public const string Assets = "Assets";
|
||||||
public const string 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 forceCreateSeparate = string.Equals(choice.Action, StoryIntelligenceAssetImportActions.CreateSeparate, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var matchedExistingId = forceCreateSeparate
|
||||||
|
? null
|
||||||
|
: candidate.ExistingAssetID
|
||||||
|
?? existingIndex.Find(importName)?.StoryAssetID
|
||||||
|
?? existingIndex.Find(candidate.DisplayName)?.StoryAssetID;
|
||||||
|
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);
|
||||||
|
}
|
||||||
@ -47,11 +47,11 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
|
|
||||||
private static readonly HashSet<string> StoryLocationSignals = CreateSet(
|
private static readonly HashSet<string> StoryLocationSignals = CreateSet(
|
||||||
"trust", "church", "school", "university", "police", "headquarters", "hq", "house", "flat", "pub",
|
"trust", "church", "school", "university", "police", "headquarters", "hq", "house", "flat", "pub",
|
||||||
"cafe", "shop", "factory", "centre", "center", "hospital", "station", "hotel", "estate");
|
"cafe", "shop", "factory", "centre", "center", "hospital", "station", "hotel", "estate", "doweries");
|
||||||
|
|
||||||
private static readonly HashSet<string> ConjunctionLocationWords = CreateSet(
|
private static readonly HashSet<string> ConjunctionLocationWords = CreateSet(
|
||||||
"shop", "staff room", "house", "garden", "road", "bridge", "kitchen", "hallway", "front garden",
|
"shop", "staff room", "house", "garden", "road", "bridge", "kitchen", "hallway", "front garden",
|
||||||
"rear garden", "room", "office", "garage", "bathroom", "bedroom", "corridor", "street", "car park");
|
"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 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);
|
private static readonly Regex NamedRoadPattern = new(@"\b(street|road|lane|avenue|drive|way|close|crescent|square|place)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|||||||
@ -11,6 +11,7 @@ public interface IStoryIntelligencePipelineStateService
|
|||||||
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 RecordLocationImportAsync(int projectId, int bookId);
|
||||||
|
Task RecordAssetImportAsync(int projectId, int bookId);
|
||||||
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
|
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,8 +97,23 @@ public sealed class StoryIntelligencePipelineStateService(
|
|||||||
{
|
{
|
||||||
ProjectID = projectId,
|
ProjectID = projectId,
|
||||||
BookID = bookId,
|
BookID = bookId,
|
||||||
CurrentStage = StoryIntelligencePipelineStages.Complete,
|
CurrentStage = StoryIntelligencePipelineStages.AssetReview,
|
||||||
LastCompletedStage = StoryIntelligencePipelineStages.LocationImport,
|
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.Complete,
|
||||||
|
LastCompletedStage = StoryIntelligencePipelineStages.AssetImport,
|
||||||
CurrentReviewStage = null,
|
CurrentReviewStage = null,
|
||||||
Status = StoryIntelligencePipelineStatuses.Complete,
|
Status = StoryIntelligencePipelineStatuses.Complete,
|
||||||
CompletedUtc = DateTime.UtcNow,
|
CompletedUtc = DateTime.UtcNow,
|
||||||
|
|||||||
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
|
||||||
@ -144,6 +144,7 @@ public sealed class StoryIntelligenceProgressViewModel
|
|||||||
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 StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new();
|
||||||
|
public StoryIntelligenceAssetReviewViewModel AssetReview { 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);
|
||||||
@ -160,6 +161,7 @@ public sealed class StoryIntelligenceProgressViewModel
|
|||||||
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 HasLocationsToReview => LocationReview.Candidates.Count > 0;
|
||||||
|
public bool HasAssetsToReview => AssetReview.Candidates.Count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligencePipelineDashboardViewModel
|
public sealed class StoryIntelligencePipelineDashboardViewModel
|
||||||
@ -172,6 +174,9 @@ public sealed class StoryIntelligencePipelineDashboardViewModel
|
|||||||
public int LocationsIdentified { get; init; }
|
public int LocationsIdentified { get; init; }
|
||||||
public int LocationsCreatedOrLinked { get; init; }
|
public int LocationsCreatedOrLinked { get; init; }
|
||||||
public bool LocationStageComplete { get; init; }
|
public bool LocationStageComplete { get; init; }
|
||||||
|
public int AssetsIdentified { get; init; }
|
||||||
|
public int AssetsCreatedOrLinked { get; init; }
|
||||||
|
public bool AssetStageComplete { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceCharacterReviewViewModel
|
public sealed class StoryIntelligenceCharacterReviewViewModel
|
||||||
@ -268,6 +273,55 @@ public static class StoryIntelligenceLocationImportActions
|
|||||||
public const string Alias = "Alias";
|
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.Approve;
|
||||||
|
public string? ImportName { get; set; }
|
||||||
|
public string? AliasTargetKey { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class StoryIntelligenceAssetImportActions
|
||||||
|
{
|
||||||
|
public const string Approve = "Approve";
|
||||||
|
public const string Ignore = "Ignore";
|
||||||
|
public const string CreateSeparate = "CreateSeparate";
|
||||||
|
public const string Alias = "Alias";
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceCompletionViewModel
|
public sealed class StoryIntelligenceCompletionViewModel
|
||||||
{
|
{
|
||||||
public StoryIntelligenceJobProgress Job { get; init; } = new();
|
public StoryIntelligenceJobProgress Job { get; init; } = new();
|
||||||
@ -283,6 +337,8 @@ public sealed class StoryIntelligenceCompletionViewModel
|
|||||||
public bool CharacterStageComplete { get; init; }
|
public bool CharacterStageComplete { get; init; }
|
||||||
public int LocationsCreatedOrLinked { get; init; }
|
public int LocationsCreatedOrLinked { get; init; }
|
||||||
public bool LocationStageComplete { get; init; }
|
public bool LocationStageComplete { get; init; }
|
||||||
|
public int AssetsCreatedOrLinked { get; init; }
|
||||||
|
public bool AssetStageComplete { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceCharacterImportResultViewModel
|
public sealed class StoryIntelligenceCharacterImportResultViewModel
|
||||||
@ -310,6 +366,19 @@ public sealed class StoryIntelligenceLocationImportResultViewModel
|
|||||||
public int SceneLocationsUpdated { 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 StoryIntelligenceOnboardingChapterViewModel
|
public sealed class StoryIntelligenceOnboardingChapterViewModel
|
||||||
{
|
{
|
||||||
public string TemporaryChapterKey { get; init; } = string.Empty;
|
public string TemporaryChapterKey { get; init; } = string.Empty;
|
||||||
|
|||||||
@ -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 = "Create 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-asset-complete-title">Assets created</h1>
|
||||||
|
</div>
|
||||||
|
<p>PlotDirector updated your story database from the approved asset decisions. Later Story Intelligence stages will continue from here.</p>
|
||||||
|
</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>Coming Soon</span></article>
|
||||||
|
<article class="story-future-stage"><strong>Review Knowledge</strong><span>Coming Soon</span></article>
|
||||||
|
<article class="story-future-stage"><strong>Review Continuity</strong><span>Coming Soon</span></article>
|
||||||
|
<article class="story-future-stage"><strong>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="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
236
PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml
Normal file
236
PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
@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">
|
||||||
|
<p class="eyebrow">Review Story Intelligence</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">
|
||||||
|
@FutureStage("Review Relationships")
|
||||||
|
@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="approve-all">Approve all</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="approve-selected">Approve selected</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="ignore-selected">Ignore selected</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>
|
||||||
|
<input type="checkbox" checked data-asset-selected aria-label="Select @candidate.AssetName" />
|
||||||
|
<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.Approve" checked data-asset-action />
|
||||||
|
@(candidate.IsExistingMatch ? "Link to existing" : "Create new asset")
|
||||||
|
</label>
|
||||||
|
@if (candidate.IsExistingMatch)
|
||||||
|
{
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.CreateSeparate" data-asset-action />
|
||||||
|
Create separate asset
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.Alias" data-asset-action />
|
||||||
|
Alias/variant 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.Approve";
|
||||||
|
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");
|
||||||
|
const selectedCards = cards().filter(card => card.querySelector("[data-asset-selected]")?.checked);
|
||||||
|
if (action === "approve-all") cards().forEach(card => setAction(card, "@StoryIntelligenceAssetImportActions.Approve"));
|
||||||
|
if (action === "approve-selected") selectedCards.forEach(card => setAction(card, "@StoryIntelligenceAssetImportActions.Approve"));
|
||||||
|
if (action === "ignore-selected") selectedCards.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);
|
||||||
|
form.addEventListener("submit", () => {
|
||||||
|
cards()
|
||||||
|
.filter(card => !card.querySelector("[data-asset-selected]")?.checked)
|
||||||
|
.forEach(card => setAction(card, "@StoryIntelligenceAssetImportActions.Ignore"));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</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>");
|
||||||
|
}
|
||||||
@ -28,6 +28,14 @@
|
|||||||
<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>
|
<div>
|
||||||
<span>Book</span>
|
<span>Book</span>
|
||||||
<strong>@Model.BookTitle</strong>
|
<strong>@Model.BookTitle</strong>
|
||||||
@ -35,10 +43,10 @@
|
|||||||
</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 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>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="onboarding-actions">
|
<div class="onboarding-actions">
|
||||||
|
|||||||
@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="story-future-stage-grid">
|
<div class="story-future-stage-grid">
|
||||||
<article class="story-future-stage"><strong>Review Assets</strong><span>Coming Soon</span></article>
|
<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 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>
|
||||||
<article class="story-future-stage"><strong>Review Continuity</strong><span>Coming Soon</span></article>
|
<article class="story-future-stage"><strong>Review Continuity</strong><span>Coming Soon</span></article>
|
||||||
@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
<div class="onboarding-actions">
|
<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-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Back to locations</a>
|
||||||
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a>
|
<a class="btn btn-primary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Review assets</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -31,14 +31,14 @@
|
|||||||
<p class="mb-0">Locations are already up to date, or there were no location suggestions ready to import.</p>
|
<p class="mb-0">Locations are already up to date, or there were no location suggestions ready to import.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="story-future-stage-grid">
|
<div class="story-future-stage-grid">
|
||||||
@FutureStage("Review Assets")
|
<article class="story-future-stage"><strong>Review Assets</strong><span>Next</span></article>
|
||||||
@FutureStage("Review Relationships")
|
@FutureStage("Review Relationships")
|
||||||
@FutureStage("Review Knowledge")
|
@FutureStage("Review Knowledge")
|
||||||
@FutureStage("Review Continuity")
|
@FutureStage("Review Continuity")
|
||||||
</div>
|
</div>
|
||||||
<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="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Continue</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
new { Name = "Phase 1", Label = "Prepare manuscript", Stages = new[] { "Connect Word Companion", "Scan Manuscript", "Review Chapters" } },
|
new { Name = "Phase 1", Label = "Prepare manuscript", Stages = new[] { "Connect Word Companion", "Scan Manuscript", "Review Chapters" } },
|
||||||
new { Name = "Phase 2", Label = "Analyse manuscript", Stages = new[] { "Analyse Manuscript" } },
|
new { Name = "Phase 2", Label = "Analyse manuscript", Stages = new[] { "Analyse Manuscript" } },
|
||||||
new { Name = "Phase 3", Label = "Review Story Intelligence", Stages = new[] { "Review Scenes", "Review Characters", "Review Locations", "Review Assets", "Review Relationships", "Review Knowledge" } },
|
new { Name = "Phase 3", Label = "Review Story Intelligence", Stages = new[] { "Review Scenes", "Review Characters", "Review Locations", "Review Assets", "Review Relationships", "Review Knowledge" } },
|
||||||
new { Name = "Phase 4", Label = "Build Story Database", Stages = new[] { "Create Scenes", "Create Characters", "Complete" } }
|
new { Name = "Phase 4", Label = "Build Story Database", Stages = new[] { "Create Scenes", "Create Characters", "Create Locations", "Create Assets", "Complete" } }
|
||||||
};
|
};
|
||||||
var flatStages = phases.SelectMany(phase => phase.Stages).ToList();
|
var flatStages = phases.SelectMany(phase => phase.Stages).ToList();
|
||||||
var currentIndex = Math.Max(0, flatStages.FindIndex(stage => string.Equals(stage, Model.CurrentStage, StringComparison.OrdinalIgnoreCase)));
|
var currentIndex = Math.Max(0, flatStages.FindIndex(stage => string.Equals(stage, Model.CurrentStage, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
|||||||
@ -26,8 +26,12 @@
|
|||||||
<strong>@(Model.LocationStageComplete ? $"Done: {Model.LocationsCreatedOrLinked:N0}" : "In review")</strong>
|
<strong>@(Model.LocationStageComplete ? $"Done: {Model.LocationsCreatedOrLinked:N0}" : "In review")</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Assets</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>
|
||||||
<div>
|
<div>
|
||||||
<span>Relationships</span>
|
<span>Relationships</span>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user