Persist scene source and correct import order

This commit is contained in:
Nick Beckley 2026-08-25 12:55:22 +00:00
parent 998c301d65
commit 0e75a95625
11 changed files with 541 additions and 39 deletions

View File

@ -64,6 +64,9 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence progress does not prematurely report safe close", StoryIntelligenceProgressDoesNotPrematurelyReportSafeClose),
("Structural import creates scenes before Word sync", StructuralImportCreatesScenesBeforeWordSync),
("Structural import persists paragraph mapping and source text", StructuralImportPersistsParagraphMappingAndSourceText),
("Structural import persists canonical scene source text", StructuralImportPersistsCanonicalSceneSourceText),
("Core import wizard orders scenes before characters", CoreImportWizardOrdersScenesBeforeCharacters),
("Story Intelligence uses persisted scene source after restart", StoryIntelligenceUsesPersistedSceneSourceAfterRestart),
("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement),
("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes),
("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding),
@ -1361,6 +1364,55 @@ static void StructuralImportPersistsParagraphMappingAndSourceText()
Assert(onboarding.Contains("SaveStructuralChapterSourcesAsync", StringComparison.Ordinal), "Onboarding import should save source text after canonical ChapterIDs exist.");
}
static void StructuralImportPersistsCanonicalSceneSourceText()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var migration = File.ReadAllText(Path.Combine(root, "Sql/150_Phase24B_SceneManuscriptSources.sql"));
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
var analyzer = File.ReadAllText(Path.Combine(root, "Services/ManuscriptStructureAnalysisService.cs"));
Assert(migration.Contains("CREATE TABLE dbo.SceneManuscriptSources", StringComparison.Ordinal), "Scene manuscript source text should be stored in a canonical SceneID table.");
Assert(migration.Contains("SceneID int NOT NULL CONSTRAINT PK_SceneManuscriptSources PRIMARY KEY", StringComparison.Ordinal), "Scene source text should be keyed by SceneID.");
Assert(migration.Contains("IX_SceneManuscriptSources_BookChapter", StringComparison.Ordinal), "Scene source text should be indexed for book/chapter retrieval.");
Assert(onboarding.Contains("SaveSceneManuscriptSourcesAsync", StringComparison.Ordinal), "Structural import should persist scene source text before Word sync.");
Assert(onboarding.IndexOf("await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result)", StringComparison.Ordinal)
< onboarding.IndexOf("await PublishBuildProgress(userId, preview.PreviewID, \"Updating and saving Word markers", StringComparison.Ordinal), "Scene source text should be persisted before marker sync can complete.");
Assert(analyzer.Contains("string.Join(Environment.NewLine + Environment.NewLine", StringComparison.Ordinal), "Scene source text should preserve paragraph separation.");
Assert(analyzer.Contains("item.IncludeInSourceText", StringComparison.Ordinal), "Scene source text should exclude non-prose structural paragraphs such as hidden markers/separators.");
}
static void CoreImportWizardOrdersScenesBeforeCharacters()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var review = File.ReadAllText(Path.Combine(root, "Views/Onboarding/ScanReview.cshtml"));
var complete = File.ReadAllText(Path.Combine(root, "Views/Onboarding/BuildComplete.cshtml"));
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
Assert(review.Contains("value=\"structure\"", StringComparison.Ordinal), "Chapter review should submit to structural import, not character review.");
Assert(controller.Contains("var readyToImport = string.Equals(intent, \"structure\"", StringComparison.Ordinal), "Controller should treat structure intent as the scene-creation gate.");
Assert(controller.Contains("FinaliseCharacterReviewAsync", StringComparison.Ordinal), "Character review should have a distinct post-structure finalisation path.");
Assert(onboarding.Contains("Import the approved chapters and scenes before reviewing characters.", StringComparison.Ordinal), "Character finalisation should refuse to run before structural scenes exist.");
Assert(complete.Contains("Review characters", StringComparison.Ordinal), "After Word sync, the next onboarding step should be character review.");
Assert(complete.Contains("Word may now be closed; keep this browser workflow open", StringComparison.Ordinal), "Word safe-close must be distinct from browser/core-import completion.");
}
static void StoryIntelligenceUsesPersistedSceneSourceAfterRestart()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var migration = File.ReadAllText(Path.Combine(root, "Sql/150_Phase24B_SceneManuscriptSources.sql"));
var sourceRepository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceSourceRepository.cs"));
var onboardingStory = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var existingQueue = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceExistingChapterQueueService.cs"));
Assert(migration.Contains("INNER JOIN dbo.SceneManuscriptSources", StringComparison.Ordinal), "Chapter source SQL should aggregate persisted canonical scene source text.");
Assert(migration.Contains("STRING_AGG", StringComparison.Ordinal), "Chapter source SQL should reconstruct chapter text from persisted scene text in order.");
Assert(sourceRepository.Contains("StoryIntelligenceSource_ChapterText_GetAdmin", StringComparison.Ordinal), "Story Intelligence source repository should use the stored source procedure.");
Assert(onboardingStory.Contains("durableSource = await sources.GetChapterSourceAsync", StringComparison.Ordinal), "Onboarding Story Intelligence should read durable chapter text from scene sources.");
Assert(onboardingStory.Contains("CoreImportComplete", StringComparison.Ordinal), "Optional onboarding Story Intelligence should require core import completion.");
Assert(existingQueue.Contains("sources.GetChapterSourceAsync", StringComparison.Ordinal), "Later Story Intelligence queueing should start from persisted chapter/scene source, not onboarding DTOs.");
}
static void WordSyncRequiresSaveAcknowledgement()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -526,10 +526,23 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
var continueToCharacters = string.Equals(intent, "characters", StringComparison.OrdinalIgnoreCase);
var continueCharacterReview = string.Equals(intent, "character-next", StringComparison.OrdinalIgnoreCase);
var backCharacterReview = string.Equals(intent, "character-back", StringComparison.OrdinalIgnoreCase);
var readyToImport = string.Equals(intent, "continue", StringComparison.OrdinalIgnoreCase);
var readyToImport = string.Equals(intent, "structure", StringComparison.OrdinalIgnoreCase);
var completeCoreImport = string.Equals(intent, "continue", StringComparison.OrdinalIgnoreCase);
var nextCharacterPage = Math.Max(1, characterPage + (backCharacterReview ? -1 : 1));
try
{
if (completeCoreImport)
{
var result = await onboarding.FinaliseCharacterReviewAsync(form);
if (result is null)
{
return NotFound();
}
TempData["OnboardingReviewMessage"] = "Characters saved. Core manuscript import is complete.";
return RedirectToAction(nameof(BuildComplete), new { previewId = form.PreviewID });
}
var model = await onboarding.SaveScanReviewAsync(form, readyToImport);
if (model is null)
{
@ -537,12 +550,12 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
}
TempData["OnboardingReviewMessage"] = readyToImport
? "Review saved. Next: story and portrait context."
? "Chapter review saved. Next: import scenes and update Word."
: continueToCharacters ? "Chapter and scene review saved. Next: review detected characters."
: continueCharacterReview || backCharacterReview ? "Character review choices saved."
: "Review choices saved.";
return readyToImport
? RedirectToAction(nameof(StoryIntelligence))
? RedirectToAction(nameof(Index))
: continueCharacterReview || backCharacterReview ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID, page = nextCharacterPage })
: continueToCharacters ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID })
: RedirectToAction(nameof(ScanReview), new { previewId = form.PreviewID });

