Phase 20AN.1 – Location Intelligence Review and Import
This commit is contained in:
parent
ef51765eb4
commit
4b6719e242
@ -29,7 +29,11 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
"room", "place", "area", "building", "street", "road", "door", "doorway", "wall", "floor",
|
||||
"pavement", "car", "car park", "parking lot", "stairs", "stairwell", "landing", "kitchen",
|
||||
"bathroom", "hallway", "corridor", "lobby", "reception", "desk", "pew", "altar", "outside",
|
||||
"inside", "nearby", "somewhere", "home", "house", "flat");
|
||||
"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(
|
||||
"room", "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "stairwell", "stairs",
|
||||
@ -481,6 +485,16 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
|
||||
private static bool IsVisibleLocationCandidate(LocationCandidate candidate)
|
||||
{
|
||||
if (IsGenericComposite(candidate.DisplayName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ContainsThrowawayFragment(candidate.DisplayName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsNamedLocation(candidate.DisplayName))
|
||||
{
|
||||
return true;
|
||||
@ -488,12 +502,13 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
|
||||
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))
|
||||
{
|
||||
return candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count() >= 2;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -506,17 +521,52 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
}
|
||||
|
||||
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)
|
||||
=> AddressPattern.IsMatch(name)
|
||||
{
|
||||
var withoutParentheses = Regex.Replace(name, @"\s*\([^)]*\)\s*$", string.Empty);
|
||||
return AddressPattern.IsMatch(withoutParentheses)
|
||||
|| IsNamedRoad(name)
|
||||
|| name.Any(char.IsUpper)
|
||||
|| name.Contains('\'')
|
||||
|| name.Contains('’');
|
||||
|| withoutParentheses.Any(char.IsUpper)
|
||||
|| withoutParentheses.Contains('\'')
|
||||
|| withoutParentheses.Contains('’');
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
@ -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">✓</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>
|
||||
225
PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml
Normal file
225
PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml
Normal 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>");
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user