Improve scan coverage and visual matching

This commit is contained in:
Nick Beckley 2026-08-17 11:06:04 +00:00
parent c96c20d18b
commit f6b4263f31
7 changed files with 54 additions and 16 deletions

View File

@ -65,6 +65,7 @@ var tests = new (string Name, Action Test)[]
("Phase 21U enforces alias age semantic and UI reset rules", Phase21UEnforcesAliasAgeSemanticAndUiResetRules),
("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific),
("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable),
("Character discovery does not truncate scan review candidates", CharacterDiscoveryDoesNotTruncateScanReviewCandidates),
("Scan review aliases feed Story Intelligence known character context", ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext),
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
@ -535,7 +536,9 @@ static void IllustrationMatchingRejectsDuplicateNamedPortraitAllocation()
static void IllustrationSemanticTypesPreventObviousMismatches()
{
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bathroom at Beth's house") == "Bathroom", "Bathroom should classify as Bathroom.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("ladies toilets in the pub") == "PublicToilet", "Public toilets must not classify as domestic bathrooms.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Bathroom", "Office"), "Bathroom must reject office imagery.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("PublicToilet", "Bathroom"), "Pub/bar toilets must not use domestic bathroom imagery.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Kitchen", "HouseExterior"), "Kitchen must reject house exterior imagery.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationCompatible("Kitchen", "FlatInterior"), "Kitchen may use generic interior fallback.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("ambulance") == "Ambulance", "Ambulance should classify as Ambulance.");
@ -861,6 +864,22 @@ static void CharacterSexHintsAreConservativeAndReviewable()
Assert(CharacterSexHintService.Normalise("Female", "Alex") == CharacterSexHintService.Female, "Reviewed sex hints should override the name heuristic.");
}
static void CharacterDiscoveryDoesNotTruncateScanReviewCandidates()
{
var method = typeof(WordCompanionService).GetMethod("DiscoverCharacterCandidates", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "Character discovery method could not be found.");
var builder = new StringBuilder();
for (var index = 0; index < 60; index++)
{
var name = $"Person{(char)('a' + index / 26)}{(char)('a' + index % 26)}";
builder.Append(name).Append(" said hello. ").Append(name).Append(" smiled. ");
}
var candidates = (IReadOnlyList<WordCompanionCharacterCandidateDto>)method!.Invoke(null, [builder.ToString(), false])!;
Assert(candidates.Count >= 60, $"Character discovery should not truncate the scan review to 50 candidates; got {candidates.Count}.");
}
static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()
{
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
@ -962,7 +981,7 @@ static void Phase21QViewKeepsPanelsBoundedAndRealControlsHidden()
Assert(!view.Contains("data-scene-title", StringComparison.Ordinal), "Redundant scene heading should be removed from the real visualisation.");
Assert(view.IndexOf("data-knowledge", StringComparison.Ordinal) < view.IndexOf("data-relationships", StringComparison.Ordinal), "Knowledge threads should be prioritised above relationship changes.");
Assert(view.Contains("Model.Mode == \"simulation\" || Model.Mode == \"replay\"", StringComparison.Ordinal), "Prototype controls must remain hidden in live mode while supporting replay.");
Assert(script.Contains("relationships.slice(0, 1)", StringComparison.Ordinal), "Relationship changes should be capped to the most relevant visible card.");
Assert(script.Contains("relationships.slice(0, 3)", StringComparison.Ordinal), "Relationship changes should show several visible cards without overflowing the panel.");
Assert(!script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Side panels must not hide readable content behind '+N more' overflow controls.");
Assert(css.Contains("max-height: calc(100vh - 126px)", StringComparison.Ordinal), "Side columns should be bounded to the viewport.");
Assert(css.Contains("overflow-y: auto", StringComparison.Ordinal), "Side columns should keep a single hidden-track vertical scroll.");
@ -1136,10 +1155,11 @@ static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules()
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane should be Lane.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Mount Pleasant Road") == "Road", "Mount Pleasant Road should be Road.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationCompatible("Street", "Road"), "Side streets should be able to reuse road artwork while demand-specific street art is pending.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "Street"), "Named roads should be able to reuse street artwork when it is the closest generated road-family image.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Street", "Road"), "Named streets should wait for street-specific imagery rather than reuse generic road art.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "Street"), "Named roads should wait for road-specific imagery rather than reuse street art.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("car interior") == "VehicleInterior", "Car interior should be VehicleInterior.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bathroom") == "Bathroom", "Bathroom should be Bathroom.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("ladies toilets in the Rose and Crown pub") == "PublicToilet", "Pub toilets should be PublicToilet.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("waiting room") == "WaitingRoom", "Waiting room should be WaitingRoom.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("VehicleInterior", "HouseExterior"), "VehicleInterior must not use building art.");
@ -1250,6 +1270,8 @@ static void Phase21RSceneBrowserAndPanelsAreReplayFriendly()
Assert(script.Contains("appearance pending", StringComparison.Ordinal), "Character evidence cards should expose unresolved appearance state.");
Assert(!css.Contains(".story-exp-insight-list {\n display: grid;\n gap: 9px;\n min-height: 0;\n overflow: auto", StringComparison.Ordinal), "Nested insight lists should not create native scrollbars.");
Assert(progressViewModel.Contains("public int? ImportSessionID", StringComparison.Ordinal), "Import progress page should carry the import session for replay links.");
Assert(script.Contains(".filter((relationship) => relationship.weight >= 54)", StringComparison.Ordinal), "Relationship links should render moderate-confidence relationship evidence.");
Assert(script.Contains(".slice(0, 5)", StringComparison.Ordinal), "Relationship drawing should not be limited to two lines.");
}
static void StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific()
@ -1273,6 +1295,7 @@ static void StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific()
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Driving Test Centre waiting room") == "DrivingTestCentre", "Driving test centre should have its own location family.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road kitchen") == "Kitchen", "Functional place context should beat a nearby road name.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road car park") == "CarPark", "Car park context should beat a nearby road name.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("ladies toilets in a bar") == "PublicToilet", "Bar toilets should be PublicToilet.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("VehicleInterior", "HouseExterior"), "Vehicle interiors must not resolve to houses or buildings.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("passport") == "Passport", "Passport should not be Generic Object.");
@ -1284,6 +1307,7 @@ static void StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific()
Assert(snapshot.Contains("type is \"Road\" or \"Lane\" or \"Street\"", StringComparison.Ordinal), "Road-family scene locations need a generic road fallback illustration.");
Assert(snapshot.Contains("\"loc-vehicle-interior\" => $\"{FallbackRoot}/location-vehicle-interior.svg\"", StringComparison.Ordinal), "Vehicle interiors need a non-building fallback illustration.");
Assert(snapshot.Contains("\"loc-generic-public-toilet\" => string.Empty", StringComparison.Ordinal), "Public toilets must not reuse domestic bathroom fallback art.");
Assert(snapshot.Contains("\"asset-document\" => $\"{FallbackRoot}/asset-document.svg\"", StringComparison.Ordinal), "Documents need a recognisable document fallback.");
Assert(snapshot.Contains("\"asset-traffic-sign\" => $\"{FallbackRoot}/asset-traffic-sign.svg\"", StringComparison.Ordinal), "Traffic signs need a recognisable fallback.");
}

