Add structural manuscript import phase
This commit is contained in:
parent
d62e825a17
commit
fbe8e60d59
@ -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; }
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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>();
|
||||
|
||||
212
PlotLine/Services/ManuscriptStructureAnalysisService.cs
Normal file
212
PlotLine/Services/ManuscriptStructureAnalysisService.cs
Normal 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; }
|
||||
}
|
||||
@ -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;
|
||||
|
||||
135
PlotLine/Sql/149_Phase24_StructuralImportMarkerSync.sql
Normal file
135
PlotLine/Sql/149_Phase24_StructuralImportMarkerSync.sql
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user