Phase 12E: Continuity Warnings Engine (Informational).
This commit is contained in:
parent
9cc20ea266
commit
7c7815642a
@ -118,6 +118,7 @@ public interface IStoryStateService
|
||||
Task<List<CharacterJourneyViewModel>> GetCharacterJourneyAsync(int projectId, int characterId, ContinuityFilter filter);
|
||||
Task<List<AssetJourneyViewModel>> GetAssetJourneyAsync(int projectId, int assetId, ContinuityFilter filter);
|
||||
Task<List<LocationActivityViewModel>> GetLocationActivityAsync(int projectId, int locationId, ContinuityFilter filter);
|
||||
Task<List<ContinuityWarningViewModel>> GetContinuityWarningsAsync(int projectId, ContinuityFilter filter);
|
||||
}
|
||||
|
||||
public interface IPlotService
|
||||
@ -2336,6 +2337,10 @@ public sealed class StoryStateService(
|
||||
.ThenBy(x => x.LocationName)
|
||||
.ToList();
|
||||
}
|
||||
else if (string.Equals(filter.DisplayMode, "ContinuityWarnings", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
model.ContinuityWarnings = await GetContinuityWarningsAsync(filter.ProjectID, filter);
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
@ -2519,6 +2524,42 @@ public sealed class StoryStateService(
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<ContinuityWarningViewModel>> GetContinuityWarningsAsync(int projectId, ContinuityFilter filter)
|
||||
{
|
||||
var context = await GetRangeContextAsync(projectId, filter);
|
||||
var characterTimeline = await characters.GetTimelineAsync(projectId, null);
|
||||
var assetTimeline = await assets.GetTimelineAsync(projectId, null);
|
||||
var allAssetLocations = await GetAssetLocationsThroughCurrentAsync(context.OrderedScenes);
|
||||
var custodyCharacters = await assets.ListCustodyCharactersByProjectAsync(projectId);
|
||||
var enabledCharacterIds = filter.CharacterIDs.ToHashSet();
|
||||
var enabledAssetIds = filter.AssetIDs.ToHashSet();
|
||||
var warnings = new List<ContinuityWarningViewModel>();
|
||||
|
||||
if (filter.IncludeCharacters)
|
||||
{
|
||||
warnings.AddRange(BuildMultipleCharacterLocationWarnings(characterTimeline.Appearances, enabledCharacterIds, context));
|
||||
warnings.AddRange(BuildImpossibleMovementWarnings(characterTimeline.Appearances, enabledCharacterIds, context));
|
||||
warnings.AddRange(BuildUnknownCharacterLocationWarnings(characterTimeline.Characters, characterTimeline.Appearances, enabledCharacterIds, context));
|
||||
}
|
||||
|
||||
if (filter.IncludeAssets)
|
||||
{
|
||||
var scopedAssetLocations = allAssetLocations.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).ToList();
|
||||
var scopedCustody = custodyCharacters.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).ToList();
|
||||
warnings.AddRange(BuildAssetLocationConflictWarnings(assetTimeline.Assets, scopedAssetLocations, scopedCustody, enabledAssetIds, context));
|
||||
warnings.AddRange(BuildUnknownAssetLocationWarnings(assetTimeline.Assets, assetTimeline.Events, allAssetLocations, custodyCharacters, enabledAssetIds, context));
|
||||
}
|
||||
|
||||
return warnings
|
||||
.OrderBy(x => WarningSeveritySort(x.Severity))
|
||||
.ThenBy(x => x.BookDisplayName)
|
||||
.ThenBy(x => x.ChapterDisplayName)
|
||||
.ThenBy(x => x.SceneID ?? int.MaxValue)
|
||||
.ThenBy(x => x.WarningType)
|
||||
.ThenBy(x => x.Message)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<RangeContext> GetRangeContextAsync(int projectId, ContinuityFilter filter)
|
||||
{
|
||||
NormaliseFilter(filter);
|
||||
@ -2737,6 +2778,203 @@ public sealed class StoryStateService(
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<ContinuityWarningViewModel> BuildMultipleCharacterLocationWarnings(
|
||||
IReadOnlyList<SceneCharacter> appearances,
|
||||
IReadOnlySet<int> enabledCharacterIds,
|
||||
RangeContext context)
|
||||
{
|
||||
return appearances
|
||||
.Where(x => context.AllowedSceneIds.Contains(x.SceneID)
|
||||
&& x.LocationID.HasValue
|
||||
&& (!enabledCharacterIds.Any() || enabledCharacterIds.Contains(x.CharacterID)))
|
||||
.GroupBy(x => new { x.SceneID, x.CharacterID, x.CharacterName })
|
||||
.Where(group => group.Select(x => x.LocationID).Distinct().Count() > 1)
|
||||
.Select(group => BuildWarning(
|
||||
"Multiple Character Locations",
|
||||
"Critical",
|
||||
$"{group.Key.CharacterName} appears in multiple locations during {context.SceneLabels.GetValueOrDefault(group.Key.SceneID, "this scene")}.",
|
||||
group.Key.SceneID,
|
||||
context,
|
||||
characterId: group.Key.CharacterID))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<ContinuityWarningViewModel> BuildImpossibleMovementWarnings(
|
||||
IReadOnlyList<SceneCharacter> appearances,
|
||||
IReadOnlySet<int> enabledCharacterIds,
|
||||
RangeContext context)
|
||||
{
|
||||
var warnings = new List<ContinuityWarningViewModel>();
|
||||
var rowsByCharacter = appearances
|
||||
.Where(x => x.LocationID.HasValue && (!enabledCharacterIds.Any() || enabledCharacterIds.Contains(x.CharacterID)))
|
||||
.GroupBy(x => x.CharacterID);
|
||||
|
||||
foreach (var group in rowsByCharacter)
|
||||
{
|
||||
var explicitLocations = group
|
||||
.GroupBy(x => x.SceneID)
|
||||
.Select(sceneGroup => sceneGroup.OrderByDescending(x => x.UpdatedDate).First())
|
||||
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
|
||||
.ToList();
|
||||
|
||||
for (var index = 1; index < explicitLocations.Count; index++)
|
||||
{
|
||||
var previous = explicitLocations[index - 1];
|
||||
var current = explicitLocations[index];
|
||||
if (previous.LocationID == current.LocationID || !context.AllowedSceneIds.Contains(current.SceneID))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var previousIndex = context.SceneIndexes.GetValueOrDefault(previous.SceneID, -1);
|
||||
var currentIndex = context.SceneIndexes.GetValueOrDefault(current.SceneID, -1);
|
||||
if (currentIndex - previousIndex != 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
warnings.Add(BuildWarning(
|
||||
"Impossible Character Movement",
|
||||
"Warning",
|
||||
$"{current.CharacterName} moved from {previous.LocationPath ?? previous.LocationName ?? "an unknown location"} to {current.LocationPath ?? current.LocationName ?? "an unknown location"} between consecutive scenes.",
|
||||
current.SceneID,
|
||||
context,
|
||||
characterId: current.CharacterID));
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private static List<ContinuityWarningViewModel> BuildUnknownCharacterLocationWarnings(
|
||||
IReadOnlyList<Character> projectCharacters,
|
||||
IReadOnlyList<SceneCharacter> appearances,
|
||||
IReadOnlySet<int> enabledCharacterIds,
|
||||
RangeContext context)
|
||||
{
|
||||
var appearancesByCharacter = appearances
|
||||
.Where(x => context.AllowedSceneIds.Contains(x.SceneID))
|
||||
.GroupBy(x => x.CharacterID)
|
||||
.ToDictionary(x => x.Key, x => x.ToList());
|
||||
var locationCharacterIds = appearances
|
||||
.Where(x => x.LocationID.HasValue)
|
||||
.Select(x => x.CharacterID)
|
||||
.ToHashSet();
|
||||
|
||||
return projectCharacters
|
||||
.Where(character => (!enabledCharacterIds.Any() || enabledCharacterIds.Contains(character.CharacterID))
|
||||
&& appearancesByCharacter.ContainsKey(character.CharacterID)
|
||||
&& !locationCharacterIds.Contains(character.CharacterID))
|
||||
.Select(character =>
|
||||
{
|
||||
var firstSceneId = appearancesByCharacter[character.CharacterID]
|
||||
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
|
||||
.First()
|
||||
.SceneID;
|
||||
return BuildWarning(
|
||||
"Characters With Unknown Locations",
|
||||
"Information",
|
||||
$"{character.CharacterName} has no known location.",
|
||||
firstSceneId,
|
||||
context,
|
||||
characterId: character.CharacterID);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<ContinuityWarningViewModel> BuildAssetLocationConflictWarnings(
|
||||
IReadOnlyList<StoryAsset> projectAssets,
|
||||
IReadOnlyList<SceneAssetLocation> sceneAssetLocations,
|
||||
IReadOnlyList<AssetCustodyCharacter> custodyCharacters,
|
||||
IReadOnlySet<int> enabledAssetIds,
|
||||
RangeContext context)
|
||||
{
|
||||
var assetNames = projectAssets.ToDictionary(x => x.StoryAssetID, x => x.AssetName);
|
||||
var directLocationGroups = sceneAssetLocations
|
||||
.Where(x => !enabledAssetIds.Any() || enabledAssetIds.Contains(x.StoryAssetID))
|
||||
.GroupBy(x => new { x.SceneID, x.StoryAssetID });
|
||||
var custodyGroups = custodyCharacters
|
||||
.Where(x => !enabledAssetIds.Any() || enabledAssetIds.Contains(x.StoryAssetID))
|
||||
.GroupBy(x => new { x.SceneID, x.StoryAssetID })
|
||||
.ToDictionary(x => x.Key, x => x.ToList());
|
||||
var warnings = new List<ContinuityWarningViewModel>();
|
||||
|
||||
foreach (var group in directLocationGroups)
|
||||
{
|
||||
var directLocationCount = group.Select(x => x.LocationID).Distinct().Count();
|
||||
custodyGroups.TryGetValue(group.Key, out var custodyRows);
|
||||
var hasCustody = custodyRows?.Any() == true;
|
||||
if (directLocationCount <= 1 && !hasCustody)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
warnings.Add(BuildWarning(
|
||||
"Asset Location Conflict",
|
||||
"Critical",
|
||||
$"{assetNames.GetValueOrDefault(group.Key.StoryAssetID, "An asset")} has multiple explicit locations in {context.SceneLabels.GetValueOrDefault(group.Key.SceneID, "this scene")}.",
|
||||
group.Key.SceneID,
|
||||
context,
|
||||
assetId: group.Key.StoryAssetID));
|
||||
}
|
||||
|
||||
foreach (var group in custodyGroups.Where(x => !directLocationGroups.Any(locationGroup => locationGroup.Key.SceneID == x.Key.SceneID && locationGroup.Key.StoryAssetID == x.Key.StoryAssetID)))
|
||||
{
|
||||
var holderCount = group.Value.Select(x => x.CharacterID?.ToString() ?? x.CharacterNameText ?? string.Empty).Where(x => x.Length > 0).Distinct().Count();
|
||||
if (holderCount <= 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
warnings.Add(BuildWarning(
|
||||
"Asset Location Conflict",
|
||||
"Critical",
|
||||
$"{assetNames.GetValueOrDefault(group.Key.StoryAssetID, "An asset")} has multiple explicit holders in {context.SceneLabels.GetValueOrDefault(group.Key.SceneID, "this scene")}.",
|
||||
group.Key.SceneID,
|
||||
context,
|
||||
assetId: group.Key.StoryAssetID));
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private static List<ContinuityWarningViewModel> BuildUnknownAssetLocationWarnings(
|
||||
IReadOnlyList<StoryAsset> projectAssets,
|
||||
IReadOnlyList<AssetEvent> assetEvents,
|
||||
IReadOnlyList<SceneAssetLocation> allAssetLocations,
|
||||
IReadOnlyList<AssetCustodyCharacter> custodyCharacters,
|
||||
IReadOnlySet<int> enabledAssetIds,
|
||||
RangeContext context)
|
||||
{
|
||||
var appearedAssetSceneIds = assetEvents
|
||||
.Where(x => context.AllowedSceneIds.Contains(x.SceneID))
|
||||
.Select(x => new { x.StoryAssetID, x.SceneID })
|
||||
.Concat(allAssetLocations.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).Select(x => new { x.StoryAssetID, x.SceneID }))
|
||||
.Concat(custodyCharacters.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).Select(x => new { x.StoryAssetID, x.SceneID }))
|
||||
.GroupBy(x => x.StoryAssetID)
|
||||
.ToDictionary(x => x.Key, x => x.Min(row => context.SceneIndexes.GetValueOrDefault(row.SceneID, int.MaxValue)));
|
||||
var locatedAssetIds = allAssetLocations.Select(x => x.StoryAssetID)
|
||||
.Concat(custodyCharacters.Select(x => x.StoryAssetID))
|
||||
.ToHashSet();
|
||||
|
||||
return projectAssets
|
||||
.Where(asset => (!enabledAssetIds.Any() || enabledAssetIds.Contains(asset.StoryAssetID))
|
||||
&& appearedAssetSceneIds.ContainsKey(asset.StoryAssetID)
|
||||
&& !locatedAssetIds.Contains(asset.StoryAssetID))
|
||||
.Select(asset =>
|
||||
{
|
||||
var firstSceneId = context.OrderedScenes.FirstOrDefault(scene => context.SceneIndexes.GetValueOrDefault(scene.SceneID, int.MaxValue) == appearedAssetSceneIds[asset.StoryAssetID])?.SceneID;
|
||||
return BuildWarning(
|
||||
"Assets With Unknown Locations",
|
||||
"Information",
|
||||
$"{asset.AssetName} has no known location.",
|
||||
firstSceneId,
|
||||
context,
|
||||
assetId: asset.StoryAssetID);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static LocationActivityEntityViewModel? BuildCurrentCharacterPresence(
|
||||
Character character,
|
||||
IReadOnlyList<SceneCharacter> assignments,
|
||||
@ -2890,6 +3128,40 @@ public sealed class StoryStateService(
|
||||
: [];
|
||||
}
|
||||
|
||||
private static ContinuityWarningViewModel BuildWarning(
|
||||
string warningType,
|
||||
string severity,
|
||||
string message,
|
||||
int? sceneId,
|
||||
RangeContext context,
|
||||
int? characterId = null,
|
||||
int? assetId = null)
|
||||
{
|
||||
var sceneContext = sceneId.HasValue ? context.SceneContexts.GetValueOrDefault(sceneId.Value) : null;
|
||||
return new ContinuityWarningViewModel
|
||||
{
|
||||
WarningType = warningType,
|
||||
Severity = severity,
|
||||
Message = message,
|
||||
CharacterID = characterId,
|
||||
AssetID = assetId,
|
||||
SceneID = sceneId,
|
||||
SceneDisplayName = sceneContext?.SceneDisplayName ?? (sceneId.HasValue ? context.SceneLabels.GetValueOrDefault(sceneId.Value, string.Empty) : string.Empty),
|
||||
BookDisplayName = sceneContext?.BookDisplayName ?? string.Empty,
|
||||
ChapterDisplayName = sceneContext?.ChapterDisplayName ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static int WarningSeveritySort(string severity)
|
||||
{
|
||||
return severity switch
|
||||
{
|
||||
"Critical" => 0,
|
||||
"Warning" => 1,
|
||||
_ => 2
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<int, int> BuildSceneIndexes(IReadOnlyList<Scene> orderedScenes)
|
||||
{
|
||||
return orderedScenes
|
||||
|
||||
@ -303,6 +303,7 @@ public sealed class ContinuityExplorerViewModel
|
||||
public IReadOnlyList<CharacterJourneyViewModel> CharacterJourney { get; set; } = [];
|
||||
public IReadOnlyList<AssetJourneyViewModel> AssetJourney { get; set; } = [];
|
||||
public IReadOnlyList<LocationActivityViewModel> LocationActivity { get; set; } = [];
|
||||
public IReadOnlyList<ContinuityWarningViewModel> ContinuityWarnings { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> ChapterOptions { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> SceneOptions { get; set; } = [];
|
||||
@ -313,6 +314,19 @@ public sealed class ContinuityExplorerViewModel
|
||||
public bool ShowChapterContext { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ContinuityWarningViewModel
|
||||
{
|
||||
public string WarningType { get; set; } = string.Empty;
|
||||
public string Severity { get; set; } = "Information";
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public int? CharacterID { get; set; }
|
||||
public int? AssetID { get; set; }
|
||||
public int? SceneID { get; set; }
|
||||
public string SceneDisplayName { get; set; } = string.Empty;
|
||||
public string BookDisplayName { get; set; } = string.Empty;
|
||||
public string ChapterDisplayName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CharacterJourneyViewModel
|
||||
{
|
||||
public int CharacterID { get; set; }
|
||||
|
||||
@ -12,6 +12,13 @@
|
||||
_ => "bg-warning text-dark"
|
||||
};
|
||||
|
||||
private static string WarningBadgeClass(string severity) => severity switch
|
||||
{
|
||||
"Critical" => "bg-danger",
|
||||
"Warning" => "bg-warning text-dark",
|
||||
_ => "bg-info"
|
||||
};
|
||||
|
||||
private bool IsEntitySelected(string entityType) =>
|
||||
Model.Filter.EntityTypes.Contains(entityType, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@ -144,6 +151,10 @@
|
||||
<input class="form-check-input" type="radio" name="DisplayMode" value="LocationActivity" checked="@(Model.Filter.DisplayMode == "LocationActivity")" />
|
||||
<span class="form-check-label">Location Activity</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="radio" name="DisplayMode" value="ContinuityWarnings" checked="@(Model.Filter.DisplayMode == "ContinuityWarnings")" />
|
||||
<span class="form-check-label">Continuity Warnings</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -350,6 +361,52 @@ else if (Model.Filter.DisplayMode == "Journey")
|
||||
}
|
||||
</section>
|
||||
}
|
||||
else if (Model.Filter.DisplayMode == "ContinuityWarnings")
|
||||
{
|
||||
<section class="story-bible-section">
|
||||
<h2>Continuity Warnings</h2>
|
||||
@if (!Model.ContinuityWarnings.Any())
|
||||
{
|
||||
<p class="muted">No continuity concerns detected.</p>
|
||||
<p class="muted">Continuity warnings highlight potential issues but cannot replace author judgement.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Severity</th>
|
||||
<th>Warning Type</th>
|
||||
<th>Message</th>
|
||||
<th>Scene</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var warning in Model.ContinuityWarnings)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="badge @WarningBadgeClass(warning.Severity)">@warning.Severity</span></td>
|
||||
<td>@warning.WarningType</td>
|
||||
<td>@warning.Message</td>
|
||||
<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>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="muted">Project-wide</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="story-bible-section">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user