Add progressive evidence cards to Story Intelligence

This commit is contained in:
Nick Beckley 2026-07-26 11:44:03 +00:00
parent f02569b7d4
commit d7fe153816
4 changed files with 283 additions and 13 deletions

View File

@ -1108,6 +1108,10 @@ static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules()
Assert(onboarding.Contains("name=\"primaryAgeGroup\"", StringComparison.Ordinal), "Import setup must ask for primary age group.");
Assert(onboarding.Contains("name=\"storyEra\"", StringComparison.Ordinal), "Import setup must ask for era.");
Assert(onboarding.Contains("name=\"storyLocation\"", StringComparison.Ordinal), "Import setup must ask for location.");
Assert(onboarding.Contains("<select class=\"form-control\" name=\"appearancePreference\"", StringComparison.Ordinal), "Appearance guidance must be a dropdown, not a radio list.");
Assert(onboarding.Contains("<select class=\"form-control\" name=\"primaryAgeGroup\"", StringComparison.Ordinal), "Primary age group must be a dropdown.");
Assert(onboarding.Contains("<select class=\"form-control\" name=\"storyEra\"", StringComparison.Ordinal), "Story era must be a dropdown.");
Assert(onboarding.Contains("<select class=\"form-control\" name=\"storyLocation\"", StringComparison.Ordinal), "Story location must be a dropdown.");
Assert(storyMemory.Contains("CharacterAppearanceExtractionService.Extract", StringComparison.Ordinal), "Durable Story Memory must consume dedicated appearance extraction.");
Assert(storyMemory.Contains("SkinToneInstruction(skinTone)", StringComparison.Ordinal), "Demand generation must use hard skin-tone wording.");
Assert(storyMemory.Contains("HairColours.Red", StringComparison.Ordinal) && storyMemory.Contains("SkinTones.Light", StringComparison.Ordinal), "Red-hair demand evidence must force light skin when skin is otherwise unknown.");
@ -1145,9 +1149,13 @@ static void Phase21RSceneBrowserAndPanelsAreReplayFriendly()
Assert(script.Contains("if (!realMode || !snapshotUrl || state.terminal) return;", StringComparison.Ordinal), "Replay must not poll live snapshot endpoints.");
Assert(css.Contains(".story-exp-replay-browser", StringComparison.Ordinal), "Replay browser styling was not found.");
Assert(css.Contains("scrollbar-width: none", StringComparison.Ordinal), "Side-panel native scrollbar tracks should be suppressed.");
Assert(css.Contains(".story-exp-rebuild {\n position: fixed;", StringComparison.Ordinal), "Replay rebuild controls should be fixed so they do not resize the whole screen.");
Assert(css.Contains(".story-exp-panel:has(.story-exp-insight-list:empty)", StringComparison.Ordinal), "Empty side panels should collapse instead of reserving large blank space.");
Assert(!css.Contains("grid-template-rows: minmax(218px, 1.35fr) minmax(185px, 1.08fr) auto", StringComparison.Ordinal), "Right rail must not use fixed-height panel tracks.");
Assert(!css.Contains("grid-template-rows: auto auto minmax(170px, 1fr) minmax(96px, 0.56fr)", StringComparison.Ordinal), "Left rail must not use fixed-height panel tracks.");
Assert(script.Contains("shouldUseCharacterPortrait", StringComparison.Ordinal), "Characters should use evidence cards until portrait evidence is strong enough.");
Assert(script.Contains("shouldUseAssetImage", StringComparison.Ordinal), "Assets should use text cards until an object-specific image is confident enough.");
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.");
}

View File

