Use structural summaries in Story Intelligence review

This commit is contained in:
Nick Beckley 2026-08-27 14:58:16 +00:00
parent b6aa53ee12
commit c10745bcc4
13 changed files with 191 additions and 61 deletions

View File

@ -85,6 +85,8 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence uses persisted scene source after restart", StoryIntelligenceUsesPersistedSceneSourceAfterRestart), ("Story Intelligence uses persisted scene source after restart", StoryIntelligenceUsesPersistedSceneSourceAfterRestart),
("Story Intelligence canonical mode bypasses duplicate boundary analysis", StoryIntelligenceCanonicalModeBypassesDuplicateBoundaryAnalysis), ("Story Intelligence canonical mode bypasses duplicate boundary analysis", StoryIntelligenceCanonicalModeBypassesDuplicateBoundaryAnalysis),
("Story Intelligence downstream imports prefer canonical SceneIDs", StoryIntelligenceDownstreamImportsPreferCanonicalSceneIds), ("Story Intelligence downstream imports prefer canonical SceneIDs", StoryIntelligenceDownstreamImportsPreferCanonicalSceneIds),
("Story Intelligence scene prompt no longer asks for duplicate factual summary", StoryIntelligenceScenePromptNoLongerRequestsDuplicateSummary),
("Story Intelligence review uses canonical structural summaries and counts", StoryIntelligenceReviewUsesCanonicalStructuralSummariesAndCounts),
("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement), ("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement),
("Word sync retry resumes existing structure", WordSyncRetryResumesExistingStructure), ("Word sync retry resumes existing structure", WordSyncRetryResumesExistingStructure),
("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes), ("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes),
@ -2035,6 +2037,63 @@ static void StoryIntelligenceDownstreamImportsPreferCanonicalSceneIds()
} }
} }
static void StoryIntelligenceScenePromptNoLongerRequestsDuplicateSummary()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
var validator = new StorySceneValidator();
Assert(prompt.Contains("structuralSummary", StringComparison.Ordinal), "Scene prompt should receive canonical structural summary context.");
Assert(prompt.Contains("Do not regenerate, rewrite or compete with that factual scene summary.", StringComparison.Ordinal), "Scene prompt should explicitly avoid duplicate factual summaries.");
Assert(!prompt.Contains("\"summary\":", StringComparison.Ordinal), "Scene prompt output shape should not request a duplicate summary object.");
Assert(!prompt.Contains("## Summary Rules", StringComparison.Ordinal), "Scene prompt should not include factual summary generation instructions.");
var validation = validator.Validate(new SceneIntelligenceScene
{
SchemaVersion = "1.0",
SceneReference = new SceneIntelligenceSceneReference { SourceLabel = "Scene 1" },
ScenePurpose = new SceneIntelligenceScenePurpose { ObservedFunction = "Reveals a threat.", Confidence = 0.8m },
PointOfView = new SceneIntelligencePointOfView { CharacterName = null, NarrativeMode = null, Confidence = 0.7m, Evidence = null },
Setting = new SceneIntelligenceSetting { LocationName = null, LocationType = "unclear", GenericRoomType = null, ParentLocationHint = null, Confidence = 0.6m },
Characters = [],
Locations = [],
Assets = [],
Relationships = [],
KnowledgeChanges = [],
TimelineClues = [],
NarrativeArcs = [],
QuestionsRaised = [],
QuestionsAnswered = [],
Observations = [],
Metrics = [],
SourceLimits = new SceneIntelligenceSourceLimits { ContainsAmbiguity = false, AmbiguityNotes = [] }
});
Assert(validation.Errors.All(error => !error.Path.StartsWith("summary", StringComparison.OrdinalIgnoreCase)), "Validator should allow new-format Scene Intelligence without a summary object.");
}
static void StoryIntelligenceReviewUsesCanonicalStructuralSummariesAndCounts()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var onboardingStory = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var commit = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs"));
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var reviewView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReview.cshtml"));
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs"));
Assert(commit.Contains("BuildReviewScenes(canonicalScenes, sceneResults, importScenes)", StringComparison.Ordinal), "Review confirmation should build canonical scene previews.");
Assert(commit.Contains("CanonicalSummary(scene)", StringComparison.Ordinal), "Review previews should prefer StructuralSummary/Summary from canonical scenes.");
Assert(commit.Contains("Analysis missing", StringComparison.Ordinal), "Canonical scenes without results should still render as missing analysis.");
Assert(commit.Contains("Analysis failed", StringComparison.Ordinal), "Canonical scenes with failed results should still render as failed analysis.");
Assert(commit.Contains("GroupBy(result => result.SceneID!.Value)", StringComparison.Ordinal), "Duplicate mapped SI rows should collapse to one canonical scene preview.");
Assert(onboardingStory.Contains("FirstConfigured(scene.Summary, scene.ParsedScene.Summary?.Short", StringComparison.Ordinal), "Onboarding preview should prefer canonical summary with legacy SI fallback.");
Assert(viewModel.Contains("chapter.Scenes.Count > 0 ? chapter.Scenes.Count : chapter.TotalDetectedScenes", StringComparison.Ordinal), "Review headline total should derive from canonical scene previews when available.");
Assert(reviewView.Contains("chapter.Scenes.Count > 0 ? chapter.Scenes.Count : chapter.TotalDetectedScenes", StringComparison.Ordinal), "Per-chapter detected count should derive from canonical scene previews.");
Assert(reviewView.Contains("chapter.Scenes.Count(scene => !scene.HasWarnings)", StringComparison.Ordinal), "Ready count should not include missing or failed canonical scene analysis.");
Assert(reviewView.Contains("@scene.AnalysisStatus", StringComparison.Ordinal), "Review UI should show missing/failed analysis state on canonical scenes.");
Assert(repository.Contains("SceneID = item.SceneID ??", StringComparison.Ordinal), "Commit should target canonical SceneID when one exists.");
}
static void WordSyncRequiresSaveAcknowledgement() static void WordSyncRequiresSaveAcknowledgement()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -44,7 +44,7 @@ It should not:
1. Treat the manuscript as the authority. 1. Treat the manuscript as the authority.
2. Prefer omission over invention. 2. Prefer omission over invention.
3. Use uncertainty explicitly. 3. Use uncertainty explicitly.
4. Keep summaries neutral and descriptive. 4. Use supplied structural summaries as context rather than regenerating them.
5. Separate present characters from mentioned-only characters. 5. Separate present characters from mentioned-only characters.
6. Separate named locations from generic rooms. 6. Separate named locations from generic rooms.
7. Treat assets as meaningful story objects, not ordinary props. 7. Treat assets as meaningful story objects, not ordinary props.
@ -77,6 +77,8 @@ It should not:
Use only the scene text and supplied context. Use only the scene text and supplied context.
When `structuralSummary` is supplied in `sceneContext`, treat it as PlotDirector's canonical factual summary of the scene. Use it to orient interpretation, metrics, relationships, knowledge, narrative arcs, context and intent. Do not regenerate, rewrite or compete with that factual scene summary.
When `knownCharacters` is supplied in the scene context, use it only as identity context for names already detected before analysis: When `knownCharacters` is supplied in the scene context, use it only as identity context for names already detected before analysis:
- match scene character names against `canonicalName` and `aliases`; - match scene character names against `canonicalName` and `aliases`;
@ -142,34 +144,6 @@ Use lower confidence when:
- the scene relies on context not supplied; - the scene relies on context not supplied;
- interpretation depends on subtext rather than explicit action. - interpretation depends on subtext rather than explicit action.
## Summary Rules
Write summaries as an archivist.
The short summary should:
- be one sentence;
- state the main scene event;
- avoid critique;
- avoid advice;
- usually be 12 to 30 words.
The detailed summary should:
- be factual;
- include only supported events;
- preserve uncertainty;
- mention major revelations or changes;
- avoid prose style evaluation.
Good summary style:
`Mara confronts Elias about the missing ledger, and he confirms it vanished after the council meeting.`
Bad summary style:
`This scene needs more tension and should make Elias more suspicious.`
## Metric Scoring Rules ## Metric Scoring Rules
Metrics are descriptive intensity scores. They are not quality ratings. Metrics are descriptive intensity scores. They are not quality ratings.
@ -579,11 +553,6 @@ Return exactly one JSON object matching this shape:
"sceneNumber": null, "sceneNumber": null,
"sourceLabel": "" "sourceLabel": ""
}, },
"summary": {
"short": "",
"detailed": "",
"confidence": 0.0
},
"scenePurpose": { "scenePurpose": {
"observedFunction": "", "observedFunction": "",
"confidence": 0.0 "confidence": 0.0

View File

@ -504,6 +504,8 @@ public sealed class StoryIntelligenceSceneImportItem
public string? PurposeNotes { get; init; } public string? PurposeNotes { get; init; }
public string? OutcomeNotes { get; init; } public string? OutcomeNotes { get; init; }
public string? ImportNoteText { get; init; } public string? ImportNoteText { get; init; }
public string AnalysisStatus { get; init; } = "Ready";
public bool HasWarnings { get; init; }
} }
public sealed class StoryIntelligenceImportCommitRequest public sealed class StoryIntelligenceImportCommitRequest