View File

@ -12,6 +12,7 @@ public interface IOnboardingBuildRepository
Task MarkMarkerSyncCompletedAsync(int buildId);
Task MarkMarkerSyncFailedAsync(int buildId, string message);
Task SaveStructuralChapterSourceAsync(StructuralChapterSourceSaveRequest request);
Task SaveSceneManuscriptSourceAsync(SceneManuscriptSourceSaveRequest request);
}
public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFactory) : IOnboardingBuildRepository
@ -110,6 +111,29 @@ public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFa
},
commandType: CommandType.StoredProcedure);
}
public async Task SaveSceneManuscriptSourceAsync(SceneManuscriptSourceSaveRequest request)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.SceneManuscriptSource_Save",
new
{
request.SceneID,
request.ProjectID,
request.BookID,
request.ChapterID,
request.SourceText,
request.SourceWordCount,
request.SourceCharacterCount,
request.SourceStartParagraph,
request.SourceEndParagraph,
request.WordStartParagraphIndex,
request.WordEndParagraphIndex,
request.SourceKind
},
commandType: CommandType.StoredProcedure);
}
}
public sealed class OnboardingManuscriptBuildRequest
@ -157,3 +181,19 @@ public sealed class StructuralChapterSourceSaveRequest
public int SourceCharacterCount { get; init; }
public string? ParagraphMapJson { get; init; }
}
public sealed class SceneManuscriptSourceSaveRequest
{
public int SceneID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public int ChapterID { get; init; }
public string SourceText { get; init; } = string.Empty;
public int SourceWordCount { get; init; }
public int SourceCharacterCount { get; init; }
public int? SourceStartParagraph { get; init; }
public int? SourceEndParagraph { get; init; }
public int? WordStartParagraphIndex { get; init; }
public int? WordEndParagraphIndex { get; init; }
public string SourceKind { get; init; } = "StructuralImport";
}

View File

