Phase 12J: Warning Investigation Workflow Improvements.

This commit is contained in:
Nick Beckley 2026-06-15 09:37:54 +01:00
parent 6a2aeeea90
commit ecc8d3c76a
4 changed files with 256 additions and 14 deletions

View File

@ -8,8 +8,15 @@ namespace PlotLine.Controllers;
[Authorize]
public sealed class ContinuityExplorerController(IStoryStateService storyState) : Controller
{
public async Task<IActionResult> Index([FromQuery] ContinuityFilter filter)
public async Task<IActionResult> Index(
[FromQuery] ContinuityFilter filter,
[FromQuery] string? mode,
[FromQuery] int? characterId,
[FromQuery] int? assetId,
[FromQuery] int? locationId,
[FromQuery] int? sceneId)
{
ApplyDeepLinkAliases(filter, mode, characterId, assetId, locationId, sceneId);
if (filter.ProjectID == 0)
{
return BadRequest("ProjectID is required.");
@ -25,4 +32,55 @@ public sealed class ContinuityExplorerController(IStoryStateService storyState)
{
return RedirectToAction(nameof(Index), filter);
}
private static void ApplyDeepLinkAliases(ContinuityFilter filter, string? mode, int? characterId, int? assetId, int? locationId, int? sceneId)
{
if (!string.IsNullOrWhiteSpace(mode))
{
filter.DisplayMode = mode;
}
var hasFriendlyEntityFilter = characterId.HasValue || assetId.HasValue || locationId.HasValue;
if (hasFriendlyEntityFilter)
{
filter.EntityTypes = [];
if (characterId.HasValue)
{
filter.EntityTypes.Add("Characters");
}
if (assetId.HasValue)
{
filter.EntityTypes.Add("Assets");
}
if (locationId.HasValue || string.Equals(filter.DisplayMode, "LocationActivity", StringComparison.OrdinalIgnoreCase))
{
filter.EntityTypes.Add("Locations");
}
}
if (characterId.HasValue && !filter.CharacterIDs.Contains(characterId.Value))
{
filter.CharacterIDs.Add(characterId.Value);
}
if (assetId.HasValue && !filter.AssetIDs.Contains(assetId.Value))
{
filter.AssetIDs.Add(assetId.Value);
}
if (locationId.HasValue && !filter.LocationIDs.Contains(locationId.Value))
{
filter.LocationIDs.Add(locationId.Value);
}
if (sceneId.HasValue && !filter.StartSceneID.HasValue && !filter.EndSceneID.HasValue && !filter.SelectedSceneID.HasValue)
{
filter.SceneRangeMode = "Custom";
filter.StartSceneID = sceneId.Value;
filter.EndSceneID = sceneId.Value;
filter.SelectedSceneID = sceneId.Value;
}
}
}

View File

@ -3029,11 +3029,17 @@ public sealed class StoryStateService(
warning.KnowledgeDescription = knowledgeDescription;
warning.PreviousKnowledgeState = previous.Item.KnowledgeStateName;
warning.CurrentKnowledgeState = current.Item.KnowledgeStateName;
var previousSceneContext = context.SceneContexts.GetValueOrDefault(previous.Item.SceneID);
var currentSceneContext = context.SceneContexts.GetValueOrDefault(current.Item.SceneID);
warning.PreviousBookId = previousSceneContext?.BookID;
warning.PreviousChapterId = previousSceneContext?.ChapterID;
warning.PreviousSceneId = previous.Item.SceneID;
warning.PreviousSceneDisplayName = context.SceneContexts.GetValueOrDefault(previous.Item.SceneID)?.SceneDisplayName
warning.PreviousSceneDisplayName = previousSceneContext?.SceneDisplayName
?? context.SceneLabels.GetValueOrDefault(previous.Item.SceneID, SceneLabel(previous.Item.SceneNumber, previous.Item.SceneTitle));
warning.CurrentBookId = currentSceneContext?.BookID;
warning.CurrentChapterId = currentSceneContext?.ChapterID;
warning.CurrentSceneId = current.Item.SceneID;
warning.CurrentSceneDisplayName = context.SceneContexts.GetValueOrDefault(current.Item.SceneID)?.SceneDisplayName
warning.CurrentSceneDisplayName = currentSceneContext?.SceneDisplayName
?? context.SceneLabels.GetValueOrDefault(current.Item.SceneID, SceneLabel(current.Item.SceneNumber, current.Item.SceneTitle));
warnings.Add(warning);
}
@ -3218,6 +3224,8 @@ public sealed class StoryStateService(
Message = message,
CharacterID = characterId,
AssetID = assetId,
BookID = sceneContext?.BookID,
ChapterID = sceneContext?.ChapterID,
SceneID = sceneId,
SceneDisplayName = sceneContext?.SceneDisplayName ?? (sceneId.HasValue ? context.SceneLabels.GetValueOrDefault(sceneId.Value, string.Empty) : string.Empty),
BookDisplayName = sceneContext?.BookDisplayName ?? string.Empty,
@ -4008,7 +4016,10 @@ public sealed class ContinuityValidationService(
.Where(x => !chapterId.HasValue || x.ChapterID == chapterId.Value)
.ToList();
var continuityWarnings = await storyState.GetContinuityWarningsAsync(projectId, BuildWarningContinuityFilter(projectId, bookId, chapterId, sceneId));
var dashboardWarnings = rows.Select(ToProjectWarning).Concat(continuityWarnings.Select(ToProjectWarning)).ToList();
var booksById = projectBooks.ToDictionary(x => x.BookID);
var chaptersById = projectChapters.ToDictionary(x => x.ChapterID);
var dashboardWarnings = rows.Select(x => ToProjectWarning(x, booksById, chaptersById)).Concat(continuityWarnings.Select(ToProjectWarning)).ToList();
ApplyWarningDisplayContext(dashboardWarnings, bookId, chapterId, booksById, chaptersById);
var availableWarnings = dashboardWarnings.ToList();
var hasAnyWarnings = availableWarnings.Any();
@ -4083,8 +4094,20 @@ public sealed class ContinuityValidationService(
return filter;
}
private static ProjectWarningViewModel ToProjectWarning(ContinuityWarning warning)
private static ProjectWarningViewModel ToProjectWarning(
ContinuityWarning warning,
IReadOnlyDictionary<int, Book> booksById,
IReadOnlyDictionary<int, Chapter> chaptersById)
{
var bookDisplayName = warning.BookID.HasValue && booksById.TryGetValue(warning.BookID.Value, out var book)
? $"Book {book.BookNumber:g}: {book.BookTitle}"
: warning.BookTitle ?? string.Empty;
var chapterDisplayName = warning.ChapterID.HasValue && chaptersById.TryGetValue(warning.ChapterID.Value, out var chapter)
? $"Chapter {chapter.ChapterNumber:g}: {chapter.ChapterTitle}"
: warning.ChapterNumber.HasValue
? $"Chapter {warning.ChapterNumber:g}: {warning.ChapterTitle}"
: string.Empty;
return new ProjectWarningViewModel
{
Source = "Persisted",
@ -4096,11 +4119,9 @@ public sealed class ContinuityValidationService(
Message = warning.Message,
Details = warning.Details,
BookID = warning.BookID,
BookDisplayName = string.IsNullOrWhiteSpace(warning.BookTitle) ? string.Empty : warning.BookTitle,
BookDisplayName = bookDisplayName,
ChapterID = warning.ChapterID,
ChapterDisplayName = warning.ChapterNumber.HasValue
? $"Ch {warning.ChapterNumber:g}: {warning.ChapterTitle}"
: string.Empty,
ChapterDisplayName = chapterDisplayName,
SceneID = warning.SceneID,
SceneDisplayName = warning.SceneID.HasValue
? SceneLabel(warning.SceneNumber ?? 0, warning.SceneTitle ?? string.Empty)
@ -4122,7 +4143,9 @@ public sealed class ContinuityValidationService(
Category = WarningCategoryForGenerated(warning),
Severity = warning.Severity,
Message = warning.Message,
BookID = warning.BookID,
BookDisplayName = warning.BookDisplayName,
ChapterID = warning.ChapterID,
ChapterDisplayName = warning.ChapterDisplayName,
SceneID = warning.SceneID,
SceneDisplayName = warning.SceneDisplayName,
@ -4138,10 +4161,16 @@ public sealed class ContinuityValidationService(
KnowledgeDescription = warning.KnowledgeDescription,
PreviousKnowledgeState = warning.PreviousKnowledgeState,
CurrentKnowledgeState = warning.CurrentKnowledgeState,
PreviousBookId = warning.PreviousBookId,
PreviousChapterId = warning.PreviousChapterId,
PreviousSceneId = warning.PreviousSceneId,
PreviousSceneDisplayName = warning.PreviousSceneDisplayName,
PreviousContextSceneDisplayName = warning.PreviousContextSceneDisplayName,
CurrentBookId = warning.CurrentBookId,
CurrentChapterId = warning.CurrentChapterId,
CurrentSceneId = warning.CurrentSceneId,
CurrentSceneDisplayName = warning.CurrentSceneDisplayName,
CurrentContextSceneDisplayName = warning.CurrentContextSceneDisplayName,
EstablishedSceneId = warning.EstablishedSceneId,
EstablishedSceneDisplayName = warning.EstablishedSceneDisplayName,
ReferencedSceneId = warning.ReferencedSceneId,
@ -4150,6 +4179,124 @@ public sealed class ContinuityValidationService(
};
}
private static void ApplyWarningDisplayContext(
List<ProjectWarningViewModel> warnings,
int? scopedBookId,
int? scopedChapterId,
IReadOnlyDictionary<int, Book> booksById,
IReadOnlyDictionary<int, Chapter> chaptersById)
{
foreach (var warning in warnings)
{
warning.ContextSceneDisplayName = FormatWarningSceneContext(
warning.BookID,
warning.BookDisplayName,
warning.ChapterID,
warning.ChapterDisplayName,
warning.SceneDisplayName,
scopedBookId,
scopedChapterId,
booksById,
chaptersById);
warning.PreviousContextSceneDisplayName = FormatWarningSceneContext(
warning.PreviousBookId,
string.Empty,
warning.PreviousChapterId,
string.Empty,
warning.PreviousSceneDisplayName,
scopedBookId,
scopedChapterId,
booksById,
chaptersById);
warning.CurrentContextSceneDisplayName = FormatWarningSceneContext(
warning.CurrentBookId,
string.Empty,
warning.CurrentChapterId,
string.Empty,
warning.CurrentSceneDisplayName,
scopedBookId,
scopedChapterId,
booksById,
chaptersById);
warning.EstablishedContextSceneDisplayName = string.IsNullOrWhiteSpace(warning.EstablishedSceneDisplayName)
? string.Empty
: warning.EstablishedSceneDisplayName;
warning.ReferencedContextSceneDisplayName = string.IsNullOrWhiteSpace(warning.ReferencedSceneDisplayName)
? string.Empty
: warning.ReferencedSceneDisplayName;
ApplyInvestigationTarget(warning);
}
}
private static string FormatWarningSceneContext(
int? bookId,
string bookDisplayName,
int? chapterId,
string chapterDisplayName,
string sceneDisplayName,
int? scopedBookId,
int? scopedChapterId,
IReadOnlyDictionary<int, Book> booksById,
IReadOnlyDictionary<int, Chapter> chaptersById)
{
if (string.IsNullOrWhiteSpace(sceneDisplayName))
{
return string.Empty;
}
if (string.IsNullOrWhiteSpace(bookDisplayName) && bookId.HasValue && booksById.TryGetValue(bookId.Value, out var book))
{
bookDisplayName = $"Book {book.BookNumber:g}: {book.BookTitle}";
}
if (string.IsNullOrWhiteSpace(chapterDisplayName) && chapterId.HasValue && chaptersById.TryGetValue(chapterId.Value, out var chapter))
{
chapterDisplayName = $"Chapter {chapter.ChapterNumber:g}: {chapter.ChapterTitle}";
}
if (scopedChapterId.HasValue)
{
return sceneDisplayName;
}
if (scopedBookId.HasValue || (bookId.HasValue && scopedBookId == bookId))
{
return string.IsNullOrWhiteSpace(chapterDisplayName)
? sceneDisplayName
: $"{chapterDisplayName} • {sceneDisplayName}";
}
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(bookDisplayName))
{
parts.Add(bookDisplayName);
}
if (!string.IsNullOrWhiteSpace(chapterDisplayName))
{
parts.Add(chapterDisplayName);
}
parts.Add(sceneDisplayName);
return string.Join(" • ", parts);
}
private static void ApplyInvestigationTarget(ProjectWarningViewModel warning)
{
if (warning.Source != "Generated")
{
return;
}
warning.CanInvestigate = true;
warning.InvestigationDisplayMode = warning.WarningTypeDisplayName switch
{
"Multiple Character Locations" => "LocationActivity",
_ => "Journey"
};
warning.InvestigationSceneId = warning.CurrentSceneId ?? warning.ReferencedSceneId ?? warning.SceneID;
}
private static string WarningCategoryForPersisted(ContinuityWarning warning)
{
return warning.EntityType switch

View File

@ -325,8 +325,11 @@ public sealed class ContinuityWarningViewModel
public int? CharacterID { get; set; }
public string CharacterName { get; set; } = string.Empty;
public int? AssetID { get; set; }
public int? BookID { get; set; }
public int? ChapterID { get; set; }
public int? SceneID { get; set; }
public string SceneDisplayName { get; set; } = string.Empty;
public string ContextSceneDisplayName { get; set; } = string.Empty;
public string BookDisplayName { get; set; } = string.Empty;
public string ChapterDisplayName { get; set; } = string.Empty;
public bool IsAcknowledged { get; set; }
@ -336,14 +339,25 @@ public sealed class ContinuityWarningViewModel
public string KnowledgeDescription { get; set; } = string.Empty;
public string PreviousKnowledgeState { get; set; } = string.Empty;
public string CurrentKnowledgeState { get; set; } = string.Empty;
public int? PreviousBookId { get; set; }
public int? PreviousChapterId { get; set; }
public int? PreviousSceneId { get; set; }
public string PreviousSceneDisplayName { get; set; } = string.Empty;
public string PreviousContextSceneDisplayName { get; set; } = string.Empty;
public int? CurrentBookId { get; set; }
public int? CurrentChapterId { get; set; }
public int? CurrentSceneId { get; set; }
public string CurrentSceneDisplayName { get; set; } = string.Empty;
public string CurrentContextSceneDisplayName { get; set; } = string.Empty;
public int? EstablishedSceneId { get; set; }
public string EstablishedSceneDisplayName { get; set; } = string.Empty;
public string EstablishedContextSceneDisplayName { get; set; } = string.Empty;
public int? ReferencedSceneId { get; set; }
public string ReferencedSceneDisplayName { get; set; } = string.Empty;
public string ReferencedContextSceneDisplayName { get; set; } = string.Empty;
public string InvestigationDisplayMode { get; set; } = string.Empty;
public int? InvestigationSceneId { get; set; }
public bool CanInvestigate { get; set; }
}
public sealed class CharacterJourneyViewModel
@ -1685,6 +1699,7 @@ public sealed class ProjectWarningViewModel
public string ChapterDisplayName { get; set; } = string.Empty;
public int? SceneID { get; set; }
public string SceneDisplayName { get; set; } = string.Empty;
public string ContextSceneDisplayName { get; set; } = string.Empty;
public int? CharacterID { get; set; }
public string CharacterName { get; set; } = string.Empty;
public int? AssetID { get; set; }
@ -1699,14 +1714,25 @@ public sealed class ProjectWarningViewModel
public string KnowledgeDescription { get; set; } = string.Empty;
public string PreviousKnowledgeState { get; set; } = string.Empty;
public string CurrentKnowledgeState { get; set; } = string.Empty;
public int? PreviousBookId { get; set; }
public int? PreviousChapterId { get; set; }
public int? PreviousSceneId { get; set; }
public string PreviousSceneDisplayName { get; set; } = string.Empty;
public string PreviousContextSceneDisplayName { get; set; } = string.Empty;
public int? CurrentBookId { get; set; }
public int? CurrentChapterId { get; set; }
public int? CurrentSceneId { get; set; }
public string CurrentSceneDisplayName { get; set; } = string.Empty;
public string CurrentContextSceneDisplayName { get; set; } = string.Empty;
public int? EstablishedSceneId { get; set; }
public string EstablishedSceneDisplayName { get; set; } = string.Empty;
public string EstablishedContextSceneDisplayName { get; set; } = string.Empty;
public int? ReferencedSceneId { get; set; }
public string ReferencedSceneDisplayName { get; set; } = string.Empty;
public string ReferencedContextSceneDisplayName { get; set; } = string.Empty;
public string InvestigationDisplayMode { get; set; } = string.Empty;
public int? InvestigationSceneId { get; set; }
public bool CanInvestigate { get; set; }
public DateTime LastDetectedDate { get; set; }
}

View File

@ -183,28 +183,28 @@
{
<small class="text-muted d-block">
Previous:
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.PreviousSceneId">@warning.PreviousSceneDisplayName</a>
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.PreviousSceneId">@warning.PreviousContextSceneDisplayName</a>
</small>
}
@if (warning.CurrentSceneId.HasValue)
{
<small class="text-muted d-block">
Current:
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.CurrentSceneId">@warning.CurrentSceneDisplayName</a>
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.CurrentSceneId">@warning.CurrentContextSceneDisplayName</a>
</small>
}
@if (warning.EstablishedSceneId.HasValue)
{
<small class="text-muted d-block">
Established:
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.EstablishedSceneId">@warning.EstablishedSceneDisplayName</a>
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.EstablishedSceneId">@(string.IsNullOrWhiteSpace(warning.EstablishedContextSceneDisplayName) ? warning.EstablishedSceneDisplayName : warning.EstablishedContextSceneDisplayName)</a>
</small>
}
@if (warning.ReferencedSceneId.HasValue)
{
<small class="text-muted d-block">
Earlier reference:
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.ReferencedSceneId">@warning.ReferencedSceneDisplayName</a>
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.ReferencedSceneId">@(string.IsNullOrWhiteSpace(warning.ReferencedContextSceneDisplayName) ? warning.ReferencedSceneDisplayName : warning.ReferencedContextSceneDisplayName)</a>
</small>
}
@if (warning.IsAcknowledged && !string.IsNullOrWhiteSpace(warning.AcknowledgementNotes))
@ -217,7 +217,7 @@
<td>
@if (warning.SceneID.HasValue)
{
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.SceneID">@warning.SceneDisplayName</a>
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.SceneID">@warning.ContextSceneDisplayName</a>
}
else
{
@ -239,6 +239,17 @@
}
</td>
<td>
@if (warning.CanInvestigate)
{
<a class="btn btn-outline-primary btn-sm"
asp-controller="ContinuityExplorer"
asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-mode="@warning.InvestigationDisplayMode"
asp-route-characterId="@warning.CharacterID"
asp-route-assetId="@warning.AssetID"
asp-route-sceneId="@warning.InvestigationSceneId">Investigate</a>
}
@if (warning.Source == "Persisted" && warning.ContinuityWarningID.HasValue)
{
@if (warning.IsDismissed || warning.IsIntentional)