View File

@ -939,12 +939,14 @@ public sealed class OnboardingStoryIntelligenceService(
.OrderBy(scene => scene.TemporarySceneNumber) .OrderBy(scene => scene.TemporarySceneNumber)
.Select(scene => new StoryIntelligenceOnboardingScenePreviewViewModel .Select(scene => new StoryIntelligenceOnboardingScenePreviewViewModel
{ {
SceneID = scene.SceneID,
SceneNumber = scene.TemporarySceneNumber, SceneNumber = scene.TemporarySceneNumber,
Summary = Trim(FirstConfigured(scene.ParsedScene.Summary?.Short, scene.ParsedScene.Summary?.Detailed, $"Scene {scene.TemporarySceneNumber:N0}")!, 180), Summary = Trim(FirstConfigured(scene.Summary, scene.ParsedScene.Summary?.Short, scene.ParsedScene.Summary?.Detailed, $"Scene {scene.TemporarySceneNumber:N0}")!, 180),
Pov = scene.PovCharacterID.HasValue ? FirstConfigured(scene.ParsedScene.PointOfView?.CharacterName, scene.ParsedScene.PointOfView?.NarrativeMode) : null, Pov = scene.PovCharacterID.HasValue ? FirstConfigured(scene.ParsedScene.PointOfView?.CharacterName, scene.ParsedScene.PointOfView?.NarrativeMode) : null,
Setting = BuildSetting(scene.ParsedScene.Setting), Setting = BuildSetting(scene.ParsedScene.Setting),
Confidence = scene.ParsedScene.Summary?.Confidence ?? scene.ParsedScene.Setting?.Confidence, Confidence = scene.ParsedScene.Setting?.Confidence ?? scene.ParsedScene.PointOfView?.Confidence,
HasWarnings = false HasWarnings = scene.HasWarnings,
AnalysisStatus = scene.AnalysisStatus
}) })
.ToList(); .ToList();

View File

@ -692,12 +692,15 @@ public sealed class StoryIntelligenceAssetImportService(
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string assetName) private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string assetName)
{ {
var summary = Clean(parsed.Summary?.Short); var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
return string.IsNullOrWhiteSpace(summary) return string.IsNullOrWhiteSpace(summary)
? $"{BuildSceneReference(scene)}: {assetName}" ? $"{BuildSceneReference(scene)}: {assetName}"
: $"{BuildSceneReference(scene)}: {summary}"; : $"{BuildSceneReference(scene)}: {summary}";
} }
private static string? FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
private static string BuildSceneReference(Scene scene) private static string BuildSceneReference(Scene scene)
=> $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}"; => $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}";