View File

@ -281,7 +281,9 @@ public static class StoryIntelligenceIllustrationCompatibility
if (ContainsAny(text, "passenger seat")) return "PassengerSeat";
if (ContainsAny(text, "rear seat", "back seat")) return "RearSeat";
if (ContainsAny(text, "car interior", "vehicle interior", "inside the car", "inside car", "boot", "cabin", "dashboard")) return "VehicleInterior";
if (ContainsAny(text, "bathroom", "toilet", "washroom")) return "Bathroom";
if (ContainsAny(text, "ladies toilet", "ladies toilets", "gents toilet", "gents toilets", "public toilet", "public toilets", "pub toilet", "pub toilets", "bar toilet", "bar toilets", "toilet cubicle", "toilet cubicles", "washroom in the pub", "washroom in the bar")) return "PublicToilet";
if (ContainsAny(text, "bathroom")) return "Bathroom";
if (ContainsAny(text, "toilet", "washroom")) return "PublicToilet";
if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen";
if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom";
if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom";
@ -321,7 +323,9 @@ public static class StoryIntelligenceIllustrationCompatibility
if (ContainsAny(text, "passenger seat")) return "PassengerSeat";
if (ContainsAny(text, "rear seat", "back seat")) return "RearSeat";
if (ContainsAny(text, "car interior", "vehicle interior", "inside the car", "inside car", "boot", "cabin", "dashboard")) return "VehicleInterior";
if (ContainsAny(text, "bathroom", "toilet", "washroom")) return "Bathroom";
if (ContainsAny(text, "ladies toilet", "ladies toilets", "gents toilet", "gents toilets", "public toilet", "public toilets", "pub toilet", "pub toilets", "bar toilet", "bar toilets", "toilet cubicle", "toilet cubicles", "washroom in the pub", "washroom in the bar")) return "PublicToilet";
if (ContainsAny(text, "bathroom")) return "Bathroom";
if (ContainsAny(text, "toilet", "washroom")) return "PublicToilet";
if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen";
if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom";
if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom";
@ -429,7 +433,6 @@ public static class StoryIntelligenceIllustrationCompatibility
public static bool LocationCompatible(string requestedType, string candidateType)
=> requestedType == candidateType
|| (requestedType is "Road" or "Lane" or "Street" or "Avenue" or "Drive" or "Close" && candidateType is "Road" or "Lane" or "Street")
|| (requestedType is "DriverSeat" or "PassengerSeat" or "RearSeat" && candidateType == "VehicleInterior")
|| (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior")
|| (requestedType is "Kitchen" or "LivingRoom" or "Bedroom" && candidateType is "FlatInterior")

View File

@ -620,6 +620,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
private static IllustrationGenerationSpecification LocationDemandSpecification(string code, string demandKey, string type)
{
var exteriorTransport = type is "Road" or "Lane" or "Street" or "Avenue" or "Drive" or "Close" or "DualCarriageway" or "Motorway" or "Bridge" or "Flyover" or "Underpass";
var publicToilet = type is "PublicToilet";
var readableType = ReadableToken(type).ToLowerInvariant();
return new()
{
@ -629,9 +630,13 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
Description = "Demand-created reusable location illustration for Story Intelligence matching.",
VisualSubject = exteriorTransport
? $"A clear reusable exterior {readableType} road environment suitable for manuscript visualisation, with visible road surface, kerbs or verges, exterior daylight or street lighting, and no indoor corridor, hallway, doors-lined passage or building interior."
: publicToilet
? "A clear reusable public toilet or pub/bar washroom interior with cubicles or sinks, commercial fixtures, practical lighting, no domestic bath, no home bathroom styling."
: $"A clear reusable {readableType} environment suitable for manuscript visualisation.",
Composition = exteriorTransport
? "single readable exterior transport environment, road type obvious at thumbnail size, no prominent people, no readable text, not an indoor corridor"
: publicToilet
? "single readable public washroom environment, cubicles or sink area obvious at thumbnail size, no bath, no domestic home decor, no prominent people"
: "single readable environment, no prominent people, strong central focal point, suitable for circular and wide crops",
Mood = "story-ready, atmospheric but clear, visually specific rather than generic",
LocationType = type,

View File

@ -635,6 +635,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
if (type is "DrivingTestCentre" or "PoliceStation" or "DerelictHospital" or "Hospital" or "Church" or "Restaurant" or "Pub"
or "Workshop" or "Garage" or "HouseExterior" or "Office" or "School" or "WaitingRoom" or "CarPark" or "Shop"
or "PublicToilet"
or "Kitchen" or "Bathroom" or "Bedroom" or "LivingRoom" or "Hallway" or "Stairwell" or "VehicleInterior"
or "DriverSeat" or "PassengerSeat" or "RearSeat" or "FlatInterior")
{
@ -2274,6 +2275,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
{
var value = $"{name} {location?.LocationType} {location?.GenericRoomType} {setting?.LocationType}".ToLowerInvariant();
var type = StoryIntelligenceIllustrationCompatibility.LocationType(value);
if (type == "PublicToilet") return "loc-generic-public-toilet";
if (type == "Bathroom") return "loc-generic-bathroom";
if (type == "Kitchen") return "loc-generic-kitchen";
if (type == "WaitingRoom") return "loc-generic-waiting-room";
@ -2334,6 +2336,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
private static string LocationFallback(string name)
=> LocationLibraryCode(name, null, null) switch
{
"loc-generic-public-toilet" => string.Empty,
"loc-laundry-utility-room" => $"{FallbackRoot}/location-laundry.svg",
"loc-narrow-stairwell" => $"{FallbackRoot}/location-stairwell.svg",
"loc-wet-car-park" => $"{FallbackRoot}/location-car-park.svg",

View File

@ -1301,6 +1301,7 @@ public sealed class StoryMemoryService(
"Underpass" => $"A clear {setting} road or pedestrian underpass matching '{nameHint}', concrete structure and readable path or carriageway, no prominent people.",
"Bridge" => $"A clear {setting} road bridge scene matching '{nameHint}', bridge structure and readable road context, no prominent people.",
"CarPark" => $"A clear {setting} car park scene matching '{nameHint}', marked bays and parked cars, no prominent people.",
"PublicToilet" => $"A clear {setting} public toilet or pub/bar washroom matching '{nameHint}', cubicles or sinks, commercial fixtures, no domestic bath, no home bathroom styling, no prominent people.",
_ => $"A clear, reusable {SplitWords(locationType).ToLowerInvariant()} environment matching '{nameHint}'."
};
}
@ -1308,7 +1309,9 @@ public sealed class StoryMemoryService(
private static string LocationComposition(string locationType)
=> locationType is "Road" or "Lane" or "Street" or "Motorway" or "Flyover" or "Underpass" or "Bridge"
? "one readable exterior transport environment, road type obvious at thumbnail size, landscape-friendly crop, no text or readable signage"
: "one readable focal environment, no prominent people, cinematic but bright enough for a small interface thumbnail";
: locationType is "PublicToilet"
? "one readable public washroom environment, cubicles or sink area obvious at thumbnail size, no bath, no domestic home decor"
: "one readable focal environment, no prominent people, cinematic but bright enough for a small interface thumbnail";
private static string InferInteriorExterior(string semanticType)
=> semanticType is "Road" or "Lane" or "Street" or "Avenue" or "Drive" or "Close" or "Motorway" or "Bridge" or "Flyover" or "Underpass" or "CarPark" or "Beach" or "Canal" or "HouseExterior" or "StreetFurniture" ? "Exterior" : "Interior";

View File

@ -43,7 +43,8 @@ public sealed class WordCompanionService(
ICurrentUserService currentUser) : IWordCompanionService
{
private const int CharacterDiscoveryMinimumMentions = 2;
private const int CharacterDiscoverySuggestionLimit = 50;
private const int CharacterCreationSelectionLimit = 250;
private const int RuntimeSuggestionSelectionLimit = 50;
private const int CharacterDiscoveryExcludedLimit = 30;
private static readonly Regex CharacterCandidateRegex = new(
@"(?<!['’\p{L}])(?:(?:Mr|Mrs|Ms|Miss|Dr|Rev|Fr|Sir|Lady|Lord|DS|DI|DC|PC|Professor|Aunt|Uncle)\.?\s+)?[A-Z][a-z]+(?:[-'][A-Z][a-z]+)?(?:\s+[A-Z][a-z]+(?:[-'][A-Z][a-z]+)?){0,2}(?=(?:['’]s)?(?!\p{L})|(?!(?:['’]))[^\p{L}]|$)",
@ -295,7 +296,7 @@ public sealed class WordCompanionService(
request.CharacterIds = request.CharacterIds
.Where(id => id > 0)
.Distinct()
.Take(CharacterDiscoverySuggestionLimit)
.Take(RuntimeSuggestionSelectionLimit)
.ToList();
return request.DocumentGuid == Guid.Empty
@ -325,7 +326,7 @@ public sealed class WordCompanionService(
request.AssetIds = request.AssetIds
.Where(id => id > 0)
.Distinct()
.Take(CharacterDiscoverySuggestionLimit)
.Take(RuntimeSuggestionSelectionLimit)
.ToList();
return request.DocumentGuid == Guid.Empty
@ -355,7 +356,7 @@ public sealed class WordCompanionService(
request.LocationIds = request.LocationIds
.Where(id => id > 0)
.Distinct()
.Take(CharacterDiscoverySuggestionLimit)
.Take(RuntimeSuggestionSelectionLimit)
.ToList();
return request.DocumentGuid == Guid.Empty
@ -553,7 +554,7 @@ public sealed class WordCompanionService(
.Select(NormalizeCandidateName)
.Where(name => !string.IsNullOrWhiteSpace(name) && name.Length <= 200)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(CharacterDiscoverySuggestionLimit)
.Take(CharacterCreationSelectionLimit)
.ToList();
return request.CandidateNames.Count == 0
@ -631,7 +632,6 @@ public sealed class WordCompanionService(
.ThenByDescending(item => ConfidenceSortValue(item.Confidence))
.ThenByDescending(item => item.MentionCount)
.ThenBy(item => item.Text, StringComparer.OrdinalIgnoreCase)
.Take(CharacterDiscoverySuggestionLimit)
.ToList();
if (includeExcluded)

View File

@ -501,7 +501,7 @@
detail: relationship.state,
tone: relationship.weight >= 90 ? "confirmed" : relationship.weight >= 70 ? "strengthening" : "emerging"
}));
renderInsights(dom.relationships, relationships.slice(0, 1));
renderInsights(dom.relationships, relationships.slice(0, 3));
}
function updateKnowledgeThreads(scene) {
@ -683,8 +683,8 @@
const occupiedRects = layoutRects(stageRect);
scene.relationships
.filter((relationship) => relationship.weight >= 72)
.slice(0, 2)
.filter((relationship) => relationship.weight >= 54)
.slice(0, 5)
.forEach((relationship, index) => {
const source = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.sourceId)}"]`);
const target = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.targetId)}"]`);