Add structural manuscript import phase

This commit is contained in:
Nick Beckley 2026-08-25 12:34:51 +00:00
parent d62e825a17
commit fbe8e60d59
6 changed files with 555 additions and 6 deletions

View File

@ -8,6 +8,10 @@ namespace PlotLine.Data;
public interface IOnboardingBuildRepository
{
Task<OnboardingManuscriptBuildResult?> 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; }
}

View File

@ -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<ManuscriptScanParagraphMap> 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<OnboardingBuildChapterMap> ChapterMappings { get; init; } = [];
public IReadOnlyList<OnboardingBuildSceneMap> SceneMappings { get; init; } = [];
public IReadOnlyList<OnboardingBuildCharacterMap> CharacterMappings { get; init; } = [];
public IReadOnlyList<OnboardingBuildMarkerInstruction> ChapterMarkerInstructions { get; init; } = [];
public IReadOnlyList<OnboardingBuildMarkerInstruction> 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";

View File

@ -215,6 +215,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceDryRunService, StoryIntelligenceDryRunService>();
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
builder.Services.AddScoped<IManuscriptStructureAnalysisService, ManuscriptStructureAnalysisService>();
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();

View File

@ -0,0 +1,212 @@
using System.Text.Json;
using PlotLine.Models;
using PlotLine.Models.StoryIntelligence;
namespace PlotLine.Services;
public interface IManuscriptStructureAnalysisService
{
Task<ManuscriptStructureAnalysisResult> AnalyseChapterAsync(ManuscriptStructureAnalysisRequest request, CancellationToken cancellationToken = default);
}
public sealed class ManuscriptStructureAnalysisService(
IStoryPromptRepository prompts,
IStoryPromptVersionService versions,
IStoryIntelligenceClient client,
IChapterStructureValidator validator,
ILogger<ManuscriptStructureAnalysisService> 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<ManuscriptStructureAnalysisResult> 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<ChapterStructureModel>(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<ManuscriptStructureSceneBoundary>();
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<OpenAIResponseEnvelope>(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<ManuscriptScanParagraphMap> 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<ManuscriptStructureSceneBoundary> Scenes { get; init; } = [];
public static ManuscriptStructureAnalysisResult Succeeded(string temporaryChapterKey, IReadOnlyList<ManuscriptStructureSceneBoundary> 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; }
}

View File

@ -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<string> 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<ManuscriptStructureSceneBoundary>();
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<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId)
=> await scanPreviews.GetBuildResultAsync(RequireUserId(), previewId);
private static IReadOnlyList<OnboardingBuildMarkerInstruction> BuildChapterMarkerInstructions(
ManuscriptScanPreview preview,
IReadOnlyList<OnboardingBuildChapterMap> 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<OnboardingBuildMarkerInstruction> BuildSceneMarkerInstructions(
IReadOnlyList<ManuscriptStructureSceneBoundary> scenes,
IReadOnlyList<OnboardingBuildSceneMap> 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<OnboardingBuildProgress, Task>? 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;

View File

@ -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