View File

@ -735,10 +735,13 @@ public sealed class StoryIntelligenceCharacterImportService(
return $"{BuildSceneReference(scene)}: {note}"; return $"{BuildSceneReference(scene)}: {note}";
} }
var summary = Clean(parsed.Summary?.Short); var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
return string.IsNullOrWhiteSpace(summary) ? $"{BuildSceneReference(scene)}: {scene.SceneTitle}" : $"{BuildSceneReference(scene)}: {summary}"; return string.IsNullOrWhiteSpace(summary) ? $"{BuildSceneReference(scene)}: {scene.SceneTitle}" : $"{BuildSceneReference(scene)}: {summary}";
} }
private static string? FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
private static string BuildSceneReference(Scene scene) private static string BuildSceneReference(Scene scene)
=> $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}"; => $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}";

View File

@ -169,13 +169,18 @@ public sealed class StoryIntelligenceImportCommitService(
var parsedChapter = TryReadChapter(chapterResult); var parsedChapter = TryReadChapter(chapterResult);
var chapterSummary = Clean(parsedChapter?.ChapterSummary); var chapterSummary = Clean(parsedChapter?.ChapterSummary);
var chapterPurposeId = MatchChapterPurpose(parsedChapter, chapterPurposeMap); var chapterPurposeId = MatchChapterPurpose(parsedChapter, chapterPurposeMap);
var canonicalScenes = run.ChapterID.HasValue
? await storyRuns.ListChapterScenesAsync(run.ChapterID.Value)
: [];
var canonicalById = canonicalScenes.ToDictionary(scene => scene.SceneID);
var importScenes = new List<StoryIntelligenceSceneImportItem>(); var importScenes = new List<StoryIntelligenceSceneImportItem>();
foreach (var sceneResult in sceneResults.OrderBy(scene => scene.TemporarySceneNumber)) foreach (var sceneResult in sceneResults.OrderBy(scene => scene.TemporarySceneNumber))
{ {
canonicalById.TryGetValue(sceneResult.SceneID ?? 0, out var canonicalScene);
var parsed = TryReadScene(sceneResult); var parsed = TryReadScene(sceneResult);
if (parsed is null) if (parsed is null)
{ {
var fallback = BuildFallbackImportScene(sceneResult, metricMap); var fallback = BuildFallbackImportScene(sceneResult, metricMap, canonicalScene);
if (fallback is null) if (fallback is null)
{ {
warnings.Add($"Scene {sceneResult.TemporarySceneNumber:N0} could not be prepared automatically and needs review."); warnings.Add($"Scene {sceneResult.TemporarySceneNumber:N0} could not be prepared automatically and needs review.");
@ -187,7 +192,7 @@ public sealed class StoryIntelligenceImportCommitService(
continue; continue;
} }
importScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap)); importScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap, canonicalScene));
} }
if (importScenes.Count == 0) if (importScenes.Count == 0)
@ -223,12 +228,12 @@ public sealed class StoryIntelligenceImportCommitService(
ChapterLabel = chapterLabel, ChapterLabel = chapterLabel,
ExistingSceneCount = readiness?.ActiveSceneCount ?? 0, ExistingSceneCount = readiness?.ActiveSceneCount ?? 0,
CommitMode = readiness?.CommitMode ?? string.Empty, CommitMode = readiness?.CommitMode ?? string.Empty,
ScenesToCreate = importScenes.Count, ScenesToCreate = canonicalScenes.Count > 0 ? canonicalScenes.Count : importScenes.Count,
MetricsToImport = importScenes.Sum(scene => scene.Metrics.Count), MetricsToImport = importScenes.Sum(scene => scene.Metrics.Count),
MetricNames = importScenes.SelectMany(scene => scene.Metrics.Select(metric => metric.MetricName)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(name => name).ToList(), MetricNames = importScenes.SelectMany(scene => scene.Metrics.Select(metric => metric.MetricName)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(name => name).ToList(),
Warnings = warnings, Warnings = warnings,
Blockers = blockers, Blockers = blockers,
Scenes = importScenes Scenes = BuildReviewScenes(canonicalScenes, sceneResults, importScenes)
}, },
importScenes, importScenes,
timeModeId, timeModeId,
@ -244,7 +249,8 @@ public sealed class StoryIntelligenceImportCommitService(
SceneIntelligenceScene parsed, SceneIntelligenceScene parsed,
IReadOnlyDictionary<string, int> characterMap, IReadOnlyDictionary<string, int> characterMap,
IReadOnlyDictionary<string, int> purposeMap, IReadOnlyDictionary<string, int> purposeMap,
IReadOnlyDictionary<string, SceneMetricType> metricMap) IReadOnlyDictionary<string, SceneMetricType> metricMap,
Scene? canonicalScene = null)
{ {
var povName = Clean(parsed.PointOfView?.CharacterName); var povName = Clean(parsed.PointOfView?.CharacterName);
var povCharacterId = !string.IsNullOrWhiteSpace(povName) var povCharacterId = !string.IsNullOrWhiteSpace(povName)
@ -252,7 +258,7 @@ public sealed class StoryIntelligenceImportCommitService(
&& characterMap.TryGetValue(povName, out var characterId) && characterMap.TryGetValue(povName, out var characterId)
? characterId ? characterId
: (int?)null; : (int?)null;
var summary = Clean(parsed.Summary?.Short); var summary = CanonicalSummary(canonicalScene) ?? Clean(parsed.Summary?.Short);
var sceneTitle = GenerateTitle(sceneResult.TemporarySceneNumber, summary); var sceneTitle = GenerateTitle(sceneResult.TemporarySceneNumber, summary);
return new StoryIntelligenceSceneImportItem return new StoryIntelligenceSceneImportItem
@ -267,24 +273,28 @@ public sealed class StoryIntelligenceImportCommitService(
PovCharacterID = povCharacterId, PovCharacterID = povCharacterId,
PurposeTypeIDs = MatchPurposes(parsed, purposeMap), PurposeTypeIDs = MatchPurposes(parsed, purposeMap),
Metrics = BuildMetrics(parsed, metricMap), Metrics = BuildMetrics(parsed, metricMap),
SceneTitle = sceneTitle, SceneTitle = canonicalScene is not null && !string.IsNullOrWhiteSpace(canonicalScene.SceneTitle) ? canonicalScene.SceneTitle : sceneTitle,
Summary = string.IsNullOrWhiteSpace(summary) ? null : summary, Summary = string.IsNullOrWhiteSpace(summary) ? null : summary,
PurposeNotes = BuildPurposeNotes(parsed, sceneResult, povName, povCharacterId), PurposeNotes = BuildPurposeNotes(parsed, sceneResult, povName, povCharacterId),
OutcomeNotes = string.IsNullOrWhiteSpace(parsed.Summary?.Detailed) ? null : Clean(parsed.Summary.Detailed), OutcomeNotes = canonicalScene is null && !string.IsNullOrWhiteSpace(parsed.Summary?.Detailed) ? Clean(parsed.Summary.Detailed) : null,
ImportNoteText = BuildImportNote(parsed, povName, povCharacterId) ImportNoteText = BuildImportNote(parsed, povName, povCharacterId),
AnalysisStatus = sceneResult.ValidationErrorsCount > 0 ? "Needs review" : "Ready",
HasWarnings = sceneResult.ValidationWarningsCount > 0 || sceneResult.ValidationErrorsCount > 0
}; };
} }
private static StoryIntelligenceSceneImportItem? BuildFallbackImportScene( private static StoryIntelligenceSceneImportItem? BuildFallbackImportScene(
StoryIntelligenceSavedSceneResult sceneResult, StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<string, SceneMetricType> metricMap) IReadOnlyDictionary<string, SceneMetricType> metricMap,
Scene? canonicalScene = null)
{ {
if (!sceneResult.StartParagraph.HasValue || !sceneResult.EndParagraph.HasValue || sceneResult.StartParagraph > sceneResult.EndParagraph) if (!sceneResult.StartParagraph.HasValue || !sceneResult.EndParagraph.HasValue || sceneResult.StartParagraph > sceneResult.EndParagraph)
{ {
return null; return null;
} }
var summary = ExtractSummaryFromJsonText(sceneResult.OutputTextJson) var summary = CanonicalSummary(canonicalScene)
?? ExtractSummaryFromJsonText(sceneResult.OutputTextJson)
?? ExtractSummaryFromJsonText(sceneResult.ParsedJson) ?? ExtractSummaryFromJsonText(sceneResult.ParsedJson)
?? $"Scene {sceneResult.TemporarySceneNumber:N0} from paragraphs {DisplayRange(sceneResult.StartParagraph, sceneResult.EndParagraph)}."; ?? $"Scene {sceneResult.TemporarySceneNumber:N0} from paragraphs {DisplayRange(sceneResult.StartParagraph, sceneResult.EndParagraph)}.";
var parsed = new SceneIntelligenceScene var parsed = new SceneIntelligenceScene
@ -323,14 +333,82 @@ public sealed class StoryIntelligenceImportCommitService(
PovCharacterID = null, PovCharacterID = null,
PurposeTypeIDs = [], PurposeTypeIDs = [],
Metrics = [], Metrics = [],
SceneTitle = GenerateTitle(sceneResult.TemporarySceneNumber, summary), SceneTitle = canonicalScene is not null && !string.IsNullOrWhiteSpace(canonicalScene.SceneTitle) ? canonicalScene.SceneTitle : GenerateTitle(sceneResult.TemporarySceneNumber, summary),
Summary = summary, Summary = summary,
PurposeNotes = $"Story Intelligence import source: run {sceneResult.StoryIntelligenceRunID:N0}, paragraphs {DisplayRange(sceneResult.StartParagraph, sceneResult.EndParagraph)}.{Environment.NewLine}Some optional analysis could not be read, so this scene was created with limited detail.", PurposeNotes = $"Story Intelligence import source: run {sceneResult.StoryIntelligenceRunID:N0}, paragraphs {DisplayRange(sceneResult.StartParagraph, sceneResult.EndParagraph)}.{Environment.NewLine}Some optional analysis could not be read, so this scene was created with limited detail.",
OutcomeNotes = null, OutcomeNotes = null,
ImportNoteText = "Scene created with limited Story Intelligence detail. Review the source manuscript for POV, setting, metrics and timeline notes." ImportNoteText = "Scene created with limited Story Intelligence detail. Review the source manuscript for POV, setting, metrics and timeline notes.",
AnalysisStatus = "Analysis failed",
HasWarnings = true
}; };
} }
private static IReadOnlyList<StoryIntelligenceSceneImportItem> BuildReviewScenes(
IReadOnlyList<Scene> canonicalScenes,
IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults,
IReadOnlyList<StoryIntelligenceSceneImportItem> importScenes)
{
if (canonicalScenes.Count == 0)
{
return importScenes;
}
var resultsBySceneId = sceneResults
.Where(result => result.SceneID.HasValue)
.GroupBy(result => result.SceneID!.Value)
.ToDictionary(
group => group.Key,
group => group
.OrderBy(result => result.ValidationErrorsCount > 0)
.ThenByDescending(result => result.SceneResultID)
.First());
var importsBySceneId = importScenes
.Where(scene => scene.SceneID.HasValue)
.GroupBy(scene => scene.SceneID!.Value)
.ToDictionary(group => group.Key, group => group.First());
return canonicalScenes
.OrderBy(scene => scene.SortOrder)
.ThenBy(scene => scene.SceneNumber)
.ThenBy(scene => scene.SceneID)
.Select((scene, index) =>
{
var sceneNumber = SceneDisplayNumber(scene.SceneNumber, index + 1);
resultsBySceneId.TryGetValue(scene.SceneID, out var result);
importsBySceneId.TryGetValue(scene.SceneID, out var importScene);
var parsed = result is null ? null : TryReadScene(result);
var summary = CanonicalSummary(scene)
?? FirstConfigured(importScene?.Summary, parsed?.Summary?.Short, parsed?.Summary?.Detailed, $"Scene {sceneNumber:N0}")
?? $"Scene {sceneNumber:N0}";
return new StoryIntelligenceSceneImportItem
{
SceneResultID = result?.SceneResultID ?? 0,
SceneID = scene.SceneID,
TemporarySceneNumber = sceneNumber,
StartParagraph = result?.StartParagraph,
EndParagraph = result?.EndParagraph,
SourceLabel = result?.SourceLabel ?? $"Scene {sceneNumber:N0}",
ParsedScene = parsed ?? new SceneIntelligenceScene(),
PovCharacterID = importScene?.PovCharacterID,
PurposeTypeIDs = importScene?.PurposeTypeIDs ?? [],
Metrics = importScene?.Metrics ?? [],
SceneTitle = string.IsNullOrWhiteSpace(scene.SceneTitle) ? $"Scene {sceneNumber:N0}" : scene.SceneTitle,
Summary = summary,
PurposeNotes = importScene?.PurposeNotes,
OutcomeNotes = null,
ImportNoteText = importScene?.ImportNoteText,
AnalysisStatus = result is null
? "Analysis missing"
: result.ValidationErrorsCount > 0
? "Analysis failed"
: "Ready",
HasWarnings = result is null || result.ValidationErrorsCount > 0 || result.ValidationWarningsCount > 0
};
})
.ToList();
}
private async Task<Dictionary<string, int>> BuildCharacterMapAsync(int projectId) private async Task<Dictionary<string, int>> BuildCharacterMapAsync(int projectId)
{ {
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
@ -872,6 +950,17 @@ public sealed class StoryIntelligenceImportCommitService(
? $"Scene {sceneNumber:N0}" ? $"Scene {sceneNumber:N0}"
: $"Scene {sceneNumber:N0} - {TrimTo(summary, 60)}"; : $"Scene {sceneNumber:N0} - {TrimTo(summary, 60)}";
private static int SceneDisplayNumber(decimal sceneNumber, int ordinal)
=> decimal.Truncate(sceneNumber) == sceneNumber && sceneNumber > 0 && sceneNumber <= int.MaxValue
? Convert.ToInt32(sceneNumber)
: ordinal;
private static string? CanonicalSummary(Scene? scene)
=> scene is null ? null : FirstConfigured(scene.StructuralSummary, scene.Summary);
private static string? FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
private static string TrimTo(string value, int maxLength) private static string TrimTo(string value, int maxLength)
=> value.Length <= maxLength ? value : value[..maxLength].TrimEnd() + "..."; => value.Length <= maxLength ? value : value[..maxLength].TrimEnd() + "...";

View File

@ -616,7 +616,7 @@ public sealed class StoryIntelligenceKnowledgeImportService(
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed) private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed)
{ {
var summary = Clean(parsed.Summary?.Short); var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
return string.IsNullOrWhiteSpace(summary) return string.IsNullOrWhiteSpace(summary)
? BuildSceneReference(scene) ? BuildSceneReference(scene)
: $"{BuildSceneReference(scene)}: {summary}"; : $"{BuildSceneReference(scene)}: {summary}";

View File

@ -735,7 +735,7 @@ public sealed class StoryIntelligenceLocationImportService(
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string locationName) private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string locationName)
{ {
var summary = Clean(parsed.Summary?.Short); var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
return string.IsNullOrWhiteSpace(summary) return string.IsNullOrWhiteSpace(summary)
? $"{BuildSceneReference(scene)}: {locationName}" ? $"{BuildSceneReference(scene)}: {locationName}"
: $"{BuildSceneReference(scene)}: {summary}"; : $"{BuildSceneReference(scene)}: {summary}";

View File

@ -623,7 +623,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed) private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed)
{ {
var summary = Clean(parsed.Summary?.Short); var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
return string.IsNullOrWhiteSpace(summary) return string.IsNullOrWhiteSpace(summary)
? BuildSceneReference(scene) ? BuildSceneReference(scene)
: $"{BuildSceneReference(scene)}: {summary}"; : $"{BuildSceneReference(scene)}: {summary}";

View File

@ -71,7 +71,6 @@ public sealed class StorySceneValidator : IStorySceneValidator
} }
RequiredObject(scene.SceneReference, "sceneReference", result); RequiredObject(scene.SceneReference, "sceneReference", result);
RequiredObject(scene.Summary, "summary", result);
RequiredObject(scene.ScenePurpose, "scenePurpose", result); RequiredObject(scene.ScenePurpose, "scenePurpose", result);
RequiredObject(scene.PointOfView, "pointOfView", result); RequiredObject(scene.PointOfView, "pointOfView", result);
RequiredObject(scene.Setting, "setting", result); RequiredObject(scene.Setting, "setting", result);

View File

@ -159,7 +159,7 @@ public sealed class StoryIntelligenceProgressViewModel
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete); public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
public int CommittedChapterCount => Chapters.Count(chapter => chapter.HasCompletedCommit); public int CommittedChapterCount => Chapters.Count(chapter => chapter.HasCompletedCommit);
public int FailedChapterCount => Chapters.Count(chapter => chapter.IsFailed); public int FailedChapterCount => Chapters.Count(chapter => chapter.IsFailed);
public int TotalDetectedScenes => Chapters.Sum(chapter => chapter.TotalDetectedScenes ?? 0); public int TotalDetectedScenes => Chapters.Sum(chapter => chapter.Scenes.Count > 0 ? chapter.Scenes.Count : chapter.TotalDetectedScenes ?? 0);
public int TotalCompletedScenes => Chapters.Sum(chapter => chapter.CompletedScenes ?? 0); public int TotalCompletedScenes => Chapters.Sum(chapter => chapter.CompletedScenes ?? 0);
public int TotalFailedScenes => Chapters.Sum(chapter => chapter.FailedScenes ?? 0); public int TotalFailedScenes => Chapters.Sum(chapter => chapter.FailedScenes ?? 0);
public int TotalAnalysedWords => Chapters.Sum(chapter => chapter.SourceWordCount ?? 0); public int TotalAnalysedWords => Chapters.Sum(chapter => chapter.SourceWordCount ?? 0);
@ -635,12 +635,14 @@ public sealed class StoryIntelligenceOnboardingChapterViewModel
public sealed class StoryIntelligenceOnboardingScenePreviewViewModel public sealed class StoryIntelligenceOnboardingScenePreviewViewModel
{ {
public int? SceneID { get; init; }
public int SceneNumber { get; init; } public int SceneNumber { get; init; }
public string Summary { get; init; } = string.Empty; public string Summary { get; init; } = string.Empty;
public string? Pov { get; init; } public string? Pov { get; init; }
public string? Setting { get; init; } public string? Setting { get; init; }
public decimal? Confidence { get; init; } public decimal? Confidence { get; init; }
public bool HasWarnings { get; init; } public bool HasWarnings { get; init; }
public string AnalysisStatus { get; init; } = "Ready";
} }
public sealed class StoryIntelligenceDashboardViewModel public sealed class StoryIntelligenceDashboardViewModel

View File

@ -144,7 +144,7 @@
<strong>Scene @scene.SceneNumber.ToString("N0")</strong> <strong>Scene @scene.SceneNumber.ToString("N0")</strong>
@if (scene.HasWarnings) @if (scene.HasWarnings)
{ {
<span class="story-review-status story-review-status--active">Review recommended</span> <span class="story-review-status story-review-status--active">@scene.AnalysisStatus</span>
} }
</div> </div>
<p>@scene.Summary</p> <p>@scene.Summary</p>
@ -208,15 +208,17 @@
|| (!chapter.CanCommit && !chapter.HasCompletedCommit && chapter.IsRunComplete); || (!chapter.CanCommit && !chapter.HasCompletedCommit && chapter.IsRunComplete);
private static int DetectedCount(StoryIntelligenceOnboardingChapterViewModel chapter) private static int DetectedCount(StoryIntelligenceOnboardingChapterViewModel chapter)
=> Math.Max(chapter.TotalDetectedScenes ?? 0, ReadyCount(chapter) + Math.Max(chapter.FailedScenes ?? 0, 0)); => chapter.Scenes.Count > 0 ? chapter.Scenes.Count : chapter.TotalDetectedScenes ?? 0;
private static int ReadyCount(StoryIntelligenceOnboardingChapterViewModel chapter) private static int ReadyCount(StoryIntelligenceOnboardingChapterViewModel chapter)
=> chapter.HasCompletedCommit ? chapter.ScenesCreated : chapter.Scenes.Count; => chapter.HasCompletedCommit ? chapter.ScenesCreated : chapter.Scenes.Count(scene => !scene.HasWarnings);
private static int RejectedCount(StoryIntelligenceOnboardingChapterViewModel chapter) => 0; private static int RejectedCount(StoryIntelligenceOnboardingChapterViewModel chapter) => 0;
private static int NeedsReviewCount(StoryIntelligenceOnboardingChapterViewModel chapter) private static int NeedsReviewCount(StoryIntelligenceOnboardingChapterViewModel chapter)
=> Math.Max(0, DetectedCount(chapter) - ReadyCount(chapter) - RejectedCount(chapter)); => chapter.Scenes.Count > 0
? chapter.Scenes.Count(scene => scene.HasWarnings)
: Math.Max(0, DetectedCount(chapter) - ReadyCount(chapter) - RejectedCount(chapter));
private static string FriendlyStatus(StoryIntelligenceOnboardingChapterViewModel chapter) private static string FriendlyStatus(StoryIntelligenceOnboardingChapterViewModel chapter)
{ {