Persist scene source and correct import order
This commit is contained in:
parent
998c301d65
commit
0e75a95625
@ -64,6 +64,9 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Story Intelligence progress does not prematurely report safe close", StoryIntelligenceProgressDoesNotPrematurelyReportSafeClose),
|
("Story Intelligence progress does not prematurely report safe close", StoryIntelligenceProgressDoesNotPrematurelyReportSafeClose),
|
||||||
("Structural import creates scenes before Word sync", StructuralImportCreatesScenesBeforeWordSync),
|
("Structural import creates scenes before Word sync", StructuralImportCreatesScenesBeforeWordSync),
|
||||||
("Structural import persists paragraph mapping and source text", StructuralImportPersistsParagraphMappingAndSourceText),
|
("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),
|
("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement),
|
||||||
("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes),
|
("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes),
|
||||||
("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding),
|
("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.");
|
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()
|
static void WordSyncRequiresSaveAcknowledgement()
|
||||||
{
|
{
|
||||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
|
|||||||
@ -526,10 +526,23 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
var continueToCharacters = string.Equals(intent, "characters", StringComparison.OrdinalIgnoreCase);
|
var continueToCharacters = string.Equals(intent, "characters", StringComparison.OrdinalIgnoreCase);
|
||||||
var continueCharacterReview = string.Equals(intent, "character-next", StringComparison.OrdinalIgnoreCase);
|
var continueCharacterReview = string.Equals(intent, "character-next", StringComparison.OrdinalIgnoreCase);
|
||||||
var backCharacterReview = string.Equals(intent, "character-back", 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));
|
var nextCharacterPage = Math.Max(1, characterPage + (backCharacterReview ? -1 : 1));
|
||||||
try
|
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);
|
var model = await onboarding.SaveScanReviewAsync(form, readyToImport);
|
||||||
if (model is null)
|
if (model is null)
|
||||||
{
|
{
|
||||||
@ -537,12 +550,12 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
}
|
}
|
||||||
|
|
||||||
TempData["OnboardingReviewMessage"] = readyToImport
|
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."
|
: continueToCharacters ? "Chapter and scene review saved. Next: review detected characters."
|
||||||
: continueCharacterReview || backCharacterReview ? "Character review choices saved."
|
: continueCharacterReview || backCharacterReview ? "Character review choices saved."
|
||||||
: "Review choices saved.";
|
: "Review choices saved.";
|
||||||
return readyToImport
|
return readyToImport
|
||||||
? RedirectToAction(nameof(StoryIntelligence))
|
? RedirectToAction(nameof(Index))
|
||||||
: continueCharacterReview || backCharacterReview ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID, page = nextCharacterPage })
|
: continueCharacterReview || backCharacterReview ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID, page = nextCharacterPage })
|
||||||
: continueToCharacters ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID })
|
: continueToCharacters ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID })
|
||||||
: RedirectToAction(nameof(ScanReview), new { previewId = form.PreviewID });
|
: RedirectToAction(nameof(ScanReview), new { previewId = form.PreviewID });
|
||||||
|
|||||||
@ -12,6 +12,7 @@ public interface IOnboardingBuildRepository
|
|||||||
Task MarkMarkerSyncCompletedAsync(int buildId);
|
Task MarkMarkerSyncCompletedAsync(int buildId);
|
||||||
Task MarkMarkerSyncFailedAsync(int buildId, string message);
|
Task MarkMarkerSyncFailedAsync(int buildId, string message);
|
||||||
Task SaveStructuralChapterSourceAsync(StructuralChapterSourceSaveRequest request);
|
Task SaveStructuralChapterSourceAsync(StructuralChapterSourceSaveRequest request);
|
||||||
|
Task SaveSceneManuscriptSourceAsync(SceneManuscriptSourceSaveRequest request);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFactory) : IOnboardingBuildRepository
|
public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFactory) : IOnboardingBuildRepository
|
||||||
@ -110,6 +111,29 @@ public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFa
|
|||||||
},
|
},
|
||||||
commandType: CommandType.StoredProcedure);
|
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
|
public sealed class OnboardingManuscriptBuildRequest
|
||||||
@ -157,3 +181,19 @@ public sealed class StructuralChapterSourceSaveRequest
|
|||||||
public int SourceCharacterCount { get; init; }
|
public int SourceCharacterCount { get; init; }
|
||||||
public string? ParagraphMapJson { 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";
|
||||||
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ public static class ManuscriptScanReviewStatuses
|
|||||||
public const string ReviewInProgress = "ReviewInProgress";
|
public const string ReviewInProgress = "ReviewInProgress";
|
||||||
public const string ReviewComplete = "ReviewComplete";
|
public const string ReviewComplete = "ReviewComplete";
|
||||||
public const string ReadyToImport = "ReadyToImport";
|
public const string ReadyToImport = "ReadyToImport";
|
||||||
|
public const string CoreImportComplete = "CoreImportComplete";
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ManuscriptScanCommand
|
public sealed class ManuscriptScanCommand
|
||||||
@ -79,6 +80,7 @@ public sealed class ManuscriptScanChapterPreview
|
|||||||
public int? StartPosition { get; init; }
|
public int? StartPosition { get; init; }
|
||||||
public int? ExistingChapterID { get; init; }
|
public int? ExistingChapterID { get; init; }
|
||||||
public IReadOnlyList<ManuscriptScanParagraphMap> ParagraphMap { get; init; } = [];
|
public IReadOnlyList<ManuscriptScanParagraphMap> ParagraphMap { get; init; } = [];
|
||||||
|
public IReadOnlyList<ManuscriptScanSourceParagraph> SourceParagraphs { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ManuscriptScanParagraphMap
|
public sealed class ManuscriptScanParagraphMap
|
||||||
@ -88,6 +90,17 @@ public sealed class ManuscriptScanParagraphMap
|
|||||||
public string Text { get; init; } = string.Empty;
|
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 sealed class ManuscriptScanScenePreview
|
||||||
{
|
{
|
||||||
public string TemporarySceneKey { get; init; } = string.Empty;
|
public string TemporarySceneKey { get; init; } = string.Empty;
|
||||||
|
|||||||
@ -72,7 +72,8 @@ public sealed class ManuscriptStructureAnalysisService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var startMap = request.ParagraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == start);
|
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(
|
return ManuscriptStructureAnalysisResult.Failed(
|
||||||
request.TemporaryChapterKey,
|
request.TemporaryChapterKey,
|
||||||
@ -81,6 +82,7 @@ public sealed class ManuscriptStructureAnalysisService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sceneParagraphs = paragraphs.Skip(start - 1).Take(end - start + 1).ToList();
|
var sceneParagraphs = paragraphs.Skip(start - 1).Take(end - start + 1).ToList();
|
||||||
|
var sourceText = BuildSourceText(request.SourceParagraphs, startMap.WordParagraphIndex, endMap.WordParagraphIndex);
|
||||||
scenes.Add(new ManuscriptStructureSceneBoundary
|
scenes.Add(new ManuscriptStructureSceneBoundary
|
||||||
{
|
{
|
||||||
TemporarySceneKey = $"{request.TemporaryChapterKey}-ai-scene-{sceneNumber}",
|
TemporarySceneKey = $"{request.TemporaryChapterKey}-ai-scene-{sceneNumber}",
|
||||||
@ -88,9 +90,11 @@ public sealed class ManuscriptStructureAnalysisService(
|
|||||||
SceneNumberWithinChapter = sceneNumber,
|
SceneNumberWithinChapter = sceneNumber,
|
||||||
Title = $"Scene {sceneNumber:N0}",
|
Title = $"Scene {sceneNumber:N0}",
|
||||||
WordCount = sceneParagraphs.Sum(CountWords),
|
WordCount = sceneParagraphs.Sum(CountWords),
|
||||||
|
SourceText = sourceText,
|
||||||
StartParagraph = start,
|
StartParagraph = start,
|
||||||
EndParagraph = end,
|
EndParagraph = end,
|
||||||
WordStartPosition = startMap.WordParagraphIndex,
|
WordStartPosition = startMap.WordParagraphIndex,
|
||||||
|
WordEndPosition = endMap.WordParagraphIndex,
|
||||||
Reason = boundary.Reason,
|
Reason = boundary.Reason,
|
||||||
Confidence = boundary.Confidence
|
Confidence = boundary.Confidence
|
||||||
});
|
});
|
||||||
@ -157,6 +161,29 @@ public sealed class ManuscriptStructureAnalysisService(
|
|||||||
return outputText.Trim();
|
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)
|
private static string BuildValidationMessage(ValidationResult validation)
|
||||||
{
|
{
|
||||||
var first = validation.Errors.FirstOrDefault()?.Message
|
var first = validation.Errors.FirstOrDefault()?.Message
|
||||||
@ -180,6 +207,7 @@ public sealed class ManuscriptStructureAnalysisRequest
|
|||||||
public string SourceLabel { get; init; } = string.Empty;
|
public string SourceLabel { get; init; } = string.Empty;
|
||||||
public string ChapterText { get; init; } = string.Empty;
|
public string ChapterText { get; init; } = string.Empty;
|
||||||
public IReadOnlyList<ManuscriptScanParagraphMap> ParagraphMap { get; init; } = [];
|
public IReadOnlyList<ManuscriptScanParagraphMap> ParagraphMap { get; init; } = [];
|
||||||
|
public IReadOnlyList<ManuscriptScanSourceParagraph> SourceParagraphs { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ManuscriptStructureAnalysisResult
|
public sealed class ManuscriptStructureAnalysisResult
|
||||||
@ -204,9 +232,11 @@ public sealed class ManuscriptStructureSceneBoundary
|
|||||||
public int SceneNumberWithinChapter { get; init; }
|
public int SceneNumberWithinChapter { get; init; }
|
||||||
public string Title { get; init; } = string.Empty;
|
public string Title { get; init; } = string.Empty;
|
||||||
public int WordCount { get; init; }
|
public int WordCount { get; init; }
|
||||||
|
public string SourceText { get; init; } = string.Empty;
|
||||||
public int StartParagraph { get; init; }
|
public int StartParagraph { get; init; }
|
||||||
public int EndParagraph { get; init; }
|
public int EndParagraph { get; init; }
|
||||||
public int WordStartPosition { get; init; }
|
public int WordStartPosition { get; init; }
|
||||||
|
public int WordEndPosition { get; init; }
|
||||||
public string? Reason { get; init; }
|
public string? Reason { get; init; }
|
||||||
public decimal? Confidence { get; init; }
|
public decimal? Confidence { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ public interface IOnboardingService
|
|||||||
Task<OnboardingWizardViewModel> GetWizardAsync();
|
Task<OnboardingWizardViewModel> GetWizardAsync();
|
||||||
Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null);
|
Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null);
|
||||||
Task<ManuscriptScanReviewViewModel?> SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport);
|
Task<ManuscriptScanReviewViewModel?> SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport);
|
||||||
|
Task<OnboardingManuscriptBuildResult?> FinaliseCharacterReviewAsync(ManuscriptScanReviewForm form);
|
||||||
Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, Func<OnboardingBuildProgress, Task>? progress = null);
|
Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, Func<OnboardingBuildProgress, Task>? progress = null);
|
||||||
Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId);
|
Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId);
|
||||||
Task<bool> ShouldShowOnboardingAsync();
|
Task<bool> ShouldShowOnboardingAsync();
|
||||||
@ -262,7 +263,8 @@ public sealed class OnboardingService(
|
|||||||
ChapterNumber = chapterDecision.ChapterNumber,
|
ChapterNumber = chapterDecision.ChapterNumber,
|
||||||
SourceLabel = $"{preview.DocumentTitle ?? "Word manuscript"}: {chapterDecision.Title}",
|
SourceLabel = $"{preview.DocumentTitle ?? "Word manuscript"}: {chapterDecision.Title}",
|
||||||
ChapterText = chapterPreview.ChapterText,
|
ChapterText = chapterPreview.ChapterText,
|
||||||
ParagraphMap = chapterPreview.ParagraphMap
|
ParagraphMap = chapterPreview.ParagraphMap,
|
||||||
|
SourceParagraphs = chapterPreview.SourceParagraphs
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!analysis.Success)
|
if (!analysis.Success)
|
||||||
@ -274,11 +276,6 @@ public sealed class OnboardingService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await PublishBuildProgress(userId, preview.PreviewID, "Preparing chapters and scenes for import...", 45, progress);
|
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
|
var request = new OnboardingManuscriptBuildRequest
|
||||||
{
|
{
|
||||||
PreviewID = preview.PreviewID,
|
PreviewID = preview.PreviewID,
|
||||||
@ -300,12 +297,7 @@ public sealed class OnboardingService(
|
|||||||
SortOrder = scene.SceneNumberWithinChapter * 10,
|
SortOrder = scene.SceneNumberWithinChapter * 10,
|
||||||
WordCount = scene.WordCount
|
WordCount = scene.WordCount
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
Characters = characterDecisions.Select(character => new OnboardingBuildCharacterRequest
|
Characters = []
|
||||||
{
|
|
||||||
TemporaryCharacterKey = character.TemporaryCharacterKey,
|
|
||||||
Name = character.Name,
|
|
||||||
ExistingCharacterID = character.ExistingCharacterID
|
|
||||||
}).ToList()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await PublishBuildProgress(userId, preview.PreviewID, "Creating PlotDirector structure...", 60, progress);
|
await PublishBuildProgress(userId, preview.PreviewID, "Creating PlotDirector structure...", 60, progress);
|
||||||
@ -335,7 +327,7 @@ public sealed class OnboardingService(
|
|||||||
MarkerWarning = result.MarkerWarning
|
MarkerWarning = result.MarkerWarning
|
||||||
};
|
};
|
||||||
await SaveStructuralChapterSourcesAsync(preview, result);
|
await SaveStructuralChapterSourcesAsync(preview, result);
|
||||||
await AddScanCharacterAliasesAsync(review, result);
|
await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result);
|
||||||
await scanPreviews.SaveBuildResultAsync(userId, 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);
|
await PublishBuildProgress(userId, preview.PreviewID, "Updating and saving Word markers. Keep Word and this browser page open.", 90, progress);
|
||||||
return result;
|
return result;
|
||||||
@ -344,6 +336,113 @@ public sealed class OnboardingService(
|
|||||||
public async Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId)
|
public async Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId)
|
||||||
=> await scanPreviews.GetBuildResultAsync(RequireUserId(), 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(
|
private static IReadOnlyList<OnboardingBuildMarkerInstruction> BuildChapterMarkerInstructions(
|
||||||
ManuscriptScanPreview preview,
|
ManuscriptScanPreview preview,
|
||||||
IReadOnlyList<OnboardingBuildChapterMap> mappings)
|
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)
|
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);
|
var state = await scanPreviews.BuildProgressAsync(userId, previewId, message, percent, result);
|
||||||
|
|||||||
@ -39,6 +39,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
IStoryIntelligenceAssetImportService assetImport,
|
IStoryIntelligenceAssetImportService assetImport,
|
||||||
IStoryIntelligenceRelationshipImportService relationshipImport,
|
IStoryIntelligenceRelationshipImportService relationshipImport,
|
||||||
IStoryIntelligenceKnowledgeImportService knowledgeImport,
|
IStoryIntelligenceKnowledgeImportService knowledgeImport,
|
||||||
|
IStoryIntelligenceSourceRepository sources,
|
||||||
IStoryIntelligencePipelineStateService pipelineState,
|
IStoryIntelligencePipelineStateService pipelineState,
|
||||||
IStoryMemoryRepository storyMemory,
|
IStoryMemoryRepository storyMemory,
|
||||||
IStoryIntelligenceClient client,
|
IStoryIntelligenceClient client,
|
||||||
@ -108,8 +109,10 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
build ??= await onboarding.BuildApprovedStructureAsync(preview.PreviewID)
|
if (build is null || build.SceneMappings.Count == 0)
|
||||||
?? throw new InvalidOperationException("The approved chapters could not be prepared for analysis.");
|
{
|
||||||
|
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 chapterIds = build.ChapterMappings.ToDictionary(chapter => chapter.TemporaryChapterKey, chapter => chapter.ChapterID, StringComparer.Ordinal);
|
||||||
var included = IncludedChapters(preview, review);
|
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.");
|
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 clientStatus = client.GetConfigurationStatus();
|
||||||
var modelSummary = StoryIntelligenceOptions.BuildModelSummary(
|
var modelSummary = StoryIntelligenceOptions.BuildModelSummary(
|
||||||
clientStatus.ChapterStructureModel,
|
clientStatus.ChapterStructureModel,
|
||||||
@ -142,7 +140,18 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
throw new InvalidOperationException($"PlotDirector could not prepare chapter '{chapter.Decision.Title}' for analysis.");
|
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 paragraphs = StoryIntelligenceParagraphs.Split(text);
|
||||||
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
|
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
|
||||||
{
|
{
|
||||||
@ -155,8 +164,8 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
SourceLabel = $"{wizard.SelectedBookTitle}: {chapter.Decision.Title}",
|
SourceLabel = $"{wizard.SelectedBookTitle}: {chapter.Decision.Title}",
|
||||||
ChapterText = text,
|
ChapterText = text,
|
||||||
SourceFileName = preview.DocumentTitle,
|
SourceFileName = preview.DocumentTitle,
|
||||||
SourceWordCount = CountWords(text),
|
SourceWordCount = durableSource?.SourceWordCount ?? CountWords(text),
|
||||||
SourceCharacterCount = text.Length,
|
SourceCharacterCount = durableSource?.SourceCharacterCount ?? text.Length,
|
||||||
SourceParagraphCount = paragraphs.Count,
|
SourceParagraphCount = paragraphs.Count,
|
||||||
SourceChapterCount = 1,
|
SourceChapterCount = 1,
|
||||||
Model = modelSummary,
|
Model = modelSummary,
|
||||||
@ -838,7 +847,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var review = await scanStore.GetReviewAsync(userId, preview.PreviewID);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
152
PlotLine/Sql/150_Phase24B_SceneManuscriptSources.sql
Normal file
152
PlotLine/Sql/150_Phase24B_SceneManuscriptSources.sql
Normal 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
|
||||||
@ -1,6 +1,7 @@
|
|||||||
@model OnboardingManuscriptBuildResult
|
@model OnboardingManuscriptBuildResult
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Manuscript imported";
|
ViewData["Title"] = "Manuscript imported";
|
||||||
|
var coreComplete = string.Equals(Model.Status, "CoreImportComplete", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="onboarding-shell" aria-labelledby="build-complete-title">
|
<section class="onboarding-shell" aria-labelledby="build-complete-title">
|
||||||
@ -20,7 +21,20 @@
|
|||||||
<span aria-hidden="true">✓</span>
|
<span aria-hidden="true">✓</span>
|
||||||
<h1 id="build-complete-title">Your manuscript has been imported.</h1>
|
<h1 id="build-complete-title">Your manuscript has been imported.</h1>
|
||||||
</div>
|
</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))
|
@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning))
|
||||||
{
|
{
|
||||||
<p class="text-warning">@Model.MarkerWarning</p>
|
<p class="text-warning">@Model.MarkerWarning</p>
|
||||||
@ -46,14 +60,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="onboarding-complete-next">
|
@if (coreComplete)
|
||||||
<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 class="onboarding-complete-next">
|
||||||
</section>
|
<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">
|
<div class="onboarding-actions">
|
||||||
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligence">Analyse my book</a>
|
@if (coreComplete)
|
||||||
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Not now</a>
|
{
|
||||||
|
<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-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>
|
<a class="btn btn-outline-secondary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -188,7 +188,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="onboarding-actions onboarding-actions--inline">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -353,13 +353,13 @@
|
|||||||
}
|
}
|
||||||
else
|
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
|
else
|
||||||
{
|
{
|
||||||
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="Index">Back to setup</a>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -2171,6 +2171,7 @@
|
|||||||
wordCount: 0,
|
wordCount: 0,
|
||||||
chapterTextParagraphs: [],
|
chapterTextParagraphs: [],
|
||||||
chapterText: "",
|
chapterText: "",
|
||||||
|
sourceParagraphs: [],
|
||||||
startPosition: paragraph?.index ?? null,
|
startPosition: paragraph?.index ?? null,
|
||||||
existingChapterID: isOpening ? null : paragraphAnchorId(paragraph, "PD-CHAPTER")
|
existingChapterID: isOpening ? null : paragraphAnchorId(paragraph, "PD-CHAPTER")
|
||||||
};
|
};
|
||||||
@ -2199,6 +2200,23 @@
|
|||||||
return scene;
|
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) => {
|
const addWords = (paragraph, text, words) => {
|
||||||
if (!chapter || !scene || words <= 0) {
|
if (!chapter || !scene || words <= 0) {
|
||||||
return;
|
return;
|
||||||
@ -2220,8 +2238,12 @@
|
|||||||
|
|
||||||
paragraphs.forEach((paragraph, index) => {
|
paragraphs.forEach((paragraph, index) => {
|
||||||
paragraph.index = index;
|
paragraph.index = index;
|
||||||
const text = String(paragraph.text || "").trim();
|
const rawText = String(paragraph.text || "");
|
||||||
|
const text = rawText.trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
|
if (chapter) {
|
||||||
|
recordSourceParagraph(paragraph, "", { isBlank: true, includeInSourceText: true });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2236,6 +2258,7 @@
|
|||||||
documentText.push(text);
|
documentText.push(text);
|
||||||
detectedHeadingChapters += 1;
|
detectedHeadingChapters += 1;
|
||||||
startChapter(paragraph, text);
|
startChapter(paragraph, text);
|
||||||
|
recordSourceParagraph(paragraph, text, { isChapterHeading: true, includeInSourceText: false });
|
||||||
totalWordCount += words;
|
totalWordCount += words;
|
||||||
chapter.wordCount += words;
|
chapter.wordCount += words;
|
||||||
chapter.chapterTextParagraphs.push(text);
|
chapter.chapterTextParagraphs.push(text);
|
||||||
@ -2260,16 +2283,19 @@
|
|||||||
|
|
||||||
if (isBuiltInHeading(paragraph, 2)) {
|
if (isBuiltInHeading(paragraph, 2)) {
|
||||||
startScene(paragraph, text);
|
startScene(paragraph, text);
|
||||||
|
recordSourceParagraph(paragraph, text, { isSceneHeading: true, includeInSourceText: true });
|
||||||
totalWordCount += words;
|
totalWordCount += words;
|
||||||
addWords(paragraph, text, words);
|
addWords(paragraph, text, words);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sceneSeparatorTexts.has(text)) {
|
if (sceneSeparatorTexts.has(text)) {
|
||||||
|
recordSourceParagraph(paragraph, text, { isSceneSeparator: true, includeInSourceText: false });
|
||||||
startScene(paragraph, null);
|
startScene(paragraph, null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordSourceParagraph(paragraph, text, { includeInSourceText: true });
|
||||||
totalWordCount += words;
|
totalWordCount += words;
|
||||||
addWords(paragraph, text, words);
|
addWords(paragraph, text, words);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user