@ -84,27 +84,61 @@
<fieldset>
<legend>Unresolved character appearance</legend>
<p>Explicit manuscript evidence always wins. This only guides characters whose appearance is not described.</p>
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.ExplicitEvidenceOnly" /> Use explicit manuscript evidence only</label>
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.Balanced" checked /> Balanced diverse cast</label>
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.PredominantlyLight" /> Predominantly light-skinned cast</label>
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.PredominantlyMedium" /> Predominantly medium-skinned cast</label>
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.PredominantlyDark" /> Predominantly dark-skinned cast</label>
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.CustomMix" /> Custom mix</label>
<label>
Appearance baseline
<select class="form-control" name="appearancePreference">
<option value="@StoryMemoryAppearancePreferences.Balanced">Balanced diverse cast</option>
<option value="@StoryMemoryAppearancePreferences.ExplicitEvidenceOnly">Explicit evidence only</option>
<option value="@StoryMemoryAppearancePreferences.PredominantlyLight">Predominantly light-skinned cast</option>
<option value="@StoryMemoryAppearancePreferences.PredominantlyMedium">Predominantly medium-skinned cast</option>
<option value="@StoryMemoryAppearancePreferences.PredominantlyDark">Predominantly dark-skinned cast</option>
<option value="@StoryMemoryAppearancePreferences.CustomMix">Custom mix</option>
</select>
</label>
</fieldset>
<fieldset>
<legend>Story context for generated portraits</legend>
<p>These guide generated images when the manuscript is silent. Explicit manuscript evidence still wins.</p>
<label>
Primary character age group
<input class="form-control" type="text" name="primaryAgeGroup" placeholder="Late teen / early 20s" />
<select class="form-control" name="primaryAgeGroup">
<option value="">No baseline</option>
<option value="Late teen / early 20s">Late teen / early 20s</option>
<option value="Young teen">Young teen</option>
<option value="Older teen">Older teen</option>
<option value="Young adult">Young adult</option>
<option value="Adult">Adult</option>
<option value="Middle-aged adult">Middle-aged adult</option>
<option value="Senior">Senior</option>
</select>
</label>
<label>
Era
<input class="form-control" type="text" name="storyEra" placeholder="1980s" />
<select class="form-control" name="storyEra">
<option value="">No baseline</option>
<option value="1980s">1980s</option>
<option value="1970s">1970s</option>
<option value="1990s">1990s</option>
<option value="2000s">2000s</option>
<option value="Contemporary">Contemporary</option>
<option value="Victorian">Victorian</option>
<option value="Medieval">Medieval</option>
<option value="Near future">Near future</option>
</select>
</label>
<label>
Location
<input class="form-control" type="text" name="storyLocation" placeholder="UK" />
<select class="form-control" name="storyLocation">
<option value="">No baseline</option>
<option value="UK">UK</option>
<option value="United States">United States</option>
<option value="Europe">Europe</option>
<option value="Urban">Urban</option>
<option value="Suburban">Suburban</option>
<option value="Rural">Rural</option>
<option value="Fantasy world">Fantasy world</option>
<option value="Science fiction setting">Science fiction setting</option>
</select>
</label>
</fieldset>
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>

View File

