Phase 21G.1 - Fix Live Snapshot Stalling
This commit is contained in:
parent
9989dfec1e
commit
34fdfb77fe
@ -52,15 +52,15 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var results = (await repository.ListSceneResultsAsync(runId))
|
var results = (await repository.ListSceneResultsAsync(runId)).ToList();
|
||||||
.OrderBy(result => result.TemporarySceneNumber)
|
|
||||||
.ThenBy(result => result.SceneResultID)
|
|
||||||
.ToList();
|
|
||||||
var parsed = results
|
var parsed = results
|
||||||
.Select(result => new ParsedSceneResult(result, TryReadScene(result)))
|
.Select(result => ReadSceneResult(runId, result))
|
||||||
|
.OrderBy(item => SceneSortChapter(item))
|
||||||
|
.ThenBy(item => SceneSortNumber(item))
|
||||||
|
.ThenBy(item => item.Result.SceneResultID)
|
||||||
.ToList();
|
.ToList();
|
||||||
var completed = parsed
|
var completed = parsed
|
||||||
.Where(item => item.Scene is not null && item.Result.ValidationErrorsCount == 0)
|
.Where(item => item.Scene is not null)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var current = PickCurrentScene(run, completed);
|
var current = PickCurrentScene(run, completed);
|
||||||
@ -79,6 +79,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
ChangeToken = ChangeToken(run, results),
|
ChangeToken = ChangeToken(run, results),
|
||||||
IsTerminal = IsTerminal(run.Status),
|
IsTerminal = IsTerminal(run.Status),
|
||||||
SnapshotMessage = SnapshotMessage(run, completed.Count),
|
SnapshotMessage = SnapshotMessage(run, completed.Count),
|
||||||
|
Diagnostics = BuildDiagnostics(run, results, parsed, completed, current),
|
||||||
ProjectName = DisplayTitle(run.ProjectTitle, "Untitled project"),
|
ProjectName = DisplayTitle(run.ProjectTitle, "Untitled project"),
|
||||||
BookTitle = DisplayTitle(run.BookTitle, "Untitled book"),
|
BookTitle = DisplayTitle(run.BookTitle, "Untitled book"),
|
||||||
Scenes = scenes
|
Scenes = scenes
|
||||||
@ -158,13 +159,14 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return completed.LastOrDefault(item => SceneNumber(item) <= (run.CompletedScenes ?? int.MaxValue)) ?? completed.Last();
|
return completed.Last();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<StoryIntelligenceExperienceCharacterState> BuildCharacters(SceneIntelligenceScene scene)
|
private static IReadOnlyList<StoryIntelligenceExperienceCharacterState> BuildCharacters(SceneIntelligenceScene scene)
|
||||||
{
|
{
|
||||||
var povName = Clean(scene.PointOfView?.CharacterName);
|
var povName = Clean(scene.PointOfView?.CharacterName);
|
||||||
var states = new List<StoryIntelligenceExperienceCharacterState>();
|
var states = new List<StoryIntelligenceExperienceCharacterState>();
|
||||||
|
var reservedCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var group in (scene.Characters ?? [])
|
foreach (var group in (scene.Characters ?? [])
|
||||||
.Where(character => !string.IsNullOrWhiteSpace(character.Name))
|
.Where(character => !string.IsNullOrWhiteSpace(character.Name))
|
||||||
.GroupBy(character => Clean(character.Name), StringComparer.OrdinalIgnoreCase)
|
.GroupBy(character => Clean(character.Name), StringComparer.OrdinalIgnoreCase)
|
||||||
@ -172,10 +174,12 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
{
|
{
|
||||||
var character = group.OrderByDescending(item => item.Confidence ?? 0).First();
|
var character = group.OrderByDescending(item => item.Confidence ?? 0).First();
|
||||||
var isPov = !string.IsNullOrWhiteSpace(povName) && string.Equals(group.Key, povName, StringComparison.OrdinalIgnoreCase);
|
var isPov = !string.IsNullOrWhiteSpace(povName) && string.Equals(group.Key, povName, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var libraryCode = CharacterLibraryCode(group.Key, character, scene, reservedCodes);
|
||||||
|
reservedCodes.Add(libraryCode);
|
||||||
states.Add(new StoryIntelligenceExperienceCharacterState
|
states.Add(new StoryIntelligenceExperienceCharacterState
|
||||||
{
|
{
|
||||||
Id = CharacterId(group.Key),
|
Id = CharacterId(group.Key),
|
||||||
LibraryCode = CharacterLibraryCode(group.Key, character),
|
LibraryCode = libraryCode,
|
||||||
Name = group.Key,
|
Name = group.Key,
|
||||||
Role = isPov ? "POV character" : FirstConfigured(character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character"),
|
Role = isPov ? "POV character" : FirstConfigured(character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character"),
|
||||||
Relevance = Trim(FirstConfigured(character.Notes, string.Join(", ", character.Actions ?? []), character.MentionedOnly == true ? "Mentioned only" : "Present"), 60),
|
Relevance = Trim(FirstConfigured(character.Notes, string.Join(", ", character.Actions ?? []), character.MentionedOnly == true ? "Mentioned only" : "Present"), 60),
|
||||||
@ -186,10 +190,11 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(povName) && states.All(character => !string.Equals(character.Name, povName, StringComparison.OrdinalIgnoreCase)))
|
if (!string.IsNullOrWhiteSpace(povName) && states.All(character => !string.Equals(character.Name, povName, StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
|
var libraryCode = CharacterLibraryCode(povName, null, scene, reservedCodes);
|
||||||
states.Insert(0, new StoryIntelligenceExperienceCharacterState
|
states.Insert(0, new StoryIntelligenceExperienceCharacterState
|
||||||
{
|
{
|
||||||
Id = CharacterId(povName),
|
Id = CharacterId(povName),
|
||||||
LibraryCode = CharacterLibraryCode(povName, null),
|
LibraryCode = libraryCode,
|
||||||
Name = povName,
|
Name = povName,
|
||||||
Role = "POV character",
|
Role = "POV character",
|
||||||
Relevance = "Point of view",
|
Relevance = "Point of view",
|
||||||
@ -327,10 +332,27 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
yield return $"Run status: {run.Status}";
|
yield return $"Run status: {run.Status}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result)
|
private ParsedSceneResult ReadSceneResult(int runId, StoryIntelligenceSavedSceneResult result)
|
||||||
{
|
{
|
||||||
|
var scene = TryReadScene(result, out var error);
|
||||||
|
if (scene is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Story Intelligence visualisation skipped unparseable scene result {SceneResultId} for run {RunId}: {ParseError}",
|
||||||
|
result.SceneResultID,
|
||||||
|
runId,
|
||||||
|
error ?? "Parsed scene was empty or missing schema version.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ParsedSceneResult(result, scene, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result, out string? safeError)
|
||||||
|
{
|
||||||
|
safeError = null;
|
||||||
if (string.IsNullOrWhiteSpace(result.ParsedJson))
|
if (string.IsNullOrWhiteSpace(result.ParsedJson))
|
||||||
{
|
{
|
||||||
|
safeError = "Parsed JSON was empty.";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -348,19 +370,82 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
|
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException ex)
|
||||||
{
|
{
|
||||||
|
safeError = ex.Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
safeError ??= "Parsed JSON did not contain a scene payload.";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ChangeToken(StoryIntelligenceSavedRun run, IReadOnlyList<StoryIntelligenceSavedSceneResult> results)
|
private static string ChangeToken(StoryIntelligenceSavedRun run, IReadOnlyList<StoryIntelligenceSavedSceneResult> results)
|
||||||
{
|
{
|
||||||
var raw = $"{run.StoryIntelligenceRunID}|{run.Status}|{run.UpdatedUtc:O}|{results.Count}|{results.Select(item => item.SceneResultID).DefaultIfEmpty().Max()}";
|
var resultToken = string.Join(';', results
|
||||||
|
.OrderBy(result => result.SceneResultID)
|
||||||
|
.Select(result => string.Join('|',
|
||||||
|
result.SceneResultID,
|
||||||
|
result.ChapterID,
|
||||||
|
result.SceneID,
|
||||||
|
result.TemporarySceneNumber,
|
||||||
|
result.ValidationErrorsCount,
|
||||||
|
result.ValidationWarningsCount,
|
||||||
|
result.InputTokens,
|
||||||
|
result.OutputTokens,
|
||||||
|
result.TotalTokens,
|
||||||
|
result.DurationMs,
|
||||||
|
result.CreatedUtc.ToUniversalTime().Ticks,
|
||||||
|
StableContentHash(result.ParsedJson))));
|
||||||
|
var raw = string.Join('|',
|
||||||
|
run.StoryIntelligenceRunID,
|
||||||
|
run.Status,
|
||||||
|
run.CurrentStage,
|
||||||
|
run.CurrentMessage,
|
||||||
|
run.CompletedScenes,
|
||||||
|
run.FailedScenes,
|
||||||
|
run.TotalDetectedScenes,
|
||||||
|
run.CompletedUtc?.ToUniversalTime().Ticks,
|
||||||
|
run.UpdatedUtc.ToUniversalTime().Ticks,
|
||||||
|
run.TotalInputTokens,
|
||||||
|
run.TotalOutputTokens,
|
||||||
|
run.TotalTokens,
|
||||||
|
run.TotalDurationMs,
|
||||||
|
run.ErrorMessage,
|
||||||
|
run.ErrorDetail is null ? string.Empty : StableContentHash(run.ErrorDetail),
|
||||||
|
results.Count,
|
||||||
|
resultToken);
|
||||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..16].ToLowerInvariant();
|
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..16].ToLowerInvariant();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static StoryIntelligenceExperienceSnapshotDiagnostics BuildDiagnostics(
|
||||||
|
StoryIntelligenceSavedRun run,
|
||||||
|
IReadOnlyList<StoryIntelligenceSavedSceneResult> results,
|
||||||
|
IReadOnlyList<ParsedSceneResult> parsed,
|
||||||
|
IReadOnlyList<ParsedSceneResult> completed,
|
||||||
|
ParsedSceneResult? current)
|
||||||
|
{
|
||||||
|
var failed = parsed.Where(item => item.Scene is null).ToList();
|
||||||
|
var warning = failed.Count == 0
|
||||||
|
? null
|
||||||
|
: $"{failed.Count:N0} scene result(s) skipped because they could not be parsed.";
|
||||||
|
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
RunStatus = run.Status,
|
||||||
|
PersistedCompletedSceneCount = run.CompletedScenes ?? 0,
|
||||||
|
TotalPersistedSceneResultCount = results.Count,
|
||||||
|
SuccessfullyParsedSceneResultCount = completed.Count,
|
||||||
|
FailedSceneResultCount = failed.Count,
|
||||||
|
HighestPersistedSceneResultId = results.Count == 0 ? null : results.Max(result => result.SceneResultID),
|
||||||
|
HighestParsedSceneResultId = completed.Count == 0 ? null : completed.Max(item => item.Result.SceneResultID),
|
||||||
|
SelectedCurrentSceneResultId = current?.Result.SceneResultID,
|
||||||
|
SelectedChapterAndSceneOrder = current is null ? "none" : $"Chapter {SceneSortChapter(current):0.##}, Scene {SceneSortNumber(current):0.##}",
|
||||||
|
CurrentChangeToken = ChangeToken(run, results),
|
||||||
|
AnalysisCursor = FirstConfigured(run.CurrentMessage, run.CurrentStage),
|
||||||
|
Warning = warning
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static string SnapshotMessage(StoryIntelligenceSavedRun run, int completedScenes)
|
private static string SnapshotMessage(StoryIntelligenceSavedRun run, int completedScenes)
|
||||||
=> run.Status switch
|
=> run.Status switch
|
||||||
{
|
{
|
||||||
@ -377,6 +462,13 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
private static int SceneNumber(ParsedSceneResult item)
|
private static int SceneNumber(ParsedSceneResult item)
|
||||||
=> item.Scene?.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(item.Scene.SceneReference.SceneNumber.Value) : item.Result.TemporarySceneNumber;
|
=> item.Scene?.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(item.Scene.SceneReference.SceneNumber.Value) : item.Result.TemporarySceneNumber;
|
||||||
|
|
||||||
|
private static decimal SceneSortChapter(ParsedSceneResult item)
|
||||||
|
=> item.Scene?.SceneReference?.ChapterNumber
|
||||||
|
?? (item.Result.ChapterID.HasValue ? item.Result.ChapterID.Value : 0);
|
||||||
|
|
||||||
|
private static decimal SceneSortNumber(ParsedSceneResult item)
|
||||||
|
=> item.Scene?.SceneReference?.SceneNumber ?? item.Result.TemporarySceneNumber;
|
||||||
|
|
||||||
private static string SceneTitle(SceneIntelligenceScene scene, StoryIntelligenceSavedSceneResult result, int sceneNumber)
|
private static string SceneTitle(SceneIntelligenceScene scene, StoryIntelligenceSavedSceneResult result, int sceneNumber)
|
||||||
=> FirstConfigured(scene.SceneReference?.SourceLabel, result.SourceLabel, scene.Summary?.Short, $"Scene {sceneNumber:N0}");
|
=> FirstConfigured(scene.SceneReference?.SourceLabel, result.SourceLabel, scene.Summary?.Short, $"Scene {sceneNumber:N0}");
|
||||||
|
|
||||||
@ -459,13 +551,21 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
|| value is "door" or "table" or "chair";
|
|| value is "door" or "table" or "chair";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string CharacterLibraryCode(string name, SceneIntelligenceCharacter? character)
|
private static string CharacterLibraryCode(
|
||||||
|
string name,
|
||||||
|
SceneIntelligenceCharacter? character,
|
||||||
|
SceneIntelligenceScene? scene = null,
|
||||||
|
IReadOnlySet<string>? reservedCodes = null)
|
||||||
{
|
{
|
||||||
var value = $"{name} {character?.RoleInScene} {character?.Notes}".ToLowerInvariant();
|
var profile = CharacterProfile.From(name, character, scene);
|
||||||
if (value.Contains("red") || value.Contains("auburn") || value.Contains("witness")) return "char-young-adult-red-haired-witness";
|
var candidates = CharacterCodeProfiles
|
||||||
if (value.Contains("older") || value.Contains("mother") || value.Contains("remember")) return "char-older-soft-presence";
|
.OrderByDescending(candidate => candidate.Score(profile))
|
||||||
if (value.Contains("suspect") || value.Contains("threat") || value.Contains("man")) return "char-middle-aged-weathered-man";
|
.ThenBy(candidate => reservedCodes?.Contains(candidate.Code) == true ? 1 : 0)
|
||||||
return "char-young-adult-brunette-observer";
|
.ThenBy(candidate => StableHash($"{name}|{candidate.Code}"))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return candidates.FirstOrDefault(candidate => reservedCodes?.Contains(candidate.Code) != true)?.Code
|
||||||
|
?? candidates.First().Code;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string LocationLibraryCode(string name, SceneIntelligenceLocation? location, SceneIntelligenceSetting? setting)
|
private static string LocationLibraryCode(string name, SceneIntelligenceLocation? location, SceneIntelligenceSetting? setting)
|
||||||
@ -498,6 +598,14 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
"char-young-adult-red-haired-witness" => $"{FallbackRoot}/character-rosie.svg",
|
"char-young-adult-red-haired-witness" => $"{FallbackRoot}/character-rosie.svg",
|
||||||
"char-middle-aged-weathered-man" => $"{FallbackRoot}/character-graham.svg",
|
"char-middle-aged-weathered-man" => $"{FallbackRoot}/character-graham.svg",
|
||||||
"char-older-soft-presence" => $"{FallbackRoot}/character-maggie.svg",
|
"char-older-soft-presence" => $"{FallbackRoot}/character-maggie.svg",
|
||||||
|
"char-young-helper" => $"{FallbackRoot}/character-beth.svg",
|
||||||
|
"char-archival-contact" => $"{FallbackRoot}/character-graham.svg",
|
||||||
|
"char-professional-investigator" => $"{FallbackRoot}/character-graham.svg",
|
||||||
|
"char-neighbourhood-friend" => $"{FallbackRoot}/character-beth.svg",
|
||||||
|
"char-family-relative" => $"{FallbackRoot}/character-maggie.svg",
|
||||||
|
"char-quiet-antagonist" => $"{FallbackRoot}/character-graham.svg",
|
||||||
|
"char-authorial-voice" => $"{FallbackRoot}/character-maggie.svg",
|
||||||
|
"char-unknown-figure" => $"{FallbackRoot}/character-beth.svg",
|
||||||
_ => $"{FallbackRoot}/character-beth.svg"
|
_ => $"{FallbackRoot}/character-beth.svg"
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -546,6 +654,15 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
return builder.ToString().Trim('-');
|
return builder.ToString().Trim('-');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int StableHash(string? value)
|
||||||
|
{
|
||||||
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
|
||||||
|
return BitConverter.ToInt32(bytes, 0) & int.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StableContentHash(string? value)
|
||||||
|
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty)))[..16].ToLowerInvariant();
|
||||||
|
|
||||||
private static string Clean(string? value)
|
private static string Clean(string? value)
|
||||||
=> string.Join(' ', (value ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).Trim();
|
=> string.Join(' ', (value ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).Trim();
|
||||||
|
|
||||||
@ -561,5 +678,104 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
return clean.Length <= max ? clean : clean[..Math.Max(0, max - 3)].TrimEnd() + "...";
|
return clean.Length <= max ? clean : clean[..Math.Max(0, max - 3)].TrimEnd() + "...";
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record ParsedSceneResult(StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene);
|
private sealed record ParsedSceneResult(StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene, string? ParseError);
|
||||||
|
|
||||||
|
private sealed record CharacterCodeProfile(
|
||||||
|
string Code,
|
||||||
|
string AgeBand,
|
||||||
|
string Presentation,
|
||||||
|
string HairColour,
|
||||||
|
string HairLength,
|
||||||
|
IReadOnlyList<string> Signals)
|
||||||
|
{
|
||||||
|
public int Score(CharacterProfile profile)
|
||||||
|
{
|
||||||
|
var score = 0;
|
||||||
|
if (profile.AgeBand == AgeBand) score += 80;
|
||||||
|
else if (CompatibleAge(profile.AgeBand, AgeBand)) score += 30;
|
||||||
|
else score -= 45;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(profile.Presentation) && Presentation.Equals(profile.Presentation, StringComparison.OrdinalIgnoreCase)) score += 24;
|
||||||
|
if (!string.IsNullOrWhiteSpace(profile.HairColour) && HairColour.Equals(profile.HairColour, StringComparison.OrdinalIgnoreCase)) score += 18;
|
||||||
|
if (!string.IsNullOrWhiteSpace(profile.HairLength) && HairLength.Equals(profile.HairLength, StringComparison.OrdinalIgnoreCase)) score += 10;
|
||||||
|
score += Signals.Count(signal => profile.Text.Contains(signal, StringComparison.OrdinalIgnoreCase)) * 14;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CompatibleAge(string required, string candidate)
|
||||||
|
=> (required, candidate) switch
|
||||||
|
{
|
||||||
|
("Teen", "YoungAdult") => true,
|
||||||
|
("YoungAdult", "Teen") => true,
|
||||||
|
("MiddleAged", "Older") => true,
|
||||||
|
("Older", "MiddleAged") => true,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record CharacterProfile(string Text, string AgeBand, string Presentation, string HairColour, string HairLength)
|
||||||
|
{
|
||||||
|
public static CharacterProfile From(string name, SceneIntelligenceCharacter? character, SceneIntelligenceScene? scene)
|
||||||
|
{
|
||||||
|
var relationshipText = string.Join(' ', (scene?.Relationships ?? [])
|
||||||
|
.Where(relationship => string.Equals(Clean(relationship.CharacterA), Clean(name), StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(Clean(relationship.CharacterB), Clean(name), StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(relationship => $"{relationship.RelationshipSignal} {relationship.Evidence}"));
|
||||||
|
var text = $"{name} {character?.RoleInScene} {character?.Notes} {string.Join(' ', character?.Actions ?? [])} {relationshipText}".ToLowerInvariant();
|
||||||
|
return new(
|
||||||
|
text,
|
||||||
|
DetectAgeBand(text),
|
||||||
|
DetectPresentation(text),
|
||||||
|
DetectHairColour(text),
|
||||||
|
DetectHairLength(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DetectAgeBand(string text)
|
||||||
|
{
|
||||||
|
if (text.Contains("grandmother") || text.Contains("grandfather") || text.Contains("elderly") || text.Contains("older") || text.Contains("retired")) return "Older";
|
||||||
|
if (text.Contains("mother") || text.Contains("father") || text.Contains("parent") || text.Contains("teacher") || text.Contains("aunt") || text.Contains("uncle") || text.Contains("middle-aged") || text.Contains("middle aged")) return "MiddleAged";
|
||||||
|
if (text.Contains("teen") || text.Contains("teenage") || text.Contains("15") || text.Contains("fifteen") || text.Contains("schoolgirl") || text.Contains("schoolboy") || text.Contains("child") || text.Contains("daughter") || text.Contains("son")) return "Teen";
|
||||||
|
if (text.Contains("young") || text.Contains("student")) return "YoungAdult";
|
||||||
|
return "YoungAdult";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DetectPresentation(string text)
|
||||||
|
{
|
||||||
|
if (text.Contains("mother") || text.Contains("daughter") || text.Contains("woman") || text.Contains("girl") || text.Contains("female") || text.Contains("sister") || text.Contains("aunt")) return "Feminine";
|
||||||
|
if (text.Contains("father") || text.Contains("son") || text.Contains("man") || text.Contains("male") || text.Contains("brother") || text.Contains("uncle")) return "Masculine";
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DetectHairColour(string text)
|
||||||
|
{
|
||||||
|
if (text.Contains("red") || text.Contains("auburn") || text.Contains("ginger")) return "Red";
|
||||||
|
if (text.Contains("grey") || text.Contains("gray") || text.Contains("white-haired") || text.Contains("white hair")) return "Grey";
|
||||||
|
if (text.Contains("black hair") || text.Contains("black-haired")) return "Black";
|
||||||
|
if (text.Contains("dark") || text.Contains("brown") || text.Contains("brunette")) return "Brown";
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DetectHairLength(string text)
|
||||||
|
{
|
||||||
|
if (text.Contains("long hair") || text.Contains("long-haired")) return "Long";
|
||||||
|
if (text.Contains("short hair") || text.Contains("short-haired")) return "Short";
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly IReadOnlyList<CharacterCodeProfile> CharacterCodeProfiles =
|
||||||
|
[
|
||||||
|
new("char-young-helper", "Teen", "Androgynous", "Black", "Short", ["teen", "child", "daughter", "son", "helper"]),
|
||||||
|
new("char-young-adult-red-haired-witness", "YoungAdult", "Feminine", "Red", "Long", ["red", "auburn", "witness", "protective"]),
|
||||||
|
new("char-young-adult-brunette-observer", "YoungAdult", "Feminine", "Brown", "Medium", ["young", "observer", "student"]),
|
||||||
|
new("char-neighbourhood-friend", "YoungAdult", "Androgynous", "Brown", "Medium", ["friend", "neighbour", "neighbor"]),
|
||||||
|
new("char-family-relative", "MiddleAged", "Feminine", "Brown", "Medium", ["mother", "parent", "aunt", "family", "relative"]),
|
||||||
|
new("char-professional-investigator", "MiddleAged", "Androgynous", "Brown", "Short", ["teacher", "professional", "investigator", "authority"]),
|
||||||
|
new("char-middle-aged-weathered-man", "MiddleAged", "Masculine", "DarkBrown", "Short", ["father", "man", "suspect", "threat", "weathered"]),
|
||||||
|
new("char-quiet-antagonist", "MiddleAged", "Androgynous", "DarkBrown", "Short", ["antagonist", "threat", "pressure", "controlled"]),
|
||||||
|
new("char-older-soft-presence", "Older", "Feminine", "Grey", "Short", ["grandmother", "older", "remembered", "mother"]),
|
||||||
|
new("char-archival-contact", "Older", "Masculine", "White", "Short", ["grandfather", "older", "archive", "records"]),
|
||||||
|
new("char-authorial-voice", "Older", "Androgynous", "Grey", "Medium", ["voice", "memory", "recorded"]),
|
||||||
|
new("char-unknown-figure", "YoungAdult", "Androgynous", "Brown", "Medium", ["unknown", "unidentified"])
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,12 +9,29 @@ public sealed class StoryIntelligenceExperiencePrototypeViewModel
|
|||||||
public DateTime GeneratedUtc { get; init; } = DateTime.UtcNow;
|
public DateTime GeneratedUtc { get; init; } = DateTime.UtcNow;
|
||||||
public bool IsTerminal { get; init; }
|
public bool IsTerminal { get; init; }
|
||||||
public string? SnapshotMessage { get; init; }
|
public string? SnapshotMessage { get; init; }
|
||||||
|
public StoryIntelligenceExperienceSnapshotDiagnostics Diagnostics { get; init; } = new();
|
||||||
public IReadOnlyList<StoryIntelligenceExperienceRunOption> AvailableRuns { get; init; } = [];
|
public IReadOnlyList<StoryIntelligenceExperienceRunOption> AvailableRuns { get; init; } = [];
|
||||||
public string ProjectName { get; init; } = string.Empty;
|
public string ProjectName { get; init; } = string.Empty;
|
||||||
public string BookTitle { get; init; } = string.Empty;
|
public string BookTitle { get; init; } = string.Empty;
|
||||||
public IReadOnlyList<StoryIntelligenceExperienceSceneState> Scenes { get; init; } = [];
|
public IReadOnlyList<StoryIntelligenceExperienceSceneState> Scenes { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceExperienceSnapshotDiagnostics
|
||||||
|
{
|
||||||
|
public string RunStatus { get; init; } = string.Empty;
|
||||||
|
public int PersistedCompletedSceneCount { get; init; }
|
||||||
|
public int TotalPersistedSceneResultCount { get; init; }
|
||||||
|
public int SuccessfullyParsedSceneResultCount { get; init; }
|
||||||
|
public int FailedSceneResultCount { get; init; }
|
||||||
|
public int? HighestPersistedSceneResultId { get; init; }
|
||||||
|
public int? HighestParsedSceneResultId { get; init; }
|
||||||
|
public int? SelectedCurrentSceneResultId { get; init; }
|
||||||
|
public string SelectedChapterAndSceneOrder { get; init; } = string.Empty;
|
||||||
|
public string CurrentChangeToken { get; init; } = string.Empty;
|
||||||
|
public string? AnalysisCursor { get; init; }
|
||||||
|
public string? Warning { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceExperienceRunOption
|
public sealed class StoryIntelligenceExperienceRunOption
|
||||||
{
|
{
|
||||||
public int RunId { get; init; }
|
public int RunId { get; init; }
|
||||||
|
|||||||
@ -189,6 +189,74 @@
|
|||||||
<dt>Operations detected</dt>
|
<dt>Operations detected</dt>
|
||||||
<dd data-diagnostics-operations>InitialRender</dd>
|
<dd data-diagnostics-operations>InitialRender</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Run status</dt>
|
||||||
|
<dd data-diagnostic-key="runStatus">@Model.Diagnostics.RunStatus</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Persisted completed scenes</dt>
|
||||||
|
<dd data-diagnostic-key="persistedCompletedSceneCount">@Model.Diagnostics.PersistedCompletedSceneCount</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Total persisted scene results</dt>
|
||||||
|
<dd data-diagnostic-key="totalPersistedSceneResultCount">@Model.Diagnostics.TotalPersistedSceneResultCount</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Successfully parsed scene results</dt>
|
||||||
|
<dd data-diagnostic-key="successfullyParsedSceneResultCount">@Model.Diagnostics.SuccessfullyParsedSceneResultCount</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Failed/unparseable scene results</dt>
|
||||||
|
<dd data-diagnostic-key="failedSceneResultCount">@Model.Diagnostics.FailedSceneResultCount</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Highest persisted SceneResultID</dt>
|
||||||
|
<dd data-diagnostic-key="highestPersistedSceneResultId">@Model.Diagnostics.HighestPersistedSceneResultId</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Highest parsed SceneResultID</dt>
|
||||||
|
<dd data-diagnostic-key="highestParsedSceneResultId">@Model.Diagnostics.HighestParsedSceneResultId</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Selected current SceneResultID</dt>
|
||||||
|
<dd data-diagnostic-key="selectedCurrentSceneResultId">@Model.Diagnostics.SelectedCurrentSceneResultId</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Selected chapter and scene order</dt>
|
||||||
|
<dd data-diagnostic-key="selectedChapterAndSceneOrder">@Model.Diagnostics.SelectedChapterAndSceneOrder</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Analysis cursor</dt>
|
||||||
|
<dd data-diagnostic-key="analysisCursor">@Model.Diagnostics.AnalysisCursor</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Snapshot warning</dt>
|
||||||
|
<dd data-diagnostic-key="warning">@Model.Diagnostics.Warning</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Last successful snapshot fetch</dt>
|
||||||
|
<dd data-diagnostic-key="lastSuccessfulSnapshotFetch">Initial</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Last snapshot HTTP status</dt>
|
||||||
|
<dd data-diagnostic-key="lastSnapshotHttpStatus">Initial</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Consecutive unchanged polls</dt>
|
||||||
|
<dd data-diagnostic-key="consecutiveUnchangedPolls">0</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Consecutive failed polls</dt>
|
||||||
|
<dd data-diagnostic-key="consecutiveFailedPolls">0</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Polling</dt>
|
||||||
|
<dd data-diagnostic-key="pollingActive">Initialising</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Polling stopped reason</dt>
|
||||||
|
<dd data-diagnostic-key="pollingStoppedReason">none</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</details>
|
</details>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -22,6 +22,11 @@
|
|||||||
polling: false,
|
polling: false,
|
||||||
changeToken: model.changeToken || root.dataset.changeToken || "",
|
changeToken: model.changeToken || root.dataset.changeToken || "",
|
||||||
terminal: model.isTerminal || root.dataset.terminal === "true",
|
terminal: model.isTerminal || root.dataset.terminal === "true",
|
||||||
|
pollingStoppedReason: "",
|
||||||
|
lastSnapshotHttpStatus: "initial",
|
||||||
|
consecutiveUnchangedPolls: 0,
|
||||||
|
consecutiveFailedPolls: 0,
|
||||||
|
lastSuccessfulSnapshotFetch: "Initial",
|
||||||
currentSnapshot: model,
|
currentSnapshot: model,
|
||||||
previousSnapshot: null,
|
previousSnapshot: null,
|
||||||
currentVersion: 1,
|
currentVersion: 1,
|
||||||
@ -79,7 +84,8 @@
|
|||||||
diagnosticsQueue: root.querySelector("[data-diagnostics-queue]"),
|
diagnosticsQueue: root.querySelector("[data-diagnostics-queue]"),
|
||||||
diagnosticsRefresh: root.querySelector("[data-diagnostics-refresh]"),
|
diagnosticsRefresh: root.querySelector("[data-diagnostics-refresh]"),
|
||||||
diagnosticsBuild: root.querySelector("[data-diagnostics-build]"),
|
diagnosticsBuild: root.querySelector("[data-diagnostics-build]"),
|
||||||
diagnosticsOperations: root.querySelector("[data-diagnostics-operations]")
|
diagnosticsOperations: root.querySelector("[data-diagnostics-operations]"),
|
||||||
|
diagnosticValues: root.querySelectorAll("[data-diagnostic-key]")
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadSceneState(index) {
|
function loadSceneState(index) {
|
||||||
@ -138,7 +144,7 @@
|
|||||||
text(book, model.bookTitle);
|
text(book, model.bookTitle);
|
||||||
}
|
}
|
||||||
transitionToNextState(state.index, operations);
|
transitionToNextState(state.index, operations);
|
||||||
if (state.terminal) stopPolling();
|
if (state.terminal) stopPolling("terminal model received");
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateProgress(scene) {
|
function updateProgress(scene) {
|
||||||
@ -1081,14 +1087,16 @@
|
|||||||
function startPolling() {
|
function startPolling() {
|
||||||
if (!realMode || !snapshotUrl || state.terminal) return;
|
if (!realMode || !snapshotUrl || state.terminal) return;
|
||||||
stopPolling();
|
stopPolling();
|
||||||
|
state.pollingStoppedReason = "";
|
||||||
state.pollTimer = window.setInterval(pollSnapshot, document.hidden ? 15000 : 7000);
|
state.pollTimer = window.setInterval(pollSnapshot, document.hidden ? 15000 : 7000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPolling() {
|
function stopPolling(reason) {
|
||||||
if (state.pollTimer) {
|
if (state.pollTimer) {
|
||||||
window.clearInterval(state.pollTimer);
|
window.clearInterval(state.pollTimer);
|
||||||
state.pollTimer = null;
|
state.pollTimer = null;
|
||||||
}
|
}
|
||||||
|
if (reason) state.pollingStoppedReason = reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollSnapshot() {
|
async function pollSnapshot() {
|
||||||
@ -1097,17 +1105,30 @@
|
|||||||
const requestStarted = performance.now();
|
const requestStarted = performance.now();
|
||||||
try {
|
try {
|
||||||
const response = await fetch(snapshotUrl, { headers: { Accept: "application/json" } });
|
const response = await fetch(snapshotUrl, { headers: { Accept: "application/json" } });
|
||||||
if (!response.ok) return;
|
state.lastSnapshotHttpStatus = String(response.status);
|
||||||
|
if (!response.ok) {
|
||||||
|
state.consecutiveFailedPolls += 1;
|
||||||
|
updateDiagnostics();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const snapshot = await response.json();
|
const snapshot = await response.json();
|
||||||
|
state.consecutiveFailedPolls = 0;
|
||||||
|
state.lastSuccessfulSnapshotFetch = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
if (snapshot.changeToken && snapshot.changeToken !== state.changeToken) {
|
if (snapshot.changeToken && snapshot.changeToken !== state.changeToken) {
|
||||||
|
state.consecutiveUnchangedPolls = 0;
|
||||||
replaceModel(snapshot, Math.round(performance.now() - requestStarted));
|
replaceModel(snapshot, Math.round(performance.now() - requestStarted));
|
||||||
} else if (snapshot.isTerminal) {
|
} else if (snapshot.isTerminal) {
|
||||||
state.terminal = true;
|
state.terminal = true;
|
||||||
stopPolling();
|
stopPolling("terminal snapshot received");
|
||||||
|
updateDiagnostics();
|
||||||
|
} else {
|
||||||
|
state.consecutiveUnchangedPolls += 1;
|
||||||
updateDiagnostics();
|
updateDiagnostics();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Development snapshot polling is best-effort.
|
state.lastSnapshotHttpStatus = "fetch failed";
|
||||||
|
state.consecutiveFailedPolls += 1;
|
||||||
|
updateDiagnostics();
|
||||||
} finally {
|
} finally {
|
||||||
state.polling = false;
|
state.polling = false;
|
||||||
}
|
}
|
||||||
@ -1226,6 +1247,23 @@
|
|||||||
text(dom.diagnosticsRefresh, state.lastRefreshTime);
|
text(dom.diagnosticsRefresh, state.lastRefreshTime);
|
||||||
text(dom.diagnosticsBuild, `${state.snapshotBuildMs}ms`);
|
text(dom.diagnosticsBuild, `${state.snapshotBuildMs}ms`);
|
||||||
text(dom.diagnosticsOperations, state.lastOperations.join(", "));
|
text(dom.diagnosticsOperations, state.lastOperations.join(", "));
|
||||||
|
const diagnostics = model.diagnostics || {};
|
||||||
|
dom.diagnosticValues.forEach((node) => {
|
||||||
|
const key = node.dataset.diagnosticKey;
|
||||||
|
const value = pollingDiagnosticValue(key, diagnostics);
|
||||||
|
text(node, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollingDiagnosticValue(key, diagnostics) {
|
||||||
|
if (key === "lastSuccessfulSnapshotFetch") return state.lastSuccessfulSnapshotFetch;
|
||||||
|
if (key === "lastSnapshotHttpStatus") return state.lastSnapshotHttpStatus;
|
||||||
|
if (key === "consecutiveUnchangedPolls") return state.consecutiveUnchangedPolls;
|
||||||
|
if (key === "consecutiveFailedPolls") return state.consecutiveFailedPolls;
|
||||||
|
if (key === "pollingActive") return state.pollTimer ? "active" : "stopped";
|
||||||
|
if (key === "pollingStoppedReason") return state.pollingStoppedReason || "none";
|
||||||
|
const value = diagnostics[key];
|
||||||
|
return value === null || value === undefined || value === "" ? "none" : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function animateNumber(node, nextValue, suffix) {
|
function animateNumber(node, nextValue, suffix) {
|
||||||
@ -1362,4 +1400,5 @@
|
|||||||
transitionToNextState(realMode ? Math.max(0, scenes.length - 1) : 0);
|
transitionToNextState(realMode ? Math.max(0, scenes.length - 1) : 0);
|
||||||
startTimer();
|
startTimer();
|
||||||
startPolling();
|
startPolling();
|
||||||
|
if (realMode && !state.terminal) pollSnapshot();
|
||||||
})();
|
})();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user