Phase 20AN.1 – Location Intelligence Review and Import

This commit is contained in:
Nick Beckley 2026-07-09 20:20:09 +01:00
parent ef51765eb4
commit 4b6719e242
3 changed files with 337 additions and 9 deletions

View File

@ -29,7 +29,11 @@ public sealed class StoryIntelligenceLocationImportService(
"room", "place", "area", "building", "street", "road", "door", "doorway", "wall", "floor", "room", "place", "area", "building", "street", "road", "door", "doorway", "wall", "floor",
"pavement", "car", "car park", "parking lot", "stairs", "stairwell", "landing", "kitchen", "pavement", "car", "car park", "parking lot", "stairs", "stairwell", "landing", "kitchen",
"bathroom", "hallway", "corridor", "lobby", "reception", "desk", "pew", "altar", "outside", "bathroom", "hallway", "corridor", "lobby", "reception", "desk", "pew", "altar", "outside",
"inside", "nearby", "somewhere", "home", "house", "flat"); "inside", "nearby", "somewhere", "home", "house", "flat", "streets", "main road", "dual carriageway",
"flyover", "motorway roundabout", "under the tree", "under-flyover / link road", "sitting room", "ring road");
private static readonly HashSet<string> ThrowawayLocationFragments = CreateSet(
"door", "porch", "exterior", "tree", "pavement");
private static readonly HashSet<string> InternalLocationWords = CreateSet( private static readonly HashSet<string> InternalLocationWords = CreateSet(
"room", "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "stairwell", "stairs", "room", "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "stairwell", "stairs",
@ -481,6 +485,16 @@ public sealed class StoryIntelligenceLocationImportService(
private static bool IsVisibleLocationCandidate(LocationCandidate candidate) private static bool IsVisibleLocationCandidate(LocationCandidate candidate)
{ {
if (IsGenericComposite(candidate.DisplayName))
{
return false;
}
if (ContainsThrowawayFragment(candidate.DisplayName))
{
return false;
}
if (IsNamedLocation(candidate.DisplayName)) if (IsNamedLocation(candidate.DisplayName))
{ {
return true; return true;
@ -488,12 +502,13 @@ public sealed class StoryIntelligenceLocationImportService(
if (!string.IsNullOrWhiteSpace(candidate.ParentLocationHint)) if (!string.IsNullOrWhiteSpace(candidate.ParentLocationHint))
{ {
return candidate.Appearances.Count(appearance => appearance.PresentInScene) > 0; return candidate.Appearances.Count(appearance => appearance.PresentInScene) > 0
&& (InternalLocationWords.Contains(Normalise(candidate.DisplayName)) || !IsGenericLocation(candidate.DisplayName));
} }
if (IsGenericLocation(candidate.DisplayName)) if (IsGenericLocation(candidate.DisplayName))
{ {
return candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count() >= 2; return false;
} }
return true; return true;
@ -506,17 +521,52 @@ public sealed class StoryIntelligenceLocationImportService(
} }
private static bool IsGenericLocation(string name) private static bool IsGenericLocation(string name)
=> GenericLocations.Contains(Normalise(name)) || InternalLocationWords.Contains(Normalise(name)); {
var normalised = Normalise(name);
var withoutParentheses = Normalise(Regex.Replace(name, @"\s*\([^)]*\)\s*$", string.Empty));
return GenericLocations.Contains(normalised)
|| GenericLocations.Contains(withoutParentheses)
|| InternalLocationWords.Contains(normalised)
|| InternalLocationWords.Contains(withoutParentheses);
}
private static bool IsGenericComposite(string name)
{
var value = Normalise(name);
if (!value.Contains('/') && !value.Contains(" / "))
{
return false;
}
return value.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.All(part => GenericLocations.Contains(part) || InternalLocationWords.Contains(part) || NamedRoadPattern.IsMatch(part) == false);
}
private static bool ContainsThrowawayFragment(string name)
{
var value = Normalise(name);
return ThrowawayLocationFragments.Any(fragment => value.Contains(fragment, StringComparison.OrdinalIgnoreCase));
}
private static bool IsNamedLocation(string name) private static bool IsNamedLocation(string name)
=> AddressPattern.IsMatch(name) {
var withoutParentheses = Regex.Replace(name, @"\s*\([^)]*\)\s*$", string.Empty);
return AddressPattern.IsMatch(withoutParentheses)
|| IsNamedRoad(name) || IsNamedRoad(name)
|| name.Any(char.IsUpper) || withoutParentheses.Any(char.IsUpper)
|| name.Contains('\'') || withoutParentheses.Contains('\'')
|| name.Contains(''); || withoutParentheses.Contains('');
}
private static bool IsNamedRoad(string name) private static bool IsNamedRoad(string name)
=> NamedRoadPattern.IsMatch(name) && !GenericLocations.Contains(Normalise(name)); {
var withoutParentheses = Regex.Replace(name, @"\s*\([^)]*\)\s*$", string.Empty);
return !name.Contains('/')
&& withoutParentheses.Any(char.IsUpper)
&& NamedRoadPattern.IsMatch(withoutParentheses)
&& !GenericLocations.Contains(Normalise(name))
&& !GenericLocations.Contains(Normalise(withoutParentheses));
}
private static string Categorise(string name, string? locationType, string? genericRoomType) private static string Categorise(string name, string? locationType, string? genericRoomType)
{ {

View File

@ -0,0 +1,53 @@
@model StoryIntelligenceLocationImportResultViewModel
@{
ViewData["Title"] = "Locations created";
}
<section class="onboarding-shell" aria-labelledby="story-location-complete-title">
<div class="onboarding-panel onboarding-review-panel">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Create Locations" })" />
<div class="onboarding-copy">
<p class="eyebrow">Build Story Database</p>
<div class="onboarding-success-heading">
<span aria-hidden="true">&check;</span>
<h1 id="story-location-complete-title">Locations created</h1>
</div>
<p>PlotDirector updated your story database from the approved location decisions. Future Story Intelligence stages will continue from here.</p>
</div>
<div class="onboarding-scan-counts onboarding-review-counts">
<div>
<span>Locations created</span>
<strong>@Model.LocationsCreated.ToString("N0")</strong>
</div>
<div>
<span>Locations linked</span>
<strong>@Model.LocationsLinked.ToString("N0")</strong>
</div>
<div>
<span>Aliases added</span>
<strong>@Model.LocationAliasesAdded.ToString("N0")</strong>
</div>
<div>
<span>Locations ignored</span>
<strong>@Model.LocationsIgnored.ToString("N0")</strong>
</div>
<div>
<span>Scene World panels updated</span>
<strong>@Model.SceneLocationsUpdated.ToString("N0")</strong>
</div>
</div>
<div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Assets</strong><span>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 Continuity</strong><span>Coming Soon</span></article>
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Back to locations</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a>
</div>
</div>
</section>

View File

@ -0,0 +1,225 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Review locations";
}
<section class="onboarding-shell" aria-labelledby="story-location-title">
<div class="onboarding-panel onboarding-review-panel story-review-page">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Locations" })" />
<div class="onboarding-copy">
<p class="eyebrow">Review Story Intelligence</p>
<h1 id="story-location-title">Review Locations</h1>
<p>Approve the places PlotDirector should create or link from your imported scenes. Nothing is created unless you approve it.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
{
<div class="alert alert-danger">@error</div>
}
@if (TempData["OnboardingStoryIntelligenceMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
<partial name="_StoryIntelligencePipelineSummary" model="Model.PipelineDashboard" />
<section class="story-review-chapter-list" aria-label="Location review">
@if (Model.LocationReview.Candidates.Count == 0)
{
<div class="story-review-note">
<strong>No location decisions are waiting.</strong>
<p class="mb-0">Locations are already up to date, or there were no location suggestions ready to import.</p>
</div>
<div class="story-future-stage-grid">
@FutureStage("Review Assets")
@FutureStage("Review Relationships")
@FutureStage("Review Knowledge")
@FutureStage("Review Continuity")
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID">Back to characters</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a>
</div>
}
else
{
<div class="story-review-card-actions">
<button class="btn btn-outline-primary btn-sm" type="button" data-location-bulk="approve-all">Approve all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="approve-selected">Approve selected</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="ignore-selected">Ignore selected</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="expand">Expand all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button>
</div>
<form asp-action="ImportStoryIntelligenceLocations" method="post" data-location-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<div class="story-character-card-grid">
@for (var i = 0; i < Model.LocationReview.Candidates.Count; i++)
{
var candidate = Model.LocationReview.Candidates[i];
<details class="story-character-card" open data-location-card>
<summary>
<input type="checkbox" checked data-location-selected aria-label="Select @candidate.LocationName" />
<span>
<strong>@candidate.LocationName</strong>
<small>@candidate.Category · @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
</span>
<em>@candidate.Confidence</em>
</summary>
<input type="hidden" name="Locations[@i].Key" value="@candidate.Key" />
<div class="story-character-card__body" data-location-key="@candidate.Key" data-location-name="@candidate.LocationName">
<dl>
<div><dt>First appearance</dt><dd>@Display(candidate.ExampleFirstAppearance)</dd></div>
<div><dt>Parent hint</dt><dd>@Display(candidate.ParentLocationHint)</dd></div>
<div><dt>Existing match</dt><dd>@(candidate.ExistingLocationName ?? "None")</dd></div>
</dl>
@if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
{
<div class="story-review-note">
<strong>Scene context</strong>
<p>@candidate.ExampleContext</p>
</div>
}
@if (candidate.IsExistingMatch)
{
<div class="story-review-note">
<strong>Possible existing location</strong>
<p>@candidate.LocationName may already be @candidate.ExistingLocationName. Choose whether to link them or create a separate location.</p>
</div>
}
<fieldset class="story-character-actions">
<legend>Decision</legend>
<label>
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.Approve" checked data-location-action />
@(candidate.IsExistingMatch ? "Link to existing" : "Create new location")
</label>
@if (candidate.IsExistingMatch)
{
<label>
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.CreateSeparate" data-location-action />
Create separate location
</label>
}
<label>
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.Alias" data-location-action />
Alias/variant of another location
</label>
<label>
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.Ignore" data-location-action />
Ignore
</label>
</fieldset>
<div data-location-import-name-panel>
<label class="form-label" for="location-import-name-@i">Import name</label>
<input id="location-import-name-@i" class="form-control" name="Locations[@i].ImportName" value="@candidate.ImportName" />
</div>
<div data-location-alias-panel hidden>
<label class="form-label" for="location-alias-target-@i">Alias/variant target</label>
<select id="location-alias-target-@i" class="form-select" name="Locations[@i].AliasTargetKey" data-location-alias-target>
<option value="">Choose location...</option>
@foreach (var target in Model.LocationReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
{
<option value="@target.Key">@target.LocationName@(target.IsExistingMatch ? $" -> {target.ExistingLocationName}" : string.Empty)</option>
}
</select>
</div>
</div>
</details>
}
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID">Back to characters</a>
<button class="btn btn-primary" type="submit">Create locations</button>
</div>
</form>
}
</section>
</div>
</section>
@section Scripts {
<script>
(() => {
const form = document.querySelector("[data-location-review-form]");
if (!form) return;
const cards = () => Array.from(form.querySelectorAll("[data-location-card]"));
const setAction = (card, action) => {
const input = card.querySelector(`[data-location-action][value="${action}"]`);
if (input) {
input.checked = true;
updateCard(card);
}
};
const actionFor = (card) => card.querySelector("[data-location-action]:checked")?.value || "@StoryIntelligenceLocationImportActions.Approve";
const isCanonicalTarget = (card) => {
const action = actionFor(card);
return action !== "@StoryIntelligenceLocationImportActions.Ignore" && action !== "@StoryIntelligenceLocationImportActions.Alias";
};
const updateAliasTargets = () => {
const allCards = cards();
for (const card of allCards) {
const body = card.querySelector("[data-location-key]");
const ownKey = body?.getAttribute("data-location-key") || "";
const select = card.querySelector("[data-location-alias-target]");
if (!select) continue;
const previous = select.value;
for (const option of Array.from(select.options)) {
if (!option.value) continue;
const targetCard = allCards.find(item => item.querySelector("[data-location-key]")?.getAttribute("data-location-key") === option.value);
option.disabled = option.value === ownKey || !targetCard || !isCanonicalTarget(targetCard);
}
if (select.selectedOptions[0]?.disabled) {
select.value = "";
} else {
select.value = previous;
}
}
};
const updateCard = (card) => {
const action = actionFor(card);
const namePanel = card.querySelector("[data-location-import-name-panel]");
const aliasPanel = card.querySelector("[data-location-alias-panel]");
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.Alias";
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceLocationImportActions.Alias";
updateAliasTargets();
};
document.querySelectorAll("[data-location-bulk]").forEach(button => {
button.addEventListener("click", () => {
const action = button.getAttribute("data-location-bulk");
const selectedCards = cards().filter(card => card.querySelector("[data-location-selected]")?.checked);
if (action === "approve-all") cards().forEach(card => setAction(card, "@StoryIntelligenceLocationImportActions.Approve"));
if (action === "approve-selected") selectedCards.forEach(card => setAction(card, "@StoryIntelligenceLocationImportActions.Approve"));
if (action === "ignore-selected") selectedCards.forEach(card => setAction(card, "@StoryIntelligenceLocationImportActions.Ignore"));
if (action === "expand") cards().forEach(card => card.open = true);
if (action === "collapse") cards().forEach(card => card.open = false);
});
});
form.querySelectorAll("[data-location-action]").forEach(input => {
input.addEventListener("change", () => updateCard(input.closest("[data-location-card]")));
});
cards().forEach(updateCard);
form.addEventListener("submit", () => {
cards()
.filter(card => !card.querySelector("[data-location-selected]")?.checked)
.forEach(card => setAction(card, "@StoryIntelligenceLocationImportActions.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>");
}