@ -287,16 +287,26 @@ input:focus-visible {
}
.story-exp-rebuild {
position: fixed;
left: 50%;
top: 66px;
z-index: 18;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 9px;
margin: 0 18px 10px;
width: clamp(320px, calc(100vw - 520px), 720px);
min-height: 42px;
max-height: 74px;
margin: 0;
border: 1px solid rgba(151, 185, 218, 0.12);
border-radius: 999px;
padding: 8px 10px;
background: rgba(4, 12, 24, 0.74);
box-shadow: 0 16px 44px rgba(0, 0, 0, 0.22);
transform: translateX(-50%);
overflow: hidden;
}
.story-exp-rebuild label {
@ -307,6 +317,7 @@ input:focus-visible {
font-size: 0.76rem;
font-weight: 800;
text-transform: uppercase;
white-space: nowrap;
}
.story-exp-rebuild select,
@ -319,7 +330,7 @@ input:focus-visible {
}
.story-exp-rebuild select {
max-width: min(280px, 40vw);
max-width: min(240px, 24vw);
padding: 6px 22px 6px 9px;
}
@ -886,6 +897,66 @@ input:focus-visible {
opacity: 0.9;
}
.story-character.is-evidence-card {
width: min(172px, calc(var(--node-size, 150px) + 44px));
height: auto;
}
.story-character.is-evidence-card .story-character__image {
display: none;
}
.story-character.is-evidence-card .story-character__text {
position: relative;
top: 0;
left: 0;
width: min(172px, calc(var(--node-size, 150px) + 44px));
min-height: 86px;
justify-items: start;
gap: 5px;
border-color: rgba(169, 205, 240, 0.22);
border-radius: 12px;
padding: 10px 11px;
background:
linear-gradient(180deg, rgba(16, 34, 58, 0.94), rgba(5, 13, 25, 0.9));
text-align: left;
transform: none;
}
.story-character.is-evidence-card.is-pov .story-character__text {
border-color: rgba(215, 168, 93, 0.68);
box-shadow: 0 0 0 5px rgba(215, 168, 93, 0.08), 0 24px 56px rgba(0, 0, 0, 0.3);
}
.story-character.is-evidence-card .story-character__text strong,
.story-character.is-evidence-card .story-character__text span {
text-align: left;
}
.story-entity-facts {
display: flex;
flex-wrap: wrap;
gap: 4px;
max-width: 100%;
}
.story-entity-facts span {
display: inline-flex;
max-width: 100%;
border: 1px solid rgba(151, 185, 218, 0.12);
border-radius: 999px;
padding: 2px 6px;
background: rgba(255, 255, 255, 0.045);
color: var(--story-soft);
font-size: 0.61rem;
font-weight: 800;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
text-transform: none;
white-space: nowrap;
}
.story-location {
position: absolute;
display: grid;
@ -994,6 +1065,30 @@ input:focus-visible {
text-align: center;
}
.story-asset.is-evidence-card {
width: 132px;
}
.story-asset.is-evidence-card .story-asset__image {
display: none;
}
.story-asset.is-evidence-card .story-asset__text {
justify-items: start;
width: 132px;
min-height: 64px;
border-color: rgba(215, 168, 93, 0.18);
padding: 8px 9px;
background:
linear-gradient(180deg, rgba(34, 29, 18, 0.88), rgba(7, 13, 21, 0.84));
text-align: left;
}
.story-asset.is-evidence-card .story-asset__text strong,
.story-asset.is-evidence-card .story-asset__text span {
text-align: left;
}
.story-asset strong,
.story-asset span {
display: block;
@ -1013,6 +1108,15 @@ input:focus-visible {
font-size: 0.64rem;
}
.story-asset .story-entity-facts span {
display: inline-flex;
width: auto;
max-width: 100%;
border-radius: 999px;
line-height: 1.2;
text-overflow: ellipsis;
}
.story-exp-ribbon-wrap {
display: grid;
grid-template-rows: auto minmax(104px, auto) 56px;

View File

@ -231,6 +231,13 @@
function setEntityImage(image, url, resolution, altText) {
if (!image) return;
image.alt = altText;
if (!url) {
image.removeAttribute("src");
image.dataset.fallbackUrl = "";
image.dataset.entityId = resolution?.entityId || "";
image.dataset.libraryCode = resolution?.stableCode || "";
return;
}
const fallbackUrl = resolution?.fallbackImageUrl || url;
const nextUrl = failedImageUrls.has(url) ? fallbackUrl : url;
image.dataset.fallbackUrl = fallbackUrl || "";
@ -249,6 +256,115 @@
}
}
function shouldUseCharacterPortrait(character) {
const resolution = character?.imageResolution || {};
if (resolution.fallbackUsed || !resolution.finalImageUrl) return false;
const facts = characterVisualFacts(character);
if (facts.length < 2) return false;
const confidence = Number(resolution.assignmentConfidence || 0);
const score = Number(resolution.matchingScore || 0);
return confidence >= 0.78 || score >= 82;
}
function shouldUseAssetImage(asset) {
const resolution = asset?.imageResolution || {};
if (resolution.fallbackUsed || !resolution.finalImageUrl) return false;
const confidence = Number(resolution.assignmentConfidence || 0);
const score = Number(resolution.matchingScore || 0);
const code = String(resolution.stableCode || asset?.libraryCode || "").toLowerCase();
if (!code || code.includes("generic") || code.includes("unknown")) return false;
return confidence >= 0.86 || score >= 90;
}
function renderFactChips(container, facts) {
if (!container) return;
container.replaceChildren(...facts.slice(0, 4).map((fact) => {
const chip = document.createElement("span");
chip.textContent = fact;
return chip;
}));
}
function characterFacts(character, isPov) {
const facts = [];
if (isPov) facts.push("POV");
facts.push(...characterVisualFacts(character));
const role = conciseCharacterLabel(character, isPov);
if (role && !facts.some((fact) => sameFact(fact, role))) facts.push(role);
if (!facts.length) facts.push(character.weight >= 70 ? "significant" : "identified");
if (!shouldUseCharacterPortrait(character)) facts.push("appearance pending");
return uniqueFacts(facts);
}
function characterVisualFacts(character) {
const resolution = character?.imageResolution || {};
const evidence = parseEvidence(resolution.currentEvidence);
const facts = [
displayEvidenceValue(evidence.age || resolution.resolvedAgeBand),
displayEvidenceValue(evidence.presentation || resolution.resolvedPresentation),
displayHairFact(evidence.hair, evidence.hairLength),
displaySkinFact(evidence.skin)
];
return uniqueFacts(facts.filter(Boolean));
}
function assetFacts(asset) {
const facts = [];
const label = conciseAssetLabel(asset);
if (label) facts.push(label);
if (!shouldUseAssetImage(asset)) facts.push("text card");
return uniqueFacts(facts);
}
function parseEvidence(value) {
const result = {};
String(value || "").split(";").forEach((part) => {
const [key, raw] = part.split("=");
if (!key || raw === undefined) return;
result[key.trim().toLowerCase()] = raw.trim();
});
return result;
}
function displayEvidenceValue(value) {
const clean = String(value || "").trim();
if (!clean || clean.toLowerCase() === "unknown") return "";
return clean
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\bOlder Teen\b/i, "late teen")
.replace(/\bYoung Adult\b/i, "young adult")
.toLowerCase();
}
function displayHairFact(colour, length) {
const hairColour = displayEvidenceValue(colour);
if (!hairColour) return "";
const hairLength = displayEvidenceValue(length);
return `${hairLength && hairLength !== "medium" ? `${hairLength} ` : ""}${hairColour} hair`;
}
function displaySkinFact(value) {
const skin = displayEvidenceValue(value);
return skin ? `${skin} skin` : "";
}
function uniqueFacts(facts) {
const seen = new Set();
return facts
.map((fact) => String(fact || "").trim())
.filter((fact) => {
if (!fact) return false;
const key = fact.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function sameFact(a, b) {
return String(a || "").trim().toLowerCase() === String(b || "").trim().toLowerCase();
}
function preloadSceneImages(scene) {
if (!scene) return;
collectSceneImages(scene).forEach((url) => {
@ -319,10 +435,13 @@
node.style.setProperty("--opacity", String(position.opacity));
node.style.setProperty("--z", String(position.z));
node.dataset.layoutRole = position.layoutRole;
setEntityImage(node.querySelector("img"), character.imagePath, character.imageResolution, `${character.name} prototype portrait`);
const portraitReady = shouldUseCharacterPortrait(character);
node.classList.toggle("is-evidence-card", !portraitReady);
setEntityImage(node.querySelector("img"), portraitReady ? character.imagePath : "", character.imageResolution, `${character.name} prototype portrait`);
text(node.querySelector("[data-character-name]"), character.name);
const label = conciseCharacterLabel(character, character.id === scene.povCharacterId);
text(node.querySelector("[data-character-role]"), label);
renderFactChips(node.querySelector("[data-character-facts]"), characterFacts(character, character.id === scene.povCharacterId));
node.title = `${character.name}: ${character.role}${character.relevance ? ` - ${character.relevance}` : ""}`;
});
@ -389,9 +508,12 @@
node.style.setProperty("--scale", String(position.scale));
node.style.setProperty("--z", String(position.z));
node.style.setProperty("--opacity", "1");
setEntityImage(node.querySelector("img"), asset.imagePath, asset.imageResolution, `${asset.name} prototype asset`);
const assetImageReady = shouldUseAssetImage(asset);
node.classList.toggle("is-evidence-card", !assetImageReady);
setEntityImage(node.querySelector("img"), assetImageReady ? asset.imagePath : "", asset.imageResolution, `${asset.name} prototype asset`);
text(node.querySelector("[data-asset-name]"), asset.name);
text(node.querySelector("[data-asset-label]"), conciseAssetLabel(asset));
renderFactChips(node.querySelector("[data-asset-facts]"), assetFacts(asset));
node.title = `${asset.name}: ${asset.label}`;
});
@ -708,6 +830,7 @@
<div class="story-character__text">
<strong data-character-name></strong>
<span data-character-role></span>
<div class="story-entity-facts" data-character-facts></div>
</div>`;
return node;
}
@ -721,6 +844,7 @@
<div class="story-asset__text">
<strong data-asset-name></strong>
<span data-asset-label></span>
<div class="story-entity-facts" data-asset-facts></div>
</div>`;
return node;
}