From fbe8e60d59240e0e4a44cbc9017428a955c0b1f7 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Tue, 25 Aug 2026 12:34:51 +0000 Subject: [PATCH] Add structural manuscript import phase --- PlotLine/Data/OnboardingBuildRepository.cs | 60 +++++ PlotLine/Models/ManuscriptScanModels.cs | 20 ++ PlotLine/Program.cs | 1 + .../ManuscriptStructureAnalysisService.cs | 212 ++++++++++++++++++ PlotLine/Services/OnboardingService.cs | 133 ++++++++++- ...149_Phase24_StructuralImportMarkerSync.sql | 135 +++++++++++ 6 files changed, 555 insertions(+), 6 deletions(-) create mode 100644 PlotLine/Services/ManuscriptStructureAnalysisService.cs create mode 100644 PlotLine/Sql/149_Phase24_StructuralImportMarkerSync.sql diff --git a/PlotLine/Data/OnboardingBuildRepository.cs b/PlotLine/Data/OnboardingBuildRepository.cs index eab12be..1442094 100644 --- a/PlotLine/Data/OnboardingBuildRepository.cs +++ b/PlotLine/Data/OnboardingBuildRepository.cs @@ -8,6 +8,10 @@ namespace PlotLine.Data; public interface IOnboardingBuildRepository { Task BuildAsync(int userId, OnboardingManuscriptBuildRequest request); + Task MarkMarkerSyncSentAsync(int buildId); + Task MarkMarkerSyncCompletedAsync(int buildId); + Task MarkMarkerSyncFailedAsync(int buildId, string message); + Task SaveStructuralChapterSourceAsync(StructuralChapterSourceSaveRequest request); } public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFactory) : IOnboardingBuildRepository @@ -61,6 +65,51 @@ public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFa CharacterMappings = characters }; } + + public async Task MarkMarkerSyncSentAsync(int buildId) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.Onboarding_ManuscriptBuild_MarkerSyncSent", + new { BuildID = buildId }, + commandType: CommandType.StoredProcedure); + } + + public async Task MarkMarkerSyncCompletedAsync(int buildId) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.Onboarding_ManuscriptBuild_MarkerSyncCompleted", + new { BuildID = buildId }, + commandType: CommandType.StoredProcedure); + } + + public async Task MarkMarkerSyncFailedAsync(int buildId, string message) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.Onboarding_ManuscriptBuild_MarkerSyncFailed", + new { BuildID = buildId, Message = message }, + commandType: CommandType.StoredProcedure); + } + + public async Task SaveStructuralChapterSourceAsync(StructuralChapterSourceSaveRequest request) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.ManuscriptStructuralChapterSource_Save", + new + { + request.ChapterID, + request.ProjectID, + request.BookID, + request.SourceText, + request.SourceWordCount, + request.SourceCharacterCount, + request.ParagraphMapJson + }, + commandType: CommandType.StoredProcedure); + } } public sealed class OnboardingManuscriptBuildRequest @@ -97,3 +146,14 @@ public sealed class OnboardingBuildCharacterRequest public string Name { get; init; } = string.Empty; public int? ExistingCharacterID { get; init; } } + +public sealed class StructuralChapterSourceSaveRequest +{ + public int ChapterID { get; init; } + public int ProjectID { get; init; } + public int BookID { get; init; } + public string SourceText { get; init; } = string.Empty; + public int SourceWordCount { get; init; } + public int SourceCharacterCount { get; init; } + public string? ParagraphMapJson { get; init; } +} diff --git a/PlotLine/Models/ManuscriptScanModels.cs b/PlotLine/Models/ManuscriptScanModels.cs index 1d5905a..b97c209 100644 --- a/PlotLine/Models/ManuscriptScanModels.cs +++ b/PlotLine/Models/ManuscriptScanModels.cs @@ -78,6 +78,14 @@ public sealed class ManuscriptScanChapterPreview public string? ChapterText { get; init; } public int? StartPosition { get; init; } public int? ExistingChapterID { get; init; } + public IReadOnlyList ParagraphMap { get; init; } = []; +} + +public sealed class ManuscriptScanParagraphMap +{ + public int StructuralParagraphOrdinal { get; init; } + public int WordParagraphIndex { get; init; } + public string Text { get; init; } = string.Empty; } public sealed class ManuscriptScanScenePreview @@ -176,6 +184,8 @@ public sealed class OnboardingManuscriptBuildResult public IReadOnlyList ChapterMappings { get; init; } = []; public IReadOnlyList SceneMappings { get; init; } = []; public IReadOnlyList CharacterMappings { get; init; } = []; + public IReadOnlyList ChapterMarkerInstructions { get; init; } = []; + public IReadOnlyList SceneMarkerInstructions { get; init; } = []; public string? MarkerWarning { get; init; } } @@ -198,6 +208,16 @@ public sealed class OnboardingBuildCharacterMap public bool Created { get; init; } } +public sealed class OnboardingBuildMarkerInstruction +{ + public string TemporaryKey { get; init; } = string.Empty; + public int EntityID { get; init; } + public int WordParagraphIndex { get; init; } + public string MarkerTag { get; init; } = string.Empty; + public string MarkerTitle { get; init; } = string.Empty; + public string MarkerPrefix { get; init; } = string.Empty; +} + public sealed class OnboardingBuildProgress { public string Status { get; init; } = "Running"; diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 67a823d..036474c 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -215,6 +215,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/ManuscriptStructureAnalysisService.cs b/PlotLine/Services/ManuscriptStructureAnalysisService.cs new file mode 100644 index 0000000..4ca89bf --- /dev/null +++ b/PlotLine/Services/ManuscriptStructureAnalysisService.cs @@ -0,0 +1,212 @@ +using System.Text.Json; +using PlotLine.Models; +using PlotLine.Models.StoryIntelligence; + +namespace PlotLine.Services; + +public interface IManuscriptStructureAnalysisService +{ + Task AnalyseChapterAsync(ManuscriptStructureAnalysisRequest request, CancellationToken cancellationToken = default); +} + +public sealed class ManuscriptStructureAnalysisService( + IStoryPromptRepository prompts, + IStoryPromptVersionService versions, + IStoryIntelligenceClient client, + IChapterStructureValidator validator, + ILogger logger) : IManuscriptStructureAnalysisService +{ + private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md"; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true + }; + + public async Task AnalyseChapterAsync(ManuscriptStructureAnalysisRequest request, CancellationToken cancellationToken = default) + { + var text = request.ChapterText?.Trim() ?? string.Empty; + var paragraphs = StoryIntelligenceParagraphs.Split(text); + if (paragraphs.Count == 0) + { + return ManuscriptStructureAnalysisResult.Failed( + request.TemporaryChapterKey, + "The approved chapter has no readable text for scene-boundary detection."); + } + + try + { + var promptVersion = versions.GetPromptVersion(ChapterPromptFile); + var clientStatus = client.GetConfigurationStatus(); + var template = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken); + var prompt = BuildChapterPrompt(template, BuildChapterContextJson(request), StoryIntelligenceParagraphs.Number(paragraphs)); + var result = await client.ExecutePromptAsync(prompt, promptVersion, cancellationToken, clientStatus.ChapterStructureModel); + var chapterJson = ExtractOutputText(result.RawResponseText); + var parsed = JsonSerializer.Deserialize(chapterJson, JsonOptions); + var normalisation = ChapterStructureBoundaryNormaliser.Normalise(parsed, paragraphs.Count); + var chapter = normalisation.ChapterStructure; + var validation = validator.Validate(chapter, paragraphs.Count); + ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, normalisation); + + if (!validation.IsValid || chapter?.SceneBoundaries is null || chapter.SceneBoundaries.Count == 0) + { + return ManuscriptStructureAnalysisResult.Failed( + request.TemporaryChapterKey, + BuildValidationMessage(validation), + chapterJson); + } + + var scenes = new List(); + foreach (var boundary in chapter.SceneBoundaries.OrderBy(item => item.SceneNumber ?? int.MaxValue)) + { + var sceneNumber = boundary.SceneNumber ?? 0; + var start = boundary.StartParagraph ?? 0; + var end = boundary.EndParagraph ?? 0; + if (sceneNumber <= 0 || start <= 0 || end <= 0 || start > end || end > paragraphs.Count) + { + return ManuscriptStructureAnalysisResult.Failed( + request.TemporaryChapterKey, + $"Scene {sceneNumber:N0} has an invalid paragraph range.", + chapterJson); + } + + var startMap = request.ParagraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == start); + if (startMap is null || startMap.WordParagraphIndex < 0) + { + return ManuscriptStructureAnalysisResult.Failed( + request.TemporaryChapterKey, + $"Scene {sceneNumber:N0} could not be mapped back to a Word paragraph.", + chapterJson); + } + + var sceneParagraphs = paragraphs.Skip(start - 1).Take(end - start + 1).ToList(); + scenes.Add(new ManuscriptStructureSceneBoundary + { + TemporarySceneKey = $"{request.TemporaryChapterKey}-ai-scene-{sceneNumber}", + TemporaryChapterKey = request.TemporaryChapterKey, + SceneNumberWithinChapter = sceneNumber, + Title = $"Scene {sceneNumber:N0}", + WordCount = sceneParagraphs.Sum(CountWords), + StartParagraph = start, + EndParagraph = end, + WordStartPosition = startMap.WordParagraphIndex, + Reason = boundary.Reason, + Confidence = boundary.Confidence + }); + } + + logger.LogInformation( + "Structural scene-boundary analysis completed. ProjectID={ProjectID} BookID={BookID} TemporaryChapterKey={TemporaryChapterKey} Scenes={SceneCount}", + request.ProjectID, + request.BookID, + request.TemporaryChapterKey, + scenes.Count); + + return ManuscriptStructureAnalysisResult.Succeeded(request.TemporaryChapterKey, scenes, chapterJson); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning( + ex, + "Structural scene-boundary analysis failed. ProjectID={ProjectID} BookID={BookID} TemporaryChapterKey={TemporaryChapterKey}", + request.ProjectID, + request.BookID, + request.TemporaryChapterKey); + return ManuscriptStructureAnalysisResult.Failed(request.TemporaryChapterKey, ex.Message); + } + } + + private static string BuildChapterContextJson(ManuscriptStructureAnalysisRequest request) + => JsonSerializer.Serialize(new + { + projectId = request.ProjectID, + bookId = request.BookID, + chapterId = (int?)null, + chapterNumber = request.ChapterNumber, + sourceLabel = request.SourceLabel + }, JsonOptions); + + private static string BuildChapterPrompt(string promptTemplate, string chapterContextJson, string chapterText) + { + if (string.IsNullOrWhiteSpace(promptTemplate)) + { + throw new InvalidOperationException("Prompt template is empty."); + } + + return promptTemplate + .Replace("{{CHAPTER_CONTEXT_JSON}}", chapterContextJson ?? string.Empty, StringComparison.Ordinal) + .Replace("{{CHAPTER_TEXT}}", chapterText ?? string.Empty, StringComparison.Ordinal); + } + + private static string ExtractOutputText(string rawResponseText) + { + var response = JsonSerializer.Deserialize(rawResponseText, JsonOptions) + ?? throw new JsonException("OpenAI response envelope was empty."); + var outputText = response.Output? + .SelectMany(item => item.Content ?? []) + .FirstOrDefault(content => string.Equals(content.Type, "output_text", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(content.Text)) + ?.Text; + + if (string.IsNullOrWhiteSpace(outputText)) + { + throw new JsonException("OpenAI response did not contain output_text content."); + } + + return outputText.Trim(); + } + + private static string BuildValidationMessage(ValidationResult validation) + { + var first = validation.Errors.FirstOrDefault()?.Message + ?? validation.Warnings.FirstOrDefault()?.Message + ?? "Chapter Structure validation failed."; + return first; + } + + private static int CountWords(string value) + => string.IsNullOrWhiteSpace(value) + ? 0 + : value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; +} + +public sealed class ManuscriptStructureAnalysisRequest +{ + public int ProjectID { get; init; } + public int BookID { get; init; } + public string TemporaryChapterKey { get; init; } = string.Empty; + public int ChapterNumber { get; init; } + public string SourceLabel { get; init; } = string.Empty; + public string ChapterText { get; init; } = string.Empty; + public IReadOnlyList ParagraphMap { get; init; } = []; +} + +public sealed class ManuscriptStructureAnalysisResult +{ + public string TemporaryChapterKey { get; init; } = string.Empty; + public bool Success { get; init; } + public string? ErrorMessage { get; init; } + public string? ParsedJson { get; init; } + public IReadOnlyList Scenes { get; init; } = []; + + public static ManuscriptStructureAnalysisResult Succeeded(string temporaryChapterKey, IReadOnlyList scenes, string? parsedJson) + => new() { TemporaryChapterKey = temporaryChapterKey, Success = true, Scenes = scenes, ParsedJson = parsedJson }; + + public static ManuscriptStructureAnalysisResult Failed(string temporaryChapterKey, string errorMessage, string? parsedJson = null) + => new() { TemporaryChapterKey = temporaryChapterKey, Success = false, ErrorMessage = errorMessage, ParsedJson = parsedJson }; +} + +public sealed class ManuscriptStructureSceneBoundary +{ + public string TemporarySceneKey { get; init; } = string.Empty; + public string TemporaryChapterKey { get; init; } = string.Empty; + public int SceneNumberWithinChapter { get; init; } + public string Title { get; init; } = string.Empty; + public int WordCount { get; init; } + public int StartParagraph { get; init; } + public int EndParagraph { get; init; } + public int WordStartPosition { get; init; } + public string? Reason { get; init; } + public decimal? Confidence { get; init; } +} diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index d653608..f15804b 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; @@ -37,6 +38,7 @@ public sealed class OnboardingService( IManuscriptScanPreviewStore scanPreviews, ICharacterRepository characters, IOnboardingBuildRepository builds, + IManuscriptStructureAnalysisService structureAnalysis, ICurrentUserService currentUser) : IOnboardingService { private static readonly HashSet WritingJourneys = new(StringComparer.Ordinal) @@ -236,7 +238,42 @@ public sealed class OnboardingService( var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList(); - await PublishBuildProgress(userId, preview.PreviewID, "Preparing chapters for analysis...", 35, progress); + await PublishBuildProgress(userId, preview.PreviewID, "Detecting scenes...", 25, progress); + var structuralScenes = new List(); + foreach (var chapterDecision in chapterDecisions) + { + var chapterPreview = preview.Chapters.FirstOrDefault(item => item.TemporaryChapterKey == chapterDecision.TemporaryChapterKey) + ?? throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' could not be found in the scan preview."); + if (string.IsNullOrWhiteSpace(chapterPreview.ChapterText)) + { + throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' has no scanned text. Scan the manuscript again before importing."); + } + + if (chapterPreview.ParagraphMap.Count == 0) + { + throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' does not have paragraph mapping. Scan the manuscript again before importing."); + } + + var analysis = await structureAnalysis.AnalyseChapterAsync(new ManuscriptStructureAnalysisRequest + { + ProjectID = preview.ProjectID, + BookID = preview.BookID, + TemporaryChapterKey = chapterDecision.TemporaryChapterKey, + ChapterNumber = chapterDecision.ChapterNumber, + SourceLabel = $"{preview.DocumentTitle ?? "Word manuscript"}: {chapterDecision.Title}", + ChapterText = chapterPreview.ChapterText, + ParagraphMap = chapterPreview.ParagraphMap + }); + + if (!analysis.Success) + { + throw new InvalidOperationException($"Scene boundary detection failed for '{chapterDecision.Title}': {analysis.ErrorMessage}"); + } + + structuralScenes.AddRange(analysis.Scenes); + } + + await PublishBuildProgress(userId, preview.PreviewID, "Preparing chapters and scenes for import...", 45, progress); var characterDecisions = review.Characters .Where(IsIncludedCanonicalCharacter) .OrderBy(character => character.Name) @@ -255,7 +292,14 @@ public sealed class OnboardingService( SortOrder = (index + 1) * 10, WordCount = preview.Chapters.FirstOrDefault(item => item.TemporaryChapterKey == chapter.TemporaryChapterKey)?.WordCount ?? 0 }).ToList(), - Scenes = [], + Scenes = structuralScenes.Select(scene => new OnboardingBuildSceneRequest + { + TemporarySceneKey = scene.TemporarySceneKey, + TemporaryChapterKey = scene.TemporaryChapterKey, + Title = scene.Title, + SortOrder = scene.SceneNumberWithinChapter * 10, + WordCount = scene.WordCount + }).ToList(), Characters = characterDecisions.Select(character => new OnboardingBuildCharacterRequest { TemporaryCharacterKey = character.TemporaryCharacterKey, @@ -264,7 +308,7 @@ public sealed class OnboardingService( }).ToList() }; - await PublishBuildProgress(userId, preview.PreviewID, "Saving chapters...", 60, progress); + await PublishBuildProgress(userId, preview.PreviewID, "Creating PlotDirector structure...", 60, progress); var result = await builds.BuildAsync(userId, request) ?? throw new InvalidOperationException($"The project chapters could not be prepared. Approved chapters: {request.Chapters.Count}. Return to review, save your selections, and try again."); result = new OnboardingManuscriptBuildResult @@ -282,22 +326,94 @@ public sealed class OnboardingService( CharactersReused = result.CharactersReused, SceneAppearancesCreated = result.SceneAppearancesCreated, AlreadyBuilt = result.AlreadyBuilt, - Message = "Approved chapters are ready for Story Intelligence.", + Message = "Manuscript structure created. Updating and saving Word markers...", ChapterMappings = result.ChapterMappings, SceneMappings = result.SceneMappings, CharacterMappings = result.CharacterMappings, + ChapterMarkerInstructions = BuildChapterMarkerInstructions(preview, result.ChapterMappings), + SceneMarkerInstructions = BuildSceneMarkerInstructions(structuralScenes, result.SceneMappings), MarkerWarning = result.MarkerWarning }; + await SaveStructuralChapterSourcesAsync(preview, result); await AddScanCharacterAliasesAsync(review, result); await scanPreviews.SaveBuildResultAsync(userId, result); - await PublishBuildProgress(userId, preview.PreviewID, "Finalising project...", 90, progress); - await PublishBuildProgress(userId, preview.PreviewID, result.Message, 100, progress, result); + await PublishBuildProgress(userId, preview.PreviewID, "Updating and saving Word markers. Keep Word and this browser page open.", 90, progress); return result; } public async Task GetBuildResultAsync(Guid previewId) => await scanPreviews.GetBuildResultAsync(RequireUserId(), previewId); + private static IReadOnlyList BuildChapterMarkerInstructions( + ManuscriptScanPreview preview, + IReadOnlyList mappings) + { + var byKey = mappings.ToDictionary(item => item.TemporaryChapterKey, StringComparer.Ordinal); + return preview.Chapters + .Where(chapter => chapter.StartPosition.HasValue && byKey.ContainsKey(chapter.TemporaryChapterKey)) + .Select(chapter => + { + var map = byKey[chapter.TemporaryChapterKey]; + return new OnboardingBuildMarkerInstruction + { + TemporaryKey = chapter.TemporaryChapterKey, + EntityID = map.ChapterID, + WordParagraphIndex = chapter.StartPosition!.Value, + MarkerTag = $"PD-CHAPTER-{map.ChapterID}", + MarkerTitle = "PlotDirector Chapter", + MarkerPrefix = "PD-CHAPTER" + }; + }) + .ToList(); + } + + private static IReadOnlyList BuildSceneMarkerInstructions( + IReadOnlyList scenes, + IReadOnlyList mappings) + { + var byKey = mappings.ToDictionary(item => item.TemporarySceneKey, StringComparer.Ordinal); + return scenes + .Where(scene => byKey.ContainsKey(scene.TemporarySceneKey)) + .Select(scene => + { + var map = byKey[scene.TemporarySceneKey]; + return new OnboardingBuildMarkerInstruction + { + TemporaryKey = scene.TemporarySceneKey, + EntityID = map.SceneID, + WordParagraphIndex = scene.WordStartPosition, + MarkerTag = $"PD-SCENE-{map.SceneID}", + MarkerTitle = "PlotDirector Scene", + MarkerPrefix = "PD-SCENE" + }; + }) + .ToList(); + } + + private async Task SaveStructuralChapterSourcesAsync(ManuscriptScanPreview preview, OnboardingManuscriptBuildResult result) + { + var chapterIds = result.ChapterMappings.ToDictionary(item => item.TemporaryChapterKey, item => item.ChapterID, StringComparer.Ordinal); + foreach (var chapter in preview.Chapters) + { + if (!chapterIds.TryGetValue(chapter.TemporaryChapterKey, out var chapterId) + || string.IsNullOrWhiteSpace(chapter.ChapterText)) + { + continue; + } + + await builds.SaveStructuralChapterSourceAsync(new StructuralChapterSourceSaveRequest + { + ChapterID = chapterId, + ProjectID = preview.ProjectID, + BookID = preview.BookID, + SourceText = chapter.ChapterText, + SourceWordCount = CountWords(chapter.ChapterText), + SourceCharacterCount = chapter.ChapterText.Length, + ParagraphMapJson = JsonSerializer.Serialize(chapter.ParagraphMap) + }); + } + } + private async Task PublishBuildProgress(int userId, Guid previewId, string message, int percent, Func? publish, OnboardingManuscriptBuildResult? result = null) { var state = await scanPreviews.BuildProgressAsync(userId, previewId, message, percent, result); @@ -942,6 +1058,11 @@ public sealed class OnboardingService( private static string Clean(string? value) => (value ?? string.Empty).Trim(); + private static int CountWords(string value) + => string.IsNullOrWhiteSpace(value) + ? 0 + : value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; + private static bool IsMicrosoftWordState(UserOnboardingState state) => string.Equals(state.WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal) || state.UsesWordCompanion == true; diff --git a/PlotLine/Sql/149_Phase24_StructuralImportMarkerSync.sql b/PlotLine/Sql/149_Phase24_StructuralImportMarkerSync.sql new file mode 100644 index 0000000..b2b04ff --- /dev/null +++ b/PlotLine/Sql/149_Phase24_StructuralImportMarkerSync.sql @@ -0,0 +1,135 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF COL_LENGTH(N'dbo.OnboardingManuscriptBuilds', N'MarkerSyncMessage') IS NULL + ALTER TABLE dbo.OnboardingManuscriptBuilds ADD MarkerSyncMessage nvarchar(500) NULL; +GO + +IF COL_LENGTH(N'dbo.OnboardingManuscriptBuilds', N'MarkerSyncCompletedUtc') IS NULL + ALTER TABLE dbo.OnboardingManuscriptBuilds ADD MarkerSyncCompletedUtc datetime2 NULL; +GO + +IF OBJECT_ID(N'dbo.ManuscriptStructuralChapterSources', N'U') IS NULL +BEGIN + CREATE TABLE dbo.ManuscriptStructuralChapterSources + ( + ChapterID int NOT NULL CONSTRAINT PK_ManuscriptStructuralChapterSources PRIMARY KEY, + ProjectID int NOT NULL, + BookID int NOT NULL, + SourceText nvarchar(max) NOT NULL, + SourceWordCount int NOT NULL, + SourceCharacterCount int NOT NULL, + ParagraphMapJson nvarchar(max) NULL, + CreatedUtc datetime2 NOT NULL CONSTRAINT DF_ManuscriptStructuralChapterSources_CreatedUtc DEFAULT SYSUTCDATETIME(), + UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_ManuscriptStructuralChapterSources_UpdatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_ManuscriptStructuralChapterSources_Chapters FOREIGN KEY (ChapterID) REFERENCES dbo.Chapters(ChapterID), + CONSTRAINT FK_ManuscriptStructuralChapterSources_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID), + CONSTRAINT FK_ManuscriptStructuralChapterSources_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID) + ); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ManuscriptStructuralChapterSource_Save + @ChapterID int, + @ProjectID int, + @BookID int, + @SourceText nvarchar(max), + @SourceWordCount int, + @SourceCharacterCount int, + @ParagraphMapJson nvarchar(max) = NULL +AS +BEGIN + SET NOCOUNT ON; + + MERGE dbo.ManuscriptStructuralChapterSources AS target + USING (SELECT @ChapterID AS ChapterID) AS source + ON target.ChapterID = source.ChapterID + WHEN MATCHED THEN + UPDATE SET ProjectID = @ProjectID, + BookID = @BookID, + SourceText = @SourceText, + SourceWordCount = @SourceWordCount, + SourceCharacterCount = @SourceCharacterCount, + ParagraphMapJson = @ParagraphMapJson, + UpdatedUtc = SYSUTCDATETIME() + WHEN NOT MATCHED THEN + INSERT (ChapterID, ProjectID, BookID, SourceText, SourceWordCount, SourceCharacterCount, ParagraphMapJson) + VALUES (@ChapterID, @ProjectID, @BookID, @SourceText, @SourceWordCount, @SourceCharacterCount, @ParagraphMapJson); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_MarkerSyncSent + @BuildID int +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.OnboardingManuscriptBuilds + SET MarkerStatus = N'Sent', + MarkerSyncMessage = NULL, + UpdatedUtc = SYSUTCDATETIME() + WHERE OnboardingManuscriptBuildID = @BuildID + AND MarkerStatus IN (N'Pending', N'Failed', N'Sent'); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_MarkerSyncCompleted + @BuildID int +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.OnboardingManuscriptBuilds + SET MarkerStatus = N'Completed', + MarkerSyncMessage = N'Word markers inserted and document saved.', + MarkerSyncCompletedUtc = SYSUTCDATETIME(), + UpdatedUtc = SYSUTCDATETIME() + WHERE OnboardingManuscriptBuildID = @BuildID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_MarkerSyncFailed + @BuildID int, + @Message nvarchar(500) = NULL +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.OnboardingManuscriptBuilds + SET MarkerStatus = N'Failed', + MarkerSyncMessage = LEFT(NULLIF(LTRIM(RTRIM(@Message)), N''), 500), + UpdatedUtc = SYSUTCDATETIME() + WHERE OnboardingManuscriptBuildID = @BuildID; +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, + source.SourceText, + source.SourceWordCount, + source.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 + LEFT JOIN dbo.ManuscriptStructuralChapterSources source ON source.ChapterID = c.ChapterID + WHERE c.ChapterID = @ChapterID + AND c.IsArchived = 0 + AND b.IsArchived = 0 + AND p.IsArchived = 0; +END; +GO