@ -19,6 +19,7 @@ public static class ManuscriptScanReviewStatuses
public const string ReviewInProgress = "ReviewInProgress";
public const string ReviewComplete = "ReviewComplete";
public const string ReadyToImport = "ReadyToImport";
public const string CoreImportComplete = "CoreImportComplete";
}
public sealed class ManuscriptScanCommand
@ -79,6 +80,7 @@ public sealed class ManuscriptScanChapterPreview
public int? StartPosition { get; init; }
public int? ExistingChapterID { get; init; }
public IReadOnlyList<ManuscriptScanParagraphMap> ParagraphMap { get; init; } = [];
public IReadOnlyList<ManuscriptScanSourceParagraph> SourceParagraphs { get; init; } = [];
}
public sealed class ManuscriptScanParagraphMap
@ -88,6 +90,17 @@ public sealed class ManuscriptScanParagraphMap
public string Text { get; init; } = string.Empty;
}
public sealed class ManuscriptScanSourceParagraph
{
public int WordParagraphIndex { get; init; }
public string Text { get; init; } = string.Empty;
public bool IsBlank { get; init; }
public bool IsChapterHeading { get; init; }
public bool IsSceneHeading { get; init; }
public bool IsSceneSeparator { get; init; }
public bool IncludeInSourceText { get; init; }
}
public sealed class ManuscriptScanScenePreview
{
public string TemporarySceneKey { get; init; } = string.Empty;

View File

@ -72,7 +72,8 @@ public sealed class ManuscriptStructureAnalysisService(
}
var startMap = request.ParagraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == start);
if (startMap is null || startMap.WordParagraphIndex < 0)
var endMap = request.ParagraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == end);
if (startMap is null || endMap is null || startMap.WordParagraphIndex < 0 || endMap.WordParagraphIndex < 0)
{
return ManuscriptStructureAnalysisResult.Failed(
request.TemporaryChapterKey,
@ -81,6 +82,7 @@ public sealed class ManuscriptStructureAnalysisService(
}
var sceneParagraphs = paragraphs.Skip(start - 1).Take(end - start + 1).ToList();
var sourceText = BuildSourceText(request.SourceParagraphs, startMap.WordParagraphIndex, endMap.WordParagraphIndex);
scenes.Add(new ManuscriptStructureSceneBoundary
{
TemporarySceneKey = $"{request.TemporaryChapterKey}-ai-scene-{sceneNumber}",
@ -88,9 +90,11 @@ public sealed class ManuscriptStructureAnalysisService(
SceneNumberWithinChapter = sceneNumber,
Title = $"Scene {sceneNumber:N0}",
WordCount = sceneParagraphs.Sum(CountWords),
SourceText = sourceText,
StartParagraph = start,
EndParagraph = end,
WordStartPosition = startMap.WordParagraphIndex,
WordEndPosition = endMap.WordParagraphIndex,
Reason = boundary.Reason,
Confidence = boundary.Confidence
});
@ -157,6 +161,29 @@ public sealed class ManuscriptStructureAnalysisService(
return outputText.Trim();
}
private static string BuildSourceText(IReadOnlyList<ManuscriptScanSourceParagraph> sourceParagraphs, int wordStartPosition, int wordEndPosition)
{
var lines = sourceParagraphs
.Where(item => item.WordParagraphIndex >= wordStartPosition
&& item.WordParagraphIndex <= wordEndPosition
&& item.IncludeInSourceText)
.OrderBy(item => item.WordParagraphIndex)
.Select(item => item.IsBlank ? string.Empty : item.Text.Trim())
.ToList();
while (lines.Count > 0 && string.IsNullOrWhiteSpace(lines[0]))
{
lines.RemoveAt(0);
}
while (lines.Count > 0 && string.IsNullOrWhiteSpace(lines[^1]))
{
lines.RemoveAt(lines.Count - 1);
}
return string.Join(Environment.NewLine + Environment.NewLine, lines);
}
private static string BuildValidationMessage(ValidationResult validation)
{
var first = validation.Errors.FirstOrDefault()?.Message
@ -180,6 +207,7 @@ public sealed class ManuscriptStructureAnalysisRequest
public string SourceLabel { get; init; } = string.Empty;
public string ChapterText { get; init; } = string.Empty;
public IReadOnlyList<ManuscriptScanParagraphMap> ParagraphMap { get; init; } = [];
public IReadOnlyList<ManuscriptScanSourceParagraph> SourceParagraphs { get; init; } = [];
}
public sealed class ManuscriptStructureAnalysisResult
@ -204,9 +232,11 @@ public sealed class ManuscriptStructureSceneBoundary
public int SceneNumberWithinChapter { get; init; }
public string Title { get; init; } = string.Empty;
public int WordCount { get; init; }
public string SourceText { get; init; } = string.Empty;
public int StartParagraph { get; init; }
public int EndParagraph { get; init; }
public int WordStartPosition { get; init; }
public int WordEndPosition { get; init; }
public string? Reason { get; init; }
public decimal? Confidence { get; init; }
}

View File

@ -11,6 +11,7 @@ public interface IOnboardingService
Task<OnboardingWizardViewModel> GetWizardAsync();
Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null);
Task<ManuscriptScanReviewViewModel?> SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport);
Task<OnboardingManuscriptBuildResult?> FinaliseCharacterReviewAsync(ManuscriptScanReviewForm form);
Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, Func<OnboardingBuildProgress, Task>? progress = null);
Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId);
Task<bool> ShouldShowOnboardingAsync();
@ -262,7 +263,8 @@ public sealed class OnboardingService(
ChapterNumber = chapterDecision.ChapterNumber,
SourceLabel = $"{preview.DocumentTitle ?? "Word manuscript"}: {chapterDecision.Title}",
ChapterText = chapterPreview.ChapterText,
ParagraphMap = chapterPreview.ParagraphMap
ParagraphMap = chapterPreview.ParagraphMap,
SourceParagraphs = chapterPreview.SourceParagraphs
});
if (!analysis.Success)
@ -274,11 +276,6 @@ public sealed class OnboardingService(
}
await PublishBuildProgress(userId, preview.PreviewID, "Preparing chapters and scenes for import...", 45, progress);
var characterDecisions = review.Characters
.Where(IsIncludedCanonicalCharacter)
.OrderBy(character => character.Name)
.ThenBy(character => character.TemporaryCharacterKey)
.ToList();
var request = new OnboardingManuscriptBuildRequest
{
PreviewID = preview.PreviewID,
@ -300,12 +297,7 @@ public sealed class OnboardingService(
SortOrder = scene.SceneNumberWithinChapter * 10,
WordCount = scene.WordCount
}).ToList(),
Characters = characterDecisions.Select(character => new OnboardingBuildCharacterRequest
{
TemporaryCharacterKey = character.TemporaryCharacterKey,
Name = character.Name,
ExistingCharacterID = character.ExistingCharacterID
}).ToList()
Characters = []
};
await PublishBuildProgress(userId, preview.PreviewID, "Creating PlotDirector structure...", 60, progress);
@ -335,7 +327,7 @@ public sealed class OnboardingService(
MarkerWarning = result.MarkerWarning
};
await SaveStructuralChapterSourcesAsync(preview, result);
await AddScanCharacterAliasesAsync(review, result);
await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result);
await scanPreviews.SaveBuildResultAsync(userId, result);
await PublishBuildProgress(userId, preview.PreviewID, "Updating and saving Word markers. Keep Word and this browser page open.", 90, progress);
return result;
@ -344,6 +336,113 @@ public sealed class OnboardingService(
public async Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId)
=> await scanPreviews.GetBuildResultAsync(RequireUserId(), previewId);
public async Task<OnboardingManuscriptBuildResult?> FinaliseCharacterReviewAsync(ManuscriptScanReviewForm form)
{
var context = await GetScanReviewContextAsync(form.PreviewID);
if (context is null)
{
return null;
}
var (userId, preview, review, _, _, existingCharacters) = context.Value;
var build = await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID);
if (build is null || build.SceneMappings.Count == 0)
{
throw new InvalidOperationException("Import the approved chapters and scenes before reviewing characters.");
}
var candidateLookup = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
var existingCharacterIds = existingCharacters.Select(character => character.CharacterID).ToHashSet();
var characterDecisions = form.Characters
.Where(item => candidateLookup.ContainsKey(item.TemporaryCharacterKey))
.Select(item =>
{
var cleanName = Clean(item.Name);
var aliasTargetKey = CleanOptional(item.AliasTargetTemporaryCharacterKey);
if (!string.IsNullOrWhiteSpace(aliasTargetKey)
&& (!candidateLookup.ContainsKey(aliasTargetKey) || string.Equals(aliasTargetKey, item.TemporaryCharacterKey, StringComparison.Ordinal)))
{
aliasTargetKey = null;
}
var matchedExistingId = string.IsNullOrWhiteSpace(aliasTargetKey) && item.ExistingCharacterID.HasValue && existingCharacterIds.Contains(item.ExistingCharacterID.Value)
? item.ExistingCharacterID
: string.IsNullOrWhiteSpace(aliasTargetKey) ? MatchExistingCharacter(cleanName, existingCharacters)?.CharacterID : null;
return new ManuscriptScanCharacterReviewDecision
{
TemporaryCharacterKey = item.TemporaryCharacterKey,
Include = item.Include,
Name = cleanName,
Category = item.Category,
ExistingCharacterID = matchedExistingId,
AliasTargetTemporaryCharacterKey = aliasTargetKey,
SexHint = CharacterSexHintService.Normalise(item.SexHint, cleanName)
};
})
.ToList();
var characterMappings = new List<OnboardingBuildCharacterMap>();
foreach (var decision in characterDecisions.Where(IsIncludedCanonicalCharacter).OrderBy(item => item.Name).ThenBy(item => item.TemporaryCharacterKey))
{
var characterId = decision.ExistingCharacterID
?? MatchExistingCharacter(decision.Name, existingCharacters)?.CharacterID
?? await characters.SaveCharacterAsync(new Character
{
ProjectID = preview.ProjectID,
CharacterName = decision.Name,
Sex = string.IsNullOrWhiteSpace(decision.SexHint) ? null : decision.SexHint,
CharacterImportance = 8,
ShowInQuickAddBar = true
});
characterMappings.Add(new OnboardingBuildCharacterMap
{
TemporaryCharacterKey = decision.TemporaryCharacterKey,
CharacterID = characterId,
Created = !decision.ExistingCharacterID.HasValue
&& existingCharacters.All(character => character.CharacterID != characterId)
});
}
var completedReview = new ManuscriptScanReviewDecision
{
PreviewID = review.PreviewID,
Status = ManuscriptScanReviewStatuses.CoreImportComplete,
Chapters = review.Chapters,
Scenes = review.Scenes,
Characters = characterDecisions
};
var completedResult = new OnboardingManuscriptBuildResult
{
BuildID = build.BuildID,
PreviewID = build.PreviewID,
ProjectID = build.ProjectID,
BookID = build.BookID,
ManuscriptDocumentID = build.ManuscriptDocumentID,
Status = "CoreImportComplete",
MarkerStatus = build.MarkerStatus,
ChaptersCreated = build.ChaptersCreated,
ScenesCreated = build.ScenesCreated,
CharactersCreated = characterMappings.Count(item => item.Created),
CharactersReused = characterMappings.Count(item => !item.Created),
SceneAppearancesCreated = build.SceneAppearancesCreated,
AlreadyBuilt = build.AlreadyBuilt,
Message = "Core manuscript import complete.",
ChapterMappings = build.ChapterMappings,
SceneMappings = build.SceneMappings,
CharacterMappings = characterMappings,
ChapterMarkerInstructions = build.ChapterMarkerInstructions,
SceneMarkerInstructions = build.SceneMarkerInstructions,
MarkerWarning = build.MarkerWarning
};
await scanPreviews.SaveReviewAsync(userId, completedReview);
await scanPreviews.SaveBuildResultAsync(userId, completedResult);
await AddScanCharacterAliasesAsync(completedReview, completedResult);
return completedResult;
}
private static IReadOnlyList<OnboardingBuildMarkerInstruction> BuildChapterMarkerInstructions(
ManuscriptScanPreview preview,
IReadOnlyList<OnboardingBuildChapterMap> mappings)
@ -414,6 +513,43 @@ public sealed class OnboardingService(
}
}
private async Task SaveSceneManuscriptSourcesAsync(
ManuscriptScanPreview preview,
IReadOnlyList<ManuscriptStructureSceneBoundary> structuralScenes,
OnboardingManuscriptBuildResult result)
{
var sceneIds = result.SceneMappings.ToDictionary(item => item.TemporarySceneKey, item => item.SceneID, StringComparer.Ordinal);
var chapterIds = result.ChapterMappings.ToDictionary(item => item.TemporaryChapterKey, item => item.ChapterID, StringComparer.Ordinal);
foreach (var scene in structuralScenes)
{
if (!sceneIds.TryGetValue(scene.TemporarySceneKey, out var sceneId)
|| !chapterIds.TryGetValue(scene.TemporaryChapterKey, out var chapterId))
{
throw new InvalidOperationException("The imported scene source could not be matched to a canonical PlotDirector scene.");
}
if (string.IsNullOrWhiteSpace(scene.SourceText))
{
throw new InvalidOperationException($"Scene {scene.SceneNumberWithinChapter:N0} source text could not be persisted because the structural source text is empty.");
}
await builds.SaveSceneManuscriptSourceAsync(new SceneManuscriptSourceSaveRequest
{
SceneID = sceneId,
ProjectID = preview.ProjectID,
BookID = preview.BookID,
ChapterID = chapterId,
SourceText = scene.SourceText,
SourceWordCount = CountWords(scene.SourceText),
SourceCharacterCount = scene.SourceText.Length,
SourceStartParagraph = scene.StartParagraph,
SourceEndParagraph = scene.EndParagraph,
WordStartParagraphIndex = scene.WordStartPosition,
WordEndParagraphIndex = scene.WordEndPosition
});
}
}
private async Task PublishBuildProgress(int userId, Guid previewId, string message, int percent, Func<OnboardingBuildProgress, Task>? publish, OnboardingManuscriptBuildResult? result = null)
{
var state = await scanPreviews.BuildProgressAsync(userId, previewId, message, percent, result);

View File

@ -39,6 +39,7 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligenceAssetImportService assetImport,
IStoryIntelligenceRelationshipImportService relationshipImport,
IStoryIntelligenceKnowledgeImportService knowledgeImport,
IStoryIntelligenceSourceRepository sources,
IStoryIntelligencePipelineStateService pipelineState,
IStoryMemoryRepository storyMemory,
IStoryIntelligenceClient client,
@ -108,8 +109,10 @@ public sealed class OnboardingStoryIntelligenceService(
return existing;
}
build ??= await onboarding.BuildApprovedStructureAsync(preview.PreviewID)
?? throw new InvalidOperationException("The approved chapters could not be prepared for analysis.");
if (build is null || build.SceneMappings.Count == 0)
{
throw new InvalidOperationException("Import the manuscript structure before starting Story Intelligence.");
}
var chapterIds = build.ChapterMappings.ToDictionary(chapter => chapter.TemporaryChapterKey, chapter => chapter.ChapterID, StringComparer.Ordinal);
var included = IncludedChapters(preview, review);
@ -118,11 +121,6 @@ public sealed class OnboardingStoryIntelligenceService(
throw new InvalidOperationException("Choose at least one chapter before analysing the manuscript.");
}
if (included.Any(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText)))
{
throw new InvalidOperationException("The Word Companion scan did not include chapter text. Scan the manuscript again before analysing it.");
}
var clientStatus = client.GetConfigurationStatus();
var modelSummary = StoryIntelligenceOptions.BuildModelSummary(
clientStatus.ChapterStructureModel,
@ -142,7 +140,18 @@ public sealed class OnboardingStoryIntelligenceService(
throw new InvalidOperationException($"PlotDirector could not prepare chapter '{chapter.Decision.Title}' for analysis.");
}
var text = chapter.Preview.ChapterText!.Trim();
var durableSource = await sources.GetChapterSourceAsync(chapterId);
var text = durableSource?.SourceText?.Trim();
if (string.IsNullOrWhiteSpace(text))
{
text = chapter.Preview.ChapterText?.Trim();
}
if (string.IsNullOrWhiteSpace(text))
{
throw new InvalidOperationException($"PlotDirector does not have persisted scene source text for chapter '{chapter.Decision.Title}'.");
}
var paragraphs = StoryIntelligenceParagraphs.Split(text);
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
{
@ -155,8 +164,8 @@ public sealed class OnboardingStoryIntelligenceService(
SourceLabel = $"{wizard.SelectedBookTitle}: {chapter.Decision.Title}",
ChapterText = text,
SourceFileName = preview.DocumentTitle,
SourceWordCount = CountWords(text),
SourceCharacterCount = text.Length,
SourceWordCount = durableSource?.SourceWordCount ?? CountWords(text),
SourceCharacterCount = durableSource?.SourceCharacterCount ?? text.Length,
SourceParagraphCount = paragraphs.Count,
SourceChapterCount = 1,
Model = modelSummary,
@ -838,7 +847,7 @@ public sealed class OnboardingStoryIntelligenceService(
}
var review = await scanStore.GetReviewAsync(userId, preview.PreviewID);
if (review is null || !string.Equals(review.Status, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
if (review is null || !string.Equals(review.Status, ManuscriptScanReviewStatuses.CoreImportComplete, StringComparison.Ordinal))
{
return null;
}

View File

@ -0,0 +1,152 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.SceneManuscriptSources', N'U') IS NULL
BEGIN
CREATE TABLE dbo.SceneManuscriptSources
(
SceneID int NOT NULL CONSTRAINT PK_SceneManuscriptSources PRIMARY KEY,
ProjectID int NOT NULL,
BookID int NOT NULL,
ChapterID int NOT NULL,
SourceText nvarchar(max) NOT NULL,
SourceWordCount int NOT NULL,
SourceCharacterCount int NOT NULL,
SourceStartParagraph int NULL,
SourceEndParagraph int NULL,
WordStartParagraphIndex int NULL,
WordEndParagraphIndex int NULL,
SourceKind nvarchar(80) NOT NULL CONSTRAINT DF_SceneManuscriptSources_SourceKind DEFAULT N'StructuralImport',
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_SceneManuscriptSources_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_SceneManuscriptSources_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_SceneManuscriptSources_Scenes FOREIGN KEY (SceneID) REFERENCES dbo.Scenes(SceneID),
CONSTRAINT FK_SceneManuscriptSources_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID),
CONSTRAINT FK_SceneManuscriptSources_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID),
CONSTRAINT FK_SceneManuscriptSources_Chapters FOREIGN KEY (ChapterID) REFERENCES dbo.Chapters(ChapterID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_SceneManuscriptSources_BookChapter' AND object_id = OBJECT_ID(N'dbo.SceneManuscriptSources'))
CREATE INDEX IX_SceneManuscriptSources_BookChapter ON dbo.SceneManuscriptSources(BookID, ChapterID, SceneID);
GO
CREATE OR ALTER PROCEDURE dbo.SceneManuscriptSource_Save
@SceneID int,
@ProjectID int,
@BookID int,
@ChapterID int,
@SourceText nvarchar(max),
@SourceWordCount int,
@SourceCharacterCount int,
@SourceStartParagraph int = NULL,
@SourceEndParagraph int = NULL,
@WordStartParagraphIndex int = NULL,
@WordEndParagraphIndex int = NULL,
@SourceKind nvarchar(80) = N'StructuralImport'
AS
BEGIN
SET NOCOUNT ON;
MERGE dbo.SceneManuscriptSources AS target
USING (SELECT @SceneID AS SceneID) AS source
ON target.SceneID = source.SceneID
WHEN MATCHED THEN
UPDATE SET ProjectID = @ProjectID,
BookID = @BookID,
ChapterID = @ChapterID,
SourceText = @SourceText,
SourceWordCount = @SourceWordCount,
SourceCharacterCount = @SourceCharacterCount,
SourceStartParagraph = @SourceStartParagraph,
SourceEndParagraph = @SourceEndParagraph,
WordStartParagraphIndex = @WordStartParagraphIndex,
WordEndParagraphIndex = @WordEndParagraphIndex,
SourceKind = @SourceKind,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT
(
SceneID, ProjectID, BookID, ChapterID, SourceText, SourceWordCount, SourceCharacterCount,
SourceStartParagraph, SourceEndParagraph, WordStartParagraphIndex, WordEndParagraphIndex, SourceKind
)
VALUES
(
@SceneID, @ProjectID, @BookID, @ChapterID, @SourceText, @SourceWordCount, @SourceCharacterCount,
@SourceStartParagraph, @SourceEndParagraph, @WordStartParagraphIndex, @WordEndParagraphIndex, @SourceKind
);
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneManuscriptSource_GetByScene
@SceneID int
AS
BEGIN
SET NOCOUNT ON;
SELECT SceneID, ProjectID, BookID, ChapterID, SourceText, SourceWordCount, SourceCharacterCount,
SourceStartParagraph, SourceEndParagraph, WordStartParagraphIndex, WordEndParagraphIndex, SourceKind,
CreatedUtc, UpdatedUtc
FROM dbo.SceneManuscriptSources
WHERE SceneID = @SceneID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneManuscriptSource_ListByChapter
@ChapterID int
AS
BEGIN
SET NOCOUNT ON;
SELECT source.SceneID, source.ProjectID, source.BookID, source.ChapterID, source.SourceText,
source.SourceWordCount, source.SourceCharacterCount, source.SourceStartParagraph, source.SourceEndParagraph,
source.WordStartParagraphIndex, source.WordEndParagraphIndex, source.SourceKind, source.CreatedUtc, source.UpdatedUtc
FROM dbo.SceneManuscriptSources source
INNER JOIN dbo.Scenes scene ON scene.SceneID = source.SceneID
WHERE source.ChapterID = @ChapterID
AND scene.IsArchived = 0
ORDER BY scene.SortOrder, scene.SceneNumber, scene.SceneID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceSource_ChapterText_GetAdmin
@ChapterID int
AS
BEGIN
SET NOCOUNT ON;
SELECT
p.ProjectID,
p.ProjectName,
b.BookID,
b.BookTitle,
b.Subtitle AS BookSubtitle,
c.ChapterID,
c.ChapterNumber,
c.ChapterTitle,
sourceText.SourceText,
sourceText.SourceWordCount,
sourceText.SourceCharacterCount
FROM dbo.Chapters c
INNER JOIN dbo.Books b ON b.BookID = c.BookID
INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID
OUTER APPLY
(
SELECT
STRING_AGG(CONVERT(nvarchar(max), sms.SourceText), CHAR(13) + CHAR(10) + CHAR(13) + CHAR(10))
WITHIN GROUP (ORDER BY s.SortOrder, s.SceneNumber, s.SceneID) AS SourceText,
SUM(sms.SourceWordCount) AS SourceWordCount,
SUM(sms.SourceCharacterCount) AS SourceCharacterCount
FROM dbo.Scenes s
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
WHERE s.ChapterID = c.ChapterID
AND s.IsArchived = 0
) sourceText
WHERE c.ChapterID = @ChapterID
AND c.IsArchived = 0
AND b.IsArchived = 0
AND p.IsArchived = 0;
END;
GO

View File

@ -1,6 +1,7 @@
@model OnboardingManuscriptBuildResult
@{
ViewData["Title"] = "Manuscript imported";
var coreComplete = string.Equals(Model.Status, "CoreImportComplete", StringComparison.OrdinalIgnoreCase);
}
<section class="onboarding-shell" aria-labelledby="build-complete-title">
@ -20,7 +21,20 @@
<span aria-hidden="true">&check;</span>
<h1 id="build-complete-title">Your manuscript has been imported.</h1>
</div>
<p>@(Model.AlreadyBuilt ? "This manuscript structure has already been created in PlotDirector, so nothing was duplicated." : "PlotDirector created the approved chapters and scenes, updated the Word markers, and saved the manuscript. Word and this browser page may now be closed.")</p>
<p>
@if (coreComplete)
{
@:PlotDirector created the approved chapters and scenes, saved the Word markers, and applied your character review. Core import is complete.
}
else if (Model.AlreadyBuilt)
{
@:This manuscript structure has already been created in PlotDirector, so nothing was duplicated. Review characters to complete the core import.
}
else
{
@:PlotDirector created the approved chapters and scenes, persisted their manuscript text, updated the Word markers, and saved the manuscript. Word may now be closed; keep this browser workflow open to review characters.
}
</p>
@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning))
{
<p class="text-warning">@Model.MarkerWarning</p>
@ -46,14 +60,31 @@
</div>
</div>
@if (coreComplete)
{
<section class="onboarding-complete-next">
<h2>Optional: analyse manuscript</h2>
<p>Story Intelligence can now enrich the existing scenes with summaries, context, relationships, knowledge, timeline details and metrics. You can analyse now or return later.</p>
</section>
}
else
{
<section class="onboarding-complete-next">
<h2>Next: review characters</h2>
<p>Character detection remains part of core import. Review detected names, aliases and existing matches to finish importing the manuscript.</p>
</section>
}
<div class="onboarding-actions">
@if (coreComplete)
{
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligence">Analyse my book</a>
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Not now</a>
}
else
{
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="ScanCharacters" asp-route-previewId="@Model.PreviewID">Review characters</a>
}
<a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Open Project Overview</a>
<a class="btn btn-outline-secondary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
</div>

View File

@ -188,7 +188,7 @@
</div>
}
<div class="onboarding-actions onboarding-actions--inline">
<button class="btn btn-primary" type="submit" name="intent" value="characters">Save and review characters</button>
<button class="btn btn-primary" type="submit" name="intent" value="structure">Save chapters and import scenes</button>
</div>
</div>
@ -353,13 +353,13 @@
}
else
{
<button class="btn btn-primary" type="submit" name="intent" value="continue">Save and continue</button>
<button class="btn btn-primary" type="submit" name="intent" value="continue">Complete core import</button>
}
}
else
{
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="Index">Back to setup</a>
<button class="btn btn-primary" type="submit" name="intent" value="characters">Save and review characters</button>
<button class="btn btn-primary" type="submit" name="intent" value="structure">Save chapters and import scenes</button>
}
</div>
</form>

View File

@ -2171,6 +2171,7 @@
wordCount: 0,
chapterTextParagraphs: [],
chapterText: "",
sourceParagraphs: [],
startPosition: paragraph?.index ?? null,
existingChapterID: isOpening ? null : paragraphAnchorId(paragraph, "PD-CHAPTER")
};
@ -2199,6 +2200,23 @@
return scene;
};
const recordSourceParagraph = (paragraph, text, options = {}) => {
if (!chapter || !paragraph || !Number.isInteger(paragraph.index)) {
return;
}
chapter.sourceParagraphs = chapter.sourceParagraphs || [];
chapter.sourceParagraphs.push({
wordParagraphIndex: paragraph.index,
text: String(text || ""),
isBlank: !!options.isBlank,
isChapterHeading: !!options.isChapterHeading,
isSceneHeading: !!options.isSceneHeading,
isSceneSeparator: !!options.isSceneSeparator,
includeInSourceText: options.includeInSourceText !== false
});
};
const addWords = (paragraph, text, words) => {
if (!chapter || !scene || words <= 0) {
return;
@ -2220,8 +2238,12 @@
paragraphs.forEach((paragraph, index) => {
paragraph.index = index;
const text = String(paragraph.text || "").trim();
const rawText = String(paragraph.text || "");
const text = rawText.trim();
if (!text) {
if (chapter) {
recordSourceParagraph(paragraph, "", { isBlank: true, includeInSourceText: true });
}
return;
}
@ -2236,6 +2258,7 @@
documentText.push(text);
detectedHeadingChapters += 1;
startChapter(paragraph, text);
recordSourceParagraph(paragraph, text, { isChapterHeading: true, includeInSourceText: false });
totalWordCount += words;
chapter.wordCount += words;
chapter.chapterTextParagraphs.push(text);
@ -2260,16 +2283,19 @@
if (isBuiltInHeading(paragraph, 2)) {
startScene(paragraph, text);
recordSourceParagraph(paragraph, text, { isSceneHeading: true, includeInSourceText: true });
totalWordCount += words;
addWords(paragraph, text, words);
return;
}
if (sceneSeparatorTexts.has(text)) {
recordSourceParagraph(paragraph, text, { isSceneSeparator: true, includeInSourceText: false });
startScene(paragraph, null);
return;
}
recordSourceParagraph(paragraph, text, { includeInSourceText: true });
totalWordCount += words;
addWords(paragraph, text, words);
});